SiderBar.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  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 shouldSkip = false;
  188. let chat = {};
  189. for (let key in chats[i]) {
  190. let link = chats[i][key];
  191. if (typeof link !== 'string') continue; // 确保链接是字符串
  192. if (link.startsWith('fluent')) {
  193. shouldSkip = true;
  194. break; // 跳过 Fluent Read
  195. }
  196. chat.text = key;
  197. chat.itemKey = 'chat' + i;
  198. chat.to = '/console/chat/' + i;
  199. }
  200. if (shouldSkip || !chat.text) continue; // 避免推入空项
  201. chatItems.push(chat);
  202. }
  203. setChatItems(chatItems);
  204. updateRouterMapWithChats(chats);
  205. }
  206. } catch (e) {
  207. console.error(e);
  208. showError('聊天数据解析失败');
  209. }
  210. }
  211. }, []);
  212. // 根据当前路径设置选中的菜单项
  213. useEffect(() => {
  214. const currentPath = location.pathname;
  215. let matchingKey = Object.keys(routerMapState).find(
  216. (key) => routerMapState[key] === currentPath,
  217. );
  218. // 处理聊天路由
  219. if (!matchingKey && currentPath.startsWith('/console/chat/')) {
  220. const chatIndex = currentPath.split('/').pop();
  221. if (!isNaN(chatIndex)) {
  222. matchingKey = 'chat' + chatIndex;
  223. } else {
  224. matchingKey = 'chat';
  225. }
  226. }
  227. // 如果找到匹配的键,更新选中的键
  228. if (matchingKey) {
  229. setSelectedKeys([matchingKey]);
  230. }
  231. }, [location.pathname, routerMapState]);
  232. // 监控折叠状态变化以更新 body class
  233. useEffect(() => {
  234. if (collapsed) {
  235. document.body.classList.add('sidebar-collapsed');
  236. } else {
  237. document.body.classList.remove('sidebar-collapsed');
  238. }
  239. }, [collapsed]);
  240. // 选中高亮颜色(统一)
  241. const SELECTED_COLOR = 'var(--semi-color-primary)';
  242. // 渲染自定义菜单项
  243. const renderNavItem = (item) => {
  244. // 跳过隐藏的项目
  245. if (item.className === 'tableHiddle') return null;
  246. const isSelected = selectedKeys.includes(item.itemKey);
  247. const textColor = isSelected ? SELECTED_COLOR : 'inherit';
  248. return (
  249. <Nav.Item
  250. key={item.itemKey}
  251. itemKey={item.itemKey}
  252. text={
  253. <div className="flex items-center">
  254. <span className="truncate font-medium text-sm" style={{ color: textColor }}>
  255. {item.text}
  256. </span>
  257. </div>
  258. }
  259. icon={
  260. <div className="sidebar-icon-container flex-shrink-0">
  261. {getLucideIcon(item.itemKey, isSelected)}
  262. </div>
  263. }
  264. className={item.className}
  265. />
  266. );
  267. };
  268. // 渲染子菜单项
  269. const renderSubItem = (item) => {
  270. if (item.items && item.items.length > 0) {
  271. const isSelected = selectedKeys.includes(item.itemKey);
  272. const textColor = isSelected ? SELECTED_COLOR : 'inherit';
  273. return (
  274. <Nav.Sub
  275. key={item.itemKey}
  276. itemKey={item.itemKey}
  277. text={
  278. <div className="flex items-center">
  279. <span className="truncate font-medium text-sm" style={{ color: textColor }}>
  280. {item.text}
  281. </span>
  282. </div>
  283. }
  284. icon={
  285. <div className="sidebar-icon-container flex-shrink-0">
  286. {getLucideIcon(item.itemKey, isSelected)}
  287. </div>
  288. }
  289. >
  290. {item.items.map((subItem) => {
  291. const isSubSelected = selectedKeys.includes(subItem.itemKey);
  292. const subTextColor = isSubSelected ? SELECTED_COLOR : 'inherit';
  293. return (
  294. <Nav.Item
  295. key={subItem.itemKey}
  296. itemKey={subItem.itemKey}
  297. text={
  298. <span className="truncate font-medium text-sm" style={{ color: subTextColor }}>
  299. {subItem.text}
  300. </span>
  301. }
  302. />
  303. );
  304. })}
  305. </Nav.Sub>
  306. );
  307. } else {
  308. return renderNavItem(item);
  309. }
  310. };
  311. return (
  312. <div
  313. className="sidebar-container"
  314. style={{ width: 'var(--sidebar-current-width)' }}
  315. >
  316. <Nav
  317. className="sidebar-nav"
  318. defaultIsCollapsed={collapsed}
  319. isCollapsed={collapsed}
  320. onCollapseChange={toggleCollapsed}
  321. selectedKeys={selectedKeys}
  322. itemStyle="sidebar-nav-item"
  323. hoverStyle="sidebar-nav-item:hover"
  324. selectedStyle="sidebar-nav-item-selected"
  325. renderWrapper={({ itemElement, props }) => {
  326. const to = routerMapState[props.itemKey] || routerMap[props.itemKey];
  327. // 如果没有路由,直接返回元素
  328. if (!to) return itemElement;
  329. return (
  330. <Link
  331. style={{ textDecoration: 'none' }}
  332. to={to}
  333. onClick={onNavigate}
  334. >
  335. {itemElement}
  336. </Link>
  337. );
  338. }}
  339. onSelect={(key) => {
  340. // 如果点击的是已经展开的子菜单的父项,则收起子菜单
  341. if (openedKeys.includes(key.itemKey)) {
  342. setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
  343. }
  344. setSelectedKeys([key.itemKey]);
  345. }}
  346. openKeys={openedKeys}
  347. onOpenChange={(data) => {
  348. setOpenedKeys(data.openKeys);
  349. }}
  350. >
  351. {/* 聊天区域 */}
  352. <div className="sidebar-section">
  353. {!collapsed && (
  354. <div className="sidebar-group-label">{t('聊天')}</div>
  355. )}
  356. {chatMenuItems.map((item) => renderSubItem(item))}
  357. </div>
  358. {/* 控制台区域 */}
  359. <Divider className="sidebar-divider" />
  360. <div>
  361. {!collapsed && (
  362. <div className="sidebar-group-label">{t('控制台')}</div>
  363. )}
  364. {workspaceItems.map((item) => renderNavItem(item))}
  365. </div>
  366. {/* 管理员区域 - 只在管理员时显示 */}
  367. {isAdmin() && (
  368. <>
  369. <Divider className="sidebar-divider" />
  370. <div>
  371. {!collapsed && (
  372. <div className="sidebar-group-label">{t('管理员')}</div>
  373. )}
  374. {adminItems.map((item) => renderNavItem(item))}
  375. </div>
  376. </>
  377. )}
  378. {/* 个人中心区域 */}
  379. <Divider className="sidebar-divider" />
  380. <div>
  381. {!collapsed && (
  382. <div className="sidebar-group-label">{t('个人中心')}</div>
  383. )}
  384. {financeItems.map((item) => renderNavItem(item))}
  385. </div>
  386. </Nav>
  387. {/* 底部折叠按钮 */}
  388. <div className="sidebar-collapse-button">
  389. <Button
  390. theme="outline"
  391. type="tertiary"
  392. size="small"
  393. icon={
  394. <ChevronLeft
  395. size={16}
  396. strokeWidth={2.5}
  397. color="var(--semi-color-text-2)"
  398. style={{ transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
  399. />
  400. }
  401. onClick={toggleCollapsed}
  402. icononly={collapsed}
  403. style={collapsed ? { padding: '4px', width: '100%' } : { padding: '4px 12px', width: '100%' }}
  404. >
  405. {!collapsed ? t('收起侧边栏') : null}
  406. </Button>
  407. </div>
  408. </div>
  409. );
  410. };
  411. export default SiderBar;