From f13dd26404d9f02e67ad3eb18e68a74da7d7a672 Mon Sep 17 00:00:00 2001 From: xiamuceer-j Date: Fri, 6 Mar 2026 14:12:18 +0800 Subject: [PATCH] =?UTF-8?q?feature:=20=E6=96=B0=E5=A2=9E=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E7=B3=BB=E7=BB=9F=EF=BC=88=E6=B5=85=E8=89=B2?= =?UTF-8?q?/=E6=B7=B1=E8=89=B2/=E8=B7=9F=E9=9A=8F=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=EF=BC=89=E3=80=81=E4=B8=BB=E9=A2=98=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E4=B8=8E=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 6 +- frontend/src/components/ThemeSwitch.tsx | 51 ++++++++ frontend/src/main.tsx | 34 +----- frontend/src/theme/ThemeProvider.tsx | 155 ++++++++++++++++++++++++ frontend/src/theme/themeConfig.ts | 65 ++++++++++ frontend/src/theme/themeContext.ts | 11 ++ frontend/src/theme/themeStorage.ts | 30 +++++ frontend/src/theme/useThemeMode.ts | 11 ++ 8 files changed, 328 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/ThemeSwitch.tsx create mode 100644 frontend/src/theme/ThemeProvider.tsx create mode 100644 frontend/src/theme/themeConfig.ts create mode 100644 frontend/src/theme/themeContext.ts create mode 100644 frontend/src/theme/themeStorage.ts create mode 100644 frontend/src/theme/useThemeMode.ts 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; +};