From 831d2ba598106912e5a341cf9f406b52b44cf467 Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 21 May 2026 12:08:19 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E4=BE=A7=E8=BE=B9=E6=A0=8F?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=94=B9=E7=94=A8=20Ant=20Design=20Menu=20?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用 Ant Design 替代自定义 div 渲染,对齐 ProLayout 体验: - buildMenuItems() 将后端 MenuInfo 树转为 Menu items 格式 - 目录图标渲染(HeartOutlined/FormOutlined 等) - 原生折叠动画 + 侧边栏折叠时 popover 子菜单 - openKeys 自动展开包含当前路由的父级 - 键盘导航 + ARIA 无障碍(Menu 内置) - 插件菜单合并为统一 Menu items - 删除 ~150 行自定义组件,清理对应 CSS Co-Authored-By: Claude Opus 4.7 --- apps/web/src/index.css | 139 +-------- apps/web/src/layouts/MainLayout.tsx | 429 ++++++---------------------- 2 files changed, 104 insertions(+), 464 deletions(-) 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)} + navigate(key)} + className="erp-sidebar-menu" /> )} - - {/* 插件菜单 */} - {pluginMenuGroups.length > 0 && ( - <> - {!sidebarCollapsed &&
插件
} -
- {pluginMenuGroups.map((group) => ( - navigate(key)} - /> - ))} -
- - )} {/* 右侧主区域 */}