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