From feff97acbdd97a6ccfd885ddb7dec74484816b95 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Tue, 21 Apr 2026 20:49:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=96=B0=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 适配后端统一模型 ID、协议字段、UUID 自动生成和结构化错误响应: - 类型定义:Provider 新增 protocol 字段,Model 新增 unifiedId,CreateModelInput 移除 id - API 客户端:提取结构化错误响应中的错误码 - 供应商管理:添加协议选择下拉框和表格列 - 模型管理:移除 ID 输入,显示统一模型 ID(只读) - Hooks:错误码映射为友好中文消息 - 测试:所有组件测试通过,mock 数据适配新字段 - 文档:更新 README 说明协议字段和统一模型 ID --- frontend/README.md | 7 +- frontend/src/__tests__/api/client.test.ts | 37 ++++-- frontend/src/__tests__/api/models.test.ts | 17 ++- frontend/src/__tests__/api/providers.test.ts | 13 +- frontend/src/__tests__/api/stats.test.ts | 8 +- .../__tests__/components/ModelForm.test.tsx | 28 ++-- .../__tests__/components/ModelTable.test.tsx | 123 ++++++++++++++++++ .../components/ProviderForm.test.tsx | 39 ++++++ .../components/ProviderTable.test.tsx | 43 +++++- .../__tests__/components/StatCards.test.tsx | 8 +- .../__tests__/components/StatsTable.test.tsx | 6 +- .../__tests__/components/UsageChart.test.tsx | 6 +- .../src/__tests__/hooks/useModels.test.tsx | 4 + .../src/__tests__/hooks/useProviders.test.tsx | 5 + frontend/src/__tests__/setup.ts | 4 +- frontend/src/api/client.ts | 16 ++- frontend/src/hooks/useModels.ts | 21 ++- frontend/src/hooks/useProviders.ts | 21 ++- frontend/src/pages/Providers/ModelForm.tsx | 13 +- frontend/src/pages/Providers/ModelTable.tsx | 7 + frontend/src/pages/Providers/ProviderForm.tsx | 13 +- .../src/pages/Providers/ProviderTable.tsx | 10 ++ frontend/src/pages/Stats/StatsTable.tsx | 5 + frontend/src/types/index.ts | 13 +- openspec/specs/error-responses/spec.md | 42 ++++++ openspec/specs/frontend/spec.md | 68 ++++++++++ openspec/specs/model-management/spec.md | 23 ++++ openspec/specs/provider-management/spec.md | 25 +++- 28 files changed, 547 insertions(+), 78 deletions(-) create mode 100644 frontend/src/__tests__/components/ModelTable.test.tsx diff --git a/frontend/README.md b/frontend/README.md index 5fdb700..ba3fd40 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -8,7 +8,7 @@ AI 网关管理前端,提供供应商配置和用量统计界面。 - **构建工具**: Vite - **语言**: TypeScript (strict mode) - **框架**: React -- **UI 组件库**: Ant Design 5 +- **UI 组件库**: TDesign - **路由**: React Router v7 - **数据获取**: TanStack Query v5 - **样式**: SCSS Modules(禁止使用纯 CSS) @@ -102,18 +102,21 @@ bun run test:e2e ### 供应商管理 -- 查看供应商列表(Ant Design Table) +- 查看供应商列表(TDesign Table) - 添加新供应商(Modal Form) - 编辑供应商配置 - 删除供应商(Popconfirm 确认) - API Key 脱敏显示 - 启用/禁用状态标签 +- **协议字段**:支持 OpenAI 和 Anthropic 协议选择 ### 模型管理 - 展开供应商行查看关联模型 - 添加/编辑/删除模型 - 按供应商筛选模型 +- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别 +- **UUID 自动生成**:创建模型时后端自动生成 UUID,无需手动输入 ID ### 用量统计 diff --git a/frontend/src/__tests__/api/client.test.ts b/frontend/src/__tests__/api/client.test.ts index ef4f2f3..0fb33a2 100644 --- a/frontend/src/__tests__/api/client.test.ts +++ b/frontend/src/__tests__/api/client.test.ts @@ -86,7 +86,7 @@ describe('request', () => { it('parses JSON and converts snake_case keys to camelCase on success', async () => { mswServer.use( - http.get('/api/test', () => { + http.get('http://localhost:3000/api/test', () => { return HttpResponse.json({ id: '1', created_at: '2025-01-01', @@ -110,7 +110,7 @@ describe('request', () => { it('throws ApiError with status and message on HTTP error', async () => { mswServer.use( - http.get('/api/test', () => { + http.get('http://localhost:3000/api/test', () => { return HttpResponse.json( { message: 'Not found' }, { status: 404 }, @@ -131,9 +131,9 @@ describe('request', () => { it('throws ApiError with default message when error body has no message', async () => { mswServer.use( - http.get('/api/test', () => { + http.get('http://localhost:3000/api/test', () => { return HttpResponse.json( - { error: 'something' }, + { details: 'something' }, { status: 500 }, ); }), @@ -149,9 +149,30 @@ describe('request', () => { } }); + it('throws ApiError with code field when error response includes code', async () => { + mswServer.use( + http.get('http://localhost:3000/api/test', () => { + return HttpResponse.json( + { error: 'Model not found', code: 'MODEL_NOT_FOUND' }, + { status: 404 }, + ); + }), + ); + + try { + await request('GET', '/api/test'); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.status).toBe(404); + expect(apiError.message).toBe('Model not found'); + expect(apiError.code).toBe('MODEL_NOT_FOUND'); + } + }); + it('throws Error on network failure', async () => { mswServer.use( - http.get('/api/test', () => { + http.get('http://localhost:3000/api/test', () => { return HttpResponse.error(); }), ); @@ -161,7 +182,7 @@ describe('request', () => { it('returns undefined for 204 No Content', async () => { mswServer.use( - http.delete('/api/test/1', () => { + http.delete('http://localhost:3000/api/test/1', () => { return new HttpResponse(null, { status: 204 }); }), ); @@ -174,7 +195,7 @@ describe('request', () => { let receivedBody: Record | null = null; mswServer.use( - http.post('/api/test', async ({ request }) => { + http.post('http://localhost:3000/api/test', async ({ request }) => { receivedBody = (await request.json()) as Record; return HttpResponse.json({ id: '1' }); }), @@ -195,7 +216,7 @@ describe('request', () => { let contentType: string | null = null; mswServer.use( - http.post('/api/test', async ({ request }) => { + http.post('http://localhost:3000/api/test', async ({ request }) => { contentType = request.headers.get('Content-Type'); return HttpResponse.json({ id: '1' }); }), diff --git a/frontend/src/__tests__/api/models.test.ts b/frontend/src/__tests__/api/models.test.ts index c55a547..518d45a 100644 --- a/frontend/src/__tests__/api/models.test.ts +++ b/frontend/src/__tests__/api/models.test.ts @@ -8,6 +8,7 @@ const mockModels = [ id: 'gpt-4', provider_id: 'prov-1', model_name: 'gpt-4', + unified_id: 'prov-1/gpt-4', enabled: true, created_at: '2025-01-01T00:00:00Z', }, @@ -15,6 +16,7 @@ const mockModels = [ id: 'claude-3', provider_id: 'prov-2', model_name: 'claude-3', + unified_id: 'prov-2/claude-3', enabled: false, created_at: '2025-01-02T00:00:00Z', }, @@ -30,7 +32,7 @@ describe('models API', () => { describe('listModels', () => { it('returns array of Model objects with camelCase keys', async () => { server.use( - http.get('/api/models', () => { + http.get('http://localhost:3000/api/models', () => { return HttpResponse.json(mockModels); }), ); @@ -42,6 +44,7 @@ describe('models API', () => { id: 'gpt-4', providerId: 'prov-1', modelName: 'gpt-4', + unifiedId: 'prov-1/gpt-4', enabled: true, createdAt: '2025-01-01T00:00:00Z', }, @@ -49,6 +52,7 @@ describe('models API', () => { id: 'claude-3', providerId: 'prov-2', modelName: 'claude-3', + unifiedId: 'prov-2/claude-3', enabled: false, createdAt: '2025-01-02T00:00:00Z', }, @@ -59,7 +63,7 @@ describe('models API', () => { let receivedUrl: string | null = null; server.use( - http.get('/api/models', ({ request }) => { + http.get('http://localhost:3000/api/models', ({ request }) => { receivedUrl = request.url; return HttpResponse.json([mockModels[0]]); }), @@ -79,7 +83,7 @@ describe('models API', () => { let receivedBody: Record | null = null; server.use( - http.post('/api/models', async ({ request }) => { + http.post('http://localhost:3000/api/models', async ({ request }) => { receivedMethod = request.method; receivedBody = (await request.json()) as Record; return HttpResponse.json(mockModels[0]); @@ -87,7 +91,6 @@ describe('models API', () => { ); const input = { - id: 'gpt-4', providerId: 'prov-1', modelName: 'gpt-4', enabled: true, @@ -97,7 +100,6 @@ describe('models API', () => { expect(receivedMethod).toBe('POST'); expect(receivedBody).toEqual({ - id: 'gpt-4', provider_id: 'prov-1', model_name: 'gpt-4', enabled: true, @@ -105,6 +107,7 @@ describe('models API', () => { expect(result.id).toBe('gpt-4'); expect(result.providerId).toBe('prov-1'); expect(result.modelName).toBe('gpt-4'); + expect(result.unifiedId).toBe('prov-1/gpt-4'); }); }); @@ -115,7 +118,7 @@ describe('models API', () => { let receivedBody: Record | null = null; server.use( - http.put('/api/models/:id', async ({ request }) => { + http.put('http://localhost:3000/api/models/:id', async ({ request }) => { receivedMethod = request.method; receivedUrl = new URL(request.url).pathname; receivedBody = (await request.json()) as Record; @@ -149,7 +152,7 @@ describe('models API', () => { let receivedUrl: string | null = null; server.use( - http.delete('/api/models/:id', ({ request }) => { + http.delete('http://localhost:3000/api/models/:id', ({ request }) => { receivedMethod = request.method; receivedUrl = new URL(request.url).pathname; return new HttpResponse(null, { status: 204 }); diff --git a/frontend/src/__tests__/api/providers.test.ts b/frontend/src/__tests__/api/providers.test.ts index 9b59cd4..7af422e 100644 --- a/frontend/src/__tests__/api/providers.test.ts +++ b/frontend/src/__tests__/api/providers.test.ts @@ -9,6 +9,7 @@ const mockProviders = [ name: 'OpenAI', api_key: 'sk-xxx', base_url: 'https://api.openai.com', + protocol: 'openai', enabled: true, created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z', @@ -18,6 +19,7 @@ const mockProviders = [ name: 'Anthropic', api_key: 'sk-yyy', base_url: 'https://api.anthropic.com', + protocol: 'anthropic', enabled: false, created_at: '2025-01-02T00:00:00Z', updated_at: '2025-01-02T00:00:00Z', @@ -34,7 +36,7 @@ describe('providers API', () => { describe('listProviders', () => { it('returns array of Provider objects with camelCase keys', async () => { server.use( - http.get('/api/providers', () => { + http.get('http://localhost:3000/api/providers', () => { return HttpResponse.json(mockProviders); }), ); @@ -47,6 +49,7 @@ describe('providers API', () => { name: 'OpenAI', apiKey: 'sk-xxx', baseUrl: 'https://api.openai.com', + protocol: 'openai', enabled: true, createdAt: '2025-01-01T00:00:00Z', updatedAt: '2025-01-01T00:00:00Z', @@ -56,6 +59,7 @@ describe('providers API', () => { name: 'Anthropic', apiKey: 'sk-yyy', baseUrl: 'https://api.anthropic.com', + protocol: 'anthropic', enabled: false, createdAt: '2025-01-02T00:00:00Z', updatedAt: '2025-01-02T00:00:00Z', @@ -70,7 +74,7 @@ describe('providers API', () => { let receivedBody: Record | null = null; server.use( - http.post('/api/providers', async ({ request }) => { + http.post('http://localhost:3000/api/providers', async ({ request }) => { receivedMethod = request.method; receivedBody = (await request.json()) as Record; return HttpResponse.json(mockProviders[0]); @@ -100,6 +104,7 @@ describe('providers API', () => { name: 'OpenAI', apiKey: 'sk-xxx', baseUrl: 'https://api.openai.com', + protocol: 'openai', enabled: true, createdAt: '2025-01-01T00:00:00Z', updatedAt: '2025-01-01T00:00:00Z', @@ -114,7 +119,7 @@ describe('providers API', () => { let receivedBody: Record | null = null; server.use( - http.put('/api/providers/:id', async ({ request, params }) => { + http.put('http://localhost:3000/api/providers/:id', async ({ request, params }) => { receivedMethod = request.method; receivedUrl = new URL(request.url).pathname; receivedBody = (await request.json()) as Record; @@ -148,7 +153,7 @@ describe('providers API', () => { let receivedUrl: string | null = null; server.use( - http.delete('/api/providers/:id', ({ request, params }) => { + http.delete('http://localhost:3000/api/providers/:id', ({ request, params }) => { receivedMethod = request.method; receivedUrl = new URL(request.url).pathname; return new HttpResponse(null, { status: 204 }); diff --git a/frontend/src/__tests__/api/stats.test.ts b/frontend/src/__tests__/api/stats.test.ts index bc1b74c..dc00c29 100644 --- a/frontend/src/__tests__/api/stats.test.ts +++ b/frontend/src/__tests__/api/stats.test.ts @@ -32,7 +32,7 @@ describe('stats API', () => { let receivedUrl: string | null = null; server.use( - http.get('/api/stats', ({ request }) => { + http.get('http://localhost:3000/api/stats', ({ request }) => { receivedUrl = request.url; return HttpResponse.json(mockStats); }), @@ -63,7 +63,7 @@ describe('stats API', () => { let receivedUrl: string | null = null; server.use( - http.get('/api/stats', ({ request }) => { + http.get('http://localhost:3000/api/stats', ({ request }) => { receivedUrl = request.url; return HttpResponse.json([]); }), @@ -86,7 +86,7 @@ describe('stats API', () => { let receivedUrl: string | null = null; server.use( - http.get('/api/stats', ({ request }) => { + http.get('http://localhost:3000/api/stats', ({ request }) => { receivedUrl = request.url; return HttpResponse.json([]); }), @@ -104,7 +104,7 @@ describe('stats API', () => { it('returns UsageStats array with camelCase keys', async () => { server.use( - http.get('/api/stats', () => { + http.get('http://localhost:3000/api/stats', () => { return HttpResponse.json(mockStats); }), ); diff --git a/frontend/src/__tests__/components/ModelForm.test.tsx b/frontend/src/__tests__/components/ModelForm.test.tsx index e387644..18ce390 100644 --- a/frontend/src/__tests__/components/ModelForm.test.tsx +++ b/frontend/src/__tests__/components/ModelForm.test.tsx @@ -10,6 +10,7 @@ const mockProviders: Provider[] = [ name: 'OpenAI', apiKey: 'sk-test', baseUrl: 'https://api.openai.com/v1', + protocol: 'openai', enabled: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', @@ -19,6 +20,7 @@ const mockProviders: Provider[] = [ name: 'Anthropic', apiKey: 'sk-ant-test', baseUrl: 'https://api.anthropic.com', + protocol: 'anthropic', enabled: true, createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', @@ -31,6 +33,7 @@ const mockModel: Model = { modelName: 'gpt-4o', enabled: true, createdAt: '2024-01-01T00:00:00Z', + unifiedId: 'openai/gpt-4o', }; const defaultProps = { @@ -57,7 +60,6 @@ describe('ModelForm', () => { const dialog = getDialog(); expect(within(dialog).getByText('添加模型')).toBeInTheDocument(); - expect(within(dialog).getByText('ID')).toBeInTheDocument(); expect(within(dialog).getByText('供应商')).toBeInTheDocument(); expect(within(dialog).getByText('模型名称')).toBeInTheDocument(); expect(within(dialog).getByText('启用')).toBeInTheDocument(); @@ -85,8 +87,7 @@ describe('ModelForm', () => { const okButton = within(dialog).getByRole('button', { name: /保/ }); await user.click(okButton); - expect(await screen.findByText('请输入模型 ID')).toBeInTheDocument(); - expect(screen.getByText('请选择供应商')).toBeInTheDocument(); + expect(await screen.findByText('请选择供应商')).toBeInTheDocument(); expect(screen.getByText('请输入模型名称')).toBeInTheDocument(); }); @@ -96,15 +97,12 @@ describe('ModelForm', () => { render(); const dialog = getDialog(); - // There are two inputs with placeholder "例如: gpt-4o": ID field (index 0) and model name (index 1) - const inputs = within(dialog).getAllByPlaceholderText('例如: gpt-4o'); + // Only one input with placeholder "例如: gpt-4o" for model name + const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o'); - // Type into the ID field - await user.clear(inputs[0]); - await user.type(inputs[0], 'gpt-4o-mini'); // Type into the model name field - await user.clear(inputs[1]); - await user.type(inputs[1], 'gpt-4o-mini'); + await user.clear(modelNameInput); + await user.type(modelNameInput, 'gpt-4o-mini'); const okButton = within(dialog).getByRole('button', { name: /保/ }); await user.click(okButton); @@ -113,7 +111,6 @@ describe('ModelForm', () => { await vi.waitFor(() => { expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ - id: 'gpt-4o-mini', providerId: 'openai', modelName: 'gpt-4o-mini', enabled: true, @@ -128,12 +125,11 @@ describe('ModelForm', () => { const dialog = getDialog(); expect(within(dialog).getByText('编辑模型')).toBeInTheDocument(); - const inputs = within(dialog).getAllByPlaceholderText('例如: gpt-4o'); - const idInput = inputs[0] as HTMLInputElement; - expect(idInput.value).toBe('gpt-4o'); - expect(idInput).toBeDisabled(); + // Check that unified ID field is displayed + expect(within(dialog).getByText('统一模型 ID')).toBeInTheDocument(); - const modelNameInput = inputs[1] as HTMLInputElement; + // Check model name input + const modelNameInput = within(dialog).getByPlaceholderText('例如: gpt-4o') as HTMLInputElement; expect(modelNameInput.value).toBe('gpt-4o'); }); diff --git a/frontend/src/__tests__/components/ModelTable.test.tsx b/frontend/src/__tests__/components/ModelTable.test.tsx new file mode 100644 index 0000000..d25403d --- /dev/null +++ b/frontend/src/__tests__/components/ModelTable.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ModelTable } from '@/pages/Providers/ModelTable'; +import type { Model } from '@/types'; + +const mockModels: Model[] = [ + { + id: 'model-1', + providerId: 'openai', + modelName: 'gpt-4o', + enabled: true, + createdAt: '2024-01-01T00:00:00Z', + unifiedId: 'openai/gpt-4o', + }, + { + id: 'model-2', + providerId: 'openai', + modelName: 'gpt-3.5-turbo', + enabled: false, + createdAt: '2024-01-02T00:00:00Z', + unifiedId: 'openai/gpt-3.5-turbo', + }, +]; + +const mockMutate = vi.fn(); + +vi.mock('@/hooks/useModels', () => ({ + useModels: vi.fn((providerId: string) => { + if (providerId === 'openai') { + return { data: mockModels, isLoading: false }; + } + return { data: [], isLoading: false }; + }), + useDeleteModel: vi.fn(() => ({ mutate: mockMutate })), +})); + +const defaultProps = { + providerId: 'openai', + onAdd: vi.fn(), + onEdit: vi.fn(), +}; + +describe('ModelTable', () => { + beforeEach(() => { + mockMutate.mockClear(); + }); + + it('renders model list with unified ID and model name', () => { + render(); + + expect(screen.getByText(/关联模型/)).toBeInTheDocument(); + expect(screen.getByText('openai/gpt-4o')).toBeInTheDocument(); + expect(screen.getByText('openai/gpt-3.5-turbo')).toBeInTheDocument(); + expect(screen.getByText('gpt-4o')).toBeInTheDocument(); + expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument(); + }); + + it('renders status tags correctly', () => { + render(); + + const enabledTags = screen.getAllByText('启用'); + const disabledTags = screen.getAllByText('禁用'); + expect(enabledTags.length).toBeGreaterThanOrEqual(1); + expect(disabledTags.length).toBeGreaterThanOrEqual(1); + }); + + it('calls onAdd when clicking "添加模型" button', async () => { + const user = userEvent.setup(); + const onAdd = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: '添加模型' })); + expect(onAdd).toHaveBeenCalledTimes(1); + }); + + it('calls onEdit with correct model when clicking "编辑"', async () => { + const user = userEvent.setup(); + const onEdit = vi.fn(); + render(); + + const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ }); + await user.click(editButtons[0]); + + expect(onEdit).toHaveBeenCalledTimes(1); + expect(onEdit).toHaveBeenCalledWith(mockModels[0]); + }); + + it('calls deleteModel.mutate with correct model ID when delete is confirmed', async () => { + const user = userEvent.setup(); + + render(); + + // Find and click the delete button for the first row + const deleteButtons = screen.getAllByRole('button', { name: '删除' }); + await user.click(deleteButtons[0]); + + // TDesign Popconfirm renders confirmation popup with "确定" button + const confirmButton = await screen.findByRole('button', { name: '确定' }); + await user.click(confirmButton); + + // Assert that deleteModel.mutate was called with the correct model ID + expect(mockMutate).toHaveBeenCalledTimes(1); + expect(mockMutate).toHaveBeenCalledWith('model-1'); + }, 10000); + + it('shows custom empty text when models list is empty', () => { + render(); + expect(screen.getByText('暂无模型,点击上方按钮添加')).toBeInTheDocument(); + }); + + it('does not render add button when onAdd is not provided', () => { + render(); + + expect(screen.queryByRole('button', { name: '添加模型' })).not.toBeInTheDocument(); + }); + + it('does not render edit button when onEdit is not provided', () => { + render(); + + expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/components/ProviderForm.test.tsx b/frontend/src/__tests__/components/ProviderForm.test.tsx index 61243c2..23806ae 100644 --- a/frontend/src/__tests__/components/ProviderForm.test.tsx +++ b/frontend/src/__tests__/components/ProviderForm.test.tsx @@ -9,6 +9,7 @@ const mockProvider: Provider = { name: 'OpenAI', apiKey: 'sk-old-key', baseUrl: 'https://api.openai.com/v1', + protocol: 'openai', enabled: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', @@ -40,6 +41,7 @@ describe('ProviderForm', () => { expect(within(dialog).getByText('名称')).toBeInTheDocument(); expect(within(dialog).getByText('API Key')).toBeInTheDocument(); expect(within(dialog).getByText('Base URL')).toBeInTheDocument(); + expect(within(dialog).getByText('协议')).toBeInTheDocument(); expect(within(dialog).getByText('启用')).toBeInTheDocument(); expect(within(dialog).getByPlaceholderText('例如: openai')).toBeInTheDocument(); expect(within(dialog).getByPlaceholderText('例如: OpenAI')).toBeInTheDocument(); @@ -154,4 +156,41 @@ describe('ProviderForm', () => { expect(screen.getByText('请输入有效的 URL')).toBeInTheDocument(); }); }, 15000); + + it('renders protocol select field with default value', () => { + render(); + + const dialog = getDialog(); + expect(within(dialog).getByText('协议')).toBeInTheDocument(); + }); + + it('includes protocol field in form submission', async () => { + const onSave = vi.fn(); + render(); + + const dialog = getDialog(); + + // Get form instance and set values directly + const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement; + const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement; + const apiKeyInput = within(dialog).getByPlaceholderText('sk-...') as HTMLInputElement; + const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement; + + // Simulate user input by directly setting values + fireEvent.change(idInput, { target: { value: 'test-provider' } }); + fireEvent.change(nameInput, { target: { value: 'Test Provider' } }); + fireEvent.change(apiKeyInput, { target: { value: 'sk-test-key' } }); + fireEvent.change(baseUrlInput, { target: { value: 'https://api.test.com/v1' } }); + + const okButton = within(dialog).getByRole('button', { name: /保/ }); + fireEvent.click(okButton); + + // Wait for the onSave to be called + await vi.waitFor(() => { + expect(onSave).toHaveBeenCalled(); + // Verify that the saved data includes a protocol field + const savedData = onSave.mock.calls[0][0]; + expect(savedData).toHaveProperty('protocol'); + }, { timeout: 5000 }); + }, 10000); }); diff --git a/frontend/src/__tests__/components/ProviderTable.test.tsx b/frontend/src/__tests__/components/ProviderTable.test.tsx index fd73ecc..c748f5d 100644 --- a/frontend/src/__tests__/components/ProviderTable.test.tsx +++ b/frontend/src/__tests__/components/ProviderTable.test.tsx @@ -5,8 +5,8 @@ import { ProviderTable } from '@/pages/Providers/ProviderTable'; import type { Provider } from '@/types'; const mockModelsData = [ - { id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true }, - { id: 'model-2', providerId: 'openai', modelName: 'gpt-3.5-turbo', enabled: false }, + { id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' }, + { id: 'model-2', providerId: 'openai', modelName: 'gpt-3.5-turbo', enabled: false, unifiedId: 'openai/gpt-3.5-turbo' }, ]; vi.mock('@/hooks/useModels', () => ({ @@ -20,6 +20,7 @@ const mockProviders: Provider[] = [ name: 'OpenAI', apiKey: 'sk-abcdefgh12345678', baseUrl: 'https://api.openai.com/v1', + protocol: 'openai', enabled: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', @@ -29,6 +30,7 @@ const mockProviders: Provider[] = [ name: 'Anthropic', apiKey: 'sk-ant-test', baseUrl: 'https://api.anthropic.com', + protocol: 'anthropic', enabled: false, createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', @@ -51,11 +53,12 @@ describe('ProviderTable', () => { expect(screen.getByText('供应商列表')).toBeInTheDocument(); - expect(screen.getByText('OpenAI')).toBeInTheDocument(); + // Check that provider names appear (they will appear in both name column and potentially protocol column) + expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0); expect(screen.getByText('https://api.openai.com/v1')).toBeInTheDocument(); expect(screen.getByText('****5678')).toBeInTheDocument(); - expect(screen.getByText('Anthropic')).toBeInTheDocument(); + expect(screen.getAllByText('Anthropic').length).toBeGreaterThan(0); expect(screen.getByText('https://api.anthropic.com')).toBeInTheDocument(); expect(screen.getByText('****test')).toBeInTheDocument(); @@ -163,4 +166,36 @@ describe('ProviderTable', () => { render(); expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument(); }); + + it('renders protocol column with correct tags', () => { + const { container } = render(); + + // Check that protocol tags are displayed in the table + const protocolCells = container.querySelectorAll('[data-colkey="protocol"]'); + expect(protocolCells.length).toBeGreaterThan(0); + + // Verify protocol tags exist + const tags = container.querySelectorAll('.t-tag'); + expect(tags.length).toBeGreaterThan(0); + }); + + it('displays protocol tag for each provider', () => { + const singleProvider: Provider[] = [ + { + id: 'test', + name: 'Test Provider', + apiKey: 'test-key', + baseUrl: 'https://test.com', + protocol: 'openai', + enabled: true, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + ]; + const { container } = render(); + + // Should display protocol column + const protocolCell = container.querySelector('[data-colkey="protocol"]'); + expect(protocolCell).toBeInTheDocument(); + }); }); diff --git a/frontend/src/__tests__/components/StatCards.test.tsx b/frontend/src/__tests__/components/StatCards.test.tsx index f4f8452..c851754 100644 --- a/frontend/src/__tests__/components/StatCards.test.tsx +++ b/frontend/src/__tests__/components/StatCards.test.tsx @@ -5,21 +5,21 @@ import type { UsageStats } from '@/types'; const mockStats: UsageStats[] = [ { - id: '1', + id: 1, providerId: 'openai', modelName: 'gpt-4o', requestCount: 100, date: '2024-01-01', }, { - id: '2', + id: 2, providerId: 'openai', modelName: 'gpt-3.5-turbo', requestCount: 200, date: '2024-01-01', }, { - id: '3', + id: 3, providerId: 'anthropic', modelName: 'claude-3', requestCount: 150, @@ -73,7 +73,7 @@ describe('StatCards', () => { const statsWithToday: UsageStats[] = [ ...mockStats, { - id: '4', + id: 4, providerId: 'openai', modelName: 'gpt-4o', requestCount: 50, diff --git a/frontend/src/__tests__/components/StatsTable.test.tsx b/frontend/src/__tests__/components/StatsTable.test.tsx index 6f28070..c7b5879 100644 --- a/frontend/src/__tests__/components/StatsTable.test.tsx +++ b/frontend/src/__tests__/components/StatsTable.test.tsx @@ -9,6 +9,7 @@ const mockProviders: Provider[] = [ name: 'OpenAI', apiKey: 'sk-test', baseUrl: 'https://api.openai.com/v1', + protocol: 'openai', enabled: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', @@ -18,6 +19,7 @@ const mockProviders: Provider[] = [ name: 'Anthropic', apiKey: 'sk-ant-test', baseUrl: 'https://api.anthropic.com', + protocol: 'anthropic', enabled: true, createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', @@ -26,14 +28,14 @@ const mockProviders: Provider[] = [ const mockStats: UsageStats[] = [ { - id: '1', + id: 1, providerId: 'openai', modelName: 'gpt-4o', requestCount: 100, date: '2024-01-15', }, { - id: '2', + id: 2, providerId: 'anthropic', modelName: 'claude-3-opus', requestCount: 50, diff --git a/frontend/src/__tests__/components/UsageChart.test.tsx b/frontend/src/__tests__/components/UsageChart.test.tsx index 3b34a54..cd924ae 100644 --- a/frontend/src/__tests__/components/UsageChart.test.tsx +++ b/frontend/src/__tests__/components/UsageChart.test.tsx @@ -16,21 +16,21 @@ vi.mock('recharts', () => ({ const mockStats: UsageStats[] = [ { - id: '1', + id: 1, providerId: 'openai', modelName: 'gpt-4o', requestCount: 100, date: '2024-01-01', }, { - id: '2', + id: 2, providerId: 'openai', modelName: 'gpt-3.5-turbo', requestCount: 200, date: '2024-01-01', }, { - id: '3', + id: 3, providerId: 'anthropic', modelName: 'claude-3', requestCount: 150, diff --git a/frontend/src/__tests__/hooks/useModels.test.tsx b/frontend/src/__tests__/hooks/useModels.test.tsx index 127acf3..47a0eec 100644 --- a/frontend/src/__tests__/hooks/useModels.test.tsx +++ b/frontend/src/__tests__/hooks/useModels.test.tsx @@ -23,6 +23,7 @@ const mockModels: Model[] = [ modelName: 'gpt-4o', enabled: true, createdAt: '2026-01-01T00:00:00Z', + unifiedId: 'gpt-4o', }, { id: 'model-2', @@ -30,6 +31,7 @@ const mockModels: Model[] = [ modelName: 'gpt-4o-mini', enabled: true, createdAt: '2026-01-02T00:00:00Z', + unifiedId: 'gpt-4o-mini', }, ]; @@ -40,6 +42,7 @@ const mockFilteredModels: Model[] = [ modelName: 'claude-sonnet-4-5', enabled: true, createdAt: '2026-02-01T00:00:00Z', + unifiedId: 'claude-sonnet-4-5', }, ]; @@ -49,6 +52,7 @@ const mockCreatedModel: Model = { modelName: 'gpt-4.1', enabled: true, createdAt: '2026-03-01T00:00:00Z', + unifiedId: 'gpt-4.1', }; // MSW handlers diff --git a/frontend/src/__tests__/hooks/useProviders.test.tsx b/frontend/src/__tests__/hooks/useProviders.test.tsx index 0923d8a..1d0436a 100644 --- a/frontend/src/__tests__/hooks/useProviders.test.tsx +++ b/frontend/src/__tests__/hooks/useProviders.test.tsx @@ -22,6 +22,7 @@ const mockProviders: Provider[] = [ name: 'OpenAI', apiKey: 'sk-xxx', baseUrl: 'https://api.openai.com', + protocol: 'openai', enabled: true, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', @@ -31,6 +32,7 @@ const mockProviders: Provider[] = [ name: 'Anthropic', apiKey: 'sk-yyy', baseUrl: 'https://api.anthropic.com', + protocol: 'anthropic', enabled: false, createdAt: '2026-02-01T00:00:00Z', updatedAt: '2026-02-01T00:00:00Z', @@ -42,6 +44,7 @@ const mockCreatedProvider: Provider = { name: 'NewProvider', apiKey: 'sk-zzz', baseUrl: 'https://api.newprovider.com', + protocol: 'openai', enabled: true, createdAt: '2026-03-01T00:00:00Z', updatedAt: '2026-03-01T00:00:00Z', @@ -135,6 +138,7 @@ describe('useCreateProvider', () => { name: 'NewProvider', apiKey: 'sk-zzz', baseUrl: 'https://api.newprovider.com', + protocol: 'openai', enabled: true, }; @@ -166,6 +170,7 @@ describe('useCreateProvider', () => { name: 'NewProvider', apiKey: 'sk-zzz', baseUrl: 'https://api.newprovider.com', + protocol: 'openai', enabled: true, }; diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index 3b0df01..064eb42 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -1,8 +1,8 @@ import '@testing-library/jest-dom/vitest'; -// Ensure jsdom environment is properly initialized +// Ensure happy-dom environment is properly initialized if (typeof window === 'undefined' || typeof document === 'undefined') { - throw new Error('jsdom environment not initialized. Check vitest config.'); + throw new Error('happy-dom environment not initialized. Check vitest config.'); } // Polyfill window.matchMedia for jsdom (required by TDesign) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index fa7591e..30fb7e2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -51,15 +51,25 @@ export async function request( if (!response.ok) { let message = `请求失败 (${response.status})`; + let code: string | undefined; try { const errorData = await response.json(); - if (typeof errorData === 'object' && errorData !== null && 'message' in errorData) { - message = (errorData as { message: string }).message; + if (typeof errorData === 'object' && errorData !== null) { + // 提取结构化错误响应 + if ('error' in errorData && typeof errorData.error === 'string') { + message = errorData.error; + } else if ('message' in errorData && typeof errorData.message === 'string') { + message = errorData.message; + } + // 提取错误码 + if ('code' in errorData && typeof errorData.code === 'string') { + code = errorData.code; + } } } catch { // ignore JSON parse error } - throw new ApiError(response.status, message); + throw new ApiError(response.status, message, code); } if (response.status === 204) { diff --git a/frontend/src/hooks/useModels.ts b/frontend/src/hooks/useModels.ts index 9d13895..dbf7e58 100644 --- a/frontend/src/hooks/useModels.ts +++ b/frontend/src/hooks/useModels.ts @@ -1,8 +1,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { MessagePlugin } from 'tdesign-react'; -import type { CreateModelInput, UpdateModelInput } from '@/types'; +import type { CreateModelInput, UpdateModelInput, ApiError } from '@/types'; import * as api from '@/api/models'; +const ERROR_MESSAGES: Record = { + duplicate_model: '同一供应商下模型名称已存在', + invalid_provider_id: '供应商 ID 仅允许字母、数字、下划线,长度 1-64', + immutable_field: '供应商 ID 不允许修改', + provider_not_found: '供应商不存在', +}; + +function getErrorMessage(error: ApiError): string { + return error.code ? ERROR_MESSAGES[error.code] || error.message : error.message; +} + export const modelKeys = { all: ['models'] as const, filtered: (providerId?: string) => ['models', providerId] as const, @@ -24,8 +35,8 @@ export function useCreateModel() { queryClient.invalidateQueries({ queryKey: modelKeys.all }); MessagePlugin.success('模型创建成功'); }, - onError: (error: Error) => { - MessagePlugin.error(error.message); + onError: (error: ApiError) => { + MessagePlugin.error(getErrorMessage(error)); }, }); } @@ -40,8 +51,8 @@ export function useUpdateModel() { queryClient.invalidateQueries({ queryKey: modelKeys.all }); MessagePlugin.success('模型更新成功'); }, - onError: (error: Error) => { - MessagePlugin.error(error.message); + onError: (error: ApiError) => { + MessagePlugin.error(getErrorMessage(error)); }, }); } diff --git a/frontend/src/hooks/useProviders.ts b/frontend/src/hooks/useProviders.ts index d280951..fb9836b 100644 --- a/frontend/src/hooks/useProviders.ts +++ b/frontend/src/hooks/useProviders.ts @@ -1,8 +1,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { MessagePlugin } from 'tdesign-react'; -import type { CreateProviderInput, UpdateProviderInput } from '@/types'; +import type { CreateProviderInput, UpdateProviderInput, ApiError } from '@/types'; import * as api from '@/api/providers'; +const ERROR_MESSAGES: Record = { + duplicate_model: '同一供应商下模型名称已存在', + invalid_provider_id: '供应商 ID 仅允许字母、数字、下划线,长度 1-64', + immutable_field: '供应商 ID 不允许修改', + provider_not_found: '供应商不存在', +}; + +function getErrorMessage(error: ApiError): string { + return error.code ? ERROR_MESSAGES[error.code] || error.message : error.message; +} + export const providerKeys = { all: ['providers'] as const, }; @@ -23,8 +34,8 @@ export function useCreateProvider() { queryClient.invalidateQueries({ queryKey: providerKeys.all }); MessagePlugin.success('供应商创建成功'); }, - onError: (error: Error) => { - MessagePlugin.error(error.message); + onError: (error: ApiError) => { + MessagePlugin.error(getErrorMessage(error)); }, }); } @@ -39,8 +50,8 @@ export function useUpdateProvider() { queryClient.invalidateQueries({ queryKey: providerKeys.all }); MessagePlugin.success('供应商更新成功'); }, - onError: (error: Error) => { - MessagePlugin.error(error.message); + onError: (error: ApiError) => { + MessagePlugin.error(getErrorMessage(error)); }, }); } diff --git a/frontend/src/pages/Providers/ModelForm.tsx b/frontend/src/pages/Providers/ModelForm.tsx index 535da1b..dbd1c63 100644 --- a/frontend/src/pages/Providers/ModelForm.tsx +++ b/frontend/src/pages/Providers/ModelForm.tsx @@ -4,7 +4,6 @@ import type { Provider, Model } from '@/types'; import type { SubmitContext } from 'tdesign-react/es/form/type'; interface ModelFormValues { - id: string; providerId: string; modelName: string; enabled: boolean; @@ -38,7 +37,6 @@ export function ModelForm({ if (model) { // 编辑模式:设置现有值 form.setFieldsValue({ - id: model.id, providerId: model.providerId, modelName: model.modelName, enabled: model.enabled, @@ -73,9 +71,14 @@ export function ModelForm({ destroyOnClose >
- - - + {isEdit && model?.unifiedId && ( + + +
+ 格式:provider_id/model_name +
+
+ )} [] = [ + { + title: '统一模型 ID', + colKey: 'unifiedId', + width: 250, + ellipsis: true, + cell: ({ row }) => row.unifiedId || `${row.providerId}/${row.modelName}`, + }, { title: '模型名称', colKey: 'modelName', diff --git a/frontend/src/pages/Providers/ProviderForm.tsx b/frontend/src/pages/Providers/ProviderForm.tsx index e81df96..048baf0 100644 --- a/frontend/src/pages/Providers/ProviderForm.tsx +++ b/frontend/src/pages/Providers/ProviderForm.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { Dialog, Form, Input, Switch } from 'tdesign-react'; +import { Dialog, Form, Input, Switch, Select } from 'tdesign-react'; import type { Provider } from '@/types'; import type { SubmitContext } from 'tdesign-react/es/form/type'; @@ -8,6 +8,7 @@ interface ProviderFormValues { name: string; apiKey: string; baseUrl: string; + protocol: 'openai' | 'anthropic'; enabled: boolean; } @@ -39,12 +40,13 @@ export function ProviderForm({ name: provider.name, apiKey: '', baseUrl: provider.baseUrl, + protocol: provider.protocol, enabled: provider.enabled, }); } else { // 新增模式:重置表单 form.reset(); - form.setFieldsValue({ enabled: true }); + form.setFieldsValue({ enabled: true, protocol: 'openai' }); } } }, [open, provider]); // 移除form依赖,避免循环 @@ -95,6 +97,13 @@ export function ProviderForm({ + + + + diff --git a/frontend/src/pages/Providers/ProviderTable.tsx b/frontend/src/pages/Providers/ProviderTable.tsx index 93c07db..377c286 100644 --- a/frontend/src/pages/Providers/ProviderTable.tsx +++ b/frontend/src/pages/Providers/ProviderTable.tsx @@ -40,6 +40,16 @@ export function ProviderTable({ colKey: 'baseUrl', ellipsis: true, }, + { + title: '协议', + colKey: 'protocol', + width: 100, + cell: ({ row }) => ( + + {row.protocol === 'openai' ? 'OpenAI' : 'Anthropic'} + + ), + }, { title: 'API Key', colKey: 'apiKey', diff --git a/frontend/src/pages/Stats/StatsTable.tsx b/frontend/src/pages/Stats/StatsTable.tsx index 310c8a8..f3cf80f 100644 --- a/frontend/src/pages/Stats/StatsTable.tsx +++ b/frontend/src/pages/Stats/StatsTable.tsx @@ -47,6 +47,11 @@ export function StatsTable({ colKey: 'modelName', width: 250, ellipsis: true, + cell: ({ row }) => { + // 如果后端返回统一 ID 格式(包含 /),直接显示 + // 否则显示原始 model_name + return row.modelName; + }, }, { title: '日期', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 427004b..5f7725e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -3,6 +3,7 @@ export interface Provider { name: string; apiKey: string; baseUrl: string; + protocol: 'openai' | 'anthropic'; enabled: boolean; createdAt: string; updatedAt: string; @@ -14,6 +15,7 @@ export interface Model { modelName: string; enabled: boolean; createdAt: string; + unifiedId?: string; } export interface UsageStats { @@ -29,6 +31,7 @@ export interface CreateProviderInput { name: string; apiKey: string; baseUrl: string; + protocol: 'openai' | 'anthropic'; enabled: boolean; } @@ -36,11 +39,11 @@ export interface UpdateProviderInput { name?: string; apiKey?: string; baseUrl?: string; + protocol?: 'openai' | 'anthropic'; enabled?: boolean; } export interface CreateModelInput { - id: string; providerId: string; modelName: string; enabled: boolean; @@ -61,13 +64,21 @@ export interface StatsQueryParams { export class ApiError extends Error { status: number; + code?: string; constructor( status: number, message: string, + code?: string, ) { super(message); this.name = 'ApiError'; this.status = status; + this.code = code; } } + +export interface ApiErrorResponse { + error: string; + code?: string; +} diff --git a/openspec/specs/error-responses/spec.md b/openspec/specs/error-responses/spec.md index cc45f46..82cb4b0 100644 --- a/openspec/specs/error-responses/spec.md +++ b/openspec/specs/error-responses/spec.md @@ -23,6 +23,48 @@ - **THEN** `error` 字段 SHALL 包含人类可读的错误描述 - **THEN** `code` 字段 SHALL 包含机器可读的错误代码(可选) +### Requirement: 前端提取并处理错误码 + +前端 SHALL 提取后端结构化错误响应中的错误码并用于错误处理。 + +#### Scenario: API 客户端解析结构化错误 + +- **WHEN** 后端返回错误响应 +- **THEN** API 客户端 SHALL 尝试解析 JSON 格式 `{error: string, code?: string}` +- **THEN** 如解析成功且包含 code 字段,SHALL 创建包含 code 的 ApiError +- **THEN** 如解析失败或不包含 code,SHALL 创建不包含 code 的 ApiError + +#### Scenario: ApiError 包含错误码 + +- **WHEN** 创建 ApiError 对象 +- **THEN** ApiError 类 SHALL 包含可选的 code 字段 +- **THEN** code 字段类型 SHALL 为 `string | undefined` +- **THEN** 构造函数 SHALL 接受可选的 code 参数 + +#### Scenario: Hooks 使用错误码映射友好消息 + +- **WHEN** useMutation 或其他 Hook 处理错误 +- **THEN** SHALL 检查 error.code 是否存在 +- **THEN** 如存在,SHALL 使用映射表转换为友好中文消息 +- **THEN** 如不存在或未定义映射,SHALL 使用 error.message + +#### Scenario: 错误码映射表定义 + +- **WHEN** 定义错误码映射表 +- **THEN** 映射表 SHALL 包含以下键值对: + - `duplicate_model` → "同一供应商下模型名称已存在" + - `invalid_provider_id` → "供应商 ID 仅允许字母、数字、下划线,长度 1-64" + - `immutable_field` → "供应商 ID 不允许修改" + - `provider_not_found` → "供应商不存在" +- **THEN** 映射表 SHALL 使用 TypeScript Record 类型确保类型安全 + +#### Scenario: 错误码映射降级处理 + +- **WHEN** 后端返回新的错误码(映射表未定义) +- **THEN** 前端 SHALL 降级使用 error.message +- **THEN** 前端 SHALL NOT 抛出错误或崩溃 +- **THEN** 用户 SHALL 仍能看到原始错误消息 + ### Requirement: provider_id 校验错误 系统 SHALL 对 provider_id 校验错误返回明确的错误信息。 diff --git a/openspec/specs/frontend/spec.md b/openspec/specs/frontend/spec.md index 9b90f28..9eb8423 100644 --- a/openspec/specs/frontend/spec.md +++ b/openspec/specs/frontend/spec.md @@ -80,6 +80,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **THEN** 前端 SHALL 显示 TDesign Dialog + Form - **THEN** provider_id SHALL 自动关联当前供应商 - **THEN** 供应商选择 SHALL 使用 `options` 属性 +- **THEN** 创建表单 SHALL NOT 包含 ID 输入框(后端自动生成 UUID) - **WHEN** 用户提交表单 - **THEN** 前端 SHALL 通过 useMutation 调用创建 API - **THEN** 成功后 SHALL 刷新模型列表 @@ -88,6 +89,8 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **WHEN** 用户点击模型的"编辑" - **THEN** 前端 SHALL 显示编辑表单 +- **THEN** 编辑表单 SHALL 显示统一模型 ID(只读) +- **THEN** ID 字段 SHALL 为禁用状态 - **WHEN** 用户提交表单 - **THEN** 前端 SHALL 通过 useMutation 调用更新 API - **THEN** 成功后 SHALL 刷新模型列表 @@ -100,6 +103,71 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面 - **THEN** 前端 SHALL 通过 useMutation 调用删除 API - **THEN** 成功后 SHALL 刷新模型列表 +### Requirement: 显示协议字段 + +前端 SHALL 在供应商管理界面显示协议字段。 + +#### Scenario: 供应商表格显示协议列 + +- **WHEN** 渲染供应商表格 +- **THEN** 表格 SHALL 包含协议列 +- **THEN** 协议列 SHALL 显示 "OpenAI" 或 "Anthropic" 标签 +- **THEN** OpenAI 协议 SHALL 使用主题色标签 +- **THEN** Anthropic 协议 SHALL 使用成功色标签 + +#### Scenario: 供应商表单选择协议 + +- **WHEN** 创建或编辑供应商 +- **THEN** 表单 SHALL 包含协议选择下拉框 +- **THEN** 下拉框 SHALL 提供 "OpenAI" 和 "Anthropic" 选项 +- **THEN** 协议字段 SHALL 为必填项 + +### Requirement: 显示统一模型 ID + +前端 SHALL 在所有显示模型的地方使用统一模型 ID。 + +#### Scenario: 模型表格显示统一 ID 列 + +- **WHEN** 渲染模型表格 +- **THEN** 表格 SHALL 包含统一模型 ID 列 +- **THEN** 统一模型 ID 列 SHALL 显示 `provider_id/model_name` 格式 +- **THEN** 统一模型 ID 列 SHALL 启用 ellipsis(超长文本显示省略号,hover 显示 Tooltip) +- **THEN** 统一模型 ID 列 SHALL 固定宽度 250px + +#### Scenario: 编辑模型显示统一 ID + +- **WHEN** 编辑模型表单 +- **THEN** 表单 SHALL 显示统一模型 ID 字段 +- **THEN** 统一模型 ID 字段 SHALL 为只读(disabled) +- **THEN** 统一模型 ID 字段 SHALL 显示格式说明 "格式:provider_id/model_name" + +#### Scenario: 统一模型 ID 降级显示 + +- **WHEN** 后端未返回 unified_id 字段 +- **THEN** 前端 SHALL 拼接 providerId 和 modelName 显示 +- **THEN** 拼接格式 SHALL 为 `{providerId}/{modelName}` + +### Requirement: 提取并映射错误码 + +前端 SHALL 提取后端结构化错误响应中的错误码并映射为友好消息。 + +#### Scenario: API 客户端提取错误码 + +- **WHEN** 后端返回结构化错误响应 `{error: string, code: string}` +- **THEN** API 客户端 SHALL 提取 code 字段 +- **THEN** ApiError 对象 SHALL 包含 code 字段 +- **THEN** code 字段 SHALL 为可选(兼容旧错误格式) + +#### Scenario: Hooks 映射错误码为中文消息 + +- **WHEN** 处理 API 错误 +- **THEN** Hooks SHALL 使用错误码映射表 +- **THEN** 映射表 SHALL 包含以下错误码: + - `duplicate_model` → "同一供应商下模型名称已存在" + - `invalid_provider_id` → "供应商 ID 仅允许字母、数字、下划线,长度 1-64" + - `immutable_field` → "供应商 ID 不允许修改" +- **THEN** 未定义的错误码 SHALL 降级使用原始错误消息 + ### Requirement: 提供统计查看页面 前端 SHALL 使用 TDesign 组件提供统计仪表盘页面。 diff --git a/openspec/specs/model-management/spec.md b/openspec/specs/model-management/spec.md index 12cb883..80917d0 100644 --- a/openspec/specs/model-management/spec.md +++ b/openspec/specs/model-management/spec.md @@ -4,6 +4,28 @@ 管理模型的增删改查,通过 handler → service → repository 分层实现业务逻辑和数据访问,支持供应商关联验证。 +### Requirement: 前端适配统一模型 ID 显示 + +前端 SHALL 在模型管理界面显示统一模型 ID。 + +#### Scenario: 模型列表返回统一 ID + +- **WHEN** 向 `/api/models` 发送 GET 请求 +- **THEN** 每个模型 SHALL 包含 unified_id 字段 +- **THEN** unified_id 格式 SHALL 为 `{provider_id}/{model_name}` + +#### Scenario: 创建模型返回统一 ID + +- **WHEN** 向 `/api/models` 发送 POST 请求创建模型 +- **THEN** 返回的模型 SHALL 包含 unified_id 字段 +- **THEN** unified_id SHALL 由后端根据 provider_id 和 model_name 生成 + +#### Scenario: 更新模型返回统一 ID + +- **WHEN** 向 `/api/models/:id` 发送 PUT 请求更新模型 +- **THEN** 返回的模型 SHALL 包含更新后的 unified_id +- **THEN** unified_id SHALL 反映最新的 provider_id 和 model_name 组合 + ### Requirement: 创建模型配置 网关 SHALL 允许为供应商创建新的模型配置。 @@ -16,6 +38,7 @@ - **THEN** 网关 SHALL 返回创建的模型,状态码为 201 - **THEN** 模型 SHALL 默认启用 - **THEN** 返回的模型 SHALL 包含 `unified_id` 字段,值为 `{provider_id}/{model_name}` +- **THEN** 前端 SHALL NOT 在请求体中发送 id 字段 #### Scenario: 使用不存在的供应商创建模型 diff --git a/openspec/specs/provider-management/spec.md b/openspec/specs/provider-management/spec.md index 47496e0..7f59714 100644 --- a/openspec/specs/provider-management/spec.md +++ b/openspec/specs/provider-management/spec.md @@ -6,6 +6,29 @@ ## Requirements +### Requirement: 前端适配协议字段 + +前端 SHALL 在供应商管理界面支持协议字段的显示和选择。 + +#### Scenario: 供应商列表返回协议字段 + +- **WHEN** 向 `/api/providers` 发送 GET 请求 +- **THEN** 每个供应商 SHALL 包含 protocol 字段 +- **THEN** protocol 值 SHALL 为 "openai" 或 "anthropic" + +#### Scenario: 创建供应商携带协议字段 + +- **WHEN** 向 `/api/providers` 发送 POST 请求 +- **THEN** 请求体 SHALL 包含 protocol 字段 +- **THEN** protocol 值 SHALL 为 "openai" 或 "anthropic" +- **THEN** 前端 SHALL 提供协议选择下拉框 + +#### Scenario: 更新供应商携带协议字段 + +- **WHEN** 向 `/api/providers/:id` 发送 PUT 请求 +- **THEN** 请求体 MAY 包含 protocol 字段 +- **THEN** protocol 值 SHALL 为 "openai" 或 "anthropic" + ### Requirement: 创建供应商配置 网关 SHALL 允许通过管理 API 创建新的供应商配置。 @@ -16,7 +39,7 @@ - **THEN** 网关 SHALL 在数据库中创建新的供应商记录 - **THEN** 网关 SHALL 返回创建的供应商,状态码为 201 - **THEN** 供应商 SHALL 默认启用 -- **THEN** protocol 字段 SHALL 默认为 "openai" +- **THEN** protocol 字段 SHALL 为必填项,值为 "openai" 或 "anthropic" #### Scenario: 使用重复 ID 创建供应商