From cd89e2e8fc735835f5ed07b43b111f9ab685ff32 Mon Sep 17 00:00:00 2001 From: Zyy Date: Thu, 20 Aug 2026 14:44:23 +0800 Subject: [PATCH] fix(admin): align quota metadata and usage layout Co-authored-by: Codex --- .../src/model/provider_credentials.rs | 23 ++- backend/crates/gateway-admin/tests/main.rs | 8 +- .../gateway-admin/tests/use_case/accounts.rs | 26 ++- .../crates/gateway-api/src/admin/accounts.rs | 17 +- backend/crates/providers/openai/src/admin.rs | 89 +++------ .../crates/providers/openai/tests/admin.rs | 12 +- backend/crates/providers/xai/src/admin.rs | 8 +- frontend/src/api/modules/accounts.ts | 4 + .../accounts/components/AccountQuotaPanel.vue | 30 ++- .../components/AccountQuotaPanelEntry.vue | 97 +++++++++ .../components/AccountQuotaSummaryCell.vue | 20 +- .../components/AccountQuotaSummaryEntry.vue | 188 ++++++++++++++++++ .../components/AccountQuotaWindowGroup.vue | 51 +++++ .../accounts/components/AccountUsagePanel.vue | 2 +- .../components/AccountUsageWindow/index.vue | 14 +- .../AccountUsageWindow/presenter.ts | 20 +- frontend/src/views/accounts/constants.ts | 76 ++++++- .../dashboard/composables/useDashboard.ts | 8 + .../usage/components/UsageLatencyCell.vue | 2 +- frontend/src/views/usage/constants.ts | 6 +- 20 files changed, 575 insertions(+), 126 deletions(-) create mode 100644 frontend/src/views/accounts/components/AccountQuotaPanelEntry.vue create mode 100644 frontend/src/views/accounts/components/AccountQuotaSummaryEntry.vue create mode 100644 frontend/src/views/accounts/components/AccountQuotaWindowGroup.vue diff --git a/backend/crates/gateway-admin/src/model/provider_credentials.rs b/backend/crates/gateway-admin/src/model/provider_credentials.rs index 785039042..1334bff61 100644 --- a/backend/crates/gateway-admin/src/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/src/model/provider_credentials.rs @@ -570,13 +570,34 @@ pub enum QuotaLocalUsageAttribution { Unavailable, } +/// Provider quota bucket 中窗口的官方位置语义。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderQuotaWindowRole { + Primary, + Secondary, + Monthly, +} + +impl ProviderQuotaWindowRole { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Primary => "primary", + Self::Secondary => "secondary", + Self::Monthly => "monthly", + } + } +} + /// 一个 Provider quota 窗口的公共投影。 #[derive(Debug, Clone, PartialEq)] pub struct ProviderQuotaWindow { pub key: String, pub group: String, pub label: String, - pub source: Option, + pub limit_id: Option, + pub limit_name: Option, + pub role: Option, pub local_usage_attribution: QuotaLocalUsageAttribution, pub window_seconds: Option, pub used_percent: Option, diff --git a/backend/crates/gateway-admin/tests/main.rs b/backend/crates/gateway-admin/tests/main.rs index 6c6091f93..315e466ac 100644 --- a/backend/crates/gateway-admin/tests/main.rs +++ b/backend/crates/gateway-admin/tests/main.rs @@ -165,7 +165,9 @@ fn representative_window_usage_should_accept_account_wide_rolling_window_without key: "free-rolling-24h".to_owned(), group: "shortTerm".to_owned(), label: "日限额".to_owned(), - source: None, + limit_id: None, + limit_name: None, + role: None, local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds: Some(86_400), used_percent: None, @@ -234,7 +236,9 @@ fn quota_window( key: group.to_owned(), group: group.to_owned(), label: group.to_owned(), - source: None, + limit_id: None, + limit_name: None, + role: None, local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds, used_percent, diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index 0f56d93d7..98ed7537b 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -1262,7 +1262,9 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop key: "primary".to_owned(), group: "shortTerm".to_owned(), label: "5小时限额".to_owned(), - source: None, + limit_id: None, + limit_name: None, + role: None, local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds: Some(5 * 60 * 60), used_percent: Some(97.0), @@ -1391,7 +1393,9 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view() key: "primary".to_owned(), group: "shortTerm".to_owned(), label: "5小时限额".to_owned(), - source: None, + limit_id: None, + limit_name: None, + role: None, local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds: Some(5 * 60 * 60), used_percent: Some(100.0), @@ -1436,7 +1440,9 @@ async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() { key: "primary".to_owned(), group: "shortTerm".to_owned(), label: "5小时限额".to_owned(), - source: None, + limit_id: None, + limit_name: None, + role: None, local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds: Some(5 * 60 * 60), used_percent: Some(100.0), @@ -1482,7 +1488,9 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() { key: "primary".to_owned(), group: "shortTerm".to_owned(), label: "5小时限额".to_owned(), - source: None, + limit_id: None, + limit_name: None, + role: None, local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds: Some(5 * 60 * 60), used_percent: Some(97.0), @@ -1547,7 +1555,9 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w key: "core-primary".to_owned(), group: "weekly".to_owned(), label: "周额度".to_owned(), - source: Some("codex".to_owned()), + limit_id: Some("codex".to_owned()), + limit_name: None, + role: None, local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds: Some(7 * 24 * 60 * 60), used_percent: Some(1.0), @@ -1559,8 +1569,10 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w ProviderQuotaWindow { key: "codex-bengalfox-primary".to_owned(), group: "weekly".to_owned(), - label: "GPT-5.3-Codex-Spark · 周额度".to_owned(), - source: Some("codex_bengalfox".to_owned()), + label: "周额度".to_owned(), + limit_id: Some("codex_bengalfox".to_owned()), + limit_name: Some("GPT-5.3-Codex-Spark".to_owned()), + role: None, local_usage_attribution: QuotaLocalUsageAttribution::Unavailable, window_seconds: Some(7 * 24 * 60 * 60), used_percent: Some(0.0), diff --git a/backend/crates/gateway-api/src/admin/accounts.rs b/backend/crates/gateway-api/src/admin/accounts.rs index 4f83a5cd8..384f79caf 100644 --- a/backend/crates/gateway-api/src/admin/accounts.rs +++ b/backend/crates/gateway-api/src/admin/accounts.rs @@ -305,8 +305,12 @@ pub struct AccountQuotaView { pub struct AccountQuotaWindowView { pub key: String, pub group: String, + pub limit_id: Option, + pub limit_name: Option, + pub role: Option, pub window_seconds: Option, pub label_display: String, + pub window_label_display: String, pub used_percent: Option, pub used_percent_display: String, pub limit_reached: bool, @@ -1603,7 +1607,9 @@ fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView { key, group, label, - source: _, + limit_id, + limit_name, + role, local_usage_attribution: _, window_seconds, used_percent, @@ -1612,10 +1618,17 @@ fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView { local_usage, provider_data: _, } = window; + let label_display = limit_name + .as_ref() + .map_or_else(|| label.clone(), |name| format!("{name} · {label}")); AccountQuotaWindowView { - label_display: label, + label_display, + window_label_display: label, key, group, + limit_id, + limit_name, + role: role.map(|value| value.as_str().to_owned()), window_seconds, used_percent, used_percent_display: used_percent diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index e5f7eb1b2..f5f5b74fb 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -19,7 +19,7 @@ use gateway_admin::model::provider_credentials::{ PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument, ProviderExport, ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderQuota, - ProviderQuotaRequest, ProviderQuotaWindow, QuotaLocalUsageAttribution, + ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole, QuotaLocalUsageAttribution, }; use gateway_admin::model::{MutationActor, MutationContext, Revision}; use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind}; @@ -734,39 +734,24 @@ fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota .windows() .iter() .filter(|window| should_project_quota_window(window)) - .map(|window| { - let mut data = Map::new(); - data.insert( - "source".to_owned(), - Value::String(window.source().to_owned()), - ); - data.insert( - "role".to_owned(), - Value::String(quota_role(window.role()).to_owned()), - ); - ProviderQuotaWindow { - key: window.key().to_owned(), - group: quota_group(window.kind()).to_owned(), - label: codex_quota_window_label( - window.limit_name(), - window.kind(), - window.role(), - window.source(), - window.window_seconds(), - ), - source: Some(window.source().to_owned()), - local_usage_attribution: if window.is_account_wide() { - QuotaLocalUsageAttribution::AccountWide - } else { - QuotaLocalUsageAttribution::Unavailable - }, - window_seconds: window.window_seconds(), - used_percent: window.used_percent(), - reset_at: window.reset_at(), - limit_reached: window.limit_reached(), - local_usage: None, - provider_data: Some(ProviderDocument::new(OpaqueProviderData::new(data))), - } + .map(|window| ProviderQuotaWindow { + key: window.key().to_owned(), + group: quota_group(window.kind()).to_owned(), + label: codex_quota_window_label(window.kind(), window.role(), window.window_seconds()), + limit_id: Some(window.source().to_owned()), + limit_name: window.limit_name().map(str::to_owned), + role: Some(quota_role(window.role())), + local_usage_attribution: if window.is_account_wide() { + QuotaLocalUsageAttribution::AccountWide + } else { + QuotaLocalUsageAttribution::Unavailable + }, + window_seconds: window.window_seconds(), + used_percent: window.used_percent(), + reset_at: window.reset_at(), + limit_reached: window.limit_reached(), + local_usage: None, + provider_data: None, }) .collect(); // 快照级 limit_reached 只看滚动后的窗口触顶:顶层标记是观测事实,不能 @@ -806,23 +791,21 @@ const fn quota_group(kind: CodexQuotaWindowKind) -> &'static str { } } -const fn quota_role(role: CodexQuotaWindowRole) -> &'static str { +const fn quota_role(role: CodexQuotaWindowRole) -> ProviderQuotaWindowRole { match role { - CodexQuotaWindowRole::Primary => "primary", - CodexQuotaWindowRole::Secondary => "secondary", - CodexQuotaWindowRole::Monthly => "monthly", + CodexQuotaWindowRole::Primary => ProviderQuotaWindowRole::Primary, + CodexQuotaWindowRole::Secondary => ProviderQuotaWindowRole::Secondary, + CodexQuotaWindowRole::Monthly => ProviderQuotaWindowRole::Monthly, } } -/// 按窗口时长显示汉化额度名;非核心桶补上上游桶名称作为前缀。 +/// 按窗口时长显示汉化额度名;额度桶名称通过独立字段投影。 fn codex_quota_window_label( - limit_name: Option<&str>, kind: CodexQuotaWindowKind, role: CodexQuotaWindowRole, - source: &str, window_seconds: Option, ) -> String { - let base = match kind { + match kind { CodexQuotaWindowKind::Monthly => "月额度".to_owned(), CodexQuotaWindowKind::Weekly => "周额度".to_owned(), CodexQuotaWindowKind::ShortTerm => { @@ -833,29 +816,7 @@ fn codex_quota_window_label( } } CodexQuotaWindowKind::Other => custom_quota_window_label(window_seconds, role), - }; - if let Some(prefix) = codex_quota_window_prefix(limit_name, source) { - return format!("{prefix} · {base}"); } - base -} - -fn codex_quota_window_prefix(limit_name: Option<&str>, source: &str) -> Option { - let source_identifier = quota_identifier(source); - if matches!(source_identifier.as_str(), "core" | "codex") { - return None; - } - let label = limit_name.unwrap_or(source); - match quota_identifier(label).as_str() { - "premium" => Some("Premium".to_owned()), - "code_review" => Some("代码审查".to_owned()), - "spend_control" => Some("消费控制".to_owned()), - _ => Some(label.replace('_', " ")), - } -} - -fn quota_identifier(value: &str) -> String { - value.trim().to_ascii_lowercase().replace(['-', ' '], "_") } fn custom_quota_window_label(window_seconds: Option, role: CodexQuotaWindowRole) -> String { diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index b08e811bb..97df854f7 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -14,7 +14,7 @@ use gateway_admin::model::provider_credentials::{ AuthorizationMutationTarget, AuthorizationOwnerBinding, CompleteAuthorization, PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation, ProviderDocument, ProviderExportCredentialInput, - ProviderQuotaRequest, QuotaLocalUsageAttribution, + ProviderQuotaRequest, ProviderQuotaWindowRole, QuotaLocalUsageAttribution, }; use gateway_admin::model::{MutationActor, MutationContext, Revision}; use gateway_admin::ports::provider::ProviderAdminErrorKind; @@ -566,7 +566,7 @@ async fn openai_admin_provider_projects_official_codex_quota_and_independent_buc assert_eq!(monthly.len(), 1); assert!(monthly.iter().any(|window| { window.label == "月额度" - && window.source.as_deref() == Some("codex") + && window.limit_id.as_deref() == Some("codex") && window.used_percent == Some(91.0) })); assert!( @@ -586,13 +586,15 @@ async fn openai_admin_provider_projects_official_codex_quota_and_independent_buc let review = quota .windows .iter() - .find(|window| window.source.as_deref() == Some("code_review")) + .find(|window| window.limit_id.as_deref() == Some("code_review")) .expect("code review quota"); - assert_eq!(review.label, "代码审查 · 周额度"); + assert_eq!(review.label, "周额度"); + assert_eq!(review.limit_name.as_deref(), Some("code_review")); + assert_eq!(review.role, Some(ProviderQuotaWindowRole::Primary)); let spark = quota .windows .iter() - .find(|window| window.source.as_deref() == Some("codex_bengalfox")) + .find(|window| window.limit_id.as_deref() == Some("codex_bengalfox")) .expect("Spark quota"); assert_eq!( ( diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index b483e52cc..587527179 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -1071,7 +1071,9 @@ fn project_quota( key: billing.period_type().unwrap_or("billing").to_owned(), group: quota_group(period_kind).to_owned(), label: xai_quota_window_label(period_kind, window_seconds), - source: None, + limit_id: None, + limit_name: None, + role: None, local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds, used_percent: billing.used_percent(), @@ -1087,7 +1089,9 @@ fn project_quota( key: "free-rolling-24h".to_owned(), group: "shortTerm".to_owned(), label: "日限额".to_owned(), - source: None, + limit_id: None, + limit_name: None, + role: None, local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, window_seconds: Some(crate::GROK_FREE_ROLLING_WINDOW_SECONDS), used_percent: None, diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index 5168cc694..633dbf626 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -14,8 +14,12 @@ export type AccountErrorReason export interface AccountQuotaWindow { key: string group: string + limitId: string | null + limitName: string | null + role: 'primary' | 'secondary' | 'monthly' | null windowSeconds: number | null labelDisplay: string + windowLabelDisplay: string usedPercent: number | null usedPercentDisplay: string limitReached: boolean diff --git a/frontend/src/views/accounts/components/AccountQuotaPanel.vue b/frontend/src/views/accounts/components/AccountQuotaPanel.vue index c6e238e64..7f461e10b 100644 --- a/frontend/src/views/accounts/components/AccountQuotaPanel.vue +++ b/frontend/src/views/accounts/components/AccountQuotaPanel.vue @@ -4,9 +4,9 @@ import { RefreshCw } from '@lucide/vue' import { computed } from 'vue' import BaseIconButton from '@/components/base/BaseIconButton.vue' -import { orderedPanelQuotaWindows } from '../constants' +import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../constants' import AccountPlanBadge from './AccountPlanBadge.vue' -import AccountUsageWindow from './AccountUsageWindow/index.vue' +import AccountQuotaPanelEntry from './AccountQuotaPanelEntry.vue' const props = defineProps<{ account: AccountRow @@ -17,23 +17,24 @@ const emit = defineEmits<{ refreshQuota: [accountId: string] }>() -const quotaWindows = computed(() => orderedPanelQuotaWindows(props.account.quota.windows)) +const quotaEntries = computed(() => groupedAccountQuotaWindows( + orderedPanelQuotaWindows(props.account.quota.windows), +)) diff --git a/frontend/src/views/accounts/components/AccountQuotaPanelEntry.vue b/frontend/src/views/accounts/components/AccountQuotaPanelEntry.vue new file mode 100644 index 000000000..4818f9ac0 --- /dev/null +++ b/frontend/src/views/accounts/components/AccountQuotaPanelEntry.vue @@ -0,0 +1,97 @@ + + + diff --git a/frontend/src/views/accounts/components/AccountQuotaSummaryCell.vue b/frontend/src/views/accounts/components/AccountQuotaSummaryCell.vue index 4ee171fcd..c1550c42e 100644 --- a/frontend/src/views/accounts/components/AccountQuotaSummaryCell.vue +++ b/frontend/src/views/accounts/components/AccountQuotaSummaryCell.vue @@ -2,7 +2,8 @@ import type { AccountRow } from '../constants' import { computed } from 'vue' -import { visibleSummaryQuotaWindows } from '../constants' +import { groupedAccountQuotaWindows, visibleSummaryQuotaWindows } from '../constants' +import AccountQuotaSummaryEntry from './AccountQuotaSummaryEntry.vue' import AccountUsageWindow from './AccountUsageWindow/index.vue' const props = defineProps<{ @@ -11,10 +12,11 @@ const props = defineProps<{ const quotaWindows = computed(() => props.account.quota.windows) const visibleQuotaWindows = computed(() => visibleSummaryQuotaWindows(quotaWindows.value)) +const summaryEntries = computed(() => groupedAccountQuotaWindows(visibleQuotaWindows.value)) const summaryClass = computed( () => - `grid w-full min-w-0 gap-1.5 py-0.5 ${ - visibleQuotaWindows.value.length <= 1 + `box-border grid w-full min-w-0 gap-1 py-1.5 ${ + summaryEntries.value.length <= 1 ? 'min-h-13 content-center' : 'min-h-16.5 content-center' }`, @@ -23,12 +25,12 @@ const summaryClass = computed( diff --git a/frontend/src/views/accounts/components/AccountQuotaSummaryEntry.vue b/frontend/src/views/accounts/components/AccountQuotaSummaryEntry.vue new file mode 100644 index 000000000..b5e54cf1f --- /dev/null +++ b/frontend/src/views/accounts/components/AccountQuotaSummaryEntry.vue @@ -0,0 +1,188 @@ + + + diff --git a/frontend/src/views/accounts/components/AccountQuotaWindowGroup.vue b/frontend/src/views/accounts/components/AccountQuotaWindowGroup.vue new file mode 100644 index 000000000..41c4468e9 --- /dev/null +++ b/frontend/src/views/accounts/components/AccountQuotaWindowGroup.vue @@ -0,0 +1,51 @@ + + + diff --git a/frontend/src/views/accounts/components/AccountUsagePanel.vue b/frontend/src/views/accounts/components/AccountUsagePanel.vue index cb130ffcf..51a97b6ed 100644 --- a/frontend/src/views/accounts/components/AccountUsagePanel.vue +++ b/frontend/src/views/accounts/components/AccountUsagePanel.vue @@ -69,7 +69,7 @@ const modelUsageColumns = defineTableColumns([

