From 3d72fc0dcbac884bec464a7058c83ebaa1d0b815 Mon Sep 17 00:00:00 2001
From: Tevin <tingquanren@163.com>
Date: Mon, 13 Apr 2026 12:22:48 +0800
Subject: [PATCH] chore: 完成 implement-c-side-menu 归档的目录移动

---
 docs/superpowers/plans/2026-04-13-c-side-menu-v2-implementation.md | 1422 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 1,422 insertions(+), 0 deletions(-)

diff --git a/docs/superpowers/plans/2026-04-13-c-side-menu-v2-implementation.md b/docs/superpowers/plans/2026-04-13-c-side-menu-v2-implementation.md
new file mode 100644
index 0000000..1b33802
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-13-c-side-menu-v2-implementation.md
@@ -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 内批量执行,带检查点审核,适合快速迭代
+
+你选择哪种方式?
\ No newline at end of file

--
Gitblit v1.9.1