feat(ui): 优化熔断状态显示和健康度格式

- 健康度格式统一为 0-1 小数格式(前后端同步)
- 添加熔断倒计时显示,支持实时更新
- 新增 useCountdownTimer composable 用于倒计时逻辑
- 添加 Key 健康恢复功能(按 API 格式恢复)
- 优化 UI 布局:简化链路预览标题、调整 Key 信息为两行显示
- 修复错误处理顺序(先刷新数据再显示成功提示)
- 减少模板中函数重复调用
This commit is contained in:
fawney19
2026-01-13 18:42:15 +08:00
parent e536b7bc35
commit 1a0a3de2fc
6 changed files with 317 additions and 208 deletions

View File

@@ -638,12 +638,13 @@ export interface RoutingKeyInfo {
is_adaptive: boolean is_adaptive: boolean
effective_rpm?: number | null effective_rpm?: number | null
cache_ttl_minutes: number cache_ttl_minutes: number
health_score: number health_score: number // 0-1 小数格式
is_active: boolean is_active: boolean
api_formats: string[] api_formats: string[]
allowed_models?: string[] | null // 允许的模型列表null 表示不限制 allowed_models?: string[] | null // 允许的模型列表null 表示不限制
circuit_breaker_open: boolean circuit_breaker_open: boolean
circuit_breaker_formats: string[] circuit_breaker_formats: string[]
next_probe_at?: string | null // 下次探测时间ISO格式
} }
/** /**

View File

@@ -0,0 +1,69 @@
import { ref, onUnmounted } from 'vue'
/**
* 倒计时定时器 composable
* 用于触发组件的定期响应式更新(如熔断探测倒计时)
*/
export function useCountdownTimer() {
const tick = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
function start() {
if (timer) return
timer = setInterval(() => {
tick.value++
}, 1000)
}
function stop() {
if (timer) {
clearInterval(timer)
timer = null
}
}
onUnmounted(stop)
return { tick, start, stop }
}
/**
* 格式化倒计时时间
* @param diffMs 剩余毫秒数
* @returns 格式化的倒计时字符串(如 "1:30" 或 "1:02:30"
*/
export function formatCountdown(diffMs: number): string {
const totalSeconds = Math.ceil(diffMs / 1000)
if (totalSeconds <= 0) return '探测中'
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
/**
* 计算探测倒计时
* @param nextProbeAt ISO 格式的探测时间字符串
* @param _tick 响应式触发器(传入 tick.value 以触发响应式更新)
* @returns 倒计时字符串,或 null如果无需显示
*/
export function getProbeCountdown(nextProbeAt: string | null | undefined, _tick: number): string | null {
// _tick 参数用于触发响应式更新,实际使用时传入 tick.value
void _tick
if (!nextProbeAt) return null
const nextProbe = new Date(nextProbeAt)
const now = new Date()
const diffMs = nextProbe.getTime() - now.getTime()
if (diffMs > 0) {
return formatCountdown(diffMs)
}
return '探测中'
}

View File

@@ -4,11 +4,12 @@
<div class="px-4 py-3 border-b border-border/60"> <div class="px-4 py-3 border-b border-border/60">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-baseline gap-2"> <div class="flex items-baseline gap-2">
<h4 class="text-sm font-semibold">别名规则</h4> <h4 class="text-sm font-semibold">映射规则</h4>
<span class="text-xs text-muted-foreground"> <span class="text-xs text-muted-foreground">
支持正则表达式 ({{ localAliases.length }}/{{ MAX_ALIASES_PER_MODEL }}) 支持正则表达式 ({{ localAliases.length }}/{{ MAX_ALIASES_PER_MODEL }})
</span> </span>
</div> </div>
<div class="flex items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -19,6 +20,17 @@
> >
<Plus class="w-4 h-4" /> <Plus class="w-4 h-4" />
</Button> </Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="刷新"
:disabled="props.loading"
@click="$emit('refresh')"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': props.loading }" />
</Button>
</div>
</div> </div>
</div> </div>
@@ -117,13 +129,12 @@
:key="item.keyId" :key="item.keyId"
class="bg-background rounded-md border p-3" class="bg-background rounded-md border p-3"
> >
<div class="flex items-center gap-2 text-sm mb-2"> <div class="flex items-center gap-1.5 text-sm mb-2">
<span class="text-muted-foreground">{{ item.providerName }}</span> <span class="text-muted-foreground">{{ item.providerName }}</span>
<span class="text-muted-foreground">/</span> <span class="text-muted-foreground">/</span>
<span class="font-medium">{{ item.keyName }}</span> <span class="font-medium">{{ item.keyName }}</span>
<span class="text-xs text-muted-foreground font-mono ml-auto"> <span class="text-muted-foreground">·</span>
{{ item.maskedKey }} <code class="text-xs text-muted-foreground/70">{{ item.maskedKey }}</code>
</span>
</div> </div>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<Badge <Badge
@@ -170,9 +181,11 @@ const props = defineProps<{
globalModelId: string globalModelId: string
modelName: string modelName: string
aliases: string[] aliases: string[]
loading?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
update: [aliases: string[]] update: [aliases: string[]]
refresh: []
}>() }>()
// 安全限制常量(与后端保持一致) // 安全限制常量(与后端保持一致)
const MAX_ALIASES_PER_MODEL = 50 const MAX_ALIASES_PER_MODEL = 50

