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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
18
frontend/src/components/ThemeToggle/index.tsx
Normal file
18
frontend/src/components/ThemeToggle/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
frontend/src/contexts/ThemeContext.tsx
Normal file
49
frontend/src/contexts/ThemeContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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' }]}>
|
||||
|
||||
@@ -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' }]}>
|
||||
|
||||
Reference in New Issue
Block a user