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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -322,3 +322,5 @@ Temporary Items
|
||||
.opencode
|
||||
openspec/changes/archive
|
||||
temp
|
||||
.agents
|
||||
skills-lock.json
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 具有描述性
|
||||
|
||||
@@ -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: 主题文件组织
|
||||
|
||||
|
||||
Reference in New Issue
Block a user