diff --git a/web/src/lib/format.test.ts b/web/src/lib/format.test.ts index d8bede5..a217884 100644 --- a/web/src/lib/format.test.ts +++ b/web/src/lib/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { fmtCost, fmtMoney, fmtNum, fmtTime } from './format' +import { fmtCost, fmtDur, fmtMoney, fmtNum, fmtTime } from './format' describe('fmtCost', () => { it('零值为 $0', () => expect(fmtCost(0)).toBe('$0')) @@ -26,3 +26,23 @@ describe('fmtTime', () => { }) it('合法时间可解析', () => expect(fmtTime('2026-01-01T00:00:00Z')).not.toBe('-')) }) + +describe('fmtDur', () => { + it('一分钟内用秒', () => { + expect(fmtDur(0)).toBe('0s') + expect(fmtDur(45)).toBe('45s') + }) + it('分钟带余秒', () => { + expect(fmtDur(90)).toBe('1m30s') + expect(fmtDur(600)).toBe('10m') + expect(fmtDur(1800)).toBe('30m') + }) + it('小时级', () => { + expect(fmtDur(7200)).toBe('2h') + expect(fmtDur(5400)).toBe('1h30m') + }) + it('负数与小数被归一', () => { + expect(fmtDur(-5)).toBe('0s') + expect(fmtDur(59.6)).toBe('1m') + }) +}) diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 6138bb6..ad333c1 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -20,3 +20,18 @@ export function fmtCost(v: number): string { if (v < 0.01) return '$' + v.toFixed(6).replace(/0+$/, '').replace(/\.$/, '') return fmtMoney(v) } + +// fmtDur 秒数转人读时长:45 → 45s、90 → 1m30s、1800 → 30m、7200 → 2h。 +// 冷却从 30s 的指数退避一路到配额类的 30 分钟,直接显示秒数已经不可读。 +export function fmtDur(seconds: number): string { + const s = Math.max(0, Math.round(seconds)) + if (s < 60) return `${s}s` + if (s < 3600) { + const m = Math.floor(s / 60) + const r = s % 60 + return r === 0 ? `${m}m` : `${m}m${r}s` + } + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + return m === 0 ? `${h}h` : `${h}h${m}m` +} diff --git a/web/src/types.ts b/web/src/types.ts index 85c9f44..68b91e9 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -36,8 +36,9 @@ export interface Channel { timeout_ms: number max_concurrency: number health_status: string - runtime_fails: number // 运行期连续失败次数(进程内,满 3 次进冷却) + runtime_fails: number // 运行期连续失败次数(进程内;满阈值进冷却,余额/配额类首次即冷却) cooldown_seconds: number // 运行期冷却剩余秒数,0 表示未冷却 + cooldown_reason?: string // 最近失败原因:network | server_error | rate_limited | quota_exhausted enabled: boolean created_at: string } diff --git a/web/src/views/admin/ChannelsView.vue b/web/src/views/admin/ChannelsView.vue index 14e00ad..4377786 100644 --- a/web/src/views/admin/ChannelsView.vue +++ b/web/src/views/admin/ChannelsView.vue @@ -4,7 +4,7 @@ import { PhCaretDown, PhPulse, PhNotePencil, PhTrash, PhStack, PhArrowsClockwise import { http, errMsg } from '@/api/client' import { useToastStore } from '@/stores/toast' import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol' -import { fmtCost } from '@/lib/format' +import { fmtCost, fmtDur } from '@/lib/format' import ChannelModelsDrawer from '@/views/admin/ChannelModelsDrawer.vue' import Button from '@/components/ui/Button.vue' import Input from '@/components/ui/Input.vue' @@ -177,13 +177,41 @@ async function testChannel(ch: Channel) { } } -// 运行期冷却是网关进程内的状态,探测写的 health_status 看不到它,所以单独提示 +// 运行期冷却是网关进程内的状态,探测写的 health_status 看不到它,所以单独提示。 +// 冷却时长与触发条件都随失败原因变化(余额/配额类首次失败即 30 分钟,其余指数退避), +// 不能再写死「/3」,也不能只给秒数。 +const cooldownReasonLabel: Record = { + network: '网络/超时', + server_error: '上游 5xx', + rate_limited: '上游限流', + quota_exhausted: '余额/配额耗尽', +} + +function cooldownReason(ch: Channel): string { + const r = ch.cooldown_reason ?? '' + return cooldownReasonLabel[r] ?? r +} + function runtimeHint(ch: Channel): string { - if (ch.cooldown_seconds > 0) return `冷却中 ${ch.cooldown_seconds}s` - if (ch.runtime_fails > 0) return `连续失败 ${ch.runtime_fails}/3` + if (ch.cooldown_seconds > 0) { + const reason = cooldownReason(ch) + const base = `冷却中 ${fmtDur(ch.cooldown_seconds)}` + if (isQuotaCooldown(ch)) return `${base} · ${reason},需充值或换 key` + return reason ? `${base} · ${reason}` : base + } + if (ch.runtime_fails > 0) { + const reason = cooldownReason(ch) + return reason ? `连续失败 ${ch.runtime_fails} 次 · ${reason}` : `连续失败 ${ch.runtime_fails} 次` + } return '' } +// 余额/配额耗尽是唯一的「重试不会自愈」场景:冷却结束也经常还是坏的,值得到管理员面前 +// 直接点出来(充值或换 key),而不是让他等 30 分钟再看。 +function isQuotaCooldown(ch: Channel): boolean { + return ch.cooldown_seconds > 0 && ch.cooldown_reason === 'quota_exhausted' +} + function needRecover(ch: Channel): boolean { return ch.cooldown_seconds > 0 || ch.health_status !== 'healthy' }