2025-12-10 20:52:44 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<!-- 自定义抽屉 -->
|
|
|
|
|
|
<Teleport to="body">
|
|
|
|
|
|
<Transition name="drawer">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="open && (loading || provider)"
|
|
|
|
|
|
class="fixed inset-0 z-50 flex justify-end"
|
|
|
|
|
|
@click.self="handleBackdropClick"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 背景遮罩 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="absolute inset-0 bg-black/30 backdrop-blur-sm"
|
|
|
|
|
|
@click="handleBackdropClick"
|
|
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 抽屉内容 -->
|
2025-12-13 22:26:36 +08:00
|
|
|
|
<Card class="relative h-full w-full sm:w-[700px] sm:max-w-[90vw] rounded-none shadow-2xl overflow-y-auto">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 加载状态 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="loading"
|
|
|
|
|
|
class="flex items-center justify-center py-12"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else-if="provider">
|
|
|
|
|
|
<!-- 头部:名称 + 快捷操作 -->
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<div class="sticky top-0 z-10 bg-background border-b px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-3">
|
2026-02-05 02:23:41 +08:00
|
|
|
|
<div class="flex items-center justify-between gap-x-3 sm:gap-x-4 flex-wrap">
|
|
|
|
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
|
|
|
|
<h2 class="text-lg sm:text-xl font-bold truncate">
|
|
|
|
|
|
{{ provider.name }}
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
:variant="provider.is_active ? 'default' : 'secondary'"
|
|
|
|
|
|
class="text-xs shrink-0"
|
|
|
|
|
|
>
|
2026-03-03 11:32:41 +08:00
|
|
|
|
{{ provider.is_active ? '活跃' : '停用' }}
|
2026-02-05 02:23:41 +08:00
|
|
|
|
</Badge>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center gap-1 shrink-0">
|
2026-02-28 00:01:15 +08:00
|
|
|
|
<span :title="systemFormatConversionEnabled ? '系统级格式转换已启用' : (provider.enable_format_conversion ? '已启用格式转换(点击关闭)' : '启用格式转换')">
|
2026-02-04 02:04:54 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
2026-02-28 00:01:15 +08:00
|
|
|
|
:class="(provider.enable_format_conversion || systemFormatConversionEnabled) ? 'text-primary' : ''"
|
2026-02-04 02:04:54 +08:00
|
|
|
|
:disabled="systemFormatConversionEnabled"
|
|
|
|
|
|
@click="toggleFormatConversion"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Shuffle class="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</span>
|
2026-03-01 00:21:16 +08:00
|
|
|
|
<span :title="hasFailoverRules ? '已配置故障转移规则(点击编辑)' : '配置故障转移规则'">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
:class="hasFailoverRules ? 'text-orange-500 dark:text-orange-400' : ''"
|
|
|
|
|
|
@click="failoverRulesDialogOpen = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<GitBranch class="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</span>
|
2026-02-24 02:44:15 +08:00
|
|
|
|
<Popover
|
|
|
|
|
|
:open="providerProxyPopoverOpen"
|
|
|
|
|
|
@update:open="handleProviderProxyPopoverToggle"
|
|
|
|
|
|
>
|
|
|
|
|
|
<PopoverTrigger as-child>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
:class="provider.proxy?.node_id ? 'text-blue-500' : ''"
|
|
|
|
|
|
:disabled="savingProviderProxy"
|
|
|
|
|
|
:title="provider.proxy?.node_id ? `代理: ${getProviderProxyNodeName()}` : '设置代理节点'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Globe class="w-4 h-4" />
|
|
|
|
|
|
</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="provider.proxy?.node_id"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-6 px-2 text-[10px] text-muted-foreground"
|
|
|
|
|
|
:disabled="savingProviderProxy"
|
|
|
|
|
|
@click="clearProviderProxy"
|
|
|
|
|
|
>
|
|
|
|
|
|
清除
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ProxyNodeSelect
|
|
|
|
|
|
:model-value="provider.proxy?.node_id || ''"
|
|
|
|
|
|
trigger-class="h-8"
|
|
|
|
|
|
@update:model-value="setProviderProxy"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p class="text-[10px] text-muted-foreground">
|
|
|
|
|
|
{{ provider.proxy?.node_id ? '当前使用独立代理' : '未设置代理节点' }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
title="编辑提供商"
|
|
|
|
|
|
@click="$emit('edit', provider)"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Edit class="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
:title="provider.is_active ? '点击停用' : '点击启用'"
|
2025-12-12 20:22:03 +08:00
|
|
|
|
@click="$emit('toggleStatus', provider)"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Power class="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
title="关闭"
|
|
|
|
|
|
@click="handleClose"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<X class="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-13 21:27:30 +08:00
|
|
|
|
<!-- 网站地址(独占整行,紧贴名称行下方) -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="provider.website"
|
|
|
|
|
|
class="-mt-0.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a
|
|
|
|
|
|
:href="provider.website"
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
|
class="text-xs text-muted-foreground hover:text-primary hover:underline transition-colors truncate block"
|
|
|
|
|
|
:title="provider.website"
|
|
|
|
|
|
>{{ provider.website }}</a>
|
2026-02-05 02:23:41 +08:00
|
|
|
|
</div>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<!-- 端点 API 格式 -->
|
|
|
|
|
|
<div class="flex items-center gap-1.5 flex-wrap mt-3">
|
2026-03-31 19:19:04 +08:00
|
|
|
|
<template v-if="loadingProviderEndpoints && endpoints.length === 0">
|
|
|
|
|
|
<span class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
|
|
|
|
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
|
|
|
|
|
加载端点中
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<template
|
|
|
|
|
|
v-for="endpoint in endpoints"
|
|
|
|
|
|
:key="endpoint.id"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="text-xs px-2 py-0.5 rounded-md border border-border bg-background hover:bg-accent hover:border-accent-foreground/20 cursor-pointer transition-colors font-medium"
|
|
|
|
|
|
:class="{ 'opacity-40': !endpoint.is_active }"
|
|
|
|
|
|
:title="`编辑 ${formatApiFormat(endpoint.api_format)} 端点`"
|
|
|
|
|
|
@click="handleEditEndpoint(endpoint)"
|
|
|
|
|
|
>{{ formatApiFormat(endpoint.api_format) }}</span>
|
|
|
|
|
|
</template>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<span
|
2026-03-31 19:19:04 +08:00
|
|
|
|
v-if="endpoints.length > 0"
|
|
|
|
|
|
class="text-xs px-2 py-0.5 rounded-md border border-dashed border-border hover:bg-accent hover:border-accent-foreground/20 cursor-pointer transition-colors text-muted-foreground"
|
|
|
|
|
|
title="编辑端点"
|
|
|
|
|
|
@click="showAddEndpointDialog"
|
|
|
|
|
|
>编辑</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
v-else
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 text-xs"
|
|
|
|
|
|
@click="showAddEndpointDialog"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus class="w-3 h-3 mr-1" />
|
|
|
|
|
|
添加 API 端点
|
|
|
|
|
|
</Button>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-13 22:26:36 +08:00
|
|
|
|
<div class="space-y-6 p-4 sm:p-6">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<!-- 配额使用情况 -->
|
|
|
|
|
|
<Card
|
|
|
|
|
|
v-if="provider.billing_type === 'monthly_quota' && provider.monthly_quota_usd"
|
|
|
|
|
|
class="p-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="space-y-3">
|
|
|
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
|
<h3 class="text-sm font-semibold">
|
|
|
|
|
|
订阅配额
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
class="text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ ((provider.monthly_used_usd || 0) / provider.monthly_quota_usd * 100).toFixed(1) }}%
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div class="relative w-full h-2 bg-border rounded-full overflow-hidden">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
|
|
|
|
|
:class="{
|
|
|
|
|
|
'bg-green-500': (provider.monthly_used_usd || 0) / provider.monthly_quota_usd < 0.7,
|
|
|
|
|
|
'bg-yellow-500': (provider.monthly_used_usd || 0) / provider.monthly_quota_usd >= 0.7 && (provider.monthly_used_usd || 0) / provider.monthly_quota_usd < 0.9,
|
|
|
|
|
|
'bg-red-500': (provider.monthly_used_usd || 0) / provider.monthly_quota_usd >= 0.9
|
|
|
|
|
|
}"
|
|
|
|
|
|
:style="{ width: `${Math.min((provider.monthly_used_usd || 0) / provider.monthly_quota_usd * 100, 100)}%` }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center justify-between text-xs">
|
|
|
|
|
|
<span class="font-semibold">
|
|
|
|
|
|
${{ (provider.monthly_used_usd || 0).toFixed(2) }} / ${{ provider.monthly_quota_usd.toFixed(2) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="provider.quota_reset_day"
|
|
|
|
|
|
class="text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
每月 {{ provider.quota_reset_day }} 号重置
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<!-- 密钥管理 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Card class="overflow-hidden">
|
|
|
|
|
|
<div class="p-4 border-b border-border/60">
|
|
|
|
|
|
<div class="flex items-center justify-between">
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<h3 class="text-sm font-semibold">
|
2026-03-01 23:32:48 +08:00
|
|
|
|
{{ isKeyManagedProviderType(provider.provider_type) ? '密钥管理' : '账号管理' }}
|
2025-12-12 16:15:36 +08:00
|
|
|
|
</h3>
|
2026-02-05 18:09:14 +08:00
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
v-if="endpoints.length > 0"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-8"
|
|
|
|
|
|
@click="handleAddKeyToFirstEndpoint"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus class="w-3.5 h-3.5 mr-1.5" />
|
2026-03-01 23:32:48 +08:00
|
|
|
|
{{ isKeyManagedProviderType(provider.provider_type) ? '添加密钥' : '添加账号' }}
|
2026-02-05 18:09:14 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<!-- 密钥列表 -->
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div
|
2026-03-31 19:19:04 +08:00
|
|
|
|
v-if="loadingProviderKeys && allKeys.length === 0"
|
|
|
|
|
|
class="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Loader2 class="w-4 h-4 animate-spin" />
|
|
|
|
|
|
正在加载{{ isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号' }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="allKeys.length > 0"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
class="divide-y divide-border/40"
|
2026-02-11 11:53:04 +08:00
|
|
|
|
:class="shouldPaginateKeys && 'flex flex-col'"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div
|
2026-02-09 02:56:21 +08:00
|
|
|
|
v-for="({ key, endpoint }, localIdx) in paginatedKeys"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
:key="key.id"
|
2026-01-11 00:22:21 +08:00
|
|
|
|
class="px-4 py-2.5 hover:bg-muted/30 transition-colors group/item"
|
|
|
|
|
|
:class="{
|
2026-02-09 02:56:21 +08:00
|
|
|
|
'opacity-50': keyDragState.isDragging && keyDragState.draggedIndex === getGlobalKeyIndex(localIdx),
|
|
|
|
|
|
'bg-primary/5 border-l-2 border-l-primary': keyDragState.targetIndex === getGlobalKeyIndex(localIdx) && keyDragState.isDragging,
|
2026-01-13 18:42:15 +08:00
|
|
|
|
'opacity-40 bg-muted/20': !key.is_active
|
2026-01-11 00:22:21 +08:00
|
|
|
|
}"
|
|
|
|
|
|
draggable="true"
|
2026-02-09 02:56:21 +08:00
|
|
|
|
@dragstart="handleKeyDragStart($event, getGlobalKeyIndex(localIdx))"
|
2026-01-11 00:22:21 +08:00
|
|
|
|
@dragend="handleKeyDragEnd"
|
2026-02-09 02:56:21 +08:00
|
|
|
|
@dragover="handleKeyDragOver($event, getGlobalKeyIndex(localIdx))"
|
2026-01-11 00:22:21 +08:00
|
|
|
|
@dragleave="handleKeyDragLeave"
|
2026-02-09 02:56:21 +08:00
|
|
|
|
@drop="handleKeyDrop($event, getGlobalKeyIndex(localIdx))"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<!-- 第一行:名称 + 状态 + 操作按钮 -->
|
|
|
|
|
|
<div class="flex items-center justify-between gap-2">
|
|
|
|
|
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
2026-01-11 00:22:21 +08:00
|
|
|
|
<!-- 拖拽手柄 -->
|
|
|
|
|
|
<div class="cursor-grab active:cursor-grabbing text-muted-foreground/30 group-hover/item:text-muted-foreground transition-colors shrink-0">
|
|
|
|
|
|
<GripVertical class="w-4 h-4" />
|
|
|
|
|
|
</div>
|
2026-01-11 19:33:16 +08:00
|
|
|
|
<div class="flex flex-col min-w-0">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div class="flex items-center gap-1.5">
|
2026-02-09 19:01:14 +08:00
|
|
|
|
<span
|
2026-02-09 19:05:29 +08:00
|
|
|
|
class="text-sm font-medium truncate"
|
|
|
|
|
|
:class="key.name ? 'cursor-pointer hover:text-primary transition-colors' : ''"
|
|
|
|
|
|
:title="key.name ? '点击复制' : ''"
|
|
|
|
|
|
@click.stop="key.name && copyToClipboard(key.name)"
|
2026-02-09 19:01:14 +08:00
|
|
|
|
>{{ key.name || '未命名密钥' }}</span>
|
2026-02-09 01:05:48 +08:00
|
|
|
|
<!-- OAuth 订阅类型标签 (Codex) -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="key.oauth_plan_type"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0 shrink-0"
|
|
|
|
|
|
:class="getOAuthPlanTypeClass(key.oauth_plan_type)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatOAuthPlanType(key.oauth_plan_type) }}
|
|
|
|
|
|
</Badge>
|
2026-03-11 21:37:08 +08:00
|
|
|
|
<Badge
|
2026-03-12 01:11:35 +08:00
|
|
|
|
v-if="getOAuthOrgBadge(key)"
|
2026-03-11 21:37:08 +08:00
|
|
|
|
variant="secondary"
|
2026-04-20 15:56:59 +08:00
|
|
|
|
class="text-[9px] px-1 py-0 h-4 shrink-0"
|
2026-03-19 00:50:18 +08:00
|
|
|
|
:title="getOAuthOrgBadge(key)?.title"
|
2026-03-11 21:37:08 +08:00
|
|
|
|
>
|
2026-03-12 01:11:35 +08:00
|
|
|
|
{{ getOAuthOrgBadge(key)?.label }}
|
2026-03-11 21:37:08 +08:00
|
|
|
|
</Badge>
|
2026-02-09 01:05:48 +08:00
|
|
|
|
<!-- Kiro 订阅类型标签 -->
|
|
|
|
|
|
<Badge
|
2026-04-20 15:00:17 +08:00
|
|
|
|
v-if="shouldShowKiroSubscriptionBadge(key)"
|
2026-02-09 01:05:48 +08:00
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0 shrink-0"
|
2026-04-20 15:00:17 +08:00
|
|
|
|
:class="getOAuthPlanTypeClass(getKiroSubscriptionBadgeLabel(key))"
|
2026-02-09 01:05:48 +08:00
|
|
|
|
>
|
2026-04-20 15:00:17 +08:00
|
|
|
|
{{ getKiroSubscriptionBadgeLabel(key) }}
|
2026-02-09 01:05:48 +08:00
|
|
|
|
</Badge>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</div>
|
2026-01-11 19:33:16 +08:00
|
|
|
|
<div class="flex items-center gap-1">
|
|
|
|
|
|
<span class="text-[11px] font-mono text-muted-foreground">
|
2026-04-17 12:57:06 +08:00
|
|
|
|
{{ isOAuthManagedCredential(key) ? '[Refresh Token]' : (isServiceAccountCredential(key) ? '[Service Account]' : key.api_key_masked) }}
|
2026-01-11 19:33:16 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
<Button
|
2026-04-17 12:57:06 +08:00
|
|
|
|
v-if="canExportOAuthCredential(key)"
|
2026-01-11 19:33:16 +08:00
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-4 w-4 shrink-0"
|
2026-02-06 21:52:22 +08:00
|
|
|
|
title="下载 Refresh Token 授权文件"
|
|
|
|
|
|
@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="复制密钥"
|
2026-01-11 19:33:16 +08:00
|
|
|
|
@click.stop="copyFullKey(key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Copy class="w-2.5 h-2.5" />
|
|
|
|
|
|
</Button>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<!-- OAuth 状态(失效/过期/倒计时)和刷新按钮 -->
|
2026-05-01 02:14:49 +08:00
|
|
|
|
<template v-if="shouldShowOAuthRefreshControl(key)">
|
2026-02-06 16:37:06 +08:00
|
|
|
|
<!-- 账号级别异常:醒目提示 + 清除按钮 -->
|
2026-03-20 19:16:52 +08:00
|
|
|
|
<template v-if="isAccountLevelBlock(key)">
|
2026-02-06 16:37:06 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0 shrink-0 gap-0.5"
|
|
|
|
|
|
:title="getOAuthStatusTitle(key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ShieldX class="w-2.5 h-2.5" />
|
|
|
|
|
|
账号异常
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-4 w-4 shrink-0 text-destructive hover:text-destructive"
|
|
|
|
|
|
:disabled="clearingOAuthInvalidKeyId === key.id"
|
|
|
|
|
|
title="清除异常标记(确认账号已完成验证后使用)"
|
|
|
|
|
|
@click.stop="handleClearOAuthInvalid(key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw
|
|
|
|
|
|
class="w-2.5 h-2.5"
|
|
|
|
|
|
:class="{ 'animate-spin': clearingOAuthInvalidKeyId === key.id }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<!-- 普通 OAuth 状态 -->
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="text-[10px]"
|
|
|
|
|
|
:class="{
|
|
|
|
|
|
'text-destructive': getKeyOAuthExpires(key)?.isInvalid || getKeyOAuthExpires(key)?.isExpired,
|
|
|
|
|
|
'text-warning': getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isInvalid,
|
|
|
|
|
|
'text-muted-foreground': !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isInvalid
|
|
|
|
|
|
}"
|
|
|
|
|
|
:title="getOAuthStatusTitle(key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ getKeyOAuthExpires(key)?.text }}
|
|
|
|
|
|
</span>
|
2026-05-01 02:14:49 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="key.oauth_temporary"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0 shrink-0"
|
|
|
|
|
|
title="仅通过 Access Token 导入,无法自动刷新,到期后需要重新导入"
|
|
|
|
|
|
>
|
|
|
|
|
|
临时
|
|
|
|
|
|
</Badge>
|
2026-02-06 16:37:06 +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.id || !canRefreshOAuthCredential(key)"
|
2026-03-20 19:16:52 +08:00
|
|
|
|
:title="getOAuthRefreshButtonTitle(key)"
|
2026-02-06 16:37:06 +08:00
|
|
|
|
@click.stop="handleRefreshOAuth(key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw
|
|
|
|
|
|
class="w-2.5 h-2.5"
|
|
|
|
|
|
:class="{ 'animate-spin': refreshingOAuthKeyId === key.id }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</template>
|
2026-02-06 16:37:06 +08:00
|
|
|
|
<!-- Antigravity 账号未激活提示 -->
|
|
|
|
|
|
<span
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="provider.provider_type === 'antigravity' && key.is_active && isOAuthManagedCredential(key) && !hasAntigravityQuotaDisplayData(key)"
|
2026-02-06 16:37:06 +08:00
|
|
|
|
class="text-[10px] text-orange-500 dark:text-orange-400"
|
|
|
|
|
|
title="该账号尚未完成 Gemini Code Assist 激活,无法获取配额和使用模型"
|
|
|
|
|
|
>
|
|
|
|
|
|
账号未激活
|
|
|
|
|
|
</span>
|
2026-01-11 19:33:16 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 并发 + 健康度 + 操作按钮 -->
|
|
|
|
|
|
<div class="flex items-center gap-1 shrink-0">
|
|
|
|
|
|
<!-- 熔断徽章 -->
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="key.circuit_breaker_open"
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0 shrink-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
熔断
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<!-- 健康度 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="key.health_score !== undefined"
|
|
|
|
|
|
class="flex items-center gap-1 mr-1"
|
|
|
|
|
|
>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div class="w-10 h-1.5 bg-border rounded-full overflow-hidden">
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="h-full transition-all duration-300"
|
|
|
|
|
|
:class="getHealthScoreBarColor(key.health_score || 0)"
|
|
|
|
|
|
:style="{ width: `${(key.health_score || 0) * 100}%` }"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
/>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="text-[10px] font-medium tabular-nums"
|
|
|
|
|
|
:class="getHealthScoreColor(key.health_score || 0)"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
{{ ((key.health_score || 0) * 100).toFixed(0) }}%
|
|
|
|
|
|
</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
v-if="key.circuit_breaker_open || (key.health_score !== undefined && key.health_score < 0.5)"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7 text-green-600"
|
|
|
|
|
|
title="刷新健康状态"
|
|
|
|
|
|
@click="handleRecoverKey(key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7"
|
|
|
|
|
|
title="模型权限"
|
|
|
|
|
|
@click="handleKeyPermissions(key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Shield class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
2026-02-27 19:25:53 +08:00
|
|
|
|
<!-- 代理节点配置 -->
|
2026-02-09 01:05:48 +08:00
|
|
|
|
<Popover
|
|
|
|
|
|
:open="proxyPopoverOpenKeyId === key.id"
|
|
|
|
|
|
@update:open="(v: boolean) => handleProxyPopoverToggle(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.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.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-02-09 13:24:42 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7"
|
|
|
|
|
|
title="编辑密钥"
|
|
|
|
|
|
@click="handleEditKey(endpoint, key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Edit class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
v-if="provider.provider_type === 'antigravity'"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7"
|
|
|
|
|
|
title="配额详情"
|
|
|
|
|
|
@click="openAntigravityQuotaDialog(key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<BarChart3 class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7"
|
|
|
|
|
|
:disabled="togglingKeyId === key.id"
|
|
|
|
|
|
:title="key.is_active ? '点击停用' : '点击启用'"
|
|
|
|
|
|
@click="toggleKeyActive(key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Power class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7"
|
|
|
|
|
|
title="删除密钥"
|
|
|
|
|
|
@click="handleDeleteKey(key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<!-- Codex 上游额度信息(仅当有元数据时显示) -->
|
|
|
|
|
|
<div
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="hasCodexQuotaDisplayData(key)"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
class="mt-2 p-2 bg-muted/30 rounded-md"
|
|
|
|
|
|
>
|
2026-02-09 01:05:48 +08:00
|
|
|
|
<div class="flex items-center justify-between mb-1">
|
|
|
|
|
|
<span class="text-[10px] text-muted-foreground">账号配额</span>
|
|
|
|
|
|
<div class="flex items-center gap-1">
|
|
|
|
|
|
<RefreshCw
|
|
|
|
|
|
v-if="refreshingQuota"
|
|
|
|
|
|
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="getCodexQuotaDisplay(key)?.updated_at"
|
2026-02-09 01:05:48 +08:00
|
|
|
|
class="text-[9px] text-muted-foreground/70"
|
|
|
|
|
|
>
|
2026-04-17 18:22:41 +08:00
|
|
|
|
{{ formatCodexUpdatedAt(getCodexQuotaDisplay(key)?.updated_at || 0) }}
|
2026-02-09 01:05:48 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-09 10:44:45 +08:00
|
|
|
|
<!-- 普通 Codex 限额并排显示:Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
|
2026-02-09 01:05:48 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="grid gap-3"
|
2026-03-01 00:32:14 +08:00
|
|
|
|
:class="isCodexTeamPlan(key) ? 'grid-cols-2' : 'grid-cols-1'"
|
2026-02-09 01:05:48 +08:00
|
|
|
|
>
|
|
|
|
|
|
<!-- 周限额 -->
|
2026-04-17 18:22:41 +08:00
|
|
|
|
<div v-if="getCodexQuotaDisplay(key)?.primary_used_percent !== undefined">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
|
|
|
|
|
<span class="text-muted-foreground">周限额</span>
|
2026-04-17 18:22:41 +08:00
|
|
|
|
<span :class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.primary_used_percent || 0)">
|
|
|
|
|
|
{{ (100 - (getCodexQuotaDisplay(key)?.primary_used_percent || 0)).toFixed(1) }}%
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
2026-04-17 18:22:41 +08:00
|
|
|
|
:class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.primary_used_percent || 0)"
|
|
|
|
|
|
:style="{ width: `${Math.max(100 - (getCodexQuotaDisplay(key)?.primary_used_percent || 0), 0)}%` }"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="(getCodexQuotaDisplay(key)?.primary_reset_at || getCodexQuotaDisplay(key)?.primary_reset_seconds) && shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.primary_used_percent || 0)"
|
2026-03-14 11:59:07 +08:00
|
|
|
|
class="text-[9px] mt-0.5 tabular-nums"
|
|
|
|
|
|
:class="getResetCountdownClass(
|
2026-04-17 18:22:41 +08:00
|
|
|
|
getCodexQuotaDisplay(key)?.primary_reset_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.primary_reset_seconds,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.updated_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.primary_used_percent
|
2026-03-14 11:59:07 +08:00
|
|
|
|
)"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
>
|
2026-03-14 11:59:07 +08:00
|
|
|
|
{{ getResetCountdownText(
|
2026-04-17 18:22:41 +08:00
|
|
|
|
getCodexQuotaDisplay(key)?.primary_reset_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.primary_reset_seconds,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.updated_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.primary_used_percent
|
2026-03-14 11:59:07 +08:00
|
|
|
|
) }}
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-09 01:05:48 +08:00
|
|
|
|
<!-- 5H限额(仅 Team/Plus/Enterprise 显示) -->
|
2026-04-17 18:22:41 +08:00
|
|
|
|
<div v-if="isCodexTeamPlan(key) && getCodexQuotaDisplay(key)?.secondary_used_percent !== undefined">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
|
|
|
|
|
<span class="text-muted-foreground">5H限额</span>
|
2026-04-17 18:22:41 +08:00
|
|
|
|
<span :class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.secondary_used_percent || 0)">
|
|
|
|
|
|
{{ (100 - (getCodexQuotaDisplay(key)?.secondary_used_percent || 0)).toFixed(1) }}%
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
2026-04-17 18:22:41 +08:00
|
|
|
|
:class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.secondary_used_percent || 0)"
|
|
|
|
|
|
:style="{ width: `${Math.max(100 - (getCodexQuotaDisplay(key)?.secondary_used_percent || 0), 0)}%` }"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-03-14 11:59:07 +08:00
|
|
|
|
<div
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.secondary_used_percent || 0)"
|
2026-03-14 11:59:07 +08:00
|
|
|
|
class="text-[9px] mt-0.5 tabular-nums"
|
|
|
|
|
|
:class="getResetCountdownClass(
|
2026-04-17 18:22:41 +08:00
|
|
|
|
getCodexQuotaDisplay(key)?.secondary_reset_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.secondary_reset_seconds,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.updated_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.secondary_used_percent
|
2026-03-14 11:59:07 +08:00
|
|
|
|
)"
|
|
|
|
|
|
>
|
2026-04-17 18:22:41 +08:00
|
|
|
|
<template v-if="getCodexQuotaDisplay(key)?.secondary_reset_at || getCodexQuotaDisplay(key)?.secondary_reset_seconds">
|
2026-03-14 11:59:07 +08:00
|
|
|
|
{{ getResetCountdownText(
|
2026-04-17 18:22:41 +08:00
|
|
|
|
getCodexQuotaDisplay(key)?.secondary_reset_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.secondary_reset_seconds,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.updated_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.secondary_used_percent
|
2026-03-14 11:59:07 +08:00
|
|
|
|
) }}
|
2026-02-05 02:23:41 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
已重置
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-09 10:44:45 +08:00
|
|
|
|
<!-- Spark 限额独立一行展示,避免与普通 Codex 周/5H 混淆 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="hasCodexSparkQuotaDisplayData(key)"
|
|
|
|
|
|
class="mt-3 border-t border-border/60 pt-2"
|
|
|
|
|
|
>
|
2026-05-12 20:11:37 +08:00
|
|
|
|
<div class="mb-1 text-[10px] text-muted-foreground">
|
|
|
|
|
|
GPT-5.3 Codex Spark
|
|
|
|
|
|
</div>
|
2026-05-09 10:44:45 +08:00
|
|
|
|
<div class="grid gap-3 grid-cols-2">
|
|
|
|
|
|
<div v-if="getCodexQuotaDisplay(key)?.spark_secondary_used_percent !== undefined">
|
|
|
|
|
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
|
|
|
|
|
<span class="text-muted-foreground">Spark 周</span>
|
|
|
|
|
|
<span :class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)">
|
|
|
|
|
|
{{ (100 - (getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)).toFixed(1) }}%
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
|
|
|
|
|
:class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)"
|
|
|
|
|
|
:style="{ width: `${Math.max(100 - (getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0), 0)}%` }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.spark_secondary_used_percent || 0)"
|
|
|
|
|
|
class="text-[9px] mt-0.5 tabular-nums"
|
|
|
|
|
|
:class="getResetCountdownClass(
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.spark_secondary_reset_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.spark_secondary_reset_seconds,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.updated_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.spark_secondary_used_percent
|
|
|
|
|
|
)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template v-if="getCodexQuotaDisplay(key)?.spark_secondary_reset_at || getCodexQuotaDisplay(key)?.spark_secondary_reset_seconds">
|
|
|
|
|
|
{{ getResetCountdownText(
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.spark_secondary_reset_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.spark_secondary_reset_seconds,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.updated_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.spark_secondary_used_percent
|
|
|
|
|
|
) }}
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
已重置
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="getCodexQuotaDisplay(key)?.spark_primary_used_percent !== undefined">
|
|
|
|
|
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
|
|
|
|
|
<span class="text-muted-foreground">Spark 5H</span>
|
|
|
|
|
|
<span :class="getQuotaRemainingClass(getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)">
|
|
|
|
|
|
{{ (100 - (getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)).toFixed(1) }}%
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
|
|
|
|
|
:class="getQuotaRemainingBarColor(getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)"
|
|
|
|
|
|
:style="{ width: `${Math.max(100 - (getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0), 0)}%` }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="shouldStartCodexResetCountdown(getCodexQuotaDisplay(key)?.spark_primary_used_percent || 0)"
|
|
|
|
|
|
class="text-[9px] mt-0.5 tabular-nums"
|
|
|
|
|
|
:class="getResetCountdownClass(
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.spark_primary_reset_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.spark_primary_reset_seconds,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.updated_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.spark_primary_used_percent
|
|
|
|
|
|
)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template v-if="getCodexQuotaDisplay(key)?.spark_primary_reset_at || getCodexQuotaDisplay(key)?.spark_primary_reset_seconds">
|
|
|
|
|
|
{{ getResetCountdownText(
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.spark_primary_reset_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.spark_primary_reset_seconds,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.updated_at,
|
|
|
|
|
|
getCodexQuotaDisplay(key)?.spark_primary_used_percent
|
|
|
|
|
|
) }}
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
已重置
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</div>
|
2026-02-06 16:37:06 +08:00
|
|
|
|
<!-- Antigravity 上游额度摘要(按家族分组展示关键配额) -->
|
|
|
|
|
|
<div
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="provider.provider_type === 'antigravity' && (hasAntigravityQuotaDisplayData(key) || isAntigravityForbiddenKey(key))"
|
2026-02-09 17:24:48 +08:00
|
|
|
|
class="mt-2 p-2 rounded-md"
|
2026-04-17 18:22:41 +08:00
|
|
|
|
:class="isAntigravityForbiddenKey(key) ? 'bg-destructive/10 border border-destructive/30' : 'bg-muted/30'"
|
2026-02-06 16:37:06 +08:00
|
|
|
|
>
|
2026-02-09 17:24:48 +08:00
|
|
|
|
<!-- 封禁状态显示 -->
|
|
|
|
|
|
<div
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="isAntigravityForbiddenKey(key)"
|
2026-02-09 17:24:48 +08:00
|
|
|
|
class="flex items-center gap-2 text-destructive"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ShieldX class="w-4 h-4 shrink-0" />
|
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
|
<div class="text-[11px] font-medium">
|
|
|
|
|
|
账户访问被禁止
|
2026-02-06 16:37:06 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="getAntigravityForbiddenReason(key)"
|
2026-02-09 17:24:48 +08:00
|
|
|
|
class="text-[10px] text-destructive/80 truncate"
|
2026-04-17 18:22:41 +08:00
|
|
|
|
:title="getAntigravityForbiddenReason(key)"
|
2026-02-06 16:37:06 +08:00
|
|
|
|
>
|
2026-04-17 18:22:41 +08:00
|
|
|
|
{{ getAntigravityForbiddenReason(key) }}
|
2026-02-06 16:37:06 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-09 17:24:48 +08:00
|
|
|
|
<span
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="getAntigravityForbiddenAt(key)"
|
2026-02-09 17:24:48 +08:00
|
|
|
|
class="text-[9px] text-destructive/60 shrink-0"
|
|
|
|
|
|
>
|
2026-04-17 18:22:41 +08:00
|
|
|
|
{{ formatBanTimestamp(getAntigravityForbiddenAt(key)) }}
|
2026-02-09 17:24:48 +08:00
|
|
|
|
</span>
|
2026-02-06 16:37:06 +08:00
|
|
|
|
</div>
|
2026-02-09 17:24:48 +08:00
|
|
|
|
<!-- 正常配额显示 -->
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<div class="flex items-center justify-between mb-1">
|
|
|
|
|
|
<span class="text-[10px] text-muted-foreground">模型配额</span>
|
|
|
|
|
|
<div class="flex items-center gap-1">
|
|
|
|
|
|
<RefreshCw
|
|
|
|
|
|
v-if="refreshingQuota"
|
|
|
|
|
|
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="getAntigravityQuotaUpdatedAt(key)"
|
2026-02-09 17:24:48 +08:00
|
|
|
|
class="text-[9px] text-muted-foreground/70"
|
|
|
|
|
|
>
|
2026-04-17 18:22:41 +08:00
|
|
|
|
{{ formatAntigravityUpdatedAt(getAntigravityQuotaUpdatedAt(key) || 0) }}
|
2026-02-09 17:24:48 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
|
|
|
|
<div
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-for="group in getAntigravityQuotaSummaryForKey(key)"
|
2026-02-09 17:24:48 +08:00
|
|
|
|
:key="group.key"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
|
|
|
|
|
<span class="text-muted-foreground truncate mr-2 min-w-0 flex-1">
|
|
|
|
|
|
{{ group.label }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span :class="getQuotaRemainingClass(group.usedPercent)">
|
|
|
|
|
|
{{ group.remainingPercent.toFixed(1) }}%
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
|
|
|
|
|
:class="getQuotaRemainingBarColor(group.usedPercent)"
|
|
|
|
|
|
:style="{ width: `${Math.max(group.remainingPercent, 0)}%` }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="group.resetSeconds !== null || group.usedPercent > 0"
|
|
|
|
|
|
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template v-if="group.resetSeconds !== null && group.resetSeconds > 0">
|
|
|
|
|
|
{{ formatResetTime(group.resetSeconds) }}后重置
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else-if="group.resetSeconds !== null && group.resetSeconds <= 0">
|
|
|
|
|
|
已重置
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
重置时间未知
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-02-06 16:37:06 +08:00
|
|
|
|
</div>
|
2026-02-09 01:05:48 +08:00
|
|
|
|
<!-- Kiro 上游额度信息(仅当有元数据时显示) -->
|
|
|
|
|
|
<div
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="provider.provider_type === 'kiro' && (hasKiroQuotaDisplayData(key) || isKiroBannedKey(key))"
|
2026-02-09 17:24:48 +08:00
|
|
|
|
class="mt-2 p-2 rounded-md"
|
2026-04-17 18:22:41 +08:00
|
|
|
|
:class="isKiroBannedKey(key) ? 'bg-destructive/10 border border-destructive/30' : 'bg-muted/30'"
|
2026-02-09 01:05:48 +08:00
|
|
|
|
>
|
2026-02-09 17:24:48 +08:00
|
|
|
|
<!-- 封禁状态显示 -->
|
|
|
|
|
|
<div
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="isKiroBannedKey(key)"
|
2026-02-09 17:24:48 +08:00
|
|
|
|
class="flex items-center gap-2 text-destructive"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ShieldX class="w-4 h-4 shrink-0" />
|
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
|
<div class="text-[11px] font-medium">
|
|
|
|
|
|
账户已封禁
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="getKiroQuotaDisplay(key)?.ban_reason"
|
2026-02-09 17:24:48 +08:00
|
|
|
|
class="text-[10px] text-destructive/80 truncate"
|
2026-04-17 18:22:41 +08:00
|
|
|
|
:title="getKiroQuotaDisplay(key)?.ban_reason"
|
2026-02-09 01:05:48 +08:00
|
|
|
|
>
|
2026-04-17 18:22:41 +08:00
|
|
|
|
{{ getKiroQuotaDisplay(key)?.ban_reason }}
|
2026-02-09 01:05:48 +08:00
|
|
|
|
</div>
|
2026-02-09 17:24:48 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<span
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="getKiroQuotaDisplay(key)?.banned_at"
|
2026-02-09 17:24:48 +08:00
|
|
|
|
class="text-[9px] text-destructive/60 shrink-0"
|
|
|
|
|
|
>
|
2026-04-17 18:22:41 +08:00
|
|
|
|
{{ formatBanTimestamp(getKiroQuotaDisplay(key)?.banned_at) }}
|
2026-02-09 17:24:48 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 正常配额显示 -->
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<div class="flex items-center justify-between mb-1">
|
|
|
|
|
|
<span class="text-[10px] text-muted-foreground">账号配额</span>
|
|
|
|
|
|
<div class="flex items-center gap-1">
|
|
|
|
|
|
<RefreshCw
|
|
|
|
|
|
v-if="refreshingQuota"
|
|
|
|
|
|
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
2026-02-09 01:05:48 +08:00
|
|
|
|
/>
|
2026-02-09 17:24:48 +08:00
|
|
|
|
<span
|
2026-04-17 18:22:41 +08:00
|
|
|
|
v-if="getKiroQuotaDisplay(key)?.updated_at"
|
2026-02-09 17:24:48 +08:00
|
|
|
|
class="text-[9px] text-muted-foreground/70"
|
|
|
|
|
|
>
|
2026-04-17 18:22:41 +08:00
|
|
|
|
{{ formatKiroUpdatedAt(getKiroQuotaDisplay(key)?.updated_at || 0) }}
|
2026-02-09 01:05:48 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-09 17:24:48 +08:00
|
|
|
|
<!-- Kiro 额度显示:使用进度 -->
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<!-- 使用额度进度条 -->
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
|
|
|
|
|
<span class="text-muted-foreground">使用额度</span>
|
2026-04-17 18:22:41 +08:00
|
|
|
|
<span :class="getQuotaRemainingClass(getKiroQuotaDisplay(key)?.usage_percentage || 0)">
|
|
|
|
|
|
{{ (100 - (getKiroQuotaDisplay(key)?.usage_percentage || 0)).toFixed(1) }}%
|
2026-02-09 17:24:48 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
2026-04-17 18:22:41 +08:00
|
|
|
|
:class="getQuotaRemainingBarColor(getKiroQuotaDisplay(key)?.usage_percentage || 0)"
|
|
|
|
|
|
:style="{ width: `${Math.max(100 - (getKiroQuotaDisplay(key)?.usage_percentage || 0), 0)}%` }"
|
2026-02-09 17:24:48 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
|
|
|
|
|
|
<span>
|
2026-04-17 18:22:41 +08:00
|
|
|
|
{{ formatKiroUsage(getKiroQuotaDisplay(key)?.current_usage) }} /
|
|
|
|
|
|
{{ formatKiroUsage(getKiroQuotaDisplay(key)?.usage_limit) }}
|
2026-02-09 17:24:48 +08:00
|
|
|
|
</span>
|
2026-04-17 18:22:41 +08:00
|
|
|
|
<span v-if="getKiroQuotaDisplay(key)?.next_reset_at">
|
|
|
|
|
|
{{ formatKiroResetTime(getKiroQuotaDisplay(key)?.next_reset_at) }}重置
|
2026-02-09 17:24:48 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-02-09 01:05:48 +08:00
|
|
|
|
</div>
|
2026-05-06 22:58:47 +08:00
|
|
|
|
<!-- ChatGPT Web 上游额度信息(生图配额) -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="provider.provider_type === 'chatgpt_web' && hasChatGPTWebQuotaDisplayData(key)"
|
|
|
|
|
|
class="mt-2 p-2 rounded-md bg-muted/30"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-center justify-between mb-1">
|
|
|
|
|
|
<span class="text-[10px] text-muted-foreground">账号配额</span>
|
|
|
|
|
|
<div class="flex items-center gap-1">
|
|
|
|
|
|
<RefreshCw
|
|
|
|
|
|
v-if="refreshingQuota"
|
|
|
|
|
|
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="getChatGPTWebQuotaDisplay(key)?.updated_at"
|
|
|
|
|
|
class="text-[9px] text-muted-foreground/70"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatKiroUpdatedAt(getChatGPTWebQuotaDisplay(key)?.updated_at || 0) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
|
|
|
|
|
<span class="text-muted-foreground">使用额度</span>
|
|
|
|
|
|
<span :class="getQuotaRemainingClass(getChatGPTWebQuotaUsedPercent(key))">
|
|
|
|
|
|
{{ getChatGPTWebQuotaRemainingPercent(key).toFixed(1) }}%
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="absolute left-0 top-0 h-full transition-all duration-300"
|
|
|
|
|
|
:class="getQuotaRemainingBarColor(getChatGPTWebQuotaUsedPercent(key))"
|
|
|
|
|
|
:style="{ width: `${Math.max(getChatGPTWebQuotaRemainingPercent(key), 0)}%` }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_used) }} /
|
|
|
|
|
|
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_total) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span v-if="getChatGPTWebQuotaDisplay(key)?.image_quota_reset_at">
|
|
|
|
|
|
{{ formatKiroResetTime(getChatGPTWebQuotaDisplay(key)?.image_quota_reset_at) }}重置
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<!-- 第二行:优先级 + API 格式(展开显示) + 统计信息 -->
|
|
|
|
|
|
<div class="flex items-center gap-1.5 mt-1 text-[11px] text-muted-foreground">
|
|
|
|
|
|
<!-- 优先级放最前面,支持点击编辑 -->
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="editingPriorityKey !== key.id"
|
|
|
|
|
|
title="点击编辑优先级"
|
|
|
|
|
|
class="font-medium text-foreground/80 cursor-pointer hover:text-primary hover:underline"
|
|
|
|
|
|
@click="startEditPriority(key)"
|
|
|
|
|
|
>P{{ key.internal_priority }}</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-else
|
|
|
|
|
|
ref="priorityInputRef"
|
|
|
|
|
|
v-model="editingPriorityValue"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
inputmode="numeric"
|
|
|
|
|
|
pattern="[0-9]*"
|
|
|
|
|
|
class="w-8 h-5 px-1 text-[11px] text-center border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary font-medium text-foreground/80"
|
|
|
|
|
|
@keydown="(e) => handlePriorityKeydown(e, key)"
|
|
|
|
|
|
@blur="handlePriorityBlur(key)"
|
|
|
|
|
|
>
|
2026-01-14 13:12:19 +08:00
|
|
|
|
<!-- 自动获取模型状态 -->
|
|
|
|
|
|
<template v-if="key.auto_fetch_models">
|
|
|
|
|
|
<span class="text-muted-foreground/40">|</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="cursor-help"
|
|
|
|
|
|
:class="key.last_models_fetch_error ? 'text-amber-600 dark:text-amber-400' : ''"
|
|
|
|
|
|
:title="getAutoFetchStatusTitle(key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ key.last_models_fetch_error ? '同步失败' : '自动同步' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<!-- RPM 限制信息(第二位) -->
|
|
|
|
|
|
<template v-if="key.rpm_limit || key.is_adaptive">
|
|
|
|
|
|
<span class="text-muted-foreground/40">|</span>
|
2026-01-17 23:44:53 +08:00
|
|
|
|
<span v-if="key.is_adaptive">
|
|
|
|
|
|
{{ key.learned_rpm_limit != null ? `${key.learned_rpm_limit}` : '探测中' }} RPM
|
|
|
|
|
|
<span class="text-muted-foreground/60">(自适应)</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span v-else>{{ key.rpm_limit }} RPM</span>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
</template>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<span class="text-muted-foreground/40">|</span>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<!-- API 格式:展开显示每个格式、倍率、熔断状态 -->
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<template
|
|
|
|
|
|
v-for="(format, idx) in getKeyApiFormats(key, endpoint)"
|
|
|
|
|
|
:key="format"
|
|
|
|
|
|
>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-if="idx > 0"
|
|
|
|
|
|
class="text-muted-foreground/40"
|
|
|
|
|
|
>/</span>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<span :class="{ 'text-destructive': isFormatCircuitOpen(key, format) }">
|
2026-04-26 22:09:16 +08:00
|
|
|
|
{{ formatApiFormatShort(format) }}
|
2026-01-13 18:42:15 +08:00
|
|
|
|
</span>
|
2026-01-11 04:12:29 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-if="editingMultiplierKey !== key.id || editingMultiplierFormat !== format"
|
|
|
|
|
|
title="点击编辑倍率"
|
|
|
|
|
|
class="cursor-pointer hover:text-primary hover:underline"
|
|
|
|
|
|
:class="{ 'text-destructive': isFormatCircuitOpen(key, format) }"
|
|
|
|
|
|
@click="startEditMultiplier(key, format)"
|
|
|
|
|
|
>{{ getKeyRateMultiplier(key, format) }}x</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-else
|
|
|
|
|
|
ref="multiplierInputRef"
|
|
|
|
|
|
v-model="editingMultiplierValue"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
inputmode="decimal"
|
|
|
|
|
|
pattern="[0-9]*\.?[0-9]*"
|
|
|
|
|
|
class="w-10 h-5 px-1 text-[11px] text-center border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary font-medium text-foreground/80"
|
|
|
|
|
|
@keydown="(e) => handleMultiplierKeydown(e, key, format)"
|
|
|
|
|
|
@blur="handleMultiplierBlur(key, format)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="getFormatProbeCountdown(key, format)"
|
|
|
|
|
|
:class="{ 'text-destructive': isFormatCircuitOpen(key, format) }"
|
|
|
|
|
|
>{{ getFormatProbeCountdown(key, format) }}</span>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
</template>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-09 02:56:21 +08:00
|
|
|
|
<!-- 分页控制 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="shouldPaginateKeys"
|
2026-02-11 11:53:04 +08:00
|
|
|
|
class="px-4 py-2 flex items-center justify-between text-xs text-muted-foreground mt-auto"
|
2026-02-09 02:56:21 +08:00
|
|
|
|
>
|
2026-03-01 23:32:48 +08:00
|
|
|
|
<span>共 {{ allKeys.length }} 个{{ isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号' }}</span>
|
2026-02-09 02:56:21 +08:00
|
|
|
|
<div class="flex items-center gap-1.5">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-6 px-2 text-xs"
|
2026-05-16 12:52:49 +08:00
|
|
|
|
:disabled="loadingProviderKeys || currentKeyPage <= 1"
|
|
|
|
|
|
@click="goToKeyPage(currentKeyPage - 1)"
|
2026-02-09 02:56:21 +08:00
|
|
|
|
>
|
|
|
|
|
|
‹
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<span class="tabular-nums">{{ currentKeyPage }} / {{ totalKeyPages }}</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-6 px-2 text-xs"
|
2026-05-16 12:52:49 +08:00
|
|
|
|
:disabled="loadingProviderKeys || currentKeyPage >= totalKeyPages"
|
|
|
|
|
|
@click="goToKeyPage(currentKeyPage + 1)"
|
2026-02-09 02:56:21 +08:00
|
|
|
|
>
|
|
|
|
|
|
›
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 空状态 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="p-8 text-center text-muted-foreground"
|
|
|
|
|
|
>
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<Key class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<p class="text-sm">
|
2026-03-01 23:32:48 +08:00
|
|
|
|
{{ isKeyManagedProviderType(provider.provider_type) ? '暂无密钥配置' : '暂无账号配置' }}
|
2025-12-12 16:15:36 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
<p class="text-xs mt-1">
|
2026-02-05 02:56:33 +08:00
|
|
|
|
{{ endpoints.length > 0
|
2026-03-01 23:32:48 +08:00
|
|
|
|
? (isKeyManagedProviderType(provider.provider_type) ? '点击上方"添加密钥"按钮创建第一个密钥' : '点击上方"添加账号"按钮添加第一个账号')
|
2026-02-05 02:56:33 +08:00
|
|
|
|
: '请先添加端点,然后再添加密钥' }}
|
2025-12-12 16:15:36 +08:00
|
|
|
|
</p>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
</Card>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<!-- 模型查看 -->
|
|
|
|
|
|
<ModelsTab
|
|
|
|
|
|
v-if="provider"
|
|
|
|
|
|
:key="`models-${provider.id}`"
|
|
|
|
|
|
:provider="provider"
|
2026-02-10 23:47:47 +08:00
|
|
|
|
:models="providerModels"
|
2026-03-06 13:13:01 +08:00
|
|
|
|
:endpoints="endpoints"
|
2026-05-12 20:11:37 +08:00
|
|
|
|
:provider-keys="providerKeys"
|
|
|
|
|
|
:loading="loadingProviderModels || loadingProviderKeys"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
@edit-model="handleEditModel"
|
|
|
|
|
|
@batch-assign="handleBatchAssign"
|
2026-02-10 23:47:47 +08:00
|
|
|
|
@refresh="loadEndpoints"
|
2025-12-17 16:41:10 +08:00
|
|
|
|
/>
|
|
|
|
|
|
|
2026-01-14 14:28:13 +08:00
|
|
|
|
<!-- 模型映射 -->
|
|
|
|
|
|
<ModelMappingTab
|
|
|
|
|
|
v-if="provider"
|
|
|
|
|
|
ref="modelMappingTabRef"
|
|
|
|
|
|
:key="`mapping-${provider.id}`"
|
|
|
|
|
|
:provider="provider"
|
2026-03-19 23:52:17 +08:00
|
|
|
|
:endpoints="endpoints"
|
2026-01-23 12:50:25 +08:00
|
|
|
|
:provider-keys="providerKeys"
|
2026-02-10 23:47:47 +08:00
|
|
|
|
:models="providerModels"
|
|
|
|
|
|
:mapping-preview="providerMappingPreview"
|
2026-03-31 19:19:04 +08:00
|
|
|
|
:loading="loadingProviderEndpoints || loadingProviderKeys || loadingProviderModels || loadingProviderMappingPreview"
|
2026-01-14 14:28:13 +08:00
|
|
|
|
@refresh="handleModelMappingChanged"
|
|
|
|
|
|
/>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Transition>
|
|
|
|
|
|
</Teleport>
|
|
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<!-- 端点表单对话框(管理/编辑) -->
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<EndpointFormDialog
|
|
|
|
|
|
v-if="provider && open"
|
|
|
|
|
|
v-model="endpointDialogOpen"
|
|
|
|
|
|
:provider="provider"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
:endpoints="endpoints"
|
2026-02-04 02:04:54 +08:00
|
|
|
|
:system-format-conversion-enabled="systemFormatConversionEnabled"
|
|
|
|
|
|
:provider-format-conversion-enabled="provider.enable_format_conversion"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@endpoint-created="handleEndpointChanged"
|
|
|
|
|
|
@endpoint-updated="handleEndpointChanged"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 密钥编辑对话框 -->
|
|
|
|
|
|
<KeyFormDialog
|
|
|
|
|
|
v-if="open"
|
|
|
|
|
|
:open="keyFormDialogOpen"
|
|
|
|
|
|
:endpoint="currentEndpoint"
|
|
|
|
|
|
:editing-key="editingKey"
|
|
|
|
|
|
:provider-id="provider ? provider.id : null"
|
2026-02-04 10:24:25 +08:00
|
|
|
|
:provider-type="provider?.provider_type || null"
|
2026-03-07 17:11:19 +08:00
|
|
|
|
:available-api-formats="availableKeyApiFormats"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@close="keyFormDialogOpen = false"
|
|
|
|
|
|
@saved="handleKeyChanged"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<!-- OAuth 账号对话框 -->
|
|
|
|
|
|
<OAuthAccountDialog
|
|
|
|
|
|
v-if="open && provider"
|
|
|
|
|
|
:open="oauthAccountDialogOpen"
|
|
|
|
|
|
:provider-id="provider.id"
|
2026-02-09 01:05:48 +08:00
|
|
|
|
:provider-type="provider.provider_type"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
@close="oauthAccountDialogOpen = false"
|
|
|
|
|
|
@saved="handleKeyChanged"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-02-09 12:44:32 +08:00
|
|
|
|
<!-- OAuth 密钥编辑对话框 -->
|
|
|
|
|
|
<OAuthKeyEditDialog
|
|
|
|
|
|
v-if="open"
|
|
|
|
|
|
:open="oauthKeyEditDialogOpen"
|
|
|
|
|
|
:editing-key="editingKey"
|
|
|
|
|
|
@close="oauthKeyEditDialogOpen = false"
|
|
|
|
|
|
@saved="handleKeyChanged"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<!-- 模型权限对话框 -->
|
|
|
|
|
|
<KeyAllowedModelsEditDialog
|
2025-12-10 20:52:44 +08:00
|
|
|
|
v-if="open"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
:open="keyPermissionsDialogOpen"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:api-key="editingKey"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
:provider-id="providerId || ''"
|
|
|
|
|
|
@close="keyPermissionsDialogOpen = false"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@saved="handleKeyChanged"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 删除密钥确认对话框 -->
|
|
|
|
|
|
<AlertDialog
|
|
|
|
|
|
v-if="open"
|
|
|
|
|
|
:model-value="deleteKeyConfirmOpen"
|
|
|
|
|
|
title="删除密钥"
|
|
|
|
|
|
:description="`确定要删除密钥 ${keyToDelete?.api_key_masked} 吗?`"
|
|
|
|
|
|
confirm-text="删除"
|
|
|
|
|
|
cancel-text="取消"
|
|
|
|
|
|
type="danger"
|
|
|
|
|
|
@update:model-value="deleteKeyConfirmOpen = $event"
|
|
|
|
|
|
@confirm="confirmDeleteKey"
|
|
|
|
|
|
@cancel="deleteKeyConfirmOpen = false"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 添加/编辑模型对话框 -->
|
|
|
|
|
|
<ProviderModelFormDialog
|
|
|
|
|
|
v-if="open && provider"
|
|
|
|
|
|
:open="modelFormDialogOpen"
|
|
|
|
|
|
:provider-id="provider.id"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
:provider-name="provider.name"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:editing-model="editingModel"
|
|
|
|
|
|
@update:open="modelFormDialogOpen = $event"
|
|
|
|
|
|
@saved="handleModelSaved"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 批量关联模型对话框 -->
|
|
|
|
|
|
<BatchAssignModelsDialog
|
|
|
|
|
|
v-if="open && provider"
|
|
|
|
|
|
:open="batchAssignDialogOpen"
|
|
|
|
|
|
:provider-id="provider.id"
|
2026-01-10 18:43:53 +08:00
|
|
|
|
:provider-name="provider.name"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@update:open="batchAssignDialogOpen = $event"
|
|
|
|
|
|
@changed="handleBatchAssignChanged"
|
|
|
|
|
|
/>
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- Antigravity 配额详情弹窗 -->
|
|
|
|
|
|
<AntigravityQuotaDialog
|
|
|
|
|
|
v-if="antigravityQuotaDialogKey"
|
|
|
|
|
|
:open="antigravityQuotaDialogOpen"
|
|
|
|
|
|
:metadata="antigravityQuotaDialogKey.upstream_metadata"
|
2026-04-17 18:22:41 +08:00
|
|
|
|
:quota-snapshot="antigravityQuotaDialogKey.status_snapshot?.quota ?? null"
|
2026-02-06 16:37:06 +08:00
|
|
|
|
:key-name="antigravityQuotaDialogKey.name || '未命名密钥'"
|
|
|
|
|
|
:provider-id="providerId"
|
|
|
|
|
|
:key-id="antigravityQuotaDialogKey.id"
|
|
|
|
|
|
@update:open="antigravityQuotaDialogOpen = $event"
|
|
|
|
|
|
/>
|
2026-03-01 00:21:16 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 故障转移规则弹窗 -->
|
|
|
|
|
|
<FailoverRulesDialog
|
|
|
|
|
|
:open="failoverRulesDialogOpen"
|
|
|
|
|
|
:provider="provider ?? null"
|
|
|
|
|
|
@update:open="failoverRulesDialogOpen = $event"
|
|
|
|
|
|
@saved="loadProvider()"
|
|
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-01-10 18:43:53 +08:00
|
|
|
|
import { ref, watch, computed, nextTick } from 'vue'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
Key,
|
2026-03-31 19:19:04 +08:00
|
|
|
|
Loader2,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
Edit,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
X,
|
|
|
|
|
|
Power,
|
|
|
|
|
|
GripVertical,
|
2025-12-29 17:14:26 +08:00
|
|
|
|
Copy,
|
2026-02-06 21:52:22 +08:00
|
|
|
|
Download,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
Shield,
|
2026-02-05 02:23:41 +08:00
|
|
|
|
Shuffle,
|
2026-02-06 16:37:06 +08:00
|
|
|
|
BarChart3,
|
|
|
|
|
|
ShieldX,
|
2026-02-09 01:05:48 +08:00
|
|
|
|
Globe,
|
2026-03-01 00:21:16 +08:00
|
|
|
|
GitBranch,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} from 'lucide-vue-next'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
2025-12-19 17:31:15 +08:00
|
|
|
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import Button from '@/components/ui/button.vue'
|
|
|
|
|
|
import Badge from '@/components/ui/badge.vue'
|
|
|
|
|
|
import Card from '@/components/ui/card.vue'
|
2026-02-09 01:05:48 +08:00
|
|
|
|
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { useToast } from '@/composables/useToast'
|
2026-02-05 18:05:46 +08:00
|
|
|
|
import { useConfirm } from '@/composables/useConfirm'
|
2025-12-28 20:41:52 +08:00
|
|
|
|
import { useClipboard } from '@/composables/useClipboard'
|
2026-03-20 19:16:52 +08:00
|
|
|
|
import { useCountdownTimer, formatCountdown, getCodexResetCountdown } from '@/composables/useCountdownTimer'
|
2026-02-10 23:47:47 +08:00
|
|
|
|
import {
|
|
|
|
|
|
getProvider,
|
|
|
|
|
|
getProviderEndpoints,
|
|
|
|
|
|
updateProvider,
|
|
|
|
|
|
getProviderModels,
|
|
|
|
|
|
getProviderMappingPreview,
|
2026-02-22 00:43:41 +08:00
|
|
|
|
type ProviderMappingPreviewResponse,
|
|
|
|
|
|
type ProviderWithEndpointsSummary,
|
2026-02-10 23:47:47 +08:00
|
|
|
|
} from '@/api/endpoints'
|
2026-02-04 02:04:54 +08:00
|
|
|
|
import { adminApi } from '@/api/admin'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import {
|
|
|
|
|
|
KeyFormDialog,
|
2026-01-10 18:43:53 +08:00
|
|
|
|
KeyAllowedModelsEditDialog,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
ModelsTab,
|
2026-02-04 23:59:45 +08:00
|
|
|
|
BatchAssignModelsDialog,
|
2026-02-09 12:44:32 +08:00
|
|
|
|
OAuthAccountDialog,
|
|
|
|
|
|
OAuthKeyEditDialog
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} from '@/features/providers/components'
|
2026-01-14 14:28:13 +08:00
|
|
|
|
import ModelMappingTab from '@/features/providers/components/provider-tabs/ModelMappingTab.vue'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
|
|
|
|
|
|
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
|
|
|
|
|
|
import AlertDialog from '@/components/common/AlertDialog.vue'
|
2026-02-06 16:37:06 +08:00
|
|
|
|
import AntigravityQuotaDialog from '@/features/providers/components/AntigravityQuotaDialog.vue'
|
2026-03-01 00:21:16 +08:00
|
|
|
|
import FailoverRulesDialog from '@/features/providers/components/FailoverRulesDialog.vue'
|
2026-02-09 01:05:48 +08:00
|
|
|
|
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
|
|
|
|
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import {
|
|
|
|
|
|
deleteEndpointKey,
|
|
|
|
|
|
recoverKeyHealth,
|
2026-05-16 12:52:49 +08:00
|
|
|
|
getProviderKeysPage,
|
2026-01-10 18:43:53 +08:00
|
|
|
|
updateProviderKey,
|
2025-12-29 17:14:26 +08:00
|
|
|
|
revealEndpointKey,
|
2026-02-09 01:05:48 +08:00
|
|
|
|
exportKey,
|
2026-02-04 23:59:45 +08:00
|
|
|
|
refreshProviderOAuth,
|
2026-02-05 18:09:14 +08:00
|
|
|
|
refreshProviderQuota,
|
2026-02-06 16:37:06 +08:00
|
|
|
|
clearOAuthInvalid,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
type ProviderEndpoint,
|
|
|
|
|
|
type EndpointAPIKey,
|
2026-01-10 18:43:53 +08:00
|
|
|
|
type Model,
|
|
|
|
|
|
API_FORMAT_ORDER,
|
|
|
|
|
|
sortApiFormats,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} from '@/api/endpoints'
|
2026-04-17 18:22:41 +08:00
|
|
|
|
import type {
|
|
|
|
|
|
UpstreamMetadata,
|
|
|
|
|
|
AntigravityModelQuota,
|
|
|
|
|
|
CodexUpstreamMetadata,
|
2026-05-06 22:58:47 +08:00
|
|
|
|
ChatGPTWebUpstreamMetadata,
|
2026-04-17 18:22:41 +08:00
|
|
|
|
KiroUpstreamMetadata,
|
|
|
|
|
|
QuotaStatusSnapshot,
|
|
|
|
|
|
QuotaWindowSnapshot,
|
|
|
|
|
|
} from '@/api/endpoints/types'
|
2026-04-26 22:09:16 +08:00
|
|
|
|
import { formatApiFormat, formatApiFormatShort } from '@/api/endpoints/types/api-format'
|
2026-03-01 23:32:48 +08:00
|
|
|
|
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
2026-04-20 15:56:59 +08:00
|
|
|
|
import {
|
|
|
|
|
|
isProviderQuotaAutoRefreshCoolingDown,
|
|
|
|
|
|
markProviderQuotaAutoRefreshAttempt,
|
|
|
|
|
|
} from '../utils/quotaAutoRefreshCooldown'
|
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,
|
2026-05-01 02:14:49 +08:00
|
|
|
|
canRefreshOAuthCredential,
|
2026-04-17 12:57:06 +08:00
|
|
|
|
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,
|
|
|
|
|
|
getOAuthStatusDisplay,
|
2026-05-01 02:14:49 +08:00
|
|
|
|
getOAuthStatusDisplayWithFallback,
|
2026-03-20 19:16:52 +08:00
|
|
|
|
getOAuthStatusTitle as resolveOAuthStatusTitle,
|
|
|
|
|
|
} from '@/utils/providerKeyStatus'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 扩展端点类型,包含密钥列表
|
|
|
|
|
|
interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
|
|
|
|
|
keys?: EndpointAPIKey[]
|
|
|
|
|
|
rpm_limit?: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
providerId: string | null
|
|
|
|
|
|
open: boolean
|
2026-03-31 19:19:04 +08:00
|
|
|
|
initialProvider?: ProviderWithEndpointsSummary | null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
(e: 'update:open', value: boolean): void
|
2026-02-22 00:43:41 +08:00
|
|
|
|
(e: 'edit', provider: ProviderWithEndpointsSummary): void
|
|
|
|
|
|
(e: 'toggleStatus', provider: ProviderWithEndpointsSummary): void
|
2025-12-10 20:52:44 +08:00
|
|
|
|
(e: 'refresh'): void
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
2026-03-20 16:50:59 +08:00
|
|
|
|
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
|
2026-02-05 18:05:46 +08:00
|
|
|
|
const { confirm } = useConfirm()
|
2025-12-28 20:41:52 +08:00
|
|
|
|
const { copyToClipboard } = useClipboard()
|
2026-01-13 18:42:15 +08:00
|
|
|
|
const { tick: countdownTick, start: startCountdownTimer, stop: stopCountdownTimer } = useCountdownTimer()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
const loading = ref(false)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const provider = ref<ProviderWithEndpointsSummary | null>(null)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const endpoints = ref<ProviderEndpointWithKeys[]>([])
|
2026-01-10 18:43:53 +08:00
|
|
|
|
const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys
|
2026-02-10 23:47:47 +08:00
|
|
|
|
const providerModels = ref<Model[]>([]) // Provider 级别的 models
|
|
|
|
|
|
const providerMappingPreview = ref<ProviderMappingPreviewResponse | null>(null) // 映射预览
|
2026-03-31 19:19:04 +08:00
|
|
|
|
const loadingProviderEndpoints = ref(false)
|
|
|
|
|
|
const loadingProviderKeys = ref(false)
|
|
|
|
|
|
const loadingProviderModels = ref(false)
|
|
|
|
|
|
const loadingProviderMappingPreview = ref(false)
|
2026-03-03 22:04:40 +08:00
|
|
|
|
let providerLoadRequestId = 0
|
|
|
|
|
|
let endpointsLoadRequestId = 0
|
2026-05-16 12:52:49 +08:00
|
|
|
|
let keysLoadRequestId = 0
|
2026-03-11 09:58:25 +08:00
|
|
|
|
let mappingPreviewLoadRequestId = 0
|
2026-05-16 12:52:49 +08:00
|
|
|
|
const PROVIDER_KEYS_PAGE_SIZE = 20
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-02-04 02:04:54 +08:00
|
|
|
|
// 系统级格式转换配置
|
|
|
|
|
|
const systemFormatConversionEnabled = ref(false)
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 端点相关状态
|
|
|
|
|
|
const endpointDialogOpen = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
// 密钥相关状态
|
|
|
|
|
|
const keyFormDialogOpen = ref(false)
|
2026-01-10 18:43:53 +08:00
|
|
|
|
const keyPermissionsDialogOpen = ref(false)
|
2026-02-04 23:59:45 +08:00
|
|
|
|
const oauthAccountDialogOpen = ref(false)
|
2026-02-09 12:44:32 +08:00
|
|
|
|
const oauthKeyEditDialogOpen = ref(false)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const currentEndpoint = ref<ProviderEndpoint | null>(null)
|
|
|
|
|
|
const editingKey = ref<EndpointAPIKey | null>(null)
|
|
|
|
|
|
const deleteKeyConfirmOpen = ref(false)
|
|
|
|
|
|
const keyToDelete = ref<EndpointAPIKey | null>(null)
|
|
|
|
|
|
const togglingKeyId = ref<string | null>(null)
|
|
|
|
|
|
|
2025-12-29 17:14:26 +08:00
|
|
|
|
// 密钥显示状态:key_id -> 完整密钥
|
|
|
|
|
|
const revealedKeys = ref<Map<string, string>>(new Map())
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 模型相关状态
|
|
|
|
|
|
const modelFormDialogOpen = ref(false)
|
|
|
|
|
|
const editingModel = ref<Model | null>(null)
|
|
|
|
|
|
const batchAssignDialogOpen = ref(false)
|
2026-01-14 14:28:13 +08:00
|
|
|
|
const modelMappingTabRef = ref<InstanceType<typeof ModelMappingTab> | null>(null)
|
2025-12-25 00:02:56 +08:00
|
|
|
|
|
2026-01-11 00:22:21 +08:00
|
|
|
|
// 密钥列表拖拽排序状态
|
|
|
|
|
|
const keyDragState = ref({
|
|
|
|
|
|
isDragging: false,
|
|
|
|
|
|
draggedIndex: null as number | null,
|
|
|
|
|
|
targetIndex: null as number | null
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 点击编辑优先级相关状态
|
|
|
|
|
|
const editingPriorityKey = ref<string | null>(null)
|
|
|
|
|
|
const editingPriorityValue = ref<number>(0)
|
2026-01-10 18:43:53 +08:00
|
|
|
|
const priorityInputRef = ref<HTMLInputElement[] | null>(null)
|
|
|
|
|
|
const prioritySaving = ref(false)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-02-04 23:59:45 +08:00
|
|
|
|
// OAuth 刷新状态
|
|
|
|
|
|
const refreshingOAuthKeyId = ref<string | null>(null)
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
// OAuth 失效清除状态
|
|
|
|
|
|
const clearingOAuthInvalidKeyId = ref<string | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
// 限额刷新状态(Codex / Antigravity)
|
2026-02-05 18:09:14 +08:00
|
|
|
|
const refreshingQuota = ref(false)
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
// Antigravity 配额详情弹窗状态
|
|
|
|
|
|
const antigravityQuotaDialogOpen = ref(false)
|
|
|
|
|
|
const antigravityQuotaDialogKey = ref<EndpointAPIKey | null>(null)
|
|
|
|
|
|
|
2026-03-01 00:21:16 +08:00
|
|
|
|
// 故障转移规则
|
|
|
|
|
|
const failoverRulesDialogOpen = ref(false)
|
|
|
|
|
|
const hasFailoverRules = computed(() => {
|
|
|
|
|
|
const rules = provider.value?.failover_rules
|
|
|
|
|
|
if (!rules) return false
|
|
|
|
|
|
return (rules.success_failover_patterns?.length || 0) > 0
|
|
|
|
|
|
|| (rules.error_stop_patterns?.length || 0) > 0
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-24 02:44:15 +08:00
|
|
|
|
// Provider 级别代理配置状态
|
2026-02-09 01:05:48 +08:00
|
|
|
|
const proxyNodesStore = useProxyNodesStore()
|
2026-02-24 02:44:15 +08:00
|
|
|
|
const providerProxyPopoverOpen = ref(false)
|
|
|
|
|
|
const savingProviderProxy = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
// Key 级别代理配置状态
|
2026-02-09 01:05:48 +08:00
|
|
|
|
const savingProxyKeyId = ref<string | null>(null)
|
|
|
|
|
|
const proxyPopoverOpenKeyId = ref<string | null>(null)
|
|
|
|
|
|
|
2026-01-11 04:12:29 +08:00
|
|
|
|
// 点击编辑倍率相关状态
|
|
|
|
|
|
const editingMultiplierKey = ref<string | null>(null)
|
|
|
|
|
|
const editingMultiplierFormat = ref<string | null>(null)
|
|
|
|
|
|
const editingMultiplierValue = ref<number>(1.0)
|
|
|
|
|
|
const multiplierInputRef = ref<HTMLInputElement[] | null>(null)
|
|
|
|
|
|
const multiplierSaving = ref(false)
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 任意模态窗口打开时,阻止抽屉被误关闭
|
|
|
|
|
|
const hasBlockingDialogOpen = computed(() =>
|
|
|
|
|
|
endpointDialogOpen.value ||
|
|
|
|
|
|
keyFormDialogOpen.value ||
|
2026-01-10 18:43:53 +08:00
|
|
|
|
keyPermissionsDialogOpen.value ||
|
2026-02-04 23:59:45 +08:00
|
|
|
|
oauthAccountDialogOpen.value ||
|
2026-02-09 12:44:32 +08:00
|
|
|
|
oauthKeyEditDialogOpen.value ||
|
2025-12-10 20:52:44 +08:00
|
|
|
|
deleteKeyConfirmOpen.value ||
|
|
|
|
|
|
modelFormDialogOpen.value ||
|
2026-01-14 14:28:13 +08:00
|
|
|
|
batchAssignDialogOpen.value ||
|
2026-02-06 16:37:06 +08:00
|
|
|
|
antigravityQuotaDialogOpen.value ||
|
2026-01-14 14:28:13 +08:00
|
|
|
|
modelMappingTabRef.value?.dialogOpen
|
2025-12-10 20:52:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-16 12:52:49 +08:00
|
|
|
|
// 当前后端分页页内的密钥列表。key 通过 api_formats 字段确定支持的格式,endpoint 可能为 undefined。
|
2026-01-10 18:43:53 +08:00
|
|
|
|
const allKeys = computed(() => {
|
2026-05-16 12:52:49 +08:00
|
|
|
|
return providerKeys.value.map(key => ({ key, endpoint: undefined as ProviderEndpointWithKeys | undefined }))
|
2026-01-10 18:43:53 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-07 17:11:19 +08:00
|
|
|
|
const availableKeyApiFormats = computed(() => {
|
|
|
|
|
|
const formatSet = new Set<string>()
|
|
|
|
|
|
|
|
|
|
|
|
for (const format of provider.value?.api_formats || []) {
|
|
|
|
|
|
if (format) {
|
|
|
|
|
|
formatSet.add(format)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const endpoint of endpoints.value) {
|
|
|
|
|
|
if (endpoint.api_format) {
|
|
|
|
|
|
formatSet.add(endpoint.api_format)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return sortApiFormats([...formatSet])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
function syncCurrentSelections(
|
|
|
|
|
|
nextEndpoints: ProviderEndpointWithKeys[] = endpoints.value,
|
|
|
|
|
|
nextProviderKeys: EndpointAPIKey[] = providerKeys.value
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (currentEndpoint.value) {
|
|
|
|
|
|
currentEndpoint.value = nextEndpoints.find(endpoint => endpoint.id === currentEndpoint.value?.id) ?? null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!editingKey.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const latestKeys: EndpointAPIKey[] = []
|
|
|
|
|
|
const seenKeyIds = new Set<string>()
|
|
|
|
|
|
|
|
|
|
|
|
for (const key of nextProviderKeys) {
|
|
|
|
|
|
if (!seenKeyIds.has(key.id)) {
|
|
|
|
|
|
seenKeyIds.add(key.id)
|
|
|
|
|
|
latestKeys.push(key)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const endpoint of nextEndpoints) {
|
|
|
|
|
|
for (const key of endpoint.keys || []) {
|
|
|
|
|
|
if (!seenKeyIds.has(key.id)) {
|
|
|
|
|
|
seenKeyIds.add(key.id)
|
|
|
|
|
|
latestKeys.push(key)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const latestEditingKey = latestKeys.find(key => key.id === editingKey.value?.id) || null
|
|
|
|
|
|
editingKey.value = latestEditingKey
|
|
|
|
|
|
|
|
|
|
|
|
if (!latestEditingKey) {
|
|
|
|
|
|
keyFormDialogOpen.value = false
|
|
|
|
|
|
keyPermissionsDialogOpen.value = false
|
|
|
|
|
|
oauthKeyEditDialogOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 12:52:49 +08:00
|
|
|
|
// ===== 账号列表后端分页 =====
|
|
|
|
|
|
const providerKeysTotal = ref(0)
|
|
|
|
|
|
const currentKeyPage = ref(1)
|
|
|
|
|
|
const keyPageSize = ref(PROVIDER_KEYS_PAGE_SIZE)
|
|
|
|
|
|
const totalKeyPages = computed(() => Math.max(1, Math.ceil(providerKeysTotal.value / keyPageSize.value)))
|
|
|
|
|
|
const shouldPaginateKeys = computed(() => totalKeyPages.value > 1)
|
|
|
|
|
|
const paginatedKeys = computed(() => allKeys.value)
|
|
|
|
|
|
|
|
|
|
|
|
function getGlobalKeyIndex(localIdx: number): number {
|
|
|
|
|
|
return localIdx
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function goToKeyPage(page: number) {
|
|
|
|
|
|
const nextPage = Math.min(Math.max(page, 1), totalKeyPages.value)
|
|
|
|
|
|
if (nextPage === currentKeyPage.value && providerKeys.value.length > 0) return
|
|
|
|
|
|
await loadProviderKeysPage(nextPage)
|
|
|
|
|
|
}
|
2026-02-09 02:56:21 +08:00
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
// 合并监听 providerId 和 open,避免同一 tick 内两个 watcher 都触发导致重复请求
|
|
|
|
|
|
watch(
|
|
|
|
|
|
[() => props.providerId, () => props.open],
|
2026-02-09 01:05:48 +08:00
|
|
|
|
async ([newId, newOpen], [_oldId, oldOpen]) => {
|
2026-02-06 16:37:06 +08:00
|
|
|
|
if (newOpen && newId) {
|
2026-05-16 12:52:49 +08:00
|
|
|
|
if (!oldOpen || provider.value?.id !== newId) {
|
|
|
|
|
|
currentKeyPage.value = 1
|
|
|
|
|
|
providerKeysTotal.value = 0
|
|
|
|
|
|
}
|
2026-04-23 14:42:51 +08:00
|
|
|
|
const hasInitialProvider = props.initialProvider?.id === newId
|
|
|
|
|
|
if (hasInitialProvider) {
|
2026-03-31 19:19:04 +08:00
|
|
|
|
provider.value = props.initialProvider
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
2026-04-23 14:42:51 +08:00
|
|
|
|
void loadSystemFormatConversionConfig()
|
2026-03-05 19:04:21 +08:00
|
|
|
|
// mapping-preview 较慢,不阻塞首屏渲染
|
|
|
|
|
|
void loadMappingPreview()
|
2026-04-23 14:42:51 +08:00
|
|
|
|
const endpointsPromise = loadEndpoints()
|
|
|
|
|
|
if (!hasInitialProvider) {
|
|
|
|
|
|
await loadProvider()
|
|
|
|
|
|
}
|
2026-02-06 16:37:06 +08:00
|
|
|
|
// 仅在抽屉刚打开时启动倒计时
|
|
|
|
|
|
if (newOpen && !oldOpen) {
|
|
|
|
|
|
startCountdownTimer()
|
|
|
|
|
|
}
|
2026-04-23 14:42:51 +08:00
|
|
|
|
void endpointsPromise.then(() => autoRefreshQuotaInBackground())
|
2026-02-06 16:37:06 +08:00
|
|
|
|
} else if (!newOpen && oldOpen) {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
// 使在途请求失效,避免关闭后旧响应回写
|
|
|
|
|
|
providerLoadRequestId += 1
|
|
|
|
|
|
endpointsLoadRequestId += 1
|
2026-05-16 12:52:49 +08:00
|
|
|
|
keysLoadRequestId += 1
|
2026-03-11 09:58:25 +08:00
|
|
|
|
mappingPreviewLoadRequestId += 1
|
2026-03-03 22:04:40 +08:00
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
// 停止倒计时定时器
|
|
|
|
|
|
stopCountdownTimer()
|
|
|
|
|
|
// 重置所有状态
|
2026-03-03 22:04:40 +08:00
|
|
|
|
loading.value = false
|
2026-02-06 16:37:06 +08:00
|
|
|
|
provider.value = null
|
|
|
|
|
|
endpoints.value = []
|
|
|
|
|
|
providerKeys.value = [] // 清空 Provider 级别的 keys
|
2026-05-16 12:52:49 +08:00
|
|
|
|
providerKeysTotal.value = 0
|
|
|
|
|
|
currentKeyPage.value = 1
|
|
|
|
|
|
keyPageSize.value = PROVIDER_KEYS_PAGE_SIZE
|
2026-03-03 22:04:40 +08:00
|
|
|
|
providerModels.value = []
|
|
|
|
|
|
providerMappingPreview.value = null
|
2026-03-31 19:19:04 +08:00
|
|
|
|
loadingProviderEndpoints.value = false
|
|
|
|
|
|
loadingProviderKeys.value = false
|
|
|
|
|
|
loadingProviderModels.value = false
|
|
|
|
|
|
loadingProviderMappingPreview.value = false
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
// 重置所有对话框状态
|
|
|
|
|
|
endpointDialogOpen.value = false
|
|
|
|
|
|
keyFormDialogOpen.value = false
|
|
|
|
|
|
keyPermissionsDialogOpen.value = false
|
|
|
|
|
|
oauthAccountDialogOpen.value = false
|
2026-02-09 12:44:32 +08:00
|
|
|
|
oauthKeyEditDialogOpen.value = false
|
2026-02-06 16:37:06 +08:00
|
|
|
|
deleteKeyConfirmOpen.value = false
|
|
|
|
|
|
batchAssignDialogOpen.value = false
|
|
|
|
|
|
antigravityQuotaDialogOpen.value = false
|
|
|
|
|
|
antigravityQuotaDialogKey.value = null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
// 重置临时数据
|
|
|
|
|
|
currentEndpoint.value = null
|
|
|
|
|
|
editingKey.value = null
|
|
|
|
|
|
keyToDelete.value = null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
// 清除已显示的密钥(安全考虑)
|
|
|
|
|
|
revealedKeys.value.clear()
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 处理背景点击
|
|
|
|
|
|
function handleBackdropClick() {
|
|
|
|
|
|
if (!hasBlockingDialogOpen.value) {
|
|
|
|
|
|
handleClose()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 关闭抽屉
|
|
|
|
|
|
function handleClose() {
|
|
|
|
|
|
if (!hasBlockingDialogOpen.value) {
|
|
|
|
|
|
emit('update:open', false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
// 切换格式转换开关
|
|
|
|
|
|
async function toggleFormatConversion() {
|
|
|
|
|
|
if (!provider.value) return
|
|
|
|
|
|
const newValue = !provider.value.enable_format_conversion
|
|
|
|
|
|
try {
|
2026-02-06 23:01:56 +08:00
|
|
|
|
const updated = await updateProvider(provider.value.id, { enable_format_conversion: newValue })
|
|
|
|
|
|
provider.value = updated
|
2026-02-02 03:16:52 +08:00
|
|
|
|
showSuccess(newValue ? '已启用格式转换' : '已禁用格式转换')
|
|
|
|
|
|
emit('refresh')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
showError('切换格式转换失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 02:44:15 +08:00
|
|
|
|
// Provider 级别代理配置
|
|
|
|
|
|
function handleProviderProxyPopoverToggle(open: boolean) {
|
|
|
|
|
|
providerProxyPopoverOpen.value = open
|
|
|
|
|
|
if (open) {
|
|
|
|
|
|
proxyNodesStore.ensureLoaded()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getProviderProxyNodeName(): string {
|
|
|
|
|
|
const nodeId = provider.value?.proxy?.node_id
|
|
|
|
|
|
if (!nodeId) return '未知节点'
|
|
|
|
|
|
const node = proxyNodesStore.nodes.find(n => n.id === nodeId)
|
|
|
|
|
|
return node ? node.name : `${nodeId.slice(0, 8)}...`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function setProviderProxy(nodeId: string) {
|
|
|
|
|
|
if (!provider.value) return
|
|
|
|
|
|
savingProviderProxy.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const updated = await updateProvider(provider.value.id, {
|
|
|
|
|
|
proxy: { node_id: nodeId, enabled: true },
|
|
|
|
|
|
})
|
|
|
|
|
|
provider.value = updated
|
|
|
|
|
|
providerProxyPopoverOpen.value = false
|
|
|
|
|
|
showSuccess('代理节点已设置')
|
|
|
|
|
|
emit('refresh')
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '设置代理失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
savingProviderProxy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function clearProviderProxy() {
|
|
|
|
|
|
if (!provider.value) return
|
|
|
|
|
|
savingProviderProxy.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const updated = await updateProvider(provider.value.id, { proxy: null })
|
|
|
|
|
|
provider.value = updated
|
|
|
|
|
|
providerProxyPopoverOpen.value = false
|
|
|
|
|
|
showSuccess('已清除提供商代理')
|
|
|
|
|
|
emit('refresh')
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '清除代理失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
savingProviderProxy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
// 显示端点管理对话框
|
2025-12-10 20:52:44 +08:00
|
|
|
|
function showAddEndpointDialog() {
|
|
|
|
|
|
endpointDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 端点事件处理 =====
|
2026-01-10 18:43:53 +08:00
|
|
|
|
function handleEditEndpoint(_endpoint: ProviderEndpoint) {
|
|
|
|
|
|
// 点击任何端点都打开管理对话框
|
2025-12-10 20:52:44 +08:00
|
|
|
|
endpointDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleEndpointChanged() {
|
2026-01-10 18:43:53 +08:00
|
|
|
|
await Promise.all([loadProvider(), loadEndpoints()])
|
2025-12-10 20:52:44 +08:00
|
|
|
|
emit('refresh')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 密钥事件处理 =====
|
|
|
|
|
|
function handleAddKey(endpoint: ProviderEndpoint) {
|
|
|
|
|
|
currentEndpoint.value = endpoint
|
|
|
|
|
|
editingKey.value = null
|
|
|
|
|
|
keyFormDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 23:59:45 +08:00
|
|
|
|
// 添加密钥/账号(如果有多个端点则添加到第一个)
|
2026-01-10 18:43:53 +08:00
|
|
|
|
function handleAddKeyToFirstEndpoint() {
|
2026-02-04 23:59:45 +08:00
|
|
|
|
if (endpoints.value.length === 0) return
|
|
|
|
|
|
|
2026-03-01 23:32:48 +08:00
|
|
|
|
// OAuth 账号型提供商:打开 OAuth 账号对话框
|
|
|
|
|
|
if (isOAuthAccountProviderType(provider.value?.provider_type)) {
|
2026-02-04 23:59:45 +08:00
|
|
|
|
oauthAccountDialogOpen.value = true
|
|
|
|
|
|
} else {
|
2026-03-01 23:32:48 +08:00
|
|
|
|
// 密钥型提供商(custom/vertex_ai):打开密钥表单对话框
|
2026-01-10 18:43:53 +08:00
|
|
|
|
handleAddKey(endpoints.value[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleEditKey(endpoint: ProviderEndpoint | undefined, key: EndpointAPIKey) {
|
|
|
|
|
|
currentEndpoint.value = endpoint || null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
editingKey.value = key
|
2026-02-09 12:44:32 +08:00
|
|
|
|
// OAuth 密钥使用专门的编辑对话框
|
2026-04-17 12:57:06 +08:00
|
|
|
|
if (canEditOAuthCredential(key)) {
|
2026-02-09 12:44:32 +08:00
|
|
|
|
oauthKeyEditDialogOpen.value = true
|
|
|
|
|
|
} else {
|
|
|
|
|
|
keyFormDialogOpen.value = true
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
function handleKeyPermissions(key: EndpointAPIKey) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
editingKey.value = key
|
2026-01-10 18:43:53 +08:00
|
|
|
|
keyPermissionsDialogOpen.value = true
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-30 02:43:50 +08:00
|
|
|
|
// 复制完整密钥或认证配置
|
2025-12-29 17:14:26 +08:00
|
|
|
|
async function copyFullKey(key: EndpointAPIKey) {
|
2026-01-15 17:03:19 +08:00
|
|
|
|
const cached = revealedKeys.value.get(key.id)
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
|
copyToClipboard(cached)
|
2025-12-29 17:14:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 否则先获取再复制
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await revealEndpointKey(key.id)
|
2026-01-30 02:43:50 +08:00
|
|
|
|
let textToCopy: string
|
|
|
|
|
|
|
2026-03-01 23:32:48 +08:00
|
|
|
|
if (result.auth_type === 'service_account' && result.auth_config) {
|
|
|
|
|
|
// Service Account 类型:复制 auth_config JSON
|
2026-01-30 02:43:50 +08:00
|
|
|
|
textToCopy = typeof result.auth_config === 'string'
|
|
|
|
|
|
? result.auth_config
|
|
|
|
|
|
: JSON.stringify(result.auth_config, null, 2)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// API Key 类型:复制 api_key
|
|
|
|
|
|
textToCopy = result.api_key || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
revealedKeys.value.set(key.id, textToCopy)
|
|
|
|
|
|
copyToClipboard(textToCopy)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '获取密钥失败'), '错误')
|
2025-12-29 17:14:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 01:05:48 +08:00
|
|
|
|
// 下载 OAuth 凭据文件(后端统一导出,前端只负责下载)
|
2026-02-06 21:52:22 +08:00
|
|
|
|
async function downloadRefreshToken(key: EndpointAPIKey) {
|
|
|
|
|
|
try {
|
2026-02-09 01:05:48 +08:00
|
|
|
|
const data = await exportKey(key.id)
|
|
|
|
|
|
const providerType = provider.value?.provider_type || 'unknown'
|
|
|
|
|
|
const safeName = (data.email || key.name || key.id.slice(0, 8)).replace(/[^a-zA-Z0-9_\-@.]/g, '_')
|
2026-02-06 21:52:22 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '导出失败'), '错误')
|
2026-02-06 21:52:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 01:05:48 +08:00
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
function handleDeleteKey(key: EndpointAPIKey) {
|
|
|
|
|
|
keyToDelete.value = key
|
|
|
|
|
|
deleteKeyConfirmOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function confirmDeleteKey() {
|
|
|
|
|
|
if (!keyToDelete.value) return
|
|
|
|
|
|
|
|
|
|
|
|
const keyId = keyToDelete.value.id
|
|
|
|
|
|
deleteKeyConfirmOpen.value = false
|
|
|
|
|
|
keyToDelete.value = null
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deleteEndpointKey(keyId)
|
|
|
|
|
|
showSuccess('密钥已删除')
|
2026-02-11 03:09:26 +08:00
|
|
|
|
// 刷新端点列表及模型数据(删除 Key 触发自动解除模型关联)
|
|
|
|
|
|
await loadEndpoints()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
emit('refresh')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '删除密钥失败'), '错误')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleRecoverKey(key: EndpointAPIKey) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await recoverKeyHealth(key.id)
|
|
|
|
|
|
showSuccess(result.message || 'Key已完全恢复')
|
|
|
|
|
|
await loadEndpoints()
|
|
|
|
|
|
emit('refresh')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, 'Key恢复失败'), '错误')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 23:59:45 +08:00
|
|
|
|
async function handleRefreshOAuth(key: EndpointAPIKey) {
|
|
|
|
|
|
if (refreshingOAuthKeyId.value) return
|
|
|
|
|
|
refreshingOAuthKeyId.value = key.id
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await refreshProviderOAuth(key.id)
|
2026-04-24 09:35:25 +08:00
|
|
|
|
const refreshedExpiresAt = typeof result.expires_at === 'number' ? result.expires_at : null
|
2026-03-20 19:16:52 +08:00
|
|
|
|
let refreshedKey: EndpointAPIKey | null = null
|
2026-02-04 23:59:45 +08:00
|
|
|
|
// 更新本地数据
|
|
|
|
|
|
const keyInList = providerKeys.value.find(k => k.id === key.id)
|
|
|
|
|
|
if (keyInList) {
|
2026-04-24 09:35:25 +08:00
|
|
|
|
keyInList.oauth_expires_at = refreshedExpiresAt
|
2026-02-04 23:59:45 +08:00
|
|
|
|
}
|
2026-05-16 12:52:49 +08:00
|
|
|
|
// 只重新加载当前 keys 页,避免整个表格刷新
|
2026-02-11 10:01:28 +08:00
|
|
|
|
if (props.providerId) {
|
2026-05-16 12:52:49 +08:00
|
|
|
|
const freshPage = await getProviderKeysPage(props.providerId, {
|
|
|
|
|
|
page: currentKeyPage.value,
|
|
|
|
|
|
page_size: keyPageSize.value,
|
|
|
|
|
|
}).catch(() => null)
|
|
|
|
|
|
if (freshPage) {
|
|
|
|
|
|
const mergedKeys = freshPage.keys.map((item) => {
|
2026-04-24 09:35:25 +08:00
|
|
|
|
if (item.id !== key.id) return item
|
|
|
|
|
|
if (refreshedExpiresAt == null) return item
|
|
|
|
|
|
if (typeof item.oauth_expires_at === 'number' && item.oauth_expires_at >= refreshedExpiresAt) {
|
|
|
|
|
|
return item
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ...item, oauth_expires_at: refreshedExpiresAt }
|
|
|
|
|
|
})
|
|
|
|
|
|
providerKeys.value = mergedKeys
|
2026-05-16 12:52:49 +08:00
|
|
|
|
providerKeysTotal.value = freshPage.total
|
|
|
|
|
|
currentKeyPage.value = freshPage.page
|
|
|
|
|
|
keyPageSize.value = freshPage.page_size
|
2026-04-24 09:35:25 +08:00
|
|
|
|
syncCurrentSelections(endpoints.value, mergedKeys)
|
|
|
|
|
|
refreshedKey = mergedKeys.find(item => item.id === key.id) ?? null
|
2026-02-11 10:01:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-20 19:16:52 +08:00
|
|
|
|
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 {
|
|
|
|
|
|
showSuccess(feedback.message)
|
|
|
|
|
|
}
|
2026-02-06 16:37:06 +08:00
|
|
|
|
// Antigravity:token 刷新后可能完成了账号激活,触发配额获取
|
|
|
|
|
|
// (不 emit('refresh'),避免触发全局 provider 余额刷新)
|
2026-04-20 15:56:59 +08:00
|
|
|
|
void autoRefreshQuotaInBackground({ ignoreCooldown: true })
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, 'Token 刷新失败'), '错误')
|
2026-02-04 23:59:45 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
refreshingOAuthKeyId.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
// 判断是否为账号级别的封禁(刷新 token 无法修复)
|
|
|
|
|
|
function isAccountLevelBlock(key: EndpointAPIKey): boolean {
|
2026-03-20 19:16:52 +08:00
|
|
|
|
const account = getAccountStatusDisplay(key)
|
|
|
|
|
|
const oauth = getOAuthStatusDisplay(key, countdownTick.value)
|
|
|
|
|
|
return account.blocked && !oauth?.isInvalid
|
2026-02-06 16:37:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清除 OAuth 失效标记
|
|
|
|
|
|
async function handleClearOAuthInvalid(key: EndpointAPIKey) {
|
|
|
|
|
|
if (clearingOAuthInvalidKeyId.value) return
|
|
|
|
|
|
|
|
|
|
|
|
const confirmed = await confirm({
|
|
|
|
|
|
title: '清除账号异常标记',
|
2026-03-20 16:50:59 +08:00
|
|
|
|
message: `确认账号 "${key.name || key.id.slice(0, 8)}" 已手动完成验证?清除后系统会按当前手动开关和调度状态重新评估该 Key。`,
|
2026-02-06 16:37:06 +08:00
|
|
|
|
confirmText: '确认清除',
|
|
|
|
|
|
variant: 'default',
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!confirmed) return
|
|
|
|
|
|
|
|
|
|
|
|
clearingOAuthInvalidKeyId.value = key.id
|
|
|
|
|
|
try {
|
|
|
|
|
|
await clearOAuthInvalid(key.id)
|
2026-03-20 16:50:59 +08:00
|
|
|
|
showSuccess('已清除 OAuth 异常标记')
|
2026-02-06 16:37:06 +08:00
|
|
|
|
// 更新本地数据
|
|
|
|
|
|
const keyInList = providerKeys.value.find(k => k.id === key.id)
|
|
|
|
|
|
if (keyInList) {
|
|
|
|
|
|
keyInList.oauth_invalid_at = null
|
|
|
|
|
|
keyInList.oauth_invalid_reason = null
|
2026-03-20 19:16:52 +08:00
|
|
|
|
if (keyInList.status_snapshot) {
|
|
|
|
|
|
keyInList.status_snapshot = {
|
|
|
|
|
|
...keyInList.status_snapshot,
|
|
|
|
|
|
oauth: {
|
|
|
|
|
|
...keyInList.status_snapshot.oauth,
|
|
|
|
|
|
code: 'none',
|
|
|
|
|
|
label: null,
|
|
|
|
|
|
reason: null,
|
|
|
|
|
|
invalid_at: null,
|
|
|
|
|
|
requires_reauth: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
account: {
|
|
|
|
|
|
...keyInList.status_snapshot.account,
|
|
|
|
|
|
code: 'ok',
|
|
|
|
|
|
label: null,
|
|
|
|
|
|
reason: null,
|
|
|
|
|
|
blocked: false,
|
|
|
|
|
|
recoverable: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-06 16:37:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
await loadEndpoints()
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '清除失败'), '错误')
|
2026-02-06 16:37:06 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
clearingOAuthInvalidKeyId.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 22:58:47 +08:00
|
|
|
|
// Codex / Antigravity / Kiro / ChatGPT Web:打开抽屉后自动后台刷新(配额缓存缺失/过期,或 Token 即将过期时触发)
|
2026-02-09 01:05:48 +08:00
|
|
|
|
const AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
|
|
|
|
|
|
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
|
|
|
|
|
|
const AUTO_TOKEN_REFRESH_SKEW_SECONDS = 2 * 60
|
2026-02-05 18:05:46 +08:00
|
|
|
|
|
2026-04-17 18:22:41 +08:00
|
|
|
|
function quotaSnapshotHasDisplayData(quota: QuotaStatusSnapshot | null | undefined): boolean {
|
|
|
|
|
|
if (!quota) return false
|
|
|
|
|
|
return Boolean(
|
|
|
|
|
|
(typeof quota.code === 'string' && quota.code.trim().toLowerCase() !== 'unknown')
|
|
|
|
|
|
|| quota.updated_at != null
|
|
|
|
|
|
|| quota.observed_at != null
|
|
|
|
|
|
|| quota.usage_ratio != null
|
|
|
|
|
|
|| (Array.isArray(quota.windows) && quota.windows.length > 0)
|
|
|
|
|
|
|| quota.credits,
|
|
|
|
|
|
)
|
2026-02-05 18:09:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 18:22:41 +08:00
|
|
|
|
function getQuotaSnapshotForProvider(
|
|
|
|
|
|
key: EndpointAPIKey,
|
2026-05-06 22:58:47 +08:00
|
|
|
|
providerType: 'codex' | 'kiro' | 'antigravity' | 'chatgpt_web' | 'gemini_cli',
|
2026-04-17 18:22:41 +08:00
|
|
|
|
): QuotaStatusSnapshot | null {
|
|
|
|
|
|
const quota = key.status_snapshot?.quota
|
|
|
|
|
|
if (!quota) return null
|
|
|
|
|
|
|
|
|
|
|
|
const snapshotProviderType = quota.provider_type?.trim().toLowerCase()
|
|
|
|
|
|
if (snapshotProviderType) {
|
|
|
|
|
|
return snapshotProviderType === providerType ? quota : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return quotaSnapshotHasDisplayData(quota) ? quota : null
|
2026-02-09 01:05:48 +08:00
|
|
|
|
}
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
2026-04-17 18:22:41 +08:00
|
|
|
|
function getQuotaSnapshotUpdatedAt(quota: QuotaStatusSnapshot | null | undefined): number | undefined {
|
|
|
|
|
|
const updatedAt = quota?.updated_at ?? quota?.observed_at
|
|
|
|
|
|
return typeof updatedAt === 'number' ? updatedAt : undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getQuotaWindow(
|
|
|
|
|
|
quota: QuotaStatusSnapshot | null | undefined,
|
|
|
|
|
|
code: string,
|
|
|
|
|
|
): QuotaWindowSnapshot | null {
|
|
|
|
|
|
const windows = quota?.windows
|
|
|
|
|
|
if (!Array.isArray(windows)) return null
|
|
|
|
|
|
return windows.find(window => String(window?.code || '').trim().toLowerCase() === code.trim().toLowerCase()) ?? null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getQuotaWindowUsedPercent(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
|
|
|
|
|
if (!window) return undefined
|
|
|
|
|
|
if (typeof window.used_ratio === 'number') {
|
|
|
|
|
|
return Math.max(Math.min(window.used_ratio * 100, 100), 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeof window.remaining_ratio === 'number') {
|
|
|
|
|
|
return Math.max(Math.min((1 - window.remaining_ratio) * 100, 100), 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
return undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getQuotaWindowRemainingPercent(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
|
|
|
|
|
if (!window) return undefined
|
|
|
|
|
|
if (typeof window.remaining_ratio === 'number') {
|
|
|
|
|
|
return Math.max(Math.min(window.remaining_ratio * 100, 100), 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeof window.used_ratio === 'number') {
|
|
|
|
|
|
return Math.max(Math.min((1 - window.used_ratio) * 100, 100), 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
return undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getQuotaWindowResetAt(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
|
|
|
|
|
return typeof window?.reset_at === 'number' ? window.reset_at : undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getQuotaWindowResetSeconds(window: QuotaWindowSnapshot | null | undefined): number | undefined {
|
|
|
|
|
|
return typeof window?.reset_seconds === 'number' ? window.reset_seconds : undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getQuotaWindowByScope(
|
|
|
|
|
|
quota: QuotaStatusSnapshot | null | undefined,
|
|
|
|
|
|
scope: string,
|
|
|
|
|
|
): QuotaWindowSnapshot[] {
|
|
|
|
|
|
const windows = quota?.windows
|
|
|
|
|
|
if (!Array.isArray(windows)) return []
|
|
|
|
|
|
return windows.filter(window => String(window?.scope || '').trim().toLowerCase() === scope.trim().toLowerCase())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getQuotaWindowLiveResetSeconds(
|
|
|
|
|
|
quota: QuotaStatusSnapshot | null | undefined,
|
|
|
|
|
|
window: QuotaWindowSnapshot | null | undefined,
|
|
|
|
|
|
): number | null {
|
|
|
|
|
|
if (!window) return null
|
|
|
|
|
|
|
|
|
|
|
|
const now = Math.floor(Date.now() / 1000)
|
|
|
|
|
|
if (typeof window.reset_at === 'number') {
|
|
|
|
|
|
return Math.max(window.reset_at - now, 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (typeof window.reset_seconds === 'number') {
|
|
|
|
|
|
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
|
|
|
|
|
const elapsed = typeof updatedAt === 'number' ? Math.max(now - updatedAt, 0) : 0
|
|
|
|
|
|
return Math.max(window.reset_seconds - elapsed, 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getCodexQuotaDisplay(key: EndpointAPIKey): CodexUpstreamMetadata | null {
|
|
|
|
|
|
const quota = getQuotaSnapshotForProvider(key, 'codex')
|
|
|
|
|
|
if (!quota) return null
|
|
|
|
|
|
|
|
|
|
|
|
const display: CodexUpstreamMetadata = {}
|
|
|
|
|
|
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
|
|
|
|
|
if (updatedAt !== undefined) display.updated_at = updatedAt
|
|
|
|
|
|
if (quota.plan_type) display.plan_type = quota.plan_type
|
|
|
|
|
|
|
|
|
|
|
|
const primaryWindow = getQuotaWindow(quota, 'weekly')
|
|
|
|
|
|
const primaryUsedPercent = getQuotaWindowUsedPercent(primaryWindow)
|
|
|
|
|
|
if (primaryUsedPercent !== undefined) display.primary_used_percent = primaryUsedPercent
|
|
|
|
|
|
const primaryResetAt = getQuotaWindowResetAt(primaryWindow)
|
|
|
|
|
|
if (primaryResetAt !== undefined) display.primary_reset_at = primaryResetAt
|
|
|
|
|
|
const primaryResetSeconds = getQuotaWindowResetSeconds(primaryWindow)
|
|
|
|
|
|
if (primaryResetSeconds !== undefined) display.primary_reset_seconds = primaryResetSeconds
|
|
|
|
|
|
if (typeof primaryWindow?.window_minutes === 'number') {
|
|
|
|
|
|
display.primary_window_minutes = primaryWindow.window_minutes
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const secondaryWindow = getQuotaWindow(quota, '5h')
|
|
|
|
|
|
const secondaryUsedPercent = getQuotaWindowUsedPercent(secondaryWindow)
|
|
|
|
|
|
if (secondaryUsedPercent !== undefined) display.secondary_used_percent = secondaryUsedPercent
|
|
|
|
|
|
const secondaryResetAt = getQuotaWindowResetAt(secondaryWindow)
|
|
|
|
|
|
if (secondaryResetAt !== undefined) display.secondary_reset_at = secondaryResetAt
|
|
|
|
|
|
const secondaryResetSeconds = getQuotaWindowResetSeconds(secondaryWindow)
|
|
|
|
|
|
if (secondaryResetSeconds !== undefined) display.secondary_reset_seconds = secondaryResetSeconds
|
|
|
|
|
|
if (typeof secondaryWindow?.window_minutes === 'number') {
|
|
|
|
|
|
display.secondary_window_minutes = secondaryWindow.window_minutes
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:44:45 +08:00
|
|
|
|
const sparkPrimaryWindow = getQuotaWindow(quota, 'spark_5h')
|
|
|
|
|
|
const sparkPrimaryUsedPercent = getQuotaWindowUsedPercent(sparkPrimaryWindow)
|
|
|
|
|
|
if (sparkPrimaryUsedPercent !== undefined) display.spark_primary_used_percent = sparkPrimaryUsedPercent
|
|
|
|
|
|
const sparkPrimaryResetAt = getQuotaWindowResetAt(sparkPrimaryWindow)
|
|
|
|
|
|
if (sparkPrimaryResetAt !== undefined) display.spark_primary_reset_at = sparkPrimaryResetAt
|
|
|
|
|
|
const sparkPrimaryResetSeconds = getQuotaWindowResetSeconds(sparkPrimaryWindow)
|
|
|
|
|
|
if (sparkPrimaryResetSeconds !== undefined) display.spark_primary_reset_seconds = sparkPrimaryResetSeconds
|
|
|
|
|
|
if (typeof sparkPrimaryWindow?.window_minutes === 'number') {
|
|
|
|
|
|
display.spark_primary_window_minutes = sparkPrimaryWindow.window_minutes
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const sparkSecondaryWindow = getQuotaWindow(quota, 'spark_weekly')
|
|
|
|
|
|
const sparkSecondaryUsedPercent = getQuotaWindowUsedPercent(sparkSecondaryWindow)
|
|
|
|
|
|
if (sparkSecondaryUsedPercent !== undefined) display.spark_secondary_used_percent = sparkSecondaryUsedPercent
|
|
|
|
|
|
const sparkSecondaryResetAt = getQuotaWindowResetAt(sparkSecondaryWindow)
|
|
|
|
|
|
if (sparkSecondaryResetAt !== undefined) display.spark_secondary_reset_at = sparkSecondaryResetAt
|
|
|
|
|
|
const sparkSecondaryResetSeconds = getQuotaWindowResetSeconds(sparkSecondaryWindow)
|
|
|
|
|
|
if (sparkSecondaryResetSeconds !== undefined) display.spark_secondary_reset_seconds = sparkSecondaryResetSeconds
|
|
|
|
|
|
if (typeof sparkSecondaryWindow?.window_minutes === 'number') {
|
|
|
|
|
|
display.spark_secondary_window_minutes = sparkSecondaryWindow.window_minutes
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 18:22:41 +08:00
|
|
|
|
return Object.keys(display).length > 0 ? display : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasCodexQuotaDisplayData(key: EndpointAPIKey): boolean {
|
|
|
|
|
|
const codex = getCodexQuotaDisplay(key)
|
|
|
|
|
|
return !!codex && (
|
|
|
|
|
|
codex.primary_used_percent !== undefined
|
|
|
|
|
|
|| codex.secondary_used_percent !== undefined
|
2026-05-09 10:44:45 +08:00
|
|
|
|
|| codex.spark_primary_used_percent !== undefined
|
|
|
|
|
|
|| codex.spark_secondary_used_percent !== undefined
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasCodexSparkQuotaDisplayData(key: EndpointAPIKey): boolean {
|
|
|
|
|
|
const codex = getCodexQuotaDisplay(key)
|
|
|
|
|
|
return !!codex && (
|
|
|
|
|
|
codex.spark_primary_used_percent !== undefined
|
|
|
|
|
|
|| codex.spark_secondary_used_percent !== undefined
|
2026-04-17 18:22:41 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getKiroQuotaDisplay(key: EndpointAPIKey): KiroUpstreamMetadata | null {
|
|
|
|
|
|
const quota = getQuotaSnapshotForProvider(key, 'kiro')
|
|
|
|
|
|
if (!quota) return null
|
|
|
|
|
|
|
|
|
|
|
|
const display: KiroUpstreamMetadata = {}
|
|
|
|
|
|
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
|
|
|
|
|
if (updatedAt !== undefined) display.updated_at = updatedAt
|
|
|
|
|
|
if (quota.plan_type) display.subscription_title = quota.plan_type
|
|
|
|
|
|
|
|
|
|
|
|
if (String(quota.code || '').trim().toLowerCase() === 'banned') {
|
|
|
|
|
|
display.is_banned = true
|
|
|
|
|
|
if (quota.reason) display.ban_reason = quota.reason
|
|
|
|
|
|
if (updatedAt !== undefined) display.banned_at = updatedAt
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const usageWindow =
|
|
|
|
|
|
getQuotaWindow(quota, 'usage')
|
|
|
|
|
|
?? getQuotaWindowByScope(quota, 'account')[0]
|
|
|
|
|
|
?? null
|
|
|
|
|
|
if (usageWindow) {
|
|
|
|
|
|
const usedPercent = getQuotaWindowUsedPercent(usageWindow)
|
|
|
|
|
|
if (usedPercent !== undefined) display.usage_percentage = usedPercent
|
|
|
|
|
|
if (typeof usageWindow.used_value === 'number') display.current_usage = usageWindow.used_value
|
|
|
|
|
|
if (typeof usageWindow.limit_value === 'number') display.usage_limit = usageWindow.limit_value
|
|
|
|
|
|
if (typeof usageWindow.remaining_value === 'number') display.remaining = usageWindow.remaining_value
|
|
|
|
|
|
|
|
|
|
|
|
const nextResetAt =
|
|
|
|
|
|
getQuotaWindowResetAt(usageWindow)
|
|
|
|
|
|
?? (() => {
|
|
|
|
|
|
const resetSeconds = getQuotaWindowResetSeconds(usageWindow)
|
|
|
|
|
|
if (updatedAt === undefined || resetSeconds === undefined) return undefined
|
|
|
|
|
|
return updatedAt + resetSeconds
|
|
|
|
|
|
})()
|
|
|
|
|
|
if (nextResetAt !== undefined) display.next_reset_at = nextResetAt
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Object.keys(display).length > 0 ? display : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasKiroQuotaDisplayData(key: EndpointAPIKey): boolean {
|
|
|
|
|
|
const kiro = getKiroQuotaDisplay(key)
|
|
|
|
|
|
return !!kiro && (kiro.usage_percentage !== undefined || kiro.usage_limit !== undefined)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 22:58:47 +08:00
|
|
|
|
type ChatGPTWebQuotaDisplay = ChatGPTWebUpstreamMetadata & {
|
|
|
|
|
|
image_quota_remaining_percent?: number
|
|
|
|
|
|
image_quota_used_percent?: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getChatGPTWebQuotaDisplay(key: EndpointAPIKey): ChatGPTWebQuotaDisplay | null {
|
|
|
|
|
|
const quota = getQuotaSnapshotForProvider(key, 'chatgpt_web')
|
|
|
|
|
|
if (!quota) return null
|
|
|
|
|
|
|
|
|
|
|
|
const display: ChatGPTWebQuotaDisplay = {}
|
|
|
|
|
|
const updatedAt = getQuotaSnapshotUpdatedAt(quota)
|
|
|
|
|
|
if (updatedAt !== undefined) display.updated_at = updatedAt
|
|
|
|
|
|
if (quota.plan_type) display.plan_type = quota.plan_type
|
|
|
|
|
|
if (quota.code === 'exhausted' || quota.code === 'banned') display.image_quota_blocked = true
|
|
|
|
|
|
|
|
|
|
|
|
const imageWindow =
|
|
|
|
|
|
getQuotaWindow(quota, 'image_gen')
|
|
|
|
|
|
?? getQuotaWindowByScope(quota, 'account')[0]
|
|
|
|
|
|
?? null
|
|
|
|
|
|
if (imageWindow) {
|
|
|
|
|
|
const remainingValue = typeof imageWindow.remaining_value === 'number' ? imageWindow.remaining_value : undefined
|
|
|
|
|
|
const limitValue = typeof imageWindow.limit_value === 'number' ? imageWindow.limit_value : undefined
|
|
|
|
|
|
const usedValue = typeof imageWindow.used_value === 'number' ? imageWindow.used_value : undefined
|
|
|
|
|
|
const remainingPercent = getQuotaWindowRemainingPercent(imageWindow)
|
|
|
|
|
|
const usedPercent = getQuotaWindowUsedPercent(imageWindow)
|
|
|
|
|
|
|
|
|
|
|
|
if (remainingValue !== undefined) display.image_quota_remaining = remainingValue
|
|
|
|
|
|
if (limitValue !== undefined) display.image_quota_total = limitValue
|
|
|
|
|
|
if (usedValue !== undefined) display.image_quota_used = usedValue
|
|
|
|
|
|
if (remainingPercent !== undefined) display.image_quota_remaining_percent = remainingPercent
|
|
|
|
|
|
if (usedPercent !== undefined) display.image_quota_used_percent = usedPercent
|
|
|
|
|
|
if (typeof imageWindow.reset_at === 'number') display.image_quota_reset_at = imageWindow.reset_at
|
|
|
|
|
|
if (typeof imageWindow.reset_seconds === 'number') {
|
|
|
|
|
|
const resetAt = updatedAt === undefined ? undefined : updatedAt + imageWindow.reset_seconds
|
|
|
|
|
|
if (resetAt !== undefined && display.image_quota_reset_at === undefined) {
|
|
|
|
|
|
display.image_quota_reset_at = resetAt
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Object.keys(display).length > 0 ? display : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasChatGPTWebQuotaDisplayData(key: EndpointAPIKey): boolean {
|
|
|
|
|
|
const display = getChatGPTWebQuotaDisplay(key)
|
|
|
|
|
|
return !!display && (
|
|
|
|
|
|
display.image_quota_remaining_percent !== undefined
|
|
|
|
|
|
|| display.image_quota_total !== undefined
|
|
|
|
|
|
|| display.image_quota_used !== undefined
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getChatGPTWebQuotaUsedPercent(key: EndpointAPIKey): number {
|
|
|
|
|
|
const display = getChatGPTWebQuotaDisplay(key)
|
|
|
|
|
|
if (!display) return 0
|
|
|
|
|
|
if (typeof display.image_quota_used_percent === 'number') return display.image_quota_used_percent
|
|
|
|
|
|
if (typeof display.image_quota_remaining_percent === 'number') {
|
|
|
|
|
|
return Math.max(100 - display.image_quota_remaining_percent, 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getChatGPTWebQuotaRemainingPercent(key: EndpointAPIKey): number {
|
|
|
|
|
|
const display = getChatGPTWebQuotaDisplay(key)
|
|
|
|
|
|
if (!display) return 0
|
|
|
|
|
|
if (typeof display.image_quota_remaining_percent === 'number') return display.image_quota_remaining_percent
|
|
|
|
|
|
if (typeof display.image_quota_used_percent === 'number') {
|
|
|
|
|
|
return Math.max(100 - display.image_quota_used_percent, 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatChatGPTWebUsage(value: number | null | undefined): string {
|
|
|
|
|
|
if (value === undefined || value === null) return '-'
|
|
|
|
|
|
if (Math.abs(value - Math.round(value)) < 1e-6) {
|
|
|
|
|
|
return String(Math.round(value))
|
|
|
|
|
|
}
|
|
|
|
|
|
return value.toFixed(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 18:22:41 +08:00
|
|
|
|
function isKiroBannedKey(key: EndpointAPIKey): boolean {
|
|
|
|
|
|
const quota = getQuotaSnapshotForProvider(key, 'kiro')
|
|
|
|
|
|
return String(quota?.code || '').trim().toLowerCase() === 'banned'
|
2026-02-09 17:24:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化封禁/禁止时间(后端返回秒级时间戳,Kiro/Antigravity 通用)
|
|
|
|
|
|
function formatBanTimestamp(timestamp: number | undefined): string {
|
|
|
|
|
|
if (!timestamp) return ''
|
|
|
|
|
|
const date = new Date(timestamp * 1000)
|
|
|
|
|
|
return date.toLocaleString('zh-CN', {
|
|
|
|
|
|
month: 'short',
|
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 18:22:41 +08:00
|
|
|
|
function isAntigravityForbiddenKey(key: EndpointAPIKey): boolean {
|
|
|
|
|
|
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
|
|
|
|
|
return String(quota?.code || '').trim().toLowerCase() === 'forbidden'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getAntigravityForbiddenReason(key: EndpointAPIKey): string | undefined {
|
|
|
|
|
|
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
|
|
|
|
|
return quota?.reason || undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getAntigravityForbiddenAt(key: EndpointAPIKey): number | undefined {
|
|
|
|
|
|
return getQuotaSnapshotUpdatedAt(getQuotaSnapshotForProvider(key, 'antigravity'))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getAntigravityQuotaUpdatedAt(key: EndpointAPIKey): number | undefined {
|
|
|
|
|
|
return getQuotaSnapshotUpdatedAt(getQuotaSnapshotForProvider(key, 'antigravity'))
|
2026-02-09 17:24:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 01:05:48 +08:00
|
|
|
|
// 格式化 Kiro 更新时间
|
|
|
|
|
|
const formatKiroUpdatedAt = formatUpdatedAt
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化 Kiro 使用量(带单位)
|
|
|
|
|
|
function formatKiroUsage(value: number | undefined): string {
|
|
|
|
|
|
if (value === undefined || value === null) return '-'
|
|
|
|
|
|
if (value >= 1000000) {
|
|
|
|
|
|
return `${(value / 1000000).toFixed(1)}M`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (value >= 1000) {
|
|
|
|
|
|
return `${(value / 1000).toFixed(1)}K`
|
|
|
|
|
|
}
|
|
|
|
|
|
return value.toFixed(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化 Kiro 重置时间
|
|
|
|
|
|
function formatKiroResetTime(timestamp: number | undefined): string {
|
|
|
|
|
|
if (!timestamp) return ''
|
|
|
|
|
|
// timestamp 可能是毫秒或秒,需要判断
|
|
|
|
|
|
const ts = timestamp > 1e12 ? timestamp : timestamp * 1000
|
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
|
const diff = ts - now
|
|
|
|
|
|
|
|
|
|
|
|
if (diff <= 0) {
|
|
|
|
|
|
return '已重置'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
|
|
|
|
|
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
|
|
|
|
|
|
|
|
|
|
|
if (days > 0) {
|
|
|
|
|
|
return `${days}天${hours}小时后`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
|
|
|
|
|
if (hours > 0) {
|
|
|
|
|
|
return `${hours}小时${minutes}分钟后`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return `${minutes}分钟后`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化 Kiro 订阅类型显示
|
|
|
|
|
|
function formatKiroSubscription(title: string | undefined): string {
|
|
|
|
|
|
if (!title) return ''
|
|
|
|
|
|
// 简化显示:KIRO PRO+ -> Pro+, KIRO FREE -> Free(首字母大写,与 Codex 保持一致)
|
|
|
|
|
|
const upper = title.toUpperCase()
|
|
|
|
|
|
if (upper.includes('POWER')) return 'Power'
|
|
|
|
|
|
if (upper.includes('PRO+')) return 'Pro+'
|
|
|
|
|
|
if (upper.includes('PRO')) return 'Pro'
|
|
|
|
|
|
if (upper.includes('FREE')) return 'Free'
|
|
|
|
|
|
return title
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 18:22:41 +08:00
|
|
|
|
function getKiroSubscriptionTitle(key: EndpointAPIKey): string | undefined {
|
|
|
|
|
|
return getKiroQuotaDisplay(key)?.subscription_title
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 15:00:17 +08:00
|
|
|
|
function getKiroSubscriptionBadgeLabel(key: EndpointAPIKey): string {
|
|
|
|
|
|
return formatKiroSubscription(getKiroSubscriptionTitle(key))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function shouldShowKiroSubscriptionBadge(key: EndpointAPIKey): boolean {
|
|
|
|
|
|
if (provider.value?.provider_type !== 'kiro') return false
|
|
|
|
|
|
|
|
|
|
|
|
const kiroLabel = getKiroSubscriptionBadgeLabel(key)
|
|
|
|
|
|
if (!kiroLabel) return false
|
|
|
|
|
|
|
|
|
|
|
|
const oauthPlanLabel = formatOAuthPlanType(key.oauth_plan_type)
|
|
|
|
|
|
if (!oauthPlanLabel) return true
|
|
|
|
|
|
|
|
|
|
|
|
return oauthPlanLabel.trim().toLowerCase() !== kiroLabel.trim().toLowerCase()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 01:05:48 +08:00
|
|
|
|
function shouldAutoRefreshCodexQuota(): boolean {
|
|
|
|
|
|
if (provider.value?.provider_type !== 'codex') return false
|
2026-02-10 21:12:31 +08:00
|
|
|
|
const now = Math.floor(Date.now() / 1000)
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
|
|
|
|
|
for (const { key } of allKeys.value) {
|
|
|
|
|
|
if (!key.is_active) continue
|
|
|
|
|
|
|
2026-02-10 21:12:31 +08:00
|
|
|
|
if (isTokenExpiringSoon(key, now)) return true
|
|
|
|
|
|
|
2026-02-09 01:05:48 +08:00
|
|
|
|
// 只要有一个活跃 key 没有配额数据,就刷新一次
|
2026-04-17 18:22:41 +08:00
|
|
|
|
if (!hasCodexQuotaDisplayData(key)) {
|
2026-02-06 16:37:06 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-02-10 21:12:31 +08:00
|
|
|
|
// 配额数据超过 5 分钟未更新,也触发刷新
|
2026-04-17 18:22:41 +08:00
|
|
|
|
const updatedAt = getCodexQuotaDisplay(key)?.updated_at
|
2026-02-10 21:12:31 +08:00
|
|
|
|
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-02-06 16:37:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 22:58:47 +08:00
|
|
|
|
// 检查 OAuth Token 是否即将过期(Codex / Antigravity / Kiro / ChatGPT Web)
|
2026-02-09 01:05:48 +08:00
|
|
|
|
function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean {
|
2026-03-20 19:16:52 +08:00
|
|
|
|
const oauthCode = String(key.status_snapshot?.oauth?.code || '').trim().toLowerCase()
|
|
|
|
|
|
if (oauthCode && oauthCode !== 'valid' && oauthCode !== 'expiring') {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return typeof key.oauth_expires_at === 'number'
|
2026-02-09 01:05:48 +08:00
|
|
|
|
&& (key.oauth_expires_at - now) <= AUTO_TOKEN_REFRESH_SKEW_SECONDS
|
|
|
|
|
|
}
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
2026-02-09 01:05:48 +08:00
|
|
|
|
function shouldAutoRefreshAntigravityQuota(): boolean {
|
|
|
|
|
|
if (provider.value?.provider_type !== 'antigravity') return false
|
|
|
|
|
|
const now = Math.floor(Date.now() / 1000)
|
|
|
|
|
|
|
|
|
|
|
|
for (const { key } of allKeys.value) {
|
|
|
|
|
|
if (!key.is_active) continue
|
|
|
|
|
|
|
|
|
|
|
|
if (isTokenExpiringSoon(key, now)) return true
|
|
|
|
|
|
|
|
|
|
|
|
// 只要有一个活跃 key 没有配额/为空/过期,就刷新一次(接口会批量刷新所有活跃 key)
|
2026-04-17 18:22:41 +08:00
|
|
|
|
if (!hasAntigravityQuotaDisplayData(key)) {
|
2026-02-09 01:05:48 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-04-17 18:22:41 +08:00
|
|
|
|
const updatedAt = getAntigravityQuotaUpdatedAt(key)
|
2026-02-09 01:05:48 +08:00
|
|
|
|
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function shouldAutoRefreshKiroQuota(): boolean {
|
|
|
|
|
|
if (provider.value?.provider_type !== 'kiro') return false
|
|
|
|
|
|
const now = Math.floor(Date.now() / 1000)
|
|
|
|
|
|
|
|
|
|
|
|
for (const { key } of allKeys.value) {
|
|
|
|
|
|
if (!key.is_active) continue
|
|
|
|
|
|
|
|
|
|
|
|
if (isTokenExpiringSoon(key, now)) return true
|
|
|
|
|
|
|
|
|
|
|
|
// 只要有一个活跃 key 没有配额数据,就刷新一次
|
2026-04-17 18:22:41 +08:00
|
|
|
|
if (!hasKiroQuotaDisplayData(key)) {
|
2026-02-10 21:12:31 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
// 配额数据超过 5 分钟未更新,也触发刷新
|
2026-04-17 18:22:41 +08:00
|
|
|
|
const updatedAt = getKiroQuotaDisplay(key)?.updated_at
|
2026-02-10 21:12:31 +08:00
|
|
|
|
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
2026-02-09 01:05:48 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 22:58:47 +08:00
|
|
|
|
function shouldAutoRefreshChatGPTWebQuota(): boolean {
|
|
|
|
|
|
if (provider.value?.provider_type !== 'chatgpt_web') return false
|
|
|
|
|
|
const now = Math.floor(Date.now() / 1000)
|
|
|
|
|
|
|
|
|
|
|
|
for (const { key } of allKeys.value) {
|
|
|
|
|
|
if (!key.is_active) continue
|
|
|
|
|
|
|
|
|
|
|
|
if (isTokenExpiringSoon(key, now)) return true
|
|
|
|
|
|
|
|
|
|
|
|
if (!hasChatGPTWebQuotaDisplayData(key)) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updatedAt = getChatGPTWebQuotaDisplay(key)?.updated_at
|
|
|
|
|
|
if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 18:22:41 +08:00
|
|
|
|
function defaultQuotaSnapshot(): QuotaStatusSnapshot {
|
|
|
|
|
|
return {
|
|
|
|
|
|
code: 'unknown',
|
|
|
|
|
|
exhausted: false,
|
|
|
|
|
|
usage_ratio: null,
|
|
|
|
|
|
updated_at: null,
|
|
|
|
|
|
reset_seconds: null,
|
|
|
|
|
|
plan_type: null,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function wrapQuotaMetadataForProvider(
|
|
|
|
|
|
providerType: string,
|
|
|
|
|
|
metadata: Record<string, unknown> | undefined,
|
|
|
|
|
|
): UpstreamMetadata | null {
|
|
|
|
|
|
if (!metadata) return null
|
|
|
|
|
|
if (providerType in metadata) {
|
|
|
|
|
|
return metadata as UpstreamMetadata
|
|
|
|
|
|
}
|
|
|
|
|
|
return { [providerType]: metadata } as UpstreamMetadata
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 17:51:49 +08:00
|
|
|
|
// 将配额刷新结果就地应用到现有 key 上,避免重新拉列表导致分页重置
|
2026-04-17 18:22:41 +08:00
|
|
|
|
function applyQuotaResults(
|
|
|
|
|
|
results: { key_id: string; status: string; metadata?: Record<string, unknown>; quota_snapshot?: QuotaStatusSnapshot }[],
|
|
|
|
|
|
): number {
|
|
|
|
|
|
const providerType = provider.value?.provider_type
|
|
|
|
|
|
if (!providerType) return 0
|
|
|
|
|
|
|
|
|
|
|
|
let applied = 0
|
2026-02-26 17:51:49 +08:00
|
|
|
|
for (const r of results) {
|
|
|
|
|
|
const target = providerKeys.value.find(k => k.id === r.key_id)
|
2026-04-17 18:22:41 +08:00
|
|
|
|
if (!target) continue
|
|
|
|
|
|
|
|
|
|
|
|
let changed = false
|
|
|
|
|
|
const wrappedMetadata = wrapQuotaMetadataForProvider(providerType, r.metadata)
|
|
|
|
|
|
if (wrappedMetadata) {
|
|
|
|
|
|
target.upstream_metadata = { ...target.upstream_metadata, ...wrappedMetadata } as typeof target.upstream_metadata
|
|
|
|
|
|
changed = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (r.quota_snapshot) {
|
|
|
|
|
|
target.status_snapshot = {
|
|
|
|
|
|
oauth: target.status_snapshot?.oauth ?? {
|
|
|
|
|
|
code: 'none',
|
|
|
|
|
|
label: null,
|
|
|
|
|
|
reason: null,
|
|
|
|
|
|
expires_at: null,
|
|
|
|
|
|
invalid_at: null,
|
|
|
|
|
|
source: null,
|
|
|
|
|
|
requires_reauth: false,
|
|
|
|
|
|
expiring_soon: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
account: target.status_snapshot?.account ?? {
|
|
|
|
|
|
code: 'ok',
|
|
|
|
|
|
label: null,
|
|
|
|
|
|
reason: null,
|
|
|
|
|
|
blocked: false,
|
|
|
|
|
|
source: null,
|
|
|
|
|
|
recoverable: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
quota: {
|
|
|
|
|
|
...defaultQuotaSnapshot(),
|
|
|
|
|
|
...(target.status_snapshot?.quota ?? {}),
|
|
|
|
|
|
...r.quota_snapshot,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
changed = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (changed) {
|
|
|
|
|
|
applied += 1
|
2026-02-26 17:51:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-17 18:22:41 +08:00
|
|
|
|
return applied
|
2026-02-26 17:51:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 22:58:47 +08:00
|
|
|
|
// 通用的自动刷新配额函数(支持 Codex、Antigravity、Kiro 和 ChatGPT Web)
|
2026-04-20 15:56:59 +08:00
|
|
|
|
async function autoRefreshQuotaInBackground(options: { ignoreCooldown?: boolean } = {}) {
|
|
|
|
|
|
const providerId = props.providerId
|
|
|
|
|
|
if (!providerId) return
|
2026-02-09 01:05:48 +08:00
|
|
|
|
if (refreshingQuota.value) return
|
|
|
|
|
|
|
|
|
|
|
|
const providerType = provider.value?.provider_type
|
2026-05-06 22:58:47 +08:00
|
|
|
|
if (providerType !== 'codex' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'chatgpt_web') return
|
2026-02-09 01:05:48 +08:00
|
|
|
|
|
|
|
|
|
|
// 检查是否需要刷新
|
|
|
|
|
|
let shouldRefresh = false
|
|
|
|
|
|
if (providerType === 'codex') {
|
|
|
|
|
|
shouldRefresh = shouldAutoRefreshCodexQuota()
|
|
|
|
|
|
} else if (providerType === 'antigravity') {
|
|
|
|
|
|
shouldRefresh = shouldAutoRefreshAntigravityQuota()
|
|
|
|
|
|
} else if (providerType === 'kiro') {
|
|
|
|
|
|
shouldRefresh = shouldAutoRefreshKiroQuota()
|
2026-05-06 22:58:47 +08:00
|
|
|
|
} else if (providerType === 'chatgpt_web') {
|
|
|
|
|
|
shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
|
2026-02-09 01:05:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (!shouldRefresh) return
|
2026-04-20 15:56:59 +08:00
|
|
|
|
if (!options.ignoreCooldown && isProviderQuotaAutoRefreshCoolingDown(providerId)) return
|
2026-02-09 01:05:48 +08:00
|
|
|
|
|
|
|
|
|
|
let hadCachedQuota = false
|
|
|
|
|
|
if (providerType === 'codex') {
|
2026-04-17 18:22:41 +08:00
|
|
|
|
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasCodexQuotaDisplayData(key))
|
2026-02-09 01:05:48 +08:00
|
|
|
|
} else if (providerType === 'antigravity') {
|
2026-04-17 18:22:41 +08:00
|
|
|
|
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasAntigravityQuotaDisplayData(key))
|
2026-02-09 01:05:48 +08:00
|
|
|
|
} else if (providerType === 'kiro') {
|
2026-04-17 18:22:41 +08:00
|
|
|
|
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaDisplayData(key))
|
2026-05-06 22:58:47 +08:00
|
|
|
|
} else if (providerType === 'chatgpt_web') {
|
|
|
|
|
|
hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasChatGPTWebQuotaDisplayData(key))
|
2026-02-09 01:05:48 +08:00
|
|
|
|
}
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
|
|
|
|
|
refreshingQuota.value = true
|
2026-04-20 15:56:59 +08:00
|
|
|
|
markProviderQuotaAutoRefreshAttempt(providerId)
|
2026-02-06 16:37:06 +08:00
|
|
|
|
try {
|
2026-04-20 15:56:59 +08:00
|
|
|
|
const result = await refreshProviderQuota(providerId)
|
2026-04-17 18:22:41 +08:00
|
|
|
|
const applied = applyQuotaResults(result.results)
|
|
|
|
|
|
if (result.success <= 0 && applied === 0 && !hadCachedQuota && providerType === 'antigravity') {
|
2026-02-06 16:37:06 +08:00
|
|
|
|
showError('没有获取到配额信息(请检查账号是否已授权、project_id 是否存在)', '提示')
|
|
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
2026-02-09 01:05:48 +08:00
|
|
|
|
if (!hadCachedQuota && providerType === 'antigravity') {
|
2026-02-22 00:43:41 +08:00
|
|
|
|
showError(parseApiError(err, '后台刷新配额失败'), '错误')
|
2026-02-06 16:37:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
refreshingQuota.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
|
|
|
|
|
|
antigravityQuotaDialogKey.value = key
|
|
|
|
|
|
antigravityQuotaDialogOpen.value = true
|
|
|
|
|
|
|
|
|
|
|
|
// 没有配额数据时主动获取
|
2026-04-17 18:22:41 +08:00
|
|
|
|
if (!hasAntigravityQuotaDisplayData(key)) {
|
2026-02-06 16:37:06 +08:00
|
|
|
|
if (refreshingQuota.value) return
|
|
|
|
|
|
refreshingQuota.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await refreshProviderQuota(props.providerId)
|
2026-04-17 18:22:41 +08:00
|
|
|
|
applyQuotaResults(result.results)
|
|
|
|
|
|
// 更新弹窗引用的 key 数据
|
|
|
|
|
|
const updated = allKeys.value.find(({ key: k }) => k.id === key.id)
|
|
|
|
|
|
if (updated) {
|
|
|
|
|
|
antigravityQuotaDialogKey.value = updated.key
|
2026-02-06 16:37:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 静默失败,弹窗会显示"暂无配额数据"
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
refreshingQuota.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
async function handleKeyChanged() {
|
2026-03-11 09:58:25 +08:00
|
|
|
|
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
2025-12-10 20:52:44 +08:00
|
|
|
|
emit('refresh')
|
2026-02-06 16:37:06 +08:00
|
|
|
|
// 添加/修改 key 后自动获取 Antigravity 配额(新 key 的 upstream_metadata 为空)
|
2026-04-20 15:56:59 +08:00
|
|
|
|
void autoRefreshQuotaInBackground({ ignoreCooldown: true })
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 切换密钥启用状态
|
|
|
|
|
|
async function toggleKeyActive(key: EndpointAPIKey) {
|
|
|
|
|
|
if (togglingKeyId.value) return
|
|
|
|
|
|
|
|
|
|
|
|
togglingKeyId.value = key.id
|
|
|
|
|
|
try {
|
|
|
|
|
|
const newStatus = !key.is_active
|
2026-01-10 18:43:53 +08:00
|
|
|
|
await updateProviderKey(key.id, { is_active: newStatus })
|
2025-12-10 20:52:44 +08:00
|
|
|
|
key.is_active = newStatus
|
|
|
|
|
|
showSuccess(newStatus ? '密钥已启用' : '密钥已停用')
|
|
|
|
|
|
emit('refresh')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '操作失败'), '错误')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
togglingKeyId.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 01:05:48 +08:00
|
|
|
|
// ===== Key 级别代理配置 =====
|
|
|
|
|
|
|
|
|
|
|
|
/** 获取 Key 当前代理节点的名称(用于显示) */
|
|
|
|
|
|
function getKeyProxyNodeName(key: EndpointAPIKey): string | null {
|
|
|
|
|
|
if (!key.proxy?.node_id) return null
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const node = proxyNodesStore.nodes.find(n => n.id === key.proxy?.node_id)
|
2026-02-09 01:05:48 +08:00
|
|
|
|
return node ? node.name : `${key.proxy.node_id.slice(0, 8) }...`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 切换代理 Popover 的打开/关闭状态 */
|
|
|
|
|
|
function handleProxyPopoverToggle(keyId: string, open: boolean) {
|
|
|
|
|
|
proxyPopoverOpenKeyId.value = open ? keyId : null
|
|
|
|
|
|
if (open) {
|
|
|
|
|
|
proxyNodesStore.ensureLoaded()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 设置 Key 的代理节点 */
|
|
|
|
|
|
async function setKeyProxy(key: EndpointAPIKey, nodeId: string) {
|
|
|
|
|
|
savingProxyKeyId.value = key.id
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateProviderKey(key.id, {
|
|
|
|
|
|
proxy: { node_id: nodeId, enabled: true },
|
|
|
|
|
|
})
|
|
|
|
|
|
key.proxy = { node_id: nodeId, enabled: true }
|
|
|
|
|
|
proxyPopoverOpenKeyId.value = null
|
|
|
|
|
|
showSuccess('代理节点已设置')
|
|
|
|
|
|
emit('refresh')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '设置代理失败'), '错误')
|
2026-02-09 01:05:48 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
savingProxyKeyId.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 清除 Key 的代理节点(回退到 Provider 级别代理) */
|
|
|
|
|
|
async function clearKeyProxy(key: EndpointAPIKey) {
|
|
|
|
|
|
savingProxyKeyId.value = key.id
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateProviderKey(key.id, { proxy: null })
|
|
|
|
|
|
key.proxy = null
|
|
|
|
|
|
proxyPopoverOpenKeyId.value = null
|
|
|
|
|
|
showSuccess('已清除账号代理,将使用提供商级别代理')
|
|
|
|
|
|
emit('refresh')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '清除代理失败'), '错误')
|
2026-02-09 01:05:48 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
savingProxyKeyId.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// ===== 模型事件处理 =====
|
|
|
|
|
|
// 处理编辑模型
|
|
|
|
|
|
function handleEditModel(model: Model) {
|
|
|
|
|
|
editingModel.value = model
|
|
|
|
|
|
modelFormDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理打开批量关联对话框
|
|
|
|
|
|
function handleBatchAssign() {
|
|
|
|
|
|
batchAssignDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理批量关联完成
|
|
|
|
|
|
async function handleBatchAssignChanged() {
|
2026-03-11 09:58:25 +08:00
|
|
|
|
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
2025-12-10 20:52:44 +08:00
|
|
|
|
emit('refresh')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 14:28:13 +08:00
|
|
|
|
// 处理模型映射变更
|
|
|
|
|
|
async function handleModelMappingChanged() {
|
2026-03-11 09:58:25 +08:00
|
|
|
|
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
2026-01-14 14:28:13 +08:00
|
|
|
|
emit('refresh')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 处理模型保存完成
|
|
|
|
|
|
async function handleModelSaved() {
|
|
|
|
|
|
editingModel.value = null
|
2026-03-11 09:58:25 +08:00
|
|
|
|
await Promise.all([loadEndpoints(), loadMappingPreview()])
|
2025-12-10 20:52:44 +08:00
|
|
|
|
emit('refresh')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 点击编辑优先级 =====
|
|
|
|
|
|
function startEditPriority(key: EndpointAPIKey) {
|
|
|
|
|
|
editingPriorityKey.value = key.id
|
|
|
|
|
|
editingPriorityValue.value = key.internal_priority ?? 0
|
2026-01-10 18:43:53 +08:00
|
|
|
|
prioritySaving.value = false
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
// v-for 中的 ref 是数组,取第一个元素
|
|
|
|
|
|
const input = Array.isArray(priorityInputRef.value) ? priorityInputRef.value[0] : priorityInputRef.value
|
|
|
|
|
|
input?.focus()
|
|
|
|
|
|
input?.select()
|
|
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cancelEditPriority() {
|
|
|
|
|
|
editingPriorityKey.value = null
|
2026-01-10 18:43:53 +08:00
|
|
|
|
prioritySaving.value = false
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
function handlePriorityKeydown(e: KeyboardEvent, key: EndpointAPIKey) {
|
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
if (!prioritySaving.value) {
|
|
|
|
|
|
prioritySaving.value = true
|
|
|
|
|
|
savePriority(key)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (e.key === 'Escape') {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
cancelEditPriority()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handlePriorityBlur(key: EndpointAPIKey) {
|
|
|
|
|
|
// 如果已经在保存中(Enter触发),不重复保存
|
|
|
|
|
|
if (prioritySaving.value) return
|
|
|
|
|
|
savePriority(key)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function savePriority(key: EndpointAPIKey) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const keyId = editingPriorityKey.value
|
2026-01-10 18:43:53 +08:00
|
|
|
|
const newPriority = parseInt(String(editingPriorityValue.value), 10) || 0
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
if (!keyId || newPriority < 0) {
|
|
|
|
|
|
cancelEditPriority()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果优先级没有变化,直接取消编辑
|
|
|
|
|
|
if (key.internal_priority === newPriority) {
|
|
|
|
|
|
cancelEditPriority()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cancelEditPriority()
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-10 18:43:53 +08:00
|
|
|
|
await updateProviderKey(keyId, { internal_priority: newPriority })
|
2025-12-10 20:52:44 +08:00
|
|
|
|
showSuccess('优先级已更新')
|
2026-01-10 18:43:53 +08:00
|
|
|
|
// 更新本地数据 - 更新 providerKeys 中的数据
|
|
|
|
|
|
const keyToUpdate = providerKeys.value.find(k => k.id === keyId)
|
|
|
|
|
|
if (keyToUpdate) {
|
|
|
|
|
|
keyToUpdate.internal_priority = newPriority
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
2026-01-10 18:43:53 +08:00
|
|
|
|
// 重新排序
|
|
|
|
|
|
providerKeys.value.sort((a, b) => (a.internal_priority ?? 0) - (b.internal_priority ?? 0))
|
2025-12-10 20:52:44 +08:00
|
|
|
|
emit('refresh')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '更新优先级失败'), '错误')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 04:12:29 +08:00
|
|
|
|
// ===== 点击编辑倍率 =====
|
|
|
|
|
|
function startEditMultiplier(key: EndpointAPIKey, format: string) {
|
|
|
|
|
|
editingMultiplierKey.value = key.id
|
|
|
|
|
|
editingMultiplierFormat.value = format
|
|
|
|
|
|
editingMultiplierValue.value = getKeyRateMultiplier(key, format)
|
|
|
|
|
|
multiplierSaving.value = false
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
const input = Array.isArray(multiplierInputRef.value) ? multiplierInputRef.value[0] : multiplierInputRef.value
|
|
|
|
|
|
input?.focus()
|
|
|
|
|
|
input?.select()
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cancelEditMultiplier() {
|
|
|
|
|
|
editingMultiplierKey.value = null
|
|
|
|
|
|
editingMultiplierFormat.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleMultiplierKeydown(e: KeyboardEvent, key: EndpointAPIKey, format: string) {
|
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
e.stopPropagation()
|
2026-03-06 01:47:09 +08:00
|
|
|
|
saveMultiplier(key, format)
|
2026-01-11 04:12:29 +08:00
|
|
|
|
} else if (e.key === 'Escape') {
|
|
|
|
|
|
e.preventDefault()
|
2026-03-06 01:47:09 +08:00
|
|
|
|
multiplierSaving.value = true // 阻止 blur 触发保存
|
2026-01-11 04:12:29 +08:00
|
|
|
|
cancelEditMultiplier()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleMultiplierBlur(key: EndpointAPIKey, format: string) {
|
|
|
|
|
|
if (multiplierSaving.value) return
|
|
|
|
|
|
saveMultiplier(key, format)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveMultiplier(key: EndpointAPIKey, format: string) {
|
2026-03-06 01:47:09 +08:00
|
|
|
|
// 防止重复调用(Enter 触发后阻止 blur 再次进入)
|
2026-01-11 04:12:29 +08:00
|
|
|
|
if (multiplierSaving.value) return
|
|
|
|
|
|
multiplierSaving.value = true
|
|
|
|
|
|
|
|
|
|
|
|
const keyId = editingMultiplierKey.value
|
|
|
|
|
|
const newMultiplier = parseFloat(String(editingMultiplierValue.value))
|
|
|
|
|
|
|
|
|
|
|
|
// 验证输入有效性
|
|
|
|
|
|
if (!keyId || isNaN(newMultiplier)) {
|
|
|
|
|
|
showError('请输入有效的倍率值')
|
|
|
|
|
|
cancelEditMultiplier()
|
2026-03-06 01:47:09 +08:00
|
|
|
|
multiplierSaving.value = false
|
2026-01-11 04:12:29 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证合理范围
|
|
|
|
|
|
if (newMultiplier <= 0 || newMultiplier > 100) {
|
|
|
|
|
|
showError('倍率必须在 0.01 到 100 之间')
|
|
|
|
|
|
cancelEditMultiplier()
|
2026-03-06 01:47:09 +08:00
|
|
|
|
multiplierSaving.value = false
|
2026-01-11 04:12:29 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果倍率没有变化,直接取消编辑(使用精度容差比较浮点数)
|
|
|
|
|
|
const currentMultiplier = getKeyRateMultiplier(key, format)
|
|
|
|
|
|
if (Math.abs(currentMultiplier - newMultiplier) < 0.0001) {
|
|
|
|
|
|
cancelEditMultiplier()
|
2026-03-06 01:47:09 +08:00
|
|
|
|
multiplierSaving.value = false
|
2026-01-11 04:12:29 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cancelEditMultiplier()
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 构建 rate_multipliers 对象
|
|
|
|
|
|
const rateMultipliers = { ...(key.rate_multipliers || {}) }
|
|
|
|
|
|
rateMultipliers[format] = newMultiplier
|
|
|
|
|
|
|
|
|
|
|
|
await updateProviderKey(keyId, { rate_multipliers: rateMultipliers })
|
|
|
|
|
|
showSuccess('倍率已更新')
|
|
|
|
|
|
|
|
|
|
|
|
// 更新本地数据
|
|
|
|
|
|
const keyToUpdate = providerKeys.value.find(k => k.id === keyId)
|
|
|
|
|
|
if (keyToUpdate) {
|
|
|
|
|
|
keyToUpdate.rate_multipliers = rateMultipliers
|
|
|
|
|
|
}
|
|
|
|
|
|
emit('refresh')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '更新倍率失败'), '错误')
|
2026-01-11 04:12:29 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
multiplierSaving.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 00:22:21 +08:00
|
|
|
|
// ===== 密钥列表拖拽排序 =====
|
|
|
|
|
|
function handleKeyDragStart(event: DragEvent, index: number) {
|
|
|
|
|
|
keyDragState.value.isDragging = true
|
|
|
|
|
|
keyDragState.value.draggedIndex = index
|
|
|
|
|
|
if (event.dataTransfer) {
|
|
|
|
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
|
|
|
|
event.dataTransfer.setData('text/plain', String(index))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleKeyDragEnd() {
|
|
|
|
|
|
keyDragState.value.isDragging = false
|
|
|
|
|
|
keyDragState.value.draggedIndex = null
|
|
|
|
|
|
keyDragState.value.targetIndex = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleKeyDragOver(event: DragEvent, index: number) {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
if (event.dataTransfer) {
|
|
|
|
|
|
event.dataTransfer.dropEffect = 'move'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (keyDragState.value.draggedIndex !== index) {
|
|
|
|
|
|
keyDragState.value.targetIndex = index
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleKeyDragLeave() {
|
|
|
|
|
|
keyDragState.value.targetIndex = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleKeyDrop(event: DragEvent, targetIndex: number) {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
|
|
|
|
|
|
const draggedIndex = keyDragState.value.draggedIndex
|
|
|
|
|
|
if (draggedIndex === null || draggedIndex === targetIndex) {
|
|
|
|
|
|
handleKeyDragEnd()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const keys = allKeys.value.map(item => item.key)
|
|
|
|
|
|
if (draggedIndex < 0 || draggedIndex >= keys.length || targetIndex < 0 || targetIndex >= keys.length) {
|
|
|
|
|
|
handleKeyDragEnd()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const draggedKey = keys[draggedIndex]
|
|
|
|
|
|
const targetKey = keys[targetIndex]
|
|
|
|
|
|
const draggedPriority = draggedKey.internal_priority ?? 0
|
|
|
|
|
|
const targetPriority = targetKey.internal_priority ?? 0
|
|
|
|
|
|
|
|
|
|
|
|
// 如果是同组内拖拽(同优先级),忽略操作
|
|
|
|
|
|
if (draggedPriority === targetPriority) {
|
|
|
|
|
|
handleKeyDragEnd()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
handleKeyDragEnd()
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-23 00:07:07 +08:00
|
|
|
|
// 记录每个 key 的原始优先级
|
|
|
|
|
|
const originalPriorityMap = new Map<string, number>()
|
|
|
|
|
|
keys.forEach(k => {
|
|
|
|
|
|
originalPriorityMap.set(k.id, k.internal_priority ?? 0)
|
2026-01-22 20:54:19 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-23 00:07:07 +08:00
|
|
|
|
// 重排数组:将被拖动项移到目标位置
|
|
|
|
|
|
const items = [...keys]
|
|
|
|
|
|
items.splice(draggedIndex, 1)
|
|
|
|
|
|
items.splice(targetIndex, 0, draggedKey)
|
|
|
|
|
|
|
|
|
|
|
|
// 按新顺序分配优先级:被拖动项单独成组,其他同组项保持在一起
|
|
|
|
|
|
const groupNewPriority = new Map<number, number>()
|
|
|
|
|
|
let currentPriority = 1
|
|
|
|
|
|
const newPriorityMap = new Map<string, number>()
|
|
|
|
|
|
|
|
|
|
|
|
items.forEach(key => {
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const originalPriority = originalPriorityMap.get(key.id) ?? 0
|
2026-01-23 00:07:07 +08:00
|
|
|
|
|
|
|
|
|
|
if (key === draggedKey) {
|
|
|
|
|
|
// 被拖动的项单独成组
|
|
|
|
|
|
newPriorityMap.set(key.id, currentPriority)
|
|
|
|
|
|
currentPriority++
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (groupNewPriority.has(originalPriority)) {
|
|
|
|
|
|
// 同组的其他项使用相同的新优先级
|
2026-02-22 00:43:41 +08:00
|
|
|
|
newPriorityMap.set(key.id, groupNewPriority.get(originalPriority) ?? currentPriority)
|
2026-01-23 00:07:07 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// 新组,分配新优先级
|
|
|
|
|
|
groupNewPriority.set(originalPriority, currentPriority)
|
|
|
|
|
|
newPriorityMap.set(key.id, currentPriority)
|
|
|
|
|
|
currentPriority++
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 更新所有优先级发生变化的 key
|
2026-01-22 20:54:19 +08:00
|
|
|
|
const updatePromises = keys.map(key => {
|
|
|
|
|
|
const oldPriority = key.internal_priority ?? 0
|
2026-01-23 00:07:07 +08:00
|
|
|
|
const newPriority = newPriorityMap.get(key.id)
|
2026-01-22 20:54:19 +08:00
|
|
|
|
if (newPriority !== undefined && oldPriority !== newPriority) {
|
|
|
|
|
|
return updateProviderKey(key.id, { internal_priority: newPriority })
|
|
|
|
|
|
}
|
|
|
|
|
|
return Promise.resolve()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await Promise.all(updatePromises)
|
2026-01-11 00:22:21 +08:00
|
|
|
|
showSuccess('优先级已更新')
|
|
|
|
|
|
await loadEndpoints()
|
|
|
|
|
|
emit('refresh')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '更新优先级失败'), '错误')
|
2026-01-11 00:22:21 +08:00
|
|
|
|
await loadEndpoints()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 18:43:53 +08:00
|
|
|
|
// 获取密钥的 API 格式列表(按指定顺序排序)
|
|
|
|
|
|
function getKeyApiFormats(key: EndpointAPIKey, endpoint?: ProviderEndpointWithKeys): string[] {
|
2026-04-23 14:42:51 +08:00
|
|
|
|
const providerType = provider.value?.provider_type
|
2026-01-10 18:43:53 +08:00
|
|
|
|
let formats: string[] = []
|
2026-04-23 14:42:51 +08:00
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
providerType
|
|
|
|
|
|
&& isOAuthAccountProviderType(providerType)
|
|
|
|
|
|
&& isOAuthManagedCredential(key)
|
|
|
|
|
|
) {
|
|
|
|
|
|
formats = [...availableKeyApiFormats.value]
|
|
|
|
|
|
} else if (key.api_formats && key.api_formats.length > 0) {
|
2026-01-10 18:43:53 +08:00
|
|
|
|
formats = [...key.api_formats]
|
|
|
|
|
|
} else if (endpoint) {
|
|
|
|
|
|
formats = [endpoint.api_format]
|
|
|
|
|
|
}
|
|
|
|
|
|
// 使用统一的排序函数
|
|
|
|
|
|
return sortApiFormats(formats)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取密钥在指定 API 格式下的成本倍率
|
|
|
|
|
|
function getKeyRateMultiplier(key: EndpointAPIKey, format: string): number {
|
|
|
|
|
|
if (key.rate_multipliers && key.rate_multipliers[format] !== undefined) {
|
|
|
|
|
|
return key.rate_multipliers[format]
|
|
|
|
|
|
}
|
2026-01-16 17:53:27 +08:00
|
|
|
|
return 1.0
|
2026-01-10 18:43:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 23:59:45 +08:00
|
|
|
|
// OAuth 订阅类型格式化
|
|
|
|
|
|
function formatOAuthPlanType(planType: string): string {
|
|
|
|
|
|
const labels: Record<string, string> = {
|
|
|
|
|
|
plus: 'Plus',
|
|
|
|
|
|
pro: 'Pro',
|
|
|
|
|
|
free: 'Free',
|
2026-02-06 16:37:06 +08:00
|
|
|
|
paid: 'Paid',
|
2026-02-04 23:59:45 +08:00
|
|
|
|
team: 'Team',
|
|
|
|
|
|
enterprise: 'Enterprise',
|
2026-02-06 16:37:06 +08:00
|
|
|
|
ultra: 'Ultra',
|
2026-02-04 23:59:45 +08:00
|
|
|
|
}
|
2026-02-06 16:37:06 +08:00
|
|
|
|
return labels[planType.toLowerCase()] || planType
|
2026-02-04 23:59:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Codex 剩余额度样式(基于已用百分比计算剩余)
|
|
|
|
|
|
function getQuotaRemainingClass(usedPercent: number): string {
|
|
|
|
|
|
const remaining = 100 - usedPercent
|
|
|
|
|
|
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'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Codex 剩余额度进度条颜色
|
|
|
|
|
|
function getQuotaRemainingBarColor(usedPercent: number): string {
|
|
|
|
|
|
const remaining = 100 - usedPercent
|
|
|
|
|
|
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'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 01:05:48 +08:00
|
|
|
|
// 判断是否为 Codex Team/Plus/Enterprise 账号(有 5H 限额,显示 3 列)
|
|
|
|
|
|
function isCodexTeamPlan(key: EndpointAPIKey): boolean {
|
2026-04-17 18:22:41 +08:00
|
|
|
|
const planType = key.oauth_plan_type?.toLowerCase() || getCodexQuotaDisplay(key)?.plan_type?.toLowerCase()
|
2026-02-09 01:05:48 +08:00
|
|
|
|
// Free 账号返回 false(2 列),其他所有账号返回 true(3 列)
|
|
|
|
|
|
return planType !== undefined && planType !== 'free'
|
2026-02-04 23:59:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
interface AntigravityQuotaItem {
|
|
|
|
|
|
model: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
usedPercent: number
|
|
|
|
|
|
remainingPercent: number
|
|
|
|
|
|
resetSeconds: number | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasAntigravityQuotaData(metadata: UpstreamMetadata | null | undefined): boolean {
|
|
|
|
|
|
const quotaByModel = metadata?.antigravity?.quota_by_model
|
|
|
|
|
|
return !!quotaByModel && typeof quotaByModel === 'object' && Object.keys(quotaByModel).length > 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 18:22:41 +08:00
|
|
|
|
function hasAntigravityQuotaDisplayData(key: EndpointAPIKey): boolean {
|
|
|
|
|
|
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
|
|
|
|
|
if (Array.isArray(quota?.windows) && quota.windows.length > 0) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return hasAntigravityQuotaData(key.upstream_metadata)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 01:05:48 +08:00
|
|
|
|
function formatUpdatedAt(updatedAt: number): string {
|
2026-02-06 16:37:06 +08:00
|
|
|
|
if (!updatedAt || typeof updatedAt !== 'number') return ''
|
|
|
|
|
|
const now = Math.floor(Date.now() / 1000)
|
|
|
|
|
|
const diff = now - updatedAt
|
|
|
|
|
|
if (diff <= 60) return '刚刚更新'
|
|
|
|
|
|
const minutes = Math.floor(diff / 60)
|
|
|
|
|
|
if (minutes < 60) return `${minutes}分钟前更新`
|
|
|
|
|
|
const hours = Math.floor(minutes / 60)
|
|
|
|
|
|
if (hours < 24) return `${hours}小时前更新`
|
|
|
|
|
|
const days = Math.floor(hours / 24)
|
|
|
|
|
|
return `${days}天前更新`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 01:05:48 +08:00
|
|
|
|
// 兼容旧函数名
|
|
|
|
|
|
const formatCodexUpdatedAt = formatUpdatedAt
|
|
|
|
|
|
const formatAntigravityUpdatedAt = formatUpdatedAt
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
function secondsUntilReset(resetTime: string): number | null {
|
|
|
|
|
|
if (!resetTime) return null
|
|
|
|
|
|
const ts = Date.parse(resetTime)
|
|
|
|
|
|
if (Number.isNaN(ts)) return null
|
|
|
|
|
|
const diff = Math.floor((ts - Date.now()) / 1000)
|
|
|
|
|
|
return diff > 0 ? diff : 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getAntigravityQuotaItems(metadata: UpstreamMetadata | null | undefined): AntigravityQuotaItem[] {
|
|
|
|
|
|
const quotaByModel = metadata?.antigravity?.quota_by_model
|
|
|
|
|
|
if (!quotaByModel || typeof quotaByModel !== 'object') return []
|
|
|
|
|
|
|
|
|
|
|
|
const items: AntigravityQuotaItem[] = []
|
|
|
|
|
|
for (const [model, rawInfo] of Object.entries(quotaByModel)) {
|
|
|
|
|
|
if (!model) continue
|
|
|
|
|
|
const info: Partial<AntigravityModelQuota> = rawInfo || {}
|
|
|
|
|
|
|
|
|
|
|
|
let usedPercent = Number(info.used_percent)
|
|
|
|
|
|
if (!Number.isFinite(usedPercent)) {
|
|
|
|
|
|
const remainingFraction = Number(info.remaining_fraction)
|
|
|
|
|
|
if (Number.isFinite(remainingFraction)) {
|
|
|
|
|
|
usedPercent = (1 - remainingFraction) * 100
|
|
|
|
|
|
} else {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (usedPercent < 0) usedPercent = 0
|
|
|
|
|
|
if (usedPercent > 100) usedPercent = 100
|
|
|
|
|
|
|
|
|
|
|
|
const remainingPercent = Math.max(100 - usedPercent, 0)
|
|
|
|
|
|
|
|
|
|
|
|
let resetSeconds: number | null = null
|
|
|
|
|
|
if (typeof info.reset_time === 'string' && info.reset_time.trim()) {
|
|
|
|
|
|
resetSeconds = secondsUntilReset(info.reset_time.trim())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
items.push({
|
|
|
|
|
|
model,
|
|
|
|
|
|
label: model,
|
|
|
|
|
|
usedPercent,
|
|
|
|
|
|
remainingPercent,
|
|
|
|
|
|
resetSeconds,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 按“最紧张”(已用最多)优先排序,便于快速定位额度风险;完整列表通过滚动展示
|
|
|
|
|
|
items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
|
|
|
|
|
|
return items
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 18:22:41 +08:00
|
|
|
|
function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQuotaItem[] {
|
|
|
|
|
|
const quota = getQuotaSnapshotForProvider(key, 'antigravity')
|
|
|
|
|
|
const windows = getQuotaWindowByScope(quota, 'model')
|
|
|
|
|
|
if (!quota || windows.length === 0) return []
|
|
|
|
|
|
|
|
|
|
|
|
const items = windows
|
|
|
|
|
|
.map((window) => {
|
|
|
|
|
|
const model = String(window.model || window.label || window.code || '').trim()
|
|
|
|
|
|
if (!model) return null
|
|
|
|
|
|
|
|
|
|
|
|
const usedPercent = getQuotaWindowUsedPercent(window)
|
|
|
|
|
|
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
|
|
|
|
|
if (usedPercent === undefined && remainingPercent === undefined) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const normalizedUsedPercent =
|
|
|
|
|
|
usedPercent !== undefined
|
|
|
|
|
|
? usedPercent
|
|
|
|
|
|
: Math.max(100 - (remainingPercent ?? 0), 0)
|
|
|
|
|
|
const normalizedRemainingPercent =
|
|
|
|
|
|
remainingPercent !== undefined
|
|
|
|
|
|
? remainingPercent
|
|
|
|
|
|
: Math.max(100 - normalizedUsedPercent, 0)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
model,
|
|
|
|
|
|
label: String(window.label || window.model || model),
|
|
|
|
|
|
usedPercent: normalizedUsedPercent,
|
|
|
|
|
|
remainingPercent: normalizedRemainingPercent,
|
|
|
|
|
|
resetSeconds: getQuotaWindowLiveResetSeconds(quota, window),
|
|
|
|
|
|
} satisfies AntigravityQuotaItem
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter((item): item is AntigravityQuotaItem => item !== null)
|
|
|
|
|
|
|
|
|
|
|
|
items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model))
|
|
|
|
|
|
return items
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
// Antigravity 配额分组定义(按匹配优先级排列,具体规则在前)
|
|
|
|
|
|
interface AntigravityQuotaGroup {
|
|
|
|
|
|
key: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
match: (model: string) => boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroup[] = [
|
2026-02-24 02:44:15 +08:00
|
|
|
|
{ key: 'claude', label: 'Claude', match: m => m.includes('claude') },
|
|
|
|
|
|
{ key: 'gemini-2.5', label: 'Gemini 2.5', match: m => m.includes('gemini-2.5') || m.includes('gemini-2-5') },
|
|
|
|
|
|
{ key: 'gemini-3', label: 'Gemini 3', match: m => m.includes('gemini-3') && !m.includes('image') },
|
|
|
|
|
|
{ key: 'gemini-3-image', label: 'Gemini 3 Image', match: m => m.includes('gemini-3') && m.includes('image') },
|
2026-02-06 16:37:06 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
interface AntigravityQuotaSummaryItem {
|
|
|
|
|
|
key: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
usedPercent: number // 组内最高已用百分比(最紧张)
|
|
|
|
|
|
remainingPercent: number // 100 - usedPercent
|
|
|
|
|
|
resetSeconds: number | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getAntigravityQuotaSummary(metadata: UpstreamMetadata | null | undefined): AntigravityQuotaSummaryItem[] {
|
|
|
|
|
|
const items = getAntigravityQuotaItems(metadata)
|
|
|
|
|
|
if (!items.length) return []
|
|
|
|
|
|
|
|
|
|
|
|
// 将每个模型归入分组
|
|
|
|
|
|
const groupMap = new Map<string, { label: string, maxUsed: number, resetSeconds: number | null }>()
|
|
|
|
|
|
|
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
|
const model = item.model.toLowerCase()
|
|
|
|
|
|
const group = ANTIGRAVITY_QUOTA_GROUPS.find(g => g.match(model))
|
|
|
|
|
|
if (!group) continue
|
|
|
|
|
|
|
|
|
|
|
|
const existing = groupMap.get(group.key)
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
|
groupMap.set(group.key, {
|
|
|
|
|
|
label: group.label,
|
|
|
|
|
|
maxUsed: item.usedPercent,
|
|
|
|
|
|
resetSeconds: item.resetSeconds,
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (item.usedPercent > existing.maxUsed) {
|
|
|
|
|
|
existing.maxUsed = item.usedPercent
|
|
|
|
|
|
}
|
|
|
|
|
|
if (existing.resetSeconds === null) {
|
|
|
|
|
|
existing.resetSeconds = item.resetSeconds
|
|
|
|
|
|
} else if (item.resetSeconds !== null && item.resetSeconds < existing.resetSeconds) {
|
|
|
|
|
|
existing.resetSeconds = item.resetSeconds
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 按 ANTIGRAVITY_QUOTA_GROUPS 定义的顺序输出
|
|
|
|
|
|
const result: AntigravityQuotaSummaryItem[] = []
|
|
|
|
|
|
for (const group of ANTIGRAVITY_QUOTA_GROUPS) {
|
|
|
|
|
|
const data = groupMap.get(group.key)
|
|
|
|
|
|
if (!data) continue
|
|
|
|
|
|
result.push({
|
|
|
|
|
|
key: group.key,
|
|
|
|
|
|
label: data.label,
|
|
|
|
|
|
usedPercent: data.maxUsed,
|
|
|
|
|
|
remainingPercent: Math.max(100 - data.maxUsed, 0),
|
|
|
|
|
|
resetSeconds: data.resetSeconds,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 18:22:41 +08:00
|
|
|
|
function getAntigravityQuotaSummaryForKey(key: EndpointAPIKey): AntigravityQuotaSummaryItem[] {
|
|
|
|
|
|
const snapshotItems = getAntigravityQuotaItemsFromSnapshot(key)
|
|
|
|
|
|
if (snapshotItems.length > 0) {
|
|
|
|
|
|
const groupMap = new Map<string, { label: string, maxUsed: number, resetSeconds: number | null }>()
|
|
|
|
|
|
|
|
|
|
|
|
for (const item of snapshotItems) {
|
|
|
|
|
|
const model = item.model.toLowerCase()
|
|
|
|
|
|
const group = ANTIGRAVITY_QUOTA_GROUPS.find(g => g.match(model))
|
|
|
|
|
|
if (!group) continue
|
|
|
|
|
|
|
|
|
|
|
|
const existing = groupMap.get(group.key)
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
|
groupMap.set(group.key, {
|
|
|
|
|
|
label: group.label,
|
|
|
|
|
|
maxUsed: item.usedPercent,
|
|
|
|
|
|
resetSeconds: item.resetSeconds,
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (item.usedPercent > existing.maxUsed) {
|
|
|
|
|
|
existing.maxUsed = item.usedPercent
|
|
|
|
|
|
}
|
|
|
|
|
|
if (existing.resetSeconds === null) {
|
|
|
|
|
|
existing.resetSeconds = item.resetSeconds
|
|
|
|
|
|
} else if (item.resetSeconds !== null && item.resetSeconds < existing.resetSeconds) {
|
|
|
|
|
|
existing.resetSeconds = item.resetSeconds
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result: AntigravityQuotaSummaryItem[] = []
|
|
|
|
|
|
for (const group of ANTIGRAVITY_QUOTA_GROUPS) {
|
|
|
|
|
|
const data = groupMap.get(group.key)
|
|
|
|
|
|
if (!data) continue
|
|
|
|
|
|
result.push({
|
|
|
|
|
|
key: group.key,
|
|
|
|
|
|
label: data.label,
|
|
|
|
|
|
usedPercent: data.maxUsed,
|
|
|
|
|
|
remainingPercent: Math.max(100 - data.maxUsed, 0),
|
|
|
|
|
|
resetSeconds: data.resetSeconds,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return getAntigravityQuotaSummary(key.upstream_metadata)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 11:59:07 +08:00
|
|
|
|
function getResetCountdownText(
|
|
|
|
|
|
resetAt: number | null | undefined,
|
|
|
|
|
|
resetSecs: number | null | undefined,
|
2026-03-18 23:38:26 +08:00
|
|
|
|
updatedAt: number | null | undefined,
|
|
|
|
|
|
usedPercent: number | null | undefined
|
2026-03-14 11:59:07 +08:00
|
|
|
|
): string {
|
2026-03-18 23:38:26 +08:00
|
|
|
|
const status = getCodexResetCountdown(
|
|
|
|
|
|
resetAt,
|
|
|
|
|
|
resetSecs,
|
|
|
|
|
|
updatedAt,
|
|
|
|
|
|
countdownTick.value,
|
|
|
|
|
|
toCodexRemainingPercent(usedPercent)
|
|
|
|
|
|
)
|
2026-03-14 11:59:07 +08:00
|
|
|
|
if (!status) return ''
|
|
|
|
|
|
return status.isExpired ? status.text : `${status.text} 后重置`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getResetCountdownClass(
|
|
|
|
|
|
resetAt: number | null | undefined,
|
|
|
|
|
|
resetSecs: number | null | undefined,
|
2026-03-18 23:38:26 +08:00
|
|
|
|
updatedAt: number | null | undefined,
|
|
|
|
|
|
usedPercent: number | null | undefined
|
2026-03-14 11:59:07 +08:00
|
|
|
|
): string {
|
2026-03-18 23:38:26 +08:00
|
|
|
|
const status = getCodexResetCountdown(
|
|
|
|
|
|
resetAt,
|
|
|
|
|
|
resetSecs,
|
|
|
|
|
|
updatedAt,
|
|
|
|
|
|
countdownTick.value,
|
|
|
|
|
|
toCodexRemainingPercent(usedPercent)
|
|
|
|
|
|
)
|
2026-03-14 11:59:07 +08:00
|
|
|
|
if (!status || status.isExpired) return 'text-muted-foreground/70'
|
|
|
|
|
|
if (status.isCritical) return 'text-destructive font-medium animate-pulse'
|
|
|
|
|
|
if (status.isUrgent) return 'text-amber-500 dark:text-amber-400'
|
|
|
|
|
|
return 'text-muted-foreground/70'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 23:38:26 +08:00
|
|
|
|
function toCodexRemainingPercent(usedPercent: number | null | undefined): number | null {
|
|
|
|
|
|
const normalizedUsed = Number(usedPercent)
|
|
|
|
|
|
if (!Number.isFinite(normalizedUsed)) return null
|
|
|
|
|
|
const clampedUsed = Math.min(Math.max(normalizedUsed, 0), 100)
|
|
|
|
|
|
return Math.max(100 - clampedUsed, 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function shouldStartCodexResetCountdown(usedPercent: number | null | undefined): boolean {
|
|
|
|
|
|
const remainingPercent = toCodexRemainingPercent(usedPercent)
|
|
|
|
|
|
if (remainingPercent == null) return true
|
|
|
|
|
|
return remainingPercent < 100
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 23:59:45 +08:00
|
|
|
|
// 格式化重置时间
|
|
|
|
|
|
function formatResetTime(seconds: number): string {
|
|
|
|
|
|
const days = Math.floor(seconds / 86400)
|
|
|
|
|
|
const hours = Math.floor((seconds % 86400) / 3600)
|
|
|
|
|
|
const minutes = Math.floor((seconds % 3600) / 60)
|
|
|
|
|
|
|
|
|
|
|
|
if (days > 0) {
|
|
|
|
|
|
return `${days}天 ${hours}小时`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hours > 0) {
|
|
|
|
|
|
return `${hours}小时 ${minutes}分钟`
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${minutes}分钟`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OAuth 订阅类型样式
|
|
|
|
|
|
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',
|
2026-02-06 16:37:06 +08:00
|
|
|
|
paid: 'border-blue-500/50 text-blue-600 dark:text-blue-400',
|
2026-02-04 23:59:45 +08:00
|
|
|
|
team: 'border-purple-500/50 text-purple-600 dark:text-purple-400',
|
|
|
|
|
|
enterprise: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
|
2026-02-06 16:37:06 +08:00
|
|
|
|
ultra: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
|
2026-02-09 01:05:48 +08:00
|
|
|
|
'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-02-04 23:59:45 +08:00
|
|
|
|
}
|
2026-02-06 16:37:06 +08:00
|
|
|
|
return classes[planType.toLowerCase()] || ''
|
2026-02-04 23:59:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OAuth 状态信息(包括失效和过期)
|
|
|
|
|
|
function getKeyOAuthExpires(key: EndpointAPIKey) {
|
2026-05-01 02:14:49 +08:00
|
|
|
|
return getOAuthStatusDisplayWithFallback(key, countdownTick.value)
|
2026-03-20 19:16:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getOAuthRefreshButtonTitle(key: EndpointAPIKey): string {
|
|
|
|
|
|
return resolveOAuthRefreshButtonTitle(key, countdownTick.value)
|
2026-02-04 23:59:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OAuth 状态的 title 提示
|
|
|
|
|
|
function getOAuthStatusTitle(key: EndpointAPIKey): string {
|
2026-03-20 19:16:52 +08:00
|
|
|
|
const accountTitle = getAccountStatusTitle(key)
|
|
|
|
|
|
if (accountTitle && isAccountLevelBlock(key)) {
|
|
|
|
|
|
return accountTitle
|
2026-02-04 23:59:45 +08:00
|
|
|
|
}
|
2026-03-20 19:16:52 +08:00
|
|
|
|
return resolveOAuthStatusTitle(key, countdownTick.value)
|
2026-02-04 23:59:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 健康度颜色
|
|
|
|
|
|
function getHealthScoreColor(score: number): string {
|
|
|
|
|
|
if (score >= 0.8) return 'text-green-600 dark:text-green-400'
|
|
|
|
|
|
if (score >= 0.5) return 'text-yellow-600 dark:text-yellow-400'
|
|
|
|
|
|
return 'text-red-600 dark:text-red-400'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getHealthScoreBarColor(score: number): string {
|
|
|
|
|
|
if (score >= 0.8) return 'bg-green-500 dark:bg-green-400'
|
|
|
|
|
|
if (score >= 0.5) return 'bg-yellow-500 dark:bg-yellow-400'
|
|
|
|
|
|
return 'bg-red-500 dark:bg-red-400'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 13:12:19 +08:00
|
|
|
|
// 获取自动获取模型状态的 title 提示
|
|
|
|
|
|
function getAutoFetchStatusTitle(key: EndpointAPIKey): string {
|
|
|
|
|
|
const parts: string[] = ['自动获取模型已启用']
|
|
|
|
|
|
|
|
|
|
|
|
if (key.last_models_fetch_at) {
|
|
|
|
|
|
const date = new Date(key.last_models_fetch_at)
|
|
|
|
|
|
parts.push(`上次同步: ${date.toLocaleString()}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (key.last_models_fetch_error) {
|
|
|
|
|
|
parts.push(`错误: ${key.last_models_fetch_error}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return parts.join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 18:42:15 +08:00
|
|
|
|
// 检查指定格式是否熔断
|
|
|
|
|
|
function isFormatCircuitOpen(key: EndpointAPIKey, format: string): boolean {
|
|
|
|
|
|
if (!key.circuit_breaker_by_format) return false
|
|
|
|
|
|
const formatData = key.circuit_breaker_by_format[format]
|
|
|
|
|
|
return formatData?.open === true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取指定格式的探测倒计时(如果熔断,返回带空格前缀的倒计时文本)
|
|
|
|
|
|
function getFormatProbeCountdown(key: EndpointAPIKey, format: string): string {
|
|
|
|
|
|
// 触发响应式更新
|
|
|
|
|
|
void countdownTick.value
|
|
|
|
|
|
|
|
|
|
|
|
if (!key.circuit_breaker_by_format) return ''
|
|
|
|
|
|
const formatData = key.circuit_breaker_by_format[format]
|
|
|
|
|
|
if (!formatData?.open) return ''
|
|
|
|
|
|
|
|
|
|
|
|
// 半开状态
|
|
|
|
|
|
if (formatData.half_open_until) {
|
|
|
|
|
|
const halfOpenUntil = new Date(formatData.half_open_until)
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
if (halfOpenUntil > now) {
|
|
|
|
|
|
return ' 探测中'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 等待探测
|
|
|
|
|
|
if (formatData.next_probe_at) {
|
|
|
|
|
|
const nextProbe = new Date(formatData.next_probe_at)
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
const diffMs = nextProbe.getTime() - now.getTime()
|
|
|
|
|
|
if (diffMs > 0) {
|
2026-01-15 17:03:19 +08:00
|
|
|
|
return ` ${formatCountdown(diffMs)}`
|
2026-01-13 18:42:15 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
return ' 探测中'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 02:04:54 +08:00
|
|
|
|
// 加载系统级格式转换配置
|
|
|
|
|
|
async function loadSystemFormatConversionConfig() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await adminApi.getSystemConfig('enable_format_conversion')
|
|
|
|
|
|
systemFormatConversionEnabled.value = result.value === true
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 获取失败时默认为关闭
|
|
|
|
|
|
systemFormatConversionEnabled.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 加载 Provider 信息
|
|
|
|
|
|
async function loadProvider() {
|
|
|
|
|
|
if (!props.providerId) return
|
2026-03-03 22:04:40 +08:00
|
|
|
|
const requestId = ++providerLoadRequestId
|
2026-03-31 19:19:04 +08:00
|
|
|
|
const shouldShowSpinner = !provider.value || provider.value.id !== props.providerId
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-31 19:19:04 +08:00
|
|
|
|
if (shouldShowSpinner) {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
// 系统级格式转换配置只影响一个图标状态,不应阻塞详情抽屉首屏。
|
|
|
|
|
|
void loadSystemFormatConversionConfig()
|
|
|
|
|
|
const providerData = await getProvider(props.providerId)
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (requestId !== providerLoadRequestId) return
|
2026-02-04 02:04:54 +08:00
|
|
|
|
provider.value = providerData
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
if (!provider.value) {
|
|
|
|
|
|
throw new Error('Provider 不存在')
|
|
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (requestId !== providerLoadRequestId) return
|
2026-02-22 00:43:41 +08:00
|
|
|
|
showError(parseApiError(err, '加载失败'), '错误')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} finally {
|
2026-03-31 19:19:04 +08:00
|
|
|
|
if (requestId === providerLoadRequestId && shouldShowSpinner) {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 12:52:49 +08:00
|
|
|
|
async function loadProviderKeysPage(page = currentKeyPage.value) {
|
|
|
|
|
|
if (!props.providerId) return
|
|
|
|
|
|
const providerId = props.providerId
|
|
|
|
|
|
const requestId = ++keysLoadRequestId
|
|
|
|
|
|
loadingProviderKeys.value = true
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await getProviderKeysPage(providerId, {
|
|
|
|
|
|
page,
|
|
|
|
|
|
page_size: keyPageSize.value,
|
|
|
|
|
|
})
|
|
|
|
|
|
if (requestId !== keysLoadRequestId || props.providerId !== providerId) return
|
|
|
|
|
|
|
|
|
|
|
|
const nextTotalPages = Math.max(1, Math.ceil(result.total / result.page_size))
|
|
|
|
|
|
if (result.keys.length === 0 && result.total > 0 && result.page > nextTotalPages) {
|
|
|
|
|
|
await loadProviderKeysPage(nextTotalPages)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
providerKeys.value = result.keys
|
|
|
|
|
|
providerKeysTotal.value = result.total
|
|
|
|
|
|
currentKeyPage.value = Math.min(result.page, nextTotalPages)
|
|
|
|
|
|
keyPageSize.value = result.page_size
|
|
|
|
|
|
syncCurrentSelections(endpoints.value, result.keys)
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
if (requestId !== keysLoadRequestId || props.providerId !== providerId) return
|
|
|
|
|
|
providerKeys.value = []
|
|
|
|
|
|
providerKeysTotal.value = 0
|
|
|
|
|
|
syncCurrentSelections(endpoints.value, [])
|
|
|
|
|
|
showError(parseApiError(err, '加载密钥失败'), '错误')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (requestId === keysLoadRequestId) {
|
|
|
|
|
|
loadingProviderKeys.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 加载端点列表
|
|
|
|
|
|
async function loadEndpoints() {
|
|
|
|
|
|
if (!props.providerId) return
|
2026-03-31 19:19:04 +08:00
|
|
|
|
const providerId = props.providerId
|
2026-03-03 22:04:40 +08:00
|
|
|
|
const requestId = ++endpointsLoadRequestId
|
2026-03-31 19:19:04 +08:00
|
|
|
|
loadingProviderEndpoints.value = true
|
|
|
|
|
|
loadingProviderKeys.value = true
|
|
|
|
|
|
loadingProviderModels.value = true
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
const sortEndpoints = (items: ProviderEndpoint[]): ProviderEndpointWithKeys[] => {
|
|
|
|
|
|
return [...items].sort((a, b) => {
|
2026-01-10 18:43:53 +08:00
|
|
|
|
const aIdx = API_FORMAT_ORDER.indexOf(a.api_format)
|
|
|
|
|
|
const bIdx = API_FORMAT_ORDER.indexOf(b.api_format)
|
|
|
|
|
|
if (aIdx === -1 && bIdx === -1) return 0
|
|
|
|
|
|
if (aIdx === -1) return 1
|
|
|
|
|
|
if (bIdx === -1) return -1
|
|
|
|
|
|
return aIdx - bIdx
|
|
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
2026-03-31 19:19:04 +08:00
|
|
|
|
|
|
|
|
|
|
const endpointsPromise = getProviderEndpoints(providerId)
|
|
|
|
|
|
.then((endpointsList) => {
|
|
|
|
|
|
if (requestId !== endpointsLoadRequestId) return
|
|
|
|
|
|
const sortedEndpoints = sortEndpoints(endpointsList)
|
|
|
|
|
|
endpoints.value = sortedEndpoints
|
|
|
|
|
|
syncCurrentSelections(sortedEndpoints, providerKeys.value)
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((err: unknown) => {
|
|
|
|
|
|
if (requestId !== endpointsLoadRequestId) return
|
|
|
|
|
|
endpoints.value = []
|
|
|
|
|
|
syncCurrentSelections([], providerKeys.value)
|
|
|
|
|
|
showError(parseApiError(err, '加载端点失败'), '错误')
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => {
|
|
|
|
|
|
if (requestId === endpointsLoadRequestId) {
|
|
|
|
|
|
loadingProviderEndpoints.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-16 12:52:49 +08:00
|
|
|
|
const providerKeysPromise = loadProviderKeysPage(currentKeyPage.value)
|
2026-03-31 19:19:04 +08:00
|
|
|
|
|
|
|
|
|
|
const modelsPromise = getProviderModels(providerId)
|
|
|
|
|
|
.catch(() => [])
|
|
|
|
|
|
.then((modelsResult) => {
|
|
|
|
|
|
if (requestId !== endpointsLoadRequestId) return
|
|
|
|
|
|
providerModels.value = modelsResult
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => {
|
|
|
|
|
|
if (requestId === endpointsLoadRequestId) {
|
|
|
|
|
|
loadingProviderModels.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await Promise.allSettled([endpointsPromise, providerKeysPromise, modelsPromise])
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
2025-12-19 17:31:15 +08:00
|
|
|
|
|
2026-03-05 19:04:21 +08:00
|
|
|
|
// 加载映射预览(独立于 loadEndpoints,不阻塞首屏渲染)
|
|
|
|
|
|
async function loadMappingPreview() {
|
|
|
|
|
|
if (!props.providerId) return
|
2026-03-11 09:58:25 +08:00
|
|
|
|
const requestId = ++mappingPreviewLoadRequestId
|
2026-03-31 19:19:04 +08:00
|
|
|
|
loadingProviderMappingPreview.value = true
|
2026-03-05 19:04:21 +08:00
|
|
|
|
try {
|
2026-03-11 09:58:25 +08:00
|
|
|
|
const preview = await getProviderMappingPreview(props.providerId)
|
|
|
|
|
|
if (requestId !== mappingPreviewLoadRequestId) return
|
|
|
|
|
|
providerMappingPreview.value = preview
|
2026-03-05 19:04:21 +08:00
|
|
|
|
} catch {
|
2026-03-11 09:58:25 +08:00
|
|
|
|
if (requestId !== mappingPreviewLoadRequestId) return
|
2026-03-05 19:04:21 +08:00
|
|
|
|
providerMappingPreview.value = null
|
2026-03-31 19:19:04 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
if (requestId === mappingPreviewLoadRequestId) {
|
|
|
|
|
|
loadingProviderMappingPreview.value = false
|
|
|
|
|
|
}
|
2026-03-05 19:04:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 17:31:15 +08:00
|
|
|
|
// 添加 ESC 键监听
|
|
|
|
|
|
useEscapeKey(() => {
|
|
|
|
|
|
if (props.open) {
|
|
|
|
|
|
handleClose()
|
|
|
|
|
|
}
|
|
|
|
|
|
}, {
|
|
|
|
|
|
disableOnInput: true,
|
|
|
|
|
|
once: false
|
|
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
/* 抽屉过渡动画 */
|
|
|
|
|
|
.drawer-enter-active,
|
|
|
|
|
|
.drawer-leave-active {
|
|
|
|
|
|
transition: opacity 0.3s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drawer-enter-active .relative,
|
|
|
|
|
|
.drawer-leave-active .relative {
|
|
|
|
|
|
transition: transform 0.3s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drawer-enter-from,
|
|
|
|
|
|
.drawer-leave-to {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drawer-enter-from .relative {
|
|
|
|
|
|
transform: translateX(100%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drawer-leave-to .relative {
|
|
|
|
|
|
transform: translateX(100%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drawer-enter-to .relative,
|
|
|
|
|
|
.drawer-leave-from .relative {
|
|
|
|
|
|
transform: translateX(0);
|
|
|
|
|
|
}
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
|
|
|
|
|
/* 轻量滚动条(用于 Antigravity 模型配额等小区域) */
|
|
|
|
|
|
.custom-scrollbar::-webkit-scrollbar {
|
|
|
|
|
|
width: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.custom-scrollbar::-webkit-scrollbar-track {
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
|
|
|
|
background-color: hsl(var(--muted-foreground) / 0.2);
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
|
|
|
|
background-color: hsl(var(--muted-foreground) / 0.4);
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</style>
|