AdmSysV2【公共组件库】@前端(For Git Submodule)
8 files modified
10 files added
1729 ■■■■■ changed files
.claude/settings.json 18 ●●●● patch | view | raw | blame | history
.claude/skills/tevin-write-e2etest/SKILL.md 94 ●●●●● patch | view | raw | blame | history
.claude/skills/tevin-write-e2etest/references/playwright-patterns.md 320 ●●●●● patch | view | raw | blame | history
.gitignore 1 ●●●● patch | view | raw | blame | history
.prettierrc 18 ●●●●● patch | view | raw | blame | history
example/App.tsx 78 ●●●●● patch | view | raw | blame | history
example/main.tsx 22 ●●●●● patch | view | raw | blame | history
example/pages/side-menu/SideMenuPage.tsx 113 ●●●●● patch | view | raw | blame | history
openspec/changes/implement-c-side-menu/tasks.md 48 ●●●● patch | view | raw | blame | history
package.json 2 ●●● patch | view | raw | blame | history
pnpm-lock.yaml 493 ●●●●● patch | view | raw | blame | history
src/framework/sideMenu/CSideMenu.tsx 227 ●●●●● patch | view | raw | blame | history
src/framework/sideMenu/cSideMenu.scss 53 ●●●●● patch | view | raw | blame | history
src/framework/sideMenu/types.ts 42 ●●●●● patch | view | raw | blame | history
src/index.ts 1 ●●●● patch | view | raw | blame | history
test/e2e/side-menu.spec.ts 28 ●●●●● patch | view | raw | blame | history
test/unit/CSideMenu.test.tsx 123 ●●●●● patch | view | raw | blame | history
tsconfig.app.json 48 ●●●● patch | view | raw | blame | history
.claude/settings.json
@@ -1,20 +1,34 @@
{
    "permissions": {
        "allow": [
            "Read(openspec/**)",
            "Edit(openspec/**)",
            "Read(src/**)",
            "Edit(src/**)",
            "Read(public/**)",
            "Edit(public/**)",
            "Bash(npx tsc:*)",
            "Read(example/**)",
            "Edit(example/**)",
            "Read(test/**)",
            "Edit(test/**)",
            "Bash(pnpm run *)",
            "Bash(pnpm eslint *)",
            "Bash(pnpm dev:*)",
            "Bash(pnpm test:run:*)",
            "Bash(pnpm build:*)",
            "Bash(pnpm test:*)",
            "Bash(pnpm exec playwright open:*)",
            "Bash(pnpm exec playwright test:*)",
            "Bash(openspec *)",
            "Bash(openspec new change *)",
            "Bash(openspec status *)",
            "Bash(openspec instructions *)",
            "Bash(cmd //c \"openspec new change *\")",
            "Bash(cmd //c \"openspec status *\")",
            "Bash(cmd //c \"openspec instructions *\")"
        ]
        ],
        "deny": ["Edit(openspec/docs/old-refactors/**)"]
    }
}
.claude/skills/tevin-write-e2etest/SKILL.md
New file
@@ -0,0 +1,94 @@
---
name: tevin-write-e2etest
description: 指导如何编写高质量、可维护的 Playwright E2E 测试。涵盖测试策略、定位器规范、多端适配、流程分支覆盖、数据管理、Mock 方案等工程化实践。
---
# 编写 Playwright E2E 测试规范
## 一、核心原则(铁律)
- **测试即文档**:每个 `test()` 的描述必须使用清晰的中文(如 `test('用户输入错误密码时应提示登录失败')`)。
- **独立性**:每个测试用例必须能够独立运行,**严禁**依赖前一个用例的浏览器状态。
- **无硬等待**:**绝对禁止**使用 `page.waitForTimeout()`。必须使用 Playwright 自带的智能等待。
- **原子性**:一个测试用例只验证一个核心业务流程或一个操作分支。
## 二、项目测试环境
- **测试目录**: `test/e2e/`
- **路由模式**: hash 路由,`http://localhost:5173/#/preview/pages/<path>`
- **开发服务器**: `pnpm dev` (http://localhost:5173)
- **运行命令**: `pnpm exec playwright test test/e2e/<spec>.ts`
## 三、多端适配策略
当需求涉及 PC 和移动端两种展示交互时,**不得**在单个测试内写 `if (isMobile)` 逻辑。
```typescript
import { test, devices } from '@playwright/test';
['桌面端 Chrome', '移动端 iPhone 12'].forEach((deviceName) => {
  test.describe(`功能 - ${deviceName}`, () => {
    test.use({
      ...(deviceName === '移动端 iPhone 12'
        ? devices['iPhone 12']
        : { viewport: { width: 1280, height: 720 } }
      )
    });
    test('测试场景', async ({ page }) => { ... });
  });
});
```
## 四、流程分支与交互路径覆盖
对于包含多种用户操作结果的组件(如表单提交成功、校验失败),**必须**为每个分支生成独立的 `test()` 用例。
```typescript
test.describe('用户操作流程', () => {
  test('操作成功,跳转至结果页', async ({ page }) => { ... });
  test('输入错误,显示错误提示', async ({ page }) => { ... });
  test('网络异常,显示网络错误提示', async ({ page }) => {
    // 使用 page.route 模拟网络错误
  });
});
```
## 五、定位器选择规范(强制)
优先级:`getByTestId` > `getByRole` > `getByText` > `getByLabel`
**严禁使用**:复杂 CSS 层级选择器(如 `div > ul > li:nth-child(2) > button`)
```typescript
// ❌ 错误
page.locator('#app > div:nth-child(3) > button')
// ✅ 正确
page.getByTestId('module-action-btn')
```
## 六、网络模拟与异常处理
```typescript
test('请求失败应提示错误', async ({ page }) => {
  await page.route('**/api/data', route => route.fulfill({
    status: 500,
    body: JSON.stringify({ message: '服务器错误' })
  }));
  await expect(page.getByText('服务器错误')).toBeVisible();
});
```
## 七、代码生成后自检清单
- [ ] 是否包含 `waitForTimeout`?(有则替换)
- [ ] 是否使用了脆弱的 CSS 定位器?(有则替换)
- [ ] 是否遗漏了异常分支的测试用例?
- [ ] 移动端/PC 端是否被正确分离?
## 参考资料
详细代码模板(如 Page Object Model 完整实现、Playwright 配置示例、CI 集成等)请查阅:
- `.claude/skills/tevin-write-e2etest/references/playwright-patterns.md`
.claude/skills/tevin-write-e2etest/references/playwright-patterns.md
New file
@@ -0,0 +1,320 @@
# Playwright E2E 测试模式参考
构建稳定、快速、可维护的 E2E 测试套件的 Playwright 模式集合。
## 测试文件组织
```
test/
├── e2e/
│   ├── auth/
│   │   ├── login.spec.ts
│   │   ├── logout.spec.ts
│   │   └── register.spec.ts
│   ├── features/
│   │   ├── browse.spec.ts
│   │   ├── search.spec.ts
│   │   └── create.spec.ts
│   └── api/
│       └── endpoints.spec.ts
├── fixtures/
│   ├── auth.ts
│   └── data.ts
└── playwright.config.ts
```
## 页面对象模型(POM)
```typescript
import { Page, Locator } from '@playwright/test'
export class ItemsPage {
  readonly page: Page
  readonly searchInput: Locator
  readonly itemCards: Locator
  readonly createButton: Locator
  constructor(page: Page) {
    this.page = page
    this.searchInput = page.locator('[data-testid="search-input"]')
    this.itemCards = page.locator('[data-testid="item-card"]')
    this.createButton = page.locator('[data-testid="create-btn"]')
  }
  async goto() {
    await this.page.goto('/items')
    await this.page.waitForLoadState('networkidle')
  }
  async search(query: string) {
    await this.searchInput.fill(query)
    await this.page.waitForResponse(resp => resp.url().includes('/api/search'))
    await this.page.waitForLoadState('networkidle')
  }
  async getItemCount() {
    return await this.itemCards.count()
  }
}
```
## 测试结构
```typescript
import { test, expect } from '@playwright/test'
import { ItemsPage } from '../../pages/ItemsPage'
test.describe('商品搜索', () => {
  let itemsPage: ItemsPage
  test.beforeEach(async ({ page }) => {
    itemsPage = new ItemsPage(page)
    await itemsPage.goto()
  })
  test('根据关键词搜索', async ({ page }) => {
    await itemsPage.search('测试商品')
    const count = await itemsPage.getItemCount()
    expect(count).toBeGreaterThan(0)
    await expect(itemsPage.itemCards.first()).toContainText(/测试/i)
    await page.screenshot({ path: 'artifacts/search-results.png' })
  })
  test('搜索无结果时显示空状态', async ({ page }) => {
    await itemsPage.search('xyznonexistent123')
    await expect(page.locator('[data-testid="no-results"]')).toBeVisible()
    expect(await itemsPage.getItemCount()).toBe(0)
  })
})
```
## Playwright 配置
```typescript
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
  testDir: './test/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html', { outputFolder: 'playwright-report' }],
    ['junit', { outputFile: 'playwright-results.xml' }],
    ['json', { outputFile: 'playwright-results.json' }]
  ],
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:5173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    actionTimeout: 10000,
    navigationTimeout: 30000,
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
  ],
  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
    timeout: 120000,
  },
})
```
## 不稳定测试模式
### 隔离
```typescript
test('不稳定测试:复杂搜索', async ({ page }) => {
  test.fixme(true, '不稳定 - Issue #123')
  // 测试代码...
})
test('条件跳过', async ({ page }) => {
  test.skip(process.env.CI, 'CI 环境不稳定 - Issue #123')
  // 测试代码...
})
```
### 识别不稳定性
```bash
npx playwright test test/search.spec.ts --repeat-each=10
npx playwright test test/search.spec.ts --retries=3
```
### 常见原因及修复
**竞态条件:**
```typescript
// 错误:假设元素已就绪
await page.click('[data-testid="button"]')
// 正确:使用自动等待的定位器
await page.locator('[data-testid="button"]').click()
```
**网络时序:**
```typescript
// 错误:任意等待
await page.waitForTimeout(5000)
// 正确:等待特定条件
await page.waitForResponse(resp => resp.url().includes('/api/data'))
```
**动画时序:**
```typescript
// 错误:动画过程中点击
await page.click('[data-testid="menu-item"]')
// 正确:等待稳定后再点击
await page.locator('[data-testid="menu-item"]').waitFor({ state: 'visible' })
await page.waitForLoadState('networkidle')
await page.locator('[data-testid="menu-item"]').click()
```
## 产物管理
### 截图
```typescript
await page.screenshot({ path: 'artifacts/after-login.png' })
await page.screenshot({ path: 'artifacts/full-page.png', fullPage: true })
await page.locator('[data-testid="chart"]').screenshot({ path: 'artifacts/chart.png' })
```
### 追踪
```typescript
await browser.startTracing(page, {
  path: 'artifacts/trace.json',
  screenshots: true,
  snapshots: true,
})
// ... 测试操作 ...
await browser.stopTracing()
```
### 视频
```typescript
// 在 playwright.config.ts 中配置
use: {
  video: 'retain-on-failure',
  videosPath: 'artifacts/videos/'
}
```
## CI/CD 集成
```yaml
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: pnpm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
        env:
          BASE_URL: ${{ vars.STAGING_URL }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30
```
## 测试报告模板
```markdown
# E2E 测试报告
**日期:** YYYY-MM-DD HH:MM
**耗时:** X分Y秒
**状态:** 通过 / 失败
## 概要
- 总计: X | 通过: Y (Z%) | 失败: A | 不稳定: B | 跳过: C
## 失败测试
### 测试名称
**文件:** `test/e2e/feature.spec.ts:45`
**错误:** 预期元素可见
**截图:** artifacts/failed.png
**推荐修复:** [描述]
## 产物
- HTML 报告: playwright-report/index.html
- 截图: artifacts/*.png
- 视频: artifacts/videos/*.webm
- 追踪: artifacts/*.zip
```
## Web3/钱包测试
```typescript
test('钱包连接', async ({ page, context }) => {
  // Mock 钱包 provider
  await context.addInitScript(() => {
    window.ethereum = {
      isMetaMask: true,
      request: async ({ method }) => {
        if (method === 'eth_requestAccounts')
          return ['0x1234567890123456789012345678901234567890']
        if (method === 'eth_chainId') return '0x1'
      }
    }
  })
  await page.goto('/')
  await page.locator('[data-testid="connect-wallet"]').click()
  await expect(page.locator('[data-testid="wallet-address"]')).toContainText('0x1234')
})
```
## 金融/关键流程测试
```typescript
test('交易执行', async ({ page }) => {
  // 生产环境跳过 — 真实资金
  test.skip(process.env.NODE_ENV === 'production', '生产环境跳过')
  await page.goto('/markets/test-market')
  await page.locator('[data-testid="position-yes"]').click()
  await page.locator('[data-testid="trade-amount"]').fill('1.0')
  // 验证预览
  const preview = page.locator('[data-testid="trade-preview"]')
  await expect(preview).toContainText('1.0')
  // 确认并等待区块链响应
  await page.locator('[data-testid="confirm-trade"]').click()
  await page.waitForResponse(
    resp => resp.url().includes('/api/trade') && resp.status() === 200,
    { timeout: 30000 }
  )
  await expect(page.locator('[data-testid="trade-success"]')).toBeVisible()
})
```
.gitignore
@@ -23,3 +23,4 @@
*.njsproj
*.sln
*.sw?
test-results/
.prettierrc
New file
@@ -0,0 +1,18 @@
{
    "printWidth": 90,
    "useTabs": false,
    "tabWidth": 4,
    "trailingComma": "all",
    "bracketSpacing": true,
    "singleQuote": true,
    "arrowParens": "avoid",
    "semi": true,
    "overrides": [
        {
            "files": "*.js",
            "options": {
                "printWidth": 100
            }
        }
    ]
}
example/App.tsx
@@ -1,8 +1,80 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import { SideMenuPage } from './pages/side-menu/SideMenuPage';
/** 组件配置 */
const components: Array<{ name: string; path: string; component: ReactNode }> = [
  {
    name: 'CSideMenu',
    path: '/pages/side-menu/SideMenuPage.tsx',
    component: <SideMenuPage />,
  },
];
/**
 * 组件展示主页面
 * 左侧:组件列表
 * 右侧:iframe 展示组件示例
 */
function App() {
  const [activeIndex, setActiveIndex] = useState(0);
  return (
    <div style={{ padding: '20px' }}>
      <h1>admin2-components</h1>
      <p>组件展示示例(待开发)</p>
    <div style={{ display: 'table', width: '100%', height: '100vh' }}>
      {/* 左侧组件列表 */}
      <div
        style={{
          display: 'table-cell',
          width: '200px',
          borderRight: '1px solid #eee',
          padding: '16px',
          background: '#fafafa',
          verticalAlign: 'top',
        }}
      >
        <h3 style={{ marginTop: 0 }}>组件列表</h3>
        <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
          {components.map((comp, index) => (
            <li key={comp.name} style={{ marginBottom: '8px' }}>
              <button
                onClick={() => setActiveIndex(index)}
                style={{
                  width: '100%',
                  padding: '8px 12px',
                  textAlign: 'left',
                  background: activeIndex === index ? '#1890ff' : '#fff',
                  color: activeIndex === index ? '#fff' : '#333',
                  border: '1px solid #d9d9d9',
                  borderRadius: '4px',
                  cursor: 'pointer',
                  fontSize: '14px',
                }}
              >
                {comp.name}
              </button>
            </li>
          ))}
        </ul>
      </div>
      {/* 右侧 iframe 容器 */}
      <div
        style={{
          display: 'table-cell',
          background: '#fff',
          verticalAlign: 'top',
        }}
      >
        <iframe
          src={`http://localhost:5173/#/preview/${components[activeIndex].path}`}
          style={{
            width: '100%',
            height: '100%',
            border: 'none',
          }}
          title={components[activeIndex].name}
        />
      </div>
    </div>
  );
}
example/main.tsx
@@ -1,5 +1,25 @@
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
import { SideMenuPage } from './pages/side-menu/SideMenuPage';
createRoot(document.getElementById('root')!).render(<App />);
/** 预览页面 - 无 shell,直接渲染组件 */
function PreviewPage() {
  const hash = window.location.hash;
  const path = hash.replace('#/preview/', '');
  if (path.includes('side-menu')) {
    return <SideMenuPage />;
  }
  return <div>Unknown component</div>;
}
const root = createRoot(document.getElementById('root')!);
// 检查是否是预览模式
if (window.location.hash.includes('/preview/')) {
  root.render(<PreviewPage />);
} else {
  root.render(<App />);
}
example/pages/side-menu/SideMenuPage.tsx
New file
@@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react';
import { CSideMenu } from '../../../src';
import type { MenuItem } from '../../../src/framework/sideMenu/types';
/** 模拟菜单数据 */
const mockTree: MenuItem = {
  key: '1',
  label: '导航1',
  type: 'folder',
  children: [
    {
      key: '1-1',
      label: '子菜单1-1',
      type: 'folder',
      children: [
        { key: '1-1-1', label: '页面1-1-1', path: '/page1-1-1', pageName: 'Page111', type: 'file' },
        { key: '1-1-2', label: '页面1-1-2', path: '/page1-1-2', pageName: 'Page112', type: 'file' },
      ],
    },
    {
      key: '1-2',
      label: '页面1-2',
      path: '/page1-2',
      pageName: 'Page12',
      type: 'file',
    },
  ],
};
/** 已打开的页面列表 */
const mockPanesOnShelf = [
  { key: '1-1-1' },
  { key: '1-2' },
];
/**
 * CSideMenu 组件示例页
 */
export function SideMenuPage() {
  const [collapsed, setCollapsed] = useState(false);
  const [curActivePaneKey, setCurActivePaneKey] = useState<string | number>('1-1-1');
  const [isMobile, setIsMobile] = useState(false);
  // 检测移动端 - 与 CSideMenu 组件的判断逻辑一致
  useEffect(() => {
    const checkMobile = () => {
      const width = window.innerWidth;
      const screenWidth = window.screen.width;
      setIsMobile(width <= 992 && screenWidth <= 1024);
    };
    checkMobile();
    window.addEventListener('resize', checkMobile);
    return () => window.removeEventListener('resize', checkMobile);
  }, []);
  const handleClickMenuItem = (item: MenuItem) => {
    console.log('点击菜单项:', item);
    setCurActivePaneKey(item.key);
  };
  const handleSetMenuCollapse = (value: boolean | void) => {
    if (typeof value === 'boolean') {
      setCollapsed(value);
    } else {
      setCollapsed((prev) => !prev);
    }
  };
  return (
    <div style={{ display: 'flex', height: '100vh' }}>
      <div style={{ flexShrink: 0 }}>
        <CSideMenu
          title="管理后台"
          tree={mockTree}
          collapsed={collapsed}
          curActivePaneKey={curActivePaneKey}
          panesOnShelf={mockPanesOnShelf}
          onClickMenuItem={handleClickMenuItem}
          onSetMenuCollapse={handleSetMenuCollapse}
        />
      </div>
      <div style={{ flex: 1, padding: '20px', position: 'relative' }}>
        {/* 移动端折叠/展开触发器 */}
        {isMobile && (
          <button
            onClick={() => handleSetMenuCollapse()}
            style={{
              position: 'absolute',
              top: '10px',
              left: '10px',
              zIndex: 100,
              padding: '8px 12px',
              background: '#1890ff',
              color: '#fff',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            {collapsed ? '展开菜单' : '收起菜单'}
          </button>
        )}
        <h2>CSideMenu 组件示例</h2>
        <p>当前选中: {curActivePaneKey}</p>
        <p>折叠状态: {collapsed ? '收起' : '展开'}</p>
      </div>
    </div>
  );
}
export default SideMenuPage;
openspec/changes/implement-c-side-menu/tasks.md
@@ -1,50 +1,50 @@
## 1. 项目初始化
- [ ] 1.1 创建 `src/framework/sideMenu/` 目录结构
- [ ] 1.2 创建类型定义文件 `sideMenuTypes.ts`(MenuTree、MenuItem、CSideMenuProps)
- [ ] 1.3 创建组件文件:`CSideMenu.tsx`、`cSideMenu.scss`
- [ ] 1.4 创建 `src/index.ts` 统一导出入口
- [x] 1.1 创建 `src/framework/sideMenu/` 目录结构
- [x] 1.2 创建类型定义文件 `sideMenuTypes.ts`(MenuTree、MenuItem、CSideMenuProps)
- [x] 1.3 创建组件文件:`CSideMenu.tsx`、`cSideMenu.scss`
- [x] 1.4 创建 `src/index.ts` 统一导出入口
## 2. example 基础结构
- [ ] 2.1 创建 `example/pages/side-menu/SideMenuPage.tsx` 组件示例页
- [ ] 2.2 在 `example/App.tsx` 中添加组件列表入口
- [x] 2.1 创建 `example/pages/side-menu/SideMenuPage.tsx` 组件示例页
- [x] 2.2 在 `example/App.tsx` 中添加组件列表入口
## 3. 核心组件实现
- [ ] 3.1 实现 `CSideMenu` 组件框架,定义 props 接口
- [ ] 3.2 使用 antd Menu 的 `items` 属性实现三级菜单嵌套
- [ ] 3.3 实现 `tree` 到 antd `items` 的转换函数
- [ ] 3.4 处理 `type` 字段的图标映射(chart、setting 等,缺省为默认图标)
- [x] 3.1 实现 `CSideMenu` 组件框架,定义 props 接口
- [x] 3.2 使用 antd Menu 的 `items` 属性实现三级菜单嵌套
- [x] 3.3 实现 `tree` 到 antd `items` 的转换函数
- [x] 3.4 处理 `type` 字段的图标映射(chart、setting 等,缺省为默认图标)
## 4. 手风琴行为
- [ ] 4.1 实现全局手风琴逻辑:在 `onOpenChange` 中用新 key 替换 `openKeys`
- [ ] 4.2 实现 `openKeys` 受控展开状态
- [x] 4.1 实现全局手风琴逻辑:在 `onOpenChange` 中用新 key 替换 `openKeys`
- [x] 4.2 实现 `openKeys` 受控展开状态
## 5. 响应式固定模式
- [ ] 5.1 添加 antd Layout.Sider,设置 `breakpoint="lg"`
- [ ] 5.2 实现移动端覆盖样式(CSS 媒体查询)
- [ ] 5.3 实现遮罩层及其点击关闭逻辑
- [x] 5.1 添加 antd Layout.Sider,设置 `breakpoint="lg"`
- [x] 5.2 实现移动端覆盖样式(CSS 媒体查询)
- [x] 5.3 实现遮罩层及其点击关闭逻辑
## 6. 交互与回调
- [ ] 6.1 实现叶子项点击 → `onClickMenuItem` 回调,传入完整菜单项对象
- [ ] 6.2 移动端点击叶子时:先 `onSetMenuCollapse(true)`,300ms 延迟后再调用导航回调
- [ ] 6.3 支持 `onSetMenuCollapse` 的 boolean 和无参两种调用模式
- [x] 6.1 实现叶子项点击 → `onClickMenuItem` 回调,传入完整菜单项对象
- [x] 6.2 移动端点击叶子时:先 `onSetMenuCollapse(true)`,300ms 延迟后再调用导航回调
- [x] 6.3 支持 `onSetMenuCollapse` 的 boolean 和无参两种调用模式
## 7. 选中与指示器
- [ ] 7.1 连接 `selectedKeys` 与 `curActivePaneKey`
- [ ] 7.2 SubMenu 下叶子被选中时应用子树选中样式
- [ ] 7.3 在 `panesOnShelf` 键匹配时显示"已打开"指示器
- [x] 7.1 连接 `selectedKeys` 与 `curActivePaneKey`
- [x] 7.2 SubMenu 下叶子被选中时应用子树选中样式
- [x] 7.3 在 `panesOnShelf` 键匹配时显示"已打开"指示器
## 8. 空状态与类型处理
- [ ] 8.1 空树时渲染标题和空区域,不报错
- [ ] 8.2 支持 `id`/`key` 比较,包含数值类型键(string/number)的类型转换
- [x] 8.1 空树时渲染标题和空区域,不报错
- [x] 8.2 支持 `id`/`key` 比较,包含数值类型键(string/number)的类型转换
## 9. 测试
- [ ] 9.1 创建 `test/unit/CSideMenu.test.tsx` 单元测试
- [x] 9.1 创建 `test/unit/CSideMenu.test.tsx` 单元测试
package.json
@@ -19,7 +19,6 @@
  },
  "devDependencies": {
    "@eslint/js": "^9.39.4",
    "@playwright/mcp": "^0.0.70",
    "@playwright/test": "^1.59.1",
    "@testing-library/jest-dom": "^6.9.1",
    "@testing-library/react": "^16.3.2",
@@ -35,6 +34,7 @@
    "globals": "^17.4.0",
    "jsdom": "^29.0.2",
    "playwright": "^1.59.1",
    "sass-embedded": "^1.99.0",
    "typescript": "~5.9.3",
    "typescript-eslint": "^8.57.0",
    "vite": "^8.0.1",
pnpm-lock.yaml
@@ -24,9 +24,6 @@
      '@eslint/js':
        specifier: ^9.39.4
        version: 9.39.4
      '@playwright/mcp':
        specifier: ^0.0.70
        version: 0.0.70
      '@playwright/test':
        specifier: ^1.59.1
        version: 1.59.1
@@ -47,7 +44,7 @@
        version: 19.2.3(@types/react@19.2.14)
      '@vitejs/plugin-react':
        specifier: ^6.0.1
        version: 6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0))
        version: 6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(sass-embedded@1.99.0)(sass@1.99.0))
      eslint:
        specifier: ^9.39.4
        version: 9.39.4
