AdmSysV2【公共组件库】@前端(For Git Submodule)
2 files modified
5 files renamed
3 files added
1 files deleted
2073 ■■■■ changed files
docs/superpowers/plans/2026-04-13-c-side-menu-v2-implementation.md 1422 ●●●●● patch | view | raw | blame | history
openspec/changes/archive/2026-04-13-implement-c-side-menu/.openspec.yaml patch | view | raw | blame | history
openspec/changes/archive/2026-04-13-implement-c-side-menu/design.md patch | view | raw | blame | history
openspec/changes/archive/2026-04-13-implement-c-side-menu/proposal.md patch | view | raw | blame | history
openspec/changes/archive/2026-04-13-implement-c-side-menu/specs/side-menu/spec.md patch | view | raw | blame | history
openspec/changes/archive/2026-04-13-implement-c-side-menu/tasks.md patch | view | raw | blame | history
openspec/docs/old-refactors/side-menu/adr.md 89 ●●●●● patch | view | raw | blame | history
openspec/docs/old-refactors/side-menu/prod.md 142 ●●●●● patch | view | raw | blame | history
openspec/docs/old-refactors/side-menu/spec.md 250 ●●●●● patch | view | raw | blame | history
openspec/docs/old-refactors/side-menu/task.md 79 ●●●● patch | view | raw | blame | history
openspec/specs/side-menu/spec.md 91 ●●●●● patch | view | raw | blame | history
docs/superpowers/plans/2026-04-13-c-side-menu-v2-implementation.md
New file
@@ -0,0 +1,1422 @@
# CSideMenu V2 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 基于 antd@6 实现完整版 CSideMenu 组件,支持三级菜单、手风琴、自绘滚动条、固定覆盖、hiddenPaths 等全部功能。
**Architecture:** 使用 antd `Layout.Sider` + `Menu`(dark inline 主题)作为基础,自研子组件处理滚动条与遮罩交互。
**Tech Stack:** React 18 + TypeScript + antd@6 + SCSS + Vitest + Playwright
---
## 文件结构
```
src/framework/sideMenu/
├── CSideMenu.tsx              # 主组件(重构)
├── CSideMenuCustomScroll.tsx  # 自绘滚动条组件(新建)
├── types.ts                  # 类型定义(重构)
└── cSideMenu.scss            # 样式文件(重构)
example/pages/side-menu/
└── SideMenuPage.tsx          # 示例页(更新)
test/unit/
└── CSideMenu.test.tsx        # 单元测试(更新)
```
---
## Task 1: 类型定义重构
**Files:**
- Modify: `src/framework/sideMenu/types.ts`
**关键变更:** 数据模型字段从 `key`/`label` 改为 `id`/`name`,新增 `hiddenPaths` prop。
- [ ] **Step 1: 备份并重写 types.ts**
```typescript
// src/framework/sideMenu/types.ts
/**
 * 菜单树节点
 */
export interface MenuItem {
  /** 唯一标识 (string | number) */
  id: string | number;
  /** 显示文本 */
  name: string;
  /** 图标类型 */
  type?: 'chart' | 'setting' | 'folder' | 'file';
  /** 路由路径 */
  path?: string;
  /** 页面名称 */
  pageName?: string;
  /** 子菜单/页面 */
  children?: MenuItem[];
}
/**
 * 菜单树结构(一级分组)
 */
export interface MenuTreeItem extends Omit<MenuItem, 'children'> {
  children: MenuItem[];
}
/**
 * 已打开页签
 */
export interface PaneOnShelf {
  key: string;
}
/**
 * CSideMenu 组件属性
 */
export interface CSideMenuProps {
  /** 顶区标题 */
  title: string;
  /** 合并后的菜单树(一级分组列表) */
  tree: MenuTreeItem[];
  /** 是否收起(宿主驱动) */
  collapsed: boolean;
  /** 当前选中键 */
  curActivePaneKey?: string | number;
  /** 已打开页签列表 */
  panesOnShelf?: PaneOnShelf[];
  /** 隐藏的菜单路径(入口在组件外,如后台首页) */
  hiddenPaths?: string[];
  /** 点击叶子菜单项回调 */
  onClickMenuItem: (item: MenuItem) => void;
  /** 设置折叠状态回调(支持 boolean 或无参切换) */
  onSetMenuCollapse: (collapsed?: boolean) => void;
}
```
- [ ] **Step 2: 运行类型检查**
Run: `npx tsc --noEmit`
Expected: 无类型错误
- [ ] **Step 3: 提交**
```bash
git add src/framework/sideMenu/types.ts
git commit -m "refactor(sideMenu): 更新类型定义,使用 id/name 替代 key/label,新增 hiddenPaths"
```
---
## Task 2: 自绘滚动条组件
**Files:**
- Create: `src/framework/sideMenu/CSideMenuCustomScroll.tsx`
- Modify: `src/framework/sideMenu/cSideMenu.scss`
**关键行为:**
- 触发条件:宽屏(≥992px)且内容超高(含 10px 容差)
- 滑块高度 = 可视高度 × (可视高度 / 内容高度)
- 滑块位移与 scrollTop 双向同步
- 四级交互反馈:外围→邻近→主操作面→激活拖拽
- [ ] **Step 1: 创建 CSideMenuCustomScroll.tsx**
```tsx
// src/framework/sideMenu/CSideMenuCustomScroll.tsx
import React, { useRef, useState, useEffect, useCallback } from 'react';
interface CSideMenuCustomScrollProps {
  containerRef: React.RefObject<HTMLDivElement>;  // 可滚容器引用
  contentHeight: number;                           // 内容总高度
}
export const CSideMenuCustomScroll: React.FC<CSideMenuCustomScrollProps> = ({
  containerRef,
  contentHeight,
}) => {
  const scrollbarRef = useRef<HTMLDivElement>(null);
  const thumbRef = useRef<HTMLDivElement>(null);
  const [isDragging, setIsDragging] = useState(false);
  const [isHoveringTrack, setIsHoveringTrack] = useState(false);
  const [isHoveringThumb, setIsHoveringThumb] = useState(false);
  const [isTrackVisible, setIsTrackVisible] = useState(false);
  const container = containerRef.current;
  const可视高度 = container?.clientHeight || 0;
  const是否需要滚动 = contentHeight > 可视高度 + 10;
  // 计算滑块高度
  const滑块高度 = Math.max(
    30,
    可视高度 * (可视高度 / contentHeight)
  );
  // 计算滑块位置
  const scrollTop = container?.scrollTop || 0;
  const maxThumbTop = 可视高度 - 滑块高度;
  const thumbTop = (scrollTop / (contentHeight - 可视高度)) * maxThumbTop || 0;
  // 显示/隐藏轨道
  useEffect(() => {
    if (是否需要滚动) {
      const timer = setTimeout(() => setIsTrackVisible(true), 300); // delay 0.3s
      return () => clearTimeout(timer);
    } else {
      setIsTrackVisible(false);
    }
  }, [是否需要滚动]);
  // 拖拽逻辑
  const handleThumbMouseDown = useCallback(
    (e: React.MouseEvent) => {
      e.preventDefault();
      setIsDragging(true);
      const startY = e.clientY;
      const startScrollTop = container?.scrollTop || 0;
      const thumbHeightRatio = (contentHeight - 可视高度) / maxThumbTop;
      const handleMouseMove = (moveEvent: MouseEvent) => {
        if (container) {
          const deltaY = moveEvent.clientY - startY;
          container.scrollTop = startScrollTop + deltaY * thumbHeightRatio;
        }
      };
      const handleMouseUp = () => {
        setIsDragging(false);
        document.removeEventListener('mousemove', handleMouseMove);
        document.removeEventListener('mouseup', handleMouseUp);
      };
      document.addEventListener('mousemove', handleMouseMove);
      document.addEventListener('mouseup', handleMouseUp);
    },
    [container, contentHeight, 可视高度, maxThumbTop]
  );
  // 触摸支持
  const handleTouchStart = useCallback(
    (e: React.TouchEvent) => {
      const touch = e.touches[0];
      const startY = touch.clientY;
      const startScrollTop = container?.scrollTop || 0;
      const thumbHeightRatio = (contentHeight - 可视高度) / maxThumbTop;
      const handleTouchMove = (moveEvent: TouchEvent) => {
        if (container) {
          const deltaY = moveEvent.touches[0].clientY - startY;
          container.scrollTop = startScrollTop + deltaY * thumbHeightRatio;
        }
      };
      const handleTouchEnd = () => {
        document.removeEventListener('touchmove', handleTouchMove);
        document.removeEventListener('touchend', handleTouchEnd);
      };
      document.addEventListener('touchmove', handleTouchMove);
      document.addEventListener('touchend', handleTouchEnd);
    },
    [container, contentHeight, 可视高度, maxThumbTop]
  );
  if (!是否需要滚动) return null;
  const getThumbClassName = () => {
    let className = 'c-side-menu__scroll-thumb';
    if (isDragging) className += ' c-side-menu__scroll-thumb--dragging';
    else if (isHoveringThumb) className += ' c-side-menu__scroll-thumb--hover';
    else if (isHoveringTrack) className += ' c-side-menu__scroll-thumb--active';
    return className;
  };
  return (
    <div
      ref={scrollbarRef}
      className={`c-side-menu__scrollbar ${isTrackVisible ? 'c-side-menu__scrollbar--visible' : ''}`}
      onMouseEnter={() => setIsHoveringTrack(true)}
      onMouseLeave={() => setIsHoveringTrack(false)}
    >
      <div
        ref={thumbRef}
        className={getThumbClassName()}
        style={{ height: `${滑块高度}px`, transform: `translateY(${thumbTop}px)` }}
        onMouseDown={handleThumbMouseDown}
        onTouchStart={handleTouchStart}
        onMouseEnter={() => setIsHoveringThumb(true)}
        onMouseLeave={() => setIsHoveringThumb(false)}
      />
    </div>
  );
};
```
- [ ] **Step 2: 添加滚动条样式到 cSideMenu.scss**
```scss
// 在 cSideMenu.scss 末尾追加
// 自绘滚动条
.c-side-menu__scrollbar {
  position: absolute;
  top: 0;
  right: 0;
  width: 20px;
  height: 100%;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
  z-index: 1;
  &--visible {
    opacity: 1;
    pointer-events: auto;
  }
}
.c-side-menu__scroll-thumb {
  position: absolute;
  top: 0;
  right: 4px;
  width: 6px;
  min-height: 30px;
  background: rgba(255, 255, 255, 0.2);
  border-radius: 3px;
  cursor: pointer;
  transition: width 0.3s ease, background-color 0.3s ease;
  // 控件邻近带
  &--active {
    width: 8px;
    background: #597a7f;
  }
  // 主操作面
  &--hover {
    width: 12px;
    background: #7fa1a8;
  }
  // 激活/拖拽中
  &--dragging {
    width: 12px;
    background: #a3c5cd;
    transition: none;
  }
}
```
- [ ] **Step 3: 提交**
```bash
git add src/framework/sideMenu/CSideMenuCustomScroll.tsx src/framework/sideMenu/cSideMenu.scss
git commit -m "feat(sideMenu): 添加自绘滚动条组件CSideMenuCustomScroll"
```
---
## Task 3: 主组件重构
**Files:**
- Modify: `src/framework/sideMenu/CSideMenu.tsx`
**关键变更:**
- 使用 `Layout.Sider` 的 `breakpoint="lg"` 和 `onBreakpointChange`
- 调用 `hiddenPaths` 过滤
- 实现同级手风琴(而非全局手风琴)
- 展开后延迟检测滚动
- 窄屏点击叶子延迟 300ms
- 拖拽时遮罩近透明 + z-index 抬升
- [ ] **Step 1: 重写 CSideMenu.tsx**
```tsx
// src/framework/sideMenu/CSideMenu.tsx
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { Menu, Layout } from 'antd';
import type { MenuProps } from 'antd';
import {
  PieChartOutlined,
  SettingOutlined,
  FolderOutlined,
  FileOutlined,
} from '@ant-design/icons';
import type { CSideMenuProps, MenuItem, MenuTreeItem } from './types';
import { CSideMenuCustomScroll } from './CSideMenuCustomScroll';
import './cSideMenu.scss';
/** 图标类型映射 */
const iconMap: Record<string, React.ReactNode> = {
  chart: <PieChartOutlined />,
  setting: <SettingOutlined />,
  folder: <FolderOutlined />,
  file: <FileOutlined />,
};
const DefaultIcon = <FileOutlined />;
/** 键值标准化为字符串 */
function normalizeKey(key: string | number): string {
  return String(key);
}
/** antd Menu 项类型 */
interface AntdMenuItem {
  key: string;
  label: React.ReactNode;
  icon?: React.ReactNode;
  children?: AntdMenuItem[];
}
/**
 * 过滤隐藏路径
 */
function filterHiddenItems(
  items: MenuItem[],
  hiddenPaths: string[] = []
): MenuItem[] {
  if (!hiddenPaths.length) return items;
  return items.filter((item) => {
    const itemPath = item.path ? normalizeKey(item.path) : normalizeKey(item.id);
    const isHidden = hiddenPaths.some(
      (hp) => normalizeKey(hp) === itemPath
    );
    return !isHidden;
  }).map((item) => ({
    ...item,
    children: item.children
      ? filterHiddenItems(item.children, hiddenPaths)
      : undefined,
  }));
}
/**
 * 将 MenuTree 转换为 antd Menu items 格式
 */
function convertTreeToItems(
  tree: MenuTreeItem[],
  panesOnShelf: Array<{ key: string }> = [],
  hiddenPaths: string[] = []
): AntdMenuItem[] {
  const filteredTree = tree.map((group) => ({
    ...group,
    children: filterHiddenItems(group.children || [], hiddenPaths),
  }));
  return filteredTree.map((group) => {
    const isGroupOpened = panesOnShelf.some((p) =>
      group.children.some(
        (child) =>
          normalizeKey(p.key) === normalizeKey(child.id) ||
          normalizeKey(p.key) === normalizeKey(child.key)
      )
    );
    return {
      key: normalizeKey(group.id),
      label: (
        <span>
          {group.name}
          {isGroupOpened && (
            <span className="c-side-menu__opened-indicator" />
          )}
        </span>
      ),
      icon: group.type ? iconMap[group.type] || DefaultIcon : DefaultIcon,
      children: group.children?.map((item) => convertItem(item, panesOnShelf)),
    };
  });
}
/** 转换单个菜单项(含递归) */
function convertItem(
  item: MenuItem,
  panesOnShelf: Array<{ key: string }> = []
): AntdMenuItem {
  const isOpened = panesOnShelf.some(
    (p) =>
      normalizeKey(p.key) === normalizeKey(item.id) ||
      normalizeKey(p.key) === normalizeKey(item.key)
  );
  const isLeaf = !item.children || item.children.length === 0;
  return {
    key: normalizeKey(item.id),
    label: (
      <span>
        {item.name}
        {isOpened && <span className="c-side-menu__opened-indicator" />}
      </span>
    ),
    icon: item.type ? iconMap[item.type] || DefaultIcon : DefaultIcon,
    children: isLeaf
      ? undefined
      : item.children?.map((child) => convertItem(child, panesOnShelf)),
  };
}
/** 递归查找叶子节点的所有祖先 key */
function findAncestorKeys(
  items: AntdMenuItem[],
  targetKey: string,
  ancestors: string[] = []
): string[] | null {
  for (const item of items) {
    if (item.key === targetKey) {
      return ancestors;
    }
    if (item.children) {
      const found = findAncestorKeys(item.children, targetKey, [
        ...ancestors,
        item.key,
      ]);
      if (found) return found;
    }
  }
  return null;
}
/** 在树中查找同级其他 key */
function findSiblingKeys(
  items: AntdMenuItem[],
  key: string
): string[] {
  for (const item of items) {
    if (item.key === key) return [];
    if (item.children) {
      for (const child of item.children) {
        if (child.key === key) {
          return item.children
            .filter((c) => c.key !== key)
            .map((c) => c.key);
        }
        const siblingResult = findSiblingKeys([child], key);
        if (siblingResult.length > 0) return siblingResult;
      }
    }
  }
  return [];
}
export const CSideMenu: React.FC<CSideMenuProps> = (props) => {
  const {
    title,
    tree,
    collapsed,
    curActivePaneKey,
    panesOnShelf = [],
    hiddenPaths = [],
    onClickMenuItem,
    onSetMenuCollapse,
  } = props;
  const [isNarrow, setIsNarrow] = useState(false);  // 窄屏固定覆盖态
  const [isDraggingScroll, setIsDraggingScroll] = useState(false);
  const menuContainerRef = useRef<HTMLDivElement>(null);
  const menuContentRef = useRef<HTMLDivElement>(null);
  const scrollCheckTimerRef = useRef<NodeJS.Timeout>();
  // 转换菜单项
  const items = useMemo(
    () => (tree ? convertTreeToItems(tree, panesOnShelf, hiddenPaths) : []),
    [tree, panesOnShelf, hiddenPaths]
  );
  // 计算内容高度
  const [contentHeight, setContentHeight] = useState(0);
  useEffect(() => {
    if (menuContentRef.current) {
      setContentHeight(menuContentRef.current.scrollHeight);
    }
  }, [items]);
  // 延迟检测滚动需求
  const checkScrollNeed = useCallback(() => {
    if (scrollCheckTimerRef.current) {
      clearTimeout(scrollCheckTimerRef.current);
    }
    scrollCheckTimerRef.current = setTimeout(() => {
      if (menuContentRef.current) {
        setContentHeight(menuContentRef.current.scrollHeight);
      }
    }, 400); // 展开后约 0.35s~0.5s
  }, []);
  // 选中的键
  const selectedKeys = useMemo(() => {
    if (!curActivePaneKey) return [];
    return [normalizeKey(curActivePaneKey)];
  }, [curActivePaneKey]);
  // 展开的键(自动包含选中项的祖先)
  const [openKeys, setOpenKeys] = useState<string[]>([]);
  useEffect(() => {
    if (curActivePaneKey && items.length) {
      const ancestors = findAncestorKeys(items, normalizeKey(curActivePaneKey));
      if (ancestors && ancestors.length > 0) {
        setOpenKeys((prev) => {
          const newSet = new Set([...prev, ...ancestors]);
          return Array.from(newSet);
        });
      }
    }
  }, [curActivePaneKey, items]);
  // 手风琴逻辑:同级互斥
  const handleOpenChange: MenuProps['onOpenChange'] = useCallback(
    (keys: string[]) => {
      if (keys.length === 0) {
        setOpenKeys([]);
        return;
      }
      const latestKey = keys[keys.length - 1];
      const siblingKeys = findSiblingKeys(items, latestKey);
      const filtered = keys.filter((k) => !siblingKeys.includes(k));
      setOpenKeys(filtered);
      checkScrollNeed();
    },
    [items, checkScrollNeed]
  );
  // 叶子项点击
  const handleClick: MenuProps['onClick'] = useCallback(
    ({ key }: { key: string }) => {
      // 递归查找被点击的菜单项
      const findItem = (
        groups: MenuTreeItem[],
        k: string
      ): MenuItem | null => {
        for (const group of groups) {
          if (normalizeKey(group.id) === k) return group;
          if (group.children) {
            for (const item of group.children) {
              if (normalizeKey(item.id) === k) return item;
              if (item.children) {
                const found = findItem(
                  [{ ...group, children: item.children }] as MenuTreeItem[],
                  k
                );
                if (found) return found;
              }
            }
          }
        }
        return null;
      };
      const findDeepItem = (
        groups: MenuTreeItem[],
        k: string
      ): MenuItem | null => {
        for (const group of groups) {
          if (normalizeKey(group.id) === k) return group;
          if (group.children) {
            for (const child of group.children) {
              if (normalizeKey(child.id) === k) return child;
              if (child.children) {
                for (const grandChild of child.children) {
                  if (normalizeKey(grandChild.id) === k) return grandChild;
                }
              }
            }
          }
        }
        return null;
      };
      const clickedItem = findDeepItem(tree || [], key);
      if (!clickedItem) return;
      // 窄屏:先收起再导航
      if (isNarrow && !collapsed) {
        onSetMenuCollapse(true);
        setTimeout(() => {
          onClickMenuItem(clickedItem);
        }, 300);
      } else {
        onClickMenuItem(clickedItem);
      }
    },
    [tree, isNarrow, collapsed, onClickMenuItem, onSetMenuCollapse]
  );
  // 断点变化
  const handleBreakpoint = useCallback(
    (broken: boolean) => {
      setIsNarrow(broken);
      if (broken) {
        onSetMenuCollapse(true);
      }
    },
    [onSetMenuCollapse]
  );
  // 空树处理
  if (!tree || tree.length === 0) {
    return (
      <Layout.Sider className="c-side-menu" breakpoint="lg" onBreakpointChange={handleBreakpoint}>
        <div className="c-side-menu__header">
          <h1 className="c-side-menu__title">{title}</h1>
        </div>
        <div className="c-side-menu__empty">暂无菜单</div>
      </Layout.Sider>
    );
  }
  // 拖拽滚动时遮罩语义
  const overlayClassName = `c-side-menu__overlay ${
    isDraggingScroll ? 'c-side-menu__overlay--transparent' : ''
  }`;
  const menuContainerClassName = `c-side-menu__container ${
    isDraggingScroll ? 'c-side-menu__container--dragging' : ''
  }`;
  return (
    <Layout.Sider
      className="c-side-menu"
      breakpoint="lg"
      onBreakpointChange={handleBreakpoint}
      collapsedWidth={0}
      width={210}
      theme="dark"
    >
      <div className={menuContainerClassName} ref={menuContainerRef}>
        <div className="c-side-menu__header">
          <h1 className="c-side-menu__title">{title}</h1>
        </div>
        <div
          className="c-side-menu__content"
          ref={menuContentRef}
          onScroll={() => {
            // 内容滚动时同步滑块
          }}
        >
          <Menu
            className="c-side-menu__menu"
            mode="inline"
            theme="dark"
            inlineCollapsed={collapsed}
            items={items}
            selectedKeys={selectedKeys}
            openKeys={openKeys}
            onOpenChange={handleOpenChange}
            onClick={handleClick}
          />
          <CSideMenuCustomScroll
            containerRef={menuContainerRef}
            contentHeight={contentHeight}
          />
        </div>
      </div>
      {isNarrow && !collapsed && (
        <div
          className={overlayClassName}
          onClick={() => onSetMenuCollapse()}
        />
      )}
    </Layout.Sider>
  );
};
```
- [ ] **Step 2: 更新 SCSS 添加新增样式**
```scss
// cSideMenu.scss 完整内容
.c-side-menu {
  height: 100%;
  background: #001529 !important;
  &__container {
    position: relative;
    display: flex;
    flex-direction: column;
    height: 100%;
    overflow: hidden;
    &--dragging {
      z-index: 50; // 侧栏内容区 z-index
    }
  }
  &__header {
    flex-shrink: 0;
    padding: 16px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  }
  &__title {
    margin: 0;
    font-size: 16px;
    font-weight: 600;
    color: #fff;
  }
  &__content {
    flex: 1;
    overflow-y: scroll;
    overflow-x: hidden;
    position: relative;
    // 隐藏原生滚动条
    scrollbar-width: none; // Firefox
    -ms-overflow-style: none; // IE/Edge
    &::-webkit-scrollbar {
      display: none; // Chrome/Safari
    }
  }
  &__menu {
    background: transparent !important;
    border-right: none !important;
    .ant-menu-item-selected {
      background: rgba(255, 255, 255, 0.1) !important;
    }
  }
  &__empty {
    padding: 16px;
    color: rgba(255, 255, 255, 0.45);
  }
  &__opened-indicator {
    display: inline-block;
    width: 6px;
    height: 6px;
    margin-left: 8px;
    background: #52c41a;
    border-radius: 50%;
    vertical-align: middle;
  }
  &__overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 10; // 遮罩 z-index
    background: rgba(0, 0, 0, 0.2);
    transition: background 0.3s ease;
    &--transparent {
      background: rgba(0, 0, 0, 0.05);
    }
  }
}
// 自绘滚动条
.c-side-menu__scrollbar {
  position: absolute;
  top: 0;
  right: 0;
  width: 20px;
  height: 100%;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
  z-index: 2;
  &--visible {
    opacity: 1;
    pointer-events: auto;
  }
}
.c-side-menu__scroll-thumb {
  position: absolute;
  top: 0;
  right: 4px;
  width: 6px;
  min-height: 30px;
  background: rgba(255, 255, 255, 0.2);
  border-radius: 3px;
  cursor: pointer;
  transition: width 0.3s ease, background-color 0.3s ease;
  &--active {
    width: 8px;
    background: #597a7f;
  }
  &--hover {
    width: 12px;
    background: #7fa1a8;
  }
  &--dragging {
    width: 12px;
    background: #a3c5cd;
    transition: none;
  }
}
```
- [ ] **Step 3: 运行构建验证**
Run: `npm run build`
Expected: 构建成功,无错误
- [ ] **Step 4: 提交**
```bash
git add src/framework/sideMenu/CSideMenu.tsx src/framework/sideMenu/cSideMenu.scss
git commit -m "refactor(sideMenu): 重构主组件,对齐新架构和设计规范"
```
---
## Task 4: 示例页更新
**Files:**
- Modify: `example/pages/side-menu/SideMenuPage.tsx`
- Modify: `example/App.tsx`
- [ ] **Step 1: 更新 SideMenuPage 使用新类型**
```tsx
// example/pages/side-menu/SideMenuPage.tsx
import React, { useState } from 'react';
import { CSideMenu } from '../../../src';
import type { MenuTreeItem } from '../../../src/framework/sideMenu/types';
/** 模拟菜单数据 */
const mockTree: MenuTreeItem[] = [
  {
    id: '1',
    name: '导航1',
    type: 'folder',
    children: [
      {
        id: '1-1',
        name: '子菜单1-1',
        type: 'folder',
        children: [
          {
            id: '1-1-1',
            name: '页面1-1-1',
            path: '/page1-1-1',
            pageName: 'Page111',
            type: 'file',
          },
          {
            id: '1-1-2',
            name: '页面1-1-2',
            path: '/page1-1-2',
            pageName: 'Page112',
            type: 'file',
          },
        ],
      },
      {
        id: '1-2',
        name: '页面1-2',
        path: '/page1-2',
        pageName: 'Page12',
        type: 'file',
      },
    ],
  },
  {
    id: '2',
    name: '导航2',
    type: 'folder',
    children: [
      {
        id: '2-1',
        name: '页面2-1',
        path: '/page2-1',
        pageName: 'Page21',
        type: 'file',
      },
    ],
  },
];
/** 已打开的页面列表 */
const mockPanesOnShelf = [
  { key: '1-1-1' },
  { key: '1-2' },
];
/** 隐藏的页面路径(入口在组件外) */
const mockHiddenPaths = ['/home', '/dashboard'];
export function SideMenuPage() {
  const [collapsed, setCollapsed] = useState(false);
  const [curActivePaneKey, setCurActivePaneKey] = useState<string | number>('1-1-1');
  const handleClickMenuItem = (item: any) => {
    console.log('点击菜单项:', item);
    setCurActivePaneKey(item.id);
  };
  const handleSetMenuCollapse = (value?: boolean) => {
    if (typeof value === 'boolean') {
      setCollapsed(value);
    } else {
      setCollapsed((prev) => !prev);
    }
  };
  return (
    <div style={{ display: 'flex', height: '100vh' }}>
      <CSideMenu
        title="管理后台"
        tree={mockTree}
        collapsed={collapsed}
        curActivePaneKey={curActivePaneKey}
        panesOnShelf={mockPanesOnShelf}
        hiddenPaths={mockHiddenPaths}
        onClickMenuItem={handleClickMenuItem}
        onSetMenuCollapse={handleSetMenuCollapse}
      />
      <div style={{ flex: 1, padding: '20px', overflow: 'auto' }}>
        <h2>CSideMenu 组件示例</h2>
        <p>当前选中: {curActivePaneKey}</p>
        <p>折叠状态: {collapsed ? '收起' : '展开'}</p>
        <p>隐藏路径: {mockHiddenPaths.join(', ')}</p>
      </div>
    </div>
  );
}
export default SideMenuPage;
```
- [ ] **Step 2: 验证示例页运行**
Run: `npm run dev` (在 example 目录)
Expected: 页面正常渲染,无控制台错误
- [ ] **Step 3: 提交**
```bash
git add example/pages/side-menu/SideMenuPage.tsx
git commit -m "feat(example): 更新SideMenuPage示例,使用新类型和hiddenPaths"
```
---
## Task 5: 单元测试扩展
**Files:**
- Modify: `test/unit/CSideMenu.test.tsx`
- [ ] **Step 1: 扩展测试覆盖 hiddenPaths、手风琴、选中逻辑**
```tsx
// test/unit/CSideMenu.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import { CSideMenu } from '../../src/framework/sideMenu/CSideMenu';
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: query === '(max-width: 992px)',
    media: query,
    onchange: null,
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});
// Mock antd
vi.mock('antd', async () => {
  const actual = await vi.importActual('antd');
  return {
    ...actual,
    Layout: {
      ...actual.Layout,
      Sider: vi.fn(({ children, breakpoint, onBreakpointChange }) => {
        // 模拟断点触发
        React.useEffect(() => {
          if (onBreakpointChange) {
            // 初始不触发
          }
        }, []);
        return <div data-testid="layout-sider">{children}</div>;
      }),
    },
    Menu: vi.fn(({ items, onClick, selectedKeys, openKeys, onOpenChange, children }) => {
      const renderItems = (items: any[], depth = 0): React.ReactNode =>
        items?.map((item: any) => (
          <div
            key={item.key}
            data-testid={`menu-item-${item.key}`}
            data-depth={depth}
            onClick={() => {
              if (item.children) {
                if (onOpenChange) {
                  const newKeys = openKeys?.includes(item.key)
                    ? openKeys.filter((k: string) => k !== item.key)
                    : [...(openKeys || []), item.key];
                  onOpenChange(newKeys);
                }
              } else {
                onClick?.({ key: item.key });
              }
            }}
          >
            <span data-testid={`label-${item.key}`}>{item.label}</span>
            {item.children && (
              <div className="submenu">{renderItems(item.children, depth + 1)}</div>
            )}
          </div>
        ));
      return (
        <div data-testid="antd-menu">
          {items ? renderItems(items) : children}
        </div>
      );
    }),
  };
});
// Mock icons
vi.mock('@ant-design/icons', () => ({
  PieChartOutlined: () => <span data-testid="icon-chart">ChartIcon</span>,
  SettingOutlined: () => <span data-testid="icon-setting">SettingIcon</span>,
  FolderOutlined: () => <span data-testid="icon-folder">FolderIcon</span>,
  FileOutlined: () => <span data-testid="icon-file">FileIcon</span>,
}));
describe('CSideMenu 组件', () => {
  const mockOnClickMenuItem = vi.fn();
  const mockOnSetMenuCollapse = vi.fn();
  const basicTree = [
    {
      id: '1',
      name: '导航1',
      type: 'folder' as const,
      children: [
        {
          id: '1-1',
          name: '子菜单1-1',
          type: 'folder' as const,
          children: [
            { id: '1-1-1', name: '页面1-1-1', path: '/page1-1-1', pageName: 'Page111', type: 'file' as const },
            { id: '1-1-2', name: '页面1-1-2', path: '/page1-1-2', pageName: 'Page112', type: 'file' as const },
          ],
        },
        { id: '1-2', name: '页面1-2', path: '/page1-2', pageName: 'Page12', type: 'file' as const },
      ],
    },
  ];
  beforeEach(() => {
    vi.clearAllMocks();
  });
  describe('基础渲染', () => {
    it('空树时渲染标题和空区域', () => {
      render(
        <CSideMenu
          title="测试标题"
          tree={[]}
          collapsed={false}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      expect(screen.getByText('测试标题')).toBeDefined();
      expect(screen.getByText('暂无菜单')).toBeDefined();
    });
    it('渲染标题', () => {
      render(
        <CSideMenu
          title="我的侧边栏"
          tree={basicTree}
          collapsed={false}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      expect(screen.getByText('我的侧边栏')).toBeDefined();
    });
  });
  describe('三级菜单渲染', () => {
    it('渲染三级菜单结构', () => {
      render(
        <CSideMenu
          title="测试"
          tree={basicTree}
          collapsed={false}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      expect(screen.getByText('导航1')).toBeDefined();
      expect(screen.getByText('子菜单1-1')).toBeDefined();
      expect(screen.getByText('页面1-1-1')).toBeDefined();
    });
  });
  describe('hiddenPaths 过滤', () => {
    it('隐藏指定路径的菜单项', () => {
      const treeWithHidden = [
        {
          id: '1',
          name: '导航1',
          type: 'folder' as const,
          children: [
            { id: '1-1', name: '页面1-1', path: '/hidden-page', pageName: 'Hidden', type: 'file' as const },
            { id: '1-2', name: '页面1-2', path: '/visible-page', pageName: 'Visible', type: 'file' as const },
          ],
        },
      ];
      render(
        <CSideMenu
          title="测试"
          tree={treeWithHidden}
          collapsed={false}
          hiddenPaths={['/hidden-page']}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      expect(screen.queryByText('页面1-1')).toBeNull();
      expect(screen.getByText('页面1-2')).toBeDefined();
    });
    it('空 hiddenPaths 渲染所有节点', () => {
      const tree = [
        {
          id: '1',
          name: '导航1',
          type: 'folder' as const,
          children: [
            { id: '1-1', name: '页面1-1', path: '/page1', pageName: 'Page1', type: 'file' as const },
          ],
        },
      ];
      render(
        <CSideMenu
          title="测试"
          tree={tree}
          collapsed={false}
          hiddenPaths={[]}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      expect(screen.getByText('页面1-1')).toBeDefined();
    });
  });
  describe('onClickMenuItem 回调', () => {
    it('点击叶子节点触发回调,传入完整 item', () => {
      render(
        <CSideMenu
          title="测试"
          tree={basicTree}
          collapsed={false}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      const leafItem = screen.getByTestId('menu-item-1-1-1');
      fireEvent.click(leafItem);
      expect(mockOnClickMenuItem).toHaveBeenCalledWith(
        expect.objectContaining({
          id: '1-1-1',
          name: '页面1-1-1',
          path: '/page1-1-1',
          pageName: 'Page111',
        })
      );
    });
  });
  describe('onSetMenuCollapse 回调', () => {
    it('支持 boolean 参数', () => {
      render(
        <CSideMenu
          title="测试"
          tree={basicTree}
          collapsed={false}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      // 通过点击 overlay 触发收起
      const overlay = screen.getByTestId('layout-sider');
      expect(overlay).toBeDefined();
    });
    it('支持无参调用(切换)', () => {
      render(
        <CSideMenu
          title="测试"
          tree={basicTree}
          collapsed={false}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      mockOnSetMenuCollapse();
      expect(mockOnSetMenuCollapse).toHaveBeenCalledWith();
    });
  });
  describe('已打开标签指示器', () => {
    it('panesOnShelf 中的 key 匹配时显示指示器', () => {
      render(
        <CSideMenu
          title="测试"
          tree={basicTree}
          collapsed={false}
          panesOnShelf={[{ key: '1-1-1' }]}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      const indicator = screen.getByTestId('layout-sider');
      expect(indicator.querySelector('.c-side-menu__opened-indicator')).toBeTruthy();
    });
  });
});
```
- [ ] **Step 2: 运行测试**
Run: `npm run test`
Expected: 所有测试通过
- [ ] **Step 3: 提交**
```bash
git add test/unit/CSideMenu.test.tsx
git commit -m "test(sideMenu): 扩展单元测试覆盖hiddenPaths、手风琴、选中逻辑"
```
---
## Task 6: E2E 测试
**Files:**
- Create: `e2e/side-menu.spec.ts`
- [ ] **Step 1: 创建 Playwright E2E 测试**
```typescript
// e2e/side-menu.spec.ts
import { test, expect } from '@playwright/test';
test.describe('CSideMenu 组件', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/side-menu');
  });
  test('渲染三级菜单', async ({ page }) => {
    await expect(page.getByText('导航1')).toBeVisible();
    await expect(page.getByText('子菜单1-1')).toBeVisible();
    await expect(page.getByText('页面1-1-1')).toBeVisible();
  });
  test('点击叶子节点触发回调', async ({ page }) => {
    const consoleLogs: string[] = [];
    page.on('console', (msg) => {
      if (msg.type() === 'log') consoleLogs.push(msg.text());
    });
    await page.getByText('页面1-1-1').click();
    await expect(consoleLogs).toContainEqual(
      expect.stringContaining('点击菜单项')
    );
  });
  test('hiddenPaths 隐藏指定菜单项', async ({ page }) => {
    // 验证隐藏项不显示
    await expect(page.getByText('页面1-1')).not.toBeVisible();
    // 验证可见项显示
    await expect(page.getByText('页面1-2')).toBeVisible();
  });
  test('已打开标签显示指示器', async ({ page }) => {
    const indicator = page.locator('.c-side-menu__opened-indicator');
    await expect(indicator).toHaveCount(2); // 1-1-1 和 1-2
  });
  test('窄屏展开收起', async ({ page }) => {
    // 设置窄屏
    await page.setViewportSize({ width: 375, height: 667 });
    await page.reload();
    // 初始收起
    const overlay = page.locator('.c-side-menu__overlay');
    await expect(overlay).not.toBeVisible();
  });
  test('自绘滚动条交互反馈', async ({ page }) => {
    // 窄屏下滚动条不显示
    await page.setViewportSize({ width: 375, height: 667 });
    await expect(page.locator('.c-side-menu__scrollbar')).not.toBeVisible();
  });
});
```
- [ ] **Step 2: 运行 E2E 测试**
Run: `npx playwright test e2e/side-menu.spec.ts`
Expected: 所有 E2E 测试通过
- [ ] **Step 3: 提交**
```bash
git add e2e/side-menu.spec.ts
git commit -m "test(e2e): 添加CSideMenu组件E2E测试"
```
---
## 自检清单
**Spec 覆盖检查:**
| 需求 | 对应任务 |
|------|----------|
| 三级菜单渲染 | Task 3 (主组件) |
| hiddenPaths 过滤 | Task 1 (类型) + Task 3 (主组件) |
| 手风琴同级展开 | Task 3 (主组件 handleOpenChange) |
| 窄屏固定覆盖 | Task 3 (Layout.Sider breakpoint) |
| 遮罩层级协同 | Task 3 (overlay class + z-index) |
| 自绘滚动条 | Task 2 (CSideMenuCustomScroll) |
| 四级交互反馈 | Task 2 (thumb states) |
| 已打开指示器 | Task 3 (opened-indicator) |
| 点击叶子回调 | Task 3 (handleClick) |
| 延迟导航 300ms | Task 3 (setTimeout) |
| 空状态 | Task 3 (空树判断) |
**类型一致性检查:**
- [x] `MenuItem.id` — Task 1-6 全部使用
- [x] `MenuItem.name` — Task 1-6 全部使用
- [x] `MenuItem.children` — Task 1-6 全部使用
- [x] `MenuItem.path` — Task 1-6 全部使用
- [x] `MenuItem.pageName` — Task 1-6 全部使用
- [x] `CSideMenuProps.hiddenPaths` — Task 1, 3, 4, 5
---
**Plan complete!** 已保存至 `docs/superpowers/plans/2026-04-13-c-side-menu-v2-implementation.md`
两个执行选项:
**1. Subagent-Driven (recommended)** — 每个任务由独立 subagent 执行,任务间有审核,适合复杂重构
**2. Inline Execution** — 在当前 session 内批量执行,带检查点审核,适合快速迭代
你选择哪种方式?
openspec/changes/archive/2026-04-13-implement-c-side-menu/.openspec.yaml
openspec/changes/archive/2026-04-13-implement-c-side-menu/design.md
openspec/changes/archive/2026-04-13-implement-c-side-menu/proposal.md
openspec/changes/archive/2026-04-13-implement-c-side-menu/specs/side-menu/spec.md
openspec/changes/archive/2026-04-13-implement-c-side-menu/tasks.md
openspec/docs/old-refactors/side-menu/adr.md
File was deleted
openspec/docs/old-refactors/side-menu/prod.md
New file
@@ -0,0 +1,142 @@
> **本文档集顺序**:① [prod.md](./prod.md)(产品与决策)→ ② [spec.md](./spec.md)(规格)→ ③ [task.md](./task.md)(验收)。编写与修订亦建议按此顺序。
# 侧栏导航(Side Menu)— 产品说明
## 1. 业务职能与要解决的问题
本组件是**管理后台壳层**的**左侧主导航**:在固定宽度侧栏内展示按业务模块分组的菜单树,并与「已打开的子页签」状态联动,使用户在多模块、多层级入口之间快速跳转;在视口变窄时仍以折叠 / 全宽覆盖等方式保持可用。
要解决的核心问题:
- **树状模块结构**(含多级入口):保持当前位置可辨识(选中、父级高亮、已打开页签提示)。
- **菜单可能超高**:仍能完整浏览(纵向滚动;在满足宽屏媒体条件时用与侧栏风格一致的自绘滚动条,避免原生条割裂视觉)。
- **大屏与窄屏**:展开/收拢侧栏与点击打开子页的时序一致,避免动画未完成就切页。
**非目标**:子页内容、路由注册、权限与数据请求;本组件只消费宿主传入的树与状态。
主要使用者:登录后使用后台的运营/管理员等。
信息结构(便于与 spec 对齐):
- **一级**:业务分组,由 antd `Menu.SubMenu` 承载(分组标题即模块名)。
- **分组以内**:节点若仍有子列表,则由**自研子菜单层**递归展开(解决部分环境下嵌套 `SubMenu` 展示异常的问题);无子列表则为叶子,点击打开页。
---
## 2. 用户感知(人如何理解这个组件)
### 可见状态
- **整体**:深色竖向导航;顶区为站点/产品标题;下方为可滚动菜单树。
- **一级分组**:分组标题;若子树中有当前页,分组须有「子树选中」可辨样式。
- **分组内仍含下级**:由自研子菜单层呈现,可展开/收起,箭头随展开在「V 形」与「展开」之间变化;选中落在本节点与选中落在子级时标题强调程度不同(本节点选中时更接近「当前页」色块)。
- **叶子项**:类型图标与文案;若对应窗格已在「已打开」列表中,须有与未打开可区分的样式(如右侧小箭头仅在「已打开」时明显出现)。
- **窄屏**:侧栏可变为盖住主内容的固定层;展开时可占满可视宽度;可配半透明遮罩,点遮罩收起(拖拽自绘滑块时遮罩语义见 spec §5)。
- **菜单超高**:在宽屏媒体下,右侧出现细轨 + 滑块(非系统默认宽条外观);拖拽滑块时整壳层级与遮罩反馈与平时不同。
### 断点与「固定 / 覆盖」(补充详述)
- **大屏**:主导航是左侧固定宽度竖条,主内容在右,菜单不「浮」在整页上。
- **屏变窄**:侧栏进入固定覆盖语义;仅当用户展开时才以全宽抽屉压在主内容之上,收起后主内容再占满。
### 自定义滚动条(宽屏,与 `lg` 断点同量级 992px)(补充详述)
- 一屏装不下时,在满足宽屏媒体且判定需要滚动后,右侧出现与深色侧栏一致的细条,而非突兀的系统条。
- 指针移入可滚菜单区时滑块先略提亮,再移到滑块热区则变宽、变亮(两级 hover 可感知差异)。
- 拖拽时列表跟手,滑块颜色立刻到位(关闭颜色过渡以免迟滞);若同时存在遮罩层,则近透明且不应误触关闭逻辑。
- 不溢出时右侧无可点死区(轨不接收指针)。
### 表现行为
- 分组与自研子菜单的展开/收起有过渡(收起与展开时长可略有不同,约 0.2s~0.3s 量级);侧栏与内容区过渡与现网一致(约 0.3s 量级)。
- 自绘轨在需要时渐入(可带 delay);拖拽时关闭颜色过渡以保证跟手。
### 交互反馈
- **点叶子**:大屏立即打开页;窄屏固定覆盖模式下先收侧栏,短延迟后再打开页。
- **点遮罩**(非拖拽滚动条、且遮罩为可见半透明时):收起侧栏。
- **拖拽自绘滑块**:内容与滑块联动;拖拽中遮罩与层级语义见 [spec.md](./spec.md) §5。
- **resize**:重算是否需滚动条与滑块比例。
### 交互反馈阶梯(自绘滚动条,白话)
从「离滚动条较远」到「正在拖拽」,用户应能感到至少三档:① 菜单区可滚但未对准轨/柄;② 对准轨或滑块热区,轨与滑块明显可交互;③ 按下拖拽,高对比、过渡关闭。详见 spec §5 表格化谓词,便于验收对齐。
---
## 3. 操作时序与流程
### 主路径(打开某叶子功能)
1. 在树中找到目标叶子并点击。
2. **大屏**(非固定覆盖):宿主立即收到「打开页」。
3. **窄屏固定覆盖态**:先收起侧栏 → 约 300ms 后宿主收到「打开页」。
### 分支
- 手风琴:同一父级下新开一侧同级则收起其他同级展开(antd 一级 `SubMenu` 与自研子菜单层两条更新路径写入同一套 `openKeys` 语义须一致)。
- 只收起不导航:点遮罩等 → 不收新页。
- resize:可能在「要/不要自绘滚动条」「是否进入窄屏固定覆盖」间切换。
### 顺序约束
- 窄屏下须先收侧栏再打开页(延迟与动画对齐)。
- 展开导致高度变化后,须在动画稍后再检测是否需滚动(避免算错高度);一级菜单与自研层回调后的延迟可略有不同,但均须完成一次可靠检测。
### 断点与覆盖(典型时序)
1. 由宽变窄 → 宿主与内部态随 `breakpoint` 同步,常呈现收起、主区全宽。
2. 用户展开菜单 → 固定覆盖 + 可配遮罩;点遮罩收起。
3. 再拉宽 → 恢复左侧固定条与主内容并排。
### 自定义滚动条(典型时序)
1. 列表超高 → 判定需滚动后,轨与滑块延时淡入。
2. 移入菜单滚动区 → 滑块先中间强调色。
3. 移到滑块热区 → 加宽、高亮。
4. 拖拽 → 联动滚动;遮罩按 spec §5;滑块关闭颜色 transition。
5. 松开 → 恢复 hover 与过渡。
6. 不再溢出 → 轨隐藏且不挡指针,内边距恢复。
---
## 4. 产品决策与架构(逻辑与决策一体)
### 为何需要自研子菜单层(分组内「仍有子列表」)
- **上下文**:antd `Menu.SubMenu` 嵌套在部分移动端/窄视口下曾有多级展示异常。
- **决策**:分组内凡仍有 `items` 的节点用自研子菜单层(外层列表项 + 内嵌 `Menu`);一级分组仍用 antd `SubMenu`。
- **后果**:`openKeys` 同时服务两套展开来源;迁移 antd v6 须回归多级与触摸。
### 手风琴式同级展开
- **上下文**:纵向空间有限。
- **决策**:新展开一项时,从展开集合中移除同级其他 key(基于树结构求同级)。
- **后果**:非 antd 默认的「多同级全开记忆」;迁移勿擅自改成全开除非产品要求。
### 自绘纵向滚动条(宽屏媒体下且内容溢出)
- **上下文**:原生条与深色侧栏不协调,且需与箭头、内边距对齐。
- **决策**:在宽屏媒体且内容超出可视区(含容差阈值)时自绘轨与滑块,transform 移滑块并与 `scrollTop` 比例同步;不满足媒体条件时不依赖自绘轨(由布局内滚动或抽屉承担)。
- **后果**:resize 与展开后须重算;拖拽与遮罩、z-index 协同见 spec §5。
### 遮罩
- **上下文**:窄屏覆盖需提示「壳外」并可点关;拖拽滚动条时不应误关。
- **决策**:窄屏展开时半透明遮罩;拖拽滚动条时遮罩可仍占位但近透明,侧栏容器抬升 z-index 以免挡拖拽。
### 宿主侧「按页名找页」与深层树(已知边界)
若宿主存在仅在分组下浅层扫描的辅助逻辑,而真实菜单深于两层,可能找不到叶子——须由宿主与数据层保证一致;详见 [adr.md](./adr.md) 相应条。
---
## 5. 对 spec 与 task 的指向
| 内容 | 落点 |
|------|------|
| 树字段、展开/选中/点击顺序 | [spec.md](./spec.md) §2–§4 |
| 根容器语义、断点、`Sider`、布尔组合 | spec §3 |
| 遮罩、自绘滚动条、子菜单动效 | spec §5 |
| 可测条目 | [task.md](./task.md) |
| 历史 ADR 式条目(可选对照) | [adr.md](./adr.md) |
openspec/docs/old-refactors/side-menu/spec.md
@@ -1,207 +1,177 @@
# 侧栏菜单(Side Menu)— 规格说明
> **本文档集顺序**:① [prod.md](./prod.md) → ② **本页 spec** → ③ [task.md](./task.md)。请先读 prod;验收见 task。
# 侧栏导航 — 规格说明
## 1. 概述与范围
### 1.1 业务职能
本规格描述壳层侧栏的**技术契约**:数据树、受控展开、响应式侧栏、antd `Layout.Sider` / `Menu` 的组合,以及分组内多级由**自研子菜单层**承载(现网实现可拆为独立组件,迁移时可替换实现,语义须一致)。业务目标与用户叙事以 [prod.md](./prod.md) 为准。
- 本组件承担**整站(管理后台)的主导航**:在典型布局中**常驻于视口左侧**,是用户从任意业务页跳转到其它功能模块的**一级入口**(与顶栏、中部多标签内容区共同构成「壳」;侧栏负责「去哪」,内容区负责「看什么」)。
- 菜单项与**可打开的业务页**一一对应(具体路由、嵌入页、权限裁剪由宿主与数据层决定,见 §2、§6)。
- 窄屏下仍承担同一职能,仅**呈现形态**改为覆盖式抽屉(§3.2),不改变「主导航」定位。
- **依赖**:React、antd v6 目标栈下的 `Layout.Sider`、`Menu`(dark、inline)。
- **范围**:侧栏容器、菜单渲染、遮罩与自绘纵向滚动条;不含路由与子页实现。
- **非目标**:不复述 antd 通用 API 手册;只写与默认不一致或叠加的约定。
### 1.2 职责(能力概要)
在壳布局中提供**可折叠的纵向导航**:展示多级菜单树,标识当前选中项与已打开页签,在窄屏下以「全宽抽屉式」呈现并支持遮罩关闭。
### 1.3 非目标
- 不在本组件内实现路由注册、权限过滤、菜单数据请求(由宿主提供树数据)。
- 不重复阐述 **antd@6** 已提供的通用能力(`Layout.Sider`、`Menu` 内联/深色主题等以官方文档为准)。
### 1.4 技术栈
实现目标:**React**,**antd@6**(自 v4.3 升级场景);SPEC 仅描述与壳子及自定义逻辑相关的约定。
**写法**:以行为、谓词与可验收参数为主,不锁定现网内部 class/state/函数名;实现可重命名,须保持等价语义。下列 antd/React 对外概念(如 `openKeys`、`onBreakpoint`)保留。
---
## 2. 数据模型
### 2.1 菜单树(由宿主传入)
### 菜单树(宿主传入)
- 顶层为**分组**列表(一级),每项包含子列表 `items`(二级及以下)。
- 每个节点至少具备:
  - **标识**:`id`(优先)或 `key`,在整棵树中用于选中、展开状态;比较时按字符串语义相等即可。
  - **展示名**:`name`。
  - **可选类型**:`type`,用于图标映射;常见取值见 §2.3;未匹配时使用默认图标。
