mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
fix(frontend): synchronize pool account state
This commit is contained in:
@@ -39,6 +39,7 @@ export interface QuotaWindowSnapshot {
|
|||||||
reset_at?: number | null
|
reset_at?: number | null
|
||||||
reset_seconds?: number | null
|
reset_seconds?: number | null
|
||||||
window_minutes?: number | null
|
window_minutes?: number | null
|
||||||
|
usage_reset_at?: number | null
|
||||||
is_exhausted?: boolean | null
|
is_exhausted?: boolean | null
|
||||||
usage?: QuotaWindowUsageSnapshot | null
|
usage?: QuotaWindowUsageSnapshot | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<string, NonNullable<RefreshQuotaResult['results'][number]['quota_snapshot']>>()
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1091,6 +1091,14 @@ let mappingPreviewLoadRequestId = 0
|
|||||||
const DEFAULT_PROVIDER_KEYS_PAGE_SIZE = 3
|
const DEFAULT_PROVIDER_KEYS_PAGE_SIZE = 3
|
||||||
const CUSTOM_PROVIDER_KEYS_PAGE_SIZE = 4
|
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 {
|
function getProviderKeysPageSize(providerType?: string | null): number {
|
||||||
return (providerType || '').trim().toLowerCase() === 'custom'
|
return (providerType || '').trim().toLowerCase() === 'custom'
|
||||||
? CUSTOM_PROVIDER_KEYS_PAGE_SIZE
|
? CUSTOM_PROVIDER_KEYS_PAGE_SIZE
|
||||||
@@ -1386,7 +1394,7 @@ async function toggleFormatConversion() {
|
|||||||
const newValue = !provider.value.enable_format_conversion
|
const newValue = !provider.value.enable_format_conversion
|
||||||
try {
|
try {
|
||||||
const updated = await updateProvider(provider.value.id, { enable_format_conversion: newValue })
|
const updated = await updateProvider(provider.value.id, { enable_format_conversion: newValue })
|
||||||
provider.value = updated
|
applyProviderSnapshot(updated)
|
||||||
showSuccess(legacyT(newValue ? '已启用格式转换' : '已禁用格式转换'))
|
showSuccess(legacyT(newValue ? '已启用格式转换' : '已禁用格式转换'))
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1408,7 +1416,7 @@ async function setProviderProxy(nodeId: string) {
|
|||||||
const updated = await updateProvider(provider.value.id, {
|
const updated = await updateProvider(provider.value.id, {
|
||||||
proxy: { node_id: nodeId, enabled: true },
|
proxy: { node_id: nodeId, enabled: true },
|
||||||
})
|
})
|
||||||
provider.value = updated
|
applyProviderSnapshot(updated)
|
||||||
providerProxyPopoverOpen.value = false
|
providerProxyPopoverOpen.value = false
|
||||||
showSuccess(legacyT('代理节点已设置'))
|
showSuccess(legacyT('代理节点已设置'))
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
@@ -1424,7 +1432,7 @@ async function clearProviderProxy() {
|
|||||||
savingProviderProxy.value = true
|
savingProviderProxy.value = true
|
||||||
try {
|
try {
|
||||||
const updated = await updateProvider(provider.value.id, { proxy: null })
|
const updated = await updateProvider(provider.value.id, { proxy: null })
|
||||||
provider.value = updated
|
applyProviderSnapshot(updated)
|
||||||
providerProxyPopoverOpen.value = false
|
providerProxyPopoverOpen.value = false
|
||||||
showSuccess(legacyT('已清除提供商代理'))
|
showSuccess(legacyT('已清除提供商代理'))
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
@@ -1447,7 +1455,7 @@ function handleEditEndpoint(_endpoint: ProviderEndpoint) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleEndpointChanged() {
|
async function handleEndpointChanged() {
|
||||||
await Promise.all([loadProvider(), loadEndpoints()])
|
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1562,7 +1570,7 @@ async function confirmDeleteKey() {
|
|||||||
await deleteEndpointKey(keyId)
|
await deleteEndpointKey(keyId)
|
||||||
showSuccess(legacyT('密钥已删除'))
|
showSuccess(legacyT('密钥已删除'))
|
||||||
// 刷新端点列表及模型数据(删除 Key 触发自动解除模型关联)
|
// 刷新端点列表及模型数据(删除 Key 触发自动解除模型关联)
|
||||||
await loadEndpoints()
|
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showError(localizedApiError(err, '删除密钥失败'), legacyT('错误'))
|
showError(localizedApiError(err, '删除密钥失败'), legacyT('错误'))
|
||||||
@@ -1573,7 +1581,7 @@ async function handleRecoverKey(key: EndpointAPIKey) {
|
|||||||
try {
|
try {
|
||||||
const result = await recoverKeyHealth(key.id)
|
const result = await recoverKeyHealth(key.id)
|
||||||
showSuccess(legacyT(result.message || 'Key已完全恢复'))
|
showSuccess(legacyT(result.message || 'Key已完全恢复'))
|
||||||
await loadEndpoints()
|
await Promise.all([loadProvider(), loadEndpoints()])
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showError(localizedApiError(err, 'Key恢复失败'), legacyT('错误'))
|
showError(localizedApiError(err, 'Key恢复失败'), legacyT('错误'))
|
||||||
@@ -1625,11 +1633,15 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
|
|||||||
} else {
|
} else {
|
||||||
showSuccess(legacyT(feedback.message))
|
showSuccess(legacyT(feedback.message))
|
||||||
}
|
}
|
||||||
// Antigravity:token 刷新后可能完成了账号激活,触发配额获取
|
emit('refresh')
|
||||||
// (不 emit('refresh'),避免触发全局 provider 余额刷新)
|
// Token 刷新可能激活账号并更新配额,完成后再同步一次父列表。
|
||||||
void autoRefreshQuotaInBackground()
|
void autoRefreshQuotaInBackground().then((changed) => {
|
||||||
|
if (changed) emit('refresh')
|
||||||
|
})
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showError(localizedApiError(err, 'Token 刷新失败'), legacyT('错误'))
|
showError(localizedApiError(err, 'Token 刷新失败'), legacyT('错误'))
|
||||||
|
await Promise.all([loadProvider(), loadEndpoints()])
|
||||||
|
emit('refresh')
|
||||||
} finally {
|
} finally {
|
||||||
refreshingOAuthKeyId.value = null
|
refreshingOAuthKeyId.value = null
|
||||||
}
|
}
|
||||||
@@ -1692,9 +1704,12 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await loadEndpoints()
|
await Promise.all([loadProvider(), loadEndpoints()])
|
||||||
|
emit('refresh')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showError(localizedApiError(err, '清除失败'), legacyT('错误'))
|
showError(localizedApiError(err, '清除失败'), legacyT('错误'))
|
||||||
|
await Promise.all([loadProvider(), loadEndpoints()])
|
||||||
|
emit('refresh')
|
||||||
} finally {
|
} finally {
|
||||||
clearingOAuthInvalidKeyId.value = null
|
clearingOAuthInvalidKeyId.value = null
|
||||||
}
|
}
|
||||||
@@ -1755,8 +1770,11 @@ async function handleConsumeCodexResetCredit(key: EndpointAPIKey) {
|
|||||||
if (result.refresh_status === 'failed') {
|
if (result.refresh_status === 'failed') {
|
||||||
showWarning(legacyT(result.refresh_error || '重置请求已处理,但最新配额刷新失败'))
|
showWarning(legacyT(result.refresh_error || '重置请求已处理,但最新配额刷新失败'))
|
||||||
}
|
}
|
||||||
|
emit('refresh')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showError(localizedApiError(err, 'Codex 重置机会使用失败'), legacyT('错误'))
|
showError(localizedApiError(err, 'Codex 重置机会使用失败'), legacyT('错误'))
|
||||||
|
await Promise.all([loadProvider(), loadEndpoints()])
|
||||||
|
emit('refresh')
|
||||||
} finally {
|
} finally {
|
||||||
consumingCodexResetCreditKeyId.value = null
|
consumingCodexResetCreditKeyId.value = null
|
||||||
}
|
}
|
||||||
@@ -2707,13 +2725,13 @@ function applyQuotaResults(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 通用的自动刷新配额函数(支持 Codex、Gemini CLI、Antigravity、Kiro、Windsurf 和 ChatGPT Web)
|
// 通用的自动刷新配额函数(支持 Codex、Gemini CLI、Antigravity、Kiro、Windsurf 和 ChatGPT Web)
|
||||||
async function autoRefreshQuotaInBackground() {
|
async function autoRefreshQuotaInBackground(): Promise<boolean> {
|
||||||
const providerId = props.providerId
|
const providerId = props.providerId
|
||||||
if (!providerId) return
|
if (!providerId) return false
|
||||||
if (refreshingQuota.value) return
|
if (refreshingQuota.value) return false
|
||||||
|
|
||||||
const providerType = provider.value?.provider_type
|
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
|
let shouldRefresh = false
|
||||||
@@ -2732,7 +2750,7 @@ async function autoRefreshQuotaInBackground() {
|
|||||||
} else if (providerType === 'chatgpt_web') {
|
} else if (providerType === 'chatgpt_web') {
|
||||||
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
|
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
|
||||||
}
|
}
|
||||||
if (!shouldRefresh) return
|
if (!shouldRefresh) return false
|
||||||
|
|
||||||
let hadCachedQuota = false
|
let hadCachedQuota = false
|
||||||
if (providerType === 'codex') {
|
if (providerType === 'codex') {
|
||||||
@@ -2758,10 +2776,12 @@ async function autoRefreshQuotaInBackground() {
|
|||||||
if (result.success <= 0 && applied === 0 && !hadCachedQuota && providerType === 'antigravity') {
|
if (result.success <= 0 && applied === 0 && !hadCachedQuota && providerType === 'antigravity') {
|
||||||
showError(legacyT('没有获取到配额信息(请检查账号是否已授权、project_id 是否存在)'), legacyT('提示'))
|
showError(legacyT('没有获取到配额信息(请检查账号是否已授权、project_id 是否存在)'), legacyT('提示'))
|
||||||
}
|
}
|
||||||
|
return applied > 0
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (!hadCachedQuota && providerType === 'antigravity') {
|
if (!hadCachedQuota && providerType === 'antigravity') {
|
||||||
showError(localizedApiError(err, '后台刷新配额失败'), legacyT('错误'))
|
showError(localizedApiError(err, '后台刷新配额失败'), legacyT('错误'))
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
refreshingQuota.value = false
|
refreshingQuota.value = false
|
||||||
}
|
}
|
||||||
@@ -2792,10 +2812,12 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleKeyChanged() {
|
async function handleKeyChanged() {
|
||||||
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
// 添加/修改 key 后自动获取已支持 provider 的配额(新 key 的 upstream_metadata 为空)
|
// 添加/修改 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
|
togglingKeyId.value = key.id
|
||||||
try {
|
try {
|
||||||
const newStatus = !key.is_active
|
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
|
key.is_active = newStatus
|
||||||
|
await Promise.all([loadProvider(), loadEndpoints()])
|
||||||
showSuccess(legacyT(newStatus ? '密钥已启用' : '密钥已停用'))
|
showSuccess(legacyT(newStatus ? '密钥已启用' : '密钥已停用'))
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -2885,20 +2909,20 @@ function handleBatchAssignDialogOpenUpdate(value: boolean) {
|
|||||||
|
|
||||||
// 处理批量关联完成
|
// 处理批量关联完成
|
||||||
async function handleBatchAssignChanged() {
|
async function handleBatchAssignChanged() {
|
||||||
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理模型映射变更
|
// 处理模型映射变更
|
||||||
async function handleModelMappingChanged() {
|
async function handleModelMappingChanged() {
|
||||||
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理模型保存完成
|
// 处理模型保存完成
|
||||||
async function handleModelSaved() {
|
async function handleModelSaved() {
|
||||||
editingModel.value = null
|
editingModel.value = null
|
||||||
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3738,7 +3762,7 @@ async function loadProvider() {
|
|||||||
void loadSystemFormatConversionConfig()
|
void loadSystemFormatConversionConfig()
|
||||||
const providerData = await getProvider(props.providerId)
|
const providerData = await getProvider(props.providerId)
|
||||||
if (requestId !== providerLoadRequestId) return
|
if (requestId !== providerLoadRequestId) return
|
||||||
provider.value = providerData
|
applyProviderSnapshot(providerData)
|
||||||
keyPageSize.value = getProviderKeysPageSize(providerData.provider_type)
|
keyPageSize.value = getProviderKeysPageSize(providerData.provider_type)
|
||||||
|
|
||||||
if (!provider.value) {
|
if (!provider.value) {
|
||||||
|
|||||||
@@ -91,6 +91,8 @@
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
:title="legacyT(provider.is_active ? '点击停用' : '点击启用')"
|
:title="legacyT(provider.is_active ? '点击停用' : '点击启用')"
|
||||||
|
:aria-label="legacyT(provider.is_active ? '点击停用' : '点击启用')"
|
||||||
|
data-testid="provider-toggle-active"
|
||||||
@click="$emit('toggleStatus', provider)"
|
@click="$emit('toggleStatus', provider)"
|
||||||
>
|
>
|
||||||
<Power class="w-4 h-4" />
|
<Power class="w-4 h-4" />
|
||||||
|
|||||||
@@ -126,6 +126,8 @@
|
|||||||
class="h-7 w-7"
|
class="h-7 w-7"
|
||||||
:disabled="toggling"
|
:disabled="toggling"
|
||||||
:title="legacyT(apiKey.is_active ? '点击停用' : '点击启用')"
|
:title="legacyT(apiKey.is_active ? '点击停用' : '点击启用')"
|
||||||
|
:aria-label="legacyT(apiKey.is_active ? '点击停用' : '点击启用')"
|
||||||
|
data-testid="provider-key-toggle-active"
|
||||||
@click="$emit('toggleActive')"
|
@click="$emit('toggleActive')"
|
||||||
>
|
>
|
||||||
<Power class="w-3.5 h-3.5" />
|
<Power class="w-3.5 h-3.5" />
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ describe('ProviderKeyActionCluster', () => {
|
|||||||
expect(root.querySelector('button[title="Recover key"]')).toBeTruthy()
|
expect(root.querySelector('button[title="Recover key"]')).toBeTruthy()
|
||||||
expect(root.querySelector('button[title="代理: Tokyo"]')).toBeTruthy()
|
expect(root.querySelector('button[title="代理: Tokyo"]')).toBeTruthy()
|
||||||
expect(root.querySelector('button[title="配额详情"]')).toBeTruthy()
|
expect(root.querySelector('button[title="配额详情"]')).toBeTruthy()
|
||||||
|
expect(root.querySelector('[data-testid="provider-key-toggle-active"]')?.getAttribute('aria-label')).toBe('点击停用')
|
||||||
|
|
||||||
unmount()
|
unmount()
|
||||||
})
|
})
|
||||||
@@ -179,4 +180,22 @@ describe('ProviderKeyActionCluster', () => {
|
|||||||
|
|
||||||
unmount()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -516,6 +516,8 @@
|
|||||||
class="h-7 w-7 text-foreground hover:text-foreground"
|
class="h-7 w-7 text-foreground hover:text-foreground"
|
||||||
:disabled="togglingKeyId === key.key_id"
|
:disabled="togglingKeyId === key.key_id"
|
||||||
:title="key.is_active ? '禁用' : '启用'"
|
:title="key.is_active ? '禁用' : '启用'"
|
||||||
|
:aria-label="key.is_active ? '禁用账号' : '启用账号'"
|
||||||
|
:data-testid="`pool-toggle-active-desktop-${key.key_id}`"
|
||||||
@click="toggleKeyActive(key)"
|
@click="toggleKeyActive(key)"
|
||||||
>
|
>
|
||||||
<Power class="w-3.5 h-3.5" />
|
<Power class="w-3.5 h-3.5" />
|
||||||
@@ -847,6 +849,8 @@
|
|||||||
class="h-7 w-7 shrink-0 text-foreground hover:text-foreground"
|
class="h-7 w-7 shrink-0 text-foreground hover:text-foreground"
|
||||||
:disabled="togglingKeyId === key.key_id"
|
:disabled="togglingKeyId === key.key_id"
|
||||||
:title="key.is_active ? '禁用' : '启用'"
|
:title="key.is_active ? '禁用' : '启用'"
|
||||||
|
:aria-label="key.is_active ? '禁用账号' : '启用账号'"
|
||||||
|
:data-testid="`pool-toggle-active-mobile-${key.key_id}`"
|
||||||
@click="toggleKeyActive(key)"
|
@click="toggleKeyActive(key)"
|
||||||
>
|
>
|
||||||
<Power class="w-3.5 h-3.5" />
|
<Power class="w-3.5 h-3.5" />
|
||||||
@@ -1124,6 +1128,8 @@ import {
|
|||||||
type PoolStatsDisplay,
|
type PoolStatsDisplay,
|
||||||
type PoolStatsMetric,
|
type PoolStatsMetric,
|
||||||
} from '@/features/pool/utils/poolStatsDisplay'
|
} 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 { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
|
||||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||||
import { formatOAuthPlanType, getOAuthPlanTypeClass } from '@/utils/oauthPlanType'
|
import { formatOAuthPlanType, getOAuthPlanTypeClass } from '@/utils/oauthPlanType'
|
||||||
@@ -1312,9 +1318,11 @@ async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } =
|
|||||||
if (requestId !== overviewRequestId) return
|
if (requestId !== overviewRequestId) return
|
||||||
if (!options.silent) {
|
if (!options.silent) {
|
||||||
showError(parseApiError(err))
|
showError(parseApiError(err))
|
||||||
|
} else {
|
||||||
|
showWarning(parseApiError(err, '同步 Provider 概览失败'))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === overviewRequestId && !options.silent) {
|
if (requestId === overviewRequestId) {
|
||||||
overviewLoading.value = false
|
overviewLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1323,11 +1331,15 @@ async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } =
|
|||||||
async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSummary) {
|
async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSummary) {
|
||||||
// 优先回写保存接口返回值,避免弹窗立即重开时读到旧配置。
|
// 优先回写保存接口返回值,避免弹窗立即重开时读到旧配置。
|
||||||
if (selectedProviderId.value && updatedProvider.id === selectedProviderId.value) {
|
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
|
showSchedulingDialog.value = false
|
||||||
showAdvancedDialog.value = false
|
showAdvancedDialog.value = false
|
||||||
await loadOverview()
|
await loadOverview({ silent: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Provider Selection ---
|
// --- Provider Selection ---
|
||||||
@@ -1699,15 +1711,23 @@ async function selectProvider(
|
|||||||
if (requestId !== selectProviderRequestId) return
|
if (requestId !== selectProviderRequestId) return
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProviderData(id: string) {
|
async function loadProviderData(id: string, options: { preserveOnError?: boolean } = {}) {
|
||||||
const requestId = ++providerDataRequestId
|
const requestId = ++providerDataRequestId
|
||||||
try {
|
try {
|
||||||
const providerData = await getProvider(id)
|
const providerData = await getProvider(id)
|
||||||
if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return
|
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 {
|
} catch {
|
||||||
if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return
|
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 {
|
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<ReturnType<typeof refreshProviderQuota>>): void {
|
function applyQuotaRefreshResultToCurrentPage(result: Awaited<ReturnType<typeof refreshProviderQuota>>): void {
|
||||||
const quotaByKeyId = new Map<string, NonNullable<NonNullable<typeof result.results>[number]['quota_snapshot']>>()
|
keyPage.value.keys = mergePoolKeyQuotaSnapshots(keyPage.value.keys, result.results)
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeQuotaUpdatedAt(raw: number | null | undefined): number | null {
|
function normalizeQuotaUpdatedAt(raw: number | null | undefined): number | null {
|
||||||
@@ -2139,7 +2205,7 @@ const currentPageQuotaRefreshStats = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function refreshCurrentPageQuotaInBackground(
|
async function refreshCurrentPageQuotaInBackground(
|
||||||
options: { silent?: boolean; reloadAfter?: boolean } = {},
|
options: { silent?: boolean; reloadAfter?: boolean | 'silent' } = {},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!selectedProviderId.value || !quotaRefreshSupported.value) return false
|
if (!selectedProviderId.value || !quotaRefreshSupported.value) return false
|
||||||
|
|
||||||
@@ -2169,7 +2235,7 @@ async function refreshCurrentPageQuotaInBackground(
|
|||||||
|
|
||||||
// 刷新当前页数据,展示最新额度与状态
|
// 刷新当前页数据,展示最新额度与状态
|
||||||
if (selectedProviderId.value === providerId && options.reloadAfter !== false) {
|
if (selectedProviderId.value === providerId && options.reloadAfter !== false) {
|
||||||
await loadKeys()
|
await loadKeys({ silent: options.reloadAfter === 'silent' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.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
|
if (!selectedProviderId.value) return
|
||||||
const requestId = ++keysRequestId
|
const requestId = ++keysRequestId
|
||||||
const providerId = selectedProviderId.value
|
const providerId = selectedProviderId.value
|
||||||
@@ -2225,7 +2291,9 @@ async function loadKeys(options: { cacheTtlMs?: number } = {}) {
|
|||||||
const search = searchQuery.value || undefined
|
const search = searchQuery.value || undefined
|
||||||
const status = statusFilter.value
|
const status = statusFilter.value
|
||||||
const sortByValue = sortBy.value || undefined
|
const sortByValue = sortBy.value || undefined
|
||||||
keysLoading.value = true
|
if (!options.silent) {
|
||||||
|
keysLoading.value = true
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const nextPage = await listPoolKeys(providerId, {
|
const nextPage = await listPoolKeys(providerId, {
|
||||||
page,
|
page,
|
||||||
@@ -2251,9 +2319,13 @@ async function loadKeys(options: { cacheTtlMs?: number } = {}) {
|
|||||||
keysLoadedOnce.value = true
|
keysLoadedOnce.value = true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
|
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
|
||||||
resetKeyPage(page, pageSizeValue)
|
if (!options.silent) {
|
||||||
keysLoadedOnce.value = true
|
resetKeyPage(page, pageSizeValue)
|
||||||
showError(parseApiError(err))
|
keysLoadedOnce.value = true
|
||||||
|
showError(parseApiError(err))
|
||||||
|
} else {
|
||||||
|
showWarning(parseApiError(err, '同步账号列表失败'))
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === keysRequestId) {
|
if (requestId === keysRequestId) {
|
||||||
keysLoading.value = false
|
keysLoading.value = false
|
||||||
@@ -2465,12 +2537,12 @@ function closeKeyBatchEditDialog(): void {
|
|||||||
|
|
||||||
async function handleKeyBatchEditSaved(): Promise<void> {
|
async function handleKeyBatchEditSaved(): Promise<void> {
|
||||||
resetPoolKeySelection(true)
|
resetPoolKeySelection(true)
|
||||||
await Promise.all([loadKeys(), loadOverview()])
|
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDialogSaved() {
|
async function handleDialogSaved() {
|
||||||
editingKeyDetail.value = null
|
editingKeyDetail.value = null
|
||||||
await loadKeys()
|
await loadKeys({ silent: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeKeyFormDialog() {
|
function closeKeyFormDialog() {
|
||||||
@@ -2647,7 +2719,7 @@ async function handleRefreshOAuth(key: PoolKeyDetail) {
|
|||||||
if (target) {
|
if (target) {
|
||||||
target.oauth_expires_at = refreshedExpiresAt
|
target.oauth_expires_at = refreshedExpiresAt
|
||||||
}
|
}
|
||||||
await loadKeys()
|
await loadKeys({ silent: true })
|
||||||
if (refreshedExpiresAt != null) {
|
if (refreshedExpiresAt != null) {
|
||||||
const reloadedTarget = keyPage.value.keys.find(k => k.key_id === key.key_id)
|
const reloadedTarget = keyPage.value.keys.find(k => k.key_id === key.key_id)
|
||||||
if (
|
if (
|
||||||
@@ -2671,7 +2743,7 @@ async function handleRefreshOAuth(key: PoolKeyDetail) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(parseApiError(err, 'Token 刷新失败'))
|
showError(parseApiError(err, 'Token 刷新失败'))
|
||||||
await loadKeys()
|
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
|
||||||
} finally {
|
} finally {
|
||||||
refreshingOAuthKeyId.value = null
|
refreshingOAuthKeyId.value = null
|
||||||
}
|
}
|
||||||
@@ -2683,8 +2755,20 @@ async function clearCooldown(keyId: string) {
|
|||||||
try {
|
try {
|
||||||
const res = await clearPoolCooldown(selectedProviderId.value, keyId)
|
const res = await clearPoolCooldown(selectedProviderId.value, keyId)
|
||||||
success(res.message)
|
success(res.message)
|
||||||
await loadKeys()
|
const key = keyPage.value.keys.find(item => item.key_id === keyId)
|
||||||
refreshOverviewInBackground()
|
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) {
|
} catch (err) {
|
||||||
showError(parseApiError(err))
|
showError(parseApiError(err))
|
||||||
}
|
}
|
||||||
@@ -2704,7 +2788,17 @@ async function handleResetCycleStats(key: PoolKeyDetail) {
|
|||||||
try {
|
try {
|
||||||
const result = await resetProviderKeyCycleStats(key.key_id)
|
const result = await resetProviderKeyCycleStats(key.key_id)
|
||||||
success(result.message || '周期统计已重置')
|
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) {
|
} catch (err) {
|
||||||
showError(parseApiError(err, '重置周期统计失败'))
|
showError(parseApiError(err, '重置周期统计失败'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2718,21 +2812,9 @@ async function toggleKeyActive(key: PoolKeyDetail) {
|
|||||||
try {
|
try {
|
||||||
const nextStatus = !key.is_active
|
const nextStatus = !key.is_active
|
||||||
await updateProviderKey(key.key_id, { is_active: nextStatus })
|
await updateProviderKey(key.key_id, { is_active: nextStatus })
|
||||||
key.is_active = nextStatus
|
applyPoolKeyActiveState(key, nextStatus)
|
||||||
if (nextStatus) {
|
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
|
||||||
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'
|
|
||||||
}
|
|
||||||
success(nextStatus ? '账号已启用' : '账号已停用')
|
success(nextStatus ? '账号已启用' : '账号已停用')
|
||||||
await loadKeys()
|
|
||||||
refreshOverviewInBackground()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(parseApiError(err))
|
showError(parseApiError(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2775,9 +2857,9 @@ async function handleProviderDrawerRefresh(): Promise<void> {
|
|||||||
if (!providerId) return
|
if (!providerId) return
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadKeys(),
|
loadKeys({ silent: true }),
|
||||||
loadOverview({ silent: true }),
|
loadOverview({ silent: true }),
|
||||||
loadProviderData(providerId),
|
loadProviderData(providerId, { preserveOnError: true }),
|
||||||
])
|
])
|
||||||
resetPoolKeySelection(true)
|
resetPoolKeySelection(true)
|
||||||
}
|
}
|
||||||
@@ -2789,8 +2871,13 @@ async function openProviderEditDialog(provider?: ProviderWithEndpointsSummary):
|
|||||||
try {
|
try {
|
||||||
const latest = await getProvider(providerId)
|
const latest = await getProvider(providerId)
|
||||||
if (selectedProviderId.value !== providerId) return
|
if (selectedProviderId.value !== providerId) return
|
||||||
selectedProviderData.value = latest
|
if (selectedProviderData.value?.id === latest.id) {
|
||||||
providerToEdit.value = latest
|
Object.assign(selectedProviderData.value, latest)
|
||||||
|
providerToEdit.value = selectedProviderData.value
|
||||||
|
} else {
|
||||||
|
selectedProviderData.value = latest
|
||||||
|
providerToEdit.value = latest
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (selectedProviderId.value !== providerId) return
|
if (selectedProviderId.value !== providerId) return
|
||||||
const fallbackProvider = provider ?? selectedProviderData.value
|
const fallbackProvider = provider ?? selectedProviderData.value
|
||||||
@@ -2806,11 +2893,16 @@ async function openProviderEditDialog(provider?: ProviderWithEndpointsSummary):
|
|||||||
|
|
||||||
async function handleProviderEditSaved(updatedProvider: ProviderWithEndpointsSummary): Promise<void> {
|
async function handleProviderEditSaved(updatedProvider: ProviderWithEndpointsSummary): Promise<void> {
|
||||||
if (selectedProviderId.value === updatedProvider.id) {
|
if (selectedProviderId.value === updatedProvider.id) {
|
||||||
selectedProviderData.value = updatedProvider
|
if (selectedProviderData.value) {
|
||||||
providerToEdit.value = updatedProvider
|
Object.assign(selectedProviderData.value, updatedProvider)
|
||||||
|
providerToEdit.value = selectedProviderData.value
|
||||||
|
} else {
|
||||||
|
selectedProviderData.value = updatedProvider
|
||||||
|
providerToEdit.value = updatedProvider
|
||||||
|
}
|
||||||
}
|
}
|
||||||
providerEditDialogOpen.value = false
|
providerEditDialogOpen.value = false
|
||||||
await loadOverview()
|
await loadOverview({ silent: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleSelectedProviderStatus(provider?: ProviderWithEndpointsSummary): Promise<void> {
|
async function toggleSelectedProviderStatus(provider?: ProviderWithEndpointsSummary): Promise<void> {
|
||||||
@@ -2834,11 +2926,15 @@ async function toggleSelectedProviderStatus(provider?: ProviderWithEndpointsSumm
|
|||||||
try {
|
try {
|
||||||
const updated = await updateProvider(providerId, { is_active: nextStatus })
|
const updated = await updateProvider(providerId, { is_active: nextStatus })
|
||||||
Object.assign(current, updated)
|
Object.assign(current, updated)
|
||||||
if (selectedProviderId.value === providerId) {
|
if (selectedProviderId.value === providerId && selectedProviderData.value !== current) {
|
||||||
selectedProviderData.value = updated
|
if (selectedProviderData.value) {
|
||||||
|
Object.assign(selectedProviderData.value, updated)
|
||||||
|
} else {
|
||||||
|
selectedProviderData.value = updated
|
||||||
|
}
|
||||||
}
|
}
|
||||||
success(nextStatus ? '提供商已启用' : '提供商已禁用')
|
success(nextStatus ? '提供商已启用' : '提供商已禁用')
|
||||||
await loadOverview()
|
await loadOverview({ silent: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(parseApiError(err, nextStatus ? '启用提供商失败' : '禁用提供商失败'))
|
showError(parseApiError(err, nextStatus ? '启用提供商失败' : '禁用提供商失败'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2848,14 +2944,14 @@ async function toggleSelectedProviderStatus(provider?: ProviderWithEndpointsSumm
|
|||||||
|
|
||||||
async function handleAccountBatchChanged(): Promise<void> {
|
async function handleAccountBatchChanged(): Promise<void> {
|
||||||
resetPoolKeySelection(true)
|
resetPoolKeySelection(true)
|
||||||
await Promise.all([loadKeys(), loadOverview()])
|
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAccountDialogSaved() {
|
async function handleAccountDialogSaved() {
|
||||||
showImportDialog.value = false
|
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 ---
|
// --- Formatting ---
|
||||||
|
|||||||
@@ -568,7 +568,13 @@ async function loadProviders(options: { cacheTtlMs?: number } = {}) {
|
|||||||
cacheTtlMs: options.cacheTtlMs ?? 0,
|
cacheTtlMs: options.cacheTtlMs ?? 0,
|
||||||
})
|
})
|
||||||
if (requestId !== providersRequestId) return
|
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
|
total.value = response.total
|
||||||
// 异步加载配置了 ops 的 provider 的余额数据
|
// 异步加载配置了 ops 的 provider 的余额数据
|
||||||
loadBalances(providers.value)
|
loadBalances(providers.value)
|
||||||
@@ -639,8 +645,8 @@ function openProviderDrawer(providerId: string) {
|
|||||||
function mergeUpdatedProvider(updated: ProviderWithEndpointsSummary) {
|
function mergeUpdatedProvider(updated: ProviderWithEndpointsSummary) {
|
||||||
const index = providers.value.findIndex(p => p.id === updated.id)
|
const index = providers.value.findIndex(p => p.id === updated.id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
providers.value[index] = updated
|
Object.assign(providers.value[index], updated)
|
||||||
loadBalances([updated], false)
|
loadBalances([providers.value[index]], false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -873,8 +873,45 @@ describe('PoolManagement Codex cycle stats mode', () => {
|
|||||||
|
|
||||||
it('resets Codex cycle stats from the action column', async () => {
|
it('resets Codex cycle stats from the action column', async () => {
|
||||||
const codexKey = createPoolKey('codex')
|
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.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
|
||||||
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey))
|
endpointMocks.listPoolKeys
|
||||||
|
.mockResolvedValueOnce(createKeyPage(codexKey))
|
||||||
|
.mockResolvedValue(createKeyPage(resetKey))
|
||||||
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
|
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
|
||||||
|
|
||||||
const root = mountPoolManagement()
|
const root = mountPoolManagement()
|
||||||
@@ -888,6 +925,100 @@ describe('PoolManagement Codex cycle stats mode', () => {
|
|||||||
|
|
||||||
expect(endpointMocks.resetProviderKeyCycleStats).toHaveBeenCalledWith(codexKey.key_id)
|
expect(endpointMocks.resetProviderKeyCycleStats).toHaveBeenCalledWith(codexKey.key_id)
|
||||||
expect(endpointMocks.listPoolKeys).toHaveBeenCalledTimes(2)
|
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<HTMLButtonElement>(
|
||||||
|
`[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<HTMLButtonElement>('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 () => {
|
it('hides the stats mode switch for non-Codex providers and keeps account totals', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user