2025-12-10 20:52:44 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
:model-value="internalOpen"
|
|
|
|
|
|
title="优先级管理"
|
2026-02-09 18:21:34 +08:00
|
|
|
|
description="拖拽调整顺序,点击序号可编辑(相同数字为同级),保存后自动切换对应的调度策略"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:icon="ListOrdered"
|
2026-01-21 01:10:35 +08:00
|
|
|
|
size="2xl"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@update:model-value="handleDialogUpdate"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="space-y-4">
|
|
|
|
|
|
<!-- 主 Tab 切换 -->
|
|
|
|
|
|
<div class="flex gap-1 p-1 bg-muted/40 rounded-lg">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:class="[
|
|
|
|
|
|
activeMainTab === 'provider'
|
|
|
|
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
|
|
|
|
|
]"
|
|
|
|
|
|
@click="activeMainTab = 'provider'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Layers class="w-4 h-4" />
|
|
|
|
|
|
<span>提供商优先</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:class="[
|
|
|
|
|
|
activeMainTab === 'key'
|
|
|
|
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
|
|
|
|
|
]"
|
|
|
|
|
|
@click="activeMainTab = 'key'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Key class="w-4 h-4" />
|
|
|
|
|
|
<span>Key 优先</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<!-- 内容区域:固定高度,切换 tab 时不跳动 -->
|
|
|
|
|
|
<div class="h-[min(65vh,520px)]">
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 提供商优先级 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-show="activeMainTab === 'provider'"
|
2026-02-09 18:21:34 +08:00
|
|
|
|
class="h-full"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 空状态 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="sortedProviders.length === 0"
|
|
|
|
|
|
class="flex flex-col items-center justify-center py-20 text-muted-foreground"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Layers class="w-10 h-10 mb-3 opacity-20" />
|
|
|
|
|
|
<span class="text-sm">暂无提供商</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 提供商列表 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
2026-02-10 18:32:51 +08:00
|
|
|
|
class="space-y-0.5 h-full overflow-y-auto pr-1"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-for="(provider, index) in sortedProviders"
|
|
|
|
|
|
:key="provider.id"
|
2026-01-21 01:10:35 +08:00
|
|
|
|
class="group flex items-center gap-3 px-3 py-1.5 rounded-lg border transition-all duration-200"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:class="[
|
|
|
|
|
|
draggedProvider === index
|
|
|
|
|
|
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
|
|
|
|
|
|
: dragOverProvider === index
|
|
|
|
|
|
? 'border-primary/30 bg-primary/5'
|
|
|
|
|
|
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
|
|
|
|
|
|
]"
|
|
|
|
|
|
draggable="true"
|
|
|
|
|
|
@dragstart="handleProviderDragStart(index, $event)"
|
|
|
|
|
|
@dragend="handleProviderDragEnd"
|
|
|
|
|
|
@dragover.prevent="handleProviderDragOver(index)"
|
|
|
|
|
|
@dragleave="handleProviderDragLeave"
|
|
|
|
|
|
@drop="handleProviderDrop(index)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 拖拽手柄 -->
|
|
|
|
|
|
<div class="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted text-muted-foreground/40 group-hover:text-muted-foreground transition-colors">
|
|
|
|
|
|
<GripVertical class="w-4 h-4" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-15 18:59:01 +08:00
|
|
|
|
<!-- 可编辑序号 -->
|
|
|
|
|
|
<div class="shrink-0">
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-if="editingProviderPriority === provider.id"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="1"
|
|
|
|
|
|
:value="provider.provider_priority"
|
|
|
|
|
|
class="w-8 h-6 rounded-md bg-background border border-primary text-xs font-medium text-center focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
|
|
|
|
autofocus
|
|
|
|
|
|
@blur="finishEditProviderPriority(provider, $event)"
|
|
|
|
|
|
@keydown.enter="($event.target as HTMLInputElement).blur()"
|
|
|
|
|
|
@keydown.escape="cancelEditProviderPriority()"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="w-6 h-6 rounded-md bg-muted/50 flex items-center justify-center text-xs font-medium text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary transition-colors"
|
2026-02-09 18:21:34 +08:00
|
|
|
|
title="点击编辑优先级,相同数字为同级"
|
2026-01-15 18:59:01 +08:00
|
|
|
|
@click.stop="startEditProviderPriority(provider)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ provider.provider_priority }}
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 提供商信息 -->
|
|
|
|
|
|
<div class="flex-1 min-w-0 flex items-center gap-2">
|
2026-01-10 18:43:53 +08:00
|
|
|
|
<span class="font-medium text-sm truncate">{{ provider.name }}</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="!provider.is_active"
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
class="text-[10px] px-1.5 h-5 shrink-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
停用
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
2026-01-21 01:10:35 +08:00
|
|
|
|
<div class="flex items-center gap-3 shrink-0 ml-2">
|
|
|
|
|
|
<!-- API 格式标签 (自适应宽度) -->
|
|
|
|
|
|
<div class="flex items-center justify-end gap-1">
|
|
|
|
|
|
<template v-if="provider.api_formats?.length">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-for="fmt in provider.api_formats"
|
|
|
|
|
|
:key="fmt"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground whitespace-nowrap"
|
|
|
|
|
|
>
|
2026-04-26 22:09:16 +08:00
|
|
|
|
{{ formatApiFormatShort(fmt) }}
|
2026-01-21 01:10:35 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 余额显示 (表格对齐) -->
|
|
|
|
|
|
<div class="min-w-[4rem] text-right">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="formatBalanceDisplay(provider.id)"
|
|
|
|
|
|
class="text-xs font-semibold text-foreground/90 tabular-nums"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatBalanceDisplay(provider.id) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-01-19 19:44:45 +08:00
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Key 优先级 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-show="activeMainTab === 'key'"
|
2026-02-09 18:21:34 +08:00
|
|
|
|
class="h-full"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 加载状态 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="loadingKeys"
|
|
|
|
|
|
class="flex items-center justify-center py-20"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div class="flex flex-col items-center gap-2">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div class="animate-spin rounded-full h-5 w-5 border-2 border-muted border-t-primary" />
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<span class="text-xs text-muted-foreground">加载中...</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 空状态 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="availableFormats.length === 0"
|
|
|
|
|
|
class="flex flex-col items-center justify-center py-20 text-muted-foreground"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Key class="w-10 h-10 mb-3 opacity-20" />
|
|
|
|
|
|
<span class="text-sm">暂无 API Key</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 左右布局:格式列表 + Key 列表 -->
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
2026-02-10 18:32:51 +08:00
|
|
|
|
class="flex gap-0 h-full"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2026-03-04 12:59:27 +08:00
|
|
|
|
<!-- 左侧:API 格式列表(按 family 分组) -->
|
|
|
|
|
|
<div class="w-36 shrink-0 overflow-y-auto border-r border-border/50 pr-3 mr-3 py-0.5">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(group, gi) in groupedFormats"
|
|
|
|
|
|
:key="group.family"
|
|
|
|
|
|
:class="gi > 0 ? 'mt-3' : ''"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
2026-03-04 12:59:27 +08:00
|
|
|
|
<div class="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
|
|
|
|
|
{{ group.label }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-0.5">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="format in group.formats"
|
|
|
|
|
|
:key="format"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="w-full px-3 py-1.5 text-xs font-medium rounded-lg text-left transition-all duration-200"
|
|
|
|
|
|
:class="[
|
|
|
|
|
|
activeFormatTab === format
|
|
|
|
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
|
|
|
|
]"
|
|
|
|
|
|
@click="activeFormatTab = format"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatKind(format) }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 右侧:Key 列表 -->
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<div class="flex-1 min-w-0 h-full overflow-hidden">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-for="format in availableFormats"
|
|
|
|
|
|
v-show="activeFormatTab === format"
|
|
|
|
|
|
:key="format"
|
2026-02-10 18:32:51 +08:00
|
|
|
|
class="h-full"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<div
|
2026-03-04 12:59:27 +08:00
|
|
|
|
v-if="displayKeysByFormat[format]?.length > 0"
|
2026-02-10 18:32:51 +08:00
|
|
|
|
class="space-y-0.5 h-full overflow-y-auto pr-1"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
2026-03-04 12:59:27 +08:00
|
|
|
|
v-for="key in displayKeysByFormat[format]"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
:key="key.id"
|
2026-02-10 18:32:51 +08:00
|
|
|
|
class="group flex items-center gap-2 px-2.5 py-1.5 rounded-lg border transition-all duration-200"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
:class="[
|
2026-02-09 18:21:34 +08:00
|
|
|
|
!(key.is_active && key.provider_active)
|
|
|
|
|
|
? 'border-border/30 bg-muted/20 opacity-50'
|
2026-03-04 12:59:27 +08:00
|
|
|
|
: draggedKey[format] === key.id
|
2026-02-09 18:21:34 +08:00
|
|
|
|
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
|
2026-03-04 12:59:27 +08:00
|
|
|
|
: dragOverKey[format] === key.id
|
2026-02-09 18:21:34 +08:00
|
|
|
|
? 'border-primary/30 bg-primary/5'
|
|
|
|
|
|
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
|
2025-12-12 16:15:36 +08:00
|
|
|
|
]"
|
2026-03-12 11:44:19 +08:00
|
|
|
|
:draggable="key.is_active && key.provider_active"
|
|
|
|
|
|
@dragstart="(key.is_active && key.provider_active) && handleKeyDragStart(format, key.id, $event)"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
@dragend="handleKeyDragEnd(format)"
|
2026-03-12 11:44:19 +08:00
|
|
|
|
@dragover.prevent="handleKeyDragOver(format, key.id)"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
@dragleave="handleKeyDragLeave(format)"
|
2026-03-12 11:44:19 +08:00
|
|
|
|
@drop="handleKeyDrop(format, key.id)"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
|
|
|
|
|
<!-- 拖拽手柄 -->
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="p-0.5 rounded transition-colors shrink-0"
|
2026-03-12 11:44:19 +08:00
|
|
|
|
:class="(key.is_active && key.provider_active)
|
2026-02-09 18:21:34 +08:00
|
|
|
|
? 'cursor-grab active:cursor-grabbing text-muted-foreground/30 group-hover:text-muted-foreground'
|
|
|
|
|
|
: 'text-muted-foreground/15 cursor-default'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<GripVertical class="w-3.5 h-3.5" />
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<!-- 可编辑序号 -->
|
|
|
|
|
|
<div class="shrink-0">
|
|
|
|
|
|
<input
|
2026-03-12 11:44:19 +08:00
|
|
|
|
v-if="editingKeyPriority[format] === key.id"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
type="number"
|
|
|
|
|
|
min="1"
|
|
|
|
|
|
:value="key.priority"
|
2026-02-09 18:21:34 +08:00
|
|
|
|
class="w-7 h-5 rounded bg-background border border-primary text-[11px] font-medium text-center focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
autofocus
|
|
|
|
|
|
@blur="finishEditKeyPriority(format, key, $event)"
|
|
|
|
|
|
@keydown.enter="($event.target as HTMLInputElement).blur()"
|
|
|
|
|
|
@keydown.escape="cancelEditKeyPriority(format)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
2026-03-12 11:44:19 +08:00
|
|
|
|
class="w-5 h-5 rounded bg-muted/50 flex items-center justify-center text-[11px] font-medium transition-colors text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary"
|
|
|
|
|
|
title="点击编辑优先级"
|
|
|
|
|
|
@click.stop="startEditKeyPriority(format, key)"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
|
|
|
|
|
{{ key.priority }}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<!-- Key 信息(两行) -->
|
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
|
<!-- 第一行:Key 名称 + Key 级别状态 -->
|
|
|
|
|
|
<div class="flex items-center gap-1.5">
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="text-sm font-medium truncate"
|
|
|
|
|
|
:class="!(key.is_active && key.provider_active) ? 'text-muted-foreground' : ''"
|
|
|
|
|
|
>{{ key.name }}</span>
|
|
|
|
|
|
<Badge
|
2026-03-04 12:59:27 +08:00
|
|
|
|
v-if="key.is_pool_aggregate"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="text-[9px] h-4 px-1 shrink-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
号池
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-else-if="key.circuit_breaker_open"
|
2026-02-09 18:21:34 +08:00
|
|
|
|
variant="destructive"
|
|
|
|
|
|
class="text-[9px] h-4 px-1 shrink-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
熔断
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-else-if="!key.is_active && key.provider_active"
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
class="text-[9px] h-4 px-1 shrink-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
停用
|
|
|
|
|
|
</Badge>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<!-- 第二行:密钥脱敏 · Provider 名称 + Provider 级别状态 -->
|
|
|
|
|
|
<div class="flex items-center gap-0 mt-0.5">
|
2026-03-04 12:59:27 +08:00
|
|
|
|
<template v-if="key.is_pool_aggregate">
|
|
|
|
|
|
<span class="text-[10px] text-muted-foreground/70 truncate">
|
|
|
|
|
|
号池: {{ key.pool_active_key_count ?? 0 }}/{{ key.pool_key_count ?? 0 }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<template v-if="key.provider_type">
|
|
|
|
|
|
<span class="text-[10px] text-muted-foreground/40 mx-1">·</span>
|
|
|
|
|
|
<span class="text-[10px] text-muted-foreground shrink-0">{{ formatProviderType(key.provider_type) }}</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<span class="font-mono text-[10px] text-muted-foreground/50 truncate">{{ key.api_key_masked }}</span>
|
|
|
|
|
|
<span class="text-[10px] text-muted-foreground/40 mx-1">·</span>
|
|
|
|
|
|
<span class="text-[10px] text-muted-foreground shrink-0">{{ key.provider_name }}</span>
|
|
|
|
|
|
</template>
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="!key.provider_active"
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
class="text-[9px] h-4 px-1 shrink-0 ml-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
停用
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<!-- 右侧:健康度/倍率 + 开关 -->
|
|
|
|
|
|
<div class="shrink-0 flex items-center gap-2">
|
|
|
|
|
|
<!-- 健康度 + 倍率(两行) -->
|
|
|
|
|
|
<div class="text-right min-w-[36px]">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
2026-01-08 02:34:59 +08:00
|
|
|
|
v-if="key.health_score != null"
|
2026-02-09 18:21:34 +08:00
|
|
|
|
class="text-[11px] font-medium tabular-nums text-foreground"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2026-02-09 18:21:34 +08:00
|
|
|
|
{{ ((key.health_score || 0) * 100).toFixed(0) }}%
|
2025-12-12 16:15:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
2026-02-09 18:21:34 +08:00
|
|
|
|
class="text-[11px] text-muted-foreground/40"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2026-02-09 18:21:34 +08:00
|
|
|
|
--
|
2025-12-12 16:15:36 +08:00
|
|
|
|
</div>
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<div class="text-[10px] text-muted-foreground tabular-nums">
|
2026-03-04 12:59:27 +08:00
|
|
|
|
{{ key.is_pool_aggregate ? 'Pool' : (key.rate_multipliers?.[format] ?? 1) + 'x' }}
|
2025-12-12 16:15:36 +08:00
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<!-- 快捷启用/禁用开关 -->
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="p-0.5 rounded transition-colors shrink-0"
|
2026-03-04 12:59:27 +08:00
|
|
|
|
:class="(key.is_pool_aggregate || !key.provider_active)
|
2026-02-09 18:21:34 +08:00
|
|
|
|
? 'text-muted-foreground/20 cursor-not-allowed'
|
|
|
|
|
|
: key.is_active
|
|
|
|
|
|
? 'text-foreground/70 hover:bg-muted hover:text-foreground'
|
|
|
|
|
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'"
|
2026-03-04 12:59:27 +08:00
|
|
|
|
:title="key.is_pool_aggregate
|
|
|
|
|
|
? '号池聚合项不支持在此单独开关'
|
|
|
|
|
|
: !key.provider_active
|
|
|
|
|
|
? 'Provider 停用'
|
|
|
|
|
|
: key.is_active
|
|
|
|
|
|
? '点击停用'
|
|
|
|
|
|
: '点击启用'"
|
|
|
|
|
|
:disabled="key.is_pool_aggregate || !key.provider_active"
|
|
|
|
|
|
@click.stop="!key.is_pool_aggregate && toggleKeyActive(format, key)"
|
2026-02-09 18:21:34 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Power class="w-3.5 h-3.5" />
|
|
|
|
|
|
</button>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="flex flex-col items-center justify-center py-20 text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Key class="w-10 h-10 mb-3 opacity-20" />
|
|
|
|
|
|
<span class="text-sm">暂无 {{ format }} 格式的 Key</span>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<div class="flex items-center justify-between w-full">
|
2026-02-10 18:32:51 +08:00
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
|
<div class="text-xs text-muted-foreground whitespace-nowrap">
|
|
|
|
|
|
当前模式: <span class="font-medium text-foreground/80">{{ activeMainTab === 'provider' ? '提供商优先' : 'Key 优先' }}</span>
|
2025-12-17 19:15:08 +08:00
|
|
|
|
</div>
|
2026-02-10 18:32:51 +08:00
|
|
|
|
<div class="flex items-center gap-1.5 pl-3 border-l border-border/60">
|
2025-12-17 19:15:08 +08:00
|
|
|
|
<span class="text-xs text-muted-foreground">调度:</span>
|
|
|
|
|
|
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="px-2 py-1 text-xs font-medium rounded transition-all"
|
|
|
|
|
|
:class="[
|
|
|
|
|
|
schedulingMode === 'cache_affinity'
|
|
|
|
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
|
|
|
|
]"
|
|
|
|
|
|
title="优先使用已缓存的Provider,利用Prompt Cache"
|
|
|
|
|
|
@click="schedulingMode = 'cache_affinity'"
|
|
|
|
|
|
>
|
|
|
|
|
|
缓存亲和
|
|
|
|
|
|
</button>
|
2026-01-08 03:20:04 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="px-2 py-1 text-xs font-medium rounded transition-all"
|
|
|
|
|
|
:class="[
|
|
|
|
|
|
schedulingMode === 'load_balance'
|
|
|
|
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
|
|
|
|
]"
|
|
|
|
|
|
title="同优先级内随机轮换,不考虑缓存"
|
|
|
|
|
|
@click="schedulingMode = 'load_balance'"
|
|
|
|
|
|
>
|
|
|
|
|
|
负载均衡
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="px-2 py-1 text-xs font-medium rounded transition-all"
|
|
|
|
|
|
:class="[
|
|
|
|
|
|
schedulingMode === 'fixed_order'
|
|
|
|
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
|
|
|
|
]"
|
|
|
|
|
|
title="严格按优先级顺序,不考虑缓存"
|
|
|
|
|
|
@click="schedulingMode = 'fixed_order'"
|
|
|
|
|
|
>
|
|
|
|
|
|
固定顺序
|
|
|
|
|
|
</button>
|
2025-12-17 19:15:08 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex gap-2">
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
:disabled="saving"
|
|
|
|
|
|
class="min-w-[72px]"
|
|
|
|
|
|
@click="save"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Loader2
|
|
|
|
|
|
v-if="saving"
|
|
|
|
|
|
class="w-3.5 h-3.5 mr-1.5 animate-spin"
|
|
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
{{ saving ? '保存中' : '保存' }}
|
|
|
|
|
|
</Button>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="min-w-[72px]"
|
|
|
|
|
|
@click="close"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, computed, watch } from 'vue'
|
2026-02-09 18:21:34 +08:00
|
|
|
|
import { GripVertical, Layers, Key, Loader2, ListOrdered, Power } from 'lucide-vue-next'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { Dialog } from '@/components/ui'
|
|
|
|
|
|
import Button from '@/components/ui/button.vue'
|
|
|
|
|
|
import Badge from '@/components/ui/badge.vue'
|
|
|
|
|
|
import { useToast } from '@/composables/useToast'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
2026-01-10 18:43:53 +08:00
|
|
|
|
import { updateProvider, updateProviderKey } from '@/api/endpoints'
|
2026-03-09 12:45:54 +08:00
|
|
|
|
import { getProvidersSummary, type ProviderWithEndpointsSummary } from '@/api/endpoints'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { adminApi } from '@/api/admin'
|
2026-01-19 19:44:45 +08:00
|
|
|
|
import { batchQueryBalance, type ActionResultResponse, type BalanceInfo } from '@/api/providerOps'
|
2026-04-26 22:09:16 +08:00
|
|
|
|
import {
|
|
|
|
|
|
sortApiFormats,
|
|
|
|
|
|
groupApiFormats,
|
|
|
|
|
|
parseApiFormat,
|
|
|
|
|
|
API_FORMAT_KIND_LABELS,
|
|
|
|
|
|
formatApiFormatShort,
|
|
|
|
|
|
normalizeApiFormatAlias,
|
|
|
|
|
|
} from '@/api/endpoints/types/api-format'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { log } from '@/utils/logger'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
interface KeyWithMeta {
|
|
|
|
|
|
id: string
|
2026-03-04 12:59:27 +08:00
|
|
|
|
provider_id: string
|
2025-12-10 20:52:44 +08:00
|
|
|
|
name: string
|
|
|
|
|
|
api_key_masked: string
|
|
|
|
|
|
internal_priority: number
|
2026-01-16 17:53:27 +08:00
|
|
|
|
global_priority_by_format: Record<string, number> | null
|
|
|
|
|
|
format_priority: number | null // 当前格式的优先级(后端计算)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
priority: number // 用于编辑的优先级
|
2026-01-16 17:53:27 +08:00
|
|
|
|
rate_multipliers: Record<string, number> | null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
is_active: boolean
|
2026-02-09 18:21:34 +08:00
|
|
|
|
provider_active: boolean
|
2025-12-10 20:52:44 +08:00
|
|
|
|
circuit_breaker_open: boolean
|
|
|
|
|
|
provider_name: string
|
|
|
|
|
|
endpoint_base_url: string
|
|
|
|
|
|
api_format: string
|
|
|
|
|
|
capabilities: string[]
|
2026-01-08 02:34:59 +08:00
|
|
|
|
health_score: number | null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
success_rate: number | null
|
|
|
|
|
|
avg_response_time_ms: number | null
|
|
|
|
|
|
request_count: number
|
2026-03-04 12:59:27 +08:00
|
|
|
|
is_pool_aggregate?: boolean
|
|
|
|
|
|
pool_key_count?: number
|
|
|
|
|
|
pool_active_key_count?: number
|
|
|
|
|
|
provider_type?: string
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
modelValue: boolean
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
'update:modelValue': [value: boolean]
|
|
|
|
|
|
saved: []
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const { success, error: showError } = useToast()
|
|
|
|
|
|
|
|
|
|
|
|
// 内部状态
|
|
|
|
|
|
const internalOpen = computed(() => props.modelValue)
|
|
|
|
|
|
|
|
|
|
|
|
function handleDialogUpdate(value: boolean) {
|
|
|
|
|
|
emit('update:modelValue', value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 主 Tab 状态
|
|
|
|
|
|
const activeMainTab = ref<'provider' | 'key'>('provider')
|
2026-04-29 09:25:19 +08:00
|
|
|
|
const activeFormatTab = ref<string>('claude:messages')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 提供商排序状态
|
|
|
|
|
|
const sortedProviders = ref<ProviderWithEndpointsSummary[]>([])
|
|
|
|
|
|
const draggedProvider = ref<number | null>(null)
|
|
|
|
|
|
const dragOverProvider = ref<number | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
// Key 排序状态
|
|
|
|
|
|
const keysByFormat = ref<Record<string, KeyWithMeta[]>>({})
|
2026-03-04 12:59:27 +08:00
|
|
|
|
const draggedKey = ref<Record<string, string | null>>({})
|
|
|
|
|
|
const dragOverKey = ref<Record<string, string | null>>({})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const loadingKeys = ref(false)
|
|
|
|
|
|
const saving = ref(false)
|
|
|
|
|
|
|
2026-03-12 17:11:14 +08:00
|
|
|
|
const SAVE_CONCURRENCY = 6
|
2026-04-05 20:23:16 +08:00
|
|
|
|
const PRIORITY_REQUEST_TIMEOUT_MS = 5 * 60 * 1000
|
2026-03-12 17:11:14 +08:00
|
|
|
|
|
|
|
|
|
|
let originalProviderPriorityById = new Map<string, number>()
|
|
|
|
|
|
let originalPoolPriorityByProviderId = new Map<string, number | null>()
|
|
|
|
|
|
let originalKeyPriorityById = new Map<string, Record<string, number>>()
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// Key 优先级编辑状态
|
|
|
|
|
|
const editingKeyPriority = ref<Record<string, string | null>>({}) // format -> keyId
|
|
|
|
|
|
|
2026-01-15 18:59:01 +08:00
|
|
|
|
// Provider 优先级编辑状态
|
|
|
|
|
|
const editingProviderPriority = ref<string | null>(null) // providerId
|
|
|
|
|
|
|
2025-12-17 19:15:08 +08:00
|
|
|
|
// 调度模式状态
|
2026-01-08 03:20:04 +08:00
|
|
|
|
const schedulingMode = ref<'fixed_order' | 'load_balance' | 'cache_affinity'>('cache_affinity')
|
2025-12-17 19:15:08 +08:00
|
|
|
|
|
2026-01-19 19:44:45 +08:00
|
|
|
|
// 余额数据缓存 {providerId: ActionResultResponse}
|
|
|
|
|
|
const balanceCache = ref<Record<string, ActionResultResponse>>({})
|
|
|
|
|
|
|
|
|
|
|
|
// 类型守卫函数
|
|
|
|
|
|
function isBalanceInfo(data: unknown): data is BalanceInfo {
|
|
|
|
|
|
return data !== null && typeof data === 'object' && 'total_available' in data
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取 provider 的余额显示
|
|
|
|
|
|
function getProviderBalance(providerId: string): { available: number | null; currency: string } | null {
|
|
|
|
|
|
const result = balanceCache.value[providerId]
|
|
|
|
|
|
if (!result || result.status !== 'success' || !result.data) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!isBalanceInfo(result.data)) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
available: result.data.total_available,
|
|
|
|
|
|
currency: result.data.currency || 'USD'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化余额显示
|
|
|
|
|
|
function formatBalanceDisplay(providerId: string): string {
|
|
|
|
|
|
const balance = getProviderBalance(providerId)
|
|
|
|
|
|
if (!balance || balance.available == null) {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
const symbol = balance.currency === 'USD' ? '$' : balance.currency
|
|
|
|
|
|
return `${symbol}${balance.available.toFixed(2)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 异步加载余额数据(使用批量接口)
|
|
|
|
|
|
async function loadBalances() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const opsProviderIds = sortedProviders.value
|
|
|
|
|
|
.filter(p => p.ops_configured)
|
|
|
|
|
|
.map(p => p.id)
|
|
|
|
|
|
if (opsProviderIds.length === 0) return
|
|
|
|
|
|
|
|
|
|
|
|
const results = await batchQueryBalance(opsProviderIds)
|
|
|
|
|
|
|
|
|
|
|
|
// 将成功的结果存入缓存
|
|
|
|
|
|
for (const [providerId, result] of Object.entries(results)) {
|
|
|
|
|
|
if (result.status === 'success') {
|
|
|
|
|
|
balanceCache.value[providerId] = result
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
2026-02-22 00:43:41 +08:00
|
|
|
|
log.warn('[loadBalances] 加载余额数据失败', e)
|
2026-01-19 19:44:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 12:59:27 +08:00
|
|
|
|
const LEGACY_API_FORMAT_MAP: Record<string, string> = {
|
2026-04-29 09:25:19 +08:00
|
|
|
|
CLAUDE: 'claude:messages',
|
|
|
|
|
|
CLAUDE_MESSAGES: 'claude:messages',
|
2026-03-04 12:59:27 +08:00
|
|
|
|
OPENAI: 'openai:chat',
|
2026-04-26 20:32:55 +08:00
|
|
|
|
OPENAI_RESPONSES: 'openai:responses',
|
|
|
|
|
|
OPENAI_RESPONSES_COMPACT: 'openai:responses:compact',
|
2026-03-04 12:59:27 +08:00
|
|
|
|
OPENAI_VIDEO: 'openai:video',
|
2026-04-29 09:25:19 +08:00
|
|
|
|
GEMINI: 'gemini:generate_content',
|
|
|
|
|
|
GEMINI_GENERATE_CONTENT: 'gemini:generate_content',
|
2026-03-04 12:59:27 +08:00
|
|
|
|
GEMINI_VIDEO: 'gemini:video',
|
2026-04-29 09:25:19 +08:00
|
|
|
|
GEMINI_FILES: 'gemini:files',
|
2026-03-04 12:59:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeApiFormatKey(value: string | null | undefined): string {
|
|
|
|
|
|
const raw = String(value || '').trim()
|
|
|
|
|
|
if (!raw) return ''
|
|
|
|
|
|
|
|
|
|
|
|
if (raw.includes(':')) {
|
|
|
|
|
|
const [family, kind] = raw.split(':', 2)
|
|
|
|
|
|
const familyNorm = family?.trim().toLowerCase()
|
|
|
|
|
|
const kindNorm = kind?.trim().toLowerCase()
|
2026-04-29 09:25:19 +08:00
|
|
|
|
if (familyNorm === 'claude' && ['chat', 'cli', 'messages'].includes(kindNorm)) {
|
|
|
|
|
|
return 'claude:messages'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (familyNorm === 'gemini' && ['chat', 'cli', 'generate_content'].includes(kindNorm)) {
|
|
|
|
|
|
return 'gemini:generate_content'
|
|
|
|
|
|
}
|
2026-03-04 12:59:27 +08:00
|
|
|
|
if (familyNorm && kindNorm) return `${familyNorm}:${kindNorm}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const legacy = raw.toUpperCase().replace(/-/g, '_')
|
|
|
|
|
|
return LEGACY_API_FORMAT_MAP[legacy] || raw.toLowerCase()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizePriorityMap(
|
|
|
|
|
|
value: Record<string, unknown> | null | undefined
|
|
|
|
|
|
): Record<string, number> {
|
|
|
|
|
|
if (!value) return {}
|
|
|
|
|
|
const normalized: Record<string, number> = {}
|
|
|
|
|
|
for (const [rawFormat, rawPriority] of Object.entries(value)) {
|
|
|
|
|
|
const format = normalizeApiFormatKey(rawFormat)
|
|
|
|
|
|
if (!format || format in normalized) continue
|
|
|
|
|
|
const num = Number(rawPriority)
|
|
|
|
|
|
if (!Number.isFinite(num)) continue
|
|
|
|
|
|
normalized[format] = Math.trunc(num)
|
|
|
|
|
|
}
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 17:11:14 +08:00
|
|
|
|
function normalizeOptionalPriority(value: unknown): number | null {
|
|
|
|
|
|
const num = Number(value)
|
|
|
|
|
|
if (!Number.isFinite(num)) return null
|
|
|
|
|
|
return Math.trunc(num)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeRequiredPriority(value: unknown, fallback: number): number {
|
|
|
|
|
|
return normalizeOptionalPriority(value) ?? fallback
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeProvidersForEditing(
|
|
|
|
|
|
providers: ProviderWithEndpointsSummary[]
|
|
|
|
|
|
): ProviderWithEndpointsSummary[] {
|
|
|
|
|
|
const normalized = providers.map((provider, index) => ({
|
|
|
|
|
|
...provider,
|
|
|
|
|
|
provider_priority: normalizeRequiredPriority(provider.provider_priority, 100 + index),
|
|
|
|
|
|
pool_advanced: provider.pool_advanced
|
|
|
|
|
|
? {
|
|
|
|
|
|
...provider.pool_advanced,
|
|
|
|
|
|
global_priority: normalizeOptionalPriority(provider.pool_advanced.global_priority),
|
|
|
|
|
|
}
|
|
|
|
|
|
: provider.pool_advanced,
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
const minProviderPriority = normalized.reduce(
|
|
|
|
|
|
(min, provider) => Math.min(min, provider.provider_priority),
|
|
|
|
|
|
Number.POSITIVE_INFINITY,
|
|
|
|
|
|
)
|
|
|
|
|
|
const providerOffset = Number.isFinite(minProviderPriority) && minProviderPriority < 1
|
|
|
|
|
|
? 1 - minProviderPriority
|
|
|
|
|
|
: 0
|
|
|
|
|
|
|
|
|
|
|
|
const explicitPoolPriorities = normalized
|
|
|
|
|
|
.map((provider) => normalizeOptionalPriority(provider.pool_advanced?.global_priority))
|
|
|
|
|
|
.filter((priority): priority is number => priority != null)
|
|
|
|
|
|
const minPoolPriority = explicitPoolPriorities.length > 0
|
|
|
|
|
|
? Math.min(...explicitPoolPriorities)
|
|
|
|
|
|
: null
|
|
|
|
|
|
const poolOffset = minPoolPriority != null && minPoolPriority < 1
|
|
|
|
|
|
? 1 - minPoolPriority
|
|
|
|
|
|
: 0
|
|
|
|
|
|
|
|
|
|
|
|
return normalized.map((provider) => ({
|
|
|
|
|
|
...provider,
|
|
|
|
|
|
provider_priority: provider.provider_priority + providerOffset,
|
|
|
|
|
|
pool_advanced: provider.pool_advanced
|
|
|
|
|
|
? {
|
|
|
|
|
|
...provider.pool_advanced,
|
|
|
|
|
|
global_priority: provider.pool_advanced.global_priority == null
|
|
|
|
|
|
? null
|
|
|
|
|
|
: provider.pool_advanced.global_priority + poolOffset,
|
|
|
|
|
|
}
|
|
|
|
|
|
: provider.pool_advanced,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function snapshotProviderBaseline(providers: ProviderWithEndpointsSummary[]) {
|
|
|
|
|
|
originalProviderPriorityById = new Map(
|
|
|
|
|
|
providers.map((provider, index) => [
|
|
|
|
|
|
provider.id,
|
|
|
|
|
|
normalizeRequiredPriority(provider.provider_priority, 100 + index),
|
|
|
|
|
|
])
|
|
|
|
|
|
)
|
|
|
|
|
|
originalPoolPriorityByProviderId = new Map(
|
|
|
|
|
|
providers.map((provider) => [
|
|
|
|
|
|
provider.id,
|
|
|
|
|
|
normalizeOptionalPriority(provider.pool_advanced?.global_priority),
|
|
|
|
|
|
])
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 12:59:27 +08:00
|
|
|
|
function normalizeRateMultipliers(
|
|
|
|
|
|
value: Record<string, unknown> | null | undefined
|
|
|
|
|
|
): Record<string, number> | null {
|
|
|
|
|
|
if (!value) return null
|
|
|
|
|
|
const normalized: Record<string, number> = {}
|
|
|
|
|
|
for (const [rawFormat, rawMultiplier] of Object.entries(value)) {
|
|
|
|
|
|
const format = normalizeApiFormatKey(rawFormat)
|
|
|
|
|
|
if (!format || format in normalized) continue
|
|
|
|
|
|
const num = Number(rawMultiplier)
|
|
|
|
|
|
if (!Number.isFinite(num)) continue
|
|
|
|
|
|
normalized[format] = num
|
|
|
|
|
|
}
|
|
|
|
|
|
return Object.keys(normalized).length > 0 ? normalized : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 17:11:14 +08:00
|
|
|
|
function buildEditableKeyPriorityMap(): Map<string, Record<string, number>> {
|
|
|
|
|
|
const priorityMapByKeyId = new Map<string, Record<string, number>>()
|
|
|
|
|
|
|
|
|
|
|
|
for (const format of Object.keys(keysByFormat.value)) {
|
|
|
|
|
|
const normalizedFormat = normalizeApiFormatKey(format)
|
|
|
|
|
|
if (!normalizedFormat) continue
|
|
|
|
|
|
|
|
|
|
|
|
const keys = keysByFormat.value[format].filter((key) => !isPoolManagedKey(key))
|
|
|
|
|
|
keys.forEach((key) => {
|
|
|
|
|
|
const existing = priorityMapByKeyId.get(key.id) || normalizePriorityMap(key.global_priority_by_format)
|
|
|
|
|
|
existing[normalizedFormat] = Math.max(0, Math.trunc(key.priority))
|
|
|
|
|
|
priorityMapByKeyId.set(key.id, existing)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return priorityMapByKeyId
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function snapshotKeyBaseline() {
|
|
|
|
|
|
originalKeyPriorityById = new Map(
|
|
|
|
|
|
Array.from(buildEditableKeyPriorityMap().entries()).map(([keyId, priorityMap]) => [
|
|
|
|
|
|
keyId,
|
|
|
|
|
|
{ ...priorityMap },
|
|
|
|
|
|
])
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 20:23:16 +08:00
|
|
|
|
function snapshotCurrentPriorityBaseline() {
|
|
|
|
|
|
snapshotProviderBaseline(sortedProviders.value)
|
|
|
|
|
|
snapshotKeyBaseline()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 17:11:14 +08:00
|
|
|
|
function arePriorityMapsEqual(
|
|
|
|
|
|
left: Record<string, number> | undefined,
|
|
|
|
|
|
right: Record<string, number> | undefined,
|
|
|
|
|
|
): boolean {
|
|
|
|
|
|
const leftEntries = Object.entries(left || {}).sort(([a], [b]) => a.localeCompare(b))
|
|
|
|
|
|
const rightEntries = Object.entries(right || {}).sort(([a], [b]) => a.localeCompare(b))
|
|
|
|
|
|
|
|
|
|
|
|
if (leftEntries.length !== rightEntries.length) return false
|
|
|
|
|
|
|
|
|
|
|
|
return leftEntries.every(([format, priority], index) => {
|
|
|
|
|
|
const [rightFormat, rightPriority] = rightEntries[index] || []
|
|
|
|
|
|
return format === rightFormat && priority === rightPriority
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function runTasksWithConcurrency(
|
|
|
|
|
|
tasks: Array<() => Promise<unknown>>,
|
|
|
|
|
|
concurrency: number = SAVE_CONCURRENCY,
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (tasks.length === 0) return
|
|
|
|
|
|
|
|
|
|
|
|
let cursor = 0
|
|
|
|
|
|
const runNext = async (): Promise<void> => {
|
|
|
|
|
|
while (cursor < tasks.length) {
|
|
|
|
|
|
const index = cursor++
|
|
|
|
|
|
await tasks[index]()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const workers = Array.from(
|
|
|
|
|
|
{ length: Math.min(concurrency, tasks.length) },
|
|
|
|
|
|
() => runNext(),
|
|
|
|
|
|
)
|
|
|
|
|
|
await Promise.all(workers)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 12:59:27 +08:00
|
|
|
|
const providerById = computed(() => {
|
|
|
|
|
|
const map = new Map<string, ProviderWithEndpointsSummary>()
|
2026-03-09 12:45:54 +08:00
|
|
|
|
sortedProviders.value.forEach((provider) => {
|
2026-03-04 12:59:27 +08:00
|
|
|
|
map.set(provider.id, provider)
|
|
|
|
|
|
})
|
|
|
|
|
|
return map
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const providerIdByName = computed(() => {
|
|
|
|
|
|
const map = new Map<string, string>()
|
2026-03-09 12:45:54 +08:00
|
|
|
|
sortedProviders.value.forEach((provider) => {
|
2026-03-04 12:59:27 +08:00
|
|
|
|
if (!map.has(provider.name)) {
|
|
|
|
|
|
map.set(provider.name, provider.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
return map
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
function resolveProviderId(key: Pick<KeyWithMeta, 'provider_id' | 'provider_name'>): string {
|
|
|
|
|
|
if (key.provider_id) return key.provider_id
|
|
|
|
|
|
return providerIdByName.value.get(key.provider_name) || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const poolProviderIds = computed(() => {
|
|
|
|
|
|
const set = new Set<string>()
|
2026-03-09 12:45:54 +08:00
|
|
|
|
sortedProviders.value.forEach((provider) => {
|
2026-03-04 12:59:27 +08:00
|
|
|
|
if (provider.pool_advanced) {
|
|
|
|
|
|
set.add(provider.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
return set
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const PROVIDER_TYPE_LABELS: Record<string, string> = {
|
|
|
|
|
|
custom: '自定义',
|
|
|
|
|
|
vertex_ai: 'Vertex AI',
|
|
|
|
|
|
claude_code: 'ClaudeCode',
|
|
|
|
|
|
codex: 'Codex',
|
2026-05-06 02:29:17 +08:00
|
|
|
|
chatgpt_web: 'ChatGPT Web',
|
2026-03-04 12:59:27 +08:00
|
|
|
|
gemini_cli: 'Gemini CLI',
|
|
|
|
|
|
antigravity: 'Antigravity',
|
|
|
|
|
|
kiro: 'Kiro',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatProviderType(type?: string): string {
|
|
|
|
|
|
if (!type) return ''
|
|
|
|
|
|
return PROVIDER_TYPE_LABELS[type] || type
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isPoolManagedProvider(providerId: string): boolean {
|
|
|
|
|
|
return providerId !== '' && poolProviderIds.value.has(providerId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isPoolManagedKey(key: KeyWithMeta): boolean {
|
|
|
|
|
|
return isPoolManagedProvider(resolveProviderId(key))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isPoolAggregateItem(key: KeyWithMeta): boolean {
|
|
|
|
|
|
return key.is_pool_aggregate === true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 11:44:19 +08:00
|
|
|
|
// 将号池聚合项的优先级写回 provider 的 pool_advanced.global_priority(本地状态)
|
|
|
|
|
|
function updatePoolGlobalPriority(providerId: string, priority: number) {
|
|
|
|
|
|
const provider = sortedProviders.value.find((p) => p.id === providerId)
|
|
|
|
|
|
if (!provider) return
|
|
|
|
|
|
if (provider.pool_advanced) {
|
|
|
|
|
|
provider.pool_advanced.global_priority = priority
|
|
|
|
|
|
} else {
|
|
|
|
|
|
provider.pool_advanced = { global_priority: priority }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 12:59:27 +08:00
|
|
|
|
function toNumberOrNull(value: unknown): number | null {
|
|
|
|
|
|
const num = Number(value)
|
|
|
|
|
|
return Number.isFinite(num) ? num : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 可用的 API 格式
|
|
|
|
|
|
const availableFormats = computed(() => {
|
2026-03-04 12:59:27 +08:00
|
|
|
|
return sortApiFormats(Object.keys(keysByFormat.value))
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-04 12:59:27 +08:00
|
|
|
|
// 按 family 分组的 API 格式(用于侧边栏分组显示)
|
|
|
|
|
|
const groupedFormats = computed(() => {
|
|
|
|
|
|
return groupApiFormats(availableFormats.value)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 获取格式的 kind 显示名称
|
|
|
|
|
|
function formatKind(format: string): string {
|
2026-04-26 22:09:16 +08:00
|
|
|
|
const { kind } = parseApiFormat(normalizeApiFormatAlias(format))
|
2026-03-04 12:59:27 +08:00
|
|
|
|
return API_FORMAT_KIND_LABELS[kind] || kind || format
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 18:21:34 +08:00
|
|
|
|
// 排序 Key:活跃的在前,停用的(Key或Provider)在后,各自按优先级排序
|
|
|
|
|
|
function sortKeysByActiveAndPriority(keys: KeyWithMeta[]): KeyWithMeta[] {
|
|
|
|
|
|
return [...keys].sort((a, b) => {
|
|
|
|
|
|
const aActive = a.is_active && a.provider_active
|
|
|
|
|
|
const bActive = b.is_active && b.provider_active
|
|
|
|
|
|
if (aActive !== bActive) return aActive ? -1 : 1
|
|
|
|
|
|
return a.priority - b.priority
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 12:59:27 +08:00
|
|
|
|
function buildPoolAggregateItem(format: string, providerId: string, sourceKeys: KeyWithMeta[]): KeyWithMeta {
|
|
|
|
|
|
const provider = providerById.value.get(providerId)
|
|
|
|
|
|
const poolPriorityRaw = provider?.pool_advanced?.global_priority
|
|
|
|
|
|
const fallbackPriority = provider?.provider_priority ?? 999999
|
|
|
|
|
|
const poolPriority = Number.isFinite(poolPriorityRaw ?? NaN)
|
|
|
|
|
|
? Number(poolPriorityRaw)
|
|
|
|
|
|
: fallbackPriority
|
|
|
|
|
|
|
|
|
|
|
|
const providerName = provider?.name || sourceKeys[0]?.provider_name || '未知 Provider'
|
|
|
|
|
|
const activeKeyCount = sourceKeys.filter((k) => k.is_active).length
|
|
|
|
|
|
const providerActive = provider?.is_active ?? sourceKeys.some((k) => k.provider_active)
|
|
|
|
|
|
const healthCandidates = sourceKeys.map((k) => k.health_score).filter((v): v is number => v != null)
|
|
|
|
|
|
const avgHealth = healthCandidates.length > 0
|
|
|
|
|
|
? healthCandidates.reduce((sum, score) => sum + score, 0) / healthCandidates.length
|
|
|
|
|
|
: null
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: `pool:${providerId}:${format}`,
|
|
|
|
|
|
provider_id: providerId,
|
|
|
|
|
|
name: providerName,
|
|
|
|
|
|
api_key_masked: '[Pool]',
|
|
|
|
|
|
internal_priority: 0,
|
|
|
|
|
|
global_priority_by_format: null,
|
|
|
|
|
|
format_priority: poolPriority,
|
|
|
|
|
|
priority: poolPriority,
|
|
|
|
|
|
rate_multipliers: { [format]: 1 },
|
|
|
|
|
|
is_active: activeKeyCount > 0,
|
|
|
|
|
|
provider_active: providerActive,
|
|
|
|
|
|
circuit_breaker_open: false,
|
|
|
|
|
|
provider_name: providerName,
|
|
|
|
|
|
endpoint_base_url: sourceKeys.find((k) => k.endpoint_base_url)?.endpoint_base_url || '',
|
|
|
|
|
|
api_format: format,
|
|
|
|
|
|
capabilities: [],
|
|
|
|
|
|
health_score: avgHealth,
|
|
|
|
|
|
success_rate: null,
|
|
|
|
|
|
avg_response_time_ms: null,
|
|
|
|
|
|
request_count: sourceKeys.reduce((sum, key) => sum + (key.request_count || 0), 0),
|
|
|
|
|
|
is_pool_aggregate: true,
|
|
|
|
|
|
pool_key_count: sourceKeys.length,
|
|
|
|
|
|
pool_active_key_count: activeKeyCount,
|
|
|
|
|
|
provider_type: provider?.provider_type || undefined,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const displayKeysByFormat = computed<Record<string, KeyWithMeta[]>>(() => {
|
|
|
|
|
|
const display: Record<string, KeyWithMeta[]> = {}
|
|
|
|
|
|
|
|
|
|
|
|
for (const [format, rawKeys] of Object.entries(keysByFormat.value)) {
|
|
|
|
|
|
const normalKeys: KeyWithMeta[] = []
|
|
|
|
|
|
const poolGroups = new Map<string, KeyWithMeta[]>()
|
|
|
|
|
|
|
|
|
|
|
|
for (const key of rawKeys) {
|
|
|
|
|
|
const providerId = resolveProviderId(key)
|
|
|
|
|
|
if (isPoolManagedProvider(providerId)) {
|
|
|
|
|
|
if (!poolGroups.has(providerId)) {
|
|
|
|
|
|
poolGroups.set(providerId, [])
|
|
|
|
|
|
}
|
|
|
|
|
|
poolGroups.get(providerId)?.push(key)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
normalKeys.push(key)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const poolItems = Array.from(poolGroups.entries()).map(([providerId, keys]) =>
|
|
|
|
|
|
buildPoolAggregateItem(format, providerId, keys)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
display[format] = sortKeysByActiveAndPriority([...normalKeys, ...poolItems])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return display
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-23 16:10:53 +08:00
|
|
|
|
// 排序 providers:启用的在前,停用的在后,各自按优先级排序
|
|
|
|
|
|
function sortProvidersByActiveAndPriority(providers: ProviderWithEndpointsSummary[]) {
|
|
|
|
|
|
return [...providers].sort((a, b) => {
|
|
|
|
|
|
if (a.is_active !== b.is_active) {
|
|
|
|
|
|
return a.is_active ? -1 : 1
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.provider_priority - b.provider_priority
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 监听对话框打开
|
|
|
|
|
|
watch(internalOpen, async (open) => {
|
|
|
|
|
|
if (open) {
|
2026-03-09 12:45:54 +08:00
|
|
|
|
await loadAllProviders()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
await loadCurrentPriorityMode()
|
|
|
|
|
|
await loadKeysByFormat()
|
2026-01-19 19:44:45 +08:00
|
|
|
|
// 异步加载余额数据
|
|
|
|
|
|
loadBalances()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-09 12:45:54 +08:00
|
|
|
|
// 加载全量 providers(优先级管理需要完整列表)
|
|
|
|
|
|
async function loadAllProviders() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await getProvidersSummary({ page: 1, page_size: 9999 })
|
2026-03-12 17:11:14 +08:00
|
|
|
|
snapshotProviderBaseline(response.items)
|
|
|
|
|
|
sortedProviders.value = sortProvidersByActiveAndPriority(
|
|
|
|
|
|
normalizeProvidersForEditing(response.items)
|
|
|
|
|
|
)
|
2026-03-09 12:45:54 +08:00
|
|
|
|
} catch {
|
2026-03-12 17:11:14 +08:00
|
|
|
|
originalProviderPriorityById = new Map()
|
|
|
|
|
|
originalPoolPriorityByProviderId = new Map()
|
2026-03-09 12:45:54 +08:00
|
|
|
|
sortedProviders.value = []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 加载当前的优先级模式配置
|
|
|
|
|
|
async function loadCurrentPriorityMode() {
|
|
|
|
|
|
try {
|
2025-12-17 19:15:08 +08:00
|
|
|
|
const [priorityResponse, schedulingResponse] = await Promise.all([
|
|
|
|
|
|
adminApi.getSystemConfig('provider_priority_mode'),
|
|
|
|
|
|
adminApi.getSystemConfig('scheduling_mode')
|
|
|
|
|
|
])
|
|
|
|
|
|
const currentMode = priorityResponse.value || 'provider'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
activeMainTab.value = currentMode === 'global_key' ? 'key' : 'provider'
|
2025-12-17 19:15:08 +08:00
|
|
|
|
|
|
|
|
|
|
const currentSchedulingMode = schedulingResponse.value || 'cache_affinity'
|
2026-01-08 03:20:04 +08:00
|
|
|
|
if (currentSchedulingMode === 'fixed_order' || currentSchedulingMode === 'load_balance' || currentSchedulingMode === 'cache_affinity') {
|
|
|
|
|
|
schedulingMode.value = currentSchedulingMode
|
|
|
|
|
|
} else {
|
|
|
|
|
|
schedulingMode.value = 'cache_affinity'
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
activeMainTab.value = 'provider'
|
2025-12-17 19:15:08 +08:00
|
|
|
|
schedulingMode.value = 'cache_affinity'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载按格式分组的 Keys
|
|
|
|
|
|
async function loadKeysByFormat() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
loadingKeys.value = true
|
|
|
|
|
|
const { default: client } = await import('@/api/client')
|
2026-04-05 20:23:16 +08:00
|
|
|
|
const response = await client.get('/api/admin/endpoints/keys/grouped-by-format', {
|
|
|
|
|
|
timeout: PRIORITY_REQUEST_TIMEOUT_MS,
|
|
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-04 12:59:27 +08:00
|
|
|
|
// 每个格式独立管理优先级,额外做一次前端归一化兜底,避免历史数据导致重复格式/脏键
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const data: Record<string, KeyWithMeta[]> = {}
|
2026-03-04 12:59:27 +08:00
|
|
|
|
const grouped = response.data as Record<string, Record<string, unknown>[]>
|
|
|
|
|
|
|
|
|
|
|
|
for (const [rawFormat, keys] of Object.entries(grouped)) {
|
|
|
|
|
|
const format = normalizeApiFormatKey(rawFormat)
|
|
|
|
|
|
if (!format) continue
|
|
|
|
|
|
|
|
|
|
|
|
if (!data[format]) {
|
|
|
|
|
|
data[format] = []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 17:53:27 +08:00
|
|
|
|
for (const key of keys) {
|
2026-03-04 12:59:27 +08:00
|
|
|
|
const providerName = typeof key.provider_name === 'string' ? key.provider_name : ''
|
|
|
|
|
|
const providerIdRaw = typeof key.provider_id === 'string' ? key.provider_id : ''
|
|
|
|
|
|
const providerId = providerIdRaw || providerIdByName.value.get(providerName) || ''
|
|
|
|
|
|
|
|
|
|
|
|
const priorityMap = normalizePriorityMap(
|
|
|
|
|
|
(key.global_priority_by_format as Record<string, unknown> | null | undefined)
|
|
|
|
|
|
)
|
|
|
|
|
|
const rateMultipliers = normalizeRateMultipliers(
|
|
|
|
|
|
(key.rate_multipliers as Record<string, unknown> | null | undefined)
|
|
|
|
|
|
)
|
|
|
|
|
|
const explicitFormatPriority = toNumberOrNull(key.format_priority)
|
|
|
|
|
|
const inferredFormatPriority = priorityMap[format]
|
|
|
|
|
|
const formatPriority = explicitFormatPriority ?? (typeof inferredFormatPriority === 'number'
|
|
|
|
|
|
? inferredFormatPriority
|
|
|
|
|
|
: null)
|
|
|
|
|
|
|
|
|
|
|
|
data[format].push({
|
|
|
|
|
|
id: String(key.id || ''),
|
|
|
|
|
|
provider_id: providerId,
|
|
|
|
|
|
name: String(key.name || 'Unnamed Key'),
|
|
|
|
|
|
api_key_masked: String(key.api_key_masked || '***'),
|
|
|
|
|
|
internal_priority: toNumberOrNull(key.internal_priority) ?? 0,
|
|
|
|
|
|
global_priority_by_format: Object.keys(priorityMap).length > 0 ? priorityMap : null,
|
|
|
|
|
|
format_priority: formatPriority,
|
|
|
|
|
|
priority: formatPriority ?? 0,
|
|
|
|
|
|
rate_multipliers: rateMultipliers,
|
|
|
|
|
|
is_active: key.is_active !== false,
|
|
|
|
|
|
provider_active: key.provider_active !== false,
|
|
|
|
|
|
circuit_breaker_open: key.circuit_breaker_open === true,
|
|
|
|
|
|
provider_name: providerName || 'Unknown Provider',
|
|
|
|
|
|
endpoint_base_url: String(key.endpoint_base_url || ''),
|
|
|
|
|
|
api_format: format,
|
|
|
|
|
|
capabilities: Array.isArray(key.capabilities)
|
|
|
|
|
|
? key.capabilities.map((cap) => String(cap))
|
|
|
|
|
|
: [],
|
|
|
|
|
|
health_score: toNumberOrNull(key.health_score),
|
|
|
|
|
|
success_rate: toNumberOrNull(key.success_rate),
|
|
|
|
|
|
avg_response_time_ms: toNumberOrNull(key.avg_response_time_ms),
|
|
|
|
|
|
request_count: toNumberOrNull(key.request_count) ?? 0,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const [format, keys] of Object.entries(data)) {
|
|
|
|
|
|
const dedupedById = new Map<string, KeyWithMeta>()
|
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
|
if (!key.id) continue
|
|
|
|
|
|
const existing = dedupedById.get(key.id)
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
|
dedupedById.set(key.id, key)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const mergedPriorityMap = {
|
|
|
|
|
|
...(existing.global_priority_by_format || {}),
|
|
|
|
|
|
...(key.global_priority_by_format || {})
|
|
|
|
|
|
}
|
|
|
|
|
|
const mergedRateMap = {
|
|
|
|
|
|
...(existing.rate_multipliers || {}),
|
|
|
|
|
|
...(key.rate_multipliers || {})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const preferredFormatPriority = existing.format_priority ?? key.format_priority
|
|
|
|
|
|
dedupedById.set(key.id, {
|
|
|
|
|
|
...existing,
|
|
|
|
|
|
...key,
|
|
|
|
|
|
global_priority_by_format: Object.keys(mergedPriorityMap).length > 0 ? mergedPriorityMap : null,
|
|
|
|
|
|
rate_multipliers: Object.keys(mergedRateMap).length > 0 ? mergedRateMap : null,
|
|
|
|
|
|
format_priority: preferredFormatPriority,
|
|
|
|
|
|
priority: preferredFormatPriority ?? existing.priority ?? key.priority,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const deduped = Array.from(dedupedById.values())
|
|
|
|
|
|
let maxPriority = 0
|
|
|
|
|
|
for (const key of deduped) {
|
2026-01-16 17:53:27 +08:00
|
|
|
|
if (key.format_priority != null) {
|
|
|
|
|
|
maxPriority = Math.max(maxPriority, key.format_priority)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let nextPriority = maxPriority + 1
|
2026-03-04 12:59:27 +08:00
|
|
|
|
data[format] = deduped.map((key) => ({
|
2025-12-10 20:52:44 +08:00
|
|
|
|
...key,
|
2026-01-16 17:53:27 +08:00
|
|
|
|
priority: key.format_priority ?? nextPriority++
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}))
|
2026-02-09 18:21:34 +08:00
|
|
|
|
// 按优先级排序:活跃的在前,停用的(Key或Provider)在后,各自按优先级排序
|
|
|
|
|
|
data[format] = sortKeysByActiveAndPriority(data[format])
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
keysByFormat.value = data
|
2026-03-12 17:11:14 +08:00
|
|
|
|
snapshotKeyBaseline()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-04 12:59:27 +08:00
|
|
|
|
const formats = sortApiFormats(Object.keys(data))
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (formats.length > 0 && !formats.includes(activeFormatTab.value)) {
|
|
|
|
|
|
activeFormatTab.value = formats[0]
|
|
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
2026-03-12 17:11:14 +08:00
|
|
|
|
originalKeyPriorityById = new Map()
|
2026-02-22 00:43:41 +08:00
|
|
|
|
showError(parseApiError(err, '加载 Key 列表失败'), '错误')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
loadingKeys.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 18:21:34 +08:00
|
|
|
|
// 快捷切换 Key 启用/禁用状态
|
|
|
|
|
|
async function toggleKeyActive(format: string, key: KeyWithMeta) {
|
2026-03-04 12:59:27 +08:00
|
|
|
|
if (isPoolAggregateItem(key)) return
|
2026-02-09 18:21:34 +08:00
|
|
|
|
const newStatus = !key.is_active
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateProviderKey(key.id, { is_active: newStatus })
|
|
|
|
|
|
// 更新本地状态(该 key 可能出现在多个格式中)
|
|
|
|
|
|
for (const fmt of Object.keys(keysByFormat.value)) {
|
|
|
|
|
|
const keys = keysByFormat.value[fmt]
|
|
|
|
|
|
const found = keys.find(k => k.id === key.id)
|
|
|
|
|
|
if (found) {
|
|
|
|
|
|
found.is_active = newStatus
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 重新排序所有格式(该 key 可能出现在多个格式中,状态变更后都需要重排)
|
|
|
|
|
|
for (const fmt of Object.keys(keysByFormat.value)) {
|
|
|
|
|
|
keysByFormat.value[fmt] = sortKeysByActiveAndPriority(keysByFormat.value[fmt])
|
|
|
|
|
|
}
|
|
|
|
|
|
success(newStatus ? 'Key 已启用' : 'Key 已停用')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '操作失败'), '错误')
|
2026-02-09 18:21:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// Key 优先级编辑
|
|
|
|
|
|
function startEditKeyPriority(format: string, key: KeyWithMeta) {
|
|
|
|
|
|
editingKeyPriority.value[format] = key.id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cancelEditKeyPriority(format: string) {
|
|
|
|
|
|
editingKeyPriority.value[format] = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function finishEditKeyPriority(format: string, key: KeyWithMeta, event: FocusEvent) {
|
|
|
|
|
|
const input = event.target as HTMLInputElement
|
|
|
|
|
|
const newPriority = parseInt(input.value, 10)
|
|
|
|
|
|
|
|
|
|
|
|
if (!isNaN(newPriority) && newPriority >= 1) {
|
2026-03-12 11:44:19 +08:00
|
|
|
|
if (isPoolAggregateItem(key) && key.provider_id) {
|
|
|
|
|
|
// 号池聚合项:写回 provider 的 pool_advanced.global_priority
|
|
|
|
|
|
updatePoolGlobalPriority(key.provider_id, newPriority)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 普通 key:直接更新优先级
|
|
|
|
|
|
key.priority = newPriority
|
|
|
|
|
|
}
|
2026-01-16 17:53:27 +08:00
|
|
|
|
// 重新排序当前格式
|
2026-02-09 18:21:34 +08:00
|
|
|
|
keysByFormat.value[format] = sortKeysByActiveAndPriority(keysByFormat.value[format])
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
editingKeyPriority.value[format] = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 18:59:01 +08:00
|
|
|
|
// Provider 优先级编辑
|
|
|
|
|
|
function startEditProviderPriority(provider: ProviderWithEndpointsSummary) {
|
|
|
|
|
|
editingProviderPriority.value = provider.id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cancelEditProviderPriority() {
|
|
|
|
|
|
editingProviderPriority.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function finishEditProviderPriority(provider: ProviderWithEndpointsSummary, event: FocusEvent) {
|
|
|
|
|
|
const input = event.target as HTMLInputElement
|
|
|
|
|
|
const newPriority = parseInt(input.value, 10)
|
|
|
|
|
|
|
|
|
|
|
|
if (!isNaN(newPriority) && newPriority >= 1) {
|
|
|
|
|
|
// 更新该 provider 的优先级
|
|
|
|
|
|
const idx = sortedProviders.value.findIndex(p => p.id === provider.id)
|
|
|
|
|
|
if (idx !== -1) {
|
|
|
|
|
|
sortedProviders.value[idx] = {
|
|
|
|
|
|
...sortedProviders.value[idx],
|
|
|
|
|
|
provider_priority: newPriority
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-23 16:10:53 +08:00
|
|
|
|
// 重新排序
|
|
|
|
|
|
sortedProviders.value = sortProvidersByActiveAndPriority(sortedProviders.value)
|
2026-01-15 18:59:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
editingProviderPriority.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// Provider 拖拽处理
|
|
|
|
|
|
function handleProviderDragStart(index: number, event: DragEvent) {
|
|
|
|
|
|
draggedProvider.value = index
|
|
|
|
|
|
if (event.dataTransfer) {
|
|
|
|
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
|
|
|
|
event.dataTransfer.setData('text/html', '')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleProviderDragEnd() {
|
|
|
|
|
|
draggedProvider.value = null
|
|
|
|
|
|
dragOverProvider.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleProviderDragOver(index: number) {
|
|
|
|
|
|
dragOverProvider.value = index
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleProviderDragLeave() {
|
|
|
|
|
|
dragOverProvider.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleProviderDrop(dropIndex: number) {
|
|
|
|
|
|
if (draggedProvider.value === null || draggedProvider.value === dropIndex) {
|
2026-01-15 18:59:01 +08:00
|
|
|
|
draggedProvider.value = null
|
|
|
|
|
|
dragOverProvider.value = null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 18:59:01 +08:00
|
|
|
|
const providers = sortedProviders.value
|
2026-01-23 00:07:07 +08:00
|
|
|
|
const dragIndex = draggedProvider.value
|
|
|
|
|
|
const draggedItem = providers[dragIndex]
|
2026-01-15 18:59:01 +08:00
|
|
|
|
const targetItem = providers[dropIndex]
|
|
|
|
|
|
const draggedPriority = draggedItem.provider_priority
|
|
|
|
|
|
const targetPriority = targetItem.provider_priority
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-22 20:54:19 +08:00
|
|
|
|
// 同优先级组内拖拽,忽略
|
2026-01-15 18:59:01 +08:00
|
|
|
|
if (draggedPriority === targetPriority) {
|
|
|
|
|
|
draggedProvider.value = null
|
|
|
|
|
|
dragOverProvider.value = null
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-23 00:07:07 +08:00
|
|
|
|
// 记录每个 provider 的原始优先级
|
|
|
|
|
|
const originalPriorityMap = new Map<string, number>()
|
|
|
|
|
|
providers.forEach(p => {
|
|
|
|
|
|
originalPriorityMap.set(p.id, p.provider_priority)
|
2026-01-22 20:54:19 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-23 00:07:07 +08:00
|
|
|
|
// 重排数组:将被拖动项移到目标位置
|
|
|
|
|
|
const items = [...providers]
|
|
|
|
|
|
items.splice(dragIndex, 1)
|
|
|
|
|
|
items.splice(dropIndex, 0, draggedItem)
|
|
|
|
|
|
|
|
|
|
|
|
// 按新顺序分配优先级:被拖动项单独成组,其他同组项保持在一起
|
|
|
|
|
|
const groupNewPriority = new Map<number, number>()
|
|
|
|
|
|
let currentPriority = 1
|
|
|
|
|
|
|
|
|
|
|
|
items.forEach(provider => {
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const originalPriority = originalPriorityMap.get(provider.id) ?? 0
|
2026-01-23 00:07:07 +08:00
|
|
|
|
|
|
|
|
|
|
if (provider === draggedItem) {
|
|
|
|
|
|
// 被拖动的项单独成组
|
|
|
|
|
|
provider.provider_priority = currentPriority
|
|
|
|
|
|
currentPriority++
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (groupNewPriority.has(originalPriority)) {
|
|
|
|
|
|
// 同组的其他项使用相同的新优先级
|
2026-02-22 00:43:41 +08:00
|
|
|
|
provider.provider_priority = groupNewPriority.get(originalPriority) ?? currentPriority
|
2026-01-23 00:07:07 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// 新组,分配新优先级
|
|
|
|
|
|
groupNewPriority.set(originalPriority, currentPriority)
|
|
|
|
|
|
provider.provider_priority = currentPriority
|
|
|
|
|
|
currentPriority++
|
|
|
|
|
|
}
|
2026-01-22 20:54:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-15 18:59:01 +08:00
|
|
|
|
// 重新排序
|
2026-01-23 16:10:53 +08:00
|
|
|
|
sortedProviders.value = sortProvidersByActiveAndPriority(items)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
draggedProvider.value = null
|
2026-01-15 18:59:01 +08:00
|
|
|
|
dragOverProvider.value = null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Key 拖拽处理
|
2026-03-04 12:59:27 +08:00
|
|
|
|
function getEditableKeysForFormat(format: string): KeyWithMeta[] {
|
2026-03-12 11:44:19 +08:00
|
|
|
|
return displayKeysByFormat.value[format] || []
|
2026-03-04 12:59:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleKeyDragStart(format: string, keyId: string, event: DragEvent) {
|
|
|
|
|
|
draggedKey.value[format] = keyId
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (event.dataTransfer) {
|
|
|
|
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
|
|
|
|
event.dataTransfer.setData('text/html', '')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleKeyDragEnd(format: string) {
|
|
|
|
|
|
draggedKey.value[format] = null
|
|
|
|
|
|
dragOverKey.value[format] = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 12:59:27 +08:00
|
|
|
|
function handleKeyDragOver(format: string, keyId: string) {
|
|
|
|
|
|
dragOverKey.value[format] = keyId
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleKeyDragLeave(format: string) {
|
|
|
|
|
|
dragOverKey.value[format] = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 12:59:27 +08:00
|
|
|
|
function handleKeyDrop(format: string, dropKeyId: string) {
|
|
|
|
|
|
const draggedKeyId = draggedKey.value[format]
|
|
|
|
|
|
if (!draggedKeyId || draggedKeyId === dropKeyId) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
draggedKey.value[format] = null
|
2026-01-15 18:59:01 +08:00
|
|
|
|
dragOverKey.value[format] = null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 12:59:27 +08:00
|
|
|
|
const editableKeys = getEditableKeysForFormat(format)
|
|
|
|
|
|
const dragIndex = editableKeys.findIndex((k) => k.id === draggedKeyId)
|
|
|
|
|
|
const dropIndex = editableKeys.findIndex((k) => k.id === dropKeyId)
|
|
|
|
|
|
if (dragIndex === -1 || dropIndex === -1) {
|
|
|
|
|
|
draggedKey.value[format] = null
|
|
|
|
|
|
dragOverKey.value[format] = null
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const draggedItem = editableKeys[dragIndex]
|
|
|
|
|
|
const targetItem = editableKeys[dropIndex]
|
2026-01-15 18:59:01 +08:00
|
|
|
|
const draggedPriority = draggedItem.priority
|
|
|
|
|
|
const targetPriority = targetItem.priority
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-22 20:54:19 +08:00
|
|
|
|
// 同优先级组内拖拽,忽略
|
2026-01-15 18:59:01 +08:00
|
|
|
|
if (draggedPriority === targetPriority) {
|
|
|
|
|
|
draggedKey.value[format] = null
|
|
|
|
|
|
dragOverKey.value[format] = null
|
|
|
|
|
|
return
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-23 00:07:07 +08:00
|
|
|
|
// 记录每个 key 的原始优先级
|
|
|
|
|
|
const originalPriorityMap = new Map<string, number>()
|
2026-03-04 12:59:27 +08:00
|
|
|
|
editableKeys.forEach(k => {
|
2026-01-23 00:07:07 +08:00
|
|
|
|
originalPriorityMap.set(k.id, k.priority)
|
2026-01-22 20:54:19 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-23 00:07:07 +08:00
|
|
|
|
// 重排数组:将被拖动项移到目标位置
|
2026-03-04 12:59:27 +08:00
|
|
|
|
const items = editableKeys.map((key) => ({ ...key }))
|
|
|
|
|
|
const dragItemIndex = items.findIndex((k) => k.id === draggedKeyId)
|
|
|
|
|
|
const dropItemIndex = items.findIndex((k) => k.id === dropKeyId)
|
|
|
|
|
|
if (dragItemIndex === -1 || dropItemIndex === -1) {
|
|
|
|
|
|
draggedKey.value[format] = null
|
|
|
|
|
|
dragOverKey.value[format] = null
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const draggedClone = items[dragItemIndex]
|
|
|
|
|
|
items.splice(dragItemIndex, 1)
|
|
|
|
|
|
items.splice(dropItemIndex, 0, draggedClone)
|
2026-01-23 00:07:07 +08:00
|
|
|
|
|
|
|
|
|
|
// 按新顺序分配优先级:被拖动项单独成组,其他同组项保持在一起
|
|
|
|
|
|
const groupNewPriority = new Map<number, number>()
|
|
|
|
|
|
let currentPriority = 1
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
2026-03-04 12:59:27 +08:00
|
|
|
|
if (key.id === draggedKeyId) {
|
2026-01-23 00:07:07 +08:00
|
|
|
|
// 被拖动的项单独成组
|
|
|
|
|
|
key.priority = currentPriority
|
|
|
|
|
|
currentPriority++
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (groupNewPriority.has(originalPriority)) {
|
|
|
|
|
|
// 同组的其他项使用相同的新优先级
|
2026-02-22 00:43:41 +08:00
|
|
|
|
key.priority = groupNewPriority.get(originalPriority) ?? currentPriority
|
2026-01-23 00:07:07 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// 新组,分配新优先级
|
|
|
|
|
|
groupNewPriority.set(originalPriority, currentPriority)
|
|
|
|
|
|
key.priority = currentPriority
|
|
|
|
|
|
currentPriority++
|
|
|
|
|
|
}
|
2026-01-22 20:54:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-04 12:59:27 +08:00
|
|
|
|
const updatedPriorityById = new Map<string, number>()
|
|
|
|
|
|
items.forEach((key) => {
|
|
|
|
|
|
updatedPriorityById.set(key.id, key.priority)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-12 11:44:19 +08:00
|
|
|
|
// 将新的优先级写回原始数据(普通 key)
|
2026-03-04 12:59:27 +08:00
|
|
|
|
keysByFormat.value[format] = sortKeysByActiveAndPriority(
|
|
|
|
|
|
(keysByFormat.value[format] || []).map((key) => {
|
|
|
|
|
|
const nextPriority = updatedPriorityById.get(key.id)
|
|
|
|
|
|
if (nextPriority == null) return key
|
|
|
|
|
|
return {
|
|
|
|
|
|
...key,
|
|
|
|
|
|
priority: nextPriority,
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
2026-03-12 11:44:19 +08:00
|
|
|
|
|
|
|
|
|
|
// 号池聚合项的优先级写回 provider 的 pool_advanced.global_priority
|
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
|
if (item.is_pool_aggregate && item.provider_id) {
|
|
|
|
|
|
updatePoolGlobalPriority(item.provider_id, item.priority)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
draggedKey.value[format] = null
|
2026-01-15 18:59:01 +08:00
|
|
|
|
dragOverKey.value[format] = null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 保存
|
|
|
|
|
|
async function save() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
saving.value = true
|
|
|
|
|
|
|
|
|
|
|
|
const newMode = activeMainTab.value === 'key' ? 'global_key' : 'provider'
|
|
|
|
|
|
|
2026-02-09 18:21:34 +08:00
|
|
|
|
// 第一步:先保存所有 Provider 和 Key 的优先级数据
|
|
|
|
|
|
// 确保优先级数据全部到位后,再切换调度模式,避免瞬态不一致
|
2026-03-12 17:11:14 +08:00
|
|
|
|
const providerTasks: Array<() => Promise<unknown>> = []
|
|
|
|
|
|
sortedProviders.value.forEach((provider) => {
|
|
|
|
|
|
const payload: Parameters<typeof updateProvider>[1] = {}
|
|
|
|
|
|
const currentProviderPriority = Math.max(0, Math.trunc(provider.provider_priority))
|
|
|
|
|
|
const originalProviderPriority = originalProviderPriorityById.get(provider.id)
|
|
|
|
|
|
if (originalProviderPriority == null || originalProviderPriority !== currentProviderPriority) {
|
|
|
|
|
|
payload.provider_priority = currentProviderPriority
|
2026-03-12 11:44:19 +08:00
|
|
|
|
}
|
2026-03-12 17:11:14 +08:00
|
|
|
|
|
|
|
|
|
|
const currentPoolPriority = normalizeOptionalPriority(provider.pool_advanced?.global_priority)
|
|
|
|
|
|
const originalPoolPriority = originalPoolPriorityByProviderId.get(provider.id) ?? null
|
|
|
|
|
|
if (currentPoolPriority !== originalPoolPriority) {
|
2026-03-12 11:44:19 +08:00
|
|
|
|
payload.pool_advanced = provider.pool_advanced
|
2026-03-12 17:11:14 +08:00
|
|
|
|
? {
|
|
|
|
|
|
...provider.pool_advanced,
|
|
|
|
|
|
global_priority: currentPoolPriority,
|
|
|
|
|
|
}
|
|
|
|
|
|
: null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (Object.keys(payload).length > 0) {
|
2026-04-05 20:23:16 +08:00
|
|
|
|
providerTasks.push(() => updateProvider(
|
|
|
|
|
|
provider.id,
|
|
|
|
|
|
payload,
|
|
|
|
|
|
{ timeout: PRIORITY_REQUEST_TIMEOUT_MS },
|
|
|
|
|
|
))
|
2026-03-12 11:44:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-01-16 17:53:27 +08:00
|
|
|
|
// 收集每个 Key 的按格式优先级(保留原有其他格式的配置)
|
2026-03-12 17:11:14 +08:00
|
|
|
|
const keyPriorityByFormatMap = buildEditableKeyPriorityMap()
|
|
|
|
|
|
const keyTasks = Array.from(keyPriorityByFormatMap.entries())
|
|
|
|
|
|
.filter(([keyId, priorityByFormat]) => !arePriorityMapsEqual(
|
|
|
|
|
|
originalKeyPriorityById.get(keyId),
|
|
|
|
|
|
priorityByFormat,
|
|
|
|
|
|
))
|
|
|
|
|
|
.map(([keyId, priorityByFormat]) => () =>
|
2026-04-05 20:23:16 +08:00
|
|
|
|
updateProviderKey(
|
|
|
|
|
|
keyId,
|
|
|
|
|
|
{ global_priority_by_format: priorityByFormat },
|
|
|
|
|
|
{ timeout: PRIORITY_REQUEST_TIMEOUT_MS },
|
|
|
|
|
|
)
|
2026-03-12 17:11:14 +08:00
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-12 17:11:14 +08:00
|
|
|
|
await runTasksWithConcurrency([...providerTasks, ...keyTasks])
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-02-09 18:21:34 +08:00
|
|
|
|
// 第二步:优先级数据全部就绪后,顺序保存调度配置
|
|
|
|
|
|
// 先保存优先级模式,再保存调度模式,确保 Scheduler 状态完整切换
|
|
|
|
|
|
await adminApi.updateSystemConfig(
|
|
|
|
|
|
'provider_priority_mode',
|
|
|
|
|
|
newMode,
|
2026-04-05 20:23:16 +08:00
|
|
|
|
'Provider/Key 优先级策略:provider(提供商优先模式) 或 global_key(全局Key优先模式)',
|
|
|
|
|
|
{ timeout: PRIORITY_REQUEST_TIMEOUT_MS },
|
2026-02-09 18:21:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
await adminApi.updateSystemConfig(
|
|
|
|
|
|
'scheduling_mode',
|
|
|
|
|
|
schedulingMode.value,
|
2026-04-05 20:23:16 +08:00
|
|
|
|
'调度模式:cache_affinity(缓存亲和模式) 或 load_balance(负载均衡模式) 或 fixed_order(固定顺序模式)',
|
|
|
|
|
|
{ timeout: PRIORITY_REQUEST_TIMEOUT_MS },
|
2026-02-09 18:21:34 +08:00
|
|
|
|
)
|
2026-04-05 20:23:16 +08:00
|
|
|
|
snapshotCurrentPriorityBaseline()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
success('优先级已保存')
|
|
|
|
|
|
emit('saved')
|
|
|
|
|
|
|
|
|
|
|
|
// 提供商优先模式保存后关闭,Key 优先模式保存后保持打开方便继续调整
|
|
|
|
|
|
if (activeMainTab.value === 'provider') {
|
|
|
|
|
|
close()
|
|
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '保存失败'), '错误')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function close() {
|
|
|
|
|
|
emit('update:modelValue', false)
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|