mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-13 21:17:21 +08:00
feat(ui): 优化熔断状态显示和健康度格式
- 健康度格式统一为 0-1 小数格式(前后端同步) - 添加熔断倒计时显示,支持实时更新 - 新增 useCountdownTimer composable 用于倒计时逻辑 - 添加 Key 健康恢复功能(按 API 格式恢复) - 优化 UI 布局:简化链路预览标题、调整 Key 信息为两行显示 - 修复错误处理顺序(先刷新数据再显示成功提示) - 减少模板中函数重复调用
This commit is contained in:
@@ -638,12 +638,13 @@ export interface RoutingKeyInfo {
|
||||
is_adaptive: boolean
|
||||
effective_rpm?: number | null
|
||||
cache_ttl_minutes: number
|
||||
health_score: number
|
||||
health_score: number // 0-1 小数格式
|
||||
is_active: boolean
|
||||
api_formats: string[]
|
||||
allowed_models?: string[] | null // 允许的模型列表,null 表示不限制
|
||||
circuit_breaker_open: boolean
|
||||
circuit_breaker_formats: string[]
|
||||
next_probe_at?: string | null // 下次探测时间(ISO格式)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
69
frontend/src/composables/useCountdownTimer.ts
Normal file
69
frontend/src/composables/useCountdownTimer.ts
Normal 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 '探测中'
|
||||
}
|
||||
@@ -4,21 +4,33 @@
|
||||
<div class="px-4 py-3 border-b border-border/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<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">
|
||||
支持正则表达式 ({{ localAliases.length }}/{{ MAX_ALIASES_PER_MODEL }})
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="添加规则"
|
||||
:disabled="localAliases.length >= MAX_ALIASES_PER_MODEL"
|
||||
@click="addAlias"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
</Button>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="添加规则"
|
||||
:disabled="localAliases.length >= MAX_ALIASES_PER_MODEL"
|
||||
@click="addAlias"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
</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>
|
||||
|
||||
@@ -117,13 +129,12 @@
|
||||
:key="item.keyId"
|
||||
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">/</span>
|
||||
<span class="font-medium">{{ item.keyName }}</span>
|
||||
<span class="text-xs text-muted-foreground font-mono ml-auto">
|
||||
{{ item.maskedKey }}
|
||||
</span>
|
||||
<span class="text-muted-foreground">·</span>
|
||||
<code class="text-xs text-muted-foreground/70">{{ item.maskedKey }}</code>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Badge
|
||||
@@ -170,9 +181,11 @@ const props = defineProps<{
|
||||
globalModelId: string
|
||||
modelName: string
|
||||
aliases: string[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
update: [aliases: string[]]
|
||||
refresh: []
|
||||
}>()
|
||||
// 安全限制常量(与后端保持一致)
|
||||
const MAX_ALIASES_PER_MODEL = 50
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
<!-- 标题栏 -->
|
||||
<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 gap-2">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<h4 class="text-sm font-semibold">
|
||||
请求链路预览
|
||||
链路预览
|
||||
</h4>
|
||||
<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">
|
||||
{{ getSchedulingModeLabel(routingData.scheduling_mode) }}
|
||||
</span>
|
||||
<span class="text-muted-foreground">·</span>
|
||||
<span class="text-xs text-muted-foreground">·</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ getPriorityModeLabel(routingData.priority_mode) }}
|
||||
</span>
|
||||
@@ -23,10 +23,10 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="添加关联"
|
||||
title="关联提供商"
|
||||
@click="$emit('addProvider')"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
<Link class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -86,16 +86,18 @@
|
||||
>
|
||||
{{ formatGroup.api_format }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatGroup.active_keys }}/{{ formatGroup.total_keys }} Keys
|
||||
<span class="mx-1.5">·</span>
|
||||
{{ formatGroup.active_providers }}/{{ formatGroup.total_providers }} 提供商
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="w-4 h-4 text-muted-foreground transition-transform"
|
||||
:class="isFormatExpanded(formatGroup.api_format) ? 'rotate-180' : ''"
|
||||
/>
|
||||
</div>
|
||||
<ChevronDown
|
||||
class="w-4 h-4 text-muted-foreground transition-transform"
|
||||
:class="isFormatExpanded(formatGroup.api_format) ? 'rotate-180' : ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 展开的内容 -->
|
||||
@@ -147,14 +149,14 @@
|
||||
:class="!keyEntry.key.is_active ? 'opacity-50' : ''"
|
||||
>
|
||||
<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)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 第一列:优先级标签 -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- 优先级标签 -->
|
||||
<div
|
||||
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
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted-foreground/20 text-muted-foreground'"
|
||||
@@ -163,78 +165,60 @@
|
||||
<span v-else>P{{ keyGroup.priority ?? '?' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 第二列:状态指示灯 -->
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="getKeyStatusClass(keyEntry.key)"
|
||||
/>
|
||||
|
||||
<!-- 第三列:Key 名称 + Provider 信息 -->
|
||||
<!-- Key 信息:两行 -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
class="text-sm font-medium truncate"
|
||||
:class="keyEntry.key.circuit_breaker_open ? 'text-destructive' : ''"
|
||||
>
|
||||
{{ 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"
|
||||
/>
|
||||
<!-- 第一行:Key 名称 -->
|
||||
<div
|
||||
class="text-sm font-medium truncate"
|
||||
:class="keyEntry.key.circuit_breaker_open ? 'text-destructive' : ''"
|
||||
>
|
||||
{{ keyEntry.key.name }}
|
||||
</div>
|
||||
<!-- Provider 和 Endpoint 信息 -->
|
||||
<div class="text-[10px] text-muted-foreground truncate">
|
||||
{{ keyEntry.provider.name }}
|
||||
<span v-if="hasModelMapping(keyEntry.provider)">
|
||||
({{ keyEntry.provider.provider_model_name }})
|
||||
</span>
|
||||
<span v-if="keyEntry.provider.billing_type">
|
||||
· {{ getBillingLabel(keyEntry.provider) }}
|
||||
</span>
|
||||
<span v-if="keyEntry.endpoint">
|
||||
· {{ keyEntry.endpoint.base_url }}
|
||||
</span>
|
||||
<!-- 第二行:提供商名 · sk -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第四列:健康度 + RPM + 操作按钮 -->
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<!-- 健康度 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-8 h-1 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}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] font-medium tabular-nums"
|
||||
:class="getHealthScoreTextColor(keyEntry.key.health_score)"
|
||||
>
|
||||
{{ Math.round(keyEntry.key.health_score) }}%
|
||||
</span>
|
||||
<!-- 熔断徽章 -->
|
||||
<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}%` }"
|
||||
/>
|
||||
</div>
|
||||
<!-- RPM -->
|
||||
<span
|
||||
v-if="keyEntry.key.effective_rpm"
|
||||
class="text-[10px] text-muted-foreground/60"
|
||||
class="text-[10px] font-medium tabular-nums w-7 text-right"
|
||||
:class="getHealthScoreTextColor(keyEntry.key.health_score)"
|
||||
>
|
||||
{{ keyEntry.key.is_adaptive ? '~' : '' }}{{ keyEntry.key.effective_rpm }}
|
||||
{{ ((keyEntry.key.health_score || 0) * 100).toFixed(0) }}%
|
||||
</span>
|
||||
<!-- 操作按钮 -->
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center shrink-0">
|
||||
<Button
|
||||
v-if="keyEntry.key.circuit_breaker_open || (keyEntry.key.health_score ?? 1) < 0.5"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
title="编辑此关联"
|
||||
@click.stop="$emit('editProvider', keyEntry.provider)"
|
||||
class="h-6 w-6 text-green-600"
|
||||
title="刷新健康状态"
|
||||
@click.stop="handleRecoverKey(keyEntry.key.id, formatGroup.api_format)"
|
||||
>
|
||||
<Edit class="w-3 h-3" />
|
||||
<RefreshCw class="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -245,24 +229,8 @@
|
||||
>
|
||||
<Power class="w-3 h-3" />
|
||||
</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
|
||||
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>
|
||||
@@ -329,10 +297,10 @@
|
||||
@click="toggleProviderInFormat(formatGroup.api_format, providerEntry.provider.id, providerEntry.endpoint?.id)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 第一列:优先级标签 -->
|
||||
<!-- 第一列:优先级标签(固定宽度,用于对齐) -->
|
||||
<div
|
||||
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
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted-foreground/20 text-muted-foreground'"
|
||||
@@ -382,15 +350,6 @@
|
||||
{{ providerEntry.active_keys }}/{{ providerEntry.keys.length }} Keys
|
||||
</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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -400,15 +359,6 @@
|
||||
>
|
||||
<Power class="w-3 h-3" />
|
||||
</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
|
||||
class="w-3.5 h-3.5 text-muted-foreground transition-transform"
|
||||
@@ -427,7 +377,7 @@
|
||||
<!-- Keys 列表 -->
|
||||
<div
|
||||
v-if="providerEntry.keys.length > 0"
|
||||
class="relative ml-1"
|
||||
class="relative"
|
||||
>
|
||||
<div
|
||||
v-for="(group, groupIndex) in getKeyPriorityGroups(providerEntry.keys)"
|
||||
@@ -436,7 +386,7 @@
|
||||
<!-- 第一组且有多个 key 时显示负载均衡标签 -->
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
@@ -453,62 +403,79 @@
|
||||
<div
|
||||
v-for="(key, keyIndex) in group.keys"
|
||||
:key="key.id"
|
||||
class="relative flex items-center"
|
||||
class="relative flex items-center gap-2"
|
||||
>
|
||||
<!-- 分支结构 -->
|
||||
<div class="flex items-center shrink-0">
|
||||
<!-- 第一列:节点(与优先级标签对齐,min-w-8) -->
|
||||
<div class="min-w-8 flex items-center justify-center shrink-0">
|
||||
<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 class="w-2 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
<!-- Key 信息 -->
|
||||
<!-- 第二列:状态灯(与提供商状态灯对齐) -->
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="getKeyStatusClass(key)"
|
||||
/>
|
||||
|
||||
<!-- 第三列:Key 信息 -->
|
||||
<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="[
|
||||
groupIndex === 0 ? 'bg-primary/5' : 'bg-muted/30',
|
||||
!key.is_active ? 'opacity-50' : ''
|
||||
]"
|
||||
:title="getKeyTooltip(key)"
|
||||
>
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="getKeyStatusClass(key)"
|
||||
/>
|
||||
<span
|
||||
class="font-medium truncate"
|
||||
:class="key.circuit_breaker_open ? 'text-destructive' : ''"
|
||||
>
|
||||
{{ key.name }}
|
||||
</span>
|
||||
<code class="font-mono text-[10px] text-muted-foreground/60 shrink-0">
|
||||
{{ key.masked_key }}
|
||||
</code>
|
||||
<Zap
|
||||
v-if="key.circuit_breaker_open"
|
||||
class="w-3 h-3 text-destructive shrink-0"
|
||||
/>
|
||||
<!-- 名称 + sk(垂直堆叠) -->
|
||||
<div class="min-w-0 flex flex-col">
|
||||
<span
|
||||
class="font-medium truncate"
|
||||
:class="key.circuit_breaker_open ? 'text-destructive' : ''"
|
||||
>
|
||||
{{ key.name }}
|
||||
</span>
|
||||
<code class="font-mono text-[10px] text-muted-foreground/60">
|
||||
{{ key.masked_key }}
|
||||
</code>
|
||||
</div>
|
||||
<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="w-8 h-1 bg-muted/80 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-300"
|
||||
:class="getHealthScoreBarColor(key.health_score)"
|
||||
:style="{ width: `${key.health_score}%` }"
|
||||
:style="{ width: `${(key.health_score || 0) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] font-medium tabular-nums"
|
||||
:class="getHealthScoreTextColor(key.health_score)"
|
||||
>
|
||||
{{ Math.round(key.health_score) }}%
|
||||
{{ ((key.health_score || 0) * 100).toFixed(0) }}%
|
||||
</span>
|
||||
</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
|
||||
v-if="key.effective_rpm"
|
||||
class="text-[10px] text-muted-foreground/60 shrink-0"
|
||||
@@ -522,23 +489,17 @@
|
||||
<!-- 优先级组之间的降级标记(如果下一组有多个 key,显示"降级 · 负载均衡") -->
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<ArrowDown class="w-3 h-3" />
|
||||
<!-- 箭头居中于节点列 -->
|
||||
<div class="min-w-8 flex items-center justify-center shrink-0">
|
||||
<ArrowDown class="w-3 h-3" />
|
||||
</div>
|
||||
<span>
|
||||
{{ getKeyPriorityGroups(providerEntry.keys)[groupIndex + 1].keys.length > 1 ? '降级 · 负载均衡' : '降级' }}
|
||||
</span>
|
||||
</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
|
||||
@@ -605,11 +566,8 @@ import {
|
||||
ChevronDown,
|
||||
Route,
|
||||
AlertCircle,
|
||||
Zap,
|
||||
Edit,
|
||||
Power,
|
||||
Trash2,
|
||||
Plus
|
||||
Link
|
||||
} from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
@@ -622,12 +580,15 @@ import {
|
||||
type RoutingEndpointInfo
|
||||
} from '@/api/global-models'
|
||||
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<{
|
||||
globalModelId: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
editProvider: [provider: RoutingProviderInfo]
|
||||
toggleProviderStatus: [provider: RoutingProviderInfo]
|
||||
deleteProvider: [provider: RoutingProviderInfo]
|
||||
@@ -635,6 +596,9 @@ defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | 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'
|
||||
}
|
||||
|
||||
// 健康度进度条颜色
|
||||
// 健康度进度条颜色(score 为 0-1 小数格式)
|
||||
function getHealthScoreBarColor(score: number): string {
|
||||
if (score >= 80) return 'bg-green-500 dark:bg-green-400'
|
||||
if (score >= 50) return 'bg-yellow-500 dark:bg-yellow-400'
|
||||
if (score >= 0.8) return 'bg-green-500 dark:bg-green-400'
|
||||
if (score >= 0.5) return 'bg-yellow-500 dark:bg-yellow-400'
|
||||
return 'bg-red-500 dark:bg-red-400'
|
||||
}
|
||||
|
||||
// 健康度文字颜色
|
||||
// 健康度文字颜色(score 为 0-1 小数格式)
|
||||
function getHealthScoreTextColor(score: number): string {
|
||||
if (score >= 80) return 'text-green-600 dark:text-green-400'
|
||||
if (score >= 50) return 'text-yellow-600 dark:text-yellow-400'
|
||||
if (score >= 0.8) return 'text-green-600 dark:text-green-400'
|
||||
if (score >= 0.5) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
|
||||
@@ -970,7 +934,7 @@ function getBillingLabel(provider: RoutingProviderInfo): string {
|
||||
return '免费'
|
||||
}
|
||||
|
||||
// 获取 Key 状态样式
|
||||
// 获取 Key 状态样式(score 为 0-1 小数格式)
|
||||
function getKeyStatusClass(key: RoutingKeyInfo): string {
|
||||
if (!key.is_active) {
|
||||
return 'bg-gray-400'
|
||||
@@ -978,10 +942,11 @@ function getKeyStatusClass(key: RoutingKeyInfo): string {
|
||||
if (key.circuit_breaker_open) {
|
||||
return 'bg-red-500'
|
||||
}
|
||||
if (key.health_score < 50) {
|
||||
const score = key.health_score ?? 1
|
||||
if (score < 0.5) {
|
||||
return 'bg-red-500'
|
||||
}
|
||||
if (key.health_score < 80) {
|
||||
if (score < 0.8) {
|
||||
return 'bg-yellow-500'
|
||||
}
|
||||
return 'bg-green-500'
|
||||
@@ -991,7 +956,7 @@ function getKeyStatusClass(key: RoutingKeyInfo): string {
|
||||
function getKeyTooltip(key: RoutingKeyInfo): string {
|
||||
const parts: string[] = []
|
||||
parts.push(`名称: ${key.name}`)
|
||||
parts.push(`健康度: ${Math.round(key.health_score)}%`)
|
||||
parts.push(`健康度: ${((key.health_score || 0) * 100).toFixed(0)}%`)
|
||||
if (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')
|
||||
}
|
||||
|
||||
// 获取 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 变化
|
||||
watch(() => props.globalModelId, () => {
|
||||
expandedFormats.value.clear()
|
||||
@@ -1012,6 +996,7 @@ watch(() => props.globalModelId, () => {
|
||||
|
||||
onMounted(() => {
|
||||
loadRoutingData()
|
||||
startCountdownTimer()
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
|
||||
@@ -176,7 +176,8 @@
|
||||
class="px-4 py-2.5 hover:bg-muted/30 transition-colors group/item"
|
||||
:class="{
|
||||
'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"
|
||||
@dragstart="handleKeyDragStart($event, index)"
|
||||
@@ -209,13 +210,10 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
v-if="!key.is_active"
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
>
|
||||
禁用
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- 并发 + 健康度 + 操作按钮 -->
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<!-- 熔断徽章 -->
|
||||
<Badge
|
||||
v-if="key.circuit_breaker_open"
|
||||
variant="destructive"
|
||||
@@ -223,16 +221,6 @@
|
||||
>
|
||||
熔断
|
||||
</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
|
||||
v-if="key.health_score !== undefined"
|
||||
@@ -321,8 +309,13 @@
|
||||
@keydown="(e) => handlePriorityKeydown(e, key)"
|
||||
@blur="handlePriorityBlur(key)"
|
||||
>
|
||||
<!-- RPM 限制信息(第二位) -->
|
||||
<template v-if="key.rpm_limit || key.is_adaptive">
|
||||
<span class="text-muted-foreground/40">|</span>
|
||||
<span>{{ key.is_adaptive ? '自适应' : key.rpm_limit }} RPM</span>
|
||||
</template>
|
||||
<span class="text-muted-foreground/40">|</span>
|
||||
<!-- API 格式:展开显示每个格式和倍率 -->
|
||||
<!-- API 格式:展开显示每个格式、倍率、熔断状态 -->
|
||||
<template
|
||||
v-for="(format, idx) in getKeyApiFormats(key, endpoint)"
|
||||
:key="format"
|
||||
@@ -331,15 +324,11 @@
|
||||
v-if="idx > 0"
|
||||
class="text-muted-foreground/40"
|
||||
>/</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>
|
||||
<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>
|
||||
@@ -592,6 +581,7 @@ import Badge from '@/components/ui/badge.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useCountdownTimer, formatCountdown } from '@/composables/useCountdownTimer'
|
||||
import { getProvider, getProviderEndpoints, getProviderAliasMappingPreview, type ProviderAliasMappingPreviewResponse } from '@/api/endpoints'
|
||||
import {
|
||||
KeyFormDialog,
|
||||
@@ -641,6 +631,7 @@ const emit = defineEmits<{
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const { tick: countdownTick, start: startCountdownTimer, stop: stopCountdownTimer } = useCountdownTimer()
|
||||
|
||||
const loading = ref(false)
|
||||
const provider = ref<any>(null)
|
||||
@@ -831,7 +822,11 @@ watch(() => props.open, (newOpen) => {
|
||||
loadProvider()
|
||||
loadEndpoints()
|
||||
loadAliasMappingPreview()
|
||||
// 启动倒计时定时器
|
||||
startCountdownTimer()
|
||||
} else if (!newOpen) {
|
||||
// 停止倒计时定时器
|
||||
stopCountdownTimer()
|
||||
// 重置所有状态
|
||||
provider.value = null
|
||||
endpoints.value = []
|
||||
@@ -1483,6 +1478,44 @@ function getHealthScoreBarColor(score: number): string {
|
||||
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 信息
|
||||
async function loadProvider() {
|
||||
if (!props.providerId) return
|
||||
|
||||
@@ -47,7 +47,7 @@ class RoutingKeyInfo(BaseModel):
|
||||
is_adaptive: bool = Field(False, description="是否为自适应 RPM 模式")
|
||||
effective_rpm: Optional[int] = Field(None, description="有效 RPM 限制")
|
||||
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
|
||||
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_formats: List[str] = Field(default_factory=list, description="熔断的 API 格式列表")
|
||||
next_probe_at: Optional[str] = Field(None, description="下次探测时间(ISO格式)")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -272,11 +273,11 @@ class AdminGetModelRoutingPreviewAdapter(AdminApiAdapter):
|
||||
if is_adaptive and key.learned_rpm_limit:
|
||||
effective_rpm = key.learned_rpm_limit
|
||||
|
||||
# 从 health_by_format 获取健康度
|
||||
health_score = 100.0
|
||||
# 从 health_by_format 获取健康度(0-1 小数格式)
|
||||
health_score = 1.0
|
||||
if key.health_by_format and 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(先解密再脱敏)
|
||||
masked_key = ""
|
||||
@@ -296,11 +297,17 @@ class AdminGetModelRoutingPreviewAdapter(AdminApiAdapter):
|
||||
# 检查熔断状态
|
||||
circuit_breaker_open = False
|
||||
circuit_breaker_formats: List[str] = []
|
||||
next_probe_at: Optional[str] = None
|
||||
if key.circuit_breaker_by_format:
|
||||
for fmt, cb_state in key.circuit_breaker_by_format.items():
|
||||
if isinstance(cb_state, dict) and cb_state.get("open"):
|
||||
circuit_breaker_open = True
|
||||
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
|
||||
# 语义说明:
|
||||
@@ -334,6 +341,7 @@ class AdminGetModelRoutingPreviewAdapter(AdminApiAdapter):
|
||||
allowed_models=allowed_models_list,
|
||||
circuit_breaker_open=circuit_breaker_open,
|
||||
circuit_breaker_formats=circuit_breaker_formats,
|
||||
next_probe_at=next_probe_at,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user