feat(web): 插件侧边栏改为三级菜单结构 — 按插件名分组可折叠

插件菜单从扁平列表改为三级结构:
  插件(分组)→ 插件名(可折叠子标题)→ 页面列表

- store 新增 PluginMenuGroup 类型和 pluginMenuGroups getter
- MainLayout 新增 SidebarSubMenu 组件,支持展开/收起
- 折叠侧边栏时子菜单显示插件图标 + tooltip
- 子菜单项增加缩进样式区分层级
- CRM 插件 name 改为 "CRM" 避免与页面标题重名
This commit is contained in:
iven
2026-04-17 01:01:19 +08:00
parent ae62e2ecb2
commit f4dd228a67
4 changed files with 173 additions and 36 deletions

View File

@@ -658,6 +658,50 @@ body {
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: 6px;
cursor: pointer;
color: #94A3B8;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.15s;
user-select: none;
}
.erp-sidebar-submenu-title:hover {
background: rgba(255, 255, 255, 0.06);
color: #E2E8F0;
}
.erp-sidebar-submenu-title-active {
color: #A5B4FC;
}
.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 {
transition: margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1);

View File

@@ -1,4 +1,4 @@
import { useCallback, memo, useEffect } from 'react';
import { useCallback, useState, memo, useEffect } from 'react';
import { Layout, Avatar, Space, Dropdown, Tooltip, theme } from 'antd';
import {
HomeOutlined,
@@ -18,11 +18,13 @@ import {
TeamOutlined,
TableOutlined,
TagsOutlined,
RightOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppStore } from '../stores/app';
import { useAuthStore } from '../stores/auth';
import { usePluginStore } from '../stores/plugin';
import type { PluginMenuGroup } from '../stores/plugin';
import NotificationPanel from '../components/NotificationPanel';
const { Header, Sider, Content, Footer } = Layout;
@@ -67,17 +69,19 @@ const SidebarMenuItem = memo(function SidebarMenuItem({
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' : ''}`}
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>}
@@ -86,10 +90,95 @@ const SidebarMenuItem = memo(function SidebarMenuItem({
);
});
// 动态图标映射
const pluginIconMap: Record<string, React.ReactNode> = {
AppstoreOutlined: <AppstoreOutlined />,
team: <TeamOutlined />,
TeamOutlined: <TeamOutlined />,
user: <UserOutlined />,
UserOutlined: <UserOutlined />,
message: <MessageOutlined />,
MessageOutlined: <MessageOutlined />,
tags: <TagsOutlined />,
TagsOutlined: <TagsOutlined />,
apartment: <ApartmentOutlined />,
ApartmentOutlined: <ApartmentOutlined />,
TableOutlined: <TableOutlined />,
DashboardOutlined: <AppstoreOutlined />,
};
function getPluginIcon(iconName: string): React.ReactNode {
return pluginIconMap[iconName] || <AppstoreOutlined />;
}
// 插件子菜单组 — 可折叠二级标题 + 三级菜单项
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) {
// 折叠模式显示插件图标Tooltip 列出所有子项
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: getPluginIcon(item.icon),
label: item.label,
}}
isActive={currentPath === item.key}
collapsed={collapsed}
onClick={() => onNavigate(item.key)}
indented
/>
))}
</div>
);
});
export default function MainLayout({ children }: { children: React.ReactNode }) {
const { sidebarCollapsed, toggleSidebar, theme: themeMode, setTheme } = useAppStore();
const { user, logout } = useAuthStore();
const { pluginMenuItems, fetchPlugins } = usePluginStore();
const { pluginMenuItems, pluginMenuGroups, fetchPlugins } = usePluginStore();
theme.useToken();
const navigate = useNavigate();
const location = useLocation();
@@ -173,40 +262,19 @@ export default function MainLayout({ children }: { children: React.ReactNode })
</div>
{/* 菜单组:插件 */}
{pluginMenuItems.length > 0 && (
{pluginMenuGroups.length > 0 && (
<>
{!sidebarCollapsed && <div className="erp-sidebar-group"></div>}
<div className="erp-sidebar-menu">
{pluginMenuItems.map((item) => {
// 动态图标映射
const iconMap: Record<string, React.ReactNode> = {
AppstoreOutlined: <AppstoreOutlined />,
team: <TeamOutlined />,
TeamOutlined: <TeamOutlined />,
user: <UserOutlined />,
UserOutlined: <UserOutlined />,
message: <MessageOutlined />,
MessageOutlined: <MessageOutlined />,
tags: <TagsOutlined />,
TagsOutlined: <TagsOutlined />,
apartment: <ApartmentOutlined />,
ApartmentOutlined: <ApartmentOutlined />,
TableOutlined: <TableOutlined />,
};
return (
<SidebarMenuItem
key={item.key}
item={{
key: item.key,
icon: iconMap[item.icon] || <AppstoreOutlined />,
label: item.label,
}}
isActive={currentPath === item.key}
collapsed={sidebarCollapsed}
onClick={() => navigate(item.key)}
/>
);
})}
{pluginMenuGroups.map((group) => (
<SidebarSubMenu
key={group.pluginId}
group={group}
collapsed={sidebarCollapsed}
currentPath={currentPath}
onNavigate={(key) => navigate(key)}
/>
))}
</div>
</>
)}

View File

@@ -9,13 +9,19 @@ export interface PluginMenuItem {
pluginId: string;
entity?: string;
pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard';
menuGroup?: string;
}
export interface PluginMenuGroup {
pluginId: string;
pluginName: string;
items: PluginMenuItem[];
}
interface PluginStore {
plugins: PluginInfo[];
loading: boolean;
pluginMenuItems: PluginMenuItem[];
pluginMenuGroups: PluginMenuGroup[];
schemaCache: Record<string, PluginSchemaResponse>;
fetchPlugins: (page?: number, status?: PluginStatus) => Promise<void>;
refreshMenuItems: () => void;
@@ -25,6 +31,7 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
plugins: [],
loading: false,
pluginMenuItems: [],
pluginMenuGroups: [],
schemaCache: {},
fetchPlugins: async (page = 1, status?: PluginStatus) => {
@@ -124,5 +131,23 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
}
set({ pluginMenuItems: items });
// 按 pluginId 分组生成三级菜单(复用上方已解构的 plugins
const groupMap = new Map<string, PluginMenuItem[]>();
for (const item of items) {
const list = groupMap.get(item.pluginId) || [];
list.push(item);
groupMap.set(item.pluginId, list);
}
const groups: PluginMenuGroup[] = [];
for (const [pluginId, groupItems] of groupMap) {
const plugin = plugins.find((p) => p.id === pluginId);
groups.push({
pluginId,
pluginName: plugin?.name || pluginId,
items: groupItems,
});
}
set({ pluginMenuGroups: groups });
},
}));

View File

@@ -1,6 +1,6 @@
[metadata]
id = "erp-crm"
name = "客户管理"
name = "CRM"
version = "0.1.0"
description = "客户关系管理插件 — ERP 平台第一个行业插件"
author = "ERP Team"