diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8a68ecf..5c818f3 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,6 +1,4 @@
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 ProjectWizardNew from './pages/ProjectWizardNew';
import Inspiration from './pages/Inspiration';
@@ -33,7 +31,7 @@ import './App.css';
function App() {
return (
-
+ <>
{/* 🧧 春节喜庆装饰 */}
-
+ >
);
}
diff --git a/frontend/src/components/ThemeSwitch.tsx b/frontend/src/components/ThemeSwitch.tsx
new file mode 100644
index 0000000..e506ba3
--- /dev/null
+++ b/frontend/src/components/ThemeSwitch.tsx
@@ -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: (
+
+
+
+ ),
+ },
+ {
+ value: 'dark',
+ label: (
+
+
+
+ ),
+ },
+ {
+ value: 'system',
+ label: (
+
+
+
+ ),
+ },
+];
+
+export default function ThemeSwitch({ size = 'middle', block = false }: ThemeSwitchProps) {
+ const { mode, setMode } = useThemeMode();
+
+ return (
+ setMode(value as ThemeMode)}
+ options={options}
+ block={block}
+ />
+ );
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 4bee79d..213b42b 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,42 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
-import { ConfigProvider } from 'antd'
-import zhCN from 'antd/locale/zh_CN'
import 'antd/dist/reset.css'
import './index.css'
import App from './App.tsx'
+import { ThemeProvider } from './theme/ThemeProvider'
createRoot(document.getElementById('root')!).render(
-
+
-
+
,
)
diff --git a/frontend/src/theme/ThemeProvider.tsx b/frontend/src/theme/ThemeProvider.tsx
new file mode 100644
index 0000000..f75a719
--- /dev/null
+++ b/frontend/src/theme/ThemeProvider.tsx
@@ -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(() => getStoredThemeMode());
+ const [systemMode, setSystemMode] = useState(() => getSystemResolvedMode());
+ const transitionCleanupRef = useRef(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 };
+ };
+
+ 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 (
+
+
+ {children}
+
+
+ );
+};
diff --git a/frontend/src/theme/themeConfig.ts b/frontend/src/theme/themeConfig.ts
new file mode 100644
index 0000000..09aa7ec
--- /dev/null
+++ b/frontend/src/theme/themeConfig.ts
@@ -0,0 +1,65 @@
+import type { ThemeConfig } from 'antd';
+import { theme } from 'antd';
+import type { ThemeMode } from './themeStorage';
+
+export type ResolvedThemeMode = Exclude;
+
+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;
+};
diff --git a/frontend/src/theme/themeContext.ts b/frontend/src/theme/themeContext.ts
new file mode 100644
index 0000000..06ee91c
--- /dev/null
+++ b/frontend/src/theme/themeContext.ts
@@ -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(undefined);
diff --git a/frontend/src/theme/themeStorage.ts b/frontend/src/theme/themeStorage.ts
new file mode 100644
index 0000000..b109b74
--- /dev/null
+++ b/frontend/src/theme/themeStorage.ts
@@ -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;
diff --git a/frontend/src/theme/useThemeMode.ts b/frontend/src/theme/useThemeMode.ts
new file mode 100644
index 0000000..db705cc
--- /dev/null
+++ b/frontend/src/theme/useThemeMode.ts
@@ -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;
+};