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

🎛️ refactor: HeaderBar into modular components, add shared skeletons, and primary-colored nav hover

Summary
- Split HeaderBar into maintainable components and hooks
- Centralized skeleton loading UI via a reusable SkeletonWrapper
- Improved navigation UX with primary-colored hover indication
- Preserved API surface and passed linters

Why
- Improve readability, reusability, and testability of the header
- Remove duplicated skeleton logic across files
- Provide clearer hover feedback consistent with the theme

What’s changed
- Components (web/src/components/layout/HeaderBar/)
  - New container: index.js
  - New UI components: HeaderLogo.js, Navigation.js, ActionButtons.js, UserArea.js, MobileMenuButton.js, NewYearButton.js, NotificationButton.js, ThemeToggle.js, LanguageSelector.js
  - New shared skeleton: SkeletonWrapper.js
  - Updated entry: HeaderBar.js now re-exports ./HeaderBar/index.js
- Hooks (web/src/hooks/common/)
  - New: useHeaderBar.js (state and actions for header)
  - New: useNotifications.js (announcements state, unread calc, open/close)
  - New: useNavigation.js (main nav link config)
- Skeleton refactor
  - Navigation.js: replaced inline skeletons with <SkeletonWrapper type="navigation" .../>
  - UserArea.js: replaced inline skeletons with <SkeletonWrapper type="userArea" .../>
  - HeaderLogo.js: replaced image/title skeletons with <SkeletonWrapper type="image"/>, <SkeletonWrapper type="title"/>
- Navigation hover UX
  - Added primary-colored hover to nav items for clearer pointer feedback
  - Final hover style: hover:text-semi-color-primary (kept rounded + transition classes)

Non-functional
- No breaking API changes; HeaderBar usage stays the same
- All modified files pass lint checks

Notes for future work
- SkeletonWrapper is extensible: add new cases (e.g., card) in one place
- Components are small and test-friendly; unit tests can be added per component

Affected files (key)
- web/src/components/layout/HeaderBar.js
- web/src/components/layout/HeaderBar/index.js
- web/src/components/layout/HeaderBar/Navigation.js
- web/src/components/layout/HeaderBar/UserArea.js
- web/src/components/layout/HeaderBar/HeaderLogo.js
- web/src/components/layout/HeaderBar/ActionButtons.js
- web/src/components/layout/HeaderBar/MobileMenuButton.js
- web/src/components/layout/HeaderBar/NewYearButton.js
- web/src/components/layout/HeaderBar/NotificationButton.js
- web/src/components/layout/HeaderBar/ThemeToggle.js
- web/src/components/layout/HeaderBar/LanguageSelector.js
- web/src/components/layout/HeaderBar/SkeletonWrapper.js
- web/src/hooks/common/useHeaderBar.js
- web/src/hooks/common/useNotifications.js
- web/src/hooks/common/useNavigation.js
t0ng7u 6 месяцев назад
Родитель
Сommit
1074f8acb1

+ 4 - 4
web/src/components/auth/LoginForm.js

@@ -170,7 +170,7 @@ const LoginForm = () => {
             setLoginLoading(false);
             return;
           }
-          
+
           userDispatch({ type: 'login', payload: data });
           setUserData(data);
           updateAPI();
