Merge pull request #6384 from alfadb/fix/unify-usage-window-style

fix(frontend): unify usage window rendering and tighten alert thresholds
This commit is contained in:
Wesley Liddick
2026-08-31 11:17:09 +08:00
committed by GitHub
10 changed files with 161 additions and 157 deletions
@@ -4,38 +4,18 @@
data-test="cn-provider-quota"
class="min-w-[220px] space-y-1"
>
<!-- Tier rows: 5h + weekly utilization bars (snapshot renders on mount) -->
<!-- Tier rows: 5h + weekly utilization bars (snapshot renders on mount).
复用账号页 UsageProgressBar:同阈值配色、同倒计时格式。 -->
<div v-if="data?.success && data.tiers?.length" class="space-y-1">
<div
<UsageProgressBar
v-for="tier in data.tiers"
:key="tier.window"
data-test="cn-provider-quota-tier"
class="flex min-w-0 items-center gap-1.5 text-[10px] leading-4"
>
<span
data-test="cn-provider-quota-label"
class="w-14 shrink-0 whitespace-nowrap text-gray-500 dark:text-gray-400"
>
{{ windowLabel(tier.window) }}
</span>
<div class="h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
class="h-full rounded-full transition-all"
:class="utilizationColor(tier.used_percent)"
:style="{ width: `${Math.min(100, Math.max(0, tier.used_percent))}%` }"
/>
</div>
<span :class="['shrink-0 font-medium', utilizationTextColor(tier.used_percent)]">
{{ Math.round(tier.used_percent) }}%
</span>
<span
v-if="tier.reset_at"
class="min-w-0 truncate text-gray-400 dark:text-gray-500"
:title="tier.reset_at"
>
· {{ formatReset(tier.reset_at) }}
</span>
</div>
:label="windowLabel(tier.window)"
:color="tier.window === 'weekly' ? 'emerald' : 'indigo'"
:utilization="tier.used_percent"
:resets-at="tier.reset_at"
/>
</div>
<!-- Explicit refresh action (aligned with the OpenAI "Query" / Grok "Probe"
@@ -86,6 +66,7 @@ import { adminAPI } from '@/api/admin'
import type { CNProviderQuotaProbeResult } from '@/api/admin/cnProviders'
import type { Account } from '@/types'
import { cnQuotaCellVisible } from './credentialsBuilder'
import UsageProgressBar from './UsageProgressBar.vue'
const props = defineProps<{
account: Account
@@ -185,33 +166,6 @@ const windowLabel = (window: string) =>
? t('admin.accounts.cnProviders.windowWeekly')
: t('admin.accounts.cnProviders.window5h')
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('admin.accounts.cnProviders.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}`
}
const handleProbe = async () => {
if (loading.value) return
loading.value = true
@@ -27,10 +27,8 @@
<!-- Progress bar row -->
<div class="flex items-center gap-1">
<!-- Label badge (fixed width for alignment) -->
<span
:class="['w-[32px] shrink-0 rounded px-1 text-center text-[10px] font-medium', labelClass]"
>
<!-- Label badge (label-width: fixed = 定宽居中, auto = 限宽截断左对齐) -->
<span :class="[labelSizeClass, labelClass]">
{{ label }}
</span>
@@ -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'
@@ -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<typeof import('vue-i18n')>('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 () => {
@@ -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')
})
})
@@ -7,26 +7,19 @@
</span>
</div>
<!-- 用量窗口条形图(样式/阈值对齐账号页 CNProviderQuotaCell) -->
<!-- 用量窗口条形图(复用账号页 UsageProgressBar:同阈值配色、同倒计时格式) -->
<div v-if="snapshot.success && tierRows.length" class="space-y-1">
<div v-for="row in tierRows" :key="row.key" class="flex items-center gap-1.5 text-[10px]">
<span class="w-14 shrink-0 truncate text-gray-500 dark:text-gray-400" :title="row.title">
{{ row.label }}
</span>
<div class="h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
class="h-full rounded-full transition-all"
:class="utilizationColor(row.tier.used_percent)"
:style="{ width: `${Math.min(100, Math.max(0, row.tier.used_percent))}%` }"
/>
</div>
<span :class="['shrink-0 font-medium', utilizationTextColor(row.tier.used_percent)]">
{{ Math.round(row.tier.used_percent) }}%
</span>
<span v-if="row.tier.reset_at" class="truncate text-gray-400 dark:text-gray-500" :title="row.tier.reset_at">
· {{ formatReset(row.tier.reset_at) }}
</span>
</div>
<UsageProgressBar
v-for="row in tierRows"
:key="row.key"
data-testid="monitor-quota-tier"
:label="row.label"
:title="row.title"
label-width="auto"
:color="row.color"
:utilization="row.tier.used_percent"
:resets-at="row.tier.reset_at ?? null"
/>
</div>
<!-- 余额(国产 payg;支持多币种) -->
@@ -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<QuotaTierRow[]>(() =>
(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}`
}
</script>
@@ -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')
})
@@ -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',
@@ -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',
@@ -349,14 +349,13 @@ export default {
},
},
balance: '余额 --',
window5h: '5 小时窗口',
windowWeekly: '每周窗口',
window5h: '5h',
windowWeekly: '7d',
probe: '查询',
probeTooltip: '请求供应商额度端点,查询 5 小时 / 每周滚动窗口用量',
balanceProbeTooltip: '请求供应商余额端点,查询账户余额',
balanceLow: '余额不足',
noBalanceEndpoint: '该平台暂无余额查询接口',
resetSoon: '即将重置',
},
types: {
oauth: 'OAuth',
@@ -477,7 +477,6 @@ export default {
// 配额快照展示(MonitorQuotaView,管理端与用户端共用)
quota: {
unavailable: '配额信息不可用',
resetSoon: '即将重置',
windows: {
'5h': '5 小时',
'7d': '7 天',