mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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))
|
||||
|
||||
@@ -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' },
|
||||
])
|
||||
|
||||
|
||||
@@ -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 || '—'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user