Files
Aether/frontend/src/views/admin/PoolManagement.vue
T

4185 lines
159 KiB
Vue
Raw Normal View History

<template>
<div class="space-y-6 pb-8">
<Card
variant="default"
class="overflow-hidden"
>
<PoolManagementHeader
v-model:provider-id="selectedProviderIdProxy"
v-model:status="statusFilter"
v-model:search="searchQuery"
:providers="poolProviders"
:provider-select-disabled="providerSelectDisabled"
:status-options="poolKeyStatusFilterOptions"
:meta-text="poolHeaderMetaText"
:pool-scheduling-label="poolSchedulingLabel"
:show-adaptive-hot-pool-metrics-button="showAdaptiveHotPoolMetricsButton"
:selected-count="selectedKeyCount"
:is-all-filtered-selected="isAllFilteredPoolKeysSelected"
:selection-disabled="keyPage.total === 0 || poolKeySelectionBusy"
:batch-actions-disabled="selectedKeyCount === 0 || poolKeySelectionBusy"
:refresh-loading="refreshCurrentPageLoading"
:refresh-title="refreshButtonTitle"
@view-provider="openProviderDrawer"
@prefetch-provider="prefetchProviderDetailDrawer"
@import="showImportDialog = true"
@scheduling="openSchedulingDialog"
@demand-metrics="showDemandMetricsDialog = true"
2026-07-27 22:06:28 +08:00
@advanced="openAdvancedDialog"
@toggle-select-all="toggleAllFilteredPoolKeys"
@batch-action="openAccountBatchDialog"
@refresh="refreshCurrentPage"
/>
<!-- Loading (initial) -->
<div
v-if="overviewLoading"
class="flex items-center justify-center py-16"
>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
<!-- No providers -->
<div
v-else-if="poolProviders.length === 0"
class="flex flex-col items-center justify-center py-16 text-center"
>
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Database class="h-8 w-8 text-muted-foreground" />
</div>
<p class="text-sm text-muted-foreground mt-4">
暂无 Provider
</p>
<p class="text-xs text-muted-foreground mt-1">
请先添加 Provider
</p>
</div>
<!-- No provider selected -->
<div
v-else-if="!selectedProviderId"
class="flex flex-col items-center justify-center py-16 text-center"
>
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Database class="h-8 w-8 text-muted-foreground" />
</div>
<p class="text-sm text-muted-foreground mt-4">
请选择一个 Provider 查看账号
</p>
</div>
<!-- Loading keys -->
<div
v-else-if="keysLoading && keyPage.keys.length === 0"
class="flex items-center justify-center py-16"
>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
<template v-else>
<!-- Desktop table -->
<div
2026-05-01 02:14:49 +08:00
v-if="keyPage.keys.length > 0 || hasPoolKeyFilters"
class="hidden xl:block overflow-x-auto"
>
<Table class="w-full table-fixed">
<colgroup>
<col :style="{ width: desktopColumnWidths.name }">
<col
v-if="showAccountQuotaColumn"
:style="{ width: desktopColumnWidths.quota }"
>
<col :style="{ width: desktopColumnWidths.stats }">
<col :style="{ width: desktopColumnWidths.imported }">
<col :style="{ width: desktopColumnWidths.lastUsed }">
<col :style="{ width: desktopColumnWidths.score }">
<col :style="{ width: desktopColumnWidths.status }">
<col :style="{ width: desktopColumnWidths.actions }">
</colgroup>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead
class="px-4 font-semibold whitespace-nowrap"
:style="{ width: desktopColumnWidths.name }"
>
<div class="flex items-center gap-2">
<Checkbox
class="h-3.5 w-3.5 shrink-0"
:checked="selectAllFilteredPoolKeys || isCurrentPoolKeyPageFullySelected"
:indeterminate="!selectAllFilteredPoolKeys && isCurrentPoolKeyPagePartiallySelected"
:disabled="keyPage.keys.length === 0 || poolKeySelectionBusy || selectAllFilteredPoolKeys"
aria-label="选择当前页账号"
data-testid="pool-select-page-desktop"
@update:checked="toggleCurrentPoolKeyPage"
/>
<div class="flex items-baseline gap-2">
<span class="leading-none">名称</span>
<span
v-if="selectedKeyCount > 0"
class="text-[11px] font-medium leading-none tabular-nums text-primary"
aria-live="polite"
data-testid="pool-selected-count-desktop"
>
{{ selectedKeyCountLabel }}
</span>
</div>
</div>
</TableHead>
<TableHead
v-if="showAccountQuotaColumn"
class="font-semibold whitespace-nowrap"
:style="{ width: desktopColumnWidths.quota }"
>
配额
</TableHead>
<TableHead
class="px-2 font-semibold text-center whitespace-nowrap"
:style="{ width: desktopColumnWidths.stats }"
>
2026-07-17 20:18:04 +08:00
<span>统计</span>
</TableHead>
2026-05-01 02:14:49 +08:00
<SortableTableHead
class="font-semibold text-center whitespace-nowrap"
2026-05-01 02:14:49 +08:00
column-key="imported_at"
:active-key="sortBy"
:direction="sortOrder"
default-direction="desc"
align="center"
:style="{ width: desktopColumnWidths.imported }"
title="按导入时间排序"
@sort="handleTableSort"
>
导入时间
</SortableTableHead>
<SortableTableHead
class="font-semibold text-center whitespace-nowrap"
column-key="last_used_at"
:active-key="sortBy"
:direction="sortOrder"
default-direction="desc"
align="center"
:style="{ width: desktopColumnWidths.lastUsed }"
2026-05-01 02:14:49 +08:00
title="按最后使用时间排序"
@sort="handleTableSort"
>
最后使用
2026-05-01 02:14:49 +08:00
</SortableTableHead>
2026-05-13 09:16:52 +08:00
<SortableTableHead
2026-05-12 15:27:21 +08:00
class="font-semibold text-center whitespace-nowrap"
2026-05-13 09:16:52 +08:00
column-key="score"
:active-key="sortBy"
:direction="sortOrder"
default-direction="desc"
align="center"
2026-05-12 15:27:21 +08:00
:style="{ width: desktopColumnWidths.score }"
2026-05-13 09:16:52 +08:00
title="按分数排序"
@sort="handleTableSort"
2026-05-12 15:27:21 +08:00
>
分数
2026-05-13 09:16:52 +08:00
</SortableTableHead>
2026-05-01 02:14:49 +08:00
<SortableTableHead
class="font-semibold text-center whitespace-nowrap"
2026-05-01 02:14:49 +08:00
column-key="status"
:sortable="false"
align="center"
:filter-active="statusFilter !== 'all'"
filter-title="筛选状态"
filter-content-class="w-44 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
:style="{ width: desktopColumnWidths.status }"
>
状态
2026-05-01 02:14:49 +08:00
<template #filter="{ close }">
<TableFilterMenu
v-model="statusFilter"
:options="poolKeyStatusFilterOptions"
@select="close"
/>
</template>
</SortableTableHead>
<TableHead
class="px-2 font-semibold text-center whitespace-nowrap"
:style="{ width: desktopColumnWidths.actions }"
>
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="key in keyPage.keys"
:key="key.key_id"
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
:class="getPoolKeyRowClass(key.key_id)"
>
<TableCell
class="px-4 py-3 align-top"
>
<div class="flex min-w-0 items-center gap-2">
<Checkbox
class="h-3.5 w-3.5 shrink-0"
:checked="isPoolKeySelected(key.key_id)"
:disabled="poolKeySelectionBusy || selectAllFilteredPoolKeys"
:aria-label="`选择账号 ${key.key_name || key.key_id}`"
:data-testid="`pool-select-desktop-${key.key_id}`"
@update:checked="togglePoolKeySelection(key.key_id, $event === true)"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-sm truncate block">
{{ key.key_name || '未命名' }}
</span>
</div>
<div class="flex items-center flex-wrap gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0">
<input
v-if="editingPriorityKeyId === key.key_id"
:value="editingPriorityValue"
type="number"
min="1"
max="999999"
autofocus
class="h-[18px] w-10 rounded border border-primary/50 bg-background px-1 text-[10px] tabular-nums text-foreground outline-none ring-1 ring-primary/30 shrink-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
@input="(e) => editingPriorityValue = Number((e.target as HTMLInputElement).value || 0)"
@blur="(e) => finishEditInternalPriority(key, e)"
@keydown.enter.prevent="(e) => finishEditInternalPriority(key, e)"
@keydown.esc.prevent="cancelEditInternalPriority"
>
<button
v-else
type="button"
class="h-4 px-1 rounded text-[10px] tabular-nums text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors shrink-0"
title="点击编辑优先级"
@click="startEditInternalPriority(key)"
>
P{{ key.internal_priority ?? 50 }}
</button>
<Button
v-if="canExportOAuthCredential(key)"
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
title="下载 OAuth 授权文件"
@click.stop="downloadRefreshToken(key)"
>
<Download class="w-2.5 h-2.5" />
</Button>
<Button
v-else-if="key.agent_identity !== true"
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
title="复制密钥"
@click.stop="copyFullKey(key)"
>
<Copy class="w-2.5 h-2.5" />
</Button>
<span class="font-mono">
{{ getProviderMaskedSecretLabel(key, selectedProviderType) }}
</span>
<template v-if="keyUiStateMap[key.key_id]?.showOAuthRefreshControl">
<Button
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
:disabled="refreshingOAuthKeyId === key.key_id || !keyUiStateMap[key.key_id]?.canRefreshToken"
:title="keyUiStateMap[key.key_id]?.oauthRefreshButtonTitle || ''"
@click.stop="handleRefreshOAuth(key)"
>
<RefreshCw
class="w-2.5 h-2.5"
:class="{ 'animate-spin': refreshingOAuthKeyId === key.key_id }"
/>
</Button>
<span
v-if="keyUiStateMap[key.key_id]?.visibleOAuthState"
class="text-[10px]"
:class="{
'text-destructive': keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid || keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired,
'text-warning': keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpiringSoon && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid,
'text-muted-foreground': !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpiringSoon && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid
}"
:title="keyUiStateMap[key.key_id]?.oauthStatusTitle || ''"
>
{{ keyUiStateMap[key.key_id]?.visibleOAuthState?.text }}
</span>
</template>
<Badge
v-if="keyUiStateMap[key.key_id]?.planLabel"
variant="outline"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:class="keyUiStateMap[key.key_id]?.planClass || ''"
>
{{ keyUiStateMap[key.key_id]?.planLabel }}
</Badge>
<Badge
v-if="keyUiStateMap[key.key_id]?.oauthOrgBadge"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:title="keyUiStateMap[key.key_id]?.oauthOrgBadge?.title"
>
{{ keyUiStateMap[key.key_id]?.oauthOrgBadge?.label }}
</Badge>
</div>
</div>
</div>
</TableCell>
<TableCell
v-if="showAccountQuotaColumn"
class="py-3 align-top"
>
<PoolKeyQuotaPanel
:items="quotaProgressDisplayMap[key.key_id] || []"
:account-quota-text="keyUiStateMap[key.key_id]?.accountQuotaText"
:fallback-text="keyUiStateMap[key.key_id]?.quotaFallbackText"
:text-class="keyUiStateMap[key.key_id]?.quotaTextClass || ''"
:reset-credit-text="getCodexResetCreditCountText(key)"
:reset-credit-items="getCodexResetCreditItemTexts(key)"
:can-consume-reset-credit="canConsumeCodexResetCredit(key)"
:consuming-reset-credit="consumingCodexResetCreditKeyId === key.key_id"
@consume-reset-credit="handleConsumeCodexResetCredit(key)"
/>
</TableCell>
<TableCell class="py-3 px-2 align-top">
<PoolKeyStatsPanel
:cycle="isPoolKeyCycleStatsDisplay(key)"
2026-07-17 20:18:04 +08:00
:cycle-groups="getPoolKeyCycleStatsGroups(key)"
:account-metrics="getPoolKeyAccountStatsMetrics(key)"
/>
</TableCell>
<TableCell class="py-3 text-center align-top">
2026-05-01 02:14:49 +08:00
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
{{ keyUiStateMap[key.key_id]?.importedAtRelative || '-' }}
</span>
</TableCell>
<TableCell class="py-3 text-center align-top">
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
{{ keyUiStateMap[key.key_id]?.lastUsedRelative || '-' }}
</span>
</TableCell>
<TableCell class="py-3 text-center align-top">
2026-05-12 15:27:21 +08:00
<div class="inline-flex items-center justify-center gap-1">
<span class="font-mono text-xs tabular-nums text-foreground/90">
{{ formatPoolScore(key.pool_score?.score) }}
</span>
<Popover
v-if="key.pool_score"
2026-05-12 16:04:00 +08:00
:open="scoreDesktopPopoverOpenKeyId === key.key_id"
@update:open="(open: boolean) => handleScoreDesktopPopoverToggle(key.key_id, open)"
2026-05-12 15:27:21 +08:00
>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
2026-05-12 16:04:00 +08:00
class="h-5 w-5 rounded-full border border-transparent text-muted-foreground/80 hover:border-border/60 hover:bg-muted/60 hover:text-foreground"
2026-05-12 15:27:21 +08:00
title="查看评分计算结果"
aria-label="查看评分计算结果"
@click.stop
>
<CircleHelp class="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
2026-05-12 16:04:00 +08:00
v-if="scoreDesktopPopoverOpenKeyId === key.key_id"
class="w-[22rem] max-w-[calc(100vw-1rem)] overflow-hidden rounded-xl border-border/60 bg-card/95 p-0 text-card-foreground shadow-xl shadow-black/5 backdrop-blur supports-[backdrop-filter]:bg-card/90"
2026-05-12 15:27:21 +08:00
side="bottom"
2026-05-12 16:04:00 +08:00
align="end"
:side-offset="8"
2026-05-12 15:27:21 +08:00
>
2026-05-12 16:04:00 +08:00
<div class="text-left">
<div class="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-3 py-2.5">
<span class="text-xs font-semibold text-foreground">评分计算结果</span>
<span class="font-mono text-xs tabular-nums text-foreground/90">
2026-05-12 15:27:21 +08:00
{{ formatPoolScore(key.pool_score?.score) }}
</span>
</div>
2026-05-12 16:04:00 +08:00
<div class="space-y-2 px-3 py-2.5">
<div class="flex flex-wrap items-center gap-1.5">
<Badge
variant="outline"
class="h-5 rounded-md border-border/60 bg-background/60 px-2 text-[10px] font-normal"
>
{{ getPoolScoreHardStateLabel(key.pool_score?.hard_state) }}
</Badge>
<Badge
variant="secondary"
class="h-5 rounded-md px-2 text-[10px] font-normal"
>
{{ getPoolScoreProbeStatusLabel(key.pool_score?.probe_status) }}
</Badge>
<span class="text-[10px] text-muted-foreground">
更新 {{ formatUnixSeconds(key.pool_score?.updated_at) }}
</span>
</div>
<pre class="max-h-56 overflow-auto rounded-md border border-border/50 bg-muted/30 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground whitespace-pre-wrap break-words">{{ formatPoolScoreReason(key.pool_score?.score_reason) }}</pre>
2026-05-12 15:27:21 +08:00
</div>
</div>
</PopoverContent>
</Popover>
</div>
</TableCell>
<TableCell class="py-3 text-center align-top">
<Badge
:variant="keyUiStateMap[key.key_id]?.schedulingBadgeVariant || 'default'"
class="text-[10px]"
:title="keyUiStateMap[key.key_id]?.schedulingTitle || ''"
>
{{ keyUiStateMap[key.key_id]?.schedulingBadgeLabel }}
</Badge>
</TableCell>
<TableCell class="py-3 px-2 align-top">
<div class="flex justify-center gap-0.5">
<Button
v-if="key.cooldown_reason"
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground hover:text-green-600"
title="清除冷却"
@click="clearCooldown(key.key_id)"
>
<RefreshCw class="w-3.5 h-3.5" />
</Button>
2026-05-07 03:23:20 +08:00
<Button
v-if="canResetCycleStats(key)"
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground hover:text-foreground"
:disabled="resettingCycleKeyId === key.key_id"
title="重置周期统计"
data-testid="pool-reset-cycle-stats"
@click="handleResetCycleStats(key)"
>
<RotateCcw
class="w-3.5 h-3.5"
:class="{ 'animate-spin': resettingCycleKeyId === key.key_id }"
/>
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="模型权限"
@click="handleKeyPermissions(key)"
>
<Shield class="w-3.5 h-3.5" />
</Button>
<Popover
:open="proxyDesktopPopoverOpenKeyId === key.key_id"
@update:open="(v: boolean) => handleProxyDesktopPopoverToggle(key.key_id, v)"
>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:class="key.proxy?.node_id ? 'text-blue-500' : ''"
:disabled="savingProxyKeyId === key.key_id"
:title="key.proxy?.node_id ? `代理: ${getKeyProxyNodeName(key)}` : '设置代理节点'"
@click.stop
>
<Globe class="w-3.5 h-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
class="w-72 p-3"
side="bottom"
align="end"
>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium">代理节点</span>
<Button
v-if="key.proxy?.node_id"
variant="ghost"
size="sm"
class="h-6 px-2 text-[10px] text-muted-foreground"
:disabled="savingProxyKeyId === key.key_id"
@click="clearKeyProxy(key)"
>
清除
</Button>
</div>
<ProxyNodeSelect
:model-value="key.proxy?.node_id || ''"
trigger-class="h-8"
@update:model-value="(v: string) => setKeyProxy(key, v)"
/>
<p class="text-[10px] text-muted-foreground">
{{ key.proxy?.node_id ? '当前使用独立代理' : '未设置,使用提供商级别代理' }}
</p>
</div>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="编辑账号"
@click="handleEditKey(key)"
>
<SquarePen class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-foreground hover:text-foreground"
:disabled="togglingKeyId === key.key_id"
:title="key.is_active ? '禁用' : '启用'"
:aria-label="key.is_active ? '禁用账号' : '启用账号'"
:data-testid="`pool-toggle-active-desktop-${key.key_id}`"
@click="toggleKeyActive(key)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive hover:text-destructive"
:disabled="deletingKeyId === key.key_id"
title="删除账号"
@click="handleDeleteKey(key)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Mobile card list -->
<div
v-if="keyPage.keys.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="key in keyPage.keys"
:key="key.key_id"
class="p-4 sm:p-5 hover:bg-muted/30 transition-colors"
:class="getPoolKeyRowClass(key.key_id)"
>
<div class="space-y-3">
<div class="flex items-center gap-1.5">
<Checkbox
class="h-3.5 w-3.5 shrink-0"
:checked="isPoolKeySelected(key.key_id)"
:disabled="poolKeySelectionBusy || selectAllFilteredPoolKeys"
:aria-label="`选择账号 ${key.key_name || key.key_id}`"
:data-testid="`pool-select-mobile-${key.key_id}`"
@update:checked="togglePoolKeySelection(key.key_id, $event === true)"
/>
<div class="min-w-0 truncate text-sm font-medium">
{{ key.key_name || '未命名' }}
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<Badge
:variant="keyUiStateMap[key.key_id]?.schedulingBadgeVariant || 'default'"
class="text-[10px] shrink-0"
:title="keyUiStateMap[key.key_id]?.schedulingTitle || ''"
>
{{ keyUiStateMap[key.key_id]?.schedulingBadgeLabel }}
</Badge>
<span
v-if="key.cooldown_ttl_seconds"
class="inline-flex items-center rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[10px] font-medium leading-4 text-red-700 dark:text-red-300"
>
冷却 {{ formatTTL(key.cooldown_ttl_seconds) }}
</span>
<template
v-for="item in keyUiStateMap[key.key_id]?.mobileTagItems || []"
:key="`${key.key_id}-${item.key}`"
>
<button
v-if="item.key === 'priority'"
type="button"
class="inline-flex max-w-full items-center rounded-full border px-2 py-0.5 text-[10px] font-medium leading-4 hover:border-primary/40 hover:text-foreground"
:class="getMobileTagClass(item)"
:title="`${item.label},点击编辑优先级`"
@click="quickEditInternalPriority(key)"
>
{{ item.label }}
</button>
<Badge
v-else-if="item.key === 'plan'"
variant="outline"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:class="keyUiStateMap[key.key_id]?.planClass || ''"
>
{{ item.label }}
</Badge>
<Badge
v-else-if="item.key === 'org'"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:title="keyUiStateMap[key.key_id]?.oauthOrgBadge?.title"
>
{{ item.label }}
</Badge>
<span
v-else
class="inline-flex max-w-full items-center rounded-full border px-2 py-0.5 text-[10px] font-medium leading-4"
:class="getMobileTagClass(item)"
:title="item.label"
>
{{ item.label }}
</span>
</template>
</div>
<div class="overflow-x-auto rounded-xl border border-border/50 bg-muted/30 px-3 py-2 text-[11px] text-muted-foreground">
2026-05-06 23:14:25 +08:00
<div class="space-y-1 text-center">
<PoolKeyStatsPanel
:cycle="isPoolKeyCycleStatsDisplay(key)"
2026-07-17 20:18:04 +08:00
:cycle-groups="getPoolKeyCycleStatsGroups(key)"
:account-metrics="getPoolKeyAccountStatsMetrics(key)"
variant="mobile"
/>
2026-05-06 23:14:25 +08:00
<div class="flex items-center justify-between gap-2 border-t border-border/40 pt-1 mt-1">
<span class="text-muted-foreground">导入</span>
<span class="font-medium text-foreground/90">{{ keyUiStateMap[key.key_id]?.importedAtRelative || '-' }}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted-foreground">最后使用</span>
<span class="font-medium text-foreground/90">{{ keyUiStateMap[key.key_id]?.lastUsedRelative || '-' }}</span>
</div>
2026-05-12 15:27:21 +08:00
<div class="flex items-center justify-between gap-2">
<span class="text-muted-foreground">分数</span>
<div class="flex items-center gap-1">
<span class="font-mono font-medium text-foreground/90 tabular-nums">
{{ formatPoolScore(key.pool_score?.score) }}
</span>
<Popover
v-if="key.pool_score"
2026-05-12 16:04:00 +08:00
:open="scoreMobilePopoverOpenKeyId === key.key_id"
@update:open="(open: boolean) => handleScoreMobilePopoverToggle(key.key_id, open)"
2026-05-12 15:27:21 +08:00
>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
2026-05-12 16:04:00 +08:00
class="h-5 w-5 rounded-full border border-transparent text-muted-foreground/80 hover:border-border/60 hover:bg-muted/60 hover:text-foreground"
2026-05-12 15:27:21 +08:00
title="查看评分计算结果"
aria-label="查看评分计算结果"
@click.stop
>
<CircleHelp class="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
2026-05-12 16:04:00 +08:00
v-if="scoreMobilePopoverOpenKeyId === key.key_id"
class="w-[22rem] max-w-[calc(100vw-1rem)] overflow-hidden rounded-xl border-border/60 bg-card/95 p-0 text-card-foreground shadow-xl shadow-black/5 backdrop-blur supports-[backdrop-filter]:bg-card/90"
2026-05-12 15:27:21 +08:00
side="bottom"
2026-05-12 16:04:00 +08:00
align="end"
:side-offset="8"
2026-05-12 15:27:21 +08:00
>
2026-05-12 16:04:00 +08:00
<div class="text-left">
<div class="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-3 py-2.5">
<span class="text-xs font-semibold text-foreground">评分计算结果</span>
<span class="font-mono text-xs tabular-nums text-foreground/90">
2026-05-12 15:27:21 +08:00
{{ formatPoolScore(key.pool_score?.score) }}
</span>
</div>
2026-05-12 16:04:00 +08:00
<div class="space-y-2 px-3 py-2.5">
<div class="flex flex-wrap items-center gap-1.5">
<Badge
variant="outline"
class="h-5 rounded-md border-border/60 bg-background/60 px-2 text-[10px] font-normal"
>
{{ getPoolScoreHardStateLabel(key.pool_score?.hard_state) }}
</Badge>
<Badge
variant="secondary"
class="h-5 rounded-md px-2 text-[10px] font-normal"
>
{{ getPoolScoreProbeStatusLabel(key.pool_score?.probe_status) }}
</Badge>
<span class="text-[10px] text-muted-foreground">
更新 {{ formatUnixSeconds(key.pool_score?.updated_at) }}
</span>
</div>
<pre class="max-h-56 overflow-auto rounded-md border border-border/50 bg-muted/30 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground whitespace-pre-wrap break-words">{{ formatPoolScoreReason(key.pool_score?.score_reason) }}</pre>
2026-05-12 15:27:21 +08:00
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
<PoolKeyQuotaPanel
v-if="showAccountQuotaColumn"
:items="quotaProgressDisplayMap[key.key_id] || []"
:account-quota-text="keyUiStateMap[key.key_id]?.accountQuotaText"
:fallback-text="keyUiStateMap[key.key_id]?.quotaFallbackText"
:text-class="keyUiStateMap[key.key_id]?.quotaTextClass || ''"
:reset-credit-text="getCodexResetCreditCountText(key)"
:reset-credit-items="getCodexResetCreditItemTexts(key)"
:can-consume-reset-credit="canConsumeCodexResetCredit(key)"
:consuming-reset-credit="consumingCodexResetCreditKeyId === key.key_id"
variant="mobile"
@consume-reset-credit="handleConsumeCodexResetCredit(key)"
/>
<div class="flex items-center gap-0.5">
<div
v-for="actionId in keyUiStateMap[key.key_id]?.mobileActionIds || []"
:key="`${key.key_id}-${actionId}`"
class="min-w-0 flex-1 flex justify-center"
>
<Button
v-if="actionId === 'copy_or_download' && canExportOAuthCredential(key)"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
title="下载 OAuth 授权文件"
@click.stop="downloadRefreshToken(key)"
>
<Download class="w-3.5 h-3.5" />
</Button>
<Button
v-else-if="actionId === 'copy_or_download' && key.agent_identity !== true"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
title="复制密钥"
@click.stop="copyFullKey(key)"
>
<Copy class="w-3.5 h-3.5" />
</Button>
<Button
v-else-if="actionId === 'refresh_token'"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
2026-05-01 02:14:49 +08:00
:disabled="refreshingOAuthKeyId === key.key_id || !keyUiStateMap[key.key_id]?.canRefreshToken"
:title="keyUiStateMap[key.key_id]?.oauthRefreshButtonTitle || ''"
@click.stop="handleRefreshOAuth(key)"
>
<RefreshCw
class="w-3.5 h-3.5"
:class="{ 'animate-spin': refreshingOAuthKeyId === key.key_id }"
/>
</Button>
<Button
v-else-if="actionId === 'clear_cooldown'"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-muted-foreground hover:text-green-600"
title="清除冷却"
@click="clearCooldown(key.key_id)"
>
<RefreshCw class="w-3.5 h-3.5" />
</Button>
<Button
v-else-if="actionId === 'permissions'"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
title="模型权限"
@click="handleKeyPermissions(key)"
>
<Shield class="w-3.5 h-3.5" />
</Button>
<Popover
v-else-if="actionId === 'proxy'"
:open="proxyMobilePopoverOpenKeyId === key.key_id"
@update:open="(v: boolean) => handleProxyMobilePopoverToggle(key.key_id, v)"
>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
:class="key.proxy?.node_id ? 'text-blue-500' : ''"
:disabled="savingProxyKeyId === key.key_id"
:title="key.proxy?.node_id ? `代理: ${getKeyProxyNodeName(key)}` : '设置代理节点'"
@click.stop
>
<Globe class="w-3.5 h-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
class="w-72 p-3"
side="bottom"
align="end"
>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium">代理节点</span>
<Button
v-if="key.proxy?.node_id"
variant="ghost"
size="sm"
class="h-6 px-2 text-[10px] text-muted-foreground"
:disabled="savingProxyKeyId === key.key_id"
@click="clearKeyProxy(key)"
>
清除
</Button>
</div>
<ProxyNodeSelect
:model-value="key.proxy?.node_id || ''"
trigger-class="h-8"
@update:model-value="(v: string) => setKeyProxy(key, v)"
/>
<p class="text-[10px] text-muted-foreground">
{{ key.proxy?.node_id ? '当前使用独立代理' : '未设置,使用提供商级别代理' }}
</p>
</div>
</PopoverContent>
</Popover>
2026-05-07 03:23:20 +08:00
<Button
v-else-if="actionId === 'reset_cycle_stats'"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"
:disabled="resettingCycleKeyId === key.key_id"
title="重置周期统计"
@click="handleResetCycleStats(key)"
>
<RotateCcw
class="w-3.5 h-3.5"
:class="{ 'animate-spin': resettingCycleKeyId === key.key_id }"
/>
</Button>
<Button
v-else-if="actionId === 'edit'"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
title="编辑账号"
@click="handleEditKey(key)"
>
<SquarePen class="w-3.5 h-3.5" />
</Button>
<Button
v-else-if="actionId === 'toggle'"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-foreground hover:text-foreground"
:disabled="togglingKeyId === key.key_id"
:title="key.is_active ? '禁用' : '启用'"
:aria-label="key.is_active ? '禁用账号' : '启用账号'"
:data-testid="`pool-toggle-active-mobile-${key.key_id}`"
@click="toggleKeyActive(key)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
v-else-if="actionId === 'delete'"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-destructive hover:text-destructive"
:disabled="deletingKeyId === key.key_id"
title="删除账号"
@click="handleDeleteKey(key)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
</div>
</div>
<!-- Empty keys -->
<div
2026-05-01 02:14:49 +08:00
v-if="keyPage.keys.length === 0 && !keysLoading && keysLoadedOnce"
class="flex flex-col items-center justify-center py-16 text-center"
>
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<KeyRound class="h-8 w-8 text-muted-foreground" />
</div>
<p class="text-sm text-muted-foreground mt-4">
2026-05-01 02:14:49 +08:00
{{ hasPoolKeyFilters ? '未找到匹配账号' : '暂无账号' }}
</p>
<Button
2026-05-01 02:14:49 +08:00
v-if="hasPoolKeyFilters"
variant="outline"
size="sm"
class="mt-3"
@click="clearPoolKeyFilters"
>
清除筛选
</Button>
<Button
v-else
variant="outline"
size="sm"
class="mt-3"
@click="showImportDialog = true"
>
<Upload class="w-3.5 h-3.5 mr-1.5" />
添加账号
</Button>
</div>
<!-- Pagination -->
<Pagination
v-if="keyPage.keys.length > 0"
:current="currentPage"
:total="keyPage.total"
:page-size="pageSize"
cache-key="pool-keys-page-size"
@update:current="currentPage = $event"
@update:page-size="pageSize = $event"
/>
</template>
</Card>
<!-- Dialogs -->
<OAuthAccountDialog
v-if="selectedProviderId"
:open="showImportDialog"
:provider-id="selectedProviderId"
:provider-type="selectedProviderType || null"
@close="showImportDialog = false"
@saved="handleAccountDialogSaved"
/>
<PoolSchedulingDialog
v-if="selectedProviderId"
v-model="showSchedulingDialog"
:provider-id="selectedProviderId"
:provider-type="selectedProviderType"
:current-config="selectedProviderConfig"
@saved="handleSchedulingSaved"
/>
<PoolAdvancedDialog
v-if="selectedProviderId"
v-model="showAdvancedDialog"
:provider-id="selectedProviderId"
:provider-type="selectedProviderType"
:current-config="selectedProviderConfig"
:current-claude-config="selectedProviderClaudeConfig"
@saved="handleSchedulingSaved"
/>
<PoolDemandMetricsDialog
v-model="showDemandMetricsDialog"
:provider-name="selectedProviderOverview?.provider_name"
:samples="providerDemandMetricSamples"
/>
<ProviderDetailDrawer
v-if="providerDrawerMounted && selectedProviderId"
:open="providerDrawerOpen"
:provider-id="selectedProviderId"
:initial-provider="selectedProviderData"
@update:open="providerDrawerOpen = $event"
@edit="openProviderEditDialog"
@toggle-status="toggleSelectedProviderStatus"
@refresh="handleProviderDrawerRefresh"
/>
2026-05-13 09:16:52 +08:00
<ProviderFormDialog
v-model="providerEditDialogOpen"
:provider="providerToEdit"
@provider-updated="handleProviderEditSaved"
/>
<PoolAccountBatchDialog
v-if="selectedProviderId"
v-model="showAccountBatchDialog"
:provider-id="selectedProviderId"
:provider-name="selectedProviderData?.name || ''"
:provider-type="selectedProviderData?.provider_type ?? selectedProviderOverview?.provider_type ?? undefined"
:batch-concurrency="selectedProviderConfig?.batch_concurrency"
:selected-keys="selectedPoolKeys"
:select-all-filtered="selectAllFilteredPoolKeys"
:selected-count="selectedKeyCount"
:selection-filters="poolKeySelectionFilters"
:initial-action="pendingAccountBatchAction"
@changed="handleAccountBatchChanged"
@edit-config="openKeyBatchEditDialog"
/>
<PoolKeyBatchEditDialog
v-if="selectedProviderId"
:open="keyBatchEditDialogOpen"
:provider-id="selectedProviderId"
:provider-name="selectedProviderData?.name || ''"
:key-ids="keyBatchEditKeyIds"
:available-api-formats="selectedProviderData?.api_formats || []"
@close="closeKeyBatchEditDialog"
@saved="handleKeyBatchEditSaved"
/>
<KeyFormDialog
v-if="selectedProviderId"
:open="keyFormDialogOpen"
:endpoint="null"
:provider-type="selectedProviderData?.provider_type ?? selectedProviderOverview?.provider_type ?? null"
:editing-key="editingKey"
:provider-id="selectedProviderId"
:available-api-formats="selectedProviderData?.api_formats || []"
@close="closeKeyFormDialog"
@saved="handleDialogSaved"
/>
<OAuthKeyEditDialog
:open="oauthKeyEditDialogOpen"
:editing-key="editingKey"
@close="closeOAuthEditDialog"
@saved="handleDialogSaved"
/>
<KeyAllowedModelsEditDialog
v-if="selectedProviderId"
:open="keyPermissionsDialogOpen"
:api-key="editingKey"
:provider-id="selectedProviderId || ''"
@close="closeKeyPermissionsDialog"
@saved="handleDialogSaved"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, defineAsyncComponent } from 'vue'
import {
Upload,
RefreshCw,
Power,
Database,
KeyRound,
Download,
Copy,
Shield,
Globe,
2026-05-07 03:23:20 +08:00
RotateCcw,
SquarePen,
Trash2,
2026-05-12 15:27:21 +08:00
CircleHelp,
} from 'lucide-vue-next'
import {
Card,
Badge,
Button,
Checkbox,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
2026-05-01 02:14:49 +08:00
SortableTableHead,
TableFilterMenu,
TableCell,
Pagination,
Popover,
PopoverTrigger,
PopoverContent,
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { useCountdownTimer, getCodexResetCountdown } from '@/composables/useCountdownTimer'
import { useConfirm } from '@/composables/useConfirm'
import { useRouteQuery } from '@/composables/useRouteQuery'
import { useBatchSelection } from '@/composables/useBatchSelection'
import { useI18n } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import { parseApiError } from '@/utils/errorParser'
import {
getPoolOverview,
getPoolSchedulingPresets,
listPoolKeys,
clearPoolCooldown,
} from '@/api/endpoints/pool'
import {
revealEndpointKey,
exportKey,
deleteEndpointKey,
updateProviderKey,
refreshProviderQuota,
consumeCodexResetCredit,
2026-05-07 03:23:20 +08:00
resetProviderKeyCycleStats,
} from '@/api/endpoints/keys'
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
import type {
PoolOverviewItem,
PoolKeyDetail,
PoolKeySelectionRequest,
PoolKeysPageResponse,
PoolPresetMeta,
} from '@/api/endpoints/pool'
import type {
ClaudeCodeAdvancedConfig,
EndpointAPIKey,
PoolAdvancedConfig,
ProviderWithEndpointsSummary,
} from '@/api/endpoints/types/provider'
import type { QuotaStatusSnapshot, QuotaWindowSnapshot } from '@/api/endpoints/types'
import { getProvider, updateProvider } from '@/api/endpoints'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue'
import PoolAdvancedDialog from '@/features/pool/components/PoolAdvancedDialog.vue'
import PoolDemandMetricsDialog from '@/features/pool/components/PoolDemandMetricsDialog.vue'
import PoolAccountBatchDialog from '@/features/pool/components/PoolAccountBatchDialog.vue'
import PoolKeyBatchEditDialog from '@/features/pool/components/PoolKeyBatchEditDialog.vue'
import PoolManagementHeader from '@/features/pool/components/PoolManagementHeader.vue'
import PoolKeyQuotaPanel from '@/features/pool/components/PoolKeyQuotaPanel.vue'
import PoolKeyStatsPanel from '@/features/pool/components/PoolKeyStatsPanel.vue'
import KeyAllowedModelsEditDialog from '@/features/providers/components/KeyAllowedModelsEditDialog.vue'
import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue'
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
2026-05-13 09:16:52 +08:00
import ProviderFormDialog from '@/features/providers/components/ProviderFormDialog.vue'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
import {
buildPoolMobileTagItems,
splitPoolMobileActions,
type PoolMobileActionId,
type PoolMobileTagItem,
type PoolMobileTagTone,
} from '@/features/pool/utils/poolMobilePresentation'
import {
buildPoolManagementQueryPatch,
readPoolManagementViewState,
resolvePoolManagementPageAfterLoad,
2026-05-01 02:14:49 +08:00
type PoolManagementSortBy,
type PoolManagementSortOrder,
type PoolManagementViewState,
writePoolManagementViewState,
} from '@/features/pool/utils/poolManagementState'
import type { PoolBatchActionValue } from '@/features/pool/utils/poolBatchActions'
2026-05-06 23:14:25 +08:00
import {
buildAccountTotalStatsDisplay,
2026-05-06 23:14:25 +08:00
buildPoolStatsDisplay,
type PoolCodexCycleStatsGroup,
type PoolStatsDisplay,
type PoolStatsMetric,
} from '@/features/pool/utils/poolStatsDisplay'
import { resetCodexCycleUsageWindows } from '@/features/pool/utils/poolCycleStats'
import { mergePoolKeyQuotaSnapshots } from '@/features/pool/utils/poolQuotaRefresh'
import { resolveAntigravityQuotaGroupLabel } from '@/features/providers/utils/antigravityQuota'
import {
clearPendingCodexResetCreditIdempotencyKey,
clearPendingCodexResetCreditIdempotencyKeyForOutcome,
createCodexResetCreditIdempotencyKey,
formatCodexResetCreditCount,
formatCodexResetCreditExpiresAt,
getCodexResetCreditAvailableCount,
getCodexResetCreditReservationIdempotencyKey,
getVisibleCodexResetCreditItems,
2026-09-04 11:34:41 +08:00
mergeCodexQuotaDisplays,
readPendingCodexResetCreditIdempotencyKey,
rememberPendingCodexResetCreditIdempotencyKey,
} from '@/features/providers/components/codex-reset-credit-display'
2026-07-17 20:18:04 +08:00
import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
2026-07-17 20:18:04 +08:00
import { formatOAuthPlanType, getOAuthPlanTypeClass } from '@/utils/oauthPlanType'
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
import {
canEditOAuthCredential,
canExportOAuthCredential,
canRefreshOAuthCredential,
getProviderAuthLabel,
getProviderMaskedSecretLabel,
isOAuthManagedCredential,
isServiceAccountCredential,
2026-05-01 02:14:49 +08:00
shouldShowOAuthRefreshControl,
} from '@/utils/providerKeyAuth'
import {
getAccountStatusDisplay,
getAccountStatusTitle,
getOAuthRefreshButtonTitle as resolveOAuthRefreshButtonTitle,
2026-05-01 02:14:49 +08:00
getOAuthStatusDisplayWithFallback,
getOAuthStatusTitle as resolveOAuthStatusTitle,
} from '@/utils/providerKeyStatus'
import {
2026-05-22 16:48:15 +08:00
getGeminiCliAccountCreditsText,
getLegacyAccountQuotaText,
getQuotaDisplayText,
} from '@/utils/providerKeyQuota'
const loadProviderDetailDrawer = () => import('@/features/providers/components/ProviderDetailDrawer.vue')
const ProviderDetailDrawer = defineAsyncComponent(loadProviderDetailDrawer)
function prefetchProviderDetailDrawer(): void {
void loadProviderDetailDrawer().catch(() => {})
}
2026-05-12 15:27:21 +08:00
type PoolKeyScore = NonNullable<PoolKeyDetail['pool_score']>
const { success, error: showError, warning: showWarning } = useToast()
const { legacyT, t } = useI18n()
const { confirm } = useConfirm()
const { copyToClipboard } = useClipboard()
const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer()
const proxyNodesStore = useProxyNodesStore()
const { getQueryValue, patchQuery } = useRouteQuery()
const poolManagementViewStorage = typeof window === 'undefined' ? undefined : window.sessionStorage
const restoredViewState = readPoolManagementViewState(
{
providerId: getQueryValue('providerId'),
search: getQueryValue('search'),
status: getQueryValue('status'),
page: getQueryValue('page'),
pageSize: getQueryValue('pageSize'),
2026-05-01 02:14:49 +08:00
sortBy: getQueryValue('sortBy'),
sortOrder: getQueryValue('sortOrder'),
},
poolManagementViewStorage,
)
// --- Overview ---
const poolProviders = ref<PoolOverviewItem[]>([])
const overviewLoading = ref(true)
let overviewRequestId = 0
let selectProviderRequestId = 0
let providerDataRequestId = 0
let keysRequestId = 0
let keysSearchDebounceTimer: number | null = null
let providerDetailDrawerPrefetchIdleId: number | null = null
let providerDetailDrawerPrefetchTimer: number | null = null
const keysSearchPending = ref(false)
let demandMetricsPollingTimer: number | null = null
let demandMetricsRequestId = 0
let suppressFiltersWatch = false
let hasHydratedInitialProviderSelection = false
const POOL_OVERVIEW_CACHE_TTL_MS = 10 * 1000
const POOL_KEYS_CACHE_TTL_MS = 10 * 1000
const POOL_SCHEDULING_PRESETS_CACHE_TTL_MS = 5 * 60 * 1000
const POOL_DEMAND_METRICS_SAMPLES_LIMIT = 120
const POOL_DEMAND_METRICS_POLL_INTERVAL_MS = 10 * 1000
type IdleCallbackWindow = typeof window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number
cancelIdleCallback?: (handle: number) => void
}
function scheduleProviderDetailDrawerPrefetch(): void {
const idleWindow = window as IdleCallbackWindow
if (idleWindow.requestIdleCallback) {
providerDetailDrawerPrefetchIdleId = idleWindow.requestIdleCallback(() => {
providerDetailDrawerPrefetchIdleId = null
prefetchProviderDetailDrawer()
}, { timeout: 1500 })
return
}
providerDetailDrawerPrefetchTimer = window.setTimeout(() => {
providerDetailDrawerPrefetchTimer = null
prefetchProviderDetailDrawer()
}, 800)
}
function cancelProviderDetailDrawerPrefetch(): void {
const idleWindow = window as IdleCallbackWindow
if (providerDetailDrawerPrefetchIdleId !== null) {
idleWindow.cancelIdleCallback?.(providerDetailDrawerPrefetchIdleId)
providerDetailDrawerPrefetchIdleId = null
}
if (providerDetailDrawerPrefetchTimer !== null) {
clearTimeout(providerDetailDrawerPrefetchTimer)
providerDetailDrawerPrefetchTimer = null
}
}
interface PoolDemandMetricSample {
providerId: string
sampledAt: number
hotCount: number
desiredHot: number
inFlight: number
emaInFlight: number
burstPending: boolean
}
const showDemandMetricsDialog = ref(false)
const providerDemandMetricSamples = ref<PoolDemandMetricSample[]>([])
2026-05-01 02:14:49 +08:00
const poolKeyStatusFilterOptions: Array<{ value: PoolManagementViewState['status'], label: string }> = [
{ value: 'all', label: '全部状态' },
{ value: 'available', label: '可用' },
2026-05-01 02:14:49 +08:00
{ value: 'cooldown', label: '冷却中' },
{ value: 'inactive', label: '已禁用' },
{ value: 'invalid', label: '已失效' },
{ value: 'expired', label: '已过期' },
{ value: 'account_banned', label: '账号封禁' },
{ value: 'quota_exhausted', label: '额度耗尽' },
{ value: 'account_forbidden', label: '访问受限' },
{ value: 'account_disabled', label: '账号停用' },
{ value: 'workspace_deactivated', label: '工作区停用' },
{ value: 'account_verification', label: '需要验证' },
{ value: 'account_quarantined', label: '账号隔离' },
{ value: 'account_blocked', label: '账号异常' },
{ value: 'rate_limited', label: '速率受限' },
{ value: 'cost_exhausted', label: '超限' },
2026-05-01 02:14:49 +08:00
]
const poolScoreHardStateOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'available', label: '可用' },
{ value: 'unknown', label: '未知' },
{ value: 'cooldown', label: '冷却' },
{ value: 'quota_exhausted', label: '额度耗尽' },
{ value: 'auth_invalid', label: '授权无效' },
{ value: 'banned', label: '封禁' },
{ value: 'inactive', label: '禁用' },
]
const poolScoreProbeStatusOptions = [
{ value: 'all', label: '全部探测' },
{ value: 'never', label: '未探测' },
{ value: 'ok', label: '正常' },
{ value: 'failed', label: '失败' },
{ value: 'stale', label: '过期' },
{ value: 'in_progress', label: '探测中' },
]
async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } = {}) {
const requestId = ++overviewRequestId
if (!options.silent) {
overviewLoading.value = true
}
try {
const res = await getPoolOverview({ cacheTtlMs: options.cacheTtlMs ?? 0 })
if (requestId !== overviewRequestId) return
const allProviders = Array.isArray(res.items) ? res.items : []
const enabledProviders = allProviders.filter(item => item.pool_enabled)
poolProviders.value = enabledProviders
2026-05-01 02:14:49 +08:00
const queryProviderId = getQueryValue('providerId')
const queryProviderExists = Boolean(
queryProviderId && enabledProviders.some(item => item.provider_id === queryProviderId),
)
const currentSelectedId = selectedProviderId.value
const currentSelectedExists = Boolean(
currentSelectedId && enabledProviders.some(item => item.provider_id === currentSelectedId),
)
const selectedId = currentSelectedExists
? currentSelectedId
: (queryProviderExists ? queryProviderId : currentSelectedId)
const selectedStillExists = Boolean(
selectedId && enabledProviders.some(item => item.provider_id === selectedId),
)
if (selectedStillExists && selectedId) {
// 页面刷新时可能先恢复了选中的 Provider,但列表请求尚未触发;
// overview 回来后补一次初始化拉取,确保空态不会卡住。
2026-05-01 02:14:49 +08:00
if (!hasHydratedInitialProviderSelection || selectedId !== selectedProviderId.value) {
await selectProvider(selectedId, {
preserveSearch: true,
preserveStatus: true,
preservePagination: true,
cacheTtlMs: options.cacheTtlMs ? POOL_KEYS_CACHE_TTL_MS : 0,
})
}
return
}
if (enabledProviders.length > 0) {
const fallbackProviderId = enabledProviders[0].provider_id
const shouldPreserveViewState = Boolean(selectedId)
2026-05-01 02:14:49 +08:00
await selectProvider(fallbackProviderId, {
preserveSearch: shouldPreserveViewState,
preserveStatus: shouldPreserveViewState,
preservePagination: shouldPreserveViewState,
cacheTtlMs: options.cacheTtlMs ? POOL_KEYS_CACHE_TTL_MS : 0,
})
} else {
selectedProviderId.value = null
selectedProviderData.value = null
2026-05-01 02:14:49 +08:00
keysLoadedOnce.value = false
resetPoolKeySelection(true)
providerDrawerOpen.value = false
showAccountBatchDialog.value = false
keyBatchEditDialogOpen.value = false
keyBatchEditKeyIds.value = []
resetKeyPage()
}
} catch (err) {
if (requestId !== overviewRequestId) return
if (!options.silent) {
showError(parseApiError(err))
} else {
showWarning(parseApiError(err, '同步 Provider 概览失败'))
}
} finally {
if (requestId === overviewRequestId) {
overviewLoading.value = false
}
}
}
async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSummary) {
2026-07-27 22:06:28 +08:00
if (!selectedProviderId.value || updatedProvider.id !== selectedProviderId.value) return
// 保存前发出的详情读取不得覆盖这次保存返回的新配置。
providerDataRequestId += 1
// 优先回写保存接口返回值,避免弹窗立即重开时读到旧配置。
2026-07-27 22:06:28 +08:00
if (selectedProviderData.value) {
Object.assign(selectedProviderData.value, updatedProvider)
} else {
selectedProviderData.value = updatedProvider
}
showSchedulingDialog.value = false
showAdvancedDialog.value = false
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
}
// --- Provider Selection ---
const selectedProviderId = ref<string | null>(restoredViewState.providerId)
const selectedProviderData = ref<ProviderWithEndpointsSummary | null>(null)
// Proxy for Select v-model (string, not string|null)
const selectedProviderIdProxy = computed({
get: () => selectedProviderId.value ?? '',
set: (val: string) => {
if (val && val !== selectedProviderId.value) {
void selectProvider(val, { cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
}
},
})
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
})
const selectedProviderClaudeConfig = computed(() => {
return (selectedProviderData.value as Record<string, unknown> | null)?.claude_code_advanced as ClaudeCodeAdvancedConfig | null ?? null
})
2026-07-27 22:06:28 +08:00
function defaultEnabledPresetCount(providerType: string): number {
return ['codex', 'windsurf'].includes(providerType) ? 2 : 1
}
const DEFAULT_PRESET_LABELS: Record<string, string> = {
lru: 'LRU',
2026-07-27 22:06:28 +08:00
free_team_first: 'Free/Team',
free_first: 'Free',
team_first: 'Team',
plus_first: 'Plus',
pro_first: 'Pro',
recent_refresh: '刷新优先',
quota_balanced: '额度均衡',
single_account: '单号优先',
}
const presetLabelsByName = ref<Record<string, string>>({ ...DEFAULT_PRESET_LABELS })
function normalizePresetName(value: unknown): string {
return String(value ?? '').trim().toLowerCase()
}
async function loadSchedulingPresetMetas(options: { cacheTtlMs?: number } = {}): Promise<void> {
try {
const metas = await getPoolSchedulingPresets({ cacheTtlMs: options.cacheTtlMs ?? 0 })
const next: Record<string, string> = {}
for (const meta of metas as PoolPresetMeta[]) {
const name = normalizePresetName(meta.name)
if (!name) continue
const label = String(meta.label ?? '').trim()
next[name] = label || name
}
if (Object.keys(next).length > 0) {
presetLabelsByName.value = next
}
} catch {
presetLabelsByName.value = { ...DEFAULT_PRESET_LABELS }
}
}
const selectedProviderOverview = computed<PoolOverviewItem | null>(() => {
const selectedId = selectedProviderId.value
if (!selectedId) return null
return poolProviders.value.find(item => item.provider_id === selectedId) || null
})
const showAdaptiveHotPoolMetricsButton = computed(() => {
if (!selectedProviderId.value) return false
return selectedProviderConfig.value?.probing_enabled === true
})
function normalizeDemandMetricNumber(value: unknown): number {
const normalized = Number(value ?? 0)
if (!Number.isFinite(normalized) || normalized <= 0) return 0
return normalized
}
function buildDemandMetricSample(overview: PoolOverviewItem): PoolDemandMetricSample {
return {
providerId: overview.provider_id,
sampledAt: Date.now(),
hotCount: Math.floor(normalizeDemandMetricNumber(overview.provider_hot_count)),
desiredHot: Math.floor(normalizeDemandMetricNumber(overview.provider_desired_hot)),
inFlight: Math.floor(normalizeDemandMetricNumber(overview.provider_in_flight)),
emaInFlight: normalizeDemandMetricNumber(overview.provider_ema_in_flight),
burstPending: overview.provider_burst_pending === true,
}
}
function appendDemandMetricSample(overview: PoolOverviewItem | null): void {
if (!overview || !showDemandMetricsDialog.value || !showAdaptiveHotPoolMetricsButton.value) return
const nextSample = buildDemandMetricSample(overview)
const existing = providerDemandMetricSamples.value.filter(
sample => sample.providerId === overview.provider_id,
)
const lastSample = existing[existing.length - 1]
if (
lastSample
&& nextSample.sampledAt - lastSample.sampledAt < 1000
&& lastSample.hotCount === nextSample.hotCount
&& lastSample.desiredHot === nextSample.desiredHot
&& lastSample.inFlight === nextSample.inFlight
&& lastSample.emaInFlight === nextSample.emaInFlight
&& lastSample.burstPending === nextSample.burstPending
) {
providerDemandMetricSamples.value = existing
return
}
providerDemandMetricSamples.value = [...existing, nextSample]
.slice(-POOL_DEMAND_METRICS_SAMPLES_LIMIT)
}
function stopDemandMetricsPolling(): void {
if (demandMetricsPollingTimer !== null) {
window.clearInterval(demandMetricsPollingTimer)
demandMetricsPollingTimer = null
}
}
async function refreshDemandMetricsOverview(): Promise<void> {
const providerId = selectedProviderId.value
if (!showDemandMetricsDialog.value || !showAdaptiveHotPoolMetricsButton.value || !providerId) {
return
}
const requestId = ++demandMetricsRequestId
try {
const res = await getPoolOverview({ cacheTtlMs: 0 })
if (
requestId !== demandMetricsRequestId
|| !showDemandMetricsDialog.value
|| selectedProviderId.value !== providerId
) {
return
}
const allProviders = Array.isArray(res.items) ? res.items : []
const enabledProviders = allProviders.filter(item => item.pool_enabled)
poolProviders.value = enabledProviders
appendDemandMetricSample(
enabledProviders.find(item => item.provider_id === providerId) || null,
)
} catch {
// 指标弹窗只做尽力刷新,失败不打断主流程。
}
}
function startDemandMetricsPolling(): void {
stopDemandMetricsPolling()
appendDemandMetricSample(selectedProviderOverview.value)
void refreshDemandMetricsOverview()
demandMetricsPollingTimer = window.setInterval(() => {
if (!showDemandMetricsDialog.value || !showAdaptiveHotPoolMetricsButton.value) return
if (document.visibilityState === 'hidden') return
void refreshDemandMetricsOverview()
}, POOL_DEMAND_METRICS_POLL_INTERVAL_MS)
}
const poolSchedulingLabel = computed(() => {
if (!selectedProviderConfig.value && selectedProviderOverview.value?.pool_enabled === false) {
return '未启用'
}
const cfg = selectedProviderConfig.value
// No pool_advanced config at all: use default enabled presets count
2026-07-27 22:06:28 +08:00
if (!cfg) return `${defaultEnabledPresetCount(selectedProviderType.value)} 维度`
const presets = Array.isArray(cfg.scheduling_presets) ? cfg.scheduling_presets : []
const presetLabels = presetLabelsByName.value
if (presets.length > 0) {
// New format: object list with { preset, enabled }
const first = presets[0]
if (typeof first === 'object' && first !== null && 'preset' in first) {
const enabledCount = (presets as Array<{ preset: string; enabled?: boolean }>)
.filter(p => p.enabled !== false)
.length
return enabledCount > 0 ? `${enabledCount} 维度` : '无启用维度'
}
// Legacy string list format
if (typeof first === 'string') {
const labels = (presets as string[])
.map(p => presetLabels[normalizePresetName(p)])
.filter(Boolean)
if (labels.length > 0) return `${labels.length} 维度`
}
}
// Fallback: legacy scheduling_mode field
if (cfg.scheduling_mode === 'multi_score') {
return '多维评分'
}
const lruEnabled = cfg.scheduling_mode === 'lru' || cfg.lru_enabled === true
const stickyTtl = Number(cfg.sticky_session_ttl_seconds ?? 3600)
const stickyEnabled = Number.isFinite(stickyTtl) && stickyTtl > 0
if (lruEnabled && stickyEnabled) return 'LRU + 粘性'
if (lruEnabled) return 'LRU'
if (!cfg.scheduling_mode && (cfg.lru_enabled === null || cfg.lru_enabled === undefined)) {
2026-07-27 22:06:28 +08:00
return `${defaultEnabledPresetCount(selectedProviderType.value)} 维度`
}
if (stickyEnabled) return '粘性'
return '随机'
})
const selectedProviderType = computed(() => {
const fromDetail = String(selectedProviderData.value?.provider_type || '').trim().toLowerCase()
if (fromDetail) return fromDetail
const fromOverview = selectedProviderOverview.value?.provider_type
return String(fromOverview || '').trim().toLowerCase()
})
const selectedProviderStatusText = computed(() => {
if (!selectedProviderId.value) return ''
const providerActive = selectedProviderData.value?.is_active
if (providerActive === false) return '禁用'
if (providerActive === true) return '启用'
if (selectedProviderOverview.value?.pool_enabled === false) return '禁用'
if (selectedProviderOverview.value?.pool_enabled === true) return '启用'
return ''
})
function formatDemandEma(value: number | undefined): string {
const normalized = Number(value ?? 0)
if (!Number.isFinite(normalized) || normalized <= 0) return '0.0'
return normalized.toFixed(1)
}
const selectedProviderDemandMetaText = computed(() => {
const overview = selectedProviderOverview.value
if (!overview) return ''
const segments: string[] = []
const desiredHot = Number(overview.provider_desired_hot ?? 0)
const hotCount = Number(overview.provider_hot_count ?? 0)
const inFlight = Number(overview.provider_in_flight ?? 0)
if (Number.isFinite(desiredHot) && desiredHot > 0) {
segments.push(`热池 ${hotCount} / ${desiredHot}`)
segments.push(`EMA ${formatDemandEma(overview.provider_ema_in_flight)}`)
}
if (Number.isFinite(inFlight) && inFlight > 0) {
segments.push(`in-flight ${inFlight}`)
}
if (overview.provider_burst_pending) {
segments.push('补热中')
}
return segments.join(' | ')
})
const poolHeaderMetaText = computed(() => {
return [
selectedProviderType.value,
selectedProviderStatusText.value,
selectedProviderDemandMetaText.value,
].filter(Boolean).join(' | ')
})
watch(showDemandMetricsDialog, (open) => {
if (open) {
startDemandMetricsPolling()
} else {
stopDemandMetricsPolling()
}
})
watch(selectedProviderId, () => {
providerDemandMetricSamples.value = []
if (showDemandMetricsDialog.value) {
appendDemandMetricSample(selectedProviderOverview.value)
}
})
watch(selectedProviderOverview, (overview) => {
appendDemandMetricSample(overview)
})
watch(showAdaptiveHotPoolMetricsButton, (enabled) => {
if (!enabled && showDemandMetricsDialog.value) {
showDemandMetricsDialog.value = false
}
})
const showAccountQuotaColumn = computed(() => {
return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'claude_code'
2026-03-10 23:07:50 +08:00
|| selectedProviderType.value === 'gemini_cli'
|| selectedProviderType.value === 'kiro'
|| selectedProviderType.value === 'windsurf'
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
2026-05-06 22:58:47 +08:00
|| selectedProviderType.value === 'chatgpt_web'
|| selectedProviderType.value === 'xai'
})
const desktopColumnWidths = computed(() => {
if (showAccountQuotaColumn.value) {
return {
name: '19%',
2026-05-12 15:27:21 +08:00
quota: '18%',
stats: '15%',
2026-05-01 02:14:49 +08:00
imported: '10%',
2026-05-12 15:27:21 +08:00
lastUsed: '8%',
score: '9%',
2026-05-01 02:14:49 +08:00
status: '7%',
2026-05-12 15:27:21 +08:00
actions: '14%',
}
}
return {
2026-05-12 15:27:21 +08:00
name: '31%',
quota: '0%',
2026-05-12 15:27:21 +08:00
stats: '15%',
imported: '11%',
lastUsed: '11%',
score: '9%',
status: '8%',
actions: '15%',
}
})
async function selectProvider(
id: string,
options: {
preserveSearch?: boolean
preserveStatus?: boolean
preservePagination?: boolean
cacheTtlMs?: number
} = {},
) {
const requestId = ++selectProviderRequestId
hasHydratedInitialProviderSelection = true
selectedProviderId.value = id
selectedProviderData.value = null
2026-07-27 22:06:28 +08:00
showSchedulingDialog.value = false
showAdvancedDialog.value = false
resetPoolKeySelection(true)
providerDrawerOpen.value = false
editingKeyDetail.value = null
showAccountBatchDialog.value = false
keyBatchEditDialogOpen.value = false
keyBatchEditKeyIds.value = []
keyPermissionsDialogOpen.value = false
keyFormDialogOpen.value = false
oauthKeyEditDialogOpen.value = false
proxyDesktopPopoverOpenKeyId.value = null
proxyMobilePopoverOpenKeyId.value = null
2026-05-12 16:04:00 +08:00
scoreDesktopPopoverOpenKeyId.value = null
scoreMobilePopoverOpenKeyId.value = null
suppressFiltersWatch = true
if (!options.preservePagination) {
currentPage.value = 1
}
if (!options.preserveSearch) {
searchQuery.value = ''
}
if (!options.preserveStatus) {
statusFilter.value = 'all'
}
suppressFiltersWatch = false
if (keysSearchDebounceTimer !== null) {
clearTimeout(keysSearchDebounceTimer)
keysSearchDebounceTimer = null
}
keysSearchPending.value = false
2026-05-01 02:14:49 +08:00
keysLoadedOnce.value = false
resetKeyPage(currentPage.value, pageSize.value)
const keysTask = loadKeys({ cacheTtlMs: options.cacheTtlMs ?? 0 })
// Provider summary is non-blocking for key list rendering.
void loadProviderData(id)
await keysTask
if (requestId !== selectProviderRequestId) return
}
async function loadProviderData(id: string, options: { preserveOnError?: boolean } = {}) {
const requestId = ++providerDataRequestId
try {
const providerData = await getProvider(id)
if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return
if (selectedProviderData.value?.id === providerData.id) {
Object.assign(selectedProviderData.value, providerData)
} else {
selectedProviderData.value = providerData
}
} catch {
if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return
if (options.preserveOnError) {
showWarning('同步 Provider 详情失败,已保留当前数据')
} else {
selectedProviderData.value = null
}
}
}
async function refresh() {
await loadKeys()
}
// --- Keys ---
function createEmptyKeyPage(page = 1, pageSizeValue = 50): PoolKeysPageResponse {
return { total: 0, page, page_size: pageSizeValue, keys: [] }
}
const keyPage = ref<PoolKeysPageResponse>(createEmptyKeyPage())
const keysLoading = ref(false)
const poolKeySelectionBusy = computed(() => keysLoading.value || keysSearchPending.value)
2026-05-01 02:14:49 +08:00
const keysLoadedOnce = ref(false)
const poolKeyPageItems = computed(() => keyPage.value.keys)
const poolKeyFilteredTotal = computed(() => keyPage.value.total)
const {
selectedIds: selectedPoolKeyIds,
selectedIdSet: selectedPoolKeyIdSet,
selectedCount: selectedKeyCount,
selectAllFiltered: selectAllFilteredPoolKeys,
isAllFilteredSelected: isAllFilteredPoolKeysSelected,
isCurrentPageFullySelected: isCurrentPoolKeyPageFullySelected,
rememberItems: rememberPoolKeys,
knownItemsById: knownPoolKeysById,
resetSelection: resetPoolKeySelection,
toggleOne: togglePoolKeySelection,
toggleSelectFiltered: toggleSelectFilteredPoolKeys,
toggleSelectCurrentPage: toggleCurrentPoolKeyPage,
} = useBatchSelection<PoolKeyDetail>({
pageItems: poolKeyPageItems,
filteredTotal: poolKeyFilteredTotal,
getItemId: key => key.key_id,
})
const selectedKeyCountLabel = computed(() => legacyT(`已选 ${selectedKeyCount.value} 个`))
const selectedPoolKeys = computed(() => selectedPoolKeyIds.value
.map(keyId => knownPoolKeysById.value[keyId])
.filter((key): key is PoolKeyDetail => Boolean(key)))
const selectedOnCurrentPoolKeyPageCount = computed(() => poolKeyPageItems.value
.filter(key => selectedPoolKeyIdSet.value.has(key.key_id)).length)
const isCurrentPoolKeyPagePartiallySelected = computed(() => (
selectedOnCurrentPoolKeyPageCount.value > 0
&& !isCurrentPoolKeyPageFullySelected.value
))
watch(poolKeyPageItems, (keys) => rememberPoolKeys(keys), { immediate: true })
function isPoolKeySelected(keyId: string): boolean {
return selectAllFilteredPoolKeys.value || selectedPoolKeyIdSet.value.has(keyId)
}
function toggleAllFilteredPoolKeys(): void {
if (poolKeySelectionBusy.value || keyPage.value.total === 0) return
toggleSelectFilteredPoolKeys(!isAllFilteredPoolKeysSelected.value)
}
function getPoolKeyRowClass(keyId: string): string {
return [
keyUiStateMap.value[keyId]?.rowClass || '',
isPoolKeySelected(keyId) ? 'bg-primary/5' : '',
].filter(Boolean).join(' ')
}
const refreshingCurrentPageQuota = ref(false)
const searchQuery = ref(restoredViewState.search)
const statusFilter = ref(restoredViewState.status)
const poolKeySelectionFilters = computed<PoolKeySelectionRequest>(() => {
const search = searchQuery.value.trim()
return {
...(search ? { search } : {}),
status: statusFilter.value,
}
})
const currentPage = ref(restoredViewState.page)
const pageSize = ref(restoredViewState.pageSize)
2026-05-01 02:14:49 +08:00
const sortBy = ref<PoolManagementSortBy | null>(restoredViewState.sortBy)
const sortOrder = ref<PoolManagementSortOrder>(restoredViewState.sortOrder)
const hasPoolKeyFilters = computed(() => searchQuery.value.trim().length > 0 || statusFilter.value !== 'all')
const MANUAL_QUOTA_REFRESH_COOLDOWN_SECONDS = 5 * 60
const refreshingOAuthKeyId = ref<string | null>(null)
const consumingCodexResetCreditKeyId = ref<string | null>(null)
2026-05-07 03:23:20 +08:00
const resettingCycleKeyId = ref<string | null>(null)
const savingProxyKeyId = ref<string | null>(null)
const proxyDesktopPopoverOpenKeyId = ref<string | null>(null)
const proxyMobilePopoverOpenKeyId = ref<string | null>(null)
2026-05-12 16:04:00 +08:00
const scoreDesktopPopoverOpenKeyId = ref<string | null>(null)
const scoreMobilePopoverOpenKeyId = ref<string | null>(null)
const deletingKeyId = ref<string | null>(null)
const togglingKeyId = ref<string | null>(null)
const editingPriorityKeyId = ref<string | null>(null)
const editingPriorityValue = ref<number>(0)
const prioritySavingKeyId = ref<string | null>(null)
const keyPermissionsDialogOpen = ref(false)
const keyFormDialogOpen = ref(false)
const keyBatchEditDialogOpen = ref(false)
const keyBatchEditKeyIds = ref<string[]>([])
const oauthKeyEditDialogOpen = ref(false)
const editingKeyDetail = ref<PoolKeyDetail | null>(null)
2026-05-01 02:14:49 +08:00
function clearPoolKeyFilters() {
if (!hasPoolKeyFilters.value) return
resetPoolKeySelection(true)
if (keysSearchDebounceTimer !== null) {
clearTimeout(keysSearchDebounceTimer)
keysSearchDebounceTimer = null
}
keysSearchPending.value = false
2026-05-01 02:14:49 +08:00
suppressFiltersWatch = true
searchQuery.value = ''
statusFilter.value = 'all'
suppressFiltersWatch = false
if (currentPage.value !== 1) {
currentPage.value = 1
return
}
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
}
watch(
() => getQueryValue('search') ?? '',
(value) => {
if (searchQuery.value === value) return
searchQuery.value = value
},
{ immediate: true },
)
watch(
() => readPoolManagementViewState({ status: getQueryValue('status') }).status,
(value) => {
if (statusFilter.value === value) return
suppressFiltersWatch = true
statusFilter.value = value
suppressFiltersWatch = false
},
{ immediate: true },
)
watch(
() => readPoolManagementViewState({ page: getQueryValue('page') }).page,
(value) => {
if (currentPage.value === value) return
currentPage.value = value
},
{ immediate: true },
)
watch(
() => readPoolManagementViewState({ pageSize: getQueryValue('pageSize') }).pageSize,
(value) => {
if (pageSize.value === value) return
pageSize.value = value
},
{ immediate: true },
)
2026-05-01 02:14:49 +08:00
watch(
() => readPoolManagementViewState({
sortBy: getQueryValue('sortBy'),
sortOrder: getQueryValue('sortOrder'),
}),
(value) => {
if (sortBy.value === value.sortBy && sortOrder.value === value.sortOrder) return
sortBy.value = value.sortBy
sortOrder.value = value.sortOrder
},
{ immediate: true },
)
watch(
() => getQueryValue('providerId'),
(value) => {
2026-05-01 02:14:49 +08:00
if (overviewLoading.value) return
if (!value || value === selectedProviderId.value) return
2026-05-01 02:14:49 +08:00
if (!poolProviders.value.some(item => item.provider_id === value)) return
void selectProvider(value, {
preserveSearch: true,
preserveStatus: true,
preservePagination: true,
cacheTtlMs: POOL_KEYS_CACHE_TTL_MS,
})
},
)
watch(
2026-07-17 20:18:04 +08:00
[selectedProviderId, searchQuery, statusFilter, currentPage, pageSize, sortBy, sortOrder],
([providerId, search, status, page, pageSizeValue, sortByValue, sortOrderValue]) => {
const nextState: PoolManagementViewState = {
providerId,
search,
status: status as PoolManagementViewState['status'],
page,
pageSize: pageSizeValue,
2026-05-01 02:14:49 +08:00
sortBy: sortByValue,
sortOrder: sortOrderValue,
2026-07-17 20:18:04 +08:00
statsMode: 'current_cycle',
}
patchQuery(buildPoolManagementQueryPatch(nextState))
writePoolManagementViewState(nextState, poolManagementViewStorage)
},
{ immediate: true },
)
interface QuotaProgressItem {
label: string
remainingPercent: number
numericOnly?: boolean
2026-07-17 20:18:04 +08:00
sortOrder?: number
detail?: string
resetAtSeconds?: number | null
resetSeconds?: number | null
updatedAtSeconds?: number | null
2026-05-22 16:48:15 +08:00
allowDynamicReset?: boolean
}
interface QuotaProgressDisplayItem {
label: string
remainingPercent: number
resetText: string
meterText: string
barClass: string
meterClass: string
numericOnly?: boolean
}
type PoolKeyUiState = {
rowClass: string
schedulingBadgeLabel: string
schedulingBadgeVariant: PoolStatusVariant
schedulingTitle: string
oauthOrgBadge: ReturnType<typeof getOAuthOrgBadge>
2026-05-01 02:14:49 +08:00
visibleOAuthState: ReturnType<typeof getOAuthStatusDisplayWithFallback>
oauthStatusTitle: string
oauthRefreshButtonTitle: string
2026-05-01 02:14:49 +08:00
showOAuthRefreshControl: boolean
canRefreshToken: boolean
planLabel: string
planClass: string
2026-05-22 16:48:15 +08:00
accountQuotaText: string | null
quotaFallbackText: string | null
quotaTextClass: string
2026-05-01 02:14:49 +08:00
importedAtRelative: string
lastUsedRelative: string
2026-05-06 23:14:25 +08:00
statsDisplay: PoolStatsDisplay
mobileTagItems: PoolMobileTagItem[]
mobileActionIds: PoolMobileActionId[]
}
const quotaProgressMap = computed<Record<string, QuotaProgressItem[]>>(() => {
const map: Record<string, QuotaProgressItem[]> = {}
for (const key of keyPage.value.keys) {
map[key.key_id] = parseQuotaProgressItems(key)
}
return map
})
const quotaProgressDisplayMap = computed<Record<string, QuotaProgressDisplayItem[]>>(() => {
const map: Record<string, QuotaProgressDisplayItem[]> = {}
for (const key of keyPage.value.keys) {
map[key.key_id] = (quotaProgressMap.value[key.key_id] || []).map(item => ({
label: getQuotaProgressLabel(item.label),
remainingPercent: item.remainingPercent,
resetText: getQuotaProgressResetDisplayText(item),
meterText: item.numericOnly
? item.detail || formatQuotaValue(item.remainingPercent)
: getQuotaProgressMeterDisplayText(item),
barClass: getQuotaRemainingBarColorByRemaining(item.remainingPercent),
meterClass: getQuotaRemainingClassByRemaining(item.remainingPercent),
numericOnly: item.numericOnly,
}))
}
return map
})
const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
const map: Record<string, PoolKeyUiState> = {}
for (const key of keyPage.value.keys) {
const visibleOAuthState = getVisibleOAuthState(key)
const oauthOrgBadge = getOAuthOrgBadge(key)
2026-05-22 16:48:15 +08:00
const accountQuotaText = getAccountQuotaText(key)
const quotaFallbackText = getQuotaFallbackText(key)
const planType = resolvePoolKeyPlanType(key)
const canRefreshToken = canRefreshOAuthCredential(key)
const showOAuthRefreshControl = shouldShowOAuthRefreshControl(key, selectedProviderType.value)
map[key.key_id] = {
rowClass: getRowClass(key),
schedulingBadgeLabel: getSchedulingBadgeLabel(key),
schedulingBadgeVariant: getSchedulingBadgeVariant(key),
schedulingTitle: getSchedulingTitle(key),
oauthOrgBadge,
visibleOAuthState,
oauthStatusTitle: visibleOAuthState ? getOAuthStatusTitle(key) : '',
2026-05-01 02:14:49 +08:00
oauthRefreshButtonTitle: showOAuthRefreshControl ? getOAuthRefreshButtonTitle(key) : '',
showOAuthRefreshControl,
canRefreshToken,
planLabel: planType ? formatOAuthPlanType(planType) : '',
planClass: planType ? getOAuthPlanTypeClass(planType) : '',
2026-05-22 16:48:15 +08:00
accountQuotaText,
quotaFallbackText,
2026-05-22 16:48:15 +08:00
quotaTextClass: accountQuotaText || quotaFallbackText
? getQuotaTextClass(accountQuotaText || quotaFallbackText || '')
: '',
2026-05-01 02:14:49 +08:00
importedAtRelative: formatPoolKeyImportedAt(key),
lastUsedRelative: key.last_used_at ? formatRelativeTime(key.last_used_at) : '-',
2026-07-17 20:18:04 +08:00
statsDisplay: buildPoolStatsDisplay(key, selectedProviderType.value, 'current_cycle'),
mobileTagItems: getMobileTagItems(key),
mobileActionIds: splitPoolMobileActions({
canDownloadOrCopy: true,
2026-05-01 02:14:49 +08:00
showRefreshToken: showOAuthRefreshControl,
2026-05-07 03:23:20 +08:00
canResetCycleStats: canResetCycleStats(key),
canClearCooldown: Boolean(key.cooldown_reason),
hasProxy: true,
}).primary,
}
}
return map
})
2026-05-06 23:14:25 +08:00
function getPoolKeyStatsDisplay(key: PoolKeyDetail): PoolStatsDisplay {
return keyUiStateMap.value[key.key_id]?.statsDisplay
2026-07-17 20:18:04 +08:00
?? buildPoolStatsDisplay(key, selectedProviderType.value, 'current_cycle')
2026-05-06 23:14:25 +08:00
}
function isPoolKeyCycleStatsDisplay(key: PoolKeyDetail): boolean {
return getPoolKeyStatsDisplay(key).kind === 'codex_cycle'
}
function getPoolKeyCycleStatsGroups(key: PoolKeyDetail): PoolCodexCycleStatsGroup[] {
const display = getPoolKeyStatsDisplay(key)
return display.kind === 'codex_cycle' ? display.groups : []
}
function getPoolKeyAccountStatsMetrics(key: PoolKeyDetail): PoolStatsMetric[] {
const display = getPoolKeyStatsDisplay(key)
return display.kind === 'account_total'
? display.metrics
: buildAccountTotalStatsDisplay(key).metrics
2026-05-06 23:14:25 +08:00
}
const quotaRefreshSupported = computed(() => {
return selectedProviderType.value === 'codex'
|| selectedProviderType.value === 'claude_code'
|| selectedProviderType.value === 'kiro'
2026-05-22 16:48:15 +08:00
|| selectedProviderType.value === 'gemini_cli'
|| selectedProviderType.value === 'windsurf'
|| selectedProviderType.value === 'antigravity'
|| selectedProviderType.value === 'grok'
2026-05-06 22:58:47 +08:00
|| selectedProviderType.value === 'chatgpt_web'
|| selectedProviderType.value === 'xai'
})
2026-05-07 03:23:20 +08:00
function canResetCycleStats(_key: PoolKeyDetail): boolean {
return selectedProviderType.value === 'codex' && Boolean(_key.key_id)
}
const refreshCurrentPageLoading = computed(() => {
return keysLoading.value || refreshingCurrentPageQuota.value
})
function resetKeyPage(page = currentPage.value, pageSizeValue = pageSize.value): void {
keyPage.value = createEmptyKeyPage(page, pageSizeValue)
}
function refreshOverviewInBackground(): void {
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,
)
}
}
2026-05-09 10:44:45 +08:00
function applyQuotaRefreshResultToCurrentPage(result: Awaited<ReturnType<typeof refreshProviderQuota>>): void {
keyPage.value.keys = mergePoolKeyQuotaSnapshots(keyPage.value.keys, result.results)
2026-05-09 10:44:45 +08:00
}
function getCodexResetCredits(key: PoolKeyDetail) {
2026-09-04 11:34:41 +08:00
if (getQuotaSnapshotProviderType(key) !== 'codex') return null
const snapshot = key.status_snapshot?.quota?.reset_credits
const snapshotUpdatedAt = key.status_snapshot?.quota?.updated_at
const snapshotDisplay = snapshot
? {
...(typeof snapshotUpdatedAt === 'number' ? { updated_at: snapshotUpdatedAt } : {}),
reset_credits: snapshot,
}
: null
2026-09-04 11:34:41 +08:00
return mergeCodexQuotaDisplays(snapshotDisplay, key.upstream_metadata?.codex)?.reset_credits ?? null
}
function getCodexCredentialGeneration(key: PoolKeyDetail): string | null | undefined {
const codex = key.upstream_metadata?.codex
return codex && typeof codex === 'object'
? codex.credential_generation?.trim() || null
: undefined
}
function getPendingCodexResetCreditIdempotencyKey(key: PoolKeyDetail): string | null {
const codex = key.upstream_metadata?.codex
const serverReservation = getCodexResetCreditReservationIdempotencyKey(codex)
if (serverReservation) return serverReservation
const generation = getCodexCredentialGeneration(key)
return generation === undefined
? null
: readPendingCodexResetCreditIdempotencyKey(key.key_id, generation)
}
function getCodexResetCreditCountText(key: PoolKeyDetail): string | null {
const count = getCodexResetCreditAvailableCount(getCodexResetCredits(key))
return count === null && !getPendingCodexResetCreditIdempotencyKey(key)
? null
: formatCodexResetCreditCount(count)
}
function getCodexResetCreditItemTexts(key: PoolKeyDetail): string[] {
return getVisibleCodexResetCreditItems(getCodexResetCredits(key), undefined, 3)
.map(item => `${item.displayKey} ${formatCodexResetCreditExpiresAt(item.expiresAt)}`)
}
function canConsumeCodexResetCredit(key: PoolKeyDetail): boolean {
return getQuotaSnapshotProviderType(key) === 'codex'
&& getCodexCredentialGeneration(key) !== undefined
&& (getPendingCodexResetCreditIdempotencyKey(key) !== null
|| (getCodexResetCreditAvailableCount(getCodexResetCredits(key)) ?? 0) > 0)
&& consumingCodexResetCreditKeyId.value === null
}
async function handleConsumeCodexResetCredit(key: PoolKeyDetail): Promise<void> {
if (!canConsumeCodexResetCredit(key)) return
const generation = getCodexCredentialGeneration(key)
if (generation === undefined) return
const pendingIdempotencyKey = getPendingCodexResetCreditIdempotencyKey(key)
const confirmed = await confirm({
title: '确认使用 Codex 重置机会',
message: pendingIdempotencyKey
? '将继续确认上次尚未完成的 Codex 重置请求。'
: '将消耗 1 次 Codex 重置机会,完成后自动刷新账号额度。',
confirmText: '确认重置',
cancelText: '取消',
variant: 'warning',
})
if (!confirmed) return
consumingCodexResetCreditKeyId.value = key.key_id
try {
const idempotencyKey = pendingIdempotencyKey
|| readPendingCodexResetCreditIdempotencyKey(key.key_id, generation)
|| createCodexResetCreditIdempotencyKey()
rememberPendingCodexResetCreditIdempotencyKey(key.key_id, idempotencyKey, generation)
const result = await consumeCodexResetCredit(key.key_id, {
idempotency_key: idempotencyKey,
expected_credential_generation: generation,
})
clearPendingCodexResetCreditIdempotencyKeyForOutcome(key.key_id, result.outcome)
keyPage.value.keys = mergePoolKeyQuotaSnapshots(keyPage.value.keys, [{
key_id: result.key_id,
key_name: key.key_name,
status: result.refresh_status === 'success' ? 'success' : result.status as 'success',
metadata: result.metadata,
quota_snapshot: result.quota_snapshot,
}])
if (result.outcome === 'reset' || result.outcome === 'already_redeemed') {
success('Codex 重置机会已使用,账号额度已刷新')
} else {
showWarning(result.message || '重置请求已处理,请查看最新额度')
}
} catch (err: unknown) {
const responseData = typeof err === 'object' && err !== null && 'response' in err
? (err as { response?: { data?: Record<string, unknown> } }).response?.data
: undefined
if (responseData?.outcome === 'credential_changed') {
clearPendingCodexResetCreditIdempotencyKey(key.key_id)
} else if (typeof responseData?.active_idempotency_key === 'string') {
rememberPendingCodexResetCreditIdempotencyKey(
key.key_id,
responseData.active_idempotency_key,
generation,
)
}
showError(parseApiError(err, 'Codex 重置机会使用失败'))
} finally {
consumingCodexResetCreditKeyId.value = null
}
}
function normalizeQuotaUpdatedAt(raw: number | null | undefined): number | null {
const value = Number(raw ?? 0)
if (!Number.isFinite(value) || value <= 0) return null
if (value > 1_000_000_000_000) {
return Math.floor(value / 1000)
}
return Math.floor(value)
}
const currentPageQuotaRefreshStats = computed(() => {
void countdownTick.value
const seen = new Set<string>()
const eligibleIds: string[] = []
let cooledDownCount = 0
let minRemainingSeconds = 0
const nowSeconds = Math.floor(Date.now() / 1000)
for (const key of keyPage.value.keys) {
const id = String(key.key_id || '').trim()
if (!id || seen.has(id)) continue
seen.add(id)
const updatedAt = normalizeQuotaUpdatedAt(key.quota_updated_at ?? null)
if (updatedAt == null) {
eligibleIds.push(id)
continue
}
const remaining = MANUAL_QUOTA_REFRESH_COOLDOWN_SECONDS - (nowSeconds - updatedAt)
if (remaining > 0) {
cooledDownCount += 1
if (minRemainingSeconds <= 0 || remaining < minRemainingSeconds) {
minRemainingSeconds = remaining
}
continue
}
eligibleIds.push(id)
}
return {
total: seen.size,
eligibleIds,
cooledDownCount,
minRemainingSeconds,
}
})
async function refreshCurrentPageQuotaInBackground(
options: { silent?: boolean; reloadAfter?: boolean | 'silent' } = {},
): Promise<boolean> {
if (!selectedProviderId.value || !quotaRefreshSupported.value) return false
const providerId = selectedProviderId.value
const quotaStats = currentPageQuotaRefreshStats.value
if (quotaStats.eligibleIds.length === 0) {
if (!options.silent && quotaStats.total > 0 && quotaStats.cooledDownCount > 0) {
const waitText = quotaStats.minRemainingSeconds > 0
? formatTTL(quotaStats.minRemainingSeconds)
: '稍后'
showWarning(`当前页额度均在冷却中,请 ${waitText} 后再试`)
}
return false
}
if (refreshingCurrentPageQuota.value) {
return false
}
refreshingCurrentPageQuota.value = true
try {
const result = await refreshProviderQuota(providerId, quotaStats.eligibleIds)
2026-05-09 10:44:45 +08:00
applyQuotaRefreshResultToCurrentPage(result)
const successCount = Number(result.success || 0)
const failedCount = Number(result.failed || 0)
const skippedCount = Math.max(quotaStats.total - quotaStats.eligibleIds.length, 0)
// 刷新当前页数据,展示最新额度与状态
if (selectedProviderId.value === providerId && options.reloadAfter !== false) {
await loadKeys({ silent: options.reloadAfter === 'silent' })
}
if (!options.silent) {
const skippedText = skippedCount > 0 ? `,冷却跳过 ${skippedCount}` : ''
const firstFailureMessage = result.results.find(item => item.status !== 'success')?.message?.trim()
if (successCount === 0 && failedCount > 0 && firstFailureMessage) {
showError(`当前页额度刷新失败:${firstFailureMessage}${skippedText}`)
} else {
success(`当前页额度刷新完成:成功 ${successCount},失败 ${failedCount}${skippedText}`)
}
}
return true
} catch (err) {
showError(parseApiError(err, '刷新当前页额度失败'))
return false
} finally {
refreshingCurrentPageQuota.value = false
}
}
const refreshButtonTitle = computed(() => {
if (refreshCurrentPageLoading.value) return '刷新中...'
if (!selectedProviderId.value) return '刷新'
if (!quotaRefreshSupported.value) return '刷新数据'
const quotaStats = currentPageQuotaRefreshStats.value
if (quotaStats.total === 0) return '刷新数据和额度'
if (quotaStats.eligibleIds.length === 0 && quotaStats.cooledDownCount > 0) {
const waitText = quotaStats.minRemainingSeconds > 0
? formatTTL(quotaStats.minRemainingSeconds)
: '稍后'
return `刷新数据(额度冷却 ${waitText})`
}
if (quotaStats.cooledDownCount > 0) {
return `刷新数据和额度(可刷新 ${quotaStats.eligibleIds.length}/${quotaStats.total})`
}
return '刷新数据和额度'
})
async function refreshCurrentPage() {
const quotaDidReload = await refreshCurrentPageQuotaInBackground({ reloadAfter: true })
if (!quotaDidReload) {
await refresh()
}
}
async function loadKeys(options: { cacheTtlMs?: number, silent?: boolean } = {}) {
if (!selectedProviderId.value) return
const requestId = ++keysRequestId
const providerId = selectedProviderId.value
const page = currentPage.value
const pageSizeValue = pageSize.value
const search = searchQuery.value || undefined
const status = statusFilter.value
2026-05-01 02:14:49 +08:00
const sortByValue = sortBy.value || undefined
if (!options.silent) {
keysLoading.value = true
}
try {
const nextPage = await listPoolKeys(providerId, {
page,
page_size: pageSizeValue,
search,
status,
2026-05-01 02:14:49 +08:00
sort_by: sortByValue || undefined,
sort_order: sortByValue ? sortOrder.value : undefined,
}, {
cacheTtlMs: options.cacheTtlMs ?? 0,
})
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
const resolvedPage = resolvePoolManagementPageAfterLoad({
requestedPage: page,
pageSize: pageSizeValue,
total: nextPage.total,
})
if (resolvedPage !== page) {
currentPage.value = resolvedPage
return
}
keyPage.value = nextPage
2026-05-01 02:14:49 +08:00
keysLoadedOnce.value = true
} catch (err) {
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
if (!options.silent) {
resetKeyPage(page, pageSizeValue)
keysLoadedOnce.value = true
showError(parseApiError(err))
} else {
showWarning(parseApiError(err, '同步账号列表失败'))
}
} finally {
if (requestId === keysRequestId) {
keysLoading.value = false
}
}
}
watch(currentPage, () => {
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
})
watch(pageSize, () => {
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
})
watch(statusFilter, () => {
if (suppressFiltersWatch) return
resetPoolKeySelection(true)
if (keysSearchDebounceTimer !== null) {
clearTimeout(keysSearchDebounceTimer)
keysSearchDebounceTimer = null
}
keysSearchPending.value = false
currentPage.value = 1
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
})
2026-05-01 02:14:49 +08:00
watch([sortBy, sortOrder], () => {
if (currentPage.value !== 1) {
currentPage.value = 1
return
}
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
})
watch(searchQuery, () => {
if (suppressFiltersWatch) return
resetPoolKeySelection(true)
currentPage.value = 1
if (keysSearchDebounceTimer !== null) {
clearTimeout(keysSearchDebounceTimer)
}
keysSearchPending.value = true
keysSearchDebounceTimer = window.setTimeout(() => {
keysSearchDebounceTimer = null
keysSearchPending.value = false
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
}, 300)
})
function normalizeAuthTypeForEdit(key: PoolKeyDetail): EndpointAPIKey['auth_type'] {
if (isOAuthManagedCredential(key)) return 'oauth'
if (isServiceAccountCredential(key)) return 'service_account'
2026-04-29 15:46:50 +08:00
if ((key.auth_type || '').trim().toLowerCase() === 'bearer') return 'bearer'
return 'api_key'
}
function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
const nowIso = new Date().toISOString()
return {
id: key.key_id,
provider_id: selectedProviderId.value || '',
api_formats: key.api_formats || [],
api_key_masked: getProviderMaskedSecretLabel(key, selectedProviderType.value),
auth_type: normalizeAuthTypeForEdit(key),
2026-04-29 15:46:50 +08:00
auth_type_by_format: key.auth_type_by_format ?? null,
credential_kind: key.credential_kind ?? null,
runtime_auth_kind: key.runtime_auth_kind ?? null,
oauth_managed: key.oauth_managed ?? undefined,
agent_identity: key.agent_identity ?? undefined,
2026-06-20 22:08:33 +08:00
oauth_header_auth: key.oauth_header_auth ?? undefined,
can_refresh_oauth: key.can_refresh_oauth ?? undefined,
can_export_oauth: key.can_export_oauth ?? undefined,
can_edit_oauth: key.can_edit_oauth ?? undefined,
name: key.key_name || '未命名',
rate_multipliers: key.rate_multipliers ?? null,
internal_priority: key.internal_priority ?? 50,
rpm_limit: key.rpm_limit ?? null,
concurrent_limit: key.concurrent_limit ?? null,
allowed_models: key.allowed_models ?? null,
capabilities: key.capabilities ?? null,
cache_ttl_minutes: key.cache_ttl_minutes ?? 5,
max_probe_interval_minutes: key.max_probe_interval_minutes ?? 32,
health_score: key.health_score ?? 1,
circuit_breaker_open: key.circuit_breaker_open ?? false,
consecutive_failures: 0,
request_count: 0,
success_count: 0,
error_count: 0,
success_rate: 0,
avg_response_time_ms: 0,
is_active: key.is_active,
note: key.note || '',
last_used_at: key.last_used_at || undefined,
created_at: key.created_at || nowIso,
updated_at: nowIso,
auto_fetch_models: key.auto_fetch_models ?? false,
locked_models: key.locked_models || [],
model_include_patterns: key.model_include_patterns || [],
model_exclude_patterns: key.model_exclude_patterns || [],
oauth_expires_at: key.oauth_expires_at ?? null,
oauth_email: null,
oauth_plan_type: key.oauth_plan_type ?? null,
oauth_account_id: key.oauth_account_id ?? null,
oauth_account_user_id: key.oauth_account_user_id ?? null,
oauth_account_name: key.oauth_account_name ?? null,
oauth_organizations: key.oauth_organizations ?? [],
2026-05-01 02:14:49 +08:00
oauth_temporary: key.oauth_temporary ?? false,
oauth_invalid_at: key.oauth_invalid_at ?? null,
oauth_invalid_reason: key.oauth_invalid_reason ?? null,
status_snapshot: key.status_snapshot ?? null,
proxy: key.proxy ?? null,
}
}
const editingKey = computed<EndpointAPIKey | null>(() => {
if (!editingKeyDetail.value) return null
return toEndpointApiKey(editingKeyDetail.value)
})
function sortCurrentPageKeysByPriority() {
keyPage.value.keys = [...keyPage.value.keys].sort((a, b) => {
const pa = Number(a.internal_priority ?? 50)
const pb = Number(b.internal_priority ?? 50)
if (pa !== pb) return pa - pb
return (a.created_at || '').localeCompare(b.created_at || '')
})
}
2026-05-01 02:14:49 +08:00
function handleTableSort(payload: { key: string, direction: PoolManagementSortOrder }) {
2026-05-13 09:16:52 +08:00
if (payload.key !== 'imported_at' && payload.key !== 'last_used_at' && payload.key !== 'score') return
2026-05-01 02:14:49 +08:00
sortBy.value = payload.key
sortOrder.value = payload.direction
}
function startEditInternalPriority(key: PoolKeyDetail) {
editingPriorityKeyId.value = key.key_id
editingPriorityValue.value = Number(key.internal_priority ?? 50)
}
function cancelEditInternalPriority() {
editingPriorityKeyId.value = null
editingPriorityValue.value = 0
}
async function applyInternalPriority(key: PoolKeyDetail, nextPriority: number) {
const normalized = Math.max(1, Math.min(999999, Math.floor(nextPriority)))
if (Number(key.internal_priority ?? 50) === normalized) return
prioritySavingKeyId.value = key.key_id
try {
await updateProviderKey(key.key_id, { internal_priority: normalized })
key.internal_priority = normalized
sortCurrentPageKeysByPriority()
success('账号优先级已更新')
} catch (err) {
showError(parseApiError(err, '更新优先级失败'))
} finally {
prioritySavingKeyId.value = null
}
}
async function quickEditInternalPriority(key: PoolKeyDetail) {
const raw = window.prompt('设置账号优先级(1-999999,数字越小越优先)', String(key.internal_priority ?? 50))
if (raw === null) return
const parsed = Number(raw)
if (!Number.isFinite(parsed)) {
showWarning('请输入有效数字')
return
}
await applyInternalPriority(key, parsed)
}
async function finishEditInternalPriority(
key: PoolKeyDetail,
event: FocusEvent | KeyboardEvent,
) {
if (prioritySavingKeyId.value) return
const target = event.target as HTMLInputElement | null
const raw = target?.value ?? String(editingPriorityValue.value)
const parsed = Number(raw)
const nextPriority = Number.isFinite(parsed) ? parsed : Number(key.internal_priority ?? 50)
cancelEditInternalPriority()
await applyInternalPriority(key, nextPriority)
}
function handleEditKey(key: PoolKeyDetail) {
editingKeyDetail.value = key
if (canEditOAuthCredential(key)) {
oauthKeyEditDialogOpen.value = true
} else {
keyFormDialogOpen.value = true
}
}
function handleKeyPermissions(key: PoolKeyDetail) {
editingKeyDetail.value = key
keyPermissionsDialogOpen.value = true
}
function openKeyBatchEditDialog(keyIds: string[]): void {
keyBatchEditKeyIds.value = [...new Set(keyIds)]
keyBatchEditDialogOpen.value = keyBatchEditKeyIds.value.length > 0
}
function closeKeyBatchEditDialog(): void {
keyBatchEditDialogOpen.value = false
keyBatchEditKeyIds.value = []
}
async function handleKeyBatchEditSaved(): Promise<void> {
resetPoolKeySelection(true)
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
}
async function handleDialogSaved() {
editingKeyDetail.value = null
await loadKeys({ silent: true })
}
function closeKeyFormDialog() {
keyFormDialogOpen.value = false
editingKeyDetail.value = null
}
function closeOAuthEditDialog() {
oauthKeyEditDialogOpen.value = false
editingKeyDetail.value = null
}
function closeKeyPermissionsDialog() {
keyPermissionsDialogOpen.value = false
editingKeyDetail.value = null
}
function getKeyProxyNodeName(key: PoolKeyDetail): string | null {
if (!key.proxy?.node_id) return null
const node = proxyNodesStore.nodes.find(n => n.id === key.proxy?.node_id)
return node ? node.name : `${key.proxy.node_id.slice(0, 8)}...`
}
2026-05-12 16:04:00 +08:00
function handleScoreDesktopPopoverToggle(keyId: string, open: boolean) {
scoreDesktopPopoverOpenKeyId.value = open ? keyId : null
if (open) {
scoreMobilePopoverOpenKeyId.value = null
}
}
function handleScoreMobilePopoverToggle(keyId: string, open: boolean) {
scoreMobilePopoverOpenKeyId.value = open ? keyId : null
if (open) {
scoreDesktopPopoverOpenKeyId.value = null
}
2026-05-12 15:27:21 +08:00
}
function handleProxyDesktopPopoverToggle(keyId: string, open: boolean) {
proxyDesktopPopoverOpenKeyId.value = open ? keyId : null
if (open) {
proxyMobilePopoverOpenKeyId.value = null
}
if (open) {
proxyNodesStore.ensureLoaded()
}
}
function handleProxyMobilePopoverToggle(keyId: string, open: boolean) {
proxyMobilePopoverOpenKeyId.value = open ? keyId : null
if (open) {
proxyDesktopPopoverOpenKeyId.value = null
}
if (open) {
proxyNodesStore.ensureLoaded()
}
}
async function setKeyProxy(key: PoolKeyDetail, nodeId: string) {
savingProxyKeyId.value = key.key_id
try {
await updateProviderKey(key.key_id, {
proxy: { node_id: nodeId, enabled: true },
})
key.proxy = { node_id: nodeId, enabled: true }
proxyDesktopPopoverOpenKeyId.value = null
proxyMobilePopoverOpenKeyId.value = null
success('代理节点已设置')
} catch (err) {
showError(parseApiError(err, '设置代理失败'))
} finally {
savingProxyKeyId.value = null
}
}
async function clearKeyProxy(key: PoolKeyDetail) {
savingProxyKeyId.value = key.key_id
try {
await updateProviderKey(key.key_id, { proxy: null })
key.proxy = null
proxyDesktopPopoverOpenKeyId.value = null
proxyMobilePopoverOpenKeyId.value = null
success('已清除账号代理,将使用提供商级别代理')
} catch (err) {
showError(parseApiError(err, '清除代理失败'))
} finally {
savingProxyKeyId.value = null
}
}
async function handleDeleteKey(key: PoolKeyDetail) {
const confirmed = await confirm({
title: '删除账号',
message: `确定要删除账号 "${key.key_name || key.key_id.slice(0, 8)}" 吗?`,
confirmText: '删除',
variant: 'destructive',
})
if (!confirmed) return
deletingKeyId.value = key.key_id
try {
await deleteEndpointKey(key.key_id)
success('账号已删除')
togglePoolKeySelection(key.key_id, false)
// 乐观更新:直接从本地列表移除,避免等待网络重载
keyPage.value.keys = keyPage.value.keys.filter(k => k.key_id !== key.key_id)
keyPage.value.total = Math.max(0, keyPage.value.total - 1)
// 当前页已空且不是第一页时,自动跳转到前一页
if (keyPage.value.keys.length === 0 && currentPage.value > 1) {
currentPage.value--
}
refreshOverviewInBackground()
} catch (err) {
showError(parseApiError(err, '删除账号失败'))
} finally {
deletingKeyId.value = null
}
}
async function copyFullKey(key: PoolKeyDetail) {
try {
const result = await revealEndpointKey(key.key_id)
let textToCopy = ''
if (result.auth_type === 'service_account' && result.auth_config) {
textToCopy = typeof result.auth_config === 'string'
? result.auth_config
: JSON.stringify(result.auth_config, null, 2)
} else if (result.auth_type === 'oauth') {
textToCopy = result.refresh_token || ''
} else {
textToCopy = result.api_key || ''
}
if (!textToCopy) {
showError('未获取到可复制内容')
return
}
await copyToClipboard(textToCopy)
} catch (err) {
showError(parseApiError(err, '获取密钥失败'))
}
}
async function downloadRefreshToken(key: PoolKeyDetail) {
try {
const data = await exportKey(key.key_id)
const providerType = selectedProviderType.value || 'unknown'
const email = typeof data.email === 'string' ? data.email : ''
const safeName = (email || key.key_name || key.key_id.slice(0, 8)).replace(/[^a-zA-Z0-9_\-@.]/g, '_')
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `aether_${providerType}_${safeName}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err) {
showError(parseApiError(err, '导出失败'))
}
}
async function handleRefreshOAuth(key: PoolKeyDetail) {
if (refreshingOAuthKeyId.value) return
refreshingOAuthKeyId.value = key.key_id
try {
const result = await refreshProviderOAuth(key.key_id)
const refreshedExpiresAt = typeof result.expires_at === 'number' ? result.expires_at : null
const target = keyPage.value.keys.find(k => k.key_id === key.key_id)
if (target) {
target.oauth_expires_at = refreshedExpiresAt
}
await loadKeys({ silent: true })
if (refreshedExpiresAt != null) {
const reloadedTarget = keyPage.value.keys.find(k => k.key_id === key.key_id)
if (
reloadedTarget
&& (typeof reloadedTarget.oauth_expires_at !== 'number'
|| reloadedTarget.oauth_expires_at < refreshedExpiresAt)
) {
reloadedTarget.oauth_expires_at = refreshedExpiresAt
}
}
const refreshedKey = keyPage.value.keys.find(k => k.key_id === key.key_id) ?? null
const feedback = getOAuthRefreshFeedback({
accountStateRecheckAttempted: result.account_state_recheck_attempted,
accountStateRecheckError: result.account_state_recheck_error,
snapshot: refreshedKey,
})
if (feedback.tone === 'warning') {
showWarning(feedback.message)
} else {
success(feedback.message)
}
} catch (err) {
showError(parseApiError(err, 'Token 刷新失败'))
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
} finally {
refreshingOAuthKeyId.value = null
}
}
// --- Actions ---
async function clearCooldown(keyId: string) {
if (!selectedProviderId.value) return
try {
const res = await clearPoolCooldown(selectedProviderId.value, keyId)
success(res.message)
const key = keyPage.value.keys.find(item => item.key_id === keyId)
if (key) {
key.cooldown_reason = null
key.cooldown_ttl_seconds = null
if (key.scheduling_reason === 'cooldown') {
key.scheduling_reason = key.is_active ? 'available' : 'inactive'
key.scheduling_status = key.is_active ? 'available' : 'blocked'
key.scheduling_label = key.is_active ? '可用' : '已禁用'
}
key.scheduling_reasons = key.scheduling_reasons?.filter(
item => item.code !== 'cooldown',
)
}
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
} catch (err) {
showError(parseApiError(err))
}
}
2026-05-07 03:23:20 +08:00
async function handleResetCycleStats(key: PoolKeyDetail) {
if (resettingCycleKeyId.value || !canResetCycleStats(key)) return
const confirmed = await confirm({
title: '重置周期统计',
message: `确定要将账号 "${key.key_name || key.key_id.slice(0, 8)}" 的 5H / 周统计从当前时间重新开始计算吗?`,
confirmText: '重置',
})
if (!confirmed) return
resettingCycleKeyId.value = key.key_id
try {
const result = await resetProviderKeyCycleStats(key.key_id)
success(result.message || '周期统计已重置')
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 })
2026-05-07 03:23:20 +08:00
} catch (err) {
showError(parseApiError(err, '重置周期统计失败'))
} finally {
resettingCycleKeyId.value = null
}
}
async function toggleKeyActive(key: PoolKeyDetail) {
if (togglingKeyId.value) return
togglingKeyId.value = key.key_id
try {
const nextStatus = !key.is_active
await updateProviderKey(key.key_id, { is_active: nextStatus })
applyPoolKeyActiveState(key, nextStatus)
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
success(nextStatus ? '账号已启用' : '账号已停用')
} catch (err) {
showError(parseApiError(err))
} finally {
togglingKeyId.value = null
}
}
// --- Dialogs ---
const showImportDialog = ref(false)
const showSchedulingDialog = ref(false)
const showAdvancedDialog = ref(false)
const providerDrawerOpen = ref(false)
const providerDrawerMounted = ref(false)
2026-05-13 09:16:52 +08:00
const providerEditDialogOpen = ref(false)
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
const showAccountBatchDialog = ref(false)
const pendingAccountBatchAction = ref<PoolBatchActionValue | null>(null)
const togglingProviderStatus = ref(false)
2026-07-27 22:06:28 +08:00
async function ensureSelectedProviderDetail(): Promise<boolean> {
const providerId = selectedProviderId.value
if (!providerId) return false
if (selectedProviderData.value?.id !== providerId) {
await loadProviderData(providerId, { preserveOnError: true })
}
if (selectedProviderData.value?.id === providerId) return true
showWarning('Provider 详情尚未加载,无法编辑调度配置')
return false
}
async function openSchedulingDialog() {
if (await ensureSelectedProviderDetail()) {
showSchedulingDialog.value = true
}
}
async function openAdvancedDialog() {
if (await ensureSelectedProviderDetail()) {
showAdvancedDialog.value = true
}
}
function openAccountBatchDialog(action: PoolBatchActionValue = 'refresh_quota'): void {
if (!selectedProviderId.value || selectedKeyCount.value === 0) return
pendingAccountBatchAction.value = action
showAccountBatchDialog.value = true
}
watch(showAccountBatchDialog, (open) => {
if (!open) pendingAccountBatchAction.value = null
})
function openProviderDrawer(): void {
prefetchProviderDetailDrawer()
if (!selectedProviderId.value) return
providerDrawerMounted.value = true
providerDrawerOpen.value = true
}
async function handleProviderDrawerRefresh(): Promise<void> {
2026-05-13 09:16:52 +08:00
const providerId = selectedProviderId.value
if (!providerId) return
await Promise.all([
loadKeys({ silent: true }),
loadOverview({ silent: true }),
loadProviderData(providerId, { preserveOnError: true }),
])
resetPoolKeySelection(true)
}
async function openProviderEditDialog(provider?: ProviderWithEndpointsSummary): Promise<void> {
const providerId = provider?.id || selectedProviderId.value
if (!providerId) return
2026-05-13 09:16:52 +08:00
try {
const latest = await getProvider(providerId)
if (selectedProviderId.value !== providerId) return
if (selectedProviderData.value?.id === latest.id) {
Object.assign(selectedProviderData.value, latest)
providerToEdit.value = selectedProviderData.value
} else {
selectedProviderData.value = latest
providerToEdit.value = latest
}
2026-05-13 09:16:52 +08:00
} catch (err) {
if (selectedProviderId.value !== providerId) return
const fallbackProvider = provider ?? selectedProviderData.value
if (!fallbackProvider) {
2026-05-13 09:16:52 +08:00
showError(parseApiError(err, '刷新提供商状态失败'))
return
}
providerToEdit.value = fallbackProvider
2026-05-13 09:16:52 +08:00
}
providerEditDialogOpen.value = true
}
async function handleProviderEditSaved(updatedProvider: ProviderWithEndpointsSummary): Promise<void> {
if (selectedProviderId.value === updatedProvider.id) {
if (selectedProviderData.value) {
Object.assign(selectedProviderData.value, updatedProvider)
providerToEdit.value = selectedProviderData.value
} else {
selectedProviderData.value = updatedProvider
providerToEdit.value = updatedProvider
}
2026-05-13 09:16:52 +08:00
}
providerEditDialogOpen.value = false
await loadOverview({ silent: true })
2026-05-13 09:16:52 +08:00
}
async function toggleSelectedProviderStatus(provider?: ProviderWithEndpointsSummary): Promise<void> {
if (togglingProviderStatus.value) return
const providerId = selectedProviderId.value
const current = provider?.id === providerId ? provider : selectedProviderData.value
if (!providerId || !current) return
const nextStatus = !current.is_active
if (!nextStatus) {
const confirmed = await confirm({
title: '禁用提供商',
message: `禁用后该提供商(${current.name})将不再参与调度,是否继续?`,
confirmText: '确认禁用',
variant: 'destructive',
})
if (!confirmed) return
}
togglingProviderStatus.value = true
try {
const updated = await updateProvider(providerId, { is_active: nextStatus })
Object.assign(current, updated)
if (selectedProviderId.value === providerId && selectedProviderData.value !== current) {
if (selectedProviderData.value) {
Object.assign(selectedProviderData.value, updated)
} else {
selectedProviderData.value = updated
}
}
success(nextStatus ? '提供商已启用' : '提供商已禁用')
await loadOverview({ silent: true })
} catch (err) {
showError(parseApiError(err, nextStatus ? '启用提供商失败' : '禁用提供商失败'))
} finally {
togglingProviderStatus.value = false
}
}
async function handleAccountBatchChanged(): Promise<void> {
resetPoolKeySelection(true)
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
}
async function handleAccountDialogSaved() {
showImportDialog.value = false
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
// 导入账号后补一次静默额度刷新,避免新账号在列表里暂无额度信息
await refreshCurrentPageQuotaInBackground({ silent: true, reloadAfter: 'silent' })
}
// --- Formatting ---
const COOLDOWN_REASON_MAP: Record<string, string> = {
rate_limited_429: '429 限流',
forbidden_403: '403 禁止',
overloaded_529: '529 过载',
auth_failed_401: '401 认证失败',
payment_required_402: '402 欠费',
server_error_500: '500 错误',
request_timeout_408: '408 超时',
conflict_409: '409 冲突',
locked_423: '423 锁定',
too_early_425: '425 Too Early',
bad_gateway_502: '502 网关错误',
service_unavailable_503: '503 服务不可用',
gateway_timeout_504: '504 网关超时',
}
function formatCooldownReason(reason: string): string {
return COOLDOWN_REASON_MAP[reason] || reason
}
type PoolStatusVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
function isHealthDerivedSchedulingReason(reason: string | null | undefined): boolean {
const normalized = String(reason || '').trim().toLowerCase()
return normalized === 'health_low'
|| normalized === 'health_degraded'
|| normalized === 'health'
|| normalized === 'circuit_open'
|| normalized === 'circuit_breaker'
}
function isHealthDerivedSchedulingLabel(label: string | null | undefined): boolean {
const normalized = String(label || '').trim()
return normalized === '健康低'
|| normalized === '健康度较低'
|| normalized === '降级'
|| normalized === '熔断'
|| normalized === '熔断中'
}
function getVisibleSchedulingReason(key: PoolKeyDetail): string | null {
const reason = String(key.scheduling_reason || '').trim()
if (!reason || isHealthDerivedSchedulingReason(reason)) return null
return reason
}
function getVisibleSchedulingReasons(key: PoolKeyDetail) {
return (key.scheduling_reasons ?? []).filter((item) => {
const source = String(item.source || '').trim().toLowerCase()
return source !== 'health'
&& !isHealthDerivedSchedulingReason(item.code)
&& !isHealthDerivedSchedulingLabel(item.label)
})
}
function getSchedulingStatus(key: PoolKeyDetail): 'available' | 'degraded' | 'blocked' {
if (getAccountAlertLabel(key)) return 'blocked'
if (getBlockingOAuthStatusLabel(key)) return 'blocked'
const status = key.scheduling_status
if (
(status === 'available' || status === 'degraded' || status === 'blocked')
&& !isHealthDerivedSchedulingReason(key.scheduling_reason)
&& !isHealthDerivedSchedulingLabel(key.scheduling_label)
) {
return status
}
if (!key.is_active) return 'blocked'
if (key.cooldown_reason) return 'degraded'
if (key.cost_limit != null && key.cost_limit > 0 && key.cost_window_usage >= key.cost_limit) return 'blocked'
return 'available'
}
2026-05-07 11:28:44 +08:00
function compactPoolStatusLabel(label: string | null | undefined): string | null {
const normalized = String(label || '').trim()
if (!normalized) return null
const mapped: Record<string, string> = {
'Token 失效': '已失效',
'Token 过期': '已过期',
Token失效: '已失效',
Token过期: '已过期',
账号已封禁: '账号封禁',
工作区已停用: '工作区停用',
账号访问受限: '访问受限',
健康度较低: '健康低',
}
const labelText = mapped[normalized] || normalized
return Array.from(labelText).slice(0, 5).join('')
}
function getOAuthStatusBadgeLabel(status: ReturnType<typeof getVisibleOAuthState>): string | null {
if (!status) return null
if (status.requiresReauth) return '续期失败'
if (status.isInvalid) return '已失效'
if (status.isExpired) return '已过期'
if (status.text === '未添加') return '未添加'
if (status.text === '有效期未知') return '未知'
if (status.isExpiringSoon) return '将过期'
return '有效'
}
function getBlockingOAuthStatusLabel(key: PoolKeyDetail): string | null {
const oauthState = getVisibleOAuthState(key)
if (!oauthState?.isInvalid && !oauthState?.isExpired) return null
return getOAuthStatusBadgeLabel(oauthState)
}
function isPoolKeyCostExhausted(key: PoolKeyDetail): boolean {
return key.cost_limit != null
&& key.cost_limit > 0
&& key.cost_window_usage >= key.cost_limit
}
function getSchedulingBadgeLabel(key: PoolKeyDetail): string {
const accountAlert = getAccountAlertLabel(key)
2026-05-07 11:28:44 +08:00
if (accountAlert) return compactPoolStatusLabel(accountAlert) || accountAlert
const oauthAlert = getBlockingOAuthStatusLabel(key)
if (oauthAlert) return oauthAlert
const rawLabel = String(key.scheduling_label || '').trim()
if (
rawLabel
&& !isHealthDerivedSchedulingReason(key.scheduling_reason)
&& !isHealthDerivedSchedulingLabel(rawLabel)
) {
if ((rawLabel === '可用' || key.scheduling_reason === 'available') && isPoolKeyCostExhausted(key)) {
return '超限'
}
if (rawLabel === '禁用' || rawLabel === '停用') return '禁用'
2026-05-07 11:28:44 +08:00
return compactPoolStatusLabel(rawLabel) || rawLabel
}
if (!key.is_active) return '已禁用'
if (key.cooldown_reason) return '冷却中'
return '可用'
}
function getSchedulingBadgeVariant(key: PoolKeyDetail): PoolStatusVariant {
if (getAccountAlertLabel(key)) return 'destructive'
if (getBlockingOAuthStatusLabel(key)) return 'destructive'
const reason = getVisibleSchedulingReason(key)
if (reason === 'manual_disabled' || reason === 'inactive') return 'secondary'
if (reason === 'account_blocked' || reason === 'account_quota_exhausted' || reason === 'cost_exhausted') return 'destructive'
if (reason === 'cooldown') return 'warning'
if (reason === 'cost_soft' || reason === 'cost') return 'warning'
if (isPoolKeyCostExhausted(key)) return 'destructive'
if (reason === 'available') return 'default'
if (!reason && !key.is_active) return 'secondary'
const status = getSchedulingStatus(key)
if (status === 'blocked') return 'destructive'
if (status === 'degraded') return 'warning'
return 'default'
}
function getSchedulingTitle(key: PoolKeyDetail): string {
const accountAlertTitle = getAccountAlertTitle(key)
if (accountAlertTitle) return accountAlertTitle
if (getBlockingOAuthStatusLabel(key)) return getOAuthStatusTitle(key)
const reasons = getVisibleSchedulingReasons(key)
if (reasons.length > 0) {
return reasons.map((item) => {
const ttl = item.ttl_seconds && item.ttl_seconds > 0 ? ` (${formatTTL(item.ttl_seconds)})` : ''
const detail = item.detail ? ` - ${item.detail}` : ''
return `${item.label}${ttl}${detail}`
}).join('\n')
}
if (key.cooldown_reason) {
const ttl = key.cooldown_ttl_seconds ? ` (${formatTTL(key.cooldown_ttl_seconds)})` : ''
return `${formatCooldownReason(key.cooldown_reason)}${ttl}`
}
if (isPoolKeyCostExhausted(key)) return '超限'
return getSchedulingBadgeLabel(key)
}
function formatTTL(seconds: number): string {
if (seconds <= 0) return ''
const m = Math.floor(seconds / 60)
const s = seconds % 60
return m > 0 ? `${m}m ${s}s` : `${s}s`
}
function getRowClass(key: PoolKeyDetail): string {
const status = getSchedulingStatus(key)
if (!key.is_active || status === 'blocked') return 'bg-muted/50 opacity-60'
return ''
}
function getAuthTypeChipLabel(key: PoolKeyDetail): string {
return getProviderAuthLabel(key)
}
function getMobileOAuthTone(key: PoolKeyDetail): PoolMobileTagTone | null {
const oauthState = getVisibleOAuthState(key)
if (!oauthState) return null
if (oauthState.isInvalid || oauthState.isExpired) return 'danger'
if (oauthState.isExpiringSoon) return 'warning'
return 'muted'
}
function getMobileTagItems(key: PoolKeyDetail): PoolMobileTagItem[] {
const accountAlert = getAccountAlertLabel(key)
const oauthState = getVisibleOAuthState(key)
const orgBadge = getOAuthOrgBadge(key)
const planType = resolvePoolKeyPlanType(key)
return buildPoolMobileTagItems({
2026-05-07 11:28:44 +08:00
accountStatusLabel: compactPoolStatusLabel(accountAlert),
accountStatusTone: accountAlert ? 'danger' : null,
2026-05-07 11:28:44 +08:00
oauthStatusLabel: getOAuthStatusBadgeLabel(oauthState),
oauthStatusTone: getMobileOAuthTone(key),
priorityLabel: `P${key.internal_priority ?? 50}`,
authLabel: getAuthTypeChipLabel(key),
planLabel: planType ? formatOAuthPlanType(planType) : null,
orgLabel: orgBadge?.label ?? null,
proxyLabel: key.proxy?.node_id ? '独立代理' : null,
})
}
function getMobileTagClass(item: PoolMobileTagItem): string {
if (item.tone === 'danger') {
return 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300'
}
if (item.tone === 'warning') {
return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300'
}
if (item.tone === 'accent') {
return 'border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300'
}
if (item.tone === 'muted') {
return 'border-border/60 bg-background/70 text-muted-foreground'
}
return 'border-border/60 bg-background/80 text-foreground/80'
}
function getVisibleOAuthState(key: PoolKeyDetail) {
2026-05-01 02:14:49 +08:00
return getOAuthStatusDisplayWithFallback(key, countdownTick.value)
}
function getOAuthRefreshButtonTitle(key: PoolKeyDetail): string {
return resolveOAuthRefreshButtonTitle(key, countdownTick.value)
}
function getOAuthStatusTitle(key: PoolKeyDetail): string {
return resolveOAuthStatusTitle(key, countdownTick.value)
}
const _accountAlertCache = new WeakMap<PoolKeyDetail, string | null>()
function getQuotaAlertSnapshotState(key: PoolKeyDetail): { label: string, title: string } | null {
const quota = getQuotaSnapshot(key)
if (!quota) return null
const code = String(quota.code || '').trim().toLowerCase()
if (!['banned', 'forbidden', 'quarantined', 'rate_limited', 'exhausted'].includes(code)) return null
let label = String(quota.label || '').trim()
if (!label) {
if (code === 'banned') label = '账号封禁'
else if (code === 'forbidden') label = '访问受限'
else if (code === 'quarantined') label = '账号隔离'
else if (code === 'rate_limited') label = '速率受限'
else label = '额度耗尽'
} else if (label === '账号已封禁' || label === '封禁') {
label = '账号封禁'
}
const reason = String(quota.reason || '').trim()
return {
label,
title: reason ? `${label}: ${reason}` : label,
}
}
function getAccountAlertLabel(key: PoolKeyDetail): string | null {
const cached = _accountAlertCache.get(key)
if (cached !== undefined) return cached
let result: string | null = getAccountStatusDisplay(key).label
const quotaAlert = getQuotaAlertSnapshotState(key)
if (!result && quotaAlert) result = quotaAlert.label
if (!result && !getQuotaSnapshot(key)) {
const quotaText = getLegacyAccountQuotaText(key)
if (quotaText === '账号已封禁' || quotaText === '封禁') result = '账号封禁'
else if (quotaText === '访问受限') result = '访问受限'
}
_accountAlertCache.set(key, result)
return result
}
function getAccountAlertTitle(key: PoolKeyDetail): string {
const label = getAccountAlertLabel(key)
if (!label) return ''
const accountTitle = getAccountStatusTitle(key)
if (accountTitle) return accountTitle
const quotaAlert = getQuotaAlertSnapshotState(key)
if (quotaAlert?.title) return quotaAlert.title
const quotaText = getLegacyAccountQuotaText(key)
if (quotaText) return `${label}: ${quotaText}`
return label
}
function normalizeQuotaLabel(label: string): string {
const normalized = label.trim()
if (!normalized) return '额度'
2026-05-09 10:44:45 +08:00
if (/spark\s*5h/i.test(normalized) || normalized.includes('Spark5H')) return 'Spark5H'
if (/spark/i.test(normalized) && normalized.includes('周')) return 'Spark周'
if (normalized.includes('5H')) return '5H'
if (normalized.includes('周')) return '周'
if (normalized.includes('月')) return '月'
if (normalized.includes('最低剩余')) return '最低'
if (normalized === '剩余' || normalized.includes('剩余')) return '剩余'
if (normalized === '额度') return '额度'
return normalized
}
function getQuotaProgressLabel(label: string): string {
if (label === '日') return '日'
if (label === '5H') return '5H'
if (label === '周') return '周'
2026-07-17 20:18:04 +08:00
if (label === '月') return '月'
if (label === '周额度') return '周'
if (label === '月额度') return '月'
if (label === '额度') return '额度'
2026-05-09 10:44:45 +08:00
if (label === 'Spark5H') return 'Spark5H'
if (label === 'Spark周') return 'Spark周'
if (label === '最低') return '最低'
if (label === '剩余') return '剩余'
return label
}
function getQuotaProgressCountdown(item: QuotaProgressItem) {
const staticResetLabels = ['日', '5H', '周', '月', '周额度', '月额度', '额度', 'Spark5H', 'Spark周', 'Spark月', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图']
2026-05-22 16:48:15 +08:00
if (!item.allowDynamicReset && !staticResetLabels.includes(item.label)) return null
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
return getCodexResetCountdown(
item.resetAtSeconds,
item.resetSeconds,
item.updatedAtSeconds,
countdownTick.value,
item.remainingPercent
)
}
function getQuotaProgressCountdownText(item: QuotaProgressItem): string {
const status = getQuotaProgressCountdown(item)
if (!status) return ''
return status.isExpired ? '' : `${status.text} 后重置`
}
function formatCompactQuotaCountdownText(text: string): string {
const normalized = text.trim()
const dayMatch = normalized.match(/^(\d+)天\s+(.+?)(?:\s+后重置)?$/)
if (dayMatch) {
return `${dayMatch[1]}天 ${dayMatch[2]}`
}
return normalized.replace(/\s+后重置$/, '')
}
function shouldHideQuotaProgressDetailText(text: string | null | undefined): boolean {
return (text ?? '').trim().includes('已重置')
}
function getQuotaProgressResetDisplayText(item: QuotaProgressItem): string {
const countdownText = getQuotaProgressCountdownText(item)
if (countdownText) return formatCompactQuotaCountdownText(countdownText)
return ''
}
function getQuotaProgressMeterDisplayText(item: QuotaProgressItem): string {
const detail = item.detail?.trim() || ''
if (!shouldHideQuotaProgressDetailText(detail) && detail) return detail
return `${item.remainingPercent.toFixed(1)}%`
}
function getQuotaFallbackText(key: PoolKeyDetail): string | null {
return getQuotaDisplayText(key, selectedProviderType.value)
}
2026-05-22 16:48:15 +08:00
function getAccountQuotaText(key: PoolKeyDetail): string | null {
return getGeminiCliAccountCreditsText(key, selectedProviderType.value)
}
function getQuotaLabelOrder(label: string): number {
if (label === 'Auto') return 0
if (label === 'Fast') return 1
if (label === 'Expert') return 2
if (label === 'Heavy') return 3
if (label === 'Grok 4.3') return 4
if (label === '日') return 5
if (label === '5H') return 6
if (label === '周') return 7
2026-07-17 20:18:04 +08:00
if (label === '月') return 8
if (label === 'Spark5H') return 9
if (label === 'Spark周') return 10
if (label === 'Spark月') return 11
if (label === 'Prompt') return 12
if (label === 'Flex') return 13
if (label === '剩余') return 14
if (label === '额度') return 14
2026-07-17 20:18:04 +08:00
if (label === '最低') return 15
if (label === '生图') return 16
if (label === '速率') return 17
if (label === '模型') return 18
return 20
}
function clampPercent(value: number): number {
if (!Number.isFinite(value)) return 0
if (value < 0) return 0
if (value > 100) return 100
return value
}
function normalizeUnixSeconds(raw: number | null | undefined): number | null {
const value = Number(raw ?? 0)
if (!Number.isFinite(value) || value <= 0) return null
if (value > 1_000_000_000_000) return Math.floor(value / 1000)
return Math.floor(value)
}
function normalizeRemainingSeconds(raw: number | null | undefined): number | null {
const value = Number(raw ?? NaN)
if (!Number.isFinite(value) || value < 0) return null
return Math.floor(value)
}
function getQuotaSnapshot(key: PoolKeyDetail): QuotaStatusSnapshot | null {
const quota = key.status_snapshot?.quota
if (!quota) return null
return quota
}
function getQuotaSnapshotProviderType(key: PoolKeyDetail): string {
const snapshotProviderType = String(getQuotaSnapshot(key)?.provider_type || '').trim().toLowerCase()
if (snapshotProviderType) return snapshotProviderType
return selectedProviderType.value
}
function getCodexQuotaSnapshot(key: PoolKeyDetail): QuotaStatusSnapshot | null {
const quota = getQuotaSnapshot(key)
if (!quota) return null
return getQuotaSnapshotProviderType(key) === 'codex' ? quota : null
}
function getQuotaSnapshotUpdatedAtSeconds(quota: QuotaStatusSnapshot | null | undefined): number | null {
return normalizeUnixSeconds(quota?.updated_at ?? quota?.observed_at ?? null)
}
function getQuotaSnapshotResetAtSeconds(quota: QuotaStatusSnapshot | null | undefined): number | null {
return normalizeUnixSeconds(quota?.reset_at ?? null)
}
function getQuotaSnapshotResetSeconds(quota: QuotaStatusSnapshot | null | undefined): number | null {
return normalizeRemainingSeconds(quota?.reset_seconds ?? null)
}
function getQuotaSnapshotWindow(
quota: QuotaStatusSnapshot | null | undefined,
code: string,
): QuotaWindowSnapshot | null {
const windows = quota?.windows
if (!Array.isArray(windows)) return null
const normalizedCode = code.trim().toLowerCase()
return windows.find(window => String(window?.code || '').trim().toLowerCase() === normalizedCode) ?? null
}
function getQuotaSnapshotWindowsByScope(
quota: QuotaStatusSnapshot | null | undefined,
scope: string,
): QuotaWindowSnapshot[] {
const windows = quota?.windows
if (!Array.isArray(windows)) return []
const normalizedScope = scope.trim().toLowerCase()
return windows.filter(window => String(window?.scope || '').trim().toLowerCase() === normalizedScope)
}
function getQuotaWindowUsedPercent(window: QuotaWindowSnapshot | null | undefined): number | null {
if (!window) return null
if (typeof window.used_ratio === 'number') {
return clampPercent(window.used_ratio * 100)
}
if (typeof window.remaining_ratio === 'number') {
return clampPercent((1 - window.remaining_ratio) * 100)
}
if (typeof window.limit_value === 'number' && window.limit_value > 0) {
if (typeof window.remaining_value === 'number') {
return clampPercent((1 - (window.remaining_value / window.limit_value)) * 100)
}
if (typeof window.used_value === 'number') {
return clampPercent((window.used_value / window.limit_value) * 100)
}
}
return null
}
function getQuotaWindowRemainingPercent(window: QuotaWindowSnapshot | null | undefined): number | null {
if (!window) return null
if (typeof window.remaining_ratio === 'number') {
return clampPercent(window.remaining_ratio * 100)
}
const usedPercent = getQuotaWindowUsedPercent(window)
return usedPercent == null ? null : clampPercent(100 - usedPercent)
}
function formatQuotaValue(value: number | null | undefined): string {
const normalized = Number(value)
if (!Number.isFinite(normalized)) return '0'
const rounded = Math.round(normalized)
if (Math.abs(normalized - rounded) < 1e-6) {
return String(rounded)
}
return normalized.toFixed(1)
}
function getQuotaWindowValueText(window: QuotaWindowSnapshot | null | undefined): string | undefined {
if (!window || typeof window.limit_value !== 'number' || window.limit_value <= 0) return undefined
if (typeof window.remaining_value === 'number') {
return `${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
}
if (typeof window.used_value === 'number') {
return `${formatQuotaValue(Math.max(window.limit_value - window.used_value, 0))}/${formatQuotaValue(window.limit_value)}`
}
return undefined
}
function resolvePoolKeyPlanType(key: PoolKeyDetail): string | null {
const direct = key.oauth_plan_type?.trim()
if (direct) return direct
const quota = getQuotaSnapshot(key)
const quotaPlan = quota?.plan_type?.trim()
if (quotaPlan) return quotaPlan
const quotaPoolTier = quota?.pool_tier?.trim()
return quotaPoolTier || null
}
const GROK_QUOTA_MODE_LABELS: Record<string, string> = {
quota_auto: 'Auto',
auto: 'Auto',
quota_fast: 'Fast',
fast: 'Fast',
quota_expert: 'Expert',
expert: 'Expert',
quota_heavy: 'Heavy',
heavy: 'Heavy',
quota_grok_4_3: 'Grok 4.3',
'grok-420-computer-use-sa': 'Grok 4.3',
}
function getGrokQuotaWindowLabel(window: QuotaWindowSnapshot): string {
const code = String(window.code || '').trim().replace(/^model:/i, '')
const label = String(window.label || window.model || code).trim()
const normalized = (label || code).toLowerCase()
return GROK_QUOTA_MODE_LABELS[normalized] || GROK_QUOTA_MODE_LABELS[code.toLowerCase()] || label || code || '模式'
}
2026-05-22 16:48:15 +08:00
function getGeminiCliQuotaWindowLabel(window: QuotaWindowSnapshot): string {
const code = String(window.code || '').trim().replace(/^model:/i, '')
const label = String(window.label || window.model || code).trim()
return label || code || '模型'
}
function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressItem[] {
const quota = getQuotaSnapshot(key)
if (!quota) return []
const providerType = getQuotaSnapshotProviderType(key)
if (providerType === 'codex') {
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
2026-07-17 20:18:04 +08:00
return (quota.windows ?? [])
.map((window): QuotaProgressItem | null => {
const presentation = getCodexQuotaWindowPresentation(window)
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (!presentation || remainingPercent == null) return null
return {
label: presentation.label,
sortOrder: presentation.sortOrder,
remainingPercent,
resetAtSeconds: normalizeUnixSeconds(window.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
allowDynamicReset: true,
}
})
2026-07-17 20:18:04 +08:00
.filter((item): item is QuotaProgressItem => item != null)
}
if (providerType === 'kiro' || providerType === 'xai') {
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
const window = getQuotaSnapshotWindow(quota, 'usage')
?? getQuotaSnapshotWindowsByScope(quota, 'account')[0]
?? null
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) return []
const detail = typeof window?.used_value === 'number' && typeof window?.limit_value === 'number'
? `${formatQuotaValue(window.used_value)}/${formatQuotaValue(window.limit_value)}`
: undefined
return [{
label: normalizeQuotaLabel(String(window?.label || '').trim() || '剩余'),
remainingPercent,
detail,
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
allowDynamicReset: true,
}]
}
if (providerType === 'grok') {
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
const modelWindows = getQuotaSnapshotWindowsByScope(quota, 'model')
if (modelWindows.length > 0) {
return modelWindows
.map((window): QuotaProgressItem | null => {
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) return null
return {
label: getGrokQuotaWindowLabel(window),
remainingPercent,
detail: getQuotaWindowValueText(window),
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
}
})
.filter((item): item is QuotaProgressItem => item != null)
}
const window = getQuotaSnapshotWindow(quota, 'usage')
?? getQuotaSnapshotWindowsByScope(quota, 'account')[0]
?? null
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) return []
return [{
label: '剩余',
remainingPercent,
detail: getQuotaWindowValueText(window),
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
}]
}
if (providerType === 'windsurf') {
const items: QuotaProgressItem[] = []
for (const [label, code] of [
['日', 'daily'],
['周', 'weekly'],
['Prompt', 'prompt'],
['Flex', 'flex'],
] as const) {
const window = getQuotaSnapshotWindow(quota, code)
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) continue
const detail = typeof window?.used_value === 'number' && typeof window?.limit_value === 'number'
? `${formatQuotaValue(window.used_value)}/${formatQuotaValue(window.limit_value)}`
: typeof window?.remaining_value === 'number' && typeof window?.limit_value === 'number'
? `剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
: undefined
items.push({
label,
remainingPercent,
detail,
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
})
}
const rateLimitWindow = getQuotaSnapshotWindow(quota, 'rate_limit')
if (rateLimitWindow) {
items.push({
label: '速率',
remainingPercent: rateLimitWindow.is_exhausted ? 0 : 100,
resetAtSeconds: normalizeUnixSeconds(rateLimitWindow.reset_at ?? null),
resetSeconds: normalizeRemainingSeconds(rateLimitWindow.reset_seconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
})
}
if (typeof quota.allowed_models_count === 'number' && Number.isFinite(quota.allowed_models_count)) {
items.push({
label: '模型',
remainingPercent: 100,
detail: `${quota.allowed_models_count} 个`,
resetAtSeconds: null,
resetSeconds: null,
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
})
}
return items
}
if (providerType === 'antigravity') {
const windows = getQuotaSnapshotWindowsByScope(quota, 'quota_group')
if (windows.length === 0) return []
return windows
.map((window, index): QuotaProgressItem | null => {
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) return null
const label = resolveAntigravityQuotaGroupLabel(window, t)
if (!label) return null
return {
label,
sortOrder: index,
remainingPercent,
resetAtSeconds: normalizeUnixSeconds(window.reset_at ?? quota.reset_at ?? null),
resetSeconds: normalizeRemainingSeconds(window.reset_seconds ?? quota.reset_seconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
allowDynamicReset: true,
}
})
.filter((item): item is QuotaProgressItem => item != null)
}
if (providerType === 'claude_code') {
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
const windowPresentations: Record<string, { labelKey: MessageKey, sortOrder: number }> = {
'5h': { labelKey: 'poolQuota.claudeCode.window5h', sortOrder: 0 },
weekly: { labelKey: 'poolQuota.claudeCode.weekly', sortOrder: 1 },
weekly_sonnet: { labelKey: 'poolQuota.claudeCode.weeklySonnet', sortOrder: 2 },
weekly_fable: { labelKey: 'poolQuota.claudeCode.weeklyFable', sortOrder: 3 },
}
return (quota.windows ?? [])
.map((window): QuotaProgressItem | null => {
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) return null
const presentation = windowPresentations[String(window.code || '')]
return {
label: t(presentation?.labelKey ?? 'poolQuota.claudeCode.unknownWindow'),
sortOrder: presentation?.sortOrder ?? 9,
remainingPercent,
resetAtSeconds: normalizeUnixSeconds(window.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
allowDynamicReset: true,
}
})
.filter((item): item is QuotaProgressItem => item != null)
}
if (providerType === 'gemini_cli') {
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
if (windows.length === 0) return []
2026-05-22 16:48:15 +08:00
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
return windows
.map((window): QuotaProgressItem | null => {
const remainingPercent = getQuotaWindowRemainingPercent(window)
?? (window?.is_exhausted === true ? 0 : null)
if (remainingPercent == null) return null
return {
label: getGeminiCliQuotaWindowLabel(window),
remainingPercent,
detail: getQuotaWindowValueText(window),
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
allowDynamicReset: true,
}
})
.filter((item): item is QuotaProgressItem => item != null)
}
2026-05-06 22:58:47 +08:00
if (providerType === 'chatgpt_web') {
const window = getQuotaSnapshotWindow(quota, 'image_gen')
?? getQuotaSnapshotWindowsByScope(quota, 'account')[0]
?? null
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (remainingPercent == null) return []
const remainingValue = typeof window?.remaining_value === 'number' ? window.remaining_value : null
const limitValue = typeof window?.limit_value === 'number' ? window.limit_value : null
const usedValue = typeof window?.used_value === 'number' ? window.used_value : null
2026-05-23 21:28:13 +08:00
const detail = remainingValue != null && limitValue != null
? `${formatQuotaValue(remainingValue)}/${formatQuotaValue(limitValue)}`
: usedValue != null && limitValue != null
? `${formatQuotaValue(Math.max(limitValue - usedValue, 0))}/${formatQuotaValue(limitValue)}`
2026-05-06 22:58:47 +08:00
: remainingValue != null
? `剩余 ${formatQuotaValue(remainingValue)}`
: undefined
return [{
label: '生图',
remainingPercent,
detail,
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
}]
}
return []
}
function resolveCodexQuotaCountdown(
key: PoolKeyDetail,
label: string
): Pick<QuotaProgressItem, 'resetAtSeconds' | 'resetSeconds' | 'updatedAtSeconds'> | null {
2026-05-09 10:44:45 +08:00
const codexWindowCodeByLabel: Record<string, string> = {
'5H': '5h',
'周': 'weekly',
Spark5H: 'spark_5h',
Spark周: 'spark_weekly',
}
const windowCode = codexWindowCodeByLabel[label]
if (!windowCode) return null
const codexSnapshot = getCodexQuotaSnapshot(key)
2026-05-09 10:44:45 +08:00
const snapshotWindow = getQuotaSnapshotWindow(codexSnapshot, windowCode)
if (!snapshotWindow) return null
const resetAtSeconds = normalizeUnixSeconds(snapshotWindow.reset_at ?? null)
const resetSeconds = normalizeRemainingSeconds(snapshotWindow.reset_seconds ?? null)
const updatedAtSeconds = getQuotaSnapshotUpdatedAtSeconds(codexSnapshot)
if (resetAtSeconds == null && resetSeconds == null) return null
return { resetAtSeconds, resetSeconds, updatedAtSeconds }
}
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 parseQuotaProgressItems(key: PoolKeyDetail): QuotaProgressItem[] {
const snapshotItems = buildQuotaProgressItemsFromSnapshot(key)
if (snapshotItems.length > 0) {
return snapshotItems.sort((a, b) => {
2026-07-17 20:18:04 +08:00
const orderDiff = (a.sortOrder ?? getQuotaLabelOrder(a.label)) - (b.sortOrder ?? getQuotaLabelOrder(b.label))
if (orderDiff !== 0) return orderDiff
return a.label.localeCompare(b.label, 'zh-Hans-CN')
})
}
if (getQuotaSnapshot(key)) return []
const quotaText = getLegacyAccountQuotaText(key)
if (!quotaText) return []
const segments = quotaText
.split('|')
.map(s => s.trim())
.filter(Boolean)
const items: QuotaProgressItem[] = []
for (const segment of segments) {
const match = segment.match(/^(.*?)(-?\d+(?:\.\d+)?)%\s*(.*)$/)
if (!match) continue
const [, rawLabel, rawPercent, rawTail] = match
const remainingPercent = clampPercent(Number(rawPercent))
const label = normalizeQuotaLabel(rawLabel)
const detail = rawTail.trim().replace(/^[()]+|[()]+$/g, '').trim()
const codexCountdown = resolveCodexQuotaCountdown(key, label)
let resetAtSeconds = codexCountdown?.resetAtSeconds ?? null
let resetSeconds = codexCountdown?.resetSeconds ?? null
let updatedAtSeconds = codexCountdown?.updatedAtSeconds ?? null
if (resetAtSeconds == null && resetSeconds == null) {
const resetRemainingSeconds = parseQuotaResetRemainingSeconds(detail || undefined)
resetAtSeconds = resetRemainingSeconds == null
? null
: Math.floor(Date.now() / 1000) + resetRemainingSeconds
resetSeconds = null
updatedAtSeconds = null
}
items.push({
label,
remainingPercent,
detail: detail || undefined,
resetAtSeconds,
resetSeconds,
updatedAtSeconds,
})
}
return items.sort((a, b) => {
const orderDiff = getQuotaLabelOrder(a.label) - getQuotaLabelOrder(b.label)
if (orderDiff !== 0) return orderDiff
return a.label.localeCompare(b.label, 'zh-Hans-CN')
})
}
function getQuotaRemainingClassByRemaining(remaining: number): string {
if (remaining <= 10) return 'text-red-600 dark:text-red-400'
if (remaining <= 30) return 'text-yellow-600 dark:text-yellow-400'
return 'text-green-600 dark:text-green-400'
}
function getQuotaRemainingBarColorByRemaining(remaining: number): string {
if (remaining <= 10) return 'bg-red-500 dark:bg-red-400'
if (remaining <= 30) return 'bg-yellow-500 dark:bg-yellow-400'
return 'bg-green-500 dark:bg-green-400'
}
function getQuotaTextClass(quotaText: string): string {
if (quotaText.includes('封禁') || quotaText.includes('受限')) {
return 'text-[11px] text-destructive leading-4'
}
return 'text-[11px] text-foreground/90 leading-4'
}
2026-05-12 15:27:21 +08:00
function formatPoolScore(value: number | null | undefined): string {
const n = Number(value)
2026-05-12 15:27:21 +08:00
if (!Number.isFinite(n)) return '-'
return n.toFixed(3)
}
2026-05-12 15:27:21 +08:00
function formatPoolScoreReason(value: PoolKeyScore['score_reason'] | null | undefined): string {
if (!value) return '暂无计算结果'
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
function getPoolScoreHardStateLabel(value: PoolKeyScore['hard_state'] | null | undefined): string {
if (!value) return '-'
return poolScoreHardStateOptions.find(item => item.value === value)?.label || value
}
2026-05-12 15:27:21 +08:00
function getPoolScoreProbeStatusLabel(value: PoolKeyScore['probe_status'] | null | undefined): string {
if (!value) return '-'
return poolScoreProbeStatusOptions.find(item => item.value === value)?.label || value
}
function formatUnixSeconds(seconds: number | null | undefined): string {
const raw = Number(seconds ?? 0)
if (!Number.isFinite(raw) || raw <= 0) return '-'
return formatRelativeTime(new Date(raw * 1000).toISOString())
}
function formatRelativeTime(isoStr: string): string {
const date = new Date(isoStr)
const pad = (n: number) => String(n).padStart(2, '0')
const M = pad(date.getMonth() + 1)
const D = pad(date.getDate())
const h = pad(date.getHours())
const m = pad(date.getMinutes())
return `${M}-${D} ${h}:${m}`
}
2026-05-01 02:14:49 +08:00
function formatPoolKeyImportedAt(key: PoolKeyDetail): string {
const value = key.imported_at || key.created_at
return value ? formatRelativeTime(value) : '-'
}
// --- Init ---
onMounted(() => {
startCountdownTimer()
void loadSchedulingPresetMetas({ cacheTtlMs: POOL_SCHEDULING_PRESETS_CACHE_TTL_MS })
void loadOverview({ cacheTtlMs: POOL_OVERVIEW_CACHE_TTL_MS })
scheduleProviderDetailDrawerPrefetch()
})
onBeforeUnmount(() => {
cancelProviderDetailDrawerPrefetch()
stopDemandMetricsPolling()
if (keysSearchDebounceTimer !== null) {
clearTimeout(keysSearchDebounceTimer)
keysSearchDebounceTimer = null
}
keysSearchPending.value = false
overviewRequestId += 1
selectProviderRequestId += 1
providerDataRequestId += 1
keysRequestId += 1
})
</script>