@@ -313,7 +313,7 @@ const LoginForm = () => {
             <Title heading={3} className='!text-gray-800'>{systemName}</Title>
           </div>
 
-          <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
+          <Card className="border-0 !rounded-2xl overflow-hidden">
             <div className="flex justify-center pt-6 pb-2">
               <Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
             </div>
@@ -430,7 +430,7 @@ const LoginForm = () => {
             <Title heading={3}>{systemName}</Title>
           </div>
 
-          <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
+          <Card className="border-0 !rounded-2xl overflow-hidden">
             <div className="flex justify-center pt-6 pb-2">
               <Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
             </div>
@@ -581,7 +581,7 @@ const LoginForm = () => {
         width={450}
         centered
       >
-        <TwoFAVerification 
+        <TwoFAVerification
           onSuccess={handle2FASuccess}
           onBack={handleBackToLogin}
           isModal={true}

+ 1 - 1
web/src/components/auth/PasswordResetConfirm.js

@@ -109,7 +109,7 @@ const PasswordResetConfirm = () => {
               <Title heading={3} className='!text-gray-800'>{systemName}</Title>
             </div>
 
-            <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
+            <Card className="border-0 !rounded-2xl overflow-hidden">
               <div className="flex justify-center pt-6 pb-2">
                 <Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置确认')}</Title>
               </div>

+ 1 - 1
web/src/components/auth/PasswordResetForm.js

@@ -109,7 +109,7 @@ const PasswordResetForm = () => {
               <Title heading={3} className='!text-gray-800'>{systemName}</Title>
             </div>
 
-            <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
+            <Card className="border-0 !rounded-2xl overflow-hidden">
               <div className="flex justify-center pt-6 pb-2">
                 <Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置')}</Title>
               </div>

+ 2 - 2
web/src/components/auth/RegisterForm.js

@@ -310,7 +310,7 @@ const RegisterForm = () => {
             <Title heading={3} className='!text-gray-800'>{systemName}</Title>
           </div>
 
-          <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
+          <Card className="border-0 !rounded-2xl overflow-hidden">
             <div className="flex justify-center pt-6 pb-2">
               <Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
             </div>
@@ -417,7 +417,7 @@ const RegisterForm = () => {
             <Title heading={3} className='!text-gray-800'>{systemName}</Title>
           </div>
 
-          <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
+          <Card className="border-0 !rounded-2xl overflow-hidden">
             <div className="flex justify-center pt-6 pb-2">
               <Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
             </div>

+ 1 - 596
web/src/components/layout/HeaderBar.js

@@ -17,599 +17,4 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 
-import React, { useContext, useEffect, useState } from 'react';
-import { Link, useNavigate, useLocation } from 'react-router-dom';
-import { UserContext } from '../../context/User/index.js';
-import { useSetTheme, useTheme } from '../../context/Theme/index.js';
-import { useTranslation } from 'react-i18next';
-import { API, getLogo, getSystemName, showSuccess, stringToColor } from '../../helpers/index.js';
-import fireworks from 'react-fireworks';
-import { CN, GB } from 'country-flag-icons/react/3x2';
-import NoticeModal from './NoticeModal.js';
-
-import {
-  IconClose,
-  IconMenu,
-  IconLanguage,
-  IconChevronDown,
-  IconSun,
-  IconMoon,
-  IconExit,
-  IconUserSetting,
-  IconCreditCard,
-  IconKey,
-  IconBell,
-} from '@douyinfe/semi-icons';
-import {
-  Avatar,
-  Button,
-  Dropdown,
-  Tag,
-  Typography,
-  Skeleton,
-  Badge,
-} from '@douyinfe/semi-ui';
-import { StatusContext } from '../../context/Status/index.js';
-import { useIsMobile } from '../../hooks/common/useIsMobile.js';
-import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
-import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime.js';
-
-const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
-  const { t, i18n } = useTranslation();
-  const [userState, userDispatch] = useContext(UserContext);
-  const [statusState, statusDispatch] = useContext(StatusContext);
-  const isMobile = useIsMobile();
-  const [collapsed, toggleCollapsed] = useSidebarCollapsed();
-  const [logoLoaded, setLogoLoaded] = useState(false);
-  let navigate = useNavigate();
-  const [currentLang, setCurrentLang] = useState(i18n.language);
-  const location = useLocation();
-  const [noticeVisible, setNoticeVisible] = useState(false);
-  const [unreadCount, setUnreadCount] = useState(0);
-
-  const loading = statusState?.status === undefined;
-  const isLoading = useMinimumLoadingTime(loading);
-
-  const systemName = getSystemName();
-  const logo = getLogo();
-  const currentDate = new Date();
-  const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1;
-
-  const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false;
-  const docsLink = statusState?.status?.docs_link || '';
-  const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
-
-  const isConsoleRoute = location.pathname.startsWith('/console');
-
-  const theme = useTheme();
-  const setTheme = useSetTheme();
-
-  const announcements = statusState?.status?.announcements || [];
-
-  const getAnnouncementKey = (a) => `${a?.publishDate || ''}-${(a?.content || '').slice(0, 30)}`;
-
-  const calculateUnreadCount = () => {
-    if (!announcements.length) return 0;
-    let readKeys = [];
-    try {
-      readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
-    } catch (_) {
-      readKeys = [];
-    }
-    const readSet = new Set(readKeys);
-    return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).length;
-  };
-
-  const getUnreadKeys = () => {
-    if (!announcements.length) return [];
-    let readKeys = [];
-    try {
-      readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
-    } catch (_) {
-      readKeys = [];
-    }
-    const readSet = new Set(readKeys);
-    return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).map(getAnnouncementKey);
-  };
-
-  useEffect(() => {
-    setUnreadCount(calculateUnreadCount());
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [announcements]);
-
-  const mainNavLinks = [
-    {
-      text: t('首页'),
-      itemKey: 'home',
-      to: '/',
-    },
-    {
-      text: t('控制台'),
-      itemKey: 'console',
-      to: '/console',
-    },
-    {
-      text: t('模型广场'),
-      itemKey: 'pricing',
-      to: '/pricing',
-    },
-    ...(docsLink
-      ? [
-        {
-          text: t('文档'),
-          itemKey: 'docs',
-          isExternal: true,
-          externalLink: docsLink,
-        },
-      ]
-      : []),
-    {
-      text: t('关于'),
-      itemKey: 'about',
-      to: '/about',
-    },
-  ];
-
-  async function logout() {
-    await API.get('/api/user/logout');
-    showSuccess(t('注销成功!'));
-    userDispatch({ type: 'logout' });
-    localStorage.removeItem('user');
-    navigate('/login');
-  }
-
-  const handleNewYearClick = () => {
-    fireworks.init('root', {});
-    fireworks.start();
-    setTimeout(() => {
-      fireworks.stop();
-    }, 3000);
-  };
-
-  const handleNoticeOpen = () => {
-    setNoticeVisible(true);
-  };
-
-  const handleNoticeClose = () => {
-    setNoticeVisible(false);
-    if (announcements.length) {
-      let readKeys = [];
-      try {
-        readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
-      } catch (_) {
-        readKeys = [];
-      }
-      const mergedKeys = Array.from(new Set([...readKeys, ...announcements.map(getAnnouncementKey)]));
-      localStorage.setItem('notice_read_keys', JSON.stringify(mergedKeys));
-    }
-    setUnreadCount(0);
-  };
-
-  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');
-    if (iframe) {
-      iframe.contentWindow.postMessage({ themeMode: theme }, '*');
-    }
-
-  }, [theme, isNewYear]);
-
-  useEffect(() => {
-    const handleLanguageChanged = (lng) => {
-      setCurrentLang(lng);
-      const iframe = document.querySelector('iframe');
-      if (iframe) {
-        iframe.contentWindow.postMessage({ lang: lng }, '*');
-      }
-    };
-
-    i18n.on('languageChanged', handleLanguageChanged);
-    return () => {
-      i18n.off('languageChanged', handleLanguageChanged);
-    };
-  }, [i18n]);
-
-  useEffect(() => {
-    setLogoLoaded(false);
-    if (!logo) return;
-    const img = new Image();
-    img.src = logo;
-    img.onload = () => setLogoLoaded(true);
-  }, [logo]);
-
-  const handleLanguageChange = (lang) => {
-    i18n.changeLanguage(lang);
-  };
-
-  const renderNavLinks = (isMobileView = false, isLoading = false) => {
-    if (isLoading) {
-      const skeletonLinkClasses = isMobileView
-        ? 'flex items-center gap-1 p-1 w-full rounded-md'
-        : 'flex items-center gap-1 p-2 rounded-md';
-      return Array(4)
-        .fill(null)
-        .map((_, index) => (
-          <div key={index} className={skeletonLinkClasses}>
-            <Skeleton
-              loading={true}
-              active
-              placeholder={
-                <Skeleton.Title
-                  active
-                  style={{ width: isMobileView ? 40 : 60, height: 16 }}
-                />
-              }
-            />
-          </div>
-        ));
-    }
-
-    return mainNavLinks.map((link) => {
-      const commonLinkClasses = isMobileView
-        ? 'flex-shrink-0 flex items-center gap-1 p-1 font-semibold'
-        : 'flex-shrink-0 flex items-center gap-1 p-2 font-semibold';
-
-      const linkContent = (
-        <span>{link.text}</span>
-      );
-
-      if (link.isExternal) {
-        return (
-          <a
-            key={link.itemKey}
-            href={link.externalLink}
-            target='_blank'
-            rel='noopener noreferrer'
-            className={commonLinkClasses}
-          >
-            {linkContent}
-          </a>
-        );
-      }
-
-      let targetPath = link.to;
-      if (link.itemKey === 'console' && !userState.user) {
-        targetPath = '/login';
-      }
-
-      return (
-        <Link
-          key={link.itemKey}
-          to={targetPath}
-          className={commonLinkClasses}
-        >
-          {linkContent}
-        </Link>
-      );
-    });
-  };
-
-  const renderUserArea = () => {
-    if (isLoading) {
-      return (
-        <div className="flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1">
-          <Skeleton
-            loading={true}
-            active
-            placeholder={<Skeleton.Avatar active size="extra-small" className="shadow-sm" />}
-          />
-          <div className="ml-1.5 mr-1">
-            <Skeleton
-              loading={true}
-              active
-              placeholder={
-                <Skeleton.Title
-                  active
-                  style={{ width: isMobile ? 15 : 50, height: 12 }}
-                />
-              }
-            />
-          </div>
-        </div>
-      );
-    }
-
-    if (userState.user) {
-      return (
-        <Dropdown
-          position="bottomRight"
-          render={
-            <Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
-              <Dropdown.Item
-                onClick={() => {
-                  navigate('/console/personal');
-                }}
-                className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
-              >
-                <div className="flex items-center gap-2">
-                  <IconUserSetting size="small" className="text-gray-500 dark:text-gray-400" />
-                  <span>{t('个人设置')}</span>
-                </div>
-              </Dropdown.Item>
-              <Dropdown.Item
-                onClick={() => {
-                  navigate('/console/token');
-                }}
-                className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
-              >
-                <div className="flex items-center gap-2">
-                  <IconKey size="small" className="text-gray-500 dark:text-gray-400" />
-                  <span>{t('令牌管理')}</span>
-                </div>
-              </Dropdown.Item>
-              <Dropdown.Item
-                onClick={() => {
-                  navigate('/console/topup');
-                }}
-                className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
-              >
-                <div className="flex items-center gap-2">
-                  <IconCreditCard size="small" className="text-gray-500 dark:text-gray-400" />
-                  <span>{t('钱包管理')}</span>
-                </div>
-              </Dropdown.Item>
-              <Dropdown.Item onClick={logout} className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white">
-                <div className="flex items-center gap-2">
-                  <IconExit size="small" className="text-gray-500 dark:text-gray-400" />
-                  <span>{t('退出')}</span>
-                </div>
-              </Dropdown.Item>
-            </Dropdown.Menu>
-          }
-        >
-          <Button
-            theme="borderless"
-            type="tertiary"
-            className="flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
-          >
-            <Avatar
-              size="extra-small"
-              color={stringToColor(userState.user.username)}
-              className="mr-1"
-            >
-              {userState.user.username[0].toUpperCase()}
-            </Avatar>
-            <span className="hidden md:inline">
-              <Typography.Text className="!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1">
-                {userState.user.username}
-              </Typography.Text>
-            </span>
-            <IconChevronDown className="text-xs text-semi-color-text-2 dark:text-gray-400" />
-          </Button>
-        </Dropdown>
-      );
-    } else {
-      const showRegisterButton = !isSelfUseMode;
-
-      const commonSizingAndLayoutClass = "flex items-center justify-center !py-[10px] !px-1.5";
-
-      const loginButtonSpecificStyling = "!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors";
-      let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`;
-
-      let registerButtonClasses = `${commonSizingAndLayoutClass}`;
-
-      const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5";
-      const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5";
-
-      if (showRegisterButton) {
-        if (isMobile) {
-          loginButtonClasses += " !rounded-full";
-        } else {
-          loginButtonClasses += " !rounded-l-full !rounded-r-none";
-        }
-        registerButtonClasses += " !rounded-r-full !rounded-l-none";
-      } else {
-        loginButtonClasses += " !rounded-full";
-      }
-
-      return (
-        <div className="flex items-center">
-          <Link to="/login" className="flex">
-            <Button
-              theme="borderless"
-              type="tertiary"
-              className={loginButtonClasses}
-            >
-              <span className={loginButtonTextSpanClass}>
-                {t('登录')}
-              </span>
-            </Button>
-          </Link>
-          {showRegisterButton && (
-            <div className="hidden md:block">
-              <Link to="/register" className="flex -ml-px">
-                <Button
-                  theme="solid"
-                  type="primary"
-                  className={registerButtonClasses}
-                >
-                  <span className={registerButtonTextSpanClass}>
-                    {t('注册')}
-                  </span>
-                </Button>
-              </Link>
-            </div>
-          )}
-        </div>
-      );
-    }
-  };
-
-  return (
-    <header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
-      <NoticeModal
-        visible={noticeVisible}
-        onClose={handleNoticeClose}
-        isMobile={isMobile}
-        defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
-        unreadKeys={getUnreadKeys()}
-      />
-      <div className="w-full px-2">
-        <div className="flex items-center justify-between h-16">
-          <div className="flex items-center">
-            {isConsoleRoute && isMobile && (
-              <Button
-                icon={
-                  (isMobile ? drawerOpen : collapsed) ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />
-                }
-                aria-label={(isMobile ? drawerOpen : collapsed) ? t('关闭侧边栏') : t('打开侧边栏')}
-                onClick={() => isMobile ? onMobileMenuToggle() : toggleCollapsed()}
-                theme="borderless"
-                type="tertiary"
-                className="!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700"
-              />
-            )}
-            {(!isMobile || !isConsoleRoute) && (
-              <Link to="/" className="flex items-center gap-2">
-                <div className="relative w-8 h-8 md:w-8 md:h-8">
-                  {(isLoading || !logoLoaded) && (
-                    <Skeleton.Image
-                      active
-                      className="absolute inset-0 !rounded-full"
-                      style={{ width: '100%', height: '100%' }}
-                    />
-                  )}
-                  <img
-                    src={logo}
-                    alt="logo"
-                    className={`absolute inset-0 w-full h-full transition-opacity duration-200 group-hover:scale-105 rounded-full ${(!isLoading && logoLoaded) ? 'opacity-100' : 'opacity-0'}`}
-                  />
-                </div>
-                <div className="hidden md:flex items-center gap-2">
-                  <div className="flex items-center gap-2">
-                    <Skeleton
-                      loading={isLoading}
-                      active
-                      placeholder={
-                        <Skeleton.Title
-                          active
-                          style={{ width: 120, height: 24 }}
-                        />
-                      }
-                    >
-                      <Typography.Title heading={4} className="!text-lg !font-semibold !mb-0">
-                        {systemName}
-                      </Typography.Title>
-                    </Skeleton>
-                    {(isSelfUseMode || isDemoSiteMode) && !isLoading && (
-                      <Tag
-                        color={isSelfUseMode ? 'purple' : 'blue'}
-                        className="text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm"
-                        size="small"
-                        shape='circle'
-                      >
-                        {isSelfUseMode ? t('自用模式') : t('演示站点')}
-                      </Tag>
-                    )}
-                  </div>
-                </div>
-              </Link>
-            )}
-          </div>
-
-          {/* 中间可滚动导航区域(全部设备)*/}
-          <nav className="flex flex-1 items-center gap-1 lg:gap-2 mx-2 md:mx-4 overflow-x-auto whitespace-nowrap scrollbar-hide">
-            {renderNavLinks(isMobile, isLoading)}
-          </nav>
-
-          {/* 右侧用户信息及功能按钮 */}
-          <div className="flex items-center gap-2 md:gap-3">
-            {isNewYear && (
-              <Dropdown
-                position="bottomRight"
-                render={
-                  <Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
-                    <Dropdown.Item onClick={handleNewYearClick} className="!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-gray-600">
-                      Happy New Year!!! 🎉
-                    </Dropdown.Item>
-                  </Dropdown.Menu>
-                }
-              >
-                <Button
-                  theme="borderless"
-                  type="tertiary"
-                  icon={<span className="text-xl">🎉</span>}
-                  aria-label="New Year"
-                  className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full"
-                />
-              </Dropdown>
-            )}
-
-            {unreadCount > 0 ? (
-              <Badge count={unreadCount} type="danger" overflowCount={99}>
-                <Button
-                  icon={<IconBell className="text-lg" />}
-                  aria-label={t('系统公告')}
-                  onClick={handleNoticeOpen}
-                  theme="borderless"
-                  type="tertiary"
-                  size='small'
-                  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"
-                />
-              </Badge>
-            ) : (
-              <Button
-                icon={<IconBell className="text-lg" />}
-                aria-label={t('系统公告')}
-                onClick={handleNoticeOpen}
-                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"
-              />
-            )}
-
-            <Button
-              icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}
-              aria-label={t('切换主题')}
-              onClick={() => setTheme(theme === 'dark' ? false : true)}
-              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 className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
-                  <Dropdown.Item
-                    onClick={() => handleLanguageChange('zh')}
-                    className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
-                  >
-                    <CN title="中文" className="!w-5 !h-auto" />
-                    <span>中文</span>
-                  </Dropdown.Item>
-                  <Dropdown.Item
-                    onClick={() => handleLanguageChange('en')}
-                    className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'en' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
-                  >
-                    <GB title="English" className="!w-5 !h-auto" />
-                    <span>English</span>
-                  </Dropdown.Item>
-                </Dropdown.Menu>
-              }
-            >
-              <Button
-                icon={<IconLanguage className="text-lg" />}
-                aria-label={t('切换语言')}
-                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>
-
-            {renderUserArea()}
-          </div>
-        </div>
-      </div>
-    </header>
-  );
-};
-
-export default HeaderBar;
+export { default } from './HeaderBar/index.js';

+ 78 - 0
web/src/components/layout/HeaderBar/ActionButtons.js

@@ -0,0 +1,78 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import NewYearButton from './NewYearButton.js';
+import NotificationButton from './NotificationButton.js';
+import ThemeToggle from './ThemeToggle.js';
+import LanguageSelector from './LanguageSelector.js';
+import UserArea from './UserArea.js';
+
+const ActionButtons = ({
+  isNewYear,
+  unreadCount,
+  onNoticeOpen,
+  theme,
+  onThemeToggle,
+  currentLang,
+  onLanguageChange,
+  userState,
+  isLoading,
+  isMobile,
+  isSelfUseMode,
+  logout,
+  navigate,
+  t,
+}) => {
+  return (
+    <div className="flex items-center gap-2 md:gap-3">
+      <NewYearButton isNewYear={isNewYear} />
+
+      <NotificationButton
+        unreadCount={unreadCount}
+        onNoticeOpen={onNoticeOpen}
+        t={t}
+      />
+
+      <ThemeToggle
+        theme={theme}
+        onThemeToggle={onThemeToggle}
+        t={t}
+      />
+
+      <LanguageSelector
+        currentLang={currentLang}
+        onLanguageChange={onLanguageChange}
+        t={t}
+      />
+
+      <UserArea
+        userState={userState}
+        isLoading={isLoading}
+        isMobile={isMobile}
+        isSelfUseMode={isSelfUseMode}
+        logout={logout}
+        navigate={navigate}
+        t={t}
+      />
+    </div>
+  );
+};
+
+export default ActionButtons;

+ 81 - 0
web/src/components/layout/HeaderBar/HeaderLogo.js

@@ -0,0 +1,81 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { Typography, Tag } from '@douyinfe/semi-ui';
+import SkeletonWrapper from './SkeletonWrapper.js';
+
+const HeaderLogo = ({
+  isMobile,
+  isConsoleRoute,
+  logo,
+  logoLoaded,
+  isLoading,
+  systemName,
+  isSelfUseMode,
+  isDemoSiteMode,
+  t,
+}) => {
+  if (isMobile && isConsoleRoute) {
+    return null;
+  }
+
+  return (
+    <Link to="/" className="flex items-center gap-2">
+      <div className="relative w-8 h-8 md:w-8 md:h-8">
+        <SkeletonWrapper
+          loading={isLoading || !logoLoaded}
+          type="image"
+        />
+        <img
+          src={logo}
+          alt="logo"
+          className={`absolute inset-0 w-full h-full transition-opacity duration-200 group-hover:scale-105 rounded-full ${(!isLoading && logoLoaded) ? 'opacity-100' : 'opacity-0'}`}
+        />
+      </div>
+      <div className="hidden md:flex items-center gap-2">
+        <div className="flex items-center gap-2">
+          <SkeletonWrapper
+            loading={isLoading}
+            type="title"
+            width={120}
+            height={24}
+          >
+            <Typography.Title heading={4} className="!text-lg !font-semibold !mb-0">
+              {systemName}
+            </Typography.Title>
+          </SkeletonWrapper>
+          {(isSelfUseMode || isDemoSiteMode) && !isLoading && (
+            <Tag
+              color={isSelfUseMode ? 'purple' : 'blue'}
+              className="text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm"
+              size="small"
+              shape='circle'
+            >
+              {isSelfUseMode ? t('自用模式') : t('演示站点')}
+            </Tag>
+          )}
+        </div>
+      </div>
+    </Link>
+  );
+};
+
+export default HeaderLogo;

+ 59 - 0
web/src/components/layout/HeaderBar/LanguageSelector.js

@@ -0,0 +1,59 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Button, Dropdown } from '@douyinfe/semi-ui';
+import { IconLanguage } from '@douyinfe/semi-icons';
+import { CN, GB } from 'country-flag-icons/react/3x2';
+
+const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
+  return (
+    <Dropdown
+      position="bottomRight"
+      render={
+        <Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
+          <Dropdown.Item
+            onClick={() => onLanguageChange('zh')}
+            className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
+          >
+            <CN title="中文" className="!w-5 !h-auto" />
+            <span>中文</span>
+          </Dropdown.Item>
+          <Dropdown.Item
+            onClick={() => onLanguageChange('en')}
+            className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'en' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
+          >
+            <GB title="English" className="!w-5 !h-auto" />
+            <span>English</span>
+          </Dropdown.Item>
+        </Dropdown.Menu>
+      }
+    >
+      <Button
+        icon={<IconLanguage className="text-lg" />}
+        aria-label={t('切换语言')}
+        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>
+  );
+};
+
+export default LanguageSelector;

+ 50 - 0
web/src/components/layout/HeaderBar/MobileMenuButton.js

@@ -0,0 +1,50 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Button } from '@douyinfe/semi-ui';
+import { IconClose, IconMenu } from '@douyinfe/semi-icons';
+
+const MobileMenuButton = ({
+  isConsoleRoute,
+  isMobile,
+  drawerOpen,
+  collapsed,
+  onToggle,
+  t,
+}) => {
+  if (!isConsoleRoute || !isMobile) {
+    return null;
+  }
+
+  return (
+    <Button
+      icon={
+        (isMobile ? drawerOpen : collapsed) ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />
+      }
+      aria-label={(isMobile ? drawerOpen : collapsed) ? t('关闭侧边栏') : t('打开侧边栏')}
+      onClick={onToggle}
+      theme="borderless"
+      type="tertiary"
+      className="!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700"
+    />
+  );
+};
+
+export default MobileMenuButton;

+ 88 - 0
web/src/components/layout/HeaderBar/Navigation.js

@@ -0,0 +1,88 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Link } from 'react-router-dom';
+import SkeletonWrapper from './SkeletonWrapper.js';
+
+const Navigation = ({
+  mainNavLinks,
+  isMobile,
+  isLoading,
+  userState
+}) => {
+  const renderNavLinks = () => {
+    const baseClasses = 'flex-shrink-0 flex items-center gap-1 font-semibold rounded-md transition-all duration-200 ease-in-out';
+    const hoverClasses = 'hover:text-semi-color-primary';
+    const spacingClasses = isMobile ? 'p-1' : 'p-2';
+
+    const commonLinkClasses = `${baseClasses} ${spacingClasses} ${hoverClasses}`;
+
+    return mainNavLinks.map((link) => {
+
+      const linkContent = <span>{link.text}</span>;
+
+      if (link.isExternal) {
+        return (
+          <a
+            key={link.itemKey}
+            href={link.externalLink}
+            target='_blank'
+            rel='noopener noreferrer'
+            className={commonLinkClasses}
+          >
+            {linkContent}
+          </a>
+        );
+      }
+
+      let targetPath = link.to;
+      if (link.itemKey === 'console' && !userState.user) {
+        targetPath = '/login';
+      }
+
+      return (
+        <Link
+          key={link.itemKey}
+          to={targetPath}
+          className={commonLinkClasses}
+        >
+          {linkContent}
+        </Link>
+      );
+    });
+  };
+
+  return (
+    <nav className="flex flex-1 items-center gap-1 lg:gap-2 mx-2 md:mx-4 overflow-x-auto whitespace-nowrap scrollbar-hide">
+      <SkeletonWrapper
+        loading={isLoading}
+        type="navigation"
+        count={4}
+        width={60}
+        height={16}
+        isMobile={isMobile}
+      >
+        {renderNavLinks()}
+      </SkeletonWrapper>
+    </nav>
+  );
+};
+
+export default Navigation;

+ 59 - 0
web/src/components/layout/HeaderBar/NewYearButton.js

@@ -0,0 +1,59 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Button, Dropdown } from '@douyinfe/semi-ui';
+import fireworks from 'react-fireworks';
+
+const NewYearButton = ({ isNewYear }) => {
+  if (!isNewYear) {
+    return null;
+  }
+
+  const handleNewYearClick = () => {
+    fireworks.init('root', {});
+    fireworks.start();
+    setTimeout(() => {
+      fireworks.stop();
+    }, 3000);
+  };
+
+  return (
+    <Dropdown
+      position="bottomRight"
+      render={
+        <Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
+          <Dropdown.Item onClick={handleNewYearClick} className="!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-gray-600">
+            Happy New Year!!! 🎉
+          </Dropdown.Item>
+        </Dropdown.Menu>
+      }
+    >
+      <Button
+        theme="borderless"
+        type="tertiary"
+        icon={<span className="text-xl">🎉</span>}
+        aria-label="New Year"
+        className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full"
+      />
+    </Dropdown>
+  );
+};
+
+export default NewYearButton;

+ 45 - 0
web/src/components/layout/HeaderBar/NotificationButton.js

@@ -0,0 +1,45 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Button, Badge } from '@douyinfe/semi-ui';
+import { IconBell } from '@douyinfe/semi-icons';
+
+const NotificationButton = ({ unreadCount, onNoticeOpen, t }) => {
+  const buttonProps = {
+    icon: <IconBell className="text-lg" />,
+    'aria-label': t('系统公告'),
+    onClick: onNoticeOpen,
+    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",
+  };
+
+  if (unreadCount > 0) {
+    return (
+      <Badge count={unreadCount} type="danger" overflowCount={99}>
+        <Button {...buttonProps} size='small' />
+      </Badge>
+    );
+  }
+
+  return <Button {...buttonProps} />;
+};
+
+export default NotificationButton;

+ 154 - 0
web/src/components/layout/HeaderBar/SkeletonWrapper.js

@@ -0,0 +1,154 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Skeleton } from '@douyinfe/semi-ui';
+
+const SkeletonWrapper = ({
+  loading = false,
+  type = 'text',
+  count = 1,
+  width = 60,
+  height = 16,
+  isMobile = false,
+  className = '',
+  children,
+  ...props
+}) => {
+  if (!loading) {
+    return children;
+  }
+
+  // 导航链接骨架屏
+  const renderNavigationSkeleton = () => {
+    const skeletonLinkClasses = isMobile
+      ? 'flex items-center gap-1 p-1 w-full rounded-md'
+      : 'flex items-center gap-1 p-2 rounded-md';
+
+    return Array(count)
+      .fill(null)
+      .map((_, index) => (
+        <div key={index} className={skeletonLinkClasses}>
+          <Skeleton
+            loading={true}
+            active
+            placeholder={
+              <Skeleton.Title
+                active
+                style={{ width: isMobile ? 40 : width, height }}
+              />
+            }
+          />
+        </div>
+      ));
+  };
+
+  // 用户区域骨架屏 (头像 + 文本)
+  const renderUserAreaSkeleton = () => {
+    return (
+      <div className={`flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 ${className}`}>
+        <Skeleton
+          loading={true}
+          active
+          placeholder={<Skeleton.Avatar active size="extra-small" className="shadow-sm" />}
+        />
+        <div className="ml-1.5 mr-1">
+          <Skeleton
+            loading={true}
+            active
+            placeholder={
+              <Skeleton.Title
+                active
+                style={{ width: isMobile ? 15 : width, height: 12 }}
+              />
+            }
+          />
+        </div>
+      </div>
+    );
+  };
+
+  // Logo图片骨架屏
+  const renderImageSkeleton = () => {
+    return (
+      <Skeleton
+        loading={true}
+        active
+        placeholder={
+          <Skeleton.Image
+            active
+            className={`absolute inset-0 !rounded-full ${className}`}
+            style={{ width: '100%', height: '100%' }}
+          />
+        }
+      />
+    );
+  };
+
+  // 系统名称骨架屏
+  const renderTitleSkeleton = () => {
+    return (
+      <Skeleton
+        loading={true}
+        active
+        placeholder={
+          <Skeleton.Title
+            active
+            style={{ width, height: 24 }}
+          />
+        }
+      />
+    );
+  };
+
+  // 通用文本骨架屏
+  const renderTextSkeleton = () => {
+    return (
+      <div className={className}>
+        <Skeleton
+          loading={true}
+          active
+          placeholder={
+            <Skeleton.Title
+              active
+              style={{ width, height }}
+            />
+          }
+        />
+      </div>
+    );
+  };
+
+  // 根据类型渲染不同的骨架屏
+  switch (type) {
+    case 'navigation':
+      return renderNavigationSkeleton();
+    case 'userArea':
+      return renderUserAreaSkeleton();
+    case 'image':
+      return renderImageSkeleton();
+    case 'title':
+      return renderTitleSkeleton();
+    case 'text':
+    default:
+      return renderTextSkeleton();
+  }
+};
+
+export default SkeletonWrapper;

+ 37 - 0
web/src/components/layout/HeaderBar/ThemeToggle.js

@@ -0,0 +1,37 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+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';
+
+const ThemeToggle = ({ theme, onThemeToggle, t }) => {
+  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"
+    />
+  );
+};
+
+export default ThemeToggle;

+ 184 - 0
web/src/components/layout/HeaderBar/UserArea.js

@@ -0,0 +1,184 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Link } from 'react-router-dom';
+import {
+  Avatar,
+  Button,
+  Dropdown,
+  Typography,
+} from '@douyinfe/semi-ui';
+import {
+  IconChevronDown,
+  IconExit,
+  IconUserSetting,
+  IconCreditCard,
+  IconKey,
+} from '@douyinfe/semi-icons';
+import { stringToColor } from '../../../helpers/index.js';
+import SkeletonWrapper from './SkeletonWrapper.js';
+
+const UserArea = ({
+  userState,
+  isLoading,
+  isMobile,
+  isSelfUseMode,
+  logout,
+  navigate,
+  t,
+}) => {
+  if (isLoading) {
+    return (
+      <SkeletonWrapper
+        loading={true}
+        type="userArea"
+        width={50}
+        isMobile={isMobile}
+      />
+    );
+  }
+
+  if (userState.user) {
+    return (
+      <Dropdown
+        position="bottomRight"
+        render={
+          <Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
+            <Dropdown.Item
+              onClick={() => {
+                navigate('/console/personal');
+              }}
+              className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
+            >
+              <div className="flex items-center gap-2">
+                <IconUserSetting size="small" className="text-gray-500 dark:text-gray-400" />
+                <span>{t('个人设置')}</span>
+              </div>
+            </Dropdown.Item>
+            <Dropdown.Item
+              onClick={() => {
+                navigate('/console/token');
+              }}
+              className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
+            >
+              <div className="flex items-center gap-2">
+                <IconKey size="small" className="text-gray-500 dark:text-gray-400" />
+                <span>{t('令牌管理')}</span>
+              </div>
+            </Dropdown.Item>
+            <Dropdown.Item
+              onClick={() => {
+                navigate('/console/topup');
+              }}
+              className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
+            >
+              <div className="flex items-center gap-2">
+                <IconCreditCard size="small" className="text-gray-500 dark:text-gray-400" />
+                <span>{t('钱包管理')}</span>
+              </div>
+            </Dropdown.Item>
+            <Dropdown.Item onClick={logout} className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white">
+              <div className="flex items-center gap-2">
+                <IconExit size="small" className="text-gray-500 dark:text-gray-400" />
+                <span>{t('退出')}</span>
+              </div>
+            </Dropdown.Item>
+          </Dropdown.Menu>
+        }
+      >
+        <Button
+          theme="borderless"
+          type="tertiary"
+          className="flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
+        >
+          <Avatar
+            size="extra-small"
+            color={stringToColor(userState.user.username)}
+            className="mr-1"
+          >
+            {userState.user.username[0].toUpperCase()}
+          </Avatar>
+          <span className="hidden md:inline">
+            <Typography.Text className="!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1">
+              {userState.user.username}
+            </Typography.Text>
+          </span>
+          <IconChevronDown className="text-xs text-semi-color-text-2 dark:text-gray-400" />
+        </Button>
+      </Dropdown>
+    );
+  } else {
+    const showRegisterButton = !isSelfUseMode;
+
+    const commonSizingAndLayoutClass = "flex items-center justify-center !py-[10px] !px-1.5";
+
+    const loginButtonSpecificStyling = "!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors";
+    let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`;
+
+    let registerButtonClasses = `${commonSizingAndLayoutClass}`;
+
+    const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5";
+    const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5";
+
+    if (showRegisterButton) {
+      if (isMobile) {
+        loginButtonClasses += " !rounded-full";
+      } else {
+        loginButtonClasses += " !rounded-l-full !rounded-r-none";
+      }
+      registerButtonClasses += " !rounded-r-full !rounded-l-none";
+    } else {
+      loginButtonClasses += " !rounded-full";
+    }
+
+    return (
+      <div className="flex items-center">
+        <Link to="/login" className="flex">
+          <Button
+            theme="borderless"
+            type="tertiary"
+            className={loginButtonClasses}
+          >
+            <span className={loginButtonTextSpanClass}>
+              {t('登录')}
+            </span>
+          </Button>
+        </Link>
+        {showRegisterButton && (
+          <div className="hidden md:block">
+            <Link to="/register" className="flex -ml-px">
+              <Button
+                theme="solid"
+                type="primary"
+                className={registerButtonClasses}
+              >
+                <span className={registerButtonTextSpanClass}>
+                  {t('注册')}
+                </span>
+              </Button>
+            </Link>
+          </div>
+        )}
+      </div>
+    );
+  }
+};
+
+export default UserArea;

+ 129 - 0
web/src/components/layout/HeaderBar/index.js

@@ -0,0 +1,129 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { useHeaderBar } from '../../../hooks/common/useHeaderBar.js';
+import { useNotifications } from '../../../hooks/common/useNotifications.js';
+import { useNavigation } from '../../../hooks/common/useNavigation.js';
+import NoticeModal from '../NoticeModal.js';
+import MobileMenuButton from './MobileMenuButton.js';
+import HeaderLogo from './HeaderLogo.js';
+import Navigation from './Navigation.js';
+import ActionButtons from './ActionButtons.js';
+
+const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
+  const {
+    userState,
+    statusState,
+    isMobile,
+    collapsed,
+    logoLoaded,
+    currentLang,
+    isLoading,
+    systemName,
+    logo,
+    isNewYear,
+    isSelfUseMode,
+    docsLink,
+    isDemoSiteMode,
+    isConsoleRoute,
+    theme,
+    logout,
+    handleLanguageChange,
+    handleThemeToggle,
+    handleMobileMenuToggle,
+    navigate,
+    t,
+  } = useHeaderBar({ onMobileMenuToggle, drawerOpen });
+
+  const {
+    noticeVisible,
+    unreadCount,
+    handleNoticeOpen,
+    handleNoticeClose,
+    getUnreadKeys,
+  } = useNotifications(statusState);
+
+  const { mainNavLinks } = useNavigation(t, docsLink);
+
+  return (
+    <header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
+      <NoticeModal
+        visible={noticeVisible}
+        onClose={handleNoticeClose}
+        isMobile={isMobile}
+        defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
+        unreadKeys={getUnreadKeys()}
+      />
+
+      <div className="w-full px-2">
+        <div className="flex items-center justify-between h-16">
+          <div className="flex items-center">
+            <MobileMenuButton
+              isConsoleRoute={isConsoleRoute}
+              isMobile={isMobile}
+              drawerOpen={drawerOpen}
+              collapsed={collapsed}
+              onToggle={handleMobileMenuToggle}
+              t={t}
+            />
+
+            <HeaderLogo
+              isMobile={isMobile}
+              isConsoleRoute={isConsoleRoute}
+              logo={logo}
+              logoLoaded={logoLoaded}
+              isLoading={isLoading}
+              systemName={systemName}
+              isSelfUseMode={isSelfUseMode}
+              isDemoSiteMode={isDemoSiteMode}
+              t={t}
+            />
+          </div>
+
+          <Navigation
+            mainNavLinks={mainNavLinks}
+            isMobile={isMobile}
+            isLoading={isLoading}
+            userState={userState}
+          />
+
+          <ActionButtons
+            isNewYear={isNewYear}
+            unreadCount={unreadCount}
+            onNoticeOpen={handleNoticeOpen}
+            theme={theme}
+            onThemeToggle={handleThemeToggle}
+            currentLang={currentLang}
+            onLanguageChange={handleLanguageChange}
+            userState={userState}
+            isLoading={isLoading}
+            isMobile={isMobile}
+            isSelfUseMode={isSelfUseMode}
+            logout={logout}
+            navigate={navigate}
+            t={t}
+          />
+        </div>
+      </div>
+    </header>
+  );
+};
+
+export default HeaderBar;

+ 153 - 0
web/src/hooks/common/useHeaderBar.js

@@ -0,0 +1,153 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { useState, useEffect, useContext } from 'react';
+import { useNavigate, useLocation } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { UserContext } from '../../context/User/index.js';
+import { StatusContext } from '../../context/Status/index.js';
+import { useSetTheme, useTheme } from '../../context/Theme/index.js';
+import { getLogo, getSystemName, API, showSuccess } from '../../helpers/index.js';
+import { useIsMobile } from './useIsMobile.js';
+import { useSidebarCollapsed } from './useSidebarCollapsed.js';
+import { useMinimumLoadingTime } from './useMinimumLoadingTime.js';
+
+export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
+  const { t, i18n } = useTranslation();
+  const [userState, userDispatch] = useContext(UserContext);
+  const [statusState, statusDispatch] = useContext(StatusContext);
+  const isMobile = useIsMobile();
+  const [collapsed, toggleCollapsed] = useSidebarCollapsed();
+  const [logoLoaded, setLogoLoaded] = useState(false);
+  const navigate = useNavigate();
+  const [currentLang, setCurrentLang] = useState(i18n.language);
+  const location = useLocation();
+
+  const loading = statusState?.status === undefined;
+  const isLoading = useMinimumLoadingTime(loading);
+
+  const systemName = getSystemName();
+  const logo = getLogo();
+  const currentDate = new Date();
+  const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1;
+
+  const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false;
+  const docsLink = statusState?.status?.docs_link || '';
+  const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
+
+  const isConsoleRoute = location.pathname.startsWith('/console');
+
+  const theme = useTheme();
+  const setTheme = useSetTheme();
+
+  // Logo loading effect
+  useEffect(() => {
+    setLogoLoaded(false);
+    if (!logo) return;
+    const img = new Image();
+    img.src = logo;
+    img.onload = () => setLogoLoaded(true);
+  }, [logo]);
+
+  // Theme effect
+  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');
+    if (iframe) {
+      iframe.contentWindow.postMessage({ themeMode: theme }, '*');
+    }
+  }, [theme, isNewYear]);
+
+  // Language change effect
+  useEffect(() => {
+    const handleLanguageChanged = (lng) => {
+      setCurrentLang(lng);
+      const iframe = document.querySelector('iframe');
+      if (iframe) {
+        iframe.contentWindow.postMessage({ lang: lng }, '*');
+      }
+    };
+
+    i18n.on('languageChanged', handleLanguageChanged);
+    return () => {
+      i18n.off('languageChanged', handleLanguageChanged);
+    };
+  }, [i18n]);
+
+  // Actions
+  const logout = async () => {
+    await API.get('/api/user/logout');
+    showSuccess(t('注销成功!'));
+    userDispatch({ type: 'logout' });
+    localStorage.removeItem('user');
+    navigate('/login');
+  };
+
+  const handleLanguageChange = (lang) => {
+    i18n.changeLanguage(lang);
+  };
+
+  const handleThemeToggle = () => {
+    setTheme(theme === 'dark' ? false : true);
+  };
+
+  const handleMobileMenuToggle = () => {
+    if (isMobile) {
+      onMobileMenuToggle();
+    } else {
+      toggleCollapsed();
+    }
+  };
+
+  return {
+    // State
+    userState,
+    statusState,
+    isMobile,
+    collapsed,
+    logoLoaded,
+    currentLang,
+    location,
+    isLoading,
+    systemName,
+    logo,
+    isNewYear,
+    isSelfUseMode,
+    docsLink,
+    isDemoSiteMode,
+    isConsoleRoute,
+    theme,
+    drawerOpen,
+
+    // Actions
+    logout,
+    handleLanguageChange,
+    handleThemeToggle,
+    handleMobileMenuToggle,
+    navigate,
+    t,
+  };
+};

