| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | 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', |
| | 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: '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')) { |
| | 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]); |
| |
|
| | |
| | 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; |
| |
|