| New file |
| | |
| | | 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} />} |
| | | </> |
| | | ); |
| | | }; |