fix(admin): align quota metadata and usage layout

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-20 15:14:28 +08:00
co-authored by Codex
parent 0cb5a29699
commit cd89e2e8fc
20 changed files with 575 additions and 126 deletions
@@ -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<String>,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<ProviderQuotaWindowRole>,
pub local_usage_attribution: QuotaLocalUsageAttribution,
pub window_seconds: Option<u64>,
pub used_percent: Option<f64>,
+6 -2
View File
@@ -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,
@@ -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),
@@ -305,8 +305,12 @@ pub struct AccountQuotaView {
pub struct AccountQuotaWindowView {
pub key: String,
pub group: String,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<String>,
pub window_seconds: Option<u64>,
pub label_display: String,
pub window_label_display: String,
pub used_percent: Option<f64>,
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
+25 -64
View File
@@ -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<u64>,
) -> 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<String> {
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<u64>, role: CodexQuotaWindowRole) -> String {
@@ -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!(
(
+6 -2
View File
@@ -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,
+4
View File
@@ -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
@@ -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),
))
</script>
<template>
<section class="rounded-lg bg-cp-surface p-4 shadow-cp-control">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<section class="flex min-h-0 flex-col rounded-lg bg-cp-surface p-4 shadow-cp-control">
<div class="mb-3 flex shrink-0 items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="m-0 text-[14px] font-heavy text-cp-primary">
账号额度
</h3>
<p
class="m-0 mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[12px] font-emphasis text-cp-secondary"
class="m-0 mt-1 flex min-w-0 items-center gap-1.5 text-[11px] font-emphasis text-cp-secondary"
>
<span>{{ account.provider === 'xai' ? 'xAI 用量窗口' : 'Codex 额度' }}</span>
<template v-if="account.provider === 'openai'">
<span>·</span>
<span>套餐:</span>
<AccountPlanBadge :plan-type="account.planType" size="sm" />
</template>
<span>·</span>
@@ -55,9 +56,16 @@ const quotaWindows = computed(() => orderedPanelQuotaWindows(props.account.quota
</BaseIconButton>
</div>
<div class="grid gap-3">
<AccountUsageWindow v-for="window in quotaWindows" :key="window.key" :window="window" />
<AccountUsageWindow v-if="quotaWindows.length === 0" />
<div class="grid max-h-48 min-h-0 gap-3 overflow-y-auto pr-1">
<AccountQuotaPanelEntry
v-for="entry in quotaEntries"
:key="entry.key"
:label="entry.label"
:windows="entry.windows"
/>
<p v-if="quotaEntries.length === 0" class="m-0 text-[12px] font-emphasis text-cp-secondary">
额度待观测
</p>
</div>
</section>
</template>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import { computed } from 'vue'
import {
quotaWindowLocalUsageDisplay,
quotaWindowPresentation,
} from './AccountUsageWindow/presenter'
const props = defineProps<{
label: string | null
windows: AccountQuotaWindow[]
}>()
const grouped = computed(() => props.windows.length > 1 && Boolean(props.label))
const items = computed(() => props.windows.map((window) => {
const presentation = quotaWindowPresentation(window, '4px')
return {
key: window.key,
label: grouped.value
? window.windowLabelDisplay
: window.labelDisplay,
ariaLabel: window.labelDisplay,
usedPercent: window.usedPercent,
usedPercentDisplay: window.usedPercentDisplay,
localUsageDisplay: quotaWindowLocalUsageDisplay(window),
resetAtDisplay: window.resetAtDisplay,
percentTextClass: presentation.percentTextClass,
barClass: presentation.barClass,
barStyle: presentation.barStyle,
}
}))
</script>
<template>
<section class="grid min-w-0 gap-2">
<h4
v-if="grouped && label"
class="m-0 truncate text-[11px] leading-4 font-heavy text-cp-secondary"
:title="label"
>
{{ label }}
</h4>
<div
class="grid min-w-0 gap-3"
: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">
<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>
</div>
</div>
</section>
</template>
@@ -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(
<template>
<div :class="summaryClass">
<AccountUsageWindow
v-for="window in visibleQuotaWindows"
:key="window.key"
:window="window"
variant="compact"
<AccountQuotaSummaryEntry
v-for="entry in summaryEntries"
:key="entry.key"
:label="entry.label"
:windows="entry.windows"
/>
<AccountUsageWindow v-if="quotaWindows.length === 0" variant="compact" />
<AccountUsageWindow v-if="summaryEntries.length === 0" variant="compact" />
</div>
</template>
@@ -0,0 +1,188 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import { computed } from 'vue'
import BasePopover from '@/components/base/BasePopover.vue'
import { useUiClock } from '@/composables/useUiClock'
import AccountQuotaWindowGroup from './AccountQuotaWindowGroup.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
import { resolveAccountUsageWindowPresentation } from './AccountUsageWindow/presenter'
const props = defineProps<{
label: string | null
windows: AccountQuotaWindow[]
}>()
const now = useUiClock()
const grouped = computed(() => (
props.windows.length > 1
&& Boolean(props.label)
))
const detailHeading = computed(() => (
props.label
))
const detailTitle = computed(() => (
detailHeading.value
?? props.windows[0]?.labelDisplay
?? '额度详情'
))
const detailItems = computed(() => props.windows.map((window) => {
const view = resolveAccountUsageWindowPresentation({
window,
variant: 'detail',
showLocalValue: true,
now: now.value.getTime(),
})
return {
key: window.key,
code: quotaWindowCode(window.windowSeconds, window.role),
label: window.windowLabelDisplay,
usedPercent: window.usedPercent,
usedPercentDisplay: window.usedPercentDisplay,
resetAtDisplay: window.resetAtDisplay,
localUsageDisplay: view.quota.localUsageVisible
? view.quota.localUsageDisplay
: '—',
percentTextClass: view.quota.percentTextClass,
barClass: view.quota.barClass,
barStyle: view.quota.barStyle,
}
}))
function quotaWindowCode(
windowSeconds: number | null,
role: AccountQuotaWindow['role'],
) {
if (typeof windowSeconds === 'number' && Number.isFinite(windowSeconds)) {
const daySeconds = 24 * 60 * 60
const hourSeconds = 60 * 60
if (windowSeconds >= daySeconds && windowSeconds % daySeconds === 0)
return `${windowSeconds / daySeconds}D`
if (windowSeconds >= hourSeconds && windowSeconds % hourSeconds === 0)
return `${windowSeconds / hourSeconds}H`
}
switch (role) {
case 'primary':
return 'P'
case 'secondary':
return 'S'
case 'monthly':
return 'M'
default:
return 'Q'
}
}
</script>
<template>
<BasePopover
class="w-full"
trigger="hover-click"
placement="right"
:hover-delay="240"
>
<template #trigger="{ open }">
<button
type="button"
class="block w-full cursor-help rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
:aria-label="`查看${detailTitle}详情`"
:aria-expanded="open"
aria-haspopup="dialog"
>
<AccountQuotaWindowGroup
v-if="grouped && label"
:label="label"
:windows="windows"
/>
<AccountUsageWindow
v-else
:window="windows[0]"
variant="compact"
:show-local-value="true"
:show-percentage="false"
:show-native-tooltip="false"
/>
</button>
</template>
<section
class="w-84 overflow-hidden rounded-cp-overlay"
role="dialog"
:aria-label="`${detailTitle}详情`"
>
<header
v-if="detailHeading"
class="bg-cp-muted p-3"
>
<h3
class="m-0 truncate text-[13px] leading-5 font-heavy text-cp-primary"
:title="detailHeading"
>
{{ detailHeading }}
</h3>
</header>
<div
class="grid gap-4"
:class="detailHeading ? 'p-3' : 'p-4'"
>
<article
v-for="item in detailItems"
:key="item.key"
>
<div class="grid grid-cols-[32px_minmax(0,1fr)_auto] items-center gap-2.5">
<span
class="grid h-5 w-8 place-items-center rounded-sm bg-cp-muted font-mono text-[9px] leading-none font-heavy tracking-[0.04em] text-cp-info-text"
aria-hidden="true"
>
{{ item.code }}
</span>
<h4 class="m-0 min-w-0 truncate text-[12px] leading-4 font-heavy text-cp-secondary">
{{ item.label }}
</h4>
<strong
class="font-mono text-[11px] leading-none font-heavy tabular-nums"
:class="item.percentTextClass"
>
{{ 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"
>
<div
class="h-full rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none"
:class="item.barClass"
:style="item.barStyle"
/>
</div>
<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>
</dl>
</article>
</div>
</section>
</BasePopover>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import { computed } from 'vue'
import { quotaWindowPresentation } from './AccountUsageWindow/presenter'
const props = defineProps<{
label: string
windows: AccountQuotaWindow[]
}>()
const windowItems = computed(() => props.windows.map(window => ({
key: window.key,
labelTooltip: window.labelDisplay,
usedPercent: window.usedPercent,
usedPercentDisplay: window.usedPercentDisplay,
presentation: quotaWindowPresentation(window, '2px'),
})))
const trackGridStyle = computed(() => ({
gridTemplateColumns: `repeat(${Math.max(windowItems.value.length, 1)}, minmax(0, 1fr))`,
}))
</script>
<template>
<div class="flex min-w-0 flex-col">
<div class="mb-1 min-w-0 truncate text-[11px] leading-3.5 font-bold text-cp-muted-text">
{{ label }}
</div>
<div class="grid min-w-0 gap-1" :style="trackGridStyle">
<div
v-for="item in windowItems"
:key="item.key"
class="h-0.75 w-full overflow-hidden rounded-full bg-cp-default-border"
role="progressbar"
:aria-label="item.labelTooltip"
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"
:class="item.presentation.barClass"
:style="item.presentation.barStyle"
/>
</div>
</div>
</div>
</template>
@@ -69,7 +69,7 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
</div>
<div
class="min-w-0 pt-4 shadow-[inset_0_1px_0_var(--cp-divider-subtle)] xl:pt-0 xl:pl-4 xl:shadow-[inset_1px_0_0_var(--cp-divider-subtle)]"
class="min-w-0 pt-4 xl:pt-0 xl:pl-4"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="m-0 text-[14px] font-heavy text-cp-primary">
@@ -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({
<span
class="min-w-0"
:class="view.classes.label"
:title="view.labelTooltip"
:title="showNativeTooltip ? view.labelTooltip : undefined"
>
{{ window.labelDisplay }}
</span>
@@ -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 }}
</span>
<span
v-if="view.quota.valueVisible"
v-if="showPercentage && view.quota.valueVisible"
class="text-right"
:class="[view.classes.value, view.quota.percentTextClass]"
:title="`额度已使用:${window.usedPercentDisplay}`"
:title="showNativeTooltip ? `额度已使用:${window.usedPercentDisplay}` : undefined"
>
{{ window.usedPercentDisplay }}
</span>
@@ -70,7 +74,7 @@ const view = computed(() => resolveAccountUsageWindowPresentation({
{{ view.quota.localUsageDisplay }}
</span>
<span
v-if="view.quota.valueVisible"
v-if="showPercentage && view.quota.valueVisible"
:class="[view.classes.value, view.quota.percentTextClass]"
>
{{ window.usedPercentDisplay }}
@@ -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 {
+70 -6
View File
@@ -5,6 +5,12 @@ import { formatProviderLabel } from '@/utils/providers'
export type AccountRow = Awaited<ReturnType<typeof getAccounts>>['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<string, number>()
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)
}
@@ -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,
@@ -15,7 +15,7 @@ const latencyDetails = computed(() => usageLatencyDetails(props.record))
<template>
<div class="flex items-center justify-end gap-1.5">
<div
class="grid grid-cols-[auto_auto] items-center justify-end gap-x-2 gap-y-1.5 font-mono text-[12px] leading-none font-heavy tabular-nums"
class="grid grid-cols-[auto_auto] items-center justify-end gap-x-2 gap-y-1.5 whitespace-nowrap font-mono text-[12px] leading-none font-heavy tabular-nums"
>
<span class="text-[11px] text-cp-muted-text">{{ latencyDetails.firstOutputLabel }}</span>
<span class="text-cp-secondary">{{ latencyDetails.firstOutputDisplay }}</span>
+3 -3
View File
@@ -19,15 +19,15 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
size: 'sm',
format: (value: unknown) => formatProvider(typeof value === 'string' ? value : null),
},
{ key: 'model', label: '模型', kind: 'custom' },
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'status' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'recordType', label: '类型', kind: 'status', size: 'sm' },
{ key: 'tokenDetails', label: 'TOKEN', kind: 'numeric', size: 'xl' },
{ key: 'billing', label: '费用', kind: 'numeric', size: 'lg' },
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'lg' },
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'xl' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '2xl' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'sm' },
])