@@ -72,6 +69,9 @@
      playwright:
        specifier: ^1.59.1
        version: 1.59.1
      sass-embedded:
        specifier: ^1.99.0
        version: 1.99.0
      typescript:
        specifier: ~5.9.3
        version: 5.9.3
@@ -80,10 +80,10 @@
        version: 8.58.0(eslint@9.39.4)(typescript@5.9.3)
      vite:
        specifier: ^8.0.1
        version: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)
        version: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(sass-embedded@1.99.0)(sass@1.99.0)
      vitest:
        specifier: ^4.1.2
        version: 4.1.2(@types/node@24.12.0)(jsdom@29.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0))
        version: 4.1.2(@types/node@24.12.0)(jsdom@29.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(sass-embedded@1.99.0)(sass@1.99.0))
packages:
@@ -210,6 +210,9 @@
  '@bramus/specificity@2.4.2':
    resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
    hasBin: true
  '@bufbuild/protobuf@2.11.0':
    resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==}
  '@csstools/color-helpers@6.0.2':
    resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
@@ -385,10 +388,93 @@
  '@oxc-project/types@0.122.0':
    resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==}
  '@playwright/mcp@0.0.70':
    resolution: {integrity: sha512-Kl0a6l9VL8rvT1oBou3hS5yArjwWV9UlwAkq+0skfK1YVg8XfmmNaAmwZhMeNx/ZhGiWXfCllo6rD/jvZz+WuA==}
    engines: {node: '>=18'}
    hasBin: true
  '@parcel/watcher-android-arm64@2.5.6':
    resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
    engines: {node: '>= 10.0.0'}
    cpu: [arm64]
    os: [android]
  '@parcel/watcher-darwin-arm64@2.5.6':
    resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
    engines: {node: '>= 10.0.0'}
    cpu: [arm64]
    os: [darwin]
  '@parcel/watcher-darwin-x64@2.5.6':
    resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
    engines: {node: '>= 10.0.0'}
    cpu: [x64]
    os: [darwin]
  '@parcel/watcher-freebsd-x64@2.5.6':
    resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
    engines: {node: '>= 10.0.0'}
    cpu: [x64]
    os: [freebsd]
  '@parcel/watcher-linux-arm-glibc@2.5.6':
    resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
    engines: {node: '>= 10.0.0'}
    cpu: [arm]
    os: [linux]
    libc: [glibc]
  '@parcel/watcher-linux-arm-musl@2.5.6':
    resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
    engines: {node: '>= 10.0.0'}
    cpu: [arm]
    os: [linux]
    libc: [musl]
  '@parcel/watcher-linux-arm64-glibc@2.5.6':
    resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
    engines: {node: '>= 10.0.0'}
    cpu: [arm64]
    os: [linux]
    libc: [glibc]
  '@parcel/watcher-linux-arm64-musl@2.5.6':
    resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
    engines: {node: '>= 10.0.0'}
    cpu: [arm64]
    os: [linux]
    libc: [musl]
  '@parcel/watcher-linux-x64-glibc@2.5.6':
    resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
    engines: {node: '>= 10.0.0'}
    cpu: [x64]
    os: [linux]
    libc: [glibc]
  '@parcel/watcher-linux-x64-musl@2.5.6':
    resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
    engines: {node: '>= 10.0.0'}
    cpu: [x64]
    os: [linux]
    libc: [musl]
  '@parcel/watcher-win32-arm64@2.5.6':
    resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
    engines: {node: '>= 10.0.0'}
    cpu: [arm64]
    os: [win32]
  '@parcel/watcher-win32-ia32@2.5.6':
    resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
    engines: {node: '>= 10.0.0'}
    cpu: [ia32]
    os: [win32]
  '@parcel/watcher-win32-x64@2.5.6':
    resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
    engines: {node: '>= 10.0.0'}
    cpu: [x64]
    os: [win32]
  '@parcel/watcher@2.5.6':
    resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
    engines: {node: '>= 10.0.0'}
  '@playwright/test@1.59.1':
    resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
