# 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 { 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; // 可滚容器引用 contentHeight: number; // 内容总高度 } export const CSideMenuCustomScroll: React.FC = ({ containerRef, contentHeight, }) => { const scrollbarRef = useRef(null); const thumbRef = useRef(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 (
setIsHoveringTrack(true)} onMouseLeave={() => setIsHoveringTrack(false)} >
setIsHoveringThumb(true)} onMouseLeave={() => setIsHoveringThumb(false)} />
); }; ``` - [ ] **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 = { chart: , setting: , folder: , file: , }; const DefaultIcon = ; /** 键值标准化为字符串 */ 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: ( {group.name} {isGroupOpened && ( )} ), 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: ( {item.name} {isOpened && } ), 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 = (props) => { const { title, tree, collapsed, curActivePaneKey, panesOnShelf = [], hiddenPaths = [], onClickMenuItem, onSetMenuCollapse, } = props; const [isNarrow, setIsNarrow] = useState(false); // 窄屏固定覆盖态 const [isDraggingScroll, setIsDraggingScroll] = useState(false); const menuContainerRef = useRef(null); const menuContentRef = useRef(null); const scrollCheckTimerRef = useRef(); // 转换菜单项 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([]); 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 (

{title}

暂无菜单
); } // 拖拽滚动时遮罩语义 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 (

{title}

{ // 内容滚动时同步滑块 }} >
{isNarrow && !collapsed && (
onSetMenuCollapse()} /> )} ); }; ``` - [ ] **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('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 (

CSideMenu 组件示例

当前选中: {curActivePaneKey}

折叠状态: {collapsed ? '收起' : '展开'}

隐藏路径: {mockHiddenPaths.join(', ')}

); } 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
{children}
; }), }, Menu: vi.fn(({ items, onClick, selectedKeys, openKeys, onOpenChange, children }) => { const renderItems = (items: any[], depth = 0): React.ReactNode => items?.map((item: any) => (
{ 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 }); } }} > {item.label} {item.children && (
{renderItems(item.children, depth + 1)}
)}
)); return (
{items ? renderItems(items) : children}
); }), }; }); // Mock icons vi.mock('@ant-design/icons', () => ({ PieChartOutlined: () => ChartIcon, SettingOutlined: () => SettingIcon, FolderOutlined: () => FolderIcon, FileOutlined: () => FileIcon, })); 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( ); expect(screen.getByText('测试标题')).toBeDefined(); expect(screen.getByText('暂无菜单')).toBeDefined(); }); it('渲染标题', () => { render( ); expect(screen.getByText('我的侧边栏')).toBeDefined(); }); }); describe('三级菜单渲染', () => { it('渲染三级菜单结构', () => { render( ); 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( ); 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( ); expect(screen.getByText('页面1-1')).toBeDefined(); }); }); describe('onClickMenuItem 回调', () => { it('点击叶子节点触发回调,传入完整 item', () => { render( ); 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( ); // 通过点击 overlay 触发收起 const overlay = screen.getByTestId('layout-sider'); expect(overlay).toBeDefined(); }); it('支持无参调用(切换)', () => { render( ); mockOnSetMenuCollapse(); expect(mockOnSetMenuCollapse).toHaveBeenCalledWith(); }); }); describe('已打开标签指示器', () => { it('panesOnShelf 中的 key 匹配时显示指示器', () => { render( ); 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 内批量执行,带检查点审核,适合快速迭代 你选择哪种方式?