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