@@ -1022,6 +1108,10 @@
    resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
    engines: {node: '>=10'}
  chokidar@4.0.3:
    resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
    engines: {node: '>= 14.16.0'}
  clsx@2.1.1:
    resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
    engines: {node: '>=6'}
@@ -1032,6 +1122,9 @@
  color-name@1.1.4:
    resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
  colorjs.io@0.5.2:
    resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
  compare-versions@6.1.1:
    resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
@@ -1272,6 +1365,9 @@
    resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
    engines: {node: '>= 4'}
  immutable@5.1.5:
    resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==}
  import-fresh@3.3.1:
    resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
    engines: {node: '>=6'}
@@ -1466,6 +1562,9 @@
  natural-compare@1.4.0:
    resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
  node-addon-api@7.1.1:
    resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
  node-releases@2.0.36:
    resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
@@ -1514,18 +1613,8 @@
    engines: {node: '>=18'}
    hasBin: true
  playwright-core@1.60.0-alpha-1774999321000:
    resolution: {integrity: sha512-ams3Zo4VXxeOg5ZTTh16GkE8g48Bmxo/9pg9gXl9SVKlVohCU7Jaog7XntY8yFuzENA6dJc1Fz7Z/NNTm9nGEw==}
    engines: {node: '>=18'}
    hasBin: true
  playwright@1.59.1:
    resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
    engines: {node: '>=18'}
    hasBin: true
  playwright@1.60.0-alpha-1774999321000:
    resolution: {integrity: sha512-Bd5DkzYKG+2g1jLO6NeTXmGLbBYSFffJIOsR4l4hUBkJvzvGGdLZ7jZb2tOtb0WIoWXQKdQj3Ap6WthV4DBS8w==}
    engines: {node: '>=18'}
    hasBin: true