View File

@@ -3,16 +3,16 @@
<!-- 标题栏 --> <!-- 标题栏 -->
<div class="px-4 py-3 border-b border-border/60"> <div class="px-4 py-3 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2"> <div class="flex items-baseline gap-2">
<h4 class="text-sm font-semibold"> <h4 class="text-sm font-semibold">
请求链路预览 链路预览
</h4> </h4>
<template v-if="routingData"> <template v-if="routingData">
<span class="text-muted-foreground">·</span> <span class="text-xs text-muted-foreground">·</span>
<span class="text-xs text-muted-foreground"> <span class="text-xs text-muted-foreground">
{{ getSchedulingModeLabel(routingData.scheduling_mode) }} {{ getSchedulingModeLabel(routingData.scheduling_mode) }}
</span> </span>
<span class="text-muted-foreground">·</span> <span class="text-xs text-muted-foreground">·</span>
<span class="text-xs text-muted-foreground"> <span class="text-xs text-muted-foreground">
{{ getPriorityModeLabel(routingData.priority_mode) }} {{ getPriorityModeLabel(routingData.priority_mode) }}
</span> </span>
@@ -23,10 +23,10 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
title="添加关联" title="关联提供商"
@click="$emit('addProvider')" @click="$emit('addProvider')"
> >
<Plus class="w-3.5 h-3.5" /> <Link class="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -86,17 +86,19 @@
> >
{{ formatGroup.api_format }} {{ formatGroup.api_format }}
</Badge> </Badge>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{{ formatGroup.active_keys }}/{{ formatGroup.total_keys }} Keys {{ formatGroup.active_keys }}/{{ formatGroup.total_keys }} Keys
<span class="mx-1.5">·</span> <span class="mx-1.5">·</span>
{{ formatGroup.active_providers }}/{{ formatGroup.total_providers }} 提供商 {{ formatGroup.active_providers }}/{{ formatGroup.total_providers }} 提供商
</span> </span>
</div>
<ChevronDown <ChevronDown
class="w-4 h-4 text-muted-foreground transition-transform" class="w-4 h-4 text-muted-foreground transition-transform"
:class="isFormatExpanded(formatGroup.api_format) ? 'rotate-180' : ''" :class="isFormatExpanded(formatGroup.api_format) ? 'rotate-180' : ''"
/> />
</div> </div>
</div>
<!-- 展开的内容 --> <!-- 展开的内容 -->
<Transition name="collapse"> <Transition name="collapse">
@@ -147,14 +149,14 @@
:class="!keyEntry.key.is_active ? 'opacity-50' : ''" :class="!keyEntry.key.is_active ? 'opacity-50' : ''"
> >
<div <div
class="group rounded-lg transition-all p-2.5" class="group rounded-lg transition-all px-3 py-2"
:class="getGlobalKeyCardClass(keyEntry, groupIndex, keyIndex)" :class="getGlobalKeyCardClass(keyEntry, groupIndex, keyIndex)"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-3">
<!-- 第一列优先级标签 --> <!-- 优先级标签 -->
<div <div
v-if="keyEntry.key.is_active" v-if="keyEntry.key.is_active"
class="px-1.5 py-0.5 rounded-full text-[10px] font-medium shrink-0" class="px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0"
:class="groupIndex === 0 && keyIndex === 0 :class="groupIndex === 0 && keyIndex === 0
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: 'bg-muted-foreground/20 text-muted-foreground'" : 'bg-muted-foreground/20 text-muted-foreground'"
@@ -163,78 +165,60 @@
<span v-else>P{{ keyGroup.priority ?? '?' }}</span> <span v-else>P{{ keyGroup.priority ?? '?' }}</span>
</div> </div>
<!-- 第二列状态指示灯 --> <!-- Key 信息两行 -->
<span
class="w-1.5 h-1.5 rounded-full shrink-0"
:class="getKeyStatusClass(keyEntry.key)"
/>
<!-- 第三列Key 名称 + Provider 信息 -->
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-1"> <!-- 第一行Key 名称 -->
<span <div
class="text-sm font-medium truncate" class="text-sm font-medium truncate"
:class="keyEntry.key.circuit_breaker_open ? 'text-destructive' : ''" :class="keyEntry.key.circuit_breaker_open ? 'text-destructive' : ''"
> >
{{ keyEntry.key.name }} {{ keyEntry.key.name }}
</span>
<code class="font-mono text-[10px] text-muted-foreground/60 shrink-0">
{{ keyEntry.key.masked_key }}
</code>
<Zap
v-if="keyEntry.key.circuit_breaker_open"
class="w-3 h-3 text-destructive shrink-0"
/>
</div> </div>
<!-- Provider Endpoint 信息 --> <!-- 第二行提供商名 · sk -->
<div class="text-[10px] text-muted-foreground truncate"> <div class="flex items-center gap-1 text-[10px] text-muted-foreground">
{{ keyEntry.provider.name }} <span>{{ keyEntry.provider.name }}</span>
<span v-if="hasModelMapping(keyEntry.provider)"> <span>·</span>
({{ keyEntry.provider.provider_model_name }}) <code class="text-muted-foreground/60">{{ keyEntry.key.masked_key }}</code>
</span>
<span v-if="keyEntry.provider.billing_type">
· {{ getBillingLabel(keyEntry.provider) }}
</span>
<span v-if="keyEntry.endpoint">
· {{ keyEntry.endpoint.base_url }}
</span>
</div> </div>
</div> </div>
<!-- 第四列健康度 + RPM + 操作按钮 --> <!-- 熔断徽章 -->
<div class="flex items-center gap-1.5 shrink-0"> <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"> <div class="flex items-center gap-1 shrink-0">
<div class="w-8 h-1 bg-muted/80 rounded-full overflow-hidden"> <div class="w-10 h-1.5 bg-muted/80 rounded-full overflow-hidden">
<div <div
class="h-full transition-all duration-300" class="h-full transition-all duration-300"
:class="getHealthScoreBarColor(keyEntry.key.health_score)" :class="getHealthScoreBarColor(keyEntry.key.health_score)"
:style="{ width: `${keyEntry.key.health_score}%` }" :style="{ width: `${(keyEntry.key.health_score || 0) * 100}%` }"
/> />
</div> </div>
<span <span
class="text-[10px] font-medium tabular-nums" class="text-[10px] font-medium tabular-nums w-7 text-right"
:class="getHealthScoreTextColor(keyEntry.key.health_score)" :class="getHealthScoreTextColor(keyEntry.key.health_score)"
> >
{{ Math.round(keyEntry.key.health_score) }}% {{ ((keyEntry.key.health_score || 0) * 100).toFixed(0) }}%
</span> </span>
</div> </div>
<!-- RPM -->
<span
v-if="keyEntry.key.effective_rpm"
class="text-[10px] text-muted-foreground/60"
>
{{ keyEntry.key.is_adaptive ? '~' : '' }}{{ keyEntry.key.effective_rpm }}
</span>
<!-- 操作按钮 --> <!-- 操作按钮 -->
<div class="flex items-center shrink-0">
<Button <Button
v-if="keyEntry.key.circuit_breaker_open || (keyEntry.key.health_score ?? 1) < 0.5"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-6 w-6" class="h-6 w-6 text-green-600"
title="编辑此关联" title="刷新健康状态"
@click.stop="$emit('editProvider', keyEntry.provider)" @click.stop="handleRecoverKey(keyEntry.key.id, formatGroup.api_format)"
> >
<Edit class="w-3 h-3" /> <RefreshCw class="w-3 h-3" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -245,24 +229,8 @@
> >
<Power class="w-3 h-3" /> <Power class="w-3 h-3" />
</Button> </Button>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
title="删除此关联"
@click.stop="$emit('deleteProvider', keyEntry.provider)"
>
<Trash2 class="w-3 h-3" />
</Button>
</div> </div>
</div> </div>
<!-- 熔断详情如果有 -->
<div
v-if="keyEntry.key.circuit_breaker_open"
class="text-[10px] text-destructive mt-1.5 ml-6"
>
熔断中: {{ keyEntry.key.circuit_breaker_formats.join(', ') }}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -329,10 +297,10 @@
@click="toggleProviderInFormat(formatGroup.api_format, providerEntry.provider.id, providerEntry.endpoint?.id)" @click="toggleProviderInFormat(formatGroup.api_format, providerEntry.provider.id, providerEntry.endpoint?.id)"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- 第一列优先级标签 --> <!-- 第一列优先级标签固定宽度用于对齐 -->
<div <div
v-if="providerEntry.provider.is_active && providerEntry.provider.model_is_active" v-if="providerEntry.provider.is_active && providerEntry.provider.model_is_active"
class="px-1.5 py-0.5 rounded-full text-[10px] font-medium shrink-0" class="min-w-8 px-1.5 py-0.5 rounded-full text-[10px] font-medium shrink-0 text-center"
:class="providerIndex === 0 :class="providerIndex === 0
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: 'bg-muted-foreground/20 text-muted-foreground'" : 'bg-muted-foreground/20 text-muted-foreground'"
@@ -382,15 +350,6 @@
{{ providerEntry.active_keys }}/{{ providerEntry.keys.length }} Keys {{ providerEntry.active_keys }}/{{ providerEntry.keys.length }} Keys
</span> </span>
<!-- 操作按钮 --> <!-- 操作按钮 -->
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
title="编辑此关联"
@click.stop="$emit('editProvider', providerEntry.provider)"
>
<Edit class="w-3 h-3" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -400,15 +359,6 @@
> >
<Power class="w-3 h-3" /> <Power class="w-3 h-3" />
</Button> </Button>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
title="删除此关联"
@click.stop="$emit('deleteProvider', providerEntry.provider)"
>
<Trash2 class="w-3 h-3" />
</Button>
<!-- 展开图标 --> <!-- 展开图标 -->
<ChevronDown <ChevronDown
class="w-3.5 h-3.5 text-muted-foreground transition-transform" class="w-3.5 h-3.5 text-muted-foreground transition-transform"
@@ -427,7 +377,7 @@
<!-- Keys 列表 --> <!-- Keys 列表 -->
<div <div
v-if="providerEntry.keys.length > 0" v-if="providerEntry.keys.length > 0"
class="relative ml-1" class="relative"
> >
<div <div
v-for="(group, groupIndex) in getKeyPriorityGroups(providerEntry.keys)" v-for="(group, groupIndex) in getKeyPriorityGroups(providerEntry.keys)"
@@ -436,7 +386,7 @@
<!-- 第一组且有多个 key 时显示负载均衡标签 --> <!-- 第一组且有多个 key 时显示负载均衡标签 -->
<div <div
v-if="groupIndex === 0 && group.keys.length > 1" v-if="groupIndex === 0 && group.keys.length > 1"
class="flex items-center gap-1 text-[10px] text-muted-foreground/60 mb-0.5" class="flex items-center gap-1 text-[10px] text-muted-foreground/60 mb-0.5 ml-4"
> >
<span>负载均衡</span> <span>负载均衡</span>
</div> </div>
@@ -453,62 +403,79 @@
<div <div
v-for="(key, keyIndex) in group.keys" v-for="(key, keyIndex) in group.keys"
:key="key.id" :key="key.id"
class="relative flex items-center" class="relative flex items-center gap-2"
> >
<!-- 分支结构 --> <!-- 第一列节点与优先级标签对齐min-w-8 -->
<div class="flex items-center shrink-0"> <div class="min-w-8 flex items-center justify-center shrink-0">
<div <div
class="w-2 h-2 rounded-full border-2 z-10" class="w-2 h-2 rounded-full border-2 z-10"
:class="groupIndex === 0 && keyIndex === 0 :class="groupIndex === 0 && keyIndex === 0
? 'bg-primary border-primary' ? 'bg-primary border-primary'
: 'bg-background border-muted-foreground/40'" : 'bg-background border-muted-foreground/40'"
/> />
<div class="w-2 h-px bg-border" />
</div> </div>
<!-- Key 信息 --> <!-- 第二列状态灯与提供商状态灯对齐 -->
<span
class="w-1.5 h-1.5 rounded-full shrink-0"
:class="getKeyStatusClass(key)"
/>
<!-- 第三列Key 信息 -->
<div <div
class="flex-1 min-w-0 flex items-center gap-1.5 px-2 py-0.5 my-0.5 rounded text-xs" class="flex-1 min-w-0 flex items-center gap-1.5 px-2 py-1 my-0.5 rounded text-xs"
:class="[ :class="[
groupIndex === 0 ? 'bg-primary/5' : 'bg-muted/30', groupIndex === 0 ? 'bg-primary/5' : 'bg-muted/30',
!key.is_active ? 'opacity-50' : '' !key.is_active ? 'opacity-50' : ''
]" ]"
:title="getKeyTooltip(key)" :title="getKeyTooltip(key)"
> >
<span <!-- 名称 + sk垂直堆叠 -->
class="w-1.5 h-1.5 rounded-full shrink-0" <div class="min-w-0 flex flex-col">
:class="getKeyStatusClass(key)"
/>
<span <span
class="font-medium truncate" class="font-medium truncate"
:class="key.circuit_breaker_open ? 'text-destructive' : ''" :class="key.circuit_breaker_open ? 'text-destructive' : ''"
> >
{{ key.name }} {{ key.name }}
</span> </span>
<code class="font-mono text-[10px] text-muted-foreground/60 shrink-0"> <code class="font-mono text-[10px] text-muted-foreground/60">
{{ key.masked_key }} {{ key.masked_key }}
</code> </code>
<Zap </div>
v-if="key.circuit_breaker_open"
class="w-3 h-3 text-destructive shrink-0"
/>
<span class="flex-1" /> <span class="flex-1" />
<!-- 熔断徽章带倒计时- 靠右 -->
<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>
<!-- 健康度进度条 + 百分比 --> <!-- 健康度进度条 + 百分比 -->
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
<div class="w-8 h-1 bg-muted/80 rounded-full overflow-hidden"> <div class="w-8 h-1 bg-muted/80 rounded-full overflow-hidden">
<div <div
class="h-full transition-all duration-300" class="h-full transition-all duration-300"
:class="getHealthScoreBarColor(key.health_score)" :class="getHealthScoreBarColor(key.health_score)"
:style="{ width: `${key.health_score}%` }" :style="{ width: `${(key.health_score || 0) * 100}%` }"
/> />
</div> </div>
<span <span
class="text-[10px] font-medium tabular-nums" class="text-[10px] font-medium tabular-nums"
:class="getHealthScoreTextColor(key.health_score)" :class="getHealthScoreTextColor(key.health_score)"
> >
{{ Math.round(key.health_score) }}% {{ ((key.health_score || 0) * 100).toFixed(0) }}%
</span> </span>
</div> </div>
<!-- 刷新健康按钮 -->
<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="刷新健康状态"
@click.stop="handleRecoverKey(key.id, formatGroup.api_format)"
>
<RefreshCw class="w-3 h-3" />
</button>
<span <span
v-if="key.effective_rpm" v-if="key.effective_rpm"
class="text-[10px] text-muted-foreground/60 shrink-0" class="text-[10px] text-muted-foreground/60 shrink-0"
@@ -522,23 +489,17 @@
<!-- 优先级组之间的降级标记如果下一组有多个 key显示"降级 · 负载均衡" --> <!-- 优先级组之间的降级标记如果下一组有多个 key显示"降级 · 负载均衡" -->
<div <div
v-if="groupIndex < getKeyPriorityGroups(providerEntry.keys).length - 1" v-if="groupIndex < getKeyPriorityGroups(providerEntry.keys).length - 1"
class="flex items-center gap-1 my-0.5 text-[10px] text-muted-foreground/50" class="flex items-center my-0.5 text-[10px] text-muted-foreground/50"
> >
<!-- 箭头居中于节点列 -->
<div class="min-w-8 flex items-center justify-center shrink-0">
<ArrowDown class="w-3 h-3" /> <ArrowDown class="w-3 h-3" />
</div>
<span> <span>
{{ getKeyPriorityGroups(providerEntry.keys)[groupIndex + 1].keys.length > 1 ? '降级 · 负载均衡' : '降级' }} {{ getKeyPriorityGroups(providerEntry.keys)[groupIndex + 1].keys.length > 1 ? '降级 · 负载均衡' : '降级' }}
</span> </span>
</div> </div>
</div> </div>
<!-- 熔断详情 -->
<div
v-for="key in providerEntry.keys.filter(k => k.circuit_breaker_open)"
:key="`cb-${key.id}`"
class="text-[10px] text-destructive mt-1 ml-4"
>
{{ key.name }} 熔断: {{ key.circuit_breaker_formats.join(', ') }}
</div>
</div> </div>
<div <div
@@ -605,11 +566,8 @@ import {
ChevronDown, ChevronDown,
Route, Route,
AlertCircle, AlertCircle,
Zap,
Edit,
Power, Power,
Trash2, Link
Plus
} from 'lucide-vue-next' } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue' import Badge from '@/components/ui/badge.vue'
@@ -622,12 +580,15 @@ import {
type RoutingEndpointInfo type RoutingEndpointInfo
} from '@/api/global-models' } from '@/api/global-models'
import { API_FORMAT_ORDER } from '@/api/endpoints/types' import { API_FORMAT_ORDER } from '@/api/endpoints/types'
import { recoverKeyHealth } from '@/api/endpoints/health'
import { useToast } from '@/composables/useToast'
import { useCountdownTimer, getProbeCountdown } from '@/composables/useCountdownTimer'
const props = defineProps<{ const props = defineProps<{
globalModelId: string globalModelId: string
}>() }>()
defineEmits<{ const emit = defineEmits<{
editProvider: [provider: RoutingProviderInfo] editProvider: [provider: RoutingProviderInfo]
toggleProviderStatus: [provider: RoutingProviderInfo] toggleProviderStatus: [provider: RoutingProviderInfo]
deleteProvider: [provider: RoutingProviderInfo] deleteProvider: [provider: RoutingProviderInfo]
@@ -635,6 +596,9 @@ defineEmits<{
refresh: [] refresh: []
}>() }>()
const { success: showSuccess, error: showError } = useToast()
const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer()
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const routingData = ref<ModelRoutingPreviewResponse | null>(null) const routingData = ref<ModelRoutingPreviewResponse | null>(null)
@@ -943,17 +907,17 @@ function getGlobalKeyCardClass(entry: GlobalKeyEntry, groupIndex: number, keyInd
return 'bg-muted/20 border border-border/50 hover:border-border' return 'bg-muted/20 border border-border/50 hover:border-border'
} }
// 健康度进度条颜色 // 健康度进度条颜色score 为 0-1 小数格式)
function getHealthScoreBarColor(score: number): string { function getHealthScoreBarColor(score: number): string {
if (score >= 80) return 'bg-green-500 dark:bg-green-400' if (score >= 0.8) return 'bg-green-500 dark:bg-green-400'
if (score >= 50) return 'bg-yellow-500 dark:bg-yellow-400' if (score >= 0.5) return 'bg-yellow-500 dark:bg-yellow-400'
return 'bg-red-500 dark:bg-red-400' return 'bg-red-500 dark:bg-red-400'
} }
// 健康度文字颜色 // 健康度文字颜色score 为 0-1 小数格式)
function getHealthScoreTextColor(score: number): string { function getHealthScoreTextColor(score: number): string {
if (score >= 80) return 'text-green-600 dark:text-green-400' if (score >= 0.8) return 'text-green-600 dark:text-green-400'
if (score >= 50) return 'text-yellow-600 dark:text-yellow-400' if (score >= 0.5) return 'text-yellow-600 dark:text-yellow-400'
return 'text-red-600 dark:text-red-400' return 'text-red-600 dark:text-red-400'
} }
@@ -970,7 +934,7 @@ function getBillingLabel(provider: RoutingProviderInfo): string {
return '免费' return '免费'
} }
// 获取 Key 状态样式 // 获取 Key 状态样式score 为 0-1 小数格式)
function getKeyStatusClass(key: RoutingKeyInfo): string { function getKeyStatusClass(key: RoutingKeyInfo): string {
if (!key.is_active) { if (!key.is_active) {
return 'bg-gray-400' return 'bg-gray-400'
@@ -978,10 +942,11 @@ function getKeyStatusClass(key: RoutingKeyInfo): string {
if (key.circuit_breaker_open) { if (key.circuit_breaker_open) {
return 'bg-red-500' return 'bg-red-500'
} }
if (key.health_score < 50) { const score = key.health_score ?? 1
if (score < 0.5) {
return 'bg-red-500' return 'bg-red-500'
} }
if (key.health_score < 80) { if (score < 0.8) {
return 'bg-yellow-500' return 'bg-yellow-500'
} }
return 'bg-green-500' return 'bg-green-500'
@@ -991,7 +956,7 @@ function getKeyStatusClass(key: RoutingKeyInfo): string {
function getKeyTooltip(key: RoutingKeyInfo): string { function getKeyTooltip(key: RoutingKeyInfo): string {
const parts: string[] = [] const parts: string[] = []
parts.push(`名称: ${key.name}`) parts.push(`名称: ${key.name}`)
parts.push(`健康度: ${Math.round(key.health_score)}%`) parts.push(`健康度: ${((key.health_score || 0) * 100).toFixed(0)}%`)
if (key.effective_rpm) { if (key.effective_rpm) {
parts.push(`RPM: ${key.is_adaptive ? '~' : ''}${key.effective_rpm}`) parts.push(`RPM: ${key.is_adaptive ? '~' : ''}${key.effective_rpm}`)
} }
@@ -1003,6 +968,25 @@ function getKeyTooltip(key: RoutingKeyInfo): string {
return parts.join('\n') return parts.join('\n')
} }
// 获取 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)
await loadRoutingData()
showSuccess(result.message || 'Key 已恢复')
emit('refresh')
} catch (err: any) {
showError(err.response?.data?.detail || 'Key 恢复失败', '错误')
}
}
// 监听 globalModelId 变化 // 监听 globalModelId 变化
watch(() => props.globalModelId, () => { watch(() => props.globalModelId, () => {
expandedFormats.value.clear() expandedFormats.value.clear()
@@ -1012,6 +996,7 @@ watch(() => props.globalModelId, () => {
onMounted(() => { onMounted(() => {
loadRoutingData() loadRoutingData()
startCountdownTimer()
}) })
// 暴露方法给父组件 // 暴露方法给父组件

View File

@@ -176,7 +176,8 @@
class="px-4 py-2.5 hover:bg-muted/30 transition-colors group/item" class="px-4 py-2.5 hover:bg-muted/30 transition-colors group/item"
:class="{ :class="{
'opacity-50': keyDragState.isDragging && keyDragState.draggedIndex === index, 'opacity-50': keyDragState.isDragging && keyDragState.draggedIndex === index,
'bg-primary/5 border-l-2 border-l-primary': keyDragState.targetIndex === index && keyDragState.isDragging 'bg-primary/5 border-l-2 border-l-primary': keyDragState.targetIndex === index && keyDragState.isDragging,
'opacity-40 bg-muted/20': !key.is_active
}" }"
draggable="true" draggable="true"
@dragstart="handleKeyDragStart($event, index)" @dragstart="handleKeyDragStart($event, index)"
@@ -209,13 +210,10 @@
</Button> </Button>
</div> </div>
</div> </div>
<Badge </div>
v-if="!key.is_active" <!-- 并发 + 健康度 + 操作按钮 -->
variant="secondary" <div class="flex items-center gap-1 shrink-0">
class="text-[10px] px-1.5 py-0 shrink-0" <!-- 熔断徽章 -->
>
禁用
</Badge>
<Badge <Badge
v-if="key.circuit_breaker_open" v-if="key.circuit_breaker_open"
variant="destructive" variant="destructive"
@@ -223,16 +221,6 @@
> >
熔断 熔断
</Badge> </Badge>
</div>
<!-- 并发 + 健康度 + 操作按钮 -->
<div class="flex items-center gap-1 shrink-0">
<!-- RPM 限制信息放在最前面 -->
<span
v-if="key.rpm_limit || key.is_adaptive"
class="text-[10px] text-muted-foreground mr-1"
>
{{ key.is_adaptive ? '自适应' : key.rpm_limit }} RPM
</span>
<!-- 健康度 --> <!-- 健康度 -->
<div <div
v-if="key.health_score !== undefined" v-if="key.health_score !== undefined"
@@ -321,8 +309,13 @@
@keydown="(e) => handlePriorityKeydown(e, key)" @keydown="(e) => handlePriorityKeydown(e, key)"
@blur="handlePriorityBlur(key)" @blur="handlePriorityBlur(key)"
> >
<!-- RPM 限制信息第二位 -->
<template v-if="key.rpm_limit || key.is_adaptive">
<span class="text-muted-foreground/40">|</span> <span class="text-muted-foreground/40">|</span>
<!-- API 格式展开显示每个格式和倍率 --> <span>{{ key.is_adaptive ? '自适应' : key.rpm_limit }} RPM</span>
</template>
<span class="text-muted-foreground/40">|</span>
<!-- API 格式展开显示每个格式倍率熔断状态 -->
<template <template
v-for="(format, idx) in getKeyApiFormats(key, endpoint)" v-for="(format, idx) in getKeyApiFormats(key, endpoint)"
:key="format" :key="format"
@@ -331,15 +324,11 @@
v-if="idx > 0" v-if="idx > 0"
class="text-muted-foreground/40" class="text-muted-foreground/40"
>/</span> >/</span>
<span>{{ API_FORMAT_SHORT[format] || format }} {{ getKeyRateMultiplier(key, format) }}x</span> <span :class="{ 'text-destructive': isFormatCircuitOpen(key, format) }">
{{ API_FORMAT_SHORT[format] || format }} {{ getKeyRateMultiplier(key, format) }}x{{ getFormatProbeCountdown(key, format) }}
</span>
</template> </template>
<span v-if="key.rate_limit">| {{ key.rate_limit }}rpm</span> <span v-if="key.rate_limit">| {{ key.rate_limit }}rpm</span>
<span
v-if="key.next_probe_at"
class="text-amber-600 dark:text-amber-400"
>
| {{ formatProbeTime(key.next_probe_at) }}探测
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -592,6 +581,7 @@ import Badge from '@/components/ui/badge.vue'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { useCountdownTimer, formatCountdown } from '@/composables/useCountdownTimer'
import { getProvider, getProviderEndpoints, getProviderAliasMappingPreview, type ProviderAliasMappingPreviewResponse } from '@/api/endpoints' import { getProvider, getProviderEndpoints, getProviderAliasMappingPreview, type ProviderAliasMappingPreviewResponse } from '@/api/endpoints'
import { import {
KeyFormDialog, KeyFormDialog,
@@ -641,6 +631,7 @@ const emit = defineEmits<{
const { error: showError, success: showSuccess } = useToast() const { error: showError, success: showSuccess } = useToast()
const { copyToClipboard } = useClipboard() const { copyToClipboard } = useClipboard()
const { tick: countdownTick, start: startCountdownTimer, stop: stopCountdownTimer } = useCountdownTimer()
const loading = ref(false) const loading = ref(false)
const provider = ref<any>(null) const provider = ref<any>(null)
@@ -831,7 +822,11 @@ watch(() => props.open, (newOpen) => {
loadProvider() loadProvider()
loadEndpoints() loadEndpoints()
loadAliasMappingPreview() loadAliasMappingPreview()
// 启动倒计时定时器
startCountdownTimer()
} else if (!newOpen) { } else if (!newOpen) {
// 停止倒计时定时器
stopCountdownTimer()
// 重置所有状态 // 重置所有状态
provider.value = null provider.value = null
endpoints.value = [] endpoints.value = []
@@ -1483,6 +1478,44 @@ function getHealthScoreBarColor(score: number): string {
return 'bg-red-500 dark:bg-red-400' return 'bg-red-500 dark:bg-red-400'
} }
// 检查指定格式是否熔断
function isFormatCircuitOpen(key: EndpointAPIKey, format: string): boolean {
if (!key.circuit_breaker_by_format) return false
const formatData = key.circuit_breaker_by_format[format]
return formatData?.open === true
}
// 获取指定格式的探测倒计时(如果熔断,返回带空格前缀的倒计时文本)
function getFormatProbeCountdown(key: EndpointAPIKey, format: string): string {
// 触发响应式更新
void countdownTick.value
if (!key.circuit_breaker_by_format) return ''
const formatData = key.circuit_breaker_by_format[format]
if (!formatData?.open) return ''
// 半开状态
if (formatData.half_open_until) {
const halfOpenUntil = new Date(formatData.half_open_until)
const now = new Date()
if (halfOpenUntil > now) {
return ' 探测中'
}
}
// 等待探测
if (formatData.next_probe_at) {
const nextProbe = new Date(formatData.next_probe_at)
const now = new Date()
const diffMs = nextProbe.getTime() - now.getTime()
if (diffMs > 0) {
return ' ' + formatCountdown(diffMs)
} else {
return ' 探测中'
}
}
return ''
}
// 加载 Provider 信息 // 加载 Provider 信息
async function loadProvider() { async function loadProvider() {
if (!props.providerId) return if (!props.providerId) return

View File

@@ -47,7 +47,7 @@ class RoutingKeyInfo(BaseModel):
is_adaptive: bool = Field(False, description="是否为自适应 RPM 模式") is_adaptive: bool = Field(False, description="是否为自适应 RPM 模式")
effective_rpm: Optional[int] = Field(None, description="有效 RPM 限制") effective_rpm: Optional[int] = Field(None, description="有效 RPM 限制")
cache_ttl_minutes: int = Field(0, description="缓存 TTL分钟") cache_ttl_minutes: int = Field(0, description="缓存 TTL分钟")
health_score: float = Field(100.0, description="健康度分数") health_score: float = Field(1.0, description="健康度分数0-1 小数格式)")
is_active: bool is_active: bool
api_formats: List[str] = Field(default_factory=list, description="支持的 API 格式") api_formats: List[str] = Field(default_factory=list, description="支持的 API 格式")
# 模型白名单 # 模型白名单
@@ -55,6 +55,7 @@ class RoutingKeyInfo(BaseModel):
# 熔断状态 # 熔断状态
circuit_breaker_open: bool = Field(False, description="熔断器是否打开") circuit_breaker_open: bool = Field(False, description="熔断器是否打开")
circuit_breaker_formats: List[str] = Field(default_factory=list, description="熔断的 API 格式列表") circuit_breaker_formats: List[str] = Field(default_factory=list, description="熔断的 API 格式列表")
next_probe_at: Optional[str] = Field(None, description="下次探测时间ISO格式")
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -272,11 +273,11 @@ class AdminGetModelRoutingPreviewAdapter(AdminApiAdapter):
if is_adaptive and key.learned_rpm_limit: if is_adaptive and key.learned_rpm_limit:
effective_rpm = key.learned_rpm_limit effective_rpm = key.learned_rpm_limit
# 从 health_by_format 获取健康度 # 从 health_by_format 获取健康度0-1 小数格式)
health_score = 100.0 health_score = 1.0
if key.health_by_format and ep.api_format: if key.health_by_format and ep.api_format:
format_health = key.health_by_format.get(ep.api_format, {}) format_health = key.health_by_format.get(ep.api_format, {})
health_score = format_health.get("health_score", 100.0) health_score = format_health.get("health_score", 1.0)
# 生成脱敏 SK先解密再脱敏 # 生成脱敏 SK先解密再脱敏
masked_key = "" masked_key = ""
@@ -296,11 +297,17 @@ class AdminGetModelRoutingPreviewAdapter(AdminApiAdapter):
# 检查熔断状态 # 检查熔断状态
circuit_breaker_open = False circuit_breaker_open = False
circuit_breaker_formats: List[str] = [] circuit_breaker_formats: List[str] = []
next_probe_at: Optional[str] = None
if key.circuit_breaker_by_format: if key.circuit_breaker_by_format:
for fmt, cb_state in key.circuit_breaker_by_format.items(): for fmt, cb_state in key.circuit_breaker_by_format.items():
if isinstance(cb_state, dict) and cb_state.get("open"): if isinstance(cb_state, dict) and cb_state.get("open"):
circuit_breaker_open = True circuit_breaker_open = True
circuit_breaker_formats.append(fmt) circuit_breaker_formats.append(fmt)
# 取最早的探测时间
fmt_next_probe = cb_state.get("next_probe_at")
if fmt_next_probe:
if next_probe_at is None or fmt_next_probe < next_probe_at:
next_probe_at = fmt_next_probe
# 解析 allowed_models # 解析 allowed_models
# 语义说明: # 语义说明:
@@ -334,6 +341,7 @@ class AdminGetModelRoutingPreviewAdapter(AdminApiAdapter):
allowed_models=allowed_models_list, allowed_models=allowed_models_list,
circuit_breaker_open=circuit_breaker_open, circuit_breaker_open=circuit_breaker_open,
circuit_breaker_formats=circuit_breaker_formats, circuit_breaker_formats=circuit_breaker_formats,
next_probe_at=next_probe_at,
) )
) )