Compare commits

...
5 Commits
Author SHA1 Message Date
ZyyandCodex 299088a6e7 chore: bump version to 3.2.15
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 20:00:59 +08:00
ZyyandCodex a23bfca848 fix(admin): normalize OAuth credential formats
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 14:57:16 +08:00
ZyyandCodex 8951a64a05 fix(admin): raise billing abbreviation threshold
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 14:15:22 +08:00
ZyyandCodex eb20561893 fix(admin): align xAI quota usage displays
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 13:58:02 +08:00
ZyyandCodex a33637a99e fix(openai): use Codex image endpoint paths
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 13:02:40 +08:00
22 changed files with 324 additions and 131 deletions
@@ -1288,9 +1288,9 @@ fn parse_oauth_import_tokens(
Value::Object(object) => {
for (key, value) in object {
let token = match key.as_str() {
"accessToken" => &mut access_token,
"refreshToken" => &mut refresh_token,
"idToken" => &mut id_token,
"accessToken" | "access_token" => &mut access_token,
"refreshToken" | "refresh_token" => &mut refresh_token,
"idToken" | "id_token" => &mut id_token,
_ => {
pending.push(value);
continue;
@@ -1,9 +1,9 @@
/// `/codex/responses`
pub const CODEX_RESPONSES_PATH: &str = "/codex/responses";
/// `/images/generations`
pub const CODEX_IMAGE_GENERATIONS_PATH: &str = "/images/generations";
/// `/images/edits`
pub const CODEX_IMAGE_EDITS_PATH: &str = "/images/edits";
/// `/codex/images/generations`
pub const CODEX_IMAGE_GENERATIONS_PATH: &str = "/codex/images/generations";
/// `/codex/images/edits`
pub const CODEX_IMAGE_EDITS_PATH: &str = "/codex/images/edits";
/// `/api/codex/usage`
pub const CODEX_USAGE_API_PATH: &str = "/api/codex/usage";
/// `/wham/usage`
@@ -59,6 +59,48 @@ async fn direct_import_persists_opaque_tokens_without_profile_or_token_validatio
assert!(account.account.next_refresh_at().is_none());
}
#[tokio::test]
async fn direct_import_accepts_snake_case_oauth_token_aliases() {
let service = CodexCredentialAdminService::new(
Arc::new(UnusedRefresher),
Arc::new(TestLeaseCoordinator::default()),
runtime_policy(),
);
let prepared = service
.prepare_import_document(serde_json::json!({
"accounts": [{
"platform": "openai",
"type": "oauth",
"credentials": {
"access_token": "snake-access-token",
"refresh_token": "snake-refresh-token",
"id_token": "snake-id-token"
}
}]
}))
.await
.expect("snake_case OAuth token aliases must be accepted");
let account = prepared.accounts().first().expect("one prepared account");
let runtime = CodexCredentialCodec::decode(&account.credential).expect("stored credential");
let oauth = runtime.authentication.oauth().expect("OAuth credential");
assert_eq!(
(
oauth.access_token.expose_secret(),
oauth
.refresh_token
.as_ref()
.map(|token| token.expose_secret()),
oauth.id_token.as_ref().map(|token| token.expose_secret()),
),
(
"snake-access-token",
Some("snake-refresh-token"),
Some("snake-id-token"),
)
);
}
#[tokio::test]
async fn direct_import_projects_access_token_jwt_expiry_without_persisting_refresh_margin() {
let expires_at = Utc
@@ -820,13 +820,13 @@ async fn image_endpoints_bypass_only_the_text_catalog_and_preserve_the_current_c
let cases = [
(
ImageRequestKind::Generation,
"/images/generations",
"/codex/images/generations",
br#"{ "model":"gpt-image-future", "prompt":"a lighthouse", "background":"transparent", "future_option":{"schema":2}, "future_integer":9007199254740993 }"#.as_slice(),
br#"{ "created": 1787212800, "data": [{"b64_json":"AAEC"}], "future": 9007199254740993 }"#.as_slice(),
),
(
ImageRequestKind::Edit,
"/images/edits",
"/codex/images/edits",
br#"{"model":"gpt-image-2","images":[{"image_url":"data:image/png;base64,AAEC"}],"prompt":"add fog","prompt":"duplicate remains opaque"}"#.as_slice(),
br#"{"created":1787212801,"data":[{"b64_json":"AwQF"}],"quality":"high"}"#.as_slice(),
),
@@ -918,7 +918,7 @@ async fn image_endpoint_returns_the_exact_upstream_error_response() {
br#"{ "model":"gpt-image-2", "images":[], "future_invalid":9007199254740993 }"#;
let response_body = br#"{ "error":{"message":"future image validation","type":"image_error","code":"future_code"}, "future":9007199254740993 }"#;
Mock::given(method("POST"))
.and(path("/images/edits"))
.and(path("/codex/images/edits"))
.and(body_bytes(request_body.to_vec()))
.respond_with(
ResponseTemplate::new(422)
@@ -195,7 +195,7 @@ fn bare_http_429_remains_a_temporary_rate_limit() {
}
#[test]
fn sub2api_openai_account_failure_matrix_is_preserved() {
fn openai_account_failure_matrix_is_preserved() {
let cases = [
(
400,
@@ -237,19 +237,19 @@ impl GrokCredentialAdmin {
let mut secret = decode_secret(&loaded.credential)?;
let mut credentials = serde_json::Map::new();
credentials.insert(
"access_token".to_owned(),
"accessToken".to_owned(),
Value::String(std::mem::take(&mut secret.access_token)),
);
credentials.insert(
"refresh_token".to_owned(),
"refreshToken".to_owned(),
Value::String(std::mem::take(&mut secret.refresh_token)),
);
if let Some(id_token) = secret.id_token.take() {
credentials.insert("id_token".to_owned(), Value::String(id_token));
credentials.insert("idToken".to_owned(), Value::String(id_token));
}
credentials.insert("token_type".to_owned(), Value::String("Bearer".to_owned()));
credentials.insert("tokenType".to_owned(), Value::String("Bearer".to_owned()));
credentials.insert(
"expires_at".to_owned(),
"expiresAt".to_owned(),
Value::String(
loaded
.account
@@ -260,11 +260,11 @@ impl GrokCredentialAdmin {
),
);
credentials.insert(
"base_url".to_owned(),
"baseUrl".to_owned(),
Value::String(GROK_CLI_BASE_URL.to_owned()),
);
credentials.insert(
"client_id".to_owned(),
"clientId".to_owned(),
Value::String(OFFICIAL_CLIENT_ID.to_owned()),
);
credentials.insert(
@@ -290,7 +290,7 @@ impl GrokCredentialAdmin {
Ok(GrokAccountExport(serde_json::json!({
"version": 1,
"type": "oauth-account-bundle",
"exported_at": exported_at.to_rfc3339(),
"exportedAt": exported_at.to_rfc3339(),
"accounts": exported_accounts,
"proxies": [],
})))
+1 -1
View File
@@ -2076,7 +2076,7 @@ const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
const OAUTH_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
const QUOTA_CATALOG_INTERVAL: Duration = Duration::from_secs(5 * 60);
// rolling 24h 描述的是上游用量窗口,不代表从本次观测起封禁 24 小时;
// 缺少可信 reset 时间时按 sub2api 的短周期探测策略恢复检查。
// 缺少可信 reset 时间时按短周期探测策略恢复检查。
const EXHAUSTED_QUOTA_FALLBACK_RECHECK_INTERVAL: Duration = Duration::from_secs(10 * 60);
const EXHAUSTED_QUOTA_REFRESH_RETRY_INTERVAL: Duration = QUOTA_CATALOG_INTERVAL;
const CLI_RELEASE_WORKER_OWNER: &str = "xai-cli-release";
@@ -108,7 +108,7 @@ impl GrokCompactionRequest {
self.reasoning_replay_session_id.as_deref()
}
/// 返回经 sub2api 别名表归一化后的 xAI wire 模型。
/// 返回归一化后的 xAI wire 模型。
pub(crate) fn upstream_model(&self) -> Option<&str> {
self.body.get("model").and_then(Value::as_str)
}
@@ -286,8 +286,7 @@ pub(crate) fn compaction_wire_events(
Value::Null,
);
let item = compaction_item(summary, encrypted_content);
// sub2api 的 compact contract 以真实密文为成功依据,不向客户端延续
// 摘要生成阶段的 incomplete 状态。
// compact 成功以真实密文为依据,不向客户端延续摘要生成阶段的 incomplete 状态。
let terminal_response = compaction_response(
completed,
terminal_source.or(created_source),
@@ -91,7 +91,7 @@ impl GrokResponsesRequest {
self.reasoning_replay_session_id.as_deref()
}
/// 返回经 sub2api 别名表归一化后的 xAI wire 模型。
/// 返回归一化后的 xAI wire 模型。
pub(crate) fn upstream_model(&self) -> Option<&str> {
self.body.get("model").and_then(Value::as_str)
}
@@ -1928,8 +1928,8 @@ impl ToolNormalizer {
"apply_patch" => self.normalize_apply_patch_tool(tool),
"x_search" | "collections_search" | "file_search" | "code_execution"
| "code_interpreter" => Ok(vec![Value::Object(without_defer_loading(tool))]),
// sub2api 的 xAI Responses 适配层对未获 xAI 接受的工具做静默过滤,
// 并在过滤后同步收敛 tool_choice;不要把客户端可选扩展升级成整单 400。
// 对未获 xAI 接受的工具做静默过滤,并在过滤后同步收敛 tool_choice;
// 不要把客户端可选扩展升级成整单 400。
"" | "computer_use_preview" | "image_generation" => Ok(Vec::new()),
_ => Ok(Vec::new()),
}
@@ -3517,15 +3517,15 @@ fn normalize_compaction_input(
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let has_sub2api_shape = item.contains_key("summary")
let has_structured_compaction_shape = item.contains_key("summary")
|| item.contains_key("id")
|| item.contains_key("status")
|| string_field(item, "type").trim() == "compaction_summary";
// 旧版 adapter 曾将明文摘要直接写入 encrypted_content,且不带
// id/status/summary。仅对这一可识别的历史形态保留明文恢复;新形态严格按
// sub2api 回放为 xAI reasoning 密文 + 可见 conversation summary。
if !has_sub2api_shape {
// id/status/summary。仅对这一可识别的历史形态保留明文恢复;新形态严格回放为
// xAI reasoning 密文 + 可见 conversation summary。
if !has_structured_compaction_shape {
let Some(summary) = encrypted else {
return Ok(Vec::new());
};
@@ -363,20 +363,48 @@ async fn oauth_bundle_export_is_provider_owned_canonical_and_debug_redacted() {
let value = export.into_value();
assert_eq!(value["version"], 1);
assert_eq!(value["type"], "oauth-account-bundle");
assert!(value.get("exportedAt").is_some());
assert!(value.get("exported_at").is_none());
assert_eq!(value["accounts"][0]["platform"], "grok");
assert_eq!(value["accounts"][0]["type"], "oauth");
assert_eq!(
value["accounts"][0]["credentials"]["base_url"],
value["accounts"][0]["credentials"]["baseUrl"],
provider_xai::GROK_CLI_BASE_URL
);
assert_eq!(
value["accounts"][0]["credentials"]["access_token"],
value["accounts"][0]["credentials"]["accessToken"],
"access-export"
);
assert_eq!(
value["accounts"][0]["credentials"]["refreshToken"],
"refresh-export"
);
assert_eq!(value["accounts"][0]["credentials"]["idToken"], "id-export");
assert_eq!(value["accounts"][0]["credentials"]["tokenType"], "Bearer");
assert!(
value["accounts"][0]["credentials"]["expiresAt"]
.as_str()
.is_some()
);
assert_eq!(
value["accounts"][0]["credentials"]["clientId"],
provider_xai::OFFICIAL_CLIENT_ID
);
assert_eq!(
value["accounts"][0]["credentials"]["scope"],
"openid profile email offline_access grok-cli:access api:access"
);
for field in [
"access_token",
"refresh_token",
"id_token",
"token_type",
"expires_at",
"base_url",
"client_id",
] {
assert!(value["accounts"][0]["credentials"].get(field).is_none());
}
assert_eq!(value["proxies"], serde_json::json!([]));
}
@@ -225,7 +225,7 @@ fn oauth_account_document_should_reject_api_key_credential_field() {
fn oauth_account_document_should_ignore_source_wrapper_and_non_auth_metadata() {
let mut document: serde_json::Value =
serde_json::from_slice(&oauth_account_document()).expect("fixture JSON");
document["type"] = serde_json::Value::String("sub2api-data".to_owned());
document["type"] = serde_json::Value::String("external-oauth-data".to_owned());
document["version"] = serde_json::Value::from(99);
document["proxies"] = serde_json::json!([{"url": "http://127.0.0.1:8080"}]);
document["source_extension"] = serde_json::json!({"opaque": true});
@@ -1534,7 +1534,7 @@ async fn compaction_wire_should_exclude_reasoning_from_summary_content() {
}
#[tokio::test]
async fn compaction_stream_should_accept_short_summary_like_sub2api() {
async fn compaction_stream_should_accept_short_summary() {
let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(compaction_sse(
"<summary>too short</summary>",
None,
@@ -76,7 +76,7 @@ fn encoder_should_preserve_history_order_and_append_summary_prompt() {
}
#[test]
fn encoder_should_preserve_sub2api_compaction_constraints() {
fn encoder_should_preserve_compaction_constraints() {
let request = encode(json!({
"model": "client-model",
"input": [
@@ -190,7 +190,7 @@ fn encoder_should_consume_only_the_terminal_compaction_trigger() {
}
#[test]
fn decoder_should_preserve_sub2api_summary_text_after_trimming_edges() {
fn decoder_should_preserve_summary_text_after_trimming_edges() {
let raw = valid_summary("checkpoint-only-secret");
let summary = decode_summary(&format!(" {raw} \n")).expect("summary");
@@ -234,7 +234,7 @@ fn decoder_should_preserve_unclosed_leading_analysis() {
}
#[test]
fn decoder_should_preserve_compaction_control_tokens_like_sub2api() {
fn decoder_should_preserve_compaction_control_tokens() {
let raw = valid_summary(
"Quoted <summary> and </summary>, <analysis> and </analysis>, \
plus <summary_request> and </summary_request>.",
@@ -278,7 +278,7 @@ fn decoder_should_preserve_numbered_sections_that_quote_analysis_close() {
}
#[test]
fn decoder_should_accept_short_summary_like_sub2api() {
fn decoder_should_accept_short_summary() {
let summary = decode_summary("<summary>too short</summary>").expect("short summary");
assert_eq!(summary, "<summary>too short</summary>");
@@ -567,7 +567,7 @@ async fn inference_transport_should_classify_free_usage_429_as_account_free_quot
}
#[tokio::test]
async fn inference_transport_should_apply_sub2api_body_aware_400_failures() {
async fn inference_transport_should_apply_body_aware_400_failures() {
let cases = [
(
json!({"error": {"code": "subscription:free-usage-exhausted", "message": "free usage exhausted"}}),
@@ -198,7 +198,7 @@ fn encoder_should_apply_build_defaults_and_normalize_reasoning_effort() {
}
#[test]
fn encoder_should_strip_sub2api_grok_unsupported_fields() {
fn encoder_should_strip_grok_unsupported_fields() {
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
@@ -943,7 +943,7 @@ fn custom_apply_patch_declaration_should_use_patch_parameter() {
}
#[test]
fn hosted_tool_choice_should_preserve_the_sub2api_specific_choice() {
fn hosted_tool_choice_should_preserve_normalized_web_search_choice() {
let request = raw_request(json!({
"model": "client",
"input": "search",
@@ -1244,7 +1244,7 @@ fn compaction_history_should_become_plaintext_user_continuation_in_place() {
}
#[test]
fn sub2api_compaction_history_should_restore_reasoning_ciphertext_and_visible_summary() {
fn structured_compaction_history_should_restore_reasoning_ciphertext_and_visible_summary() {
for item_type in ["compaction", "compaction_summary"] {
let request = raw_request(json!({
"model": "client",
@@ -1264,7 +1264,7 @@ fn sub2api_compaction_history_should_restore_reasoning_ciphertext_and_visible_su
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect("sub2api compaction continuation");
.expect("structured compaction continuation");
let body = Value::Object(encoded.body().clone());
assert_eq!(
@@ -1290,7 +1290,7 @@ fn sub2api_compaction_history_should_restore_reasoning_ciphertext_and_visible_su
}
#[test]
fn malformed_compaction_history_should_be_dropped_like_sub2api() {
fn malformed_compaction_history_should_be_dropped() {
for encrypted_content in [
None,
Some(json!(null)),
@@ -2,9 +2,12 @@
import type { AccountQuotaWindow } from '../constants'
import { computed } from 'vue'
import { useUiClock } from '@/composables/useUiClock'
import AccountRequestTimeline from './AccountUsageWindow/AccountRequestTimeline.vue'
import {
quotaWindowLocalUsageDisplay,
quotaWindowPresentation,
resolveAccountUsageWindowPresentation,
} from './AccountUsageWindow/presenter'
const props = defineProps<{
@@ -12,12 +15,25 @@ const props = defineProps<{
windows: AccountQuotaWindow[]
}>()
const now = useUiClock()
const grouped = computed(() => props.windows.length > 1 && Boolean(props.label))
const items = computed(() => props.windows.map((window) => {
const presentation = quotaWindowPresentation(window, '4px')
const view = resolveAccountUsageWindowPresentation({
window,
variant: 'detail',
showLocalValue: true,
now: now.value.getTime(),
})
return {
key: window.key,
local: view.mode === 'local',
localLabel: view.local.label,
requestDisplay: view.local.requestDisplay,
requestBars: view.local.requestBars,
timelineTitle: view.local.timelineTitle,
durationDisplay: view.local.durationDisplay,
label: grouped.value
? window.windowLabelDisplay
: window.labelDisplay,
@@ -48,49 +64,80 @@ const items = computed(() => props.windows.map((window) => {
:class="grouped ? 'grid-cols-2' : 'grid-cols-1'"
>
<div v-for="item in items" :key="item.key" class="grid min-w-0 gap-1.5">
<div class="flex min-w-0 items-baseline justify-between gap-2 text-[11px] leading-3.5">
<span class="min-w-0 truncate font-bold text-cp-muted-text">
{{ item.label }}
</span>
<span class="flex shrink-0 items-baseline gap-1.5 font-mono font-heavy tabular-nums">
<template v-if="item.local">
<div class="flex min-w-0 items-baseline justify-between gap-2 text-[11px] leading-3.5">
<span class="min-w-0 truncate font-bold text-cp-muted-text">
{{ item.localLabel }}
</span>
<strong
v-if="item.localUsageDisplay"
class="text-cp-muted-text"
:title="`窗口消耗:${item.localUsageDisplay}`"
class="shrink-0 font-mono font-heavy tabular-nums text-cp-primary"
:title="`窗口请求:${item.requestDisplay} 次`"
>
{{ item.localUsageDisplay }}
{{ item.requestDisplay }} 次
</strong>
<strong
:class="item.percentTextClass"
:title="`额度已使用:${item.usedPercentDisplay}`"
>
{{ item.usedPercentDisplay }}
</strong>
</span>
</div>
</div>
<div
class="h-1.5 w-full overflow-hidden rounded-full bg-cp-default-border"
role="progressbar"
:aria-label="item.ariaLabel"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="item.usedPercent ?? undefined"
:aria-valuetext="item.usedPercentDisplay"
>
<div
class="h-full rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none"
:class="item.barClass"
:style="item.barStyle"
<AccountRequestTimeline
class="h-1.5 w-full"
:bars="item.requestBars"
:label="item.timelineTitle"
/>
</div>
<p class="m-0 flex min-w-0 items-center justify-between gap-2 text-[10px] leading-3.5 text-cp-tertiary">
<span class="shrink-0 font-emphasis">重置</span>
<span class="min-w-0 truncate font-mono font-emphasis tabular-nums" :title="item.resetAtDisplay">
{{ item.resetAtDisplay }}
</span>
</p>
<p class="m-0 flex min-w-0 items-center justify-between gap-2 text-[10px] leading-3.5 text-cp-tertiary">
<span class="shrink-0 font-emphasis">统计范围</span>
<span
class="min-w-0 truncate font-mono font-emphasis tabular-nums"
:title="`滚动 ${item.durationDisplay}`"
>
滚动 {{ item.durationDisplay }}
</span>
</p>
</template>
<template v-else>
<div class="flex min-w-0 items-baseline justify-between gap-2 text-[11px] leading-3.5">
<span class="min-w-0 truncate font-bold text-cp-muted-text">
{{ item.label }}
</span>
<span class="flex shrink-0 items-baseline gap-1.5 font-mono font-heavy tabular-nums">
<strong
v-if="item.localUsageDisplay"
class="text-cp-muted-text"
:title="`窗口消耗:${item.localUsageDisplay}`"
>
{{ item.localUsageDisplay }}
</strong>
<strong
:class="item.percentTextClass"
:title="`额度已使用:${item.usedPercentDisplay}`"
>
{{ item.usedPercentDisplay }}
</strong>
</span>
</div>
<div
class="h-1.5 w-full overflow-hidden rounded-full bg-cp-default-border"
role="progressbar"
:aria-label="item.ariaLabel"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="item.usedPercent ?? undefined"
:aria-valuetext="item.usedPercentDisplay"
>
<div
class="h-full rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none"
:class="item.barClass"
:style="item.barStyle"
/>
</div>
<p class="m-0 flex min-w-0 items-center justify-between gap-2 text-[10px] leading-3.5 text-cp-tertiary">
<span class="shrink-0 font-emphasis">重置</span>
<span class="min-w-0 truncate font-mono font-emphasis tabular-nums" :title="item.resetAtDisplay">
{{ item.resetAtDisplay }}
</span>
</p>
</template>
</div>
</div>
</section>
@@ -6,6 +6,7 @@ import { computed } from 'vue'
import BasePopover from '@/components/base/BasePopover.vue'
import { useUiClock } from '@/composables/useUiClock'
import AccountQuotaWindowGroup from './AccountQuotaWindowGroup.vue'
import AccountRequestTimeline from './AccountUsageWindow/AccountRequestTimeline.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
import { resolveAccountUsageWindowPresentation } from './AccountUsageWindow/presenter'
@@ -37,6 +38,12 @@ const detailItems = computed(() => props.windows.map((window) => {
return {
key: window.key,
local: view.mode === 'local',
localLabel: view.local.label,
requestDisplay: view.local.requestDisplay,
requestBars: view.local.requestBars,
timelineTitle: view.local.timelineTitle,
durationDisplay: view.local.durationDisplay,
code: quotaWindowCode(window.windowSeconds, window.role),
label: window.windowLabelDisplay,
usedPercent: window.usedPercent,
@@ -141,45 +148,69 @@ function quotaWindowCode(
{{ item.code }}
</span>
<h4 class="m-0 min-w-0 truncate text-[12px] leading-4 font-heavy text-cp-secondary">
{{ item.label }}
{{ item.local ? item.localLabel : item.label }}
</h4>
<strong
class="font-mono text-[11px] leading-none font-heavy tabular-nums"
:class="item.percentTextClass"
:class="item.local ? 'text-cp-primary' : item.percentTextClass"
>
{{ item.usedPercentDisplay }}
{{ item.local ? `${item.requestDisplay} 次` : item.usedPercentDisplay }}
</strong>
</div>
<div
class="mt-2 h-1 w-full overflow-hidden rounded-full bg-cp-default-border"
role="progressbar"
:aria-label="item.label"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="item.usedPercent ?? undefined"
:aria-valuetext="item.usedPercentDisplay"
>
<AccountRequestTimeline
v-if="item.local"
class="mt-2 h-1 w-full"
:bars="item.requestBars"
:label="item.timelineTitle"
/>
<template v-else>
<div
class="h-full rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none"
:class="item.barClass"
:style="item.barStyle"
/>
</div>
class="mt-2 h-1 w-full overflow-hidden rounded-full bg-cp-default-border"
role="progressbar"
:aria-label="item.label"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="item.usedPercent ?? undefined"
:aria-valuetext="item.usedPercentDisplay"
>
<div
class="h-full rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none"
:class="item.barClass"
:style="item.barStyle"
/>
</div>
</template>
<dl class="mt-2.5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-[10px] leading-4">
<dt class="font-emphasis text-cp-tertiary">
窗口消耗
</dt>
<dd class="m-0 text-right font-mono font-heavy tabular-nums text-cp-primary">
{{ item.localUsageDisplay }}
</dd>
<dt class="font-emphasis text-cp-tertiary">
重置时间
</dt>
<dd class="m-0 truncate text-right font-mono font-emphasis tabular-nums text-cp-secondary">
{{ item.resetAtDisplay }}
</dd>
<template v-if="item.local">
<dt class="font-emphasis text-cp-tertiary">
统计方式
</dt>
<dd class="m-0 text-right font-mono font-heavy tabular-nums text-cp-primary">
滚动窗口
</dd>
<dt class="font-emphasis text-cp-tertiary">
统计范围
</dt>
<dd class="m-0 truncate text-right font-mono font-emphasis tabular-nums text-cp-secondary">
{{ item.durationDisplay }}
</dd>
</template>
<template v-else>
<dt class="font-emphasis text-cp-tertiary">
窗口消耗
</dt>
<dd class="m-0 text-right font-mono font-heavy tabular-nums text-cp-primary">
{{ item.localUsageDisplay }}
</dd>
<dt class="font-emphasis text-cp-tertiary">
重置时间
</dt>
<dd class="m-0 truncate text-right font-mono font-emphasis tabular-nums text-cp-secondary">
{{ item.resetAtDisplay }}
</dd>
</template>
</dl>
</article>
</div>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { AccountRequestBar } from './presenter'
withDefaults(
defineProps<{
bars: AccountRequestBar[]
label: string
showNativeTooltip?: boolean
}>(),
{
showNativeTooltip: true,
},
)
</script>
<template>
<div
class="flex items-stretch gap-px overflow-hidden rounded-full"
role="img"
:aria-label="label"
:title="showNativeTooltip ? label : undefined"
>
<span
v-for="bar in bars"
:key="bar.key"
class="relative min-w-0 flex-1 bg-cp-default-border"
:title="showNativeTooltip ? bar.title : undefined"
>
<span
class="absolute inset-x-0 bottom-0 bg-cp-success"
:style="{ height: bar.height }"
/>
</span>
</div>
</template>
@@ -3,6 +3,7 @@ import type { AccountQuotaWindow } from '../../constants'
import type { AccountUsageWindowVariant } from './presenter'
import { computed } from 'vue'
import { useUiClock } from '@/composables/useUiClock'
import AccountRequestTimeline from './AccountRequestTimeline.vue'
import { resolveAccountUsageWindowPresentation } from './presenter'
const props = withDefaults(
@@ -116,25 +117,12 @@ const view = computed(() => resolveAccountUsageWindowPresentation({
{{ view.local.requestDisplay }}
</strong>
</div>
<div
class="flex items-stretch gap-px"
<AccountRequestTimeline
:bars="view.local.requestBars"
:label="view.local.timelineTitle"
:show-native-tooltip="showNativeTooltip"
:class="[view.classes.trackShape, view.classes.trackOffset]"
role="img"
:aria-label="view.local.timelineTitle"
:title="view.local.timelineTitle"
>
<span
v-for="bar in view.local.requestBars"
:key="bar.key"
class="relative min-w-0 flex-1 bg-cp-default-border"
:title="bar.title"
>
<span
class="absolute inset-x-0 bottom-0 bg-cp-success"
:style="{ height: bar.height }"
/>
</span>
</div>
/>
</template>
<div v-else :class="view.classes.header">
@@ -12,6 +12,13 @@ interface AccountRequestBucket {
requestCount: number
}
export interface AccountRequestBar {
key: string
requestCount: number
height: string
title: string
}
interface AccountLocalUsage {
requestCount?: number
requestCountDisplay?: string
@@ -160,6 +167,7 @@ export function resolveAccountUsageWindowPresentation(
requestValueVisible: localRequestValueVisible,
timelineTitle: `${localRequestLabel} ${localRequestDisplay} 次`,
requestBars: requestTimeline(localUsage?.requestBuckets ?? [], input.now),
durationDisplay: rollingWindowDurationDisplay(input.window?.windowSeconds),
},
}
}
@@ -242,6 +250,21 @@ function requestCountDisplay(localUsage: AccountLocalUsage | null) {
return typeof count === 'number' ? formatInteger(count) : '0'
}
function rollingWindowDurationDisplay(windowSeconds: number | null | undefined) {
if (typeof windowSeconds !== 'number' || !Number.isFinite(windowSeconds) || windowSeconds <= 0)
return '—'
const hourSeconds = 60 * 60
const daySeconds = 24 * hourSeconds
if (windowSeconds <= daySeconds * 2 && windowSeconds % hourSeconds === 0)
return `${formatInteger(windowSeconds / hourSeconds)} 小时`
if (windowSeconds % daySeconds === 0)
return `${formatInteger(windowSeconds / daySeconds)} 天`
if (windowSeconds % hourSeconds === 0)
return `${formatInteger(windowSeconds / hourSeconds)} 小时`
return `${formatInteger(windowSeconds)} 秒`
}
export function quotaWindowPresentation(window: AccountQuotaWindow, minimumWidth: string) {
const percent = clamp(window.usedPercent ?? 0, 0, 100)
const tone = quotaWindowTone(window.usedPercent)
@@ -268,7 +291,7 @@ function quotaWindowTone(usedPercent: number | null): QuotaWindowTone {
function requestTimeline(
buckets: NonNullable<AccountLocalUsage['requestBuckets']>,
now: number,
) {
): AccountRequestBar[] {
const currentHour = Math.floor(now / hourMilliseconds) * hourMilliseconds
const bucketCounts = new Map(
buckets.map((bucket) => {
@@ -519,7 +519,7 @@ function formatDashboardUsd(value: string) {
const amount = Number(normalized.slice(1).replaceAll(',', ''))
if (!Number.isFinite(amount))
return value
if (amount < 1_000)
if (amount < 10_000)
return `$${amount.toFixed(2)}`
return `$${(amount / 1_000).toFixed(3)}k`
}
+1 -1
View File
@@ -1 +1 @@
version: "3.2.14"
version: "3.2.15"