8 files modified
10 files added
| | |
| | | { |
| | | "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/**)"] |
| | | } |
| | | } |
| New file |
| | |
| | | --- |
| | | 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` |
| New file |
| | |
| | | # 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() |
| | | }) |
| | | ``` |
| | |
| | | *.njsproj |
| | | *.sln |
| | | *.sw? |
| | | test-results/ |
| New file |
| | |
| | | { |
| | | "printWidth": 90, |
| | | "useTabs": false, |
| | | "tabWidth": 4, |
| | | "trailingComma": "all", |
| | | "bracketSpacing": true, |
| | | "singleQuote": true, |
| | | "arrowParens": "avoid", |
| | | "semi": true, |
| | | "overrides": [ |
| | | { |
| | | "files": "*.js", |
| | | "options": { |
| | | "printWidth": 100 |
| | | } |
| | | } |
| | | ] |
| | | } |
| | |
| | | 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> |
| | | ); |
| | | } |
| | |
| | | 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 />); |
| | | } |
| New file |
| | |
| | | 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; |
| | |
| | | ## 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` 单元测试 |
| | |
| | | }, |
| | | "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", |
| | |
| | | "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", |
| | |
| | | '@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 |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | 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: |
| | | |
| | |
| | | '@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==} |
| | |
| | | '@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==} |
| | |
| | | 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'} |
| | |
| | | |
| | | 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==} |
| | |
| | | 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'} |
| | |
| | | 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==} |
| | | |
| | |
| | | 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 |
| | | |
| | |
| | | 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'} |
| | |
| | | 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: |
| | |
| | | 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==} |
| | |
| | | |
| | | 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==} |
| | |
| | | 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)': |
| | |
| | | |
| | | '@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: |
| | |
| | | '@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: |
| | |
| | | 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: |
| | |
| | | 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: |
| | |
| | | color-name: 1.1.4 |
| | | |
| | | color-name@1.1.4: {} |
| | | |
| | | colorjs.io@0.5.2: {} |
| | | |
| | | compare-versions@6.1.1: {} |
| | | |
| | |
| | | |
| | | ignore@7.0.5: {} |
| | | |
| | | immutable@5.1.5: {} |
| | | |
| | | import-fresh@3.3.1: |
| | | dependencies: |
| | | parent-module: 1.0.1 |
| | |
| | | |
| | | natural-compare@1.4.0: {} |
| | | |
| | | node-addon-api@7.1.1: |
| | | optional: true |
| | | |
| | | node-releases@2.0.36: {} |
| | | |
| | | obug@2.1.1: {} |
| | |
| | | |
| | | 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 |
| | | |
| | |
| | | react-is@18.3.1: {} |
| | | |
| | | react@19.2.4: {} |
| | | |
| | | readdirp@4.1.2: |
| | | optional: true |
| | | |
| | | redent@3.0.0: |
| | | dependencies: |
| | |
| | | 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: |
| | |
| | | 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: {} |
| | | |
| | |
| | | |
| | | ts-pattern@5.9.0: {} |
| | | |
| | | tslib@2.8.1: |
| | | optional: true |
| | | tslib@2.8.1: {} |
| | | |
| | | type-check@0.4.0: |
| | | dependencies: |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | 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 |
| New file |
| | |
| | | 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} />} |
| | | </> |
| | | ); |
| | | }; |
| New file |
| | |
| | | .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); |
| | | } |
| | | } |
| New file |
| | |
| | | /** |
| | | * 菜单树节点 |
| | | */ |
| | | 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; |
| | | } |
| New file |
| | |
| | | export { CSideMenu } from './framework/sideMenu/CSideMenu'; |
| New file |
| | |
| | | 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(); |
| | | }); |
| | | }); |
| New file |
| | |
| | | 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(); |
| | | }); |
| | | }); |
| | | }); |
| | |
| | | { |
| | | "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"] |
| | | } |