| New file |
| | |
| | | # 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 内批量执行,带检查点审核,适合快速迭代 |
| | | |
| | | 你选择哪种方式? |