+ 59 - 0
web/src/hooks/common/useNavigation.js

@@ -0,0 +1,59 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { useMemo } from 'react';
+
+export const useNavigation = (t, docsLink) => {
+  const mainNavLinks = useMemo(() => [
+    {
+      text: t('首页'),
+      itemKey: 'home',
+      to: '/',
+    },
+    {
+      text: t('控制台'),
+      itemKey: 'console',
+      to: '/console',
+    },
+    {
+      text: t('模型广场'),
+      itemKey: 'pricing',
+      to: '/pricing',
+    },
+    ...(docsLink
+      ? [
+        {
+          text: t('文档'),
+          itemKey: 'docs',
+          isExternal: true,
+          externalLink: docsLink,
+        },
+      ]
+      : []),
+    {
+      text: t('关于'),
+      itemKey: 'about',
+      to: '/about',
+    },
+  ], [t, docsLink]);
+
+  return {
+    mainNavLinks,
+  };
+};

+ 88 - 0
web/src/hooks/common/useNotifications.js

@@ -0,0 +1,88 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { useState, useEffect } from 'react';
+
+export const useNotifications = (statusState) => {
+  const [noticeVisible, setNoticeVisible] = useState(false);
+  const [unreadCount, setUnreadCount] = useState(0);
+
+  const announcements = statusState?.status?.announcements || [];
+
+  // Helper functions
+  const getAnnouncementKey = (a) => `${a?.publishDate || ''}-${(a?.content || '').slice(0, 30)}`;
+
+  const calculateUnreadCount = () => {
+    if (!announcements.length) return 0;
+    let readKeys = [];
+    try {
+      readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
+    } catch (_) {
+      readKeys = [];
+    }
+    const readSet = new Set(readKeys);
+    return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).length;
+  };
+
+  const getUnreadKeys = () => {
+    if (!announcements.length) return [];
+    let readKeys = [];
+    try {
+      readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
+    } catch (_) {
+      readKeys = [];
+    }
+    const readSet = new Set(readKeys);
+    return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).map(getAnnouncementKey);
+  };
+
+  // Effects
+  useEffect(() => {
+    setUnreadCount(calculateUnreadCount());
+  }, [announcements]);
+
+  // Actions
+  const handleNoticeOpen = () => {
+    setNoticeVisible(true);
+  };
+
+  const handleNoticeClose = () => {
+    setNoticeVisible(false);
+    if (announcements.length) {
+      let readKeys = [];
+      try {
+        readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
+      } catch (_) {
+        readKeys = [];
+      }
+      const mergedKeys = Array.from(new Set([...readKeys, ...announcements.map(getAnnouncementKey)]));
+      localStorage.setItem('notice_read_keys', JSON.stringify(mergedKeys));
+    }
+    setUnreadCount(0);
+  };
+
+  return {
+    noticeVisible,
+    unreadCount,
+    announcements,
+    handleNoticeOpen,
+    handleNoticeClose,
+    getUnreadKeys,
+  };
+};