| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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', |
| 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: '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)', |
| background: 'var(--semi-color-bg-0)', |
| }} |
| > |
| <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; |
|
|