refactor(web): 侧边栏菜单改用 Ant Design Menu 组件
用 Ant Design <Menu mode="inline"> 替代自定义 div 渲染,对齐 ProLayout 体验: - buildMenuItems() 将后端 MenuInfo 树转为 Menu items 格式 - 目录图标渲染(HeartOutlined/FormOutlined 等) - 原生折叠动画 + 侧边栏折叠时 popover 子菜单 - openKeys 自动展开包含当前路由的父级 - 键盘导航 + ARIA 无障碍(Menu 内置) - 插件菜单合并为统一 Menu items - 删除 ~150 行自定义组件,清理对应 CSS Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -836,6 +836,11 @@ body {
|
|||||||
* Layout Utilities
|
* Layout Utilities
|
||||||
* ==================================================================== */
|
* ==================================================================== */
|
||||||
|
|
||||||
|
/* Ant Design Menu 主题覆盖 — 侧边栏 */
|
||||||
|
.erp-sidebar-menu {
|
||||||
|
border-inline-end: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.erp-sidebar-menu .ant-menu-item {
|
.erp-sidebar-menu .ant-menu-item {
|
||||||
margin: 1px 8px !important;
|
margin: 1px 8px !important;
|
||||||
border-radius: var(--erp-radius-md) !important;
|
border-radius: var(--erp-radius-md) !important;
|
||||||
@@ -843,6 +848,13 @@ body {
|
|||||||
line-height: 36px !important;
|
line-height: 36px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.erp-sidebar-menu .ant-menu-submenu-title {
|
||||||
|
margin: 1px 8px !important;
|
||||||
|
border-radius: var(--erp-radius-md) !important;
|
||||||
|
height: 36px !important;
|
||||||
|
line-height: 36px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.erp-sidebar-menu .ant-menu-item-selected {
|
.erp-sidebar-menu .ant-menu-item-selected {
|
||||||
background: var(--erp-bg-sidebar-active) !important;
|
background: var(--erp-bg-sidebar-active) !important;
|
||||||
color: var(--erp-text-sidebar-active) !important;
|
color: var(--erp-text-sidebar-active) !important;
|
||||||
@@ -856,47 +868,6 @@ body {
|
|||||||
background: var(--erp-bg-sidebar-hover) !important;
|
background: var(--erp-bg-sidebar-hover) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar group label */
|
|
||||||
.erp-sidebar-group {
|
|
||||||
padding: 16px 20px 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.8px;
|
|
||||||
color: var(--erp-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar collapsible directory group */
|
|
||||||
.erp-sidebar-group-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px 20px 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.8px;
|
|
||||||
color: var(--erp-text-tertiary);
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.erp-sidebar-group-toggle:hover {
|
|
||||||
color: var(--erp-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.erp-sidebar-group-arrow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-right: 6px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.erp-sidebar-group-label {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ====================================================================
|
/* ====================================================================
|
||||||
* MainLayout — CSS classes replacing inline styles
|
* MainLayout — CSS classes replacing inline styles
|
||||||
* ==================================================================== */
|
* ==================================================================== */
|
||||||
@@ -951,92 +922,6 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar menu item */
|
|
||||||
.erp-sidebar-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 36px;
|
|
||||||
margin: 1px 8px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: var(--erp-radius-md);
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--erp-text-sidebar);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 400;
|
|
||||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
will-change: background, color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-layout-sider-collapsed .erp-sidebar-item {
|
|
||||||
padding: 0;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.erp-sidebar-item:hover:not(.erp-sidebar-item-active) {
|
|
||||||
background: var(--erp-bg-sidebar-hover);
|
|
||||||
color: var(--erp-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.erp-sidebar-item-active {
|
|
||||||
background: var(--erp-bg-sidebar-active);
|
|
||||||
color: var(--erp-text-sidebar-active);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.erp-sidebar-item-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.erp-sidebar-item-label {
|
|
||||||
margin-left: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar sub-menu (plugin group) */
|
|
||||||
.erp-sidebar-submenu-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 32px;
|
|
||||||
margin: 6px 8px 2px 8px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: var(--erp-radius-md);
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--erp-text-tertiary);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
transition: all 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.erp-sidebar-submenu-title:hover {
|
|
||||||
background: var(--erp-bg-sidebar-hover);
|
|
||||||
color: var(--erp-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.erp-sidebar-submenu-title-active {
|
|
||||||
color: var(--erp-text-sidebar-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.erp-sidebar-submenu-arrow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-right: 8px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.erp-sidebar-submenu-label {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.erp-sidebar-item-indented {
|
|
||||||
padding-left: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main layout */
|
/* Main layout */
|
||||||
.erp-main-layout {
|
.erp-main-layout {
|
||||||
background: var(--erp-bg-page) !important;
|
background: var(--erp-bg-page) !important;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useCallback, useState, memo, useEffect, useMemo } from 'react';
|
import { useCallback, useState, useEffect, useMemo } from 'react';
|
||||||
import { Layout, Avatar, Space, Dropdown, Tooltip, Spin, theme } from 'antd';
|
import { Layout, Avatar, Space, Dropdown, Tooltip, Spin, theme, Menu } from 'antd';
|
||||||
|
import type { MenuItemType, SubMenuType } from 'antd/es/menu/hooks/useItems';
|
||||||
import {
|
import {
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
RightOutlined,
|
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
RobotOutlined,
|
RobotOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
@@ -23,12 +23,6 @@ import AiSidebar from '../components/ai/AiSidebar';
|
|||||||
|
|
||||||
const { Header, Sider, Content, Footer } = Layout;
|
const { Header, Sider, Content, Footer } = Layout;
|
||||||
|
|
||||||
interface MenuItem {
|
|
||||||
key: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 路由标题 fallback — 仅保留后端菜单无法覆盖的路由
|
// 路由标题 fallback — 仅保留后端菜单无法覆盖的路由
|
||||||
// 1. 动态参数路由(:id/:id/edit)— 菜单表不会存储这些路径
|
// 1. 动态参数路由(:id/:id/edit)— 菜单表不会存储这些路径
|
||||||
// 2. 无后端菜单记录的静态页面路由
|
// 2. 无后端菜单记录的静态页面路由
|
||||||
@@ -60,316 +54,62 @@ function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 侧边栏菜单项
|
// 将后端 MenuInfo 树转为 Ant Design Menu 的 items 格式
|
||||||
const SidebarMenuItem = memo(function SidebarMenuItem({
|
type AntMenuItem = MenuItemType | SubMenuType;
|
||||||
item,
|
|
||||||
isActive,
|
|
||||||
collapsed,
|
|
||||||
onClick,
|
|
||||||
indented,
|
|
||||||
}: {
|
|
||||||
item: MenuItem;
|
|
||||||
isActive: boolean;
|
|
||||||
collapsed: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
indented?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Tooltip title={collapsed ? item.label : ''} placement="right">
|
|
||||||
<div
|
|
||||||
onClick={onClick}
|
|
||||||
className={`erp-sidebar-item ${isActive ? 'erp-sidebar-item-active' : ''} ${indented ? 'erp-sidebar-item-indented' : ''}`}
|
|
||||||
>
|
|
||||||
<span className="erp-sidebar-item-icon">{item.icon}</span>
|
|
||||||
{!collapsed && <span className="erp-sidebar-item-label">{item.label}</span>}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 可折叠子分组(3 级菜单)
|
function buildMenuItems(menus: MenuInfo[]): AntMenuItem[] {
|
||||||
const CollapsibleSubGroup = memo(function CollapsibleSubGroup({
|
return menus
|
||||||
directory,
|
.filter((m) => m.visible !== false && m.menu_type !== 'button')
|
||||||
collapsed,
|
.map((m) => {
|
||||||
currentPath,
|
const visibleChildren = m.children?.filter((c) => c.visible !== false && c.menu_type !== 'button') || [];
|
||||||
onNavigate,
|
if ((m.menu_type === 'directory') && visibleChildren.length > 0) {
|
||||||
}: {
|
return {
|
||||||
directory: MenuInfo;
|
key: m.id,
|
||||||
collapsed: boolean;
|
icon: getIcon(m.icon),
|
||||||
currentPath: string;
|
label: m.title,
|
||||||
onNavigate: (key: string) => void;
|
children: buildMenuItems(visibleChildren),
|
||||||
}) {
|
};
|
||||||
const [expanded, setExpanded] = useState(false);
|
}
|
||||||
const visibleChildren = directory.children?.filter((c) => c.visible !== false) || [];
|
return {
|
||||||
const hasActive = visibleChildren.some((c) => currentPath === (c.path || c.id));
|
key: m.path || m.id,
|
||||||
|
icon: getIcon(m.icon),
|
||||||
useEffect(() => {
|
label: m.title,
|
||||||
if (hasActive) setExpanded(true); // eslint-disable-line react-hooks/set-state-in-effect -- 初始展开包含活跃菜单的分组
|
};
|
||||||
}, [hasActive]);
|
});
|
||||||
|
|
||||||
if (collapsed) {
|
|
||||||
return (
|
|
||||||
<Tooltip title={directory.title} placement="right">
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
const first = visibleChildren[0];
|
|
||||||
if (first) onNavigate(first.path || first.id);
|
|
||||||
}}
|
|
||||||
className={`erp-sidebar-item ${hasActive ? 'erp-sidebar-item-active' : ''}`}
|
|
||||||
>
|
|
||||||
<span className="erp-sidebar-item-icon">{getIcon(directory.icon)}</span>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className={`erp-sidebar-submenu-title ${hasActive ? 'erp-sidebar-submenu-title-active' : ''}`}
|
|
||||||
onClick={() => setExpanded((e) => !e)}
|
|
||||||
>
|
|
||||||
<span className="erp-sidebar-submenu-arrow">
|
|
||||||
<RightOutlined
|
|
||||||
style={{ fontSize: 10, transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="erp-sidebar-submenu-label">{directory.title}</span>
|
|
||||||
</div>
|
|
||||||
{expanded && visibleChildren.map((child) => (
|
|
||||||
<SidebarMenuItem
|
|
||||||
key={child.id}
|
|
||||||
item={{
|
|
||||||
key: child.path || child.id,
|
|
||||||
icon: getIcon(child.icon),
|
|
||||||
label: child.title,
|
|
||||||
}}
|
|
||||||
isActive={currentPath === (child.path || child.id)}
|
|
||||||
collapsed={collapsed}
|
|
||||||
onClick={() => onNavigate(child.path || child.id)}
|
|
||||||
indented
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 插件子菜单组
|
|
||||||
const SidebarSubMenu = memo(function SidebarSubMenu({
|
|
||||||
group,
|
|
||||||
collapsed,
|
|
||||||
currentPath,
|
|
||||||
onNavigate,
|
|
||||||
}: {
|
|
||||||
group: PluginMenuGroup;
|
|
||||||
collapsed: boolean;
|
|
||||||
currentPath: string;
|
|
||||||
onNavigate: (key: string) => void;
|
|
||||||
}) {
|
|
||||||
const [expanded, setExpanded] = useState(true);
|
|
||||||
const hasActive = group.items.some((item) => currentPath === item.key);
|
|
||||||
|
|
||||||
if (collapsed) {
|
|
||||||
const tooltipContent = group.items.map((item) => item.label).join(' / ');
|
|
||||||
return (
|
|
||||||
<Tooltip title={tooltipContent} placement="right">
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
const first = group.items[0];
|
|
||||||
if (first) onNavigate(first.key);
|
|
||||||
}}
|
|
||||||
className={`erp-sidebar-item ${hasActive ? 'erp-sidebar-item-active' : ''}`}
|
|
||||||
>
|
|
||||||
<span className="erp-sidebar-item-icon"><AppstoreOutlined /></span>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className={`erp-sidebar-submenu-title ${hasActive ? 'erp-sidebar-submenu-title-active' : ''}`}
|
|
||||||
onClick={() => setExpanded((e) => !e)}
|
|
||||||
>
|
|
||||||
<span className="erp-sidebar-submenu-arrow">
|
|
||||||
<RightOutlined
|
|
||||||
style={{ fontSize: 10, transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="erp-sidebar-submenu-label">{group.pluginName}</span>
|
|
||||||
</div>
|
|
||||||
{expanded && group.items.map((item) => (
|
|
||||||
<SidebarMenuItem
|
|
||||||
key={item.key}
|
|
||||||
item={{
|
|
||||||
key: item.key,
|
|
||||||
icon: getIcon(item.icon),
|
|
||||||
label: item.label,
|
|
||||||
}}
|
|
||||||
isActive={currentPath === item.key}
|
|
||||||
collapsed={collapsed}
|
|
||||||
onClick={() => onNavigate(item.key)}
|
|
||||||
indented
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 判断是否为可点击的叶子菜单类型
|
|
||||||
function isLeafType(menuType: string): boolean {
|
|
||||||
return menuType === 'menu' || menuType === 'page';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 一级目录分组(可折叠)
|
// 查找包含指定 path 的所有父级 key(用于自动展开 openKeys)
|
||||||
const CollapsibleDirectoryGroup = memo(function CollapsibleDirectoryGroup({
|
function findParentKeys(menus: MenuInfo[], targetPath: string): string[] {
|
||||||
directory,
|
const keys: string[] = [];
|
||||||
collapsed,
|
function walk(items: MenuInfo[], parents: string[]): boolean {
|
||||||
currentPath,
|
for (const m of items) {
|
||||||
onNavigate,
|
if (m.path === targetPath) {
|
||||||
}: {
|
keys.push(...parents);
|
||||||
directory: MenuInfo;
|
return true;
|
||||||
collapsed: boolean;
|
}
|
||||||
currentPath: string;
|
if (m.children) {
|
||||||
onNavigate: (key: string) => void;
|
if (walk(m.children, [...parents, m.id])) return true;
|
||||||
}) {
|
}
|
||||||
const [expanded, setExpanded] = useState(true);
|
|
||||||
const visibleChildren = directory.children?.filter((c) => c.visible !== false) || [];
|
|
||||||
const hasActive = visibleChildren.some((c) => {
|
|
||||||
if (c.menu_type === 'directory') {
|
|
||||||
return c.children?.some((gc) => currentPath === (gc.path || gc.id));
|
|
||||||
}
|
}
|
||||||
return currentPath === (c.path || c.id);
|
return false;
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasActive) setExpanded(true); // eslint-disable-line react-hooks/set-state-in-effect -- 导航到子菜单时自动展开分组
|
|
||||||
}, [hasActive]);
|
|
||||||
|
|
||||||
if (collapsed) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{visibleChildren.filter((c) => isLeafType(c.menu_type)).map((child) => (
|
|
||||||
<SidebarMenuItem
|
|
||||||
key={child.id}
|
|
||||||
item={{
|
|
||||||
key: child.path || child.id,
|
|
||||||
icon: getIcon(child.icon),
|
|
||||||
label: child.title,
|
|
||||||
}}
|
|
||||||
isActive={currentPath === (child.path || child.id)}
|
|
||||||
collapsed
|
|
||||||
onClick={() => onNavigate(child.path || child.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
walk(menus, []);
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
// 插件菜单也纳入 Menu items
|
||||||
<div>
|
function buildPluginItems(groups: PluginMenuGroup[]): AntMenuItem[] {
|
||||||
<div
|
return groups.map((g) => ({
|
||||||
className="erp-sidebar-group-toggle"
|
key: `plugin-${g.pluginId}`,
|
||||||
onClick={() => setExpanded((e) => !e)}
|
icon: <AppstoreOutlined />,
|
||||||
>
|
label: g.pluginName,
|
||||||
<span className="erp-sidebar-group-arrow">
|
children: g.items.map((item) => ({
|
||||||
<RightOutlined
|
key: item.key,
|
||||||
style={{ fontSize: 10, transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
icon: getIcon(item.icon),
|
||||||
/>
|
label: item.label,
|
||||||
</span>
|
})),
|
||||||
<span className="erp-sidebar-group-label">{directory.title}</span>
|
}));
|
||||||
</div>
|
}
|
||||||
<div
|
|
||||||
className="erp-sidebar-group-content"
|
|
||||||
style={{
|
|
||||||
maxHeight: expanded ? '2000px' : '0px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
transition: 'max-height 0.25s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="erp-sidebar-menu">
|
|
||||||
{visibleChildren.map((child) => {
|
|
||||||
if (child.menu_type === 'directory') {
|
|
||||||
return (
|
|
||||||
<CollapsibleSubGroup
|
|
||||||
key={child.id}
|
|
||||||
directory={child}
|
|
||||||
collapsed={collapsed}
|
|
||||||
currentPath={currentPath}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isLeafType(child.menu_type)) {
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem
|
|
||||||
key={child.id}
|
|
||||||
item={{
|
|
||||||
key: child.path || child.id,
|
|
||||||
icon: getIcon(child.icon),
|
|
||||||
label: child.title,
|
|
||||||
}}
|
|
||||||
isActive={currentPath === (child.path || child.id)}
|
|
||||||
collapsed={collapsed}
|
|
||||||
onClick={() => onNavigate(child.path || child.id)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 动态菜单渲染(支持多级嵌套)
|
|
||||||
const DynamicMenuSection = memo(function DynamicMenuSection({
|
|
||||||
menus,
|
|
||||||
collapsed,
|
|
||||||
currentPath,
|
|
||||||
onNavigate,
|
|
||||||
}: {
|
|
||||||
menus: MenuInfo[];
|
|
||||||
collapsed: boolean;
|
|
||||||
currentPath: string;
|
|
||||||
onNavigate: (key: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{menus.map((menu) => {
|
|
||||||
if (menu.menu_type === 'directory') {
|
|
||||||
return (
|
|
||||||
<CollapsibleDirectoryGroup
|
|
||||||
key={menu.id}
|
|
||||||
directory={menu}
|
|
||||||
collapsed={collapsed}
|
|
||||||
currentPath={currentPath}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isLeafType(menu.menu_type) && menu.visible !== false) {
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem
|
|
||||||
key={menu.id}
|
|
||||||
item={{
|
|
||||||
key: menu.path || menu.id,
|
|
||||||
icon: getIcon(menu.icon),
|
|
||||||
label: menu.title,
|
|
||||||
}}
|
|
||||||
isActive={currentPath === (menu.path || menu.id)}
|
|
||||||
collapsed={collapsed}
|
|
||||||
onClick={() => onNavigate(menu.path || menu.id)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||||
const { sidebarCollapsed, toggleSidebar } = useAppStore();
|
const { sidebarCollapsed, toggleSidebar } = useAppStore();
|
||||||
@@ -424,6 +164,35 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 合并动态菜单 + 插件菜单为 Ant Design Menu items
|
||||||
|
const allMenuItems = useMemo(() => {
|
||||||
|
const items = buildMenuItems(dynamicMenus);
|
||||||
|
if (pluginMenuGroups.length > 0) {
|
||||||
|
items.push(...buildPluginItems(pluginMenuGroups));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [dynamicMenus, pluginMenuGroups]);
|
||||||
|
|
||||||
|
// openKeys: 自动展开包含当前路由的父级
|
||||||
|
const autoExpandKeys = useMemo(() => {
|
||||||
|
const keys = findParentKeys(dynamicMenus, currentPath);
|
||||||
|
for (const g of pluginMenuGroups) {
|
||||||
|
if (g.items.some((it) => it.key === currentPath)) {
|
||||||
|
keys.push(`plugin-${g.pluginId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}, [currentPath, dynamicMenus, pluginMenuGroups]);
|
||||||
|
|
||||||
|
const [openKeys, setOpenKeys] = useState<string[]>([]);
|
||||||
|
const [lastExpandedPath, setLastExpandedPath] = useState(currentPath);
|
||||||
|
if (currentPath !== lastExpandedPath) {
|
||||||
|
setLastExpandedPath(currentPath);
|
||||||
|
if (autoExpandKeys.length > 0) {
|
||||||
|
setOpenKeys((prev) => [...new Set([...prev, ...autoExpandKeys])]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加载插件菜单
|
// 加载插件菜单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlugins(1, 'running');
|
fetchPlugins(1, 'running');
|
||||||
@@ -489,37 +258,23 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 动态菜单 */}
|
{/* 菜单 */}
|
||||||
{menuLoading ? (
|
{menuLoading ? (
|
||||||
<div style={{ padding: 16, textAlign: 'center' }}>
|
<div style={{ padding: 16, textAlign: 'center' }}>
|
||||||
<Spin size="small" />
|
<Spin size="small" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DynamicMenuSection
|
<Menu
|
||||||
menus={dynamicMenus}
|
mode="inline"
|
||||||
collapsed={sidebarCollapsed}
|
inlineCollapsed={sidebarCollapsed}
|
||||||
currentPath={currentPath}
|
items={allMenuItems}
|
||||||
onNavigate={(key) => navigate(key)}
|
selectedKeys={[currentPath]}
|
||||||
|
openKeys={openKeys}
|
||||||
|
onOpenChange={setOpenKeys}
|
||||||
|
onClick={({ key }) => navigate(key)}
|
||||||
|
className="erp-sidebar-menu"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 插件菜单 */}
|
|
||||||
{pluginMenuGroups.length > 0 && (
|
|
||||||
<>
|
|
||||||
{!sidebarCollapsed && <div className="erp-sidebar-group">插件</div>}
|
|
||||||
<div className="erp-sidebar-menu">
|
|
||||||
{pluginMenuGroups.map((group) => (
|
|
||||||
<SidebarSubMenu
|
|
||||||
key={group.pluginId}
|
|
||||||
group={group}
|
|
||||||
collapsed={sidebarCollapsed}
|
|
||||||
currentPath={currentPath}
|
|
||||||
onNavigate={(key) => navigate(key)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Sider>
|
</Sider>
|
||||||
|
|
||||||
{/* 右侧主区域 */}
|
{/* 右侧主区域 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user