From e7f8a5e5ad40f5f21980cd9cb1f3dcb7ba7c9554 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:48:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=B8=A0=E9=81=93=E5=86=B7?= =?UTF-8?q?=E5=8D=B4=E6=98=AF=E6=8C=89=E5=8E=9F=E5=9B=A0=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E7=9A=84=EF=BC=8C=E6=8F=90=E7=A4=BA=E4=B8=8D=E5=86=8D=E5=86=99?= =?UTF-8?q?=E6=AD=BB=E3=80=8C=E8=BF=9E=E7=BB=AD=E5=A4=B1=E8=B4=A5=20X/3?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端冷却策略已改成按失败原因区分(余额/配额类首次失败即冷却 30 分钟,其余按连续 进冷却次数指数退避 30s→60s→120s 封顶 10 分钟),前端还停留在「满 3 次进冷却 + 原始 秒数」的旧假设上,管理员看到的提示与真实状态对不上。 - Channel 类型补 cooldown_reason(network | server_error | rate_limited | quota_exhausted) - 冷却提示带失败原因;配额类额外提示「需充值或换 key」——唯一重试不会自愈的场景, 应当让管理员直接处理而不是等 30 分钟再看 - 连续失败提示去掉硬编码的 /3(触发阈值随原因变化) - 新增 fmtDur:1800 → 30m、5400 → 1h30m,替代直接显示秒数(附 4 组用例) --- web/src/lib/format.test.ts | 22 ++++++++++++++++- web/src/lib/format.ts | 15 ++++++++++++ web/src/types.ts | 3 ++- web/src/views/admin/ChannelsView.vue | 36 ++++++++++++++++++++++++---- 4 files changed, 70 insertions(+), 6 deletions(-) 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' }