From 1074f8acb1be3b0ca9b5f7275a897486b165484e Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 18 Aug 2025 03:20:56 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=9B=EF=B8=8F=20refactor:=20HeaderBar?= =?UTF-8?q?=20into=20modular=20components,=20add=20shared=20skeletons,=20a?= =?UTF-8?q?nd=20primary-colored=20nav=20hover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - UserArea.js: replaced inline skeletons with - HeaderLogo.js: replaced image/title skeletons with , - 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 --- web/src/components/auth/LoginForm.js | 8 +- .../components/auth/PasswordResetConfirm.js | 2 +- web/src/components/auth/PasswordResetForm.js | 2 +- web/src/components/auth/RegisterForm.js | 4 +- web/src/components/layout/HeaderBar.js | 597 +----------------- .../layout/HeaderBar/ActionButtons.js | 78 +++ .../components/layout/HeaderBar/HeaderLogo.js | 81 +++ .../layout/HeaderBar/LanguageSelector.js | 59 ++ .../layout/HeaderBar/MobileMenuButton.js | 50 ++ .../components/layout/HeaderBar/Navigation.js | 88 +++ .../layout/HeaderBar/NewYearButton.js | 59 ++ .../layout/HeaderBar/NotificationButton.js | 45 ++ .../layout/HeaderBar/SkeletonWrapper.js | 154 +++++ .../layout/HeaderBar/ThemeToggle.js | 37 ++ .../components/layout/HeaderBar/UserArea.js | 184 ++++++ web/src/components/layout/HeaderBar/index.js | 129 ++++ web/src/hooks/common/useHeaderBar.js | 153 +++++ web/src/hooks/common/useNavigation.js | 59 ++ web/src/hooks/common/useNotifications.js | 88 +++ 19 files changed, 1273 insertions(+), 604 deletions(-) create mode 100644 web/src/components/layout/HeaderBar/ActionButtons.js create mode 100644 web/src/components/layout/HeaderBar/HeaderLogo.js create mode 100644 web/src/components/layout/HeaderBar/LanguageSelector.js create mode 100644 web/src/components/layout/HeaderBar/MobileMenuButton.js create mode 100644 web/src/components/layout/HeaderBar/Navigation.js create mode 100644 web/src/components/layout/HeaderBar/NewYearButton.js create mode 100644 web/src/components/layout/HeaderBar/NotificationButton.js create mode 100644 web/src/components/layout/HeaderBar/SkeletonWrapper.js create mode 100644 web/src/components/layout/HeaderBar/ThemeToggle.js create mode 100644 web/src/components/layout/HeaderBar/UserArea.js create mode 100644 web/src/components/layout/HeaderBar/index.js create mode 100644 web/src/hooks/common/useHeaderBar.js create mode 100644 web/src/hooks/common/useNavigation.js create mode 100644 web/src/hooks/common/useNotifications.js diff --git a/web/src/components/auth/LoginForm.js b/web/src/components/auth/LoginForm.js index 9c6650f8..1d532efd 100644 --- a/web/src/components/auth/LoginForm.js +++ b/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 = () => { {systemName} - +
{t('登 录')}
@@ -430,7 +430,7 @@ const LoginForm = () => { {systemName} - +
{t('登 录')}
@@ -581,7 +581,7 @@ const LoginForm = () => { width={450} centered > - { {systemName} - +
{t('密码重置确认')}
diff --git a/web/src/components/auth/PasswordResetForm.js b/web/src/components/auth/PasswordResetForm.js index 3602f317..a8e4a4d6 100644 --- a/web/src/components/auth/PasswordResetForm.js +++ b/web/src/components/auth/PasswordResetForm.js @@ -109,7 +109,7 @@ const PasswordResetForm = () => { {systemName} - +
{t('密码重置')}
diff --git a/web/src/components/auth/RegisterForm.js b/web/src/components/auth/RegisterForm.js index 071631c6..5df3bd04 100644 --- a/web/src/components/auth/RegisterForm.js +++ b/web/src/components/auth/RegisterForm.js @@ -310,7 +310,7 @@ const RegisterForm = () => { {systemName} - +
{t('注 册')}
@@ -417,7 +417,7 @@ const RegisterForm = () => { {systemName} - +
{t('注 册')}
diff --git a/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js index 8e958212..4f8ea8d6 100644 --- a/web/src/components/layout/HeaderBar.js +++ b/web/src/components/layout/HeaderBar.js @@ -17,599 +17,4 @@ along with this program. If not, see . 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) => ( -
- - } - /> -
- )); - } - - 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 = ( - {link.text} - ); - - if (link.isExternal) { - return ( - - {linkContent} - - ); - } - - let targetPath = link.to; - if (link.itemKey === 'console' && !userState.user) { - targetPath = '/login'; - } - - return ( - - {linkContent} - - ); - }); - }; - - const renderUserArea = () => { - if (isLoading) { - return ( -
- } - /> -
- - } - /> -
-
- ); - } - - if (userState.user) { - return ( - - { - 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" - > -
- - {t('个人设置')} -
-
- { - 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" - > -
- - {t('令牌管理')} -
-
- { - 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" - > -
- - {t('钱包管理')} -
-
- -
- - {t('退出')} -
-
- - } - > - -
- ); - } 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 ( -
- - - - {showRegisterButton && ( -
- - - -
- )} -
- ); - } - }; - - return ( -
- 0 ? 'system' : 'inApp'} - unreadKeys={getUnreadKeys()} - /> -
-
-
- {isConsoleRoute && isMobile && ( -
- - {/* 中间可滚动导航区域(全部设备)*/} - - - {/* 右侧用户信息及功能按钮 */} -
- {isNewYear && ( - - - Happy New Year!!! 🎉 - - - } - > -
-
-
-
- ); -}; - -export default HeaderBar; \ No newline at end of file +export { default } from './HeaderBar/index.js'; diff --git a/web/src/components/layout/HeaderBar/ActionButtons.js b/web/src/components/layout/HeaderBar/ActionButtons.js new file mode 100644 index 00000000..01c75d5b --- /dev/null +++ b/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 . + +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 ( +
+ + + + + + + + + +
+ ); +}; + +export default ActionButtons; diff --git a/web/src/components/layout/HeaderBar/HeaderLogo.js b/web/src/components/layout/HeaderBar/HeaderLogo.js new file mode 100644 index 00000000..77e27bad --- /dev/null +++ b/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 . + +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 ( + +
+ + logo +
+
+
+ + + {systemName} + + + {(isSelfUseMode || isDemoSiteMode) && !isLoading && ( + + {isSelfUseMode ? t('自用模式') : t('演示站点')} + + )} +
+
+ + ); +}; + +export default HeaderLogo; diff --git a/web/src/components/layout/HeaderBar/LanguageSelector.js b/web/src/components/layout/HeaderBar/LanguageSelector.js new file mode 100644 index 00000000..1f5e0bb7 --- /dev/null +++ b/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 . + +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 ( + + 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'}`} + > + + 中文 + + 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'}`} + > + + English + + + } + > + + + ); + } 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 ( +
+ + + + {showRegisterButton && ( +
+ + + +
+ )} +
+ ); + } +}; + +export default UserArea; diff --git a/web/src/components/layout/HeaderBar/index.js b/web/src/components/layout/HeaderBar/index.js new file mode 100644 index 00000000..2bdf0aca --- /dev/null +++ b/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 . + +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 ( +
+ 0 ? 'system' : 'inApp'} + unreadKeys={getUnreadKeys()} + /> + +
+
+
+ + + +
+ + + + +
+
+
+ ); +}; + +export default HeaderBar; diff --git a/web/src/hooks/common/useHeaderBar.js b/web/src/hooks/common/useHeaderBar.js new file mode 100644 index 00000000..13b23e30 --- /dev/null +++ b/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 . + +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, + }; +}; diff --git a/web/src/hooks/common/useNavigation.js b/web/src/hooks/common/useNavigation.js new file mode 100644 index 00000000..0fbe3d09 --- /dev/null +++ b/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 . + +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, + }; +}; diff --git a/web/src/hooks/common/useNotifications.js b/web/src/hooks/common/useNotifications.js new file mode 100644 index 00000000..3df154de --- /dev/null +++ b/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 . + +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, + }; +};