@@ -415,6 +445,7 @@ import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
+import { resolveCodexUsageWindow } from '@/utils/codexUsage'
import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
import { formatCompactNumber } from '@/utils/format'
import UsageProgressBar from './UsageProgressBar.vue'
@@ -477,11 +508,31 @@ const geminiUsageAvailable = computed(() => {
)
})
+const codex5hWindow = computed(() => resolveCodexUsageWindow(props.account.extra, '5h'))
+const codex7dWindow = computed(() => resolveCodexUsageWindow(props.account.extra, '7d'))
+
+const hasCodexUsage = computed(() => {
+ if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false
+ return codex5hWindow.value.usedPercent !== null || codex7dWindow.value.usedPercent !== null
+})
+
const hasOpenAIUsageFallback = computed(() => {
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false
return !!usageInfo.value?.five_hour || !!usageInfo.value?.seven_day
})
+const isActiveOpenAIRateLimited = computed(() => {
+ if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false
+ if (!props.account.rate_limit_reset_at) return false
+ const resetAt = Date.parse(props.account.rate_limit_reset_at)
+ return !Number.isNaN(resetAt) && resetAt > Date.now()
+})
+
+const codex5hUsedPercent = computed(() => codex5hWindow.value.usedPercent)
+const codex5hResetAt = computed(() => codex5hWindow.value.resetAt)
+const codex7dUsedPercent = computed(() => codex7dWindow.value.usedPercent)
+const codex7dResetAt = computed(() => codex7dWindow.value.resetAt)
+
const openAIUsageRefreshKey = computed(() => buildOpenAIUsageRefreshKey(props.account))
const shouldAutoLoadUsageOnMount = computed(() => {
diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
index 73d0ec5644..7a739d442b 100644
--- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
+++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
@@ -389,6 +389,117 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('7d|0|27700')
})
+ it('OpenAI OAuth 在 usage 请求失败时仍回退显示本地 codex 快照', async () => {
+ getUsage.mockRejectedValue(new Error('network error'))
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const wrapper = mount(AccountUsageCell, {
+ props: {
+ account: makeAccount({
+ id: 2004,
+ 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'
+ }
+ })
+ },
+ global: {
+ stubs: {
+ UsageProgressBar: {
+ props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
+ template: '
{{ label }}|{{ utilization }}|{{ resetsAt }}
'
+ },
+ AccountQuotaInfo: true
+ }
+ }
+ })
+
+ await flushPromises()
+
+ expect(getUsage).toHaveBeenCalledWith(2004)
+ expect(wrapper.text()).toContain('5h|12|2099-03-07T12:00:00.000Z')
+ expect(wrapper.text()).toContain('7d|34|2099-03-13T12:00:00.000Z')
+ errorSpy.mockRestore()
+ })
+
+ it('OpenAI OAuth 已限额时首屏优先等待重新查询的 usage,而不是先显示旧 codex 快照', async () => {
+ let resolveUsage: ((value: any) => void) | null = null
+ getUsage.mockReturnValue(
+ new Promise((resolve) => {
+ resolveUsage = resolve
+ })
+ )
+
+ const wrapper = mount(AccountUsageCell, {
+ props: {
+ account: makeAccount({
+ id: 2005,
+ platform: 'openai',
+ type: 'oauth',
+ rate_limit_reset_at: '2099-03-07T12:00:00Z',
+ extra: {
+ codex_5h_used_percent: 0,
+ codex_5h_reset_at: '2099-03-07T12:00:00Z',
+ codex_7d_used_percent: 0,
+ codex_7d_reset_at: '2099-03-13T12:00:00Z'
+ }
+ })
+ },
+ global: {
+ stubs: {
+ UsageProgressBar: {
+ props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
+ template: '
{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}
'
+ },
+ AccountQuotaInfo: true
+ }
+ }
+ })
+
+ await Promise.resolve()
+
+ expect(getUsage).toHaveBeenCalledWith(2005)
+ expect(wrapper.text()).not.toContain('5h|0|')
+ expect(wrapper.text()).not.toContain('7d|0|')
+
+ resolveUsage?.({
+ five_hour: {
+ utilization: 100,
+ resets_at: '2026-03-07T12:00:00Z',
+ remaining_seconds: 3600,
+ window_stats: {
+ requests: 211,
+ tokens: 106540000,
+ cost: 38.13,
+ standard_cost: 38.13,
+ user_cost: 38.13
+ }
+ },
+ seven_day: {
+ utilization: 100,
+ resets_at: '2026-03-13T12:00:00Z',
+ remaining_seconds: 3600,
+ window_stats: {
+ requests: 211,
+ tokens: 106540000,
+ cost: 38.13,
+ standard_cost: 38.13,
+ user_cost: 38.13
+ }
+ }
+ })
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('5h|100|106540000')
+ expect(wrapper.text()).toContain('7d|100|106540000')
+ })
+
it('OpenAI OAuth 在行数据刷新但仍无 codex 快照时会重新拉取 usage', async () => {
getUsage
.mockResolvedValueOnce({
diff --git a/frontend/src/utils/__tests__/codexUsage.spec.ts b/frontend/src/utils/__tests__/codexUsage.spec.ts
new file mode 100644
index 0000000000..b7453544c7
--- /dev/null
+++ b/frontend/src/utils/__tests__/codexUsage.spec.ts
@@ -0,0 +1,186 @@
+import { describe, expect, it } from 'vitest'
+import { resolveCodexUsageWindow } from '@/utils/codexUsage'
+
+describe('resolveCodexUsageWindow', () => {
+ it('快照为空时返回空窗口', () => {
+ const result = resolveCodexUsageWindow(null, '5h', new Date('2026-02-20T08:00:00Z'))
+ expect(result).toEqual({ usedPercent: null, resetAt: null })
+ })
+
+ it('优先使用后端提供的绝对重置时间', () => {
+ const result = resolveCodexUsageWindow(
+ {
+ codex_5h_used_percent: 55,
+ codex_5h_reset_at: '2026-02-20T10:00:00Z',
+ codex_5h_reset_after_seconds: 1
+ },
+ '5h',
+ new Date('2026-02-20T08:00:00Z')
+ )
+
+ expect(result.usedPercent).toBe(55)
+ expect(result.resetAt).toBe('2026-02-20T10:00:00.000Z')
+ })
+
+ it('窗口已过期时自动归零', () => {
+ const result = resolveCodexUsageWindow(
+ {
+ codex_7d_used_percent: 100,
+ codex_7d_reset_at: '2026-02-20T07:00:00Z'
+ },
+ '7d',
+ new Date('2026-02-20T08:00:00Z')
+ )
+
+ expect(result.usedPercent).toBe(0)
+ expect(result.resetAt).toBe('2026-02-20T07:00:00.000Z')
+ })
+
+ it('无绝对时间时使用 updated_at + seconds 回退计算', () => {
+ const result = resolveCodexUsageWindow(
+ {
+ codex_5h_used_percent: 20,
+ codex_5h_reset_after_seconds: 3600,
+ codex_usage_updated_at: '2026-02-20T06:30:00Z'
+ },
+ '5h',
+ new Date('2026-02-20T07:00:00Z')
+ )
+
+ expect(result.usedPercent).toBe(20)
+ expect(result.resetAt).toBe('2026-02-20T07:30:00.000Z')
+ })
+
+ it('支持 legacy primary/secondary 字段映射', () => {
+ const snapshot = {
+ codex_primary_window_minutes: 10080,
+ codex_primary_used_percent: 70,
+ codex_primary_reset_after_seconds: 86400,
+ codex_secondary_window_minutes: 300,
+ codex_secondary_used_percent: 15,
+ codex_secondary_reset_after_seconds: 1200,
+ codex_usage_updated_at: '2026-02-20T07:00:00Z'
+ }
+
+ const result5h = resolveCodexUsageWindow(snapshot, '5h', new Date('2026-02-20T07:05:00Z'))
+ const result7d = resolveCodexUsageWindow(snapshot, '7d', new Date('2026-02-20T07:05:00Z'))
+
+ expect(result5h.usedPercent).toBe(15)
+ expect(result5h.resetAt).toBe('2026-02-20T07:20:00.000Z')
+ expect(result7d.usedPercent).toBe(70)
+ expect(result7d.resetAt).toBe('2026-02-21T07:00:00.000Z')
+ })
+
+ it('legacy 5h 在 primary<=360 时优先 primary 并支持字符串数字', () => {
+ const result = resolveCodexUsageWindow(
+ {
+ codex_primary_window_minutes: '300',
+ codex_primary_used_percent: '21',
+ codex_primary_reset_after_seconds: '1800',
+ codex_secondary_window_minutes: '10080',
+ codex_secondary_used_percent: '99',
+ codex_secondary_reset_after_seconds: '99999',
+ codex_usage_updated_at: '2026-02-20T08:00:00Z'
+ },
+ '5h',
+ new Date('2026-02-20T08:10:00Z')
+ )
+
+ expect(result.usedPercent).toBe(21)
+ expect(result.resetAt).toBe('2026-02-20T08:30:00.000Z')
+ })
+
+ it('legacy 5h 在无窗口信息时回退 secondary', () => {
+ const result = resolveCodexUsageWindow(
+ {
+ codex_secondary_used_percent: 19,
+ codex_secondary_reset_after_seconds: 120,
+ codex_usage_updated_at: '2026-02-20T08:00:00Z'
+ },
+ '5h',
+ new Date('2026-02-20T08:00:01Z')
+ )
+
+ expect(result.usedPercent).toBe(19)
+ expect(result.resetAt).toBe('2026-02-20T08:02:00.000Z')
+ })
+
+ it('legacy 场景下 secondary 为 7d 时能正确识别', () => {
+ const result = resolveCodexUsageWindow(
+ {
+ codex_primary_window_minutes: 300,
+ codex_primary_used_percent: 5,
+ codex_primary_reset_after_seconds: 600,
+ codex_secondary_window_minutes: 10080,
+ codex_secondary_used_percent: 66,
+ codex_secondary_reset_after_seconds: 7200,
+ codex_usage_updated_at: '2026-02-20T07:00:00Z'
+ },
+ '7d',
+ new Date('2026-02-20T07:30:00Z')
+ )
+
+ expect(result.usedPercent).toBe(66)
+ expect(result.resetAt).toBe('2026-02-20T09:00:00.000Z')
+ })
+
+ it('绝对时间非法时回退到 updated_at + seconds', () => {
+ const result = resolveCodexUsageWindow(
+ {
+ codex_5h_used_percent: 33,
+ codex_5h_reset_at: 'not-a-date',
+ codex_5h_reset_after_seconds: 900,
+ codex_usage_updated_at: '2026-02-20T07:30:00Z'
+ },
+ '5h',
+ new Date('2026-02-20T07:40:00Z')
+ )
+
+ expect(result.usedPercent).toBe(33)
+ expect(result.resetAt).toBe('2026-02-20T07:45:00.000Z')
+ })
+
+ it('updated_at 非法且无绝对时间时 resetAt 返回 null', () => {
+ const result = resolveCodexUsageWindow(
+ {
+ codex_5h_used_percent: 10,
+ codex_5h_reset_after_seconds: 123,
+ codex_usage_updated_at: 'invalid-time'
+ },
+ '5h',
+ new Date('2026-02-20T08:00:00Z')
+ )
+
+ expect(result.usedPercent).toBe(10)
+ expect(result.resetAt).toBeNull()
+ })
+
+ it('reset_after_seconds 为负数时按 0 秒处理', () => {
+ const result = resolveCodexUsageWindow(
+ {
+ codex_5h_used_percent: 80,
+ codex_5h_reset_after_seconds: -30,
+ codex_usage_updated_at: '2026-02-20T08:00:00Z'
+ },
+ '5h',
+ new Date('2026-02-20T07:59:00Z')
+ )
+
+ expect(result.usedPercent).toBe(80)
+ expect(result.resetAt).toBe('2026-02-20T08:00:00.000Z')
+ })
+
+ it('百分比缺失时仍可计算 resetAt 供倒计时展示', () => {
+ const result = resolveCodexUsageWindow(
+ {
+ codex_7d_reset_after_seconds: 60,
+ codex_usage_updated_at: '2026-02-20T08:00:00Z'
+ },
+ '7d',
+ new Date('2026-02-20T08:00:01Z')
+ )
+
+ expect(result.usedPercent).toBeNull()
+ expect(result.resetAt).toBe('2026-02-20T08:01:00.000Z')
+ })
+})
diff --git a/frontend/src/utils/codexUsage.ts b/frontend/src/utils/codexUsage.ts
new file mode 100644
index 0000000000..57d8287beb
--- /dev/null
+++ b/frontend/src/utils/codexUsage.ts
@@ -0,0 +1,140 @@
+import type { CodexUsageSnapshot } from '@/types'
+
+export interface ResolvedCodexUsageWindow {
+ usedPercent: number | null
+ resetAt: string | null
+}
+
+type WindowKind = '5h' | '7d'
+
+function asNumber(value: unknown): number | null {
+ if (typeof value === 'number' && Number.isFinite(value)) return value
+ if (typeof value === 'string' && value.trim() !== '') {
+ const n = Number(value)
+ if (Number.isFinite(n)) return n
+ }
+ return null
+}
+
+function asString(value: unknown): string | null {
+ if (typeof value !== 'string') return null
+ const trimmed = value.trim()
+ return trimmed === '' ? null : trimmed
+}
+
+function asISOTime(value: unknown): string | null {
+ const raw = asString(value)
+ if (!raw) return null
+ const date = new Date(raw)
+ if (Number.isNaN(date.getTime())) return null
+ return date.toISOString()
+}
+
+function resolveLegacy5h(snapshot: Record
): {
+ used: number | null
+ resetAfterSeconds: number | null
+} {
+ const primaryWindow = asNumber(snapshot.codex_primary_window_minutes)
+ const secondaryWindow = asNumber(snapshot.codex_secondary_window_minutes)
+ const primaryUsed = asNumber(snapshot.codex_primary_used_percent)
+ const secondaryUsed = asNumber(snapshot.codex_secondary_used_percent)
+ const primaryReset = asNumber(snapshot.codex_primary_reset_after_seconds)
+ const secondaryReset = asNumber(snapshot.codex_secondary_reset_after_seconds)
+
+ if (primaryWindow != null && primaryWindow <= 360) {
+ return { used: primaryUsed, resetAfterSeconds: primaryReset }
+ }
+ if (secondaryWindow != null && secondaryWindow <= 360) {
+ return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
+ }
+ return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
+}
+
+function resolveLegacy7d(snapshot: Record): {
+ used: number | null
+ resetAfterSeconds: number | null
+} {
+ const primaryWindow = asNumber(snapshot.codex_primary_window_minutes)
+ const secondaryWindow = asNumber(snapshot.codex_secondary_window_minutes)
+ const primaryUsed = asNumber(snapshot.codex_primary_used_percent)
+ const secondaryUsed = asNumber(snapshot.codex_secondary_used_percent)
+ const primaryReset = asNumber(snapshot.codex_primary_reset_after_seconds)
+ const secondaryReset = asNumber(snapshot.codex_secondary_reset_after_seconds)
+
+ if (primaryWindow != null && primaryWindow >= 10000) {
+ return { used: primaryUsed, resetAfterSeconds: primaryReset }
+ }
+ if (secondaryWindow != null && secondaryWindow >= 10000) {
+ return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
+ }
+ return { used: primaryUsed, resetAfterSeconds: primaryReset }
+}
+
+function resolveFromSeconds(
+ snapshot: Record,
+ resetAfterSeconds: number | null
+): string | null {
+ if (resetAfterSeconds == null) return null
+
+ const baseRaw = asString(snapshot.codex_usage_updated_at)
+ const base = baseRaw ? new Date(baseRaw) : new Date()
+ if (Number.isNaN(base.getTime())) return null
+
+ const sec = Math.max(0, resetAfterSeconds)
+ const resetAt = new Date(base.getTime() + sec * 1000)
+ return resetAt.toISOString()
+}
+
+function applyExpiredRule(
+ window: ResolvedCodexUsageWindow,
+ now: Date
+): ResolvedCodexUsageWindow {
+ if (window.usedPercent == null || !window.resetAt) return window
+ const resetDate = new Date(window.resetAt)
+ if (Number.isNaN(resetDate.getTime())) return window
+ if (resetDate.getTime() <= now.getTime()) {
+ return { usedPercent: 0, resetAt: resetDate.toISOString() }
+ }
+ return window
+}
+
+export function resolveCodexUsageWindow(
+ snapshot: (CodexUsageSnapshot & Record) | null | undefined,
+ window: WindowKind,
+ now: Date = new Date()
+): ResolvedCodexUsageWindow {
+ if (!snapshot) {
+ return { usedPercent: null, resetAt: null }
+ }
+
+ const typedSnapshot = snapshot as Record
+ let usedPercent: number | null
+ let resetAfterSeconds: number | null
+ let resetAt: string | null
+
+ if (window === '5h') {
+ usedPercent = asNumber(typedSnapshot.codex_5h_used_percent)
+ resetAfterSeconds = asNumber(typedSnapshot.codex_5h_reset_after_seconds)
+ resetAt = asISOTime(typedSnapshot.codex_5h_reset_at)
+ if (usedPercent == null || (resetAfterSeconds == null && !resetAt)) {
+ const legacy = resolveLegacy5h(typedSnapshot)
+ if (usedPercent == null) usedPercent = legacy.used
+ if (resetAfterSeconds == null) resetAfterSeconds = legacy.resetAfterSeconds
+ }
+ } else {
+ usedPercent = asNumber(typedSnapshot.codex_7d_used_percent)
+ resetAfterSeconds = asNumber(typedSnapshot.codex_7d_reset_after_seconds)
+ resetAt = asISOTime(typedSnapshot.codex_7d_reset_at)
+ if (usedPercent == null || (resetAfterSeconds == null && !resetAt)) {
+ const legacy = resolveLegacy7d(typedSnapshot)
+ if (usedPercent == null) usedPercent = legacy.used
+ if (resetAfterSeconds == null) resetAfterSeconds = legacy.resetAfterSeconds
+ }
+ }
+
+ if (!resetAt) {
+ resetAt = resolveFromSeconds(typedSnapshot, resetAfterSeconds)
+ }
+
+ return applyExpiredRule({ usedPercent, resetAt }, now)
+}