feat(web-platform): release V0.9.3 全模块界面重构
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-09-08 17:57:57 +08:00
parent 831d0b95bb
commit 13e35be98b
124 changed files with 5182 additions and 2141 deletions
+8
View File
@@ -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因起终点编辑使同步状态持续失效而无法训练:移除训练面板起终点坐标显示与编辑,训练/调参启动时自动重新编译已应用碰撞场景,并自动选择满足净空、最小距离和连通性约束的参考点。
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mujoco-web-platform",
"version": "0.9.2",
"version": "0.9.3",
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
"private": true,
"type": "module",
+171 -89
View File
@@ -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 = `
<mujoco model="e2e">
<compiler angle="radian"/>
@@ -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(/kpMJCF stiffness/),
kv = page.getByLabel(/kvMJCF 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('位置 Xm').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']);
});
+7 -2
View File
@@ -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);
});
+6 -6
View File
@@ -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('</mujoco>', actuators + '</mujoco>')),
});
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();
@@ -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 策略运行/ });
@@ -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();
+99
View File
@@ -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,
},
};
}
+191
View File
@@ -0,0 +1,191 @@
import { expect, test, type Page } from '@playwright/test';
const MODEL = `<mujoco model="domain-panels"><worldbody><geom type="plane" size="3 3 .1"/><body name="box" pos="0 0 1"><joint name="slide" type="slide" axis="1 0 0" range="-1 1"/><geom type="box" size=".2 .2 .2" mass="1"/></body></worldbody><actuator><motor name="motor" joint="slide" ctrlrange="-2 2"/></actuator></mujoco>`;
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);
});
}
+106
View File
@@ -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();
});
+354
View File
@@ -0,0 +1,354 @@
import { expect, test, type Page } from '@playwright/test';
import { zipSync } from 'fflate';
const MODEL = `<mujoco model="layout-camera"><worldbody>
<light pos="0 0 3"/><geom type="plane" size="3 3 .1"/>
<camera pos="3 -3 2" xyaxes="1 1 0 -1 1 3"/>
<body name="box" pos="0 0 1"><joint name="slide" type="slide" axis="1 0 0" range="-1 1"/>
<geom type="box" size=".2 .2 .2" mass="1"/></body>
</worldbody></mujoco>`;
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<HTMLElement>('[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(
'<mujoco><worldbody><body><joint/><geom type="box" size="1 1 1"/></body></worldbody></mujoco>',
),
});
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('视口状态')],
});
});
}
+284
View File
@@ -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<HTMLElement>('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(
'<robot name="jointed"><link name="base"><inertial><mass value="1"/><origin xyz="0 0 0"/><inertia ixx=".1" iyy=".1" izz=".1" ixy="0" ixz="0" iyz="0"/></inertial><visual><geometry><box size=".4 .4 .2"/></geometry></visual></link><link name="arm"><inertial><mass value=".2"/><origin xyz="0 0 .25"/><inertia ixx=".01" iyy=".01" izz=".01" ixy="0" ixz="0" iyz="0"/></inertial><visual><origin xyz="0 0 .25"/><geometry><box size=".1 .1 .5"/></geometry></visual></link><joint name="shoulder" type="revolute"><parent link="base"/><child link="arm"/><origin xyz="0 0 .1"/><axis xyz="0 1 0"/><limit lower="-1" upper="1" effort="10" velocity="2"/></joint></robot>',
),
});
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(
'<mujoco><worldbody><geom type="sphere" size=".1"/></worldbody></mujoco>',
),
})),
);
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(
'<mujoco><worldbody><geom type="sphere" size="-1"/></worldbody></mujoco>',
),
});
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') });
});
+15 -33
View File
@@ -10,6 +10,14 @@
/>
<link rel="icon" href="data:," />
<title>MuJoCo Web 仿真平台</title>
<script>
try {
document.documentElement.dataset.bootTheme =
localStorage.getItem('mujoco-platform-theme') === 'light' ? 'light' : 'dark';
} catch {
/* 默认深色,启动不依赖存储权限。 */
}
</script>
<style>
html,
body,
@@ -19,52 +27,26 @@
}
body {
background: #09111e;
color: #f1f5f9;
}
html[data-boot-theme='light'] body {
background: #f7f9fc;
color: #172b41;
}
.boot-screen {
display: grid;
height: 100%;
place-items: center;
color: #f1f5f9;
font:
13px Inter,
system-ui,
13px system-ui,
sans-serif;
}
.boot-mark {
display: grid;
width: 42px;
height: 42px;
margin: 0 auto 14px;
place-items: center;
border: 1px solid #2b604f;
border-radius: 13px;
background: #123b31;
color: #38d39f;
font-weight: 800;
box-shadow: 0 16px 48px rgb(0 0 0 / 35%);
animation: boot-pulse 1.4s ease-in-out infinite;
}
.boot-caption {
color: #8fa0b5;
font-size: 11px;
letter-spacing: 0.04em;
text-align: center;
}
@keyframes boot-pulse {
50% {
transform: translateY(-2px);
box-shadow: 0 18px 54px rgb(56 211 159 / 15%);
}
}
</style>
</head>
<body>
<div id="root">
<div class="boot-screen" role="status" aria-label="正在启动仿真工作台">
<div>
<div class="boot-mark">M</div>
<div class="boot-caption">正在启动本地仿真工作台…</div>
</div>
正在启动本地仿真工作台…
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
+432 -393
View File
@@ -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<HTMLDivElement>(null);
const orientationHost = useRef<HTMLDivElement>(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<ViewerDisplayOptions>(initialDisplayOptions),
[showSensorCamera, setShowSensorCamera] = useState(true),
[theme, setTheme] = useState<ViewerTheme>(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<string, EditableMapDocument> = editorDraftsRef.current,
): Promise<boolean> => {
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<string, EditableMapDocument> = editorDraftsRef.current,
): Promise<boolean> => {
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<string>();
@@ -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 ? (
<Suspense
fallback={
<div
role="status"
className="grid min-h-0 flex-1 place-items-center p-4 text-sm text-text-tertiary"
>
</div>
}
>
<WorkspaceToolsPanel
active={workspaceTool}
snapshot={state.snapshot}
loading={state.loading}
ignoreJointLimits={ignoreJointLimits}
jointAdvanced={jointAdvanced}
angleUnit={angleUnit}
forceScale={forceScale}
controllerPaths={state.files
.filter((file) => /\.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}
/>
</Suspense>
) : undefined;
const [workspaceToolsVisited, setWorkspaceToolsVisited] = useState(false);
if (workspaceTool && !workspaceToolsVisited) setWorkspaceToolsVisited(true);
const workspaceTools =
workspaceToolsVisited || workspaceTool ? (
<Suspense
fallback={
<div
role="status"
className="grid min-h-0 flex-1 place-items-center p-4 text-sm text-text-tertiary"
>
</div>
}
>
<WorkspaceToolsPanel
active={workspaceTool ?? 'controls'}
snapshot={state.snapshot}
loading={state.loading}
ignoreJointLimits={ignoreJointLimits}
jointAdvanced={jointAdvanced}
angleUnit={angleUnit}
forceScale={forceScale}
controllerPaths={state.files
.filter((file) => /\.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}
/>
</Suspense>
) : undefined;
return (
<div
ref={root}
@@ -2481,26 +2500,15 @@ export function App() {
onDrop={drop}
>
<WorkbenchHeader
paused={state.paused}
ready={Boolean(state.snapshot)}
speed={state.speed}
theme={theme}
loading={state.loading}
leftOpen={leftOpen}
rightOpen={rightOpen}
fullscreen={fullscreen}
hasProject={Boolean(generatedMjcf)}
onFiles={changeFiles}
onFolder={changeFiles}
onOpenSource={() => 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={
<>
<NotificationCenter
@@ -2511,22 +2519,13 @@ export function App() {
onClear={() => setNotifications([])}
onOpenLog={() => setDiagnosticsOpen(true)}
/>
<span className="hidden items-center gap-0.5 xl:flex">
<IconButton
tooltip="布局设置"
aria-label="布局设置"
onClick={() => setLayoutOpen(true)}
>
<PanelsTopLeft className="h-4 w-4" />
</IconButton>
<IconButton
tooltip="工作台设置"
aria-label="工作台设置"
onClick={() => setSettingsOpen(true)}
>
<SettingsIcon className="h-4 w-4" />
</IconButton>
</span>
<IconButton
tooltip="布局设置"
aria-label="布局设置"
onClick={() => setLayoutOpen(true)}
>
<PanelsTopLeft className="h-4 w-4" />
</IconButton>
</>
}
compactMenu={
@@ -2541,250 +2540,291 @@ export function App() {
/>
}
onCommands={() => setCommandOpen(true)}
onToggleFullscreen={toggleFullscreen}
center={
<ViewerToolDock
mode={state.mode}
display={displayOptions}
mapEditContext={
mapEditingActive
? {
active: state.mode === 'select',
label: `地图 · ${{ translate: '移动', rotate: '旋转', scale: '缩放' }[mapTransformMode]}`,
onActivate: activateMapEditing,
/>
<PanelWidthBudgetContext
value={
leftOpen && rightOpen && workspaceWidth >= 1024 ? (workspaceWidth - 480) / 2 : undefined
}
>
<div
className="workbench-body relative flex min-h-0 flex-1"
data-both-sidebars={leftOpen && rightOpen}
>
<ProjectSidebar
visible={leftOpen}
projectName={state.projectName}
files={state.files}
entries={state.entries}
selectedEntry={state.selectedEntry}
snapshot={state.snapshot}
selection={editorSelection}
loading={state.loading}
nativeUrdf={selectedFormat === 'urdf' && urdfMode === 'native'}
mapSelection={mapSelection}
maps={projectMaps}
placedMaps={placedMapAssets}
pendingSceneChangeCount={sceneDraftChangeCount}
pendingSceneIds={pendingSceneIds}
editorDocuments={sceneEditorDocuments}
assetPlacementMode={assetPlacementMode}
activeTab={projectSidebarTab}
onActiveTabChange={setProjectSidebarTab}
onRemove={removeProject}
onSelectEntry={requestLoadEntry}
onSelectBody={(bodyId) => {
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)}
/>
<main ref={viewportShell} className="viewport-shell relative min-w-0 flex-1">
<div ref={viewerHost} className="absolute inset-0" />
<ViewportOverlayLayout
status={<ViewportHUD />}
orientation={<div ref={orientationHost} />}
view={
<div
aria-label="视图工具"
className="flex items-center gap-1 rounded-lg border border-border bg-panel/90 p-1"
>
<ViewerDisplayPopover value={displayOptions} onChange={setDisplayOptions} />
<IconButton
tooltip="相机复位"
aria-label="相机复位"
onClick={() => viewer.current?.resetCamera()}
>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
</div>
}
controls={
<div className="viewport-control-surface">
<SimulationControls
paused={state.paused}
ready={Boolean(state.snapshot)}
speed={state.speed}
loading={state.loading}
onTogglePause={togglePause}
onStep={singleStep}
onReset={reset}
onSpeed={changeSpeed}
/>
<ViewerToolDock mode={state.mode} onModeChange={mode} />
<MapViewportToolbar
visible={mapEditingActive && Boolean(state.snapshot)}
interactionActive={state.mode === 'select'}
mode={mapTransformMode}
snapping={mapSnapping}
placementMode={activePlacementMode}
hasSelectedObject={Boolean(selectedMapObject)}
allowScale={
mapSelection.kind === 'project' &&
selectedMapObject?.placementMode !== 'locked'
}
loading={state.loading}
onActivate={activateMapEditing}
onModeChange={changeMapTransformMode}
onSnappingChange={setMapSnapping}
onPlacementModeChange={changeMapPlacementMode}
onAlignToSurface={alignSelectedMapObject}
/>
</div>
}
draft={
<MapDraftStatusOverlay
visible={Boolean(state.snapshot)}
changeCount={sceneDraftChangeCount}
loading={mapCommitState === 'submitting'}
error={
mapCommitState === 'failed'
? (state.diagnostic?.summary ?? '请检查诊断后重试;草稿已保留')
: undefined
}
onCommit={() => void commitMapScene()}
onDiscard={discardMapSceneDraft}
/>
}
notices={
<div className="flex max-h-[32vh] flex-col gap-2 overflow-auto">
{state.diagnostic && (
<ErrorRecoveryPanel
key={state.diagnostic.at}
value={state.diagnostic}
onClose={() => state.setDiagnostic(undefined)}
onRetry={
state.diagnostic.category === '模型编译' && state.diagnostic.path
? () => void loadEntry(state.diagnostic!.path!)
: undefined
}
onOpenProject={() => {
setLeftOpen(true);
state.setDiagnostic(undefined);
}}
/>
)}
{!state.diagnostic && (
<ToastViewport item={toast} onDismiss={() => setToast(undefined)} />
)}
</div>
}
context={
trainingDeployment && (
<div className="rounded border border-warning-border bg-panel p-2 text-xs">
{trainingDeployment.terrain?.approximation && '训练专用离散近似。'}
{(policyStatus?.observationSize === 81 ||
policyStatus?.observationSize === 97) && (
<label>
<input
type="checkbox"
checked={showPerceptionRays}
onChange={(e) => setShowPerceptionRays(e.target.checked)}
/>
线
</label>
)}
</div>
)
}
camera={
Boolean(state.snapshot?.model.ncam) &&
(showSensorCamera ? (
<div
aria-label="摄像头画面"
ref={(node) => {
sensorCameraFrame.current = node;
viewer.current?.setSensorCameraViewportElement(node);
}}
className="sensor-camera-frame overflow-hidden rounded-lg border border-border-strong shadow-xl"
>
<div className="pointer-events-auto absolute inset-x-0 top-0 flex h-7 items-center justify-between bg-black/65 px-2 text-xs font-medium text-white">
<span className="flex items-center gap-1">
<Camera className="h-3 w-3" />
</span>
<button
type="button"
className="rounded px-1.5 py-0.5 hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
onClick={() => setShowSensorCamera(false)}
>
</button>
</div>
</div>
) : (
<Button
icon={<Camera className="h-3.5 w-3.5" />}
onClick={() => setShowSensorCamera(true)}
>
</Button>
))
}
/>
<MapAssetDropIndicator target={mapAssetDropTarget} />
<WorkspaceOverlays
loading={state.loading}
hasSnapshot={Boolean(state.snapshot)}
dragActive={dragActive}
progress={importProgress}
/>
{state.entries.length > 1 && !state.selectedEntry && !pendingUrdfPath && (
<EntrySelectionDialog entries={state.entries} onSelect={requestLoadEntry} />
)}{' '}
</main>
<ModelControlsSidebar
visible={rightOpen}
snapshot={state.snapshot}
selection={editorSelection}
viewerSelection={state.selection}
selectedFormat={selectedFormat}
loading={state.loading}
urdfMode={urdfMode}
baseMode={baseMode}
showCollision={showCollision}
ignoreJointLimits={ignoreJointLimits}
jointAdvanced={jointAdvanced}
angleUnit={angleUnit}
mapSelection={mapSelection}
activeMapAssetId={activeMapAssetId}
activeMapAssetName={
placedMapAssets.find((asset) => 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);
}}
/>
}
/>
<div className="relative flex min-h-0 flex-1">
<ProjectSidebar
visible={leftOpen}
projectName={state.projectName}
files={state.files}
entries={state.entries}
selectedEntry={state.selectedEntry}
snapshot={state.snapshot}
selection={editorSelection}
loading={state.loading}
nativeUrdf={selectedFormat === 'urdf' && urdfMode === 'native'}
mapSelection={mapSelection}
maps={projectMaps}
placedMaps={placedMapAssets}
pendingSceneChangeCount={sceneDraftChangeCount}
pendingSceneIds={pendingSceneIds}
editorDocuments={sceneEditorDocuments}
assetPlacementMode={assetPlacementMode}
activeTab={projectSidebarTab}
onActiveTabChange={setProjectSidebarTab}
onRemove={removeProject}
onSelectEntry={requestLoadEntry}
onSelectBody={(bodyId) => {
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)}
/>
<main ref={viewportShell} className="viewport-shell relative min-w-0 flex-1">
<div ref={viewerHost} className="absolute inset-0" />
<ViewportHUD
paused={state.paused}
mode={state.mode}
selection={state.selection}
ready={Boolean(state.snapshot)}
mapEditing={mapEditingActive}
/>
<MapViewportToolbar
visible={mapEditingActive && Boolean(state.snapshot)}
interactionActive={state.mode === 'select'}
mode={mapTransformMode}
snapping={mapSnapping}
placementMode={activePlacementMode}
hasSelectedObject={Boolean(selectedMapObject)}
allowScale={
mapSelection.kind === 'project' && selectedMapObject?.placementMode !== 'locked'
}
loading={state.loading}
onActivate={activateMapEditing}
onModeChange={changeMapTransformMode}
onSnappingChange={setMapSnapping}
onPlacementModeChange={changeMapPlacementMode}
onAlignToSurface={alignSelectedMapObject}
/>
<MapDraftStatusOverlay
visible={Boolean(state.snapshot)}
changeCount={sceneDraftChangeCount}
loading={state.loading}
onCommit={() => void commitMapScene()}
onDiscard={discardMapSceneDraft}
/>
<MapAssetDropIndicator target={mapAssetDropTarget} />
<WorkspaceOverlays
loading={state.loading}
hasSnapshot={Boolean(state.snapshot)}
dragActive={dragActive}
progress={importProgress}
/>
<ToastViewport item={toast} onDismiss={() => setToast(undefined)} />
{trainingDeployment && (
<div className="absolute top-16 left-4 z-20 rounded bg-app p-2 text-xs">
{trainingDeployment.terrain?.approximation && '训练专用离散近似。'}
{(policyStatus?.observationSize === 81 || policyStatus?.observationSize === 97) && (
<label>
<input
type="checkbox"
checked={showPerceptionRays}
onChange={(e) => setShowPerceptionRays(e.target.checked)}
/>
线
</label>
)}
</div>
)}
{Boolean(state.snapshot?.model.ncam) &&
(showSensorCamera ? (
<div
aria-label="摄像头画面"
className="pointer-events-none absolute bottom-4 left-4 z-20 aspect-[4/3] w-[min(320px,32%)] min-w-[120px] overflow-hidden rounded-lg border border-border-strong shadow-2xl"
>
<div className="pointer-events-auto absolute inset-x-0 top-0 flex h-7 items-center justify-between bg-black/65 px-2 text-[10px] font-medium text-white">
<span className="flex items-center gap-1">
<Camera className="h-3 w-3" />
</span>
<button
type="button"
className="rounded px-1.5 py-0.5 hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
onClick={() => setShowSensorCamera(false)}
>
</button>
</div>
</div>
) : (
<Button
className="absolute bottom-4 left-4 z-20"
icon={<Camera className="h-3.5 w-3.5" />}
onClick={() => setShowSensorCamera(true)}
>
</Button>
))}
{state.entries.length > 1 && !state.selectedEntry && !pendingUrdfPath && (
<EntrySelectionDialog entries={state.entries} onSelect={requestLoadEntry} />
)}{' '}
{state.diagnostic && (
<ErrorRecoveryPanel
key={state.diagnostic.at}
value={state.diagnostic}
onClose={() => state.setDiagnostic(undefined)}
onRetry={
state.diagnostic.category === '模型编译' && state.diagnostic.path
? () => void loadEntry(state.diagnostic!.path!)
: undefined
}
onOpenProject={() => {
setLeftOpen(true);
state.setDiagnostic(undefined);
}}
/>
)}
</main>
<ModelControlsSidebar
visible={rightOpen}
snapshot={state.snapshot}
selection={editorSelection}
viewerSelection={state.selection}
selectedFormat={selectedFormat}
loading={state.loading}
urdfMode={urdfMode}
baseMode={baseMode}
showCollision={showCollision}
ignoreJointLimits={ignoreJointLimits}
jointAdvanced={jointAdvanced}
angleUnit={angleUnit}
mapSelection={mapSelection}
activeMapAssetId={activeMapAssetId}
activeMapAssetName={placedMapAssets.find((asset) => 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);
}}
/>
</div>
</div>
</PanelWidthBudgetContext>
{pendingUrdfPath && (
<UrdfImportOptionsDialog
open
@@ -2865,7 +2905,6 @@ export function App() {
</p>
<p className="mt-2 text-xs text-text-tertiary"></p>
</ConfirmDialog>
<StoreStatusBar />
</div>
);
}
@@ -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(
<ErrorBoundary>
<Broken />
</ErrorBoundary>,
);
expect(screen.getByRole('main')).toHaveClass('theme-light');
expect(screen.getByText('渲染失败')).toBeVisible();
expect(screen.getByText(/重新加载会丢失/)).toBeVisible();
expect(screen.getByRole('button', { name: '重新加载' })).toBeEnabled();
});
+22 -15
View File
@@ -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 ? (
<main className="grid h-screen place-items-center bg-app text-text-primary">
<section className="max-w-xl rounded-xl border border-danger-border bg-panel p-6 shadow-xl">
<h1 className="text-xl font-semibold"></h1>
<pre className="mt-3 whitespace-pre-wrap text-sm text-danger">
{this.state.error.message}
</pre>
<Button variant="danger" className="mt-4" onClick={() => location.reload()}>
</Button>
</section>
</main>
) : (
this.props.children
);
return this.state.error ? <FatalError error={this.state.error} /> : this.props.children;
}
}
function FatalError({ error }: { error: Error }) {
const [theme] = useThemePreference();
return (
<main
className={`theme-${theme} grid min-h-screen place-items-center bg-app p-4 text-text-primary`}
>
<section className="min-w-0 max-w-xl rounded-lg border border-danger-border bg-panel p-6 shadow-xl">
<h1 className="text-xl font-semibold"></h1>
<pre className="mt-3 max-h-[50vh] overflow-auto whitespace-pre-wrap break-words text-sm text-danger">
{error.message}
</pre>
<p className="mt-3 text-xs text-warning"></p>
<Button variant="danger" className="mt-4" onClick={() => location.reload()}>
</Button>
</section>
</main>
);
}
@@ -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({
>
<span className="flex h-5 w-5 items-center justify-center">{command.icon}</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{command.label}</span>
<span className="block text-[10px] text-text-tertiary">{command.group}</span>
<span className="block break-words font-medium">{command.label}</span>
<span className="block text-xs text-text-tertiary">{command.group}</span>
</span>
{command.shortcut && <Kbd>{command.shortcut}</Kbd>}
</button>
@@ -22,7 +22,7 @@ export function DiagnosticNotice({
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-text-primary">{value.summary}</h2>
{value.path && (
<p className="mt-0.5 truncate text-xs text-text-tertiary" title={value.path}>
<p className="mt-0.5 break-all text-xs text-text-tertiary" title={value.path}>
{value.path}
</p>
)}
@@ -41,7 +41,7 @@ export function DiagnosticNotice({
</IconButton>
</div>
{expanded && (
<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">
<pre className="max-h-36 overflow-auto whitespace-pre-wrap break-words border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">
{value.detail}
</pre>
)}
@@ -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<Filter>('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 (
<div className="space-y-2">
<div className="diagnostics-terminal panel-scroll min-h-32 max-h-[45vh] overflow-auto rounded-lg border border-border-subtle bg-input/75">
{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 (
<article key={item.id} className="rounded-lg border border-border bg-surface p-3">
<div className="flex items-start gap-2">
<Icon
className={`mt-0.5 h-4 w-4 ${item.tone === 'danger' ? 'text-danger' : item.tone === 'warning' ? 'text-warning' : 'text-success'}`}
/>
<div className="min-w-0 flex-1">
<h3 className="text-xs font-semibold">{item.title}</h3>
<time className="text-[10px] text-text-tertiary">
{new Date(item.at).toLocaleString('zh-CN')}
</time>
{item.detail && (
<pre className="mt-2 whitespace-pre-wrap text-[10px] leading-4 text-text-secondary">
{item.detail}
</pre>
<article
key={item.id}
className="grid grid-cols-[auto_minmax(0,1fr)] sm:grid-cols-[auto_minmax(0,1fr)_auto] gap-2 border-b border-border-subtle px-3 py-2.5 last:border-0 hover:bg-element-hover/45"
>
<Icon className={`mt-0.5 h-3.5 w-3.5 ${meta.color}`} aria-hidden="true" />
<div className="min-w-0">
<div className="flex min-w-0 items-baseline gap-2">
<span className={`technical-value text-xs font-bold ${meta.color}`}>
{meta.label}
</span>
<h3 className="break-words text-xs font-semibold text-text-primary">
{item.title}
</h3>
{item.category && (
<span className="technical-value rounded border border-border-subtle px-1 text-xs uppercase text-text-tertiary">
{item.category}
</span>
)}
</div>
{item.detail && (
<pre className="mt-1 min-w-0 whitespace-pre-wrap break-words font-mono text-xs leading-4 text-text-tertiary">
{item.detail}
</pre>
)}
</div>
<div className="flex items-start gap-1.5">
<time
dateTime={validDate ? date.toISOString() : undefined}
className="technical-value whitespace-nowrap text-xs text-text-tertiary"
>
{validDate ? date.toLocaleTimeString('zh-CN', { hour12: false }) : '—'}
</time>
{item.detail && (
<CopyButton value={`${item.title}\n${item.detail}`} label="复制事件详情" />
)}
@@ -54,19 +92,28 @@ export function DiagnosticsDrawer({
);
})
) : (
<p className="p-8 text-center text-xs text-text-tertiary"></p>
<div className="grid min-h-32 place-items-center text-xs text-text-tertiary">
</div>
)}
</div>
);
};
return (
<Dialog
open={open}
onClose={onClose}
title="诊断与事件日志"
className="max-w-2xl"
placement="bottom"
className="bg-panel"
contentClassName="pt-3"
footer={
<div className="flex justify-end">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs text-text-tertiary">
<TerminalSquare className="h-3.5 w-3.5 text-accent" />
</span>
<Button variant="danger" disabled={!items.length} onClick={onClear}>
</Button>
@@ -79,15 +126,21 @@ export function DiagnosticsDrawer({
onValueChange={setFilter}
keepMounted={false}
items={[
{ value: 'all', label: `全部 ${items.length}`, content: content('all') },
{ value: 'all', label: `全部 ${groups.all.length}`, content: content('all') },
{
value: 'import',
label: `导入 ${groups.import.length}`,
icon: <FileInput className="h-3 w-3" />,
content: content('import'),
},
{
value: 'warning',
label: `警告 ${items.filter((item) => item.tone === 'warning').length}`,
label: `警告 ${groups.warning.length}`,
content: content('warning'),
},
{
value: 'danger',
label: `错误 ${items.filter((item) => item.tone === 'danger').length}`,
label: `错误 ${groups.danger.length}`,
content: content('danger'),
},
]}
@@ -9,7 +9,7 @@ describe('EntrySelectionDialog', () => {
],
select = vi.fn();
const { rerender } = render(<EntrySelectionDialog entries={entries} onSelect={select} />);
const entry = screen.getByRole('button', { name: '模型 A' });
const entry = screen.getByRole('button', { name: '模型 A a.xml' });
entry.focus();
rerender(<EntrySelectionDialog entries={[...entries]} onSelect={select} />);
expect(entry).toHaveFocus();
@@ -15,11 +15,17 @@ export function EntrySelectionDialog({
{entries.map((entry) => (
<Button
key={entry.path}
className="w-full justify-start overflow-hidden"
aria-label={`${entry.label} ${entry.path}`}
className="!h-auto min-h-8 w-full justify-start py-2 text-left"
onClick={() => onSelect(entry.path)}
icon={<FileCode2 className="h-4 w-4" />}
>
<span className="truncate">{entry.label}</span>
<span className="min-w-0">
<span className="block break-words">{entry.label}</span>
<span className="block break-all font-mono text-xs text-text-tertiary">
{entry.path}
</span>
</span>
</Button>
))}
</div>
@@ -17,7 +17,7 @@ export function ErrorRecoveryPanel({
return (
<section
role="alert"
className="absolute bottom-4 left-1/2 z-30 w-[min(42rem,calc(100%-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-danger-border bg-panel shadow-2xl"
className="w-full shrink-0 overflow-hidden rounded-xl border border-danger-border bg-panel shadow-2xl"
>
<div className="flex items-start gap-3 p-3">
<span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full bg-danger-soft text-danger">
@@ -52,6 +52,7 @@ describe('工作台反馈组件', () => {
it('展示格式化状态数据', () => {
render(<StatusBar time={1.25} fps={60} stepMs={0.5} memoryMb={10} loaded overBudget={false} />);
expect(screen.getByText(/时间 1.250 s/)).toBeVisible();
expect(screen.getByText(/WASM 已加载/)).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: /FPS 60/ }));
expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('WASM已加载');
});
});
@@ -1,5 +1,5 @@
import { Columns3, Focus, PanelLeft, PanelRight, RotateCcw } from 'lucide-react';
import { Button, Dialog } from '../../components/ui';
import { Button, Dialog, Tooltip } from '../../components/ui';
export type LayoutPreset = 'default' | 'viewport' | 'project' | 'control';
const presets = [
{ value: 'default' as const, label: '默认布局', detail: '左右面板均衡显示', icon: Columns3 },
@@ -49,19 +49,20 @@ export function LayoutSettingsDialog({
<h3 className="mb-2 mt-4 text-xs font-semibold"></h3>
<div className="grid grid-cols-2 gap-2">
{presets.map((item) => (
<button
key={item.value}
onClick={() => onPreset(item.value)}
className="flex gap-2 rounded-lg border border-border bg-surface p-3 text-left hover:border-accent hover:bg-accent-soft focus-visible:ring-2 focus-visible:ring-accent/30"
>
<item.icon className="h-4 w-4 shrink-0 text-accent" />
<span>
<span className="block text-xs font-medium">{item.label}</span>
<span className="mt-0.5 block text-[10px] text-text-tertiary">{item.detail}</span>
</span>
</button>
<Tooltip key={item.value} content={item.detail}>
<button
onClick={() => onPreset(item.value)}
className="flex gap-2 rounded-lg border border-border bg-surface p-3 text-left hover:border-accent hover:bg-accent-soft focus-visible:ring-2 focus-visible:ring-accent/30"
>
<item.icon className="h-4 w-4 shrink-0 text-accent" />
<span>
<span className="block text-xs font-medium">{item.label}</span>
</span>
</button>
</Tooltip>
))}
</div>
<p className="mt-3 text-xs text-text-tertiary"></p>
<Button
className="mt-4 w-full"
onClick={onReset}
@@ -64,7 +64,18 @@ describe('MapViewportToolbar', () => {
});
describe('MapDraftStatusOverlay', () => {
it('常驻显示未保存数量并提供提交与丢弃入口', () => {
it('提交中与失败摘要不能隐藏,失败保留重试和放弃入口', () => {
const props = { visible: true, changeCount: 2, onCommit: noop, onDiscard: noop };
const { rerender } = render(<MapDraftStatusOverlay {...props} loading />);
expect(screen.getByRole('status')).toHaveTextContent('正在应用草稿');
expect(screen.getByRole('button', { name: '提交地图草稿' })).toBeDisabled();
rerender(<MapDraftStatusOverlay {...props} loading={false} error="编译失败,草稿已保留" />);
expect(screen.getByRole('status')).toHaveTextContent('应用失败');
expect(screen.getByRole('status')).toHaveTextContent('编译失败,草稿已保留');
expect(screen.getByRole('button', { name: '提交地图草稿' })).toBeEnabled();
expect(screen.getByRole('button', { name: '丢弃地图草稿' })).toBeEnabled();
});
it('显示未保存数量并提供提交与丢弃入口', () => {
const onCommit = vi.fn();
const onDiscard = vi.fn();
render(
@@ -83,7 +94,7 @@ describe('MapDraftStatusOverlay', () => {
expect(onDiscard).toHaveBeenCalledOnce();
});
it('无草稿时显示已同步并禁用动作', () => {
it('无草稿时不占位', () => {
render(
<MapDraftStatusOverlay
visible
@@ -93,7 +104,6 @@ describe('MapDraftStatusOverlay', () => {
onDiscard={noop}
/>,
);
expect(screen.getByText('地图草稿已同步')).toBeVisible();
expect(screen.getByRole('button', { name: '提交地图草稿' })).toBeDisabled();
expect(screen.queryByLabelText('地图草稿状态')).not.toBeInTheDocument();
});
});
@@ -61,7 +61,7 @@ export function MapViewportToolbar({
return (
<div
aria-label="地图视口工具"
className="engineering-glass map-tool-enter absolute left-1/2 top-3 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-1.5 rounded-xl border p-1.5"
className="flex max-w-full flex-wrap items-center justify-center gap-1 p-1"
>
<button
type="button"
@@ -69,7 +69,7 @@ export function MapViewportToolbar({
aria-pressed={interactionActive}
disabled={loading}
onClick={onActivate}
className={`hidden h-8 items-center gap-1.5 rounded-lg px-2 text-[10px] font-semibold transition-colors sm:flex ${interactionActive ? 'bg-accent-soft text-accent' : 'bg-surface/80 text-text-secondary hover:bg-element-hover'}`}
className={`flex h-8 items-center gap-1.5 rounded-lg px-2 text-xs font-semibold transition-colors ${interactionActive ? 'bg-accent-soft text-accent' : 'bg-surface/80 text-text-secondary hover:bg-element-hover'}`}
>
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
@@ -91,8 +91,8 @@ export function MapViewportToolbar({
<Grid3X3 className="h-3.5 w-3.5" />
</IconButton>
<span aria-hidden="true" className="mx-0.5 h-5 w-px bg-border" />
<label className="flex items-center gap-1 text-[10px] text-text-tertiary">
<span className="hidden lg:inline"></span>
<label className="flex items-center gap-1 text-xs text-text-tertiary">
<span></span>
<Select
aria-label="贴地检测模式"
className="w-[112px]"
@@ -129,27 +129,34 @@ export function MapDraftStatusOverlay({
loading,
onCommit,
onDiscard,
error,
}: {
visible: boolean;
changeCount: number;
loading: boolean;
error?: string;
onCommit: () => void;
onDiscard: () => void;
}) {
if (!visible) return null;
const dirty = changeCount > 0;
if (!visible || (!dirty && !loading && !error)) return null;
return (
<div
aria-label="地图草稿状态"
role="status"
className="engineering-glass absolute bottom-3 left-1/2 z-20 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-2 rounded-xl border px-2 py-1.5"
className="engineering-glass flex max-w-full flex-wrap items-center justify-center gap-2 rounded-xl border px-2 py-1.5"
>
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 rounded-full ${dirty ? 'draft-dirty-dot bg-warning' : 'bg-success'}`}
/>
<span className="min-w-0 whitespace-nowrap text-[10px] font-medium text-text-secondary sm:text-[11px]">
{dirty ? `${changeCount} 项未保存改动` : '地图草稿已同步'}
<span className="min-w-0 text-xs font-medium text-text-secondary">
{loading
? '正在应用草稿…'
: error
? `应用失败 · ${changeCount} 项未保存改动`
: `${changeCount} 项未保存改动`}
{error && <span className="block text-danger">{error}</span>}
</span>
<Button
variant="ghost"
@@ -158,7 +165,7 @@ export function MapDraftStatusOverlay({
icon={<Trash2 className="h-3 w-3" />}
onClick={onDiscard}
>
<span className="hidden sm:inline"></span>
</Button>
<Button
variant="primary"
@@ -167,7 +174,7 @@ export function MapDraftStatusOverlay({
icon={<Check className="h-3 w-3" />}
onClick={onCommit}
>
<span className="hidden sm:inline"></span>
</Button>
</div>
);
@@ -191,7 +198,7 @@ export function MapAssetDropIndicator({ target }: { target?: MapAssetDropTarget
<span className="grid h-8 w-8 place-items-center rounded-full border border-current bg-panel/85 backdrop-blur">
<MapPinned className="h-4 w-4" />
</span>
<span className="absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg border border-border-strong bg-panel/90 px-2 py-1 text-[10px] font-medium shadow-xl backdrop-blur">
<span className="absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg border border-border-strong bg-panel/90 px-2 py-1 text-xs font-medium shadow-xl backdrop-blur">
{valid
? `释放放置 · ${target.position![0].toFixed(1)}, ${target.position![1].toFixed(1)}`
: '请拖到 3D 地面'}
@@ -83,24 +83,30 @@ function props(
}
describe('ModelControlsSidebar', () => {
it('没有选择时显示场景摘要,并保留基础地图快速入口', () => {
it('没有选择时显示收敛的场景摘要', () => {
render(<ModelControlsSidebar {...props()} />);
expect(screen.getByText('未选择对象')).toBeVisible();
expect(screen.getByText('模型摘要')).toBeVisible();
expect(screen.getByText('未选择地图实例')).toBeVisible();
expect(screen.getByRole('button', { name: '模型摘要' })).toHaveAttribute(
'aria-expanded',
'false',
);
fireEvent.click(screen.getByRole('button', { name: '模型摘要' }));
expect(screen.getByText('qpos / qvel')).toBeVisible();
expect(screen.queryByText('未选择地图实例')).not.toBeInTheDocument();
});
it('按照统一选择自动路由 Body 与 Joint 检查器', () => {
const view = render(
<ModelControlsSidebar {...props({ selection: { kind: 'body', bodyId: 2 } })} />,
);
expect(screen.getByText('Robot / Body')).toBeVisible();
expect(screen.getByText('arm', { selector: '[title="arm"]' })).toBeVisible();
expect(screen.getByText('Body #2')).toBeVisible();
expect(screen.getByText('arm')).toBeVisible();
view.rerender(
<ModelControlsSidebar {...props({ selection: { kind: 'joint', jointId: 7, bodyId: 2 } })} />,
);
expect(screen.getByText('Robot / Joint')).toBeVisible();
expect(screen.getByRole('slider', { name: 'arm_joint' })).toBeVisible();
expect(screen.getByText('Hinge · arm')).toBeVisible();
expect(screen.getByText('关联 Actuator')).toBeVisible();
});
@@ -153,8 +159,8 @@ describe('ModelControlsSidebar', () => {
})}
/>,
);
expect(screen.getByText('Map / Instance')).toBeVisible();
expect(screen.getByText('随机粗糙地形', { selector: '[title="随机粗糙地形"]' })).toBeVisible();
expect(screen.getByText('参数化地形')).toBeVisible();
expect(screen.getByText('随机粗糙地形', { selector: 'header span' })).toBeVisible();
fireEvent.click(screen.getByRole('tab', { name: '控制台' }));
fireEvent.click(screen.getByRole('tab', { name: '数据录制' }));
expect(onWorkspaceToolChange.mock.calls).toEqual([['controls'], ['data']]);
@@ -105,7 +105,10 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
{props.workspaceTool ? (
props.workspaceTools
) : (
<div className="p-4 text-sm text-text-tertiary"></div>
<div className="m-2.5 flex h-14 items-center justify-center gap-2 border border-dashed border-border-subtle text-xs text-text-tertiary">
<PanelRight className="h-4 w-4" aria-hidden="true" />
</div>
)}
</SidebarPanel>
);
@@ -231,7 +234,6 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
onBaseMode={props.onBaseMode}
onShowCollision={props.onShowCollision}
/>
{props.mapSelection.kind === 'none' && mapPanel}
</>
);
}
@@ -247,7 +249,9 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
>
{inspector}
</div>
{props.workspaceTool && props.workspaceTools}
<div hidden={!props.workspaceTool} className="min-h-0 flex-1 overflow-auto panel-scroll">
{props.workspaceTools}
</div>
</SidebarPanel>
);
}
@@ -4,7 +4,11 @@ import { Badge, IconButton, Popover } from '../../components/ui';
export interface WorkbenchNotification {
id: number;
title: string;
/** 通知中心/诊断抽屉中的完整技术详情。 */
detail?: string;
/** Toast 使用的一行摘要,避免把日志常驻在视口上。 */
message?: string;
category?: 'import' | 'compile' | 'runtime' | 'system';
tone: 'success' | 'warning' | 'danger' | 'info';
at: number;
}
@@ -33,13 +37,13 @@ export function NotificationCenter({
)}
>
{({ close }) => (
<div className="w-80 overflow-hidden rounded-lg border border-border bg-surface-elevated shadow-xl">
<div className="w-80 max-w-full overflow-hidden rounded-lg border border-border bg-surface-elevated shadow-xl">
<header className="flex h-9 items-center justify-between border-b border-border px-3">
<h2 className="text-xs font-semibold"></h2>
<div className="flex gap-2">
{onOpenLog && (
<button
className="text-[10px] text-accent"
className="min-h-7 text-xs text-accent"
onClick={() => {
close();
onOpenLog();
@@ -50,7 +54,7 @@ export function NotificationCenter({
)}
{items.length > 0 && (
<button
className="flex items-center gap-1 text-[10px] text-text-tertiary hover:text-danger"
className="flex min-h-7 items-center gap-1 text-xs text-text-tertiary hover:text-danger"
onClick={onClear}
>
<Trash2 className="h-3 w-3" />
@@ -73,7 +77,7 @@ export function NotificationCenter({
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-xs font-medium">{item.title}</h3>
<h3 className="break-words text-xs font-medium">{item.title}</h3>
<Badge>
{new Date(item.at).toLocaleTimeString('zh-CN', {
hour: '2-digit',
@@ -82,9 +86,12 @@ export function NotificationCenter({
</Badge>
</div>
{item.detail && (
<p className="mt-1 line-clamp-3 text-[10px] leading-4 text-text-tertiary">
{item.detail}
</p>
<details className="domain-details mt-1">
<summary></summary>
<p className="whitespace-pre-wrap break-words text-xs text-text-secondary">
{item.detail}
</p>
</details>
)}
</div>
<IconButton
@@ -127,13 +134,15 @@ export function ToastViewport({
return (
<div
role="status"
className="pointer-events-auto absolute right-4 top-4 z-30 flex w-80 gap-2 rounded-lg border border-border bg-surface-elevated p-3 shadow-xl"
className="pointer-events-auto flex w-full gap-2 rounded-lg border border-border bg-surface-elevated p-3 shadow-xl"
>
<Icon className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium">{item.title}</p>
{item.detail && (
<p className="mt-1 line-clamp-2 text-[10px] text-text-tertiary">{item.detail}</p>
{(item.message ?? item.detail) && (
<p className="mt-1 line-clamp-3 text-xs text-text-tertiary">
{item.message ?? item.detail}
</p>
)}
</div>
</div>
@@ -1,36 +1,34 @@
import { Activity, ChevronUp, Cpu, MemoryStick, TriangleAlert } from 'lucide-react';
import { Activity, ChevronUp, MemoryStick, TriangleAlert } from 'lucide-react';
import { Badge, Popover, PropertyRow, Separator } from '../../components/ui';
export function PerformancePopover({
fps,
stepMs,
memoryMb,
overBudget,
loaded,
}: {
fps: number;
stepMs: number;
memoryMb?: number;
overBudget: boolean;
loaded?: boolean;
}) {
return (
<Popover
label="性能详情"
placement="top-left"
placement="bottom-left"
trigger={({ open, toggle }) => (
<button
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={toggle}
className="flex h-6 items-center gap-3 rounded px-1.5 hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"
className="flex h-7 items-center gap-3 rounded px-1.5 hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"
>
<span className="flex items-center gap-1.5">
<Activity className="h-3 w-3" />
FPS {fps.toFixed(0)}
</span>
<span className="flex items-center gap-1.5">
<Cpu className="h-3 w-3" />
{stepMs.toFixed(2)} ms
</span>
<ChevronUp className={`h-3 w-3 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
)}
@@ -43,6 +41,9 @@ export function PerformancePopover({
{overBudget ? '预算超限' : '运行正常'}
</Badge>
</div>
{loaded !== undefined && (
<PropertyRow label="WASM" value={loaded ? '已加载' : '未加载'} />
)}
<PropertyRow label="渲染帧率" value={`${fps.toFixed(0)} FPS`} />
<PropertyRow label="物理步进" value={`${stepMs.toFixed(2)} ms`} />
<PropertyRow
@@ -1,6 +1,6 @@
import { ChevronRight, FolderRoot } from 'lucide-react';
import type { ModelEntry } from '../../project/types';
import { SearchableCombobox } from '../../components/ui';
import { SearchableCombobox, Tooltip } from '../../components/ui';
export function ProjectBreadcrumb({
projectName,
entries,
@@ -17,21 +17,24 @@ export function ProjectBreadcrumb({
const parts = selectedEntry?.split('/').filter(Boolean) ?? [];
return (
<div className="border-b border-border bg-surface px-3 py-2">
<div
aria-label="当前工程路径"
className="flex min-w-0 items-center gap-1 text-[10px] text-text-tertiary"
>
<FolderRoot className="h-3 w-3 shrink-0 text-accent" />
<span className="truncate">{projectName}</span>
{parts.map((part, index) => (
<span key={`${part}-${index}`} className="contents">
<ChevronRight className="h-3 w-3 shrink-0" />
<span className={`truncate ${index === parts.length - 1 ? 'text-text-primary' : ''}`}>
{part}
<Tooltip content={[projectName, selectedEntry].filter(Boolean).join('/')}>
<div
tabIndex={0}
aria-label="当前工程路径"
className="flex min-w-0 items-center gap-1 text-xs text-text-tertiary"
>
<FolderRoot className="h-3 w-3 shrink-0 text-accent" />
<span className="truncate">{projectName}</span>
{parts.map((part, index) => (
<span key={`${part}-${index}`} className="contents">
<ChevronRight className="h-3 w-3 shrink-0" />
<span className={`truncate ${index === parts.length - 1 ? 'text-text-primary' : ''}`}>
{part}
</span>
</span>
</span>
))}
</div>
))}
</div>
</Tooltip>
{entries.length > 1 && (
<div className="mt-2">
<SearchableCombobox
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { FolderTree, Library, Search } from 'lucide-react';
import { FolderTree, Library } from 'lucide-react';
import type { MapEntry, ModelEntry } from '../../project/types';
import {
countProjectSearchResults,
@@ -106,7 +106,7 @@ export function ProjectSidebar({
const sceneMatches = countSceneSearchResults(snapshot, placedMaps, editorDocuments, sceneQuery);
return (
<SidebarPanel title="场景大纲与资产中心" side="left" visible={visible} icon={<Library />}>
<SidebarPanel title="场景与资源" side="left" visible={visible} icon={<Library />}>
{projectName ? (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
@@ -114,12 +114,6 @@ export function ProjectSidebar({
<div className="truncate text-sm font-medium text-accent" title={projectName}>
{projectName}
</div>
<div className="mt-0.5 truncate text-[10px] text-text-tertiary">
{snapshot
? `${snapshot.bodies.filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_')).length} Body`
: '模型未加载'}{' '}
· {placedMaps.length} · {files.length}
</div>
</div>
<Button variant="danger" onClick={onRemove} disabled={loading}>
@@ -136,10 +130,6 @@ export function ProjectSidebar({
secondClassName="flex flex-col"
first={
<>
<div className="flex h-8 shrink-0 items-center gap-2 px-3 text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">
<Search className="h-3.5 w-3.5" aria-hidden="true" />
</div>
<TreeSearchField
value={sceneQuery}
onChange={setSceneQuery}
@@ -23,7 +23,7 @@ export function RightSidebarTabs({
<div
role="tablist"
aria-label="右侧工作区视图"
className="grid shrink-0 grid-cols-3 gap-1 border-b border-border bg-panel-muted/40 p-2"
className="grid shrink-0 grid-cols-3 gap-1 border-b border-border-subtle bg-panel-muted/35 p-1"
>
{VIEWS.map((view, index) => {
const Icon = view.icon;
@@ -35,9 +35,9 @@ export function RightSidebarTabs({
role="tab"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
className={`flex min-w-0 items-center justify-center gap-1.5 rounded-md px-1 py-2 text-[11px] font-medium transition-colors ${
className={`flex h-8 min-w-0 items-center justify-center gap-1 rounded px-1 text-xs font-medium transition-colors ${
selected
? 'bg-panel text-accent shadow-sm'
? 'border border-border-subtle bg-panel text-accent shadow-sm tool-active-glow'
: 'text-text-tertiary hover:bg-element-hover hover:text-text-primary'
}`}
onClick={() => onChange(view.value)}
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { Box, Bot, Layers3, MapPinned, Trash2, Zap } from 'lucide-react';
import { Button, EmptySearchState, SearchHighlight } from '../../components/ui';
import { Button, EmptySearchState, SearchHighlight, Tooltip } from '../../components/ui';
import type { EditableMapDocument } from '../../map/editor/types';
import type { PlacedMapAsset } from '../../map/types';
import {
@@ -136,7 +136,7 @@ export function SceneOutliner({
{pendingSceneChangeCount > 0 && (
<div
role="status"
className="mb-2 rounded-lg border border-accent/30 bg-accent-soft p-2.5 text-[10px] text-text-secondary"
className="mb-2 rounded-lg border border-accent/30 bg-accent-soft p-2.5 text-xs text-text-secondary"
>
<div className="flex items-center gap-1.5 font-medium text-accent">
<Zap className="h-3.5 w-3.5" aria-hidden="true" />
@@ -158,7 +158,7 @@ export function SceneOutliner({
<summary className="flex cursor-pointer select-none items-center gap-2 rounded px-1.5 py-1.5 text-xs font-semibold text-text-primary hover:bg-element-hover">
<Bot className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate"></span>
<span className="technical-value text-[9px] font-normal text-text-tertiary">
<span className="technical-value text-xs font-normal text-text-tertiary">
{robotBodies.length} Body
</span>
</summary>
@@ -182,7 +182,7 @@ export function SceneOutliner({
<summary className="flex cursor-pointer select-none items-center gap-2 rounded px-1.5 py-1.5 text-xs font-semibold text-text-primary hover:bg-element-hover">
<Layers3 className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate"></span>
<span className="technical-value text-[9px] font-normal text-text-tertiary">
<span className="technical-value text-xs font-normal text-text-tertiary">
{maps.length}
</span>
</summary>
@@ -213,20 +213,22 @@ export function SceneOutliner({
<SearchHighlight text={asset.name} query={query} />
</span>
{pending.has(asset.id) && (
<span className="rounded bg-warning/10 px-1 text-[9px] text-warning">
<span className="rounded bg-warning/10 px-1 text-xs text-warning">
</span>
)}
</button>
<button
type="button"
aria-label={`删除地图实例 ${asset.name}`}
className="mr-1 rounded p-1 text-text-tertiary hover:bg-danger/10 hover:text-danger"
disabled={loading}
onClick={() => onRemoveMap(asset.id)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
<Tooltip content={`从场景移除 ${asset.name};应用前可放弃更改。`}>
<button
type="button"
aria-label={`删除地图实例 ${asset.name}`}
className="mr-1 grid h-7 w-7 shrink-0 place-items-center rounded p-1 text-text-tertiary hover:bg-danger/10 hover:text-danger"
disabled={loading}
onClick={() => onRemoveMap(asset.id)}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
</Tooltip>
</div>
{objects.length > 0 && (
<ul role="group" className="ml-3 border-l border-border pl-1">
@@ -242,7 +244,7 @@ export function SceneOutliner({
role="treeitem"
aria-selected={selected}
disabled={loading}
className={`flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-[11px] ${
className={`flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-xs ${
selected
? 'bg-accent-soft text-accent'
: 'text-text-secondary hover:bg-element-hover'
@@ -253,7 +255,7 @@ export function SceneOutliner({
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={object.name} query={query} />
</span>
<span className="text-[9px] text-text-tertiary">{object.type}</span>
<span className="text-xs text-text-tertiary">{object.type}</span>
</button>
</li>
);
@@ -1,7 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { ShortcutHelpDialog } from './ShortcutHelpDialog';
import { TreeSearchField } from './TreeSearchField';
import { ViewportHUD } from './ViewportHUD';
import { EmptyWorkspace } from './WorkspaceOverlays';
import { ViewerDisplayPopover } from './ViewerDisplayPopover';
import { DEFAULT_VIEWER_DISPLAY_OPTIONS } from '../../viewer/displayOptions';
@@ -25,24 +24,10 @@ describe('第二批工作台组件', () => {
fireEvent.keyDown(document, { key: 'Escape' });
expect(close).toHaveBeenCalledTimes(1);
});
it('视口 HUD 复用状态并给出当前模式的鼠标提示', () => {
render(
<ViewportHUD
ready
paused={false}
mode="joint"
selection={{ bodyId: 2, bodyName: 'arm', geomId: 3, geomType: 1, position: [0, 0, 0] }}
/>,
);
expect(screen.getByLabelText('视口状态')).toHaveTextContent('仿真中');
expect(screen.getByLabelText('视口状态')).toHaveTextContent('关节拖动');
expect(screen.getByLabelText('视口状态')).toHaveTextContent('arm');
expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('左键拖动关节');
expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('右键平移');
});
it('空工作区解释导入到仿真的三步流程', () => {
render(<EmptyWorkspace />);
expect(screen.getByRole('region', { name: '导入模型工程' })).toBeVisible();
fireEvent.click(screen.getByText('导入与仿真流程'));
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('导入');
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('检查与配置');
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('运行与调试');
@@ -47,8 +47,12 @@ export function SettingsDialog({
</section>
<section>
<h3 className="mb-2 text-xs font-semibold"></h3>
<p className="mb-2 text-xs text-warning">
</p>
<PropertyRow
label="角度单位"
description="只切换显示单位,不改变模型关节限位"
value={
<Select
aria-label="设置角度单位"
@@ -1,5 +1,6 @@
import { Dialog, Kbd, Separator } from '../../components/ui';
const shortcuts = [
['Ctrl / Cmd + K', '打开命令面板'],
['Space', '播放 / 暂停'],
['R', '重置仿真(非地图编辑)'],
['1', '选择模式'],
@@ -17,11 +18,14 @@ const shortcuts = [
export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
return (
<Dialog open={open} onClose={onClose} title="快捷键与视口操作">
<p className="mb-4 text-xs text-text-tertiary">
Monaco 仿 Ctrl/Cmd+S
</p>
<section>
<h3 className="mb-2 text-xs font-semibold text-text-primary"></h3>
<dl className="space-y-2">
{shortcuts.map(([key, label]) => (
<div key={key} className="flex items-center justify-between text-xs">
<div key={key} className="flex flex-wrap items-center justify-between gap-2 text-xs">
<dt className="text-text-secondary">{label}</dt>
<dd>
<Kbd>{key}</Kbd>
@@ -18,10 +18,10 @@ export function SidebarPanel({
return (
<ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}>
<aside
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border`}
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border-subtle `}
>
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-gradient-to-r from-panel via-surface/70 to-panel px-3 text-sm font-semibold tracking-tight text-text-primary shadow-[inset_0_-1px_0_rgb(255_255_255/0.025)]">
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent [&>svg]:h-3.5 [&>svg]:w-3.5">
<h2 className="flex h-9 shrink-0 items-center gap-2 border-b border-border-subtle px-3 text-xs font-semibold text-text-primary">
<span className="text-accent [&>svg]:h-4 [&>svg]:w-4">
{icon ?? <Settings2 aria-hidden="true" className="h-3.5 w-3.5" />}
</span>
{title}
@@ -0,0 +1,35 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { SimulationControls } from './SimulationControls';
describe('SimulationControls', () => {
it('迁移后保留播放、单步、重置、速度及就绪限制', () => {
const pause = vi.fn(),
step = vi.fn(),
reset = vi.fn(),
speed = vi.fn();
const props = {
ready: true,
paused: true,
loading: false,
speed: 1,
onTogglePause: pause,
onStep: step,
onReset: reset,
onSpeed: speed,
};
const { rerender } = render(<SimulationControls {...props} />);
fireEvent.click(screen.getByRole('button', { name: '▶ 播放' }));
fireEvent.click(screen.getByRole('button', { name: '单步' }));
fireEvent.click(screen.getByRole('button', { name: '重置' }));
fireEvent.change(screen.getByLabelText('仿真速度'), { target: { value: '2' } });
expect(pause).toHaveBeenCalledOnce();
expect(step).toHaveBeenCalledOnce();
expect(reset).toHaveBeenCalledOnce();
expect(speed).toHaveBeenCalledWith(2);
rerender(<SimulationControls {...props} paused={false} />);
expect(screen.getByRole('button', { name: '⏸ 暂停' })).toBeEnabled();
expect(screen.getByRole('button', { name: '单步' })).toBeDisabled();
rerender(<SimulationControls {...props} ready={false} />);
expect(screen.getByRole('button', { name: '▶ 播放' })).toBeDisabled();
expect(screen.getByRole('button', { name: '重置' })).toBeDisabled();
});
});
@@ -0,0 +1,64 @@
import { Pause, Play, RotateCcw, StepForward } from 'lucide-react';
import { Button, IconButton, Select } from '../../components/ui';
export function SimulationControls({
paused,
ready,
speed,
loading,
onTogglePause,
onStep,
onReset,
onSpeed,
}: {
paused: boolean;
ready: boolean;
speed: number;
loading: boolean;
onTogglePause: () => void;
onStep: () => void;
onReset: () => void;
onSpeed: (speed: number) => void;
}) {
return (
<div aria-label="仿真控制" className="flex flex-wrap items-center justify-center gap-1">
<Button
variant="ghost"
onClick={onTogglePause}
disabled={!ready}
aria-label={paused ? '▶ 播放' : '⏸ 暂停'}
icon={paused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
>
{paused ? '播放' : '暂停'}
</Button>
<IconButton
tooltip="单步(暂停时可用)"
aria-label="单步"
onClick={onStep}
disabled={!ready || !paused}
>
<StepForward className="h-3.5 w-3.5" />
</IconButton>
<IconButton
tooltip="重置仿真(R;地图编辑时 R 为缩放)"
aria-label="重置"
onClick={onReset}
disabled={!ready}
>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
<Select
aria-label="仿真速度"
value={speed}
disabled={loading}
onChange={(event) => onSpeed(Number(event.target.value))}
className="w-[70px]"
>
<option value={0.25}>0.25×</option>
<option value={0.5}>0.5×</option>
<option value={1}>1×</option>
<option value={2}>2×</option>
<option value={4}>4×</option>
</Select>
</div>
);
}
@@ -0,0 +1,49 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { SourceEditorDialog } from './SourceEditorDialog';
vi.mock('./monacoSetup', () => ({}));
vi.mock('@monaco-editor/react', () => ({
default: ({
value,
onChange,
theme,
}: {
value: string;
onChange(value: string): void;
theme: string;
}) => (
<textarea
aria-label="源码"
data-theme={theme}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
it('保存失败保留草稿与风险,嵌套确认 Escape 只关闭确认', async () => {
const close = vi.fn(),
save = vi.fn().mockRejectedValue(new Error('编译失败,上一模型保留'));
render(
<SourceEditorDialog
open
code="<mujoco/>"
filePath="model.xml"
theme="light"
onClose={close}
onSave={save}
/>,
);
const editor = screen.getByLabelText('源码');
expect(editor).toHaveAttribute('data-theme', 'light');
expect(screen.getByText(/保存将重新编译/)).toBeVisible();
fireEvent.change(editor, { target: { value: '<mujoco model="new"/>' } });
editor.focus();
fireEvent.keyDown(editor, { key: 's', ctrlKey: true });
expect(await screen.findByRole('alert')).toHaveTextContent('编译失败,上一模型保留');
expect(editor).toHaveValue('<mujoco model="new"/>');
expect(save).toHaveBeenCalledWith('model.xml', '<mujoco model="new"/>');
fireEvent.click(screen.getByRole('button', { name: '关闭源代码编辑器' }));
expect(screen.getByRole('dialog', { name: '放弃未保存的修改?' })).toBeVisible();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: '放弃未保存的修改?' })).not.toBeInTheDocument();
expect(close).not.toHaveBeenCalled();
});
@@ -1,3 +1,4 @@
import { FloatingLayerContext, useFloatingLayer } from '../../components/ui/floating';
import './monacoSetup';
import Editor from '@monaco-editor/react';
import {
@@ -42,6 +43,7 @@ export function SourceEditorDialog({
const [code, setCode] = useState(sourceCode),
[savedCode, setSavedCode] = useState(sourceCode),
[saving, setSaving] = useState(false),
[saveError, setSaveError] = useState<string>(),
[copied, setCopied] = useState(false),
[maximized, setMaximized] = useState(false),
[discardOpen, setDiscardOpen] = useState(false),
@@ -61,13 +63,17 @@ export function SourceEditorDialog({
const save = useCallback(async () => {
if (!dirty || problem) return;
setSaving(true);
setSaveError(undefined);
try {
await onSave(filePath, code);
setSavedCode(code);
} catch (value) {
setSaveError(value instanceof Error ? value.message : String(value));
} finally {
setSaving(false);
}
}, [code, dirty, filePath, onSave, problem]);
const layer = useFloatingLayer({ open, roots: () => [dialog.current], dismiss: requestClose });
useEffect(() => {
if (!open) return;
previousFocus.current =
@@ -80,7 +86,7 @@ export function SourceEditorDialog({
}, [open]);
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (discardOpen) return;
if (!open || discardOpen || !dialog.current?.contains(document.activeElement)) return;
if (
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === 's' &&
@@ -89,14 +95,11 @@ export function SourceEditorDialog({
) {
event.preventDefault();
void save();
} else if (event.key === 'Escape') {
event.preventDefault();
requestClose();
}
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
}, [dirty, discardOpen, problem, requestClose, save]);
}, [open, dirty, discardOpen, problem, requestClose, save]);
const copy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
@@ -124,7 +127,7 @@ export function SourceEditorDialog({
};
if (!open) return null;
return (
<>
<FloatingLayerContext.Provider value={layer.context}>
<div className="fixed inset-0 z-[390] pointer-events-none" role="presentation">
<section
ref={dialog}
@@ -133,12 +136,20 @@ export function SourceEditorDialog({
aria-modal="false"
aria-label="转换后的 MJCF 编辑器"
style={
maximized ? undefined : { left: position.x, top: position.y, width: 900, height: 650 }
maximized
? { zIndex: layer.zIndex }
: {
zIndex: layer.zIndex,
left: `clamp(16px, ${position.x}px, max(16px, calc(100vw - 916px)))`,
top: `clamp(16px, ${position.y}px, max(16px, calc(100vh - 666px)))`,
width: 'min(900px, calc(100vw - 32px))',
height: 'min(650px, calc(100vh - 32px))',
}
}
className={`source-editor-window pointer-events-auto fixed flex min-h-[360px] min-w-[520px] flex-col overflow-hidden border border-border-strong bg-panel shadow-2xl ${maximized ? 'inset-0 h-full w-full' : 'resize'}`}
className={`source-editor-window pointer-events-auto fixed flex min-h-64 min-w-0 max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)] flex-col overflow-hidden border border-border-strong bg-panel shadow-2xl ${maximized ? 'inset-0 h-full w-full' : 'resize'}`}
>
<header
className="flex h-11 shrink-0 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3"
className="flex min-h-11 flex-wrap shrink-0 py-2 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3"
onPointerDown={pointerDown}
onPointerMove={pointerMove}
onPointerUp={() => {
@@ -151,16 +162,16 @@ export function SourceEditorDialog({
<div className="truncate font-mono text-xs font-semibold text-text-primary">
MJCF
</div>
<div className="truncate font-mono text-[9px] text-text-tertiary" title={filePath}>
<div className="break-all font-mono text-xs text-text-tertiary" title={filePath}>
{filePath}
</div>
</div>
<span className="text-[10px] text-text-tertiary">{contentSize(code)}</span>
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-[9px] font-semibold text-accent">
<span className="text-xs text-text-tertiary">{contentSize(code)}</span>
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-xs font-semibold text-accent">
·
</span>
{dirty && (
<span className="rounded bg-warning-soft px-1.5 py-0.5 text-[9px] font-semibold text-warning">
<span className="rounded bg-warning-soft px-1.5 py-0.5 text-xs font-semibold text-warning">
</span>
)}
@@ -193,6 +204,14 @@ export function SourceEditorDialog({
<X className="h-4 w-4" />
</IconButton>
</header>
<p className="border-b border-border px-3 py-2 text-xs text-warning">
</p>
{saveError && (
<p role="alert" className="px-3 py-2 text-xs text-danger">
{saveError}
</p>
)}
<div className="min-h-0 flex-1 bg-input">
<Editor
height="100%"
@@ -218,8 +237,8 @@ export function SourceEditorDialog({
}}
/>
</div>
<footer className="flex h-7 shrink-0 items-center justify-between gap-3 border-t border-border bg-surface px-3 text-[10px]">
<div className={problem ? 'truncate text-warning' : 'text-success'}>
<footer className="flex min-h-7 flex-wrap shrink-0 items-center justify-between gap-3 border-t border-border bg-surface px-3 text-xs">
<div className={problem ? 'break-words text-warning' : 'text-success'}>
{problem ? `XML 错误:${problem}` : '✓ XML 结构正常'}
</div>
<div className="flex items-center gap-2 font-mono text-text-tertiary">
@@ -243,6 +262,6 @@ export function SourceEditorDialog({
MJCF
</p>
</ConfirmDialog>
</>
</FloatingLayerContext.Provider>
);
}
+18 -37
View File
@@ -1,7 +1,5 @@
import type { ReactNode } from 'react';
import { Box, Clock3, MemoryStick, TriangleAlert } from 'lucide-react';
import { Clock3, TriangleAlert } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { Kbd } from '../../components/ui';
import { useAppStore } from '../../stores/useAppStore';
import { PerformancePopover } from './PerformancePopover';
export interface StatusBarProps {
@@ -12,47 +10,30 @@ export interface StatusBarProps {
loaded: boolean;
overBudget: boolean;
}
function Item({
icon: Icon,
children,
className = '',
}: {
icon: typeof Clock3;
children: ReactNode;
className?: string;
}) {
return (
<span className={`items-center gap-1.5 ${className || 'flex'}`}>
<Icon aria-hidden="true" className="h-3 w-3 text-text-tertiary" />
{children}
</span>
);
}
export function StatusBar({ time, fps, stepMs, memoryMb, loaded, overBudget }: StatusBarProps) {
return (
<footer className="technical-value relative z-30 flex h-7 shrink-0 items-center gap-3 overflow-hidden border-t border-border bg-panel px-3 text-[11px] text-text-tertiary lg:gap-5">
<Item icon={Clock3}> {time?.toFixed(3) ?? '—'} s</Item>
<PerformancePopover fps={fps} stepMs={stepMs} memoryMb={memoryMb} overBudget={overBudget} />
<Item icon={MemoryStick} className="hidden items-center gap-1.5 md:flex">
{memoryMb === undefined ? '—' : `${memoryMb.toFixed(1)} MiB`}
</Item>
<Item icon={Box} className="hidden items-center gap-1.5 sm:flex">
WASM {loaded ? '已加载' : '未加载'}
</Item>
<div className="technical-value flex flex-wrap items-center gap-2 text-xs text-text-secondary">
<span className="flex items-center gap-1 whitespace-nowrap">
<Clock3 aria-hidden="true" className="h-3.5 w-3.5" />
{time?.toFixed(3) ?? '—'} s
</span>
<PerformancePopover
fps={fps}
stepMs={stepMs}
memoryMb={memoryMb}
loaded={loaded}
overBudget={overBudget}
/>
{overBudget && (
<span className="hidden min-w-0 items-center gap-1 truncate text-warning lg:flex">
<TriangleAlert className="h-3 w-3 shrink-0" />
线
<span className="flex items-center gap-1 text-warning">
<TriangleAlert aria-hidden="true" className="h-3.5 w-3.5" />
</span>
)}
<span className="ml-auto hidden items-center gap-1.5 xl:flex">
<Kbd>Space</Kbd> / · <Kbd>R</Kbd> · <Kbd>1/2/3</Kbd>
</span>
</footer>
</div>
);
}
/** 仅让状态栏订阅高频性能数据,避免带动整个工作台重渲染。 */
/** 仅浮层订阅高频性能数据,不把 FPS/耗时提升到 App。 */
export function StoreStatusBar() {
const metrics = useAppStore(
useShallow((state) => ({
@@ -28,7 +28,6 @@ export function ToolbarOverflowMenu({
return (
<DropdownMenu
label="更多工作台操作"
className="xl:hidden"
items={[
{
id: 'commands',
@@ -1,7 +1,7 @@
import { useState, type ReactNode } from 'react';
import { Camera, Settings2 } from 'lucide-react';
import type { CameraDirection, UrdfEnhancementOptions } from '../../project/urdfToMjcf';
import { Button, Dialog, Select } from '../../components/ui';
import { Button, Dialog, Select, Tooltip } from '../../components/ui';
function OptionCard({
checked,
@@ -84,9 +84,8 @@ export function UrdfImportOptionsDialog({
</div>
}
>
<p className="text-sm text-text-secondary">
<strong className="text-text-primary">{path}</strong>{' '}
仿 URDF
<p className="break-words text-sm text-text-secondary">
<strong className="text-text-primary">{path}</strong> 仿
</p>
<div className="mt-4 space-y-3">
<OptionCard
@@ -94,7 +93,7 @@ export function UrdfImportOptionsDialog({
onChange={(addActuators) => setOptions((value) => ({ ...value, addActuators }))}
icon={<Settings2 className="h-4 w-4" />}
title="为关节添加驱动器"
description="为每个 hinge/slide 关节生成控制输入不限幅 motor 驱动器;hinge 使用 N·mslide 使用 N。kp/kv 用于调整对应 MJCF 关节的刚度和阻尼,已有驱动器不重复添加。"
description="生成不限幅 motor 控制输入:hinge N·mslide N。已有驱动器不重复添加。"
/>
<OptionCard
checked={options.addSensors}
@@ -104,9 +103,9 @@ export function UrdfImportOptionsDialog({
description="在浮动基座添加三轴陀螺仪和三轴加速度计(6轴 IMU),并添加一台 640×480 固定摄像头。"
/>
{options.addSensors && (
<div className="rounded-lg border border-border bg-surface p-3">
<div className="border-t border-border pt-3">
<div className="mb-2 text-xs font-medium text-text-primary"></div>
<label className="block text-[11px] text-text-secondary">
<label className="block text-xs text-text-secondary">
<span className="mb-1 block"> Body</span>
<Select
aria-label="摄像头固连 Body"
@@ -132,11 +131,14 @@ export function UrdfImportOptionsDialog({
</label>
<div className="mt-3 grid grid-cols-3 gap-2">
{(['X', 'Y', 'Z'] as const).map((axis, index) => (
<label key={axis} className="text-[11px] text-text-secondary">
<span className="mb-1 block"> {axis}m</span>
<label key={axis} className="text-xs text-text-secondary">
<span className="mb-0.5 flex items-center gap-1">
<span className={`axis-badge axis-${axis.toLowerCase()}`}>{axis}</span>
m
</span>
<input
aria-label={`摄像头位置 ${axis}`}
className="field h-8 w-full px-2 text-xs"
className="field technical-value h-8 w-full px-1.5 text-xs"
type="number"
step="0.01"
value={(options.cameraPosition ?? [0.1, 0, 0.05])[index]}
@@ -145,7 +147,7 @@ export function UrdfImportOptionsDialog({
</label>
))}
</div>
<label className="mt-3 block text-[11px] text-text-secondary">
<label className="mt-3 block text-xs text-text-secondary">
<span className="mb-1 block">Body </span>
<Select
aria-label="摄像头朝向"
@@ -163,9 +165,11 @@ export function UrdfImportOptionsDialog({
))}
</Select>
</label>
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">
Body ROS 使 +X +Z
</p>
<Tooltip content="位置和朝向相对于所选 Body;ROS 摄像头通常 +X 朝前、+Z 朝上">
<span tabIndex={0} className="mt-2 inline-block text-xs text-text-tertiary">
</span>
</Tooltip>
</div>
)}
</div>
@@ -1,8 +1,6 @@
import { Crosshair, Hand, MapPinned, MousePointer2, RotateCcw } from 'lucide-react';
import { Crosshair, Hand, MousePointer2 } from 'lucide-react';
import type { InteractionMode } from '../../viewer/MuJoCoViewer';
import type { ViewerDisplayOptions } from '../../viewer/displayOptions';
import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
import { ViewerDisplayPopover } from './ViewerDisplayPopover';
import { ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
const tools: ToolbarItem<InteractionMode>[] = [
{ value: 'select', label: '选择', icon: MousePointer2 },
{ value: 'joint', label: '关节拖动', icon: Hand },
@@ -10,42 +8,12 @@ const tools: ToolbarItem<InteractionMode>[] = [
];
export function ViewerToolDock({
mode,
display,
mapEditContext,
onModeChange,
onDisplayChange,
onResetCamera,
}: {
mode: InteractionMode;
display: ViewerDisplayOptions;
mapEditContext?: {
active: boolean;
label: string;
onActivate: () => void;
};
onModeChange: (mode: InteractionMode) => void;
onDisplayChange: (next: ViewerDisplayOptions) => void;
onResetCamera: () => void;
}) {
return (
<div className="flex items-center gap-1">
<ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式" />
{mapEditContext && (
<button
type="button"
aria-label={`地图编辑联动:${mapEditContext.label}`}
aria-pressed={mapEditContext.active}
onClick={mapEditContext.onActivate}
className={`hidden h-7 items-center gap-1.5 rounded-lg border px-2 text-[10px] font-semibold transition-colors md:flex ${mapEditContext.active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-border bg-surface/80 text-text-tertiary hover:bg-element-hover hover:text-text-primary'}`}
>
<MapPinned className="h-3.5 w-3.5" aria-hidden="true" />
{mapEditContext.label}
</button>
)}
<ViewerDisplayPopover value={display} onChange={onDisplayChange} />
<IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
</div>
<ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式" />
);
}
@@ -0,0 +1,26 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { ViewportHUD } from './ViewportHUD';
import { useAppStore } from '../../stores/useAppStore';
describe('ViewportHUD', () => {
beforeEach(() => useAppStore.getState().clearProject());
it('独立更新性能,不带动父布局;细节按需展示,无手势长串', () => {
const parent = vi.fn();
function Layout() {
parent();
return <ViewportHUD />;
}
render(<Layout />);
expect(screen.getByLabelText('视口状态')).toHaveTextContent('待导入');
act(() => useAppStore.getState().setMetrics(60, 1.25, 32, false));
expect(screen.getByRole('button', { name: /FPS 60/ })).toBeVisible();
expect(screen.queryByText(/物理 1.25/)).not.toBeInTheDocument();
expect(screen.queryByLabelText('视口操作提示')).not.toBeInTheDocument();
expect(parent).toHaveBeenCalledOnce();
fireEvent.click(screen.getByRole('button', { name: /FPS/ }));
expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('1.25 ms');
expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('WASM未加载');
act(() => useAppStore.getState().setMetrics(30, 40, 32, true));
expect(screen.getByLabelText('视口状态')).toHaveTextContent('步进预算超限');
expect(parent).toHaveBeenCalledOnce();
});
});
+20 -83
View File
@@ -1,88 +1,25 @@
import { CirclePause, CirclePlay, Mouse, MousePointer2 } from 'lucide-react';
import type { InteractionMode, ViewerSelection } from '../../viewer/MuJoCoViewer';
import { Badge, Kbd } from '../../components/ui';
const labels: Record<InteractionMode, string> = {
select: '选择',
joint: '关节拖动',
force: '外力施加',
};
const primaryGestures: Record<InteractionMode, string> = {
select: '左键旋转',
joint: '左键拖动关节',
force: '左键拖动施力',
};
export function ViewportHUD({
paused,
mode,
selection,
ready,
mapEditing = false,
}: {
paused: boolean;
mode: InteractionMode;
selection: ViewerSelection | null;
ready: boolean;
mapEditing?: boolean;
}) {
if (!ready) return null;
import { CirclePause, CirclePlay } from 'lucide-react';
import { useAppStore } from '../../stores/useAppStore';
import { Badge } from '../../components/ui';
import { StoreStatusBar } from './StatusBar';
export function ViewportHUD() {
const paused = useAppStore((state) => state.paused);
const ready = useAppStore((state) => Boolean(state.snapshot));
return (
<>
<div
aria-label="视口状态"
className="pointer-events-none absolute left-3 top-3 z-10 flex max-w-[70%] flex-wrap items-center gap-1.5"
>
<Badge tone={paused ? 'neutral' : 'success'}>
{paused ? <CirclePause className="h-3 w-3" /> : <CirclePlay className="h-3 w-3" />}
{paused ? '已暂停' : '仿真中'}
</Badge>
<Badge tone="accent">
<MousePointer2 className="h-3 w-3" />
{labels[mode]}
</Badge>
{selection && (
<Badge title={`body ${selection.bodyId} · geom ${selection.geomId}`}>
{selection.bodyName}
</Badge>
)}
</div>
<div
aria-label="视口操作提示"
className="pointer-events-none absolute bottom-14 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel/85 px-3 py-1.5 text-[10px] text-text-secondary shadow-xl backdrop-blur lg:flex"
>
<Mouse aria-hidden="true" className="h-3 w-3 text-text-secondary" />
<span>{primaryGestures[mode]}</span>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<span></span>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<span></span>
{mapEditing && mode === 'select' ? (
<>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<Kbd>W</Kbd>
<Kbd>E</Kbd>
<Kbd>R</Kbd>
<span></span>
<Kbd>F</Kbd>
<span></span>
</>
<div
aria-label="视口状态"
className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-panel/90 px-2 py-1 text-xs shadow-sm"
>
<Badge tone={!ready || paused ? 'neutral' : 'success'}>
{!ready || paused ? (
<CirclePause className="h-3.5 w-3.5" />
) : (
mode !== 'select' && (
<>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<Kbd>1</Kbd>
<span></span>
</>
)
<CirclePlay className="h-3.5 w-3.5" />
)}
</div>
</>
{!ready ? '待导入' : paused ? '已暂停' : '仿真中'}
</Badge>
<StoreStatusBar />
</div>
);
}
@@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
/** 仅分配空间;槽位内容自行订阅状态,禁止在此接入仿真业务。 */
export function ViewportOverlayLayout({
status,
view,
notices,
context,
camera,
orientation,
controls,
draft,
}: {
status: ReactNode;
view: ReactNode;
notices?: ReactNode;
context?: ReactNode;
camera?: ReactNode;
orientation?: ReactNode;
controls: ReactNode;
draft?: ReactNode;
}) {
return (
<div className="viewport-overlays pointer-events-none absolute inset-0 z-20 p-3">
<div className="viewport-overlay-top">
<div data-overlay-slot="status">{status}</div>
<div data-overlay-slot="view">{view}</div>
</div>
<div className="viewport-overlay-notices">
<div data-overlay-slot="context">{context}</div>
<div data-overlay-slot="notices">{notices}</div>
</div>
<div className="min-h-0 flex-1" />
<div className="viewport-overlay-bottom">
<div data-overlay-slot="camera">{camera}</div>
<div data-overlay-slot="orientation">{orientation}</div>
<div className="viewport-overlay-actions">
<div data-overlay-slot="controls">{controls}</div>
<div data-overlay-slot="draft">{draft}</div>
</div>
</div>
</div>
);
}
@@ -2,57 +2,41 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { WorkbenchHeader } from './WorkbenchHeader';
const fn = () => {};
describe('WorkbenchHeader', () => {
it('透传仿真动作且保留可访问名称', () => {
const pause = vi.fn(),
step = vi.fn(),
reset = vi.fn(),
speed = vi.fn(),
openSource = vi.fn();
it('保留导入、工程菜单、命令与侧栏入口,不再重复仿真控制', () => {
const source = vi.fn(),
files = vi.fn(),
left = vi.fn();
render(
<WorkbenchHeader
paused
ready
speed={1}
theme="dark"
loading={false}
hasProject
leftOpen
rightOpen
fullscreen={false}
center={<span></span>}
onFiles={fn}
onFiles={files}
onFolder={fn}
onOpenSource={openSource}
onTogglePause={pause}
onStep={step}
onReset={reset}
onSpeed={speed}
onToggleLeft={fn}
onOpenSource={source}
onToggleLeft={left}
onToggleRight={fn}
onToggleTheme={fn}
onHelp={fn}
onCommands={fn}
onToggleFullscreen={fn}
/>,
);
const sourceButton = screen.getByRole('button', { name: '源代码' });
expect(sourceButton).toHaveTextContent('源代码');
fireEvent.click(sourceButton);
fireEvent.click(screen.getByRole('button', { name: '▶ 播放' }));
fireEvent.click(screen.getByRole('button', { name: '单步' }));
fireEvent.click(screen.getByRole('button', { name: '重置' }));
fireEvent.change(screen.getByLabelText('仿真速度'), { target: { value: '2' } });
expect(openSource).toHaveBeenCalledTimes(1);
expect(pause).toHaveBeenCalledTimes(1);
expect(step).toHaveBeenCalledTimes(1);
expect(reset).toHaveBeenCalledTimes(1);
expect(speed).toHaveBeenCalledWith(2);
expect(screen.getByRole('button', { name: '切换到白天主题' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'MuJoCo' })).toBeVisible();
fireEvent.change(screen.getByLabelText('打开文件'), {
target: { files: [new File(['x'], 'model.xml')] },
});
expect(files).toHaveBeenCalledOnce();
expect(screen.getByLabelText('打开文件夹')).toHaveAttribute('webkitdirectory');
expect(screen.queryByRole('button', { name: '源代码' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '工程' }));
fireEvent.click(screen.getByRole('button', { name: '源代码' }));
expect(source).toHaveBeenCalledOnce();
fireEvent.click(screen.getByRole('button', { name: '隐藏工程面板' }));
expect(left).toHaveBeenCalledOnce();
expect(screen.getByRole('button', { name: '隐藏工程面板' })).toHaveAttribute(
'aria-expanded',
'true',
);
expect(screen.getByRole('button', { name: '打开命令面板' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '进入全屏' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '打开命令面板' })).toBeVisible();
expect(screen.queryByRole('button', { name: '▶ 播放' })).not.toBeInTheDocument();
});
});
@@ -1,88 +1,45 @@
import type { ChangeEvent, ReactNode } from 'react';
import {
Boxes,
CircleHelp,
Code2,
Expand,
FolderOpen,
Minimize,
PanelLeft,
PanelRight,
Pause,
Play,
RotateCcw,
Search,
StepForward,
Sun,
Moon,
Upload,
} from 'lucide-react';
import { Button, IconButton, Select } from '../../components/ui';
import { Boxes, Code2, FolderOpen, PanelLeft, PanelRight, Search, Upload } from 'lucide-react';
import { IconButton, Popover } from '../../components/ui';
const fileActionClass =
'inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface/80 px-2 text-xs font-medium text-text-primary shadow-sm transition-[background-color,border-color,transform] hover:-translate-y-px hover:border-border-strong hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
const fileActionLabelClass = 'hidden sm:inline';
'inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface/80 px-2 text-xs font-medium text-text-primary shadow-sm transition-[background-color,border-color,transform] hover:-translate-y-px hover:border-border-strong hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
const fileActionLabelClass = 'whitespace-nowrap';
export function WorkbenchHeader({
paused,
ready,
speed,
theme,
loading,
leftOpen,
rightOpen,
fullscreen,
hasProject,
center,
endActions,
compactMenu,
onFiles,
onFolder,
onOpenSource,
onTogglePause,
onStep,
onReset,
onSpeed,
onToggleLeft,
onToggleRight,
onToggleTheme,
onHelp,
onCommands,
onToggleFullscreen,
}: {
paused: boolean;
ready: boolean;
speed: number;
theme: 'light' | 'dark';
loading: boolean;
leftOpen: boolean;
rightOpen: boolean;
fullscreen: boolean;
hasProject?: boolean;
center: ReactNode;
endActions?: ReactNode;
compactMenu?: ReactNode;
onFiles: (event: ChangeEvent<HTMLInputElement>) => void;
onFolder: (event: ChangeEvent<HTMLInputElement>) => void;
onOpenSource?: () => void;
onTogglePause: () => void;
onStep: () => void;
onReset: () => void;
onSpeed: (value: number) => void;
onToggleLeft: () => void;
onToggleRight: () => void;
onToggleTheme: () => void;
onHelp: () => void;
onCommands: () => void;
onToggleFullscreen: () => void;
}) {
return (
<header className="workbench-header relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
<header className="workbench-header relative z-40 flex h-11 shrink-0 justify-between items-center gap-2 border-b border-border px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
<div className="flex min-w-0 items-center gap-1">
<div className="mr-2 hidden items-center gap-2 border-r border-border pr-3 xl:flex">
<div className="mr-2 flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm">
<Boxes aria-hidden="true" className="h-3.5 w-3.5" />
</span>
<h1 className="truncate text-sm font-semibold tracking-tight text-text-primary">
MuJoCo Web 仿
MuJoCo
</h1>
</div>
<label
@@ -119,48 +76,34 @@ export function WorkbenchHeader({
onChange={onFolder}
/>
</label>
<button
type="button"
className={`${fileActionClass} disabled:cursor-not-allowed disabled:opacity-40`}
title="查看和修改缓存源代码"
disabled={!hasProject || loading}
onClick={onOpenSource}
<Popover
label="工程菜单"
placement="bottom-left"
trigger={({ toggle }) => (
<button type="button" className={fileActionClass} onClick={toggle}>
</button>
)}
>
<Code2 className="h-3.5 w-3.5" />
<span className={fileActionLabelClass}></span>
</button>
{({ close }) => (
<div className="w-52 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl">
<button
type="button"
className="flex h-8 w-full items-center gap-2 rounded px-2 text-xs hover:bg-element-hover disabled:opacity-40"
disabled={!hasProject || loading}
onClick={() => {
close();
onOpenSource?.();
}}
>
<Code2 className="h-4 w-4" />
</button>
</div>
)}
</Popover>
</div>
<div className="flex items-center justify-center">{center}</div>
<div className="flex min-w-0 items-center justify-end gap-0.5">
<Button
variant="ghost"
onClick={onTogglePause}
disabled={!ready}
aria-label={paused ? '▶ 播放' : '⏸ 暂停'}
icon={paused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
>
{paused ? '播放' : '暂停'}
</Button>
<IconButton tooltip="单步" aria-label="单步" onClick={onStep} disabled={!ready || !paused}>
<StepForward className="h-3.5 w-3.5" />
</IconButton>
<IconButton tooltip="重置" aria-label="重置" onClick={onReset} disabled={!ready}>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
<Select
aria-label="仿真速度"
value={speed}
disabled={loading}
onChange={(event) => onSpeed(Number(event.target.value))}
className="w-[70px]"
>
<option value={0.25}>0.25×</option>
<option value={0.5}>0.5×</option>
<option value={1}>1×</option>
<option value={2}>2×</option>
<option value={4}>4×</option>
</Select>
<span className="mx-1 h-5 border-l border-border" />
<IconButton
tooltip={leftOpen ? '隐藏工程面板' : '显示工程面板'}
aria-label={leftOpen ? '隐藏工程面板' : '显示工程面板'}
@@ -179,38 +122,9 @@ export function WorkbenchHeader({
</IconButton>
{endActions}
{compactMenu}
<IconButton
className="hidden xl:inline-flex"
tooltip="命令面板(Ctrl+K"
aria-label="打开命令面板"
onClick={onCommands}
>
<IconButton tooltip="命令面板(Ctrl+K" aria-label="打开命令面板" onClick={onCommands}>
<Search className="h-4 w-4" />
</IconButton>
<IconButton
className="hidden xl:inline-flex"
tooltip={fullscreen ? '退出全屏' : '进入全屏'}
aria-label={fullscreen ? '退出全屏' : '进入全屏'}
onClick={onToggleFullscreen}
>
{fullscreen ? <Minimize className="h-4 w-4" /> : <Expand className="h-4 w-4" />}
</IconButton>
<IconButton
className="hidden xl:inline-flex"
tooltip="快捷键帮助"
aria-label="快捷键帮助"
onClick={onHelp}
>
<CircleHelp className="h-4 w-4" />
</IconButton>
<IconButton
className="hidden xl:inline-flex"
tooltip={theme === 'dark' ? '切换到白天主题' : '切换到黑夜主题'}
aria-label={theme === 'dark' ? '切换到白天主题' : '切换到黑夜主题'}
onClick={onToggleTheme}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</IconButton>
</div>
</header>
);
@@ -6,12 +6,10 @@ import {
LoaderCircle,
PlayCircle,
Settings2,
ShieldCheck,
Sparkles,
Upload,
UploadCloud,
} from 'lucide-react';
import { ProgressBar, Skeleton } from '../../components/ui';
import { Button, ProgressBar, Skeleton, Tooltip } from '../../components/ui';
export interface ImportProgress {
title?: string;
@@ -41,79 +39,36 @@ export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
return (
<section
aria-label="导入模型工程"
className="workspace-welcome relative w-[min(600px,calc(100vw-32px))] overflow-hidden rounded-[22px] border border-border-strong bg-panel/90 px-6 py-6 text-center shadow-2xl backdrop-blur-md sm:px-7"
className="mx-auto w-full max-w-md rounded-lg border border-border bg-panel p-6 text-center"
>
<div aria-hidden="true" className="welcome-glow" />
<div className="relative">
<div className="mb-4 flex items-center justify-center gap-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-accent">
<Sparkles className="h-3.5 w-3.5" />
Local Simulation Workspace
</div>
<span className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-2xl border border-accent/25 bg-accent-soft text-accent shadow-[0_12px_32px_rgba(53,199,146,0.16)]">
<UploadCloud className="h-6 w-6" />
</span>
<h2 className="text-lg font-semibold tracking-tight text-text-primary">
</h2>
<p className="mt-1.5 text-xs leading-5 text-text-tertiary">
</p>
<div
className="mt-4 flex flex-wrap items-center justify-center gap-1.5"
aria-label="支持格式"
<h2 className="text-base font-semibold"></h2>
<p className="mt-2 text-xs text-text-tertiary"> MJCF/XMLURDF ZIP</p>
<div className="mt-4 flex flex-wrap justify-center gap-2">
<Button
variant="primary"
icon={<Upload className="h-4 w-4" />}
onClick={() => document.getElementById('mujoco-project-files')?.click()}
>
{['MJCF / XML', 'URDF', 'ZIP', 'OBJ / STL / DAE'].map((format) => (
<span
key={format}
className="rounded-full border border-border bg-surface/80 px-2.5 py-1 text-[10px] font-medium text-text-secondary"
>
{format}
</span>
))}
</div>
<div className="mt-5 flex items-center justify-center gap-2">
<label
htmlFor="mujoco-project-files"
className="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3.5 text-xs font-semibold text-white shadow-[0_8px_22px_rgba(22,131,95,0.22)] transition-[background-color,transform,box-shadow] hover:-translate-y-0.5 hover:bg-accent-hover hover:shadow-[0_10px_26px_rgba(22,131,95,0.28)] focus-within:ring-2 focus-within:ring-accent/40"
>
<Upload className="h-3.5 w-3.5" />
</label>
<label
htmlFor="mujoco-project-folder"
className="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-surface px-3.5 text-xs font-semibold text-text-primary shadow-sm transition-[background-color,transform] hover:-translate-y-0.5 hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/40"
>
<FolderOpen className="h-3.5 w-3.5" />
</label>
</div>
<ol
aria-label="仿真工作流程"
className="mt-6 hidden grid-cols-3 gap-2 border-t border-border/80 pt-4 sm:grid"
</Button>
<Button
icon={<FolderOpen className="h-4 w-4" />}
onClick={() => document.getElementById('mujoco-project-folder')?.click()}
>
{workflow.map((item, index) => (
<li
key={item.label}
className="rounded-xl border border-border/70 bg-surface/70 px-3 py-3 text-left"
>
<div className="flex items-center gap-2">
<span className="technical-value text-[10px] font-semibold text-accent">
0{index + 1}
</span>
<item.icon aria-hidden="true" className="h-3.5 w-3.5 text-text-secondary" />
<span className="text-xs font-semibold text-text-primary">{item.label}</span>
</div>
<span className="mt-1 block text-[10px] leading-4 text-text-tertiary">
{item.detail}
</span>
</Button>
</div>
<details className="domain-details mt-4 text-left">
<summary>仿</summary>
<ol aria-label="仿真工作流程" className="space-y-2 text-xs text-text-secondary">
{workflow.map((item) => (
<li key={item.label}>
{item.label} · {item.detail}
</li>
))}
</ol>
<p className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary">
<ShieldCheck aria-hidden="true" className="h-3.5 w-3.5 text-success" />
</p>
</div>
</details>
<p className="mt-3 text-xs text-text-tertiary"></p>
</section>
);
}
@@ -122,20 +77,20 @@ function LoadingCard({ progress, compact }: { progress?: ImportProgress; compact
const title = progress?.title ?? '正在加载 MuJoCo 与模型';
return (
<div
className={`${compact ? 'w-[min(360px,calc(100vw-32px))]' : 'w-[min(400px,calc(100vw-32px))]'} overflow-hidden rounded-2xl border border-border-strong bg-panel/95 shadow-2xl backdrop-blur-md`}
className={`${compact ? 'w-[min(360px,calc(100vw-32px))]' : 'w-[min(400px,calc(100vw-32px))]'} overflow-hidden rounded-lg border border-border bg-panel shadow-xl`}
>
<div className="h-0.5 bg-gradient-to-r from-transparent via-accent to-transparent opacity-90" />
<div className={compact ? 'px-4 py-3.5' : 'px-5 py-5'}>
<div className="flex items-start gap-3">
<span className="relative mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-accent-soft text-accent">
<LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin" />
<span className="absolute inset-0 animate-ping rounded-xl border border-accent/20" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-text-primary">{title}</div>
<div className="mt-0.5 truncate text-[11px] text-text-tertiary">
{progress?.detail ?? '首次运行会下载并编译本地 WebAssembly 运行时'}
</div>
<Tooltip content={progress?.detail ?? '首次运行会下载并编译本地 WebAssembly 运行时'}>
<span tabIndex={0} className="mt-1 inline-block text-xs text-text-tertiary">
</span>
</Tooltip>
</div>
</div>
{progress ? (
@@ -168,7 +123,7 @@ export function WorkspaceOverlays({
<>
{!hasSnapshot && !loading && (
<div className="pointer-events-none absolute inset-0 grid place-items-center p-4">
<div className="pointer-events-auto">
<div className="pointer-events-auto w-full max-w-[600px]">
<EmptyWorkspace />
</div>
</div>
@@ -180,7 +135,7 @@ export function WorkspaceOverlays({
aria-label={progress?.label ?? '正在加载 MuJoCo 与模型'}
className={
hasSnapshot
? 'pointer-events-none absolute right-4 top-4 z-30'
? 'pointer-events-none absolute inset-0 z-30 grid place-items-center p-4'
: 'absolute inset-0 z-30 grid place-items-center bg-app/70 p-4 backdrop-blur-[3px]'
}
>
@@ -1,5 +1,5 @@
import type { ComponentProps } from 'react';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { SimulationSnapshot } from '../../simulation/SimulationSession';
import { WorkspaceToolsPanel } from './WorkspaceToolsPanel';
@@ -56,6 +56,20 @@ function props(): ComponentProps<typeof WorkspaceToolsPanel> {
}
describe('WorkspaceToolsPanel', () => {
it('首次展开后折叠训练组不丢连接凭据与状态', () => {
render(<WorkspaceToolsPanel {...props()} />);
expect(screen.queryByLabelText('训练服务访问令牌')).not.toBeInTheDocument();
const section = screen.getByRole('button', { name: '强化学习任务' });
fireEvent.click(section);
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), {
target: { value: 'session-only' },
});
fireEvent.click(section);
expect(screen.getByLabelText('训练服务访问令牌')).not.toBeVisible();
expect(section).toHaveTextContent('离线');
fireEvent.click(section);
expect(screen.getByLabelText('训练服务访问令牌')).toHaveValue('session-only');
});
it('按实时控制到自动化流程排序,并默认折叠所有控制台组件', () => {
render(<WorkspaceToolsPanel {...props()} />);
@@ -63,12 +77,12 @@ describe('WorkspaceToolsPanel', () => {
.getAllByRole('button')
.filter((button) => button.hasAttribute('aria-expanded'));
const expected = [
['执行器实时控制', '控制'],
['关节姿态调试', '调试'],
['外力交互参数', '交互'],
['Python 脚本控制', '控制', '运行'],
['ONNX 策略运行', '推理', '停止'],
['强化学习任务', '训练'],
['执行器实时控制'],
['关节姿态调试'],
['外力交互参数'],
['Python 脚本控制', '运行'],
['ONNX 策略运行', '停止'],
['强化学习任务'],
];
expect(sections).toHaveLength(expected.length);
@@ -1,3 +1,4 @@
import { useState } from 'react';
import type { TrainingSceneCompiler } from '../../map/trainingMap';
import type { PolicyDeployment } from '../../rl/deployment';
import type { PlacedMapAsset } from '../../map/types';
@@ -10,25 +11,22 @@ import type { ActuatorParameters, SimulationSnapshot } from '../../simulation/Si
import type { DataRecorderConfig } from '../../telemetry/DataRecorder';
import { DataRecordingPanel } from '../../telemetry/DataRecordingPanel';
import { LocalTrainingPanel } from '../../training/LocalTrainingPanel';
import { Badge, Button, CollapsibleSection } from '../../components/ui';
import { Badge, Button, CollapsibleSection, Tooltip } from '../../components/ui';
import type { RightSidebarView } from './RightSidebarTabs';
export type WorkspaceTool = Exclude<RightSidebarView, 'inspector'>;
function ConsoleSectionBadges({
category,
count,
state,
running = false,
}: {
category: string;
count?: number;
state?: string;
running?: boolean;
}) {
return (
<span className="flex shrink-0 items-center gap-1">
<Badge tone="accent">{category}</Badge>
{count !== undefined && <Badge>{count}</Badge>}
{state && <Badge tone={running ? 'success' : 'neutral'}>{state}</Badge>}
</span>
@@ -126,6 +124,7 @@ export function WorkspaceToolsPanel({
onDataRecordingClear: () => void;
onDataRecordingExport: (format: 'csv' | 'json') => void;
}) {
const [trainingSummary, setTrainingSummary] = useState('');
const resolvedControllerStatus = controllerStatus ?? snapshot?.controller;
const resolvedPolicyStatus = policyStatus ?? snapshot?.rlPolicy;
const controls = snapshot ? (
@@ -133,7 +132,8 @@ export function WorkspaceToolsPanel({
<CollapsibleSection
title="执行器实时控制"
defaultOpen={false}
badge={<ConsoleSectionBadges category="控制" count={snapshot.actuators.length} />}
keepMounted
badge={<ConsoleSectionBadges count={snapshot.actuators.length} />}
>
{snapshot.actuators.length ? (
snapshot.actuators.map((actuator) => (
@@ -151,7 +151,8 @@ export function WorkspaceToolsPanel({
<CollapsibleSection
title="关节姿态调试"
defaultOpen={false}
badge={<ConsoleSectionBadges category="调试" count={snapshot.joints.length} />}
keepMounted
badge={<ConsoleSectionBadges count={snapshot.joints.length} />}
>
<div className="mb-4 grid grid-cols-2 gap-2">
<Button onClick={onResetJoints}></Button>
@@ -203,25 +204,33 @@ export function WorkspaceToolsPanel({
<CollapsibleSection
title="外力交互参数"
defaultOpen={false}
badge={<ConsoleSectionBadges category="交互" />}
keepMounted
badge={<ConsoleSectionBadges />}
>
<ControlSlider
label={`${forceScale.toFixed(0)} N/屏幕单位`}
label="外力强度"
unit=" N/屏幕单位"
value={forceScale}
min={5}
max={200}
onChange={onForceScale}
/>
<p className="text-xs text-text-tertiary">
</p>
<Tooltip content="选择视口底部的外力施加工具,在动态物体上拖动,松开即清零。">
<button
type="button"
className="min-h-7 text-xs text-text-secondary underline decoration-dotted"
>
</button>
</Tooltip>
</CollapsibleSection>
<CollapsibleSection
title="Python 脚本控制"
forceOpen={Boolean(resolvedControllerStatus?.error)}
defaultOpen={false}
keepMounted
badge={
<ConsoleSectionBadges
category="控制"
state={
resolvedControllerStatus
? resolvedControllerStatus.enabled
@@ -248,10 +257,11 @@ export function WorkspaceToolsPanel({
</CollapsibleSection>
<CollapsibleSection
title="ONNX 策略运行"
forceOpen={Boolean(resolvedPolicyStatus?.error)}
defaultOpen={false}
keepMounted
badge={
<ConsoleSectionBadges
category="推理"
state={
resolvedPolicyStatus ? (resolvedPolicyStatus.enabled ? '运行' : '停止') : undefined
}
@@ -280,9 +290,16 @@ export function WorkspaceToolsPanel({
<CollapsibleSection
title="强化学习任务"
defaultOpen={false}
badge={<ConsoleSectionBadges category="训练" />}
keepMounted
badge={
<ConsoleSectionBadges
state={trainingSummary || undefined}
running={trainingSummary === '训练中'}
/>
}
>
<LocalTrainingPanel
onStatusChange={setTrainingSummary}
onPolicyReady={onImportPolicy}
compileScene={compileTrainingScene}
sceneMaps={trainingSceneMaps}
@@ -300,21 +317,23 @@ export function WorkspaceToolsPanel({
aria-label={active === 'controls' ? '控制台' : '数据录制'}
className="min-h-0 flex-1 overflow-y-auto panel-scroll"
>
{active === 'controls' ? (
controls
) : snapshot ? (
<DataRecordingPanel
status={snapshot.telemetry}
bodies={snapshot.bodies}
onConfigure={onDataRecorderConfigure}
onStart={onDataRecordingStart}
onStop={onDataRecordingStop}
onClear={onDataRecordingClear}
onExport={onDataRecordingExport}
/>
) : (
<p className="p-4 text-sm text-text-tertiary"></p>
)}
<div hidden={active !== 'controls'}>{controls}</div>
<div hidden={active !== 'data'}>
{active === 'data' &&
(snapshot ? (
<DataRecordingPanel
status={snapshot.telemetry}
bodies={snapshot.bodies}
onConfigure={onDataRecorderConfigure}
onStart={onDataRecordingStart}
onStop={onDataRecordingStop}
onClear={onDataRecordingClear}
onExport={onDataRecordingExport}
/>
) : (
<p className="p-4 text-sm text-text-tertiary"></p>
))}
</div>
</div>
);
}
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import type { MapEditorTransformMode } from '../../map/editor/types';
import { isTextEditingTarget } from '../keyboard';
export interface MapEditorShortcutOptions {
enabled: boolean;
@@ -16,13 +17,6 @@ export interface MapEditorShortcutOptions {
onSave: () => void;
}
function isTextEditingTarget(target: EventTarget | null): boolean {
return (
target instanceof HTMLElement &&
(target.matches('input, textarea, select') || target.isContentEditable)
);
}
/**
*
*
@@ -0,0 +1,52 @@
import { act, renderHook } from '@testing-library/react';
import { useSidebarLayout } from './useSidebarLayout';
const resize = (width: number) => {
window.innerWidth = width;
window.dispatchEvent(new Event('resize'));
};
describe('useSidebarLayout', () => {
beforeEach(() => localStorage.clear());
it('长期持有的导入/选择回调使用最新宽度,不误写左栏偏好', () => {
resize(1280);
const { result } = renderHook(useSidebarLayout);
const openInspector = result.current.setRightOpen;
act(() => resize(1440));
act(() => openInspector(true));
expect([result.current.leftOpen, result.current.rightOpen]).toEqual([true, true]);
expect(result.current.setRightOpen).toBe(openInspector);
act(() => resize(768));
act(() => openInspector(true));
expect([result.current.leftOpen, result.current.rightOpen]).toEqual([false, true]);
expect(localStorage.getItem('mujoco-platform-layout')).toBe('{"left":true,"right":true}');
});
it.each([
[1920, true, true],
[1440, true, true],
[1366, false, true],
[1024, false, true],
[768, false, false],
])('宽度 %i 使用批准默认布局', (width, left, right) => {
resize(width as number);
const { result } = renderHook(useSidebarLayout);
expect([result.current.leftOpen, result.current.rightOpen]).toEqual([left, right]);
expect(localStorage.getItem('mujoco-platform-layout')).toBeNull();
});
it('窄屏一次只开一侧,回到桌面恢复原偏好,不写入临时折叠', () => {
localStorage.setItem('mujoco-platform-layout', '{"left":true,"right":true}');
resize(1440);
const { result } = renderHook(useSidebarLayout);
act(() => resize(768));
expect([result.current.leftOpen, result.current.rightOpen]).toEqual([false, false]);
act(() => result.current.setLeftOpen(true));
act(() => result.current.setRightOpen(true));
expect([result.current.leftOpen, result.current.rightOpen]).toEqual([false, true]);
expect(localStorage.getItem('mujoco-platform-layout')).toBe('{"left":true,"right":true}');
act(() => resize(1440));
expect([result.current.leftOpen, result.current.rightOpen]).toEqual([true, true]);
act(() => result.current.setLeftOpen((value) => !value));
expect(JSON.parse(localStorage.getItem('mujoco-platform-layout')!)).toEqual({
left: false,
right: true,
});
});
});
@@ -0,0 +1,65 @@
import { useCallback, useSyncExternalStore, useState, type SetStateAction } from 'react';
const STORAGE_KEY = 'mujoco-platform-layout';
function readPreference(): { left: boolean; right: boolean } | null {
try {
const value = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null');
return typeof value?.left === 'boolean' && typeof value?.right === 'boolean' ? value : null;
} catch {
return null;
}
}
const resolve = (next: SetStateAction<boolean>, current: boolean) =>
typeof next === 'function' ? next(current) : next;
/** 窄屏开关是临时状态;不会用临时折叠覆盖桌面偏好。侧栏始终挂载。 */
export function useSidebarLayout() {
const [preference, setPreference] = useState(readPreference);
const [narrowSide, setNarrowSide] = useState<'left' | 'right' | null>(null);
const subscribe = useCallback((notify: () => void) => {
const resize = () => {
if (window.innerWidth >= 1024) setNarrowSide(null);
notify();
};
window.addEventListener('resize', resize);
return () => window.removeEventListener('resize', resize);
}, []);
const width = useSyncExternalStore(subscribe, () => window.innerWidth);
const narrow = width < 1024;
const desktop = preference ?? { left: width >= 1440, right: true };
// Viewer 与导入回调会长期持有 setter;不能捕获首次渲染时的屏幕宽度。
const setSide = useCallback((side: 'left' | 'right', next: SetStateAction<boolean>) => {
const currentWidth = window.innerWidth;
if (currentWidth < 1024) {
setNarrowSide((current) =>
resolve(next, current === side) ? side : current === side ? null : current,
);
return;
}
setPreference((current) => {
const value = current ?? { left: currentWidth >= 1440, right: true };
const updated = { ...value, [side]: resolve(next, value[side]) };
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
} catch {
/* 会话内仍可调整 */
}
return updated;
});
}, []);
const setLeftOpen = useCallback(
(value: SetStateAction<boolean>) => setSide('left', value),
[setSide],
);
const setRightOpen = useCallback(
(value: SetStateAction<boolean>) => setSide('right', value),
[setSide],
);
return {
width,
leftOpen: narrow ? narrowSide === 'left' : desktop.left,
rightOpen: narrow ? narrowSide === 'right' : desktop.right,
setLeftOpen,
setRightOpen,
};
}
@@ -0,0 +1,47 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { useThemePreference } from './useThemePreference';
function Consumer({ name }: { name: string }) {
const [theme, setTheme] = useThemePreference();
return (
<button onClick={() => setTheme((value) => (value === 'dark' ? 'light' : 'dark'))}>
{name}:{theme}
</button>
);
}
beforeEach(() => {
localStorage.clear();
window.dispatchEvent(new StorageEvent('storage', { key: null }));
});
it('保留存储键,同标签页消费者和跨标签页变更一致', () => {
render(
<>
<Consumer name="主应用" />
<Consumer name="调参" />
</>,
);
fireEvent.click(screen.getByText('主应用:dark'));
expect(screen.getByText('调参:light')).toBeVisible();
expect(localStorage.getItem('mujoco-platform-theme')).toBe('light');
act(() => {
localStorage.setItem('mujoco-platform-theme', 'dark');
window.dispatchEvent(new StorageEvent('storage', { key: 'mujoco-platform-theme' }));
});
expect(screen.getByText('主应用:dark')).toBeVisible();
expect(document.documentElement.style.colorScheme).toBe('dark');
});
it('拒绝写入存储时仍可切换并同步当前会话', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('denied');
});
render(
<>
<Consumer name="主应用" />
<Consumer name="调参" />
</>,
);
fireEvent.click(screen.getByText('主应用:dark'));
expect(screen.getByText('调参:light')).toBeVisible();
fireEvent.click(screen.getByText('调参:light'));
expect(screen.getByText('主应用:dark')).toBeVisible();
});
@@ -0,0 +1,50 @@
import { useCallback, useEffect, useSyncExternalStore, type SetStateAction } from 'react';
type ThemePreference = 'light' | 'dark';
const storageKey = 'mujoco-platform-theme';
const changeEvent = 'mujoco-theme-preference';
let sessionTheme: ThemePreference = 'dark';
let storageBlocked = false;
function readTheme(): ThemePreference {
if (storageBlocked) return sessionTheme;
try {
sessionTheme = localStorage.getItem(storageKey) === 'light' ? 'light' : 'dark';
} catch {
// 无存储权限时仍保留当前标签页偏好。
}
return sessionTheme;
}
function subscribe(notify: () => void) {
const storage = (event: StorageEvent) => {
if (event.key === storageKey || event.key === null) {
storageBlocked = false;
notify();
}
};
window.addEventListener('storage', storage);
window.addEventListener(changeEvent, notify);
return () => {
window.removeEventListener('storage', storage);
window.removeEventListener(changeEvent, notify);
};
}
/** 主工作台、独立调参与编辑器共享原存储键;同标签页和跨标签页同步。 */
export function useThemePreference() {
const theme = useSyncExternalStore(subscribe, readTheme);
const setTheme = useCallback((value: SetStateAction<ThemePreference>) => {
sessionTheme = typeof value === 'function' ? value(readTheme()) : value;
try {
localStorage.setItem(storageKey, sessionTheme);
} catch {
storageBlocked = true;
// 不阻断会话内主题切换。
}
window.dispatchEvent(new Event(changeEvent));
}, []);
useEffect(() => {
document.documentElement.style.colorScheme = theme;
}, [theme]);
return [theme, setTheme] as const;
}
+21
View File
@@ -0,0 +1,21 @@
import { isTextEditingTarget } from './keyboard';
describe('isTextEditingTarget', () => {
it.each(['input', 'textarea', 'select'])('快捷键避让 %s', (tag) => {
expect(isTextEditingTarget(document.createElement(tag))).toBe(true);
});
it.each(['contenteditable="true"', 'role="textbox"', 'data-text-editing="true"'])(
'避让自定义编辑器及嵌套目标 %s',
(attribute) => {
const host = document.createElement('div');
host.innerHTML = `<div ${attribute}><span>编辑内容</span></div>`;
expect(isTextEditingTarget(host.querySelector('span'))).toBe(true);
},
);
it('不屏蔽普通按钮及视口,兼容空事件目标', () => {
expect(isTextEditingTarget(document.createElement('button'))).toBe(false);
expect(isTextEditingTarget(document.createElement('canvas'))).toBe(false);
expect(isTextEditingTarget(null)).toBe(false);
expect(isTextEditingTarget(document)).toBe(false);
});
});
+11
View File
@@ -0,0 +1,11 @@
/** 全局快捷键必须避让所有可编辑控件,包括嵌套在标签或自定义控件中的输入区。 */
export function isTextEditingTarget(target: EventTarget | null): boolean {
return (
target instanceof HTMLElement &&
Boolean(
target.closest(
'input, textarea, select, [contenteditable="true"], [role="textbox"], [data-text-editing="true"]',
),
)
);
}
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { ScalarChart } from './ScalarChart';
import { ScalarChart as TuningChart } from '../../tuning/ScalarChart';
import type uPlot from 'uplot';
@@ -7,6 +7,7 @@ const spies = vi.hoisted(() => ({
destroy: vi.fn(),
setData: vi.fn(),
setScale: vi.fn(),
redraw: vi.fn(),
}));
vi.mock('uplot', () => ({
default: class {
@@ -21,6 +22,7 @@ vi.mock('uplot', () => ({
setScale = spies.setScale;
setData = spies.setData;
setSize() {}
redraw = spies.redraw;
destroy = spies.destroy;
},
}));
@@ -40,6 +42,15 @@ it('共享原tuning API、EMA只修改曲线、悬停保留原值、缩放及卸
expect(points[1].value).toBe(10);
fireEvent.click(screen.getByRole('button', { name: '训练与评估 Scalars 放大' }));
expect(spies.setScale).toHaveBeenCalled();
const scalesBeforeTheme = spies.setScale.mock.calls.length;
const createsBeforeTheme = spies.create.mock.calls.length;
act(() => {
localStorage.setItem('mujoco-platform-theme', 'light');
window.dispatchEvent(new StorageEvent('storage', { key: 'mujoco-platform-theme' }));
});
expect(spies.redraw).toHaveBeenCalledWith(true, true);
expect(spies.setScale).toHaveBeenCalledTimes(scalesBeforeTheme);
expect(spies.create).toHaveBeenCalledTimes(createsBeforeTheme);
fireEvent.click(screen.getByRole('button', { name: '训练与评估 Scalars 重置缩放' }));
expect(spies.setData).toHaveBeenCalled();
view.unmount();
@@ -1,7 +1,9 @@
import { useThemePreference } from '../../app/hooks/useThemePreference';
import { useEffect, useMemo, useRef } from 'react';
import { RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { IconButton, Tooltip } from '../ui';
import type { ScalarSeries } from '../../training/types';
const COLORS = ['#38d39f', '#60a5fa', '#f59e0b', '#f472b6', '#a78bfa', '#fb7185'];
@@ -27,6 +29,7 @@ export function ScalarChart({
title?: string;
xLabel?: string;
}) {
const [theme] = useThemePreference();
const host = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
const trackZoom = useRef(false);
@@ -51,11 +54,10 @@ export function ScalarChart({
useEffect(() => {
if (!host.current || series.length === 0 || prepared[0].length === 0) return;
const element = host.current;
const width = Math.max(280, Math.floor(element.getBoundingClientRect().width));
const width = Math.max(1, Math.floor(element.getBoundingClientRect().width));
trackZoom.current = false;
const style = getComputedStyle(element);
const axisColor = style.getPropertyValue('--ui-text-tertiary').trim() || '#8fa0b5';
const gridColor = style.getPropertyValue('--ui-border').trim() || '#213044';
const axisColor = () => getComputedStyle(element).getPropertyValue('--ui-text-tertiary').trim();
const gridColor = () => getComputedStyle(element).getPropertyValue('--ui-border').trim();
const chart = new uPlot(
{
width,
@@ -127,7 +129,7 @@ export function ScalarChart({
const observer = new ResizeObserver(() => {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => {
const nextWidth = Math.max(280, Math.floor(element.getBoundingClientRect().width));
const nextWidth = Math.max(1, Math.floor(element.getBoundingClientRect().width));
if (nextWidth !== lastWidth) {
lastWidth = nextWidth;
chart.setSize({ width: nextWidth, height: 280 });
@@ -145,6 +147,10 @@ export function ScalarChart({
};
}, [prepared, series, xLabel]);
useEffect(() => {
chartRef.current?.redraw(true, true);
}, [theme]);
const zoom = (factor: number) => {
const chart = chartRef.current;
if (!chart) return;
@@ -167,50 +173,50 @@ export function ScalarChart({
if (series.length === 0)
return (
<div className="grid h-[280px] place-items-center rounded-lg border border-border bg-app text-xs text-text-tertiary">
<div className="grid py-10 place-items-center rounded-lg border border-border bg-app text-xs text-text-tertiary">
trial scalar
</div>
);
return (
<section className="min-w-0 overflow-hidden rounded-lg border border-border bg-app">
<header className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<h3 className="min-w-0 truncate text-[10px] font-semibold" title={title}>
{title}
</h3>
<h3 className="min-w-0 break-all text-xs font-semibold">{title}</h3>
<div className="flex shrink-0 items-center gap-1">
<button
<IconButton
type="button"
aria-label={`${title} 放大`}
title="放大"
tooltip="放大"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={() => zoom(0.7)}
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
</IconButton>
<IconButton
type="button"
aria-label={`${title} 缩小`}
title="缩小"
tooltip="缩小"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={() => zoom(1.4)}
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<button
</IconButton>
<IconButton
type="button"
aria-label={`${title} 重置缩放`}
title="重置缩放"
tooltip="重置缩放"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={resetZoom}
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</IconButton>
</div>
</header>
<div ref={host} className="min-w-0 w-full overflow-hidden" />
<p className="border-t border-border px-3 py-1.5 text-[9px] text-text-tertiary">
线
</p>
<Tooltip content="图例显示原始值;滚轮或拖拽缩放,右上角复位。">
<span tabIndex={0} className="m-2 inline-block text-xs text-text-tertiary">
</span>
</Tooltip>
</section>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@ export function Badge({
return (
<span
title={title}
className={`engineering-badge inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}
className={`engineering-badge inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium ${toneClass} ${className}`}
>
{children}
</span>
+3 -3
View File
@@ -16,21 +16,21 @@ export function Button({
...props
}: ButtonProps) {
const variants = {
primary: 'border-transparent bg-accent text-white hover:bg-accent-hover',
primary: 'border-transparent bg-accent text-accent-contrast hover:bg-accent-hover',
secondary: 'border-border bg-surface text-text-primary hover:bg-element-hover',
ghost:
'border-transparent bg-transparent text-text-secondary hover:bg-element-hover hover:text-text-primary',
danger: 'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-white',
};
const sizes = {
sm: 'h-7 gap-1.5 rounded-md px-2 text-xs',
sm: 'h-control gap-1.5 rounded-md px-2 text-xs',
md: 'h-8 gap-2 rounded-md px-3 text-sm',
icon: 'h-7 w-7 rounded-md p-0',
};
return (
<button
type={type}
className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:hover:-translate-y-px enabled:active:translate-y-0 enabled:active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()}
className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:hover:-translate-y-px enabled:active:translate-y-0 enabled:active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()}
{...props}
>
{icon && (
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { CollapsibleSection } from './CollapsibleSection';
describe('CollapsibleSection', () => {
it('遵循默认折叠状态并可展开', () => {
render(
@@ -23,3 +25,38 @@ describe('CollapsibleSection', () => {
expect(screen.getByText('错误详情')).toBeVisible();
});
});
it('保持挂载仅在首次展开后生效;关闭保留输入,错误强制可发现', () => {
const unmount = vi.fn();
function Content() {
const [value, setValue] = useState('');
useEffect(() => unmount, []);
return (
<input
aria-label="任务输入"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
);
}
const view = render(
<CollapsibleSection title="训练" defaultOpen={false} keepMounted>
<Content />
</CollapsibleSection>,
);
expect(screen.queryByLabelText('任务输入')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '训练' }));
fireEvent.change(screen.getByLabelText('任务输入'), { target: { value: '保留草稿' } });
fireEvent.click(screen.getByRole('button', { name: '训练' }));
expect(screen.getByLabelText('任务输入')).not.toBeVisible();
expect(unmount).not.toHaveBeenCalled();
view.rerender(
<CollapsibleSection title="训练" defaultOpen={false} keepMounted forceOpen>
<Content />
</CollapsibleSection>,
);
expect(screen.getByLabelText('任务输入')).toBeVisible();
expect(screen.getByLabelText('任务输入')).toHaveValue('保留草稿');
view.unmount();
expect(unmount).toHaveBeenCalledOnce();
});
@@ -6,22 +6,26 @@ export function CollapsibleSection({
defaultOpen = true,
forceOpen = false,
badge,
keepMounted = false,
}: {
title: string;
children: ReactNode;
defaultOpen?: boolean;
forceOpen?: boolean;
badge?: ReactNode;
keepMounted?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const expanded = forceOpen || open;
const [visited, setVisited] = useState(defaultOpen || forceOpen);
if (expanded && !visited) setVisited(true);
return (
<section className="border-b border-border">
<button
type="button"
aria-expanded={expanded}
onClick={() => setOpen((value) => !value)}
className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs font-semibold text-text-secondary transition-colors hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30"
className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs font-semibold text-text-secondary transition-colors hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
>
<ChevronRight
aria-hidden="true"
@@ -30,7 +34,11 @@ export function CollapsibleSection({
<span className="min-w-0 flex-1 truncate">{title}</span>
{badge}
</button>
{expanded && <div className="px-3 pb-3">{children}</div>}
{(expanded || (keepMounted && visited)) && (
<div hidden={!expanded} className="px-3 pb-3">
{children}
</div>
)}
</section>
);
}
@@ -26,7 +26,7 @@ export function ConfirmDialog({
onClose={onClose}
title={title}
footer={
<div className="flex justify-end gap-2">
<div className="flex flex-wrap justify-end gap-2">
<Button onClick={onClose}>{cancelLabel}</Button>
<Button variant={danger ? 'danger' : 'primary'} onClick={onConfirm}>
{confirmLabel}
@@ -34,7 +34,7 @@ export function ConfirmDialog({
</div>
}
>
{children}
<div className="space-y-3 break-words text-sm text-text-secondary">{children}</div>
</Dialog>
);
}
@@ -15,6 +15,21 @@ function Fixture() {
);
}
describe('Dialog', () => {
it('不可关闭的顶层对话框拦截 Escape,不穿透父层', () => {
const outerClose = vi.fn();
const innerClose = vi.fn();
render(
<Dialog open title="外层" onClose={outerClose}>
<Dialog open title="提交中" onClose={innerClose} closable={false}>
</Dialog>
</Dialog>,
);
fireEvent.keyDown(document, { key: 'Escape' });
fireEvent.pointerDown(document.body);
expect(outerClose).not.toHaveBeenCalled();
expect(innerClose).not.toHaveBeenCalled();
});
it('支持 Escape 关闭并恢复触发器焦点', () => {
render(<Fixture />);
const trigger = screen.getByRole('button', { name: '打开' });
+92 -49
View File
@@ -1,10 +1,14 @@
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { IconButton } from './IconButton';
const FOCUSABLE =
'button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
import {
FOCUSABLE,
FloatingLayerContext,
resolvePortalContainer,
useFloatingLayer,
} from './floating';
export function Dialog({
open,
onClose,
@@ -12,6 +16,8 @@ export function Dialog({
children,
footer,
className = '',
contentClassName = '',
placement = 'center',
closable = true,
}: {
open: boolean;
@@ -20,12 +26,34 @@ export function Dialog({
children: ReactNode;
footer?: ReactNode;
className?: string;
contentClassName?: string;
placement?: 'center' | 'bottom';
closable?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null),
previous = useRef<HTMLElement | null>(null),
onCloseRef = useRef(onClose),
titleId = useId();
const anchor = useRef<HTMLSpanElement>(null);
const [container, setContainer] = useState<Element | null>(null);
const layer = useFloatingLayer({
open,
roots: () => [ref.current],
dismiss: () => onCloseRef.current(),
escape: closable,
outside: closable,
});
const isTopRef = useRef(layer.isTop);
useEffect(() => {
isTopRef.current = layer.isTop;
});
useEffect(() => {
if (!open) return;
const update = () => setContainer(resolvePortalContainer(anchor.current));
update();
document.addEventListener('fullscreenchange', update);
return () => document.removeEventListener('fullscreenchange', update);
}, [open]);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
@@ -33,14 +61,15 @@ export function Dialog({
if (!open) return;
previous.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
ref.current?.focus();
return () => {
if (previous.current && document.contains(previous.current)) previous.current.focus();
};
}, [open]);
useEffect(() => {
if (!open) return;
if (!ref.current?.contains(document.activeElement)) ref.current?.focus();
const key = (event: KeyboardEvent) => {
if (event.key === 'Escape' && closable) {
event.preventDefault();
onCloseRef.current();
return;
}
if (event.key !== 'Tab' || !ref.current) return;
if (event.key !== 'Tab' || !ref.current || !isTopRef.current()) return;
const items = Array.from(ref.current.querySelectorAll<HTMLElement>(FOCUSABLE));
if (!items.length) {
event.preventDefault();
@@ -49,10 +78,16 @@ export function Dialog({
}
const first = items[0],
last = items.at(-1)!;
if (event.shiftKey && document.activeElement === first) {
if (
event.shiftKey &&
(document.activeElement === first || document.activeElement === ref.current)
) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
} else if (
!event.shiftKey &&
(document.activeElement === last || !ref.current.contains(document.activeElement))
) {
event.preventDefault();
first.focus();
}
@@ -60,46 +95,54 @@ export function Dialog({
document.addEventListener('keydown', key);
return () => {
document.removeEventListener('keydown', key);
if (previous.current && document.contains(previous.current)) previous.current.focus();
};
}, [open, closable]);
}, [open, container]);
if (!open) return null;
const backdrop = (
<div
aria-hidden="true"
className="absolute inset-0 bg-black/55 backdrop-blur-[1px]"
onMouseDown={(event) => {
if (closable && event.target === event.currentTarget) onCloseRef.current();
}}
/>
<div aria-hidden="true" className="absolute inset-0 bg-black/55 backdrop-blur-[1px]" />
);
return createPortal(
<div className="fixed inset-0 z-[400] grid place-items-center p-6" role="presentation">
{backdrop}
<div
ref={ref}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={`relative flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border bg-panel shadow-2xl focus:outline-none ${className}`}
>
<header className="flex h-11 shrink-0 items-center justify-between border-b border-border bg-surface px-4">
<h2 id={titleId} className="truncate text-sm font-semibold text-text-primary">
{title}
</h2>
{closable && (
<IconButton aria-label="关闭" tooltip="关闭" onClick={() => onCloseRef.current()}>
<X className="h-4 w-4" />
</IconButton>
)}
</header>
<div className="overflow-y-auto p-4">{children}</div>
{footer && (
<footer className="border-t border-border bg-surface px-4 py-3">{footer}</footer>
)}
</div>
</div>,
document.fullscreenElement ?? document.body,
return (
<>
<span ref={anchor} hidden />
{createPortal(
<FloatingLayerContext.Provider value={layer.context}>
<div
style={{ zIndex: layer.zIndex }}
className={`fixed inset-0 grid ${placement === 'bottom' ? 'items-end p-0' : 'place-items-center p-6'}`}
role="presentation"
>
{backdrop}
<div
ref={ref}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={`relative flex w-full flex-col overflow-hidden border border-border bg-panel shadow-2xl focus:outline-none ${
placement === 'bottom'
? 'max-h-[42vh] max-w-none rounded-t-xl border-x-0 border-b-0'
: 'max-h-[80vh] max-w-lg rounded-xl'
} ${className}`}
>
<header className="flex h-11 shrink-0 items-center justify-between border-b border-border bg-surface px-4">
<h2 id={titleId} className="truncate text-sm font-semibold text-text-primary">
{title}
</h2>
{closable && (
<IconButton aria-label="关闭" tooltip="关闭" onClick={() => onCloseRef.current()}>
<X className="h-4 w-4" />
</IconButton>
)}
</header>
<div className={`overflow-y-auto p-4 ${contentClassName}`}>{children}</div>
{footer && (
<footer className="border-t border-border bg-surface px-4 py-3">{footer}</footer>
)}
</div>
</div>
</FloatingLayerContext.Provider>,
container ?? resolvePortalContainer(),
)}
</>
);
}
@@ -15,8 +15,9 @@ export function IconButton({
const button = (
<button
type={type}
aria-label={props['aria-label'] ?? tooltip}
aria-pressed={active || undefined}
className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'tool-active-glow border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()}
className={`inline-flex h-hit-compact w-hit-compact shrink-0 items-center justify-center rounded-md border transition-[color,background-color,border-color,transform,box-shadow] duration-150 ease-out enabled:active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'tool-active-glow border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()}
{...props}
/>
);
@@ -0,0 +1,144 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { Dialog } from './Dialog';
import { Popover } from './Popover';
import { IconButton } from './IconButton';
import { DropdownMenu } from './DropdownMenu';
import { SearchableCombobox } from './SearchableCombobox';
function Fixture() {
const [open, setOpen] = useState(true);
return (
<Dialog open={open} onClose={() => setOpen(false)} title="父对话框">
<Popover
label="参数详情"
trigger={({ toggle }) => (
<IconButton tooltip="参数解释" onClick={toggle}>
</IconButton>
)}
>
{({ close }) => (
<div>
<input aria-label="数值" />
<button onClick={() => close()}></button>
<Popover
label="嵌套详情"
trigger={({ toggle }) => <button onClick={toggle}></button>}
>
{() => <button></button>}
</Popover>
</div>
)}
</Popover>
<button></button>
</Dialog>
);
}
describe('Popover', () => {
it('点击对话框遮罩时只关闭其上的 Popover,第二次才关闭对话框', () => {
render(<Fixture />);
fireEvent.click(screen.getByRole('button', { name: '参数解释' }));
const backdrop = screen.getByRole('presentation').querySelector('[aria-hidden="true"]')!;
fireEvent.pointerDown(backdrop);
expect(screen.queryByRole('dialog', { name: '参数详情' })).not.toBeInTheDocument();
expect(screen.getByRole('dialog', { name: '父对话框' })).toBeInTheDocument();
fireEvent.pointerDown(backdrop);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('兼容 IconButton,提供 aria、Portal 内操作与焦点恢复', () => {
render(<Fixture />);
const trigger = screen.getByRole('button', { name: '参数解释' });
fireEvent.click(trigger);
const popover = screen.getByRole('dialog', { name: '参数详情' });
expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(trigger).toHaveAttribute('aria-controls', popover.id);
expect(screen.getByRole('dialog', { name: '父对话框' })).not.toContainElement(popover);
expect(screen.getByRole('textbox')).toHaveFocus();
fireEvent.pointerDown(screen.getByRole('textbox'));
expect(popover).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '完成' }));
expect(trigger).toHaveFocus();
expect(trigger).toHaveAttribute('aria-expanded', 'false');
});
it('嵌套 Escape 每次只关闭最上层,不穿透到全局快捷键', () => {
render(<Fixture />);
fireEvent.click(screen.getByRole('button', { name: '参数解释' }));
fireEvent.click(screen.getByRole('button', { name: '更多' }));
const global = vi.fn();
document.addEventListener('keydown', global);
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: '嵌套详情' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '更多' })).toHaveFocus();
expect(screen.getByRole('dialog', { name: '参数详情' })).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: '参数详情' })).not.toBeInTheDocument();
expect(screen.getByRole('dialog', { name: '父对话框' })).toBeInTheDocument();
// 恢复焦点的 Tooltip 也遵守最上层规则。
if (screen.queryByRole('tooltip')) fireEvent.keyDown(document, { key: 'Escape' });
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(global).not.toHaveBeenCalled();
document.removeEventListener('keydown', global);
});
it('外部点击只关闭顶层,不偷取点击目标的焦点;Ctrl+K 继续传播', () => {
render(<Fixture />);
fireEvent.click(screen.getByRole('button', { name: '参数解释' }));
fireEvent.click(screen.getByRole('button', { name: '更多' }));
const outside = screen.getByRole('button', { name: '外部' });
fireEvent.pointerDown(outside);
act(() => outside.focus());
expect(outside).toHaveFocus();
expect(screen.queryByRole('dialog', { name: '嵌套详情' })).not.toBeInTheDocument();
expect(screen.getByRole('dialog', { name: '参数详情' })).toBeInTheDocument();
const global = vi.fn();
document.addEventListener('keydown', global);
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
expect(screen.queryByRole('dialog', { name: '参数详情' })).not.toBeInTheDocument();
expect(global).toHaveBeenCalledOnce();
document.removeEventListener('keydown', global);
});
it('Tab 从 Portal 边界退出并回到触发器,父对话框继续约束焦点', () => {
render(<Fixture />);
fireEvent.click(screen.getByRole('button', { name: '参数解释' }));
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab', shiftKey: true });
expect(screen.queryByRole('dialog', { name: '参数详情' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '参数解释' })).toHaveFocus();
});
it('下拉菜单与可搜索选择器保留方向键和 Enter 选择', () => {
const select = vi.fn();
render(
<>
<DropdownMenu
items={[
{ id: 'a', label: '首项', onSelect: select },
{ id: 'b', label: '末项', onSelect: select },
]}
/>
<SearchableCombobox
label="模型"
options={[
{ value: 'a', label: '模型 A' },
{ value: 'b', label: '模型 B' },
]}
onChange={select}
/>
</>,
);
fireEvent.click(screen.getByRole('button', { name: '更多操作' }));
fireEvent.keyDown(screen.getByRole('menu'), { key: 'End' });
expect(screen.getByRole('menuitem', { name: '末项' })).toHaveFocus();
fireEvent.keyDown(document, { key: 'Escape' });
fireEvent.click(screen.getByRole('button', { name: '模型' }));
const input = screen.getByRole('combobox');
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'Enter' });
expect(select).toHaveBeenCalledWith('b');
expect(screen.getByRole('button', { name: '模型' })).toHaveFocus();
});
});
+82 -46
View File
@@ -1,5 +1,17 @@
/* eslint-disable react-hooks/refs -- refs are read only inside event callbacks passed to render props */
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import {
cloneElement,
isValidElement,
useCallback,
useEffect,
useId,
useRef,
useState,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { FOCUSABLE, FloatingLayerContext, useFloatingLayer, useFloatingPosition } from './floating';
export function Popover({
trigger,
children,
@@ -11,56 +23,80 @@ export function Popover({
placement?: 'bottom-right' | 'bottom-left' | 'top-left';
label: string;
}) {
const [open, setOpen] = useState(false),
root = useRef<HTMLDivElement>(null),
previous = useRef<HTMLElement | null>(null);
const [open, setOpen] = useState(false);
const root = useRef<HTMLDivElement>(null);
const surface = useRef<HTMLElement>(null);
const previous = useRef<HTMLElement | null>(null);
const id = useId();
const close = useCallback((restoreFocus = true) => {
setOpen(false);
if (restoreFocus) previous.current?.focus();
if (restoreFocus && previous.current?.isConnected) previous.current.focus();
}, []);
const layer = useFloatingLayer({
open,
roots: () => [root.current, surface.current],
dismiss: close,
outside: true,
command: true,
});
const { container, style } = useFloatingPosition(open, root, surface, placement);
useEffect(() => {
if (!open) return;
const pointer = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
},
key = (event: KeyboardEvent) => {
if (
event.key === 'Escape' ||
((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k')
) {
setOpen(false);
previous.current?.focus();
}
};
document.addEventListener('pointerdown', pointer);
document.addEventListener('keydown', key);
return () => {
document.removeEventListener('pointerdown', pointer);
document.removeEventListener('keydown', key);
};
}, [open]);
const position =
placement === 'top-left'
? 'bottom-8 left-0'
: placement === 'bottom-left'
? 'left-0 top-9'
: 'right-0 top-9';
if (!open || !container) return;
if (!surface.current?.contains(document.activeElement))
(surface.current?.querySelector<HTMLElement>(FOCUSABLE) ?? surface.current)?.focus();
}, [open, container]);
const button = trigger({
open,
toggle: () => {
if (!open)
previous.current =
root.current?.querySelector<HTMLElement>(FOCUSABLE) ??
(document.activeElement instanceof HTMLElement ? document.activeElement : null);
setOpen((value) => !value);
},
});
return (
<div ref={root} className="relative">
{trigger({
open,
toggle: () => {
if (!open)
previous.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
setOpen((value) => !value);
},
})}
{open && (
<section role="dialog" aria-label={label} className={`absolute z-50 ${position}`}>
{children({ close })}
</section>
)}
<div ref={root} className="relative min-w-0">
{isValidElement<Record<string, unknown>>(button)
? cloneElement(button, {
'aria-expanded': open,
'aria-controls': open ? id : undefined,
'aria-haspopup': 'dialog',
})
: button}
{open &&
container &&
createPortal(
<FloatingLayerContext.Provider value={layer.context}>
<section
ref={surface}
id={id}
role="dialog"
aria-label={label}
tabIndex={-1}
style={{ ...style, zIndex: layer.zIndex }}
className="ui-popover overflow-auto rounded-lg text-xs text-text-primary focus:outline-none"
onKeyDown={(event) => {
if (event.key !== 'Tab' || !layer.isTop()) return;
const items = Array.from(
surface.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [],
);
if (
!items.length ||
(event.shiftKey
? document.activeElement === items[0]
: document.activeElement === items.at(-1))
) {
close();
if (event.shiftKey) event.preventDefault();
}
}}
>
{children({ close })}
</section>
</FloatingLayerContext.Provider>,
container,
)}
</div>
);
}
@@ -0,0 +1,37 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { PropertyRow } from './PropertyRow';
describe('PropertyRow', () => {
it('多轴值可换行保留数值与单位,不依赖截断 Tooltip', () => {
render(<PropertyRow label="世界系速度 XYZ" value="1.000, 2.000, 3.000 m/s" wrapValue />);
expect(screen.getByText('1.000, 2.000, 3.000 m/s')).toHaveClass('whitespace-normal');
expect(screen.getByText('1.000, 2.000, 3.000 m/s')).not.toHaveClass('truncate');
});
it('保留必要标签、值、单位和操作,说明可通过 focus 访问', () => {
render(
<PropertyRow
label="控制频率"
value="200 Hz"
description="每秒更新次数"
action={<button></button>}
/>,
);
expect(screen.getByText('200 Hz')).toBeVisible();
expect(screen.getByRole('button', { name: '修改' })).toBeVisible();
fireEvent.focus(screen.getByText('控制频率'));
expect(screen.getByRole('tooltip')).toHaveTextContent('控制频率:每秒更新次数');
});
it('仅截断文本提供额外停靠点及完整名称', () => {
const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(300);
const client = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(100);
const { unmount } = render(<PropertyRow label="长参数名称" value="完整模型路径" />);
expect(screen.getByText('完整模型路径')).toHaveAttribute('tabindex', '0');
fireEvent.focus(screen.getByText('完整模型路径'));
expect(screen.getByRole('tooltip')).toHaveTextContent('完整模型路径');
unmount();
width.mockRestore();
client.mockRestore();
render(<PropertyRow label="频率" value={200} />);
expect(screen.getByText('频率')).not.toHaveAttribute('tabindex');
});
});
+60 -5
View File
@@ -1,19 +1,74 @@
import type { ReactNode } from 'react';
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { Tooltip } from './Tooltip';
function PropertyText({
text,
description,
className = '',
}: {
text: string;
description?: ReactNode;
className?: string;
}) {
const ref = useRef<HTMLSpanElement>(null);
const [truncated, setTruncated] = useState(false);
useLayoutEffect(() => {
const update = () =>
setTruncated(Boolean(ref.current && ref.current.scrollWidth > ref.current.clientWidth));
update();
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
if (ref.current) observer?.observe(ref.current);
window.addEventListener('resize', update);
return () => {
observer?.disconnect();
window.removeEventListener('resize', update);
};
}, [text, truncated, description]);
const content = description ? (
<>
{text}{description}
</>
) : truncated ? (
text
) : undefined;
return (
<Tooltip content={content}>
<span
ref={ref}
tabIndex={content ? 0 : undefined}
className={`truncate rounded-sm ${className}`}
>
{text}
</span>
</Tooltip>
);
}
export function PropertyRow({
label,
value,
action,
description,
wrapValue = false,
}: {
label: string;
value: ReactNode;
action?: ReactNode;
description?: ReactNode;
wrapValue?: boolean;
}) {
return (
<div className="grid min-h-6 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 text-xs">
<span className="truncate text-text-tertiary">{label}</span>
<div className="grid min-h-7 grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center gap-2 text-xs leading-relaxed">
<PropertyText text={label} description={description} className="text-text-secondary" />
<span className="flex min-w-0 items-center justify-end gap-1 text-right text-text-primary">
<span className="technical-value truncate">{value}</span>
{action}
{wrapValue ? (
<span className="technical-value min-w-0 whitespace-normal break-words">{value}</span>
) : typeof value === 'string' || typeof value === 'number' ? (
<PropertyText text={String(value)} className="technical-value" />
) : (
<span className="technical-value min-w-0">{value}</span>
)}
{action && <span className="shrink-0">{action}</span>}
</span>
</div>
);
@@ -0,0 +1,33 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { ResizablePanel } from './ResizablePanel';
import { PanelWidthBudgetContext } from './panelWidthBudget';
describe('ResizablePanel 工作台宽度预算', () => {
it('显示宽度、ARIA 与键盘调节遵守预算,临时约束不覆盖存储', () => {
window.innerWidth = 1440;
localStorage.setItem('budget-width', '500');
const panel = (
<ResizablePanel side="left" storageKey="budget-width">
<input aria-label="未提交输入" defaultValue="草稿" />
</ResizablePanel>
);
const { rerender } = render(
<PanelWidthBudgetContext value={272}>{panel}</PanelWidthBudgetContext>,
);
const handle = screen.getByRole('separator');
expect(handle).toHaveAttribute('aria-valuenow', '272');
expect(handle).toHaveAttribute('aria-valuemax', '272');
expect(localStorage.getItem('budget-width')).toBe('500');
fireEvent.keyDown(handle, { key: 'ArrowLeft' });
expect(handle).toHaveAttribute('aria-valuenow', '256');
expect(localStorage.getItem('budget-width')).toBe('256');
act(() =>
window.dispatchEvent(
new CustomEvent('mujoco-layout-widths', { detail: { left: 500, right: 500 } }),
),
);
rerender(<PanelWidthBudgetContext value={undefined}>{panel}</PanelWidthBudgetContext>);
expect(handle).toHaveAttribute('aria-valuenow', '500');
expect(screen.getByLabelText('未提交输入')).toHaveValue('草稿');
});
});
@@ -1,23 +1,26 @@
import {
useContext,
useEffect,
useRef,
useState,
useSyncExternalStore,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
import { PanelWidthBudgetContext } from './panelWidthBudget';
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const panelMaxWidth = (minWidth: number) =>
Math.max(minWidth, Math.min(576, window.innerWidth * 0.4));
function subscribeViewportWidth(notify: () => void) {
window.addEventListener('resize', notify);
return () => window.removeEventListener('resize', notify);
}
function storedWidth(key: string, fallback: number, minWidth: number) {
try {
const value = Number(localStorage.getItem(key));
return clamp(
Number.isFinite(value) && value > 0 ? value : fallback,
minWidth,
panelMaxWidth(minWidth),
);
return clamp(Number.isFinite(value) && value > 0 ? value : fallback, minWidth, 576);
} catch {
return clamp(fallback, minWidth, panelMaxWidth(minWidth));
return clamp(fallback, minWidth, 576);
}
}
export function ResizablePanel({
@@ -37,10 +40,14 @@ export function ResizablePanel({
children: ReactNode;
className?: string;
}) {
const budget = useContext(PanelWidthBudgetContext);
const viewportWidth = useSyncExternalStore(subscribeViewportWidth, () => window.innerWidth);
const [width, setWidth] = useState(() => storedWidth(storageKey, defaultWidth, minWidth)),
cleanupRef = useRef<() => void>(() => {});
const maxWidth = Math.max(minWidth, Math.min(576, viewportWidth * 0.4, budget ?? Infinity));
const displayedWidth = Math.min(width, maxWidth);
const update = (next: number) => {
const value = clamp(next, minWidth, panelMaxWidth(minWidth));
const value = clamp(next, minWidth, maxWidth);
setWidth(value);
try {
localStorage.setItem(storageKey, String(value));
@@ -58,15 +65,12 @@ export function ResizablePanel({
/* 忽略 */
}
},
resize = () => setWidth((value) => clamp(value, minWidth, panelMaxWidth(minWidth))),
layout = (event: Event) => {
const widths = (event as CustomEvent<{ left: number; right: number }>).detail;
persist(widths[side]);
};
window.addEventListener('resize', resize);
window.addEventListener('mujoco-layout-widths', layout);
return () => {
window.removeEventListener('resize', resize);
window.removeEventListener('mujoco-layout-widths', layout);
cleanupRef.current();
};
@@ -75,7 +79,7 @@ export function ResizablePanel({
event.preventDefault();
cleanupRef.current();
const origin = event.clientX,
startWidth = width,
startWidth = event.currentTarget.parentElement?.getBoundingClientRect().width ?? width,
pointerId = event.pointerId;
const move = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId === pointerId)
@@ -99,8 +103,8 @@ export function ResizablePanel({
return (
<div
hidden={!visible}
className={`relative shrink-0 max-lg:absolute max-lg:inset-y-0 max-lg:z-40 max-lg:shadow-2xl ${side === 'left' ? 'max-lg:left-0' : 'max-lg:right-0'} ${className}`}
style={{ width }}
className={`resizable-panel relative shrink-0 max-lg:absolute max-lg:inset-y-0 max-lg:z-40 max-lg:shadow-2xl ${side === 'left' ? 'max-lg:left-0' : 'max-lg:right-0'} ${className}`}
style={{ width: displayedWidth }}
>
{children}
<button
@@ -109,14 +113,15 @@ export function ResizablePanel({
aria-label={side === 'left' ? '调整工程面板宽度' : '调整右侧面板宽度'}
aria-orientation="vertical"
aria-valuemin={minWidth}
aria-valuemax={Math.round(panelMaxWidth(minWidth))}
aria-valuenow={Math.round(width)}
aria-valuemax={Math.round(maxWidth)}
aria-valuenow={Math.round(displayedWidth)}
onPointerDown={start}
onKeyDown={(event) => {
if (event.key === 'Home') update(minWidth);
else if (event.key === 'End') update(panelMaxWidth(minWidth));
else if (event.key === 'ArrowLeft') update(width + (side === 'left' ? -16 : 16));
else if (event.key === 'ArrowRight') update(width + (side === 'left' ? 16 : -16));
else if (event.key === 'End') update(maxWidth);
else if (event.key === 'ArrowLeft') update(displayedWidth + (side === 'left' ? -16 : 16));
else if (event.key === 'ArrowRight')
update(displayedWidth + (side === 'left' ? 16 : -16));
else return;
event.preventDefault();
}}
@@ -11,6 +11,69 @@ describe('ScrubbableNumberInput', () => {
expect(onValueChange).toHaveBeenCalledWith(2.5);
});
it('允许框选初始零值后直接替换', () => {
const onValueChange = vi.fn();
render(<ScrubbableNumberInput label="位置 X" value={0} onValueChange={onValueChange} />);
const input = screen.getByLabelText('位置 X') as HTMLInputElement;
fireEvent.focus(input);
input.setSelectionRange(0, input.value.length);
fireEvent.change(input, { target: { value: '7' } });
expect(input).toHaveValue('7');
expect(onValueChange).toHaveBeenLastCalledWith(7);
});
it('保留空值和负号中间态,并在失焦时恢复无效草稿', () => {
const onValueChange = vi.fn();
render(<ScrubbableNumberInput label="偏移" value={0} onValueChange={onValueChange} />);
const input = screen.getByLabelText('偏移');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '' } });
expect(input).toHaveValue('');
fireEvent.change(input, { target: { value: '-' } });
expect(input).toHaveValue('-');
fireEvent.blur(input);
expect(input).toHaveValue('0');
expect(onValueChange).not.toHaveBeenCalled();
});
it('Enter 提交归一化结果,Escape 放弃草稿', () => {
const onValueChange = vi.fn();
render(
<ScrubbableNumberInput
label="透明度"
value={0}
min={0}
max={1}
step={0.1}
onValueChange={onValueChange}
/>,
);
const input = screen.getByLabelText('透明度');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '2' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(input).toHaveValue('1');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '0.4' } });
fireEvent.keyDown(input, { key: 'Escape' });
expect(input).toHaveValue('0');
});
it('输入键不会冒泡触发工作台快捷键', () => {
const shortcut = vi.fn();
window.addEventListener('keydown', shortcut);
render(<ScrubbableNumberInput label="数值" value={0} onValueChange={() => {}} />);
fireEvent.keyDown(screen.getByLabelText('数值'), { key: '1', code: 'Digit1' });
expect(shortcut).not.toHaveBeenCalled();
window.removeEventListener('keydown', shortcut);
});
it('在标签上水平拖拽并支持 Shift 精调', () => {
const onValueChange = vi.fn();
render(
@@ -7,6 +7,7 @@ import {
type PointerEvent as ReactPointerEvent,
} from 'react';
import { GripHorizontal } from 'lucide-react';
import { Tooltip } from './Tooltip';
function clamp(value: number, min?: number, max?: number): number {
return Math.min(
@@ -35,6 +36,7 @@ export interface ScrubbableNumberInputProps extends Omit<
step?: number;
min?: number;
max?: number;
axis?: 'x' | 'y' | 'z';
onValueChange: (value: number) => void;
containerClassName?: string;
labelClassName?: string;
@@ -44,6 +46,9 @@ export interface ScrubbableNumberInputProps extends Omit<
/**
*
* Shift Alt Ctrl/Meta
*
* 稿使
* 0
*/
export function ScrubbableNumberInput({
label,
@@ -51,6 +56,7 @@ export function ScrubbableNumberInput({
step = 0.1,
min,
max,
axis,
disabled,
onValueChange,
containerClassName = '',
@@ -59,16 +65,25 @@ export function ScrubbableNumberInput({
'aria-label': ariaLabel,
onFocus: onInputFocus,
onBlur: onInputBlur,
onKeyDown: onInputKeyDown,
onPointerDown: onInputPointerDown,
...inputProps
}: ScrubbableNumberInputProps) {
const [text, setText] = useState(() => String(value));
const [scrubbing, setScrubbing] = useState(false);
const labelId = useId();
const editing = useRef(false);
const canceling = useRef(false);
const textRef = useRef(String(value));
const cleanup = useRef<() => void>(() => {});
const updateText = (next: string) => {
textRef.current = next;
setText(next);
};
useEffect(() => {
if (!editing.current && !scrubbing) setText(String(value));
if (!editing.current && !scrubbing) updateText(String(value));
}, [value, scrubbing]);
useEffect(
@@ -80,16 +95,29 @@ export function ScrubbableNumberInput({
const emit = (next: number, quantizationStep = step) => {
const normalized = normalize(next, quantizationStep, min, max);
setText(String(normalized));
updateText(String(normalized));
onValueChange(normalized);
};
const commit = (raw = textRef.current) => {
const trimmed = raw.trim();
const next = Number(trimmed);
if (trimmed !== '' && Number.isFinite(next)) emit(next);
else updateText(String(value));
};
const startScrub = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (disabled || event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
cleanup.current();
const startX = event.clientX;
const startValue = Number.isFinite(value) ? value : 0;
const currentTextValue = Number(textRef.current);
const startValue = Number.isFinite(currentTextValue)
? currentTextValue
: Number.isFinite(value)
? value
: 0;
const pointerId = event.pointerId;
setScrubbing(true);
document.body.classList.add('is-scrubbing-number');
@@ -126,41 +154,44 @@ export function ScrubbableNumberInput({
<div
className={`scrubbable-number ${scrubbing ? 'scrubbable-number-active' : ''} ${containerClassName}`.trim()}
>
<button
type="button"
className={`scrubbable-number-label ${labelClassName}`.trim()}
aria-label="拖拽调节数值"
aria-describedby={labelId}
title={`拖拽调节 ${label}Shift 精调,Alt 超精调,Ctrl 粗调`}
disabled={disabled}
onPointerDown={startScrub}
onKeyDown={(event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
const modifier = event.altKey
? 0.01
: event.shiftKey
? 0.1
: event.ctrlKey || event.metaKey
? 10
: 1;
emit(value + (event.key === 'ArrowRight' ? 1 : -1) * step * modifier, step * modifier);
}}
<Tooltip
content={`拖拽或左右方向键调节 ${label}Shift 精调,Alt 超精调,Ctrl/Meta 粗调${min === undefined && max === undefined ? '' : `;范围 ${min ?? '−∞'}${max ?? '+∞'}`}`}
>
<span id={labelId} className="min-w-0 truncate">
{label}
</span>
<GripHorizontal className="h-3 w-3 shrink-0 opacity-45" aria-hidden="true" />
</button>
<button
type="button"
className={`scrubbable-number-label ${labelClassName}`.trim()}
aria-label="拖拽调节数值"
aria-describedby={labelId}
disabled={disabled}
onPointerDown={startScrub}
onKeyDown={(event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
const modifier = event.altKey
? 0.01
: event.shiftKey
? 0.1
: event.ctrlKey || event.metaKey
? 10
: 1;
const current = Number(textRef.current);
const base = Number.isFinite(current) ? current : value;
emit(base + (event.key === 'ArrowRight' ? 1 : -1) * step * modifier, step * modifier);
}}
>
{axis && <span className={`axis-badge axis-${axis}`}>{axis.toUpperCase()}</span>}
<span id={labelId} className="min-w-0 whitespace-normal break-words">
{label}
</span>
<GripHorizontal className="ml-auto h-3 w-3 shrink-0 opacity-40" aria-hidden="true" />
</button>
</Tooltip>
<input
{...inputProps}
aria-label={ariaLabel ?? label}
type="number"
type="text"
inputMode="decimal"
value={text}
step={step}
min={min}
max={max}
disabled={disabled}
className={`scrubbable-number-input ${inputClassName}`.trim()}
onFocus={(event) => {
@@ -169,20 +200,34 @@ export function ScrubbableNumberInput({
}}
onChange={(event) => {
const nextText = event.currentTarget.value;
setText(nextText);
updateText(nextText);
const next = Number(nextText);
if (nextText !== '' && Number.isFinite(next)) onValueChange(clamp(next, min, max));
// 保留原始字符串;合法数值仍实时驱动视口,但不让 prop 回写打断编辑。
if (nextText.trim() !== '' && Number.isFinite(next)) onValueChange(clamp(next, min, max));
}}
onPointerDown={(event) => {
event.stopPropagation();
onInputPointerDown?.(event);
}}
onBlur={(event) => {
editing.current = false;
const next = Number(text);
if (text !== '' && Number.isFinite(next)) emit(next);
else setText(String(value));
if (canceling.current) canceling.current = false;
else commit(event.currentTarget.value);
onInputBlur?.(event);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
inputProps.onKeyDown?.(event);
event.stopPropagation();
if (event.key === 'Enter') {
event.preventDefault();
commit(event.currentTarget.value);
event.currentTarget.blur();
} else if (event.key === 'Escape') {
event.preventDefault();
canceling.current = true;
updateText(String(value));
event.currentTarget.blur();
}
onInputKeyDown?.(event);
}}
/>
</div>
@@ -41,7 +41,7 @@ export function SearchableCombobox({
aria-label={label}
aria-expanded={open}
onClick={toggle}
className="flex h-8 w-full items-center justify-between rounded-md border border-border bg-input px-2 text-xs text-text-primary focus-visible:ring-2 focus-visible:ring-accent/30 disabled:opacity-40"
className="flex h-8 w-full items-center justify-between rounded-md border border-border bg-input px-2 text-xs text-text-primary focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-40"
>
<span className="truncate">{selected?.label ?? '请选择'}</span>
<ChevronsUpDown className="h-3.5 w-3.5 text-text-tertiary" />
+1 -1
View File
@@ -2,7 +2,7 @@ import type { SelectHTMLAttributes } from 'react';
export function Select({ className = '', ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
className={`h-7 rounded-md border border-border bg-input px-2 text-xs text-text-primary transition-colors hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:opacity-40 ${className}`.trim()}
className={`h-control rounded-md border border-border bg-input px-2 text-xs text-text-primary transition-colors hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-40 ${className}`.trim()}
{...props}
/>
);
+1 -1
View File
@@ -60,7 +60,7 @@ export function Tabs<T extends string>({
disabled={item.disabled}
onClick={() => onValueChange(item.value)}
onKeyDown={navigate}
className={`relative flex h-8 items-center gap-1.5 px-2 text-xs font-medium focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/40 ${item.value === active.value ? 'text-accent after:absolute after:inset-x-1 after:bottom-0 after:h-0.5 after:rounded-full after:bg-accent' : 'text-text-tertiary hover:text-text-primary'}`}
className={`relative flex h-8 items-center gap-1.5 px-2 text-xs font-medium focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent ${item.value === active.value ? 'text-accent after:absolute after:inset-x-1 after:bottom-0 after:h-0.5 after:rounded-full after:bg-accent' : 'text-text-tertiary hover:text-text-primary'}`}
>
{item.icon}
{item.label}
@@ -0,0 +1,91 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { Tooltip } from './Tooltip';
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe('Tooltip', () => {
it('hover 延时 350ms,在 Portal 中显示且可移入说明区域', () => {
const { container } = render(
<Tooltip content="完整说明">
<button></button>
</Tooltip>,
);
fireEvent.mouseEnter(screen.getByRole('button'));
act(() => vi.advanceTimersByTime(349));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
act(() => vi.advanceTimersByTime(1));
expect(container).not.toContainElement(screen.getByRole('tooltip'));
fireEvent.mouseLeave(screen.getByRole('button'));
fireEvent.mouseEnter(screen.getByRole('tooltip'));
act(() => vi.advanceTimersByTime(200));
expect(screen.getByRole('tooltip')).toBeVisible();
fireEvent.mouseLeave(screen.getByRole('tooltip'));
act(() => vi.advanceTimersByTime(120));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('focus 及时显示并合并稳定描述关联,Escape 后不立即重开', () => {
render(
<>
<span id="existing"></span>
<Tooltip content="说明">
<button aria-describedby="existing"></button>
</Tooltip>
</>,
);
const button = screen.getByRole('button');
act(() => button.focus());
const id = screen.getByRole('tooltip').id;
expect(button).toHaveAttribute('aria-describedby', `existing ${id}`);
fireEvent.keyDown(button, { key: 'Escape' });
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
expect(button).toHaveAttribute('aria-describedby', 'existing');
fireEvent.mouseEnter(button);
act(() => vi.advanceTimersByTime(350));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
act(() => button.blur());
act(() => button.focus());
expect(screen.getByRole('tooltip').id).toBe(id);
});
it('仅 disabled 触发器添加包装 Tab 停靠点,并清理延时', () => {
const { unmount } = render(
<Tooltip content="请先导入模型">
<button disabled></button>
</Tooltip>,
);
const wrapper = screen.getByRole('button').parentElement!;
expect(wrapper).toHaveAttribute('tabindex', '0');
act(() => wrapper.focus());
expect(wrapper).toHaveAttribute('aria-describedby', screen.getByRole('tooltip').id);
unmount();
render(
<Tooltip content="正常">
<button></button>
</Tooltip>,
);
expect(screen.getByRole('button').parentElement).not.toHaveAttribute('tabindex');
fireEvent.mouseEnter(screen.getByRole('button'));
});
it('继承最近主题容器,并在全屏切换后重新挂载', () => {
const { unmount } = render(
<div className="theme-light" data-testid="theme">
<Tooltip content="说明">
<button></button>
</Tooltip>
</div>,
);
fireEvent.focus(screen.getByRole('button'));
expect(screen.getByTestId('theme')).toContainElement(screen.getByRole('tooltip'));
const fullscreen = document.createElement('div');
document.body.append(fullscreen);
Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: fullscreen });
fireEvent(document, new Event('fullscreenchange'));
expect(fullscreen).toContainElement(screen.getByRole('tooltip'));
unmount();
Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null });
fullscreen.remove();
});
});
+92 -9
View File
@@ -1,24 +1,107 @@
import type { ReactElement, ReactNode } from 'react';
import {
cloneElement,
useEffect,
useId,
useRef,
useState,
type ReactElement,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { FloatingLayerContext, useFloatingLayer, useFloatingPosition } from './floating';
type TriggerProps = { 'aria-describedby'?: string; disabled?: boolean };
export function Tooltip({
content,
children,
side = 'bottom',
className = '',
}: {
content: ReactNode;
children: ReactElement;
side?: 'top' | 'bottom';
className?: string;
}) {
const id = useId();
const [open, setOpen] = useState(false);
const anchor = useRef<HTMLSpanElement>(null);
const surface = useRef<HTMLSpanElement>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const dismissed = useRef(false);
const child = children as ReactElement<TriggerProps>;
const clearTimer = () => {
if (timer.current !== null) clearTimeout(timer.current);
timer.current = null;
};
const close = () => {
clearTimer();
dismissed.current = true;
setOpen(false);
};
const layer = useFloatingLayer({
open: open && Boolean(content),
roots: () => [anchor.current, surface.current],
dismiss: close,
passive: true,
});
const { container, style } = useFloatingPosition(open && Boolean(content), anchor, surface, side);
useEffect(
() => () => {
if (timer.current !== null) clearTimeout(timer.current);
},
[],
);
if (!content) return children;
const description =
[child.props['aria-describedby'], open ? id : undefined].filter(Boolean).join(' ') || undefined;
const leave = () => {
clearTimer();
dismissed.current = false;
if (anchor.current?.contains(document.activeElement)) return;
timer.current = setTimeout(() => setOpen(false), 120);
};
return (
<span className="group/tooltip relative inline-flex">
{children}
<span
role="tooltip"
className={`pointer-events-none absolute left-1/2 z-[500] hidden w-max max-w-64 -translate-x-1/2 rounded-md border border-border bg-surface-elevated px-2 py-1 text-[10px] font-medium text-text-primary shadow-lg group-hover/tooltip:block group-focus-within/tooltip:block ${side === 'top' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'}`}
>
{content}
</span>
<span
ref={anchor}
className={`inline-flex min-w-0 max-w-full ${className}`}
tabIndex={child.props.disabled ? 0 : undefined}
aria-describedby={child.props.disabled ? description : undefined}
onMouseEnter={() => {
clearTimer();
if (!dismissed.current) timer.current = setTimeout(() => setOpen(true), 350);
}}
onMouseLeave={leave}
onFocus={() => {
clearTimer();
if (!dismissed.current) setOpen(true);
}}
onBlur={(event) => {
if (event.currentTarget.contains(event.relatedTarget as Node)) return;
clearTimer();
dismissed.current = false;
setOpen(false);
}}
onClickCapture={close}
>
{cloneElement(child, { 'aria-describedby': description })}
{open &&
container &&
createPortal(
<FloatingLayerContext.Provider value={layer.context}>
<span
ref={surface}
id={id}
role="tooltip"
style={{ ...style, maxWidth: 'min(16rem, calc(100vw - 16px))', zIndex: layer.zIndex }}
className="ui-tooltip w-max max-w-64 rounded-md border border-border-strong bg-surface-elevated px-2 py-1 text-xs font-medium leading-relaxed text-text-primary shadow-lg"
onMouseEnter={clearTimer}
onMouseLeave={leave}
>
{content}
</span>
</FloatingLayerContext.Provider>,
container,
)}
</span>
);
}
@@ -0,0 +1,76 @@
import { act, renderHook } from '@testing-library/react';
import { computeFloatingPosition, resolvePortalContainer, useFloatingPosition } from './floating';
const anchor = { left: 260, right: 300, top: 180, bottom: 200, width: 40 };
describe('floating', () => {
it('滚动时更新定位,关闭后移除监听器', () => {
const trigger = document.createElement('button');
const surface = document.createElement('section');
let left = 20;
const measure = vi
.spyOn(trigger, 'getBoundingClientRect')
.mockImplementation(
() => ({ left, right: left + 40, width: 40, top: 20, bottom: 40, height: 20 }) as DOMRect,
);
vi.spyOn(surface, 'getBoundingClientRect').mockReturnValue({
width: 100,
height: 80,
} as DOMRect);
const triggerRef = { current: trigger };
const surfaceRef = { current: surface };
const { result, rerender, unmount } = renderHook(
({ open }) => useFloatingPosition(open, triggerRef, surfaceRef, 'bottom-left'),
{ initialProps: { open: true } },
);
expect(result.current.style.left).toBe(20);
left = 100;
act(() => document.dispatchEvent(new Event('scroll')));
expect(result.current.style.left).toBe(100);
rerender({ open: false });
const calls = measure.mock.calls.length;
act(() => window.dispatchEvent(new Event('resize')));
act(() => document.dispatchEvent(new Event('scroll')));
expect(measure).toHaveBeenCalledTimes(calls);
unmount();
});
it('在右下边缘翻转并限制水平位置', () => {
expect(
computeFloatingPosition(
anchor,
{ width: 120, height: 80 },
{ width: 320, height: 240 },
'bottom-left',
),
).toEqual({ left: 192, top: 94 });
});
it('顶部无空间时向下翻转,并约束超大浮层', () => {
expect(
computeFloatingPosition(
{ ...anchor, top: 0, bottom: 20 },
{ width: 120, height: 80 },
{ width: 320, height: 240 },
'top',
),
).toEqual({ left: 192, top: 26 });
expect(
computeFloatingPosition(
anchor,
{ width: 500, height: 500 },
{ width: 320, height: 240 },
'bottom-right',
),
).toEqual({ left: 8, top: 8 });
});
it('优先最近主题而非页面第一个主题', () => {
const first = document.createElement('div');
first.className = 'theme-dark';
const second = document.createElement('div');
second.className = 'theme-light';
const button = document.createElement('button');
second.append(button);
document.body.append(first, second);
expect(resolvePortalContainer(button)).toBe(second);
first.remove();
second.remove();
});
});
+191
View File
@@ -0,0 +1,191 @@
import {
createContext,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type RefObject,
} from 'react';
const EDGE = 8;
const GAP = 6;
export const FOCUSABLE =
'button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
export type FloatingPlacement = 'top' | 'bottom' | 'bottom-right' | 'bottom-left' | 'top-left';
export const FloatingLayerContext = createContext<{ id: string; depth: number } | null>(null);
export function resolvePortalContainer(anchor?: Element | null): Element {
const fullscreen = document.fullscreenElement;
const theme = anchor?.closest('.theme-dark, .theme-light');
if (fullscreen) return theme && fullscreen.contains(theme) ? theme : fullscreen;
return theme ?? document.querySelector('.theme-dark, .theme-light') ?? document.body;
}
/** 统一使用视口坐标;先选择空间较多的一侧,再将浮层约束在可见边界内。 */
export function computeFloatingPosition(
anchor: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width'>,
size: { width: number; height: number },
viewport: { width: number; height: number },
placement: FloatingPlacement,
) {
const above = anchor.top - EDGE - GAP;
const below = viewport.height - anchor.bottom - EDGE - GAP;
let topSide = placement.startsWith('top');
if ((topSide ? above : below) < size.height && (topSide ? below > above : above > below))
topSide = !topSide;
const left = placement.endsWith('-left')
? anchor.left
: placement.endsWith('-right')
? anchor.right - size.width
: anchor.left + (anchor.width - size.width) / 2;
const top = topSide ? anchor.top - size.height - GAP : anchor.bottom + GAP;
return {
left: Math.max(EDGE, Math.min(left, viewport.width - size.width - EDGE)),
top: Math.max(EDGE, Math.min(top, viewport.height - size.height - EDGE)),
};
}
export function useFloatingPosition(
open: boolean,
anchor: RefObject<HTMLElement | null>,
surface: RefObject<HTMLElement | null>,
placement: FloatingPlacement,
) {
const [container, setContainer] = useState<Element | null>(null);
const [style, setStyle] = useState<CSSProperties>({ position: 'fixed', visibility: 'hidden' });
useLayoutEffect(() => {
if (!open) return;
const update = () => {
setContainer(resolvePortalContainer(anchor.current));
if (!anchor.current || !surface.current) return;
const viewport = { width: window.innerWidth, height: window.innerHeight };
const position = computeFloatingPosition(
anchor.current.getBoundingClientRect(),
surface.current.getBoundingClientRect(),
viewport,
placement,
);
setStyle({
position: 'fixed',
...position,
maxWidth: viewport.width - EDGE * 2,
maxHeight: viewport.height - EDGE * 2,
});
};
update();
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
if (anchor.current) observer?.observe(anchor.current);
if (surface.current) observer?.observe(surface.current);
window.addEventListener('resize', update);
document.addEventListener('scroll', update, true);
document.addEventListener('fullscreenchange', update);
return () => {
observer?.disconnect();
window.removeEventListener('resize', update);
document.removeEventListener('scroll', update, true);
document.removeEventListener('fullscreenchange', update);
};
}, [open, anchor, surface, placement, container]);
return { container, style };
}
type Layer = {
id: string;
parent: string | undefined;
roots: () => (HTMLElement | null)[];
dismiss: (restoreFocus: boolean) => void;
outside: boolean;
escape: boolean;
command: boolean;
passive: boolean;
};
const layers: Layer[] = [];
function topLayer(ignorePassive = false) {
const candidates = ignorePassive ? layers.filter((layer) => !layer.passive) : layers;
return [...candidates]
.reverse()
.find((candidate) => !candidates.some((layer) => layer.parent === candidate.id));
}
function onKeyDown(event: KeyboardEvent) {
const top = topLayer();
if (!top) return;
if (event.key === 'Escape') {
// 即使最上层不可关闭,也不能让 Escape 穿透到对话框或工作台快捷键。
event.preventDefault();
event.stopImmediatePropagation();
if (top.escape) top.dismiss(true);
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
if (top.passive) top.dismiss(false);
const interactive = topLayer(true);
if (interactive?.command) interactive.dismiss(true);
}
}
function onPointerDown(event: PointerEvent) {
const tooltip = topLayer();
if (tooltip?.passive) {
if (tooltip.roots().some((root) => root?.contains(event.target as Node))) return;
tooltip.dismiss(false);
}
const top = topLayer(true);
if (top?.outside && !top.roots().some((root) => root?.contains(event.target as Node)))
top.dismiss(false);
}
/** 所有共享浮层共用一个捕获阶段监听器,单次 Escape/外部点击只处理最上层。 */
export function useFloatingLayer({
open,
roots,
dismiss,
outside = false,
escape = true,
command = false,
passive = false,
}: {
open: boolean;
roots: () => (HTMLElement | null)[];
dismiss: (restoreFocus: boolean) => void;
outside?: boolean;
escape?: boolean;
command?: boolean;
passive?: boolean;
}) {
const id = useId();
const parent = useContext(FloatingLayerContext);
const latest = useRef({ roots, dismiss });
useLayoutEffect(() => {
latest.current = { roots, dismiss };
});
useEffect(() => {
if (!open) return;
const layer: Layer = {
id,
parent: parent?.id,
roots: () => latest.current.roots(),
dismiss: (restore) => latest.current.dismiss(restore),
outside,
escape,
command,
passive,
};
layers.push(layer);
if (layers.length === 1) {
document.addEventListener('keydown', onKeyDown, true);
document.addEventListener('pointerdown', onPointerDown, true);
}
return () => {
layers.splice(layers.indexOf(layer), 1);
if (!layers.length) {
document.removeEventListener('keydown', onKeyDown, true);
document.removeEventListener('pointerdown', onPointerDown, true);
}
};
}, [open, id, parent?.id, outside, escape, command, passive]);
return {
context: { id, depth: (parent?.depth ?? -1) + 1 },
zIndex: `calc(var(--ui-layer-overlay, 400) + ${(parent?.depth ?? -1) + 1})`,
isTop: () => topLayer(true)?.id === id,
};
}
@@ -0,0 +1,4 @@
import { createContext } from 'react';
/** 主工作台分配给每侧的显示宽度上限;不改写已保存的用户宽度。 */
export const PanelWidthBudgetContext = createContext<number | undefined>(undefined);
@@ -30,6 +30,9 @@ describe('PythonControllerPanel', () => {
onRemove={noop}
/>,
);
expect(screen.getByText('200 Hz')).toBeVisible();
expect(screen.getByText(/仅加载可信脚本/)).toBeVisible();
expect(screen.getByText('脚本详情').closest('details')).not.toHaveAttribute('open');
fireEvent.click(screen.getByRole('button', { name: '前进' }));
fireEvent.click(screen.getByRole('button', { name: '左转' }));
fireEvent.click(screen.getByRole('button', { name: '起跳' }));
@@ -11,7 +11,7 @@ import {
Trash2,
} from 'lucide-react';
import type { ControllerCommand, ControllerStatus } from './types';
import { Badge, Button, PropertyRow, Select } from '../components/ui';
import { Badge, Button, PropertyRow, Select, Tooltip } from '../components/ui';
export interface PythonControllerPanelProps {
paths: string[];
@@ -88,28 +88,38 @@ export function PythonControllerPanel({
</Button>
</div>
<p className="mt-2 text-xs text-warning"></p>
{loading && (
<p role="status" className="mt-2 text-xs">
</p>
)}
{status ? (
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mt-3 border-t border-border pt-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-text-primary" title={status.path}>
{status.name}
</span>
<Badge>{status.enabled ? '运行中' : '已停止'}</Badge>
</div>
<PropertyRow label="语言" value="Python / Pyodide" />
<PropertyRow label="控制频率" value={`${status.controlHz} Hz`} />
<PropertyRow label="上次耗时" value={`${status.lastStepMs.toFixed(3)} ms`} />
<details className="domain-details">
<summary></summary>
<PropertyRow label="路径" value={status.path} />
<PropertyRow label="运行时" value="Python / Pyodide" />
<PropertyRow label="上次耗时" value={`${status.lastStepMs.toFixed(3)} ms`} />
</details>
{status.error && (
<p
role="alert"
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
className="mt-2 break-words rounded bg-danger/10 p-2 text-xs leading-4 text-danger"
>
{status.error}
</p>
)}
{status.acceptsCommands && (
<div className="mt-3 border-t border-border pt-3">
<p className="mb-2 text-[10px] text-text-tertiary"></p>
<p className="mb-2 text-xs text-text-tertiary"></p>
<div className="grid grid-cols-3 gap-1.5">
<span />
<Button
@@ -175,9 +185,14 @@ export function PythonControllerPanel({
</div>
</div>
) : (
<p className="mt-3 text-xs leading-5 text-text-tertiary">
Python mj_step 仿 100 Hz
</p>
<div className="mt-3 text-xs text-text-secondary">
<p> · 100 Hz</p>
<Tooltip content="脚本在每次 mj_step 前按仿真时间同步执行。">
<button type="button" className="min-h-7 underline decoration-dotted">
</button>
</Tooltip>
</div>
)}
</div>
);
@@ -21,7 +21,8 @@ describe('MapAssetLibrary', () => {
expect(decodeMapLibraryDragPayload('{"kind":"terrain","preset":"unknown"}')).toBeNull();
renderLibrary();
const library = screen.getByLabelText('地图资产库');
expect(library).toHaveTextContent('场景资产库');
expect(library).toBeVisible();
expect(library).not.toHaveTextContent('场景资产库');
expect(library).toHaveTextContent('物理几何原语');
expect(library).toHaveTextContent('预设地形');
expect(library).not.toHaveTextContent('已放置地图');
+43 -70
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { BadgeCheck, CheckCircle2, MapPinned, Mountain, Plus } from 'lucide-react';
import { Button, Select } from '../components/ui';
import { BadgeCheck, MapPinned, Mountain, Plus } from 'lucide-react';
import { Button, Select, Tooltip } from '../components/ui';
import {
CERTIFIED_MAP_ASSETS,
encodeMapLibraryDragPayload,
@@ -30,7 +30,7 @@ function AssetPreview({ type, color }: { type: EditableMapObjectType; color: str
? 'h-9 w-11 [clip-path:polygon(0_100%,0_66%,34%_66%,34%_33%,67%_33%,67%_0,100%_0,100%_100%)]'
: 'h-9 w-9 rounded-sm';
return (
<div className="flex h-16 items-center justify-center rounded bg-black/10" aria-hidden="true">
<div className="flex h-11 items-center justify-center bg-black/10" aria-hidden="true">
<div
className={`${shape} shadow-[0_6px_14px_rgba(0,0,0,0.22)]`}
style={type === 'ramp' ? { borderBottomColor: color } : { backgroundColor: color }}
@@ -114,7 +114,7 @@ function TerrainPreview({ preset }: { preset: SystemTerrainPreset }) {
</g>
);
return (
<svg viewBox="0 0 120 80" className="h-[76px] w-full text-accent" aria-hidden="true">
<svg viewBox="0 0 120 80" className="h-12 w-full text-accent" aria-hidden="true">
{grid}
{shape}
</svg>
@@ -123,9 +123,9 @@ function TerrainPreview({ preset }: { preset: SystemTerrainPreset }) {
function AddAction({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-accent">
<span className="inline-flex items-center gap-1 text-xs font-medium text-accent">
{label}
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-accent text-white">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-accent text-app">
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
</span>
</span>
@@ -162,23 +162,16 @@ export function MapAssetLibrary({
onPlacementModeChange?.(mode);
};
return (
<section aria-label="地图资产库" className="space-y-3 p-3">
<header>
<div className="flex items-center gap-1.5 text-xs font-semibold text-text-primary">
<MapPinned className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
</div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
</p>
</header>
<section aria-label="地图资产库" className="space-y-2.5 p-2.5">
{maps.length > 0 && (
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
<div className="text-xs font-medium text-text-primary"></div>
<p className="mt-1 text-[10px] text-text-tertiary">
姿
</p>
<div className="mt-2 space-y-1">
<div className="border-t border-border-subtle pt-2">
<div
className="text-xs font-medium text-text-primary"
title="实例位姿独立;源内容由同源实例共享"
>
</div>
<div className="mt-1.5 space-y-1">
{maps.map((map) => (
<div
key={map.descriptorPath}
@@ -202,7 +195,7 @@ export function MapAssetLibrary({
}}
>
<MapPinned className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-[11px] text-text-secondary">
<span className="min-w-0 flex-1 truncate text-xs text-text-secondary">
{map.name}
</span>
<Button
@@ -218,17 +211,12 @@ export function MapAssetLibrary({
</div>
)}
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
<BadgeCheck className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
</div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
</p>
<div className="flex items-end justify-between gap-3 border-t border-border-subtle pt-2">
<div className="flex min-w-0 items-center gap-1.5 text-xs font-medium text-text-primary">
<BadgeCheck className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
</div>
<label className="w-36 shrink-0 text-[10px] text-text-secondary">
<label className="min-w-0 flex-1 text-xs text-text-secondary">
<Select
aria-label="新增资产放置方式"
@@ -251,7 +239,7 @@ export function MapAssetLibrary({
key={asset.type}
draggable={!disabled}
data-map-asset={asset.type}
className={`overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
className={`overflow-hidden rounded border border-border-subtle bg-panel-muted/40 transition-colors ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
onDragStart={(event) => {
if (disabled) {
event.preventDefault();
@@ -269,38 +257,28 @@ export function MapAssetLibrary({
event.dataTransfer.setData('text/plain', asset.name);
}}
>
<div className="p-2 pb-0">
<AssetPreview type={asset.type} color={asset.color} />
</div>
<div className="border-t border-border-subtle p-2">
<div className="truncate text-[11px] font-semibold text-text-primary">
<AssetPreview type={asset.type} color={asset.color} />
<div className="flex items-center gap-1.5 border-t border-border-subtle p-1.5">
<div className="min-w-0 flex-1 truncate text-xs font-medium text-text-primary">
{asset.name}
</div>
<div className="mt-0.5 truncate text-[9px] text-text-tertiary">
{asset.description}
</div>
<Button
className="mt-2 w-full"
disabled={disabled}
aria-label={`添加${asset.name}`}
onClick={() => void onAdd(asset.type, placementMode)}
>
<Plus className="mr-1 h-3 w-3" aria-hidden="true" />
</Button>
<Tooltip content={asset.description}>
<Button
disabled={disabled}
aria-label={`添加${asset.name}`}
onClick={() => void onAdd(asset.type, placementMode)}
>
<Plus className="h-3 w-3" aria-hidden="true" />
</Button>
</Tooltip>
</div>
</article>
))}
</div>
<div className="border-t border-border-subtle pt-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
<Mountain className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
</div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
</p>
<div className="flex items-center gap-1.5 border-t border-border-subtle pt-2 text-xs font-medium text-text-primary">
<Mountain className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
</div>
<div className="grid grid-cols-2 gap-2">
{SYSTEM_TERRAIN_PRESETS.map((preset) => (
@@ -308,7 +286,7 @@ export function MapAssetLibrary({
key={preset}
draggable={!disabled}
data-system-terrain={preset}
className={`group relative overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
className={`group relative overflow-hidden rounded border border-border-subtle bg-panel-muted/40 transition-colors ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
onDragStart={(event) => {
if (disabled) {
event.preventDefault();
@@ -322,22 +300,17 @@ export function MapAssetLibrary({
event.dataTransfer.setData('text/plain', PHYSICAL_MAP_PRESET_LABELS[preset]);
}}
>
<div className="absolute right-2 top-2 z-10 rounded-full bg-panel-bg/90 p-0.5 text-success">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
</div>
<div className="px-1 pt-1">
<TerrainPreview preset={preset} />
</div>
<div className="border-t border-border-subtle px-2 pb-2 pt-1.5">
<div className="truncate text-[11px] font-semibold text-text-primary">
<TerrainPreview preset={preset} />
<div className="border-t border-border-subtle px-1.5 py-1">
<div className="truncate text-xs font-semibold text-text-primary">
{PHYSICAL_MAP_PRESET_LABELS[preset]}
</div>
<div className="mt-0.5 text-[9px] text-text-tertiary">
<div className="mt-0.5 text-xs text-text-tertiary">
{terrainSize.toFixed(2)} × {terrainSize.toFixed(2)} m
</div>
<button
type="button"
className="mt-1 flex w-full justify-end rounded outline-none focus-visible:ring-2 focus-visible:ring-accent/30"
className="mt-1 flex min-h-7 w-full items-center justify-end rounded outline-none focus-visible:ring-2 focus-visible:ring-accent/30"
disabled={disabled}
aria-label={`添加${PHYSICAL_MAP_PRESET_LABELS[preset]}`}
onClick={() => onSelectTerrain(preset)}
+1 -1
View File
@@ -160,7 +160,7 @@ describe('MapEditorPanel', () => {
);
fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象表面材质'), { target: { value: 'rubber' } });
expect(screen.getByLabelText('对象滑动摩擦')).toHaveValue(1.5);
expect(screen.getByLabelText('对象滑动摩擦')).toHaveValue('1.5');
expect(onDraftChange).toHaveBeenLastCalledWith(
'maps/map/map.json',
expect.objectContaining({
+92 -79
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, ScrubbableNumberInput, Select } from '../components/ui';
import { Button, ScrubbableNumberInput, Select, Tooltip } from '../components/ui';
import { MapEditSession } from './editor/MapEditSession';
import type {
EditableMapDocument,
@@ -9,6 +9,7 @@ import type {
MapEditorSessionState,
} from './editor/types';
import { MapObjectInspector } from './MapObjectInspector';
import { isTextEditingTarget } from '../app/keyboard';
const labels: Record<EditableMapObjectType, string> = {
box: '方盒',
@@ -221,9 +222,7 @@ export function MapEditorPanel({
if (!session) return;
const keydown = (event: KeyboardEvent) => {
if (loading) return;
const target = event.target as HTMLElement | null;
if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey)
return;
if (isTextEditingTarget(event.target) || event.altKey) return;
const key = event.key.toLowerCase();
if ((event.ctrlKey || event.metaKey) && key === 'z') {
event.preventDefault();
@@ -275,21 +274,22 @@ export function MapEditorPanel({
return (
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-tertiary">
<p> V3 authoring.source</p>
<p className="text-[10px] leading-relaxed">
boxcylindercapsule MJCF
<p className="text-xs text-warning">
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
disabled={loading || converting || !canConvert}
onClick={() => {
setConverting(true);
void onConvert().finally(() => setConverting(false));
}}
>
{converting ? '正在转换…' : '创建可编辑副本'}
</Button>
<Tooltip content="仅支持由 box、cylinder、capsule 组成且不含资产、材质或碰撞过滤的静态 MJCF。">
<Button
variant="primary"
disabled={loading || converting || !canConvert}
onClick={() => {
setConverting(true);
void onConvert().finally(() => setConverting(false));
}}
>
{converting ? '正在转换…' : '创建可编辑副本'}
</Button>
</Tooltip>
<Button disabled={loading || converting} onClick={onExport}>
ZIP
</Button>
@@ -337,8 +337,8 @@ export function MapEditorPanel({
/>
)}
<section className="space-y-2 rounded-lg border border-border-subtle p-2.5">
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-text-tertiary">
<section className="space-y-2 border-t border-border-subtle pt-2">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-text-tertiary">
</div>
<div className="flex gap-2">
@@ -388,74 +388,79 @@ export function MapEditorPanel({
</div>
</section>
<div className="space-y-2 rounded border border-border-subtle p-2">
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
<span></span>
<Button
disabled={loading}
onClick={() => {
session.addSpawn();
refresh();
}}
>
</Button>
</div>
{current.spawnPoints.map((spawn) => (
<div key={spawn.id} className="space-y-2 rounded bg-panel-muted p-2">
<input
aria-label={`出生点名称 ${spawn.id}`}
className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs"
value={spawn.name}
disabled={loading}
onChange={(event) => {
if (!event.target.value.trim()) return;
session.updateSpawn(spawn.id, { name: event.target.value });
refresh();
}}
/>
<div className="grid grid-cols-4 gap-1">
{spawn.position.map((value, index) => (
<ScrubbableNumberInput
key={index}
label={['X', 'Y', 'Z'][index]}
aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`}
value={value}
step={0.1}
disabled={loading}
onValueChange={(next) =>
updateSpawnPosition(spawn.id, spawn.position, index, next)
}
/>
))}
<ScrubbableNumberInput
label="Yaw°"
aria-label={`出生点${spawn.id}朝向`}
value={spawn.yawDeg}
step={1}
disabled={loading}
onValueChange={(yawDeg) => {
session.updateSpawn(spawn.id, { yawDeg });
refresh();
}}
/>
</div>
<details className="domain-details">
<summary> · {current.spawnPoints.length}</summary>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
<span></span>
<Button
variant="danger"
disabled={loading}
onClick={() => {
session.removeSpawn(spawn.id);
session.addSpawn();
refresh();
}}
>
</Button>
</div>
))}
{!current.spawnPoints.length && (
<div className="text-[11px] text-text-tertiary"></div>
)}
</div>
{current.spawnPoints.map((spawn) => (
<div key={spawn.id} className="space-y-2 rounded bg-panel-muted p-2">
<input
aria-label={`出生点名称 ${spawn.id}`}
className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs"
value={spawn.name}
disabled={loading}
onChange={(event) => {
if (!event.target.value.trim()) return;
session.updateSpawn(spawn.id, { name: event.target.value });
refresh();
}}
/>
<div className="grid grid-cols-2 gap-2">
{spawn.position.map((value, index) => (
<ScrubbableNumberInput
key={index}
label={`${['X', 'Y', 'Z'][index]} m`}
axis={(['x', 'y', 'z'] as const)[index]}
aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`}
value={value}
step={0.1}
disabled={loading}
onValueChange={(next) =>
updateSpawnPosition(spawn.id, spawn.position, index, next)
}
/>
))}
<ScrubbableNumberInput
label="Yaw°"
axis="z"
aria-label={`出生点${spawn.id}朝向`}
value={spawn.yawDeg}
step={1}
disabled={loading}
onValueChange={(yawDeg) => {
session.updateSpawn(spawn.id, { yawDeg });
refresh();
}}
/>
</div>
<Button
variant="danger"
disabled={loading}
onClick={() => {
session.removeSpawn(spawn.id);
refresh();
}}
>
</Button>
</div>
))}
{!current.spawnPoints.length && (
<div className="text-xs text-text-tertiary"></div>
)}
</div>
</details>
<div className="grid grid-cols-2 gap-2">
<Button
@@ -502,6 +507,14 @@ export function MapEditorPanel({
>
</Button>
<Tooltip content="撤销 Ctrl/Cmd+Z;重做 Ctrl/Cmd+Shift+Z 或 Ctrl/Cmd+YEsc 取消选择。输入框内保留文本编辑快捷键。">
<button
type="button"
className="min-h-7 text-xs text-text-secondary underline decoration-dotted"
>
</button>
</Tooltip>
{session.dirty && (
<div role="status" className="text-xs text-warning">
稿
+22 -13
View File
@@ -1,5 +1,5 @@
import { Copy, LockKeyhole, Trash2 } from 'lucide-react';
import { Button, ScrubbableNumberInput, Select } from '../components/ui';
import { Button, ScrubbableNumberInput, Select, Tooltip } from '../components/ui';
import { MAP_OBJECT_PLACEMENT_LABELS, type EditableMapObject } from './editor/types';
const PARAMETER_LABELS: Record<string, string> = {
@@ -83,8 +83,8 @@ function colorValue(object: EditableMapObject): string {
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="engineering-card rounded-lg border p-2.5">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-text-tertiary">
<section className="border-b border-border-subtle pb-3">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-[0.12em] text-text-tertiary">
{title}
</h3>
{children}
@@ -132,7 +132,7 @@ export function MapObjectInspector({
<div className="space-y-2" aria-label="地图物体检查器">
<Section title="对象">
<div className="space-y-2">
<label className="block text-[11px] text-text-secondary">
<label className="block text-xs text-text-secondary">
<input
aria-label="对象名称"
@@ -144,7 +144,7 @@ export function MapObjectInspector({
}}
/>
</label>
<label className="flex items-center justify-between text-[11px] text-text-secondary">
<label className="flex items-center justify-between text-xs text-text-secondary">
<span></span>
<input
aria-label="启用地图对象"
@@ -158,11 +158,15 @@ export function MapObjectInspector({
</Section>
<Section title="位姿">
<div className="mb-2 flex items-center justify-between rounded bg-surface/55 px-2 py-1.5 text-[10px] text-text-secondary">
<div className="mb-2 flex items-center justify-between rounded bg-surface/55 px-2 py-1.5 text-xs text-text-secondary">
<span>{MAP_OBJECT_PLACEMENT_LABELS[object.placementMode]}</span>
<span className="flex items-center gap-1 text-text-tertiary">
{poseLocked && <LockKeyhole className="h-3 w-3" aria-hidden="true" />}
<Tooltip content="在视口底部切换放置方式;Z 随贴地或支撑面计算,锁定时不可移动。">
<button type="button" className="min-h-7 underline decoration-dotted">
</button>
</Tooltip>
</span>
</div>
<div className="grid grid-cols-3 gap-2">
@@ -170,6 +174,7 @@ export function MapObjectInspector({
<ScrubbableNumberInput
key={index}
label={`${['X', 'Y', 'Z'][index]}m`}
axis={(['x', 'y', 'z'] as const)[index]}
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
value={value}
step={0.1}
@@ -181,6 +186,7 @@ export function MapObjectInspector({
<ScrubbableNumberInput
containerClassName="mt-2"
label="绕 Z 旋转(°)"
axis="z"
aria-label="对象绕Z旋转"
value={Number(yawDegrees(object).toFixed(4))}
step={1}
@@ -203,6 +209,7 @@ export function MapObjectInspector({
<ScrubbableNumberInput
key={key}
label={PARAMETER_LABELS[key] ?? key}
labelClassName="whitespace-normal"
aria-label={`对象参数${key}`}
value={value}
min={0.001}
@@ -214,9 +221,10 @@ export function MapObjectInspector({
</div>
</Section>
<Section title="表面材质">
<details className="domain-details">
<summary></summary>
<div className="grid grid-cols-2 gap-2">
<label className="text-[10px] text-text-secondary">
<label className="text-xs text-text-secondary">
<Select
aria-label="对象表面材质"
@@ -245,7 +253,7 @@ export function MapObjectInspector({
))}
</Select>
</label>
<label className="text-[10px] text-text-secondary">
<label className="text-xs text-text-secondary">
<input
aria-label="对象颜色"
@@ -283,9 +291,10 @@ export function MapObjectInspector({
})
}
/>
</Section>
</details>
<Section title="摩擦力">
<details className="domain-details">
<summary></summary>
<div className="grid grid-cols-3 gap-2">
{object.friction.map((value, index) => (
<ScrubbableNumberInput
@@ -301,7 +310,7 @@ export function MapObjectInspector({
/>
))}
</div>
</Section>
</details>
<div className="grid grid-cols-2 gap-2">
<Button disabled={loading} icon={<Copy className="h-3 w-3" />} onClick={onDuplicate}>
@@ -39,7 +39,7 @@ describe('PhysicalMapPanel', () => {
onApply={onApply}
/>,
);
expect(screen.getByText(/场景草稿正在轻量预览/)).toBeInTheDocument();
expect(screen.getByText(/未应用场景草稿/)).toBeInTheDocument();
const apply = screen.getByRole('button', { name: '应用并重新编译' });
expect(apply).toBeEnabled();
fireEvent.click(apply);
@@ -96,7 +96,7 @@ describe('PhysicalMapPanel', () => {
/>,
);
expect(screen.queryByLabelText('地图变换模式')).not.toBeInTheDocument();
expect(screen.getByText(/已统一到视口浮动工具条/)).toBeInTheDocument();
expect(screen.getByLabelText('物理地图预设')).toBeVisible();
});
it('表单参数与外部视口变换合并到同一份草稿', () => {
@@ -125,8 +125,8 @@ describe('PhysicalMapPanel', () => {
view.rerender(
<PhysicalMapPanel {...common} value={moved} sceneDirty onDraft={onDraft} onApply={onApply} />,
);
expect(screen.getByLabelText('地形边长(m')).toHaveValue(7);
expect(screen.getByLabelText('位置 Xm')).toHaveValue(2.4);
expect(screen.getByLabelText('地形边长(m')).toHaveValue('7');
expect(screen.getByLabelText('位置 Xm')).toHaveValue('2.4');
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenLastCalledWith(
expect.objectContaining({
+55 -43
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Button, ScrubbableNumberInput, Select } from '../components/ui';
import { Info } from 'lucide-react';
import { Button, ScrubbableNumberInput, Select, Tooltip } from '../components/ui';
import type { MapEntry } from '../project/types';
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
@@ -25,6 +26,7 @@ function NumberField({
min,
max,
step = 1,
axis,
disabled,
onChange,
}: {
@@ -33,6 +35,7 @@ function NumberField({
min: number;
max: number;
step?: number;
axis?: 'x' | 'y' | 'z';
disabled: boolean;
onChange: (value: number) => void;
}) {
@@ -43,6 +46,7 @@ function NumberField({
min={min}
max={max}
step={step}
axis={axis}
disabled={disabled}
onValueChange={onChange}
/>
@@ -181,15 +185,19 @@ export function PhysicalMapPanel({
const editorPanel =
draft.kind === 'project' &&
draft.descriptorPath === (value.kind === 'project' ? value.descriptorPath : '') ? (
<div className="space-y-2 rounded border border-border-subtle p-3">
<div className="text-xs font-medium text-text-primary">
{selectedEditorObjectId ? '选中物体属性' : '认证资产与场景对象属性'}
<div className="space-y-2 border-t border-border-subtle pt-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
<span className="text-warning"></span>
<Tooltip content="源内容由同源实例共享;实例位姿彼此独立">
<button
type="button"
aria-label="地图编辑说明"
className="grid h-7 w-7 place-items-center"
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</Tooltip>
</div>
<p className="text-[10px] leading-relaxed text-text-tertiary">
{selectedEditorObjectId
? '尺寸、位姿、表面材质与摩擦力会实时写入 MapEditSession 草稿。'
: '此处编辑地图源内容;同源实例共享内容,实例位姿彼此独立。'}
</p>
<MapEditorPanel
document={editorDocument}
draftDocument={editorDraftDocument}
@@ -212,13 +220,10 @@ export function PhysicalMapPanel({
if (value.kind === 'none' && draft.kind === 'none')
return (
<div className="space-y-3 p-3">
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-3">
<div className="space-y-2 p-2.5">
<div className="border-b border-border-subtle pb-2">
<div className="text-xs font-semibold text-text-primary"></div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
</p>
<label className="mt-3 block text-xs text-text-secondary">
<label className="mt-2 block text-xs text-text-secondary">
<span className="mb-1 block"></span>
<Select
aria-label="地图来源"
@@ -259,14 +264,7 @@ export function PhysicalMapPanel({
);
return (
<div className="space-y-4 p-3">
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-3">
<div className="text-xs font-semibold text-text-primary"></div>
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
</p>
</div>
<div className="space-y-3 p-2.5">
{editorPanel}
<label className="block text-xs text-text-secondary">
@@ -290,10 +288,6 @@ export function PhysicalMapPanel({
{draft.kind === 'builtin' && (
<>
<div className="text-xs font-medium text-text-primary"></div>
<p className="rounded border border-border-subtle bg-panel-muted/50 p-2 text-[10px] leading-relaxed text-text-tertiary">
</p>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block"></span>
<Select
@@ -312,7 +306,7 @@ export function PhysicalMapPanel({
))}
</Select>
</label>
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
<div className="border-t border-border-subtle pt-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-text-primary"></span>
<Button
@@ -332,6 +326,7 @@ export function PhysicalMapPanel({
<div className="grid grid-cols-3 gap-2">
<NumberField
label="位置 Xm"
axis="x"
value={draft.config.positionX}
min={-100}
max={100}
@@ -341,6 +336,7 @@ export function PhysicalMapPanel({
/>
<NumberField
label="位置 Ym"
axis="y"
value={draft.config.positionY}
min={-100}
max={100}
@@ -350,6 +346,7 @@ export function PhysicalMapPanel({
/>
<NumberField
label="旋转 Z(°)"
axis="z"
value={draft.config.yawDeg}
min={-180}
max={180}
@@ -358,9 +355,6 @@ export function PhysicalMapPanel({
onChange={(next) => updateBuiltin('yawDeg', next)}
/>
</div>
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<NumberField
@@ -459,14 +453,32 @@ export function PhysicalMapPanel({
)}
{draft.kind === 'project' && projectMap && (
<div className="space-y-3 rounded border border-border-subtle bg-panel-muted p-3">
<div className="text-xs font-medium text-text-primary">{projectMap.name}</div>
<div className="break-all text-[11px] text-text-tertiary">
{projectMap.descriptorPath}
<div className="space-y-2.5 border-t border-border-subtle pt-2">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-xs font-medium text-text-primary">
{projectMap.name}
</div>
<code
className="max-w-[45%] truncate text-xs text-text-tertiary"
title={projectMap.descriptorPath}
>
{projectMap.descriptorPath}
</code>
</div>
<div className="rounded-lg border border-border-subtle bg-panel-bg/40 p-2.5">
<div className="border-t border-border-subtle pt-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-text-primary"></span>
<span className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
<Tooltip content="位姿仅属于当前实例;源内容由同源实例共享">
<button
type="button"
aria-label="实例变换说明"
className="grid h-7 w-7 place-items-center"
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</Tooltip>
</span>
<Button
disabled={
loading ||
@@ -482,6 +494,7 @@ export function PhysicalMapPanel({
<div className="grid grid-cols-3 gap-2">
<NumberField
label="位置 Xm"
axis="x"
value={draft.positionX ?? 0}
min={-100}
max={100}
@@ -491,6 +504,7 @@ export function PhysicalMapPanel({
/>
<NumberField
label="位置 Ym"
axis="y"
value={draft.positionY ?? 0}
min={-100}
max={100}
@@ -500,6 +514,7 @@ export function PhysicalMapPanel({
/>
<NumberField
label="旋转 Z(°)"
axis="z"
value={draft.yawDeg ?? 0}
min={-180}
max={180}
@@ -508,9 +523,6 @@ export function PhysicalMapPanel({
onChange={(yawDeg) => replaceProject({ ...draft, yawDeg })}
/>
</div>
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
姿
</p>
</div>
{projectMap.spawnPoints.length > 0 && (
<label className="block text-xs text-text-secondary">
@@ -555,7 +567,7 @@ export function PhysicalMapPanel({
</label>
)}
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">使</span>
<span className="mb-1 block"> · 使</span>
<input
aria-label="地图摩擦系数覆盖"
type="number"
@@ -564,7 +576,7 @@ export function PhysicalMapPanel({
step={0.05}
value={draft.frictionOverride ?? ''}
disabled={loading}
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary"
className="field h-7 w-full px-2 text-xs tabular-nums text-text-primary"
onChange={(event) =>
replaceProject({
...draft,
@@ -609,7 +621,7 @@ export function PhysicalMapPanel({
role="status"
className="rounded border border-accent/30 bg-accent-soft p-2 text-xs text-accent"
>
稿
稿 ·
</p>
)}
+23 -21
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Box, File, FileCode2, Folder, FolderOpen } from 'lucide-react';
import type { ModelEntry } from './types';
import { EmptySearchState, SearchHighlight, VirtualTreeViewport } from '../components/ui';
import { EmptySearchState, SearchHighlight, VirtualTreeViewport, Tooltip } from '../components/ui';
export interface ProjectTreeFile {
path: string;
@@ -152,15 +152,17 @@ function TreeNodes({
className={`flex min-w-0 items-center gap-1.5 rounded px-1.5 py-1 text-xs ${selectedEntry === node.path ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'}`}
>
<EntryIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={node.name} query={query} />
</span>
<Tooltip content={node.path}>
<span tabIndex={0} className="min-w-0 flex-1 truncate">
<SearchHighlight text={node.name} query={query} />
</span>
</Tooltip>
{entryFormats.has(node.path) && (
<span className="shrink-0 text-[10px] uppercase text-accent">
<span className="shrink-0 text-xs uppercase text-accent">
{entryFormats.get(node.path)}
</span>
)}
<span className="shrink-0 text-[10px] text-text-tertiary">
<span className="shrink-0 text-xs text-text-tertiary">
{formatSize(node.size ?? 0)}
</span>
</li>
@@ -272,22 +274,22 @@ export function ProjectTree({
? Box
: File;
return (
<div
title={node.path}
onDoubleClick={() => toggle({ node, depth })}
className={`flex h-full items-center gap-1.5 rounded px-1.5 text-xs ${selectedEntry === node.path ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'}`}
style={{ paddingLeft: depth * 12 + 6 }}
>
<EntryIcon className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={node.name} query={query} />
</span>
{!directory && (
<span className="text-[10px] text-text-tertiary">
{formatSize(node.size ?? 0)}
<Tooltip content={node.path}>
<div
tabIndex={0}
onDoubleClick={() => toggle({ node, depth })}
className={`flex h-full items-center gap-1.5 rounded px-1.5 text-xs ${selectedEntry === node.path ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'}`}
style={{ paddingLeft: depth * 12 + 6 }}
>
<EntryIcon className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={node.name} query={query} />
</span>
)}
</div>
{!directory && (
<span className="text-xs text-text-tertiary">{formatSize(node.size ?? 0)}</span>
)}
</div>
</Tooltip>
);
}}
/>
+10 -1
View File
@@ -35,6 +35,10 @@ it('仅避障策略显示实时导航状态,支持暂停时设定/复位', ()
const view = render(<RLPolicyPanel {...props} />);
expect(screen.getByText('(2.00, -1.00)')).toBeInTheDocument();
expect(screen.getByText('3.25 m')).toBeInTheDocument();
expect(screen.getByText('81 / 12')).toBeVisible();
expect(screen.getByText(/射线有矮障碍与跌落盲区/)).toBeVisible();
expect(screen.getByText('推理详情').closest('details')).not.toHaveAttribute('open');
expect(screen.getByRole('button', { name: '启用' })).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: /^设定目标$/ }));
expect(props.onNavigationTargetMode).toHaveBeenCalledWith(true);
fireEvent.click(screen.getByRole('button', { name: '复位目标点' }));
@@ -45,6 +49,8 @@ it('仅避障策略显示实时导航状态,支持暂停时设定/复位', ()
expect(screen.getByText(/仿真已暂停/)).toBeVisible();
view.rerender(<RLPolicyPanel {...props} status={{ ...status, error: '跌倒' }} />);
expect(screen.getByText(/导航安全停止:请重置并重新启用/)).toBeVisible();
expect(screen.getByRole('alert')).toHaveTextContent('跌倒');
expect(screen.getByRole('button', { name: '启用' })).toBeDisabled();
view.rerender(
<RLPolicyPanel
{...props}
@@ -53,7 +59,10 @@ it('仅避障策略显示实时导航状态,支持暂停时设定/复位', ()
/>,
);
expect(screen.getByText('1.10 m')).toBeInTheDocument();
expect(screen.getByText(/Esc 取消/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: '取消设定目标' })).toHaveAttribute(
'title',
expect.stringContaining('Esc 取消'),
);
view.rerender(
<RLPolicyPanel
{...props}
+36 -20
View File
@@ -2,7 +2,7 @@ import { useRef, type ChangeEvent } from 'react';
import { BrainCircuit, FileUp, Power, RotateCw, Trash2 } from 'lucide-react';
import type { RLCommand, RLPolicyStatus } from './types';
import { useAppStore } from '../stores/useAppStore';
import { Badge, Button, PropertyRow, Select } from '../components/ui';
import { Badge, Button, PropertyRow, Select, Tooltip } from '../components/ui';
export interface RLPolicyPanelProps {
paths: string[];
@@ -87,8 +87,14 @@ export function RLPolicyPanel({
</Button>
</div>
{loading && (
<p role="status" className="mt-2 text-xs">
</p>
)}
<p className="mt-2 text-xs text-warning">/</p>
{status ? (
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mt-3 border-t border-border pt-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span
className="flex min-w-0 items-center gap-1.5 truncate text-xs font-medium text-text-primary"
@@ -104,12 +110,16 @@ export function RLPolicyPanel({
label="观测 / 动作"
value={`${status.observationSize} / ${status.actionSize}`}
/>
<PropertyRow label="推理次数" value={status.inferenceCount} />
<PropertyRow label="上次推理" value={`${status.lastInferenceMs.toFixed(2)} ms`} />
<details className="domain-details">
<summary></summary>
<PropertyRow label="策略路径" value={status.path} />
<PropertyRow label="推理次数" value={status.inferenceCount} />
<PropertyRow label="上次推理" value={`${status.lastInferenceMs.toFixed(2)} ms`} />
</details>
{status.taskId === 'Unitree-Go2-ObstacleAvoidance' && status.navigation && (
<div className="mt-3 border-t border-border pt-3">
<PropertyRow
label="当前目标 (X, Y)"
label="当前目标 (X, Y) m"
value={`(${status.navigation.target[0].toFixed(2)}, ${status.navigation.target[1].toFixed(2)})`}
/>
<PropertyRow label="剩余距离" value={`${status.navigation.distance.toFixed(2)} m`} />
@@ -126,6 +136,7 @@ export function RLPolicyPanel({
<Button
disabled={loading}
aria-pressed={navigationTargetMode}
title="点击主视口地形设定目标;Esc 取消"
onClick={() => onNavigationTargetMode?.(!navigationTargetMode)}
>
{navigationTargetMode ? '取消设定目标' : '设定目标'}
@@ -134,20 +145,28 @@ export function RLPolicyPanel({
</Button>
</div>
{navigationTargetMode && (
<p className="mt-2 text-xs text-accent">
Esc
</p>
)}
</div>
)}
{status.observationSize === 81 || status.observationSize === 97 ? (
<p className="mt-3 text-xs">
20//线/Go2-W不是Go2同构模型
</p>
<div
className="mt-2"
title="评测在超时、跌倒或越界时停止;水平射线存在矮障碍与跌落盲区"
>
<Badge tone="accent"></Badge>
<p className="mt-1 text-xs text-warning">
线
</p>
</div>
) : (
<div className="mt-3 border-t border-border pt-3">
<p className="mb-2 text-[10px] text-text-tertiary"></p>
<Tooltip content="速度指令使用机身坐标系。">
<button
type="button"
className="mb-2 min-h-7 text-xs text-text-secondary underline decoration-dotted"
>
</button>
</Tooltip>
<CommandInput
label="前向 m/s"
value={command.linearX}
@@ -180,7 +199,7 @@ export function RLPolicyPanel({
{status.error && (
<p
role="alert"
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
className="mt-2 break-words rounded bg-danger/10 p-2 text-xs leading-4 text-danger"
>
{status.error}
</p>
@@ -200,10 +219,7 @@ export function RLPolicyPanel({
</div>
</div>
) : (
<p className="mt-3 text-xs leading-5 text-text-tertiary">
mjlab policy.onnx使 47 Go2 actor
12 Go2-W
</p>
<p className="mt-2 text-xs text-text-secondary"></p>
)}
</div>
);
@@ -223,7 +239,7 @@ function CommandInput({
onChange(value: number): void;
}) {
return (
<label className="mb-2 grid grid-cols-[1fr_72px] items-center gap-2 text-[10px] text-text-tertiary">
<label className="mb-2 grid grid-cols-[1fr_72px] items-center gap-2 text-xs text-text-tertiary">
<span>{label}</span>
<input
className="field h-7 w-full px-2 text-right text-xs text-text-primary"

Some files were not shown because too many files have changed in this diff Show More