1
0

feat: 完成前端重构,采用 Ant Design 5 和完整测试体系

- 采用 Ant Design 5 作为 UI 组件库,替换自定义组件
- 集成 React Router v7 提供路由导航
- 使用 TanStack Query v5 管理数据获取和缓存
- 建立 Vitest + React Testing Library 测试体系
- 添加 Playwright E2E 测试覆盖
- 使用 MSW mock API 响应
- 配置 TypeScript strict 模式
- 采用 SCSS Modules 组织样式
- 更新 OpenSpec 规格以反映前端架构变更
- 归档 frontend-refactor 变更记录
This commit is contained in:
2026-04-16 11:21:48 +08:00
parent c17903dcbc
commit 9359ca7f62
61 changed files with 4588 additions and 1095 deletions

View File

@@ -0,0 +1,144 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { ModelForm } from '@/pages/Providers/ModelForm';
import type { Provider, Model } from '@/types';
const mockProviders: Provider[] = [
{
id: 'openai',
name: 'OpenAI',
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
enabled: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
{
id: 'anthropic',
name: 'Anthropic',
apiKey: 'sk-ant-test',
baseUrl: 'https://api.anthropic.com',
enabled: true,
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
},
];
const mockModel: Model = {
id: 'gpt-4o',
providerId: 'openai',
modelName: 'gpt-4o',
enabled: true,
createdAt: '2024-01-01T00:00:00Z',
};
const defaultProps = {
open: true,
providerId: 'openai',
providers: mockProviders,
onSave: vi.fn(),
onCancel: vi.fn(),
loading: false,
};
function getDialog() {
return screen.getByRole('dialog');
}
describe('ModelForm', () => {
it('renders form with provider select', () => {
render(<ModelForm {...defaultProps} />);
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();
// The selected provider (OpenAI) is shown; Anthropic is not rendered until dropdown opens
expect(within(dialog).getByText('OpenAI')).toBeInTheDocument();
});
it('defaults providerId to the passed providerId in create mode', () => {
render(<ModelForm {...defaultProps} />);
const dialog = getDialog();
const selectionItem = dialog.querySelector('.ant-select-selection-item');
expect(selectionItem).toBeInTheDocument();
expect(selectionItem?.textContent).toBe('OpenAI');
});
it('shows validation error messages for required fields', async () => {
const user = userEvent.setup();
render(
<ModelForm
{...defaultProps}
providerId={undefined as unknown as string}
providers={[]}
/>,
);
const dialog = getDialog();
const okButton = within(dialog).getByRole('button', { name: /保/ });
await user.click(okButton);
expect(await screen.findByText('请输入模型 ID')).toBeInTheDocument();
expect(screen.getByText('请选择供应商')).toBeInTheDocument();
expect(screen.getByText('请输入模型名称')).toBeInTheDocument();
});
it('calls onSave with form values on successful submission', async () => {
const user = userEvent.setup();
const onSave = vi.fn();
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');
// Type into the ID field
await user.type(inputs[0], 'gpt-4o-mini');
// Type into the model name field
await user.type(inputs[1], 'gpt-4o-mini');
const okButton = within(dialog).getByRole('button', { name: /保/ });
await user.click(okButton);
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
id: 'gpt-4o-mini',
providerId: 'openai',
modelName: 'gpt-4o-mini',
enabled: true,
}),
);
});
it('renders pre-filled fields in edit mode', () => {
render(<ModelForm {...defaultProps} model={mockModel} />);
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();
const modelNameInput = inputs[1] as HTMLInputElement;
expect(modelNameInput.value).toBe('gpt-4o');
});
it('calls onCancel when clicking cancel button', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<ModelForm {...defaultProps} onCancel={onCancel} />);
const dialog = getDialog();
const cancelButton = within(dialog).getByRole('button', { name: /取/ });
await user.click(cancelButton);
expect(onCancel).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,147 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { ProviderForm } from '@/pages/Providers/ProviderForm';
import type { Provider } from '@/types';
const mockProvider: Provider = {
id: 'openai',
name: 'OpenAI',
apiKey: 'sk-old-key',
baseUrl: 'https://api.openai.com/v1',
enabled: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
const defaultProps = {
open: true,
onSave: vi.fn(),
onCancel: vi.fn(),
loading: false,
};
function getDialog() {
return screen.getByRole('dialog');
}
describe('ProviderForm', () => {
it('renders form fields in create mode', () => {
render(<ProviderForm {...defaultProps} />);
const dialog = getDialog();
expect(within(dialog).getByText('添加供应商')).toBeInTheDocument();
expect(within(dialog).getByText('ID')).toBeInTheDocument();
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).getByPlaceholderText('例如: openai')).toBeInTheDocument();
expect(within(dialog).getByPlaceholderText('例如: OpenAI')).toBeInTheDocument();
expect(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1')).toBeInTheDocument();
});
it('renders pre-filled fields in edit mode', () => {
render(<ProviderForm {...defaultProps} provider={mockProvider} />);
const dialog = getDialog();
expect(within(dialog).getByText('编辑供应商')).toBeInTheDocument();
const idInput = within(dialog).getByPlaceholderText('例如: openai') as HTMLInputElement;
expect(idInput.value).toBe('openai');
expect(idInput).toBeDisabled();
const nameInput = within(dialog).getByPlaceholderText('例如: OpenAI') as HTMLInputElement;
expect(nameInput.value).toBe('OpenAI');
const baseUrlInput = within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1') as HTMLInputElement;
expect(baseUrlInput.value).toBe('https://api.openai.com/v1');
});
it('shows API Key label variant in edit mode', () => {
render(<ProviderForm {...defaultProps} provider={mockProvider} />);
const dialog = getDialog();
expect(within(dialog).getByText('API Key留空则不修改')).toBeInTheDocument();
});
it('shows validation error messages for required fields', async () => {
const user = userEvent.setup();
render(<ProviderForm {...defaultProps} />);
const dialog = getDialog();
const okButton = within(dialog).getByRole('button', { name: /保/ });
await user.click(okButton);
// Wait for validation messages to appear
expect(await screen.findByText('请输入供应商 ID')).toBeInTheDocument();
expect(screen.getByText('请输入名称')).toBeInTheDocument();
expect(screen.getByText('请输入 API Key')).toBeInTheDocument();
expect(screen.getByText('请输入 Base URL')).toBeInTheDocument();
});
it('calls onSave with form values on successful submission', async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<ProviderForm {...defaultProps} onSave={onSave} />);
const dialog = getDialog();
await user.type(within(dialog).getByPlaceholderText('例如: openai'), 'test-provider');
await user.type(within(dialog).getByPlaceholderText('例如: OpenAI'), 'Test Provider');
await user.type(within(dialog).getByPlaceholderText('sk-...'), 'sk-test-key');
await user.type(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1'), 'https://api.test.com/v1');
const okButton = within(dialog).getByRole('button', { name: /保/ });
await user.click(okButton);
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
id: 'test-provider',
name: 'Test Provider',
apiKey: 'sk-test-key',
baseUrl: 'https://api.test.com/v1',
enabled: true,
}),
);
});
it('calls onCancel when clicking cancel button', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<ProviderForm {...defaultProps} onCancel={onCancel} />);
const dialog = getDialog();
const cancelButton = within(dialog).getByRole('button', { name: /取/ });
await user.click(cancelButton);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('shows confirm loading state', () => {
render(<ProviderForm {...defaultProps} loading={true} />);
const dialog = getDialog();
const okButton = within(dialog).getByRole('button', { name: /保/ });
expect(okButton).toHaveClass('ant-btn-loading');
});
it('shows validation error for invalid URL format', async () => {
const user = userEvent.setup();
render(<ProviderForm {...defaultProps} />);
const dialog = getDialog();
// Fill in required fields
await user.type(within(dialog).getByPlaceholderText('例如: openai'), 'test-provider');
await user.type(within(dialog).getByPlaceholderText('例如: OpenAI'), 'Test Provider');
await user.type(within(dialog).getByPlaceholderText('sk-...'), 'sk-test-key');
// Enter an invalid URL in the Base URL field
await user.type(within(dialog).getByPlaceholderText('例如: https://api.openai.com/v1'), 'not-a-url');
// Submit the form
const okButton = within(dialog).getByRole('button', { name: /保/ });
await user.click(okButton);
// Verify that a URL validation error message appears
expect(await screen.findByText('请输入有效的 URL')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,140 @@
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
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 },
];
vi.mock('@/hooks/useModels', () => ({
useModels: vi.fn(() => ({ data: mockModelsData, isLoading: false })),
useDeleteModel: vi.fn(() => ({ mutate: vi.fn() })),
}));
const mockProviders: Provider[] = [
{
id: 'openai',
name: 'OpenAI',
apiKey: 'sk-abcdefgh12345678',
baseUrl: 'https://api.openai.com/v1',
enabled: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
{
id: 'anthropic',
name: 'Anthropic',
apiKey: 'sk-ant-test',
baseUrl: 'https://api.anthropic.com',
enabled: false,
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
},
];
const defaultProps = {
providers: mockProviders,
loading: false,
onAdd: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
onAddModel: vi.fn(),
onEditModel: vi.fn(),
};
describe('ProviderTable', () => {
it('renders provider list with name, baseUrl, masked apiKey, and status tags', () => {
render(<ProviderTable {...defaultProps} />);
expect(screen.getByText('供应商列表')).toBeInTheDocument();
expect(screen.getByText('OpenAI')).toBeInTheDocument();
expect(screen.getByText('https://api.openai.com/v1')).toBeInTheDocument();
expect(screen.getByText('****5678')).toBeInTheDocument();
expect(screen.getByText('Anthropic')).toBeInTheDocument();
expect(screen.getByText('https://api.anthropic.com')).toBeInTheDocument();
expect(screen.getByText('****test')).toBeInTheDocument();
const enabledTags = screen.getAllByText('启用');
const disabledTags = screen.getAllByText('禁用');
expect(enabledTags.length).toBeGreaterThanOrEqual(1);
expect(disabledTags.length).toBeGreaterThanOrEqual(1);
});
it('renders short api keys fully masked', () => {
const shortKeyProvider: Provider[] = [
{
...mockProviders[0],
id: 'short',
name: 'ShortKey',
apiKey: 'ab',
},
];
render(<ProviderTable {...defaultProps} providers={shortKeyProvider} />);
expect(screen.getByText('****')).toBeInTheDocument();
});
it('calls onAdd when clicking "添加供应商" button', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(<ProviderTable {...defaultProps} onAdd={onAdd} />);
await user.click(screen.getByRole('button', { name: '添加供应商' }));
expect(onAdd).toHaveBeenCalledTimes(1);
});
it('calls onEdit with correct provider when clicking "编辑"', async () => {
const user = userEvent.setup();
const onEdit = vi.fn();
render(<ProviderTable {...defaultProps} onEdit={onEdit} />);
const editButtons = screen.getAllByRole('button', { name: '编辑' });
await user.click(editButtons[0]);
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onEdit).toHaveBeenCalledWith(mockProviders[0]);
});
it('calls onDelete with correct provider ID when delete is confirmed', async () => {
const user = userEvent.setup();
const onDelete = vi.fn();
render(<ProviderTable {...defaultProps} onDelete={onDelete} />);
// Find and click the delete button for the first row
const deleteButtons = screen.getAllByRole('button', { name: '删除' });
await user.click(deleteButtons[0]);
// Find and click the "确 定" confirm button in the Popconfirm popup
// antd renders the text with spaces between Chinese characters
const confirmButtons = await screen.findAllByText('确 定');
await user.click(confirmButtons[0]);
// Assert that onDelete was called with the correct provider ID
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onDelete).toHaveBeenCalledWith('openai');
});
it('shows loading state', () => {
render(<ProviderTable {...defaultProps} loading={true} />);
expect(document.querySelector('.ant-spin')).toBeInTheDocument();
});
it('renders expandable ModelTable when row is expanded', async () => {
const user = userEvent.setup();
render(<ProviderTable {...defaultProps} />);
// Find and click the expand button for the first row
const expandButtons = screen.getAllByRole('button', { name: /expand/i });
expect(expandButtons.length).toBeGreaterThanOrEqual(1);
await user.click(expandButtons[0]);
// Verify that ModelTable content is rendered with data from mocked useModels
expect(await screen.findByText('gpt-4o')).toBeInTheDocument();
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,159 @@
import { render, screen, within, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { StatsTable } from '@/pages/Stats/StatsTable';
import type { Provider, UsageStats } from '@/types';
const mockProviders: Provider[] = [
{
id: 'openai',
name: 'OpenAI',
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
enabled: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
{
id: 'anthropic',
name: 'Anthropic',
apiKey: 'sk-ant-test',
baseUrl: 'https://api.anthropic.com',
enabled: true,
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
},
];
const mockStats: UsageStats[] = [
{
id: 1,
providerId: 'openai',
modelName: 'gpt-4o',
requestCount: 100,
date: '2024-01-15',
},
{
id: 2,
providerId: 'anthropic',
modelName: 'claude-3-opus',
requestCount: 50,
date: '2024-01-15',
},
];
const mockUseStats = vi.fn(() => ({
data: mockStats,
isLoading: false,
}));
vi.mock('@/hooks/useStats', () => ({
useStats: (...args: unknown[]) => mockUseStats(...args),
}));
describe('StatsTable', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('renders stats table with data', () => {
render(<StatsTable providers={mockProviders} />);
expect(screen.getByText('gpt-4o')).toBeInTheDocument();
expect(screen.getByText('claude-3-opus')).toBeInTheDocument();
// Both rows share the same date
const dateCells = screen.getAllByText('2024-01-15');
expect(dateCells.length).toBe(2);
expect(screen.getByText('100')).toBeInTheDocument();
expect(screen.getByText('50')).toBeInTheDocument();
});
it('shows provider name from providers prop instead of providerId', () => {
render(<StatsTable providers={mockProviders} />);
expect(screen.getByText('OpenAI')).toBeInTheDocument();
// "Anthropic" appears in both the provider column and the filter select options
const allAnthropic = screen.getAllByText('Anthropic');
expect(allAnthropic.length).toBeGreaterThanOrEqual(1);
});
it('renders filter controls with Select, Input, and DatePicker', () => {
render(<StatsTable providers={mockProviders} />);
// Check that the select element exists for provider filter
const selects = document.querySelectorAll('.ant-select');
expect(selects.length).toBeGreaterThanOrEqual(1);
// Check that the Input element exists for model name filter
const modelInput = screen.getByPlaceholderText('模型名称');
expect(modelInput).toBeInTheDocument();
// Verify placeholder text is rendered
expect(screen.getByText('所有供应商')).toBeInTheDocument();
const rangePicker = document.querySelector('.ant-picker-range');
expect(rangePicker).toBeInTheDocument();
});
it('renders table headers correctly', () => {
render(<StatsTable providers={mockProviders} />);
expect(screen.getByText('供应商')).toBeInTheDocument();
expect(screen.getByText('模型')).toBeInTheDocument();
expect(screen.getByText('日期')).toBeInTheDocument();
expect(screen.getByText('请求数')).toBeInTheDocument();
});
it('falls back to providerId when provider not found in providers prop', () => {
const limitedProviders = [mockProviders[0]]; // only OpenAI
render(<StatsTable providers={limitedProviders} />);
// OpenAI should show name
expect(screen.getByText('OpenAI')).toBeInTheDocument();
// Anthropic is not in providers list, so providerId "anthropic" should show
expect(screen.getByText('anthropic')).toBeInTheDocument();
});
it('renders with empty stats data', () => {
mockUseStats.mockReturnValueOnce({
data: [],
isLoading: false,
});
render(<StatsTable providers={mockProviders} />);
// Table should still be rendered, just empty
expect(screen.getByText('供应商')).toBeInTheDocument();
expect(screen.getByText('模型')).toBeInTheDocument();
});
it('updates provider filter when selecting a provider', () => {
render(<StatsTable providers={mockProviders} />);
// Initially useStats should be called with no providerId filter
expect(mockUseStats).toHaveBeenLastCalledWith(
expect.objectContaining({
providerId: undefined,
}),
);
// Find the provider Select and change its value
const selectElement = document.querySelector('.ant-select');
expect(selectElement).toBeInTheDocument();
// Open the select dropdown
fireEvent.mouseDown(selectElement!.querySelector('.ant-select-selector')!);
// Click on the "OpenAI" option from the dropdown
const dropdown = document.querySelector('.ant-select-dropdown');
expect(dropdown).toBeInTheDocument();
const openaiOption = within(dropdown as HTMLElement).getByText('OpenAI');
fireEvent.click(openaiOption);
// After selecting, useStats should be called with providerId set to 'openai'
expect(mockUseStats).toHaveBeenLastCalledWith(
expect.objectContaining({
providerId: 'openai',
}),
);
});
});