@@ -1560,6 +1649,10 @@
    resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
    engines: {node: '>=0.10.0'}
  readdirp@4.1.2:
    resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
    engines: {node: '>= 14.18.0'}
  redent@3.0.0:
    resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
    engines: {node: '>=8'}
@@ -1575,6 +1668,131 @@
  rolldown@1.0.0-rc.12:
    resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==}
    engines: {node: ^20.19.0 || >=22.12.0}
    hasBin: true
  rxjs@7.8.2:
    resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
  sass-embedded-all-unknown@1.99.0:
    resolution: {integrity: sha512-qPIRG8Uhjo6/OKyAKixTnwMliTz+t9K6Duk0mx5z+K7n0Ts38NSJz2sjDnc7cA/8V9Lb3q09H38dZ1CLwD+ssw==}
    cpu: ['!arm', '!arm64', '!riscv64', '!x64']
  sass-embedded-android-arm64@1.99.0:
    resolution: {integrity: sha512-fNHhdnP23yqqieCbAdym4N47AleSwjbNt6OYIYx4DdACGdtERjQB4iOX/TaKsW034MupfF7SjnAAK8w7Ptldtg==}
    engines: {node: '>=14.0.0'}
    cpu: [arm64]
    os: [android]
  sass-embedded-android-arm@1.99.0:
    resolution: {integrity: sha512-EHvJ0C7/VuP78Qr6f8gIUVUmCqIorEQpw2yp3cs3SMg02ZuumlhjXvkTcFBxHmFdFR23vTNk1WnhY6QSeV1nFQ==}
    engines: {node: '>=14.0.0'}
    cpu: [arm]
    os: [android]
  sass-embedded-android-riscv64@1.99.0:
    resolution: {integrity: sha512-4zqDFRvgGDTL5vTHuIhRxUpXFoh0Cy7Gm5Ywk19ASd8Settmd14YdPRZPmMxfgS1GH292PofV1fq1ifiSEJWBw==}
    engines: {node: '>=14.0.0'}
    cpu: [riscv64]
    os: [android]
  sass-embedded-android-x64@1.99.0:
    resolution: {integrity: sha512-Uk53k/dGYt04RjOL4gFjZ0Z9DH9DKh8IA8WsXUkNqsxerAygoy3zqRBS2zngfE9K2jiOM87q+1R1p87ory9oQQ==}
    engines: {node: '>=14.0.0'}
    cpu: [x64]
    os: [android]
  sass-embedded-darwin-arm64@1.99.0:
    resolution: {integrity: sha512-u61/7U3IGLqoO6gL+AHeiAtlTPFwJK1+964U8gp45ZN0hzh1yrARf5O1mivXv8NnNgJvbG2wWJbiNZP0lG/lTg==}
    engines: {node: '>=14.0.0'}
    cpu: [arm64]
    os: [darwin]
  sass-embedded-darwin-x64@1.99.0:
    resolution: {integrity: sha512-j/kkk/NcXdIameLezSfXjgCiBkVcA+G60AXrX768/3g0miK1g7M9dj7xOhCb1i7/wQeiEI3rw2LLuO63xRIn4A==}
    engines: {node: '>=14.0.0'}
    cpu: [x64]
    os: [darwin]
  sass-embedded-linux-arm64@1.99.0:
    resolution: {integrity: sha512-btNcFpItcB56L40n8hDeL7sRSMLDXQ56nB5h2deddJx1n60rpKSElJmkaDGHtpkrY+CTtDRV0FZDjHeTJddYew==}
    engines: {node: '>=14.0.0'}
    cpu: [arm64]
    os: [linux]
    libc: glibc
  sass-embedded-linux-arm@1.99.0:
    resolution: {integrity: sha512-d4IjJZrX2+AwB2YCy1JySwdptJECNP/WfAQLUl8txI3ka8/d3TUI155GtelnoZUkio211PwIeFvvAeZ9RXPQnw==}
    engines: {node: '>=14.0.0'}
    cpu: [arm]
    os: [linux]
    libc: glibc
  sass-embedded-linux-musl-arm64@1.99.0:
    resolution: {integrity: sha512-Hi2bt/IrM5P4FBKz6EcHAlniwfpoz9mnTdvSd58y+avA3SANM76upIkAdSayA8ZGwyL3gZokru1AKDPF9lJDNw==}
    engines: {node: '>=14.0.0'}
    cpu: [arm64]
    os: [linux]
    libc: musl
  sass-embedded-linux-musl-arm@1.99.0:
    resolution: {integrity: sha512-2gvHOupgIw3ytatXT4nFUow71LFbuOZPEwG+HUzcNQDH8ue4Ez8cr03vsv5MDv3lIjOKcXwDvWD980t18MwkoQ==}
    engines: {node: '>=14.0.0'}
    cpu: [arm]
    os: [linux]
    libc: musl
  sass-embedded-linux-musl-riscv64@1.99.0:
    resolution: {integrity: sha512-mKqGvVaJ9rHMqyZsF0kikQe4NO0f4osb67+X6nLhBiVDKvyazQHJ3zJQreNefIE36yL2sjHIclSB//MprzaQDg==}
    engines: {node: '>=14.0.0'}
    cpu: [riscv64]
    os: [linux]
    libc: musl
  sass-embedded-linux-musl-x64@1.99.0:
    resolution: {integrity: sha512-huhgOMmOc30r7CH7qbRbT9LerSEGSnWuS4CYNOskr9BvNeQp4dIneFufNRGZ7hkOAxUM8DglxIZJN/cyAT95Ew==}
    engines: {node: '>=14.0.0'}
    cpu: [x64]
    os: [linux]
    libc: musl
  sass-embedded-linux-riscv64@1.99.0:
    resolution: {integrity: sha512-mevFPIFAVhrH90THifxLfOntFmHtcEKOcdWnep2gJ0X4DVva4AiVIRlQe/7w9JFx5+gnDRE1oaJJkzuFUuYZsA==}
    engines: {node: '>=14.0.0'}
    cpu: [riscv64]
    os: [linux]
    libc: glibc
  sass-embedded-linux-x64@1.99.0:
    resolution: {integrity: sha512-9k7IkULqIZdCIVt4Mboryt6vN8Mjmm3EhI1P3mClU5y5i3wLK5ExC3cbVWk047KsID/fvB1RLslqghXJx5BoxA==}
    engines: {node: '>=14.0.0'}
    cpu: [x64]
    os: [linux]
    libc: glibc
  sass-embedded-unknown-all@1.99.0:
    resolution: {integrity: sha512-P7MxiUtL/XzGo3PX0CaB8lNNEFLQWKikPA8pbKytx9ZCLZSDkt2NJcdAbblB/sqMs4AV3EK2NadV8rI/diq3xg==}
    os: ['!android', '!darwin', '!linux', '!win32']
  sass-embedded-win32-arm64@1.99.0:
    resolution: {integrity: sha512-8whpsW7S+uO8QApKfQuc36m3P9EISzbVZOgC79goob4qGy09u8Gz/rYvw8h1prJDSjltpHGhOzBE6LDz7WvzVw==}
    engines: {node: '>=14.0.0'}
    cpu: [arm64]
    os: [win32]
  sass-embedded-win32-x64@1.99.0:
    resolution: {integrity: sha512-ipuOv1R2K4MHeuCEAZGpuUbAgma4gb0sdacyrTjJtMOy/OY9UvWfVlwErdB09KIkp4fPDpQJDJfvYN6bC8jeNg==}
    engines: {node: '>=14.0.0'}
    cpu: [x64]
    os: [win32]
  sass-embedded@1.99.0:
    resolution: {integrity: sha512-gF/juR1aX02lZHkvwxdF80SapkQeg2fetoDF6gIQkNbSw5YEUFspMkyGTjPjgZSgIHuZpy+Wz4PlebKnLXMjdg==}
    engines: {node: '>=16.0.0'}
    hasBin: true
  sass@1.99.0:
    resolution: {integrity: sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==}
    engines: {node: '>=14.0.0'}
    hasBin: true
  saxes@6.0.0:
@@ -1638,8 +1856,20 @@
    resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
    engines: {node: '>=8'}
  supports-color@8.1.1:
    resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
    engines: {node: '>=10'}
  symbol-tree@3.2.4:
    resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
  sync-child-process@1.0.2:
    resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==}
    engines: {node: '>=16.0.0'}
  sync-message-port@1.2.0:
    resolution: {integrity: sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==}
    engines: {node: '>=16.0.0'}
  throttle-debounce@5.0.2:
    resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
