diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index a430767..e305121 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -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;
diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx
index fc5afe2..1e06d25 100644
--- a/apps/web/src/layouts/MainLayout.tsx
+++ b/apps/web/src/layouts/MainLayout.tsx
@@ -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 (
-
-
- {item.icon}
- {!collapsed && {item.label}}
-
-
- );
-});
+// 将后端 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 (
-
- {
- const first = visibleChildren[0];
- if (first) onNavigate(first.path || first.id);
- }}
- className={`erp-sidebar-item ${hasActive ? 'erp-sidebar-item-active' : ''}`}
- >
- {getIcon(directory.icon)}
-
-
- );
- }
-
- return (
-
-
setExpanded((e) => !e)}
- >
-
-
-
- {directory.title}
-
- {expanded && visibleChildren.map((child) => (
-
onNavigate(child.path || child.id)}
- indented
- />
- ))}
-
- );
-});
-
-// 插件子菜单组
-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 (
-
- {
- const first = group.items[0];
- if (first) onNavigate(first.key);
- }}
- className={`erp-sidebar-item ${hasActive ? 'erp-sidebar-item-active' : ''}`}
- >
-
-
-
- );
- }
-
- return (
-
-
setExpanded((e) => !e)}
- >
-
-
-
- {group.pluginName}
-
- {expanded && group.items.map((item) => (
-
onNavigate(item.key)}
- indented
- />
- ))}
-
- );
-});
-
-// 判断是否为可点击的叶子菜单类型
-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) => (
- onNavigate(child.path || child.id)}
- />
- ))}
- >
- );
+ return false;
}
+ walk(menus, []);
+ return keys;
+}
- return (
-
-
setExpanded((e) => !e)}
- >
-
-
-
- {directory.title}
-
-
-
- {visibleChildren.map((child) => {
- if (child.menu_type === 'directory') {
- return (
-
- );
- }
- if (isLeafType(child.menu_type)) {
- return (
- onNavigate(child.path || child.id)}
- />
- );
- }
- return null;
- })}
-
-
-
- );
-});
-
-// 动态菜单渲染(支持多级嵌套)
-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 (
-
- );
- }
- if (isLeafType(menu.menu_type) && menu.visible !== false) {
- return (
- onNavigate(menu.path || menu.id)}
- />
- );
- }
- return null;
- })}
- >
- );
-});
+// 插件菜单也纳入 Menu items
+function buildPluginItems(groups: PluginMenuGroup[]): AntMenuItem[] {
+ return groups.map((g) => ({
+ key: `plugin-${g.pluginId}`,
+ icon: ,
+ 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([]);
+ 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 })
)}
- {/* 动态菜单 */}
+ {/* 菜单 */}
{menuLoading ? (
) : (
- navigate(key)}
+