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 天',