/* * @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 = { '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([]); const { allMenus, collapsed, loadMenus, toggleSider } = useMenu() const [menus, setMenus] = useState([]) const { user, storageType } = useUser() const subscriptionDetailRef = useRef(null) const switchSpaceModalRef = useRef(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: ( {menu.i18nKey ? t(menu.i18nKey) : menu.label} ), icon: iconSrc ? {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 ? {iconSrc} : , 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(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 ( {/* Sidebar header with logo/workspace name and collapse toggle */}
{!collapsed && source === 'space' && user.current_workspace_name ? {user.current_workspace_name[0]}
{user.current_workspace_name}
{t(`space.${storageType}`)}
: !collapsed ? {logo} {t('title')} : null }
{/* Main navigation menu */} {/* Return to space button for superusers */} {source === 'space' &&
{collapsed ? null : t('common.switchSpace')}
{user?.is_superuser &&
{collapsed ? null : t('common.returnToSpace')}
}
} {source === 'manage' && subscription && !collapsed &&
{subscription.package_plan?.[getKeyWithLanguage('name')]}
{['workspace_quota', 'skill_quota', 'app_quota', 'model_quota'].map(key => (
{subscription.quotas?.[key as keyof typeof subscription.quotas] ?? t('package.noLimit')}
{t(`index.${key}`)}
))}
{t('package.viewDetail')}
}
); }; export default Menu;