SiderBar.jsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useEffect, useMemo, useState } from 'react';
  16. import { Link, useLocation } from 'react-router-dom';
  17. import { useTranslation } from 'react-i18next';
  18. import { getLucideIcon } from '../../helpers/render';
  19. import { ChevronLeft } from 'lucide-react';
  20. import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
  21. import { useSidebar } from '../../hooks/common/useSidebar';
  22. import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
  23. import { isAdmin, isRoot, showError } from '../../helpers';
  24. import SkeletonWrapper from './components/SkeletonWrapper';
  25. import { Nav, Divider, Button } from '@douyinfe/semi-ui';
  26. const routerMap = {
  27. home: '/',
  28. channel: '/console/channel',
  29. token: '/console/token',
  30. redemption: '/console/redemption',
  31. topup: '/console/topup',
  32. user: '/console/user',
  33. log: '/console/log',
  34. midjourney: '/console/midjourney',
  35. setting: '/console/setting',
  36. about: '/about',
  37. detail: '/console',
  38. pricing: '/pricing',
  39. task: '/console/task',
  40. models: '/console/models',
  41. deployment: '/console/deployment',
  42. playground: '/console/playground',
  43. personal: '/console/personal',
  44. };
  45. const SiderBar = ({ onNavigate = () => {} }) => {
  46. const { t } = useTranslation();
  47. const [collapsed, toggleCollapsed] = useSidebarCollapsed();
  48. const {
  49. isModuleVisible,
  50. hasSectionVisibleModules,
  51. loading: sidebarLoading,
  52. } = useSidebar();
  53. const showSkeleton = useMinimumLoadingTime(sidebarLoading, 200);
  54. const [selectedKeys, setSelectedKeys] = useState(['home']);
  55. const [chatItems, setChatItems] = useState([]);
  56. const [openedKeys, setOpenedKeys] = useState([]);
  57. const location = useLocation();
  58. const [routerMapState, setRouterMapState] = useState(routerMap);
  59. const workspaceItems = useMemo(() => {
  60. const items = [
  61. {
  62. text: t('数据看板'),
  63. itemKey: 'detail',
  64. to: '/detail',
  65. className:
  66. localStorage.getItem('enable_data_export') === 'true'
  67. ? ''
  68. : 'tableHiddle',
  69. },
  70. {
  71. text: t('令牌管理'),
  72. itemKey: 'token',
  73. to: '/token',
  74. },
  75. {
  76. text: t('使用日志'),
  77. itemKey: 'log',
  78. to: '/log',
  79. },
  80. {
  81. text: t('绘图日志'),
  82. itemKey: 'midjourney',
  83. to: '/midjourney',
  84. className:
  85. localStorage.getItem('enable_drawing') === 'true'
  86. ? ''
  87. : 'tableHiddle',
  88. },
  89. {
  90. text: t('任务日志'),
  91. itemKey: 'task',
  92. to: '/task',
  93. className:
  94. localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
  95. },
  96. ];
  97. // 根据配置过滤项目
  98. const filteredItems = items.filter((item) => {
  99. const configVisible = isModuleVisible('console', item.itemKey);
  100. return configVisible;
  101. });
  102. return filteredItems;
  103. }, [
  104. localStorage.getItem('enable_data_export'),
  105. localStorage.getItem('enable_drawing'),
  106. localStorage.getItem('enable_task'),
  107. t,
  108. isModuleVisible,
  109. ]);
  110. const financeItems = useMemo(() => {
  111. const items = [
  112. {
  113. text: t('钱包管理'),
  114. itemKey: 'topup',
  115. to: '/topup',
  116. },
  117. {
  118. text: t('个人设置'),
  119. itemKey: 'personal',
  120. to: '/personal',
  121. },
  122. ];
  123. // 根据配置过滤项目
  124. const filteredItems = items.filter((item) => {
  125. const configVisible = isModuleVisible('personal', item.itemKey);
  126. return configVisible;
  127. });
  128. return filteredItems;
  129. }, [t, isModuleVisible]);
  130. const adminItems = useMemo(() => {
  131. const items = [
  132. {
  133. text: t('渠道管理'),
  134. itemKey: 'channel',
  135. to: '/channel',
  136. className: isAdmin() ? '' : 'tableHiddle',
  137. },
  138. {
  139. text: t('模型管理'),
  140. itemKey: 'models',
  141. to: '/console/models',
  142. className: isAdmin() ? '' : 'tableHiddle',
  143. },
  144. {
  145. text: t('模型部署'),
  146. itemKey: 'deployment',
  147. to: '/deployment',
  148. className: isAdmin() ? '' : 'tableHiddle',
  149. },
  150. {
  151. text: t('兑换码管理'),
  152. itemKey: 'redemption',
  153. to: '/redemption',
  154. className: isAdmin() ? '' : 'tableHiddle',
  155. },
  156. {
  157. text: t('用户管理'),
  158. itemKey: 'user',
  159. to: '/user',
  160. className: isAdmin() ? '' : 'tableHiddle',
  161. },
  162. {
  163. text: t('系统设置'),
  164. itemKey: 'setting',
  165. to: '/setting',
  166. className: isRoot() ? '' : 'tableHiddle',
  167. },
  168. ];
  169. // 根据配置过滤项目
  170. const filteredItems = items.filter((item) => {
  171. const configVisible = isModuleVisible('admin', item.itemKey);
  172. return configVisible;
  173. });
  174. return filteredItems;
  175. }, [isAdmin(), isRoot(), t, isModuleVisible]);
  176. const chatMenuItems = useMemo(() => {
  177. const items = [
  178. {
  179. text: t('操练场'),
  180. itemKey: 'playground',
  181. to: '/playground',
  182. },
  183. {
  184. text: t('聊天'),
  185. itemKey: 'chat',
  186. items: chatItems,
  187. },
  188. ];
  189. // 根据配置过滤项目
  190. const filteredItems = items.filter((item) => {
  191. const configVisible = isModuleVisible('chat', item.itemKey);
  192. return configVisible;
  193. });
  194. return filteredItems;
  195. }, [chatItems, t, isModuleVisible]);
  196. // 更新路由映射,添加聊天路由
  197. const updateRouterMapWithChats = (chats) => {
  198. const newRouterMap = { ...routerMap };
  199. if (Array.isArray(chats) && chats.length > 0) {
  200. for (let i = 0; i < chats.length; i++) {
  201. newRouterMap['chat' + i] = '/console/chat/' + i;
  202. }
  203. }
  204. setRouterMapState(newRouterMap);
  205. return newRouterMap;
  206. };
  207. // 加载聊天项
  208. useEffect(() => {
  209. let chats = localStorage.getItem('chats');
  210. if (chats) {
  211. try {
  212. chats = JSON.parse(chats);
  213. if (Array.isArray(chats)) {
  214. let chatItems = [];
  215. for (let i = 0; i < chats.length; i++) {
  216. let shouldSkip = false;
  217. let chat = {};
  218. for (let key in chats[i]) {
  219. let link = chats[i][key];
  220. if (typeof link !== 'string') continue; // 确保链接是字符串
  221. if (link.startsWith('fluent')) {
  222. shouldSkip = true;
  223. break; // 跳过 Fluent Read
  224. }
  225. chat.text = key;
  226. chat.itemKey = 'chat' + i;
  227. chat.to = '/console/chat/' + i;
  228. }
  229. if (shouldSkip || !chat.text) continue; // 避免推入空项
  230. chatItems.push(chat);
  231. }
  232. setChatItems(chatItems);
  233. updateRouterMapWithChats(chats);
  234. }
  235. } catch (e) {
  236. showError('聊天数据解析失败');
  237. }
  238. }
  239. }, []);
  240. // 根据当前路径设置选中的菜单项
  241. useEffect(() => {
  242. const currentPath = location.pathname;
  243. let matchingKey = Object.keys(routerMapState).find(
  244. (key) => routerMapState[key] === currentPath,
  245. );
  246. // 处理聊天路由
  247. if (!matchingKey && currentPath.startsWith('/console/chat/')) {
  248. const chatIndex = currentPath.split('/').pop();
  249. if (!isNaN(chatIndex)) {
  250. matchingKey = 'chat' + chatIndex;
  251. } else {
  252. matchingKey = 'chat';
  253. }
  254. }
  255. // 如果找到匹配的键,更新选中的键
  256. if (matchingKey) {
  257. setSelectedKeys([matchingKey]);
  258. }
  259. }, [location.pathname, routerMapState]);
  260. // 监控折叠状态变化以更新 body class
  261. useEffect(() => {
  262. if (collapsed) {
  263. document.body.classList.add('sidebar-collapsed');
  264. } else {
  265. document.body.classList.remove('sidebar-collapsed');
  266. }
  267. }, [collapsed]);
  268. // 选中高亮颜色(统一)
  269. const SELECTED_COLOR = 'var(--semi-color-primary)';
  270. // 渲染自定义菜单项
  271. const renderNavItem = (item) => {
  272. // 跳过隐藏的项目
  273. if (item.className === 'tableHiddle') return null;
  274. const isSelected = selectedKeys.includes(item.itemKey);
  275. const textColor = isSelected ? SELECTED_COLOR : 'inherit';
  276. return (
  277. <Nav.Item
  278. key={item.itemKey}
  279. itemKey={item.itemKey}
  280. text={
  281. <span
  282. className='truncate font-medium text-sm'
  283. style={{ color: textColor }}
  284. >
  285. {item.text}
  286. </span>
  287. }
  288. icon={
  289. <div className='sidebar-icon-container flex-shrink-0'>
  290. {getLucideIcon(item.itemKey, isSelected)}
  291. </div>
  292. }
  293. className={item.className}
  294. />
  295. );
  296. };
  297. // 渲染子菜单项
  298. const renderSubItem = (item) => {
  299. if (item.items && item.items.length > 0) {
  300. const isSelected = selectedKeys.includes(item.itemKey);
  301. const textColor = isSelected ? SELECTED_COLOR : 'inherit';
  302. return (
  303. <Nav.Sub
  304. key={item.itemKey}
  305. itemKey={item.itemKey}
  306. text={
  307. <span
  308. className='truncate font-medium text-sm'
  309. style={{ color: textColor }}
  310. >
  311. {item.text}
  312. </span>
  313. }
  314. icon={
  315. <div className='sidebar-icon-container flex-shrink-0'>
  316. {getLucideIcon(item.itemKey, isSelected)}
  317. </div>
  318. }
  319. >
  320. {item.items.map((subItem) => {
  321. const isSubSelected = selectedKeys.includes(subItem.itemKey);
  322. const subTextColor = isSubSelected ? SELECTED_COLOR : 'inherit';
  323. return (
  324. <Nav.Item
  325. key={subItem.itemKey}
  326. itemKey={subItem.itemKey}
  327. text={
  328. <span
  329. className='truncate font-medium text-sm'
  330. style={{ color: subTextColor }}
  331. >
  332. {subItem.text}
  333. </span>
  334. }
  335. />
  336. );
  337. })}
  338. </Nav.Sub>
  339. );
  340. } else {
  341. return renderNavItem(item);
  342. }
  343. };
  344. return (
  345. <div
  346. className='sidebar-container'
  347. style={{
  348. width: 'var(--sidebar-current-width)',
  349. }}
  350. >
  351. <SkeletonWrapper
  352. loading={showSkeleton}
  353. type='sidebar'
  354. className=''
  355. collapsed={collapsed}
  356. showAdmin={isAdmin()}
  357. >
  358. <Nav
  359. className='sidebar-nav'
  360. defaultIsCollapsed={collapsed}
  361. isCollapsed={collapsed}
  362. onCollapseChange={toggleCollapsed}
  363. selectedKeys={selectedKeys}
  364. itemStyle='sidebar-nav-item'
  365. hoverStyle='sidebar-nav-item:hover'
  366. selectedStyle='sidebar-nav-item-selected'
  367. renderWrapper={({ itemElement, props }) => {
  368. const to =
  369. routerMapState[props.itemKey] || routerMap[props.itemKey];
  370. // 如果没有路由,直接返回元素
  371. if (!to) return itemElement;
  372. return (
  373. <Link
  374. style={{ textDecoration: 'none' }}
  375. to={to}
  376. onClick={onNavigate}
  377. >
  378. {itemElement}
  379. </Link>
  380. );
  381. }}
  382. onSelect={(key) => {
  383. // 如果点击的是已经展开的子菜单的父项,则收起子菜单
  384. if (openedKeys.includes(key.itemKey)) {
  385. setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
  386. }
  387. setSelectedKeys([key.itemKey]);
  388. }}
  389. openKeys={openedKeys}
  390. onOpenChange={(data) => {
  391. setOpenedKeys(data.openKeys);
  392. }}
  393. >
  394. {/* 聊天区域 */}
  395. {hasSectionVisibleModules('chat') && (
  396. <div className='sidebar-section'>
  397. {!collapsed && (
  398. <div className='sidebar-group-label'>{t('聊天')}</div>
  399. )}
  400. {chatMenuItems.map((item) => renderSubItem(item))}
  401. </div>
  402. )}
  403. {/* 控制台区域 */}
  404. {hasSectionVisibleModules('console') && (
  405. <>
  406. <Divider className='sidebar-divider' />
  407. <div>
  408. {!collapsed && (
  409. <div className='sidebar-group-label'>{t('控制台')}</div>
  410. )}
  411. {workspaceItems.map((item) => renderNavItem(item))}
  412. </div>
  413. </>
  414. )}
  415. {/* 个人中心区域 */}
  416. {hasSectionVisibleModules('personal') && (
  417. <>
  418. <Divider className='sidebar-divider' />
  419. <div>
  420. {!collapsed && (
  421. <div className='sidebar-group-label'>{t('个人中心')}</div>
  422. )}
  423. {financeItems.map((item) => renderNavItem(item))}
  424. </div>
  425. </>
  426. )}
  427. {/* 管理员区域 - 只在管理员时显示且配置允许时显示 */}
  428. {isAdmin() && hasSectionVisibleModules('admin') && (
  429. <>
  430. <Divider className='sidebar-divider' />
  431. <div>
  432. {!collapsed && (
  433. <div className='sidebar-group-label'>{t('管理员')}</div>
  434. )}
  435. {adminItems.map((item) => renderNavItem(item))}
  436. </div>
  437. </>
  438. )}
  439. </Nav>
  440. </SkeletonWrapper>
  441. {/* 底部折叠按钮 */}
  442. <div className='sidebar-collapse-button'>
  443. <SkeletonWrapper
  444. loading={showSkeleton}
  445. type='button'
  446. width={collapsed ? 36 : 156}
  447. height={24}
  448. className='w-full'
  449. >
  450. <Button
  451. theme='outline'
  452. type='tertiary'
  453. size='small'
  454. icon={
  455. <ChevronLeft
  456. size={16}
  457. strokeWidth={2.5}
  458. color='var(--semi-color-text-2)'
  459. style={{
  460. transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)',
  461. }}
  462. />
  463. }
  464. onClick={toggleCollapsed}
  465. icononly={collapsed}
  466. style={
  467. collapsed
  468. ? { width: 36, height: 24, padding: 0 }
  469. : { padding: '4px 12px', width: '100%' }
  470. }
  471. >
  472. {!collapsed ? t('收起侧边栏') : null}
  473. </Button>
  474. </SkeletonWrapper>
  475. </div>
  476. </div>
  477. );
  478. };
  479. export default SiderBar;