2026-01-12 23:28:37 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<Card class="overflow-hidden">
|
|
|
|
|
|
<!-- 标题栏 -->
|
|
|
|
|
|
<div class="px-4 py-3 border-b border-border/60">
|
|
|
|
|
|
<div class="flex items-center justify-between gap-4">
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<div class="flex items-baseline gap-2">
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<h4 class="text-sm font-semibold">
|
2026-01-13 18:42:15 +08:00
|
|
|
|
链路预览
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</h4>
|
|
|
|
|
|
<template v-if="routingData">
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<span class="text-xs text-muted-foreground">·</span>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<span class="text-xs text-muted-foreground">
|
|
|
|
|
|
{{ getSchedulingModeLabel(routingData.scheduling_mode) }}
|
|
|
|
|
|
</span>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<span class="text-xs text-muted-foreground">·</span>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<span class="text-xs text-muted-foreground">
|
|
|
|
|
|
{{ getPriorityModeLabel(routingData.priority_mode) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
2026-01-13 18:42:15 +08:00
|
|
|
|
title="关联提供商"
|
2026-01-12 23:28:37 +08:00
|
|
|
|
@click="$emit('addProvider')"
|
|
|
|
|
|
>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<Link class="w-3.5 h-3.5" />
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="刷新"
|
|
|
|
|
|
@click="loadRoutingData"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw
|
|
|
|
|
|
class="w-3.5 h-3.5"
|
|
|
|
|
|
:class="loading ? 'animate-spin' : ''"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 加载状态 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="loading"
|
|
|
|
|
|
class="flex items-center justify-center py-12"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else-if="routingData">
|
|
|
|
|
|
<div class="p-4 space-y-4">
|
|
|
|
|
|
<!-- 空状态 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="apiFormatGroups.length === 0"
|
|
|
|
|
|
class="text-center py-8"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Route class="w-10 h-10 mx-auto text-muted-foreground/30 mb-3" />
|
|
|
|
|
|
<p class="text-sm text-muted-foreground">
|
|
|
|
|
|
暂无关联提供商
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground mt-1">
|
|
|
|
|
|
请先为此模型添加提供商关联
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- API 格式分组 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="formatGroup in apiFormatGroups"
|
|
|
|
|
|
:key="formatGroup.api_format"
|
|
|
|
|
|
class="border border-border/60 rounded-lg overflow-hidden"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 格式标题栏 -->
|
|
|
|
|
|
<div
|
2026-01-13 16:04:15 +08:00
|
|
|
|
class="px-4 py-3 bg-muted/30 flex items-center justify-between cursor-pointer hover:bg-muted/50 transition-colors"
|
2026-01-12 23:28:37 +08:00
|
|
|
|
@click="toggleFormat(formatGroup.api_format)"
|
|
|
|
|
|
>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<div class="flex items-center gap-3">
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
variant="secondary"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
class="text-xs font-semibold px-2.5 py-1"
|
2026-01-12 23:28:37 +08:00
|
|
|
|
>
|
2026-02-19 14:51:08 +08:00
|
|
|
|
{{ formatApiFormat(formatGroup.api_format) }}
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</Badge>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center gap-3">
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<span class="text-sm text-muted-foreground">
|
2026-01-12 23:28:37 +08:00
|
|
|
|
{{ formatGroup.active_keys }}/{{ formatGroup.total_keys }} Keys
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<span class="mx-1.5">·</span>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
{{ formatGroup.active_providers }}/{{ formatGroup.total_providers }} 提供商
|
|
|
|
|
|
</span>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<ChevronDown
|
|
|
|
|
|
class="w-4 h-4 text-muted-foreground transition-transform"
|
|
|
|
|
|
:class="isFormatExpanded(formatGroup.api_format) ? 'rotate-180' : ''"
|
|
|
|
|
|
/>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 展开的内容 -->
|
|
|
|
|
|
<Transition name="collapse">
|
|
|
|
|
|
<div v-if="isFormatExpanded(formatGroup.api_format)">
|
|
|
|
|
|
<!-- ========== 全局 Key 优先模式 ========== -->
|
|
|
|
|
|
<template v-if="isGlobalKeyMode">
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<div class="py-2 pl-3">
|
|
|
|
|
|
<template
|
|
|
|
|
|
v-for="(keyGroup, groupIndex) in formatGroup.keyGroups"
|
|
|
|
|
|
:key="groupIndex"
|
|
|
|
|
|
>
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<!-- 第一组且有多个 key 时显示调度行为标签 -->
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="groupIndex === 0 && keyGroup.keys.length > 1"
|
|
|
|
|
|
class="ml-6 mr-3 mb-1 flex items-center gap-1 text-[10px] text-muted-foreground/60"
|
2026-01-12 23:28:37 +08:00
|
|
|
|
>
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<span>{{ samePriorityLabel }}</span>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</div>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<!-- 该优先级组内的 Keys -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(keyEntry, keyIndex) in keyGroup.keys"
|
|
|
|
|
|
:key="keyEntry.key.id"
|
|
|
|
|
|
class="flex py-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 左侧:节点 + 连线 -->
|
|
|
|
|
|
<div class="w-6 flex flex-col items-center shrink-0">
|
|
|
|
|
|
<!-- 上半段连线 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="w-0.5 flex-1"
|
|
|
|
|
|
:class="groupIndex === 0 && keyIndex === 0 ? 'bg-transparent' : 'bg-border'"
|
|
|
|
|
|
/>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<!-- 节点圆点 -->
|
|
|
|
|
|
<div
|
2026-01-13 16:04:15 +08:00
|
|
|
|
class="w-3 h-3 rounded-full border-2 shrink-0"
|
2026-01-12 23:28:37 +08:00
|
|
|
|
:class="getGlobalKeyNodeClass(keyEntry, groupIndex, keyIndex)"
|
|
|
|
|
|
/>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<!-- 下半段连线 -->
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<div
|
2026-01-13 16:04:15 +08:00
|
|
|
|
class="w-0.5 flex-1"
|
|
|
|
|
|
:class="isLastKeyInFormat(formatGroup, groupIndex, keyIndex) ? 'bg-transparent' : 'bg-border'"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Key 卡片 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="flex-1 mr-3"
|
|
|
|
|
|
:class="!keyEntry.key.is_active ? 'opacity-50' : ''"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
2026-01-13 18:42:15 +08:00
|
|
|
|
class="group rounded-lg transition-all px-3 py-2"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
:class="getGlobalKeyCardClass(keyEntry, groupIndex, keyIndex)"
|
2026-01-12 23:28:37 +08:00
|
|
|
|
>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
|
<!-- 优先级标签 -->
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="keyEntry.key.is_active"
|
2026-01-13 18:42:15 +08:00
|
|
|
|
class="px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
:class="groupIndex === 0 && keyIndex === 0
|
|
|
|
|
|
? 'bg-primary text-primary-foreground'
|
|
|
|
|
|
: 'bg-muted-foreground/20 text-muted-foreground'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span v-if="groupIndex === 0 && keyIndex === 0">首选</span>
|
|
|
|
|
|
<span v-else>P{{ keyGroup.priority ?? '?' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<!-- Key 信息:两行 -->
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<div class="min-w-0 flex-1">
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<!-- 第一行:Key 名称 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="text-sm font-medium truncate"
|
|
|
|
|
|
:class="keyEntry.key.circuit_breaker_open ? 'text-destructive' : ''"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ keyEntry.key.name }}
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</div>
|
2026-01-15 02:36:00 +08:00
|
|
|
|
<!-- 第二行:提供商名 · sk · 模型映射 -->
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<div class="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
|
|
|
|
<span>{{ keyEntry.provider.name }}</span>
|
|
|
|
|
|
<span>·</span>
|
|
|
|
|
|
<code class="text-muted-foreground/60">{{ keyEntry.key.masked_key }}</code>
|
2026-01-15 02:36:00 +08:00
|
|
|
|
<!-- Key 白名单映射显示 -->
|
|
|
|
|
|
<template v-if="getKeyMatchedModels(keyEntry.key).length > 0">
|
|
|
|
|
|
<span>·</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="text-primary/70"
|
|
|
|
|
|
:title="getKeyMatchedModels(keyEntry.key).join(', ')"
|
|
|
|
|
|
>{{ formatMatchedModels(getKeyMatchedModels(keyEntry.key)) }}</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<!-- Provider 模型映射显示 -->
|
|
|
|
|
|
<template v-else-if="hasModelMapping(keyEntry.provider)">
|
|
|
|
|
|
<span>·</span>
|
|
|
|
|
|
<span class="text-primary/70">{{ keyEntry.provider.provider_model_name }}</span>
|
|
|
|
|
|
</template>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<!-- 熔断徽章 -->
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="keyEntry.key.circuit_breaker_open"
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0 shrink-0 tabular-nums"
|
|
|
|
|
|
>
|
|
|
|
|
|
熔断{{ getKeyProbeCountdown(keyEntry.key) }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 健康度 -->
|
|
|
|
|
|
<div class="flex items-center gap-1 shrink-0">
|
|
|
|
|
|
<div class="w-10 h-1.5 bg-muted/80 rounded-full overflow-hidden">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="h-full transition-all duration-300"
|
|
|
|
|
|
:class="getHealthScoreBarColor(keyEntry.key.health_score)"
|
|
|
|
|
|
:style="{ width: `${(keyEntry.key.health_score || 0) * 100}%` }"
|
|
|
|
|
|
/>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</div>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<span
|
2026-01-13 18:42:15 +08:00
|
|
|
|
class="text-[10px] font-medium tabular-nums w-7 text-right"
|
|
|
|
|
|
:class="getHealthScoreTextColor(keyEntry.key.health_score)"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
{{ ((keyEntry.key.health_score || 0) * 100).toFixed(0) }}%
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</span>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 操作按钮 -->
|
|
|
|
|
|
<div class="flex items-center shrink-0">
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<Button
|
2026-01-13 18:42:15 +08:00
|
|
|
|
v-if="keyEntry.key.circuit_breaker_open || (keyEntry.key.health_score ?? 1) < 0.5"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
2026-01-13 18:42:15 +08:00
|
|
|
|
class="h-6 w-6 text-green-600"
|
|
|
|
|
|
title="刷新健康状态"
|
2026-04-14 11:50:52 +08:00
|
|
|
|
@click.stop="handleRecoverKey(keyEntry.key.id, keyEntry.endpoint?.api_format || formatGroup.api_format)"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<RefreshCw class="w-3 h-3" />
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-6 w-6"
|
|
|
|
|
|
:title="keyEntry.provider.model_is_active ? '停用此关联' : '启用此关联'"
|
|
|
|
|
|
@click.stop="$emit('toggleProviderStatus', keyEntry.provider)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Power class="w-3 h-3" />
|
|
|
|
|
|
</Button>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</div>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</div>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</div>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<!-- 降级标记 -->
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="groupIndex < formatGroup.keyGroups.length - 1"
|
|
|
|
|
|
class="flex py-0.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="w-6 flex justify-center shrink-0">
|
|
|
|
|
|
<div class="w-0.5 h-full bg-border" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center gap-1 text-[10px] text-muted-foreground/50">
|
|
|
|
|
|
<ArrowDown class="w-3 h-3" />
|
|
|
|
|
|
<span>
|
2026-02-09 18:21:34 +08:00
|
|
|
|
{{ getDemoteLabel(formatGroup.keyGroups[groupIndex + 1].keys.length) }}
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ========== 提供商优先模式 ========== -->
|
|
|
|
|
|
<template v-else>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<div class="py-2 pl-3">
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<div
|
2026-01-13 16:04:15 +08:00
|
|
|
|
v-for="(providerEntry, providerIndex) in formatGroup.providers"
|
|
|
|
|
|
:key="`${providerEntry.provider.id}-${providerEntry.endpoint?.id || providerIndex}`"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 提供商行 -->
|
|
|
|
|
|
<div class="flex py-1">
|
|
|
|
|
|
<!-- 左侧:节点 + 连线 -->
|
2026-01-13 16:06:52 +08:00
|
|
|
|
<div class="w-6 flex flex-col items-center shrink-0 self-stretch">
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<!-- 上半段连线 -->
|
|
|
|
|
|
<div
|
2026-01-13 16:06:52 +08:00
|
|
|
|
class="w-0.5 h-5"
|
2026-01-13 16:04:15 +08:00
|
|
|
|
:class="providerIndex === 0 ? 'bg-transparent' : 'bg-border'"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<!-- 节点圆点 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="w-3 h-3 rounded-full border-2 shrink-0"
|
|
|
|
|
|
:class="getFormatProviderNodeClass(providerEntry, providerIndex)"
|
|
|
|
|
|
/>
|
2026-01-13 16:06:52 +08:00
|
|
|
|
<!-- 下半段连线(延伸到底部) -->
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="w-0.5 flex-1"
|
|
|
|
|
|
:class="providerIndex === formatGroup.providers.length - 1 ? 'bg-transparent' : 'bg-border'"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 提供商卡片 -->
|
|
|
|
|
|
<div
|
2026-01-13 16:04:15 +08:00
|
|
|
|
class="flex-1 mr-3"
|
2026-01-12 23:28:37 +08:00
|
|
|
|
:class="!providerEntry.provider.is_active || !providerEntry.provider.model_is_active ? 'opacity-50' : ''"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="group rounded-lg transition-all"
|
|
|
|
|
|
:class="getFormatProviderCardClass(providerEntry, providerIndex)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 卡片头部 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="p-2.5 cursor-pointer"
|
|
|
|
|
|
@click="toggleProviderInFormat(formatGroup.api_format, providerEntry.provider.id, providerEntry.endpoint?.id)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<!-- 第一列:优先级标签(固定宽度,用于对齐) -->
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="providerEntry.provider.is_active && providerEntry.provider.model_is_active"
|
2026-01-13 18:42:15 +08:00
|
|
|
|
class="min-w-8 px-1.5 py-0.5 rounded-full text-[10px] font-medium shrink-0 text-center"
|
2026-01-12 23:28:37 +08:00
|
|
|
|
:class="providerIndex === 0
|
|
|
|
|
|
? 'bg-primary text-primary-foreground'
|
|
|
|
|
|
: 'bg-muted-foreground/20 text-muted-foreground'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span v-if="providerIndex === 0">首选</span>
|
|
|
|
|
|
<span v-else>P{{ providerEntry.provider.provider_priority }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 第二列:状态指示灯 -->
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="w-1.5 h-1.5 rounded-full shrink-0"
|
|
|
|
|
|
:class="getProviderStatusClass(providerEntry.provider)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 第三列:名称(第一行) + URL(第二行) -->
|
|
|
|
|
|
<div class="min-w-0 flex-1">
|
|
|
|
|
|
<!-- 第一行:提供商名称 + 模型映射 -->
|
|
|
|
|
|
<div class="flex items-center gap-1">
|
|
|
|
|
|
<span class="text-sm font-medium truncate">{{ providerEntry.provider.name }}</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="hasModelMapping(providerEntry.provider)"
|
|
|
|
|
|
class="text-[10px] text-muted-foreground shrink-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
({{ providerEntry.provider.provider_model_name }})
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 第二行:Endpoint URL -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="providerEntry.endpoint"
|
|
|
|
|
|
class="text-[10px] text-muted-foreground truncate"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ providerEntry.endpoint.base_url }}{{ providerEntry.endpoint.custom_path || '' }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 第四列:操作区域 -->
|
|
|
|
|
|
<div class="flex items-center gap-1 shrink-0">
|
|
|
|
|
|
<!-- 计费标签 -->
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="providerEntry.provider.billing_type"
|
|
|
|
|
|
class="text-[10px] text-muted-foreground mr-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ getBillingLabel(providerEntry.provider) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<!-- Keys 统计 -->
|
|
|
|
|
|
<span class="text-[10px] text-muted-foreground">
|
|
|
|
|
|
{{ providerEntry.active_keys }}/{{ providerEntry.keys.length }} Keys
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<!-- 操作按钮 -->
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-6 w-6"
|
|
|
|
|
|
:title="providerEntry.provider.model_is_active ? '停用此关联' : '启用此关联'"
|
|
|
|
|
|
@click.stop="$emit('toggleProviderStatus', providerEntry.provider)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Power class="w-3 h-3" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<!-- 展开图标 -->
|
|
|
|
|
|
<ChevronDown
|
|
|
|
|
|
class="w-3.5 h-3.5 text-muted-foreground transition-transform"
|
|
|
|
|
|
:class="isProviderInFormatExpanded(formatGroup.api_format, providerEntry.provider.id, providerEntry.endpoint?.id) ? 'rotate-180' : ''"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 展开的 Keys 详情 -->
|
|
|
|
|
|
<Transition name="collapse">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="isProviderInFormatExpanded(formatGroup.api_format, providerEntry.provider.id, providerEntry.endpoint?.id)"
|
|
|
|
|
|
class="border-t border-border/30 p-2.5"
|
|
|
|
|
|
>
|
2026-01-15 02:36:00 +08:00
|
|
|
|
<!-- 模型映射显示 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="hasModelMapping(providerEntry.provider)"
|
|
|
|
|
|
class="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-2 px-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="text-muted-foreground/60">映射:</span>
|
|
|
|
|
|
<span class="text-primary/70 font-medium">{{ providerEntry.provider.provider_model_name }}</span>
|
|
|
|
|
|
</div>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<!-- Keys 列表 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="providerEntry.keys.length > 0"
|
2026-01-13 18:42:15 +08:00
|
|
|
|
class="relative"
|
2026-01-12 23:28:37 +08:00
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(group, groupIndex) in getKeyPriorityGroups(providerEntry.keys)"
|
|
|
|
|
|
:key="groupIndex"
|
|
|
|
|
|
>
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<!-- 第一组且有多个 key 时显示调度行为标签 -->
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="groupIndex === 0 && group.keys.length > 1"
|
2026-01-13 18:42:15 +08:00
|
|
|
|
class="flex items-center gap-1 text-[10px] text-muted-foreground/60 mb-0.5 ml-4"
|
2026-01-12 23:28:37 +08:00
|
|
|
|
>
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<span>{{ samePriorityLabel }}</span>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 该优先级组内的 Keys -->
|
|
|
|
|
|
<div class="relative">
|
|
|
|
|
|
<!-- 垂直主干线 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="group.keys.length > 1"
|
|
|
|
|
|
class="absolute left-1 top-2 w-px bg-border"
|
|
|
|
|
|
:style="{ height: `calc(100% - 1rem)` }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(key, keyIndex) in group.keys"
|
|
|
|
|
|
:key="key.id"
|
2026-01-13 18:42:15 +08:00
|
|
|
|
class="relative flex items-center gap-2"
|
2026-01-12 23:28:37 +08:00
|
|
|
|
>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<!-- 第一列:节点(与优先级标签对齐,min-w-8) -->
|
|
|
|
|
|
<div class="min-w-8 flex items-center justify-center shrink-0">
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="w-2 h-2 rounded-full border-2 z-10"
|
|
|
|
|
|
:class="groupIndex === 0 && keyIndex === 0
|
|
|
|
|
|
? 'bg-primary border-primary'
|
|
|
|
|
|
: 'bg-background border-muted-foreground/40'"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<!-- 第二列:状态灯(与提供商状态灯对齐) -->
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="w-1.5 h-1.5 rounded-full shrink-0"
|
|
|
|
|
|
:class="getKeyStatusClass(key)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 第三列:Key 信息 -->
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<div
|
2026-01-13 18:42:15 +08:00
|
|
|
|
class="flex-1 min-w-0 flex items-center gap-1.5 px-2 py-1 my-0.5 rounded text-xs"
|
2026-01-12 23:28:37 +08:00
|
|
|
|
:class="[
|
|
|
|
|
|
groupIndex === 0 ? 'bg-primary/5' : 'bg-muted/30',
|
|
|
|
|
|
!key.is_active ? 'opacity-50' : ''
|
|
|
|
|
|
]"
|
|
|
|
|
|
:title="getKeyTooltip(key)"
|
|
|
|
|
|
>
|
2026-01-15 02:36:00 +08:00
|
|
|
|
<!-- 名称 + sk + 映射(垂直堆叠) -->
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<div class="min-w-0 flex flex-col">
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="font-medium truncate"
|
|
|
|
|
|
:class="key.circuit_breaker_open ? 'text-destructive' : ''"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ key.name }}
|
|
|
|
|
|
</span>
|
2026-01-15 02:36:00 +08:00
|
|
|
|
<div class="flex items-center gap-1">
|
|
|
|
|
|
<code class="font-mono text-[10px] text-muted-foreground/60">
|
|
|
|
|
|
{{ key.masked_key }}
|
|
|
|
|
|
</code>
|
|
|
|
|
|
<template v-if="getKeyMatchedModels(key).length > 0">
|
|
|
|
|
|
<span class="text-[10px] text-muted-foreground/40">·</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="text-[10px] text-primary/70"
|
|
|
|
|
|
:title="getKeyMatchedModels(key).join(', ')"
|
|
|
|
|
|
>{{ formatMatchedModels(getKeyMatchedModels(key)) }}</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
</div>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<span class="flex-1" />
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<!-- 熔断徽章(带倒计时)- 靠右 -->
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="key.circuit_breaker_open"
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
class="text-[9px] px-1 py-0 h-4 shrink-0 tabular-nums"
|
|
|
|
|
|
>
|
|
|
|
|
|
熔断{{ getKeyProbeCountdown(key) }}
|
|
|
|
|
|
</Badge>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<!-- 健康度(进度条 + 百分比) -->
|
|
|
|
|
|
<div class="flex items-center gap-1 shrink-0">
|
|
|
|
|
|
<div class="w-8 h-1 bg-muted/80 rounded-full overflow-hidden">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="h-full transition-all duration-300"
|
|
|
|
|
|
:class="getHealthScoreBarColor(key.health_score)"
|
2026-01-13 18:42:15 +08:00
|
|
|
|
:style="{ width: `${(key.health_score || 0) * 100}%` }"
|
2026-01-12 23:28:37 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="text-[10px] font-medium tabular-nums"
|
|
|
|
|
|
:class="getHealthScoreTextColor(key.health_score)"
|
|
|
|
|
|
>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
{{ ((key.health_score || 0) * 100).toFixed(0) }}%
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<!-- 刷新健康按钮 -->
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="key.circuit_breaker_open || (key.health_score ?? 1) < 0.5"
|
|
|
|
|
|
class="p-0.5 rounded hover:bg-muted/50 text-green-600 shrink-0"
|
|
|
|
|
|
title="刷新健康状态"
|
2026-04-14 11:50:52 +08:00
|
|
|
|
@click.stop="handleRecoverKey(key.id, providerEntry.endpoint?.api_format || formatGroup.api_format)"
|
2026-01-13 18:42:15 +08:00
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw class="w-3 h-3" />
|
|
|
|
|
|
</button>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-09 18:21:34 +08:00
|
|
|
|
<!-- 优先级组之间的降级标记 -->
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="groupIndex < getKeyPriorityGroups(providerEntry.keys).length - 1"
|
2026-01-13 18:42:15 +08:00
|
|
|
|
class="flex items-center my-0.5 text-[10px] text-muted-foreground/50"
|
2026-01-12 23:28:37 +08:00
|
|
|
|
>
|
2026-01-13 18:42:15 +08:00
|
|
|
|
<!-- 箭头居中于节点列 -->
|
|
|
|
|
|
<div class="min-w-8 flex items-center justify-center shrink-0">
|
|
|
|
|
|
<ArrowDown class="w-3 h-3" />
|
|
|
|
|
|
</div>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
<span>
|
2026-02-09 18:21:34 +08:00
|
|
|
|
{{ getDemoteLabel(getKeyPriorityGroups(providerEntry.keys)[groupIndex + 1].keys.length) }}
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="text-[10px] text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
暂无可用 Key
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Transition>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-13 16:04:15 +08:00
|
|
|
|
</div>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
|
2026-01-13 16:04:15 +08:00
|
|
|
|
<!-- 降级标记 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="providerIndex < formatGroup.providers.length - 1"
|
|
|
|
|
|
class="flex py-0.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="w-6 flex justify-center shrink-0">
|
|
|
|
|
|
<div class="w-0.5 h-full bg-border" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center gap-1 text-[10px] text-muted-foreground/50">
|
|
|
|
|
|
<ArrowDown class="w-3 h-3" />
|
|
|
|
|
|
<span>降级</span>
|
2026-01-12 23:28:37 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Transition>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 错误状态 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="error"
|
|
|
|
|
|
class="p-8 text-center"
|
|
|
|
|
|
>
|
|
|
|
|
|
<AlertCircle class="w-12 h-12 mx-auto text-destructive/50 mb-3" />
|
|
|
|
|
|
<p class="text-sm text-destructive">
|
|
|
|
|
|
{{ error }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="mt-4"
|
|
|
|
|
|
@click="loadRoutingData"
|
|
|
|
|
|
>
|
|
|
|
|
|
重试
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, computed, watch, onMounted } from 'vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
Loader2,
|
|
|
|
|
|
ArrowDown,
|
|
|
|
|
|
ChevronDown,
|
|
|
|
|
|
Route,
|
|
|
|
|
|
AlertCircle,
|
|
|
|
|
|
Power,
|
2026-01-13 18:42:15 +08:00
|
|
|
|
Link
|
2026-01-12 23:28:37 +08:00
|
|
|
|
} from 'lucide-vue-next'
|
|
|
|
|
|
import Card from '@/components/ui/card.vue'
|
|
|
|
|
|
import Badge from '@/components/ui/badge.vue'
|
|
|
|
|
|
import Button from '@/components/ui/button.vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
getGlobalModelRoutingPreview,
|
|
|
|
|
|
type ModelRoutingPreviewResponse,
|
|
|
|
|
|
type RoutingProviderInfo,
|
|
|
|
|
|
type RoutingKeyInfo,
|
|
|
|
|
|
type RoutingEndpointInfo
|
|
|
|
|
|
} from '@/api/global-models'
|
|
|
|
|
|
import { API_FORMAT_ORDER } from '@/api/endpoints/types'
|
2026-02-19 14:51:08 +08:00
|
|
|
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
2026-01-13 18:42:15 +08:00
|
|
|
|
import { recoverKeyHealth } from '@/api/endpoints/health'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
2026-01-13 18:42:15 +08:00
|
|
|
|
import { useToast } from '@/composables/useToast'
|
|
|
|
|
|
import { useCountdownTimer, getProbeCountdown } from '@/composables/useCountdownTimer'
|
2026-01-15 17:03:19 +08:00
|
|
|
|
import { MAX_MODEL_NAME_LENGTH, createLRURegexCache, getCompiledModelMappingRegex } from '@/features/models/utils/model-mapping-regex'
|
2026-01-12 23:28:37 +08:00
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
globalModelId: string
|
2026-02-10 23:47:47 +08:00
|
|
|
|
routingData?: ModelRoutingPreviewResponse | null
|
|
|
|
|
|
loading?: boolean
|
|
|
|
|
|
error?: string | null
|
2026-01-12 23:28:37 +08:00
|
|
|
|
}>()
|
|
|
|
|
|
|
2026-01-13 18:42:15 +08:00
|
|
|
|
const emit = defineEmits<{
|
2026-01-12 23:28:37 +08:00
|
|
|
|
editProvider: [provider: RoutingProviderInfo]
|
|
|
|
|
|
toggleProviderStatus: [provider: RoutingProviderInfo]
|
|
|
|
|
|
deleteProvider: [provider: RoutingProviderInfo]
|
|
|
|
|
|
addProvider: []
|
|
|
|
|
|
refresh: []
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
2026-01-13 18:42:15 +08:00
|
|
|
|
const { success: showSuccess, error: showError } = useToast()
|
|
|
|
|
|
const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer()
|
|
|
|
|
|
|
2026-02-10 23:47:47 +08:00
|
|
|
|
// 使用外部传入的数据或内部状态
|
|
|
|
|
|
const internalRoutingData = ref<ModelRoutingPreviewResponse | null>(null)
|
|
|
|
|
|
const internalLoading = ref(false)
|
|
|
|
|
|
const internalError = ref<string | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
// 计算属性:优先使用外部传入的数据
|
|
|
|
|
|
const routingData = computed(() => props.routingData ?? internalRoutingData.value)
|
|
|
|
|
|
const loading = computed(() => props.loading ?? internalLoading.value)
|
|
|
|
|
|
const error = computed(() => props.error ?? internalError.value)
|
|
|
|
|
|
|
2026-01-15 17:03:19 +08:00
|
|
|
|
const modelMappingRegexCache = createLRURegexCache(200)
|
|
|
|
|
|
const keyMatchedModelsCache = new Map<string, string[]>()
|
|
|
|
|
|
const compiledGlobalModelMappingRegexes = ref<RegExp[]>([])
|
2026-01-12 23:28:37 +08:00
|
|
|
|
|
|
|
|
|
|
// 是否为全局 Key 优先模式
|
|
|
|
|
|
const isGlobalKeyMode = computed(() => routingData.value?.priority_mode === 'global_key')
|
|
|
|
|
|
|
|
|
|
|
|
// ========== 数据结构定义 ==========
|
|
|
|
|
|
|
|
|
|
|
|
interface FormatProviderEntry {
|
|
|
|
|
|
provider: RoutingProviderInfo
|
|
|
|
|
|
endpoint: RoutingEndpointInfo | null
|
|
|
|
|
|
keys: RoutingKeyInfo[]
|
|
|
|
|
|
active_keys: number
|
2026-04-14 11:50:52 +08:00
|
|
|
|
is_cross_format: boolean
|
|
|
|
|
|
priority_api_format: string
|
2026-01-12 23:28:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 全局 Key 模式下的 Key 条目(包含 Provider 信息)
|
|
|
|
|
|
interface GlobalKeyEntry {
|
|
|
|
|
|
key: RoutingKeyInfo
|
|
|
|
|
|
provider: RoutingProviderInfo
|
|
|
|
|
|
endpoint: RoutingEndpointInfo | null
|
2026-04-14 11:50:52 +08:00
|
|
|
|
is_cross_format: boolean
|
|
|
|
|
|
priority_api_format: string
|
2026-01-12 23:28:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 全局 Key 模式下的优先级分组
|
|
|
|
|
|
interface GlobalKeyGroup {
|
|
|
|
|
|
priority: number | null
|
2026-04-14 11:50:52 +08:00
|
|
|
|
demote_cross_format: boolean
|
2026-01-12 23:28:37 +08:00
|
|
|
|
keys: GlobalKeyEntry[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ApiFormatGroup {
|
|
|
|
|
|
api_format: string
|
|
|
|
|
|
// 提供商优先模式使用
|
|
|
|
|
|
providers: FormatProviderEntry[]
|
|
|
|
|
|
// 全局 Key 优先模式使用
|
|
|
|
|
|
keyGroups: GlobalKeyGroup[]
|
|
|
|
|
|
total_providers: number
|
|
|
|
|
|
active_providers: number
|
|
|
|
|
|
total_keys: number
|
|
|
|
|
|
active_keys: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 11:50:52 +08:00
|
|
|
|
const STANDARD_ROUTING_API_FORMATS = [
|
|
|
|
|
|
'openai:chat',
|
2026-04-26 20:32:55 +08:00
|
|
|
|
'openai:responses',
|
2026-04-29 09:25:19 +08:00
|
|
|
|
'claude:messages',
|
|
|
|
|
|
'gemini:generate_content'
|
2026-04-14 11:50:52 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-04-26 21:47:01 +08:00
|
|
|
|
function normalizeLegacyOpenAIFormatAlias(apiFormat: string): string {
|
2026-04-14 11:50:52 +08:00
|
|
|
|
switch (apiFormat.trim().toLowerCase()) {
|
2026-04-26 21:47:01 +08:00
|
|
|
|
default:
|
|
|
|
|
|
return apiFormat.trim().toLowerCase()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function apiDataFormatId(apiFormat: string): string | null {
|
|
|
|
|
|
switch (normalizeLegacyOpenAIFormatAlias(apiFormat)) {
|
2026-04-29 09:25:19 +08:00
|
|
|
|
case 'claude:messages':
|
2026-04-14 11:50:52 +08:00
|
|
|
|
return 'claude'
|
2026-04-29 09:25:19 +08:00
|
|
|
|
case 'gemini:generate_content':
|
2026-04-14 11:50:52 +08:00
|
|
|
|
return 'gemini'
|
|
|
|
|
|
case 'openai:chat':
|
|
|
|
|
|
return 'openai_chat'
|
2026-04-26 20:32:55 +08:00
|
|
|
|
case 'openai:responses':
|
|
|
|
|
|
case 'openai:responses:compact':
|
2026-04-14 11:50:52 +08:00
|
|
|
|
return 'openai_responses'
|
|
|
|
|
|
default:
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function requestConversionKind(clientApiFormat: string, providerApiFormat: string): string | null {
|
2026-04-26 21:47:01 +08:00
|
|
|
|
const client = normalizeLegacyOpenAIFormatAlias(clientApiFormat)
|
|
|
|
|
|
const provider = normalizeLegacyOpenAIFormatAlias(providerApiFormat)
|
2026-04-14 11:50:52 +08:00
|
|
|
|
if (client === provider) return null
|
|
|
|
|
|
if (!STANDARD_ROUTING_API_FORMATS.includes(client) || !STANDARD_ROUTING_API_FORMATS.includes(provider)) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch (provider) {
|
|
|
|
|
|
case 'openai:chat':
|
|
|
|
|
|
return 'to_openai_chat'
|
2026-04-26 20:32:55 +08:00
|
|
|
|
case 'openai:responses':
|
|
|
|
|
|
return 'to_openai_responses'
|
2026-04-29 09:25:19 +08:00
|
|
|
|
case 'claude:messages':
|
2026-04-14 11:50:52 +08:00
|
|
|
|
return 'to_claude'
|
2026-04-29 09:25:19 +08:00
|
|
|
|
case 'gemini:generate_content':
|
2026-04-14 11:50:52 +08:00
|
|
|
|
return 'to_gemini'
|
|
|
|
|
|
default:
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function requestConversionRequiresEnableFlag(clientApiFormat: string, providerApiFormat: string): boolean {
|
|
|
|
|
|
const clientDataFormat = apiDataFormatId(clientApiFormat)
|
|
|
|
|
|
const providerDataFormat = apiDataFormatId(providerApiFormat)
|
|
|
|
|
|
if (!clientDataFormat || !providerDataFormat) return true
|
|
|
|
|
|
return clientDataFormat !== providerDataFormat
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function endpointFormatAcceptanceEnabled(endpoint: RoutingEndpointInfo): boolean {
|
|
|
|
|
|
return endpoint.format_acceptance_config?.enabled === true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function endpointFormatListContains(formats: string[] | null | undefined, apiFormat: string): boolean {
|
|
|
|
|
|
if (!Array.isArray(formats)) return false
|
|
|
|
|
|
return formats.some(value => value.trim().toLowerCase() === apiFormat.trim().toLowerCase())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function endpointAcceptsClientFormat(endpoint: RoutingEndpointInfo, clientApiFormat: string): boolean {
|
|
|
|
|
|
if (!endpointFormatAcceptanceEnabled(endpoint)) return false
|
|
|
|
|
|
|
|
|
|
|
|
const config = endpoint.format_acceptance_config
|
|
|
|
|
|
if (endpointFormatListContains(config?.reject_formats, clientApiFormat)) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if (config?.accept_formats) {
|
|
|
|
|
|
return endpointFormatListContains(config.accept_formats, clientApiFormat)
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function endpointSupportsClientFormat(
|
|
|
|
|
|
provider: RoutingProviderInfo,
|
|
|
|
|
|
endpoint: RoutingEndpointInfo,
|
|
|
|
|
|
clientApiFormat: string,
|
|
|
|
|
|
providerApiFormat: string
|
|
|
|
|
|
): boolean {
|
|
|
|
|
|
const clientFormat = clientApiFormat.trim().toLowerCase()
|
|
|
|
|
|
const providerFormat = providerApiFormat.trim().toLowerCase()
|
|
|
|
|
|
if (clientFormat === providerFormat) return true
|
|
|
|
|
|
if (!requestConversionKind(clientFormat, providerFormat)) return false
|
|
|
|
|
|
if (!requestConversionRequiresEnableFlag(clientFormat, providerFormat)) return true
|
|
|
|
|
|
return !!provider.enable_format_conversion || endpointAcceptsClientFormat(endpoint, clientFormat)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function targetFormatsForEndpoint(
|
|
|
|
|
|
provider: RoutingProviderInfo,
|
|
|
|
|
|
endpoint: RoutingEndpointInfo
|
|
|
|
|
|
): string[] {
|
2026-05-17 18:59:41 +08:00
|
|
|
|
const endpointFormat = normalizeLegacyOpenAIFormatAlias(endpoint.api_format)
|
|
|
|
|
|
const candidateFormats = Array.from(new Set([...STANDARD_ROUTING_API_FORMATS, endpointFormat]))
|
|
|
|
|
|
return candidateFormats.filter(format =>
|
2026-04-14 11:50:52 +08:00
|
|
|
|
endpointSupportsClientFormat(provider, endpoint, format, endpoint.api_format)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function keepPriorityOnConversion(provider: RoutingProviderInfo): boolean {
|
|
|
|
|
|
return !!(routingData.value?.keep_priority_on_conversion || provider.keep_priority_on_conversion)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function shouldDemoteCrossFormat(
|
|
|
|
|
|
targetApiFormat: string,
|
|
|
|
|
|
entryApiFormat: string,
|
|
|
|
|
|
provider: RoutingProviderInfo
|
|
|
|
|
|
): boolean {
|
|
|
|
|
|
return targetApiFormat !== entryApiFormat && !keepPriorityOnConversion(provider)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolvedGlobalKeyPriority(keyEntry: GlobalKeyEntry): number {
|
|
|
|
|
|
const priorityByFormat = keyEntry.key.global_priority_by_format
|
|
|
|
|
|
if (!priorityByFormat) return 999
|
|
|
|
|
|
const value = priorityByFormat[keyEntry.priority_api_format]
|
|
|
|
|
|
return typeof value === 'number' ? value : 999
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 23:28:37 +08:00
|
|
|
|
// 按 API 格式分组的计算属性
|
|
|
|
|
|
const apiFormatGroups = computed<ApiFormatGroup[]>(() => {
|
|
|
|
|
|
if (!routingData.value) return []
|
|
|
|
|
|
|
|
|
|
|
|
const formatMap = new Map<string, {
|
|
|
|
|
|
providers: FormatProviderEntry[]
|
|
|
|
|
|
allKeys: GlobalKeyEntry[]
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
// 遍历所有提供商和它们的 endpoints
|
|
|
|
|
|
for (const provider of routingData.value.providers) {
|
|
|
|
|
|
for (const endpoint of provider.endpoints || []) {
|
2026-04-14 11:50:52 +08:00
|
|
|
|
for (const format of targetFormatsForEndpoint(provider, endpoint)) {
|
|
|
|
|
|
if (!formatMap.has(format)) {
|
|
|
|
|
|
formatMap.set(format, { providers: [], allKeys: [] })
|
|
|
|
|
|
}
|
2026-01-12 23:28:37 +08:00
|
|
|
|
|
2026-04-14 11:50:52 +08:00
|
|
|
|
const data = formatMap.get(format)
|
|
|
|
|
|
if (!data) continue
|
2026-01-12 23:28:37 +08:00
|
|
|
|
|
2026-04-14 11:50:52 +08:00
|
|
|
|
data.providers.push({
|
2026-01-12 23:28:37 +08:00
|
|
|
|
provider,
|
2026-04-14 11:50:52 +08:00
|
|
|
|
endpoint,
|
|
|
|
|
|
keys: endpoint.keys || [],
|
|
|
|
|
|
active_keys: endpoint.active_keys || 0,
|
|
|
|
|
|
is_cross_format: endpoint.api_format !== format,
|
|
|
|
|
|
priority_api_format: endpoint.api_format
|
2026-01-12 23:28:37 +08:00
|
|
|
|
})
|
2026-04-14 11:50:52 +08:00
|
|
|
|
|
|
|
|
|
|
for (const key of endpoint.keys || []) {
|
|
|
|
|
|
data.allKeys.push({
|
|
|
|
|
|
key,
|
|
|
|
|
|
provider,
|
|
|
|
|
|
endpoint,
|
|
|
|
|
|
is_cross_format: endpoint.api_format !== format,
|
|
|
|
|
|
priority_api_format: endpoint.api_format
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-01-12 23:28:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为数组并计算统计
|
|
|
|
|
|
const groups: ApiFormatGroup[] = []
|
|
|
|
|
|
for (const [format, data] of formatMap) {
|
|
|
|
|
|
// Provider 排序(提供商优先模式)
|
|
|
|
|
|
const sortedProviders = [...data.providers].sort((a, b) => {
|
|
|
|
|
|
const aActive = a.provider.is_active && a.provider.model_is_active
|
|
|
|
|
|
const bActive = b.provider.is_active && b.provider.model_is_active
|
|
|
|
|
|
if (aActive !== bActive) return bActive ? 1 : -1
|
2026-04-14 11:50:52 +08:00
|
|
|
|
const aDemoted = shouldDemoteCrossFormat(format, a.priority_api_format, a.provider)
|
|
|
|
|
|
const bDemoted = shouldDemoteCrossFormat(format, b.priority_api_format, b.provider)
|
|
|
|
|
|
if (aDemoted !== bDemoted) return aDemoted ? 1 : -1
|
2026-01-12 23:28:37 +08:00
|
|
|
|
return a.provider.provider_priority - b.provider.provider_priority
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Key 按全局优先级分组排序(全局 Key 优先模式)
|
2026-04-14 11:50:52 +08:00
|
|
|
|
const keyGroupMap = new Map<string, GlobalKeyGroup>()
|
2026-01-12 23:28:37 +08:00
|
|
|
|
for (const keyEntry of data.allKeys) {
|
2026-04-14 11:50:52 +08:00
|
|
|
|
const priority = resolvedGlobalKeyPriority(keyEntry)
|
|
|
|
|
|
const demoteCrossFormat = shouldDemoteCrossFormat(format, keyEntry.priority_api_format, keyEntry.provider)
|
|
|
|
|
|
const groupKey = `${demoteCrossFormat ? 1 : 0}:${priority}`
|
|
|
|
|
|
if (!keyGroupMap.has(groupKey)) {
|
|
|
|
|
|
keyGroupMap.set(groupKey, {
|
|
|
|
|
|
priority: priority === 999 ? null : priority,
|
|
|
|
|
|
demote_cross_format: demoteCrossFormat,
|
|
|
|
|
|
keys: []
|
|
|
|
|
|
})
|
2026-01-12 23:28:37 +08:00
|
|
|
|
}
|
2026-04-14 11:50:52 +08:00
|
|
|
|
keyGroupMap.get(groupKey)?.keys.push(keyEntry)
|
2026-01-12 23:28:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为分组数组并排序
|
2026-04-14 11:50:52 +08:00
|
|
|
|
const keyGroups: GlobalKeyGroup[] = Array.from(keyGroupMap.values())
|
|
|
|
|
|
.sort((a, b) => {
|
|
|
|
|
|
if (a.demote_cross_format !== b.demote_cross_format) {
|
|
|
|
|
|
return a.demote_cross_format ? 1 : -1
|
|
|
|
|
|
}
|
|
|
|
|
|
return (a.priority ?? 999) - (b.priority ?? 999)
|
|
|
|
|
|
})
|
|
|
|
|
|
.map(group => ({
|
|
|
|
|
|
...group,
|
|
|
|
|
|
keys: group.keys.sort((a, b) => {
|
2026-01-12 23:28:37 +08:00
|
|
|
|
// 同优先级内按活跃状态和健康度排序
|
|
|
|
|
|
const aActive = a.key.is_active && a.provider.is_active && a.provider.model_is_active
|
|
|
|
|
|
const bActive = b.key.is_active && b.provider.is_active && b.provider.model_is_active
|
|
|
|
|
|
if (aActive !== bActive) return bActive ? 1 : -1
|
|
|
|
|
|
return b.key.health_score - a.key.health_score
|
|
|
|
|
|
})
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
const activeProviders = sortedProviders.filter(
|
2026-01-15 02:59:15 +08:00
|
|
|
|
e => e.provider.is_active && e.provider.model_is_active && e.endpoint?.is_active && e.active_keys > 0
|
2026-01-12 23:28:37 +08:00
|
|
|
|
).length
|
|
|
|
|
|
const totalKeys = sortedProviders.reduce((sum, e) => sum + e.keys.length, 0)
|
|
|
|
|
|
const activeKeys = sortedProviders.reduce((sum, e) => sum + e.active_keys, 0)
|
|
|
|
|
|
|
|
|
|
|
|
groups.push({
|
|
|
|
|
|
api_format: format,
|
|
|
|
|
|
providers: sortedProviders,
|
|
|
|
|
|
keyGroups,
|
|
|
|
|
|
total_providers: sortedProviders.length,
|
|
|
|
|
|
active_providers: activeProviders,
|
|
|
|
|
|
total_keys: totalKeys,
|
|
|
|
|
|
active_keys: activeKeys
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 按 API 格式排序
|
|
|
|
|
|
groups.sort((a, b) => {
|
|
|
|
|
|
const aIndex = API_FORMAT_ORDER.indexOf(a.api_format)
|
|
|
|
|
|
const bIndex = API_FORMAT_ORDER.indexOf(b.api_format)
|
|
|
|
|
|
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
|
|
|
|
|
|
if (aIndex === -1) return 1
|
|
|
|
|
|
if (bIndex === -1) return -1
|
|
|
|
|
|
return aIndex - bIndex
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return groups
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// ========== 展开状态管理 ==========
|
|
|
|
|
|
|
|
|
|
|
|
const expandedFormats = ref<Set<string>>(new Set())
|
|
|
|
|
|
|
|
|
|
|
|
function isFormatExpanded(format: string): boolean {
|
|
|
|
|
|
return expandedFormats.value.has(format)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleFormat(format: string) {
|
|
|
|
|
|
if (expandedFormats.value.has(format)) {
|
|
|
|
|
|
expandedFormats.value.delete(format)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
expandedFormats.value.add(format)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 提供商模式:格式内提供商级别的展开状态
|
|
|
|
|
|
const expandedProvidersInFormat = ref<Set<string>>(new Set())
|
|
|
|
|
|
|
|
|
|
|
|
function isProviderInFormatExpanded(format: string, providerId: string, endpointId?: string): boolean {
|
|
|
|
|
|
const key = endpointId ? `${format}:${providerId}:${endpointId}` : `${format}:${providerId}`
|
|
|
|
|
|
return expandedProvidersInFormat.value.has(key)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleProviderInFormat(format: string, providerId: string, endpointId?: string) {
|
|
|
|
|
|
const key = endpointId ? `${format}:${providerId}:${endpointId}` : `${format}:${providerId}`
|
|
|
|
|
|
if (expandedProvidersInFormat.value.has(key)) {
|
|
|
|
|
|
expandedProvidersInFormat.value.delete(key)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
expandedProvidersInFormat.value.add(key)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 23:47:47 +08:00
|
|
|
|
// 加载数据(仅在没有外部数据时使用)
|
2026-01-12 23:28:37 +08:00
|
|
|
|
async function loadRoutingData() {
|
2026-02-10 23:47:47 +08:00
|
|
|
|
// 如果有外部传入的数据,通知父组件刷新
|
|
|
|
|
|
if (props.routingData !== undefined) {
|
|
|
|
|
|
emit('refresh')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 23:28:37 +08:00
|
|
|
|
if (!props.globalModelId) return
|
|
|
|
|
|
|
2026-01-15 17:03:19 +08:00
|
|
|
|
modelMappingRegexCache.clear()
|
|
|
|
|
|
keyMatchedModelsCache.clear()
|
|
|
|
|
|
compiledGlobalModelMappingRegexes.value = []
|
|
|
|
|
|
|
2026-02-10 23:47:47 +08:00
|
|
|
|
internalLoading.value = true
|
|
|
|
|
|
internalError.value = null
|
2026-01-12 23:28:37 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-15 17:03:19 +08:00
|
|
|
|
const data = await getGlobalModelRoutingPreview(props.globalModelId)
|
|
|
|
|
|
|
|
|
|
|
|
const compiled: RegExp[] = []
|
|
|
|
|
|
for (const pattern of data.global_model_mappings || []) {
|
|
|
|
|
|
const regex = getCompiledModelMappingRegex(pattern, modelMappingRegexCache)
|
|
|
|
|
|
if (regex) compiled.push(regex)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 23:47:47 +08:00
|
|
|
|
internalRoutingData.value = data
|
2026-01-15 17:03:19 +08:00
|
|
|
|
compiledGlobalModelMappingRegexes.value = compiled
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
internalError.value = parseApiError(err, '加载失败')
|
2026-01-12 23:28:37 +08:00
|
|
|
|
} finally {
|
2026-02-10 23:47:47 +08:00
|
|
|
|
internalLoading.value = false
|
2026-01-12 23:28:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 23:47:47 +08:00
|
|
|
|
// 监听外部 routingData 变化,更新编译后的正则
|
|
|
|
|
|
watch(() => props.routingData, (data) => {
|
|
|
|
|
|
if (data) {
|
|
|
|
|
|
modelMappingRegexCache.clear()
|
|
|
|
|
|
keyMatchedModelsCache.clear()
|
|
|
|
|
|
|
|
|
|
|
|
const compiled: RegExp[] = []
|
|
|
|
|
|
for (const pattern of data.global_model_mappings || []) {
|
|
|
|
|
|
const regex = getCompiledModelMappingRegex(pattern, modelMappingRegexCache)
|
|
|
|
|
|
if (regex) compiled.push(regex)
|
|
|
|
|
|
}
|
|
|
|
|
|
compiledGlobalModelMappingRegexes.value = compiled
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { immediate: true })
|
|
|
|
|
|
|
2026-01-12 23:28:37 +08:00
|
|
|
|
// 获取调度模式标签
|
|
|
|
|
|
function getSchedulingModeLabel(mode: string): string {
|
|
|
|
|
|
const labels: Record<string, string> = {
|
|
|
|
|
|
cache_affinity: '缓存亲和',
|
|
|
|
|
|
fixed_order: '固定顺序',
|
|
|
|
|
|
load_balance: '负载均衡'
|
|
|
|
|
|
}
|
|
|
|
|
|
return labels[mode] || mode
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 18:21:34 +08:00
|
|
|
|
// 当前调度模式的显示标签(复用 getSchedulingModeLabel,computed 缓存避免重复计算)
|
|
|
|
|
|
const samePriorityLabel = computed(() =>
|
|
|
|
|
|
getSchedulingModeLabel(routingData.value?.scheduling_mode || 'cache_affinity')
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 获取降级标签(含同优先级调度行为)
|
|
|
|
|
|
function getDemoteLabel(nextGroupKeyCount: number): string {
|
|
|
|
|
|
if (nextGroupKeyCount > 1) {
|
|
|
|
|
|
return `降级 · ${samePriorityLabel.value}`
|
|
|
|
|
|
}
|
|
|
|
|
|
return '降级'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 23:28:37 +08:00
|
|
|
|
// 获取优先级模式标签
|
|
|
|
|
|
function getPriorityModeLabel(mode: string): string {
|
|
|
|
|
|
const labels: Record<string, string> = {
|
|
|
|
|
|
provider: '提供商优先',
|
|
|
|
|
|
global_key: '全局 Key 优先'
|
|
|
|
|
|
}
|
|
|
|
|
|
return labels[mode] || mode
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-20 01:32:47 +08:00
|
|
|
|
// 判断是否存在模型映射(始终显示 provider_model_name)
|
2026-01-12 23:28:37 +08:00
|
|
|
|
function hasModelMapping(provider: RoutingProviderInfo): boolean {
|
2026-01-20 01:32:47 +08:00
|
|
|
|
return !!provider.provider_model_name
|
2026-01-12 23:28:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 02:36:00 +08:00
|
|
|
|
// 获取 Key 的 allowed_models 中匹配当前 GlobalModel 的所有模型名
|
|
|
|
|
|
// 逻辑:用 GlobalModel 的 model_mappings(正则模式)去匹配 Key 的 allowed_models 中的值
|
|
|
|
|
|
function getKeyMatchedModels(key: RoutingKeyInfo): string[] {
|
2026-01-15 17:03:19 +08:00
|
|
|
|
const cached = keyMatchedModelsCache.get(key.id)
|
|
|
|
|
|
if (cached !== undefined) {
|
|
|
|
|
|
return cached
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 02:36:00 +08:00
|
|
|
|
if (!key.allowed_models || key.allowed_models.length === 0) {
|
2026-01-15 17:03:19 +08:00
|
|
|
|
keyMatchedModelsCache.set(key.id, [])
|
2026-01-15 02:36:00 +08:00
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
const globalModelName = routingData.value?.global_model_name
|
|
|
|
|
|
if (!globalModelName) {
|
2026-01-15 17:03:19 +08:00
|
|
|
|
keyMatchedModelsCache.set(key.id, [])
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const patterns = compiledGlobalModelMappingRegexes.value
|
|
|
|
|
|
if (patterns.length === 0) {
|
|
|
|
|
|
keyMatchedModelsCache.set(key.id, [])
|
2026-01-15 02:36:00 +08:00
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const matched: string[] = []
|
|
|
|
|
|
// 遍历 Key 的白名单
|
|
|
|
|
|
for (const allowedModel of key.allowed_models) {
|
|
|
|
|
|
// 如果完全匹配 GlobalModel 名称,跳过(不需要显示)
|
|
|
|
|
|
if (allowedModel === globalModelName) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-01-15 17:03:19 +08:00
|
|
|
|
if (allowedModel.length > MAX_MODEL_NAME_LENGTH) continue
|
|
|
|
|
|
|
2026-01-15 02:36:00 +08:00
|
|
|
|
// 用 GlobalModel 的映射模式匹配白名单中的模型名
|
2026-01-15 17:03:19 +08:00
|
|
|
|
for (const regex of patterns) {
|
|
|
|
|
|
if (regex.test(allowedModel)) {
|
|
|
|
|
|
matched.push(allowedModel)
|
|
|
|
|
|
break // 该 allowedModel 已匹配,不需要继续检查其他 pattern
|
2026-01-15 02:36:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-15 17:03:19 +08:00
|
|
|
|
keyMatchedModelsCache.set(key.id, matched)
|
2026-01-15 02:36:00 +08:00
|
|
|
|
return matched
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化匹配的模型显示文本
|
|
|
|
|
|
function formatMatchedModels(models: string[]): string {
|
|
|
|
|
|
if (models.length === 0) return ''
|
|
|
|
|
|
if (models.length === 1) return models[0]
|
|
|
|
|
|
return `${models[0]} +${models.length - 1}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 23:28:37 +08:00
|
|
|
|
// 按优先级分组 Keys(提供商优先模式下使用)
|
|
|
|
|
|
interface KeyPriorityGroup {
|
|
|
|
|
|
priority: number | null
|
|
|
|
|
|
keys: RoutingKeyInfo[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getKeyPriorityGroups(keys: RoutingKeyInfo[]): KeyPriorityGroup[] {
|
|
|
|
|
|
const groups = new Map<number, KeyPriorityGroup>()
|
|
|
|
|
|
|
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
|
// 提供商优先模式:按 internal_priority 分组
|
|
|
|
|
|
const priority = key.internal_priority ?? 999
|
|
|
|
|
|
|
|
|
|
|
|
if (!groups.has(priority)) {
|
|
|
|
|
|
groups.set(priority, {
|
|
|
|
|
|
priority: priority === 999 ? null : priority,
|
|
|
|
|
|
keys: []
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
groups.get(priority)?.keys.push(key)
|
2026-01-12 23:28:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Array.from(groups.values()).sort((a, b) => {
|
|
|
|
|
|
const pa = a.priority ?? 999
|
|
|
|
|
|
const pb = b.priority ?? 999
|
|
|
|
|
|
return pa - pb
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取提供商状态样式
|
|
|
|
|
|
function getProviderStatusClass(provider: RoutingProviderInfo): string {
|
|
|
|
|
|
if (!provider.is_active) {
|
|
|
|
|
|
return 'bg-gray-400'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!provider.model_is_active) {
|
|
|
|
|
|
return 'bg-yellow-500'
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'bg-green-500'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取格式分组中提供商节点圆点样式(提供商优先模式)
|
|
|
|
|
|
function getFormatProviderNodeClass(entry: FormatProviderEntry, index: number): string {
|
|
|
|
|
|
if (!entry.provider.is_active || !entry.provider.model_is_active) {
|
|
|
|
|
|
return 'bg-muted border-muted-foreground/30'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (index === 0) {
|
|
|
|
|
|
return 'bg-primary border-primary'
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'bg-background border-border'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取格式分组中提供商卡片样式(提供商优先模式)
|
|
|
|
|
|
function getFormatProviderCardClass(entry: FormatProviderEntry, index: number): string {
|
|
|
|
|
|
if (!entry.provider.is_active || !entry.provider.model_is_active) {
|
|
|
|
|
|
return 'bg-muted/30 border border-border/40'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (index === 0) {
|
|
|
|
|
|
return 'bg-primary/5 border border-primary/30 shadow-sm'
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'bg-muted/20 border border-border/50 hover:border-border'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取全局 Key 节点圆点样式(全局 Key 优先模式)
|
|
|
|
|
|
function getGlobalKeyNodeClass(entry: GlobalKeyEntry, groupIndex: number, keyIndex: number): string {
|
|
|
|
|
|
if (!entry.key.is_active || !entry.provider.is_active || !entry.provider.model_is_active) {
|
|
|
|
|
|
return 'bg-muted border-muted-foreground/30'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (groupIndex === 0 && keyIndex === 0) {
|
|
|
|
|
|
return 'bg-primary border-primary'
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'bg-background border-border'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 16:04:15 +08:00
|
|
|
|
// 判断是否为格式组中的最后一个 Key
|
|
|
|
|
|
function isLastKeyInFormat(formatGroup: ApiFormatGroup, groupIndex: number, keyIndex: number): boolean {
|
|
|
|
|
|
const isLastGroup = groupIndex === formatGroup.keyGroups.length - 1
|
|
|
|
|
|
const isLastKeyInGroup = keyIndex === formatGroup.keyGroups[groupIndex].keys.length - 1
|
|
|
|
|
|
return isLastGroup && isLastKeyInGroup
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 23:28:37 +08:00
|
|
|
|
// 获取全局 Key 卡片样式(全局 Key 优先模式)
|
|
|
|
|
|
function getGlobalKeyCardClass(entry: GlobalKeyEntry, groupIndex: number, keyIndex: number): string {
|
|
|
|
|
|
if (!entry.key.is_active || !entry.provider.is_active || !entry.provider.model_is_active) {
|
|
|
|
|
|
return 'bg-muted/30 border border-border/40'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (groupIndex === 0 && keyIndex === 0) {
|
|
|
|
|
|
return 'bg-primary/5 border border-primary/30 shadow-sm'
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'bg-muted/20 border border-border/50 hover:border-border'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 18:42:15 +08:00
|
|
|
|
// 健康度进度条颜色(score 为 0-1 小数格式)
|
2026-01-12 23:28:37 +08:00
|
|
|
|
function getHealthScoreBarColor(score: number): string {
|
2026-01-13 18:42:15 +08:00
|
|
|
|
if (score >= 0.8) return 'bg-green-500 dark:bg-green-400'
|
|
|
|
|
|
if (score >= 0.5) return 'bg-yellow-500 dark:bg-yellow-400'
|
2026-01-12 23:28:37 +08:00
|
|
|
|
return 'bg-red-500 dark:bg-red-400'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 18:42:15 +08:00
|
|
|
|
// 健康度文字颜色(score 为 0-1 小数格式)
|
2026-01-12 23:28:37 +08:00
|
|
|
|
function getHealthScoreTextColor(score: number): string {
|
2026-01-13 18:42:15 +08:00
|
|
|
|
if (score >= 0.8) return 'text-green-600 dark:text-green-400'
|
|
|
|
|
|
if (score >= 0.5) return 'text-yellow-600 dark:text-yellow-400'
|
2026-01-12 23:28:37 +08:00
|
|
|
|
return 'text-red-600 dark:text-red-400'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取计费标签
|
|
|
|
|
|
function getBillingLabel(provider: RoutingProviderInfo): string {
|
|
|
|
|
|
if (provider.billing_type === 'monthly_quota') {
|
|
|
|
|
|
const used = provider.monthly_used_usd || 0
|
|
|
|
|
|
const quota = provider.monthly_quota_usd
|
|
|
|
|
|
return quota ? `$${used.toFixed(0)}/$${quota.toFixed(0)}` : '月卡'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (provider.billing_type === 'pay_as_you_go') {
|
|
|
|
|
|
return '按量'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '免费'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 18:42:15 +08:00
|
|
|
|
// 获取 Key 状态样式(score 为 0-1 小数格式)
|
2026-01-12 23:28:37 +08:00
|
|
|
|
function getKeyStatusClass(key: RoutingKeyInfo): string {
|
|
|
|
|
|
if (!key.is_active) {
|
|
|
|
|
|
return 'bg-gray-400'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (key.circuit_breaker_open) {
|
|
|
|
|
|
return 'bg-red-500'
|
|
|
|
|
|
}
|
2026-01-13 18:42:15 +08:00
|
|
|
|
const score = key.health_score ?? 1
|
|
|
|
|
|
if (score < 0.5) {
|
2026-01-12 23:28:37 +08:00
|
|
|
|
return 'bg-red-500'
|
|
|
|
|
|
}
|
2026-01-13 18:42:15 +08:00
|
|
|
|
if (score < 0.8) {
|
2026-01-12 23:28:37 +08:00
|
|
|
|
return 'bg-yellow-500'
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'bg-green-500'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取 Key 提示信息
|
|
|
|
|
|
function getKeyTooltip(key: RoutingKeyInfo): string {
|
|
|
|
|
|
const parts: string[] = []
|
|
|
|
|
|
parts.push(`名称: ${key.name}`)
|
2026-01-13 18:42:15 +08:00
|
|
|
|
parts.push(`健康度: ${((key.health_score || 0) * 100).toFixed(0)}%`)
|
2026-01-12 23:28:37 +08:00
|
|
|
|
if (!key.is_active) {
|
2026-03-03 11:32:41 +08:00
|
|
|
|
parts.push('状态: 禁用')
|
2026-01-12 23:28:37 +08:00
|
|
|
|
} else if (key.circuit_breaker_open) {
|
|
|
|
|
|
parts.push(`熔断中: ${key.circuit_breaker_formats.join(', ')}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
return parts.join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 18:42:15 +08:00
|
|
|
|
// 获取 Key 探测倒计时(用于 RoutingKeyInfo)
|
|
|
|
|
|
function getKeyProbeCountdown(key: RoutingKeyInfo): string {
|
|
|
|
|
|
if (!key.circuit_breaker_open) return ''
|
|
|
|
|
|
const countdown = getProbeCountdown(key.next_probe_at, countdownTick.value)
|
|
|
|
|
|
return countdown ? ` · ${countdown}` : ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 恢复 Key 健康状态(仅恢复指定 API 格式)
|
|
|
|
|
|
async function handleRecoverKey(keyId: string, apiFormat: string) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await recoverKeyHealth(keyId, apiFormat)
|
2026-02-10 23:47:47 +08:00
|
|
|
|
// 通知父组件刷新数据
|
2026-01-13 18:42:15 +08:00
|
|
|
|
emit('refresh')
|
2026-02-10 23:47:47 +08:00
|
|
|
|
showSuccess(result.message || 'Key 已恢复')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, 'Key 恢复失败'), '错误')
|
2026-01-13 18:42:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 23:28:37 +08:00
|
|
|
|
// 监听 globalModelId 变化
|
|
|
|
|
|
watch(() => props.globalModelId, () => {
|
|
|
|
|
|
expandedFormats.value.clear()
|
|
|
|
|
|
expandedProvidersInFormat.value.clear()
|
2026-02-10 23:47:47 +08:00
|
|
|
|
// 如果没有外部数据,才自己加载
|
|
|
|
|
|
if (props.routingData === undefined) {
|
|
|
|
|
|
loadRoutingData()
|
|
|
|
|
|
}
|
2026-01-12 23:28:37 +08:00
|
|
|
|
}, { immediate: false })
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
2026-02-10 23:47:47 +08:00
|
|
|
|
// 如果没有外部数据,才自己加载
|
|
|
|
|
|
if (props.routingData === undefined) {
|
|
|
|
|
|
loadRoutingData()
|
|
|
|
|
|
}
|
2026-01-13 18:42:15 +08:00
|
|
|
|
startCountdownTimer()
|
2026-01-12 23:28:37 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 暴露方法给父组件
|
|
|
|
|
|
defineExpose({
|
|
|
|
|
|
loadRoutingData
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.collapse-enter-active,
|
|
|
|
|
|
.collapse-leave-active {
|
|
|
|
|
|
transition: all 0.2s ease;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.collapse-enter-from,
|
|
|
|
|
|
.collapse-leave-to {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
max-height: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.collapse-enter-to,
|
|
|
|
|
|
.collapse-leave-from {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
max-height: 500px;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|