@@ -1718,6 +1948,9 @@
  uri-js@4.4.1:
    resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
  varint@6.0.0:
    resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==}
  vite@8.0.3:
    resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==}
@@ -2022,6 +2255,8 @@
    dependencies:
      css-tree: 3.2.1
  '@bufbuild/protobuf@2.11.0': {}
  '@csstools/color-helpers@6.0.2': {}
  '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
@@ -2215,10 +2450,66 @@
  '@oxc-project/types@0.122.0': {}
  '@playwright/mcp@0.0.70':
  '@parcel/watcher-android-arm64@2.5.6':
    optional: true
  '@parcel/watcher-darwin-arm64@2.5.6':
    optional: true
  '@parcel/watcher-darwin-x64@2.5.6':
    optional: true
  '@parcel/watcher-freebsd-x64@2.5.6':
    optional: true
  '@parcel/watcher-linux-arm-glibc@2.5.6':
    optional: true
  '@parcel/watcher-linux-arm-musl@2.5.6':
    optional: true
  '@parcel/watcher-linux-arm64-glibc@2.5.6':
    optional: true
  '@parcel/watcher-linux-arm64-musl@2.5.6':
    optional: true
  '@parcel/watcher-linux-x64-glibc@2.5.6':
    optional: true
  '@parcel/watcher-linux-x64-musl@2.5.6':
    optional: true
  '@parcel/watcher-win32-arm64@2.5.6':
    optional: true
  '@parcel/watcher-win32-ia32@2.5.6':
    optional: true
  '@parcel/watcher-win32-x64@2.5.6':
    optional: true
  '@parcel/watcher@2.5.6':
    dependencies:
      playwright: 1.60.0-alpha-1774999321000
      playwright-core: 1.60.0-alpha-1774999321000
      detect-libc: 2.1.2
      is-glob: 4.0.3
      node-addon-api: 7.1.1
      picomatch: 4.0.4
    optionalDependencies:
      '@parcel/watcher-android-arm64': 2.5.6
      '@parcel/watcher-darwin-arm64': 2.5.6
      '@parcel/watcher-darwin-x64': 2.5.6
      '@parcel/watcher-freebsd-x64': 2.5.6
      '@parcel/watcher-linux-arm-glibc': 2.5.6
      '@parcel/watcher-linux-arm-musl': 2.5.6
      '@parcel/watcher-linux-arm64-glibc': 2.5.6
      '@parcel/watcher-linux-arm64-musl': 2.5.6
      '@parcel/watcher-linux-x64-glibc': 2.5.6
      '@parcel/watcher-linux-x64-musl': 2.5.6
      '@parcel/watcher-win32-arm64': 2.5.6
      '@parcel/watcher-win32-ia32': 2.5.6
      '@parcel/watcher-win32-x64': 2.5.6
    optional: true
  '@playwright/test@1.59.1':
    dependencies:
