AdmSysV2【公共组件库】@前端(For Git Submodule)
Tevin
10 hours ago 73f6da477cbb11542401c6019149c94269482259
docs: 添加 CSideMenu V2 实现计划
1 files added
1422 ■■■■■ changed files
docs/superpowers/plans/2026-04-13-c-side-menu-v2-implementation.md 1422 ●●●●● patch | view | raw | blame | history
docs/superpowers/plans/2026-04-13-c-side-menu-v2-implementation.md
New file
@@ -0,0 +1,1422 @@
# CSideMenu V2 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 基于 antd@6 实现完整版 CSideMenu 组件,支持三级菜单、手风琴、自绘滚动条、固定覆盖、hiddenPaths 等全部功能。
**Architecture:** 使用 antd `Layout.Sider` + `Menu`(dark inline 主题)作为基础,自研子组件处理滚动条与遮罩交互。
**Tech Stack:** React 18 + TypeScript + antd@6 + SCSS + Vitest + Playwright
---
## 文件结构
```
src/framework/sideMenu/
├── CSideMenu.tsx              # 主组件(重构)
├── CSideMenuCustomScroll.tsx  # 自绘滚动条组件(新建)
├── types.ts                  # 类型定义(重构)
└── cSideMenu.scss            # 样式文件(重构)
example/pages/side-menu/
└── SideMenuPage.tsx          # 示例页(更新)
test/unit/
└── CSideMenu.test.tsx        # 单元测试(更新)
```
---
## Task 1: 类型定义重构
**Files:**
- Modify: `src/framework/sideMenu/types.ts`
**关键变更:** 数据模型字段从 `key`/`label` 改为 `id`/`name`,新增 `hiddenPaths` prop。
- [ ] **Step 1: 备份并重写 types.ts**
```typescript
// src/framework/sideMenu/types.ts
/**
 * 菜单树节点
 */
export interface MenuItem {
  /** 唯一标识 (string | number) */
  id: string | number;
  /** 显示文本 */
  name: string;
  /** 图标类型 */
  type?: 'chart' | 'setting' | 'folder' | 'file';
  /** 路由路径 */
  path?: string;
  /** 页面名称 */
  pageName?: string;
  /** 子菜单/页面 */
  children?: MenuItem[];
}
/**
 * 菜单树结构(一级分组)
 */
export interface MenuTreeItem extends Omit<MenuItem, 'children'> {
  children: MenuItem[];
}
/**
 * 已打开页签
 */
export interface PaneOnShelf {
  key: string;
}
/**
 * CSideMenu 组件属性
 */
export interface CSideMenuProps {
  /** 顶区标题 */
  title: string;
  /** 合并后的菜单树(一级分组列表) */
  tree: MenuTreeItem[];
  /** 是否收起(宿主驱动) */
  collapsed: boolean;
  /** 当前选中键 */
  curActivePaneKey?: string | number;
  /** 已打开页签列表 */
  panesOnShelf?: PaneOnShelf[];
  /** 隐藏的菜单路径(入口在组件外,如后台首页) */
  hiddenPaths?: string[];
  /** 点击叶子菜单项回调 */
  onClickMenuItem: (item: MenuItem) => void;
  /** 设置折叠状态回调(支持 boolean 或无参切换) */
  onSetMenuCollapse: (collapsed?: boolean) => void;
}
```
- [ ] **Step 2: 运行类型检查**
Run: `npx tsc --noEmit`
Expected: 无类型错误
- [ ] **Step 3: 提交**
```bash
git add src/framework/sideMenu/types.ts
git commit -m "refactor(sideMenu): 更新类型定义,使用 id/name 替代 key/label,新增 hiddenPaths"
```
---
## Task 2: 自绘滚动条组件
**Files:**
- Create: `src/framework/sideMenu/CSideMenuCustomScroll.tsx`
- Modify: `src/framework/sideMenu/cSideMenu.scss`
**关键行为:**
- 触发条件:宽屏(≥992px)且内容超高(含 10px 容差)
- 滑块高度 = 可视高度 × (可视高度 / 内容高度)
- 滑块位移与 scrollTop 双向同步
- 四级交互反馈:外围→邻近→主操作面→激活拖拽
- [ ] **Step 1: 创建 CSideMenuCustomScroll.tsx**
```tsx
// src/framework/sideMenu/CSideMenuCustomScroll.tsx
import React, { useRef, useState, useEffect, useCallback } from 'react';
interface CSideMenuCustomScrollProps {
  containerRef: React.RefObject<HTMLDivElement>;  // 可滚容器引用
  contentHeight: number;                           // 内容总高度
}
export const CSideMenuCustomScroll: React.FC<CSideMenuCustomScrollProps> = ({
  containerRef,
  contentHeight,
}) => {
  const scrollbarRef = useRef<HTMLDivElement>(null);
  const thumbRef = useRef<HTMLDivElement>(null);
  const [isDragging, setIsDragging] = useState(false);
  const [isHoveringTrack, setIsHoveringTrack] = useState(false);
  const [isHoveringThumb, setIsHoveringThumb] = useState(false);
  const [isTrackVisible, setIsTrackVisible] = useState(false);
  const container = containerRef.current;
  const可视高度 = container?.clientHeight || 0;
  const是否需要滚动 = contentHeight > 可视高度 + 10;
  // 计算滑块高度
  const滑块高度 = Math.max(
    30,
    可视高度 * (可视高度 / contentHeight)
  );
  // 计算滑块位置
  const scrollTop = container?.scrollTop || 0;
  const maxThumbTop = 可视高度 - 滑块高度;
  const thumbTop = (scrollTop / (contentHeight - 可视高度)) * maxThumbTop || 0;
  // 显示/隐藏轨道
  useEffect(() => {
    if (是否需要滚动) {
      const timer = setTimeout(() => setIsTrackVisible(true), 300); // delay 0.3s
      return () => clearTimeout(timer);
    } else {
      setIsTrackVisible(false);
    }
  }, [是否需要滚动]);
  // 拖拽逻辑
  const handleThumbMouseDown = useCallback(
    (e: React.MouseEvent) => {
      e.preventDefault();
      setIsDragging(true);
      const startY = e.clientY;
      const startScrollTop = container?.scrollTop || 0;
      const thumbHeightRatio = (contentHeight - 可视高度) / maxThumbTop;
      const handleMouseMove = (moveEvent: MouseEvent) => {
        if (container) {
          const deltaY = moveEvent.clientY - startY;
          container.scrollTop = startScrollTop + deltaY * thumbHeightRatio;
        }
      };
      const handleMouseUp = () => {
        setIsDragging(false);
        document.removeEventListener('mousemove', handleMouseMove);
        document.removeEventListener('mouseup', handleMouseUp);
      };
      document.addEventListener('mousemove', handleMouseMove);
      document.addEventListener('mouseup', handleMouseUp);
    },
    [container, contentHeight, 可视高度, maxThumbTop]
  );
  // 触摸支持
  const handleTouchStart = useCallback(
    (e: React.TouchEvent) => {
      const touch = e.touches[0];
      const startY = touch.clientY;
      const startScrollTop = container?.scrollTop || 0;
      const thumbHeightRatio = (contentHeight - 可视高度) / maxThumbTop;
      const handleTouchMove = (moveEvent: TouchEvent) => {
        if (container) {
          const deltaY = moveEvent.touches[0].clientY - startY;
          container.scrollTop = startScrollTop + deltaY * thumbHeightRatio;
        }
      };
      const handleTouchEnd = () => {
        document.removeEventListener('touchmove', handleTouchMove);
        document.removeEventListener('touchend', handleTouchEnd);
      };
      document.addEventListener('touchmove', handleTouchMove);
      document.addEventListener('touchend', handleTouchEnd);
    },
    [container, contentHeight, 可视高度, maxThumbTop]
  );
  if (!是否需要滚动) return null;
  const getThumbClassName = () => {
    let className = 'c-side-menu__scroll-thumb';
    if (isDragging) className += ' c-side-menu__scroll-thumb--dragging';
    else if (isHoveringThumb) className += ' c-side-menu__scroll-thumb--hover';
    else if (isHoveringTrack) className += ' c-side-menu__scroll-thumb--active';
    return className;
  };
  return (
    <div
      ref={scrollbarRef}
      className={`c-side-menu__scrollbar ${isTrackVisible ? 'c-side-menu__scrollbar--visible' : ''}`}
      onMouseEnter={() => setIsHoveringTrack(true)}
      onMouseLeave={() => setIsHoveringTrack(false)}
    >
      <div
        ref={thumbRef}
        className={getThumbClassName()}
        style={{ height: `${滑块高度}px`, transform: `translateY(${thumbTop}px)` }}
        onMouseDown={handleThumbMouseDown}
        onTouchStart={handleTouchStart}
        onMouseEnter={() => setIsHoveringThumb(true)}
        onMouseLeave={() => setIsHoveringThumb(false)}
      />
    </div>
  );
};
```
- [ ] **Step 2: 添加滚动条样式到 cSideMenu.scss**
```scss
// 在 cSideMenu.scss 末尾追加
// 自绘滚动条
.c-side-menu__scrollbar {
  position: absolute;
  top: 0;
  right: 0;
  width: 20px;
  height: 100%;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
  z-index: 1;
  &--visible {
    opacity: 1;
    pointer-events: auto;
  }
}
.c-side-menu__scroll-thumb {
  position: absolute;
  top: 0;
  right: 4px;
  width: 6px;
  min-height: 30px;
  background: rgba(255, 255, 255, 0.2);
  border-radius: 3px;
  cursor: pointer;
  transition: width 0.3s ease, background-color 0.3s ease;
  // 控件邻近带
  &--active {
    width: 8px;
    background: #597a7f;
  }
  // 主操作面
  &--hover {
    width: 12px;
    background: #7fa1a8;
  }
  // 激活/拖拽中
  &--dragging {
    width: 12px;
    background: #a3c5cd;
    transition: none;
  }
}
```
- [ ] **Step 3: 提交**
```bash
git add src/framework/sideMenu/CSideMenuCustomScroll.tsx src/framework/sideMenu/cSideMenu.scss
git commit -m "feat(sideMenu): 添加自绘滚动条组件CSideMenuCustomScroll"
```
---
## Task 3: 主组件重构
**Files:**
- Modify: `src/framework/sideMenu/CSideMenu.tsx`
**关键变更:**
- 使用 `Layout.Sider` 的 `breakpoint="lg"` 和 `onBreakpointChange`
- 调用 `hiddenPaths` 过滤
- 实现同级手风琴(而非全局手风琴)
- 展开后延迟检测滚动
- 窄屏点击叶子延迟 300ms
- 拖拽时遮罩近透明 + z-index 抬升
- [ ] **Step 1: 重写 CSideMenu.tsx**
```tsx
// src/framework/sideMenu/CSideMenu.tsx
import React, { useState, useCallback, useEffect, useRef, useMemo } 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, MenuTreeItem } from './types';
import { CSideMenuCustomScroll } from './CSideMenuCustomScroll';
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[];
}
/**
 * 过滤隐藏路径
 */
function filterHiddenItems(
  items: MenuItem[],
  hiddenPaths: string[] = []
): MenuItem[] {
  if (!hiddenPaths.length) return items;
  return items.filter((item) => {
    const itemPath = item.path ? normalizeKey(item.path) : normalizeKey(item.id);
    const isHidden = hiddenPaths.some(
      (hp) => normalizeKey(hp) === itemPath
    );
    return !isHidden;
  }).map((item) => ({
    ...item,
    children: item.children
      ? filterHiddenItems(item.children, hiddenPaths)
      : undefined,
  }));
}
/**
 * 将 MenuTree 转换为 antd Menu items 格式
 */
function convertTreeToItems(
  tree: MenuTreeItem[],
  panesOnShelf: Array<{ key: string }> = [],
  hiddenPaths: string[] = []
): AntdMenuItem[] {
  const filteredTree = tree.map((group) => ({
    ...group,
    children: filterHiddenItems(group.children || [], hiddenPaths),
  }));
  return filteredTree.map((group) => {
    const isGroupOpened = panesOnShelf.some((p) =>
      group.children.some(
        (child) =>
          normalizeKey(p.key) === normalizeKey(child.id) ||
          normalizeKey(p.key) === normalizeKey(child.key)
      )
    );
    return {
      key: normalizeKey(group.id),
      label: (
        <span>
          {group.name}
          {isGroupOpened && (
            <span className="c-side-menu__opened-indicator" />
          )}
        </span>
      ),
      icon: group.type ? iconMap[group.type] || DefaultIcon : DefaultIcon,
      children: group.children?.map((item) => convertItem(item, panesOnShelf)),
    };
  });
}
/** 转换单个菜单项(含递归) */
function convertItem(
  item: MenuItem,
  panesOnShelf: Array<{ key: string }> = []
): AntdMenuItem {
  const isOpened = panesOnShelf.some(
    (p) =>
      normalizeKey(p.key) === normalizeKey(item.id) ||
      normalizeKey(p.key) === normalizeKey(item.key)
  );
  const isLeaf = !item.children || item.children.length === 0;
  return {
    key: normalizeKey(item.id),
    label: (
      <span>
        {item.name}
        {isOpened && <span className="c-side-menu__opened-indicator" />}
      </span>
    ),
    icon: item.type ? iconMap[item.type] || DefaultIcon : DefaultIcon,
    children: isLeaf
      ? undefined
      : item.children?.map((child) => convertItem(child, panesOnShelf)),
  };
}
/** 递归查找叶子节点的所有祖先 key */
function findAncestorKeys(
  items: AntdMenuItem[],
  targetKey: string,
  ancestors: string[] = []
): string[] | null {
  for (const item of items) {
    if (item.key === targetKey) {
      return ancestors;
    }
    if (item.children) {
      const found = findAncestorKeys(item.children, targetKey, [
        ...ancestors,
        item.key,
      ]);
      if (found) return found;
    }
  }
  return null;
}
/** 在树中查找同级其他 key */
function findSiblingKeys(
  items: AntdMenuItem[],
  key: string
): string[] {
  for (const item of items) {
    if (item.key === key) return [];
    if (item.children) {
      for (const child of item.children) {
        if (child.key === key) {
          return item.children
            .filter((c) => c.key !== key)
            .map((c) => c.key);
        }
        const siblingResult = findSiblingKeys([child], key);
        if (siblingResult.length > 0) return siblingResult;
      }
    }
  }
  return [];
}
export const CSideMenu: React.FC<CSideMenuProps> = (props) => {
  const {
    title,
    tree,
    collapsed,
    curActivePaneKey,
    panesOnShelf = [],
    hiddenPaths = [],
    onClickMenuItem,
    onSetMenuCollapse,
  } = props;
  const [isNarrow, setIsNarrow] = useState(false);  // 窄屏固定覆盖态
  const [isDraggingScroll, setIsDraggingScroll] = useState(false);
  const menuContainerRef = useRef<HTMLDivElement>(null);
  const menuContentRef = useRef<HTMLDivElement>(null);
  const scrollCheckTimerRef = useRef<NodeJS.Timeout>();
  // 转换菜单项
  const items = useMemo(
    () => (tree ? convertTreeToItems(tree, panesOnShelf, hiddenPaths) : []),
    [tree, panesOnShelf, hiddenPaths]
  );
  // 计算内容高度
  const [contentHeight, setContentHeight] = useState(0);
  useEffect(() => {
    if (menuContentRef.current) {
      setContentHeight(menuContentRef.current.scrollHeight);
    }
  }, [items]);
  // 延迟检测滚动需求
  const checkScrollNeed = useCallback(() => {
    if (scrollCheckTimerRef.current) {
      clearTimeout(scrollCheckTimerRef.current);
    }
    scrollCheckTimerRef.current = setTimeout(() => {
      if (menuContentRef.current) {
        setContentHeight(menuContentRef.current.scrollHeight);
      }
    }, 400); // 展开后约 0.35s~0.5s
  }, []);
  // 选中的键
  const selectedKeys = useMemo(() => {
    if (!curActivePaneKey) return [];
    return [normalizeKey(curActivePaneKey)];
  }, [curActivePaneKey]);
  // 展开的键(自动包含选中项的祖先)
  const [openKeys, setOpenKeys] = useState<string[]>([]);
  useEffect(() => {
    if (curActivePaneKey && items.length) {
      const ancestors = findAncestorKeys(items, normalizeKey(curActivePaneKey));
      if (ancestors && ancestors.length > 0) {
        setOpenKeys((prev) => {
          const newSet = new Set([...prev, ...ancestors]);
          return Array.from(newSet);
        });
      }
    }
  }, [curActivePaneKey, items]);
  // 手风琴逻辑:同级互斥
  const handleOpenChange: MenuProps['onOpenChange'] = useCallback(
    (keys: string[]) => {
      if (keys.length === 0) {
        setOpenKeys([]);
        return;
      }
      const latestKey = keys[keys.length - 1];
      const siblingKeys = findSiblingKeys(items, latestKey);
      const filtered = keys.filter((k) => !siblingKeys.includes(k));
      setOpenKeys(filtered);
      checkScrollNeed();
    },
    [items, checkScrollNeed]
  );
  // 叶子项点击
  const handleClick: MenuProps['onClick'] = useCallback(
    ({ key }: { key: string }) => {
      // 递归查找被点击的菜单项
      const findItem = (
        groups: MenuTreeItem[],
        k: string
      ): MenuItem | null => {
        for (const group of groups) {
          if (normalizeKey(group.id) === k) return group;
          if (group.children) {
            for (const item of group.children) {
              if (normalizeKey(item.id) === k) return item;
              if (item.children) {
                const found = findItem(
                  [{ ...group, children: item.children }] as MenuTreeItem[],
                  k
                );
                if (found) return found;
              }
            }
          }
        }
        return null;
      };
      const findDeepItem = (
        groups: MenuTreeItem[],
        k: string
      ): MenuItem | null => {
        for (const group of groups) {
          if (normalizeKey(group.id) === k) return group;
          if (group.children) {
            for (const child of group.children) {
              if (normalizeKey(child.id) === k) return child;
              if (child.children) {
                for (const grandChild of child.children) {
                  if (normalizeKey(grandChild.id) === k) return grandChild;
                }
              }
            }
          }
        }
        return null;
      };
      const clickedItem = findDeepItem(tree || [], key);
      if (!clickedItem) return;
      // 窄屏:先收起再导航
      if (isNarrow && !collapsed) {
        onSetMenuCollapse(true);
        setTimeout(() => {
          onClickMenuItem(clickedItem);
        }, 300);
      } else {
        onClickMenuItem(clickedItem);
      }
    },
    [tree, isNarrow, collapsed, onClickMenuItem, onSetMenuCollapse]
  );
  // 断点变化
  const handleBreakpoint = useCallback(
    (broken: boolean) => {
      setIsNarrow(broken);
      if (broken) {
        onSetMenuCollapse(true);
      }
    },
    [onSetMenuCollapse]
  );
  // 空树处理
  if (!tree || tree.length === 0) {
    return (
      <Layout.Sider className="c-side-menu" breakpoint="lg" onBreakpointChange={handleBreakpoint}>
        <div className="c-side-menu__header">
          <h1 className="c-side-menu__title">{title}</h1>
        </div>
        <div className="c-side-menu__empty">暂无菜单</div>
      </Layout.Sider>
    );
  }
  // 拖拽滚动时遮罩语义
  const overlayClassName = `c-side-menu__overlay ${
    isDraggingScroll ? 'c-side-menu__overlay--transparent' : ''
  }`;
  const menuContainerClassName = `c-side-menu__container ${
    isDraggingScroll ? 'c-side-menu__container--dragging' : ''
  }`;
  return (
    <Layout.Sider
      className="c-side-menu"
      breakpoint="lg"
      onBreakpointChange={handleBreakpoint}
      collapsedWidth={0}
      width={210}
      theme="dark"
    >
      <div className={menuContainerClassName} ref={menuContainerRef}>
        <div className="c-side-menu__header">
          <h1 className="c-side-menu__title">{title}</h1>
        </div>
        <div
          className="c-side-menu__content"
          ref={menuContentRef}
          onScroll={() => {
            // 内容滚动时同步滑块
          }}
        >
          <Menu
            className="c-side-menu__menu"
            mode="inline"
            theme="dark"
            inlineCollapsed={collapsed}
            items={items}
            selectedKeys={selectedKeys}
            openKeys={openKeys}
            onOpenChange={handleOpenChange}
            onClick={handleClick}
          />
          <CSideMenuCustomScroll
            containerRef={menuContainerRef}
            contentHeight={contentHeight}
          />
        </div>
      </div>
      {isNarrow && !collapsed && (
        <div
          className={overlayClassName}
          onClick={() => onSetMenuCollapse()}
        />
      )}
    </Layout.Sider>
  );
};
```
- [ ] **Step 2: 更新 SCSS 添加新增样式**
```scss
// cSideMenu.scss 完整内容
.c-side-menu {
  height: 100%;
  background: #001529 !important;
  &__container {
    position: relative;
    display: flex;
    flex-direction: column;
    height: 100%;
    overflow: hidden;
    &--dragging {
      z-index: 50; // 侧栏内容区 z-index
    }
  }
  &__header {
    flex-shrink: 0;
    padding: 16px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  }
  &__title {
    margin: 0;
    font-size: 16px;
    font-weight: 600;
    color: #fff;
  }
  &__content {
    flex: 1;
    overflow-y: scroll;
    overflow-x: hidden;
    position: relative;
    // 隐藏原生滚动条
    scrollbar-width: none; // Firefox
    -ms-overflow-style: none; // IE/Edge
    &::-webkit-scrollbar {
      display: none; // Chrome/Safari
    }
  }
  &__menu {
    background: transparent !important;
    border-right: none !important;
    .ant-menu-item-selected {
      background: rgba(255, 255, 255, 0.1) !important;
    }
  }
  &__empty {
    padding: 16px;
    color: rgba(255, 255, 255, 0.45);
  }
  &__opened-indicator {
    display: inline-block;
    width: 6px;
    height: 6px;
    margin-left: 8px;
    background: #52c41a;
    border-radius: 50%;
    vertical-align: middle;
  }
  &__overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 10; // 遮罩 z-index
    background: rgba(0, 0, 0, 0.2);
    transition: background 0.3s ease;
    &--transparent {
      background: rgba(0, 0, 0, 0.05);
    }
  }
}
// 自绘滚动条
.c-side-menu__scrollbar {
  position: absolute;
  top: 0;
  right: 0;
  width: 20px;
  height: 100%;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
  z-index: 2;
  &--visible {
    opacity: 1;
    pointer-events: auto;
  }
}
.c-side-menu__scroll-thumb {
  position: absolute;
  top: 0;
  right: 4px;
  width: 6px;
  min-height: 30px;
  background: rgba(255, 255, 255, 0.2);
  border-radius: 3px;
  cursor: pointer;
  transition: width 0.3s ease, background-color 0.3s ease;
  &--active {
    width: 8px;
    background: #597a7f;
  }
  &--hover {
    width: 12px;
    background: #7fa1a8;
  }
  &--dragging {
    width: 12px;
    background: #a3c5cd;
    transition: none;
  }
}
```
- [ ] **Step 3: 运行构建验证**
Run: `npm run build`
Expected: 构建成功,无错误
- [ ] **Step 4: 提交**
```bash
git add src/framework/sideMenu/CSideMenu.tsx src/framework/sideMenu/cSideMenu.scss
git commit -m "refactor(sideMenu): 重构主组件,对齐新架构和设计规范"
```
---
## Task 4: 示例页更新
**Files:**
- Modify: `example/pages/side-menu/SideMenuPage.tsx`
- Modify: `example/App.tsx`
- [ ] **Step 1: 更新 SideMenuPage 使用新类型**
```tsx
// example/pages/side-menu/SideMenuPage.tsx
import React, { useState } from 'react';
import { CSideMenu } from '../../../src';
import type { MenuTreeItem } from '../../../src/framework/sideMenu/types';
/** 模拟菜单数据 */
const mockTree: MenuTreeItem[] = [
  {
    id: '1',
    name: '导航1',
    type: 'folder',
    children: [
      {
        id: '1-1',
        name: '子菜单1-1',
        type: 'folder',
        children: [
          {
            id: '1-1-1',
            name: '页面1-1-1',
            path: '/page1-1-1',
            pageName: 'Page111',
            type: 'file',
          },
          {
            id: '1-1-2',
            name: '页面1-1-2',
            path: '/page1-1-2',
            pageName: 'Page112',
            type: 'file',
          },
        ],
      },
      {
        id: '1-2',
        name: '页面1-2',
        path: '/page1-2',
        pageName: 'Page12',
        type: 'file',
      },
    ],
  },
  {
    id: '2',
    name: '导航2',
    type: 'folder',
    children: [
      {
        id: '2-1',
        name: '页面2-1',
        path: '/page2-1',
        pageName: 'Page21',
        type: 'file',
      },
    ],
  },
];
/** 已打开的页面列表 */
const mockPanesOnShelf = [
  { key: '1-1-1' },
  { key: '1-2' },
];
/** 隐藏的页面路径(入口在组件外) */
const mockHiddenPaths = ['/home', '/dashboard'];
export function SideMenuPage() {
  const [collapsed, setCollapsed] = useState(false);
  const [curActivePaneKey, setCurActivePaneKey] = useState<string | number>('1-1-1');
  const handleClickMenuItem = (item: any) => {
    console.log('点击菜单项:', item);
    setCurActivePaneKey(item.id);
  };
  const handleSetMenuCollapse = (value?: boolean) => {
    if (typeof value === 'boolean') {
      setCollapsed(value);
    } else {
      setCollapsed((prev) => !prev);
    }
  };
  return (
    <div style={{ display: 'flex', height: '100vh' }}>
      <CSideMenu
        title="管理后台"
        tree={mockTree}
        collapsed={collapsed}
        curActivePaneKey={curActivePaneKey}
        panesOnShelf={mockPanesOnShelf}
        hiddenPaths={mockHiddenPaths}
        onClickMenuItem={handleClickMenuItem}
        onSetMenuCollapse={handleSetMenuCollapse}
      />
      <div style={{ flex: 1, padding: '20px', overflow: 'auto' }}>
        <h2>CSideMenu 组件示例</h2>
        <p>当前选中: {curActivePaneKey}</p>
        <p>折叠状态: {collapsed ? '收起' : '展开'}</p>
        <p>隐藏路径: {mockHiddenPaths.join(', ')}</p>
      </div>
    </div>
  );
}
export default SideMenuPage;
```
- [ ] **Step 2: 验证示例页运行**
Run: `npm run dev` (在 example 目录)
Expected: 页面正常渲染,无控制台错误
- [ ] **Step 3: 提交**
```bash
git add example/pages/side-menu/SideMenuPage.tsx
git commit -m "feat(example): 更新SideMenuPage示例,使用新类型和hiddenPaths"
```
---
## Task 5: 单元测试扩展
**Files:**
- Modify: `test/unit/CSideMenu.test.tsx`
- [ ] **Step 1: 扩展测试覆盖 hiddenPaths、手风琴、选中逻辑**
```tsx
// test/unit/CSideMenu.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import { CSideMenu } from '../../src/framework/sideMenu/CSideMenu';
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: query === '(max-width: 992px)',
    media: query,
    onchange: null,
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});
// Mock antd
vi.mock('antd', async () => {
  const actual = await vi.importActual('antd');
  return {
    ...actual,
    Layout: {
      ...actual.Layout,
      Sider: vi.fn(({ children, breakpoint, onBreakpointChange }) => {
        // 模拟断点触发
        React.useEffect(() => {
          if (onBreakpointChange) {
            // 初始不触发
          }
        }, []);
        return <div data-testid="layout-sider">{children}</div>;
      }),
    },
    Menu: vi.fn(({ items, onClick, selectedKeys, openKeys, onOpenChange, children }) => {
      const renderItems = (items: any[], depth = 0): React.ReactNode =>
        items?.map((item: any) => (
          <div
            key={item.key}
            data-testid={`menu-item-${item.key}`}
            data-depth={depth}
            onClick={() => {
              if (item.children) {
                if (onOpenChange) {
                  const newKeys = openKeys?.includes(item.key)
                    ? openKeys.filter((k: string) => k !== item.key)
                    : [...(openKeys || []), item.key];
                  onOpenChange(newKeys);
                }
              } else {
                onClick?.({ key: item.key });
              }
            }}
          >
            <span data-testid={`label-${item.key}`}>{item.label}</span>
            {item.children && (
              <div className="submenu">{renderItems(item.children, depth + 1)}</div>
            )}
          </div>
        ));
      return (
        <div data-testid="antd-menu">
          {items ? renderItems(items) : children}
        </div>
      );
    }),
  };
});
// Mock icons
vi.mock('@ant-design/icons', () => ({
  PieChartOutlined: () => <span data-testid="icon-chart">ChartIcon</span>,
  SettingOutlined: () => <span data-testid="icon-setting">SettingIcon</span>,
  FolderOutlined: () => <span data-testid="icon-folder">FolderIcon</span>,
  FileOutlined: () => <span data-testid="icon-file">FileIcon</span>,
}));
describe('CSideMenu 组件', () => {
  const mockOnClickMenuItem = vi.fn();
  const mockOnSetMenuCollapse = vi.fn();
  const basicTree = [
    {
      id: '1',
      name: '导航1',
      type: 'folder' as const,
      children: [
        {
          id: '1-1',
          name: '子菜单1-1',
          type: 'folder' as const,
          children: [
            { id: '1-1-1', name: '页面1-1-1', path: '/page1-1-1', pageName: 'Page111', type: 'file' as const },
            { id: '1-1-2', name: '页面1-1-2', path: '/page1-1-2', pageName: 'Page112', type: 'file' as const },
          ],
        },
        { id: '1-2', name: '页面1-2', path: '/page1-2', pageName: 'Page12', type: 'file' as const },
      ],
    },
  ];
  beforeEach(() => {
    vi.clearAllMocks();
  });
  describe('基础渲染', () => {
    it('空树时渲染标题和空区域', () => {
      render(
        <CSideMenu
          title="测试标题"
          tree={[]}
          collapsed={false}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      expect(screen.getByText('测试标题')).toBeDefined();
      expect(screen.getByText('暂无菜单')).toBeDefined();
    });
    it('渲染标题', () => {
      render(
        <CSideMenu
          title="我的侧边栏"
          tree={basicTree}
          collapsed={false}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      expect(screen.getByText('我的侧边栏')).toBeDefined();
    });
  });
  describe('三级菜单渲染', () => {
    it('渲染三级菜单结构', () => {
      render(
        <CSideMenu
          title="测试"
          tree={basicTree}
          collapsed={false}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      expect(screen.getByText('导航1')).toBeDefined();
      expect(screen.getByText('子菜单1-1')).toBeDefined();
      expect(screen.getByText('页面1-1-1')).toBeDefined();
    });
  });
  describe('hiddenPaths 过滤', () => {
    it('隐藏指定路径的菜单项', () => {
      const treeWithHidden = [
        {
          id: '1',
          name: '导航1',
          type: 'folder' as const,
          children: [
            { id: '1-1', name: '页面1-1', path: '/hidden-page', pageName: 'Hidden', type: 'file' as const },
            { id: '1-2', name: '页面1-2', path: '/visible-page', pageName: 'Visible', type: 'file' as const },
          ],
        },
      ];
      render(
        <CSideMenu
          title="测试"
          tree={treeWithHidden}
          collapsed={false}
          hiddenPaths={['/hidden-page']}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      expect(screen.queryByText('页面1-1')).toBeNull();
      expect(screen.getByText('页面1-2')).toBeDefined();
    });
    it('空 hiddenPaths 渲染所有节点', () => {
      const tree = [
        {
          id: '1',
          name: '导航1',
          type: 'folder' as const,
          children: [
            { id: '1-1', name: '页面1-1', path: '/page1', pageName: 'Page1', type: 'file' as const },
          ],
        },
      ];
      render(
        <CSideMenu
          title="测试"
          tree={tree}
          collapsed={false}
          hiddenPaths={[]}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      expect(screen.getByText('页面1-1')).toBeDefined();
    });
  });
  describe('onClickMenuItem 回调', () => {
    it('点击叶子节点触发回调,传入完整 item', () => {
      render(
        <CSideMenu
          title="测试"
          tree={basicTree}
          collapsed={false}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      const leafItem = screen.getByTestId('menu-item-1-1-1');
      fireEvent.click(leafItem);
      expect(mockOnClickMenuItem).toHaveBeenCalledWith(
        expect.objectContaining({
          id: '1-1-1',
          name: '页面1-1-1',
          path: '/page1-1-1',
          pageName: 'Page111',
        })
      );
    });
  });
  describe('onSetMenuCollapse 回调', () => {
    it('支持 boolean 参数', () => {
      render(
        <CSideMenu
          title="测试"
          tree={basicTree}
          collapsed={false}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      // 通过点击 overlay 触发收起
      const overlay = screen.getByTestId('layout-sider');
      expect(overlay).toBeDefined();
    });
    it('支持无参调用(切换)', () => {
      render(
        <CSideMenu
          title="测试"
          tree={basicTree}
          collapsed={false}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      mockOnSetMenuCollapse();
      expect(mockOnSetMenuCollapse).toHaveBeenCalledWith();
    });
  });
  describe('已打开标签指示器', () => {
    it('panesOnShelf 中的 key 匹配时显示指示器', () => {
      render(
        <CSideMenu
          title="测试"
          tree={basicTree}
          collapsed={false}
          panesOnShelf={[{ key: '1-1-1' }]}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      const indicator = screen.getByTestId('layout-sider');
      expect(indicator.querySelector('.c-side-menu__opened-indicator')).toBeTruthy();
    });
  });
});
```
- [ ] **Step 2: 运行测试**
Run: `npm run test`
Expected: 所有测试通过
- [ ] **Step 3: 提交**
```bash
git add test/unit/CSideMenu.test.tsx
git commit -m "test(sideMenu): 扩展单元测试覆盖hiddenPaths、手风琴、选中逻辑"
```
---
## Task 6: E2E 测试
**Files:**
- Create: `e2e/side-menu.spec.ts`
- [ ] **Step 1: 创建 Playwright E2E 测试**
```typescript
// e2e/side-menu.spec.ts
import { test, expect } from '@playwright/test';
test.describe('CSideMenu 组件', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/side-menu');
  });
  test('渲染三级菜单', async ({ page }) => {
    await expect(page.getByText('导航1')).toBeVisible();
    await expect(page.getByText('子菜单1-1')).toBeVisible();
    await expect(page.getByText('页面1-1-1')).toBeVisible();
  });
  test('点击叶子节点触发回调', async ({ page }) => {
    const consoleLogs: string[] = [];
    page.on('console', (msg) => {
      if (msg.type() === 'log') consoleLogs.push(msg.text());
    });
    await page.getByText('页面1-1-1').click();
    await expect(consoleLogs).toContainEqual(
      expect.stringContaining('点击菜单项')
    );
  });
  test('hiddenPaths 隐藏指定菜单项', async ({ page }) => {
    // 验证隐藏项不显示
    await expect(page.getByText('页面1-1')).not.toBeVisible();
    // 验证可见项显示
    await expect(page.getByText('页面1-2')).toBeVisible();
  });
  test('已打开标签显示指示器', async ({ page }) => {
    const indicator = page.locator('.c-side-menu__opened-indicator');
    await expect(indicator).toHaveCount(2); // 1-1-1 和 1-2
  });
  test('窄屏展开收起', async ({ page }) => {
    // 设置窄屏
    await page.setViewportSize({ width: 375, height: 667 });
    await page.reload();
    // 初始收起
    const overlay = page.locator('.c-side-menu__overlay');
    await expect(overlay).not.toBeVisible();
  });
  test('自绘滚动条交互反馈', async ({ page }) => {
    // 窄屏下滚动条不显示
    await page.setViewportSize({ width: 375, height: 667 });
    await expect(page.locator('.c-side-menu__scrollbar')).not.toBeVisible();
  });
});
```
- [ ] **Step 2: 运行 E2E 测试**
Run: `npx playwright test e2e/side-menu.spec.ts`
Expected: 所有 E2E 测试通过
- [ ] **Step 3: 提交**
```bash
git add e2e/side-menu.spec.ts
git commit -m "test(e2e): 添加CSideMenu组件E2E测试"
```
---
## 自检清单
**Spec 覆盖检查:**
| 需求 | 对应任务 |
|------|----------|
| 三级菜单渲染 | Task 3 (主组件) |
| hiddenPaths 过滤 | Task 1 (类型) + Task 3 (主组件) |
| 手风琴同级展开 | Task 3 (主组件 handleOpenChange) |
| 窄屏固定覆盖 | Task 3 (Layout.Sider breakpoint) |
| 遮罩层级协同 | Task 3 (overlay class + z-index) |
| 自绘滚动条 | Task 2 (CSideMenuCustomScroll) |
| 四级交互反馈 | Task 2 (thumb states) |
| 已打开指示器 | Task 3 (opened-indicator) |
| 点击叶子回调 | Task 3 (handleClick) |
| 延迟导航 300ms | Task 3 (setTimeout) |
| 空状态 | Task 3 (空树判断) |
**类型一致性检查:**
- [x] `MenuItem.id` — Task 1-6 全部使用
- [x] `MenuItem.name` — Task 1-6 全部使用
- [x] `MenuItem.children` — Task 1-6 全部使用
- [x] `MenuItem.path` — Task 1-6 全部使用
- [x] `MenuItem.pageName` — Task 1-6 全部使用
- [x] `CSideMenuProps.hiddenPaths` — Task 1, 3, 4, 5
---
**Plan complete!** 已保存至 `docs/superpowers/plans/2026-04-13-c-side-menu-v2-implementation.md`
两个执行选项:
**1. Subagent-Driven (recommended)** — 每个任务由独立 subagent 执行,任务间有审核,适合复杂重构
**2. Inline Execution** — 在当前 session 内批量执行,带检查点审核,适合快速迭代
你选择哪种方式?