SiderBar.jsx 15 KB

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