Просмотр исходного кода

🌓 feat(ui): add auto theme mode, refactor ThemeToggle, optimize header theme handling

- Feature: Introduce 'auto' theme mode
  - Detect system preference via matchMedia('(prefers-color-scheme: dark)')
  - Add useActualTheme context to expose the effective theme ('light'|'dark')
  - Persist selected mode in localStorage ('theme-mode') with 'auto' as default
  - Apply/remove `dark` class on <html> and sync `theme-mode` on <body>
  - Broadcast effective theme to iframes

- UI: Redesign ThemeToggle with Dropdown items and custom highlight
  - Replace non-existent IconMonitor with IconRefresh
  - Use Dropdown.Menu + Dropdown.Item with built-in icon prop
  - Selected state uses custom background highlight; hover state preserved
  - Remove checkmark; selection relies on background styling
  - Current button icon reflects selected mode

- Performance: reduce re-renders and unnecessary effects
  - Memoize theme options and current button icon (useMemo)
  - Simplify handleThemeToggle to accept only explicit modes ('light'|'dark'|'auto')
  - Minimize useEffect dependencies; remove unrelated deps

- Header: streamline useHeaderBar
  - Use useActualTheme for iframe theme messaging
  - Remove unused statusDispatch
  - Remove isNewYear from theme effect dependencies

- Home: send effective theme (useActualTheme) to external content iframes

- i18n: add/enhance theme-related copy in locales (en/zh)

- Chore: minor code cleanup and consistency
  - Improve readability and maintainability
  - Lint clean; no functional regressions
t0ng7u 6 месяцев назад
Родитель
Сommit
61ae19ac82

+ 2 - 2
web/src/components/layout/HeaderBar/LanguageSelector.jsx

@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
 
 
 import React from 'react';
 import React from 'react';
 import { Button, Dropdown } from '@douyinfe/semi-ui';
 import { Button, Dropdown } from '@douyinfe/semi-ui';
-import { IconLanguage } from '@douyinfe/semi-icons';
+import { Languages } from 'lucide-react';
 import { CN, GB } from 'country-flag-icons/react/3x2';
 import { CN, GB } from 'country-flag-icons/react/3x2';
 
 
 const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
 const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
@@ -46,7 +46,7 @@ const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
       }
       }
     >
     >
       <Button
       <Button
-        icon={<IconLanguage className="text-lg" />}
+        icon={<Languages size={18} />}
         aria-label={t('切换语言')}
         aria-label={t('切换语言')}
         theme="borderless"
         theme="borderless"
         type="tertiary"
         type="tertiary"

+ 2 - 2
web/src/components/layout/HeaderBar/NotificationButton.jsx

@@ -19,11 +19,11 @@ For commercial licensing, please contact support@quantumnous.com
 
 
 import React from 'react';
 import React from 'react';
 import { Button, Badge } from '@douyinfe/semi-ui';
 import { Button, Badge } from '@douyinfe/semi-ui';
