SiderBar.js 12 KB

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