2026-02-27 13:58:58 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="space-y-6 pb-8">
|
|
|
|
|
|
<Card
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
class="overflow-hidden"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- Header -->
|
|
|
|
|
|
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
|
|
|
|
|
|
<!-- Mobile -->
|
2026-04-09 13:51:50 +08:00
|
|
|
|
<div class="flex flex-col gap-3 xl:hidden">
|
|
|
|
|
|
<div class="min-w-0">
|
|
|
|
|
|
<h3 class="text-base font-semibold">
|
|
|
|
|
|
号池管理
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p
|
|
|
|
|
|
v-if="poolHeaderMetaText"
|
|
|
|
|
|
class="mt-1 text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ poolHeaderMetaText }}
|
|
|
|
|
|
</p>
|
2026-02-27 13:58:58 +08:00
|
|
|
|
</div>
|
2026-04-09 13:51:50 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="grid grid-cols-3 items-center gap-2"
|
|
|
|
|
|
>
|
2026-03-03 11:32:41 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
v-model="selectedProviderIdProxy"
|
|
|
|
|
|
:disabled="providerSelectDisabled"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger
|
2026-04-09 13:51:50 +08:00
|
|
|
|
class="h-9 text-xs border-border/60"
|
2026-03-03 11:32:41 +08:00
|
|
|
|
:disabled="providerSelectDisabled"
|
|
|
|
|
|
>
|
2026-02-27 13:58:58 +08:00
|
|
|
|
<SelectValue placeholder="选择 Provider" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem
|
|
|
|
|
|
v-for="item in poolProviders"
|
|
|
|
|
|
:key="item.provider_id"
|
|
|
|
|
|
:value="item.provider_id"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ item.provider_name }}
|
|
|
|
|
|
<span class="text-muted-foreground ml-1">({{ item.total_keys }})</span>
|
2026-03-05 15:15:26 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-if="!item.pool_enabled"
|
|
|
|
|
|
class="ml-1 text-[10px] text-amber-600"
|
|
|
|
|
|
>未启用</span>
|
2026-02-27 13:58:58 +08:00
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Select v-model="statusFilter">
|
2026-04-09 13:51:50 +08:00
|
|
|
|
<SelectTrigger class="h-9 w-full text-xs border-border/60">
|
2026-02-27 13:58:58 +08:00
|
|
|
|
<SelectValue placeholder="状态" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="all">
|
|
|
|
|
|
全部
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="active">
|
2026-03-20 16:50:59 +08:00
|
|
|
|
可调度
|
2026-02-27 13:58:58 +08:00
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="cooldown">
|
|
|
|
|
|
冷却中
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="inactive">
|
2026-03-03 11:32:41 +08:00
|
|
|
|
禁用
|
2026-02-27 13:58:58 +08:00
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
2026-04-09 13:51:50 +08:00
|
|
|
|
<div class="relative min-w-0">
|
|
|
|
|
|
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="searchQuery"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="搜索账号..."
|
|
|
|
|
|
class="w-full pl-8 pr-3 h-9 text-sm bg-background/50 border-border/60"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-02-27 13:58:58 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="selectedProviderId"
|
2026-04-09 13:51:50 +08:00
|
|
|
|
class="flex items-center gap-1"
|
2026-02-27 13:58:58 +08:00
|
|
|
|
>
|
2026-04-09 13:51:50 +08:00
|
|
|
|
<div class="min-w-0 flex-1 flex justify-center">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8 shrink-0"
|
|
|
|
|
|
title="添加账号"
|
|
|
|
|
|
@click="showImportDialog = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Upload class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="min-w-0 flex-1 flex justify-center">
|
|
|
|
|
|
<ProviderProxyPopover
|
|
|
|
|
|
:open="providerProxyMobilePopoverOpen"
|
|
|
|
|
|
:node-id="selectedProviderData?.proxy?.node_id"
|
|
|
|
|
|
:saving="savingProviderProxy"
|
|
|
|
|
|
:title="getProviderProxyButtonTitle()"
|
|
|
|
|
|
@update:open="(open: boolean) => handleProviderProxyPopoverToggle('mobile', open)"
|
|
|
|
|
|
@select="setProviderProxy"
|
|
|
|
|
|
@clear="clearProviderProxy"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="min-w-0 flex-1 flex justify-center">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8 shrink-0"
|
|
|
|
|
|
title="号池调度"
|
|
|
|
|
|
@click="openSchedulingDialog()"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SlidersHorizontal class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="min-w-0 flex-1 flex justify-center">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8 shrink-0"
|
|
|
|
|
|
title="账号批量操作"
|
|
|
|
|
|
@click="showAccountBatchDialog = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Users class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-05-13 09:16:52 +08:00
|
|
|
|
<div class="min-w-0 flex-1 flex justify-center">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8 shrink-0"
|
|
|
|
|
|
title="编辑提供商"
|
|
|
|
|
|
@click="openProviderEditDialog"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Edit class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="min-w-0 flex-1 flex justify-center">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8 shrink-0"
|
|
|
|
|
|
title="编辑端点"
|
|
|
|
|
|
@click="openEndpointEditDialog"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plug class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-05-15 01:46:24 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="showAdaptiveHotPoolMetricsButton"
|
|
|
|
|
|
class="min-w-0 flex-1 flex justify-center"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8 shrink-0"
|
|
|
|
|
|
data-testid="pool-demand-metrics-button"
|
|
|
|
|
|
title="查看自适应热池指标"
|
|
|
|
|
|
@click="showDemandMetricsDialog = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Activity class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-04-09 13:51:50 +08:00
|
|
|
|
<div class="min-w-0 flex-1 flex justify-center">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8 shrink-0"
|
|
|
|
|
|
title="高级设置"
|
|
|
|
|
|
@click="showAdvancedDialog = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Settings2 class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="min-w-0 flex-1 flex justify-center">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8 shrink-0"
|
|
|
|
|
|
:class="getProviderToggleButtonClass()"
|
|
|
|
|
|
:disabled="togglingProviderStatus"
|
|
|
|
|
|
:title="getProviderToggleButtonTitle()"
|
|
|
|
|
|
@click="toggleSelectedProviderStatus"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Power class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="min-w-0 flex-1 flex justify-center">
|
|
|
|
|
|
<RefreshButton
|
|
|
|
|
|
:loading="refreshCurrentPageLoading"
|
|
|
|
|
|
:title="refreshButtonTitle"
|
|
|
|
|
|
@click="refreshCurrentPage"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-02-27 13:58:58 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Desktop -->
|
2026-04-09 13:51:50 +08:00
|
|
|
|
<div class="hidden xl:flex items-center justify-between gap-4">
|
2026-03-03 09:22:20 +08:00
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<h3 class="text-base font-semibold">
|
|
|
|
|
|
号池管理
|
2026-03-05 15:15:26 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-if="poolHeaderMetaText"
|
|
|
|
|
|
class="ml-2 text-xs font-normal text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
| {{ poolHeaderMetaText }}
|
|
|
|
|
|
</span>
|
2026-03-03 09:22:20 +08:00
|
|
|
|
</h3>
|
|
|
|
|
|
</div>
|
2026-05-06 23:14:25 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="flex items-center gap-2"
|
|
|
|
|
|
data-testid="pool-header-actions"
|
|
|
|
|
|
>
|
2026-03-03 11:32:41 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
v-model="selectedProviderIdProxy"
|
|
|
|
|
|
:disabled="providerSelectDisabled"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger
|
|
|
|
|
|
class="w-36 h-8 text-xs border-border/60"
|
|
|
|
|
|
:disabled="providerSelectDisabled"
|
|
|
|
|
|
>
|
2026-02-27 13:58:58 +08:00
|
|
|
|
<SelectValue placeholder="选择 Provider" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem
|
|
|
|
|
|
v-for="item in poolProviders"
|
|
|
|
|
|
:key="item.provider_id"
|
|
|
|
|
|
:value="item.provider_id"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ item.provider_name }}
|
|
|
|
|
|
<span class="text-muted-foreground ml-1">({{ item.total_keys }})</span>
|
2026-03-05 15:15:26 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-if="!item.pool_enabled"
|
|
|
|
|
|
class="ml-1 text-[10px] text-amber-600"
|
|
|
|
|
|
>未启用</span>
|
2026-02-27 13:58:58 +08:00
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<div class="h-4 w-px bg-border" />
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="selectedProviderId"
|
|
|
|
|
|
class="relative"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="searchQuery"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="搜索账号..."
|
|
|
|
|
|
class="w-40 pl-8 pr-2 h-8 text-xs bg-background/50 border-border/60"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="selectedProviderId"
|
|
|
|
|
|
class="h-4 w-px bg-border"
|
|
|
|
|
|
/>
|
2026-03-04 22:06:19 +08:00
|
|
|
|
<button
|
2026-02-27 13:58:58 +08:00
|
|
|
|
v-if="selectedProviderId"
|
2026-03-04 22:06:19 +08:00
|
|
|
|
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
|
|
|
|
|
|
title="点击调整号池调度"
|
2026-03-05 15:15:26 +08:00
|
|
|
|
@click="openSchedulingDialog()"
|
2026-02-27 13:58:58 +08:00
|
|
|
|
>
|
2026-03-04 22:06:19 +08:00
|
|
|
|
<span class="text-muted-foreground/80 hidden lg:inline">调度:</span>
|
|
|
|
|
|
<span class="font-medium text-foreground/90">{{ poolSchedulingLabel }}</span>
|
|
|
|
|
|
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
|
|
|
|
|
|
</button>
|
2026-03-05 09:53:23 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="selectedProviderId"
|
|
|
|
|
|
class="h-4 w-px bg-border"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
v-if="selectedProviderId"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="添加账号"
|
|
|
|
|
|
@click="showImportDialog = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Upload class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
2026-03-05 15:15:26 +08:00
|
|
|
|
<ProviderProxyPopover
|
|
|
|
|
|
v-if="selectedProviderId"
|
|
|
|
|
|
:open="providerProxyDesktopPopoverOpen"
|
|
|
|
|
|
:node-id="selectedProviderData?.proxy?.node_id"
|
|
|
|
|
|
:saving="savingProviderProxy"
|
|
|
|
|
|
:title="getProviderProxyButtonTitle()"
|
|
|
|
|
|
@update:open="(open: boolean) => handleProviderProxyPopoverToggle('desktop', open)"
|
|
|
|
|
|
@select="setProviderProxy"
|
|
|
|
|
|
@clear="clearProviderProxy"
|
|
|
|
|
|
/>
|
2026-05-13 09:16:52 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
v-if="selectedProviderId"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="编辑提供商"
|
|
|
|
|
|
@click="openProviderEditDialog"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Edit class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
v-if="selectedProviderId"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="编辑端点"
|
|
|
|
|
|
@click="openEndpointEditDialog"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plug class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
2026-05-15 01:46:24 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
v-if="showAdaptiveHotPoolMetricsButton"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
data-testid="pool-demand-metrics-button"
|
|
|
|
|
|
title="查看自适应热池指标"
|
|
|
|
|
|
@click="showDemandMetricsDialog = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Activity class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
2026-03-03 09:22:20 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
v-if="selectedProviderId"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
2026-03-05 15:15:26 +08:00
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="高级设置"
|
|
|
|
|
|
@click="showAdvancedDialog = true"
|
2026-03-03 09:22:20 +08:00
|
|
|
|
>
|
2026-03-05 15:15:26 +08:00
|
|
|
|
<Settings2 class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
v-if="selectedProviderId"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="账号"
|
|
|
|
|
|
@click="showAccountBatchDialog = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Users class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
v-if="selectedProviderId"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
:class="getProviderToggleButtonClass()"
|
|
|
|
|
|
:disabled="togglingProviderStatus"
|
|
|
|
|
|
:title="getProviderToggleButtonTitle()"
|
|
|
|
|
|
@click="toggleSelectedProviderStatus"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Power class="w-3.5 h-3.5" />
|
2026-03-03 09:22:20 +08:00
|
|
|
|
</Button>
|
2026-02-27 13:58:58 +08:00
|
|
|
|
<RefreshButton
|
2026-03-04 22:06:19 +08:00
|
|
|
|
:loading="refreshCurrentPageLoading"
|
|
|
|
|
|
:title="refreshButtonTitle"
|
2026-03-03 11:32:41 +08:00
|
|
|
|
@click="refreshCurrentPage"
|
2026-02-27 13:58:58 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 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-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
|
|
|
|
|
|
class="font-semibold whitespace-nowrap"
|
|
|
|
|
|
:style="{ width: desktopColumnWidths.name }"
|
|
|
|
|
|
>
|
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-05-07 03:23:20 +08:00
|
|
|
|
<div class="flex items-center justify-center gap-1.5">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="showCodexStatsModeToggle"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
|
|
|
|
|
:title="poolStatsMode === 'current_cycle' ? '切换为总计统计' : '切换为周期统计'"
|
|
|
|
|
|
:aria-label="poolStatsMode === 'current_cycle' ? '切换为总计统计' : '切换为周期统计'"
|
|
|
|
|
|
:aria-pressed="poolStatsMode === 'current_cycle'"
|
|
|
|
|
|
data-testid="pool-stats-mode-control"
|
|
|
|
|
|
@click.stop="togglePoolStatsMode"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Repeat2 class="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span>统计</span>
|
|
|
|
|
|
</div>
|
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-04-21 16:19:07 +08:00
|
|
|
|
:class="keyUiStateMap[key.key_id]?.rowClass || ''"
|
2026-02-27 13:58:58 +08:00
|
|
|
|
>
|
2026-03-05 09:53:23 +08:00
|
|
|
|
<TableCell
|
|
|
|
|
|
class="py-3"
|
|
|
|
|
|
>
|
2026-03-05 15:15:26 +08:00
|
|
|
|
<div class="min-w-0">
|
2026-03-03 09:22:20 +08:00
|
|
|
|
<div class="flex items-center gap-1.5 min-w-0">
|
|
|
|
|
|
<span class="text-sm truncate block">
|
|
|
|
|
|
{{ key.key_name || '未命名' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-03-05 15:15:26 +08:00
|
|
|
|
<div class="flex items-center flex-wrap gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0">
|
2026-03-05 09:53:23 +08:00
|
|
|
|
<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-04-17 12:57:06 +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"
|
|
|
|
|
|
title="下载 OAuth 授权文件"
|
|
|
|
|
|
@click.stop="downloadRefreshToken(key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Download class="w-2.5 h-2.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
v-else
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-4 w-4 shrink-0"
|
|
|
|
|
|
title="复制密钥"
|
|
|
|
|
|
@click.stop="copyFullKey(key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Copy class="w-2.5 h-2.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<span class="font-mono">
|
2026-05-16 19:23:09 +08:00
|
|
|
|
{{ getProviderMaskedSecretLabel(key, selectedProviderType) }}
|
2026-03-03 09:22:20 +08:00
|
|
|
|
</span>
|
2026-05-01 02:14:49 +08:00
|
|
|
|
<template v-if="keyUiStateMap[key.key_id]?.showOAuthRefreshControl">
|
2026-03-03 09:22:20 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-4 w-4 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-03-03 09:22:20 +08:00
|
|
|
|
@click.stop="handleRefreshOAuth(key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw
|
|
|
|
|
|
class="w-2.5 h-2.5"
|
|
|
|
|
|
:class="{ 'animate-spin': refreshingOAuthKeyId === key.key_id }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Button>
|
2026-03-04 12:59:27 +08:00
|
|
|
|
<span
|
2026-04-21 16:19:07 +08:00
|
|
|
|
v-if="keyUiStateMap[key.key_id]?.visibleOAuthState"
|
2026-03-04 12:59:27 +08:00
|
|
|
|
class="text-[10px]"
|
|
|
|
|
|
:class="{
|
2026-04-21 16:19:07 +08:00
|
|
|
|
'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
|
2026-03-04 12:59:27 +08:00
|
|
|
|
}"
|
2026-04-21 16:19:07 +08:00
|
|
|
|
:title="keyUiStateMap[key.key_id]?.oauthStatusTitle || ''"
|
2026-03-04 12:59:27 +08:00
|
|
|
|
>
|
2026-04-21 16:19:07 +08:00
|
|
|
|
{{ keyUiStateMap[key.key_id]?.visibleOAuthState?.text }}
|
2026-03-04 12:59:27 +08:00
|
|
|
|
</span>
|
2026-03-03 09:22:20 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
<Badge
|
2026-05-16 19:23:09 +08:00
|
|
|
|
v-if="keyUiStateMap[key.key_id]?.planLabel"
|
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-21 16:19:07 +08:00
|
|
|
|
{{ keyUiStateMap[key.key_id]?.planLabel }}
|
2026-03-03 09:22:20 +08:00
|
|
|
|
</Badge>
|
2026-03-11 21:37:08 +08:00
|
|
|
|
<Badge
|
2026-04-21 16:19:07 +08:00
|
|
|
|
v-if="keyUiStateMap[key.key_id]?.oauthOrgBadge"
|
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-21 16:19:07 +08:00
|
|
|
|
{{ keyUiStateMap[key.key_id]?.oauthOrgBadge?.label }}
|
2026-03-11 21:37:08 +08:00
|
|
|
|
</Badge>
|
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-03-05 15:15:26 +08:00
|
|
|
|
class="py-3 align-middle"
|
2026-03-01 00:32:14 +08:00
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="quotaProgressMap[key.key_id]?.length"
|
2026-03-19 00:50:18 +08:00
|
|
|
|
class="max-w-[208px] space-y-2"
|
2026-03-01 00:32:14 +08:00
|
|
|
|
>
|
|
|
|
|
|
<div
|
2026-05-09 10:44:45 +08:00
|
|
|
|
v-for="(item, idx) in quotaProgressMap[key.key_id]"
|
2026-03-01 00:32:14 +08:00
|
|
|
|
:key="`${key.key_id}-quota-${idx}`"
|
2026-03-19 20:04:56 +08:00
|
|
|
|
class="flex flex-col gap-1 min-w-[140px] max-w-[208px]"
|
2026-03-01 00:32:14 +08:00
|
|
|
|
>
|
2026-03-19 20:04:56 +08:00
|
|
|
|
<div class="flex items-center justify-between text-[10px] leading-none">
|
|
|
|
|
|
<span class="text-muted-foreground font-medium shrink-0">{{ getQuotaProgressLabel(item.label) }}</span>
|
2026-03-19 00:50:18 +08:00
|
|
|
|
<span
|
2026-05-16 19:23:09 +08:00
|
|
|
|
v-if="getQuotaProgressResetDisplayText(item)"
|
|
|
|
|
|
data-testid="pool-quota-reset-text"
|
2026-03-19 20:04:56 +08:00
|
|
|
|
class="text-muted-foreground/80 tabular-nums truncate"
|
2026-05-16 19:23:09 +08:00
|
|
|
|
:title="getQuotaProgressResetDisplayText(item)"
|
|
|
|
|
|
>{{ getQuotaProgressResetDisplayText(item) }}</span>
|
2026-03-19 00:50:18 +08:00
|
|
|
|
</div>
|
2026-03-19 20:04:56 +08:00
|
|
|
|
<div class="flex items-center gap-1.5">
|
|
|
|
|
|
<div class="relative flex-1 h-1.5 rounded-full bg-border overflow-hidden">
|
2026-03-03 09:22:20 +08:00
|
|
|
|
<div
|
2026-03-19 00:50:18 +08:00
|
|
|
|
class="absolute left-0 top-0 h-full rounded-full transition-all duration-300"
|
2026-03-03 09:22:20 +08:00
|
|
|
|
:class="getQuotaRemainingBarColorByRemaining(item.remainingPercent)"
|
|
|
|
|
|
:style="{ width: `${item.remainingPercent}%` }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-03-19 00:50:18 +08:00
|
|
|
|
<span
|
2026-05-16 19:23:09 +08:00
|
|
|
|
data-testid="pool-quota-meter-text"
|
2026-03-19 20:04:56 +08:00
|
|
|
|
class="shrink-0 text-[10px] font-medium tabular-nums leading-none"
|
2026-03-19 00:50:18 +08:00
|
|
|
|
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
|
2026-05-16 19:23:09 +08:00
|
|
|
|
>{{ getQuotaProgressMeterDisplayText(item) }}</span>
|
2026-03-01 00:32:14 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-22 16:48:15 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="keyUiStateMap[key.key_id]?.accountQuotaText"
|
|
|
|
|
|
class="text-[10px] leading-none text-muted-foreground tabular-nums"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ keyUiStateMap[key.key_id]?.accountQuotaText }}
|
|
|
|
|
|
</div>
|
2026-03-01 00:32:14 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<span
|
2026-05-22 16:48:15 +08:00
|
|
|
|
v-else-if="keyUiStateMap[key.key_id]?.accountQuotaText || keyUiStateMap[key.key_id]?.quotaFallbackText"
|
2026-04-21 16:19:07 +08:00
|
|
|
|
:class="keyUiStateMap[key.key_id]?.quotaTextClass || ''"
|
2026-03-01 00:32:14 +08:00
|
|
|
|
>
|
2026-05-22 16:48:15 +08:00
|
|
|
|
{{ keyUiStateMap[key.key_id]?.accountQuotaText || keyUiStateMap[key.key_id]?.quotaFallbackText }}
|
2026-03-01 00:32:14 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="text-xs text-muted-foreground"
|
|
|
|
|
|
>-</span>
|
|
|
|
|
|
</TableCell>
|
2026-03-05 15:15:26 +08:00
|
|
|
|
<TableCell class="py-3 px-2 align-middle">
|
2026-05-06 23:14:25 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="isPoolKeyCycleStatsDisplay(key)"
|
2026-05-07 03:23:20 +08:00
|
|
|
|
class="mx-auto w-[188px] text-[10px] leading-4"
|
2026-05-06 23:14:25 +08:00
|
|
|
|
data-testid="pool-stats-cycle-groups"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
2026-05-07 03:23:20 +08:00
|
|
|
|
class="grid min-h-16 w-[188px] grid-cols-[38px_64px_10px_64px] items-center gap-x-1"
|
|
|
|
|
|
data-testid="pool-stats-cycle-grid"
|
2026-05-06 23:14:25 +08:00
|
|
|
|
>
|
2026-05-07 03:23:20 +08:00
|
|
|
|
<span aria-hidden="true" />
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="text-center text-[9px] font-semibold text-muted-foreground/80"
|
|
|
|
|
|
data-testid="pool-stats-cycle-group-5h"
|
|
|
|
|
|
>5H</span>
|
|
|
|
|
|
<span class="text-center text-muted-foreground/50">|</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="text-center text-[9px] font-semibold text-muted-foreground/80"
|
|
|
|
|
|
data-testid="pool-stats-cycle-group-weekly"
|
|
|
|
|
|
>周</span>
|
|
|
|
|
|
|
|
|
|
|
|
<template
|
|
|
|
|
|
v-for="row in getPoolKeyCycleStatsRows(key)"
|
|
|
|
|
|
:key="`${key.key_id}-${row.key}-desktop-cycle-row`"
|
2026-05-06 23:14:25 +08:00
|
|
|
|
>
|
2026-05-07 03:23:20 +08:00
|
|
|
|
<span class="text-muted-foreground truncate">{{ row.label }}</span>
|
2026-05-06 23:14:25 +08:00
|
|
|
|
<span
|
2026-05-07 03:23:20 +08:00
|
|
|
|
class="min-w-0 truncate text-center tabular-nums text-foreground/90"
|
|
|
|
|
|
:class="row.fiveH.missing ? 'text-muted-foreground/80' : ''"
|
|
|
|
|
|
:data-testid="`pool-stats-5h-${row.key}`"
|
|
|
|
|
|
:title="row.fiveH.value"
|
|
|
|
|
|
>{{ row.fiveH.value }}</span>
|
|
|
|
|
|
<span class="text-center text-muted-foreground/50">|</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="min-w-0 truncate text-center tabular-nums text-foreground/90"
|
|
|
|
|
|
:class="row.weekly.missing ? 'text-muted-foreground/80' : ''"
|
|
|
|
|
|
:data-testid="`pool-stats-weekly-${row.key}`"
|
|
|
|
|
|
:title="row.weekly.value"
|
|
|
|
|
|
>{{ row.weekly.value }}</span>
|
|
|
|
|
|
</template>
|
2026-03-03 09:22:20 +08:00
|
|
|
|
</div>
|
2026-05-06 23:14:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
2026-05-07 03:23:20 +08:00
|
|
|
|
class="grid min-h-16 w-[188px] grid-rows-4 gap-0 mx-auto text-[10px] leading-4"
|
2026-05-06 23:14:25 +08:00
|
|
|
|
data-testid="pool-stats-account-total"
|
|
|
|
|
|
>
|
2026-05-07 03:23:20 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="invisible h-4"
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
>
|
|
|
|
|
|
-
|
|
|
|
|
|
</div>
|
2026-05-06 23:14:25 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-for="metric in getPoolKeyAccountStatsMetrics(key)"
|
|
|
|
|
|
:key="`${key.key_id}-${metric.key}-account-total`"
|
2026-05-07 03:23:20 +08:00
|
|
|
|
class="grid grid-cols-[64px_124px] items-center"
|
2026-05-06 23:14:25 +08:00
|
|
|
|
>
|
2026-05-07 03:23:20 +08:00
|
|
|
|
<span class="text-muted-foreground truncate">{{ metric.label }}</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="min-w-0 truncate text-center tabular-nums text-foreground/90"
|
|
|
|
|
|
:title="metric.value"
|
|
|
|
|
|
>
|
2026-05-06 23:14:25 +08:00
|
|
|
|
{{ metric.value }}
|
2026-03-11 15:56:58 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-03-03 09:22:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
2026-05-01 02:14:49 +08:00
|
|
|
|
<TableCell class="py-3 text-center">
|
|
|
|
|
|
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
|
|
|
|
|
|
{{ keyUiStateMap[key.key_id]?.importedAtRelative || '-' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</TableCell>
|
2026-03-05 15:15:26 +08:00
|
|
|
|
<TableCell class="py-3 text-center">
|
|
|
|
|
|
<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-05-12 15:27:21 +08:00
|
|
|
|
<TableCell class="py-3 text-center align-middle">
|
|
|
|
|
|
<div class="inline-flex items-center justify-center gap-1">
|
|
|
|
|
|
<span class="font-mono text-xs tabular-nums text-foreground/90">
|
|
|
|
|
|
{{ formatPoolScore(key.pool_score?.score) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
v-if="key.pool_score"
|
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-03-05 15:15:26 +08:00
|
|
|
|
<TableCell class="py-3 text-center">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<TableCell class="py-3 px-2 align-middle">
|
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 ? '禁用' : '启用'"
|
|
|
|
|
|
@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-04-21 16:19:07 +08:00
|
|
|
|
:class="keyUiStateMap[key.key_id]?.rowClass || ''"
|
2026-02-27 13:58:58 +08:00
|
|
|
|
>
|
2026-04-09 13:51:50 +08:00
|
|
|
|
<div class="space-y-3">
|
|
|
|
|
|
<div class="text-sm font-medium truncate">
|
|
|
|
|
|
{{ key.key_name || '未命名' }}
|
|
|
|
|
|
</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-04-09 13:51:50 +08:00
|
|
|
|
class="inline-flex max-w-full items-center rounded-full border px-2 py-0.5 text-[10px] font-medium leading-4"
|
|
|
|
|
|
:class="`${getMobileTagClass(item)} hover:border-primary/40 hover:text-foreground`"
|
|
|
|
|
|
:title="`${item.label},点击编辑优先级`"
|
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">
|
|
|
|
|
|
<template v-if="isPoolKeyCycleStatsDisplay(key)">
|
|
|
|
|
|
<div
|
2026-05-07 03:23:20 +08:00
|
|
|
|
class="grid min-h-16 w-[188px] grid-cols-[38px_64px_10px_64px] items-center gap-x-1 text-left"
|
|
|
|
|
|
data-testid="pool-mobile-stats-cycle-grid"
|
2026-05-06 23:14:25 +08:00
|
|
|
|
>
|
2026-05-07 03:23:20 +08:00
|
|
|
|
<span aria-hidden="true" />
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="text-center text-[10px] font-semibold text-foreground"
|
|
|
|
|
|
data-testid="pool-mobile-stats-cycle-group-5h"
|
|
|
|
|
|
>5H</span>
|
|
|
|
|
|
<span class="text-center text-muted-foreground/50">|</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="text-center text-[10px] font-semibold text-foreground"
|
|
|
|
|
|
data-testid="pool-mobile-stats-cycle-group-weekly"
|
|
|
|
|
|
>周</span>
|
|
|
|
|
|
|
|
|
|
|
|
<template
|
|
|
|
|
|
v-for="row in getPoolKeyCycleStatsRows(key)"
|
|
|
|
|
|
:key="`${key.key_id}-${row.key}-mobile-cycle-row`"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="text-muted-foreground truncate">{{ row.label }}</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="min-w-0 truncate text-center font-medium text-foreground/90 tabular-nums"
|
|
|
|
|
|
:class="row.fiveH.missing ? 'text-muted-foreground/80' : ''"
|
|
|
|
|
|
:title="row.fiveH.value"
|
|
|
|
|
|
>{{ row.fiveH.value }}</span>
|
|
|
|
|
|
<span class="text-center text-muted-foreground/50">|</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="min-w-0 truncate text-center font-medium text-foreground/90 tabular-nums"
|
|
|
|
|
|
:class="row.weekly.missing ? 'text-muted-foreground/80' : ''"
|
|
|
|
|
|
:title="row.weekly.value"
|
|
|
|
|
|
>{{ row.weekly.value }}</span>
|
|
|
|
|
|
</template>
|
2026-05-06 23:14:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
2026-05-07 03:23:20 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="invisible h-4"
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
>
|
|
|
|
|
|
-
|
|
|
|
|
|
</div>
|
2026-05-06 23:14:25 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-for="metric in getPoolKeyAccountStatsMetrics(key)"
|
|
|
|
|
|
:key="`${key.key_id}-${metric.key}-mobile-account-total`"
|
2026-05-07 03:23:20 +08:00
|
|
|
|
class="grid h-4 w-[188px] grid-cols-[64px_124px] items-center text-left"
|
2026-05-06 23:14:25 +08:00
|
|
|
|
>
|
2026-05-07 03:23:20 +08:00
|
|
|
|
<span class="text-muted-foreground truncate">{{ metric.label }}</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="min-w-0 truncate text-center font-medium text-foreground/90"
|
|
|
|
|
|
:title="metric.value"
|
|
|
|
|
|
>{{ metric.value }}</span>
|
2026-05-06 23:14:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<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-03-01 00:32:14 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="showAccountQuotaColumn"
|
2026-04-09 13:51:50 +08:00
|
|
|
|
class="rounded-xl border border-border/50 bg-muted/30 px-3 py-2 text-xs"
|
2026-03-01 00:32:14 +08:00
|
|
|
|
>
|
2026-04-09 13:51:50 +08:00
|
|
|
|
<div class="text-muted-foreground mb-1">
|
2026-03-01 00:32:14 +08:00
|
|
|
|
配额
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="quotaProgressMap[key.key_id]?.length"
|
2026-03-19 00:50:18 +08:00
|
|
|
|
class="space-y-2"
|
2026-03-01 00:32:14 +08:00
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(item, idx) in quotaProgressMap[key.key_id]"
|
|
|
|
|
|
:key="`${key.key_id}-quota-mobile-${idx}`"
|
2026-03-19 20:04:56 +08:00
|
|
|
|
class="flex flex-col gap-1 min-w-0"
|
2026-03-01 00:32:14 +08:00
|
|
|
|
>
|
2026-03-19 20:04:56 +08:00
|
|
|
|
<div class="flex items-center justify-between text-[10px] leading-none">
|
|
|
|
|
|
<span class="text-muted-foreground font-medium shrink-0">{{ getQuotaProgressLabel(item.label) }}</span>
|
2026-05-28 12:11:43 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-if="getQuotaProgressResetDisplayText(item)"
|
|
|
|
|
|
data-testid="pool-quota-reset-text"
|
|
|
|
|
|
class="text-muted-foreground/80 tabular-nums truncate"
|
|
|
|
|
|
:title="getQuotaProgressResetDisplayText(item)"
|
|
|
|
|
|
>{{ getQuotaProgressResetDisplayText(item) }}</span>
|
2026-03-19 00:50:18 +08:00
|
|
|
|
</div>
|
2026-03-19 20:04:56 +08:00
|
|
|
|
<div class="flex items-center gap-1.5">
|
|
|
|
|
|
<div class="relative flex-1 h-1.5 rounded-full bg-border overflow-hidden">
|
2026-03-03 09:22:20 +08:00
|
|
|
|
<div
|
2026-03-19 00:50:18 +08:00
|
|
|
|
class="absolute left-0 top-0 h-full rounded-full transition-all duration-300"
|
2026-03-03 09:22:20 +08:00
|
|
|
|
:class="getQuotaRemainingBarColorByRemaining(item.remainingPercent)"
|
|
|
|
|
|
:style="{ width: `${item.remainingPercent}%` }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-03-19 00:50:18 +08:00
|
|
|
|
<span
|
2026-05-16 19:23:09 +08:00
|
|
|
|
data-testid="pool-quota-meter-text"
|
2026-03-19 20:04:56 +08:00
|
|
|
|
class="shrink-0 text-[10px] font-medium tabular-nums leading-none"
|
2026-03-19 00:50:18 +08:00
|
|
|
|
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
|
2026-05-16 19:23:09 +08:00
|
|
|
|
>{{ getQuotaProgressMeterDisplayText(item) }}</span>
|
2026-03-01 00:32:14 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-22 16:48:15 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="keyUiStateMap[key.key_id]?.accountQuotaText"
|
|
|
|
|
|
class="text-[10px] leading-none text-muted-foreground tabular-nums"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ keyUiStateMap[key.key_id]?.accountQuotaText }}
|
|
|
|
|
|
</div>
|
2026-03-01 00:32:14 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
2026-05-22 16:48:15 +08:00
|
|
|
|
v-else-if="keyUiStateMap[key.key_id]?.accountQuotaText || keyUiStateMap[key.key_id]?.quotaFallbackText"
|
2026-04-21 16:19:07 +08:00
|
|
|
|
:class="keyUiStateMap[key.key_id]?.quotaTextClass || ''"
|
2026-03-01 00:32:14 +08:00
|
|
|
|
>
|
2026-05-22 16:48:15 +08:00
|
|
|
|
{{ keyUiStateMap[key.key_id]?.accountQuotaText || keyUiStateMap[key.key_id]?.quotaFallbackText }}
|
2026-03-01 00:32:14 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
-
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
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
|
|
|
|
|
|
v-else-if="actionId === 'copy_or_download'"
|
|
|
|
|
|
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 ? '禁用' : '启用'"
|
|
|
|
|
|
@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-05-13 09:16:52 +08:00
|
|
|
|
<ProviderFormDialog
|
|
|
|
|
|
v-model="providerEditDialogOpen"
|
|
|
|
|
|
:provider="providerToEdit"
|
|
|
|
|
|
@provider-updated="handleProviderEditSaved"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<EndpointFormDialog
|
|
|
|
|
|
v-if="selectedProviderData"
|
|
|
|
|
|
v-model="endpointEditDialogOpen"
|
|
|
|
|
|
:provider="selectedProviderData"
|
|
|
|
|
|
:endpoints="providerEndpointsForEdit"
|
|
|
|
|
|
:provider-format-conversion-enabled="selectedProviderData.enable_format_conversion"
|
|
|
|
|
|
@endpoint-created="handleEndpointEditSaved"
|
|
|
|
|
|
@endpoint-updated="handleEndpointEditSaved"
|
|
|
|
|
|
/>
|
2026-03-05 15:15:26 +08:00
|
|
|
|
<PoolAccountBatchDialog
|
|
|
|
|
|
v-if="selectedProviderId"
|
|
|
|
|
|
v-model="showAccountBatchDialog"
|
|
|
|
|
|
:provider-id="selectedProviderId"
|
|
|
|
|
|
:provider-name="selectedProviderData?.name || ''"
|
2026-03-11 18:18:59 +08:00
|
|
|
|
:provider-type="selectedProviderData?.provider_type || selectedProviderType"
|
2026-03-05 15:15:26 +08:00
|
|
|
|
:batch-concurrency="selectedProviderConfig?.batch_concurrency"
|
|
|
|
|
|
@changed="handleAccountBatchChanged"
|
|
|
|
|
|
/>
|
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-03-03 09:22:20 +08:00
|
|
|
|
:provider-type="selectedProviderData?.provider_type || selectedProviderType"
|
|
|
|
|
|
: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-03-03 22:04:40 +08:00
|
|
|
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
2026-03-03 09:22:20 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Search,
|
|
|
|
|
|
Upload,
|
2026-03-04 22:06:19 +08:00
|
|
|
|
ChevronDown,
|
2026-03-03 09:22:20 +08:00
|
|
|
|
RefreshCw,
|
2026-05-15 01:46:24 +08:00
|
|
|
|
Activity,
|
2026-03-03 09:22:20 +08:00
|
|
|
|
Power,
|
|
|
|
|
|
Database,
|
|
|
|
|
|
KeyRound,
|
|
|
|
|
|
Download,
|
|
|
|
|
|
Copy,
|
|
|
|
|
|
Shield,
|
|
|
|
|
|
Globe,
|
2026-05-07 03:23:20 +08:00
|
|
|
|
Repeat2,
|
|
|
|
|
|
RotateCcw,
|
2026-03-03 09:22:20 +08:00
|
|
|
|
SquarePen,
|
|
|
|
|
|
Trash2,
|
2026-03-05 15:15:26 +08:00
|
|
|
|
Users,
|
|
|
|
|
|
Settings2,
|
2026-04-09 13:51:50 +08:00
|
|
|
|
SlidersHorizontal,
|
2026-05-12 15:27:21 +08:00
|
|
|
|
CircleHelp,
|
2026-05-13 09:16:52 +08:00
|
|
|
|
Edit,
|
|
|
|
|
|
Plug,
|
2026-03-03 09:22:20 +08:00
|
|
|
|
} from 'lucide-vue-next'
|
2026-02-27 13:58:58 +08:00
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
Card,
|
|
|
|
|
|
Badge,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
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 RefreshButton from '@/components/ui/refresh-button.vue'
|
|
|
|
|
|
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-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-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,
|
|
|
|
|
|
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,
|
2026-05-13 09:16:52 +08:00
|
|
|
|
ProviderEndpoint,
|
2026-03-11 21:37:08 +08:00
|
|
|
|
PoolAdvancedConfig,
|
|
|
|
|
|
ProviderWithEndpointsSummary,
|
|
|
|
|
|
} from '@/api/endpoints/types/provider'
|
2026-04-17 18:22:41 +08:00
|
|
|
|
import type { QuotaStatusSnapshot, QuotaWindowSnapshot } from '@/api/endpoints/types'
|
2026-05-13 09:16:52 +08:00
|
|
|
|
import { getProvider, getProviderEndpoints, 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'
|
|
|
|
|
|
import ProviderProxyPopover from '@/features/pool/components/ProviderProxyPopover.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 EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
|
|
|
|
|
|
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-05-06 23:14:25 +08:00
|
|
|
|
type PoolManagementStatsMode,
|
2026-04-09 13:51:50 +08:00
|
|
|
|
type PoolManagementViewState,
|
|
|
|
|
|
writePoolManagementViewState,
|
|
|
|
|
|
} from '@/features/pool/utils/poolManagementState'
|
2026-05-06 23:14:25 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildPoolStatsDisplay,
|
|
|
|
|
|
type PoolCodexCycleStatsGroup,
|
|
|
|
|
|
type PoolStatsDisplay,
|
|
|
|
|
|
type PoolStatsMetric,
|
|
|
|
|
|
} from '@/features/pool/utils/poolStatsDisplay'
|
2026-03-12 01:11:35 +08:00
|
|
|
|
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
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-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-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-05-06 23:14:25 +08:00
|
|
|
|
statsMode: getQueryValue('statsMode'),
|
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-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
|
|
|
|
|
|
|
|
|
|
|
|
interface PoolDemandMetricSample {
|
|
|
|
|
|
providerId: string
|
|
|
|
|
|
sampledAt: number
|
|
|
|
|
|
hotCount: number
|
|
|
|
|
|
desiredHot: number
|
|
|
|
|
|
inFlight: number
|
|
|
|
|
|
emaInFlight: number
|
|
|
|
|
|
burstPending: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const showDemandMetricsDialog = ref(false)
|
|
|
|
|
|
const providerDemandMetricSamples = ref<PoolDemandMetricSample[]>([])
|
2026-05-01 02:14:49 +08:00
|
|
|
|
const poolKeyStatusFilterOptions: Array<{ value: PoolManagementViewState['status'], label: string }> = [
|
|
|
|
|
|
{ value: 'all', label: '全部状态' },
|
|
|
|
|
|
{ value: 'active', label: '可调度' },
|
|
|
|
|
|
{ value: 'cooldown', label: '冷却中' },
|
|
|
|
|
|
{ value: 'inactive', label: '禁用' },
|
|
|
|
|
|
]
|
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-05-13 09:16:52 +08:00
|
|
|
|
endpointEditDialogOpen.value = false
|
|
|
|
|
|
providerEndpointsForEdit.value = []
|
2026-04-09 13:51:50 +08:00
|
|
|
|
showAccountBatchDialog.value = false
|
|
|
|
|
|
closeProviderProxyPopovers()
|
|
|
|
|
|
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-02-27 13:58:58 +08:00
|
|
|
|
} finally {
|
2026-05-15 01:46:24 +08:00
|
|
|
|
if (requestId === overviewRequestId && !options.silent) {
|
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) {
|
|
|
|
|
|
// 优先回写保存接口返回值,避免弹窗立即重开时读到旧配置。
|
|
|
|
|
|
if (selectedProviderId.value && updatedProvider.id === selectedProviderId.value) {
|
|
|
|
|
|
selectedProviderData.value = updatedProvider
|
|
|
|
|
|
}
|
|
|
|
|
|
showSchedulingDialog.value = false
|
2026-03-05 15:15:26 +08:00
|
|
|
|
showAdvancedDialog.value = false
|
2026-03-05 09:53:23 +08:00
|
|
|
|
await loadOverview()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-03-09 15:44:04 +08:00
|
|
|
|
const DEFAULT_ENABLED_PRESETS = new Set(['cache_affinity', 'recent_refresh'])
|
|
|
|
|
|
|
2026-03-04 22:06:19 +08:00
|
|
|
|
const DEFAULT_PRESET_LABELS: Record<string, string> = {
|
|
|
|
|
|
lru: 'LRU',
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
const lastSample = existing.at(-1)
|
|
|
|
|
|
if (
|
|
|
|
|
|
lastSample
|
|
|
|
|
|
&& nextSample.sampledAt - lastSample.sampledAt < 1000
|
|
|
|
|
|
&& lastSample.hotCount === nextSample.hotCount
|
|
|
|
|
|
&& lastSample.desiredHot === nextSample.desiredHot
|
|
|
|
|
|
&& lastSample.inFlight === nextSample.inFlight
|
|
|
|
|
|
&& lastSample.emaInFlight === nextSample.emaInFlight
|
|
|
|
|
|
&& lastSample.burstPending === nextSample.burstPending
|
|
|
|
|
|
) {
|
|
|
|
|
|
providerDemandMetricSamples.value = existing
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
providerDemandMetricSamples.value = [...existing, nextSample]
|
|
|
|
|
|
.slice(-POOL_DEMAND_METRICS_SAMPLES_LIMIT)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopDemandMetricsPolling(): void {
|
|
|
|
|
|
if (demandMetricsPollingTimer !== null) {
|
|
|
|
|
|
window.clearInterval(demandMetricsPollingTimer)
|
|
|
|
|
|
demandMetricsPollingTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function refreshDemandMetricsOverview(): Promise<void> {
|
|
|
|
|
|
const providerId = selectedProviderId.value
|
|
|
|
|
|
if (!showDemandMetricsDialog.value || !showAdaptiveHotPoolMetricsButton.value || !providerId) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const requestId = ++demandMetricsRequestId
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await getPoolOverview({ cacheTtlMs: 0 })
|
|
|
|
|
|
if (
|
|
|
|
|
|
requestId !== demandMetricsRequestId
|
|
|
|
|
|
|| !showDemandMetricsDialog.value
|
|
|
|
|
|
|| selectedProviderId.value !== providerId
|
|
|
|
|
|
) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const allProviders = Array.isArray(res.items) ? res.items : []
|
|
|
|
|
|
const enabledProviders = allProviders.filter(item => item.pool_enabled)
|
|
|
|
|
|
poolProviders.value = enabledProviders
|
|
|
|
|
|
appendDemandMetricSample(
|
|
|
|
|
|
enabledProviders.find(item => item.provider_id === providerId) || null,
|
|
|
|
|
|
)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 指标弹窗只做尽力刷新,失败不打断主流程。
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startDemandMetricsPolling(): void {
|
|
|
|
|
|
stopDemandMetricsPolling()
|
|
|
|
|
|
appendDemandMetricSample(selectedProviderOverview.value)
|
|
|
|
|
|
void refreshDemandMetricsOverview()
|
|
|
|
|
|
demandMetricsPollingTimer = window.setInterval(() => {
|
|
|
|
|
|
if (!showDemandMetricsDialog.value || !showAdaptiveHotPoolMetricsButton.value) return
|
|
|
|
|
|
if (document.visibilityState === 'hidden') return
|
|
|
|
|
|
void refreshDemandMetricsOverview()
|
|
|
|
|
|
}, POOL_DEMAND_METRICS_POLL_INTERVAL_MS)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
|
|
if (!cfg) return `${DEFAULT_ENABLED_PRESETS.size} 维度`
|
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)) {
|
|
|
|
|
|
return `${DEFAULT_ENABLED_PRESETS.size} 维度`
|
|
|
|
|
|
}
|
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-05-07 03:23:20 +08:00
|
|
|
|
const showCodexStatsModeToggle = computed(() => selectedProviderType.value === 'codex')
|
2026-05-06 23:14:25 +08:00
|
|
|
|
|
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-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-03-01 00:32:14 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-05 15:15:26 +08:00
|
|
|
|
const desktopColumnWidths = computed(() => {
|
|
|
|
|
|
if (showAccountQuotaColumn.value) {
|
|
|
|
|
|
return {
|
2026-05-12 15:27:21 +08:00
|
|
|
|
name: '21%',
|
|
|
|
|
|
quota: '18%',
|
|
|
|
|
|
stats: '13%',
|
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-05-13 09:16:52 +08:00
|
|
|
|
endpointEditDialogOpen.value = false
|
|
|
|
|
|
providerEndpointsForEdit.value = []
|
2026-03-03 09:22:20 +08:00
|
|
|
|
editingKeyDetail.value = null
|
2026-03-05 15:15:26 +08:00
|
|
|
|
showAccountBatchDialog.value = false
|
2026-03-03 09:22:20 +08:00
|
|
|
|
keyPermissionsDialogOpen.value = false
|
|
|
|
|
|
keyFormDialogOpen.value = false
|
|
|
|
|
|
oauthKeyEditDialogOpen.value = false
|
2026-03-05 15:15:26 +08:00
|
|
|
|
closeProviderProxyPopovers()
|
2026-03-03 09:22:20 +08:00
|
|
|
|
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-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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadProviderData(id: string) {
|
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
|
|
|
|
|
|
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-02-27 13:58:58 +08:00
|
|
|
|
selectedProviderData.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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-05-01 02:14:49 +08:00
|
|
|
|
const keysLoadedOnce = ref(false)
|
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)
|
|
|
|
|
|
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)
|
2026-05-06 23:14:25 +08:00
|
|
|
|
const poolStatsMode = ref<PoolManagementStatsMode>(restoredViewState.statsMode)
|
2026-05-01 02:14:49 +08:00
|
|
|
|
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-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)
|
|
|
|
|
|
const oauthKeyEditDialogOpen = ref(false)
|
|
|
|
|
|
const editingKeyDetail = ref<PoolKeyDetail | null>(null)
|
2026-02-27 13:58:58 +08:00
|
|
|
|
|
2026-05-07 03:23:20 +08:00
|
|
|
|
function togglePoolStatsMode() {
|
|
|
|
|
|
poolStatsMode.value = poolStatsMode.value === 'current_cycle'
|
|
|
|
|
|
? 'account_total'
|
|
|
|
|
|
: 'current_cycle'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-01 02:14:49 +08:00
|
|
|
|
function clearPoolKeyFilters() {
|
|
|
|
|
|
if (!hasPoolKeyFilters.value) return
|
|
|
|
|
|
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-05-06 23:14:25 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
() => readPoolManagementViewState(
|
|
|
|
|
|
{ statsMode: getQueryValue('statsMode') },
|
|
|
|
|
|
poolManagementViewStorage,
|
|
|
|
|
|
).statsMode,
|
|
|
|
|
|
(value) => {
|
|
|
|
|
|
if (poolStatsMode.value === value) return
|
|
|
|
|
|
poolStatsMode.value = value
|
|
|
|
|
|
},
|
|
|
|
|
|
{ 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-05-06 23:14:25 +08:00
|
|
|
|
[selectedProviderId, searchQuery, statusFilter, currentPage, pageSize, sortBy, sortOrder, poolStatsMode],
|
|
|
|
|
|
([providerId, search, status, page, pageSizeValue, sortByValue, sortOrderValue, statsMode]) => {
|
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-05-06 23:14:25 +08:00
|
|
|
|
statsMode: statsMode as PoolManagementStatsMode,
|
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
|
|
|
|
|
|
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-05-07 03:23:20 +08:00
|
|
|
|
interface PoolCodexCycleStatsRow {
|
|
|
|
|
|
key: PoolStatsMetric['key']
|
|
|
|
|
|
label: string
|
|
|
|
|
|
fiveH: PoolStatsMetric
|
|
|
|
|
|
weekly: PoolStatsMetric
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const CODEX_CYCLE_STAT_KEYS: Array<PoolStatsMetric['key']> = ['request_count', 'total_tokens', 'total_cost_usd']
|
|
|
|
|
|
const CODEX_CYCLE_STAT_LABELS: Record<PoolStatsMetric['key'], string> = {
|
|
|
|
|
|
request_count: '请求',
|
|
|
|
|
|
total_tokens: 'Token',
|
|
|
|
|
|
total_cost_usd: '费用',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-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-05-06 23:14:25 +08:00
|
|
|
|
statsDisplay: buildPoolStatsDisplay(key, selectedProviderType.value, poolStatsMode.value),
|
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
|
|
|
|
|
|
?? buildPoolStatsDisplay(key, selectedProviderType.value, poolStatsMode.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 : []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 03:23:20 +08:00
|
|
|
|
function createMissingCycleMetric(key: PoolStatsMetric['key']): PoolStatsMetric {
|
|
|
|
|
|
return {
|
|
|
|
|
|
key,
|
|
|
|
|
|
label: CODEX_CYCLE_STAT_LABELS[key],
|
|
|
|
|
|
value: '—',
|
|
|
|
|
|
missing: true,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function findCycleMetric(
|
|
|
|
|
|
group: PoolCodexCycleStatsGroup | undefined,
|
|
|
|
|
|
key: PoolStatsMetric['key'],
|
|
|
|
|
|
): PoolStatsMetric {
|
|
|
|
|
|
return group?.metrics.find(metric => metric.key === key) ?? createMissingCycleMetric(key)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getPoolKeyCycleStatsRows(key: PoolKeyDetail): PoolCodexCycleStatsRow[] {
|
|
|
|
|
|
const groups = getPoolKeyCycleStatsGroups(key)
|
|
|
|
|
|
const fiveHGroup = groups.find(group => group.code === '5h')
|
|
|
|
|
|
const weeklyGroup = groups.find(group => group.code === 'weekly')
|
|
|
|
|
|
|
|
|
|
|
|
return CODEX_CYCLE_STAT_KEYS.map((metricKey) => {
|
|
|
|
|
|
const fiveH = findCycleMetric(fiveHGroup, metricKey)
|
|
|
|
|
|
const weekly = findCycleMetric(weeklyGroup, metricKey)
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: metricKey,
|
|
|
|
|
|
label: CODEX_CYCLE_STAT_LABELS[metricKey],
|
|
|
|
|
|
fiveH,
|
|
|
|
|
|
weekly,
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 23:14:25 +08:00
|
|
|
|
function getPoolKeyAccountStatsMetrics(key: PoolKeyDetail): PoolStatsMetric[] {
|
|
|
|
|
|
const display = getPoolKeyStatsDisplay(key)
|
|
|
|
|
|
return display.kind === 'account_total'
|
|
|
|
|
|
? display.metrics
|
|
|
|
|
|
: buildPoolStatsDisplay(key, selectedProviderType.value, 'account_total').metrics
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 11:32:41 +08:00
|
|
|
|
const quotaRefreshSupported = computed(() => {
|
|
|
|
|
|
return selectedProviderType.value === 'codex'
|
|
|
|
|
|
|| 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-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 {
|
|
|
|
|
|
void loadOverview()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:44:45 +08:00
|
|
|
|
function applyQuotaRefreshResultToCurrentPage(result: Awaited<ReturnType<typeof refreshProviderQuota>>): void {
|
|
|
|
|
|
const successfulResults = Array.isArray(result.results)
|
|
|
|
|
|
? result.results.filter((item) => item.status === 'success' && item.quota_snapshot)
|
|
|
|
|
|
: []
|
|
|
|
|
|
if (successfulResults.length === 0) return
|
|
|
|
|
|
|
|
|
|
|
|
const quotaByKeyId = new Map(successfulResults.map((item) => [item.key_id, item.quota_snapshot!]))
|
|
|
|
|
|
keyPage.value.keys = keyPage.value.keys.map((key) => {
|
|
|
|
|
|
const quotaSnapshot = quotaByKeyId.get(key.key_id)
|
|
|
|
|
|
if (!quotaSnapshot) return key
|
|
|
|
|
|
return {
|
|
|
|
|
|
...key,
|
|
|
|
|
|
quota_updated_at: quotaSnapshot.updated_at ?? quotaSnapshot.observed_at ?? key.quota_updated_at ?? null,
|
|
|
|
|
|
status_snapshot: {
|
|
|
|
|
|
...(key.status_snapshot ?? {}),
|
|
|
|
|
|
quota: quotaSnapshot,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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(
|
|
|
|
|
|
options: { silent?: boolean; reloadAfter?: boolean } = {},
|
|
|
|
|
|
): 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-03-03 11:32:41 +08:00
|
|
|
|
await loadKeys()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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-04-19 15:17:25 +08:00
|
|
|
|
async function loadKeys(options: { cacheTtlMs?: number } = {}) {
|
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
|
|
|
|
|
|
const status = statusFilter.value as 'all' | 'active' | 'cooldown' | 'inactive'
|
2026-05-01 02:14:49 +08:00
|
|
|
|
const sortByValue = sortBy.value || undefined
|
2026-02-27 13:58:58 +08:00
|
|
|
|
keysLoading.value = true
|
|
|
|
|
|
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-03-11 15:56:58 +08:00
|
|
|
|
resetKeyPage(page, pageSizeValue)
|
2026-05-01 02:14:49 +08:00
|
|
|
|
keysLoadedOnce.value = true
|
2026-02-27 13:58:58 +08:00
|
|
|
|
showError(parseApiError(err))
|
|
|
|
|
|
} finally {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (requestId === keysRequestId) {
|
|
|
|
|
|
keysLoading.value = false
|
|
|
|
|
|
}
|
2026-02-27 13:58:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
watch([currentPage, 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-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
|
|
|
|
|
|
currentPage.value = 1
|
|
|
|
|
|
if (keysSearchDebounceTimer !== null) {
|
|
|
|
|
|
clearTimeout(keysSearchDebounceTimer)
|
|
|
|
|
|
}
|
|
|
|
|
|
keysSearchDebounceTimer = window.setTimeout(() => {
|
|
|
|
|
|
keysSearchDebounceTimer = null
|
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,
|
|
|
|
|
|
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,
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleDialogSaved() {
|
|
|
|
|
|
editingKeyDetail.value = null
|
|
|
|
|
|
await loadKeys()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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-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
|
|
|
|
}
|
|
|
|
|
|
await loadKeys()
|
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-03-06 01:47:09 +08:00
|
|
|
|
await loadKeys()
|
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)
|
|
|
|
|
|
await loadKeys()
|
2026-03-11 15:56:58 +08:00
|
|
|
|
refreshOverviewInBackground()
|
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 || '周期统计已重置')
|
|
|
|
|
|
await loadKeys()
|
|
|
|
|
|
} 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 })
|
|
|
|
|
|
key.is_active = nextStatus
|
2026-03-03 11:32:41 +08:00
|
|
|
|
if (nextStatus) {
|
|
|
|
|
|
delete key.scheduling_label
|
|
|
|
|
|
delete key.scheduling_status
|
|
|
|
|
|
if (key.scheduling_reason === 'manual_disabled') {
|
|
|
|
|
|
delete key.scheduling_reason
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
key.scheduling_label = '禁用'
|
|
|
|
|
|
key.scheduling_status = 'blocked'
|
|
|
|
|
|
key.scheduling_reason = 'manual_disabled'
|
|
|
|
|
|
}
|
2026-03-03 09:22:20 +08:00
|
|
|
|
success(nextStatus ? '账号已启用' : '账号已停用')
|
2026-03-03 11:32:41 +08:00
|
|
|
|
await loadKeys()
|
2026-03-11 15:56:58 +08:00
|
|
|
|
refreshOverviewInBackground()
|
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-05-13 09:16:52 +08:00
|
|
|
|
const providerEditDialogOpen = ref(false)
|
|
|
|
|
|
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
|
|
|
|
|
|
const endpointEditDialogOpen = ref(false)
|
|
|
|
|
|
const providerEndpointsForEdit = ref<ProviderEndpoint[]>([])
|
2026-03-05 15:15:26 +08:00
|
|
|
|
const showAccountBatchDialog = ref(false)
|
|
|
|
|
|
const providerProxyMobilePopoverOpen = ref(false)
|
|
|
|
|
|
const providerProxyDesktopPopoverOpen = ref(false)
|
|
|
|
|
|
const savingProviderProxy = ref(false)
|
|
|
|
|
|
const togglingProviderStatus = ref(false)
|
2026-05-13 09:16:52 +08:00
|
|
|
|
let endpointEditRequestId = 0
|
2026-03-05 15:15:26 +08:00
|
|
|
|
|
|
|
|
|
|
function openSchedulingDialog() {
|
|
|
|
|
|
showSchedulingDialog.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 09:16:52 +08:00
|
|
|
|
async function openProviderEditDialog(): Promise<void> {
|
|
|
|
|
|
const providerId = selectedProviderId.value
|
|
|
|
|
|
if (!providerId) return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const latest = await getProvider(providerId)
|
|
|
|
|
|
if (selectedProviderId.value !== providerId) return
|
|
|
|
|
|
selectedProviderData.value = latest
|
|
|
|
|
|
providerToEdit.value = latest
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (selectedProviderId.value !== providerId) return
|
|
|
|
|
|
if (!selectedProviderData.value) {
|
|
|
|
|
|
showError(parseApiError(err, '刷新提供商状态失败'))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
providerToEdit.value = selectedProviderData.value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
providerEditDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleProviderEditSaved(updatedProvider: ProviderWithEndpointsSummary): Promise<void> {
|
|
|
|
|
|
if (selectedProviderId.value === updatedProvider.id) {
|
|
|
|
|
|
selectedProviderData.value = updatedProvider
|
|
|
|
|
|
providerToEdit.value = updatedProvider
|
|
|
|
|
|
}
|
|
|
|
|
|
providerEditDialogOpen.value = false
|
|
|
|
|
|
await loadOverview()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openEndpointEditDialog(): Promise<void> {
|
|
|
|
|
|
const providerId = selectedProviderId.value
|
|
|
|
|
|
if (!providerId) return
|
|
|
|
|
|
|
|
|
|
|
|
const requestId = ++endpointEditRequestId
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [provider, endpoints] = await Promise.all([
|
|
|
|
|
|
getProvider(providerId),
|
|
|
|
|
|
getProviderEndpoints(providerId),
|
|
|
|
|
|
])
|
|
|
|
|
|
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
|
|
|
|
|
|
selectedProviderData.value = provider
|
|
|
|
|
|
providerEndpointsForEdit.value = endpoints
|
|
|
|
|
|
endpointEditDialogOpen.value = true
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
|
|
|
|
|
|
showError(parseApiError(err, '加载端点失败'))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleEndpointEditSaved(): Promise<void> {
|
|
|
|
|
|
const providerId = selectedProviderId.value
|
|
|
|
|
|
if (!providerId) return
|
|
|
|
|
|
|
|
|
|
|
|
const requestId = ++endpointEditRequestId
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [provider, endpoints] = await Promise.all([
|
|
|
|
|
|
getProvider(providerId),
|
|
|
|
|
|
getProviderEndpoints(providerId),
|
|
|
|
|
|
])
|
|
|
|
|
|
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
|
|
|
|
|
|
selectedProviderData.value = provider
|
|
|
|
|
|
providerEndpointsForEdit.value = endpoints
|
|
|
|
|
|
await Promise.all([loadOverview(), loadKeys()])
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
|
|
|
|
|
|
showError(parseApiError(err, '刷新端点失败'))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 15:15:26 +08:00
|
|
|
|
function getProviderProxyNodeName(): string | null {
|
|
|
|
|
|
const nodeId = selectedProviderData.value?.proxy?.node_id
|
|
|
|
|
|
if (!nodeId) return null
|
|
|
|
|
|
const node = proxyNodesStore.nodes.find(n => n.id === nodeId)
|
|
|
|
|
|
return node ? node.name : `${nodeId.slice(0, 8)}...`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getProviderProxyButtonTitle(): string {
|
|
|
|
|
|
const nodeName = getProviderProxyNodeName()
|
|
|
|
|
|
if (nodeName) return `提供商代理(当前: ${nodeName})`
|
|
|
|
|
|
return '提供商代理(未设置)'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeProviderProxyPopovers(): void {
|
|
|
|
|
|
providerProxyMobilePopoverOpen.value = false
|
|
|
|
|
|
providerProxyDesktopPopoverOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleProviderProxyPopoverToggle(scope: 'mobile' | 'desktop', open: boolean): void {
|
|
|
|
|
|
if (scope === 'mobile') {
|
|
|
|
|
|
providerProxyMobilePopoverOpen.value = open
|
|
|
|
|
|
if (open) {
|
|
|
|
|
|
providerProxyDesktopPopoverOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
providerProxyDesktopPopoverOpen.value = open
|
|
|
|
|
|
if (open) {
|
|
|
|
|
|
providerProxyMobilePopoverOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (open) {
|
|
|
|
|
|
proxyNodesStore.ensureLoaded()
|
|
|
|
|
|
proxyDesktopPopoverOpenKeyId.value = null
|
|
|
|
|
|
proxyMobilePopoverOpenKeyId.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function setProviderProxy(nodeId: string): Promise<void> {
|
|
|
|
|
|
const providerId = selectedProviderId.value
|
|
|
|
|
|
if (!providerId) return
|
|
|
|
|
|
savingProviderProxy.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const updated = await updateProvider(providerId, {
|
|
|
|
|
|
proxy: { node_id: nodeId, enabled: true },
|
|
|
|
|
|
})
|
|
|
|
|
|
if (selectedProviderId.value === providerId) {
|
|
|
|
|
|
selectedProviderData.value = updated
|
|
|
|
|
|
}
|
|
|
|
|
|
closeProviderProxyPopovers()
|
|
|
|
|
|
success('提供商代理已设置')
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(parseApiError(err, '设置提供商代理失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
savingProviderProxy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function clearProviderProxy(): Promise<void> {
|
|
|
|
|
|
const providerId = selectedProviderId.value
|
|
|
|
|
|
if (!providerId) return
|
|
|
|
|
|
savingProviderProxy.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const updated = await updateProvider(providerId, { proxy: null })
|
|
|
|
|
|
if (selectedProviderId.value === providerId) {
|
|
|
|
|
|
selectedProviderData.value = updated
|
|
|
|
|
|
}
|
|
|
|
|
|
closeProviderProxyPopovers()
|
|
|
|
|
|
success('提供商代理已清除')
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(parseApiError(err, '清除提供商代理失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
savingProviderProxy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getProviderToggleButtonTitle(): string {
|
|
|
|
|
|
const active = selectedProviderData.value?.is_active !== false
|
|
|
|
|
|
return active ? '当前状态:已启用,点击禁用提供商' : '当前状态:已禁用,点击启用提供商'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getProviderToggleButtonClass(): string {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function toggleSelectedProviderStatus(): Promise<void> {
|
|
|
|
|
|
if (togglingProviderStatus.value) return
|
|
|
|
|
|
const providerId = selectedProviderId.value
|
|
|
|
|
|
const current = selectedProviderData.value
|
|
|
|
|
|
if (!providerId || !current) return
|
|
|
|
|
|
|
|
|
|
|
|
const nextStatus = !current.is_active
|
|
|
|
|
|
if (!nextStatus) {
|
|
|
|
|
|
const confirmed = await confirm({
|
|
|
|
|
|
title: '禁用提供商',
|
|
|
|
|
|
message: `禁用后该提供商(${current.name})将不再参与调度,是否继续?`,
|
|
|
|
|
|
confirmText: '确认禁用',
|
|
|
|
|
|
variant: 'destructive',
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!confirmed) return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
togglingProviderStatus.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const updated = await updateProvider(providerId, { is_active: nextStatus })
|
|
|
|
|
|
if (selectedProviderId.value === providerId) {
|
|
|
|
|
|
selectedProviderData.value = updated
|
|
|
|
|
|
}
|
|
|
|
|
|
success(nextStatus ? '提供商已启用' : '提供商已禁用')
|
|
|
|
|
|
await loadOverview()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(parseApiError(err, nextStatus ? '启用提供商失败' : '禁用提供商失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
togglingProviderStatus.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleAccountBatchChanged(): Promise<void> {
|
|
|
|
|
|
await Promise.all([loadKeys(), loadOverview()])
|
|
|
|
|
|
}
|
2026-02-27 13:58:58 +08:00
|
|
|
|
|
2026-03-03 09:22:20 +08:00
|
|
|
|
async function handleAccountDialogSaved() {
|
|
|
|
|
|
showImportDialog.value = false
|
|
|
|
|
|
await Promise.all([loadKeys(), loadOverview()])
|
2026-03-04 22:06:19 +08:00
|
|
|
|
// 导入账号后补一次静默额度刷新,避免新账号在列表里暂无额度信息
|
|
|
|
|
|
await refreshCurrentPageQuotaInBackground({ silent: true })
|
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-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-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-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-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
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
if (key.cost_limit != null && key.cost_limit > 0 && key.cost_window_usage >= key.cost_limit) return '超限'
|
|
|
|
|
|
return '可用'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getSchedulingBadgeVariant(key: PoolKeyDetail): PoolStatusVariant {
|
2026-03-04 22:06:19 +08:00
|
|
|
|
if (getAccountAlertLabel(key)) return 'destructive'
|
|
|
|
|
|
|
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'
|
|
|
|
|
|
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-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}`
|
|
|
|
|
|
}
|
|
|
|
|
|
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-03 09:22:20 +08:00
|
|
|
|
function formatOAuthPlanType(planType: string): string {
|
|
|
|
|
|
const labelMap: Record<string, string> = {
|
|
|
|
|
|
plus: 'Plus',
|
|
|
|
|
|
pro: 'Pro',
|
|
|
|
|
|
free: 'Free',
|
|
|
|
|
|
paid: 'Paid',
|
|
|
|
|
|
team: 'Team',
|
|
|
|
|
|
enterprise: 'Enterprise',
|
|
|
|
|
|
ultra: 'Ultra',
|
|
|
|
|
|
'pro+': 'Pro+',
|
|
|
|
|
|
power: 'Power',
|
2026-05-16 19:23:09 +08:00
|
|
|
|
basic: 'Basic',
|
|
|
|
|
|
super: 'Super',
|
|
|
|
|
|
heavy: 'Heavy',
|
2026-03-03 09:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
return labelMap[planType.toLowerCase()] || planType
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getOAuthPlanTypeClass(planType: string): string {
|
|
|
|
|
|
const classes: Record<string, string> = {
|
|
|
|
|
|
plus: 'border-green-500/50 text-green-600 dark:text-green-400',
|
|
|
|
|
|
pro: 'border-blue-500/50 text-blue-600 dark:text-blue-400',
|
|
|
|
|
|
free: 'border-primary/50 text-primary',
|
|
|
|
|
|
paid: 'border-blue-500/50 text-blue-600 dark:text-blue-400',
|
|
|
|
|
|
team: 'border-purple-500/50 text-purple-600 dark:text-purple-400',
|
|
|
|
|
|
enterprise: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
|
|
|
|
|
|
ultra: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
|
|
|
|
|
|
'pro+': 'border-purple-500/50 text-purple-600 dark:text-purple-400',
|
|
|
|
|
|
power: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
|
2026-05-16 19:23:09 +08:00
|
|
|
|
basic: 'border-primary/50 text-primary',
|
|
|
|
|
|
super: 'border-green-500/50 text-green-600 dark:text-green-400',
|
|
|
|
|
|
heavy: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
|
2026-03-03 09:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
return classes[planType.toLowerCase()] || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 '周'
|
|
|
|
|
|
if (normalized.includes('最低剩余')) return '最低'
|
|
|
|
|
|
if (normalized === '剩余' || normalized.includes('剩余')) 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-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-05-28 11:58:00 +08:00
|
|
|
|
const staticResetLabels = ['日', '5H', '周', 'Spark5H', '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
|
|
|
|
|
|
if (label === 'Spark5H') return 8
|
|
|
|
|
|
if (label === 'Spark周') return 9
|
|
|
|
|
|
if (label === 'Prompt') return 10
|
|
|
|
|
|
if (label === 'Flex') return 11
|
|
|
|
|
|
if (label === '剩余') return 12
|
|
|
|
|
|
if (label === '最低') return 13
|
|
|
|
|
|
if (label === '生图') return 14
|
|
|
|
|
|
if (label === '速率') return 15
|
|
|
|
|
|
if (label === '模型') return 16
|
|
|
|
|
|
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') {
|
|
|
|
|
|
const items: QuotaProgressItem[] = []
|
2026-05-16 19:23:09 +08:00
|
|
|
|
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
|
|
|
|
|
|
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
|
2026-05-09 10:44:45 +08:00
|
|
|
|
for (const [label, code] of [
|
|
|
|
|
|
['5H', '5h'],
|
|
|
|
|
|
['周', 'weekly'],
|
|
|
|
|
|
['Spark5H', 'spark_5h'],
|
|
|
|
|
|
['Spark周', 'spark_weekly'],
|
|
|
|
|
|
] as const) {
|
2026-04-17 18:22:41 +08:00
|
|
|
|
const window = getQuotaSnapshotWindow(quota, code)
|
|
|
|
|
|
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
|
|
|
|
|
if (remainingPercent == null) continue
|
|
|
|
|
|
items.push({
|
|
|
|
|
|
label,
|
|
|
|
|
|
remainingPercent,
|
2026-05-16 19:23:09 +08:00
|
|
|
|
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),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return items
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (providerType === 'kiro') {
|
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 [{
|
|
|
|
|
|
label: '剩余',
|
|
|
|
|
|
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),
|
|
|
|
|
|
}]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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') {
|
|
|
|
|
|
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
|
|
|
|
|
if (windows.length === 0) return []
|
|
|
|
|
|
|
|
|
|
|
|
const remainingPercents = windows
|
|
|
|
|
|
.map(getQuotaWindowRemainingPercent)
|
|
|
|
|
|
.filter((value): value is number => value != null)
|
|
|
|
|
|
if (remainingPercents.length === 0) return []
|
|
|
|
|
|
|
|
|
|
|
|
return [{
|
|
|
|
|
|
label: '最低',
|
|
|
|
|
|
remainingPercent: Math.min(...remainingPercents),
|
|
|
|
|
|
detail: `${windows.length} 模型`,
|
|
|
|
|
|
resetAtSeconds: null,
|
|
|
|
|
|
resetSeconds: null,
|
|
|
|
|
|
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
|
|
|
|
|
}]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) => {
|
|
|
|
|
|
const orderDiff = getQuotaLabelOrder(a.label) - getQuotaLabelOrder(b.label)
|
|
|
|
|
|
if (orderDiff !== 0) return orderDiff
|
|
|
|
|
|
return a.label.localeCompare(b.label, 'zh-Hans-CN')
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (getQuotaSnapshot(key)) return []
|
|
|
|
|
|
|
|
|
|
|
|
const quotaText = getLegacyAccountQuotaText(key)
|
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-02-27 13:58:58 +08:00
|
|
|
|
})
|
2026-03-03 22:04:40 +08:00
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
2026-05-15 01:46:24 +08:00
|
|
|
|
stopDemandMetricsPolling()
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (keysSearchDebounceTimer !== null) {
|
|
|
|
|
|
clearTimeout(keysSearchDebounceTimer)
|
|
|
|
|
|
keysSearchDebounceTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
overviewRequestId += 1
|
|
|
|
|
|
selectProviderRequestId += 1
|
|
|
|
|
|
providerDataRequestId += 1
|
|
|
|
|
|
keysRequestId += 1
|
|
|
|
|
|
})
|
2026-02-27 13:58:58 +08:00
|
|
|
|
</script>
|