@@ -2777,10 +3068,10 @@
      '@typescript-eslint/types': 8.58.0
      eslint-visitor-keys: 5.0.1
  '@vitejs/plugin-react@6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0))':
  '@vitejs/plugin-react@6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(sass-embedded@1.99.0)(sass@1.99.0))':
    dependencies:
      '@rolldown/pluginutils': 1.0.0-rc.7
      vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)
      vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(sass-embedded@1.99.0)(sass@1.99.0)
  '@vitest/expect@4.1.2':
    dependencies:
@@ -2791,13 +3082,13 @@
      chai: 6.2.2
      tinyrainbow: 3.1.0
  '@vitest/mocker@4.1.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0))':
  '@vitest/mocker@4.1.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(sass-embedded@1.99.0)(sass@1.99.0))':
    dependencies:
      '@vitest/spy': 4.1.2
      estree-walker: 3.0.3
      magic-string: 0.30.21
    optionalDependencies:
      vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)
      vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(sass-embedded@1.99.0)(sass@1.99.0)
  '@vitest/pretty-format@4.1.2':
    dependencies:
@@ -2949,6 +3240,11 @@
      ansi-styles: 4.3.0
      supports-color: 7.2.0
  chokidar@4.0.3:
    dependencies:
      readdirp: 4.1.2
    optional: true
  clsx@2.1.1: {}
  color-convert@2.0.1:
@@ -2956,6 +3252,8 @@
      color-name: 1.1.4
  color-name@1.1.4: {}
  colorjs.io@0.5.2: {}
  compare-versions@6.1.1: {}
@@ -3201,6 +3499,8 @@
  ignore@7.0.5: {}
  immutable@5.1.5: {}
  import-fresh@3.3.1:
    dependencies:
      parent-module: 1.0.1
@@ -3362,6 +3662,9 @@
  natural-compare@1.4.0: {}
  node-addon-api@7.1.1:
    optional: true
  node-releases@2.0.36: {}
  obug@2.1.1: {}
@@ -3403,17 +3706,9 @@
  playwright-core@1.59.1: {}
  playwright-core@1.60.0-alpha-1774999321000: {}
  playwright@1.59.1:
    dependencies:
      playwright-core: 1.59.1
    optionalDependencies:
      fsevents: 2.3.2
  playwright@1.60.0-alpha-1774999321000:
    dependencies:
      playwright-core: 1.60.0-alpha-1774999321000
    optionalDependencies:
      fsevents: 2.3.2
@@ -3443,6 +3738,9 @@
  react-is@18.3.1: {}
  react@19.2.4: {}
  readdirp@4.1.2:
    optional: true
  redent@3.0.0:
    dependencies:
@@ -3476,6 +3774,106 @@
    transitivePeerDependencies:
      - '@emnapi/core'
      - '@emnapi/runtime'
  rxjs@7.8.2:
    dependencies:
      tslib: 2.8.1
  sass-embedded-all-unknown@1.99.0:
    dependencies:
      sass: 1.99.0
    optional: true
  sass-embedded-android-arm64@1.99.0:
    optional: true
  sass-embedded-android-arm@1.99.0:
    optional: true
  sass-embedded-android-riscv64@1.99.0:
    optional: true
  sass-embedded-android-x64@1.99.0:
    optional: true
  sass-embedded-darwin-arm64@1.99.0:
    optional: true
  sass-embedded-darwin-x64@1.99.0:
    optional: true
  sass-embedded-linux-arm64@1.99.0:
    optional: true
  sass-embedded-linux-arm@1.99.0:
    optional: true
  sass-embedded-linux-musl-arm64@1.99.0:
    optional: true
  sass-embedded-linux-musl-arm@1.99.0:
    optional: true
  sass-embedded-linux-musl-riscv64@1.99.0:
    optional: true
  sass-embedded-linux-musl-x64@1.99.0:
    optional: true
  sass-embedded-linux-riscv64@1.99.0:
    optional: true
  sass-embedded-linux-x64@1.99.0:
    optional: true
  sass-embedded-unknown-all@1.99.0:
    dependencies:
      sass: 1.99.0
    optional: true
  sass-embedded-win32-arm64@1.99.0:
    optional: true
  sass-embedded-win32-x64@1.99.0:
    optional: true
  sass-embedded@1.99.0:
    dependencies:
      '@bufbuild/protobuf': 2.11.0
      colorjs.io: 0.5.2
      immutable: 5.1.5
      rxjs: 7.8.2
      supports-color: 8.1.1
      sync-child-process: 1.0.2
      varint: 6.0.0
    optionalDependencies:
      sass-embedded-all-unknown: 1.99.0
      sass-embedded-android-arm: 1.99.0
      sass-embedded-android-arm64: 1.99.0
      sass-embedded-android-riscv64: 1.99.0
      sass-embedded-android-x64: 1.99.0
      sass-embedded-darwin-arm64: 1.99.0
      sass-embedded-darwin-x64: 1.99.0
      sass-embedded-linux-arm: 1.99.0
      sass-embedded-linux-arm64: 1.99.0
      sass-embedded-linux-musl-arm: 1.99.0
      sass-embedded-linux-musl-arm64: 1.99.0
      sass-embedded-linux-musl-riscv64: 1.99.0
      sass-embedded-linux-musl-x64: 1.99.0
      sass-embedded-linux-riscv64: 1.99.0
      sass-embedded-linux-x64: 1.99.0
      sass-embedded-unknown-all: 1.99.0
      sass-embedded-win32-arm64: 1.99.0
      sass-embedded-win32-x64: 1.99.0
  sass@1.99.0:
    dependencies:
      chokidar: 4.0.3
      immutable: 5.1.5
      source-map-js: 1.2.1
    optionalDependencies:
      '@parcel/watcher': 2.5.6
    optional: true
  saxes@6.0.0:
    dependencies:
