From a0767d957c4c75a4a4047296030ab6843cbebc98 Mon Sep 17 00:00:00 2001 From: elky Date: Thu, 23 Jul 2026 16:16:42 +0800 Subject: [PATCH] fix(frontend): synchronize pool account state --- .../src/api/endpoints/types/statusSnapshot.ts | 1 + .../utils/__tests__/poolCycleStats.spec.ts | 58 +++++ .../utils/__tests__/poolQuotaRefresh.spec.ts | 69 ++++++ .../src/features/pool/utils/poolCycleStats.ts | 27 ++ .../features/pool/utils/poolQuotaRefresh.ts | 28 +++ .../components/ProviderDetailDrawer.vue | 68 +++-- .../components/ProviderDetailHeader.vue | 2 + .../components/ProviderKeyActionCluster.vue | 2 + .../ProviderKeyActionCluster.spec.ts | 19 ++ frontend/src/views/admin/PoolManagement.vue | 232 +++++++++++++----- .../src/views/admin/ProviderManagement.vue | 12 +- .../PoolManagement.codex-cycle-stats.spec.ts | 133 +++++++++- 12 files changed, 557 insertions(+), 94 deletions(-) create mode 100644 frontend/src/features/pool/utils/__tests__/poolCycleStats.spec.ts create mode 100644 frontend/src/features/pool/utils/__tests__/poolQuotaRefresh.spec.ts create mode 100644 frontend/src/features/pool/utils/poolCycleStats.ts create mode 100644 frontend/src/features/pool/utils/poolQuotaRefresh.ts diff --git a/frontend/src/api/endpoints/types/statusSnapshot.ts b/frontend/src/api/endpoints/types/statusSnapshot.ts index 8b8e035e5..17b6f0605 100644 --- a/frontend/src/api/endpoints/types/statusSnapshot.ts +++ b/frontend/src/api/endpoints/types/statusSnapshot.ts @@ -39,6 +39,7 @@ export interface QuotaWindowSnapshot { reset_at?: number | null reset_seconds?: number | null window_minutes?: number | null + usage_reset_at?: number | null is_exhausted?: boolean | null usage?: QuotaWindowUsageSnapshot | null } diff --git a/frontend/src/features/pool/utils/__tests__/poolCycleStats.spec.ts b/frontend/src/features/pool/utils/__tests__/poolCycleStats.spec.ts new file mode 100644 index 000000000..225aee5f6 --- /dev/null +++ b/frontend/src/features/pool/utils/__tests__/poolCycleStats.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' + +import type { QuotaWindowSnapshot } from '@/api/endpoints/types' +import { resetCodexCycleUsageWindows } from '@/features/pool/utils/poolCycleStats' + +describe('resetCodexCycleUsageWindows', () => { + it('mirrors the backend reset scope and creates zero usage for resettable windows', () => { + const windows: QuotaWindowSnapshot[] = [ + { + code: '5h', + scope: 'account', + window_minutes: 300, + usage: null, + }, + { + code: 'spark_weekly', + scope: 'account', + window_minutes: 10_080, + usage: { request_count: 3, total_tokens: 30, total_cost_usd: '0.03' }, + }, + { + code: 'model_daily', + scope: 'model', + window_minutes: 1_440, + usage: { request_count: 4, total_tokens: 40, total_cost_usd: '0.04' }, + }, + { + code: 'lifetime', + scope: 'account', + window_minutes: 0, + usage: { request_count: 5, total_tokens: 50, total_cost_usd: '0.05' }, + }, + ] + + const result = resetCodexCycleUsageWindows(windows, 123) + + expect(result[0]).toMatchObject({ + usage_reset_at: 123, + usage: { request_count: 0, total_tokens: 0, total_cost_usd: '0.00000000' }, + }) + expect(result[1]).toBe(windows[1]) + expect(result[2]).toBe(windows[2]) + expect(result[3]).toBe(windows[3]) + }) + + it('treats a missing scope as account and ignores empty codes', () => { + const windows: QuotaWindowSnapshot[] = [ + { code: 'weekly', usage: { request_count: 7 } }, + { code: ' ', scope: 'account', usage: { request_count: 8 } }, + ] + + const result = resetCodexCycleUsageWindows(windows, 456) + + expect(result[0].usage?.request_count).toBe(0) + expect(result[0].usage_reset_at).toBe(456) + expect(result[1]).toBe(windows[1]) + }) +}) diff --git a/frontend/src/features/pool/utils/__tests__/poolQuotaRefresh.spec.ts b/frontend/src/features/pool/utils/__tests__/poolQuotaRefresh.spec.ts new file mode 100644 index 000000000..d3068e8f8 --- /dev/null +++ b/frontend/src/features/pool/utils/__tests__/poolQuotaRefresh.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' + +import type { PoolKeyDetail } from '@/api/endpoints/pool' +import type { RefreshQuotaResult } from '@/api/endpoints/keys' +import { mergePoolKeyQuotaSnapshots } from '@/features/pool/utils/poolQuotaRefresh' + +function createKey(keyId: string): PoolKeyDetail { + return { + key_id: keyId, + key_name: keyId, + is_active: true, + auth_type: 'oauth', + api_formats: ['openai:responses'], + internal_priority: 0, + account_quota: null, + cooldown_reason: null, + cooldown_ttl_seconds: null, + cost_window_usage: 0, + cost_limit: null, + request_count: 0, + total_tokens: 0, + total_cost_usd: '0', + sticky_sessions: 0, + lru_score: null, + created_at: null, + last_used_at: null, + } +} + +describe('mergePoolKeyQuotaSnapshots', () => { + it('merges snapshots from non-success quota results', () => { + const keys = [createKey('exhausted'), createKey('invalid'), createKey('unchanged')] + const results: RefreshQuotaResult['results'] = [ + { + key_id: 'exhausted', + key_name: 'exhausted', + status: 'quota_exhausted', + quota_snapshot: { + code: 'exhausted', + exhausted: true, + updated_at: 123, + }, + }, + { + key_id: 'invalid', + key_name: 'invalid', + status: 'auth_invalid', + quota_snapshot: { + code: 'unknown', + exhausted: false, + observed_at: 456, + }, + }, + { + key_id: 'unchanged', + key_name: 'unchanged', + status: 'error', + }, + ] + + const merged = mergePoolKeyQuotaSnapshots(keys, results) + + expect(merged[0].status_snapshot?.quota.code).toBe('exhausted') + expect(merged[0].quota_updated_at).toBe(123) + expect(merged[1].status_snapshot?.quota.code).toBe('unknown') + expect(merged[1].quota_updated_at).toBe(456) + expect(merged[2]).toBe(keys[2]) + }) +}) diff --git a/frontend/src/features/pool/utils/poolCycleStats.ts b/frontend/src/features/pool/utils/poolCycleStats.ts new file mode 100644 index 000000000..44fb19627 --- /dev/null +++ b/frontend/src/features/pool/utils/poolCycleStats.ts @@ -0,0 +1,27 @@ +import type { QuotaWindowSnapshot } from '@/api/endpoints/types' + +export function resetCodexCycleUsageWindows( + windows: QuotaWindowSnapshot[], + resetAt: number, +): QuotaWindowSnapshot[] { + return windows.map((window) => { + const code = String(window.code || '').trim() + const scope = String(window.scope || 'account').trim().toLowerCase() + const shouldReset = Boolean(code) + && scope === 'account' + && !code.toLowerCase().startsWith('spark_') + && window.window_minutes !== 0 + + if (!shouldReset) return window + + return { + ...window, + usage_reset_at: Number.isFinite(resetAt) ? resetAt : window.usage_reset_at, + usage: { + request_count: 0, + total_tokens: 0, + total_cost_usd: '0.00000000', + }, + } + }) +} diff --git a/frontend/src/features/pool/utils/poolQuotaRefresh.ts b/frontend/src/features/pool/utils/poolQuotaRefresh.ts new file mode 100644 index 000000000..175084e2a --- /dev/null +++ b/frontend/src/features/pool/utils/poolQuotaRefresh.ts @@ -0,0 +1,28 @@ +import type { PoolKeyDetail } from '@/api/endpoints/pool' +import type { RefreshQuotaResult } from '@/api/endpoints/keys' + +export function mergePoolKeyQuotaSnapshots( + keys: PoolKeyDetail[], + results: RefreshQuotaResult['results'], +): PoolKeyDetail[] { + const quotaByKeyId = new Map>() + for (const result of results) { + if (result.quota_snapshot) { + quotaByKeyId.set(result.key_id, result.quota_snapshot) + } + } + if (quotaByKeyId.size === 0) return keys + + return keys.map((key) => { + const quotaSnapshot = quotaByKeyId.get(key.key_id) + if (!quotaSnapshot) return key + return { + ...key, + quota_updated_at: quotaSnapshot.updated_at ?? quotaSnapshot.observed_at ?? key.quota_updated_at ?? null, + status_snapshot: { + ...(key.status_snapshot ?? {}), + quota: quotaSnapshot, + }, + } + }) +} diff --git a/frontend/src/features/providers/components/ProviderDetailDrawer.vue b/frontend/src/features/providers/components/ProviderDetailDrawer.vue index 5eef42813..f1551201e 100644 --- a/frontend/src/features/providers/components/ProviderDetailDrawer.vue +++ b/frontend/src/features/providers/components/ProviderDetailDrawer.vue @@ -1091,6 +1091,14 @@ let mappingPreviewLoadRequestId = 0 const DEFAULT_PROVIDER_KEYS_PAGE_SIZE = 3 const CUSTOM_PROVIDER_KEYS_PAGE_SIZE = 4 +function applyProviderSnapshot(updated: ProviderWithEndpointsSummary): void { + if (provider.value?.id === updated.id) { + Object.assign(provider.value, updated) + return + } + provider.value = updated +} + function getProviderKeysPageSize(providerType?: string | null): number { return (providerType || '').trim().toLowerCase() === 'custom' ? CUSTOM_PROVIDER_KEYS_PAGE_SIZE @@ -1386,7 +1394,7 @@ async function toggleFormatConversion() { const newValue = !provider.value.enable_format_conversion try { const updated = await updateProvider(provider.value.id, { enable_format_conversion: newValue }) - provider.value = updated + applyProviderSnapshot(updated) showSuccess(legacyT(newValue ? '已启用格式转换' : '已禁用格式转换')) emit('refresh') } catch { @@ -1408,7 +1416,7 @@ async function setProviderProxy(nodeId: string) { const updated = await updateProvider(provider.value.id, { proxy: { node_id: nodeId, enabled: true }, }) - provider.value = updated + applyProviderSnapshot(updated) providerProxyPopoverOpen.value = false showSuccess(legacyT('代理节点已设置')) emit('refresh') @@ -1424,7 +1432,7 @@ async function clearProviderProxy() { savingProviderProxy.value = true try { const updated = await updateProvider(provider.value.id, { proxy: null }) - provider.value = updated + applyProviderSnapshot(updated) providerProxyPopoverOpen.value = false showSuccess(legacyT('已清除提供商代理')) emit('refresh') @@ -1447,7 +1455,7 @@ function handleEditEndpoint(_endpoint: ProviderEndpoint) { } async function handleEndpointChanged() { - await Promise.all([loadProvider(), loadEndpoints()]) + await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()]) emit('refresh') } @@ -1562,7 +1570,7 @@ async function confirmDeleteKey() { await deleteEndpointKey(keyId) showSuccess(legacyT('密钥已删除')) // 刷新端点列表及模型数据(删除 Key 触发自动解除模型关联) - await loadEndpoints() + await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()]) emit('refresh') } catch (err: unknown) { showError(localizedApiError(err, '删除密钥失败'), legacyT('错误')) @@ -1573,7 +1581,7 @@ async function handleRecoverKey(key: EndpointAPIKey) { try { const result = await recoverKeyHealth(key.id) showSuccess(legacyT(result.message || 'Key已完全恢复')) - await loadEndpoints() + await Promise.all([loadProvider(), loadEndpoints()]) emit('refresh') } catch (err: unknown) { showError(localizedApiError(err, 'Key恢复失败'), legacyT('错误')) @@ -1625,11 +1633,15 @@ async function handleRefreshOAuth(key: EndpointAPIKey) { } else { showSuccess(legacyT(feedback.message)) } - // Antigravity:token 刷新后可能完成了账号激活,触发配额获取 - // (不 emit('refresh'),避免触发全局 provider 余额刷新) - void autoRefreshQuotaInBackground() + emit('refresh') + // Token 刷新可能激活账号并更新配额,完成后再同步一次父列表。 + void autoRefreshQuotaInBackground().then((changed) => { + if (changed) emit('refresh') + }) } catch (err: unknown) { showError(localizedApiError(err, 'Token 刷新失败'), legacyT('错误')) + await Promise.all([loadProvider(), loadEndpoints()]) + emit('refresh') } finally { refreshingOAuthKeyId.value = null } @@ -1692,9 +1704,12 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) { } } } - await loadEndpoints() + await Promise.all([loadProvider(), loadEndpoints()]) + emit('refresh') } catch (err: unknown) { showError(localizedApiError(err, '清除失败'), legacyT('错误')) + await Promise.all([loadProvider(), loadEndpoints()]) + emit('refresh') } finally { clearingOAuthInvalidKeyId.value = null } @@ -1755,8 +1770,11 @@ async function handleConsumeCodexResetCredit(key: EndpointAPIKey) { if (result.refresh_status === 'failed') { showWarning(legacyT(result.refresh_error || '重置请求已处理,但最新配额刷新失败')) } + emit('refresh') } catch (err: unknown) { showError(localizedApiError(err, 'Codex 重置机会使用失败'), legacyT('错误')) + await Promise.all([loadProvider(), loadEndpoints()]) + emit('refresh') } finally { consumingCodexResetCreditKeyId.value = null } @@ -2707,13 +2725,13 @@ function applyQuotaResults( } // 通用的自动刷新配额函数(支持 Codex、Gemini CLI、Antigravity、Kiro、Windsurf 和 ChatGPT Web) -async function autoRefreshQuotaInBackground() { +async function autoRefreshQuotaInBackground(): Promise { const providerId = props.providerId - if (!providerId) return - if (refreshingQuota.value) return + if (!providerId) return false + if (refreshingQuota.value) return false const providerType = provider.value?.provider_type - if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok') return + if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok') return false // 检查是否需要刷新 let shouldRefresh = false @@ -2732,7 +2750,7 @@ async function autoRefreshQuotaInBackground() { } else if (providerType === 'chatgpt_web') { shouldRefresh = shouldAutoRefreshChatGPTWebQuota() } - if (!shouldRefresh) return + if (!shouldRefresh) return false let hadCachedQuota = false if (providerType === 'codex') { @@ -2758,10 +2776,12 @@ async function autoRefreshQuotaInBackground() { if (result.success <= 0 && applied === 0 && !hadCachedQuota && providerType === 'antigravity') { showError(legacyT('没有获取到配额信息(请检查账号是否已授权、project_id 是否存在)'), legacyT('提示')) } + return applied > 0 } catch (err: unknown) { if (!hadCachedQuota && providerType === 'antigravity') { showError(localizedApiError(err, '后台刷新配额失败'), legacyT('错误')) } + return false } finally { refreshingQuota.value = false } @@ -2792,10 +2812,12 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) { } async function handleKeyChanged() { - await Promise.all([loadEndpoints(), loadMappingPreview()]) + await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()]) emit('refresh') // 添加/修改 key 后自动获取已支持 provider 的配额(新 key 的 upstream_metadata 为空) - void autoRefreshQuotaInBackground() + void autoRefreshQuotaInBackground().then((changed) => { + if (changed) emit('refresh') + }) } // 切换密钥启用状态 @@ -2805,8 +2827,10 @@ async function toggleKeyActive(key: EndpointAPIKey) { togglingKeyId.value = key.id try { const newStatus = !key.is_active - await updateProviderKey(key.id, { is_active: newStatus }) + const updated = await updateProviderKey(key.id, { is_active: newStatus }) + Object.assign(key, updated) key.is_active = newStatus + await Promise.all([loadProvider(), loadEndpoints()]) showSuccess(legacyT(newStatus ? '密钥已启用' : '密钥已停用')) emit('refresh') } catch (err: unknown) { @@ -2885,20 +2909,20 @@ function handleBatchAssignDialogOpenUpdate(value: boolean) { // 处理批量关联完成 async function handleBatchAssignChanged() { - await Promise.all([loadEndpoints(), loadMappingPreview()]) + await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()]) emit('refresh') } // 处理模型映射变更 async function handleModelMappingChanged() { - await Promise.all([loadEndpoints(), loadMappingPreview()]) + await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()]) emit('refresh') } // 处理模型保存完成 async function handleModelSaved() { editingModel.value = null - await Promise.all([loadEndpoints(), loadMappingPreview()]) + await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()]) emit('refresh') } @@ -3738,7 +3762,7 @@ async function loadProvider() { void loadSystemFormatConversionConfig() const providerData = await getProvider(props.providerId) if (requestId !== providerLoadRequestId) return - provider.value = providerData + applyProviderSnapshot(providerData) keyPageSize.value = getProviderKeysPageSize(providerData.provider_type) if (!provider.value) { diff --git a/frontend/src/features/providers/components/ProviderDetailHeader.vue b/frontend/src/features/providers/components/ProviderDetailHeader.vue index ef1f01d5d..c79e58f73 100644 --- a/frontend/src/features/providers/components/ProviderDetailHeader.vue +++ b/frontend/src/features/providers/components/ProviderDetailHeader.vue @@ -91,6 +91,8 @@ variant="ghost" size="icon" :title="legacyT(provider.is_active ? '点击停用' : '点击启用')" + :aria-label="legacyT(provider.is_active ? '点击停用' : '点击启用')" + data-testid="provider-toggle-active" @click="$emit('toggleStatus', provider)" > diff --git a/frontend/src/features/providers/components/ProviderKeyActionCluster.vue b/frontend/src/features/providers/components/ProviderKeyActionCluster.vue index 7cf5f43c8..d0a5a5f3f 100644 --- a/frontend/src/features/providers/components/ProviderKeyActionCluster.vue +++ b/frontend/src/features/providers/components/ProviderKeyActionCluster.vue @@ -126,6 +126,8 @@ class="h-7 w-7" :disabled="toggling" :title="legacyT(apiKey.is_active ? '点击停用' : '点击启用')" + :aria-label="legacyT(apiKey.is_active ? '点击停用' : '点击启用')" + data-testid="provider-key-toggle-active" @click="$emit('toggleActive')" > diff --git a/frontend/src/features/providers/components/__tests__/ProviderKeyActionCluster.spec.ts b/frontend/src/features/providers/components/__tests__/ProviderKeyActionCluster.spec.ts index ccb461728..288bb5d11 100644 --- a/frontend/src/features/providers/components/__tests__/ProviderKeyActionCluster.spec.ts +++ b/frontend/src/features/providers/components/__tests__/ProviderKeyActionCluster.spec.ts @@ -128,6 +128,7 @@ describe('ProviderKeyActionCluster', () => { expect(root.querySelector('button[title="Recover key"]')).toBeTruthy() expect(root.querySelector('button[title="代理: Tokyo"]')).toBeTruthy() expect(root.querySelector('button[title="配额详情"]')).toBeTruthy() + expect(root.querySelector('[data-testid="provider-key-toggle-active"]')?.getAttribute('aria-label')).toBe('点击停用') unmount() }) @@ -179,4 +180,22 @@ describe('ProviderKeyActionCluster', () => { unmount() }) + + it('offers an enable action for an inactive account', () => { + const onToggleActive = vi.fn() + const { root, unmount } = mount({ + apiKey: createProviderKey({ is_active: false }), + onToggleActive, + }) + + const enableButton = root.querySelector('[data-testid="provider-key-toggle-active"]') as HTMLButtonElement | null + expect(enableButton).toBeTruthy() + expect(enableButton?.getAttribute('title')).toBe('点击启用') + expect(enableButton?.getAttribute('aria-label')).toBe('点击启用') + + enableButton?.click() + expect(onToggleActive).toHaveBeenCalledTimes(1) + + unmount() + }) }) diff --git a/frontend/src/views/admin/PoolManagement.vue b/frontend/src/views/admin/PoolManagement.vue index 3c3465052..ec2af1733 100644 --- a/frontend/src/views/admin/PoolManagement.vue +++ b/frontend/src/views/admin/PoolManagement.vue @@ -516,6 +516,8 @@ class="h-7 w-7 text-foreground hover:text-foreground" :disabled="togglingKeyId === key.key_id" :title="key.is_active ? '禁用' : '启用'" + :aria-label="key.is_active ? '禁用账号' : '启用账号'" + :data-testid="`pool-toggle-active-desktop-${key.key_id}`" @click="toggleKeyActive(key)" > @@ -847,6 +849,8 @@ class="h-7 w-7 shrink-0 text-foreground hover:text-foreground" :disabled="togglingKeyId === key.key_id" :title="key.is_active ? '禁用' : '启用'" + :aria-label="key.is_active ? '禁用账号' : '启用账号'" + :data-testid="`pool-toggle-active-mobile-${key.key_id}`" @click="toggleKeyActive(key)" > @@ -1124,6 +1128,8 @@ import { type PoolStatsDisplay, type PoolStatsMetric, } from '@/features/pool/utils/poolStatsDisplay' +import { resetCodexCycleUsageWindows } from '@/features/pool/utils/poolCycleStats' +import { mergePoolKeyQuotaSnapshots } from '@/features/pool/utils/poolQuotaRefresh' import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow' import { getOAuthOrgBadge } from '@/utils/oauthIdentity' import { formatOAuthPlanType, getOAuthPlanTypeClass } from '@/utils/oauthPlanType' @@ -1312,9 +1318,11 @@ async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } = if (requestId !== overviewRequestId) return if (!options.silent) { showError(parseApiError(err)) + } else { + showWarning(parseApiError(err, '同步 Provider 概览失败')) } } finally { - if (requestId === overviewRequestId && !options.silent) { + if (requestId === overviewRequestId) { overviewLoading.value = false } } @@ -1323,11 +1331,15 @@ async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } = async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSummary) { // 优先回写保存接口返回值,避免弹窗立即重开时读到旧配置。 if (selectedProviderId.value && updatedProvider.id === selectedProviderId.value) { - selectedProviderData.value = updatedProvider + if (selectedProviderData.value) { + Object.assign(selectedProviderData.value, updatedProvider) + } else { + selectedProviderData.value = updatedProvider + } } showSchedulingDialog.value = false showAdvancedDialog.value = false - await loadOverview() + await loadOverview({ silent: true }) } // --- Provider Selection --- @@ -1699,15 +1711,23 @@ async function selectProvider( if (requestId !== selectProviderRequestId) return } -async function loadProviderData(id: string) { +async function loadProviderData(id: string, options: { preserveOnError?: boolean } = {}) { const requestId = ++providerDataRequestId try { const providerData = await getProvider(id) if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return - selectedProviderData.value = providerData + if (selectedProviderData.value?.id === providerData.id) { + Object.assign(selectedProviderData.value, providerData) + } else { + selectedProviderData.value = providerData + } } catch { if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return - selectedProviderData.value = null + if (options.preserveOnError) { + showWarning('同步 Provider 详情失败,已保留当前数据') + } else { + selectedProviderData.value = null + } } } @@ -2069,30 +2089,76 @@ function resetKeyPage(page = currentPage.value, pageSizeValue = pageSize.value): } function refreshOverviewInBackground(): void { - void loadOverview() + void loadOverview({ silent: true }) +} + +function clampActiveKeyCount(current: unknown, total: unknown, delta: number): number { + const currentValue = Number(current) + const nextValue = Math.max(0, (Number.isFinite(currentValue) ? currentValue : 0) + delta) + const totalValue = Number(total) + if (!Number.isFinite(totalValue)) return nextValue + return Math.min(Math.max(0, totalValue), nextValue) +} + +function isManualInactiveReason(reason: { code?: string; source?: string }): boolean { + const code = String(reason.code || '').trim().toLowerCase() + return code === 'inactive' || code === 'manual_disabled' +} + +function applyPoolKeyActiveState(key: PoolKeyDetail, nextStatus: boolean): void { + const previousStatus = key.is_active + key.is_active = nextStatus + + if (nextStatus) { + const remainingReasons = (key.scheduling_reasons ?? []).filter( + reason => !isManualInactiveReason(reason), + ) + key.scheduling_reasons = remainingReasons + const remainingBlockingReason = remainingReasons.find(reason => reason.blocking) + if (remainingBlockingReason) { + key.scheduling_reason = remainingBlockingReason.code + key.scheduling_label = remainingBlockingReason.label + key.scheduling_status = remainingBlockingReason.code === 'cooldown' ? 'degraded' : 'blocked' + } else if (key.cooldown_reason) { + key.scheduling_reason = 'cooldown' + key.scheduling_label = '冷却中' + key.scheduling_status = 'degraded' + } else { + key.scheduling_reason = 'available' + key.scheduling_label = '可用' + key.scheduling_status = 'available' + } + } else { + key.scheduling_label = '已禁用' + key.scheduling_status = 'blocked' + key.scheduling_reason = 'inactive' + key.scheduling_reasons = [{ + code: 'inactive', + label: '已禁用', + blocking: true, + source: 'manual', + ttl_seconds: null, + detail: null, + }] + } + + if (previousStatus === nextStatus) return + const delta = nextStatus ? 1 : -1 + const overview = poolProviders.value.find(item => item.provider_id === selectedProviderId.value) + if (overview) { + overview.active_keys = clampActiveKeyCount(overview.active_keys, overview.total_keys, delta) + } + if (selectedProviderData.value) { + selectedProviderData.value.active_keys = clampActiveKeyCount( + selectedProviderData.value.active_keys, + selectedProviderData.value.total_keys, + delta, + ) + } } function applyQuotaRefreshResultToCurrentPage(result: Awaited>): void { - const quotaByKeyId = new Map[number]['quota_snapshot']>>() - for (const item of result.results) { - if (item.status === 'success' && item.quota_snapshot) { - quotaByKeyId.set(item.key_id, item.quota_snapshot) - } - } - if (quotaByKeyId.size === 0) return - - keyPage.value.keys = keyPage.value.keys.map((key) => { - const quotaSnapshot = quotaByKeyId.get(key.key_id) - if (!quotaSnapshot) return key - return { - ...key, - quota_updated_at: quotaSnapshot.updated_at ?? quotaSnapshot.observed_at ?? key.quota_updated_at ?? null, - status_snapshot: { - ...(key.status_snapshot ?? {}), - quota: quotaSnapshot, - }, - } - }) + keyPage.value.keys = mergePoolKeyQuotaSnapshots(keyPage.value.keys, result.results) } function normalizeQuotaUpdatedAt(raw: number | null | undefined): number | null { @@ -2139,7 +2205,7 @@ const currentPageQuotaRefreshStats = computed(() => { }) async function refreshCurrentPageQuotaInBackground( - options: { silent?: boolean; reloadAfter?: boolean } = {}, + options: { silent?: boolean; reloadAfter?: boolean | 'silent' } = {}, ): Promise { if (!selectedProviderId.value || !quotaRefreshSupported.value) return false @@ -2169,7 +2235,7 @@ async function refreshCurrentPageQuotaInBackground( // 刷新当前页数据,展示最新额度与状态 if (selectedProviderId.value === providerId && options.reloadAfter !== false) { - await loadKeys() + await loadKeys({ silent: options.reloadAfter === 'silent' }) } if (!options.silent) { @@ -2216,7 +2282,7 @@ async function refreshCurrentPage() { } } -async function loadKeys(options: { cacheTtlMs?: number } = {}) { +async function loadKeys(options: { cacheTtlMs?: number, silent?: boolean } = {}) { if (!selectedProviderId.value) return const requestId = ++keysRequestId const providerId = selectedProviderId.value @@ -2225,7 +2291,9 @@ async function loadKeys(options: { cacheTtlMs?: number } = {}) { const search = searchQuery.value || undefined const status = statusFilter.value const sortByValue = sortBy.value || undefined - keysLoading.value = true + if (!options.silent) { + keysLoading.value = true + } try { const nextPage = await listPoolKeys(providerId, { page, @@ -2251,9 +2319,13 @@ async function loadKeys(options: { cacheTtlMs?: number } = {}) { keysLoadedOnce.value = true } catch (err) { if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return - resetKeyPage(page, pageSizeValue) - keysLoadedOnce.value = true - showError(parseApiError(err)) + if (!options.silent) { + resetKeyPage(page, pageSizeValue) + keysLoadedOnce.value = true + showError(parseApiError(err)) + } else { + showWarning(parseApiError(err, '同步账号列表失败')) + } } finally { if (requestId === keysRequestId) { keysLoading.value = false @@ -2465,12 +2537,12 @@ function closeKeyBatchEditDialog(): void { async function handleKeyBatchEditSaved(): Promise { resetPoolKeySelection(true) - await Promise.all([loadKeys(), loadOverview()]) + await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })]) } async function handleDialogSaved() { editingKeyDetail.value = null - await loadKeys() + await loadKeys({ silent: true }) } function closeKeyFormDialog() { @@ -2647,7 +2719,7 @@ async function handleRefreshOAuth(key: PoolKeyDetail) { if (target) { target.oauth_expires_at = refreshedExpiresAt } - await loadKeys() + await loadKeys({ silent: true }) if (refreshedExpiresAt != null) { const reloadedTarget = keyPage.value.keys.find(k => k.key_id === key.key_id) if ( @@ -2671,7 +2743,7 @@ async function handleRefreshOAuth(key: PoolKeyDetail) { } } catch (err) { showError(parseApiError(err, 'Token 刷新失败')) - await loadKeys() + await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })]) } finally { refreshingOAuthKeyId.value = null } @@ -2683,8 +2755,20 @@ async function clearCooldown(keyId: string) { try { const res = await clearPoolCooldown(selectedProviderId.value, keyId) success(res.message) - await loadKeys() - refreshOverviewInBackground() + const key = keyPage.value.keys.find(item => item.key_id === keyId) + if (key) { + key.cooldown_reason = null + key.cooldown_ttl_seconds = null + if (key.scheduling_reason === 'cooldown') { + key.scheduling_reason = key.is_active ? 'available' : 'inactive' + key.scheduling_status = key.is_active ? 'available' : 'blocked' + key.scheduling_label = key.is_active ? '可用' : '已禁用' + } + key.scheduling_reasons = key.scheduling_reasons?.filter( + item => item.code !== 'cooldown', + ) + } + await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })]) } catch (err) { showError(parseApiError(err)) } @@ -2704,7 +2788,17 @@ async function handleResetCycleStats(key: PoolKeyDetail) { try { const result = await resetProviderKeyCycleStats(key.key_id) success(result.message || '周期统计已重置') - await loadKeys() + if (key.status_snapshot?.quota?.windows) { + const resetAt = Number(result.reset_at) + key.status_snapshot = { + ...key.status_snapshot, + quota: { + ...key.status_snapshot.quota, + windows: resetCodexCycleUsageWindows(key.status_snapshot.quota.windows, resetAt), + }, + } + } + await loadKeys({ silent: true }) } catch (err) { showError(parseApiError(err, '重置周期统计失败')) } finally { @@ -2718,21 +2812,9 @@ async function toggleKeyActive(key: PoolKeyDetail) { try { const nextStatus = !key.is_active await updateProviderKey(key.key_id, { is_active: nextStatus }) - key.is_active = nextStatus - if (nextStatus) { - delete key.scheduling_label - delete key.scheduling_status - if (key.scheduling_reason === 'manual_disabled') { - delete key.scheduling_reason - } - } else { - key.scheduling_label = '禁用' - key.scheduling_status = 'blocked' - key.scheduling_reason = 'manual_disabled' - } + applyPoolKeyActiveState(key, nextStatus) + await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })]) success(nextStatus ? '账号已启用' : '账号已停用') - await loadKeys() - refreshOverviewInBackground() } catch (err) { showError(parseApiError(err)) } finally { @@ -2775,9 +2857,9 @@ async function handleProviderDrawerRefresh(): Promise { if (!providerId) return await Promise.all([ - loadKeys(), + loadKeys({ silent: true }), loadOverview({ silent: true }), - loadProviderData(providerId), + loadProviderData(providerId, { preserveOnError: true }), ]) resetPoolKeySelection(true) } @@ -2789,8 +2871,13 @@ async function openProviderEditDialog(provider?: ProviderWithEndpointsSummary): try { const latest = await getProvider(providerId) if (selectedProviderId.value !== providerId) return - selectedProviderData.value = latest - providerToEdit.value = latest + if (selectedProviderData.value?.id === latest.id) { + Object.assign(selectedProviderData.value, latest) + providerToEdit.value = selectedProviderData.value + } else { + selectedProviderData.value = latest + providerToEdit.value = latest + } } catch (err) { if (selectedProviderId.value !== providerId) return const fallbackProvider = provider ?? selectedProviderData.value @@ -2806,11 +2893,16 @@ async function openProviderEditDialog(provider?: ProviderWithEndpointsSummary): async function handleProviderEditSaved(updatedProvider: ProviderWithEndpointsSummary): Promise { if (selectedProviderId.value === updatedProvider.id) { - selectedProviderData.value = updatedProvider - providerToEdit.value = updatedProvider + if (selectedProviderData.value) { + Object.assign(selectedProviderData.value, updatedProvider) + providerToEdit.value = selectedProviderData.value + } else { + selectedProviderData.value = updatedProvider + providerToEdit.value = updatedProvider + } } providerEditDialogOpen.value = false - await loadOverview() + await loadOverview({ silent: true }) } async function toggleSelectedProviderStatus(provider?: ProviderWithEndpointsSummary): Promise { @@ -2834,11 +2926,15 @@ async function toggleSelectedProviderStatus(provider?: ProviderWithEndpointsSumm try { const updated = await updateProvider(providerId, { is_active: nextStatus }) Object.assign(current, updated) - if (selectedProviderId.value === providerId) { - selectedProviderData.value = updated + if (selectedProviderId.value === providerId && selectedProviderData.value !== current) { + if (selectedProviderData.value) { + Object.assign(selectedProviderData.value, updated) + } else { + selectedProviderData.value = updated + } } success(nextStatus ? '提供商已启用' : '提供商已禁用') - await loadOverview() + await loadOverview({ silent: true }) } catch (err) { showError(parseApiError(err, nextStatus ? '启用提供商失败' : '禁用提供商失败')) } finally { @@ -2848,14 +2944,14 @@ async function toggleSelectedProviderStatus(provider?: ProviderWithEndpointsSumm async function handleAccountBatchChanged(): Promise { resetPoolKeySelection(true) - await Promise.all([loadKeys(), loadOverview()]) + await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })]) } async function handleAccountDialogSaved() { showImportDialog.value = false - await Promise.all([loadKeys(), loadOverview()]) + await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })]) // 导入账号后补一次静默额度刷新,避免新账号在列表里暂无额度信息 - await refreshCurrentPageQuotaInBackground({ silent: true }) + await refreshCurrentPageQuotaInBackground({ silent: true, reloadAfter: 'silent' }) } // --- Formatting --- diff --git a/frontend/src/views/admin/ProviderManagement.vue b/frontend/src/views/admin/ProviderManagement.vue index 6a250bd23..f6120c6de 100644 --- a/frontend/src/views/admin/ProviderManagement.vue +++ b/frontend/src/views/admin/ProviderManagement.vue @@ -568,7 +568,13 @@ async function loadProviders(options: { cacheTtlMs?: number } = {}) { cacheTtlMs: options.cacheTtlMs ?? 0, }) if (requestId !== providersRequestId) return - providers.value = response.items + const existingProviders = new Map(providers.value.map(provider => [provider.id, provider])) + providers.value = response.items.map((item) => { + const existing = existingProviders.get(item.id) + if (!existing) return item + Object.assign(existing, item) + return existing + }) total.value = response.total // 异步加载配置了 ops 的 provider 的余额数据 loadBalances(providers.value) @@ -639,8 +645,8 @@ function openProviderDrawer(providerId: string) { function mergeUpdatedProvider(updated: ProviderWithEndpointsSummary) { const index = providers.value.findIndex(p => p.id === updated.id) if (index !== -1) { - providers.value[index] = updated - loadBalances([updated], false) + Object.assign(providers.value[index], updated) + loadBalances([providers.value[index]], false) } } diff --git a/frontend/src/views/admin/__tests__/PoolManagement.codex-cycle-stats.spec.ts b/frontend/src/views/admin/__tests__/PoolManagement.codex-cycle-stats.spec.ts index 3df46b68a..be61cf90d 100644 --- a/frontend/src/views/admin/__tests__/PoolManagement.codex-cycle-stats.spec.ts +++ b/frontend/src/views/admin/__tests__/PoolManagement.codex-cycle-stats.spec.ts @@ -873,8 +873,45 @@ describe('PoolManagement Codex cycle stats mode', () => { it('resets Codex cycle stats from the action column', async () => { const codexKey = createPoolKey('codex') + const resetKey = createPoolKey('codex', { + status_snapshot: { + ...codexKey.status_snapshot, + quota: { + ...codexKey.status_snapshot?.quota, + windows: [ + { + code: '5h', + scope: 'account', + window_minutes: 300, + usage_reset_at: 123, + usage: { request_count: 0, total_tokens: 0, total_cost_usd: '0.00000000' }, + }, + { + code: 'spark_preview', + scope: 'account', + window_minutes: 300, + usage: { request_count: 12, total_tokens: 500, total_cost_usd: '0.01' }, + }, + { + code: 'model_window', + scope: 'model', + window_minutes: 300, + usage: { request_count: 9, total_tokens: 400, total_cost_usd: '0.02' }, + }, + { + code: 'lifetime', + scope: 'account', + window_minutes: 0, + usage: { request_count: 8, total_tokens: 300, total_cost_usd: '0.03' }, + }, + ], + }, + }, + }) endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] }) - endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey)) + endpointMocks.listPoolKeys + .mockResolvedValueOnce(createKeyPage(codexKey)) + .mockResolvedValue(createKeyPage(resetKey)) endpointMocks.getProvider.mockResolvedValue(createProvider('codex')) const root = mountPoolManagement() @@ -888,6 +925,100 @@ describe('PoolManagement Codex cycle stats mode', () => { expect(endpointMocks.resetProviderKeyCycleStats).toHaveBeenCalledWith(codexKey.key_id) expect(endpointMocks.listPoolKeys).toHaveBeenCalledTimes(2) + expect(root.querySelector('[data-testid="pool-stats-cycle-request_count"]')?.textContent?.trim()).toBe('-/0') + }) + + it('toggles a pool account and silently revalidates the current key page', async () => { + const inactiveKey = createPoolKey('codex', { + is_active: false, + cooldown_reason: 'rate_limited_429', + cooldown_ttl_seconds: 60, + scheduling_status: 'blocked', + scheduling_reason: 'inactive', + scheduling_label: '已禁用', + scheduling_reasons: [{ + code: 'inactive', + label: '已禁用', + blocking: true, + source: 'manual', + }], + }) + const enabledKey = createPoolKey('codex', { + is_active: true, + cooldown_reason: 'rate_limited_429', + cooldown_ttl_seconds: 60, + scheduling_status: 'degraded', + scheduling_reason: 'cooldown', + scheduling_label: '冷却中', + scheduling_reasons: [{ + code: 'cooldown', + label: '冷却中', + blocking: true, + source: 'pool', + ttl_seconds: 60, + detail: 'rate_limited_429', + }], + }) + endpointMocks.getPoolOverview.mockResolvedValue({ + items: [{ ...createOverview('codex'), active_keys: 0 }], + }) + endpointMocks.listPoolKeys + .mockResolvedValueOnce(createKeyPage(inactiveKey)) + .mockResolvedValue(createKeyPage(enabledKey)) + endpointMocks.getProvider.mockResolvedValue(createProvider('codex', { + total_keys: 1, + active_keys: 0, + })) + endpointMocks.updateProviderKey.mockResolvedValue({ ...inactiveKey, is_active: true }) + + const root = mountPoolManagement() + await settle() + + const toggleButton = root.querySelector( + `[data-testid="pool-toggle-active-desktop-${inactiveKey.key_id}"]`, + ) + expect(toggleButton).not.toBeNull() + expect(toggleButton?.getAttribute('aria-label')).toBe('启用账号') + const listCallsBeforeToggle = endpointMocks.listPoolKeys.mock.calls.length + + toggleButton?.click() + await settle() + + expect(endpointMocks.updateProviderKey).toHaveBeenCalledWith(inactiveKey.key_id, { is_active: true }) + expect(toggleButton?.getAttribute('aria-label')).toBe('禁用账号') + expect(endpointMocks.listPoolKeys).toHaveBeenCalledTimes(listCallsBeforeToggle + 1) + expect(root.textContent).toContain('冷却中') + }) + + it('revalidates the pool page when OAuth refresh fails after server-side invalidation', async () => { + const oauthKey = createPoolKey('codex', { + auth_type: 'oauth', + oauth_managed: true, + can_refresh_oauth: true, + status_snapshot: { + ...createPoolKey('codex').status_snapshot, + oauth: { code: 'expired', expires_at: 1 }, + }, + }) + endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] }) + endpointMocks.listPoolKeys + .mockResolvedValueOnce(createKeyPage(oauthKey)) + .mockResolvedValue({ total: 0, page: 1, page_size: 50, keys: [] }) + endpointMocks.getProvider.mockResolvedValue(createProvider('codex')) + endpointMocks.refreshProviderOAuth.mockRejectedValue(new Error('OAuth invalid')) + + const root = mountPoolManagement() + await settle() + + const refreshButton = Array.from(root.querySelectorAll('button')) + .find(button => button.title === '重新授权') + expect(refreshButton).not.toBeUndefined() + refreshButton?.click() + await settle() + + expect(endpointMocks.refreshProviderOAuth).toHaveBeenCalledWith(oauthKey.key_id) + expect(endpointMocks.listPoolKeys).toHaveBeenCalledTimes(2) + expect(root.textContent).not.toContain(oauthKey.key_name) }) it('hides the stats mode switch for non-Codex providers and keeps account totals', async () => {