feature: 新增全局主题系统(浅色/深色/跟随系统)、主题持久化与主题切换组件
This commit is contained in:
@@ -1,6 +1,4 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { ConfigProvider } from 'antd';
|
|
||||||
import zhCN from 'antd/locale/zh_CN';
|
|
||||||
import ProjectList from './pages/ProjectList';
|
import ProjectList from './pages/ProjectList';
|
||||||
import ProjectWizardNew from './pages/ProjectWizardNew';
|
import ProjectWizardNew from './pages/ProjectWizardNew';
|
||||||
import Inspiration from './pages/Inspiration';
|
import Inspiration from './pages/Inspiration';
|
||||||
@@ -33,7 +31,7 @@ import './App.css';
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ConfigProvider locale={zhCN}>
|
<>
|
||||||
{/* 🧧 春节喜庆装饰 */}
|
{/* 🧧 春节喜庆装饰 */}
|
||||||
<SpringFestival />
|
<SpringFestival />
|
||||||
<BrowserRouter
|
<BrowserRouter
|
||||||
@@ -74,7 +72,7 @@ function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ConfigProvider>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Segmented, Tooltip } from 'antd';
|
||||||
|
import { BulbOutlined, MoonOutlined, DesktopOutlined } from '@ant-design/icons';
|
||||||
|
import { useThemeMode } from '../theme/useThemeMode';
|
||||||
|
import type { ThemeMode } from '../theme/themeStorage';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface ThemeSwitchProps {
|
||||||
|
size?: 'small' | 'middle' | 'large';
|
||||||
|
block?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: Array<{ value: ThemeMode; label: ReactNode }> = [
|
||||||
|
{
|
||||||
|
value: 'light',
|
||||||
|
label: (
|
||||||
|
<Tooltip title="浅色模式">
|
||||||
|
<BulbOutlined />
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dark',
|
||||||
|
label: (
|
||||||
|
<Tooltip title="深色模式">
|
||||||
|
<MoonOutlined />
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'system',
|
||||||
|
label: (
|
||||||
|
<Tooltip title="跟随系统">
|
||||||
|
<DesktopOutlined />
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ThemeSwitch({ size = 'middle', block = false }: ThemeSwitchProps) {
|
||||||
|
const { mode, setMode } = useThemeMode();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Segmented
|
||||||
|
size={size}
|
||||||
|
value={mode}
|
||||||
|
onChange={(value) => setMode(value as ThemeMode)}
|
||||||
|
options={options}
|
||||||
|
block={block}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-31
@@ -1,42 +1,14 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { ConfigProvider } from 'antd'
|
|
||||||
import zhCN from 'antd/locale/zh_CN'
|
|
||||||
import 'antd/dist/reset.css'
|
import 'antd/dist/reset.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { ThemeProvider } from './theme/ThemeProvider'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ConfigProvider
|
<ThemeProvider>
|
||||||
locale={zhCN}
|
|
||||||
theme={{
|
|
||||||
token: {
|
|
||||||
colorPrimary: '#4D8088', // 天青
|
|
||||||
colorBgBase: '#F8F6F1', // 米汤色
|
|
||||||
colorTextBase: '#2B2B2B', // 墨色
|
|
||||||
borderRadius: 6,
|
|
||||||
wireframe: false,
|
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif",
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
Layout: {
|
|
||||||
bodyBg: '#F8F6F1',
|
|
||||||
headerBg: '#FFFFFF',
|
|
||||||
siderBg: '#FFFFFF',
|
|
||||||
},
|
|
||||||
Card: {
|
|
||||||
colorBgContainer: '#FFFFFF',
|
|
||||||
boxShadowTertiary: '0 4px 12px rgba(0, 0, 0, 0.05)', // 更柔和的阴影
|
|
||||||
},
|
|
||||||
Button: {
|
|
||||||
borderRadius: 6,
|
|
||||||
controlHeight: 36,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<App />
|
<App />
|
||||||
</ConfigProvider>
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { ConfigProvider } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
import { getThemeConfig, type ResolvedThemeMode } from './themeConfig';
|
||||||
|
import { ThemeModeContext } from './themeContext';
|
||||||
|
import { getStoredThemeMode, setStoredThemeMode, type ThemeMode } from './themeStorage';
|
||||||
|
|
||||||
|
const getSystemResolvedMode = (): ResolvedThemeMode => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResolvedMode = (themeMode: ThemeMode, currentSystemMode: ResolvedThemeMode): ResolvedThemeMode => {
|
||||||
|
return themeMode === 'system' ? currentSystemMode : themeMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hexToRgba = (hexColor: string, alpha: number): string => {
|
||||||
|
const hex = hexColor.replace('#', '').trim();
|
||||||
|
|
||||||
|
if (/^[\da-fA-F]{3}$/.test(hex)) {
|
||||||
|
const [r, g, b] = hex.split('');
|
||||||
|
return `rgba(${parseInt(`${r}${r}`, 16)}, ${parseInt(`${g}${g}`, 16)}, ${parseInt(`${b}${b}`, 16)}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[\da-fA-F]{6}$/.test(hex)) {
|
||||||
|
return `rgba(${parseInt(hex.slice(0, 2), 16)}, ${parseInt(hex.slice(2, 4), 16)}, ${parseInt(hex.slice(4, 6), 16)}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `rgba(136, 77, 92, ${alpha})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ThemeProvider = ({ children }: PropsWithChildren) => {
|
||||||
|
const [mode, setModeState] = useState<ThemeMode>(() => getStoredThemeMode());
|
||||||
|
const [systemMode, setSystemMode] = useState<ResolvedThemeMode>(() => getSystemResolvedMode());
|
||||||
|
const transitionCleanupRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handleChange = (event: MediaQueryListEvent) => {
|
||||||
|
setSystemMode(event.matches ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
setSystemMode(mediaQuery.matches ? 'dark' : 'light');
|
||||||
|
|
||||||
|
if (typeof mediaQuery.addEventListener === 'function') {
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaQuery.addListener(handleChange);
|
||||||
|
return () => mediaQuery.removeListener(handleChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolvedMode: ResolvedThemeMode = getResolvedMode(mode, systemMode);
|
||||||
|
const themeConfig = useMemo(() => getThemeConfig(resolvedMode), [resolvedMode]);
|
||||||
|
|
||||||
|
const setMode = (nextMode: ThemeMode) => {
|
||||||
|
if (nextMode === mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextResolvedMode = getResolvedMode(nextMode, systemMode);
|
||||||
|
const applyMode = () => {
|
||||||
|
setModeState(nextMode);
|
||||||
|
setStoredThemeMode(nextMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
applyMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
const docWithViewTransition = document as Document & {
|
||||||
|
startViewTransition?: (callback: () => void) => { finished: Promise<void> };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!docWithViewTransition.startViewTransition || nextResolvedMode === resolvedMode) {
|
||||||
|
applyMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.setAttribute('data-theme-transition', nextResolvedMode === 'dark' ? 'to-dark' : 'to-light');
|
||||||
|
|
||||||
|
try {
|
||||||
|
docWithViewTransition.startViewTransition(() => {
|
||||||
|
applyMode();
|
||||||
|
}).finished.finally(() => {
|
||||||
|
if (transitionCleanupRef.current !== null) {
|
||||||
|
window.clearTimeout(transitionCleanupRef.current);
|
||||||
|
}
|
||||||
|
transitionCleanupRef.current = window.setTimeout(() => {
|
||||||
|
root.removeAttribute('data-theme-transition');
|
||||||
|
transitionCleanupRef.current = null;
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
root.removeAttribute('data-theme-transition');
|
||||||
|
applyMode();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.setAttribute('data-theme-mode', mode);
|
||||||
|
root.setAttribute('data-theme-resolved', resolvedMode);
|
||||||
|
root.style.colorScheme = resolvedMode;
|
||||||
|
|
||||||
|
const tooltipBg = themeConfig.token?.colorPrimary ?? '#884d5c';
|
||||||
|
root.style.setProperty('--app-tooltip-bg', tooltipBg);
|
||||||
|
root.style.setProperty('--app-tooltip-shadow', hexToRgba(tooltipBg, 0.3));
|
||||||
|
}, [mode, resolvedMode, themeConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (transitionCleanupRef.current !== null) {
|
||||||
|
window.clearTimeout(transitionCleanupRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
mode,
|
||||||
|
resolvedMode,
|
||||||
|
setMode,
|
||||||
|
}),
|
||||||
|
[mode, resolvedMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeModeContext.Provider value={contextValue}>
|
||||||
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={{
|
||||||
|
...themeConfig,
|
||||||
|
cssVar: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ConfigProvider>
|
||||||
|
</ThemeModeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { ThemeConfig } from 'antd';
|
||||||
|
import { theme } from 'antd';
|
||||||
|
import type { ThemeMode } from './themeStorage';
|
||||||
|
|
||||||
|
export type ResolvedThemeMode = Exclude<ThemeMode, 'system'>;
|
||||||
|
|
||||||
|
const sharedToken: ThemeConfig['token'] = {
|
||||||
|
colorPrimary: '#4D8088',
|
||||||
|
borderRadius: 8,
|
||||||
|
wireframe: false,
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sharedComponents: ThemeConfig['components'] = {
|
||||||
|
Button: {
|
||||||
|
borderRadius: 8,
|
||||||
|
controlHeight: 36,
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
},
|
||||||
|
Tooltip: {
|
||||||
|
colorBgSpotlight: sharedToken.colorPrimary,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const lightThemeConfig: ThemeConfig = {
|
||||||
|
algorithm: theme.defaultAlgorithm,
|
||||||
|
token: {
|
||||||
|
...sharedToken,
|
||||||
|
colorBgBase: '#F8F6F1',
|
||||||
|
colorTextBase: '#2B2B2B',
|
||||||
|
colorBgLayout: '#F8F6F1',
|
||||||
|
colorBgContainer: '#FFFFFF',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
...sharedComponents,
|
||||||
|
Layout: {
|
||||||
|
bodyBg: '#F8F6F1',
|
||||||
|
headerBg: '#FFFFFF',
|
||||||
|
siderBg: '#FFFFFF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const darkThemeConfig: ThemeConfig = {
|
||||||
|
algorithm: theme.darkAlgorithm,
|
||||||
|
token: {
|
||||||
|
...sharedToken,
|
||||||
|
colorBgBase: '#141414',
|
||||||
|
colorTextBase: '#f5f5f5',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
...sharedComponents,
|
||||||
|
Layout: {
|
||||||
|
bodyBg: '#0f1115',
|
||||||
|
headerBg: '#141414',
|
||||||
|
siderBg: '#141414',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getThemeConfig = (mode: ResolvedThemeMode): ThemeConfig => {
|
||||||
|
return mode === 'dark' ? darkThemeConfig : lightThemeConfig;
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
import type { ResolvedThemeMode } from './themeConfig';
|
||||||
|
import type { ThemeMode } from './themeStorage';
|
||||||
|
|
||||||
|
export interface ThemeContextValue {
|
||||||
|
mode: ThemeMode;
|
||||||
|
resolvedMode: ResolvedThemeMode;
|
||||||
|
setMode: (mode: ThemeMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeModeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
const THEME_MODE_STORAGE_KEY = 'mumu_theme_mode';
|
||||||
|
|
||||||
|
const isThemeMode = (value: string | null): value is ThemeMode => {
|
||||||
|
return value === 'light' || value === 'dark' || value === 'system';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStoredThemeMode = (): ThemeMode => {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(THEME_MODE_STORAGE_KEY);
|
||||||
|
if (isThemeMode(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('读取主题模式失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'system';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setStoredThemeMode = (mode: ThemeMode): void => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_MODE_STORAGE_KEY, mode);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('保存主题模式失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getThemeModeStorageKey = (): string => THEME_MODE_STORAGE_KEY;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { ThemeModeContext } from './themeContext';
|
||||||
|
import type { ThemeContextValue } from './themeContext';
|
||||||
|
|
||||||
|
export const useThemeMode = (): ThemeContextValue => {
|
||||||
|
const context = useContext(ThemeModeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useThemeMode 必须在 ThemeProvider 内使用');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user