feat(pool,ui,trace): 号池按页配额刷新、配额倒计时、Trace 全量候选与 UI 用语统一

- 号池管理支持按当前页 Key 刷新配额,后端 refresh-quota 接口支持 key_ids 参数筛选
- 配额进度条 tooltip 展示重置倒计时,前端解析后端重置时间并实时倒计时
- Provider 选择器在无号池提供商时禁用,切换/刷新后保持选中状态对齐
- 启停账号后立即更新调度标签并刷新列表
- Trace 监控展示全量候选记录,不再过滤 available/unused 状态
- 请求时间线号池节点与 Provider 节点去重
- 全局 UI 用语统一:已停用/已禁用 -> 停用/禁用
- 导航菜单调整模型管理与号池管理顺序
This commit is contained in:
fawney19
2026-03-03 11:32:41 +08:00
parent 022aec5720
commit 7ebce161e8
24 changed files with 359 additions and 75 deletions

View File

@@ -197,8 +197,12 @@ export interface RefreshQuotaResult {
}> }>
} }
export async function refreshProviderQuota(providerId: string): Promise<RefreshQuotaResult> { export async function refreshProviderQuota(
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/refresh-quota`) providerId: string,
keyIds?: string[],
): Promise<RefreshQuotaResult> {
const body = keyIds && keyIds.length > 0 ? { key_ids: keyIds } : undefined
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/refresh-quota`, body)
return response.data return response.data
} }

View File

