| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532 |
- /*
- Copyright (C) 2025 QuantumNous
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <https://www.gnu.org/licenses/>.
- For commercial licensing, please contact support@quantumnous.com
- */
- import React, { useEffect, useMemo, useState } from 'react';
- import { Link, useLocation } from 'react-router-dom';
- import { useTranslation } from 'react-i18next';
- import { getLucideIcon } from '../../helpers/render';
- import { ChevronLeft } from 'lucide-react';
- import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
- import { useSidebar } from '../../hooks/common/useSidebar';
- import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
- import { isAdmin, isRoot, showError } from '../../helpers';
- import SkeletonWrapper from './components/SkeletonWrapper';
- import { Nav, Divider, Button } from '@douyinfe/semi-ui';
- const routerMap = {
- home: '/',
- channel: '/console/channel',
- token: '/console/token',
- redemption: '/console/redemption',
- topup: '/console/topup',
- user: '/console/user',
- subscription: '/console/subscription',
- log: '/console/log',
- midjourney: '/console/midjourney',
- setting: '/console/setting',
- about: '/about',
- detail: '/console',
- pricing: '/pricing',
- task: '/console/task',
- models: '/console/models',
- deployment: '/console/deployment',
- playground: '/console/playground',
- personal: '/console/personal',
- };
- const SiderBar = ({ onNavigate = () => {} }) => {
- const { t } = useTranslation();
- const [collapsed, toggleCollapsed] = useSidebarCollapsed();
- const {
- isModuleVisible,
- hasSectionVisibleModules,
- loading: sidebarLoading,
- } = useSidebar();
- const showSkeleton = useMinimumLoadingTime(sidebarLoading, 200);
- const [selectedKeys, setSelectedKeys] = useState(['home']);
- const [chatItems, setChatItems] = useState([]);
- const [openedKeys, setOpenedKeys] = useState([]);
- const location = useLocation();
- const [routerMapState, setRouterMapState] = useState(routerMap);
- const workspaceItems = useMemo(() => {
- const items = [
- {
- text: t('数据看板'),
- itemKey: 'detail',
- to: '/detail',
- className:
- localStorage.getItem('enable_data_export') === 'true'
- ? ''
- : 'tableHiddle',
- },
- {
- text: t('令牌管理'),
- itemKey: 'token',
- to: '/token',
- },
- {
- text: t('使用日志'),
- itemKey: 'log',
- to: '/log',
- },
- {
- text: t('绘图日志'),
- itemKey: 'midjourney',
- to: '/midjourney',
- className:
- localStorage.getItem('enable_drawing') === 'true'
- ? ''
- : 'tableHiddle',
- },
- {
- text: t('任务日志'),
- itemKey: 'task',
- to: '/task',
- className:
- localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
- },
- ];
- // 根据配置过滤项目
- const filteredItems = items.filter((item) => {
- const configVisible = isModuleVisible('console', item.itemKey);
- return configVisible;
- });
- return filteredItems;
- }, [
- localStorage.getItem('enable_data_export'),
- localStorage.getItem('enable_drawing'),
- localStorage.getItem('enable_task'),
- t,
- isModuleVisible,
- ]);
- const financeItems = useMemo(() => {
- const items = [
- {
- text: t('钱包管理'),
- itemKey: 'topup',
- to: '/topup',
- },
- {
- text: t('个人设置'),
- itemKey: 'personal',
- to: '/personal',
- },
- ];
- // 根据配置过滤项目
- const filteredItems = items.filter((item) => {
- const configVisible = isModuleVisible('personal', item.itemKey);
- return configVisible;
- });
- return filteredItems;
- }, [t, isModuleVisible]);
- const adminItems = useMemo(() => {
- const items = [
- {
- text: t('渠道管理'),
- itemKey: 'channel',
- to: '/channel',
- className: isAdmin() ? '' : 'tableHiddle',
- },
- {
- text: t('订阅管理'),
- itemKey: 'subscription',
- to: '/subscription',
- className: isAdmin() ? '' : 'tableHiddle',
- },
- {
- text: t('模型管理'),
- itemKey: 'models',
- to: '/console/models',
- className: isAdmin() ? '' : 'tableHiddle',
- },
- {
- text: t('模型部署'),
- itemKey: 'deployment',
- to: '/deployment',
- className: isAdmin() ? '' : 'tableHiddle',
- },
- {
- text: t('兑换码管理'),
- itemKey: 'redemption',
- to: '/redemption',
- className: isAdmin() ? '' : 'tableHiddle',
- },
- {
- text: t('用户管理'),
- itemKey: 'user',
- to: '/user',
- className: isAdmin() ? '' : 'tableHiddle',
- },
- {
- text: t('系统设置'),
- itemKey: 'setting',
- to: '/setting',
- className: isRoot() ? '' : 'tableHiddle',
- },
- ];
- // 根据配置过滤项目
- const filteredItems = items.filter((item) => {
- const configVisible = isModuleVisible('admin', item.itemKey);
- return configVisible;
- });
- return filteredItems;
- }, [isAdmin(), isRoot(), t, isModuleVisible]);
- const chatMenuItems = useMemo(() => {
- const items = [
- {
- text: t('操练场'),
- itemKey: 'playground',
- to: '/playground',
- },
- {
- text: t('聊天'),
- itemKey: 'chat',
- items: chatItems,
- },
- ];
- // 根据配置过滤项目
- const filteredItems = items.filter((item) => {
- const configVisible = isModuleVisible('chat', item.itemKey);
- return configVisible;
- });
- return filteredItems;
- }, [chatItems, t, isModuleVisible]);
- // 更新路由映射,添加聊天路由
- const updateRouterMapWithChats = (chats) => {
- const newRouterMap = { ...routerMap };
- if (Array.isArray(chats) && chats.length > 0) {
- for (let i = 0; i < chats.length; i++) {
- newRouterMap['chat' + i] = '/console/chat/' + i;
- }
- }
- setRouterMapState(newRouterMap);
- return newRouterMap;
- };
- // 加载聊天项
- useEffect(() => {
- let chats = localStorage.getItem('chats');
- if (chats) {
- try {
- chats = JSON.parse(chats);
- if (Array.isArray(chats)) {
- let chatItems = [];
- for (let i = 0; i < chats.length; i++) {
- let shouldSkip = false;
- let chat = {};
- for (let key in chats[i]) {
- let link = chats[i][key];
- if (typeof link !== 'string') continue; // 确保链接是字符串
- if (link.startsWith('fluent') || link.startsWith('ccswitch')) {
- shouldSkip = true;
- break;
- }
- chat.text = key;
- chat.itemKey = 'chat' + i;
- chat.to = '/console/chat/' + i;
- }
- if (shouldSkip || !chat.text) continue; // 避免推入空项
- chatItems.push(chat);
- }
- setChatItems(chatItems);
- updateRouterMapWithChats(chats);
- }
- } catch (e) {
- showError('聊天数据解析失败');
- }
- }
- }, []);
- // 根据当前路径设置选中的菜单项
- useEffect(() => {
- const currentPath = location.pathname;
- let matchingKey = Object.keys(routerMapState).find(
- (key) => routerMapState[key] === currentPath,
- );
- // 处理聊天路由
- if (!matchingKey && currentPath.startsWith('/console/chat/')) {
- const chatIndex = currentPath.split('/').pop();
- if (!isNaN(chatIndex)) {
- matchingKey = 'chat' + chatIndex;
- } else {
- matchingKey = 'chat';
- }
- }
- // 如果找到匹配的键,更新选中的键
- if (matchingKey) {
- setSelectedKeys([matchingKey]);
- }
- }, [location.pathname, routerMapState]);
- // 监控折叠状态变化以更新 body class
- useEffect(() => {
- if (collapsed) {
- document.body.classList.add('sidebar-collapsed');
- } else {
- document.body.classList.remove('sidebar-collapsed');
- }
- }, [collapsed]);
- // 选中高亮颜色(统一)
- const SELECTED_COLOR = 'var(--semi-color-primary)';
- // 渲染自定义菜单项
- const renderNavItem = (item) => {
- // 跳过隐藏的项目
- if (item.className === 'tableHiddle') return null;
- const isSelected = selectedKeys.includes(item.itemKey);
- const textColor = isSelected ? SELECTED_COLOR : 'inherit';
- return (
- <Nav.Item
- key={item.itemKey}
- itemKey={item.itemKey}
- text={
- <span
- className='truncate font-medium text-sm'
- style={{ color: textColor }}
- >
- {item.text}
- </span>
- }
- icon={
- <div className='sidebar-icon-container flex-shrink-0'>
- {getLucideIcon(item.itemKey, isSelected)}
- </div>
- }
- className={item.className}
- />
- );
- };
- // 渲染子菜单项
- const renderSubItem = (item) => {
- if (item.items && item.items.length > 0) {
- const isSelected = selectedKeys.includes(item.itemKey);
- const textColor = isSelected ? SELECTED_COLOR : 'inherit';
- return (
- <Nav.Sub
- key={item.itemKey}
- itemKey={item.itemKey}
- text={
- <span
- className='truncate font-medium text-sm'
- style={{ color: textColor }}
- >
- {item.text}
- </span>
- }
- icon={
- <div className='sidebar-icon-container flex-shrink-0'>
- {getLucideIcon(item.itemKey, isSelected)}
- </div>
- }
- >
- {item.items.map((subItem) => {
- const isSubSelected = selectedKeys.includes(subItem.itemKey);
- const subTextColor = isSubSelected ? SELECTED_COLOR : 'inherit';
- return (
- <Nav.Item
- key={subItem.itemKey}
- itemKey={subItem.itemKey}
- text={
- <span
- className='truncate font-medium text-sm'
- style={{ color: subTextColor }}
- >
- {subItem.text}
- </span>
- }
- />
- );
- })}
- </Nav.Sub>
- );
- } else {
- return renderNavItem(item);
- }
- };
- return (
- <div
- className='sidebar-container'
- style={{
- width: 'var(--sidebar-current-width)',
- }}
- >
- <SkeletonWrapper
- loading={showSkeleton}
- type='sidebar'
- className=''
- collapsed={collapsed}
- showAdmin={isAdmin()}
- >
- <Nav
- className='sidebar-nav'
- defaultIsCollapsed={collapsed}
- isCollapsed={collapsed}
- onCollapseChange={toggleCollapsed}
- selectedKeys={selectedKeys}
- itemStyle='sidebar-nav-item'
- hoverStyle='sidebar-nav-item:hover'
- selectedStyle='sidebar-nav-item-selected'
- renderWrapper={({ itemElement, props }) => {
- const to =
- routerMapState[props.itemKey] || routerMap[props.itemKey];
- // 如果没有路由,直接返回元素
- if (!to) return itemElement;
- return (
- <Link
- style={{ textDecoration: 'none' }}
- to={to}
- onClick={onNavigate}
- >
- {itemElement}
- </Link>
- );
- }}
- onSelect={(key) => {
- // 如果点击的是已经展开的子菜单的父项,则收起子菜单
- if (openedKeys.includes(key.itemKey)) {
- setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
- }
- setSelectedKeys([key.itemKey]);
- }}
- openKeys={openedKeys}
- onOpenChange={(data) => {
- setOpenedKeys(data.openKeys);
- }}
- >
- {/* 聊天区域 */}
- {hasSectionVisibleModules('chat') && (
- <div className='sidebar-section'>
- {!collapsed && (
- <div className='sidebar-group-label'>{t('聊天')}</div>
- )}
- {chatMenuItems.map((item) => renderSubItem(item))}
- </div>
- )}
- {/* 控制台区域 */}
- {hasSectionVisibleModules('console') && (
- <>
- <Divider className='sidebar-divider' />
- <div>
- {!collapsed && (
- <div className='sidebar-group-label'>{t('控制台')}</div>
- )}
- {workspaceItems.map((item) => renderNavItem(item))}
- </div>
- </>
- )}
- {/* 个人中心区域 */}
- {hasSectionVisibleModules('personal') && (
- <>
- <Divider className='sidebar-divider' />
- <div>
- {!collapsed && (
- <div className='sidebar-group-label'>{t('个人中心')}</div>
- )}
- {financeItems.map((item) => renderNavItem(item))}
- </div>
- </>
- )}
- {/* 管理员区域 - 只在管理员时显示且配置允许时显示 */}
- {isAdmin() && hasSectionVisibleModules('admin') && (
- <>
- <Divider className='sidebar-divider' />
- <div>
- {!collapsed && (
- <div className='sidebar-group-label'>{t('管理员')}</div>
- )}
- {adminItems.map((item) => renderNavItem(item))}
- </div>
- </>
- )}
- </Nav>
- </SkeletonWrapper>
- {/* 底部折叠按钮 */}
- <div className='sidebar-collapse-button'>
- <SkeletonWrapper
- loading={showSkeleton}
- type='button'
- width={collapsed ? 36 : 156}
- height={24}
- className='w-full'
- >
- <Button
- theme='outline'
- type='tertiary'
- size='small'
- icon={
- <ChevronLeft
- size={16}
- strokeWidth={2.5}
- color='var(--semi-color-text-2)'
- style={{
- transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)',
- }}
- />
- }
- onClick={toggleCollapsed}
- icononly={collapsed}
- style={
- collapsed
- ? { width: 36, height: 24, padding: 0 }
- : { padding: '4px 12px', width: '100%' }
- }
- >
- {!collapsed ? t('收起侧边栏') : null}
- </Button>
- </SkeletonWrapper>
- </div>
- </div>
- );
- };
- export default SiderBar;
|