feat: 前端适配后端新接口
适配后端统一模型 ID、协议字段、UUID 自动生成和结构化错误响应: - 类型定义:Provider 新增 protocol 字段,Model 新增 unifiedId,CreateModelInput 移除 id - API 客户端:提取结构化错误响应中的错误码 - 供应商管理:添加协议选择下拉框和表格列 - 模型管理:移除 ID 输入,显示统一模型 ID(只读) - Hooks:错误码映射为友好中文消息 - 测试:所有组件测试通过,mock 数据适配新字段 - 文档:更新 README 说明协议字段和统一模型 ID
This commit is contained in:
@@ -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(<ModelForm {...defaultProps} onSave={onSave} />);
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
123
frontend/src/__tests__/components/ModelTable.test.tsx
Normal file
123
frontend/src/__tests__/components/ModelTable.test.tsx
Normal file
@@ -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(<ModelTable {...defaultProps} />);
|
||||
|
||||
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(<ModelTable {...defaultProps} />);
|
||||
|
||||
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(<ModelTable {...defaultProps} onAdd={onAdd} />);
|
||||
|
||||
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(<ModelTable {...defaultProps} onEdit={onEdit} />);
|
||||
|
||||
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(<ModelTable {...defaultProps} />);
|
||||
|
||||
// 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(<ModelTable providerId="anthropic" />);
|
||||
expect(screen.getByText('暂无模型,点击上方按钮添加')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render add button when onAdd is not provided', () => {
|
||||
render(<ModelTable providerId="openai" />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '添加模型' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render edit button when onEdit is not provided', () => {
|
||||
render(<ModelTable providerId="openai" onAdd={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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(<ProviderForm {...defaultProps} />);
|
||||
|
||||
const dialog = getDialog();
|
||||
expect(within(dialog).getByText('协议')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('includes protocol field in form submission', async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<ProviderForm {...defaultProps} onSave={onSave} />);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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(<ProviderTable {...defaultProps} providers={[]} />);
|
||||
expect(screen.getByText('暂无供应商,点击上方按钮添加')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders protocol column with correct tags', () => {
|
||||
const { container } = render(<ProviderTable {...defaultProps} />);
|
||||
|
||||
// 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(<ProviderTable {...defaultProps} providers={singleProvider} />);
|
||||
|
||||
// Should display protocol column
|
||||
const protocolCell = container.querySelector('[data-colkey="protocol"]');
|
||||
expect(protocolCell).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user