mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 06:00:20 +08:00
4138 lines
156 KiB
Vue
4138 lines
156 KiB
Vue
<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"
|
||
@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
|
||
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 }"
|
||
>
|
||
<span>统计</span>
|
||
</TableHead>
|
||
<SortableTableHead
|
||
class="font-semibold text-center whitespace-nowrap"
|
||
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 }"
|
||
title="按最后使用时间排序"
|
||
@sort="handleTableSort"
|
||
>
|
||
最后使用
|
||
</SortableTableHead>
|
||
<SortableTableHead
|
||
class="font-semibold text-center whitespace-nowrap"
|
||
column-key="score"
|
||
:active-key="sortBy"
|
||
:direction="sortOrder"
|
||
default-direction="desc"
|
||
align="center"
|
||
:style="{ width: desktopColumnWidths.score }"
|
||
title="按分数排序"
|
||
@sort="handleTableSort"
|
||
>
|
||
分数
|
||
</SortableTableHead>
|
||
<SortableTableHead
|
||
class="font-semibold text-center whitespace-nowrap"
|
||
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 }"
|
||
>
|
||
状态
|
||
<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"
|
||
>
|
||
<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-middle"
|
||
>
|
||
<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-middle">
|
||
<PoolKeyStatsPanel
|
||
:cycle="isPoolKeyCycleStatsDisplay(key)"
|
||
:cycle-groups="getPoolKeyCycleStatsGroups(key)"
|
||
:account-metrics="getPoolKeyAccountStatsMetrics(key)"
|
||
/>
|
||
</TableCell>
|
||
<TableCell class="py-3 text-center">
|
||
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
|
||
{{ keyUiStateMap[key.key_id]?.importedAtRelative || '-' }}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell class="py-3 text-center">
|
||
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
|
||
{{ keyUiStateMap[key.key_id]?.lastUsedRelative || '-' }}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell class="py-3 text-center align-middle">
|
||
<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"
|
||
:open="scoreDesktopPopoverOpenKeyId === key.key_id"
|
||
@update:open="(open: boolean) => handleScoreDesktopPopoverToggle(key.key_id, open)"
|
||
>
|
||
<PopoverTrigger as-child>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
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"
|
||
title="查看评分计算结果"
|
||
aria-label="查看评分计算结果"
|
||
@click.stop
|
||
>
|
||
<CircleHelp class="h-3.5 w-3.5" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
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"
|
||
side="bottom"
|
||
align="end"
|
||
:side-offset="8"
|
||
>
|
||
<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">
|
||
{{ formatPoolScore(key.pool_score?.score) }}
|
||
</span>
|
||
</div>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell class="py-3 text-center">
|
||
<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-middle">
|
||
<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>
|
||
<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"
|
||
:class="`${getMobileTagClass(item)} hover:border-primary/40 hover:text-foreground`"
|
||
: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">
|
||
<div class="space-y-1 text-center">
|
||
<PoolKeyStatsPanel
|
||
:cycle="isPoolKeyCycleStatsDisplay(key)"
|
||
:cycle-groups="getPoolKeyCycleStatsGroups(key)"
|
||
:account-metrics="getPoolKeyAccountStatsMetrics(key)"
|
||
variant="mobile"
|
||
/>
|
||
<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>
|
||
<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"
|
||
:open="scoreMobilePopoverOpenKeyId === key.key_id"
|
||
@update:open="(open: boolean) => handleScoreMobilePopoverToggle(key.key_id, open)"
|
||
>
|
||
<PopoverTrigger as-child>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
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"
|
||
title="查看评分计算结果"
|
||
aria-label="查看评分计算结果"
|
||
@click.stop
|
||
>
|
||
<CircleHelp class="h-3.5 w-3.5" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
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"
|
||
side="bottom"
|
||
align="end"
|
||
:side-offset="8"
|
||
>
|
||
<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">
|
||
{{ formatPoolScore(key.pool_score?.score) }}
|
||
</span>
|
||
</div>
|
||
<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>
|
||
</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"
|
||
: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>
|
||
<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
|
||
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">
|
||
{{ hasPoolKeyFilters ? '未找到匹配账号' : '暂无账号' }}
|
||
</p>
|
||
<Button
|
||
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"
|
||
/>
|
||
<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 || selectedProviderType"
|
||
: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 || selectedProviderType"
|
||
: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,
|
||
RotateCcw,
|
||
SquarePen,
|
||
Trash2,
|
||
CircleHelp,
|
||
} from 'lucide-vue-next'
|
||
|
||
import {
|
||
Card,
|
||
Badge,
|
||
Button,
|
||
Checkbox,
|
||
Table,
|
||
TableHeader,
|
||
TableBody,
|
||
TableRow,
|
||
TableHead,
|
||
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 { parseApiError } from '@/utils/errorParser'
|
||
import {
|
||
getPoolOverview,
|
||
getPoolSchedulingPresets,
|
||
listPoolKeys,
|
||
clearPoolCooldown,
|
||
} from '@/api/endpoints/pool'
|
||
import {
|
||
revealEndpointKey,
|
||
exportKey,
|
||
deleteEndpointKey,
|
||
updateProviderKey,
|
||
refreshProviderQuota,
|
||
consumeCodexResetCredit,
|
||
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'
|
||
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,
|
||
type PoolManagementSortBy,
|
||
type PoolManagementSortOrder,
|
||
type PoolManagementViewState,
|
||
writePoolManagementViewState,
|
||
} from '@/features/pool/utils/poolManagementState'
|
||
import type { PoolBatchActionValue } from '@/features/pool/utils/poolBatchActions'
|
||
import {
|
||
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 {
|
||
dedupeAntigravityQuotaItemsByLabel,
|
||
resolveAntigravityQuotaLabel,
|
||
} from '@/features/providers/utils/antigravityQuota'
|
||
import {
|
||
clearPendingCodexResetCreditIdempotencyKey,
|
||
clearPendingCodexResetCreditIdempotencyKeyForOutcome,
|
||
createCodexResetCreditIdempotencyKey,
|
||
formatCodexResetCreditCount,
|
||
formatCodexResetCreditExpiresAt,
|
||
getCodexResetCreditAvailableCount,
|
||
getCodexResetCreditReservationIdempotencyKey,
|
||
getVisibleCodexResetCreditItems,
|
||
readPendingCodexResetCreditIdempotencyKey,
|
||
rememberPendingCodexResetCreditIdempotencyKey,
|
||
} from '@/features/providers/components/codex-reset-credit-display'
|
||
import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
|
||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||
import { formatOAuthPlanType, getOAuthPlanTypeClass } from '@/utils/oauthPlanType'
|
||
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
|
||
import {
|
||
canEditOAuthCredential,
|
||
canExportOAuthCredential,
|
||
canRefreshOAuthCredential,
|
||
getProviderAuthLabel,
|
||
getProviderMaskedSecretLabel,
|
||
isOAuthManagedCredential,
|
||
isServiceAccountCredential,
|
||
shouldShowOAuthRefreshControl,
|
||
} from '@/utils/providerKeyAuth'
|
||
import {
|
||
getAccountStatusDisplay,
|
||
getAccountStatusTitle,
|
||
getOAuthRefreshButtonTitle as resolveOAuthRefreshButtonTitle,
|
||
getOAuthStatusDisplayWithFallback,
|
||
getOAuthStatusTitle as resolveOAuthStatusTitle,
|
||
} from '@/utils/providerKeyStatus'
|
||
import {
|
||
getGeminiCliAccountCreditsText,
|
||
getLegacyAccountQuotaText,
|
||
getQuotaDisplayText,
|
||
} from '@/utils/providerKeyQuota'
|
||
|
||
const loadProviderDetailDrawer = () => import('@/features/providers/components/ProviderDetailDrawer.vue')
|
||
const ProviderDetailDrawer = defineAsyncComponent(loadProviderDetailDrawer)
|
||
|
||
function prefetchProviderDetailDrawer(): void {
|
||
void loadProviderDetailDrawer().catch(() => {})
|
||
}
|
||
|
||
type PoolKeyScore = NonNullable<PoolKeyDetail['pool_score']>
|
||
|
||
const { success, error: showError, warning: showWarning } = useToast()
|
||
const { legacyT } = 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'),
|
||
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[]>([])
|
||
const poolKeyStatusFilterOptions: Array<{ value: PoolManagementViewState['status'], label: string }> = [
|
||
{ value: 'all', label: '全部状态' },
|
||
{ value: 'available', label: '可用' },
|
||
{ 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: '超限' },
|
||
]
|
||
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
|
||
|
||
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 回来后补一次初始化拉取,确保空态不会卡住。
|
||
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)
|
||
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
|
||
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) {
|
||
if (!selectedProviderId.value || updatedProvider.id !== selectedProviderId.value) return
|
||
// 优先回写保存接口返回值,避免弹窗立即重开时读到旧配置。
|
||
if (selectedProviderData.value) {
|
||
Object.assign(selectedProviderData.value, updatedProvider)
|
||
} else {
|
||
selectedProviderData.value = updatedProvider
|
||
}
|
||
showSchedulingDialog.value = false
|
||
showAdvancedDialog.value = false
|
||
await 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
|
||
})
|
||
|
||
function defaultEnabledPresetCount(providerType: string): number {
|
||
return ['codex', 'windsurf'].includes(providerType) ? 2 : 1
|
||
}
|
||
|
||
const DEFAULT_PRESET_LABELS: Record<string, string> = {
|
||
lru: 'LRU',
|
||
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.at(-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
|
||
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)) {
|
||
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 === 'gemini_cli'
|
||
|| selectedProviderType.value === 'kiro'
|
||
|| selectedProviderType.value === 'windsurf'
|
||
|| selectedProviderType.value === 'antigravity'
|
||
|| selectedProviderType.value === 'grok'
|
||
|| selectedProviderType.value === 'chatgpt_web'
|
||
})
|
||
|
||
const desktopColumnWidths = computed(() => {
|
||
if (showAccountQuotaColumn.value) {
|
||
return {
|
||
name: '19%',
|
||
quota: '18%',
|
||
stats: '15%',
|
||
imported: '10%',
|
||
lastUsed: '8%',
|
||
score: '9%',
|
||
status: '7%',
|
||
actions: '14%',
|
||
}
|
||
}
|
||
return {
|
||
name: '31%',
|
||
quota: '0%',
|
||
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
|
||
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
|
||
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
|
||
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)
|
||
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)
|
||
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)
|
||
const resettingCycleKeyId = ref<string | null>(null)
|
||
const savingProxyKeyId = ref<string | null>(null)
|
||
const proxyDesktopPopoverOpenKeyId = ref<string | null>(null)
|
||
const proxyMobilePopoverOpenKeyId = ref<string | null>(null)
|
||
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)
|
||
|
||
function clearPoolKeyFilters() {
|
||
if (!hasPoolKeyFilters.value) return
|
||
resetPoolKeySelection(true)
|
||
if (keysSearchDebounceTimer !== null) {
|
||
clearTimeout(keysSearchDebounceTimer)
|
||
keysSearchDebounceTimer = null
|
||
}
|
||
keysSearchPending.value = false
|
||
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 },
|
||
)
|
||
|
||
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) => {
|
||
if (overviewLoading.value) return
|
||
if (!value || value === selectedProviderId.value) return
|
||
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(
|
||
[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,
|
||
sortBy: sortByValue,
|
||
sortOrder: sortOrderValue,
|
||
statsMode: 'current_cycle',
|
||
}
|
||
patchQuery(buildPoolManagementQueryPatch(nextState))
|
||
writePoolManagementViewState(nextState, poolManagementViewStorage)
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
interface QuotaProgressItem {
|
||
label: string
|
||
remainingPercent: number
|
||
numericOnly?: boolean
|
||
sortOrder?: number
|
||
detail?: string
|
||
resetAtSeconds?: number | null
|
||
resetSeconds?: number | null
|
||
updatedAtSeconds?: number | null
|
||
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>
|
||
visibleOAuthState: ReturnType<typeof getOAuthStatusDisplayWithFallback>
|
||
oauthStatusTitle: string
|
||
oauthRefreshButtonTitle: string
|
||
showOAuthRefreshControl: boolean
|
||
canRefreshToken: boolean
|
||
planLabel: string
|
||
planClass: string
|
||
accountQuotaText: string | null
|
||
quotaFallbackText: string | null
|
||
quotaTextClass: string
|
||
importedAtRelative: string
|
||
lastUsedRelative: string
|
||
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
|
||
? 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)
|
||
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) : '',
|
||
oauthRefreshButtonTitle: showOAuthRefreshControl ? getOAuthRefreshButtonTitle(key) : '',
|
||
showOAuthRefreshControl,
|
||
canRefreshToken,
|
||
planLabel: planType ? formatOAuthPlanType(planType) : '',
|
||
planClass: planType ? getOAuthPlanTypeClass(planType) : '',
|
||
accountQuotaText,
|
||
quotaFallbackText,
|
||
quotaTextClass: accountQuotaText || quotaFallbackText
|
||
? getQuotaTextClass(accountQuotaText || quotaFallbackText || '')
|
||
: '',
|
||
importedAtRelative: formatPoolKeyImportedAt(key),
|
||
lastUsedRelative: key.last_used_at ? formatRelativeTime(key.last_used_at) : '-',
|
||
statsDisplay: buildPoolStatsDisplay(key, selectedProviderType.value, 'current_cycle'),
|
||
mobileTagItems: getMobileTagItems(key),
|
||
mobileActionIds: splitPoolMobileActions({
|
||
canDownloadOrCopy: true,
|
||
showRefreshToken: showOAuthRefreshControl,
|
||
canResetCycleStats: canResetCycleStats(key),
|
||
canClearCooldown: Boolean(key.cooldown_reason),
|
||
hasProxy: true,
|
||
}).primary,
|
||
}
|
||
}
|
||
|
||
return map
|
||
})
|
||
|
||
function getPoolKeyStatsDisplay(key: PoolKeyDetail): PoolStatsDisplay {
|
||
return keyUiStateMap.value[key.key_id]?.statsDisplay
|
||
?? buildPoolStatsDisplay(key, selectedProviderType.value, 'current_cycle')
|
||
}
|
||
|
||
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
|
||
: buildPoolStatsDisplay(key, selectedProviderType.value, 'account_total').metrics
|
||
}
|
||
|
||
const quotaRefreshSupported = computed(() => {
|
||
return selectedProviderType.value === 'codex'
|
||
|| selectedProviderType.value === 'kiro'
|
||
|| selectedProviderType.value === 'gemini_cli'
|
||
|| selectedProviderType.value === 'windsurf'
|
||
|| selectedProviderType.value === 'antigravity'
|
||
|| selectedProviderType.value === 'grok'
|
||
|| selectedProviderType.value === 'chatgpt_web'
|
||
})
|
||
|
||
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,
|
||
)
|
||
}
|
||
}
|
||
|
||
function applyQuotaRefreshResultToCurrentPage(result: Awaited<ReturnType<typeof refreshProviderQuota>>): void {
|
||
keyPage.value.keys = mergePoolKeyQuotaSnapshots(keyPage.value.keys, result.results)
|
||
}
|
||
|
||
function getCodexResetCredits(key: PoolKeyDetail) {
|
||
return getQuotaSnapshotProviderType(key) === 'codex'
|
||
? key.status_snapshot?.quota?.reset_credits ?? null
|
||
: 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)
|
||
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
|
||
const sortByValue = sortBy.value || undefined
|
||
if (!options.silent) {
|
||
keysLoading.value = true
|
||
}
|
||
try {
|
||
const nextPage = await listPoolKeys(providerId, {
|
||
page,
|
||
page_size: pageSizeValue,
|
||
search,
|
||
status,
|
||
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
|
||
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 })
|
||
})
|
||
|
||
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'
|
||
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),
|
||
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,
|
||
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 ?? [],
|
||
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 || '')
|
||
})
|
||
}
|
||
|
||
function handleTableSort(payload: { key: string, direction: PoolManagementSortOrder }) {
|
||
if (payload.key !== 'imported_at' && payload.key !== 'last_used_at' && payload.key !== 'score') return
|
||
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)}...`
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
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))
|
||
}
|
||
}
|
||
|
||
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 })
|
||
} 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)
|
||
const providerEditDialogOpen = ref(false)
|
||
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
|
||
const showAccountBatchDialog = ref(false)
|
||
const pendingAccountBatchAction = ref<PoolBatchActionValue | null>(null)
|
||
const togglingProviderStatus = ref(false)
|
||
|
||
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> {
|
||
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
|
||
|
||
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
|
||
}
|
||
} catch (err) {
|
||
if (selectedProviderId.value !== providerId) return
|
||
const fallbackProvider = provider ?? selectedProviderData.value
|
||
if (!fallbackProvider) {
|
||
showError(parseApiError(err, '刷新提供商状态失败'))
|
||
return
|
||
}
|
||
providerToEdit.value = fallbackProvider
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
providerEditDialogOpen.value = false
|
||
await loadOverview({ silent: true })
|
||
}
|
||
|
||
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'
|
||
}
|
||
|
||
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)
|
||
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 '禁用'
|
||
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({
|
||
accountStatusLabel: compactPoolStatusLabel(accountAlert),
|
||
accountStatusTone: accountAlert ? 'danger' : null,
|
||
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) {
|
||
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 '额度'
|
||
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 === '剩余' || normalized.includes('剩余')) return '剩余'
|
||
return normalized
|
||
}
|
||
|
||
function getQuotaProgressLabel(label: string): string {
|
||
if (label === '日') return '日'
|
||
if (label === '5H') return '5H'
|
||
if (label === '周') return '周'
|
||
if (label === '月') return '月'
|
||
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', '生图']
|
||
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)
|
||
}
|
||
|
||
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
|
||
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 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 || '模式'
|
||
}
|
||
|
||
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)
|
||
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,
|
||
}
|
||
})
|
||
.filter((item): item is QuotaProgressItem => item != null)
|
||
}
|
||
|
||
if (providerType === 'kiro') {
|
||
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: '剩余',
|
||
remainingPercent,
|
||
detail,
|
||
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
|
||
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
|
||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||
}]
|
||
}
|
||
|
||
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, 'model')
|
||
if (windows.length === 0) return []
|
||
const opaqueDisplayIndex = { value: 1 }
|
||
return dedupeAntigravityQuotaItemsByLabel(windows
|
||
.map((window): (QuotaProgressItem & { model: string, resetSeconds: number | null }) | null => {
|
||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||
if (remainingPercent == null) return null
|
||
const model = String(window.model || window.code || '').trim().replace(/^model:/i, '')
|
||
return {
|
||
model,
|
||
label: resolveAntigravityQuotaLabel(model, window.label, opaqueDisplayIndex),
|
||
remainingPercent,
|
||
numericOnly: true,
|
||
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 & { model: string, resetSeconds: number | null } => item != null))
|
||
}
|
||
|
||
if (providerType === 'gemini_cli') {
|
||
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
||
if (windows.length === 0) return []
|
||
|
||
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)
|
||
}
|
||
|
||
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
|
||
const detail = remainingValue != null && limitValue != null
|
||
? `${formatQuotaValue(remainingValue)}/${formatQuotaValue(limitValue)}`
|
||
: usedValue != null && limitValue != null
|
||
? `${formatQuotaValue(Math.max(limitValue - usedValue, 0))}/${formatQuotaValue(limitValue)}`
|
||
: 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 {
|
||
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)
|
||
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) => {
|
||
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'
|
||
}
|
||
|
||
function formatPoolScore(value: number | null | undefined): string {
|
||
const n = Number(value)
|
||
if (!Number.isFinite(n)) return '-'
|
||
return n.toFixed(3)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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}`
|
||
}
|
||
|
||
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>
|