-import { IconBell } from '@douyinfe/semi-icons';
+import { Bell } from 'lucide-react';
 
 
 const NotificationButton = ({ unreadCount, onNoticeOpen, t }) => {
 const NotificationButton = ({ unreadCount, onNoticeOpen, t }) => {
   const buttonProps = {
   const buttonProps = {
-    icon: <IconBell className="text-lg" />,
+    icon: <Bell size={18} />,
     'aria-label': t('系统公告'),
     'aria-label': t('系统公告'),
     onClick: onNoticeOpen,
     onClick: onNoticeOpen,
     theme: "borderless",
     theme: "borderless",

+ 79 - 11
web/src/components/layout/HeaderBar/ThemeToggle.jsx

@@ -17,20 +17,88 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 For commercial licensing, please contact support@quantumnous.com
 */
 */
 
 
-import React from 'react';
-import { Button } from '@douyinfe/semi-ui';
-import { IconSun, IconMoon } from '@douyinfe/semi-icons';
+import React, { useMemo } from 'react';
+import { Button, Dropdown } from '@douyinfe/semi-ui';
+import { Sun, Moon, Monitor } from 'lucide-react';
+import { useActualTheme } from '../../../context/Theme';
 
 
 const ThemeToggle = ({ theme, onThemeToggle, t }) => {
 const ThemeToggle = ({ theme, onThemeToggle, t }) => {
+  const actualTheme = useActualTheme();
+
+  const themeOptions = useMemo(() => ([
+    {
+      key: 'light',
+      icon: <Sun size={18} />,
+      buttonIcon: <Sun size={18} />,
+      label: t('浅色模式'),
+      description: t('始终使用浅色主题')
+    },
+    {
+      key: 'dark',
+      icon: <Moon size={18} />,
+      buttonIcon: <Moon size={18} />,
+      label: t('深色模式'),
+      description: t('始终使用深色主题')
+    },
+    {
+      key: 'auto',
+      icon: <Monitor size={18} />,
+      buttonIcon: <Monitor size={18} />,
+      label: t('自动模式'),
+      description: t('跟随系统主题设置')
+    }
+  ]), [t]);
+
+  const getItemClassName = (isSelected) =>
+    isSelected
+      ? '!bg-semi-color-primary-light-default !font-semibold'
+      : 'hover:!bg-semi-color-fill-1';
+
+  const currentButtonIcon = useMemo(() => {
+    const currentOption = themeOptions.find(option => option.key === theme);
+    return currentOption?.buttonIcon || themeOptions[2].buttonIcon;
+  }, [theme, themeOptions]);
+
   return (
   return (
-    <Button
-      icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}
-      aria-label={t('切换主题')}
-      onClick={onThemeToggle}
-      theme="borderless"
-      type="tertiary"
-      className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
-    />
+    <Dropdown
+      position="bottomRight"
+      render={
+        <Dropdown.Menu>
+          {themeOptions.map((option) => (
+            <Dropdown.Item
+              key={option.key}
+              icon={option.icon}
+              onClick={() => onThemeToggle(option.key)}
+              className={getItemClassName(theme === option.key)}
+            >
+              <div className="flex flex-col">
+                <span>{option.label}</span>
+                <span className="text-xs text-semi-color-text-2">
+                  {option.description}
+                </span>
+              </div>
+            </Dropdown.Item>
+          ))}
+
+          {theme === 'auto' && (
+            <>
+              <Dropdown.Divider />
+              <div className="px-3 py-2 text-xs text-semi-color-text-2">
+                {t('当前跟随系统')}:{actualTheme === 'dark' ? t('深色') : t('浅色')}
+              </div>
+            </>
+          )}
+        </Dropdown.Menu>
+      }
+    >
+      <Button
+        icon={currentButtonIcon}
+        aria-label={t('切换主题')}
+        theme="borderless"
+        type="tertiary"
+        className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1"
+      />
+    </Dropdown>
   );
   );
 };
 };
 
 

+ 61 - 10
web/src/context/Theme/index.jsx

@@ -17,39 +17,90 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 For commercial licensing, please contact support@quantumnous.com
 */
 */
 
 
-import { createContext, useCallback, useContext, useState } from 'react';
+import { createContext, useCallback, useContext, useState, useEffect } from 'react';
 
 
 const ThemeContext = createContext(null);
 const ThemeContext = createContext(null);
 export const useTheme = () => useContext(ThemeContext);
 export const useTheme = () => useContext(ThemeContext);
 
 
+const ActualThemeContext = createContext(null);
+export const useActualTheme = () => useContext(ActualThemeContext);
+
 const SetThemeContext = createContext(null);
 const SetThemeContext = createContext(null);
 export const useSetTheme = () => useContext(SetThemeContext);
 export const useSetTheme = () => useContext(SetThemeContext);
 
 
+// 检测系统主题偏好
+const getSystemTheme = () => {
+  if (typeof window !== 'undefined' && window.matchMedia) {
+    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+  }
+  return 'light';
+};
+
 export const ThemeProvider = ({ children }) => {
 export const ThemeProvider = ({ children }) => {
   const [theme, _setTheme] = useState(() => {
   const [theme, _setTheme] = useState(() => {
     try {
     try {
-      return localStorage.getItem('theme-mode') || null;
+      return localStorage.getItem('theme-mode') || 'auto';
     } catch {
     } catch {
-      return null;
+      return 'auto';
     }
     }
   });
   });
 
 
-  const setTheme = useCallback((input) => {
-    _setTheme(input ? 'dark' : 'light');
+  const [systemTheme, setSystemTheme] = useState(getSystemTheme());
+
+  // 计算实际应用的主题
+  const actualTheme = theme === 'auto' ? systemTheme : theme;
+
+  // 监听系统主题变化
+  useEffect(() => {
+    if (typeof window !== 'undefined' && window.matchMedia) {
+      const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
 
 
+      const handleSystemThemeChange = (e) => {
+        setSystemTheme(e.matches ? 'dark' : 'light');
+      };
+
+      mediaQuery.addEventListener('change', handleSystemThemeChange);
+
+      return () => {
+        mediaQuery.removeEventListener('change', handleSystemThemeChange);
+      };
+    }
+  }, []);
+
+  // 应用主题到DOM
+  useEffect(() => {
     const body = document.body;
     const body = document.body;
-    if (!input) {
+    if (actualTheme === 'dark') {
+      body.setAttribute('theme-mode', 'dark');
+      document.documentElement.classList.add('dark');
+    } else {
       body.removeAttribute('theme-mode');
       body.removeAttribute('theme-mode');
-      localStorage.setItem('theme-mode', 'light');
+      document.documentElement.classList.remove('dark');
+    }
+  }, [actualTheme]);
+
+  const setTheme = useCallback((newTheme) => {
+    let themeValue;
+
+    if (typeof newTheme === 'boolean') {
+      // 向后兼容原有的 boolean 参数
+      themeValue = newTheme ? 'dark' : 'light';
+    } else if (typeof newTheme === 'string') {
+      // 新的字符串参数支持 'light', 'dark', 'auto'
+      themeValue = newTheme;
     } else {
     } else {
-      body.setAttribute('theme-mode', 'dark');
-      localStorage.setItem('theme-mode', 'dark');
+      themeValue = 'auto';
     }
     }
+
+    _setTheme(themeValue);
+    localStorage.setItem('theme-mode', themeValue);
   }, []);
   }, []);
 
 
   return (
   return (
     <SetThemeContext.Provider value={setTheme}>
     <SetThemeContext.Provider value={setTheme}>
-      <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
+      <ActualThemeContext.Provider value={actualTheme}>
+        <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
+      </ActualThemeContext.Provider>
     </SetThemeContext.Provider>
     </SetThemeContext.Provider>
   );
   );
 };
 };

+ 11 - 15
web/src/hooks/common/useHeaderBar.js

@@ -22,7 +22,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { UserContext } from '../../context/User';
 import { UserContext } from '../../context/User';
 import { StatusContext } from '../../context/Status';
 import { StatusContext } from '../../context/Status';
-import { useSetTheme, useTheme } from '../../context/Theme';
+import { useSetTheme, useTheme, useActualTheme } from '../../context/Theme';
 import { getLogo, getSystemName, API, showSuccess } from '../../helpers';
 import { getLogo, getSystemName, API, showSuccess } from '../../helpers';
 import { useIsMobile } from './useIsMobile';
 import { useIsMobile } from './useIsMobile';
 import { useSidebarCollapsed } from './useSidebarCollapsed';
 import { useSidebarCollapsed } from './useSidebarCollapsed';
@@ -31,7 +31,7 @@ import { useMinimumLoadingTime } from './useMinimumLoadingTime';
 export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
 export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
   const { t, i18n } = useTranslation();
   const { t, i18n } = useTranslation();
   const [userState, userDispatch] = useContext(UserContext);
   const [userState, userDispatch] = useContext(UserContext);
-  const [statusState, statusDispatch] = useContext(StatusContext);
+  const [statusState] = useContext(StatusContext);
   const isMobile = useIsMobile();
   const isMobile = useIsMobile();
   const [collapsed, toggleCollapsed] = useSidebarCollapsed();
   const [collapsed, toggleCollapsed] = useSidebarCollapsed();
   const [logoLoaded, setLogoLoaded] = useState(false);
   const [logoLoaded, setLogoLoaded] = useState(false);
@@ -54,6 +54,7 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
   const isConsoleRoute = location.pathname.startsWith('/console');
   const isConsoleRoute = location.pathname.startsWith('/console');
 
 
   const theme = useTheme();
   const theme = useTheme();
+  const actualTheme = useActualTheme();
   const setTheme = useSetTheme();
   const setTheme = useSetTheme();
 
 
   // Logo loading effect
   // Logo loading effect
@@ -65,21 +66,13 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
     img.onload = () => setLogoLoaded(true);
     img.onload = () => setLogoLoaded(true);
   }, [logo]);
   }, [logo]);
 
 
