mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-14 05:25:19 +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
|
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格式)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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,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
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 暴露方法给父组件
|
// 暴露方法给父组件
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user