@@ -1102,7 +1102,7 @@ function getKeyTooltip(key: RoutingKeyInfo): string {
parts.push(`名称: ${key.name}`) parts.push(`名称: ${key.name}`)
parts.push(`健康度: ${((key.health_score || 0) * 100).toFixed(0)}%`) parts.push(`健康度: ${((key.health_score || 0) * 100).toFixed(0)}%`)
if (!key.is_active) { if (!key.is_active) {
parts.push('状态: 禁用') parts.push('状态: 禁用')
} else if (key.circuit_breaker_open) { } else if (key.circuit_breaker_open) {
parts.push(`熔断中: ${key.circuit_breaker_formats.join(', ')}`) parts.push(`熔断中: ${key.circuit_breaker_formats.join(', ')}`)
} }

View File

@@ -32,7 +32,7 @@
variant="secondary" variant="secondary"
class="text-xs" class="text-xs"
> >
停用 停用
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">

View File

@@ -323,7 +323,7 @@
: key.is_active : key.is_active
? 'text-foreground/70 hover:bg-muted hover:text-foreground' ? 'text-foreground/70 hover:bg-muted hover:text-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'" : 'text-muted-foreground hover:bg-muted hover:text-foreground'"
:title="!key.provider_active ? 'Provider 停用' : key.is_active ? '点击停用' : '点击启用'" :title="!key.provider_active ? 'Provider 停用' : key.is_active ? '点击停用' : '点击启用'"
:disabled="!key.provider_active" :disabled="!key.provider_active"
@click.stop="toggleKeyActive(format, key)" @click.stop="toggleKeyActive(format, key)"
> >

View File

@@ -35,7 +35,7 @@
:variant="provider.is_active ? 'default' : 'secondary'" :variant="provider.is_active ? 'default' : 'secondary'"
class="text-xs shrink-0" class="text-xs shrink-0"
> >
{{ provider.is_active ? '活跃' : '停用' }} {{ provider.is_active ? '活跃' : '停用' }}
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">

View File

@@ -133,7 +133,7 @@
:variant="provider.is_active ? 'success' : 'secondary'" :variant="provider.is_active ? 'success' : 'secondary'"
class="text-xs" class="text-xs"
> >
{{ provider.is_active ? '活跃' : '停用' }} {{ provider.is_active ? '活跃' : '停用' }}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell <TableCell

View File

@@ -372,7 +372,7 @@ function getVideoPricingTooltip(model: Model): string {
// 获取状态指示灯样式 // 获取状态指示灯样式
function getStatusIndicatorClass(model: Model): string { function getStatusIndicatorClass(model: Model): string {
if (!model.is_active) { if (!model.is_active) {
// 停用 - 灰色 // 停用 - 灰色
return 'bg-gray-400 dark:bg-gray-600' return 'bg-gray-400 dark:bg-gray-600'
} }
if (model.is_available) { if (model.is_available) {
@@ -386,7 +386,7 @@ function getStatusIndicatorClass(model: Model): string {
// 获取状态提示文本 // 获取状态提示文本
function getStatusTitle(model: Model): string { function getStatusTitle(model: Model): string {
if (!model.is_active) { if (!model.is_active) {
return '停用' return '停用'
} }
if (model.is_available) { if (model.is_available) {
return '活跃且可用' return '活跃且可用'

View File

@@ -75,7 +75,7 @@ export function getEndpointTooltip(endpoint: EndpointHealthDetail): string {
switch (status) { switch (status) {
case 'disabled': case 'disabled':
return `${format}: 端点禁用` return `${format}: 端点禁用`
case 'no_keys': case 'no_keys':
return `${format}: 未配置密钥` return `${format}: 未配置密钥`
case 'keys_disabled': case 'keys_disabled':

View File

@@ -19,7 +19,7 @@ export function useProviderFilters(
const statusFilters: FilterOption[] = [ const statusFilters: FilterOption[] = [
{ value: 'all', label: '全部状态' }, { value: 'all', label: '全部状态' },
{ value: 'active', label: '活跃' }, { value: 'active', label: '活跃' },
{ value: 'inactive', label: '停用' }, { value: 'inactive', label: '停用' },
] ]
const apiFormatFilters: FilterOption[] = [ const apiFormatFilters: FilterOption[] = [

View File

@@ -754,6 +754,11 @@ const getProviderDisplayName = (attempt: CandidateRecord | null | undefined): st
return providerName ? normalizeProviderName(providerName) : '未知' return providerName ? normalizeProviderName(providerName) : '未知'
} }
const normalizeProviderIdentity = (value: unknown): string => {
if (typeof value !== 'string') return ''
return normalizeProviderName(value).trim().toLowerCase()
}
const buildProviderGroups = (items: CandidateRecord[]): NodeGroup[] => { const buildProviderGroups = (items: CandidateRecord[]): NodeGroup[] => {
const groups: NodeGroup[] = [] const groups: NodeGroup[] = []
let currentGroup: NodeGroup | null = null let currentGroup: NodeGroup | null = null
@@ -829,7 +834,31 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
isPoolGroup: true, isPoolGroup: true,
} }
return [poolGroup, ...providerGroups] const poolProviderIds = new Set(
poolAttempts
.map(item => String(item.provider_id || '').trim())
.filter(Boolean),
)
const poolProviderNames = new Set(
poolAttempts
.map(item => normalizeProviderIdentity(item.provider_name))
.filter(Boolean),
)
const dedupedProviderGroups = providerGroups.filter((group) => {
const sameProviderById = group.allAttempts.some((attempt) => {
const providerId = String(attempt.provider_id || '').trim()
return providerId !== '' && poolProviderIds.has(providerId)
})
if (sameProviderById) return false
const groupName = normalizeProviderIdentity(group.primary.provider_name || group.providerName)
if (groupName && poolProviderNames.has(groupName)) return false
return true
})
return [poolGroup, ...dedupedProviderGroups]
}) })
// 格式转换分界点索引(首个 hasConversion=true 的 group index // 格式转换分界点索引(首个 hasConversion=true 的 group index

View File

@@ -556,8 +556,8 @@ const navigation = computed(() => {
items: [ items: [
{ name: '用户管理', href: '/admin/users', icon: Users }, { name: '用户管理', href: '/admin/users', icon: Users },
{ name: '提供商', href: '/admin/providers', icon: FolderTree }, { name: '提供商', href: '/admin/providers', icon: FolderTree },
{ name: '号池管理', href: '/admin/pool', icon: Database },
{ name: '模型管理', href: '/admin/models', icon: Layers }, { name: '模型管理', href: '/admin/models', icon: Layers },
{ name: '号池管理', href: '/admin/pool', icon: Database },
{ name: '独立密钥', href: '/admin/keys', icon: Key }, { name: '独立密钥', href: '/admin/keys', icon: Key },
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap }, { name: '异步任务', href: '/admin/async-tasks', icon: Zap },
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 }, { name: '使用记录', href: '/admin/usage', icon: BarChart3 },

View File

@@ -1229,7 +1229,7 @@ const batchManageShortcuts = computed(() => {
const defs: { label: string; description: string; filter: (m: GlobalModelResponse) => boolean }[] = [ const defs: { label: string; description: string; filter: (m: GlobalModelResponse) => boolean }[] = [
{ label: '无提供商', description: '没有关联任何提供商的模型', filter: m => (m.provider_count || 0) === 0 }, { label: '无提供商', description: '没有关联任何提供商的模型', filter: m => (m.provider_count || 0) === 0 },
{ label: '无活跃提供商', description: '有提供商但没有活跃提供商的模型', filter: m => (m.active_provider_count || 0) === 0 && (m.provider_count || 0) > 0 }, { label: '无活跃提供商', description: '有提供商但没有活跃提供商的模型', filter: m => (m.active_provider_count || 0) === 0 && (m.provider_count || 0) > 0 },
{ label: '禁用', description: '被禁用的模型', filter: m => !m.is_active }, { label: '禁用', description: '被禁用的模型', filter: m => !m.is_active },
{ label: '未调用', description: '没有调用记录的模型', filter: m => (m.usage_count || 0) === 0 }, { label: '未调用', description: '没有调用记录的模型', filter: m => (m.usage_count || 0) === 0 },
{ label: '无价格', description: '没有配置任何价格的模型', filter: m => hasNoPrice(m) }, { label: '无价格', description: '没有配置任何价格的模型', filter: m => hasNoPrice(m) },
] ]

View File

@@ -154,7 +154,7 @@
class="text-sm" class="text-sm"
:class="module.enabled ? 'text-foreground' : 'text-muted-foreground'" :class="module.enabled ? 'text-foreground' : 'text-muted-foreground'"
> >
{{ module.enabled ? '启用' : '禁用' }} {{ module.enabled ? '启用' : '禁用' }}
</span> </span>
<!-- 配置未验证提示(小字) --> <!-- 配置未验证提示(小字) -->
<span <span

View File

@@ -53,15 +53,21 @@
<Ban class="w-3.5 h-3.5" /> <Ban class="w-3.5 h-3.5" />
</Button> </Button>
<RefreshButton <RefreshButton
:loading="overviewLoading || keysLoading" :loading="keysLoading"
@click="refresh" @click="refreshCurrentPage"
/> />
</div> </div>
</div> </div>
<!-- Filters (mobile) --> <!-- Filters (mobile) -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Select v-model="selectedProviderIdProxy"> <Select
<SelectTrigger class="flex-1 h-8 text-xs border-border/60"> v-model="selectedProviderIdProxy"
:disabled="providerSelectDisabled"
>
<SelectTrigger
class="flex-1 h-8 text-xs border-border/60"
:disabled="providerSelectDisabled"
>
<SelectValue placeholder="选择 Provider" /> <SelectValue placeholder="选择 Provider" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -90,7 +96,7 @@
冷却中 冷却中
</SelectItem> </SelectItem>
<SelectItem value="inactive"> <SelectItem value="inactive">
禁用 禁用
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -124,8 +130,14 @@
</Badge> </Badge>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Select v-model="selectedProviderIdProxy"> <Select
<SelectTrigger class="w-36 h-8 text-xs border-border/60"> v-model="selectedProviderIdProxy"
:disabled="providerSelectDisabled"
>
<SelectTrigger
class="w-36 h-8 text-xs border-border/60"
:disabled="providerSelectDisabled"
>
<SelectValue placeholder="选择 Provider" /> <SelectValue placeholder="选择 Provider" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -167,7 +179,7 @@
冷却中 冷却中
</SelectItem> </SelectItem>
<SelectItem value="inactive"> <SelectItem value="inactive">
禁用 禁用
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -206,8 +218,8 @@
<Ban class="w-3.5 h-3.5" /> <Ban class="w-3.5 h-3.5" />
</Button> </Button>
<RefreshButton <RefreshButton
:loading="overviewLoading || keysLoading" :loading="keysLoading"
@click="refresh" @click="refreshCurrentPage"
/> />
</div> </div>
</div> </div>
@@ -384,20 +396,18 @@
> >
<div <div
v-if="quotaProgressMap[key.key_id]?.length" v-if="quotaProgressMap[key.key_id]?.length"
:class=" class="space-y-1 max-w-[220px]"
quotaProgressMap[key.key_id]?.length === 1
? 'h-[33px] max-w-[220px] flex items-center'
: 'grid grid-rows-[16px_16px] gap-1 max-w-[220px]'
"
> >
<div <div
v-for="(item, idx) in quotaProgressMap[key.key_id].slice(0, 2)" v-for="(item, idx) in quotaProgressMap[key.key_id].slice(0, 2)"
:key="`${key.key_id}-quota-${idx}`" :key="`${key.key_id}-quota-${idx}`"
class="w-full h-4" class="w-full"
:title="item.detail || ''" >
<div class="h-4 grid grid-cols-[20px_minmax(0,1fr)_42px] items-center gap-1 text-[10px] leading-tight">
<span
class="text-muted-foreground whitespace-nowrap text-right tabular-nums"
:title="getQuotaProgressTooltip(item)"
> >
<div class="h-full grid grid-cols-[20px_minmax(0,1fr)_46px] items-center gap-1.5 text-[10px] leading-tight">
<span class="text-muted-foreground whitespace-nowrap text-right tabular-nums">
{{ getQuotaProgressLabel(item.label) }} {{ getQuotaProgressLabel(item.label) }}
</span> </span>
<div class="relative flex-1 h-1.5 bg-border rounded-full overflow-hidden"> <div class="relative flex-1 h-1.5 bg-border rounded-full overflow-hidden">
@@ -408,7 +418,7 @@
/> />
</div> </div>
<span <span
class="tabular-nums text-right" class="tabular-nums text-right whitespace-nowrap"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)" :class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
> >
{{ item.remainingPercent.toFixed(1) }}% {{ item.remainingPercent.toFixed(1) }}%
@@ -1027,10 +1037,12 @@
v-for="(item, idx) in quotaProgressMap[key.key_id]" v-for="(item, idx) in quotaProgressMap[key.key_id]"
:key="`${key.key_id}-quota-mobile-${idx}`" :key="`${key.key_id}-quota-mobile-${idx}`"
class="w-full" class="w-full"
:title="item.detail || ''"
> >
<div class="grid grid-cols-[20px_minmax(0,1fr)_46px] items-center gap-1.5 text-[10px] leading-tight"> <div class="grid grid-cols-[20px_minmax(0,1fr)_42px] items-center gap-1 text-[10px] leading-tight">
<span class="text-muted-foreground whitespace-nowrap text-right tabular-nums"> <span
class="text-muted-foreground whitespace-nowrap text-right tabular-nums"
:title="getQuotaProgressTooltip(item)"
>
{{ getQuotaProgressLabel(item.label) }} {{ getQuotaProgressLabel(item.label) }}
</span> </span>
<div class="relative flex-1 h-1.5 bg-border rounded-full overflow-hidden"> <div class="relative flex-1 h-1.5 bg-border rounded-full overflow-hidden">
@@ -1041,7 +1053,7 @@
/> />
</div> </div>
<span <span
class="tabular-nums text-right" class="tabular-nums text-right whitespace-nowrap"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)" :class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
> >
{{ item.remainingPercent.toFixed(1) }}% {{ item.remainingPercent.toFixed(1) }}%
@@ -1203,6 +1215,7 @@ import {
exportKey, exportKey,
deleteEndpointKey, deleteEndpointKey,
updateProviderKey, updateProviderKey,
refreshProviderQuota,
} from '@/api/endpoints/keys' } from '@/api/endpoints/keys'
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth' import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
import { recoverKeyHealth } from '@/api/endpoints/health' import { recoverKeyHealth } from '@/api/endpoints/health'
@@ -1235,10 +1248,22 @@ async function loadOverview() {
overviewLoading.value = true overviewLoading.value = true
try { try {
const res = await getPoolOverview() const res = await getPoolOverview()
poolProviders.value = res.items.filter(item => item.pool_enabled) const enabledProviders = res.items.filter(item => item.pool_enabled)
// Auto-select first provider if none selected poolProviders.value = enabledProviders
if (!selectedProviderId.value && res.items.length > 0) {
await selectProvider(res.items[0].provider_id) // Keep selected provider aligned with dropdown options.
const selectedId = selectedProviderId.value
const selectedStillExists = Boolean(
selectedId && enabledProviders.some(item => item.provider_id === selectedId),
)
if (!selectedStillExists) {
if (enabledProviders.length > 0) {
await selectProvider(enabledProviders[0].provider_id)
} else {
selectedProviderId.value = null
selectedProviderData.value = null
}
} }
} catch (err) { } catch (err) {
showError(parseApiError(err)) showError(parseApiError(err))
@@ -1261,6 +1286,8 @@ const selectedProviderIdProxy = computed({
}, },
}) })
const providerSelectDisabled = computed(() => poolProviders.value.length === 0)
const selectedProviderConfig = computed<PoolAdvancedConfig | null>(() => { const selectedProviderConfig = computed<PoolAdvancedConfig | null>(() => {
return (selectedProviderData.value as Record<string, unknown> | null)?.pool_advanced as PoolAdvancedConfig | null ?? null return (selectedProviderData.value as Record<string, unknown> | null)?.pool_advanced as PoolAdvancedConfig | null ?? null
}) })
@@ -1303,15 +1330,14 @@ async function loadProviderData(id: string) {
} }
async function refresh() { async function refresh() {
await loadOverview()
if (selectedProviderId.value) {
await loadKeys() await loadKeys()
}
} }
// --- Keys --- // --- Keys ---
const keyPage = ref<PoolKeysPageResponse>({ total: 0, page: 1, page_size: 50, keys: [] }) const keyPage = ref<PoolKeysPageResponse>({ total: 0, page: 1, page_size: 50, keys: [] })
const keysLoading = ref(false) const keysLoading = ref(false)
const refreshingCurrentPageQuota = ref(false)
const queuedCurrentPageQuotaRefresh = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const statusFilter = ref('all') const statusFilter = ref('all')
const currentPage = ref(1) const currentPage = ref(1)
@@ -1336,6 +1362,7 @@ interface QuotaProgressItem {
label: string label: string
remainingPercent: number remainingPercent: number
detail?: string detail?: string
resetAtSeconds?: number | null
} }
const quotaProgressMap = computed<Record<string, QuotaProgressItem[]>>(() => { const quotaProgressMap = computed<Record<string, QuotaProgressItem[]>>(() => {
@@ -1346,6 +1373,65 @@ const quotaProgressMap = computed<Record<string, QuotaProgressItem[]>>(() => {
return map return map
}) })
const quotaRefreshSupported = computed(() => {
return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'antigravity'
})
function getCurrentPageQuotaKeyIds(): string[] {
const ids: string[] = []
const seen = new Set<string>()
for (const key of keyPage.value.keys) {
const id = String(key.key_id || '').trim()
if (!id || seen.has(id)) continue
seen.add(id)
ids.push(id)
}
return ids
}
async function refreshCurrentPageQuotaInBackground(options: { silent?: boolean } = {}) {
if (!selectedProviderId.value || !quotaRefreshSupported.value) return
const providerId = selectedProviderId.value
const keyIds = getCurrentPageQuotaKeyIds()
if (keyIds.length === 0) return
if (refreshingCurrentPageQuota.value) {
queuedCurrentPageQuotaRefresh.value = true
return
}
refreshingCurrentPageQuota.value = true
try {
const result = await refreshProviderQuota(providerId, keyIds)
const successCount = Number(result.success || 0)
const failedCount = Number(result.failed || 0)
// 刷新当前页数据,展示最新额度与状态
if (selectedProviderId.value === providerId) {
await loadKeys()
}
if (!options.silent) {
success(`当前页额度刷新完成:成功 ${successCount},失败 ${failedCount}`)
}
} catch (err) {
showError(parseApiError(err, '刷新当前页额度失败'))
} finally {
refreshingCurrentPageQuota.value = false
if (queuedCurrentPageQuotaRefresh.value) {
queuedCurrentPageQuotaRefresh.value = false
void refreshCurrentPageQuotaInBackground(options)
}
}
}
async function refreshCurrentPage() {
await refresh()
}
async function loadKeys() { async function loadKeys() {
if (!selectedProviderId.value) return if (!selectedProviderId.value) return
keysLoading.value = true keysLoading.value = true
@@ -1659,7 +1745,19 @@ async function toggleKeyActive(key: PoolKeyDetail) {
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 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'
}
success(nextStatus ? '账号已启用' : '账号已停用') success(nextStatus ? '账号已启用' : '账号已停用')
await loadKeys()
} catch (err) { } catch (err) {
showError(parseApiError(err)) showError(parseApiError(err))
} finally { } finally {
@@ -1727,7 +1825,11 @@ function getSchedulingStatus(key: PoolKeyDetail): 'available' | 'degraded' | 'bl
} }
function getSchedulingBadgeLabel(key: PoolKeyDetail): string { function getSchedulingBadgeLabel(key: PoolKeyDetail): string {
if (key.scheduling_label) return key.scheduling_label const rawLabel = String(key.scheduling_label || '').trim()
if (rawLabel) {
if (rawLabel === '禁用' || rawLabel === '停用') return '禁用'
return rawLabel
}
if (!key.is_active) return '禁用' if (!key.is_active) return '禁用'
if (key.cooldown_reason) return '冷却' if (key.cooldown_reason) return '冷却'
@@ -1965,6 +2067,14 @@ function getQuotaProgressLabel(label: string): string {
return label return label
} }
function getQuotaProgressTooltip(item: QuotaProgressItem): string {
const detail = item.detail?.trim() || ''
if ((item.label === '5H' || item.label === '周') && item.resetAtSeconds != null) {
return `${formatQuotaInlineCountdown(item.resetAtSeconds)} 后重置`
}
return detail
}
function getQuotaLabelOrder(label: string): number { function getQuotaLabelOrder(label: string): number {
if (label === '5H') return 0 if (label === '5H') return 0
if (label === '周') return 1 if (label === '周') return 1
@@ -1980,6 +2090,49 @@ function clampPercent(value: number): number {
return value return value
} }
function parseQuotaResetRemainingSeconds(detail: string | undefined): number | null {
if (!detail) return null
const text = detail.replace(/\s+/g, '')
if (text.includes('已重置')) return 0
if (text.includes('即将重置')) return 1
if (!text.includes('后重置')) return null
const dayMatch = text.match(/(\d+)天/)
const hourMatch = text.match(/(\d+)小时/)
const minuteMatch = text.match(/(\d+)分钟/)
const secondMatch = text.match(/(\d+)秒/)
const days = dayMatch ? Number(dayMatch[1]) : 0
const hours = hourMatch ? Number(hourMatch[1]) : 0
const minutes = minuteMatch ? Number(minuteMatch[1]) : 0
const seconds = secondMatch ? Number(secondMatch[1]) : 0
const total = days * 86400 + hours * 3600 + minutes * 60 + seconds
if (total <= 0) return 1
return total
}
function formatQuotaInlineCountdown(resetAtSeconds: number): string {
// 触发响应式更新,保持倒计时每秒刷新
void countdownTick.value
const now = Math.floor(Date.now() / 1000)
const remain = Math.max(0, Math.floor(resetAtSeconds - now))
const days = Math.floor(remain / 86400)
const hours = Math.floor((remain % 86400) / 3600)
const minutes = Math.floor((remain % 3600) / 60)
const seconds = remain % 60
if (days > 0) {
return `${days}d${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
}
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}`
}
return `${minutes}:${String(seconds).padStart(2, '0')}`
}
function parseQuotaProgressItems(quotaText: string | null | undefined): QuotaProgressItem[] { function parseQuotaProgressItems(quotaText: string | null | undefined): QuotaProgressItem[] {
if (!quotaText) return [] if (!quotaText) return []
@@ -1997,11 +2150,16 @@ function parseQuotaProgressItems(quotaText: string | null | undefined): QuotaPro
const remainingPercent = clampPercent(Number(rawPercent)) const remainingPercent = clampPercent(Number(rawPercent))
const label = normalizeQuotaLabel(rawLabel) const label = normalizeQuotaLabel(rawLabel)
const detail = rawTail.trim().replace(/^[()]+|[()]+$/g, '').trim() const detail = rawTail.trim().replace(/^[()]+|[()]+$/g, '').trim()
const resetRemainingSeconds = parseQuotaResetRemainingSeconds(detail || undefined)
const resetAtSeconds = resetRemainingSeconds == null
? null
: Math.floor(Date.now() / 1000) + resetRemainingSeconds
items.push({ items.push({
label, label,
remainingPercent, remainingPercent,
detail: detail || undefined, detail: detail || undefined,
resetAtSeconds,
}) })
} }
@@ -2064,5 +2222,6 @@ function formatRelativeTime(isoStr: string): string {
onMounted(async () => { onMounted(async () => {
startCountdownTimer() startCountdownTimer()
await loadOverview() await loadOverview()
void refreshCurrentPageQuotaInBackground({ silent: true })
}) })
</script> </script>

View File

@@ -551,7 +551,7 @@
:variant="apiKey.is_active ? 'success' : 'secondary'" :variant="apiKey.is_active ? 'success' : 'secondary'"
class="text-xs" class="text-xs"
> >
{{ apiKey.is_active ? '活跃' : '禁用' }} {{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge> </Badge>
<Badge <Badge
v-if="apiKey.is_locked" v-if="apiKey.is_locked"

View File

@@ -389,7 +389,7 @@
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-muted-foreground">账户状态</span> <span class="text-muted-foreground">账户状态</span>
<span :class="profile?.is_active ? 'text-success' : 'text-destructive'"> <span :class="profile?.is_active ? 'text-success' : 'text-destructive'">
{{ profile?.is_active ? '活跃' : '停用' }} {{ profile?.is_active ? '活跃' : '停用' }}
</span> </span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">

View File

@@ -8,6 +8,7 @@ from dataclasses import dataclass
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
@@ -349,10 +350,15 @@ CODEX_WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
# ========== Kiro Quota Refresh API ========== # ========== Kiro Quota Refresh API ==========
class RefreshProviderQuotaRequest(BaseModel):
key_ids: list[str] | None = Field(default=None, description="仅刷新指定 Key 列表(可选)")
@router.post("/providers/{provider_id}/refresh-quota") @router.post("/providers/{provider_id}/refresh-quota")
async def refresh_provider_quota( async def refresh_provider_quota(
provider_id: str, provider_id: str,
request: Request, request: Request,
payload: RefreshProviderQuotaRequest | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
""" """
@@ -365,13 +371,18 @@ async def refresh_provider_quota(
**路径参数**: **路径参数**:
- `provider_id`: Provider ID - `provider_id`: Provider ID
**请求体**(可选):
- `key_ids`: 仅刷新指定 Key 列表,不传时刷新所有活跃 Key
**返回字段**: **返回字段**:
- `success`: 成功刷新的 Key 数量 - `success`: 成功刷新的 Key 数量
- `failed`: 失败的 Key 数量 - `failed`: 失败的 Key 数量
- `results`: 每个 Key 的刷新结果 - `results`: 每个 Key 的刷新结果
""" """
adapter = AdminRefreshProviderQuotaAdapter(provider_id=provider_id) adapter = AdminRefreshProviderQuotaAdapter(
provider_id=provider_id,
key_ids=payload.key_ids if payload else None,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -380,10 +391,12 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
"""刷新 Provider 所有 Keys 的限额信息""" """刷新 Provider 所有 Keys 的限额信息"""
provider_id: str provider_id: str
key_ids: list[str] | None = None
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
return await refresh_provider_quota_for_provider( return await refresh_provider_quota_for_provider(
db=context.db, db=context.db,
provider_id=self.provider_id, provider_id=self.provider_id,
codex_wham_usage_url=CODEX_WHAM_USAGE_URL, codex_wham_usage_url=CODEX_WHAM_USAGE_URL,
key_ids=self.key_ids,
) )

View File

@@ -163,18 +163,17 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
# 查询所有候选后,默认只展示已发生调度结果的子集 # 查询并展示该请求的全量候选
# - 过滤 available/unused预创建但未实际参与本次调度 # - 包含 available/unused未执行
# - 若过滤后为空(例如请求尚未开始),回退到全量,避免前端空白 # - 包含 skipped跳过
# - 包含 pending/streaming/success/failed/cancelled执行中/结果)
all_candidates = RequestCandidateService.get_candidates_by_request_id(db, self.request_id) all_candidates = RequestCandidateService.get_candidates_by_request_id(db, self.request_id)
# 如果没有数据,返回 404 # 如果没有数据,返回 404
if not all_candidates: if not all_candidates:
raise HTTPException(status_code=404, detail="Request not found") raise HTTPException(status_code=404, detail="Request not found")
candidates = [ candidates = all_candidates
c for c in all_candidates if c.status not in ("available", "unused")
] or all_candidates
# 计算总延迟只统计已完成的候选success, failed, cancelled # 计算总延迟只统计已完成的候选success, failed, cancelled
# 使用显式的 is not None 检查,避免过滤掉 0ms 的快速响应 # 使用显式的 is not None 检查,避免过滤掉 0ms 的快速响应

View File

@@ -234,6 +234,28 @@ def _format_quota_value(value: float) -> str:
return f"{value:.1f}" return f"{value:.1f}"
def _format_reset_after(seconds_raw: Any) -> str | None:
seconds = _to_float(seconds_raw)
if seconds is None:
return None
total_seconds = int(seconds)
if total_seconds <= 0:
return "已重置"
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
if days > 0:
return f"{days}{hours}小时后重置"
if hours > 0:
return f"{hours}小时{minutes}分钟后重置"
if minutes > 0:
return f"{minutes}分钟后重置"
return "即将重置"
def _build_codex_account_quota(upstream_metadata: dict[str, Any]) -> str | None: def _build_codex_account_quota(upstream_metadata: dict[str, Any]) -> str | None:
codex = upstream_metadata.get("codex") codex = upstream_metadata.get("codex")
if not isinstance(codex, dict): if not isinstance(codex, dict):
@@ -243,11 +265,19 @@ def _build_codex_account_quota(upstream_metadata: dict[str, Any]) -> str | None:
primary_used = _to_float(codex.get("primary_used_percent")) primary_used = _to_float(codex.get("primary_used_percent"))
if primary_used is not None: if primary_used is not None:
parts.append(f"周剩余 {_format_percent(100.0 - primary_used)}") part = f"周剩余 {_format_percent(100.0 - primary_used)}"
reset_text = _format_reset_after(codex.get("primary_reset_seconds"))
if reset_text:
part = f"{part} ({reset_text})"
parts.append(part)
secondary_used = _to_float(codex.get("secondary_used_percent")) secondary_used = _to_float(codex.get("secondary_used_percent"))
if secondary_used is not None: if secondary_used is not None:
parts.append(f"5H剩余 {_format_percent(100.0 - secondary_used)}") part = f"5H剩余 {_format_percent(100.0 - secondary_used)}"
reset_text = _format_reset_after(codex.get("secondary_reset_seconds"))
if reset_text:
part = f"{part} ({reset_text})"
parts.append(part)
if parts: if parts:
return " | ".join(parts) return " | ".join(parts)

View File

@@ -70,7 +70,7 @@ class PoolSchedulingDimension(Protocol):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class _ManualEnableDimension: class _ManualEnableDimension:
code: str = "manual_disabled" code: str = "manual_disabled"
label: str = "禁用" label: str = "禁用"
source: str = "manual" source: str = "manual"
weight: int = 8 weight: int = 8

View File

@@ -101,6 +101,7 @@ async def refresh_provider_quota_for_provider(
db: Session, db: Session,
provider_id: str, provider_id: str,
codex_wham_usage_url: str, codex_wham_usage_url: str,
key_ids: list[str] | None = None,
) -> dict: ) -> dict:
"""刷新 Provider 限额信息(惰性导入实现)。""" """刷新 Provider 限额信息(惰性导入实现)。"""
from src.services.provider_keys.key_quota_service import ( from src.services.provider_keys.key_quota_service import (
@@ -111,4 +112,5 @@ async def refresh_provider_quota_for_provider(
db=db, db=db,
provider_id=provider_id, provider_id=provider_id,
codex_wham_usage_url=codex_wham_usage_url, codex_wham_usage_url=codex_wham_usage_url,
key_ids=key_ids,
) )

View File

@@ -64,8 +64,9 @@ async def refresh_provider_quota_for_provider(
db: Session, db: Session,
provider_id: str, provider_id: str,
codex_wham_usage_url: str, codex_wham_usage_url: str,
key_ids: list[str] | None = None,
) -> dict: ) -> dict:
"""刷新指定 Provider 下所有活跃 Key 的限额信息。""" """刷新指定 Provider 的限额信息(默认所有活跃 Key可按 key_ids 限定)"""
provider = db.query(Provider).filter(Provider.id == provider_id).first() provider = db.query(Provider).filter(Provider.id == provider_id).first()
if not provider: if not provider:
raise NotFoundException(f"Provider {provider_id} 不存在") raise NotFoundException(f"Provider {provider_id} 不存在")
@@ -74,21 +75,42 @@ async def refresh_provider_quota_for_provider(
if provider_type not in {ProviderType.CODEX, ProviderType.ANTIGRAVITY, ProviderType.KIRO}: if provider_type not in {ProviderType.CODEX, ProviderType.ANTIGRAVITY, ProviderType.KIRO}:
raise InvalidRequestException("仅支持 Codex / Antigravity / Kiro 类型的 Provider 刷新限额") raise InvalidRequestException("仅支持 Codex / Antigravity / Kiro 类型的 Provider 刷新限额")
keys = ( selected_key_ids: list[str] | None = None
db.query(ProviderAPIKey) if key_ids is not None:
.filter( deduped: list[str] = []
seen: set[str] = set()
for raw in key_ids:
value = str(raw).strip()
if not value or value in seen:
continue
seen.add(value)
deduped.append(value)
selected_key_ids = deduped
keys_query = db.query(ProviderAPIKey).filter(
ProviderAPIKey.provider_id == provider_id, ProviderAPIKey.provider_id == provider_id,
ProviderAPIKey.is_active.is_(True),
)
.all()
) )
if selected_key_ids is None:
keys_query = keys_query.filter(ProviderAPIKey.is_active.is_(True))
else:
if not selected_key_ids:
return {
"success": 0,
"failed": 0,
"total": 0,
"results": [],
"message": "未提供可刷新的 Key",
}
keys_query = keys_query.filter(ProviderAPIKey.id.in_(selected_key_ids))
keys = keys_query.all()
if not keys: if not keys:
return { return {
"success": 0, "success": 0,
"failed": 0, "failed": 0,
"total": 0, "total": 0,
"results": [], "results": [],
"message": "没有活跃的 Key", "message": "没有可刷新的 Key",
} }
endpoint = _select_refresh_endpoint(provider, provider_type) endpoint = _select_refresh_endpoint(provider, provider_type)

View File

@@ -147,7 +147,33 @@ async def test_refresh_provider_quota_no_active_keys_returns_empty() -> None:
"failed": 0, "failed": 0,
"total": 0, "total": 0,
"results": [], "results": [],
"message": "没有活跃的 Key", "message": "没有可刷新的 Key",
}
@pytest.mark.asyncio
async def test_refresh_provider_quota_empty_key_ids_returns_empty() -> None:
provider = SimpleNamespace(
id="p1",
provider_type=ProviderType.CODEX,
endpoints=[SimpleNamespace(api_format="openai:cli", is_active=True)],
)
key = SimpleNamespace(id="k1", name="K1", upstream_metadata={})
db = _FakeDB(provider=provider, keys=[key])
result = await refresh_provider_quota_for_provider(
db=cast(Any, db),
provider_id="p1",
codex_wham_usage_url="https://example.test/wham/usage",
key_ids=[],
)
assert result == {
"success": 0,
"failed": 0,
"total": 0,
"results": [],
"message": "未提供可刷新的 Key",
} }

View File

@@ -42,7 +42,7 @@ def _context() -> SimpleNamespace:
) )
def test_trace_prefers_attempted_subset(monkeypatch: object) -> None: def test_trace_returns_all_candidates_including_unused(monkeypatch: object) -> None:
candidates = [ candidates = [
_candidate(status="available"), _candidate(status="available"),
_candidate(status="unused"), _candidate(status="unused"),
@@ -57,13 +57,13 @@ def test_trace_prefers_attempted_subset(monkeypatch: object) -> None:
adapter = AdminGetRequestTraceAdapter(request_id="req-1") adapter = AdminGetRequestTraceAdapter(request_id="req-1")
response = asyncio.run(adapter.handle(_context())) response = asyncio.run(adapter.handle(_context()))
assert response.total_candidates == 1 assert response.total_candidates == 3
assert len(response.candidates) == 1 assert len(response.candidates) == 3
assert response.candidates[0].status == "failed" assert {c.status for c in response.candidates} == {"available", "unused", "failed"}
assert response.total_latency_ms == 123 assert response.total_latency_ms == 123
def test_trace_falls_back_to_all_when_no_attempted(monkeypatch: object) -> None: def test_trace_returns_unattempted_candidates(monkeypatch: object) -> None:
candidates = [ candidates = [
_candidate(status="available"), _candidate(status="available"),
_candidate(status="unused"), _candidate(status="unused"),