mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 00:48:53 +00:00
Merge pull request #842 from pkssssss/fix/openai-ws-usage-refresh
fix: 修复 OpenAI WS 用量窗口刷新与限额状态不同步
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
<!-- Rate Limit Display (429) - Two-line layout -->
|
||||
<div v-if="isRateLimited" class="flex flex-col items-center gap-1">
|
||||
<span class="badge text-xs badge-warning">{{ t('admin.accounts.status.rateLimited') }}</span>
|
||||
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ rateLimitCountdown }}</span>
|
||||
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ rateLimitResumeText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Overload Display (529) - Two-line layout -->
|
||||
@@ -67,9 +67,9 @@
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-56 -translate-x-1/2 whitespace-normal rounded bg-gray-900 px-3 py-2 text-center text-xs leading-relaxed text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.status.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }}
|
||||
{{ t('admin.accounts.status.rateLimitedUntil', { time: formatDateTime(account.rate_limit_reset_at) }) }}
|
||||
<div
|
||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
||||
></div>
|
||||
@@ -97,7 +97,7 @@
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-56 -translate-x-1/2 whitespace-normal rounded bg-gray-900 px-3 py-2 text-center text-xs leading-relaxed text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) }) }}
|
||||
<div
|
||||
@@ -117,7 +117,7 @@
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-56 -translate-x-1/2 whitespace-normal rounded bg-gray-900 px-3 py-2 text-center text-xs leading-relaxed text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
{{ t('admin.accounts.status.overloadedUntil', { time: formatTime(account.overload_until) }) }}
|
||||
<div
|
||||
@@ -132,7 +132,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account } from '@/types'
|
||||
import { formatCountdownWithSuffix, formatTime } from '@/utils/format'
|
||||
import { formatCountdown, formatDateTime, formatCountdownWithSuffix, formatTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -231,7 +231,12 @@ const hasError = computed(() => {
|
||||
|
||||
// Computed: countdown text for rate limit (429)
|
||||
const rateLimitCountdown = computed(() => {
|
||||
return formatCountdownWithSuffix(props.account.rate_limit_reset_at)
|
||||
return formatCountdown(props.account.rate_limit_reset_at)
|
||||
})
|
||||
|
||||
const rateLimitResumeText = computed(() => {
|
||||
if (!rateLimitCountdown.value) return ''
|
||||
return t('admin.accounts.status.rateLimitedAutoResume', { time: rateLimitCountdown.value })
|
||||
})
|
||||
|
||||
// Computed: countdown text for overload (529)
|
||||
|
||||
@@ -69,9 +69,39 @@
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</template>
|
||||
|
||||
<!-- OpenAI OAuth accounts: show Codex usage from extra field -->
|
||||
<!-- OpenAI OAuth accounts: prefer fresh usage query for active rate-limited rows -->
|
||||
<template v-else-if="account.platform === 'openai' && account.type === 'oauth'">
|
||||
<div v-if="hasCodexUsage" class="space-y-1">
|
||||
<div v-if="preferFetchedOpenAIUsage" 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-if="isActiveOpenAIRateLimited && loading" class="space-y-1.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-1.5 w-8 animate-pulse rounded-full 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 class="flex items-center gap-1">
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-1.5 w-8 animate-pulse rounded-full 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 v-else-if="hasCodexUsage" class="space-y-1">
|
||||
<!-- 5h Window -->
|
||||
<UsageProgressBar
|
||||
v-if="codex5hUsedPercent !== null"
|
||||
@@ -308,10 +338,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
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 UsageProgressBar from './UsageProgressBar.vue'
|
||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||
@@ -373,6 +404,36 @@ const hasOpenAIUsageFallback = computed(() => {
|
||||
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 preferFetchedOpenAIUsage = computed(() => {
|
||||
return (isActiveOpenAIRateLimited.value || isOpenAICodexSnapshotStale.value) && hasOpenAIUsageFallback.value
|
||||
})
|
||||
|
||||
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(() => {
|
||||
if (props.account.platform === 'openai' && props.account.type === 'oauth') {
|
||||
return isActiveOpenAIRateLimited.value || !hasCodexUsage.value || isOpenAICodexSnapshotStale.value
|
||||
}
|
||||
return shouldFetchUsage.value
|
||||
})
|
||||
|
||||
const codex5hUsedPercent = computed(() => codex5hWindow.value.usedPercent)
|
||||
const codex5hResetAt = computed(() => codex5hWindow.value.resetAt)
|
||||
const codex7dUsedPercent = computed(() => codex7dWindow.value.usedPercent)
|
||||
@@ -749,6 +810,17 @@ const loadUsage = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!shouldAutoLoadUsageOnMount.value) return
|
||||
loadUsage()
|
||||
})
|
||||
|
||||
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
|
||||
if (!prevKey || nextKey === prevKey) return
|
||||
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
|
||||
if (!isActiveOpenAIRateLimited.value && hasCodexUsage.value && !isOpenAICodexSnapshotStale.value) return
|
||||
|
||||
loadUsage().catch((e) => {
|
||||
console.error('Failed to refresh OpenAI usage:', e)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Window stats row (above progress bar, left-right aligned with progress bar) -->
|
||||
<div
|
||||
v-if="windowStats"
|
||||
class="mb-0.5 flex items-center justify-between"
|
||||
:title="statsTitle || t('admin.accounts.usageWindow.statsTitle')"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-help 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">
|
||||
{{ formatRequests }} req
|
||||
</span>
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||
{{ formatTokens }}
|
||||
</span>
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> A ${{ formatAccountCost }} </span>
|
||||
<span
|
||||
v-if="windowStats?.user_cost != null"
|
||||
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
|
||||
>
|
||||
U ${{ formatUserCost }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar row -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Label badge (fixed width for alignment) -->
|
||||
@@ -57,7 +32,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { WindowStats } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -66,11 +40,8 @@ const props = defineProps<{
|
||||
resetsAt?: string | null
|
||||
color: 'indigo' | 'emerald' | 'purple' | 'amber'
|
||||
windowStats?: WindowStats | null
|
||||
statsTitle?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Label background colors
|
||||
const labelClass = computed(() => {
|
||||
const colors = {
|
||||
@@ -117,12 +88,12 @@ const displayPercent = computed(() => {
|
||||
|
||||
// Format reset time
|
||||
const formatResetTime = computed(() => {
|
||||
if (!props.resetsAt) return t('common.notAvailable')
|
||||
if (!props.resetsAt) return '-'
|
||||
const date = new Date(props.resetsAt)
|
||||
const now = new Date()
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
|
||||
if (diffMs <= 0) return t('common.now')
|
||||
if (diffMs <= 0) return '现在'
|
||||
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
||||
@@ -137,31 +108,4 @@ const formatResetTime = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Format window stats
|
||||
const formatRequests = computed(() => {
|
||||
if (!props.windowStats) return ''
|
||||
const r = props.windowStats.requests
|
||||
if (r >= 1000000) return `${(r / 1000000).toFixed(1)}M`
|
||||
if (r >= 1000) return `${(r / 1000).toFixed(1)}K`
|
||||
return r.toString()
|
||||
})
|
||||
|
||||
const formatTokens = computed(() => {
|
||||
if (!props.windowStats) return ''
|
||||
const t = 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(() => {
|
||||
if (!props.windowStats) return '0.00'
|
||||
return props.windowStats.cost.toFixed(2)
|
||||
})
|
||||
|
||||
const formatUserCost = computed(() => {
|
||||
if (!props.windowStats || props.windowStats.user_cost == null) return '0.00'
|
||||
return props.windowStats.user_cost.toFixed(2)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -68,6 +68,102 @@ describe('AccountUsageCell', () => {
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.gemini3Image|70|2026-03-01T09:00:00Z')
|
||||
})
|
||||
|
||||
|
||||
it('OpenAI OAuth 快照已过期时首屏会重新请求 usage', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
five_hour: {
|
||||
utilization: 15,
|
||||
resets_at: '2026-03-08T12:00:00Z',
|
||||
remaining_seconds: 3600,
|
||||
window_stats: {
|
||||
requests: 3,
|
||||
tokens: 300,
|
||||
cost: 0.03,
|
||||
standard_cost: 0.03,
|
||||
user_cost: 0.03
|
||||
}
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 77,
|
||||
resets_at: '2026-03-13T12:00:00Z',
|
||||
remaining_seconds: 3600,
|
||||
window_stats: {
|
||||
requests: 3,
|
||||
tokens: 300,
|
||||
cost: 0.03,
|
||||
standard_cost: 0.03,
|
||||
user_cost: 0.03
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
id: 2000,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
extra: {
|
||||
codex_usage_updated_at: '2026-03-07T00:00:00Z',
|
||||
codex_5h_used_percent: 12,
|
||||
codex_5h_reset_at: '2026-03-08T12:00:00Z',
|
||||
codex_7d_used_percent: 34,
|
||||
codex_7d_reset_at: '2026-03-13T12:00:00Z'
|
||||
}
|
||||
} as any
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(getUsage).toHaveBeenCalledWith(2000)
|
||||
expect(wrapper.text()).toContain('5h|15|300')
|
||||
expect(wrapper.text()).toContain('7d|77|300')
|
||||
})
|
||||
|
||||
it('OpenAI OAuth 有现成快照且未限额时不会首屏请求 usage', async () => {
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
id: 2001,
|
||||
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'
|
||||
}
|
||||
} as any
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(getUsage).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('5h|12')
|
||||
expect(wrapper.text()).toContain('7d|34')
|
||||
})
|
||||
|
||||
it('OpenAI OAuth 在无 codex 快照时会回退显示 usage 接口窗口', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
five_hour: {
|
||||
@@ -122,4 +218,137 @@ describe('AccountUsageCell', () => {
|
||||
expect(wrapper.text()).toContain('5h|0|27700')
|
||||
expect(wrapper.text()).toContain('7d|0|27700')
|
||||
})
|
||||
|
||||
it('OpenAI OAuth 在行数据刷新但仍无 codex 快照时会重新拉取 usage', async () => {
|
||||
getUsage
|
||||
.mockResolvedValueOnce({
|
||||
five_hour: {
|
||||
utilization: 0,
|
||||
resets_at: null,
|
||||
remaining_seconds: 0,
|
||||
window_stats: {
|
||||
requests: 1,
|
||||
tokens: 100,
|
||||
cost: 0.01,
|
||||
standard_cost: 0.01,
|
||||
user_cost: 0.01
|
||||
}
|
||||
},
|
||||
seven_day: null
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
five_hour: {
|
||||
utilization: 0,
|
||||
resets_at: null,
|
||||
remaining_seconds: 0,
|
||||
window_stats: {
|
||||
requests: 2,
|
||||
tokens: 200,
|
||||
cost: 0.02,
|
||||
standard_cost: 0.02,
|
||||
user_cost: 0.02
|
||||
}
|
||||
},
|
||||
seven_day: null
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
id: 2003,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
updated_at: '2026-03-07T10:00:00Z',
|
||||
extra: {}
|
||||
} as any
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('5h|0|100')
|
||||
expect(getUsage).toHaveBeenCalledTimes(1)
|
||||
|
||||
await wrapper.setProps({
|
||||
account: {
|
||||
id: 2003,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
updated_at: '2026-03-07T10:01:00Z',
|
||||
extra: {}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(getUsage).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('5h|0|200')
|
||||
})
|
||||
|
||||
it('OpenAI OAuth 已限额时首屏优先展示重新查询后的 usage,而不是旧 codex 快照', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: {
|
||||
id: 2004,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
rate_limit_reset_at: '2099-03-07T12:00:00Z',
|
||||
extra: {
|
||||
codex_5h_used_percent: 0,
|
||||
codex_7d_used_percent: 0
|
||||
}
|
||||
} as any
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'],
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(getUsage).toHaveBeenCalledWith(2004)
|
||||
expect(wrapper.text()).toContain('5h|100|106540000')
|
||||
expect(wrapper.text()).toContain('7d|100|106540000')
|
||||
expect(wrapper.text()).not.toContain('5h|0|')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1694,7 +1694,8 @@ export default {
|
||||
rateLimited: 'Rate Limited',
|
||||
overloaded: 'Overloaded',
|
||||
tempUnschedulable: 'Temp Unschedulable',
|
||||
rateLimitedUntil: 'Rate limited until {time}',
|
||||
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
|
||||
rateLimitedAutoResume: 'Auto resumes in {time}',
|
||||
modelRateLimitedUntil: '{model} rate limited until {time}',
|
||||
overloadedUntil: 'Overloaded until {time}',
|
||||
viewTempUnschedDetails: 'View temp unschedulable details'
|
||||
|
||||
@@ -1859,7 +1859,8 @@ export default {
|
||||
rateLimited: '限流中',
|
||||
overloaded: '过载中',
|
||||
tempUnschedulable: '临时不可调度',
|
||||
rateLimitedUntil: '限流中,重置时间:{time}',
|
||||
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
|
||||
rateLimitedAutoResume: '{time} 自动恢复',
|
||||
modelRateLimitedUntil: '{model} 限流至 {time}',
|
||||
overloadedUntil: '负载过重,重置时间:{time}',
|
||||
viewTempUnschedDetails: '查看临时不可调度详情'
|
||||
|
||||
39
frontend/src/utils/__tests__/accountUsageRefresh.spec.ts
Normal file
39
frontend/src/utils/__tests__/accountUsageRefresh.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildOpenAIUsageRefreshKey } from '../accountUsageRefresh'
|
||||
|
||||
describe('buildOpenAIUsageRefreshKey', () => {
|
||||
it('会在 codex 快照变化时生成不同 key', () => {
|
||||
const base = {
|
||||
id: 1,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
updated_at: '2026-03-07T10:00:00Z',
|
||||
extra: {
|
||||
codex_usage_updated_at: '2026-03-07T10:00:00Z',
|
||||
codex_5h_used_percent: 0,
|
||||
codex_7d_used_percent: 0
|
||||
}
|
||||
} as any
|
||||
|
||||
const next = {
|
||||
...base,
|
||||
extra: {
|
||||
...base.extra,
|
||||
codex_usage_updated_at: '2026-03-07T10:01:00Z',
|
||||
codex_5h_used_percent: 100
|
||||
}
|
||||
}
|
||||
|
||||
expect(buildOpenAIUsageRefreshKey(base)).not.toBe(buildOpenAIUsageRefreshKey(next))
|
||||
})
|
||||
|
||||
it('非 OpenAI OAuth 账号返回空 key', () => {
|
||||
expect(buildOpenAIUsageRefreshKey({
|
||||
id: 2,
|
||||
platform: 'anthropic',
|
||||
type: 'oauth',
|
||||
updated_at: '2026-03-07T10:00:00Z',
|
||||
extra: {}
|
||||
} as any)).toBe('')
|
||||
})
|
||||
})
|
||||
28
frontend/src/utils/accountUsageRefresh.ts
Normal file
28
frontend/src/utils/accountUsageRefresh.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const normalizeUsageRefreshValue = (value: unknown): string => {
|
||||
if (value == null) return ''
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export const buildOpenAIUsageRefreshKey = (account: Pick<Account, 'id' | 'platform' | 'type' | 'updated_at' | 'rate_limit_reset_at' | 'extra'>): string => {
|
||||
if (account.platform !== 'openai' || account.type !== 'oauth') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const extra = account.extra ?? {}
|
||||
return [
|
||||
account.id,
|
||||
account.updated_at,
|
||||
account.rate_limit_reset_at,
|
||||
extra.codex_usage_updated_at,
|
||||
extra.codex_5h_used_percent,
|
||||
extra.codex_5h_reset_at,
|
||||
extra.codex_5h_reset_after_seconds,
|
||||
extra.codex_5h_window_minutes,
|
||||
extra.codex_7d_used_percent,
|
||||
extra.codex_7d_reset_at,
|
||||
extra.codex_7d_reset_after_seconds,
|
||||
extra.codex_7d_window_minutes
|
||||
].map(normalizeUsageRefreshValue).join('|')
|
||||
}
|
||||
@@ -312,6 +312,7 @@ import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
|
||||
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import type { Account, AccountPlatform, AccountType, Proxy, AdminGroup, WindowStats, ClaudeModel } from '@/types'
|
||||
|
||||
@@ -660,7 +661,8 @@ const shouldReplaceAutoRefreshRow = (current: Account, next: Account) => {
|
||||
current.status !== next.status ||
|
||||
current.rate_limit_reset_at !== next.rate_limit_reset_at ||
|
||||
current.overload_until !== next.overload_until ||
|
||||
current.temp_unschedulable_until !== next.temp_unschedulable_until
|
||||
current.temp_unschedulable_until !== next.temp_unschedulable_until ||
|
||||
buildOpenAIUsageRefreshKey(current) !== buildOpenAIUsageRefreshKey(next)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user