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,270 @@
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders';
import type { Provider, CreateProviderInput, UpdateProviderInput } from '@/types';
// Mock antd message since it uses DOM APIs not available in jsdom
vi.mock('antd', () => ({
message: {
success: vi.fn(),
error: vi.fn(),
},
}));
// Import the mocked message for assertions
import { message } from 'antd';
// Test data
const mockProviders: Provider[] = [
{
id: 'provider-1',
name: 'OpenAI',
apiKey: 'sk-xxx',
baseUrl: 'https://api.openai.com',
enabled: true,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
},
{
id: 'provider-2',
name: 'Anthropic',
apiKey: 'sk-yyy',
baseUrl: 'https://api.anthropic.com',
enabled: false,
createdAt: '2026-02-01T00:00:00Z',
updatedAt: '2026-02-01T00:00:00Z',
},
];
const mockCreatedProvider: Provider = {
id: 'provider-3',
name: 'NewProvider',
apiKey: 'sk-zzz',
baseUrl: 'https://api.newprovider.com',
enabled: true,
createdAt: '2026-03-01T00:00:00Z',
updatedAt: '2026-03-01T00:00:00Z',
};
// MSW handlers
const handlers = [
http.get('/api/providers', () => {
return HttpResponse.json(mockProviders);
}),
http.post('/api/providers', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({
...mockCreatedProvider,
...body,
});
}),
http.put('/api/providers/:id', async ({ request, params }) => {
const body = await request.json() as Record<string, unknown>;
const existing = mockProviders.find((p) => p.id === params['id']);
return HttpResponse.json({ ...existing, ...body });
}),
http.delete('/api/providers/:id', () => {
return new HttpResponse(null, { status: 204 });
}),
];
const server = setupServer(...handlers);
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function createWrapper() {
const testQueryClient = createTestQueryClient();
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
);
};
}
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
vi.clearAllMocks();
});
afterAll(() => server.close());
describe('useProviders', () => {
it('fetches and returns provider list', async () => {
const { result } = renderHook(() => useProviders(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockProviders);
expect(result.current.data).toHaveLength(2);
expect(result.current.data![0]!.name).toBe('OpenAI');
expect(result.current.data![1]!.name).toBe('Anthropic');
});
});
describe('useCreateProvider', () => {
it('calls API and invalidates provider queries', async () => {
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
const { result } = renderHook(() => useCreateProvider(), {
wrapper: Wrapper,
});
const input: CreateProviderInput = {
id: 'provider-3',
name: 'NewProvider',
apiKey: 'sk-zzz',
baseUrl: 'https://api.newprovider.com',
enabled: true,
};
result.current.mutate(input);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toMatchObject({
id: 'provider-3',
name: 'NewProvider',
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] });
expect(message.success).toHaveBeenCalledWith('供应商创建成功');
});
it('calls message.error on failure', async () => {
server.use(
http.post('/api/providers', () => {
return HttpResponse.json({ message: '创建失败' }, { status: 500 });
}),
);
const { result } = renderHook(() => useCreateProvider(), {
wrapper: createWrapper(),
});
const input: CreateProviderInput = {
id: 'provider-3',
name: 'NewProvider',
apiKey: 'sk-zzz',
baseUrl: 'https://api.newprovider.com',
enabled: true,
};
result.current.mutate(input);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
});
});
describe('useUpdateProvider', () => {
it('calls API and invalidates provider queries', async () => {
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
const { result } = renderHook(() => useUpdateProvider(), {
wrapper: Wrapper,
});
const input: UpdateProviderInput = { name: 'UpdatedProvider' };
result.current.mutate({ id: 'provider-1', input });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toMatchObject({
name: 'UpdatedProvider',
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] });
expect(message.success).toHaveBeenCalledWith('供应商更新成功');
});
it('calls message.error on failure', async () => {
server.use(
http.put('/api/providers/:id', () => {
return HttpResponse.json({ message: '更新失败' }, { status: 500 });
}),
);
const { result } = renderHook(() => useUpdateProvider(), {
wrapper: createWrapper(),
});
result.current.mutate({ id: 'provider-1', input: { name: 'Updated' } });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
});
});
describe('useDeleteProvider', () => {
it('calls API and invalidates provider queries', async () => {
const queryClient = createTestQueryClient();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
const { result } = renderHook(() => useDeleteProvider(), {
wrapper: Wrapper,
});
result.current.mutate('provider-1');
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] });
expect(message.success).toHaveBeenCalledWith('供应商删除成功');
});
it('calls message.error on failure', async () => {
server.use(
http.delete('/api/providers/:id', () => {
return HttpResponse.json({ message: '删除失败' }, { status: 500 });
}),
);
const { result } = renderHook(() => useDeleteProvider(), {
wrapper: createWrapper(),
});
result.current.mutate('provider-1');
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
});
});