SiderBar.jsx 15 KB

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