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

@@ -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