@@ -3521,7 +3919,17 @@
    dependencies:
      has-flag: 4.0.0
  supports-color@8.1.1:
    dependencies:
      has-flag: 4.0.0
  symbol-tree@3.2.4: {}
  sync-child-process@1.0.2:
    dependencies:
      sync-message-port: 1.2.0
  sync-message-port@1.2.0: {}
  throttle-debounce@5.0.2: {}
@@ -3556,8 +3964,7 @@
  ts-pattern@5.9.0: {}
  tslib@2.8.1:
    optional: true
  tslib@2.8.1: {}
  type-check@0.4.0:
    dependencies:
@@ -3590,7 +3997,9 @@
    dependencies:
      punycode: 2.3.1
  vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0):
  varint@6.0.0: {}
  vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(sass-embedded@1.99.0)(sass@1.99.0):
    dependencies:
      lightningcss: 1.32.0
      picomatch: 4.0.4
@@ -3600,14 +4009,16 @@
    optionalDependencies:
      '@types/node': 24.12.0
      fsevents: 2.3.3
      sass: 1.99.0
      sass-embedded: 1.99.0
    transitivePeerDependencies:
      - '@emnapi/core'
      - '@emnapi/runtime'
  vitest@4.1.2(@types/node@24.12.0)(jsdom@29.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)):
  vitest@4.1.2(@types/node@24.12.0)(jsdom@29.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(sass-embedded@1.99.0)(sass@1.99.0)):
    dependencies:
      '@vitest/expect': 4.1.2
      '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0))
      '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(sass-embedded@1.99.0)(sass@1.99.0))
      '@vitest/pretty-format': 4.1.2
      '@vitest/runner': 4.1.2
      '@vitest/snapshot': 4.1.2
@@ -3624,7 +4035,7 @@
      tinyexec: 1.0.4
      tinyglobby: 0.2.15
      tinyrainbow: 3.1.0
      vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)
      vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(sass-embedded@1.99.0)(sass@1.99.0)
      why-is-node-running: 2.3.0
    optionalDependencies:
      '@types/node': 24.12.0
src/framework/sideMenu/CSideMenu.tsx
New file
@@ -0,0 +1,227 @@
import React, { useState, useCallback, useEffect } 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 } from './types';
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[];
}
/**
 * 将 MenuTree 转换为 antd Menu items 格式
 */
function convertTreeToItems(
  tree: MenuItem[],
  panesOnShelf: Array<{ key: string }> = []
): AntdMenuItem[] {
  return tree.map((node) => {
    const icon = node.type ? iconMap[node.type] || DefaultIcon : DefaultIcon;
    const isOpened = panesOnShelf.some((p) => normalizeKey(p.key) === normalizeKey(node.key));
    const item: AntdMenuItem = {
      key: normalizeKey(node.key),
      label: isOpened ? (
        <span>
          {node.label}
          <span className="c-side-menu__opened-indicator"> (已打开)</span>
        </span>
      ) : (
        node.label
      ),
      icon,
    };
    if (node.children && node.children.length > 0) {
      item.children = convertTreeToItems(node.children, panesOnShelf);
    }
    return item;
  });
}
/** 递归查找叶子节点 */
function findLeafKeys(items: AntdMenuItem[], selectedKey: string): string[] {
  const result: string[] = [];
  const findKey = (items: AntdMenuItem[], key: string): boolean => {
    for (const item of items || []) {
      if (item.key === key) {
        if (!item.children || item.children.length === 0) {
          result.push(key);
        }
        return true;
      }
      if (item.children && findKey(item.children, key)) {
        return true;
      }
    }
    return false;
  };
  findKey(items, selectedKey);
  return result;
}
export const CSideMenu: React.FC<CSideMenuProps> = (props) => {
  const {
    title,
    tree,
    collapsed,
    curActivePaneKey,
    panesOnShelf = [],
    onClickMenuItem,
    onSetMenuCollapse,
  } = props;
  const [isMobile, setIsMobile] = useState(false);
  const [openKeys, setOpenKeys] = useState<string[]>([]);
  // 监听响应式断点 - 与 antd Layout.Sider breakpoint="lg" 一致 (992px)
  useEffect(() => {
    const checkMobile = () => {
      const width = window.innerWidth;
      const screenWidth = window.screen.width;
      // 只有当视口和屏幕都比较小时才认为是移动端
      setIsMobile(width <= 992 && screenWidth <= 1024);
    };
    const mediaQuery = window.matchMedia('(max-width: 992px)');
    checkMobile(); // 初始检测
    const handler = () => {
      checkMobile();
    };
    mediaQuery.addEventListener('change', handler);
    return () => mediaQuery.removeEventListener('change', handler);
  }, []);
  // 菜单项
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const items: any[] = tree ? convertTreeToItems([tree], panesOnShelf) : [];
  // 选中的键
  const selectedKeys = curActivePaneKey ? findLeafKeys(items, normalizeKey(curActivePaneKey)) : [];
  // 全局手风琴逻辑 - 当展开一个新分支时,关闭之前展开的分支
  const handleOpenChange: MenuProps['onOpenChange'] = useCallback(
    (keys: string[]) => {
      const latestKey = keys[keys.length - 1];
      if (latestKey) {
        // 只保留最新展开的键
        setOpenKeys([latestKey]);
      } else {
        setOpenKeys([]);
      }
    },
    []
  );
  // 叶子项点击
  const handleClick: MenuProps['onClick'] = useCallback(
    ({ key }: { key: string }) => {
      if (!tree) return;
      // 递归查找被点击的菜单项
      const findItem = (nodes: MenuItem[], k: string): MenuItem | null => {
        for (const node of nodes) {
          if (normalizeKey(node.key) === k) return node;
          if (node.children) {
            const found = findItem(node.children, k);
            if (found) return found;
          }
        }
        return null;
      };
      const clickedItem = findItem([tree], key);
      if (clickedItem) {
        // 移动端:先收起,延迟后再导航
        if (isMobile) {
          setTimeout(() => {
            onClickMenuItem(clickedItem);
          }, 300);
        } else {
          onClickMenuItem(clickedItem);
        }
      }
    },
    [tree, isMobile, onClickMenuItem]
  );
  // 空树处理
  if (!tree) {
    return (
      <div className="c-side-menu">
        <div className="c-side-menu__header">
          <h1 className="c-side-menu__title">{title}</h1>
        </div>
        <div className="c-side-menu__empty">暂无菜单</div>
      </div>
    );
  }
  // 移动端遮罩点击关闭
  const handleOverlayClick = () => {
    onSetMenuCollapse(true);
  };
  const menuContent = (
    <>
      <div className="c-side-menu__header">
        <h1 className="c-side-menu__title">{title}</h1>
      </div>
      <Menu
        className="c-side-menu__menu"
        mode="inline"
        inlineCollapsed={isMobile ? false : collapsed}
        items={items}
        selectedKeys={selectedKeys}
        openKeys={openKeys}
        onOpenChange={handleOpenChange}
        onClick={handleClick}
      />
    </>
  );
  // PC 端 - 禁用 breakpoint,响应式由外部控制
  if (!isMobile) {
    return (
      <Layout.Sider className="c-side-menu" collapsedWidth={0} width={200}>
        {menuContent}
      </Layout.Sider>
    );
  }
  // 移动端 - 使用外部传入的 collapsed 控制显示
  return (
    <>
      <div className={`c-side-menu c-side-menu--mobile ${!collapsed ? 'c-side-menu--open' : ''}`}>
        {menuContent}
      </div>
      {!collapsed && <div className="c-side-menu__overlay" onClick={handleOverlayClick} />}
    </>
  );
};
src/framework/sideMenu/cSideMenu.scss
New file
@@ -0,0 +1,53 @@
.c-side-menu {
  &__header {
    padding: 16px;
  }
  &__title {
    margin: 0;
    font-size: 16px;
    font-weight: 600;
  }
  &__menu {
    border-right: none;
  }
  &__empty {
    padding: 16px;
    color: #999;
  }
  &__opened-indicator {
    color: #52c41a;
    font-size: 12px;
  }
  // 移动端样式
  &--mobile {
    position: fixed;
    top: 0;
    left: 0;
    z-index: 1000;
    width: 200px;
    height: 100vh;
    background: #fff;
    transform: translateX(-100%);
    transition: transform 0.3s ease;
    box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
    &--open {
      transform: translateX(0);
    }
  }
  &__overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 999;
    background: rgba(0, 0, 0, 0.5);
  }
}
src/framework/sideMenu/types.ts
New file
@@ -0,0 +1,42 @@
/**
 * 菜单树节点
 */
