feat(web): 插件侧边栏改为三级菜单结构 — 按插件名分组可折叠
插件菜单从扁平列表改为三级结构: 插件(分组)→ 插件名(可折叠子标题)→ 页面列表 - store 新增 PluginMenuGroup 类型和 pluginMenuGroups getter - MainLayout 新增 SidebarSubMenu 组件,支持展开/收起 - 折叠侧边栏时子菜单显示插件图标 + tooltip - 子菜单项增加缩进样式区分层级 - CRM 插件 name 改为 "CRM" 避免与页面标题重名
This commit is contained in:
@@ -658,6 +658,50 @@ body {
|
|||||||
margin-left: 12px;
|
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 */
|
/* Main layout */
|
||||||
.erp-main-layout {
|
.erp-main-layout {
|
||||||
transition: margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|||||||
@@ -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 { Layout, Avatar, Space, Dropdown, Tooltip, theme } from 'antd';
|
||||||
import {
|
import {
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
@@ -18,11 +18,13 @@ import {
|
|||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
TableOutlined,
|
TableOutlined,
|
||||||
TagsOutlined,
|
TagsOutlined,
|
||||||
|
RightOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAppStore } from '../stores/app';
|
import { useAppStore } from '../stores/app';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { usePluginStore } from '../stores/plugin';
|
import { usePluginStore } from '../stores/plugin';
|
||||||
|
import type { PluginMenuGroup } from '../stores/plugin';
|
||||||
import NotificationPanel from '../components/NotificationPanel';
|
import NotificationPanel from '../components/NotificationPanel';
|
||||||
|
|
||||||
const { Header, Sider, Content, Footer } = Layout;
|
const { Header, Sider, Content, Footer } = Layout;
|
||||||
@@ -67,17 +69,19 @@ const SidebarMenuItem = memo(function SidebarMenuItem({
|
|||||||
isActive,
|
isActive,
|
||||||
collapsed,
|
collapsed,
|
||||||
onClick,
|
onClick,
|
||||||
|
indented,
|
||||||
}: {
|
}: {
|
||||||
item: MenuItem;
|
item: MenuItem;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
indented?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Tooltip title={collapsed ? item.label : ''} placement="right">
|
<Tooltip title={collapsed ? item.label : ''} placement="right">
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
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>
|
<span className="erp-sidebar-item-icon">{item.icon}</span>
|
||||||
{!collapsed && <span className="erp-sidebar-item-label">{item.label}</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 }) {
|
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||||
const { sidebarCollapsed, toggleSidebar, theme: themeMode, setTheme } = useAppStore();
|
const { sidebarCollapsed, toggleSidebar, theme: themeMode, setTheme } = useAppStore();
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
const { pluginMenuItems, fetchPlugins } = usePluginStore();
|
const { pluginMenuItems, pluginMenuGroups, fetchPlugins } = usePluginStore();
|
||||||
theme.useToken();
|
theme.useToken();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -173,40 +262,19 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 菜单组:插件 */}
|
{/* 菜单组:插件 */}
|
||||||
{pluginMenuItems.length > 0 && (
|
{pluginMenuGroups.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{!sidebarCollapsed && <div className="erp-sidebar-group">插件</div>}
|
{!sidebarCollapsed && <div className="erp-sidebar-group">插件</div>}
|
||||||
<div className="erp-sidebar-menu">
|
<div className="erp-sidebar-menu">
|
||||||
{pluginMenuItems.map((item) => {
|
{pluginMenuGroups.map((group) => (
|
||||||
// 动态图标映射
|
<SidebarSubMenu
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
key={group.pluginId}
|
||||||
AppstoreOutlined: <AppstoreOutlined />,
|
group={group}
|
||||||
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}
|
collapsed={sidebarCollapsed}
|
||||||
onClick={() => navigate(item.key)}
|
currentPath={currentPath}
|
||||||
|
onNavigate={(key) => navigate(key)}
|
||||||
/>
|
/>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,13 +9,19 @@ export interface PluginMenuItem {
|
|||||||
pluginId: string;
|
pluginId: string;
|
||||||
entity?: string;
|
entity?: string;
|
||||||
pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard';
|
pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard';
|
||||||
menuGroup?: string;
|
}
|
||||||
|
|
||||||
|
export interface PluginMenuGroup {
|
||||||
|
pluginId: string;
|
||||||
|
pluginName: string;
|
||||||
|
items: PluginMenuItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PluginStore {
|
interface PluginStore {
|
||||||
plugins: PluginInfo[];
|
plugins: PluginInfo[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
pluginMenuItems: PluginMenuItem[];
|
pluginMenuItems: PluginMenuItem[];
|
||||||
|
pluginMenuGroups: PluginMenuGroup[];
|
||||||
schemaCache: Record<string, PluginSchemaResponse>;
|
schemaCache: Record<string, PluginSchemaResponse>;
|
||||||
fetchPlugins: (page?: number, status?: PluginStatus) => Promise<void>;
|
fetchPlugins: (page?: number, status?: PluginStatus) => Promise<void>;
|
||||||
refreshMenuItems: () => void;
|
refreshMenuItems: () => void;
|
||||||
@@ -25,6 +31,7 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
|
|||||||
plugins: [],
|
plugins: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
pluginMenuItems: [],
|
pluginMenuItems: [],
|
||||||
|
pluginMenuGroups: [],
|
||||||
schemaCache: {},
|
schemaCache: {},
|
||||||
|
|
||||||
fetchPlugins: async (page = 1, status?: PluginStatus) => {
|
fetchPlugins: async (page = 1, status?: PluginStatus) => {
|
||||||
@@ -124,5 +131,23 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
set({ pluginMenuItems: items });
|
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 });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[metadata]
|
[metadata]
|
||||||
id = "erp-crm"
|
id = "erp-crm"
|
||||||
name = "客户管理"
|
name = "CRM"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "客户关系管理插件 — ERP 平台第一个行业插件"
|
description = "客户关系管理插件 — ERP 平台第一个行业插件"
|
||||||
author = "ERP Team"
|
author = "ERP Team"
|
||||||
|
|||||||
Reference in New Issue
Block a user