AdmSysV2【公共组件库】@前端(For Git Submodule)
Tevin
1 days ago 73ec1b274f98e139c79ef9d588045e1cb3314c74
feat(framework): 添加 CSideMenu 组件框架

- 新增 src/framework/sideMenu/CSideMenu.tsx 侧边菜单组件
- 新增 src/framework/sideMenu/cSideMenu.scss 组件样式
- 新增 src/framework/sideMenu/types.ts 类型定义
- 新增 src/index.ts 组件库导出入口

Co-Authored-By: ClaudeCode
4 files added
323 ■■■■■ changed files
src/framework/sideMenu/CSideMenu.tsx 227 ●●●●● patch | view | raw | blame | history
src/framework/sideMenu/cSideMenu.scss 53 ●●●●● patch | view | raw | blame | history
src/framework/sideMenu/types.ts 42 ●●●●● patch | view | raw | blame | history
src/index.ts 1 ●●●● patch | view | raw | blame | history
src/framework/sideMenu/CSideMenu.tsx
New file
@@ -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} />}
    </>
  );
};
src/framework/sideMenu/cSideMenu.scss
New file
@@ -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);
  }
}
src/framework/sideMenu/types.ts
New file
@@ -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;
}
src/index.ts
New file
@@ -0,0 +1 @@
export { CSideMenu } from './framework/sideMenu/CSideMenu';