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> {
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/refresh-quota`)
export async function refreshProviderQuota(
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
}

View File

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

View File

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

View File

@@ -323,7 +323,7 @@
: key.is_active
? 'text-foreground/70 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"
@click.stop="toggleKeyActive(format, key)"
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -754,6 +754,11 @@ const getProviderDisplayName = (attempt: CandidateRecord | null | undefined): st
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 groups: NodeGroup[] = []
let currentGroup: NodeGroup | null = null
@@ -829,7 +834,31 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
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

View File

@@ -556,8 +556,8 @@ const navigation = computed(() => {
items: [
{ name: '用户管理', href: '/admin/users', icon: Users },
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
{ name: '号池管理', href: '/admin/pool', icon: Database },
{ name: '模型管理', href: '/admin/models', icon: Layers },
{ name: '号池管理', href: '/admin/pool', icon: Database },
{ name: '独立密钥', href: '/admin/keys', icon: Key },
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap },
{ 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 }[] = [
{ 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.is_active },
{ label: '禁用', description: '被禁用的模型', filter: m => !m.is_active },
{ label: '未调用', description: '没有调用记录的模型', filter: m => (m.usage_count || 0) === 0 },
{ label: '无价格', description: '没有配置任何价格的模型', filter: m => hasNoPrice(m) },
]

View File

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

View File

@@ -53,15 +53,21 @@
<Ban class="w-3.5 h-3.5" />
</Button>
<RefreshButton
:loading="overviewLoading || keysLoading"
@click="refresh"
:loading="keysLoading"
@click="refreshCurrentPage"
/>
</div>
</div>
<!-- Filters (mobile) -->
<div class="flex items-center gap-2">
<Select v-model="selectedProviderIdProxy">
<SelectTrigger class="flex-1 h-8 text-xs border-border/60">
<Select
v-model="selectedProviderIdProxy"
:disabled="providerSelectDisabled"
>
<SelectTrigger
class="flex-1 h-8 text-xs border-border/60"
:disabled="providerSelectDisabled"
>
<SelectValue placeholder="选择 Provider" />
</SelectTrigger>
<SelectContent>
@@ -90,7 +96,7 @@
冷却中
</SelectItem>
<SelectItem value="inactive">
禁用
禁用
</SelectItem>
</SelectContent>
</Select>
@@ -124,8 +130,14 @@
</Badge>
</div>
<div class="flex items-center gap-2">
<Select v-model="selectedProviderIdProxy">
<SelectTrigger class="w-36 h-8 text-xs border-border/60">
<Select
v-model="selectedProviderIdProxy"
:disabled="providerSelectDisabled"
>
<SelectTrigger
class="w-36 h-8 text-xs border-border/60"
:disabled="providerSelectDisabled"
>
<SelectValue placeholder="选择 Provider" />
</SelectTrigger>
<SelectContent>
@@ -167,7 +179,7 @@
冷却中
</SelectItem>
<SelectItem value="inactive">
禁用
禁用
</SelectItem>
</SelectContent>
</Select>
@@ -206,8 +218,8 @@
<Ban class="w-3.5 h-3.5" />
</Button>
<RefreshButton
:loading="overviewLoading || keysLoading"
@click="refresh"
:loading="keysLoading"
@click="refreshCurrentPage"
/>
</div>
</div>
@@ -384,20 +396,18 @@
>
<div
v-if="quotaProgressMap[key.key_id]?.length"
:class="
quotaProgressMap[key.key_id]?.length === 1
? 'h-[33px] max-w-[220px] flex items-center'
: 'grid grid-rows-[16px_16px] gap-1 max-w-[220px]'
"
class="space-y-1 max-w-[220px]"
>
<div
v-for="(item, idx) in quotaProgressMap[key.key_id].slice(0, 2)"
:key="`${key.key_id}-quota-${idx}`"
class="w-full h-4"
:title="item.detail || ''"
class="w-full"
>
<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">
<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)"
>
{{ getQuotaProgressLabel(item.label) }}
</span>
<div class="relative flex-1 h-1.5 bg-border rounded-full overflow-hidden">
@@ -408,7 +418,7 @@
/>
</div>
<span
class="tabular-nums text-right"
class="tabular-nums text-right whitespace-nowrap"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>
{{ item.remainingPercent.toFixed(1) }}%
@@ -1027,10 +1037,12 @@
v-for="(item, idx) in quotaProgressMap[key.key_id]"
:key="`${key.key_id}-quota-mobile-${idx}`"
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">
<span class="text-muted-foreground whitespace-nowrap text-right tabular-nums">
<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"
:title="getQuotaProgressTooltip(item)"
>
{{ getQuotaProgressLabel(item.label) }}
</span>
<div class="relative flex-1 h-1.5 bg-border rounded-full overflow-hidden">
@@ -1041,7 +1053,7 @@
/>
</div>
<span
class="tabular-nums text-right"
class="tabular-nums text-right whitespace-nowrap"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>
{{ item.remainingPercent.toFixed(1) }}%
@@ -1203,6 +1215,7 @@ import {
exportKey,
deleteEndpointKey,
updateProviderKey,
refreshProviderQuota,
} from '@/api/endpoints/keys'
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
import { recoverKeyHealth } from '@/api/endpoints/health'
@@ -1235,10 +1248,22 @@ async function loadOverview() {
overviewLoading.value = true
try {
const res = await getPoolOverview()
poolProviders.value = res.items.filter(item => item.pool_enabled)
// Auto-select first provider if none selected
if (!selectedProviderId.value && res.items.length > 0) {
await selectProvider(res.items[0].provider_id)
const enabledProviders = res.items.filter(item => item.pool_enabled)
poolProviders.value = enabledProviders
// 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) {
showError(parseApiError(err))
@@ -1261,6 +1286,8 @@ const selectedProviderIdProxy = computed({
},
})
const providerSelectDisabled = computed(() => poolProviders.value.length === 0)
const selectedProviderConfig = computed<PoolAdvancedConfig | 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() {
await loadOverview()
if (selectedProviderId.value) {
await loadKeys()
}
await loadKeys()
}
// --- Keys ---
const keyPage = ref<PoolKeysPageResponse>({ total: 0, page: 1, page_size: 50, keys: [] })
const keysLoading = ref(false)
const refreshingCurrentPageQuota = ref(false)
const queuedCurrentPageQuotaRefresh = ref(false)
const searchQuery = ref('')
const statusFilter = ref('all')
const currentPage = ref(1)
@@ -1336,6 +1362,7 @@ interface QuotaProgressItem {
label: string
remainingPercent: number
detail?: string
resetAtSeconds?: number | null
}
const quotaProgressMap = computed<Record<string, QuotaProgressItem[]>>(() => {
@@ -1346,6 +1373,65 @@ const quotaProgressMap = computed<Record<string, QuotaProgressItem[]>>(() => {
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() {
if (!selectedProviderId.value) return
keysLoading.value = true
@@ -1659,7 +1745,19 @@ async function toggleKeyActive(key: PoolKeyDetail) {
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'
}
success(nextStatus ? '账号已启用' : '账号已停用')
await loadKeys()
} catch (err) {
showError(parseApiError(err))
} finally {
@@ -1727,7 +1825,11 @@ function getSchedulingStatus(key: PoolKeyDetail): 'available' | 'degraded' | 'bl
}
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.cooldown_reason) return '冷却'
@@ -1965,6 +2067,14 @@ function getQuotaProgressLabel(label: string): string {
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 {
if (label === '5H') return 0
if (label === '周') return 1
@@ -1980,6 +2090,49 @@ function clampPercent(value: number): number {
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[] {
if (!quotaText) return []
@@ -1997,11 +2150,16 @@ function parseQuotaProgressItems(quotaText: string | null | undefined): QuotaPro
const remainingPercent = clampPercent(Number(rawPercent))
const label = normalizeQuotaLabel(rawLabel)
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({
label,
remainingPercent,
detail: detail || undefined,
resetAtSeconds,
})
}
@@ -2064,5 +2222,6 @@ function formatRelativeTime(isoStr: string): string {
onMounted(async () => {
startCountdownTimer()
await loadOverview()
void refreshCurrentPageQuotaInBackground({ silent: true })
})
</script>

View File

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

View File

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