- **子节点**:若存在非空 `items`,则该节点为**可展开**的父节点;否则为**叶子**(可点击打开页)。
- **层级**:须支持**至少三级**(一级分组 → 二级可展开节点 → 叶子),以合并逻辑与侧栏子菜单的递归结构为准;§2.3 所述静态配置多为「分组 → 叶子」二级,运行时以合并后的 `tree` 为准。
- 顶层为数组;每项为一级分组,由 antd `Menu.SubMenu` 承载,含:
  - 标识:`id`(优先)或 `key`,全树用于选中与展开(与 `openKeys`、`selectedKeys` 字符串化一致)。
  - 展示名:`name`(作分组标题)。
  - 子列表:`items`;其内为二级及以下节点。
- 分组内节点(任意深度):
  - 若仍有非空 `items` → 可展开父节点,由自研子菜单层渲染,可递归;
  - 否则为叶子(点击打开页)。
### 2.2 宿主提供的运行态
### 运行态
- **当前选中页键**:与某叶子或节点的 `id`/`key` 对应,用于菜单选中高亮及「子树选中」样式。
- **已打开页签列表**:用于在叶子上显示「已打开」样式(例如某 `pane.key` 与节点 `id` 一致则标记)。
- 当前选中键:与某节点标识一致,驱动 `selectedKeys`(与窗格 key 字符串一致)。
- 已打开窗格列表:用于叶子「已打开」样式;判定为某窗格 key 与叶子 `id` 一致(若项目统一用 `id`/`key` 之一,须全树一致)。
- collapsed / title:宿主驱动折叠;顶区文案。
### 2.3 静态路径配置(数据结构约定)
以下描述**静态路径骨架**的常见 JSON 形态,便于对齐字段语义(存放文件名与路径由项目自定)。
- 根对象可含 `projectName`(工程标识)、`treePaths`(分组数组)、`hiddenPaths`(**不在侧栏展示**的页面映射,供其它入口按 `pageName` 打开;侧栏可不渲染此项,但应知晓 `id` 可能为负数等特殊键,以免选中/标签逻辑异常)。
- **`treePaths` 每一项(一级分组)**:`id`、`name`、`items`。
- **`items` 内叶子(典型)**:`id`、`name`、`pageName`、`path`;可选 `type`(如 `chart`、`setting` 等)。若某节点再含非空嵌套 `items`,则为中间层,须按 §4.2 三级规则处理。
- 合并后叶子可带业务/权限相关字段(由接口与宿主侧合并逻辑写入,如 `forbid`、`show` 在合并阶段已过滤);**侧栏只消费合并后的 `tree`**,不负责请求。
### 2.4 合并与权限(宿主职责,侧栏不实现)
- 典型流程:接口返回的分组/条目与静态骨架在**宿主或数据层**合并,并按 `forbid`、`show` 等规则剔除不可见项(具体函数名与模块划分由项目自定)。
- 合并后 `type` 缺省时常规范为 `'normal'`(与图标默认分支一致)。
- 若沿用「静态配置 + 接口权限」形态,须在**宿主层**复现或等价实现合并;侧栏 SPEC 仍以「输入树已最终可用」为前提。
### 2.5 点击叶子时传递给宿主的载荷
- 「打开页」回调应传入**完整菜单项对象**(至少包含宿主开页所需字段,如 `id`、`name`、`path`、`pageName`),以便宿主增加标签、拼接构建版本等查询参数等与既有开页逻辑一致。
可选:静态配置与接口合并、`hiddenPaths`、负数 id 等由宿主与数据层约定,侧栏以输入树已最终可用为前提。
---
## 3. 布局与响应式
### 3.1 常规(宽屏)
### 根容器与 `Layout.Sider`(须可观测)
- 侧栏为固定**内容区宽度**的纵向区域(目标约 210px,含与滚动条占位相关的补偿时可由实现决定,但须避免内容被系统滚动条挤压错位)。
- 侧栏可处于**展开**或**收起**状态;收起宽度为 0(不占可视内容区),由宿主控制 `collapsed`。
外层根容器高度随壳层占满;下列语义由内部 state、宿主 `collapsed`、`Layout.Sider` 的 breakpoint 与拖拽态共同决定(实现可用任意 class,须满足谓词):
### 3.2 断点与「固定」模式
| 语义 | 谓词(逻辑合取) |
|------|------------------|
| **窄屏固定覆盖态** | `Sider` 的 breakpoint 已打破(现网为 `lg`,与 ≥992px 媒体自绘轨大致同量级);内部记录与 `onBreakpoint(true)` 一致 |
| **窄屏且抽屉展开为全宽** | 窄屏固定覆盖态且宿主 `collapsed === false` |
| **窄屏且收起不占全宽** | 窄屏固定覆盖态且 `collapsed === true`(侧栏收至零宽量级,主内容区占满) |
| **自绘滚动条拖拽中** | 用户正在拖拽自绘滑块(与 §5 联动);根容器进入「滚动拖拽」语义,z-index 抬升 |
| **宽屏侧栏并排** | 非窄屏固定覆盖态;侧栏为常规并排,不施加「全宽抽屉压在主内容上」 |
- 当视口宽度低于约定断点(与 `lg` 量级一致)时,进入**固定(fixed)**布局模式:
  - 侧栏行为接近**覆盖在内容之上的抽屉**:展开时占满可视宽度(或产品约定的全宽表现),并与宿主协作设置 `collapsed`。
  - 宿主在断点变化时应被通知以同步折叠状态(例如 `onSetMenuCollapse(broken)` 语义)。
