diff --git a/Makefile b/Makefile index b99d6ca022..e205902e2f 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,7 @@ FRONTEND_CRITICAL_VITEST := \ src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts \ src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts \ src/components/keys/__tests__/BulkEditKeysModal.spec.ts \ + src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts \ src/views/user/__tests__/KeysView.spec.ts \ src/api/__tests__/channelMonitorV2.spec.ts \ src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \ diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 738e0531c1..fae7ec6f83 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -330,7 +330,12 @@ export async function bindUserAuthIdentity( /** * Platform quota types */ -export type PlatformQuotaPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' +// Keep aligned with backend/internal/service/domain_constants.go AllowedQuotaPlatforms. +export const PLATFORM_QUOTA_PLATFORMS = [ + 'anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', +] as const +export type PlatformQuotaPlatform = typeof PLATFORM_QUOTA_PLATFORMS[number] export type PlatformQuotaWindow = 'daily' | 'weekly' | 'monthly' export interface PlatformQuotaItem { diff --git a/frontend/src/components/admin/user/UserPlatformQuotaModal.vue b/frontend/src/components/admin/user/UserPlatformQuotaModal.vue index 57cb4b394d..b1c5e799db 100644 --- a/frontend/src/components/admin/user/UserPlatformQuotaModal.vue +++ b/frontend/src/components/admin/user/UserPlatformQuotaModal.vue @@ -119,6 +119,7 @@ import { ref, reactive, watch, computed } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' +import { PLATFORM_QUOTA_PLATFORMS } from '@/api/admin/users' import type { AdminUser, PlatformQuotaItem, PlatformQuotaPlatform, PlatformQuotaWindow } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' @@ -128,8 +129,6 @@ const emit = defineEmits(['close', 'success']) const { t } = useI18n() const appStore = useAppStore() -const PLATFORMS: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok'] - interface QuotaRow { platform: PlatformQuotaPlatform daily_limit_usd: number | null @@ -176,7 +175,7 @@ function emptyRow(p: PlatformQuotaPlatform): QuotaRow { function normalize(items: PlatformQuotaItem[]): QuotaRow[] { const byPlatform = new Map() for (const it of items) byPlatform.set(it.platform, it) - return PLATFORMS.map((p) => { + return PLATFORM_QUOTA_PLATFORMS.map((p) => { const it = byPlatform.get(p) if (!it) return emptyRow(p) return { @@ -205,7 +204,7 @@ async function load() { savedConfigured.value = configuredPlatforms(data.platform_quotas || []) } catch { appStore.showError(t('admin.users.platformQuota.loadFailed')) - quotas.value = PLATFORMS.map(emptyRow) + quotas.value = PLATFORM_QUOTA_PLATFORMS.map(emptyRow) savedConfigured.value = new Set() } finally { loading.value = false diff --git a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts index 50740b7cef..b102ab56c1 100644 --- a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts +++ b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts @@ -50,7 +50,7 @@ vi.mock('@/components/common/BaseDialog.vue', () => ({ })) import UserPlatformQuotaModal from '../UserPlatformQuotaModal.vue' -import type { UserSubscription } from '@/types' +import type { PlatformQuotaUpdateItem, UserSubscription } from '@/types' function makeUser(overrides: { subscriptions?: UserSubscription[] } = {}) { return { id: 99, email: 'u@example.com', ...overrides } as any @@ -101,16 +101,48 @@ describe('UserPlatformQuotaModal', () => { expect(apiMocks.getPlatformQuotas).toHaveBeenCalledWith(99) }) - it('空数据渲染 5 个 platform 行', async () => { + it('renders all ten supported platforms with empty limits', async () => { const w = await mountAndOpen() - const html = w.html() - expect(html).toContain('anthropic') - expect(html).toContain('openai') - expect(html).toContain('gemini') - expect(html).toContain('antigravity') - expect(html).toContain('grok') + const rows = w.findAll('tbody tr') + expect(rows.map(row => row.find('td').text())).toEqual([ + 'anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', + ]) + for (const row of rows) { + const inputs = row.findAll('input[type=number]') + expect(inputs).toHaveLength(3) + expect(inputs.map(input => input.element.value)).toEqual(['', '', '']) + } + w.unmount() }) + it.each(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'] as const)( + 'saves edits to %s without erasing existing platform limits', async (platform) => { + const existing: PlatformQuotaUpdateItem[] = [ + { platform: 'openai', daily_limit_usd: 10, weekly_limit_usd: 20, monthly_limit_usd: 100 }, + ...(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'] as const).map(p => ({ + platform: p, daily_limit_usd: 0, weekly_limit_usd: null, monthly_limit_usd: 50, + })), + ] + apiMocks.getPlatformQuotas.mockResolvedValueOnce({ platform_quotas: existing }) + const w = await mountAndOpen() + const row = w.findAll('tbody tr').find(r => r.find('td').text() === platform)! + const inputs = row.findAll('input[type=number]') + expect(inputs.map(input => input.element.value)).toEqual(['0', '', '50']) + await inputs[1].setValue('12.5') + await w.findAll('button').find(b => b.text() === 'admin.users.platformQuota.save')!.trigger('click') + await flushPromises() + const expected = existing.map(item => item.platform === platform + ? { ...item, weekly_limit_usd: 12.5 } + : item) + expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1) + expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledWith(99, expect.arrayContaining(expected)) + expect(apiMocks.updatePlatformQuotas.mock.calls[0][1]).toHaveLength(10) + expect(w.emitted('success')).toHaveLength(1) + w.unmount() + }, + ) + it('已有数据正确填充 limit input', async () => { apiMocks.getPlatformQuotas.mockResolvedValueOnce({ platform_quotas: [ @@ -120,13 +152,13 @@ describe('UserPlatformQuotaModal', () => { }) const w = await mountAndOpen() const inputs = w.findAll('input[type=number]') - // 5 platforms × 3 windows = 15 inputs - expect(inputs.length).toBe(15) + // 10 platforms × 3 windows = 30 inputs + expect(inputs.length).toBe(30) // 第一个 input 是 anthropic.daily = 10 expect((inputs[0].element as HTMLInputElement).value).toBe('10') }) - it('保存提交完整 5 platform payload', async () => { + it('保存提交完整 10 platform payload', async () => { apiMocks.getPlatformQuotas.mockResolvedValueOnce({ platform_quotas: [ { platform: 'openai', daily_limit_usd: null, weekly_limit_usd: 20, monthly_limit_usd: null, @@ -143,7 +175,7 @@ describe('UserPlatformQuotaModal', () => { expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1) const [uid, payload] = apiMocks.updatePlatformQuotas.mock.calls[0] expect(uid).toBe(99) - expect(payload).toHaveLength(5) // 5 platforms always submitted + expect(payload).toHaveLength(10) // 10 platforms always submitted const openai = payload.find((p: any) => p.platform === 'openai') expect(openai.weekly_limit_usd).toBe(20) }) @@ -208,7 +240,7 @@ describe('UserPlatformQuotaModal', () => { it('未配置限额的平台重置按钮禁用并提示不可用', async () => { const w = await mountAndOpen() const resetBtns = w.findAll('button').filter((b) => b.text() === '↻') - expect(resetBtns.length).toBe(15) // 5 平台 × 3 窗口 + expect(resetBtns.length).toBe(30) // 10 平台 × 3 窗口 for (const b of resetBtns) { expect((b.element as HTMLButtonElement).disabled).toBe(true) expect(b.attributes('title')).toBe('admin.users.platformQuota.reset.unavailable')