Files
MemoryBear/web/src/components/SiderMenu/index.tsx
2026-04-27 10:48:03 +08:00

460 lines
17 KiB
TypeScript

/*
* @Author: ZhaoYing
* @Date: 2026-02-02 15:25:31
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-21 17:56:09
*/
/**
* SiderMenu Component
*
* A collapsible sidebar navigation menu with:
* - Dynamic menu generation from configuration
* - Active state management with icon switching
* - Nested submenu support
* - Workspace/space context switching
* - Role-based menu filtering
* - Internationalization support
*
* @component
*/
import { UserOutlined } from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { Menu as AntMenu, Divider, Flex, Layout } from 'antd';
import clsx from 'clsx';
import { useEffect, useRef, useState, type FC } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { getTenantSubscription } from '@/api/user';
import logo from '@/assets/images/logo.png';
import { useI18n } from '@/store/locale';
import { useMenu, type MenuItem } from '@/store/menu';
import { useUser } from '@/store/user';
import styles from './index.module.css';
import SubscriptionDetailModal, { type SubscriptionDetailModalRef } from './SubscriptionDetailModal';
import SwitchSpaceModal, { type SwitchSpaceModalRef } from './SwitchSpaceModal';
// Import SVG files
// space
import apiKeyIcon from '@/assets/images/menuNew/apiKey.svg';
import apiKeyActiveIcon from '@/assets/images/menuNew/apiKey_active.svg';
import applicationIcon from '@/assets/images/menuNew/application.svg';
import applicationActiveIcon from '@/assets/images/menuNew/application_active.svg';
import dashboardIcon from '@/assets/images/menuNew/dashboard.svg';
import dashboardActiveIcon from '@/assets/images/menuNew/dashboard_active.svg';
import knowledgeIcon from '@/assets/images/menuNew/knowledge.svg';
import knowledgeActiveIcon from '@/assets/images/menuNew/knowledge_active.svg';
import memberIcon from '@/assets/images/menuNew/member.svg';
import memberActiveIcon from '@/assets/images/menuNew/member_active.svg';
import memoryIcon from '@/assets/images/menuNew/memory.svg';
import memoryActiveIcon from '@/assets/images/menuNew/memory_active.svg';
import memoryConversationIcon from '@/assets/images/menuNew/memoryConversation.svg';
import memoryConversationActiveIcon from '@/assets/images/menuNew/memoryConversation_active.svg';
import ontologyIcon from '@/assets/images/menuNew/ontology.svg';
import ontologyActiveIcon from '@/assets/images/menuNew/ontology_active.svg';
import promptIcon from '@/assets/images/menuNew/prompt.svg';
import promptActiveIcon from '@/assets/images/menuNew/prompt_active.svg';
import spaceConfigIcon from '@/assets/images/menuNew/spaceConfig.svg';
import spaceConfigActiveIcon from '@/assets/images/menuNew/spaceConfig_active.svg';
import userMemoryIcon from '@/assets/images/menuNew/userMemory.svg';
import userMemoryActiveIcon from '@/assets/images/menuNew/userMemory_active.svg';
// manage
import modelIcon from '@/assets/images/menuNew/model.svg';
import modelActiveIcon from '@/assets/images/menuNew/model_active.svg';
import pricingIcon from '@/assets/images/menuNew/pricing.svg';
import pricingActiveIcon from '@/assets/images/menuNew/pricing_active.svg';
import skillsIcon from '@/assets/images/menuNew/skills.svg';
import skillsActiveIcon from '@/assets/images/menuNew/skills_active.svg';
import spaceIcon from '@/assets/images/menuNew/space.svg';
import spaceActiveIcon from '@/assets/images/menuNew/space_active.svg';
import toolIcon from '@/assets/images/menuNew/tool.svg';
import toolActiveIcon from '@/assets/images/menuNew/tool_active.svg';
import userIcon from '@/assets/images/menuNew/user.svg';
import userActiveIcon from '@/assets/images/menuNew/user_active.svg';
export interface PackagePlan {
id: string
name: string
name_en?: string
version: string
category: string
tier_level: number
price: number
billing_cycle: string
core_value?: string
core_value_en?: string
tech_support?: string
tech_support_en?: string
sla_compliance?: string
sla_compliance_en?: string
page_customization?: string
page_customization_en?: string
theme_color?: string
}
export interface SubscriptionQuota {
app_quota: number
model_quota: number
skill_quota: number
end_user_quota: number
workspace_quota: number
api_ops_rate_limit: number
memory_engine_quota: number
ontology_project_quota: number
knowledge_capacity_quota: number
}
export interface Subscription {
subscription_id: string | null
tenant_id: string
package_plan_id: string
package_version: string
package_plan: PackagePlan
started_at: number | null
expired_at: number | null
status: string
quotas: SubscriptionQuota
created_at: number
updated_at: number
}
/** Icon path mapping table for menu items (normal and active states) */
const iconPathMap: Record<string, string> = {
'dashboard': dashboardIcon,
'dashboardActive': dashboardActiveIcon,
'model': modelIcon,
'modelActive': modelActiveIcon,
'memory': memoryIcon,
'memoryActive': memoryActiveIcon,
'space': spaceIcon,
'spaceActive': spaceActiveIcon,
'user': userIcon,
'userActive': userActiveIcon,
'userMemory': userMemoryIcon,
'userMemoryActive': userMemoryActiveIcon,
'application': applicationIcon,
'applicationActive': applicationActiveIcon,
'knowledge': knowledgeIcon,
'knowledgeActive': knowledgeActiveIcon,
'memoryConversation': memoryConversationIcon,
'memoryConversationActive': memoryConversationActiveIcon,
'member': memberIcon,
'memberActive': memberActiveIcon,
'tool': toolIcon,
'toolActive': toolActiveIcon,
'apiKey': apiKeyIcon,
'apiKeyActive': apiKeyActiveIcon,
'pricing': pricingIcon,
'pricingActive': pricingActiveIcon,
'spaceConfig': spaceConfigIcon,
'spaceConfigActive': spaceConfigActiveIcon,
'ontology': ontologyIcon,
'ontologyActive': ontologyActiveIcon,
'prompt': promptIcon,
'promptActive': promptActiveIcon,
'skills': skillsIcon,
'skillsActive': skillsActiveIcon,
};
const { Sider } = Layout;
/** Sidebar menu component with collapsible navigation */
const Menu: FC<{
/** Menu display mode */
mode?: 'vertical' | 'horizontal' | 'inline';
/** Menu context (space or manage) */
source?: 'space' | 'manage';
}> = ({ mode = 'inline', source = 'manage' }) => {
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const { language } = useI18n()
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const { allMenus, collapsed, loadMenus, toggleSider } = useMenu()
const [menus, setMenus] = useState<MenuItem[]>([])
const { user, storageType } = useUser()
const subscriptionDetailRef = useRef<SubscriptionDetailModalRef>(null)
const switchSpaceModalRef = useRef<SwitchSpaceModalRef>(null)
/** Filter menus based on user role and source */
useEffect(() => {
if (!user) return
let menuList: MenuItem[] = []
if (user.role === 'member' && source === 'space') {
menuList = (allMenus[source] || []).filter(menu => menu.code !== 'member')
} else if (user) {
menuList = allMenus[source] || []
}
const noAuthList = ['user', 'pricing'].filter(vo => (Array.isArray(user.permissions) && !user.permissions?.includes(vo) && !user.permissions?.includes('all')) || !Array.isArray(user.permissions))
if (noAuthList && !noAuthList?.includes('all')) {
const filterMenus = (list: MenuItem[]): MenuItem[] =>{
const filterList = list?.filter(menu => !noAuthList?.includes(menu.code as string))
const showList: MenuItem[] = []
filterList?.forEach(menu => {
const filteredSubs = filterMenus(menu.subs || [])
const hadSubs = menu.subs && menu.subs.length > 0
if (hadSubs && filteredSubs.length === 0) return
if (menu.type === 'group' && (!menu.subs || menu.subs?.length < 1)) return
showList.push({ ...menu, subs: filteredSubs })
})
return showList
}
menuList = filterMenus(menuList)
}
setMenus(menuList)
}, [source, allMenus, user])
/** Handle menu item click and navigate to path */
const handleMenuClick: MenuProps['onClick'] = (e) => {
const path = e.key;
if (path) {
navigate(path);
setSelectedKeys([path]);
}
};
/** Convert custom menu format to Ant Design Menu items format */
const generateMenuItems = (menuList: MenuItem[]): MenuProps['items'] => {
const items: MenuProps['items'] = [];
const filteredMenus = menuList.filter(menu => menu.display);
filteredMenus.forEach((menu, index) => {
const iconKey = selectedKeys.includes(menu.path || '') ? `${menu.code}Active` : menu.code;
const iconSrc = iconPathMap[iconKey as keyof typeof iconPathMap];
const subs = (menu.subs || []).filter(sub => sub.display);
/** Leaf node - menu item without children */
if (!subs || subs.length === 0) {
if (menu.path) {
items.push({
key: menu.path,
title: menu.i18nKey ? t(menu.i18nKey) : menu.label,
label: (
<span data-menu-id={menu.path}>
{menu.i18nKey ? t(menu.i18nKey) : menu.label}
</span>
),
icon: iconSrc ? <img
src={iconSrc}
className="rb:w-4 rb:h-4"
alt={iconSrc}
/> : null,
});
}
} else {
/** Node with submenu - menu item with children */
const menuLabel = collapsed && menu.type === 'group'? '': menu.i18nKey ? t(menu.i18nKey) : menu.label;
const children = generateMenuItems(subs) || [];
items.push({
key: `submenu-${menu.id}`,
...(menu.type === 'group' ? { type: 'group' as const } : {}),
title: menuLabel,
label: menuLabel,
icon: iconSrc ? <img
src={iconSrc}
className="rb:w-4 rb:h-4"
alt={iconSrc}
/> : <UserOutlined/>,
children,
});
}
/** Add divider after group items (except the last one) */
if (menu.type === 'group' && index < filteredMenus.length - 1) {
items.push({ type: 'divider', key: `divider-${menu.id}` });
}
});
return items;
};
/** Generate menu items from configuration */
const menuItems = generateMenuItems(menus);
/** Load menus on component mount */
useEffect(() => {
loadMenus(source);
}, [])
/** Handle current path matching and update selected keys */
useEffect(() => {
/** Use location.pathname to get current path, ensuring consistency with routing system */
const currentPath = location.pathname || '/';
/** Try to find matching menu item and corresponding parent menu path */
const findMatchingKey = (menuList: MenuItem[], parentPaths: string[] = []): { key: string | null; } => {
for (const menu of menuList) {
if (menu.path) {
const menuPath = menu.path?.[0] !== '/' ? '/' + menu.path : menu.path;
/** Exact match or path prefix match (ensure complete path segment match) */
const isExactMatch = menuPath === currentPath;
const isPrefixMatch = currentPath.startsWith(menuPath + '/') ||
currentPath === menuPath;
if (isExactMatch || isPrefixMatch) {
return { key: menu.path };
}
}
/** Recursively check submenus */
if (menu.subs && menu.subs.length > 0) {
const newParentPaths = [...parentPaths, `submenu-${menu.id}`];
const found = findMatchingKey(menu.subs, newParentPaths);
if (found.key) {
return found;
}
}
}
return { key: null };
};
const { key: matchingKey } = findMatchingKey(menus);
if (matchingKey) {
setSelectedKeys([matchingKey]);
} else {
setSelectedKeys([])
}
}, [menus, location.pathname]);
/** Navigate to space list and clear user cache */
const goToSpace = () => {
navigate('/space')
localStorage.removeItem('user')
}
const [subscription, setSubscription] = useState<Subscription | null>(null)
useEffect(() => {
if (source === 'manage') {
getTenantSubscription()
.then(res => {
setSubscription(res as Subscription)
})
} else {
setSubscription(null)
}
}, [source])
const getKeyWithLanguage = (key: string) => {
return (language === 'en' ? `${key}_en` : key) as keyof Subscription['package_plan']
}
const handleViewDetail = () => {
subscriptionDetailRef.current?.handleOpen(subscription)
}
const handleSwitchSpace = () => {
switchSpaceModalRef.current?.handleOpen()
}
return (
<Sider
width={240}
collapsedWidth={64}
collapsed={collapsed}
className={styles.sider}
>
{/* Sidebar header with logo/workspace name and collapse toggle */}
<div className={clsx(styles.title, {
[styles.collapsed]: collapsed,
'rb:flex rb:items-center rb:text-[14px]! rb:py-2!': !collapsed && source === 'space' && user.current_workspace_name,
})}>
{!collapsed && source === 'space' && user.current_workspace_name
? <Flex gap={9}>
<Flex align="center" justify="center" className="rb:size-10 rb:rounded-xl rb:bg-[#171719] rb:text-white rb:text-[18px] rb:font-medium">{user.current_workspace_name[0]}</Flex>
<div className="rb:w-32">
<div className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:font-medium rb:text-[16px] rb:leading-5.5">{user.current_workspace_name}</div>
<span className="rb:text-[14px] rb:text-[#5B6167] rb:leading-5 rb:font-regular">
{t(`space.${storageType}`)}
</span>
</div>
</Flex>
: !collapsed
? <Flex>
<img src={logo} className={styles.logo}
alt={logo} />
{t('title')}
</Flex>
: null
}
<div className={clsx("rb:cursor-pointer rb:size-5 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/menuFold.svg')]", {
'rb:rotate-180': collapsed
})} onClick={toggleSider}></div>
</div>
{/* Main navigation menu */}
<AntMenu
style={{ borderRight: 0 }}
mode={mode}
selectedKeys={selectedKeys}
// openKeys={openKeys}
onClick={handleMenuClick}
items={menuItems}
inlineCollapsed={collapsed}
inlineIndent={10}
className="rb:overflow-y-auto rb:flex-1!"
/>
{/* Return to space button for superusers */}
{source === 'space' &&
<Flex gap={4} vertical className="rb:my-3! rb:mx-3!">
<Divider className="rb:mb-2.5! rb:mt-0! rb:border-[#DFE4ED]! rb:mx-2! rb:min-w-[calc(100%-20px)]! rb:w-[calc(100%-20px)]!" />
<Flex
gap={8}
align="center"
justify="start"
onClick={handleSwitchSpace}
className="rb:p-2.5! rb:text-[13px] rb:hover:bg-[rgba(223,228,237,0.5)] rb:rounded-lg rb:leading-3.5 rb:font-regular rb:text-center rb:cursor-pointer"
>
<div className="rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/switch.svg')]"></div>
{collapsed ? null : t('common.switchSpace')}
</Flex>
{user?.is_superuser &&
<Flex
gap={8}
align="center"
justify="start"
onClick={goToSpace}
className="rb:p-2.5! rb:text-[13px] rb:hover:bg-[rgba(223,228,237,0.5)] rb:rounded-lg rb:leading-3.5 rb:font-regular rb:text-center rb:cursor-pointer"
>
<div className="rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/return.svg')]"></div>
{collapsed ? null : t('common.returnToSpace')}
</Flex>
}
</Flex>
}
{source === 'manage' && subscription && !collapsed &&
<div className="rb:mb-3 rb:ml-3 rb:mr-3 rb:py-3 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/package_bg.png')] rb:overflow-hidden rb:rounded-xl">
<div className="rb:h-4.5 rb:flex-1 rb:truncate rb:px-3 rb:text-[13px] rb:font-medium rb:leading-4.5">{subscription.package_plan?.[getKeyWithLanguage('name')]}</div>
<div className="rb:grid rb:grid-cols-4 rb:mt-4">
{['workspace_quota', 'skill_quota', 'app_quota', 'model_quota'].map(key => (
<div key={key} className="rb:text-center">
<div className="rb:text-[13px] rb:font-[MiSans-Semibold] rb:font-semibold">{subscription.quotas?.[key as keyof typeof subscription.quotas] ?? t('package.noLimit')}</div>
<div className="rb:mt-1 rb:text-[#5B6167] rb:text-[10px] rb:leading-3.5">{t(`index.${key}`)}</div>
</div>
))}
</div>
<Flex align="center" justify="center" className="rb:mt-4! rb:border rb:p-2! rb:text-[12px] rb:leading-4 rb:mx-3! rb:rounded-lg rb:cursor-pointer"
onClick={handleViewDetail}
>
{t('package.viewDetail')}
<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/index/arrow_right_dark.svg')]"></div>
</Flex>
</div>
}
<SubscriptionDetailModal
ref={subscriptionDetailRef}
/>
<SwitchSpaceModal
ref={switchSpaceModalRef}
/>
</Sider>
);
};
export default Menu;