|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|