1
0

refactor: 迁移前端 antd 组件至 v6 规范 API,消除废弃用法

- 迁移静态 message 到 App.useApp() 模式,使 message 感知 ConfigProvider 上下文
- Button type/danger 迁移为 variant/color 新 API
- Space direction 迁移为 vertical 布尔属性
- Select.Option 子组件迁移为 options 属性
- AppLayout 硬编码颜色替换为 antd design token
- 优化 useThemeConfig:default/dark 改为静态导出,减少不必要的 hook 调用
- 同步更新 openspec 主规范文档
This commit is contained in:
2026-04-17 00:59:36 +08:00
parent 49818ed4d8
commit 6eeb38c15e
19 changed files with 121 additions and 96 deletions

2
.gitignore vendored
View File

@@ -322,3 +322,5 @@ Temporary Items
.opencode
openspec/changes/archive
temp
.agents
skills-lock.json

View File

@@ -1,6 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router';
import { ConfigProvider } from 'antd';
import { App as AntApp, ConfigProvider } from 'antd';
import { AppRoutes } from '@/routes';
import { ThemeProvider, useTheme } from '@/contexts/ThemeContext';
import { useThemeConfig } from '@/themes';
@@ -21,9 +21,11 @@ function ThemedApp() {
return (
<ConfigProvider {...configProps}>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
<AntApp>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</AntApp>
</ConfigProvider>
);
}

View File

@@ -70,7 +70,7 @@ describe('AppLayout', () => {
const { container } = renderWithRouter(<AppLayout />);
const header = container.querySelector('.ant-layout-header') as HTMLElement;
expect(header.style.background).toBe('#141414');
expect(header.style.borderBottom).toContain('1px solid');
});
it('uses light menu theme in default mode', async () => {

View File

@@ -101,7 +101,7 @@ describe('ProviderTable', () => {
const onEdit = vi.fn();
render(<ProviderTable {...defaultProps} onEdit={onEdit} />);
const editButtons = screen.getAllByRole('button', { name: '编辑' });
const editButtons = screen.getAllByRole('button', { name: /编 ?辑/ });
await user.click(editButtons[0]);
expect(onEdit).toHaveBeenCalledTimes(1);

View File

@@ -6,15 +6,21 @@ import { setupServer } from 'msw/node';
import { useModels, useCreateModel, useUpdateModel, useDeleteModel } from '@/hooks/useModels';
import type { Model, CreateModelInput, UpdateModelInput } from '@/types';
// Mock antd message since it uses DOM APIs not available in jsdom
vi.mock('antd', () => ({
message: {
success: vi.fn(),
error: vi.fn(),
},
}));
const mockMessage = {
success: vi.fn(),
error: vi.fn(),
};
import { message } from 'antd';
vi.mock('antd', async (importOriginal) => {
const original = await importOriginal<typeof import('antd')>();
return {
...original,
App: {
...original.App,
useApp: () => ({ message: mockMessage }),
},
};
});
// Test data
const mockModels: Model[] = [
@@ -167,7 +173,7 @@ describe('useCreateModel', () => {
modelName: 'gpt-4.1',
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] });
expect(message.success).toHaveBeenCalledWith('模型创建成功');
expect(mockMessage.success).toHaveBeenCalledWith('模型创建成功');
});
it('calls message.error on failure', async () => {
@@ -191,7 +197,7 @@ describe('useCreateModel', () => {
result.current.mutate(input);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
expect(mockMessage.error).toHaveBeenCalled();
});
});
@@ -222,7 +228,7 @@ describe('useUpdateModel', () => {
modelName: 'gpt-4o-updated',
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] });
expect(message.success).toHaveBeenCalledWith('模型更新成功');
expect(mockMessage.success).toHaveBeenCalledWith('模型更新成功');
});
it('calls message.error on failure', async () => {
@@ -239,7 +245,7 @@ describe('useUpdateModel', () => {
result.current.mutate({ id: 'model-1', input: { modelName: 'Updated' } });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
expect(mockMessage.error).toHaveBeenCalled();
});
});
@@ -265,7 +271,7 @@ describe('useDeleteModel', () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['models'] });
expect(message.success).toHaveBeenCalledWith('模型删除成功');
expect(mockMessage.success).toHaveBeenCalledWith('模型删除成功');
});
it('calls message.error on failure', async () => {
@@ -282,6 +288,6 @@ describe('useDeleteModel', () => {
result.current.mutate('model-1');
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
expect(mockMessage.error).toHaveBeenCalled();
});
});

