From 6532d5b61effd64780af275e97a213c369cf0150 Mon Sep 17 00:00:00 2001 From: alfadb Date: Sun, 30 Aug 2026 15:34:21 +0800 Subject: [PATCH] fix(frontend): unify usage window rendering and tighten alert thresholds CN provider accounts (Kimi/Zhipu/DeepSeek) and the monitor quota view each hand-rolled their own usage bar, so the same account column rendered three different label styles, bar widths and threshold palettes. Both now reuse UsageProgressBar, the component the other five platforms already share. Also move the default utilization thresholds from >=100 red / >=80 amber to >=90 / >=75. Turning red only once the quota is exhausted is an after-the-fact notice rather than a warning. The remainingCapacity mode keeps its 20/50 pair, which is already a warning semantic. - UsageProgressBar: add labelWidth 'fixed' | 'auto' so the monitor view can render composite labels ("Pro/7d") without breaking column alignment; the fixed branch is byte-identical, so the existing platforms are untouched - drop the superseded utilizationColor/utilizationTextColor/formatReset copies - shorten CN window labels to 5h / 7d and remove the now-dead resetSoon keys --- .../account/CNProviderQuotaCell.vue | 64 +++------------- .../components/account/UsageProgressBar.vue | 45 +++++++----- .../__tests__/CNProviderQuotaCell.spec.ts | 58 +++++++++------ .../__tests__/UsageProgressBar.spec.ts | 57 +++++++++++++++ .../components/common/MonitorQuotaView.vue | 73 ++++++------------- .../common/__tests__/MonitorQuotaView.spec.ts | 9 ++- .../src/i18n/locales/en/admin/accounts.ts | 5 +- frontend/src/i18n/locales/en/dashboard.ts | 1 - .../src/i18n/locales/zh/admin/accounts.ts | 5 +- frontend/src/i18n/locales/zh/dashboard.ts | 1 - 10 files changed, 161 insertions(+), 157 deletions(-) diff --git a/frontend/src/components/account/CNProviderQuotaCell.vue b/frontend/src/components/account/CNProviderQuotaCell.vue index a7df801d6c..4d609455d4 100644 --- a/frontend/src/components/account/CNProviderQuotaCell.vue +++ b/frontend/src/components/account/CNProviderQuotaCell.vue @@ -4,38 +4,18 @@ data-test="cn-provider-quota" class="min-w-[220px] space-y-1" > - +
-
- - {{ windowLabel(tier.window) }} - -
-
-
- - {{ Math.round(tier.used_percent) }}% - - - · {{ formatReset(tier.reset_at) }} - -
+ :label="windowLabel(tier.window)" + :color="tier.window === 'weekly' ? 'emerald' : 'indigo'" + :utilization="tier.used_percent" + :resets-at="tier.reset_at" + />
- - + + {{ label }} @@ -62,15 +60,20 @@ import { useI18n } from 'vue-i18n' import type { WindowStats } from '@/types' import { formatCompactNumber } from '@/utils/format' -const props = defineProps<{ - label: string - utilization: number // Percentage (0-100+) - resetsAt?: string | null - color: 'indigo' | 'emerald' | 'purple' | 'amber' - windowStats?: WindowStats | null - showNowWhenIdle?: boolean - remainingCapacity?: boolean -}>() +const props = withDefaults( + defineProps<{ + label: string + utilization: number // Percentage (0-100+) + resetsAt?: string | null + color: 'indigo' | 'emerald' | 'purple' | 'amber' + windowStats?: WindowStats | null + showNowWhenIdle?: boolean + remainingCapacity?: boolean + /** fixed: 定宽居中徽章(账号页纵向对齐);auto: 限宽截断左对齐(监控页组合标签) */ + labelWidth?: 'fixed' | 'auto' + }>(), + { labelWidth: 'fixed' } +) const { t } = useI18n() @@ -108,6 +111,14 @@ const labelClass = computed(() => { return colors[props.color] }) +// Label badge width mode: fixed 定宽保证账号页纵向对齐;auto 限宽截断适配 +// 监控页「Pro/7 天」类组合标签。百分比列在两种模式下保持不变。 +const labelSizeClass = computed(() => + props.labelWidth === 'auto' + ? 'max-w-[72px] shrink-0 truncate rounded px-1 text-left text-[10px] font-medium' + : 'w-[32px] shrink-0 rounded px-1 text-center text-[10px] font-medium' +) + // Progress bar color based on utilization const barClass = computed(() => { if (props.remainingCapacity) { @@ -118,9 +129,9 @@ const barClass = computed(() => { } return 'bg-green-500' } - if (props.utilization >= 100) { + if (props.utilization >= 90) { return 'bg-red-500' - } else if (props.utilization >= 80) { + } else if (props.utilization >= 75) { return 'bg-amber-500' } else { return 'bg-green-500' @@ -137,9 +148,9 @@ const textClass = computed(() => { } return 'text-gray-600 dark:text-gray-400' } - if (props.utilization >= 100) { + if (props.utilization >= 90) { return 'text-red-600 dark:text-red-400' - } else if (props.utilization >= 80) { + } else if (props.utilization >= 75) { return 'text-amber-600 dark:text-amber-400' } else { return 'text-gray-600 dark:text-gray-400' diff --git a/frontend/src/components/account/__tests__/CNProviderQuotaCell.spec.ts b/frontend/src/components/account/__tests__/CNProviderQuotaCell.spec.ts index ab50774ed1..e0384fe72c 100644 --- a/frontend/src/components/account/__tests__/CNProviderQuotaCell.spec.ts +++ b/frontend/src/components/account/__tests__/CNProviderQuotaCell.spec.ts @@ -1,6 +1,7 @@ import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import CNProviderQuotaCell from '../CNProviderQuotaCell.vue' +import UsageProgressBar from '../UsageProgressBar.vue' import type { Account } from '@/types' const { queryQuota } = vi.hoisted(() => ({ @@ -13,11 +14,17 @@ vi.mock('@/api/admin', () => ({ } })) -vi.mock('vue-i18n', () => ({ - useI18n: () => ({ - t: (key: string) => key - }) -})) +// 保留 vue-i18n 真实导出:UsageProgressBar 依赖 @/utils/format → @/i18n, +// 其模块级 createI18n 需要真实 createI18n 存在。 +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key + }) + } +}) const account = { id: 7, @@ -38,7 +45,7 @@ describe('CNProviderQuotaCell', () => { queryQuota.mockReset() }) - it('keeps the compact quota stack readable inside the account table cell', async () => { + it('renders tier rows through the shared UsageProgressBar inside the account table cell', async () => { queryQuota.mockResolvedValue({ success: true, tiers: [ @@ -51,27 +58,30 @@ describe('CNProviderQuotaCell', () => { const root = wrapper.get('[data-test="cn-provider-quota"]') expect(root.classes()).toContain('min-w-[220px]') - const probeButton = root.get('button') - expect(probeButton.classes()).toContain('whitespace-nowrap') - expect(probeButton.classes()).toContain('leading-4') + // 新鲜快照:挂载即渲染条形图,不触发探测 + await flushPromises() + expect(queryQuota).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('27%') + + // probe 按钮文案是动词 key(i18n mock 返回 key 本身),点击触发查询 + const probeButton = root.get('[data-test="cn-provider-quota-probe"]') + expect(probeButton.text()).toBe('admin.accounts.cnProviders.probe') await probeButton.trigger('click') await flushPromises() - - const tiers = root.findAll('[data-test="cn-provider-quota-tier"]') - expect(tiers).toHaveLength(2) - for (const tier of tiers) { - expect(tier.classes()).toContain('min-w-0') - expect(tier.classes()).toContain('leading-4') - } - - const labels = root.findAll('[data-test="cn-provider-quota-label"]') - expect(labels).toHaveLength(2) - for (const label of labels) { - expect(label.classes()).toContain('w-14') - expect(label.classes()).toContain('whitespace-nowrap') - } - expect(queryQuota).toHaveBeenCalledWith(account.id) + + // tier 行由 UsageProgressBar 渲染:数量、label/color/utilization/reset 逐行对齐 + expect(root.findAll('[data-test="cn-provider-quota-tier"]')).toHaveLength(2) + const bars = root.findAllComponents(UsageProgressBar) + expect(bars).toHaveLength(2) + expect(bars[0].props('label')).toBe('admin.accounts.cnProviders.window5h') + expect(bars[0].props('utilization')).toBe(0) + expect(bars[0].props('color')).toBe('indigo') + expect(bars[0].props('resetsAt')).toBe('2026-08-18T12:30:00+08:00') + expect(bars[1].props('label')).toBe('admin.accounts.cnProviders.windowWeekly') + expect(bars[1].props('utilization')).toBe(27) + expect(bars[1].props('color')).toBe('emerald') + expect(bars[1].props('resetsAt')).toBe('2026-08-22T00:00:00+08:00') }) it('labels the refresh control with an explicit action verb, not a data caption', async () => { diff --git a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts index af5fc5d66d..e92268e597 100644 --- a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts +++ b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts @@ -146,4 +146,61 @@ describe('UsageProgressBar', () => { expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 100%') expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500') }) + + it('默认利用率模式按 75/90 阈值提前预警分级', () => { + const mountAt = (utilization: number) => + mount(UsageProgressBar, { + props: { label: '5h', utilization, color: 'indigo' } + }) + + // 条形配色:74 绿 / 75 与 89 黄 / 90 红 + expect(mountAt(74).get('.h-1\\.5 > div').classes()).toContain('bg-green-500') + expect(mountAt(75).get('.h-1\\.5 > div').classes()).toContain('bg-amber-500') + expect(mountAt(89).get('.h-1\\.5 > div').classes()).toContain('bg-amber-500') + expect(mountAt(90).get('.h-1\\.5 > div').classes()).toContain('bg-red-500') + + // 百分比文本同步分级 + expect(mountAt(74).get('.h-1\\.5 + span').classes()).toContain('text-gray-600') + expect(mountAt(75).get('.h-1\\.5 + span').classes()).toContain('text-amber-600') + expect(mountAt(89).get('.h-1\\.5 + span').classes()).toContain('text-amber-600') + expect(mountAt(90).get('.h-1\\.5 + span').classes()).toContain('text-red-600') + }) + + it('labelWidth 默认 fixed:标签保持定宽居中,百分比列不变', () => { + const wrapper = mount(UsageProgressBar, { + props: { label: '5h', utilization: 30, color: 'indigo' } + }) + + const label = wrapper.get('.gap-1 > span') + expect(label.classes()).toContain('w-[32px]') + expect(label.classes()).toContain('text-center') + expect(label.classes()).not.toContain('max-w-[72px]') + + const percent = wrapper.get('.h-1\\.5 + span') + expect(percent.classes()).toContain('w-[32px]') + expect(percent.classes()).toContain('text-right') + }) + + it('labelWidth=auto 时标签限宽截断左对齐,百分比列保持不变', () => { + const wrapper = mount(UsageProgressBar, { + props: { + label: 'Pro/7 天', + utilization: 30, + color: 'purple', + labelWidth: 'auto' + } + }) + + const label = wrapper.get('.gap-1 > span') + expect(label.text()).toBe('Pro/7 天') + expect(label.classes()).toContain('max-w-[72px]') + expect(label.classes()).toContain('truncate') + expect(label.classes()).toContain('text-left') + expect(label.classes()).not.toContain('w-[32px]') + expect(label.classes()).not.toContain('text-center') + + const percent = wrapper.get('.h-1\\.5 + span') + expect(percent.classes()).toContain('w-[32px]') + expect(percent.classes()).toContain('text-right') + }) }) diff --git a/frontend/src/components/common/MonitorQuotaView.vue b/frontend/src/components/common/MonitorQuotaView.vue index 060e890636..a155be967a 100644 --- a/frontend/src/components/common/MonitorQuotaView.vue +++ b/frontend/src/components/common/MonitorQuotaView.vue @@ -7,26 +7,19 @@
- +
-
- - {{ row.label }} - -
-
-
- - {{ Math.round(row.tier.used_percent) }}% - - - · {{ formatReset(row.tier.reset_at) }} - -
+
@@ -50,12 +43,14 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import type { MonitorQuotaSnapshot, MonitorQuotaTier } from '@/api/admin/channelMonitor' +import UsageProgressBar from '@/components/account/UsageProgressBar.vue' /** * 配额快照渲染(管理端监控列表/运行结果 + 用户端监控卡片共用)。 - * 展示形态对齐账号管理侧的用量视图(CNProviderQuotaCell:同阈值配色、 - * 同倒计时格式);tier 的 Window/Label 是后端约定的机器 token, - * 已知 token 走 i18n,未知 token 原样展示(前向兼容)。 + * 用量条直接复用账号页的 UsageProgressBar(与 CNProviderQuotaCell、 + * AccountUsageCell 同一组件:同阈值配色、同倒计时格式);tier 的 + * Window/Label 是后端约定的机器 token,已知 token 走 i18n,未知 token + * 原样展示(前向兼容)。 */ const props = defineProps<{ snapshot?: MonitorQuotaSnapshot | null @@ -63,10 +58,13 @@ const props = defineProps<{ const { t, te } = useI18n() +type TierColor = 'indigo' | 'emerald' | 'purple' | 'amber' + interface QuotaTierRow { key: string label: string title: string + color: TierColor tier: MonitorQuotaTier } @@ -103,11 +101,15 @@ function tierLabel(tier: MonitorQuotaTier): string { return `${label}/${window}` } +// tier 配色按数组顺序轮转(UsageProgressBar 支持的色板)。 +const tierColors: TierColor[] = ['indigo', 'emerald', 'purple', 'amber'] + const tierRows = computed(() => (props.snapshot?.tiers || []).map((tier, idx) => ({ key: `${tier.window}-${tier.label || ''}-${idx}`, label: tierLabel(tier), title: tierLabel(tier), + color: tierColors[idx % tierColors.length], tier, })), ) @@ -126,31 +128,4 @@ const truncatedError = computed(() => { const error = props.snapshot?.error || t('monitorCommon.quota.unavailable') return error.length > 48 ? `${error.slice(0, 48)}…` : error }) - -const utilizationColor = (pct: number) => { - if (pct >= 90) return 'bg-red-500' - if (pct >= 75) return 'bg-amber-500' - return 'bg-emerald-500' -} - -const utilizationTextColor = (pct: number) => { - if (pct >= 90) return 'text-red-600 dark:text-red-400' - if (pct >= 75) return 'text-amber-600 dark:text-amber-400' - return 'text-emerald-600 dark:text-emerald-400' -} - -// 重置时间相对/绝对简短显示(与账号页一致)。 -const formatReset = (iso: string) => { - const d = new Date(iso) - if (isNaN(d.getTime())) return iso - const now = Date.now() - const diffMs = d.getTime() - now - if (diffMs <= 0) return t('monitorCommon.quota.resetSoon') - if (diffMs < 3_600_000) return `${Math.max(1, Math.round(diffMs / 60_000))}m` - const hours = Math.round(diffMs / 3_600_000) - if (hours < 48) return `${hours}h` - const mm = String(d.getMonth() + 1).padStart(2, '0') - const dd = String(d.getDate()).padStart(2, '0') - return `${mm}-${dd}` -} diff --git a/frontend/src/components/common/__tests__/MonitorQuotaView.spec.ts b/frontend/src/components/common/__tests__/MonitorQuotaView.spec.ts index 9f305d2156..48ac944976 100644 --- a/frontend/src/components/common/__tests__/MonitorQuotaView.spec.ts +++ b/frontend/src/components/common/__tests__/MonitorQuotaView.spec.ts @@ -42,9 +42,9 @@ describe('MonitorQuotaView', () => { }, }) - const rows = wrapper.findAll('[data-testid="monitor-quota-view"] .flex.items-center') + const rows = wrapper.findAll('[data-testid="monitor-quota-tier"]') // tier 行只在 success 且有数据时渲染 - expect(rows.length).toBeGreaterThanOrEqual(3) + expect(rows).toHaveLength(3) const text = wrapper.text() // 已知 window token 走 i18n expect(text).toContain('monitorCommon.quota.windows.5h') @@ -59,8 +59,9 @@ describe('MonitorQuotaView', () => { expect(text).toContain('95%') const html = wrapper.html() - // 阈值配色:≥90 红 / ≥75 黄 / 其余绿(与账号页 CNProviderQuotaCell 对齐) - expect(html).toContain('bg-emerald-500') + // 阈值配色(三处共用的 UsageProgressBar 统一):≥90 红 / ≥75 黄 / 其余绿 + // 42.4 → 绿、80 → 黄、95 → 红 + expect(html).toContain('bg-green-500') expect(html).toContain('bg-amber-500') expect(html).toContain('bg-red-500') }) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 5317941e2b..96b4f3333c 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -146,14 +146,13 @@ export default { }, }, balance: 'Balance --', - window5h: '5-hour window', - windowWeekly: 'Weekly window', + window5h: '5h', + windowWeekly: '7d', probe: 'Query', probeTooltip: 'Query the provider quota endpoint for 5-hour / weekly rolling window usage', balanceProbeTooltip: 'Query the provider balance endpoint for the account balance', balanceLow: 'Insufficient balance', noBalanceEndpoint: 'This platform has no balance query endpoint', - resetSoon: 'reset soon', }, types: { oauth: 'OAuth', diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index 12169ebbe9..021a615357 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -472,7 +472,6 @@ export default { // Quota snapshot rendering (MonitorQuotaView, shared by admin + user views) quota: { unavailable: 'Quota unavailable', - resetSoon: 'resetting', windows: { '5h': '5h', '7d': '7d', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 48ba2c5546..d256d5b732 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -349,14 +349,13 @@ export default { }, }, balance: '余额 --', - window5h: '5 小时窗口', - windowWeekly: '每周窗口', + window5h: '5h', + windowWeekly: '7d', probe: '查询', probeTooltip: '请求供应商额度端点,查询 5 小时 / 每周滚动窗口用量', balanceProbeTooltip: '请求供应商余额端点,查询账户余额', balanceLow: '余额不足', noBalanceEndpoint: '该平台暂无余额查询接口', - resetSoon: '即将重置', }, types: { oauth: 'OAuth', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index 7aad26eaa0..da006b7821 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -477,7 +477,6 @@ export default { // 配额快照展示(MonitorQuotaView,管理端与用户端共用) quota: { unavailable: '配额信息不可用', - resetSoon: '即将重置', windows: { '5h': '5 小时', '7d': '7 天',