diff --git a/CHANGELOG.md b/CHANGELOG.md index a1134111..0c811f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ## [未发布] +## [0.9.3] - 2026-09-08 + +- 重构绿色主工作台:紧凑全局入口、可收起资源/上下文侧栏,统一状态、仿真/地图工具、草稿、摄像头与风险槽位;窄屏临时布局不覆盖桌面偏好。 +- 逐模块整理地图、Body/Joint/Actuator、Python/ONNX、训练与录制的信息层级,保留单位、主动作、兼容限制及危险后果;控制台首次懒挂载后保留输入与任务状态。 +- 独立调参改为指标/排行主区和会话/决策侧展,保留审批、Monaco 差异、参数护栏及停止入口;主工作台/调参共享深浅主题,换主题不丢图表缩放或编辑草稿。 +- 统一 Tooltip/Popover/对话框边界、全屏 Portal 与顶层 Escape;修复 Dialog 焦点恢复、Monaco 模型释放顺序、摄像头实际渲染槽位,以及浅色主按钮对比色被共享 CSS 覆盖的问题。 +- 补深浅主题五档布局、领域/调参/系统截图和交互回归,同步设计文档及旧 E2E 入口。训练/调参布局采用 mock;真实训练、Agent、外部真实策略/上传服务和长期性能不作为本次 UI 验收通过项。 + ## [0.9.2] - 2026-09-08 - 修复custom_boxes因起终点编辑使同步状态持续失效而无法训练:移除训练面板起终点坐标显示与编辑,训练/调参启动时自动重新编译已应用碰撞场景,并自动选择满足净空、最小距离和连通性约束的参考点。 diff --git a/package-lock.json b/package-lock.json index 5940c3a0..c24f7520 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mujoco-web-platform", - "version": "0.9.2", + "version": "0.9.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mujoco-web-platform", - "version": "0.9.2", + "version": "0.9.3", "license": "Apache-2.0", "dependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/package.json b/package.json index 6271485b..9ac2bbd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mujoco-web-platform", - "version": "0.9.2", + "version": "0.9.3", "description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台", "private": true, "type": "module", diff --git a/web_platform/e2e/app.spec.ts b/web_platform/e2e/app.spec.ts index 74822e3c..1898b7c9 100644 --- a/web_platform/e2e/app.spec.ts +++ b/web_platform/e2e/app.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { zipSync } from 'fflate'; @@ -6,6 +6,33 @@ import { zipSync } from 'fflate'; const fixture = (relative: string) => fileURLToPath(new URL(`../fixtures/${relative}`, import.meta.url)); +async function openProjectPanel(page: Page) { + const show = page.getByRole('button', { name: '显示工程面板', exact: true }); + if (await show.isVisible()) await show.click(); +} + +async function expectImportedFiles(page: Page, names: string[]) { + await openProjectPanel(page); + await page.getByRole('tab', { name: '工程文件', exact: true }).click(); + const tree = page.getByLabel('工程文件树', { exact: true }); + for (const name of names) await expect(tree.getByText(name, { exact: true })).toBeVisible(); + await page.screenshot({ path: test.info().outputPath('imported-project-files.png') }); +} + +async function expectNotificationDetails( + page: Page, + messages: RegExp[], + absentMessages: RegExp[] = [], +) { + await page.getByRole('button', { name: '通知中心' }).click(); + const notifications = page.getByRole('dialog', { name: '通知中心' }); + for (const summary of await notifications.getByText('事件详情', { exact: true }).all()) + await summary.click(); + for (const message of messages) await expect(notifications).toContainText(message); + for (const message of absentMessages) await expect(notifications).not.toContainText(message); + await page.keyboard.press('Escape'); +} + const SIMPLE_MODEL = ` @@ -79,7 +106,7 @@ const LARGE_MODEL = ` test('独立自调参工作台不需要加载 MuJoCo 主应用即可打开', async ({ page }) => { await page.goto('/tuning.html'); - await expect(page.getByRole('heading', { name: 'Go2 奖励函数自调参 Agent' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Go2 自调参' })).toBeVisible(); await expect(page.getByText('新建 Unitree-Go2-Flat 调参 Session')).toBeVisible(); await expect(page.getByLabel('访问令牌(仅当前标签页)')).toBeVisible(); await expect(page.getByRole('button', { name: '启动自调参' })).toBeVisible(); @@ -102,20 +129,23 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => { const resetCameraBox = await page.getByRole('button', { name: '相机复位' }).boundingBox(), playBox = await page.getByRole('button', { name: '▶ 播放' }).boundingBox(); expect( - resetCameraBox && playBox && resetCameraBox.x + resetCameraBox.width <= playBox.x, + resetCameraBox && playBox && resetCameraBox.y + resetCameraBox.height < playBox.y, ).toBeTruthy(); await page.getByRole('button', { name: '更多工作台操作' }).click(); await expect(page.getByRole('menuitem', { name: '工作台设置' })).toBeVisible(); await page.keyboard.press('Escape'); await page.setViewportSize({ width: 1440, height: 900 }); - await expect(page.getByRole('heading', { name: 'MuJoCo Web 仿真平台' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'MuJoCo' })).toBeVisible(); await expect(page.getByRole('main').getByText('拖放模型工程到此处')).toBeVisible(); await expect(page.getByRole('img', { name: 'XYZ 方向指示器' })).toBeVisible(); - await expect(page.getByRole('button', { name: '切换到白天主题' })).toBeVisible(); + await page.getByRole('button', { name: '更多工作台操作' }).click(); + await expect(page.getByRole('menuitem', { name: '切换主题' })).toBeVisible(); + await page.keyboard.press('Escape'); await page.getByRole('button', { name: '布局设置' }).click(); await expect(page.getByRole('dialog', { name: '布局设置' })).toBeVisible(); await page.keyboard.press('Escape'); - await page.getByRole('button', { name: '工作台设置' }).click(); + await page.getByRole('button', { name: '更多工作台操作' }).click(); + await page.getByRole('menuitem', { name: '工作台设置' }).click(); await expect(page.getByRole('dialog', { name: '工作台设置' })).toBeVisible(); await page.keyboard.press('Escape'); await page.keyboard.press('Control+k'); @@ -123,16 +153,20 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => { await page.getByLabel('搜索命令').fill('复位相机'); await expect(page.getByRole('option', { name: /复位相机/ })).toBeVisible(); await page.keyboard.press('Escape'); - await page.getByRole('button', { name: '进入全屏' }).click(); - await expect(page.getByRole('button', { name: '退出全屏' })).toBeVisible(); + await page.getByRole('button', { name: '更多工作台操作' }).click(); + await page.getByRole('menuitem', { name: '进入全屏' }).click(); + await expect.poll(() => page.evaluate(() => Boolean(document.fullscreenElement))).toBe(true); await page.keyboard.press('Control+k'); await expect(page.getByRole('dialog', { name: '命令面板' })).toBeVisible(); await page.keyboard.press('Escape'); - await page.getByRole('button', { name: '退出全屏' }).click(); - await page.getByRole('button', { name: '切换到白天主题' }).click(); + await page.getByRole('button', { name: '更多工作台操作' }).click(); + await page.getByRole('menuitem', { name: '退出全屏' }).click(); + await page.getByRole('button', { name: '更多工作台操作' }).click(); + await page.getByRole('menuitem', { name: '切换主题' }).click(); await expect(page.locator('#root > div')).toHaveClass(/theme-light/); - await expect(page.getByRole('button', { name: '切换到黑夜主题' })).toBeVisible(); - await page.getByRole('button', { name: '切换到黑夜主题' }).click(); + await expect(page.locator('#root > div')).toHaveClass(/theme-light/); + await page.getByRole('button', { name: '更多工作台操作' }).click(); + await page.getByRole('menuitem', { name: '切换主题' }).click(); await expect(page.locator('#root > div')).toHaveClass(/theme-dark/); await page @@ -144,7 +178,7 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => { buffer: Buffer.from(SIMPLE_MODEL), }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('button', { name: '显示设置' }).click(); const displayDialog = page.getByRole('dialog', { name: '视图显示设置' }); @@ -160,8 +194,9 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => { await page.getByText('事件日志').click(); await expect(page.getByRole('dialog', { name: '诊断与事件日志' })).toBeVisible(); await page.keyboard.press('Escape'); - await page.getByRole('button', { name: /FPS .*物理/ }).click(); + await page.getByRole('button', { name: /FPS \d/ }).click(); await expect(page.getByRole('dialog', { name: '性能详情' })).toBeVisible(); + await expect(page.getByRole('dialog', { name: '性能详情' })).toContainText('WASM已加载'); await page.keyboard.press('Escape'); await page.getByRole('tab', { name: '控制台' }).click(); const tools = page.getByRole('tabpanel', { name: '控制台' }); @@ -171,7 +206,7 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => { await expect(tools.getByText('motor', { exact: true })).toBeVisible(); await expect(tools.getByText('关节:slide', { exact: true })).toBeVisible(); await page.getByRole('tab', { name: '数据录制' }).click(); - await expect(page.getByRole('tabpanel', { name: '数据录制' })).toContainText('仿真遥测记录'); + await expect(page.getByRole('tabpanel', { name: '数据录制' })).toContainText('仅当前会话保存'); await expect(page.locator('main canvas')).toBeVisible(); await page.getByRole('tab', { name: '检查器' }).click(); const structure = page.getByRole('navigation', { name: '模型结构树' }); @@ -192,9 +227,9 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => { 'aria-pressed', 'true', ); - await expect(page.getByText('已忽略').first()).toBeVisible(); + await expect(page.getByText('已忽略关节限位').first()).toBeVisible(); await page.getByRole('button', { name: '重置关节' }).click(); - await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible(); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); }); test('窄视口默认保留完整视口并可按需打开侧栏', async ({ page }) => { @@ -204,9 +239,7 @@ test('窄视口默认保留完整视口并可按需打开侧栏', async ({ page await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible(); await expect(page.getByRole('button', { name: '显示右侧面板' })).toBeVisible(); await page.getByRole('button', { name: '显示右侧面板' }).click(); - await expect( - page.getByRole('complementary').filter({ hasText: '导入模型后显示检查器' }), - ).toBeVisible(); + await expect(page.getByRole('complementary').filter({ hasText: '检查器待命' })).toBeVisible(); }); test('工作区布局与视口显示偏好在刷新后保留', async ({ page }) => { @@ -230,7 +263,8 @@ test('转换后的 MJCF 可编辑并重新载入', async ({ page }) => { .locator('input[type="file"]') .first() .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); + await page.getByRole('button', { name: '工程', exact: true }).click(); await page.getByRole('button', { name: '源代码' }).click(); const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' }); await expect(dialog).toBeVisible(); @@ -245,7 +279,7 @@ test('转换后的 MJCF 可编辑并重新载入', async ({ page }) => { timeout: 30_000, }); await dialog.getByRole('button', { name: '关闭源代码编辑器' }).click(); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await expect(page.getByRole('button', { name: '导出 URDF' })).toHaveCount(0); await expect(page.getByRole('button', { name: '导出 MJCF' })).toHaveCount(0); }); @@ -256,7 +290,8 @@ test('关闭已修改的 MJCF 前要求确认', async ({ page }) => { .locator('input[type="file"]') .first() .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); + await page.getByRole('button', { name: '工程', exact: true }).click(); await page.getByRole('button', { name: '源代码' }).click(); const editorDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' }); await editorDialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } }); @@ -287,8 +322,14 @@ test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({ page }) => { fixture('mjcf_include/triangle.stl'), fixture('mjcf_include/checker.png'), ]); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); - await expect(page.getByText('5 个文件')).toBeVisible(); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); + await expectImportedFiles(page, [ + 'model.xml', + 'world.xml', + 'triangle.obj', + 'triangle.stl', + 'checker.png', + ]); }); test('加载引用 OBJ 的 URDF 工程', async ({ page }) => { @@ -301,16 +342,19 @@ test('加载引用 OBJ 的 URDF 工程', async ({ page }) => { await expect(options.getByRole('checkbox', { name: /为关节添加驱动器/ })).toBeChecked(); await expect(options.getByRole('checkbox', { name: /添加传感器/ })).toBeChecked(); await options.getByRole('button', { name: '转换并加载' }).click(); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); - await expect(page.getByText('2 个文件')).toBeVisible(); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); + await expectImportedFiles(page, ['robot.urdf', 'triangle.obj']); await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf'); await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating'); await page.getByRole('button', { name: '通知中心' }).click(); const notifications = page.getByRole('dialog', { name: '通知中心' }); + for (const summary of await notifications.getByText('事件详情', { exact: true }).all()) + await summary.click(); await expect(notifications).toContainText(/模型已加载 · \d+ 项兼容调整/); await expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/); await page.keyboard.press('Escape'); await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked(); + await page.getByRole('button', { name: '工程', exact: true }).click(); await page.getByRole('button', { name: '源代码' }).click(); const sourceDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' }); await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible(); @@ -321,7 +365,7 @@ test('加载引用 OBJ 的 URDF 工程', async ({ page }) => { await expect( sourceDialog.getByRole('button', { name: '保存并重新载入', exact: true }), ).toBeDisabled({ timeout: 30_000 }); - await expect(page.getByText('WASM 已加载')).toBeVisible(); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); await sourceDialog.getByRole('button', { name: '关闭源代码编辑器' }).click(); }); @@ -340,13 +384,15 @@ test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译', a .getByRole('dialog', { name: '配置 URDF 仿真组件' }) .getByRole('button', { name: '转换并加载' }) .click(); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await expect(page.getByLabel('摄像头画面')).toBeVisible(); await page.getByRole('button', { name: '隐藏画面' }).click(); await page.getByRole('button', { name: '显示摄像头画面' }).click(); await expect(page.getByLabel('摄像头画面')).toBeVisible(); await page.getByRole('button', { name: '通知中心' }).click(); const notifications = page.getByRole('dialog', { name: '通知中心' }); + for (const summary of await notifications.getByText('事件详情', { exact: true }).all()) + await summary.click(); await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器'); await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm'); await page.keyboard.press('Escape'); @@ -357,8 +403,8 @@ test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译', a .click(); await expect(page.getByText('shoulder_motor')).toBeVisible(); await expect(page.getByText('关节:shoulder')).toBeVisible(); - await expect(page.getByText('N·m', { exact: true })).toBeVisible(); - await page.getByText('常用参数').click(); + await expect(page.locator('output').filter({ hasText: 'N·m' })).toBeVisible(); + await page.getByText('增益与输出限幅').click(); const kp = page.getByLabel(/kp(MJCF stiffness/), kv = page.getByLabel(/kv(MJCF damping/); await kp.fill('150'); @@ -387,7 +433,8 @@ test('转换后的 MJCF 保存时保留 DAE 转换缓存资源', async ({ page } .getByRole('dialog', { name: '配置 URDF 仿真组件' }) .getByRole('button', { name: '转换并加载' }) .click(); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); + await page.getByRole('button', { name: '工程', exact: true }).click(); await page.getByRole('button', { name: '源代码' }).click(); const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' }); await dialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } }); @@ -397,7 +444,7 @@ test('转换后的 MJCF 保存时保留 DAE 转换缓存资源', async ({ page } await expect(dialog.getByRole('button', { name: '保存并重新载入', exact: true })).toBeDisabled({ timeout: 30_000, }); - await expect(page.getByText('WASM 已加载')).toBeVisible(); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); await expect(page.getByText('模型编译失败')).toHaveCount(0); }); @@ -411,7 +458,7 @@ test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加', async ({ pa mimeType: 'text/xml', buffer: Buffer.from(SLIDE_DIRECTION_MODEL), }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('button', { name: '关节拖动' }).click(); const canvas = page.locator('main canvas').first(), box = await canvas.boundingBox(); @@ -439,7 +486,7 @@ test('可导入并启用 Python 控制器', async ({ page }) => { .locator('input[type="file"]') .first() .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('tab', { name: '控制台' }).click(); await page .getByRole('tabpanel', { name: '控制台' }) @@ -450,11 +497,13 @@ test('可导入并启用 Python 控制器', async ({ page }) => { .locator('input[accept=".py,text/x-python"]') .setInputFiles({ name: 'balance.py', mimeType: 'text/x-python', buffer: Buffer.from(python) }); await expect(page.getByText('测试 PD 控制器', { exact: true })).toBeVisible({ timeout: 30_000 }); + await page.getByText('脚本详情', { exact: true }).click(); await expect(page.getByText('Python / Pyodide')).toBeVisible(); await page.getByRole('button', { name: '启用', exact: true }).click(); await expect( page.getByRole('tabpanel', { name: '控制台' }).getByRole('button', { name: /Python 脚本控制/ }), ).toContainText('运行'); + await page.screenshot({ path: test.info().outputPath('python-running.png') }); }); test('中等规模模型持续步进并可重复加载', async ({ page }) => { @@ -462,30 +511,33 @@ test('中等规模模型持续步进并可重复加载', async ({ page }) => { const input = page.locator('input[type="file"]').first(); const modelFile = { name: 'large.xml', mimeType: 'text/xml', buffer: Buffer.from(LARGE_MODEL) }; await input.setInputFiles(modelFile); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('button', { name: '▶ 播放' }).click(); await page.waitForTimeout(2_000); - await expect(page.locator('footer')).not.toContainText('时间 0.000 s'); + await expect(page.getByLabel('视口状态')).not.toContainText('时间 0.000 s'); // 播放过程中重置必须同时暂停底层会话,之后仍可正常播放和暂停。 await page.getByRole('button', { name: '重置', exact: true }).click(); - await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible(); - await expect(page.locator('footer')).toContainText('时间 0.000 s'); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); + await expect(page.getByLabel('视口状态')).toContainText('时间 0.000 s'); await page.getByRole('button', { name: '▶ 播放' }).click(); await page.waitForTimeout(500); await page.getByRole('button', { name: '⏸ 暂停' }).click(); await page.waitForTimeout(200); - const pausedTime = (await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1]; + const pausedTime = (await page.getByLabel('视口状态').innerText()).match(/时间 ([\d.]+) s/)?.[1]; expect(Number(pausedTime)).toBeGreaterThan(0); await page.waitForTimeout(500); - expect((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1]).toBe(pausedTime); + expect((await page.getByLabel('视口状态').innerText()).match(/时间 ([\d.]+) s/)?.[1]).toBe( + pausedTime, + ); await input.setInputFiles(modelFile); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await expect(page.getByRole('alert')).toHaveCount(0); }); test('认证资产可点击创建场景并拖到画布落位', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); await page.goto('/'); await page .locator('input[type="file"]') @@ -521,9 +573,11 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page }) const beforeRotation = await gizmoLine.getAttribute('x2'); const canvasBox = await page.locator('main canvas').first().boundingBox(); expect(canvasBox).not.toBeNull(); - await page.mouse.move(canvasBox!.x + 24, canvasBox!.y + 24); + // 左上现在是可交互状态槽位;从画布空白中部拖动,继续验证真实 OrbitControls。 + const dragY = canvasBox!.y + canvasBox!.height * 0.35; + await page.mouse.move(canvasBox!.x + 24, dragY); await page.mouse.down({ button: 'left' }); - await page.mouse.move(canvasBox!.x + 104, canvasBox!.y + 50, { steps: 8 }); + await page.mouse.move(canvasBox!.x + 104, dragY + 26, { steps: 8 }); await page.mouse.up({ button: 'left' }); await expect.poll(() => gizmoLine.getAttribute('x2')).not.toBe(beforeRotation); await expect(page.getByLabel('对象名称')).toHaveCount(0); @@ -545,16 +599,16 @@ test('认证资产自动打开地图属性并与参数地形一次编译', async const library = page.getByLabel('地图资产库'); await library.getByRole('button', { name: '添加基础方盒' }).click(); - await expect(page.getByText('Map / Object')).toBeVisible(); + await expect(page.getByLabel('地图物体检查器')).toBeVisible(); await expect(page.getByLabel('地图物体检查器')).toBeVisible(); await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); const sceneTree = page.getByLabel('场景资产树'); await expect(sceneTree.getByText('基础方盒')).toBeVisible(); await sceneTree.getByRole('treeitem', { name: 'box', exact: true }).click(); - await expect(page.getByText('Robot / Body')).toBeVisible(); + await expect(page.getByText(/^Body #/)).toBeVisible(); await sceneTree.getByRole('treeitem', { name: /基础方盒/ }).click(); - await expect(page.getByText('Map / Object')).toBeVisible(); + await expect(page.getByLabel('地图物体检查器')).toBeVisible(); await library.getByRole('button', { name: '添加随机粗糙地形' }).click(); await page.getByLabel('位置 X(m)').fill('4'); @@ -585,8 +639,13 @@ test('认证资产自动打开地图属性并与参数地形一次编译', async await page.getByRole('button', { name: '应用场景' }).click(); await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 }); - await expect(page.getByText(/已加载工程地图“场景 1”(1 个物理几何/)).toBeVisible(); - await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible(); + await page.getByRole('button', { name: '通知中心' }).click(); + const notifications = page.getByRole('dialog', { name: '通知中心' }); + for (const summary of await notifications.getByText('事件详情', { exact: true }).all()) + await summary.click(); + await expect(notifications).toContainText(/已加载工程地图“场景 1”(1 个物理几何/); + await expect(notifications).toContainText(/已加载随机粗糙地形物理地图/); + await page.keyboard.press('Escape'); await expect(page.getByLabel('地图来源')).toHaveValue('builtin'); // 当前仍选中参数地形时,直接点已编译的认证资产也必须反查场景与对象,首次点击即挂载操纵器。 @@ -682,8 +741,7 @@ test('工程地图与参数地形共享放置草稿、实例变换和回滚入 await expect(page.getByText('2 项场景更改待应用')).toBeVisible(); await page.getByRole('button', { name: '应用场景' }).click(); await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 }); - await expect(page.getByText(/已加载工程地图“草稿仓库”/)).toBeVisible(); - await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible(); + await expectNotificationDetails(page, [/已加载工程地图“草稿仓库”/, /已加载波浪地形物理地图/]); await page.getByRole('button', { name: '删除地图实例 草稿仓库' }).click(); await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); @@ -739,23 +797,26 @@ test('参数化地形可连续拖到画布并一次性编译', async ({ page }) await expect(page.getByLabel('场景资产树')).toContainText('波浪地形'); await page.getByLabel('场景资产树').getByRole('treeitem', { name: 'box' }).click(); - await expect(page.getByText('Robot / Body')).toBeVisible(); + await expect(page.getByText(/^Body #/)).toBeVisible(); const canvasBox = await page.locator('main canvas').first().boundingBox(); expect(canvasBox).not.toBeNull(); await page.mouse.click( canvasBox!.x + canvasBox!.width * 0.5, canvasBox!.y + canvasBox!.height * 0.78, ); - await expect(page.getByText('Map / Instance')).toBeVisible(); + await expect(page.getByText('参数化地形')).toBeVisible(); await expect(page.getByLabel('地图视口工具')).toBeVisible(); - await expect(page.getByText('地图与对象属性')).toBeVisible(); + await expect(page.getByLabel('物理地图预设')).toBeVisible(); await page.getByRole('button', { name: '应用场景' }).click(); await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 }); - await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible({ - timeout: 30_000, - }); - await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible(); + await page.getByRole('button', { name: '通知中心' }).click(); + const notifications = page.getByRole('dialog', { name: '通知中心' }); + for (const summary of await notifications.getByText('事件详情', { exact: true }).all()) + await summary.click(); + await expect(notifications).toContainText(/已加载随机粗糙地形物理地图/); + await expect(notifications).toContainText(/已加载波浪地形物理地图/); + await page.keyboard.press('Escape'); }); test('应用内置 MJCF 楼梯物理地图', async ({ page }) => { @@ -769,13 +830,14 @@ test('应用内置 MJCF 楼梯物理地图', async ({ page }) => { mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL), }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); - await page.getByLabel('地图来源').selectOption('builtin'); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); + await page.getByLabel('地图资产库').getByRole('button', { name: '添加波浪地形' }).click(); await page.getByLabel('物理地图预设').selectOption('stairs'); await page.getByLabel('台阶数量').fill('6'); await page.getByRole('button', { name: '应用并重新编译' }).click(); - await expect(page.getByText(/已加载楼梯物理地图/)).toBeVisible({ timeout: 30_000 }); - await expect(page.getByText('WASM 已加载')).toBeVisible(); + await expect(page.getByLabel('地图草稿状态')).toBeHidden({ timeout: 30_000 }); + await expectNotificationDetails(page, [/已加载楼梯物理地图/]); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); }); test('依次应用全部系统参数化地形', async ({ page }) => { @@ -800,19 +862,18 @@ test('依次应用全部系统参数化地形', async ({ page }) => { mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL), }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); - await page.getByLabel('地图来源').selectOption('builtin'); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); + await page.getByLabel('地图资产库').getByRole('button', { name: '添加波浪地形' }).click(); for (const [preset, label] of terrains) { await page.getByLabel('物理地图预设').selectOption(preset); await page.getByLabel('地形边长(m)').fill('6'); if (preset === 'rough' || preset === 'wave') await page.getByLabel('水平采样间距(m)').fill('0.25'); await page.getByRole('button', { name: '应用并重新编译' }).click(); - await expect(page.getByText(new RegExp(`已加载${label}物理地图`))).toBeVisible({ - timeout: 30_000, - }); + await expect(page.getByLabel('地图草稿状态')).toBeHidden({ timeout: 30_000 }); + await expectNotificationDetails(page, [new RegExp(`已加载${label}物理地图`)]); } - await expect(page.getByText('WASM 已加载')).toBeVisible(); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); }); test('导入并应用分层工程地图包', async ({ page }) => { @@ -843,11 +904,18 @@ test('导入并应用分层工程地图包', async ({ page }) => { mimeType: 'application/zip', buffer: Buffer.from(project), }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); - await page.getByLabel('地图来源').selectOption({ label: '测试场景' }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); + await page + .getByLabel('地图资产库') + .getByRole('button', { name: '放置工程地图 测试场景' }) + .click(); + // 资产库放置默认不移动机器人;显式选择出生点后仍验证原编译路径。 + await expect(page.getByLabel('地图出生点')).toHaveValue(''); + await page.getByLabel('地图出生点').selectOption('start'); await expect(page.getByLabel('地图出生点')).toHaveValue('start'); await page.getByRole('button', { name: '应用并重新编译' }).click(); - await expect(page.getByText(/已加载工程地图“测试场景”/)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByLabel('地图草稿状态')).toBeHidden({ timeout: 30_000 }); + await expectNotificationDetails(page, [/已加载工程地图“测试场景”/], [/视觉地图加载失败/]); await expect(page.getByText(/视觉地图加载失败/)).toHaveCount(0); }); @@ -877,10 +945,13 @@ test('将受支持的只读物理地图转换为可编辑副本', async ({ page mimeType: 'application/zip', buffer: Buffer.from(project), }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); - await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); + await page + .getByLabel('地图资产库') + .getByRole('button', { name: '放置工程地图 旧版基础场景' }) + .click(); await page.getByRole('button', { name: '应用并重新编译' }).click(); - const editor = page.getByText('认证资产与场景对象属性').locator('..'); + const editor = page.getByText('源内容修改影响所有同源实例').locator('../..'); await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 }); await editor.getByRole('button', { name: '创建可编辑副本' }).click(); await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 }); @@ -924,10 +995,15 @@ test('编辑 V3 地图对象并事务式应用', async ({ page }) => { mimeType: 'application/zip', buffer: Buffer.from(project), }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); - await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' }); - await page.getByRole('button', { name: '应用并重新编译' }).click(); - await expect(page.getByText('认证资产与场景对象属性')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); + await page + .getByLabel('地图资产库') + .getByRole('button', { name: '放置工程地图 可编辑场景' }) + .click(); + await page.getByRole('button', { name: '应用场景', exact: true }).click(); + await expect(page.getByText('源内容修改影响所有同源实例', { exact: true })).toBeVisible({ + timeout: 30_000, + }); const mapTools = page.getByLabel('地图视口工具'); await expect(mapTools.getByRole('button', { name: '移动工具 W' })).toHaveAttribute( 'aria-pressed', @@ -955,7 +1031,7 @@ test('编辑 V3 地图对象并事务式应用', async ({ page }) => { ).toBeVisible({ timeout: 30_000, }); - await expect(draftStatus).toContainText('地图草稿已同步'); + await expect(draftStatus).toBeHidden(); await page .getByLabel('地图对象列表') @@ -969,13 +1045,13 @@ test('编辑 V3 地图对象并事务式应用', async ({ page }) => { .getByRole('button', { name: /box · 方盒/ }) .click(); await expect(page.getByLabel('对象位置X')).toHaveValue('2'); - await expect(draftStatus).toContainText('地图草稿已同步'); + await expect(draftStatus).toBeHidden(); await page.getByRole('button', { name: '新增', exact: true }).click(); await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(2); await page.keyboard.press('Delete'); await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(1); - await expect(page.getByText('WASM 已加载')).toBeVisible(); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); }); test('工程地图编译失败时保留上一仿真会话', async ({ page }) => { @@ -1004,34 +1080,40 @@ test('工程地图编译失败时保留上一仿真会话', async ({ page }) => mimeType: 'application/zip', buffer: Buffer.from(project), }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('button', { name: '▶ 播放' }).click(); await page.waitForTimeout(200); const runningTime = Number( - (await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0, + (await page.getByLabel('视口状态').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0, ); - await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' }); - await page.getByRole('button', { name: '应用并重新编译' }).click(); + await page + .getByLabel('地图资产库') + .getByRole('button', { name: '放置工程地图 动态错误地图' }) + .click(); + await page.getByRole('button', { name: '应用场景', exact: true }).click(); await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 }); await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/invalid/map.json'); + await expect(page.getByLabel('地图草稿状态')).toContainText('应用失败'); + await page.screenshot({ path: test.info().outputPath('map-compile-failed.png') }); await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); await expect(page.getByLabel('场景资产树')).toContainText('待应用'); - await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible(); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); await page.getByRole('button', { name: '关闭错误' }).click(); await page.getByRole('button', { name: '▶ 播放' }).click(); await expect .poll(async () => - Number((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0), + Number((await page.getByLabel('视口状态').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0), ) .toBeGreaterThan(runningTime); await page.getByRole('button', { name: '放弃更改' }).click(); await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0); - await expect(page.getByLabel('地图来源')).toHaveValue('none'); + await expect(page.getByLabel('场景资产树')).not.toContainText('动态错误地图'); + await expect(page.getByLabel('地图草稿状态')).toBeHidden(); }); test('无效模型显示中文诊断且保留工程树', async ({ page }) => { await page.goto('/'); await page.locator('input[type="file"]').first().setInputFiles(fixture('invalid.xml')); await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 }); - await expect(page.getByText('invalid.xml', { exact: false }).first()).toBeVisible(); + await expectImportedFiles(page, ['invalid.xml']); }); diff --git a/web_platform/e2e/multiRing.spec.ts b/web_platform/e2e/multiRing.spec.ts index 82a4dcc6..4e65d95a 100644 --- a/web_platform/e2e/multiRing.spec.ts +++ b/web_platform/e2e/multiRing.spec.ts @@ -63,7 +63,7 @@ test('训练作业一键导入:真实WASM地图+97维ORT+PiP+射线开关', as .locator('input[type="file"]') .first() .setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('tab', { name: '控制台' }).click(); const tools = page.getByRole('tabpanel', { name: '控制台' }); await tools.getByRole('button', { name: /强化学习任务/ }).click(); @@ -82,6 +82,7 @@ test('训练作业一键导入:真实WASM地图+97维ORT+PiP+射线开关', as if ((await section.getAttribute('aria-expanded')) === 'false') await section.click(); await expect(tools.getByText('97 / 12')).toBeVisible(); await expect(tools.getByText('Go2 前视射线避障导航', { exact: true })).toBeVisible(); + await tools.getByText('推理详情', { exact: true }).click(); const count = tools.getByText('推理次数', { exact: true }).locator('..'); await expect .poll(async () => Number((await count.textContent())?.replace(/\D/g, ''))) @@ -90,7 +91,9 @@ test('训练作业一键导入:真实WASM地图+97维ORT+PiP+射线开关', as if (await pause.isVisible()) await pause.click(); const stop = tools.getByRole('button', { name: '停止', exact: true }); if (await stop.isVisible()) await stop.click(); - const targetRow = tools.getByText('当前目标 (X, Y)', { exact: true }).locator('..'); + await section.evaluate((element) => element.scrollIntoView({ block: 'start' })); + await page.screenshot({ path: test.info().outputPath('onnx-policy.png') }); + const targetRow = tools.getByText('当前目标 (X, Y) m', { exact: true }).locator('..'); const initialTarget = await targetRow.textContent(); const selection = () => page.locator('[aria-selected="true"]').allTextContents(); const beforeSelection = await selection(); @@ -139,6 +142,7 @@ test('训练作业一键导入:真实WASM地图+97维ORT+PiP+射线开关', as await tools.getByRole('button', { name: '导入策略' }).click(); await expect(tools.getByRole('alert')).toContainText('维度'); await expect(tools.getByText('97 / 12')).toBeVisible(); + await page.screenshot({ path: test.info().outputPath('onnx-contract-error.png') }); await expect(targetRow).toHaveText(initialTarget!); await tools.getByRole('button', { name: '设定目标', exact: true }).click(); await tools.getByRole('button', { name: '卸载', exact: true }).click(); @@ -148,6 +152,7 @@ test('训练作业一键导入:真实WASM地图+97维ORT+PiP+射线开关', as await expect(tools.locator('.uplot canvas')).toHaveCount(1); await tools.getByRole('tab', { name: '综合', exact: true }).click(); await expect(tools.locator('.uplot canvas')).toHaveCount(5); + await page.screenshot({ path: test.info().outputPath('training-metrics.png') }); await tools.getByRole('button', { name: /训练指标趋势/ }).click(); await expect(tools.locator('.uplot')).toHaveCount(0); }); diff --git a/web_platform/e2e/obstacle.spec.ts b/web_platform/e2e/obstacle.spec.ts index ad68dafe..59e67b74 100644 --- a/web_platform/e2e/obstacle.spec.ts +++ b/web_platform/e2e/obstacle.spec.ts @@ -62,7 +62,7 @@ test('训练作业一键导入:真实WASM地图+81维ORT+PiP+射线开关', as .locator('input[type="file"]') .first() .setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('tab', { name: '控制台' }).click(); const tools = page.getByRole('tabpanel', { name: '控制台' }); await tools.getByRole('button', { name: /强化学习任务/ }).click(); @@ -89,7 +89,7 @@ test('训练作业一键导入:真实WASM地图+81维ORT+PiP+射线开关', as if (await pause.isVisible()) await pause.click(); const stop = tools.getByRole('button', { name: '停止', exact: true }); if (await stop.isVisible()) await stop.click(); - const targetRow = tools.getByText('当前目标 (X, Y)', { exact: true }).locator('..'); + const targetRow = tools.getByText('当前目标 (X, Y) m', { exact: true }).locator('..'); const initialTarget = await targetRow.textContent(); const selection = () => page.locator('[aria-selected="true"]').allTextContents(); const beforeSelection = await selection(); @@ -153,7 +153,7 @@ test('下载metadata与作业不匹配时拒绝,未更换地图或启用策略 .locator('input[type="file"]') .first() .setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('tab', { name: '控制台' }).click(); const tools = page.getByRole('tabpanel', { name: '控制台' }); await tools.getByRole('button', { name: /强化学习任务/ }).click(); @@ -194,7 +194,7 @@ for (const failure of ['wrong-graph', 'ort-init-failure']) { .locator('input[type="file"]') .first() .setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('tab', { name: '控制台' }).click(); const tools = page.getByRole('tabpanel', { name: '控制台' }); await tools.getByRole('button', { name: /强化学习任务/ }).click(); @@ -220,7 +220,7 @@ for (const failure of ['wrong-graph', 'ort-init-failure']) { await expect(inference).toHaveText(before!); await expect(page.getByLabel('摄像头画面', { exact: true })).toBeVisible(); await expect(page.getByText('训练配套物理地图', { exact: false })).toBeVisible(); - await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible(); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); }); } @@ -274,7 +274,7 @@ test('新server默认Flat作业兼容旧无metadata47维ONNX,错误graph拒绝 mimeType: 'text/xml', buffer: Buffer.from(go2.replace('', actuators + '')), }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('tab', { name: '控制台' }).click(); const tools = page.getByRole('tabpanel', { name: '控制台' }); await tools.getByRole('button', { name: /强化学习任务/ }).click(); diff --git a/web_platform/e2e/pretrainedNavigation.spec.ts b/web_platform/e2e/pretrainedNavigation.spec.ts index a2081995..87cdf1f1 100644 --- a/web_platform/e2e/pretrainedNavigation.spec.ts +++ b/web_platform/e2e/pretrainedNavigation.spec.ts @@ -170,7 +170,7 @@ test('用户原始47维ONNX通过普通Flat面板加载,不新增点击导航 .locator('input[type="file"]') .first() .setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(xml) }); - await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('tab', { name: '控制台' }).click(); const tools = page.getByRole('tabpanel', { name: '控制台' }); const section = tools.getByRole('button', { name: /ONNX 策略运行/ }); diff --git a/web_platform/e2e/pretrainedUpload.spec.ts b/web_platform/e2e/pretrainedUpload.spec.ts index d537baa4..8d7b8e7d 100644 --- a/web_platform/e2e/pretrainedUpload.spec.ts +++ b/web_platform/e2e/pretrainedUpload.spec.ts @@ -103,6 +103,7 @@ for (const panel of ['ordinary', 'tuning'] as const) { await ui .getByRole('button', { name: panel === 'ordinary' ? /^连接$/ : '连接/刷新' }) .click(); + await ui.getByText('上传基础策略', { exact: true }).click(); await expect(ui.getByLabel('确认Go2 legacy47模板')).toBeEnabled(); await expect(ui.getByLabel('基础策略', { exact: true })).toHaveValue(''); await ui.getByLabel('确认Go2 legacy47模板').check(); @@ -121,6 +122,7 @@ for (const panel of ['ordinary', 'tuning'] as const) { ).json(); const record = catalog.pretrainedSources[0]; await expect(ui.getByLabel('基础策略', { exact: true })).toHaveValue(record.id); + await ui.getByText('来源与校验详情', { exact: true }).click(); await expect(ui.getByText(/原文件 SHA256/)).toContainText(sha); await expect(ui.getByText(/上传格式/)).toContainText(format); await expect(ui.getByText(/已选择.*仅继承策略权重/)).toBeVisible(); diff --git a/web_platform/e2e/tuningFixture.ts b/web_platform/e2e/tuningFixture.ts new file mode 100644 index 00000000..4056268f --- /dev/null +++ b/web_platform/e2e/tuningFixture.ts @@ -0,0 +1,99 @@ +import type { TuningSession } from '../src/training/types'; +const objectives = { + velocity_tracking: 0.35, + action_smoothness: 0.2, + posture_stability: 0.15, + fall_avoidance: 0.15, + foot_slip: 0.1, + energy: 0.05, +}; + +export function tuningFixture(): TuningSession { + const baseConfig = { weights: { pose: 1 }, params: {} }; + const resultConfig = { weights: { pose: 1.1 }, params: {} }; + return { + id: 'a'.repeat(32), + state: 'awaiting_approval', + mode: 'approval', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:02:00Z', + config: { + taskId: 'Unitree-Go2-Flat', + mode: 'approval', + runName: '界面验收会话', + numEnvs: 16, + seed: 42, + gpuIds: [0], + trialCount: 2, + initialIterations: 300, + middleIterations: 900, + finalIterations: 2000, + evalNumEnvs: 8, + evalSteps: 10, + objectiveWeights: objectives, + fallbackEnabled: false, + rungs: [300, 900, 2000], + promote: [2, 2, 2], + }, + objectiveWeights: objectives, + message: 'paused', + bestTrialId: '2'.repeat(32), + consecutiveNoImprove: 0, + fallbackEnabled: false, + trials: [ + { + id: '1'.repeat(32), + sessionId: 'a'.repeat(32), + number: 0, + state: 'completed', + rung: 0, + targetIterations: 300, + rewardConfig: baseConfig, + score: 0, + eligible: true, + createdAt: '2026-01-01T00:00:00Z', + message: 'done', + }, + { + id: '2'.repeat(32), + sessionId: 'a'.repeat(32), + number: 1, + state: 'completed', + rung: 0, + targetIterations: 300, + rewardConfig: resultConfig, + proposalId: '3'.repeat(32), + score: 0.12, + eligible: true, + evaluation: { + metrics: { linear_velocity_rmse: 0.2 }, + score: { score: 0.12, eligible: true, components: { velocity_tracking: 0.2 } }, + }, + createdAt: '2026-01-01T00:01:00Z', + message: 'done', + }, + ], + proposals: [ + { + id: '3'.repeat(32), + sessionId: 'a'.repeat(32), + baseTrialId: '1'.repeat(32), + state: 'pending', + source: 'agent', + patch: { weights: { pose: 1.1 }, params: {} }, + rationale: '提高姿态奖励以降低躯干倾角。', + expectedImpact: { posture_stability: '姿态误差预计下降 8%' }, + confidence: 0.82, + createdAt: '2026-01-01T00:00:30Z', + }, + ], + audit: [], + control: { + runPolicy: 'step', + dispatchTokens: 0, + constraintsRevision: 0, + constraints: {}, + effectiveAfterCurrent: false, + }, + }; +} diff --git a/web_platform/e2e/ui-domain-panels.spec.ts b/web_platform/e2e/ui-domain-panels.spec.ts new file mode 100644 index 00000000..e6e92184 --- /dev/null +++ b/web_platform/e2e/ui-domain-panels.spec.ts @@ -0,0 +1,191 @@ +import { expect, test, type Page } from '@playwright/test'; + +const MODEL = ``; +async function load(page: Page) { + await page.goto('/'); + await page + .locator('#mujoco-project-files') + .setInputFiles({ name: 'domain.xml', mimeType: 'text/xml', buffer: Buffer.from(MODEL) }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); +} +async function left(page: Page) { + const button = page.getByRole('button', { name: '显示工程面板', exact: true }); + if (await button.isVisible()) await button.click(); +} +async function right(page: Page) { + const button = page.getByRole('button', { name: '显示右侧面板', exact: true }); + if (await button.isVisible()) await button.click(); +} +async function screenshot(page: Page, name: string) { + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ + path: test.info().outputPath(name + '.png'), + mask: [page.getByLabel('视口状态')], + }); +} + +for (const theme of ['dark', 'light']) { + for (const [width, height] of [ + [1920, 1080], + [1440, 900], + [1366, 768], + [1024, 768], + [768, 800], + ]) { + test(`领域检查器与录制 ${theme} ${width}`, async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.setViewportSize({ width, height }); + await page.addInitScript( + (value) => localStorage.setItem('mujoco-platform-theme', value), + theme, + ); + await load(page); + await left(page); + await page + .getByRole('navigation', { name: '模型结构树' }) + .getByRole('treeitem', { name: /box/ }) + .first() + .click(); + await expect(page.getByText(/^Body #/)).toBeVisible(); + await expect(page.getByRole('button', { name: '标识与层级' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + await screenshot(page, 'body'); + await page + .getByRole('tabpanel', { name: '检查器' }) + .getByRole('button', { name: /slide/ }) + .click(); + await expect(page.getByRole('slider', { name: 'slide' })).toBeVisible(); + await page.getByRole('slider', { name: 'slide' }).focus(); + await page.keyboard.press('ArrowRight'); + await expect(page.getByRole('slider', { name: 'slide' })).not.toHaveValue('0'); + await page.getByText('增益与输出限幅', { exact: true }).click(); + await expect(page.getByText(/参数立即作用于模型/)).toBeVisible(); + await screenshot(page, 'joint-actuator'); + await page.getByRole('tab', { name: '数据录制' }).click(); + await page.getByRole('button', { name: '开始记录' }).click(); + await expect(page.getByText('记录中', { exact: true })).toBeVisible(); + await expect(page.getByLabel('采样频率', { exact: true })).toBeDisabled(); + await page.getByRole('button', { name: '▶ 播放' }).click(); + await page.waitForTimeout(150); + await page.getByRole('button', { name: '⏸ 暂停' }).click(); + await page.getByRole('button', { name: '停止记录' }).click(); + await page.getByText('实时运动状态', { exact: true }).click(); + await expect(page.getByRole('button', { name: '导出 CSV' })).toBeEnabled(); + await screenshot(page, 'recording'); + await left(page); + await page.getByLabel('地图资产库').getByRole('button', { name: '添加基础方盒' }).click(); + await page.getByRole('tab', { name: '检查器' }).click(); + await expect(page.getByLabel('对象参数sizeX', { exact: true })).toBeVisible(); + await expect(page.getByText('表面材质', { exact: true }).locator('..')).not.toHaveAttribute( + 'open', + ); + await page.getByLabel('对象参数sizeX', { exact: true }).fill('1.5'); + await page.getByLabel('对象参数sizeX', { exact: true }).press('Enter'); + await expect(page.getByLabel('地图草稿状态')).toContainText('未保存'); + await screenshot(page, 'map-draft'); + if (width === 1440) { + await page + .getByLabel('对象参数sizeX', { exact: true }) + .locator('..') + .getByRole('button') + .focus(); + await expect(page.getByRole('tooltip')).toContainText('Shift 精调'); + const tip = await page.getByRole('tooltip').boundingBox(); + expect(tip!.x + tip!.width).toBeLessThanOrEqual(width); + await screenshot(page, 'map-keyboard-tooltip'); + await page.keyboard.press('Escape'); + await expect(page.getByRole('tooltip')).toHaveCount(0); + } + expect(errors).toEqual([]); + }); + } + + test(`训练折叠详情与跨视图状态 ${theme}`, async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.addInitScript( + (value) => localStorage.setItem('mujoco-platform-theme', value), + theme, + ); + let starts = 0; + let offline = true; + const job = { + id: 'a'.repeat(32), + taskId: 'Unitree-Go2-Flat', + state: 'running', + progress: 0.4, + iteration: 4, + maxIterations: 10, + message: '模拟训练中,未启动真实作业', + artifactReady: false, + logs: ['Learning iteration 4 / 10', 'Mean value loss: 0.9'], + }; + await page.route('http://127.0.0.1:8765/**', async (route) => { + const request = route.request(); + if (offline) return route.fulfill({ status: 503, json: { error: '模拟服务断连' } }); + if (request.url().endsWith('/health')) + return route.fulfill({ + json: { + ready: true, + trainerRoot: '/mock/trainer', + tasks: ['Unitree-Go2-Flat'], + taskMetadata: [ + { + id: 'Unitree-Go2-Flat', + name: 'Go2 平地', + terrainPresets: ['plane'], + terrainParameters: { size: { min: 4, max: 30, default: 8 } }, + sensorParameters: {}, + browserCompatible: true, + }, + ], + }, + }); + if (request.url().endsWith('/presets')) return route.fulfill({ json: { presets: [] } }); + if (request.method() === 'POST') starts++; + return route.fulfill({ json: job }); + }); + await load(page); + await right(page); + await page.getByRole('tab', { name: '控制台' }).click(); + const tools = page.getByRole('tabpanel', { name: '控制台' }); + const group = tools.getByRole('button', { name: /强化学习任务/ }); + await group.click(); + await page.getByLabel('训练服务访问令牌').fill('mock-token'); + await tools.getByRole('button', { name: '连接', exact: true }).click(); + await expect(tools.getByRole('alert')).toContainText('模拟服务断连'); + await screenshot(page, 'training-disconnected'); + offline = false; + await tools.getByRole('button', { name: '连接', exact: true }).click(); + await page.getByLabel('训练地形', { exact: true }).selectOption('plane'); + await tools.getByRole('button', { name: '地形详细参数' }).click(); + await page.getByLabel('地图尺寸 m', { exact: true }).fill('100'); + await tools.getByRole('button', { name: '地形详细参数' }).click(); + await tools.getByRole('button', { name: '发起本地训练' }).click(); + await expect(tools.getByRole('alert')).toContainText('超出允许范围'); + await expect(tools.getByRole('alert')).toBeInViewport(); + await expect(page.getByLabel('地图尺寸 m', { exact: true })).toBeVisible(); + expect(starts).toBe(0); + await screenshot(page, 'training-validation'); + await page.getByLabel('地图尺寸 m', { exact: true }).fill('12'); + await page.getByLabel('运行名称', { exact: true }).fill('preserved'); + await group.click(); + await page.getByRole('tab', { name: '检查器' }).click(); + await page.getByRole('tab', { name: '数据录制' }).click(); + await page.getByRole('tab', { name: '控制台' }).click(); + await group.click(); + await expect(page.getByLabel('运行名称', { exact: true })).toHaveValue('preserved'); + await expect(page.getByLabel('训练服务访问令牌')).toHaveValue('mock-token'); + await tools.getByRole('button', { name: '发起本地训练' }).click(); + await expect(tools.getByRole('button', { name: '停止训练' })).toBeVisible(); + await screenshot(page, 'training-running'); + await group.click(); + await expect(group).toContainText('训练中'); + await page.getByRole('tab', { name: '检查器' }).click(); + await page.getByRole('tab', { name: '控制台' }).click(); + await expect(group).toContainText('训练中'); + expect(starts).toBe(1); + }); +} diff --git a/web_platform/e2e/ui-floating.spec.ts b/web_platform/e2e/ui-floating.spec.ts new file mode 100644 index 00000000..dbb9fed0 --- /dev/null +++ b/web_platform/e2e/ui-floating.spec.ts @@ -0,0 +1,106 @@ +import { expect, test, type Locator } from '@playwright/test'; + +async function expectWithinViewport(locator: Locator, width: number, height: number) { + await expect(locator).toBeVisible(); + await expect + .poll(async () => { + const box = await locator.boundingBox(); + return Boolean( + box && + box.x >= 7 && + box.y >= 7 && + box.x + box.width <= width - 7 && + box.y + box.height <= height - 7, + ); + }) + .toBe(true); +} + +for (const theme of ['dark', 'light']) { + for (const [width, height] of [ + [1920, 1080], + [1440, 900], + [1366, 768], + [1024, 768], + [768, 800], + ]) { + test(`共享浮层边界与主题 ${theme} ${width}×${height}`, async ({ page }) => { + await page.setViewportSize({ width, height }); + await page.addInitScript( + (value) => localStorage.setItem('mujoco-platform-theme', value), + theme, + ); + await page.goto('/'); + const trigger = page.getByRole('button', { name: /隐藏工程面板|显示工程面板/ }); + await trigger.focus(); + const tooltip = page.getByRole('tooltip'); + await expectWithinViewport(tooltip, width, height); + await expect(trigger).toHaveAttribute( + 'aria-describedby', + (await tooltip.getAttribute('id')) as string, + ); + expect( + await tooltip.evaluate((node) => node.closest('.theme-dark, .theme-light')?.className), + ).toContain(`theme-${theme}`); + await page.keyboard.press('Escape'); + await expect(tooltip).toHaveCount(0); + await page.getByRole('button', { name: /FPS \d/ }).click(); + const popover = page.getByRole('dialog', { name: '性能详情' }); + await expectWithinViewport(popover, width, height); + await page.screenshot({ + path: test.info().outputPath(`performance-${theme}-${width}.png`), + fullPage: true, + }); + await page.setViewportSize({ width: width - 40, height: height - 40 }); + await expectWithinViewport(popover, width - 40, height - 40); + await page.keyboard.press('Escape'); + await expect(popover).toHaveCount(0); + }); + } +} + +test('边缘 hover 提示可移入,disabled 控件说明可经键盘访问', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/'); + const theme = page.getByRole('button', { name: '打开命令面板' }); + await theme.hover(); + const tooltip = page.getByRole('tooltip'); + await expectWithinViewport(tooltip, 1440, 900); + await tooltip.hover(); + await expect(tooltip).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(tooltip).toHaveCount(0); + const step = page.getByRole('button', { name: '单步', exact: true }); + await expect(step).toBeDisabled(); + await step.locator('..').focus(); + await expect(tooltip).toHaveText('单步(暂停时可用)'); + expect(await theme.evaluate((node) => node.parentElement?.hasAttribute('tabindex'))).toBe(false); +}); + +test('全屏中的对话框 Tooltip 不裁剪且 Escape 仅关闭顶层', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/'); + await page.getByRole('button', { name: '更多工作台操作' }).click(); + await page.getByRole('menuitem', { name: '进入全屏' }).click(); + await page.getByRole('button', { name: '更多工作台操作' }).click(); + await page.getByRole('menuitem', { name: '工作台设置' }).click(); + const dialog = page.getByRole('dialog', { name: '工作台设置' }); + await expect(dialog).toBeVisible(); + const close = dialog.getByRole('button', { name: '关闭', exact: true }); + await close.focus(); + const tooltip = page.getByRole('tooltip'); + await expectWithinViewport(tooltip, 1440, 900); + expect(await tooltip.evaluate((node) => document.fullscreenElement?.contains(node))).toBe(true); + expect(await tooltip.evaluate((node) => Boolean(node.closest('[role="dialog"]')))).toBe(false); + await page.screenshot({ + path: test.info().outputPath('fullscreen-dialog-tooltip.png'), + fullPage: true, + }); + await page.keyboard.press('Escape'); + await expect(tooltip).toHaveCount(0); + await expect(dialog).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await page.getByRole('button', { name: '更多工作台操作' }).click(); + await page.getByRole('menuitem', { name: '退出全屏' }).click(); +}); diff --git a/web_platform/e2e/ui-layout.spec.ts b/web_platform/e2e/ui-layout.spec.ts new file mode 100644 index 00000000..90fc4753 --- /dev/null +++ b/web_platform/e2e/ui-layout.spec.ts @@ -0,0 +1,354 @@ +import { expect, test, type Page } from '@playwright/test'; +import { zipSync } from 'fflate'; + +const MODEL = ` + + + + +`; +async function importModel(page: Page) { + await page + .locator('#mujoco-project-files') + .setInputFiles({ name: 'layout.xml', mimeType: 'text/xml', buffer: Buffer.from(MODEL) }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); +} +async function openLeft(page: Page) { + const button = page.getByRole('button', { name: '显示工程面板', exact: true }); + if (await button.isVisible()) await button.click(); +} +async function expectSlots(page: Page) { + await expect + .poll(() => + page.locator('main').evaluate((main) => { + const bounds = main.getBoundingClientRect(); + const slots = [...main.querySelectorAll('[data-overlay-slot]')] + .map((node) => ({ name: node.dataset.overlaySlot, rect: node.getBoundingClientRect() })) + .filter(({ rect }) => rect.width > 0 && rect.height > 0); + const problems: string[] = []; + for (const { name, rect } of slots) { + if ( + rect.left < bounds.left - 1 || + rect.right > bounds.right + 1 || + rect.top < bounds.top - 1 || + rect.bottom > bounds.bottom + 1 + ) + problems.push(`越界 ${name}`); + } + for (let a = 0; a < slots.length; a++) + for (let b = a + 1; b < slots.length; b++) { + const x = slots[a], + y = slots[b]; + if ( + Math.min(x.rect.right, y.rect.right) - Math.max(x.rect.left, y.rect.left) > 1 && + Math.min(x.rect.bottom, y.rect.bottom) - Math.max(x.rect.top, y.rect.top) > 1 + ) + problems.push(`重叠 ${x.name}/${y.name}`); + } + if (document.documentElement.scrollWidth > window.innerWidth) problems.push('页面横向溢出'); + return problems; + }), + ) + .toEqual([]); +} + +for (const theme of ['dark', 'light']) + for (const [width, height] of [ + [1920, 1080], + [1440, 900], + [1366, 768], + [1024, 768], + [768, 800], + ]) { + test(`工作台槽位 ${theme} ${width}×${height} 摄像头/草稿/通知`, async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.setViewportSize({ width, height }); + await page.addInitScript( + (value) => localStorage.setItem('mujoco-platform-theme', value), + theme, + ); + await page.goto('/'); + await expect( + page.getByRole('button', { + name: width >= 1440 ? '隐藏工程面板' : '显示工程面板', + exact: true, + }), + ).toBeVisible(); + await expect( + page.getByRole('button', { + name: width >= 1024 ? '隐藏右侧面板' : '显示右侧面板', + exact: true, + }), + ).toBeVisible(); + await expect(page.locator('header')).not.toContainText('播放'); + await expect(page.getByLabel('地图草稿状态')).toHaveCount(0); + await expectSlots(page); + await page.screenshot({ path: test.info().outputPath(`empty-${theme}-${width}.png`) }); + await importModel(page); + await openLeft(page); + await page.getByLabel('地图资产库').getByRole('button', { name: '添加基础方盒' }).click(); + await expect(page.getByLabel('地图草稿状态')).toContainText('1 项未保存改动'); + await expect(page.getByLabel('摄像头画面')).toBeVisible(); + if (width < 1024) + await page.getByRole('button', { name: '隐藏右侧面板', exact: true }).click(); + await expect(page.locator('[data-overlay-slot="notices"]')).toContainText('模型加载完成'); + await expectSlots(page); + if (width >= 1024) + expect((await page.getByRole('main').boundingBox())!.width).toBeGreaterThanOrEqual(480); + await page.screenshot({ + path: test.info().outputPath(`draft-camera-${theme}-${width}.png`), + mask: [page.getByLabel('视口状态')], + }); + await page.getByRole('button', { name: '隐藏画面' }).click(); + await expect(page.getByLabel('摄像头画面')).toHaveCount(0); + await page.getByRole('button', { name: '显示摄像头画面' }).click(); + await expectSlots(page); + // 摄像头与草稿占位时,浮层仍须脱离侧栏裁剪并保持主题和键盘关闭。 + const performance = page.getByRole('button', { name: /FPS \d/ }); + await performance.click(); + const popover = page.getByRole('dialog', { name: '性能详情' }); + await expect(popover).toBeVisible(); + expect( + await popover.evaluate((node) => { + const rect = node.getBoundingClientRect(); + return ( + rect.left >= 7 && + rect.top >= 7 && + rect.right <= innerWidth - 7 && + rect.bottom <= innerHeight - 7 + ); + }), + ).toBe(true); + expect( + await popover.evaluate((node) => node.closest('.theme-dark, .theme-light')?.className), + ).toContain(`theme-${theme}`); + await page.screenshot({ + path: test.info().outputPath(`draft-camera-popover-${theme}-${width}.png`), + mask: [page.getByLabel('视口状态')], + }); + await page.keyboard.press('Escape'); + await expect(popover).toHaveCount(0); + await expect(performance).toBeFocused(); + if (width === 1024 || width === 768) { + await page.getByRole('button', { name: '更多工作台操作' }).click(); + await page.getByRole('menuitem', { name: '进入全屏' }).click(); + await expect + .poll(() => page.evaluate(() => Boolean(document.fullscreenElement))) + .toBe(true); + await expectSlots(page); + await page.screenshot({ + path: test.info().outputPath('fullscreen-draft-camera.png'), + mask: [page.getByLabel('视口状态')], + }); + await page.getByRole('button', { name: '更多工作台操作' }).click(); + await page.getByRole('menuitem', { name: '工作台设置' }).click(); + const settings = page.getByRole('dialog', { name: '工作台设置' }); + await settings.getByRole('button', { name: '关闭', exact: true }).focus(); + const tooltip = page.getByRole('tooltip'); + await expect(tooltip).toBeVisible(); + expect( + await tooltip.evaluate((node) => { + const rect = node.getBoundingClientRect(); + return ( + Boolean(document.fullscreenElement?.contains(node)) && + rect.left >= 7 && + rect.top >= 7 && + rect.right <= innerWidth - 7 && + rect.bottom <= innerHeight - 7 + ); + }), + ).toBe(true); + await page.screenshot({ + path: test.info().outputPath(`fullscreen-dialog-camera-${theme}-${width}.png`), + mask: [page.getByLabel('视口状态')], + }); + await page.keyboard.press('Escape'); + await expect(tooltip).toHaveCount(0); + await expect(settings).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(settings).toHaveCount(0); + await expect(page.getByLabel('地图草稿状态')).toContainText('未保存改动'); + await expect(page.getByLabel('摄像头画面')).toBeVisible(); + await expectSlots(page); + await page.getByRole('button', { name: '更多工作台操作' }).click(); + await page.getByRole('menuitem', { name: '退出全屏' }).click(); + } + expect(errors).toEqual([]); + }); + } + +test('导入→大纲选择→键盘编辑关节→运行,侧栏切换保留输入与草稿', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/'); + await importModel(page); + await page.getByRole('treeitem', { name: /slide/ }).click(); + const joint = page.getByRole('slider', { name: /slide/ }); + await joint.focus(); + await page.keyboard.press('ArrowRight'); + await expect(joint).not.toHaveValue('0'); + const value = await joint.inputValue(); + await page.getByRole('button', { name: '隐藏右侧面板', exact: true }).click(); + await page.getByRole('button', { name: '显示右侧面板', exact: true }).click(); + await expect(joint).toHaveValue(value); + await page.getByRole('button', { name: '隐藏右侧面板', exact: true }).blur(); + await page.keyboard.press('Space'); + await expect(page.getByRole('button', { name: '⏸ 暂停' })).toBeEnabled(); + await expect(page.getByLabel('视口状态')).not.toContainText('时间 0.000 s'); + await page.keyboard.press('Space'); + await expect(page.getByLabel('视口状态')).toContainText('已暂停'); + await page.getByLabel('地图资产库').getByRole('button', { name: '添加基础方盒' }).click(); + await page.getByLabel('对象位置X').fill('2'); + await page.getByLabel('对象位置X').blur(); + await page.getByRole('button', { name: '隐藏右侧面板', exact: true }).click(); + await page.getByRole('button', { name: '显示右侧面板', exact: true }).click(); + await expect(page.getByLabel('对象位置X')).toHaveValue('2'); + await expect(page.getByLabel('地图草稿状态')).toContainText('未保存改动'); + await page.keyboard.press('Control+s'); + await expect(page.getByLabel('地图草稿状态')).toBeHidden({ timeout: 30_000 }); + await page + .getByLabel('地图对象列表') + .getByRole('button', { name: /基础方盒/ }) + .click(); + await page.getByLabel('对象位置X').fill('3'); + await page.getByLabel('地图草稿状态').getByRole('button', { name: '丢弃地图草稿' }).click(); + await page + .getByLabel('地图对象列表') + .getByRole('button', { name: /基础方盒/ }) + .click(); + await expect(page.getByLabel('对象位置X')).toHaveValue('2'); + await expect(page.getByLabel('地图草稿状态')).toBeHidden(); + await page.screenshot({ + path: test.info().outputPath('closed-loop-applied.png'), + mask: [page.getByLabel('视口状态')], + }); +}); + +test('宽度预算、键盘调整与窄屏临时折叠不覆盖桌面偏好', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.addInitScript(() => { + localStorage.setItem('mujoco-platform-layout', '{"left":true,"right":true}'); + localStorage.setItem('mujoco-left-sidebar-width', '500'); + localStorage.setItem('mujoco-right-sidebar-width', '500'); + }); + await page.goto('/'); + expect((await page.getByRole('main').boundingBox())!.width).toBeGreaterThanOrEqual(480); + await page.setViewportSize({ width: 1024, height: 768 }); + await expect + .poll(async () => (await page.getByRole('main').boundingBox())!.width) + .toBeGreaterThanOrEqual(480); + await page.setViewportSize({ width: 768, height: 800 }); + await expect(page.getByRole('button', { name: '显示工程面板', exact: true })).toBeVisible(); + await page.getByRole('button', { name: '显示工程面板', exact: true }).click(); + await page.getByRole('button', { name: '显示右侧面板', exact: true }).click(); + await expect(page.getByRole('button', { name: '显示工程面板', exact: true })).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem('mujoco-left-sidebar-width'))).toBe('500'); + expect(await page.evaluate(() => localStorage.getItem('mujoco-platform-layout'))).toBe( + '{"left":true,"right":true}', + ); + await page.setViewportSize({ width: 1920, height: 1080 }); + const left = page.getByRole('separator', { name: '调整工程面板宽度' }); + await expect.poll(async () => (await left.locator('..').boundingBox())!.width).toBe(500); + await left.focus(); + await page.keyboard.press('ArrowLeft'); + expect(await page.evaluate(() => localStorage.getItem('mujoco-left-sidebar-width'))).toBe('484'); +}); + +test('编译失败时摄像头、风险摘要与重试/放弃入口不重叠', async ({ page }) => { + await page.setViewportSize({ width: 1024, height: 768 }); + await page.goto('/'); + const project = zipSync({ + 'model.xml': Buffer.from(MODEL), + 'maps/invalid/map.json': Buffer.from( + JSON.stringify({ + schemaVersion: 1, + id: 'invalid', + name: '错误地图', + coordinateSystem: { units: 'm', up: 'Z', forward: '+X' }, + physics: { source: 'world.xml' }, + spawnPoints: [], + }), + ), + 'maps/invalid/world.xml': Buffer.from( + '', + ), + }); + await page.locator('#mujoco-project-files').setInputFiles({ + name: 'invalid.zip', + mimeType: 'application/zip', + buffer: Buffer.from(project), + }); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); + await openLeft(page); + await page + .getByLabel('地图资产库') + .getByRole('button', { name: '放置工程地图 错误地图' }) + .click(); + await page.getByLabel('地图草稿状态').getByRole('button', { name: '提交地图草稿' }).click(); + await expect(page.getByRole('alert')).toContainText('模型编译失败'); + await expect(page.getByLabel('地图草稿状态')).toContainText('应用失败'); + await expect(page.getByLabel('摄像头画面')).toBeVisible(); + await expectSlots(page); + await page.getByRole('button', { name: '技术详情', exact: true }).click(); + await expectSlots(page); + await page.screenshot({ path: test.info().outputPath('camera-draft-error-1024.png') }); + await page.getByRole('button', { name: '关闭错误' }).click(); + await expect(page.getByLabel('地图草稿状态')).toContainText('应用失败'); + await page.getByLabel('地图草稿状态').getByRole('button', { name: '丢弃地图草稿' }).click(); + await expect(page.getByLabel('地图草稿状态')).toBeHidden(); +}); + +for (const theme of ['dark', 'light']) { + test(`主动作实际对比度与减少动效 ${theme}`, async ({ page }) => { + await page.setViewportSize({ width: 768, height: 800 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.addInitScript( + (value) => localStorage.setItem('mujoco-platform-theme', value), + theme, + ); + await page.goto('/'); + const primary = page.getByRole('button', { name: '选择文件', exact: true }); + await expect(primary).toBeVisible(); + const contrast = () => + primary.evaluate((node) => { + const style = getComputedStyle(node); + const luminance = (color: string) => { + const values = color + .match(/[\d.]+/g)! + .slice(0, 3) + .map(Number) + .map((value) => { + const channel = value / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722; + }; + const fg = luminance(style.color), + bg = luminance(style.backgroundColor); + return (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05); + }); + await expect.poll(contrast).toBeGreaterThanOrEqual(4.5); + await primary.hover(); + await expect.poll(contrast).toBeGreaterThanOrEqual(4.5); + await primary.focus(); + expect( + await primary.evaluate((node) => + Number.parseFloat(getComputedStyle(node).transitionDuration), + ), + ).toBeLessThanOrEqual(0.00001); + await importModel(page); + await openLeft(page); + await page.getByLabel('地图资产库').getByRole('button', { name: '添加基础方盒' }).click(); + await page.getByRole('button', { name: '隐藏右侧面板', exact: true }).click(); + await expectSlots(page); + expect( + await page + .locator('.draft-dirty-dot') + .evaluate((node) => Number.parseFloat(getComputedStyle(node).animationDuration)), + ).toBeLessThanOrEqual(0.00001); + await page.screenshot({ + path: test.info().outputPath(`reduced-motion-${theme}.png`), + mask: [page.getByLabel('视口状态')], + }); + }); +} diff --git a/web_platform/e2e/ui-tuning-system.spec.ts b/web_platform/e2e/ui-tuning-system.spec.ts new file mode 100644 index 00000000..ee2b2221 --- /dev/null +++ b/web_platform/e2e/ui-tuning-system.spec.ts @@ -0,0 +1,284 @@ +import { expect, test, type Page } from '@playwright/test'; +import { tuningFixture } from './tuningFixture'; + +const sizes = [ + [1920, 1080], + [1440, 900], + [1366, 768], + [1024, 768], + [768, 800], +] as const; +async function noOverflow(page: Page) { + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + const overflowing = await page + .locator('#root') + .evaluate((root) => + [...root.querySelectorAll('main, nav, header, [role="dialog"]')] + .filter( + (el) => el.getClientRects().length && el.getBoundingClientRect().right > innerWidth + 1, + ) + .map((el) => el.tagName), + ); + expect(overflowing).toEqual([]); +} +for (const theme of ['dark', 'light'] as const) + for (const [width, height] of sizes) { + test(`调参与系统 ${theme} ${width}`, async ({ page }, info) => { + await page.setViewportSize({ width, height }); + await page.addInitScript( + (value) => localStorage.setItem('mujoco-platform-theme', value), + theme, + ); + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + const session = tuningFixture(); + const actions: { method: string; path: string; body: unknown }[] = []; + await page.route('**/api/tuning/**', async (route) => { + const req = route.request(), + path = new URL(req.url()).pathname; + expect(req.headers().authorization).toBe('Bearer ui-mock-token'); + if (req.method() !== 'GET') + actions.push({ method: req.method(), path, body: req.postDataJSON() }); + let body: unknown = session; + if (path.endsWith('/capabilities')) + body = { configured: true, ready: true, model: 'UI Mock Agent', pretrainedSources: [] }; + else if (path === '/api/tuning/sessions') body = { sessions: [session] }; + else if (path.endsWith('/metrics')) + body = { + series: [ + { + tag: 'Train/mean_reward', + points: Array.from({ length: 30 }, (_, step) => ({ + step, + wallTime: step, + value: step / 10 + Math.sin(step) / 4, + })), + }, + ], + }; + else if (path.endsWith('/approve')) { + session.proposals[0].state = 'approved'; + session.state = 'paused'; + } else if (req.method() === 'DELETE') session.state = 'cancelled'; + await route.fulfill({ json: body }); + }); + await page.goto('/tuning.html'); + await expect(page.getByRole('heading', { name: 'Go2 自调参' })).toBeVisible(); + await expect(page.locator('#root > div')).toHaveClass(new RegExp(`theme-${theme}`)); + await noOverflow(page); + await page.screenshot({ path: info.outputPath('tuning-empty.png') }); + await page.getByLabel('训练服务地址').fill('http://127.0.0.1:8765'); + await page.getByLabel('访问令牌(仅当前标签页)').fill('ui-mock-token'); + await page.getByRole('button', { name: '连接/刷新' }).click(); + await expect(page.getByRole('img', { name: /收敛曲线/ })).toBeVisible(); + await expect(page.getByRole('button', { name: '停止 Session' })).toBeVisible(); + await expect(page.getByRole('button', { name: /决策与审批 · 待审批 1/ })).toBeVisible(); + await page.getByRole('button', { name: '收敛曲线放大', exact: true }).click(); + await page + .getByRole('button', { name: `切换到${theme === 'dark' ? '白天' : '黑夜'}主题` }) + .click(); + await expect(page.locator('#root > div')).toHaveClass( + new RegExp(`theme-${theme === 'dark' ? 'light' : 'dark'}`), + ); + await page + .getByRole('button', { name: `切换到${theme === 'dark' ? '黑夜' : '白天'}主题` }) + .click(); + await noOverflow(page); + await page.screenshot({ path: info.outputPath('tuning-metrics.png') }); + await page.getByRole('heading', { name: 'Trial Leaderboard' }).scrollIntoViewIfNeeded(); + await noOverflow(page); + await page.screenshot({ path: info.outputPath('tuning-leaderboard.png') }); + await page.getByRole('button', { name: '会话与 Trial' }).click(); + await expect(page.getByRole('button', { name: '导入', exact: true })).toBeVisible(); + await expect(page.getByText('导入会替换主工作台策略;无主窗口时下载文件。')).toBeVisible(); + await noOverflow(page); + await page.screenshot({ path: info.outputPath('tuning-sessions.png') }); + await page.getByRole('button', { name: /决策与审批/ }).click(); + await page.getByRole('button', { name: 'Monaco Diff 审查' }).scrollIntoViewIfNeeded(); + await noOverflow(page); + await page.screenshot({ path: info.outputPath('tuning-decisions.png') }); + await page.getByRole('button', { name: 'Monaco Diff 审查' }).click(); + const review = page.getByRole('dialog', { name: /Reward Merge Patch 审查/ }); + await expect(review.locator('.monaco-diff-editor')).toBeVisible(); + await expect( + review.locator(theme === 'light' ? '.monaco-editor.vs' : '.monaco-editor.vs-dark').first(), + ).toBeVisible(); + await review + .locator('.monaco-editor') + .last() + .click({ position: { x: 180, y: 80 } }); + await page.keyboard.press('Control+Home'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Home'); + await page.keyboard.press('Shift+End'); + await page.keyboard.insertText(' "pose": 1.2'); + await page.evaluate( + (value) => { + localStorage.setItem('mujoco-platform-theme', value); + window.dispatchEvent(new StorageEvent('storage', { key: 'mujoco-platform-theme' })); + }, + theme === 'light' ? 'dark' : 'light', + ); + await expect( + review.locator(theme === 'light' ? '.monaco-editor.vs-dark' : '.monaco-editor.vs').last(), + ).toBeVisible(); + await page.evaluate((value) => { + localStorage.setItem('mujoco-platform-theme', value); + window.dispatchEvent(new StorageEvent('storage', { key: 'mujoco-platform-theme' })); + }, theme); + await expect(review.locator('.monaco-editor').last()).toContainText('1.2'); + await page.getByLabel('Proposal 审批反馈').fill('界面验收,不启动真实训练'); + await noOverflow(page); + await page.screenshot({ path: info.outputPath('tuning-review.png') }); + await page.getByRole('button', { name: '批准编辑后的 Patch' }).click(); + await expect(review).toBeHidden(); + expect(actions.find((item) => item.path.endsWith('/approve'))?.body).toEqual({ + feedback: '界面验收,不启动真实训练', + patch: { weights: { pose: 1.2 }, params: {} }, + }); + await page.getByRole('button', { name: '参数护栏' }).click(); + await expect(page.getByRole('dialog', { name: /参数安全护栏/ })).toBeVisible(); + await noOverflow(page); + await page.screenshot({ path: info.outputPath('tuning-constraints.png') }); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: '停止 Session' }).click(); + expect(actions.filter((item) => item.method === 'DELETE')).toHaveLength(1); + expect( + actions.filter((item) => item.path === '/api/tuning/sessions' && item.method === 'POST'), + ).toHaveLength(0); + await page.goto('/'); + await expect(page.getByRole('region', { name: '导入模型工程' })).toBeVisible(); + await page.getByRole('button', { name: '更多工作台操作' }).click(); + await page.getByRole('menuitem', { name: '工作台设置' }).click(); + await expect(page.getByRole('dialog', { name: '工作台设置' })).toBeVisible(); + await noOverflow(page); + await page.screenshot({ path: info.outputPath('settings.png') }); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: '布局设置' }).click(); + await page.screenshot({ path: info.outputPath('layout.png') }); + await page.keyboard.press('Escape'); + await page.keyboard.press('Control+k'); + await page.getByLabel('搜索命令').fill('快捷'); + await expect(page.getByLabel('搜索命令')).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog', { name: '快捷键与视口操作' })).toBeVisible(); + await page.screenshot({ path: info.outputPath('help.png') }); + await page.keyboard.press('Escape'); + await page.keyboard.press('Control+k'); + await page.getByLabel('搜索命令').fill('无匹配命令'); + await expect(page.getByText('没有匹配的命令')).toBeVisible(); + await page.screenshot({ path: info.outputPath('command-empty.png') }); + expect(errors).toEqual([]); + }); + } + +for (const theme of ['dark', 'light'] as const) + test(`源码与导入风险 ${theme}`, async ({ page }, info) => { + await page.setViewportSize({ width: 768, height: 800 }); + await page.addInitScript( + (value) => localStorage.setItem('mujoco-platform-theme', value), + theme, + ); + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto('/'); + await page.locator('#mujoco-project-files').setInputFiles({ + name: 'model.urdf', + mimeType: 'text/xml', + buffer: Buffer.from( + '', + ), + }); + const urdf = page.getByRole('dialog', { name: '配置 URDF 仿真组件' }); + await expect(urdf.getByText(/生成不限幅 motor 控制输入/)).toBeVisible(); + await page.screenshot({ path: info.outputPath('urdf-options.png') }); + await urdf.getByRole('button', { name: '转换并加载' }).click(); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); + await page.getByRole('button', { name: '通知中心' }).click(); + const notifications = page.getByRole('dialog', { name: '通知中心' }); + for (const summary of await notifications.getByText('事件详情', { exact: true }).all()) + await summary.click(); + await page.screenshot({ path: info.outputPath('notifications.png') }); + await page.getByRole('button', { name: '事件日志', exact: true }).click(); + await expect(page.getByRole('dialog', { name: '诊断与事件日志' })).toBeVisible(); + await page.screenshot({ path: info.outputPath('diagnostics.png') }); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: '工程', exact: true }).click(); + await page.getByRole('button', { name: '源代码', exact: true }).click(); + const source = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' }); + await expect(source.locator('.monaco-editor')).toBeVisible(); + await noOverflow(page); + await page.screenshot({ path: info.outputPath('source.png') }); + await source.locator('.monaco-editor').click({ position: { x: 200, y: 100 } }); + await page.keyboard.press('Control+End'); + await page.keyboard.insertText('\n'); + await expect(source.getByText('已修改', { exact: true })).toBeVisible(); + await page.keyboard.press('Escape'); + const confirm = page.getByRole('dialog', { name: '放弃未保存的修改?' }); + await expect(confirm.getByText(/无法恢复/)).toBeVisible(); + await page.screenshot({ path: info.outputPath('confirm.png') }); + await page.keyboard.press('Escape'); + await expect(confirm).toBeHidden(); + await expect(source).toBeVisible(); + await source.getByRole('button', { name: '关闭源代码编辑器' }).click(); + await confirm.getByRole('button', { name: '放弃修改' }).click(); + await expect(source).toBeHidden(); + expect(errors).toEqual([]); + }); + +test('主工作台与独立调参标签页双向同步主题', async ({ page, context }) => { + await page.goto('/'); + const tuning = await context.newPage(); + await tuning.goto('/tuning.html'); + await page.getByRole('button', { name: '更多工作台操作' }).click(); + await page.getByRole('menuitem', { name: '切换主题' }).click(); + await expect(tuning.locator('#root > div')).toHaveClass(/theme-light/); + await tuning.getByRole('button', { name: '切换到黑夜主题' }).click(); + await expect(page.locator('#root > div')).toHaveClass(/theme-dark/); + await tuning.close(); +}); + +for (const theme of ['dark', 'light'] as const) + test(`连接错误与入口选择 ${theme}`, async ({ page }, info) => { + await page.setViewportSize({ width: 768, height: 800 }); + await page.addInitScript( + (value) => localStorage.setItem('mujoco-platform-theme', value), + theme, + ); + await page.route('**/api/tuning/**', (route) => + route.fulfill({ status: 503, json: { error: '训练服务未就绪,请检查本地服务' } }), + ); + await page.goto('/tuning.html'); + await page.getByLabel('访问令牌(仅当前标签页)').fill('ui-mock-token'); + await page.getByRole('button', { name: '连接/刷新' }).click(); + await expect(page.getByRole('alert')).toBeInViewport(); + await expect(page.getByRole('button', { name: '关闭错误' })).toBeVisible(); + await page.screenshot({ path: info.outputPath('tuning-error.png') }); + await page.goto('/'); + await page.locator('#mujoco-project-files').setInputFiles( + ['first.xml', 'second.xml'].map((name) => ({ + name, + mimeType: 'text/xml', + buffer: Buffer.from( + '', + ), + })), + ); + const entries = page.getByRole('dialog', { name: '选择模型入口' }); + await expect(entries).toBeVisible(); + await page.screenshot({ path: info.outputPath('entry-selection.png') }); + await entries.getByRole('button', { name: /first.xml/ }).click(); + await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); + await page.locator('#mujoco-project-files').setInputFiles({ + name: 'bad.xml', + mimeType: 'text/xml', + buffer: Buffer.from( + '', + ), + }); + await expect(page.getByRole('alert')).toContainText('模型编译失败'); + await page.getByRole('button', { name: '技术详情', exact: true }).click(); + await noOverflow(page); + await page.screenshot({ path: info.outputPath('compile-error.png') }); + }); diff --git a/web_platform/index.html b/web_platform/index.html index 336f510d..a0b87450 100644 --- a/web_platform/index.html +++ b/web_platform/index.html @@ -10,6 +10,14 @@ /> MuJoCo Web 仿真平台 +
-
-
M
-
正在启动本地仿真工作台…
-
+ 正在启动本地仿真工作台…
diff --git a/web_platform/src/app/App.tsx b/web_platform/src/app/App.tsx index a9ff0d6a..84d5db59 100644 --- a/web_platform/src/app/App.tsx +++ b/web_platform/src/app/App.tsx @@ -1,3 +1,4 @@ +import { useThemePreference } from './hooks/useThemePreference'; import { resolvePolicyDeployment, type PolicyDeployment } from '../rl/deployment'; /* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */ /* eslint-disable react-hooks/exhaustive-deps */ @@ -29,7 +30,6 @@ import { Pause, Play, RotateCcw, - Settings as SettingsIcon, SlidersHorizontal, SunMoon, } from 'lucide-react'; @@ -50,7 +50,7 @@ import type { ActuatorParameters } from '../simulation/SimulationSession'; import type { DataRecorderConfig } from '../telemetry/DataRecorder'; import type { ControllerCommand, ControllerStatus } from '../controller/types'; import type { RLCommand, RLPolicyStatus } from '../rl/types'; -import type { MuJoCoViewer, InteractionMode, ViewerTheme } from '../viewer/MuJoCoViewer'; +import type { MuJoCoViewer, InteractionMode } from '../viewer/MuJoCoViewer'; import { DEFAULT_VIEWER_DISPLAY_OPTIONS, type ViewerDisplayOptions, @@ -68,11 +68,15 @@ import { ModelControlsSidebar } from './components/ModelControlsSidebar'; import { ProjectSidebar, type ProjectResourceTab } from './components/ProjectSidebar'; import type { WorkspaceTool } from './components/WorkspaceToolsPanel'; import type { EditorSelection } from './editorSelection'; +import { isTextEditingTarget } from './keyboard'; import { useMapEditorShortcuts } from './hooks/useMapEditorShortcuts'; import { WorkspaceOverlays, type ImportProgress } from './components/WorkspaceOverlays'; import { EntrySelectionDialog } from './components/EntrySelectionDialog'; import { ErrorRecoveryPanel } from './components/ErrorRecoveryPanel'; -import { StoreStatusBar } from './components/StatusBar'; +import { useSidebarLayout } from './hooks/useSidebarLayout'; +import { ViewportOverlayLayout } from './components/ViewportOverlayLayout'; +import { SimulationControls } from './components/SimulationControls'; +import { ViewerDisplayPopover } from './components/ViewerDisplayPopover'; import { ViewportHUD } from './components/ViewportHUD'; import { ShortcutHelpDialog } from './components/ShortcutHelpDialog'; import { CommandPalette, type WorkbenchCommand } from './components/CommandPalette'; @@ -88,6 +92,7 @@ import { type LayoutPreset, } from './components/LayoutSettingsDialog'; import { Button, ConfirmDialog, IconButton } from '../components/ui'; +import { PanelWidthBudgetContext } from '../components/ui/panelWidthBudget'; import { DiagnosticsDrawer } from './components/DiagnosticsDrawer'; import { ToolbarOverflowMenu } from './components/ToolbarOverflowMenu'; @@ -162,28 +167,6 @@ function diagnostic( const detail = error instanceof Error ? error.message : String(error); return { category, summary: `${category}失败`, detail, path, at: Date.now() }; } -function initialTheme(): ViewerTheme { - try { - return localStorage.getItem('mujoco-platform-theme') === 'light' ? 'light' : 'dark'; - } catch { - return 'dark'; - } -} -function initialSidebarVisibility(): { left: boolean; right: boolean } { - const width = typeof window === 'undefined' ? 1280 : window.innerWidth; - if (width < 900) return { left: false, right: false }; - try { - const stored = JSON.parse(localStorage.getItem('mujoco-platform-layout') ?? 'null') as { - left?: unknown; - right?: unknown; - } | null; - if (stored && typeof stored.left === 'boolean' && typeof stored.right === 'boolean') - return { left: stored.left, right: stored.right }; - } catch { - /* 使用响应式默认布局 */ - } - return width >= 1280 ? { left: true, right: true } : { left: false, right: true }; -} function initialDisplayOptions(): ViewerDisplayOptions { try { const stored = JSON.parse( @@ -298,9 +281,17 @@ export function App() { addSensors: true, sensorType: 'camera', }); + const { + width: workspaceWidth, + leftOpen, + rightOpen, + setLeftOpen, + setRightOpen, + } = useSidebarLayout(); + const sensorCameraFrame = useRef(null); + const orientationHost = useRef(null); + const [mapCommitState, setMapCommitState] = useState<'idle' | 'submitting' | 'failed'>('idle'); const [forceScale, setForceScale] = useState(50), - [leftOpen, setLeftOpen] = useState(() => initialSidebarVisibility().left), - [rightOpen, setRightOpen] = useState(() => initialSidebarVisibility().right), [helpOpen, setHelpOpen] = useState(false), [commandOpen, setCommandOpen] = useState(false), [sourceOpen, setSourceOpen] = useState(false), @@ -356,7 +347,7 @@ export function App() { [showMapCollision, setShowMapCollision] = useState(false); const [displayOptions, setDisplayOptions] = useState(initialDisplayOptions), [showSensorCamera, setShowSensorCamera] = useState(true), - [theme, setTheme] = useState(initialTheme), + [theme, setTheme] = useThemePreference(), [jointAdvanced, setJointAdvanced] = useState(false), [ignoreJointLimits, setIgnoreJointLimits] = useState(false), [angleUnit, setAngleUnit] = useState<'rad' | 'deg'>('rad'); @@ -512,6 +503,8 @@ export function App() { next.setDisplayOptions(settings.displayOptions); next.setMapDisplay(settings.showVisualMap, settings.showMapCollision); next.setShowSensorCamera(settings.showSensorCamera); + next.setSensorCameraViewportElement(sensorCameraFrame.current); + if (orientationHost.current) next.setOrientationGizmoHost(orientationHost.current); next.setTheme(settings.theme); next.setParametricMapAssets( placedMapAssetsRef.current, @@ -570,28 +563,11 @@ export function App() { useEffect(() => { viewer.current?.setShowPerceptionRays(showPerceptionRays); }, [showPerceptionRays]); - useEffect(() => { - if (window.innerWidth < 900) return; - try { - localStorage.setItem( - 'mujoco-platform-layout', - JSON.stringify({ left: leftOpen, right: rightOpen }), - ); - } catch { - /* 当前会话仍可修改 */ - } - }, [leftOpen, rightOpen]); useEffect(() => { viewer.current?.setShowSensorCamera(showSensorCamera); }, [showSensorCamera]); useEffect(() => { viewer.current?.setTheme(theme); - document.documentElement.style.colorScheme = theme; - try { - localStorage.setItem('mujoco-platform-theme', theme); - } catch { - /* 当前会话仍可切换 */ - } }, [theme]); useEffect(() => { const change = () => setFullscreen(document.fullscreenElement === root.current); @@ -680,6 +656,7 @@ export function App() { ); } state.setSnapshot(snapshot); + setMapCommitState('idle'); state.setSelection(null); setEditorSelection(null); state.setPaused(true); @@ -716,6 +693,8 @@ export function App() { detail: [...snapshot.warnings, ...(visualMapWarning ? [visualMapWarning] : [])].join('\n') || path, + message: `${snapshot.model.nbody} Bodies · ${snapshot.model.njnt} Joints · ${snapshot.model.ngeom} Geoms`, + category: 'compile', tone: snapshot.warnings.length || visualMapWarning ? 'warning' : 'success', at: Date.now(), }; @@ -749,6 +728,8 @@ export function App() { id: ++notificationId.current, title: '模型编译失败', detail: error instanceof Error ? error.message : String(error), + message: path, + category: 'compile', tone: 'danger', at: Date.now(), }; @@ -857,6 +838,23 @@ export function App() { next.entries, next.selectedEntry, ); + const importNotice: WorkbenchNotification = { + id: ++notificationId.current, + title: '工程导入完成', + message: `${next.files.length} files · ${next.entries.length} entries · ${next.maps.length} maps`, + detail: [ + `工程:${next.name}`, + `文件:${next.files.length}`, + `模型入口:${next.entries.length}`, + `地图:${next.maps.length}`, + next.selectedEntry ? `默认入口:${next.selectedEntry}` : '默认入口:未选择', + ].join('\n'), + category: 'import', + tone: 'success', + at: Date.now(), + }; + setNotifications((items) => [importNotice, ...items].slice(0, 20)); + setToast(importNotice); if (next.selectedEntry) await requestLoadEntry(next.selectedEntry); } catch (error) { state.setDiagnostic( @@ -870,6 +868,8 @@ export function App() { id: ++notificationId.current, title: '工程导入失败', detail: error instanceof Error ? error.message : String(error), + message: error instanceof ProjectImportError ? error.path : undefined, + category: 'import', tone: 'danger', at: Date.now(), }; @@ -887,6 +887,7 @@ export function App() { if (state.projectName) setRemoveConfirmOpen(true); }; const confirmRemoveProject = () => { + setMapCommitState('idle'); void viewer.current?.setVisualMaps([]); viewer.current?.attach(null); adapter.current.dispose(); @@ -1477,7 +1478,7 @@ export function App() { adapter.current.setPaused(true); useAppStore.getState().setPaused(true); }; - const commitMapScene = async ( + const performMapSceneCommit = async ( requestedDrafts: ReadonlyMap = editorDraftsRef.current, ): Promise => { const entry = state.entries.find((candidate) => candidate.path === state.selectedEntry); @@ -1559,8 +1560,23 @@ export function App() { setEditorSelection(selectedMapAssetId ? { kind: 'map', mapAssetId: selectedMapAssetId } : null); return true; }; + const commitMapScene = async ( + requestedDrafts: ReadonlyMap = editorDraftsRef.current, + ): Promise => { + if (state.loading || loadInFlight.current) return false; + setMapCommitState('submitting'); + try { + const applied = await performMapSceneCommit(requestedDrafts); + setMapCommitState(applied ? 'idle' : 'failed'); + return applied; + } catch (error) { + setMapCommitState('failed'); + throw error; + } + }; const discardMapSceneDraft = () => { if (state.loading || loadInFlight.current) return; + setMapCommitState('idle'); editorInteraction.current?.onDiscard(); clearEditorDrafts(); const omittedPaths = new Set(); @@ -2246,7 +2262,7 @@ export function App() { ) return; const target = event.target instanceof HTMLElement ? event.target : null; - if (target?.closest('input,select,textarea,[contenteditable="true"]')) return; + if (isTextEditingTarget(target)) return; if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k') { event.preventDefault(); setCommandOpen(true); @@ -2392,84 +2408,87 @@ export function App() { run: () => setHelpOpen(true), }, ]; - const workspaceTools = workspaceTool ? ( - - 正在加载工作区工具… - - } - > - /\.py$/i.test(file.path)) - .map((file) => file.path)} - selectedControllerPath={selectedControllerPath} - controllerStatus={controllerStatus} - policyPaths={state.files - .filter((file) => /\.onnx$/i.test(file.path)) - .map((file) => file.path)} - selectedPolicyPath={selectedPolicyPath} - policyStatus={policyStatus} - onResetJoints={resetJoints} - onToggleJointLimits={toggleJointLimits} - onToggleAdvanced={() => setJointAdvanced((value) => !value)} - onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))} - onActuator={setActuator} - onActuatorParameters={setActuatorParameters} - onJoint={setJoint} - onForceScale={setForceScale} - onSelectControllerPath={setSelectedControllerPath} - onLoadControllerPath={loadControllerPath} - onImportController={importController} - onToggleController={toggleController} - onControllerCommand={sendControllerCommand} - onRemoveController={removeController} - onSelectPolicyPath={setSelectedPolicyPath} - onLoadPolicyPath={loadPolicyPath} - onImportPolicy={importPolicy} - compileTrainingScene={(coordinates) => { - if ( - mapSceneDirty || - trainingDeployment || - useAppStore.getState().loading || - loadInFlight.current - ) - throw new Error('请先应用地图草稿;训练部署/加载中的场景不能同步'); - return adapter.current.exportTrainingTerrain(appliedMapAssets, coordinates); - }} - trainingSceneMaps={appliedMapAssets} - trainingSceneDirty={mapSceneDirty || Boolean(trainingDeployment)} - onTogglePolicy={togglePolicy} - onPolicyCommand={setPolicyCommand} - navigationTargetMode={navigationTargetMode} - onNavigationTargetMode={(active) => viewer.current?.setNavigationTargetMode(active)} - onResetNavigationTarget={() => { - viewer.current?.setNavigationTargetMode(false); - adapter.current.resetNavigationTarget(); - const snapshot = adapter.current.snapshot(); - state.setSnapshot(snapshot ?? undefined); - setPolicyStatus(snapshot?.rlPolicy); - }} - onRemovePolicy={removePolicy} - onDataRecorderConfigure={configureDataRecorder} - onDataRecordingStart={startDataRecording} - onDataRecordingStop={stopDataRecording} - onDataRecordingClear={clearDataRecording} - onDataRecordingExport={exportDataRecording} - /> - - ) : undefined; + const [workspaceToolsVisited, setWorkspaceToolsVisited] = useState(false); + if (workspaceTool && !workspaceToolsVisited) setWorkspaceToolsVisited(true); + const workspaceTools = + workspaceToolsVisited || workspaceTool ? ( + + 正在加载工作区工具… + + } + > + /\.py$/i.test(file.path)) + .map((file) => file.path)} + selectedControllerPath={selectedControllerPath} + controllerStatus={controllerStatus} + policyPaths={state.files + .filter((file) => /\.onnx$/i.test(file.path)) + .map((file) => file.path)} + selectedPolicyPath={selectedPolicyPath} + policyStatus={policyStatus} + onResetJoints={resetJoints} + onToggleJointLimits={toggleJointLimits} + onToggleAdvanced={() => setJointAdvanced((value) => !value)} + onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))} + onActuator={setActuator} + onActuatorParameters={setActuatorParameters} + onJoint={setJoint} + onForceScale={setForceScale} + onSelectControllerPath={setSelectedControllerPath} + onLoadControllerPath={loadControllerPath} + onImportController={importController} + onToggleController={toggleController} + onControllerCommand={sendControllerCommand} + onRemoveController={removeController} + onSelectPolicyPath={setSelectedPolicyPath} + onLoadPolicyPath={loadPolicyPath} + onImportPolicy={importPolicy} + compileTrainingScene={(coordinates) => { + if ( + mapSceneDirty || + trainingDeployment || + useAppStore.getState().loading || + loadInFlight.current + ) + throw new Error('请先应用地图草稿;训练部署/加载中的场景不能同步'); + return adapter.current.exportTrainingTerrain(appliedMapAssets, coordinates); + }} + trainingSceneMaps={appliedMapAssets} + trainingSceneDirty={mapSceneDirty || Boolean(trainingDeployment)} + onTogglePolicy={togglePolicy} + onPolicyCommand={setPolicyCommand} + navigationTargetMode={navigationTargetMode} + onNavigationTargetMode={(active) => viewer.current?.setNavigationTargetMode(active)} + onResetNavigationTarget={() => { + viewer.current?.setNavigationTargetMode(false); + adapter.current.resetNavigationTarget(); + const snapshot = adapter.current.snapshot(); + state.setSnapshot(snapshot ?? undefined); + setPolicyStatus(snapshot?.rlPolicy); + }} + onRemovePolicy={removePolicy} + onDataRecorderConfigure={configureDataRecorder} + onDataRecordingStart={startDataRecording} + onDataRecordingStop={stopDataRecording} + onDataRecordingClear={clearDataRecording} + onDataRecordingExport={exportDataRecording} + /> + + ) : undefined; return (
setSourceOpen(true)} - onTogglePause={togglePause} - onStep={singleStep} - onReset={reset} - onSpeed={changeSpeed} onToggleLeft={() => setLeftOpen((value) => !value)} onToggleRight={() => setRightOpen((value) => !value)} - onToggleTheme={() => setTheme((value) => (value === 'dark' ? 'light' : 'dark'))} - onHelp={() => setHelpOpen(true)} endActions={ <> setNotifications([])} onOpenLog={() => setDiagnosticsOpen(true)} /> - - setLayoutOpen(true)} - > - - - setSettingsOpen(true)} - > - - - + setLayoutOpen(true)} + > + + } compactMenu={ @@ -2541,250 +2540,291 @@ export function App() { /> } onCommands={() => setCommandOpen(true)} - onToggleFullscreen={toggleFullscreen} - center={ - + = 1024 ? (workspaceWidth - 480) / 2 : undefined + } + > +
+ { + state.setSelection(null); + setEditorSelection({ kind: 'body', bodyId }); + viewer.current?.selectMapEditorObject(null); + viewer.current?.selectParametricMapAsset(null); + viewer.current?.highlightJoint(null); + setRightOpen(true); + }} + onSelectJoint={(jointId, bodyId) => { + state.setSelection(null); + setEditorSelection({ kind: 'joint', jointId, bodyId }); + viewer.current?.selectMapEditorObject(null); + viewer.current?.selectParametricMapAsset(null); + viewer.current?.highlightJoint(jointId); + setRightOpen(true); + }} + onJointHover={(jointId) => + viewer.current?.highlightJoint( + jointId ?? (editorSelection?.kind === 'joint' ? editorSelection.jointId : null), + ) + } + onAddMapAsset={(type, placementMode) => + addCertifiedMapAsset(type, undefined, placementMode) + } + onAddProjectMap={addProjectMapAsset} + onSelectTerrain={selectTerrainAsset} + onAssetPlacementModeChange={setAssetPlacementMode} + onApplyScene={() => void commitMapScene()} + onDiscardScene={discardMapSceneDraft} + onSelectMap={activateMapAsset} + onRemoveMap={removePlacedMapAsset} + onSelectMapObject={(mapId, objectId) => activateMapAsset(mapId, objectId)} + /> +
+
+ } + orientation={
} + view={ +
+ + viewer.current?.resetCamera()} + > + + +
+ } + controls={ +
+ + + +
+ } + draft={ + void commitMapScene()} + onDiscard={discardMapSceneDraft} + /> + } + notices={ +
+ {state.diagnostic && ( + state.setDiagnostic(undefined)} + onRetry={ + state.diagnostic.category === '模型编译' && state.diagnostic.path + ? () => void loadEntry(state.diagnostic!.path!) + : undefined + } + onOpenProject={() => { + setLeftOpen(true); + state.setDiagnostic(undefined); + }} + /> + )} + {!state.diagnostic && ( + setToast(undefined)} /> + )} +
+ } + context={ + trainingDeployment && ( +
+ 训练配套物理地图(编辑器地图未更改,重载模型恢复)。 + {trainingDeployment.terrain?.approximation && '训练专用离散近似。'} + {(policyStatus?.observationSize === 81 || + policyStatus?.observationSize === 97) && ( + + )} +
+ ) + } + camera={ + Boolean(state.snapshot?.model.ncam) && + (showSensorCamera ? ( +
{ + sensorCameraFrame.current = node; + viewer.current?.setSensorCameraViewportElement(node); + }} + className="sensor-camera-frame overflow-hidden rounded-lg border border-border-strong shadow-xl" + > +
+ + + 摄像头 + + +
+
+ ) : ( + + )) + } + /> + + + {state.entries.length > 1 && !state.selectedEntry && !pendingUrdfPath && ( + + )}{' '} +
+ asset.id === activeMapAssetId)?.name + } + mapSceneDirty={mapSceneDirty} + maps={projectMaps} + showVisualMap={showVisualMap} + showMapCollision={showMapCollision} + editorDocument={editorDocument} + editorDraftDocument={ + mapSelection.kind === 'project' + ? editorDrafts.get(mapSelection.descriptorPath) : undefined } - onModeChange={mode} - onDisplayChange={setDisplayOptions} - onResetCamera={() => viewer.current?.resetCamera()} + workspaceTool={workspaceTool} + workspaceTools={workspaceTools} + onWorkspaceToolChange={(tool) => { + setWorkspaceTool(tool); + if (tool) setRightOpen(true); + }} + onSelectJoint={(jointId, bodyId) => { + setEditorSelection({ kind: 'joint', jointId, bodyId }); + viewer.current?.highlightJoint(jointId); + }} + onUrdfMode={changeUrdfMode} + onBaseMode={changeBaseMode} + onShowCollision={setShowCollision} + onResetJoints={resetJoints} + onToggleJointLimits={toggleJointLimits} + onToggleAdvanced={() => setJointAdvanced((value) => !value)} + onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))} + onActuator={setActuator} + onActuatorParameters={setActuatorParameters} + onJoint={setJoint} + onApplyMap={applyMapSelection} + onMapDraft={stageMapSelectionDraft} + onEditorPreview={previewEditorDocument} + onEditorDraftChange={updateEditorDraft} + onEditorApply={applyEditorDocument} + onEditorExport={exportSelectedMap} + onEditorConvert={convertSelectedMap} + onEditorBindInteraction={bindEditorInteraction} + onEditorSelect={selectEditorObject} + onEditorSessionStateChange={updateEditorSessionState} + onEditorSurfaceHeight={editorSurfaceHeight} + onMapDisplay={(visual, collision) => { + setShowVisualMap(visual); + setShowMapCollision(collision); + }} /> - } - /> -
- { - state.setSelection(null); - setEditorSelection({ kind: 'body', bodyId }); - viewer.current?.selectMapEditorObject(null); - viewer.current?.selectParametricMapAsset(null); - viewer.current?.highlightJoint(null); - setRightOpen(true); - }} - onSelectJoint={(jointId, bodyId) => { - state.setSelection(null); - setEditorSelection({ kind: 'joint', jointId, bodyId }); - viewer.current?.selectMapEditorObject(null); - viewer.current?.selectParametricMapAsset(null); - viewer.current?.highlightJoint(jointId); - setRightOpen(true); - }} - onJointHover={(jointId) => - viewer.current?.highlightJoint( - jointId ?? (editorSelection?.kind === 'joint' ? editorSelection.jointId : null), - ) - } - onAddMapAsset={(type, placementMode) => - addCertifiedMapAsset(type, undefined, placementMode) - } - onAddProjectMap={addProjectMapAsset} - onSelectTerrain={selectTerrainAsset} - onAssetPlacementModeChange={setAssetPlacementMode} - onApplyScene={() => void commitMapScene()} - onDiscardScene={discardMapSceneDraft} - onSelectMap={activateMapAsset} - onRemoveMap={removePlacedMapAsset} - onSelectMapObject={(mapId, objectId) => activateMapAsset(mapId, objectId)} - /> -
-
- - - void commitMapScene()} - onDiscard={discardMapSceneDraft} - /> - - - setToast(undefined)} /> - {trainingDeployment && ( -
- 训练配套物理地图(编辑器地图未更改,重载模型恢复)。 - {trainingDeployment.terrain?.approximation && '训练专用离散近似。'} - {(policyStatus?.observationSize === 81 || policyStatus?.observationSize === 97) && ( - - )} -
- )} - {Boolean(state.snapshot?.model.ncam) && - (showSensorCamera ? ( -
-
- - - 摄像头 - - -
-
- ) : ( - - ))} - {state.entries.length > 1 && !state.selectedEntry && !pendingUrdfPath && ( - - )}{' '} - {state.diagnostic && ( - state.setDiagnostic(undefined)} - onRetry={ - state.diagnostic.category === '模型编译' && state.diagnostic.path - ? () => void loadEntry(state.diagnostic!.path!) - : undefined - } - onOpenProject={() => { - setLeftOpen(true); - state.setDiagnostic(undefined); - }} - /> - )} -
- asset.id === activeMapAssetId)?.name} - mapSceneDirty={mapSceneDirty} - maps={projectMaps} - showVisualMap={showVisualMap} - showMapCollision={showMapCollision} - editorDocument={editorDocument} - editorDraftDocument={ - mapSelection.kind === 'project' - ? editorDrafts.get(mapSelection.descriptorPath) - : undefined - } - workspaceTool={workspaceTool} - workspaceTools={workspaceTools} - onWorkspaceToolChange={(tool) => { - setWorkspaceTool(tool); - if (tool) setRightOpen(true); - }} - onSelectJoint={(jointId, bodyId) => { - setEditorSelection({ kind: 'joint', jointId, bodyId }); - viewer.current?.highlightJoint(jointId); - }} - onUrdfMode={changeUrdfMode} - onBaseMode={changeBaseMode} - onShowCollision={setShowCollision} - onResetJoints={resetJoints} - onToggleJointLimits={toggleJointLimits} - onToggleAdvanced={() => setJointAdvanced((value) => !value)} - onToggleAngleUnit={() => setAngleUnit((value) => (value === 'rad' ? 'deg' : 'rad'))} - onActuator={setActuator} - onActuatorParameters={setActuatorParameters} - onJoint={setJoint} - onApplyMap={applyMapSelection} - onMapDraft={stageMapSelectionDraft} - onEditorPreview={previewEditorDocument} - onEditorDraftChange={updateEditorDraft} - onEditorApply={applyEditorDocument} - onEditorExport={exportSelectedMap} - onEditorConvert={convertSelectedMap} - onEditorBindInteraction={bindEditorInteraction} - onEditorSelect={selectEditorObject} - onEditorSessionStateChange={updateEditorSessionState} - onEditorSurfaceHeight={editorSurfaceHeight} - onMapDisplay={(visual, collision) => { - setShowVisualMap(visual); - setShowMapCollision(collision); - }} - /> -
+
+
{pendingUrdfPath && (

该操作不会删除本地文件。

-
); } diff --git a/web_platform/src/app/ErrorBoundary.test.tsx b/web_platform/src/app/ErrorBoundary.test.tsx new file mode 100644 index 00000000..c4ebef24 --- /dev/null +++ b/web_platform/src/app/ErrorBoundary.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react'; +import { ErrorBoundary } from './ErrorBoundary'; +it('致命错误保留主题、完整错误与重新加载风险', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + localStorage.setItem('mujoco-platform-theme', 'light'); + function Broken(): never { + throw new Error('渲染失败'); + } + render( + + + , + ); + expect(screen.getByRole('main')).toHaveClass('theme-light'); + expect(screen.getByText('渲染失败')).toBeVisible(); + expect(screen.getByText(/重新加载会丢失/)).toBeVisible(); + expect(screen.getByRole('button', { name: '重新加载' })).toBeEnabled(); +}); diff --git a/web_platform/src/app/ErrorBoundary.tsx b/web_platform/src/app/ErrorBoundary.tsx index 5f8646d2..d0d0d8fa 100644 --- a/web_platform/src/app/ErrorBoundary.tsx +++ b/web_platform/src/app/ErrorBoundary.tsx @@ -1,3 +1,4 @@ +import { useThemePreference } from './hooks/useThemePreference'; import { Component, type ErrorInfo, type ReactNode } from 'react'; import { Button } from '../components/ui'; export class ErrorBoundary extends Component<{ children: ReactNode }, { error?: Error }> { @@ -9,20 +10,26 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { error?: console.error('React fatal error', error, info); } render() { - return this.state.error ? ( -
-
-

