1
0

feat: 升级 Ant Design 6 并实现主题切换功能

- 升级 antd 从 5.24.9 到 6.3.5,@ant-design/icons 从 5.6.1 到 6.1.1
- 新增 ThemeContext 和 ThemeToggle 组件,支持明暗主题切换
- 移除自定义 SCSS 样式,采用 Ant Design 主题系统
- 测试环境从 jsdom 切换到 happy-dom,提升测试性能
- 更新 AppLayout、ModelForm、ProviderForm 以适配新主题系统
This commit is contained in:
2026-04-16 13:31:30 +08:00
parent 1580b5b838
commit 47ecbadc7c
15 changed files with 374 additions and 176 deletions

View File

@@ -1,6 +1,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router';
import { ConfigProvider, theme } from 'antd';
import { AppRoutes } from '@/routes';
import { ThemeProvider, useTheme } from '@/contexts/ThemeContext';
const queryClient = new QueryClient({
defaultOptions: {
@@ -12,12 +14,28 @@ const queryClient = new QueryClient({
},
});
function App() {
function ThemedApp() {
const { mode } = useTheme();
return (
<QueryClientProvider client={queryClient}>
<ConfigProvider
theme={{
algorithm: mode === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</ConfigProvider>
);
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<ThemedApp />
</ThemeProvider>
</QueryClientProvider>
);
}

View File

@@ -1,5 +1,10 @@
import '@testing-library/jest-dom/vitest';
// Ensure jsdom environment is properly initialized
if (typeof window === 'undefined' || typeof document === 'undefined') {
throw new Error('jsdom environment not initialized. Check vitest config.');
}
// Polyfill window.matchMedia for jsdom (required by antd)
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -24,3 +29,11 @@ window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => {
return {} as CSSStyleDeclaration;
}
};
// Polyfill ResizeObserver for antd
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};

View File

@@ -1,30 +0,0 @@
.layout {
min-height: 100vh;
}
.header {
display: flex;
align-items: center;
padding: 0 2rem;
}
.logo {
color: #fff;
font-size: 1.25rem;
font-weight: 600;
margin-right: 2rem;
white-space: nowrap;
}
.menu {
flex: 1;
min-width: 0;
}
.content {
padding: 2rem;
max-width: 1400px;
width: 100%;
margin: 0 auto;
box-sizing: border-box;
}

View File

@@ -1,10 +1,7 @@
import { Layout, Menu } from 'antd';
import {
CloudServerOutlined,
BarChartOutlined,
} from '@ant-design/icons';
import { CloudServerOutlined, BarChartOutlined } from '@ant-design/icons';
import { Outlet, useLocation, useNavigate } from 'react-router';
import styles from './AppLayout.module.scss';
import { ThemeToggle } from '@/components/ThemeToggle';
const menuItems = [
{ key: '/providers', label: '供应商管理', icon: <CloudServerOutlined /> },
@@ -16,19 +13,44 @@ export function AppLayout() {
const navigate = useNavigate();
return (
<Layout className={styles.layout}>
<Layout.Header className={styles.header}>
<div className={styles.logo}>AI Gateway</div>
<Layout style={{ minHeight: '100vh' }}>
<Layout.Header
style={{
display: 'flex',
alignItems: 'center',
padding: '0 2rem',
}}
>
<div
style={{
color: '#fff',
fontSize: '1.25rem',
fontWeight: 600,
marginRight: '2rem',
whiteSpace: 'nowrap',
}}
>
AI Gateway
</div>
<Menu
theme="dark"
mode="horizontal"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => navigate(key)}
className={styles.menu}
style={{ flex: 1, minWidth: 0 }}
/>
<ThemeToggle />
</Layout.Header>
<Layout.Content className={styles.content}>
<Layout.Content
style={{
padding: '2rem',
maxWidth: '1400px',
width: '100%',
margin: '0 auto',
boxSizing: 'border-box',
}}
>
<Outlet />
</Layout.Content>
</Layout>

View File

@@ -0,0 +1,18 @@
import { Button, Tooltip } from 'antd';
import { SunOutlined, MoonOutlined } from '@ant-design/icons';
import { useTheme } from '@/contexts/ThemeContext';
export function ThemeToggle() {
const { mode, toggleTheme } = useTheme();
return (
<Tooltip title={mode === 'light' ? '切换到暗色模式' : '切换到亮色模式'}>
<Button
type="text"
icon={mode === 'light' ? <MoonOutlined /> : <SunOutlined />}
onClick={toggleTheme}
style={{ color: 'inherit' }}
/>
</Tooltip>
);
}

View File

@@ -0,0 +1,49 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
type ThemeMode = 'light' | 'dark';
interface ThemeContextValue {
mode: ThemeMode;
toggleTheme: () => void;
setTheme: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
// 从 localStorage 恢复主题
const [mode, setMode] = useState<ThemeMode>(() => {
if (typeof window === 'undefined') return 'light';
const saved = localStorage.getItem('theme-mode');
return (saved as ThemeMode) || 'light';
});
// 持久化主题
useEffect(() => {
localStorage.setItem('theme-mode', mode);
// 更新 document class方便全局样式判断
document.documentElement.classList.toggle('dark', mode === 'dark');
}, [mode]);
const toggleTheme = () => {
setMode(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ mode, toggleTheme, setTheme: setMode }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}

View File

@@ -1,20 +0,0 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -1,6 +1,5 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.scss'
import App from './App'
createRoot(document.getElementById('root')!).render(

View File

@@ -56,7 +56,7 @@ export function ModelForm({
confirmLoading={loading}
okText="保存"
cancelText="取消"
destroyOnClose
destroyOnHidden
>
<Form form={form} layout="vertical" onFinish={onSave} initialValues={{ enabled: true }}>
<Form.Item label="ID" name="id" rules={[{ required: true, message: '请输入模型 ID' }]}>

View File

@@ -53,7 +53,7 @@ export function ProviderForm({
confirmLoading={loading}
okText="保存"
cancelText="取消"
destroyOnClose
destroyOnHidden
>
<Form form={form} layout="vertical" onFinish={onSave} initialValues={{ enabled: true }}>
<Form.Item label="ID" name="id" rules={[{ required: true, message: '请输入供应商 ID' }]}>