export interface MenuItem {
  /** 唯一标识 (string | number) */
  key: string | number;
  /** 显示文本 */
  label: string;
  /** 子菜单/页面 */
  children?: MenuItem[];
  /** 页面路径 */
  path?: string;
  /** 页面名称 */
  pageName?: string;
  /** 图标类型 */
  type?: 'chart' | 'setting' | 'folder' | 'file';
}
/**
 * 菜单树结构
 */
export interface MenuTree extends MenuItem {}
/**
 * CSideMenu 组件属性
 */
export interface CSideMenuProps {
  /** 顶区标题 */
  title: string;
  /** 合并后的菜单树 */
  tree?: MenuTree;
  /** 是否收起 */
  collapsed: boolean;
  /** 当前选中键 */
  curActivePaneKey?: string | number;
  /** 已打开页签列表 */
  panesOnShelf?: Array<{ key: string }>;
  /** 点击叶子菜单项回调 */
  onClickMenuItem: (item: MenuItem) => void;
  /** 设置折叠状态回调 */
  onSetMenuCollapse: (collapsed: boolean | void) => void;
}
src/index.ts
New file
@@ -0,0 +1 @@
export { CSideMenu } from './framework/sideMenu/CSideMenu';
test/e2e/side-menu.spec.ts
New file
@@ -0,0 +1,28 @@
import { test, expect } from '@playwright/test';
test.describe('CSideMenu 组件', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:5173/#/preview/pages/side-menu/SideMenuPage.tsx');
  });
  test('页面加载正常', async ({ page }) => {
    // 等待页面加载
    await page.waitForSelector('text=CSideMenu 组件示例', { timeout: 10000 });
    // 验证标题
    await expect(page.locator('text=CSideMenu 组件示例')).toBeVisible();
  });
  test('左侧菜单显示正常', async ({ page }) => {
    // 验证菜单标题
    await expect(page.locator('text=管理后台')).toBeVisible();
    // 验证菜单项
    await expect(page.locator('text=导航1')).toBeVisible();
  });
  test('菜单点击功能正常', async ({ page }) => {
    // 点击子菜单
    await page.click('text=子菜单1-1');
    // 验证页面1-1-1 显示
    await expect(page.locator('text=页面1-1-1')).toBeVisible();
  });
});
test/unit/CSideMenu.test.tsx
New file
@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } 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: false,
    media: query,
    onchange: null,
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});
// Mock antd Menu component
vi.mock('antd', async () => {
  const actual = await vi.importActual('antd');
  return {
    ...actual,
    Menu: vi.fn(({ items, onClick, selectedKeys, openKeys, onOpenChange, children }) => {
      // Render items as a nested structure for testing
      const renderItems = (items: any[]): React.ReactNode =>
        items?.map((item) => (
          <div key={item.key} data-testid={`menu-item-${item.key}`}>
            {item.label}
            {item.children && <div className="submenu">{renderItems(item.children)}</div>}
          </div>
        ));
      return <div data-testid="antd-menu">{items ? renderItems(items) : children}</div>;
    }),
    Layout: {
      Sider: vi.fn(({ children, breakpoint, onBreakpointChange }) => children),
    },
  };
});
// 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>,
  RightOutlined: () => <span data-testid="icon-right">RightIcon</span>,
}));
describe('CSideMenu 组件', () => {
  const mockOnClickMenuItem = vi.fn();
  const mockOnSetMenuCollapse = vi.fn();
  const basicTree = {
    key: '1',
    label: '导航1',
    children: [
      {
        key: '1-1',
        label: '子菜单1-1',
        children: [
          { key: '1-1-1', label: '页面1-1-1', path: '/page1-1-1', pageName: 'Page111' },
          { key: '1-1-2', label: '页面1-1-2', path: '/page1-1-2', pageName: 'Page112' },
        ],
      },
      {
        key: '1-2',
        label: '页面1-2',
        path: '/page1-2',
        pageName: 'Page12',
      },
    ],
  };
  beforeEach(() => {
    vi.clearAllMocks();
  });
  describe('基础渲染', () => {
    it('1.1 渲染空树时不报错', () => {
      render(
        <CSideMenu
          title="测试标题"
          tree={undefined as any}
          collapsed={false}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      expect(screen.getByText('测试标题')).toBeDefined();
    });
    it('1.2 渲染标题', () => {
      render(
        <CSideMenu
          title="我的侧边栏"
          tree={basicTree}
          collapsed={false}
          onClickMenuItem={mockOnClickMenuItem}
          onSetMenuCollapse={mockOnSetMenuCollapse}
        />
      );
      expect(screen.getByText('我的侧边栏')).toBeDefined();
    });
  });
  describe('三级菜单渲染', () => {
    it('3.1 渲染三级菜单结构', () => {
      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();
    });
  });
});
tsconfig.app.json
@@ -1,28 +1,28 @@
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "ES2023",
    "useDefineForClassFields": true,
    "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "types": ["vite/client"],
    "skipLibCheck": true,
    "compilerOptions": {
        "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
        "target": "ES2023",
        "useDefineForClassFields": true,
        "lib": ["ES2023", "DOM", "DOM.Iterable"],
        "module": "ESNext",
        "types": ["vite/client"],
        "skipLibCheck": true,
    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
        /* Bundler mode */
        "moduleResolution": "bundler",
        "allowImportingTsExtensions": true,
        "verbatimModuleSyntax": true,
        "moduleDetection": "force",
        "noEmit": true,
        "jsx": "react-jsx",
    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["src"]
        /* Linting */
        "strict": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "erasableSyntaxOnly": true,
        "noFallthroughCasesInSwitch": true,
        "noUncheckedSideEffectImports": true
    },
    "include": ["src", "example", "test"]
}