SiderBar.js 13 KB

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