界面发生致命错误

-
-            {this.state.error.message}
-          
- -
-
- ) : ( - this.props.children - ); + return this.state.error ? : this.props.children; } } + +function FatalError({ error }: { error: Error }) { + const [theme] = useThemePreference(); + return ( +
+
+

界面发生致命错误

+
+          {error.message}
+        
+

重新加载会丢失当前浏览器会话中未保存的修改。

+ +
+
+ ); +} diff --git a/web_platform/src/app/components/CommandPalette.tsx b/web_platform/src/app/components/CommandPalette.tsx index e79ada07..9eee2be7 100644 --- a/web_platform/src/app/components/CommandPalette.tsx +++ b/web_platform/src/app/components/CommandPalette.tsx @@ -35,6 +35,12 @@ export function CommandPalette({ useEffect(() => { if (open) requestAnimationFrame(() => input.current?.focus()); }, [open]); + useEffect(() => { + if (open && highlighted >= 0) + document + .getElementById(`${listId}-${filtered[highlighted].id}`) + ?.scrollIntoView?.({ block: 'nearest' }); + }, [open, highlighted, filtered, listId]); const close = () => { setQuery(''); setActive(0); @@ -105,8 +111,8 @@ export function CommandPalette({ > {command.icon} - {command.label} - {command.group} + {command.label} + {command.group} {command.shortcut && {command.shortcut}} diff --git a/web_platform/src/app/components/DiagnosticNotice.tsx b/web_platform/src/app/components/DiagnosticNotice.tsx index 957224a0..4e0fee11 100644 --- a/web_platform/src/app/components/DiagnosticNotice.tsx +++ b/web_platform/src/app/components/DiagnosticNotice.tsx @@ -22,7 +22,7 @@ export function DiagnosticNotice({

{value.summary}

{value.path && ( -

+

路径:{value.path}

)} @@ -41,7 +41,7 @@ export function DiagnosticNotice({
{expanded && ( -
+        
           {value.detail}
         
)} diff --git a/web_platform/src/app/components/DiagnosticsDrawer.tsx b/web_platform/src/app/components/DiagnosticsDrawer.tsx index 1137db8b..08bcc76c 100644 --- a/web_platform/src/app/components/DiagnosticsDrawer.tsx +++ b/web_platform/src/app/components/DiagnosticsDrawer.tsx @@ -1,8 +1,24 @@ -import { useState } from 'react'; -import { CheckCircle2, Info, TriangleAlert, XCircle } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { + CheckCircle2, + FileInput, + Info, + TerminalSquare, + TriangleAlert, + XCircle, +} from 'lucide-react'; import { Button, CopyButton, Dialog, Tabs } from '../../components/ui'; import type { WorkbenchNotification } from './NotificationCenter'; -type Filter = 'all' | 'warning' | 'danger'; + +type Filter = 'all' | 'import' | 'warning' | 'danger'; + +const toneMeta = { + danger: { icon: XCircle, color: 'text-danger', label: 'ERROR' }, + warning: { icon: TriangleAlert, color: 'text-warning', label: 'WARN' }, + success: { icon: CheckCircle2, color: 'text-success', label: 'OK' }, + info: { icon: Info, color: 'text-accent', label: 'INFO' }, +} as const; + export function DiagnosticsDrawer({ open, items, @@ -15,37 +31,59 @@ export function DiagnosticsDrawer({ onClear: () => void; }) { const [filter, setFilter] = useState('all'); + const groups = useMemo( + () => ({ + all: items, + import: items.filter((item) => item.category === 'import'), + warning: items.filter((item) => item.tone === 'warning'), + danger: items.filter((item) => item.tone === 'danger'), + }), + [items], + ); + const content = (value: Filter) => { - const filtered = items.filter((item) => value === 'all' || item.tone === value); + const filtered = groups[value]; return ( -
+
{filtered.length ? ( filtered.map((item) => { - const Icon = - item.tone === 'danger' - ? XCircle - : item.tone === 'warning' - ? TriangleAlert - : item.tone === 'success' - ? CheckCircle2 - : Info; + const meta = toneMeta[item.tone]; + const Icon = meta.icon; + const date = new Date(item.at); + const validDate = Number.isFinite(date.getTime()); return ( -
-
- -
-

{item.title}

- - {item.detail && ( -
-                        {item.detail}
-                      
+
+