SiderBar.js 13 KB

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