diff --git a/frontend/src/features/providers/components/ProviderDetailDrawer.vue b/frontend/src/features/providers/components/ProviderDetailDrawer.vue index 9dfa87967..ac108ff3d 100644 --- a/frontend/src/features/providers/components/ProviderDetailDrawer.vue +++ b/frontend/src/features/providers/components/ProviderDetailDrawer.vue @@ -980,6 +980,7 @@ import ProviderQuotaProgressRow from '@/features/providers/components/ProviderQu import ProviderQuotaSectionHeader from '@/features/providers/components/ProviderQuotaSectionHeader.vue' import { useProxyNodesStore } from '@/stores/proxy-nodes' import { resolveAntigravityQuotaGroupLabel } from '@/features/providers/utils/antigravityQuota' +import { refreshQuotaInBackground } from '@/features/providers/utils/refreshQuotaInBackground' import { deleteEndpointKey, recoverKeyHealth, @@ -2862,15 +2863,21 @@ async function autoRefreshQuotaInBackground(): Promise { } refreshingQuota.value = true + const isCurrent = () => props.open && props.providerId === providerId try { - const result = await refreshProviderQuota(providerId) + const result = await refreshQuotaInBackground({ + refresh: () => refreshProviderQuota(providerId), + isCurrent, + retryInitialEmptyQuota: providerType === 'antigravity' && !hadCachedQuota, + }) + if (!result) return false const applied = applyQuotaResults(result.results) if (result.success <= 0 && applied === 0 && !hadCachedQuota && providerType === 'antigravity') { - showError(legacyT('没有获取到配额信息(请检查账号是否已授权、project_id 是否存在)'), legacyT('提示')) + showWarning(legacyT('配额暂未就绪,请稍后刷新'), legacyT('提示')) } return applied > 0 } catch (err: unknown) { - if (!hadCachedQuota && providerType === 'antigravity') { + if (isCurrent() && !hadCachedQuota && providerType === 'antigravity') { showError(localizedApiError(err, '后台刷新配额失败'), legacyT('错误')) } return false diff --git a/frontend/src/features/providers/utils/__tests__/refreshQuotaInBackground.spec.ts b/frontend/src/features/providers/utils/__tests__/refreshQuotaInBackground.spec.ts new file mode 100644 index 000000000..5ec0d3424 --- /dev/null +++ b/frontend/src/features/providers/utils/__tests__/refreshQuotaInBackground.spec.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RefreshQuotaResult } from '@/api/endpoints/keys' +import { refreshQuotaInBackground } from '../refreshQuotaInBackground' + +function emptyQuota(status: 'error' | 'no_metadata' | 'forbidden' = 'no_metadata'): RefreshQuotaResult { + return { + success: 0, + failed: 1, + total: 1, + results: [{ key_id: 'key-1', key_name: 'account', status }], + } +} + +const readyQuota: RefreshQuotaResult = { + success: 1, + failed: 0, + total: 1, + results: [{ key_id: 'key-1', key_name: 'account', status: 'success' }], +} + +afterEach(() => vi.useRealTimers()) + +describe('initial background quota refresh', () => { + it('waits and retries an initial empty response once', async () => { + vi.useFakeTimers() + const refresh = vi.fn().mockResolvedValueOnce(emptyQuota()).mockResolvedValueOnce(readyQuota) + const pending = refreshQuotaInBackground({ refresh, isCurrent: () => true, retryInitialEmptyQuota: true }) + await vi.advanceTimersByTimeAsync(999) + expect(refresh).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(await pending).toBe(readyQuota) + expect(refresh).toHaveBeenCalledTimes(2) + }) + + it('returns persistent failures after at most one retry', async () => { + vi.useFakeTimers() + const result = emptyQuota('error') + const refresh = vi.fn().mockResolvedValue(result) + const pending = refreshQuotaInBackground({ refresh, isCurrent: () => true, retryInitialEmptyQuota: true }) + await vi.runAllTimersAsync() + expect(await pending).toBe(result) + expect(refresh).toHaveBeenCalledTimes(2) + }) + + it.each([readyQuota, emptyQuota('forbidden'), { + ...emptyQuota('error'), + results: [{ ...emptyQuota('error').results[0]!, status_code: 401 }], + }])('does not retry successful or rejected authorization results', async (result) => { + const refresh = vi.fn().mockResolvedValue(result) + expect(await refreshQuotaInBackground({ refresh, isCurrent: () => true, retryInitialEmptyQuota: true })).toBe(result) + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it('does not retry when initial quota retry is disabled', async () => { + const refresh = vi.fn().mockResolvedValue(emptyQuota()) + await refreshQuotaInBackground({ refresh, isCurrent: () => true, retryInitialEmptyQuota: false }) + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it('cancels the retry when the drawer closes or switches providers', async () => { + vi.useFakeTimers() + let current = true + const refresh = vi.fn().mockResolvedValue(emptyQuota()) + const pending = refreshQuotaInBackground({ refresh, isCurrent: () => current, retryInitialEmptyQuota: true }) + await vi.advanceTimersByTimeAsync(1) + current = false + await vi.runAllTimersAsync() + expect(await pending).toBeNull() + expect(refresh).toHaveBeenCalledTimes(1) + }) + + it('discards an in-flight response from a closed drawer', async () => { + let current = true + const refresh = vi.fn(async () => { + current = false + return readyQuota + }) + expect(await refreshQuotaInBackground({ refresh, isCurrent: () => current, retryInitialEmptyQuota: true })).toBeNull() + }) +}) diff --git a/frontend/src/features/providers/utils/refreshQuotaInBackground.ts b/frontend/src/features/providers/utils/refreshQuotaInBackground.ts new file mode 100644 index 000000000..5c3239676 --- /dev/null +++ b/frontend/src/features/providers/utils/refreshQuotaInBackground.ts @@ -0,0 +1,35 @@ +import type { RefreshQuotaResult } from '@/api/endpoints/keys' + +interface BackgroundQuotaRefreshOptions { + refresh: () => Promise + isCurrent: () => boolean + retryInitialEmptyQuota: boolean +} + +function shouldRetryEmptyQuota(result: RefreshQuotaResult): boolean { + return result.success === 0 + && result.results.length > 0 + && result.results.every(item => + (item.status === 'no_metadata' || item.status === 'error') + && item.status_code !== 401 + && item.status_code !== 403 + && !item.metadata + && !item.quota_snapshot, + ) +} + +export async function refreshQuotaInBackground({ + refresh, + isCurrent, + retryInitialEmptyQuota, +}: BackgroundQuotaRefreshOptions): Promise { + if (!isCurrent()) return null + const result = await refresh() + if (!isCurrent()) return null + if (!retryInitialEmptyQuota || !shouldRetryEmptyQuota(result)) return result + + await new Promise(resolve => setTimeout(resolve, 1000)) + if (!isCurrent()) return null + const retried = await refresh() + return isCurrent() ? retried : null +} diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 6329d8bea..633eb605a 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -2158,7 +2158,7 @@ const legacyExactEnglishMessages: Record = { '倍率必须在 0.01 到 100 之间': 'Multiplier must be between 0.01 and 100', '切换格式转换失败': 'Failed to toggle format conversion', '后台刷新配额失败': 'Failed to refresh quota in the background', - '没有获取到配额信息(请检查账号是否已授权、project_id 是否存在)': 'No quota information was returned. Check whether the account is authorized and project_id exists.', + '配额暂未就绪,请稍后刷新': 'Quota information is not ready yet. Please refresh again shortly.', 'Provider 不存在': 'Provider does not exist', '未知节点': 'Unknown node', '冷却中': 'Cooling down',