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