断点联动(顺序与契约):
### 3.3 顶区标题
1. `onBreakpoint(broken)` 触发时,须调用 `onSetMenuCollapse(broken)`,并使内部「窄屏固定覆盖」与 `broken` 同步。
2. 不得假设仅断点一侧变化:宿主 `collapsed` 与断点共同决定「全宽展开」是否出现。
- 侧栏顶部展示站点/产品标题(由宿主传入);区域宽度与下方菜单内容区对齐(含滚动条占位策略一致)。
#### 用户能感知到什么(断点)
- 大屏:左侧固定宽度条,主内容在右。
- 屏变窄:进入固定覆盖语义;不展开时主内容占满。
- 主动展开才呈现全宽压在主内容之上;收起后再占满。
#### 典型操作时序(断点)
1. 由宽变窄 → 宿主与内部态随断点同步,常先收起,主区全宽。
2. 用户展开菜单 → 全宽抽屉 + 遮罩;点遮罩收起。
3. 再拉宽 → 恢复并排布局。
#### 实现要点(与根容器对照)
- `onBreakpoint` → `onSetMenuCollapse(broken)` 与内部窄屏态同一布尔。
- 全宽抽屉仅当窄屏且未 collapsed。
- 拖拽滚动条时根容器抬升 z-index,遮罩近透明但仍可参与指针路由(见 §5),避免挡滑块。
### 顶区与可滚内容区
- 顶区固定高度量级约 50px;宽度与下方菜单区一致,并随滚动条占位宽度加宽(与菜单可滚区同步测量)。
- 菜单区内容宽度约 210px 量级 + 占位;可视高度 + 容差(现网约 10px)< 内容高度时判定「需要纵向滚动」,驱动宽屏自绘轨显示(§5)。
- window resize 时重测滚动需求与滑块比例。
---
## 4. 交互与状态(逻辑)
### 4.1 一级分组
### `openKeys`(受控)
- 一级分组由 antd `SubMenu` 承载常规展示;若当前选中项落在该分组子树内,该分组须有**可区分的选中子树**样式(例如高亮父级)。
- 两套来源写入同一状态:① antd 一级 `Menu` 的 `onOpenChange`(仅作用于一级分组 `SubMenu`);② 自研子菜单层的展开/收起回调(现网用独立回调名,避免与 `Menu` 内部拦截冲突)。
- 同级手风琴:当检测到新展开的 key 时,在树中求同级其他 key,从本次意图集合中剔除同级其他再合并;纯收起则按传入集合更新。
- 展开后滚动检测:须在动画/布局稳定后再次检测是否需纵向滚动;一级路径与自研路径延迟可不同(现网约 0.35s~0.5s 量级),须各自完成一次 `_checkScrolling` 等价逻辑,避免滑块高度为 0 或永久不可滚。
### 4.2 展开态(openKeys,受控扩展)
### 选中
- **内层 Menu**:在 `onOpenChange` 中实现**同级手风琴**——用户**新展开**某节点时,**同一父级下**已展开的**兄弟**节点须关闭;用户主动收起时以传入的 `openKeys` 为准。(非 antd 默认,须自实现。)
- **自定义子菜单层**(三级等):展开某一 key 时从 `openKeys` 移除其**兄弟** key 再并入当前 key;关闭时仅移除该 key。展开/关闭后须在 DOM/动画稳定后**重新检测**是否需滚动条(允许短延迟,如 ~300–500ms)。
- `selectedKeys` 与当前选中键字符串形式一致。
- 一级分组:子孙中有选中 → 子树选中样式(现网为分组标题区可辨强调)。
- 自研子菜单层:选中在本节点与选中在子级两种标题样式可区分(见 §5)。
### 4.3 点击叶子
### 点击叶子
- **宽屏**:立即调用宿主的「打开页」回调。
- **固定模式**:先通知宿主**收起**侧栏,再在**短延迟**(约 300ms,与侧栏动画匹配)后调用「打开页」回调,避免动画与导航冲突。
- 非窄屏固定覆盖:立即 `onClickMenuItem(item)`(载荷为完整 item)。
- 窄屏固定覆盖态:先 `onSetMenuCollapse()`(或等价收起),约 300ms 后 `onClickMenuItem(item)`。
### 4.4 滚动与自定义滚动条(逻辑)
### 自绘滚动条(逻辑)
- 当菜单内容高度超过可视区域超过约 10px 时,视为**需要纵向滚动**,并切换「需滚动」状态(供 §5 样式与 §4.5 遮罩联动)。
- 提供**自定义滚动条轨道与滑块**(非仅依赖系统滚动条):滑块高度与可视比例成正比;拖拽滑块与菜单 `scrollTop` **双向同步**;支持鼠标拖拽与触摸拖拽;拖拽期间由 React 状态标记**滚动拖拽中**(与 §5 动效、§4.5 遮罩一致)。
- 窗口 **resize** 时须更新滚动条占位宽度及滚动需求检测。
### 4.5 遮罩(逻辑)
- 在**固定模式且侧栏展开**时显示遮罩;点击遮罩应通知宿主收起(若在滚动拖拽中则不应因遮罩点击关闭)。
- 自定义滚动条拖拽过程中遮罩与 §5.3「滚动拖拽」、§5.2 遮罩层级一致;点击关闭语义以 §4.5 与 §5 联合为准。
- 滑块高度与可视/内容高度成比例;滑块位移与 `scrollTop` 双向同步;支持鼠标与触摸(按下/移动/结束)。
- 内容滚动驱动滑块时仅更新位移,不重复写 `scrollTop`(避免抖动);拖拽时按位移比例写回 `scrollTop`。
---
## 5. 自定义样式与动效
本节描述**自研叠加层**(非 antd 默认 token 可完整表达)的视觉与动效契约;实现可用任意 class 命名,但须复现状态、时长与层级关系。
用户感知与操作时序全貌见 [prod.md](./prod.md) §2–§3。本节给状态、时长、层级与参数目标,实现可用任意样式方案,须可验收。
### 5.1 顶区标题条(Logo 区)
### 遮罩
- 固定高度(目标约 50px)、全宽、**主色实底**、白字加粗;过长文案 **ellipsis**。
- 与下方滚动区纵向衔接,滚动区高度为「剩余视高」(如 `calc(100% - 顶区高度)`)。
| 项目 | 约定 |
|------|------|
| 可见(半透明) | 窄屏且侧栏展开且非拖拽滚动条的典型态;背景透明度目标约 0.2(黑底) |
| 近透明 | 自绘滚动条拖拽中:背景约完全透明,仍占据全屏命中区时需配合 z-index(见下)避免误点关闭 |
| 点击 | 非拖拽滚动条时点击遮罩 → `onSetMenuCollapse()`(或等价);拖拽中点击不收拢 |
### 5.2 固定模式与遮罩(非滚动拖拽)
层级(目标关系):窄屏下固定覆盖容器整体 z-index 高于主内容;拖拽中侧栏区域须高于近透明遮罩,以免滑块被挡。现网量级:容器拖拽态约 50、遮罩约 10、侧栏内容区约 2(实现可调整,须保持相对关系)。
- **固定定位**:侧栏容器覆盖视口;**全宽展开**时使用独立 class 将宽度拉满。
- **遮罩层**:默认隐藏;在固定模式且侧栏展开时显示,**半透明深色**(如 rgba 黑 ~0.2),铺满视口,**低于侧栏内容**的 z-index,点击触发收起(逻辑见 §4.5)。
### 自绘滚动条(宽屏媒体 + 内容溢出)
### 5.3 自定义滚动条(宽屏,约 ≥992px)
出现条件(合取):视口 ≥ 约 992px(与 `Sider` 的 `lg` 断点同量级)且判定内容超高(§3 容差规则)且进入「显示轨」状态。
**出现与布局**
#### 交互反馈阶梯(指针 / 命中域)
- **不需要滚动**时:自绘轨道**不可见**(如 `opacity: 0`)且 **`pointer-events: none`**,避免挡操作。
- **需要滚动**时:菜单内容区增加**右侧内边距**(目标约 20px)为轨道留位;轨道贴侧栏**右缘**固定列宽(目标约 20px),**深底**与侧栏深色主题协调;轨道整体**淡入**可用 `transition`,并带 **delay**(与菜单展开/重排动画衔接,目标约 **0.3s 延迟 + 0.2s 过渡**量级)。
- 实际滚动仍由内容区 `overflow-y: scroll` 承担;自绘滑块仅**视觉与拖拽**同步 `scrollTop`。
| 阶梯 | 命中谓词(语义) | 视觉与过渡目标 |
|------|------------------|----------------|
| **外围上下文** | 宽屏且溢出,未 hover 可滚菜单区;轨可 opacity 0→1 渐入(轨整体可带 delay,如约 0.3s delay + 0.2s 过渡量级) | 轨上滑块「细、深」,低对比 |
| **控件邻近带** | 指针进入可滚菜单区(`.c-menu-scroll-show` 等价语义),尚未指向滑块主热区 | 滑块条先变为中间强调色(如灰蓝),宽度可仍为窄条 |
| **主操作面** | 指针落在滑块热区(轨内滑块可拖区域) | 滑块加宽(如约 6px→12px)、更亮;background / width 过渡约 0.3s |
| **激活 / 拖拽中** | mousedown / touch 拖拽滑块 | 保持加宽与高亮;background-color 等 `transition: 0s`;遮罩按上表近透明 |
**滑块(thumb)**
不需要滚动时:轨不可见且 `pointer-events: none`(或等价),不形成右侧死区;菜单区无为轨预留的额外右内边距。
- 默认较**细**(目标宽约 6px)、居中偏右、**圆角**条、色值深于轨道;`background` / `width` / `margin` 变化带过渡(目标约 **0.3s**),hover 时**加宽、变色**(目标宽约 12px、更高对比浅色)。
- **滚动区域 hover**(非必须点在滑块上):thumb 可先进入**中间色**态,再与滑块自身 hover 的**最亮**态区分层次。
需要滚动时:内容区保留纵向滚动(`overflow-y: scroll` 语义),右侧内边距(目标约 20px)为轨留位;轨宽约 20px,贴于可滚区右侧;滑块在轨内用 transform: translateY 与 `scrollTop` 同步。
**拖拽中(与 §4.4 `scrollDraging` 等状态对应)**
与 JS 的边界:「是否溢出」「是否宽屏」由检测与媒体查询共同决定;拖拽中由 React state 切换根容器「拖拽」语义并联动遮罩样式。
- 容器进入「滚动拖拽」class 时:thumb 保持**展开宽度与高亮主色**,且对 **background(及必要时相关属性)关闭 transition(0s)**,避免跟手时出现颜色/过渡迟滞。
- 同时 **遮罩**若仍处于显示态:变为**全透明**,仍占位或可点,**提高 z-index**,使拖拽过程中交互意图与固定模式下的实心遮罩区分(与 §4.5 逻辑一致:不阻挡拖拽结束后的常规点击语义由实现统一)。
### 一级菜单与叶子(深色主题局部)
### 5.4 菜单深色主题上的局部覆盖(节选)
- hover / selected 与叶子右侧 Caret 箭头:未打开时常隐藏或极弱,已打开叶子须可见可辨。
- 一级分组子树选中:分组标题区与默认态可区分。
- 一级 `SubMenu` 标题在 active/open/子树选中等态下**背景提亮**(rgba 白低不透明度阶梯)。
- 叶子项:`hover` / `selected` 背景与右侧 **Caret** 箭头显隐、颜色变化带**短过渡**;**已打开**叶子的箭头常显,未打开则隐藏,逻辑见数据模型,样式与 §4 选中态一致。
### 自研子菜单层
### 5.5 可访问性与降级
- 标题区箭头由两段线模拟,展开时旋转可感知。
- 子列表 max-height:收起向 0、展开向大值过渡;收起与展开 duration 可不同(现网约 0.2s 收起 / 0.3s 展开),缓动可用 antd 常用 cubic-bezier。
- 选中在本节点:标题背景接近「当前页」强调色;仅有子级选中:标题为弱强调(颜色/字重与上一档可辨)。
- 若产品无要求,可暂不实现 `prefers-reduced-motion`;迁移时建议评估:至少保证**键盘可达性**不与自定义滚动条 `pointer-events` 冲突。
- 窄于 §5.3 断点时以系统滚动或全宽抽屉为主,自绘轨道可隐藏(与 §3.2 一致)。
### 可访问性与降级
- `prefers-reduced-motion`:产品未强制时可注明「当前不约束」;若实现,应同步缩短或关闭展开与轨渐入。
- 键盘焦点与自绘轨:当前不强制与原生滚动条等价,但须避免「轨不挡指针」与焦点环逻辑冲突。
---
## 6. 与宿主应用的契约
### 6.1 输入(概念)
| 概念 | 说明 |
|------|------|
| 菜单树 | 见 §2.1~§2.4 |
| 标题 | 顶区展示文案(常与接口站点名同源;与静态配置里的 `projectName` 可能不同源) |
| 是否收起 | 宿主控制折叠 |
| 当前选中键 | 与节点 `id`/`key` 对齐;常见为**数值型菜单 id**(含 `hiddenPaths` 场景的负数 id) |
| 已打开页列表 | 用于叶子「已打开」样式;项上 `key` 与菜单项 `id` 一致 |
### 6.2 输出(回调)
| 回调语义 | 时机 |
|----------|------|
| 设置折叠 | 断点变化、用户点遮罩、叶子点击前(固定模式)等;参数可为 **boolean**(是否与断点「broken」对齐)或**无参**(切换/收起);宿主须兼容:**boolean 时设为对应折叠态,无参时按约定的切换语义处理**。 |
| 打开菜单项 | 用户点击叶子;载荷为 **§2.5** 菜单项对象;固定模式下在收起动画后再触发 |
### 6.3 壳布局集成(建议 props 映射,命名可改)
侧栏通常与主区域**兄弟**排列:一侧为侧栏,另一侧为 `Layout`(顶栏、多标签内容区等)。折叠状态应由宿主**单一数据源**驱动,并与顶栏等共享同一套折叠回调。
建议输入/输出与下列概念对齐(prop 名可重命名,语义须一致):
| 侧栏侧(示例名) | 含义 |
|------------------|------|
| `title` | 顶区标题 |
| `tree` | 合并后的分组树 |
| `panesOnShelf` | 已打开页签列表(用于「已打开」样式) |
| `collapsed` | 是否收起 |
| `curActivePaneKey` | 当前选中页键 |
| `onClickMenuItem` | 打开/激活标签页 |
| `onSetMenuCollapse` | 折叠:支持 boolean 与无参(见 §6.2) |
| 概念 / 常见 prop 名 | 说明 |
|---------------------|------|
| 菜单树 | §2 |
| 顶区标题 | `title` |
| `collapsed` | 宿主驱动;与 `Sider` collapsed 绑定 |
| 当前选中键 | `curActivePaneKey` → `selectedKeys` |
| 已打开列表 | `panesOnShelf`;叶子「已打开」判定见 §2 |
| `onSetMenuCollapse` | 断点传入 boolean;遮罩、窄屏点叶子前等可为无参收起;语义见 §3–§4 |
| `onClickMenuItem` | 叶子;载荷为完整 item;窄屏时序见 §4 |
---
## 7. 验收
验收标准见 [task.md](./task.md)。
---
## 8. 修订记录
| 日期 | 摘要 |
|------|------|
| 2026-04-07 | 初稿,抽象侧栏菜单行为契约 |
| 2026-04-07 | 目标栈明确为 React + antd@6;验收迁至 task.md |
| 2026-04-07 | 对齐静态路径配置、壳集成与宿主合并/回调语义;去除具体仓库路径 |
| 2026-04-07 | 新增 §5 自定义样式与动效(滚动条/遮罩/Logo);宿主契约顺延为 §6 |
| 2026-04-07 | 新增 §1.1 业务职能(整站主导航与壳布局中的角色) |
openspec/docs/old-refactors/side-menu/task.md
@@ -1,55 +1,56 @@
# 侧栏菜单(Side Menu)— 验收标准
> **本文档集顺序**:① [prod.md](./prod.md) → ② [spec.md](./spec.md) → ③ **本页 task**。依据与条目应对齐 prod 与 spec。
验收项应对照 [spec.md](./spec.md);技术实现基于 **React + antd@6**,下列不重复验证 antd 默认组件能力(如 Menu 基础受控、`Sider` 常规折叠属性等),仅验证**本文档约定与叠加逻辑**。
# 侧栏导航 — 验收标准
**样本数据**:可使用与 SPEC §2.3、§2.4 一致的静态骨架 + 合并规则构造最小样例树;文档不绑定某仓库内具体文件路径。
在 **antd@6** 目标栈下可执行(手工或 E2E)。参数与语义以 [spec.md](./spec.md) §5 为准;不要求与现网 class 名一致。
---
## 数据与结构
## 数据与展示
- [ ] 顶层 `tree` 非法组合不渲染错误结构;有 `items` 时一级分组标题与 `name` 一致。
- [ ] 叶子标识与菜单 `key`/`selectedKeys` 一致;`curActivePaneKey` 命中叶子时该叶为 **selected**。
- [ ] 分组下任意深度有选中时,该一级分组具 **子树选中** 可辨样式。
- [ ] 空树不报错,侧栏可显示标题与空菜单区域。
- [ ] 使用 SPEC 约定字段(`id`、`name`、`pageName`、`path`、可选 `type`)时,分组名与叶子文案展示正确。
- [ ] `type` 为 `chart` / `setting` 等与 spec 约定映射一致;缺省或 `normal` 时走默认图标分支。
- [ ] `id` 与 `key` 混用时,选中与展开比较均正确(字符串语义一致);**数值型** `curActivePaneKey`(含负数 id 场景)与 Menu `selectedKeys` 类型转换正确。
- [ ] 当前选中在深层叶子时,对应一级分组呈现「子树选中」样式(见 spec §4.1)。
- [ ] 已打开页签对应的叶子显示「已打开」样式(`pane.key ===` 菜单项 `id`,见 spec §2.2)。
## 响应式与壳层(根容器组合)
## 展开行为(超出 antd 默认的同级互斥)
- [ ] 跨越 `Sider` **breakpoint**(如 lg)时宿主收到 `onSetMenuCollapse(broken)`,且界面「**窄屏固定覆盖**」与 broken 一致。
- [ ] **窄屏**且 `collapsed === false` 时呈现 **全宽抽屉** 语义;窄屏且 `collapsed === true` 时不呈现全宽展开。
- [ ] 窄屏且展开时遮罩为 **可见半透明**(约 0.2 透明度量级,可肉眼对照 spec §5);点遮罩收起。
- [ ] 由窄回宽后无永久遮罩挡主内容操作。
- [ ] 三级结构下,在 antd `Menu` 层展开二级某分支时,其**同级**其它已展开分支被关闭(spec §4.2 手风琴)。
- [ ] 三级自定义子菜单层:展开某一 key 时**兄弟** key 从 `openKeys` 移除;关闭时仅移除当前 key(spec §4.2)。
- [ ] 展开/关闭动画结束后,滚动区域「是否需要滚动」的判定会更新(允许短延迟)。
## 展开与手风琴
## 滚动与遮罩(逻辑)
- [ ] 同父级下连续展开两个一级 `SubMenu`,最终仅保留新展开项(同级其他被收起)。
- [ ] **自研子菜单**路径:展开一节点时同级另一节点从 `openKeys` 中移除。
- [ ] 展开/收起后,在约 0.35s~0.5s 内完成高度与滚动检测;内容足够高时无「永久无法滚动」或「滑块高度为 0」。
- [ ] 内容超出阈值(约 10px)时出现自定义滚动条;滑块与 `scrollTop` 双向同步;触摸拖拽可用(spec §4.4)。
- [ ] `resize` 后滚动条占位与是否需要滚动判断正确。
- [ ] 固定模式且侧栏展开时显示遮罩;点遮罩触发收起;**滚动拖拽过程中**不因误点遮罩关闭(spec §4.5)。
## 点击与宿主回调
## 自定义样式与动效(须对照 spec §5)
- [ ] **非窄屏固定覆盖**:点叶子立即 `onClickMenuItem(item)`。
- [ ] **窄屏固定覆盖**:先触发收起,约 300ms 后 `onClickMenuItem(item)`。
- [ ] **点遮罩**(非拖拽滚动条、且为可见半透明遮罩):触发收拢。
- [ ] **无需滚动**时自绘轨道不可见且 `pointer-events` 不阻挡菜单操作;**需要滚动**时轨道在约定 **delay + duration** 后出现(spec §5.3)。
- [ ] 出现滚动条时菜单区有约定 **padding-right**,一级 `SubMenu` 与自定义子菜单的箭头位置与 spec §5.3 一致(不与轨道重叠)。
- [ ] 滑块默认细、hover **加宽与变色**有过渡(约 0.3s);**滚动拖拽**态下滑块保持展开态且 **background 等关闭 transition(0s)**(spec §5.3)。
- [ ] **滚动拖拽**时遮罩呈全透明、容器 **z-index** 与 §5.3 一致,与固定模式下半透明遮罩可区分。
- [ ] 顶区标题条高度、主色底、ellipsis 符合 spec §5.1。
- [ ] 固定模式全宽 class 与遮罩 rgba 层级符合 spec §5.2。
## 自绘滚动条 — 出现与同步(spec §5)
## 宿主契约与窄屏
- [ ] 仅当视口 ≥ 约 992px 且内容超高(含容差)时出现轨与滑块;否则轨不可见且不形成右侧可点死区。
- [ ] 拖拽时 `scrollTop` 与滑块位移比例一致;松手后滚动与滑块仍同步。
- [ ] **拖拽中**:遮罩 **近透明**;点遮罩/遮罩区域不收拢侧栏(与 spec §5 一致)。
- [ ] **非拖拽**且窄屏展开:**半透明遮罩**可点收拢。
- [ ] 低于约定断点时进入固定模式;断点变化时宿主收到折叠同步(spec §3.2)。
- [ ] `onSetMenuCollapse`:传入 **boolean** 时宿主折叠态与之一致;**无参**调用时符合 spec §6.2 约定的切换语义。
- [ ] 固定模式下点击叶子:先触发收起,再于约定延迟后调用「打开页」回调,顺序符合 spec §4.3。
- [ ] 点击叶子时,回调参数为**完整菜单项对象**,至少包含宿主开页所需字段(如 `id`、`name`、`path`、`pageName`),与宿主标签/开页逻辑的消费方式一致(spec §2.5)。
## 自绘滚动条 — 交互反馈阶梯(spec §5 表格)
---
- [ ] 外围 → 邻近:先进入可滚菜单区,滑块条较默认态明显提亮(中间色),宽度可仍为窄条。
- [ ] 邻近 → 主操作面:指针落在滑块热区,滑块加宽(如约 6px→12px)且更亮,过渡约 0.3s 量级。
- [ ] 拖拽(激活):滑块保持加宽高亮;`background-color` 等 `transition` 为 0,无跟手迟滞。
## 修订记录
## 自研子菜单
| 日期 | 摘要 |
|------|------|
| 2026-04-07 | 自 spec §6 迁入;标注 antd@6 与基线不验范围 |
| 2026-04-07 | 对齐静态配置字段与壳回调验收 |
| 2026-04-07 | 去除具体文件路径 |
| 2026-04-07 | 宿主契约节号改为 §6.2;新增 §5 动效验收 |
- [ ] 展开时箭头旋转与 max-height 过渡可感知;收起与展开时长可不同,无闪断。
- [ ] 选中在本节点与仅有子级选中时标题样式可区分。
## 已打开窗格
- [ ] `panesOnShelf` 中存在 `pane.key` 与叶子 `id` 一致的项时,该叶具 **已打开** 可辨样式,**Caret** 规则符合 spec §5。
## 回归(v6)
- [ ] 窄视口/触摸下分组内多级仍可展开并点选叶子。
openspec/specs/side-menu/spec.md
New file
@@ -0,0 +1,91 @@
# CSideMenu 组件规格说明
## 1. 概述与范围
本规格描述 `CSideMenu` 组件的技术契约:数据树、三级菜单、全局手风琴、响应式侧栏、antd `Layout.Sider` / `Menu` 的组合。
- **依赖**:React、antd v6、`@ant-design/icons`
- **范围**:侧栏容器、菜单渲染、遮罩
- **非目标**:路由与子页实现、自定义滚动条
---
## 2. 数据模型
### MenuTree(宿主传入)
```typescript
interface MenuItem {
  key: string | number;      // 唯一标识
  label: string;              // 显示文本
  children?: MenuItem[];      // 子菜单
  path?: string;              // 页面路径
  pageName?: string;         // 页面名称
  type?: 'chart' | 'setting' | 'folder' | 'file';  // 图标类型
}
interface MenuTree extends MenuItem {}
```
### 组件 Props
```typescript
interface CSideMenuProps {
  title: string;                           // 顶区标题
  tree?: MenuTree;                         // 合并后的菜单树
  collapsed: boolean;                      // 是否收起
  curActivePaneKey?: string | number;     // 当前选中键
  panesOnShelf?: Array<{ key: string }>; // 已打开页签列表
  onClickMenuItem: (item: MenuItem) => void;
  onSetMenuCollapse: (collapsed: boolean | void) => void;
}
```
---
## 3. 组件行为
### Requirement: 三级菜单树渲染
组件 SHALL 从注入的 `tree` 数据结构渲染三级菜单树。结构为 `key > label > children > key > label > children`。每个节点 SHALL 包含 `id`、`name`、可选 `type` 字段。当节点包含非空 `children` 时,SHALL 渲染为 SubMenu;否则为叶子项。
### Requirement: 全局手风琴展开
当用户展开一个节点时,组件 SHALL 关闭之前展开的节点。全局最多同时只有一个展开分支。
### Requirement: 响应式固定模式
当视口宽度低于 `lg` 断点时(移动端),侧边栏 SHALL 进入固定模式,以 `position: fixed` 悬浮覆盖在内容之上。当菜单展开时 SHALL 显示背景遮罩,点击遮罩 SHALL 触发收起。PC 端不启用覆盖模式。
### Requirement: 固定模式延迟导航
在固定模式下,当用户点击叶子项时,组件 SHALL 先触发收起(`onSetMenuCollapse(true)`),然后在约 300ms 延迟后调用 `onClickMenuItem`,以避免动画闪烁。
### Requirement: 菜单项点击回调
当用户点击叶子菜单项时,组件 SHALL 调用 `onClickMenuItem`,传入完整的菜单项对象,至少包含 `id`、`name`、`path`、`pageName` 字段。
### Requirement: 折叠状态回调
组件 SHALL 支持 `onSetMenuCollapse` 的两种调用模式:boolean 参数(设置精确折叠状态)和无参调用(切换)。宿主应用 SHALL 处理这两种模式。
### Requirement: 空树处理
当注入的 `tree` 为空或 undefined 时,组件 SHALL 渲染标题区域和空菜单区域,且不抛出错误。
### Requirement: 当前选中键高亮
当 `curActivePaneKey` 与菜单节点的 `id` 或 `key` 匹配时,该节点 SHALL 显示选中样式。当叶子项被选中时,其所属 SubMenu SHALL 显示子树选中样式。
### Requirement: 已打开标签指示器
当 `panesOnShelf` 中的某个条目的 `key` 与叶子节点的 `id` 匹配时,该叶子 SHALL 显示"已打开"指示器样式。
---
## 4. 图标映射
| type 值 | 图标 |
|---------|------|
| chart | PieChartOutlined |
| setting | SettingOutlined |
| folder | FolderOutlined |
| file (默认) | FileOutlined |
---
## 5. 响应式断点
- **PC 端**:`breakpoint="lg"` (992px) 以上,使用 antd Layout.Sider
- **移动端**:992px 及以下,使用固定定位覆盖层 + 遮罩