import React, { useState, useCallback, useEffect } from 'react';
|
import { Menu, Layout } from 'antd';
|
import type { MenuProps } from 'antd';
|
import {
|
PieChartOutlined,
|
SettingOutlined,
|
FolderOutlined,
|
FileOutlined,
|
} from '@ant-design/icons';
|
import type { CSideMenuProps, MenuItem } from './types';
|
import './cSideMenu.scss';
|
|
/** 图标类型映射 */
|
const iconMap: Record<string, React.ReactNode> = {
|
chart: <PieChartOutlined />,
|
setting: <SettingOutlined />,
|
folder: <FolderOutlined />,
|
file: <FileOutlined />,
|
};
|
|
/** 默认图标 */
|
const DefaultIcon = <FileOutlined />;
|
|
/** 转换键值类型 */
|
function normalizeKey(key: string | number): string {
|
return String(key);
|
}
|
|
/** antd Menu 项类型 */
|
interface AntdMenuItem {
|
key: string;
|
label: React.ReactNode;
|
icon?: React.ReactNode;
|
children?: AntdMenuItem[];
|
}
|
|
/**
|
* 将 MenuTree 转换为 antd Menu items 格式
|
*/
|
function convertTreeToItems(
|
tree: MenuItem[],
|
panesOnShelf: Array<{ key: string }> = []
|
): AntdMenuItem[] {
|
return tree.map((node) => {
|
const icon = node.type ? iconMap[node.type] || DefaultIcon : DefaultIcon;
|
const isOpened = panesOnShelf.some((p) => normalizeKey(p.key) === normalizeKey(node.key));
|
const item: AntdMenuItem = {
|
key: normalizeKey(node.key),
|
label: isOpened ? (
|
<span>
|
{node.label}
|
<span className="c-side-menu__opened-indicator"> (已打开)</span>
|
</span>
|
) : (
|
node.label
|
),
|
icon,
|
};
|
if (node.children && node.children.length > 0) {
|
item.children = convertTreeToItems(node.children, panesOnShelf);
|
}
|
return item;
|
});
|
}
|
|
/** 递归查找叶子节点 */
|
function findLeafKeys(items: AntdMenuItem[], selectedKey: string): string[] {
|
const result: string[] = [];
|
const findKey = (items: AntdMenuItem[], key: string): boolean => {
|
for (const item of items || []) {
|
if (item.key === key) {
|
if (!item.children || item.children.length === 0) {
|
result.push(key);
|
}
|
return true;
|
}
|
if (item.children && findKey(item.children, key)) {
|
return true;
|
}
|
}
|
return false;
|
};
|
findKey(items, selectedKey);
|
return result;
|
}
|
|
export const CSideMenu: React.FC<CSideMenuProps> = (props) => {
|
const {
|
title,
|
tree,
|
collapsed,
|
curActivePaneKey,
|
panesOnShelf = [],
|
onClickMenuItem,
|
onSetMenuCollapse,
|
} = props;
|
|
const [isMobile, setIsMobile] = useState(false);
|
const [openKeys, setOpenKeys] = useState<string[]>([]);
|
|
// 监听响应式断点 - 与 antd Layout.Sider breakpoint="lg" 一致 (992px)
|
useEffect(() => {
|
const checkMobile = () => {
|
const width = window.innerWidth;
|
const screenWidth = window.screen.width;
|
// 只有当视口和屏幕都比较小时才认为是移动端
|
setIsMobile(width <= 992 && screenWidth <= 1024);
|
};
|
|
const mediaQuery = window.matchMedia('(max-width: 992px)');
|
checkMobile(); // 初始检测
|
|
const handler = () => {
|
checkMobile();
|
};
|
|
mediaQuery.addEventListener('change', handler);
|
return () => mediaQuery.removeEventListener('change', handler);
|
}, []);
|
|
// 菜单项
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const items: any[] = tree ? convertTreeToItems([tree], panesOnShelf) : [];
|
|
// 选中的键
|
const selectedKeys = curActivePaneKey ? findLeafKeys(items, normalizeKey(curActivePaneKey)) : [];
|
|
// 全局手风琴逻辑 - 当展开一个新分支时,关闭之前展开的分支
|
const handleOpenChange: MenuProps['onOpenChange'] = useCallback(
|
(keys: string[]) => {
|
const latestKey = keys[keys.length - 1];
|
if (latestKey) {
|
// 只保留最新展开的键
|
setOpenKeys([latestKey]);
|
} else {
|
setOpenKeys([]);
|
}
|
},
|
[]
|
);
|
|
// 叶子项点击
|
const handleClick: MenuProps['onClick'] = useCallback(
|
({ key }: { key: string }) => {
|
if (!tree) return;
|
|
// 递归查找被点击的菜单项
|
const findItem = (nodes: MenuItem[], k: string): MenuItem | null => {
|
for (const node of nodes) {
|
if (normalizeKey(node.key) === k) return node;
|
if (node.children) {
|
const found = findItem(node.children, k);
|
if (found) return found;
|
}
|
}
|
return null;
|
};
|
|
const clickedItem = findItem([tree], key);
|
if (clickedItem) {
|
// 移动端:先收起,延迟后再导航
|
if (isMobile) {
|
setTimeout(() => {
|
onClickMenuItem(clickedItem);
|
}, 300);
|
} else {
|
onClickMenuItem(clickedItem);
|
}
|
}
|
},
|
[tree, isMobile, onClickMenuItem]
|
);
|
|
// 空树处理
|
if (!tree) {
|
return (
|
<div className="c-side-menu">
|
<div className="c-side-menu__header">
|
<h1 className="c-side-menu__title">{title}</h1>
|
</div>
|
<div className="c-side-menu__empty">暂无菜单</div>
|
</div>
|
);
|
}
|
|
// 移动端遮罩点击关闭
|
const handleOverlayClick = () => {
|
onSetMenuCollapse(true);
|
};
|
|
const menuContent = (
|
<>
|
<div className="c-side-menu__header">
|
<h1 className="c-side-menu__title">{title}</h1>
|
</div>
|
<Menu
|
className="c-side-menu__menu"
|
mode="inline"
|
inlineCollapsed={isMobile ? false : collapsed}
|
items={items}
|
selectedKeys={selectedKeys}
|
openKeys={openKeys}
|
onOpenChange={handleOpenChange}
|
onClick={handleClick}
|
/>
|
</>
|
);
|
|
// PC 端 - 禁用 breakpoint,响应式由外部控制
|
if (!isMobile) {
|
return (
|
<Layout.Sider className="c-side-menu" collapsedWidth={0} width={200}>
|
{menuContent}
|
</Layout.Sider>
|
);
|
}
|
|
// 移动端 - 使用外部传入的 collapsed 控制显示
|
return (
|
<>
|
<div className={`c-side-menu c-side-menu--mobile ${!collapsed ? 'c-side-menu--open' : ''}`}>
|
{menuContent}
|
</div>
|
{!collapsed && <div className="c-side-menu__overlay" onClick={handleOverlayClick} />}
|
</>
|
);
|
};
|