diff --git a/frontend/src/views/accounts/components/AccountUsageWindow/index.vue b/frontend/src/views/accounts/components/AccountUsageWindow/index.vue index 669e1ffb8..8b50ec436 100644 --- a/frontend/src/views/accounts/components/AccountUsageWindow/index.vue +++ b/frontend/src/views/accounts/components/AccountUsageWindow/index.vue @@ -10,10 +10,14 @@ const props = withDefaults( window?: AccountQuotaWindow variant?: AccountUsageWindowVariant showLocalValue?: boolean + showPercentage?: boolean + showNativeTooltip?: boolean }>(), { variant: 'detail', showLocalValue: true, + showPercentage: true, + showNativeTooltip: true, }, ) @@ -33,7 +37,7 @@ const view = computed(() => resolveAccountUsageWindowPresentation({ {{ window.labelDisplay }} @@ -45,15 +49,15 @@ const view = computed(() => resolveAccountUsageWindowPresentation({ v-if="view.quota.localUsageVisible" class="text-right text-cp-muted-text" :class="view.classes.value" - :title="`本地 Token 用量:${view.quota.localUsageDisplay}`" + :title="showNativeTooltip ? `窗口消耗:${view.quota.localUsageDisplay}` : undefined" > {{ view.quota.localUsageDisplay }} {{ window.usedPercentDisplay }} @@ -70,7 +74,7 @@ const view = computed(() => resolveAccountUsageWindowPresentation({ {{ view.quota.localUsageDisplay }} {{ window.usedPercentDisplay }} diff --git a/frontend/src/views/accounts/components/AccountUsageWindow/presenter.ts b/frontend/src/views/accounts/components/AccountUsageWindow/presenter.ts index b86e7328a..afa0f9270 100644 --- a/frontend/src/views/accounts/components/AccountUsageWindow/presenter.ts +++ b/frontend/src/views/accounts/components/AccountUsageWindow/presenter.ts @@ -119,9 +119,7 @@ export function resolveAccountUsageWindowPresentation( const localUsage = accountLocalUsage(input.window?.localUsage) const mode = usageWindowMode(input.window, localUsage) const quotaLocalUsageDisplay = localTokenDisplay(localUsage) - const quotaLocalUsageVisible = input.showLocalValue - && typeof localUsage?.totalTokens === 'number' - && localUsage.totalTokens > 0 + const quotaLocalUsageVisible = input.showLocalValue && Boolean(quotaLocalUsageDisplay) const localRequestDisplay = requestCountDisplay(localUsage) const localRequestValueVisible = input.showLocalValue && typeof localUsage?.requestCount === 'number' @@ -146,7 +144,7 @@ export function resolveAccountUsageWindowPresentation( }, quota: { valueVisible: typeof input.window?.usedPercent === 'number' - && input.window.usedPercent > 0, + && (input.variant !== 'compact' || input.window.usedPercent > 0), localUsageDisplay: quotaLocalUsageDisplay, localUsageVisible: quotaLocalUsageVisible, barStyle: quota?.barStyle, @@ -221,11 +219,19 @@ function usageWindowMode( } function localTokenDisplay(localUsage: AccountLocalUsage | null) { + const total = localUsage?.totalTokens + if (typeof total !== 'number' || total <= 0) + return '' + const display = localUsage?.totalTokensDisplay if (typeof display === 'string' && display.trim()) return display.trim() - const total = localUsage?.totalTokens - return typeof total === 'number' && total > 0 ? formatInteger(total) : '' + return formatInteger(total) +} + +export function quotaWindowLocalUsageDisplay(window: AccountQuotaWindow) { + const localUsage = accountLocalUsage(window.localUsage) + return localTokenDisplay(localUsage) || null } function requestCountDisplay(localUsage: AccountLocalUsage | null) { @@ -236,7 +242,7 @@ function requestCountDisplay(localUsage: AccountLocalUsage | null) { return typeof count === 'number' ? formatInteger(count) : '0' } -function quotaWindowPresentation(window: AccountQuotaWindow, minimumWidth: string) { +export function quotaWindowPresentation(window: AccountQuotaWindow, minimumWidth: string) { const percent = clamp(window.usedPercent ?? 0, 0, 100) const tone = quotaWindowTone(window.usedPercent) return { diff --git a/frontend/src/views/accounts/constants.ts b/frontend/src/views/accounts/constants.ts index 823fc7466..2075a4295 100644 --- a/frontend/src/views/accounts/constants.ts +++ b/frontend/src/views/accounts/constants.ts @@ -5,6 +5,12 @@ import { formatProviderLabel } from '@/utils/providers' export type AccountRow = Awaited>['items'][number] export type AccountQuotaWindow = AccountRow['quota']['windows'][number] +export interface AccountQuotaWindowEntry { + key: string + label: string | null + windows: AccountQuotaWindow[] +} + const quotaGroupOrder = new Map([ ['shortTerm', 0], ['monthly', 1], @@ -104,6 +110,37 @@ export function orderedPanelQuotaWindows(windows: AccountQuotaWindow[]) { return [...windows].sort(compareQuotaWindows) } +export function groupedAccountQuotaWindows(windows: AccountQuotaWindow[]) { + const entries: AccountQuotaWindowEntry[] = [] + const limitEntryIndexes = new Map() + + for (const window of windows) { + const limitLabel = quotaLimitLabel(window) + if (!window.limitId || !limitLabel) { + entries.push({ key: window.key, label: null, windows: [window] }) + continue + } + + const existingIndex = limitEntryIndexes.get(window.limitId) + if (existingIndex !== undefined) { + entries[existingIndex]?.windows.push(window) + continue + } + + limitEntryIndexes.set(window.limitId, entries.length) + entries.push({ + key: `limit:${window.limitId}`, + label: limitLabel, + windows: [window], + }) + } + + for (const entry of entries) + entry.windows.sort(compareQuotaLimitWindows) + + return entries +} + export function modelSuccessRateTextClass(successRate: number | null) { if (successRate === null) return 'text-cp-muted-text' @@ -121,12 +158,7 @@ function compareQuotaWindows(left: AccountQuotaWindow, right: AccountQuotaWindow if (groupDifference !== 0) return groupDifference - const secondsDifference = (left.windowSeconds ?? Number.MAX_SAFE_INTEGER) - - (right.windowSeconds ?? Number.MAX_SAFE_INTEGER) - if (secondsDifference !== 0) - return secondsDifference - - // 同组同周期保留 Provider 的投影顺序,避免内部 key 把 additional 排到 core 前面。 + // 同组保留 Provider 的投影顺序,避免按窗口时长打散 core 与模型专属额度。 return 0 } @@ -134,6 +166,38 @@ function groupOrder(window: AccountQuotaWindow) { return quotaGroupOrder.get(window.group) ?? quotaGroupOrder.size } +function compareQuotaLimitWindows(left: AccountQuotaWindow, right: AccountQuotaWindow) { + const durationDifference = windowDurationOrder(left.windowSeconds) + - windowDurationOrder(right.windowSeconds) + if (durationDifference !== 0) + return durationDifference + + return windowRoleOrder(left.role) - windowRoleOrder(right.role) +} + +function windowDurationOrder(windowSeconds: number | null) { + return windowSeconds ?? Number.POSITIVE_INFINITY +} + +function windowRoleOrder(role: AccountQuotaWindow['role']) { + switch (role) { + case 'primary': + return 0 + case 'secondary': + return 1 + case 'monthly': + return 2 + default: + return 3 + } +} + +function quotaLimitLabel(window: AccountQuotaWindow) { + if (window.limitId === 'codex') + return '核心额度' + return window.limitName +} + function accountProviderLabel(value?: string | null) { return formatProviderLabel(value) } diff --git a/frontend/src/views/dashboard/composables/useDashboard.ts b/frontend/src/views/dashboard/composables/useDashboard.ts index 088ad0242..73ecb29df 100644 --- a/frontend/src/views/dashboard/composables/useDashboard.ts +++ b/frontend/src/views/dashboard/composables/useDashboard.ts @@ -462,7 +462,11 @@ function dashboardUsageWindow(item: DashboardSummary['accountUsage'][number]) { return { key: 'quota', group: 'other', + limitId: null, + limitName: null, + role: null, labelDisplay: '额度', + windowLabelDisplay: '额度', usedPercent, usedPercentDisplay: usedPercent === null ? '—' : `${usedPercent}%`, limitReached: (usedPercent ?? 0) >= 100, @@ -484,7 +488,11 @@ function dashboardUsageWindow(item: DashboardSummary['accountUsage'][number]) { return { key: 'dailyRequests', group: 'other', + limitId: null, + limitName: null, + role: null, labelDisplay: '日请求', + windowLabelDisplay: '日请求', usedPercent: null, usedPercentDisplay: '—', limitReached: false, diff --git a/frontend/src/views/usage/components/UsageLatencyCell.vue b/frontend/src/views/usage/components/UsageLatencyCell.vue index 6fa4ca8f2..b3d21ea87 100644 --- a/frontend/src/views/usage/components/UsageLatencyCell.vue +++ b/frontend/src/views/usage/components/UsageLatencyCell.vue @@ -15,7 +15,7 @@ const latencyDetails = computed(() => usageLatencyDetails(props.record))