View File

@@ -6,16 +6,21 @@ 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(),
},
}));
const mockMessage = {
success: vi.fn(),
error: vi.fn(),
};
// Import the mocked message for assertions
import { message } from 'antd';
vi.mock('antd', async (importOriginal) => {
const original = await importOriginal<typeof import('antd')>();
return {
...original,
App: {
...original.App,
useApp: () => ({ message: mockMessage }),
},
};
});
// Test data
const mockProviders: Provider[] = [
@@ -149,7 +154,7 @@ describe('useCreateProvider', () => {
name: 'NewProvider',
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] });
expect(message.success).toHaveBeenCalledWith('供应商创建成功');
expect(mockMessage.success).toHaveBeenCalledWith('供应商创建成功');
});
it('calls message.error on failure', async () => {
@@ -174,7 +179,7 @@ describe('useCreateProvider', () => {
result.current.mutate(input);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
expect(mockMessage.error).toHaveBeenCalled();
});
});
@@ -205,7 +210,7 @@ describe('useUpdateProvider', () => {
name: 'UpdatedProvider',
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] });
expect(message.success).toHaveBeenCalledWith('供应商更新成功');
expect(mockMessage.success).toHaveBeenCalledWith('供应商更新成功');
});
it('calls message.error on failure', async () => {
@@ -222,7 +227,7 @@ describe('useUpdateProvider', () => {
result.current.mutate({ id: 'provider-1', input: { name: 'Updated' } });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
expect(mockMessage.error).toHaveBeenCalled();
});
});
@@ -248,7 +253,7 @@ describe('useDeleteProvider', () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['providers'] });
expect(message.success).toHaveBeenCalledWith('供应商删除成功');
expect(mockMessage.success).toHaveBeenCalledWith('供应商删除成功');
});
it('calls message.error on failure', async () => {
@@ -265,6 +270,6 @@ describe('useDeleteProvider', () => {
result.current.mutate('provider-1');
await waitFor(() => expect(result.current.isError).toBe(true));
expect(message.error).toHaveBeenCalled();
expect(mockMessage.error).toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Layout, Menu } from 'antd';
import { Layout, Menu, theme } from 'antd';
import { CloudServerOutlined, BarChartOutlined, SettingOutlined } from '@ant-design/icons';
import { Outlet, useLocation, useNavigate } from 'react-router';
import { useTheme } from '@/contexts/ThemeContext';
@@ -17,6 +17,7 @@ export function AppLayout() {
const { effectiveThemeId } = useTheme();
const isDark = effectiveThemeId === 'dark';
const [collapsed, setCollapsed] = useState(false);
const { token } = theme.useToken();
const getPageTitle = () => {
if (location.pathname === '/providers') return '供应商管理';
@@ -49,7 +50,7 @@ export function AppLayout() {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: isDark ? '#fff' : 'rgba(0, 0, 0, 0.88)',
color: token.colorText,
fontSize: collapsed ? '1rem' : '1.25rem',
fontWeight: 600,
transition: 'all 0.2s',
@@ -72,10 +73,10 @@ export function AppLayout() {
<Layout.Header
style={{
padding: '0 2rem',
background: effectiveThemeId === 'dark' ? '#141414' : '#fff',
background: token.colorBgContainer,
display: 'flex',
alignItems: 'center',
borderBottom: effectiveThemeId === 'dark' ? '1px solid #303030' : '1px solid #f0f0f0',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>{getPageTitle()}</h1>

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { message } from 'antd';
import { App } from 'antd';
import type { CreateModelInput, UpdateModelInput } from '@/types';
import * as api from '@/api/models';
@@ -17,6 +17,7 @@ export function useModels(providerId?: string) {
export function useCreateModel() {
const queryClient = useQueryClient();
const { message } = App.useApp();
return useMutation({
mutationFn: (input: CreateModelInput) => api.createModel(input),
@@ -32,6 +33,7 @@ export function useCreateModel() {
export function useUpdateModel() {
const queryClient = useQueryClient();
const { message } = App.useApp();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateModelInput }) =>
@@ -48,6 +50,7 @@ export function useUpdateModel() {
export function useDeleteModel() {
const queryClient = useQueryClient();
const { message } = App.useApp();
return useMutation({
mutationFn: (id: string) => api.deleteModel(id),

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { message } from 'antd';
import { App } from 'antd';
import type { CreateProviderInput, UpdateProviderInput } from '@/types';
import * as api from '@/api/providers';
@@ -16,6 +16,7 @@ export function useProviders() {
export function useCreateProvider() {
const queryClient = useQueryClient();
const { message } = App.useApp();
return useMutation({
mutationFn: (input: CreateProviderInput) => api.createProvider(input),
@@ -31,6 +32,7 @@ export function useCreateProvider() {
export function useUpdateProvider() {
const queryClient = useQueryClient();
const { message } = App.useApp();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateProviderInput }) =>
@@ -47,6 +49,7 @@ export function useUpdateProvider() {
export function useDeleteProvider() {
const queryClient = useQueryClient();
const { message } = App.useApp();
return useMutation({
mutationFn: (id: string) => api.deleteProvider(id),

View File

@@ -10,7 +10,7 @@ export function NotFound() {
title="404"
subTitle="抱歉,您访问的页面不存在。"
extra={
<Button type="primary" onClick={() => navigate('/providers')}>
<Button color="primary" variant="solid" onClick={() => navigate('/providers')}>
</Button>
}

View File

@@ -68,13 +68,9 @@ export function ModelForm({
name="providerId"
rules={[{ required: true, message: '请选择供应商' }]}
>
<Select>
{providers.map((p) => (
<Select.Option key={p.id} value={p.id}>
{p.name}
</Select.Option>
))}
</Select>
<Select
options={providers.map((p) => ({ label: p.name, value: p.id }))}
/>
</Form.Item>
<Form.Item

View File

@@ -35,7 +35,7 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
render: (_, record) => (
<Space>
{onEdit && (
<Button type="link" size="small" onClick={() => onEdit(record)}>
<Button variant="link" size="small" onClick={() => onEdit(record)}>
</Button>
)}
@@ -45,7 +45,7 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
okText="确定"
cancelText="取消"
>
<Button type="link" danger size="small">
<Button variant="link" color="danger" size="small">
</Button>
</Popconfirm>
@@ -59,7 +59,7 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontWeight: 500 }}> ({models.length})</span>
{onAdd && (
<Button type="link" size="small" onClick={onAdd}>
<Button variant="link" size="small" onClick={onAdd}>
</Button>
)}

View File

@@ -68,7 +68,7 @@ export function ProviderTable({
width: 160,
render: (_, record) => (
<Space>
<Button type="link" size="small" onClick={() => onEdit(record)}>
<Button variant="link" size="small" onClick={() => onEdit(record)}>
</Button>
<Popconfirm
@@ -77,7 +77,7 @@ export function ProviderTable({
okText="确定"
cancelText="取消"
>
<Button type="link" danger size="small">
<Button variant="link" color="danger" size="small">
</Button>
</Popconfirm>
@@ -90,7 +90,7 @@ export function ProviderTable({
<Card
title="供应商列表"
extra={
<Button type="primary" onClick={onAdd}>
<Button color="primary" variant="solid" onClick={onAdd}>
</Button>
}

View File

@@ -8,9 +8,9 @@ export function SettingsPage() {
const { themeId, followSystem, setThemeId, setFollowSystem } = useTheme();
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Space vertical size="large" style={{ width: '100%' }}>
<Card title="主题">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space vertical size="middle" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Text strong></Text>

View File

@@ -1,10 +1,10 @@
import { theme } from 'antd';
import type { ConfigProviderProps } from 'antd';
const useDarkTheme = (): ConfigProviderProps => ({
const darkConfig: ConfigProviderProps = {
theme: {
algorithm: theme.darkAlgorithm,
},
});
};
export default useDarkTheme;
export default darkConfig;

View File

@@ -1,10 +1,10 @@
import { theme } from 'antd';
import type { ConfigProviderProps } from 'antd';
const useDefaultTheme = (): ConfigProviderProps => ({
const defaultConfig: ConfigProviderProps = {
theme: {
algorithm: theme.defaultAlgorithm,
},
});
};
export default useDefaultTheme;
export default defaultConfig;

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import type { ConfigProviderProps } from 'antd';
import useDefaultTheme from './default';
import useDarkTheme from './dark';
import defaultConfig from './default';
import darkConfig from './dark';
import useMuiTheme from './mui';
import useShadcnTheme from './shadcn';
import useBootstrapTheme from './bootstrap';
@@ -25,23 +26,22 @@ export const themeOptions: ThemeOption[] = [
const themeIdSet = new Set<ThemeId>(themeOptions.map((opt) => opt.id));
export function useThemeConfig(themeId: ThemeId): ConfigProviderProps {
const defaultConfig = useDefaultTheme();
const darkConfig = useDarkTheme();
const muiConfig = useMuiTheme();
const shadcnConfig = useShadcnTheme();
const bootstrapConfig = useBootstrapTheme();
const glassConfig = useGlassTheme();
const configs: Record<ThemeId, ConfigProviderProps> = {
default: defaultConfig,
dark: darkConfig,
mui: muiConfig,
shadcn: shadcnConfig,
bootstrap: bootstrapConfig,
glass: glassConfig,
};
return configs[themeId] ?? configs.default;
return useMemo(() => {
const configs: Record<ThemeId, ConfigProviderProps> = {
default: defaultConfig,
dark: darkConfig,
mui: muiConfig,
shadcn: shadcnConfig,
bootstrap: bootstrapConfig,
glass: glassConfig,
};
return configs[themeId] ?? configs.default;
}, [themeId, muiConfig, shadcnConfig, bootstrapConfig, glassConfig]);
}
export function isValidThemeId(value: string): value is ThemeId {

View File

@@ -8,7 +8,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
### Requirement: 提供供应商管理页面
前端 SHALL 使用 Ant Design 组件提供供应商管理页面。
前端 SHALL 使用 Ant Design 组件提供供应商管理页面,所有组件 SHALL 遵循 antd v6 API 规范
#### Scenario: 显示供应商列表
@@ -41,7 +41,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 用户提交包含有效数据的表单
- **THEN** 前端 SHALL 通过 useMutation 调用创建 API
- **THEN** 成功后 SHALL 关闭 Modal 并刷新供应商列表
- **THEN** 失败 SHALL 使用 message.error() 提示
- **THEN** 失败 SHALL 使用 `App.useApp()` 提供的 `message` 实例显示错误提示
#### Scenario: 编辑现有供应商
@@ -61,7 +61,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
### Requirement: 提供模型管理界面
前端 SHALL 在供应商页面展开行中提供模型管理。
前端 SHALL 在供应商页面展开行中提供模型管理,所有组件 SHALL 遵循 antd v6 API 规范
#### Scenario: 显示供应商的模型
@@ -80,6 +80,7 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 用户在展开行中点击"添加模型"
- **THEN** 前端 SHALL 显示 Ant Design Modal + Form
- **THEN** provider_id SHALL 自动关联当前供应商
- **THEN** 供应商选择 SHALL 使用 `options` 属性而非 `Select.Option` 子组件
- **WHEN** 用户提交表单
- **THEN** 前端 SHALL 通过 useMutation 调用创建 API
- **THEN** 成功后 SHALL 刷新模型列表
@@ -141,12 +142,12 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
### Requirement: 优雅处理 API 错误
前端 SHALL 处理 API 错误并显示用户友好的消息。
前端 SHALL 处理 API 错误并显示用户友好的消息message 组件 SHALL 通过 `App.useApp()` 获取
#### Scenario: API 请求失败
- **WHEN** API 请求失败网络错误、4xx、5xx
- **THEN** 前端 SHALL 使用 Ant Design 的 message.error() 显示全局错误提示
- **THEN** 前端 SHALL 使用 `App.useApp()` 提供的 `message` 实例显示全局错误提示
- **THEN** 错误消息 SHALL 具有描述性
#### Scenario: 验证错误
@@ -187,21 +188,24 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
### Requirement: 使用无组件库的最小 UI
前端 SHALL 使用 Ant Design 6 作为 UI 组件库。
前端 SHALL 使用 Ant Design 6 作为 UI 组件库,所有组件 SHALL 使用 antd v6 推荐的 API
#### Scenario: Ant Design 组件使用
- **WHEN** 实现前端
- **THEN** SHALL 使用 Ant Design 6 组件Table、Form、Modal、Menu、Tag、Popconfirm、DatePicker、Button、Select 等)
- **THEN** 它 SHALL 使用 @ant-design/icons 提供图标
- **THEN** Button 组件 SHALL 使用 `variant``color` 属性替代已废弃的 `type`link/text/dashed`danger` 属性
- **THEN** Button `type="link"` SHALL 替换为 `variant="link"`
- **THEN** Button `danger` 属性 SHALL 替换为 `color="danger"`
- **THEN** Space 组件 SHALL 使用 `vertical` 布尔属性替代已废弃的 `direction` 属性
- **THEN** Select 组件 SHALL 使用 `options` 属性替代 `Select.Option` 子组件
- **THEN** 图标 SHALL 优先使用图标库中已有的图标
#### Scenario: Ant Design 主题支持
#### Scenario: App 组件包裹
- **WHEN** 配置 Ant Design 主题
- **THEN** 前端 SHALL 支持亮色和暗色模式切换
- **THEN** 前端 SHALL 使用 Ant Design v6 的 theme.darkAlgorithm 和 theme.defaultAlgorithm
- **THEN** 前端 SHALL 通过 ConfigProvider 动态切换主题
- **WHEN** 应用初始化
- **THEN** `App.tsx` SHALL 在 `ConfigProvider` 内部使用 Ant Design `<App>` 组件包裹应用
- **THEN** hooks 中 SHALL 使用 `App.useApp()` 获取 `message` 实例
- **THEN** `message` 实例 SHALL NOT 通过静态 `import { message } from 'antd'` 获取
### Requirement: 样式体系
@@ -351,10 +355,10 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 用户执行创建、更新或删除操作
- **THEN** 前端 SHALL 使用 TanStack Query 的 useMutation hook
- **THEN** 操作成功后 SHALL 自动失效相关查询缓存
- **THEN** 操作失败 SHALL 使用 Ant Design message.error() 显示错误提示
- **THEN** 操作失败 SHALL 使用 `App.useApp()` 提供的 `message` 实例显示错误提示
#### Scenario: 错误提示
- **WHEN** API 请求失败网络错误、4xx、5xx
- **THEN** 前端 SHALL 使用 Ant Design 的 message.error() 显示全局错误提示
- **THEN** 前端 SHALL 使用 `App.useApp()` 提供的 `message` 实例显示全局错误提示
- **THEN** 错误消息 SHALL 具有描述性

View File

@@ -104,13 +104,14 @@
### Requirement: 提供设置页面
前端 SHALL 提供设置页面,使用卡片布局组织设置内容。
前端 SHALL 提供设置页面,使用卡片布局组织设置内容,所有布局组件 SHALL 遵循 antd v6 API 规范
#### Scenario: 主题卡片
- **WHEN** 用户访问设置页面
- **THEN** 前端 SHALL 显示主题设置卡片
- **THEN** 卡片内 SHALL 包含主题下拉栏和跟随系统开关
- **THEN** 卡片内布局 SHALL 使用 `Space``vertical` 布尔属性替代已废弃的 `direction` 属性
#### Scenario: 主题下拉栏
@@ -136,14 +137,15 @@
### Requirement: 侧边栏跟随应用主题
侧边栏 SHALL 根据 `effectiveThemeId` 动态切换亮色或暗色外观。
侧边栏 SHALL 根据 `effectiveThemeId` 动态切换亮色或暗色外观,使用 antd token 获取颜色值
#### Scenario: 亮色主题下的侧边栏
- **WHEN** `effectiveThemeId` 不为 `'dark'`
- **THEN** 侧边栏 Sider 背景 SHALL 为浅色(`#fff`
- **THEN** Logo 文字颜色 SHALL 为深色
- **THEN** 侧边栏 Sider 背景 SHALL 使用 antd token 的 `colorBgContainer` 而非硬编码颜色值
- **THEN** Logo 文字颜色 SHALL 使用 antd token 的 `colorText` 而非硬编码颜色值
- **THEN** Menu 组件 SHALL 使用 `theme="light"`
- **THEN** Header 背景和边框 SHALL 使用 antd token 获取颜色值
#### Scenario: 暗色主题下的侧边栏
@@ -167,6 +169,7 @@
- **WHEN** 应用初始化
- **THEN** 主题注册表 SHALL 包含 6 套主题的完整配置
- **THEN** 每套主题 SHALL 通过 `useConfig()` hook 返回 `ConfigProviderProps`
- **THEN** `useThemeConfig` SHALL 优化加载策略,避免不必要的主题初始化开销
#### Scenario: 主题文件组织