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