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:
iven
2026-05-21 12:08:19 +08:00
parent 8c9d177642
commit 831d2ba598
2 changed files with 104 additions and 464 deletions

View File

@@ -836,6 +836,11 @@ body {
* Layout Utilities
* ==================================================================== */
/* Ant Design Menu 主题覆盖 — 侧边栏 */
.erp-sidebar-menu {
border-inline-end: none !important;
}
.erp-sidebar-menu .ant-menu-item {
margin: 1px 8px !important;
border-radius: var(--erp-radius-md) !important;
@@ -843,6 +848,13 @@ body {
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 {
background: var(--erp-bg-sidebar-active) !important;
color: var(--erp-text-sidebar-active) !important;
@@ -856,47 +868,6 @@ body {
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
* ==================================================================== */
@@ -951,92 +922,6 @@ body {
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 */
.erp-main-layout {
background: var(--erp-bg-page) !important;

View File

@@ -1,12 +1,12 @@
import { useCallback, useState, memo, useEffect, useMemo } from 'react';
import { Layout, Avatar, Space, Dropdown, Tooltip, Spin, theme } from 'antd';
import { useCallback, useState, useEffect, useMemo } from 'react';
import { Layout, Avatar, Space, Dropdown, Tooltip, Spin, theme, Menu } from 'antd';
import type { MenuItemType, SubMenuType } from 'antd/es/menu/hooks/useItems';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
LogoutOutlined,
SearchOutlined,
AppstoreOutlined,
RightOutlined,
UserOutlined,
RobotOutlined,
} from '@ant-design/icons';
@@ -23,12 +23,6 @@ import AiSidebar from '../components/ai/AiSidebar';
const { Header, Sider, Content, Footer } = Layout;
interface MenuItem {
key: string;
icon: React.ReactNode;
label: string;
}
// 路由标题 fallback — 仅保留后端菜单无法覆盖的路由
// 1. 动态参数路由(:id/:id/edit— 菜单表不会存储这些路径
// 2. 无后端菜单记录的静态页面路由
@@ -60,316 +54,62 @@ function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined
return undefined;
}
// 侧边栏菜单项
const SidebarMenuItem = memo(function SidebarMenuItem({
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>
);
});
// 将后端 MenuInfo 树转为 Ant Design Menu 的 items 格式
type AntMenuItem = MenuItemType | SubMenuType;
// 可折叠子分组3 级菜单)
const CollapsibleSubGroup = memo(function CollapsibleSubGroup({
directory,
collapsed,
currentPath,
onNavigate,
}: {
directory: MenuInfo;
collapsed: boolean;
currentPath: string;
onNavigate: (key: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const visibleChildren = directory.children?.filter((c) => c.visible !== false) || [];
const hasActive = visibleChildren.some((c) => currentPath === (c.path || c.id));
useEffect(() => {
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';
function buildMenuItems(menus: MenuInfo[]): AntMenuItem[] {
return menus
.filter((m) => m.visible !== false && m.menu_type !== 'button')
.map((m) => {
const visibleChildren = m.children?.filter((c) => c.visible !== false && c.menu_type !== 'button') || [];
if ((m.menu_type === 'directory') && visibleChildren.length > 0) {
return {
key: m.id,
icon: getIcon(m.icon),
label: m.title,
children: buildMenuItems(visibleChildren),
};
}
return {
key: m.path || m.id,
icon: getIcon(m.icon),
label: m.title,
};
});
}
// 一级目录分组(可折叠
const CollapsibleDirectoryGroup = memo(function CollapsibleDirectoryGroup({
directory,
collapsed,
currentPath,
onNavigate,
}: {
directory: MenuInfo;
collapsed: boolean;
currentPath: string;
onNavigate: (key: string) => void;
}) {
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));
// 查找包含指定 path 的所有父级 key用于自动展开 openKeys
function findParentKeys(menus: MenuInfo[], targetPath: string): string[] {
const keys: string[] = [];
function walk(items: MenuInfo[], parents: string[]): boolean {
for (const m of items) {
if (m.path === targetPath) {
keys.push(...parents);
return true;
}
if (m.children) {
if (walk(m.children, [...parents, m.id])) return true;
}
}
return currentPath === (c.path || c.id);
});
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)}
/>
))}
</>
);
return false;
}
walk(menus, []);
return keys;
}
return (
<div>
<div
className="erp-sidebar-group-toggle"
onClick={() => setExpanded((e) => !e)}
>
<span className="erp-sidebar-group-arrow">
<RightOutlined
style={{ fontSize: 10, transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
/>
</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;
})}
</>
);
});
// 插件菜单也纳入 Menu items
function buildPluginItems(groups: PluginMenuGroup[]): AntMenuItem[] {
return groups.map((g) => ({
key: `plugin-${g.pluginId}`,
icon: <AppstoreOutlined />,
label: g.pluginName,
children: g.items.map((item) => ({
key: item.key,
icon: getIcon(item.icon),
label: item.label,
})),
}));
}
export default function MainLayout({ children }: { children: React.ReactNode }) {
const { sidebarCollapsed, toggleSidebar } = useAppStore();
@@ -424,6 +164,35 @@ export default function MainLayout({ children }: { children: React.ReactNode })
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(() => {
fetchPlugins(1, 'running');
@@ -489,37 +258,23 @@ export default function MainLayout({ children }: { children: React.ReactNode })
)}
</div>
{/* 动态菜单 */}
{/* 菜单 */}
{menuLoading ? (
<div style={{ padding: 16, textAlign: 'center' }}>
<Spin size="small" />
</div>
) : (
<DynamicMenuSection
menus={dynamicMenus}
collapsed={sidebarCollapsed}
currentPath={currentPath}
onNavigate={(key) => navigate(key)}
<Menu
mode="inline"
inlineCollapsed={sidebarCollapsed}
items={allMenuItems}
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>
{/* 右侧主区域 */}