fix(frontend): refine account usage presentation

Select quota summaries by the most recently used model, streamline dashboard account identities, and align diagnostic client metadata with successful records.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-03 12:21:15 +08:00
co-authored by Codex
parent bd223f6540
commit 0f0b464f4e
9 changed files with 193 additions and 53 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Key, Openai, Xai } from '@boxicons/vue'
import { Key, LinkAlt, Openai, Xai } from '@boxicons/vue'
import { computed } from 'vue'
import { formatProviderLabel } from '@/utils/providers'
@@ -38,6 +38,8 @@ const providerLabel = computed(() => formatProviderLabel(props.provider, '未知
const authenticationLabel = computed(() => {
if (normalizedAuthenticationKind.value === 'oauth')
return 'OAuth'
if (normalizedAuthenticationKind.value === 'api_key')
return 'API Key'
return props.authenticationKind?.trim() || '未知认证类型'
})
</script>
@@ -60,7 +62,8 @@ const authenticationLabel = computed(() => {
:class="iconContainerClass"
:title="authenticationLabel"
>
<Key v-if="normalizedAuthenticationKind === 'oauth'" :class="iconClass" />
<LinkAlt v-if="normalizedAuthenticationKind === 'oauth'" :class="iconClass" />
<Key v-else-if="normalizedAuthenticationKind === 'api_key'" :class="iconClass" />
<span v-else class="text-[10px] font-heavy text-cp-text-quaternary">?</span>
</span>
@@ -6,6 +6,7 @@ import { groupedAccountQuotaWindows, visibleSummaryQuotaWindows } from '../../co
import AccountUsageWindow from '../AccountUsageWindow/index.vue'
import { quotaWindowPresentation } from '../AccountUsageWindow/presenter'
import AccountQuotaSummaryEntry from './Entry.vue'
import { recentlyUsedQuotaEntry, representativeQuotaWindow } from './presenter'
const props = defineProps<{
account: AccountRow
@@ -15,26 +16,15 @@ const quotaWindows = computed(() => props.account.quota.windows)
const visibleQuotaWindows = computed(() => visibleSummaryQuotaWindows(quotaWindows.value))
const summaryEntries = computed(() => groupedAccountQuotaWindows(visibleQuotaWindows.value))
const hasUsage = computed(() => (props.account.usage.requestCount ?? 0) > 0)
const highestUsageWindow = computed(() => visibleQuotaWindows.value.reduce((highest, window) => {
if (typeof window.usedPercent !== 'number')
return highest
if (!highest || typeof highest.usedPercent !== 'number' || window.usedPercent > highest.usedPercent)
return window
return highest
}, undefined as (typeof visibleQuotaWindows.value)[number] | undefined))
const highestUsageDisplay = computed(() => highestUsageWindow.value?.usedPercentDisplay ?? '—')
const highestUsageTextClass = computed(() => highestUsageWindow.value
? quotaWindowPresentation(highestUsageWindow.value, '2px').percentTextClass
const recentUsageEntry = computed(() => recentlyUsedQuotaEntry(
summaryEntries.value,
props.account.usage.models,
))
const currentUsageWindow = computed(() => representativeQuotaWindow(recentUsageEntry.value))
const currentUsageDisplay = computed(() => currentUsageWindow.value?.usedPercentDisplay ?? '—')
const currentUsageTextClass = computed(() => currentUsageWindow.value
? quotaWindowPresentation(currentUsageWindow.value, '2px').percentTextClass
: 'text-cp-text-quaternary')
const highestUsageEntry = computed(() => {
const highestWindow = highestUsageWindow.value
if (!highestWindow)
return summaryEntries.value[0]
return summaryEntries.value.find(entry =>
entry.windows.some(window => window.key === highestWindow.key),
) ?? summaryEntries.value[0]
})
const additionalEntryCount = computed(() => Math.max(summaryEntries.value.length - 1, 0))
</script>
@@ -58,20 +48,20 @@ const additionalEntryCount = computed(() => Math.max(summaryEntries.value.length
</span>
<span
class="flex shrink-0 items-baseline gap-1 text-[9px] font-emphasis text-cp-text-quaternary"
title="所有额度窗口中的最高已用比例"
title="最近使用额度的当前已用比例"
>
<span>最高占用</span>
<strong class="font-mono font-heavy tabular-nums" :class="highestUsageTextClass">
{{ highestUsageDisplay }}
<span>消耗比例</span>
<strong class="font-mono font-heavy tabular-nums" :class="currentUsageTextClass">
{{ currentUsageDisplay }}
</strong>
</span>
</div>
<div v-if="highestUsageEntry" class="flex min-w-0 items-end gap-2">
<div v-if="recentUsageEntry" class="flex min-w-0 items-end gap-2">
<div class="min-w-0 flex-1">
<AccountQuotaSummaryEntry
:label="highestUsageEntry.label"
:windows="highestUsageEntry.windows"
:label="recentUsageEntry.label"
:windows="recentUsageEntry.windows"
:show-percentage="false"
/>
</div>
@@ -0,0 +1,74 @@
import type { AccountQuotaWindow, AccountQuotaWindowEntry, AccountRow } from '../../constants'
type AccountModelUsage = AccountRow['usage']['models'][number]
/**
* 根据账号最近一次模型请求选择对应额度组。
* 没有专属额度的模型统一回退到 Codex 通用额度。
*/
export function recentlyUsedQuotaEntry(
entries: readonly AccountQuotaWindowEntry[],
models: readonly AccountModelUsage[],
) {
const recentModel = latestModelUsage(models)
if (recentModel) {
const modelKey = quotaIdentity(recentModel.model)
const modelEntry = entries.find(entry => quotaEntryIdentities(entry).has(modelKey))
if (modelEntry)
return modelEntry
}
return entries.find(entry => entry.windows.some(window => window.limitId === 'codex'))
?? entries[0]
}
/** 一个额度组可能包含多个滚动窗口,摘要使用其中当前占用最高的窗口。 */
export function representativeQuotaWindow(entry: AccountQuotaWindowEntry | undefined) {
return entry?.windows.reduce<AccountQuotaWindow | undefined>((representative, window) => {
if (typeof window.usedPercent !== 'number')
return representative
if (
!representative
|| typeof representative.usedPercent !== 'number'
|| window.usedPercent > representative.usedPercent
) {
return window
}
return representative
}, undefined)
}
function latestModelUsage(models: readonly AccountModelUsage[]) {
let latest: AccountModelUsage | undefined
let latestTimestamp = Number.NEGATIVE_INFINITY
for (const model of models) {
const timestamp = Date.parse(model.lastUsedAt)
if (!Number.isFinite(timestamp) || timestamp <= latestTimestamp)
continue
latest = model
latestTimestamp = timestamp
}
return latest
}
function quotaEntryIdentities(entry: AccountQuotaWindowEntry) {
const identities = new Set<string>()
addQuotaIdentity(identities, entry.label)
for (const window of entry.windows) {
addQuotaIdentity(identities, window.limitId)
addQuotaIdentity(identities, window.limitName)
}
return identities
}
function addQuotaIdentity(identities: Set<string>, value: string | null) {
const identity = quotaIdentity(value)
if (identity)
identities.add(identity)
}
function quotaIdentity(value: string | null) {
return value?.trim().toLowerCase().replace(/[^a-z0-9]+/g, '') ?? ''
}
@@ -5,11 +5,10 @@ import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { formatCompactNumber } from '@/utils/number'
import AccountIdentityCell from '@/views/accounts/components/AccountIdentityCell.vue'
import AccountUsageWindow from '@/views/accounts/components/AccountUsageWindow/index.vue'
import { metricToneIconClasses, metricToneValueClasses } from '../constants'
import DashboardAccountIdentity from './DashboardAccountIdentity.vue'
type DashboardSnapshot = ReturnType<typeof dashboardSnapshotView>
@@ -233,24 +232,12 @@ const statusBars = computed(() => {
<article
v-for="account in accounts"
:key="account.id"
class="grid w-full shrink-0 grid-cols-1 gap-3 rounded-xl bg-cp-fill-alter/70 px-3.5 py-3.5 transition-colors xl:h-18.75 xl:grid-cols-[minmax(0,1fr)_minmax(70px,0.36fr)_minmax(74px,0.38fr)_minmax(82px,0.46fr)] xl:items-center xl:gap-4 xl:py-0"
class="grid w-full shrink-0 grid-cols-1 gap-3 rounded-xl bg-cp-fill-alter/70 px-3.5 py-3.5 transition-colors xl:h-18.75 xl:grid-cols-[minmax(0,1.32fr)_minmax(64px,0.4fr)_minmax(66px,0.42fr)_minmax(96px,0.7fr)] xl:items-center xl:gap-3 xl:px-4 xl:py-0"
>
<AccountIdentityCell
<DashboardAccountIdentity
:account="account"
show-plan
title-mode="email"
meta-position="secondary"
meta-size="xs"
class="min-w-0"
>
<template #meta>
<ProviderIconGroup
:provider="account.provider"
:authentication-kind="account.authenticationKind"
size="xs"
/>
</template>
</AccountIdentityCell>
class="min-w-0 xl:pr-4"
/>
<span
class="grid min-w-0 grid-cols-[minmax(0,0.82fr)_minmax(0,0.64fr)_minmax(104px,1fr)] items-start gap-3 xl:contents"
@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { dashboardSnapshotView } from '../composables/useDashboard'
import { Key, LinkAlt, Openai, Xai } from '@boxicons/vue'
import { computed } from 'vue'
import { formatProviderLabel } from '@/utils/providers'
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
import { stablePresetVisualToneClass } from '@/views/accounts/utils/visualTone'
type DashboardSnapshot = ReturnType<typeof dashboardSnapshotView>
type DashboardAccount = DashboardSnapshot['accountUsage'][number]
const props = defineProps<{
account: DashboardAccount
}>()
const email = computed(() => props.account.email?.trim() || String(props.account.id))
const displayTitle = computed(() => email.value.split('@')[0] || email.value)
const normalizedProvider = computed(() => props.account.provider?.trim().toLowerCase() || '')
const normalizedAuthenticationKind = computed(() =>
props.account.authenticationKind?.trim().toLowerCase() || '',
)
const providerLabel = computed(() => formatProviderLabel(props.account.provider, '未知平台'))
const authenticationLabel = computed(() => {
if (normalizedAuthenticationKind.value === 'oauth')
return 'OAuth'
if (normalizedAuthenticationKind.value === 'api_key')
return 'API Key'
return props.account.authenticationKind?.trim() || '未知认证类型'
})
const avatarToneClass = computed(() =>
stablePresetVisualToneClass(props.account.id || props.account.email || email.value),
)
</script>
<template>
<div class="flex min-w-0 items-center gap-3">
<span class="relative inline-flex size-9 shrink-0">
<span
class="inline-flex size-9 items-center justify-center rounded-lg shadow-cp-tertiary"
:class="avatarToneClass"
:title="providerLabel"
>
<Openai v-if="normalizedProvider === 'openai'" class="size-3.5" />
<Xai v-else-if="normalizedProvider === 'xai'" class="size-3.5" />
<span v-else class="text-cp-xs font-heavy">?</span>
<span class="sr-only">{{ providerLabel }}</span>
</span>
<span
class="absolute -right-1 -bottom-1 inline-flex size-4 items-center justify-center rounded-[5px] bg-cp-bg-container text-cp-text shadow-cp-tertiary"
:title="authenticationLabel"
>
<LinkAlt v-if="normalizedAuthenticationKind === 'oauth'" class="size-2.5" />
<Key v-else-if="normalizedAuthenticationKind === 'api_key'" class="size-2.5" />
<span v-else class="text-[8px] font-heavy text-cp-text-quaternary">?</span>
<span class="sr-only">{{ authenticationLabel }}</span>
</span>
</span>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-1.5">
<strong class="min-w-0 truncate text-cp leading-[1.15] font-heavy text-cp-text">
{{ displayTitle }}
</strong>
<AccountPlanBadge :plan-type="account.planType" size="xs" />
</span>
<span
class="mt-0.5 block min-w-0 truncate font-mono text-cp-xs font-emphasis text-cp-text-quaternary"
:title="email"
>
{{ email }}
</span>
</span>
</div>
</template>
@@ -12,7 +12,9 @@ import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { useOpsErrorsTable } from '../composables/useOpsErrorsTable'
import { opsErrorColumns } from '../constants'
import { opsErrorSummary } from '../utils/opsErrorPresentation'
import { usageUserAgent } from '../utils/records'
import OpsErrorDetailModal from './OpsErrorDetailModal.vue'
import UsageClientIpCell from './UsageClientIpCell.vue'
const props = defineProps<{
timeRangeParams: UsageTimeRangeParams
@@ -157,6 +159,14 @@ function upstreamSendStateText(value: string | null | undefined) {
{{ modelText(row) }}
</span>
</template>
<template #clientIp="{ row }">
<UsageClientIpCell :record="row" />
</template>
<template #userAgent="{ row }">
<span class="block max-w-full wrap-break-word whitespace-normal font-mono text-cp-sm leading-[1.4] font-emphasis text-cp-text-secondary">
{{ usageUserAgent(row) }}
</span>
</template>
<template #actions="{ row }">
<BaseIconButton
variant="ghost"
@@ -1,11 +1,9 @@
<script setup lang="ts">
import type { UsageDisplayRecord } from '../utils/records'
import { computed } from 'vue'
import { usageClientIp } from '../utils/records'
const props = defineProps<{
record: UsageDisplayRecord
record: { clientIp?: string | null }
}>()
const clientIp = computed(() => usageClientIp(props.record))
+2 -2
View File
@@ -42,8 +42,8 @@ export const opsErrorColumns = defineTableColumns<OpsErrorRow>([
{ key: 'route', label: '端点', kind: 'mono', size: 'xl', emptyText: '未记录' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'requestId', label: '请求 ID', kind: 'mono', size: '2xl', emptyText: '未记录' },
{ key: 'clientIp', label: 'IP', kind: 'mono', size: '3xl', emptyText: '未记录' },
{ key: 'userAgent', label: 'User-Agent', kind: 'mono', size: '4xl', emptyText: '未记录' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl', emptyText: '未记录' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl', emptyText: '未记录' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'sm' },
])
+2 -2
View File
@@ -178,11 +178,11 @@ export function usageAuthenticationKind(record: UsageCommonRecord) {
return typeof record.authenticationKind === 'string' ? record.authenticationKind : null
}
export function usageClientIp(record: UsageCommonRecord) {
export function usageClientIp(record: { clientIp?: string | null }) {
return record.clientIp || '—'
}
export function usageUserAgent(record: UsageCommonRecord) {
export function usageUserAgent(record: { userAgent?: string | null }) {
return record.userAgent || '—'
}