HeaderBar.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. import React, { useContext, useEffect, useState } from 'react';
  2. import { Link, useNavigate } from 'react-router-dom';
  3. import { UserContext } from '../context/User';
  4. import { useSetTheme, useTheme } from '../context/Theme';
  5. import { useTranslation } from 'react-i18next';
  6. import { API, getLogo, getSystemName, showSuccess } from '../helpers';
  7. import fireworks from 'react-fireworks';
  8. import { CN, GB } from 'country-flag-icons/react/3x2';
  9. import {
  10. IconClose,
  11. IconMenu,
  12. IconLanguage,
  13. IconChevronDown,
  14. IconSun,
  15. IconMoon,
  16. } from '@douyinfe/semi-icons';
  17. import {
  18. Avatar,
  19. Button,
  20. Dropdown,
  21. Tag,
  22. Typography,
  23. Skeleton,
  24. } from '@douyinfe/semi-ui';
  25. import { stringToColor } from '../helpers/render';
  26. import { StatusContext } from '../context/Status/index.js';
  27. import { StyleContext } from '../context/Style/index.js';
  28. const HeaderBar = () => {
  29. const { t, i18n } = useTranslation();
  30. const [userState, userDispatch] = useContext(UserContext);
  31. const [statusState, statusDispatch] = useContext(StatusContext);
  32. const [styleState, styleDispatch] = useContext(StyleContext);
  33. const [isLoading, setIsLoading] = useState(true);
  34. let navigate = useNavigate();
  35. const [currentLang, setCurrentLang] = useState(i18n.language);
  36. const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
  37. const systemName = getSystemName();
  38. const logo = getLogo();
  39. const currentDate = new Date();
  40. const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1;
  41. const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false;
  42. const docsLink = statusState?.status?.docs_link || '';
  43. const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
  44. const theme = useTheme();
  45. const setTheme = useSetTheme();
  46. const mainNavLinks = [
  47. {
  48. text: t('首页'),
  49. itemKey: 'home',
  50. to: '/',
  51. },
  52. {
  53. text: t('控制台'),
  54. itemKey: 'detail',
  55. to: '/detail',
  56. },
  57. {
  58. text: t('定价'),
  59. itemKey: 'pricing',
  60. to: '/pricing',
  61. },
  62. ...(docsLink
  63. ? [
  64. {
  65. text: t('文档'),
  66. itemKey: 'docs',
  67. isExternal: true,
  68. externalLink: docsLink,
  69. },
  70. ]
  71. : []),
  72. {
  73. text: t('关于'),
  74. itemKey: 'about',
  75. to: '/about',
  76. },
  77. ];
  78. async function logout() {
  79. await API.get('/api/user/logout');
  80. showSuccess(t('注销成功!'));
  81. userDispatch({ type: 'logout' });
  82. localStorage.removeItem('user');
  83. navigate('/login');
  84. setMobileMenuOpen(false);
  85. }
  86. const handleNewYearClick = () => {
  87. fireworks.init('root', {});
  88. fireworks.start();
  89. setTimeout(() => {
  90. fireworks.stop();
  91. }, 3000);
  92. };
  93. useEffect(() => {
  94. if (theme === 'dark') {
  95. document.body.setAttribute('theme-mode', 'dark');
  96. document.documentElement.classList.add('dark');
  97. } else {
  98. document.body.removeAttribute('theme-mode');
  99. document.documentElement.classList.remove('dark');
  100. }
  101. const iframe = document.querySelector('iframe');
  102. if (iframe) {
  103. iframe.contentWindow.postMessage({ themeMode: theme }, '*');
  104. }
  105. }, [theme, isNewYear]);
  106. useEffect(() => {
  107. const handleLanguageChanged = (lng) => {
  108. setCurrentLang(lng);
  109. const iframe = document.querySelector('iframe');
  110. if (iframe) {
  111. iframe.contentWindow.postMessage({ lang: lng }, '*');
  112. }
  113. };
  114. i18n.on('languageChanged', handleLanguageChanged);
  115. return () => {
  116. i18n.off('languageChanged', handleLanguageChanged);
  117. };
  118. }, [i18n]);
  119. useEffect(() => {
  120. // 模拟加载用户状态的过程
  121. const timer = setTimeout(() => {
  122. setIsLoading(false);
  123. }, 500);
  124. return () => clearTimeout(timer);
  125. }, []);
  126. const handleLanguageChange = (lang) => {
  127. i18n.changeLanguage(lang);
  128. setMobileMenuOpen(false);
  129. };
  130. const handleNavLinkClick = (itemKey) => {
  131. if (itemKey === 'home') {
  132. styleDispatch({ type: 'SET_INNER_PADDING', payload: false });
  133. styleDispatch({ type: 'SET_SIDER', payload: false });
  134. } else {
  135. styleDispatch({ type: 'SET_INNER_PADDING', payload: true });
  136. if (!styleState.isMobile) {
  137. styleDispatch({ type: 'SET_SIDER', payload: true });
  138. }
  139. }
  140. setMobileMenuOpen(false);
  141. };
  142. const renderNavLinks = (isMobileView = false) =>
  143. mainNavLinks.map((link) => {
  144. const commonLinkClasses = isMobileView
  145. ? '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'
  146. : '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';
  147. const linkContent = (
  148. <span>{link.text}</span>
  149. );
  150. if (link.isExternal) {
  151. return (
  152. <a
  153. key={link.itemKey}
  154. href={link.externalLink}
  155. target='_blank'
  156. rel='noopener noreferrer'
  157. className={commonLinkClasses}
  158. onClick={() => handleNavLinkClick(link.itemKey)}
  159. >
  160. {linkContent}
  161. </a>
  162. );
  163. }
  164. return (
  165. <Link
  166. key={link.itemKey}
  167. to={link.to}
  168. className={commonLinkClasses}
  169. onClick={() => handleNavLinkClick(link.itemKey)}
  170. >
  171. {linkContent}
  172. </Link>
  173. );
  174. });
  175. const renderUserArea = () => {
  176. if (isLoading) {
  177. return (
  178. <div className="flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1">
  179. <Skeleton.Avatar size="extra-small" className="shadow-sm" />
  180. <div className="ml-1.5 mr-1">
  181. <Skeleton.Title style={{ width: styleState.isMobile ? 15 : 50, height: 12 }} />
  182. </div>
  183. </div>
  184. );
  185. }
  186. if (userState.user) {
  187. return (
  188. <Dropdown
  189. position="bottomRight"
  190. render={
  191. <Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
  192. <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">
  193. {t('退出')}
  194. </Dropdown.Item>
  195. </Dropdown.Menu>
  196. }
  197. >
  198. <Button
  199. theme="borderless"
  200. type="tertiary"
  201. 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"
  202. >
  203. <Avatar
  204. size="extra-small"
  205. color={stringToColor(userState.user.username)}
  206. className="mr-1"
  207. >
  208. {userState.user.username[0].toUpperCase()}
  209. </Avatar>
  210. <span className="hidden md:inline">
  211. <Typography.Text className="!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1">
  212. {userState.user.username}
  213. </Typography.Text>
  214. </span>
  215. <IconChevronDown className="text-xs text-semi-color-text-2 dark:text-gray-400" />
  216. </Button>
  217. </Dropdown>
  218. );
  219. } else {
  220. const showRegisterButton = !isSelfUseMode;
  221. const commonSizingAndLayoutClass = "flex items-center justify-center !py-[10px] !px-1.5";
  222. 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";
  223. let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`;
  224. let registerButtonClasses = `${commonSizingAndLayoutClass}`;
  225. const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5";
  226. const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5";
  227. if (showRegisterButton) {
  228. if (styleState.isMobile) {
  229. loginButtonClasses += " !rounded-full";
  230. } else {
  231. loginButtonClasses += " !rounded-l-full !rounded-r-none";
  232. }
  233. registerButtonClasses += " !rounded-r-full !rounded-l-none";
  234. } else {
  235. loginButtonClasses += " !rounded-full";
  236. }
  237. return (
  238. <div className="flex items-center">
  239. <Link to="/login" onClick={() => handleNavLinkClick('login')} className="flex">
  240. <Button
  241. theme="borderless"
  242. type="tertiary"
  243. className={loginButtonClasses}
  244. >
  245. <span className={loginButtonTextSpanClass}>
  246. {t('登录')}
  247. </span>
  248. </Button>
  249. </Link>
  250. {showRegisterButton && (
  251. <div className="hidden md:block">
  252. <Link to="/register" onClick={() => handleNavLinkClick('register')} className="flex -ml-px">
  253. <Button
  254. theme="solid"
  255. type="primary"
  256. className={registerButtonClasses}
  257. >
  258. <span className={registerButtonTextSpanClass}>
  259. {t('注册')}
  260. </span>
  261. </Button>
  262. </Link>
  263. </div>
  264. )}
  265. </div>
  266. );
  267. }
  268. };
  269. return (
  270. <header className="bg-semi-color-bg-0 text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300">
  271. <div className="w-full px-4">
  272. <div className="flex items-center justify-between h-16">
  273. <div className="flex items-center">
  274. <div className="md:hidden">
  275. <Button
  276. icon={mobileMenuOpen ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />}
  277. aria-label={mobileMenuOpen ? t('关闭菜单') : t('打开菜单')}
  278. onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
  279. theme="borderless"
  280. type="tertiary"
  281. className="!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700"
  282. />
  283. </div>
  284. <Link to="/" onClick={() => handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2">
  285. <img src={logo} alt="logo" className="h-7 md:h-8 transition-transform duration-300 ease-in-out group-hover:scale-105" />
  286. <div className="hidden md:flex items-center gap-2">
  287. <div className="flex items-center gap-2">
  288. <Typography.Title heading={4} className="!text-lg !font-semibold !mb-0
  289. bg-gradient-to-r from-blue-500 to-purple-500 dark:from-blue-400 dark:to-purple-400
  290. bg-clip-text text-transparent">
  291. {systemName}
  292. </Typography.Title>
  293. {(isSelfUseMode || isDemoSiteMode) && (
  294. <Tag
  295. color={isSelfUseMode ? 'purple' : 'blue'}
  296. className="text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm"
  297. size="small"
  298. >
  299. {isSelfUseMode ? t('自用模式') : t('演示站点')}
  300. </Tag>
  301. )}
  302. </div>
  303. </div>
  304. </Link>
  305. {(isSelfUseMode || isDemoSiteMode) && (
  306. <div className="md:hidden">
  307. <Tag
  308. color={isSelfUseMode ? 'purple' : 'blue'}
  309. className="ml-2 text-xs px-1 py-0.5 rounded whitespace-nowrap shadow-sm"
  310. size="small"
  311. >
  312. {isSelfUseMode ? t('自用模式') : t('演示站点')}
  313. </Tag>
  314. </div>
  315. )}
  316. <nav className="hidden md:flex items-center gap-1 lg:gap-2 ml-6">
  317. {renderNavLinks()}
  318. </nav>
  319. </div>
  320. <div className="flex items-center gap-2 md:gap-3">
  321. {isNewYear && (
  322. <Dropdown
  323. position="bottomRight"
  324. render={
  325. <Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
  326. <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">
  327. Happy New Year!!! 🎉
  328. </Dropdown.Item>
  329. </Dropdown.Menu>
  330. }
  331. >
  332. <Button
  333. theme="borderless"
  334. type="tertiary"
  335. icon={<span className="text-xl">🎉</span>}
  336. aria-label="New Year"
  337. className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full"
  338. />
  339. </Dropdown>
  340. )}
  341. <Button
  342. icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}
  343. aria-label={t('切换主题')}
  344. onClick={() => setTheme(theme === 'dark' ? false : true)}
  345. theme="borderless"
  346. type="tertiary"
  347. 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"
  348. />
  349. <Dropdown
  350. position="bottomRight"
  351. render={
  352. <Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
  353. <Dropdown.Item
  354. onClick={() => handleLanguageChange('zh')}
  355. 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'}`}
  356. >
  357. <CN title="中文" className="!w-5 !h-auto" />
  358. <span>中文</span>
  359. </Dropdown.Item>
  360. <Dropdown.Item
  361. onClick={() => handleLanguageChange('en')}
  362. 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'}`}
  363. >
  364. <GB title="English" className="!w-5 !h-auto" />
  365. <span>English</span>
  366. </Dropdown.Item>
  367. </Dropdown.Menu>
  368. }
  369. >
  370. <Button
  371. icon={<IconLanguage className="text-lg" />}
  372. aria-label={t('切换语言')}
  373. theme="borderless"
  374. type="tertiary"
  375. 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"
  376. />
  377. </Dropdown>
  378. {renderUserArea()}
  379. </div>
  380. </div>
  381. </div>
  382. <div className="md:hidden">
  383. <div
  384. className={`
  385. absolute top-16 left-0 right-0 bg-semi-color-bg-0
  386. shadow-lg p-3
  387. transform transition-all duration-300 ease-in-out
  388. ${mobileMenuOpen ? 'translate-y-0 opacity-100 visible' : '-translate-y-4 opacity-0 invisible'}
  389. `}
  390. >
  391. <nav className="flex flex-col gap-1">
  392. {renderNavLinks(true)}
  393. </nav>
  394. </div>
  395. </div>
  396. </header>
  397. );
  398. };
  399. export default HeaderBar;