| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641 |
- 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/useIsMobile.js';
- import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.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 [isLoading, setIsLoading] = useState(true);
- let navigate = useNavigate();
- const [currentLang, setCurrentLang] = useState(i18n.language);
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
- const location = useLocation();
- const [noticeVisible, setNoticeVisible] = useState(false);
- const [unreadCount, setUnreadCount] = useState(0);
- 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');
- setMobileMenuOpen(false);
- }
- 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(() => {
- const timer = setTimeout(() => {
- setIsLoading(false);
- }, 500);
- return () => clearTimeout(timer);
- }, []);
- const handleLanguageChange = (lang) => {
- i18n.changeLanguage(lang);
- setMobileMenuOpen(false);
- };
- const handleNavLinkClick = (itemKey) => {
- if (itemKey === 'home') {
- // styleDispatch(styleActions.setSider(false)); // This line is removed
- }
- setMobileMenuOpen(false);
- };
- const renderNavLinks = (isMobileView = false, isLoading = false) => {
- if (isLoading) {
- const skeletonLinkClasses = isMobileView
- ? 'flex items-center gap-1 p-3 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 ? 100 : 60, height: 16 }}
- />
- }
- />
- </div>
- ));
- }
- return mainNavLinks.map((link) => {
- const commonLinkClasses = isMobileView
- ? 'flex items-center gap-1 p-3 w-full text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors font-semibold'
- : 'flex items-center gap-1 p-2 text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors rounded-md 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}
- onClick={() => handleNavLinkClick(link.itemKey)}
- >
- {linkContent}
- </a>
- );
- }
- let targetPath = link.to;
- if (link.itemKey === 'console' && !userState.user) {
- targetPath = '/login';
- }
- return (
- <Link
- key={link.itemKey}
- to={targetPath}
- className={commonLinkClasses}
- onClick={() => handleNavLinkClick(link.itemKey)}
- >
- {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');
- setMobileMenuOpen(false);
- }}
- 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');
- setMobileMenuOpen(false);
- }}
- 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('API令牌')}</span>
- </div>
- </Dropdown.Item>
- <Dropdown.Item
- onClick={() => {
- navigate('/console/topup');
- setMobileMenuOpen(false);
- }}
- 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" onClick={() => handleNavLinkClick('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" onClick={() => handleNavLinkClick('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">
- <div className="md:hidden">
- <Button
- icon={
- isConsoleRoute
- ? ((isMobile ? drawerOpen : collapsed) ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
- : (mobileMenuOpen ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
- }
- aria-label={
- isConsoleRoute
- ? ((isMobile ? drawerOpen : collapsed) ? t('关闭侧边栏') : t('打开侧边栏'))
- : (mobileMenuOpen ? t('关闭菜单') : t('打开菜单'))
- }
- onClick={() => {
- if (isConsoleRoute) {
- // 控制侧边栏的显示/隐藏,无论是否移动设备
- isMobile ? onMobileMenuToggle() : toggleCollapsed();
- } else {
- // 控制HeaderBar自己的移动菜单
- setMobileMenuOpen(!mobileMenuOpen);
- }
- }}
- theme="borderless"
- type="tertiary"
- className="!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700"
- />
- </div>
- <Link to="/" onClick={() => handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2">
- <Skeleton
- loading={isLoading}
- active
- placeholder={
- <Skeleton.Image
- active
- className="h-7 md:h-8 !rounded-full"
- style={{ width: 32, height: 32 }}
- />
- }
- >
- <img src={logo} alt="logo" className="h-7 md:h-8 transition-transform duration-300 ease-in-out group-hover:scale-105 rounded-full" />
- </Skeleton>
- <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>
- {(isSelfUseMode || isDemoSiteMode) && !isLoading && (
- <div className="md:hidden">
- <Tag
- color={isSelfUseMode ? 'purple' : 'blue'}
- className="ml-2 text-xs px-1 py-0.5 rounded whitespace-nowrap shadow-sm"
- size="small"
- shape='circle'
- >
- {isSelfUseMode ? t('自用模式') : t('演示站点')}
- </Tag>
- </div>
- )}
- <nav className="hidden md:flex items-center gap-1 lg:gap-2 ml-6">
- {renderNavLinks(false, isLoading)}
- </nav>
- </div>
- <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"
- 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>
- <div className="md:hidden">
- <div
- className={`
- absolute top-16 left-0 right-0 bg-semi-color-bg-0
- shadow-lg p-3
- transform transition-all duration-300 ease-in-out
- ${(!isConsoleRoute && mobileMenuOpen) ? 'translate-y-0 opacity-100 visible' : '-translate-y-4 opacity-0 invisible'}
- `}
- >
- <nav className="flex flex-col gap-1">
- {renderNavLinks(true, isLoading)}
- </nav>
- </div>
- </div>
- </header>
- );
- };
- export default HeaderBar;
|