(() => {
// 建立 by_platform Map
- const byPlat = new Map
()
+ const byPlat = new Map()
for (const item of props.stats?.by_platform ?? []) byPlat.set(item.platform, item)
- // 建立 quota Map
+ // 建立 quota Map。三档全空的记录不产生卡片,挂到卡片上也不渲染配额区。
const byQuota = new Map()
for (const q of props.platformQuotas ?? []) byQuota.set(q.platform, q)
- // union 平台集合。后端 by_platform / quota 接口均不会返回 platform='__other__',
+ // 卡片集合 = 有用量的平台 ∪ 至少配置了一档限额的平台。
+ // 三档全空的限额记录等价于不限额,不单独产生卡片。
+ // 后端 by_platform / quota 接口均不会返回 platform='__other__',
// 无需显式排除;__other__ 由下方差值补差逻辑单独追加。
- const platforms = new Set([...byPlat.keys(), ...byQuota.keys()])
+ const platforms = new Set(byPlat.keys())
+ for (const [platform, q] of byQuota) {
+ if (hasAnyLimit(q)) platforms.add(platform)
+ }
const PLATFORM_ORDER = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok']
const cards: FusedPlatformCard[] = []
@@ -330,6 +332,9 @@ const platformCards = computed(() => {
return cards
})
+// 标题右侧的平台计数 = 实际渲染的平台卡片数,不含"其他"差额卡。
+const platformCount = computed(() => platformCards.value.filter((c) => !c.isOther).length)
+
// Quota helpers
type QuotaWindow = 'daily' | 'weekly' | 'monthly'
diff --git a/frontend/src/components/user/dashboard/__tests__/UserDashboardStats.spec.ts b/frontend/src/components/user/dashboard/__tests__/UserDashboardStats.spec.ts
new file mode 100644
index 0000000000..a2ab9ed5cf
--- /dev/null
+++ b/frontend/src/components/user/dashboard/__tests__/UserDashboardStats.spec.ts
@@ -0,0 +1,154 @@
+import { describe, it, expect, vi } from 'vitest'
+import { mount, type VueWrapper } from '@vue/test-utils'
+
+// t() 回显 key;带参数时附上 JSON,便于断言计数
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string, params?: Record) =>
+ params ? `${key}:${JSON.stringify(params)}` : key,
+ }),
+ }
+})
+
+import UserDashboardStats from '../UserDashboardStats.vue'
+import type { UserDashboardStats as UserStatsType, PlatformDashboardStats } from '@/api/usage'
+import type { PlatformQuotaItem } from '@/types'
+
+function makeStats(over: Partial = {}): UserStatsType {
+ return {
+ total_api_keys: 1,
+ active_api_keys: 1,
+ total_requests: 0,
+ total_input_tokens: 0,
+ total_output_tokens: 0,
+ total_cache_creation_tokens: 0,
+ total_cache_read_tokens: 0,
+ total_tokens: 0,
+ total_cost: 0,
+ total_actual_cost: 0,
+ today_requests: 0,
+ today_input_tokens: 0,
+ today_output_tokens: 0,
+ today_cache_creation_tokens: 0,
+ today_cache_read_tokens: 0,
+ today_tokens: 0,
+ today_cost: 0,
+ today_actual_cost: 0,
+ average_duration_ms: 0,
+ rpm: 0,
+ tpm: 0,
+ by_platform: [],
+ ...over,
+ }
+}
+
+function usage(platform: string, cost: number): PlatformDashboardStats {
+ return {
+ platform,
+ total_requests: 1,
+ total_tokens: 10,
+ total_actual_cost: cost,
+ today_requests: 1,
+ today_tokens: 10,
+ today_actual_cost: cost,
+ }
+}
+
+function quota(over: Partial & { platform: string }): PlatformQuotaItem {
+ return {
+ daily_limit_usd: null,
+ weekly_limit_usd: null,
+ monthly_limit_usd: null,
+ daily_usage_usd: 0,
+ weekly_usage_usd: 0,
+ monthly_usage_usd: 0,
+ ...over,
+ } as PlatformQuotaItem
+}
+
+function mountStats(stats: UserStatsType, platformQuotas: PlatformQuotaItem[] | null = null, isSimple = false) {
+ return mount(UserDashboardStats, {
+ props: { stats, balance: 0, isSimple, platformQuotas },
+ global: { stubs: { Icon: true } },
+ })
+}
+
+/** 渲染出的平台卡片,按 DOM 顺序返回 data-platform */
+function cardPlatforms(w: VueWrapper): string[] {
+ return w.findAll('[data-testid="platform-card"]').map((c) => c.attributes('data-platform') ?? '')
+}
+
+describe('UserDashboardStats 按平台拆分', () => {
+ it('只有用量的平台才产生卡片;三档全空的限额记录不产生卡片', () => {
+ const w = mountStats(
+ makeStats({ total_actual_cost: 0.03, today_actual_cost: 0.03, by_platform: [usage('grok', 0.03)] }),
+ [
+ quota({ platform: 'anthropic' }),
+ quota({ platform: 'openai' }),
+ quota({ platform: 'gemini' }),
+ quota({ platform: 'grok' }),
+ ]
+ )
+ expect(cardPlatforms(w)).toEqual(['grok'])
+ expect(w.text()).toContain('dashboard.platformCount:{"count":1}')
+ expect(w.html()).not.toContain('dashboard.platformQuota.title')
+ })
+
+ it('配置了限额但没有用量的平台也产生卡片,并渲染配额区', () => {
+ const w = mountStats(
+ makeStats({ total_actual_cost: 0.03, today_actual_cost: 0.03, by_platform: [usage('grok', 0.03)] }),
+ [quota({ platform: 'openai', daily_limit_usd: 10, daily_usage_usd: 2.5 }), quota({ platform: 'anthropic' })]
+ )
+ // 固定顺序:openai 排在 grok 前
+ expect(cardPlatforms(w)).toEqual(['openai', 'grok'])
+ expect(w.text()).toContain('dashboard.platformQuota.title')
+ expect(w.text()).toContain('dashboard.platformCount:{"count":2}')
+ })
+
+ it('同一平台既有用量又有限额只产生一张卡片', () => {
+ const w = mountStats(
+ makeStats({ total_actual_cost: 1, today_actual_cost: 1, by_platform: [usage('openai', 1)] }),
+ [quota({ platform: 'openai', daily_limit_usd: 10, daily_usage_usd: 1 })]
+ )
+ expect(cardPlatforms(w)).toEqual(['openai'])
+ expect(w.text()).toContain('dashboard.platformQuota.title')
+ })
+
+ it('限额为 0 的平台视为已配置,渲染禁用态', () => {
+ const w = mountStats(makeStats(), [quota({ platform: 'gemini', weekly_limit_usd: 0 })])
+ expect(cardPlatforms(w)).toEqual(['gemini'])
+ expect(w.text()).toContain('dashboard.platformQuota.disabled')
+ expect(w.text()).toContain('dashboard.platformCount:{"count":1}')
+ })
+
+ it('固定顺序之外的平台也产生卡片,并排在固定顺序之后', () => {
+ const w = mountStats(
+ makeStats({ total_actual_cost: 0.5, today_actual_cost: 0, by_platform: [usage('kimi', 0.3), usage('anthropic', 0.2)] })
+ )
+ expect(cardPlatforms(w)).toEqual(['anthropic', 'kimi'])
+ expect(w.text()).toContain('Kimi')
+ })
+
+ it('总值大于各平台之和时追加"其他"卡片,且不计入平台计数', () => {
+ const w = mountStats(
+ makeStats({ total_actual_cost: 1.0, today_actual_cost: 0, by_platform: [usage('anthropic', 0.4)] })
+ )
+ expect(cardPlatforms(w)).toEqual(['anthropic', '__other__'])
+ expect(w.text()).toContain('dashboard.platformOther')
+ expect(w.text()).toContain('dashboard.platformCount:{"count":1}')
+ })
+
+ it('没有任何用量也没有配置限额时不渲染整块', () => {
+ const w = mountStats(makeStats(), [quota({ platform: 'anthropic' }), quota({ platform: 'openai' })])
+ expect(w.html()).not.toContain('dashboard.platformBreakdown')
+ expect(cardPlatforms(w)).toEqual([])
+ })
+
+ it('简易模式不渲染整块', () => {
+ const w = mountStats(makeStats({ by_platform: [usage('openai', 1)] }), null, true)
+ expect(w.html()).not.toContain('dashboard.platformBreakdown')
+ })
+})
diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts
index 18e9318abe..f81b1ced93 100644
--- a/frontend/src/i18n/locales/en/admin/overview.ts
+++ b/frontend/src/i18n/locales/en/admin/overview.ts
@@ -758,6 +758,7 @@ export default {
clearAllConfirm: 'Clear daily / weekly / monthly limits for ALL platforms? All platforms will become "unlimited" with no local undo — you must manually re-enter values before saving.',
reset: {
button: 'Reset window',
+ unavailable: 'No limit configured for this platform, so there is no usage window to reset',
confirm: 'Reset the {window} usage for {platform} for this user? This is effective immediately.',
success: 'Reset {platform} {window} usage',
failed: 'Reset failed',
diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts
index da509a8251..534f79d617 100644
--- a/frontend/src/i18n/locales/en/admin/settings.ts
+++ b/frontend/src/i18n/locales/en/admin/settings.ts
@@ -407,7 +407,7 @@ export default {
subscriptionGroup: 'Subscription Group',
subscriptionValidityDays: 'Validity (days)',
defaultPlatformQuotas: 'Default Platform Quotas (on signup)',
- defaultPlatformQuotasHint: 'Automatically assigned to new users on signup; existing users are not affected. Leave blank = unlimited.',
+ defaultPlatformQuotasHint: 'Applied to new users on signup; existing users are not affected. Leave blank = no limit for that platform and window.',
platformQuotaNotice: 'Monthly quota uses a 30-day rolling window, not a calendar month.',
},
platformQuota: {
diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts
index 8eca58fe35..d7ba0942f6 100644
--- a/frontend/src/i18n/locales/zh/admin/overview.ts
+++ b/frontend/src/i18n/locales/zh/admin/overview.ts
@@ -755,6 +755,7 @@ export default {
clearAllConfirm: '确认清空全部平台的日 / 周 / 月限额?所有平台将变为"无限额",本地无法撤销,需要在保存前手动重填。',
reset: {
button: '重置该窗口',
+ unavailable: '该平台未配置限额,没有可重置的用量窗口',
confirm: '确认重置该用户 {platform} 平台的 {window} 用量?此操作立即生效。',
success: '已重置 {platform} {window} 用量',
failed: '重置失败',
diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts
index 5bdedb0104..fd2530daba 100644
--- a/frontend/src/i18n/locales/zh/admin/settings.ts
+++ b/frontend/src/i18n/locales/zh/admin/settings.ts
@@ -402,7 +402,7 @@ export default {
subscriptionGroup: '订阅分组',
subscriptionValidityDays: '有效期(天)',
defaultPlatformQuotas: '默认平台限额(注册时分配)',
- defaultPlatformQuotasHint: '新用户注册时自动写入平台限额记录;已有用户不受影响。留空 = 该平台该窗口不限制。',
+ defaultPlatformQuotasHint: '新用户注册时自动获得这里配置的限额;已有用户不受影响。留空 = 该平台该窗口不限制。',
platformQuotaNotice: '月限额为 30 天滚动窗口,非自然月',
},
platformQuota: {