-  // Theme effect
+  // Send theme to iframe
   useEffect(() => {
   useEffect(() => {
-    if (theme === 'dark') {
-      document.body.setAttribute('theme-mode', 'dark');
-      document.documentElement.classList.add('dark');
-    } else {
-      document.body.removeAttribute('theme-mode');
-      document.documentElement.classList.remove('dark');
-    }
-
     const iframe = document.querySelector('iframe');
     const iframe = document.querySelector('iframe');
     if (iframe) {
     if (iframe) {
-      iframe.contentWindow.postMessage({ themeMode: theme }, '*');
+      iframe.contentWindow.postMessage({ themeMode: actualTheme }, '*');
     }
     }
-  }, [theme, isNewYear]);
+  }, [actualTheme]);
 
 
   // Language change effect
   // Language change effect
   useEffect(() => {
   useEffect(() => {
@@ -110,8 +103,11 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
     i18n.changeLanguage(lang);
     i18n.changeLanguage(lang);
   };
   };
 
 
-  const handleThemeToggle = () => {
-    setTheme(theme === 'dark' ? false : true);
+  const handleThemeToggle = (newTheme) => {
+    if (!newTheme || (newTheme !== 'light' && newTheme !== 'dark' && newTheme !== 'auto')) {
+      return;
+    }
+    setTheme(newTheme);
   };
   };
 
 
   const handleMobileMenuToggle = () => {
   const handleMobileMenuToggle = () => {

+ 11 - 1
web/src/i18n/locales/en.json

@@ -1984,5 +1984,15 @@
   "按顺序替换content中的变量占位符": "Replace variable placeholders in content in order",
   "按顺序替换content中的变量占位符": "Replace variable placeholders in content in order",
   "Unix时间戳": "Unix timestamp",
   "Unix时间戳": "Unix timestamp",
   "隐私设置": "Privacy settings",
   "隐私设置": "Privacy settings",
-  "记录请求与错误日志IP": "Record request and error log IP"
+  "记录请求与错误日志IP": "Record request and error log IP",
+  "切换主题": "Switch Theme",
+  "浅色模式": "Light Mode",
+  "深色模式": "Dark Mode",
+  "自动模式": "Auto Mode",
+  "始终使用浅色主题": "Always use light theme",
+  "始终使用深色主题": "Always use dark theme",
+  "跟随系统主题设置": "Follow system theme",
+  "当前跟随系统": "Currently following system",
+  "深色": "Dark",
+  "浅色": "Light"
 }
 }

+ 4 - 3
web/src/pages/Home/index.jsx

@@ -18,11 +18,12 @@ For commercial licensing, please contact support@quantumnous.com
 */
 */
 
 
 import React, { useContext, useEffect, useState } from 'react';
 import React, { useContext, useEffect, useState } from 'react';
-import { Button, Typography, Tag, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui';
+import { Button, Typography, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui';
 import { API, showError, copy, showSuccess } from '../../helpers';
 import { API, showError, copy, showSuccess } from '../../helpers';
 import { useIsMobile } from '../../hooks/common/useIsMobile';
 import { useIsMobile } from '../../hooks/common/useIsMobile';
 import { API_ENDPOINTS } from '../../constants/common.constant';
 import { API_ENDPOINTS } from '../../constants/common.constant';
 import { StatusContext } from '../../context/Status';
 import { StatusContext } from '../../context/Status';
+import { useActualTheme } from '../../context/Theme';
 import { marked } from 'marked';
 import { marked } from 'marked';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { IconGithubLogo, IconPlay, IconFile, IconCopy } from '@douyinfe/semi-icons';
 import { IconGithubLogo, IconPlay, IconFile, IconCopy } from '@douyinfe/semi-icons';
@@ -35,6 +36,7 @@ const { Text } = Typography;
 const Home = () => {
 const Home = () => {
   const { t, i18n } = useTranslation();
   const { t, i18n } = useTranslation();
   const [statusState] = useContext(StatusContext);
   const [statusState] = useContext(StatusContext);
+  const actualTheme = useActualTheme();
   const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
   const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
   const [homePageContent, setHomePageContent] = useState('');
   const [homePageContent, setHomePageContent] = useState('');
   const [noticeVisible, setNoticeVisible] = useState(false);
   const [noticeVisible, setNoticeVisible] = useState(false);
@@ -62,9 +64,8 @@ const Home = () => {
       if (data.startsWith('https://')) {
       if (data.startsWith('https://')) {
         const iframe = document.querySelector('iframe');
         const iframe = document.querySelector('iframe');
         if (iframe) {
         if (iframe) {
-          const theme = localStorage.getItem('theme-mode') || 'light';
           iframe.onload = () => {
           iframe.onload = () => {
-            iframe.contentWindow.postMessage({ themeMode: theme }, '*');
+            iframe.contentWindow.postMessage({ themeMode: actualTheme }, '*');
             iframe.contentWindow.postMessage({ lang: i18n.language }, '*');
             iframe.contentWindow.postMessage({ lang: i18n.language }, '*');
           };
           };
         }
         }