Commit 1acfc46f authored by Ethan0x0000's avatar Ethan0x0000
Browse files

fix: always show usage stats for OpenAI OAuth and hide zero-value badges

- Simplify OpenAI rendering: always fetch /usage, prefer fetched data over
  codex snapshot (snapshot serves as loading placeholder only)
- Remove dead code: preferFetchedOpenAIUsage, isOpenAICodexSnapshotStale,
  and unreachable template branch
- Add today-stats support for key accounts (req/tokens/A/U badges)
- Use formatCompactNumber for consistent number formatting
- Add A/U badge titles for clarity
- Filter zero-value window stats in UsageProgressBar to avoid empty badges
- Update tests to match new fetched-data-first behavior
parent fbffb08a
...@@ -75,7 +75,7 @@ ...@@ -75,7 +75,7 @@
<!-- OpenAI OAuth accounts: prefer fresh usage query for active rate-limited rows --> <!-- OpenAI OAuth accounts: prefer fresh usage query for active rate-limited rows -->
<template v-else-if="account.platform === 'openai' && account.type === 'oauth'"> <template v-else-if="account.platform === 'openai' && account.type === 'oauth'">
<div v-if="preferFetchedOpenAIUsage" class="space-y-1"> <div v-if="hasOpenAIUsageFallback" class="space-y-1">
<UsageProgressBar <UsageProgressBar
v-if="usageInfo?.five_hour" v-if="usageInfo?.five_hour"
label="5h" label="5h"
...@@ -136,24 +136,6 @@ ...@@ -136,24 +136,6 @@
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div> <div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div> </div>
</div> </div>
<div v-else-if="hasOpenAIUsageFallback" class="space-y-1">
<UsageProgressBar
v-if="usageInfo?.five_hour"
label="5h"
:utilization="usageInfo.five_hour.utilization"
:resets-at="usageInfo.five_hour.resets_at"
:window-stats="usageInfo.five_hour.window_stats"
color="indigo"
/>
<UsageProgressBar
v-if="usageInfo?.seven_day"
label="7d"
:utilization="usageInfo.seven_day.utilization"
:resets-at="usageInfo.seven_day.resets_at"
:window-stats="usageInfo.seven_day.window_stats"
color="emerald"
/>
</div>
<div v-else class="text-xs text-gray-400">-</div> <div v-else class="text-xs text-gray-400">-</div>
</template> </template>
...@@ -389,8 +371,43 @@ ...@@ -389,8 +371,43 @@
<div v-else> <div v-else>
<!-- Gemini API Key accounts: show quota info --> <!-- Gemini API Key accounts: show quota info -->
<AccountQuotaInfo v-if="account.platform === 'gemini'" :account="account" /> <AccountQuotaInfo v-if="account.platform === 'gemini'" :account="account" />
<!-- API Key accounts with quota limits: show progress bars --> <!-- Key/Bedrock accounts: show today stats + optional quota bars -->
<div v-else-if="hasApiKeyQuota" class="space-y-1"> <div v-else class="space-y-1">
<!-- Today stats row (requests, tokens, cost, user_cost) -->
<div
v-if="todayStats"
class="mb-0.5 flex items-center"
>
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400">
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatKeyRequests }} req
</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatKeyTokens }}
</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800" :title="t('usage.accountBilled')">
A ${{ formatKeyCost }}
</span>
<span
v-if="todayStats.user_cost != null"
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
:title="t('usage.userBilled')"
>
U ${{ formatKeyUserCost }}
</span>
</div>
</div>
<!-- Loading skeleton for today stats -->
<div
v-else-if="todayStatsLoading"
class="mb-0.5 flex items-center gap-1"
>
<div class="h-3 w-10 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-8 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<!-- API Key accounts with quota limits: show progress bars -->
<UsageProgressBar <UsageProgressBar
v-if="quotaDailyBar" v-if="quotaDailyBar"
label="1d" label="1d"
...@@ -411,8 +428,10 @@ ...@@ -411,8 +428,10 @@
:utilization="quotaTotalBar.utilization" :utilization="quotaTotalBar.utilization"
color="purple" color="purple"
/> />
<!-- No data at all -->
<div v-if="!todayStats && !todayStatsLoading && !hasApiKeyQuota" class="text-xs text-gray-400">-</div>
</div> </div>
<div v-else class="text-xs text-gray-400">-</div>
</div> </div>
</template> </template>
...@@ -423,12 +442,23 @@ import { adminAPI } from '@/api/admin' ...@@ -423,12 +442,23 @@ import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types' import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh' import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { resolveCodexUsageWindow } from '@/utils/codexUsage' import { resolveCodexUsageWindow } from '@/utils/codexUsage'
import { formatCompactNumber } from '@/utils/format'
import UsageProgressBar from './UsageProgressBar.vue' import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue' import AccountQuotaInfo from './AccountQuotaInfo.vue'
const props = defineProps<{ const props = withDefaults(
account: Account defineProps<{
}>() account: Account
todayStats?: WindowStats | null
todayStatsLoading?: boolean
manualRefreshToken?: number
}>(),
{
todayStats: null,
todayStatsLoading: false,
manualRefreshToken: 0
}
)
const { t } = useI18n() const { t } = useI18n()
...@@ -490,26 +520,9 @@ const isActiveOpenAIRateLimited = computed(() => { ...@@ -490,26 +520,9 @@ const isActiveOpenAIRateLimited = computed(() => {
return !Number.isNaN(resetAt) && resetAt > Date.now() return !Number.isNaN(resetAt) && resetAt > Date.now()
}) })
const preferFetchedOpenAIUsage = computed(() => {
return (isActiveOpenAIRateLimited.value || isOpenAICodexSnapshotStale.value) && hasOpenAIUsageFallback.value
})
const openAIUsageRefreshKey = computed(() => buildOpenAIUsageRefreshKey(props.account)) const openAIUsageRefreshKey = computed(() => buildOpenAIUsageRefreshKey(props.account))
const isOpenAICodexSnapshotStale = computed(() => {
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false
const extra = props.account.extra as Record<string, unknown> | undefined
const updatedAtRaw = extra?.codex_usage_updated_at
if (!updatedAtRaw) return true
const updatedAt = Date.parse(String(updatedAtRaw))
if (Number.isNaN(updatedAt)) return true
return Date.now() - updatedAt >= 10 * 60 * 1000
})
const shouldAutoLoadUsageOnMount = computed(() => { const shouldAutoLoadUsageOnMount = computed(() => {
if (props.account.platform === 'openai' && props.account.type === 'oauth') {
return isActiveOpenAIRateLimited.value || !hasCodexUsage.value || isOpenAICodexSnapshotStale.value
}
return shouldFetchUsage.value return shouldFetchUsage.value
}) })
...@@ -1006,6 +1019,28 @@ const quotaTotalBar = computed((): QuotaBarInfo | null => { ...@@ -1006,6 +1019,28 @@ const quotaTotalBar = computed((): QuotaBarInfo | null => {
return makeQuotaBar(props.account.quota_used ?? 0, limit) return makeQuotaBar(props.account.quota_used ?? 0, limit)
}) })
// ===== Key account today stats formatters =====
const formatKeyRequests = computed(() => {
if (!props.todayStats) return ''
return formatCompactNumber(props.todayStats.requests, { allowBillions: false })
})
const formatKeyTokens = computed(() => {
if (!props.todayStats) return ''
return formatCompactNumber(props.todayStats.tokens)
})
const formatKeyCost = computed(() => {
if (!props.todayStats) return '0.00'
return props.todayStats.cost.toFixed(2)
})
const formatKeyUserCost = computed(() => {
if (!props.todayStats || props.todayStats.user_cost == null) return '0.00'
return props.todayStats.user_cost.toFixed(2)
})
onMounted(() => { onMounted(() => {
if (!shouldAutoLoadUsageOnMount.value) return if (!shouldAutoLoadUsageOnMount.value) return
loadUsage() loadUsage()
...@@ -1014,10 +1049,21 @@ onMounted(() => { ...@@ -1014,10 +1049,21 @@ onMounted(() => {
watch(openAIUsageRefreshKey, (nextKey, prevKey) => { watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
if (!prevKey || nextKey === prevKey) return if (!prevKey || nextKey === prevKey) return
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
if (!isActiveOpenAIRateLimited.value && hasCodexUsage.value && !isOpenAICodexSnapshotStale.value) return
loadUsage().catch((e) => { loadUsage().catch((e) => {
console.error('Failed to refresh OpenAI usage:', e) console.error('Failed to refresh OpenAI usage:', e)
}) })
}) })
watch(
() => props.manualRefreshToken,
(nextToken, prevToken) => {
if (nextToken === prevToken) return
if (!shouldFetchUsage.value) return
loadUsage().catch((e) => {
console.error('Failed to refresh usage after manual refresh:', e)
})
}
)
</script> </script>
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<div> <div>
<!-- Window stats row (above progress bar) --> <!-- Window stats row (above progress bar) -->
<div <div
v-if="windowStats" v-if="windowStats && (windowStats.requests > 0 || windowStats.tokens > 0)"
class="mb-0.5 flex items-center" class="mb-0.5 flex items-center"
> >
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"> <div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400">
...@@ -12,12 +12,13 @@ ...@@ -12,12 +12,13 @@
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> <span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatTokens }} {{ formatTokens }}
</span> </span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> <span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800" :title="t('usage.accountBilled')">
A ${{ formatAccountCost }} A ${{ formatAccountCost }}
</span> </span>
<span <span
v-if="windowStats?.user_cost != null" v-if="windowStats?.user_cost != null"
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800" class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
:title="t('usage.userBilled')"
> >
U ${{ formatUserCost }} U ${{ formatUserCost }}
</span> </span>
...@@ -56,7 +57,9 @@ ...@@ -56,7 +57,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { WindowStats } from '@/types' import type { WindowStats } from '@/types'
import { formatCompactNumber } from '@/utils/format'
const props = defineProps<{ const props = defineProps<{
label: string label: string
...@@ -66,6 +69,8 @@ const props = defineProps<{ ...@@ -66,6 +69,8 @@ const props = defineProps<{
windowStats?: WindowStats | null windowStats?: WindowStats | null
}>() }>()
const { t } = useI18n()
// Label background colors // Label background colors
const labelClass = computed(() => { const labelClass = computed(() => {
const colors = { const colors = {
...@@ -135,19 +140,12 @@ const formatResetTime = computed(() => { ...@@ -135,19 +140,12 @@ const formatResetTime = computed(() => {
// Window stats formatters // Window stats formatters
const formatRequests = computed(() => { const formatRequests = computed(() => {
if (!props.windowStats) return '' if (!props.windowStats) return ''
const r = props.windowStats.requests return formatCompactNumber(props.windowStats.requests, { allowBillions: false })
if (r >= 1000000) return `${(r / 1000000).toFixed(1)}M`
if (r >= 1000) return `${(r / 1000).toFixed(1)}K`
return r.toString()
}) })
const formatTokens = computed(() => { const formatTokens = computed(() => {
if (!props.windowStats) return '' if (!props.windowStats) return ''
const t = props.windowStats.tokens return formatCompactNumber(props.windowStats.tokens)
if (t >= 1000000000) return `${(t / 1000000000).toFixed(1)}B`
if (t >= 1000000) return `${(t / 1000000).toFixed(1)}M`
if (t >= 1000) return `${(t / 1000).toFixed(1)}K`
return t.toString()
}) })
const formatAccountCost = computed(() => { const formatAccountCost = computed(() => {
......
...@@ -198,7 +198,34 @@ describe('AccountUsageCell', () => { ...@@ -198,7 +198,34 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('7d|77|300') expect(wrapper.text()).toContain('7d|77|300')
}) })
it('OpenAI OAuth 有现成快照且未限额时不会首屏请求 usage', async () => { it('OpenAI OAuth 有现成快照时首屏先显示快照再加载 usage 覆盖', async () => {
getUsage.mockResolvedValue({
five_hour: {
utilization: 18,
resets_at: '2099-03-07T12:00:00Z',
remaining_seconds: 3600,
window_stats: {
requests: 9,
tokens: 900,
cost: 0.09,
standard_cost: 0.09,
user_cost: 0.09
}
},
seven_day: {
utilization: 36,
resets_at: '2099-03-13T12:00:00Z',
remaining_seconds: 3600,
window_stats: {
requests: 9,
tokens: 900,
cost: 0.09,
standard_cost: 0.09,
user_cost: 0.09
}
}
})
const wrapper = mount(AccountUsageCell, { const wrapper = mount(AccountUsageCell, {
props: { props: {
account: makeAccount({ account: makeAccount({
...@@ -218,7 +245,7 @@ describe('AccountUsageCell', () => { ...@@ -218,7 +245,7 @@ describe('AccountUsageCell', () => {
stubs: { stubs: {
UsageProgressBar: { UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'], props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>' template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>'
}, },
AccountQuotaInfo: true AccountQuotaInfo: true
} }
...@@ -227,9 +254,80 @@ describe('AccountUsageCell', () => { ...@@ -227,9 +254,80 @@ describe('AccountUsageCell', () => {
await flushPromises() await flushPromises()
expect(getUsage).not.toHaveBeenCalled() // 始终拉 usage,fetched data 优先显示(包含 window_stats)
expect(wrapper.text()).toContain('5h|12') expect(getUsage).toHaveBeenCalledWith(2001)
expect(wrapper.text()).toContain('7d|34') expect(wrapper.text()).toContain('5h|18|900')
expect(wrapper.text()).toContain('7d|36|900')
})
it('OpenAI OAuth 有现成快照时,手动刷新信号会触发 usage 重拉', async () => {
getUsage.mockResolvedValue({
five_hour: {
utilization: 18,
resets_at: '2099-03-07T12:00:00Z',
remaining_seconds: 3600,
window_stats: {
requests: 9,
tokens: 900,
cost: 0.09,
standard_cost: 0.09,
user_cost: 0.09
}
},
seven_day: {
utilization: 36,
resets_at: '2099-03-13T12:00:00Z',
remaining_seconds: 3600,
window_stats: {
requests: 9,
tokens: 900,
cost: 0.09,
standard_cost: 0.09,
user_cost: 0.09
}
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 2010,
platform: 'openai',
type: 'oauth',
extra: {
codex_usage_updated_at: '2099-03-07T10:00:00Z',
codex_5h_used_percent: 12,
codex_5h_reset_at: '2099-03-07T12:00:00Z',
codex_7d_used_percent: 34,
codex_7d_reset_at: '2099-03-13T12:00:00Z'
},
rate_limit_reset_at: null
}),
manualRefreshToken: 0
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>'
},
AccountQuotaInfo: true
}
}
})
await flushPromises()
// mount 时已经拉取一次
expect(getUsage).toHaveBeenCalledTimes(1)
await wrapper.setProps({ manualRefreshToken: 1 })
await flushPromises()
// 手动刷新再拉一次
expect(getUsage).toHaveBeenCalledTimes(2)
expect(getUsage).toHaveBeenCalledWith(2010)
// fetched data 优先显示,包含 window_stats
expect(wrapper.text()).toContain('5h|18|900')
}) })
it('OpenAI OAuth 在无 codex 快照时会回退显示 usage 接口窗口', async () => { it('OpenAI OAuth 在无 codex 快照时会回退显示 usage 接口窗口', async () => {
...@@ -414,9 +512,96 @@ describe('AccountUsageCell', () => { ...@@ -414,9 +512,96 @@ describe('AccountUsageCell', () => {
await flushPromises() await flushPromises()
expect(getUsage).toHaveBeenCalledWith(2004) expect(getUsage).toHaveBeenCalledWith(2004)
expect(wrapper.text()).toContain('5h|100|106540000') expect(wrapper.text()).toContain('5h|100|106540000')
expect(wrapper.text()).toContain('7d|100|106540000') expect(wrapper.text()).toContain('7d|100|106540000')
expect(wrapper.text()).not.toContain('5h|0|') expect(wrapper.text()).not.toContain('5h|0|')
})
it('Key 账号会展示 today stats 徽章并带 A/U 提示', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 3001,
platform: 'anthropic',
type: 'apikey'
}),
todayStats: {
requests: 1_000_000,
tokens: 1_000_000_000,
cost: 12.345,
standard_cost: 12.345,
user_cost: 6.789
}
},
global: {
stubs: {
UsageProgressBar: true,
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('1.0M req')
expect(wrapper.text()).toContain('1.0B')
expect(wrapper.text()).toContain('A $12.35')
expect(wrapper.text()).toContain('U $6.79')
const badges = wrapper.findAll('span[title]')
expect(badges.some(node => node.attributes('title') === 'usage.accountBilled')).toBe(true)
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
})
it('Key 账号在 today stats loading 时显示骨架屏', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 3002,
platform: 'anthropic',
type: 'apikey'
}),
todayStats: null,
todayStatsLoading: true
},
global: {
stubs: {
UsageProgressBar: true,
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(wrapper.findAll('.animate-pulse').length).toBeGreaterThan(0)
})
it('Key 账号在无 today stats 且无配额时显示兜底短横线', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 3003,
platform: 'anthropic',
type: 'apikey',
quota_limit: 0,
quota_daily_limit: 0,
quota_weekly_limit: 0
}),
todayStats: null,
todayStatsLoading: false
},
global: {
stubs: {
UsageProgressBar: true,
AccountQuotaInfo: true
}
}
})
await flushPromises()
expect(wrapper.text().trim()).toBe('-')
}) })
}) })
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment