From 73ec1b274f98e139c79ef9d588045e1cb3314c74 Mon Sep 17 00:00:00 2001
From: Tevin <tingquanren@163.com>
Date: Thu, 09 Apr 2026 16:59:37 +0800
Subject: [PATCH] feat(framework): 添加 CSideMenu 组件框架
---
src/framework/sideMenu/types.ts | 42 +++++++
src/framework/sideMenu/cSideMenu.scss | 53 ++++++++
src/index.ts | 1
src/framework/sideMenu/CSideMenu.tsx | 227 +++++++++++++++++++++++++++++++++++++
4 files changed, 323 insertions(+), 0 deletions(-)
diff --git a/src/framework/sideMenu/CSideMenu.tsx b/src/framework/sideMenu/CSideMenu.tsx
new file mode 100644
index 0000000..c14a47b
--- /dev/null
+++ b/src/framework/sideMenu/CSideMenu.tsx
@@ -0,0 +1,227 @@
+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} />}
+ </>
+ );
+};
diff --git a/src/framework/sideMenu/cSideMenu.scss b/src/framework/sideMenu/cSideMenu.scss
new file mode 100644
index 0000000..c6d4bc3
--- /dev/null
+++ b/src/framework/sideMenu/cSideMenu.scss
@@ -0,0 +1,53 @@
+.c-side-menu {
+ &__header {
+ padding: 16px;
+ }
+
+ &__title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ }
+
+ &__menu {
+ border-right: none;
+ }
+
+ &__empty {
+ padding: 16px;
+ color: #999;
+ }
+
+ &__opened-indicator {
+ color: #52c41a;
+ font-size: 12px;
+ }
+
+ // 移动端样式
+ &--mobile {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 1000;
+ width: 200px;
+ height: 100vh;
+ background: #fff;
+ transform: translateX(-100%);
+ transition: transform 0.3s ease;
+ box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
+
+ &--open {
+ transform: translateX(0);
+ }
+ }
+
+ &__overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 999;
+ background: rgba(0, 0, 0, 0.5);
+ }
+}
diff --git a/src/framework/sideMenu/types.ts b/src/framework/sideMenu/types.ts
new file mode 100644
index 0000000..481984c
--- /dev/null
+++ b/src/framework/sideMenu/types.ts
@@ -0,0 +1,42 @@
+/**
+ * 菜单树节点
+ */
+export interface MenuItem {
+ /** 唯一标识 (string | number) */
+ key: string | number;
+ /** 显示文本 */
+ label: string;
+ /** 子菜单/页面 */
+ children?: MenuItem[];
+ /** 页面路径 */
+ path?: string;
+ /** 页面名称 */
+ pageName?: string;
+ /** 图标类型 */
+ type?: 'chart' | 'setting' | 'folder' | 'file';
+}
+
+/**
+ * 菜单树结构
+ */
+export interface MenuTree extends MenuItem {}
+
+/**
+ * CSideMenu 组件属性
+ */
+export interface CSideMenuProps {
+ /** 顶区标题 */
+ title: string;
+ /** 合并后的菜单树 */
+ tree?: MenuTree;
+ /** 是否收起 */
+ collapsed: boolean;
+ /** 当前选中键 */
+ curActivePaneKey?: string | number;
+ /** 已打开页签列表 */
+ panesOnShelf?: Array<{ key: string }>;
+ /** 点击叶子菜单项回调 */
+ onClickMenuItem: (item: MenuItem) => void;
+ /** 设置折叠状态回调 */
+ onSetMenuCollapse: (collapsed: boolean | void) => void;
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..454624a
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1 @@
+export { CSideMenu } from './framework/sideMenu/CSideMenu';
--
Gitblit v1.9.1