Merge pull request 'Cen' (#5) from Cen into main
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-09-01 16:03:39 +08:00
173 changed files with 21627 additions and 2887 deletions
+14
View File
@@ -0,0 +1,14 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
[*.py]
indent_size = 4
[*.md]
trim_trailing_whitespace = false
+12 -3
View File
@@ -9,15 +9,22 @@ permissions:
jobs: jobs:
quality: quality:
name: TypeScript、Lint、Unit、Build name: TypeScript, lint, unit, build
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: 24 node-version-file: .nvmrc
cache: npm cache: npm
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
- run: npm install --global npm@11.17.0
- run: npm ci - run: npm ci
- run: python -m pip install -r requirements-dev.txt
- run: npm run lint:python
- run: npm run check - run: npm run check
e2e: e2e:
@@ -27,8 +34,10 @@ jobs:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: 24 node-version-file: .nvmrc
cache: npm cache: npm
- run: npm install --global npm@11.17.0
- run: npm ci - run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build - run: npm run build
- run: npm run test:e2e - run: npm run test:e2e
+46
View File
@@ -0,0 +1,46 @@
name: web-platform-release
on:
push:
tags:
- 'V*'
permissions:
contents: write
jobs:
release:
name: Build and publish release
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
- run: npm install --global npm@11.17.0
- run: npm ci
- run: python -m pip install -r requirements-dev.txt
- run: npm run lint:python
- run: npm run check
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
- name: Package static site
shell: bash
run: |
archive="mujoco-web-platform-${GITHUB_REF_NAME}.tar.gz"
tar -C web-platform-dist -czf "$archive" .
sha256sum "$archive" > SHA256SUMS
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" \
"mujoco-web-platform-${GITHUB_REF_NAME}.tar.gz" \
SHA256SUMS \
--generate-notes \
--title "$GITHUB_REF_NAME"
+1
View File
@@ -7,6 +7,7 @@ playwright-report/
web_platform/test-results/ web_platform/test-results/
web_platform/playwright-report/ web_platform/playwright-report/
web_platform/node_modules/.vite/ web_platform/node_modules/.vite/
.playwright-cli/
*.tsbuildinfo *.tsbuildinfo
# Python # Python
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+1
View File
@@ -0,0 +1 @@
24.19.0
+15
View File
@@ -0,0 +1,15 @@
AGENTS.md
context.md
plans/
.git/
.venv/
build/
node_modules/
web-platform-dist/
coverage/
playwright-report/
test-results/
.playwright-cli/
web_platform/fixtures/
web_platform/public/
package-lock.json
+7
View File
@@ -0,0 +1,7 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 100,
"proseWrap": "preserve"
}
+45
View File
@@ -0,0 +1,45 @@
# 更新日志
本项目的重要变更记录在此文件中,版本标签沿用仓库现有的 `V主版本.次版本[.修订版本]` 格式。
## [0.7.1] - 2026-09-01
### 新增
- 增加浏览器内仿真遥测记录模块,可配置记录 Body、采样频率和样本上限。
- 记录位置、移动速度、机身侧倾/俯仰/偏航角、角速度、累计里程、接触与驱动指标。
- 增加实时数据与摘要面板,并支持导出稳定列结构的 CSV 和 Schema V1 JSON。
- 提供 `TelemetrySource`、`registerDataChannel` 及记录生命周期接口,便于扩展业务指标和其他仿真后端。
- 仿真重置使用数据分段,避免跨重置计算错误速度;达到样本上限时自动停止。
## [0.6.1] - 2026-08-28
### 新增
- 固定并强制使用 Node.js、npm、Prettier 和 Ruff 开发工具版本。
- 为核心导入、仿真数学、状态管理和训练客户端增加覆盖率门槛。
- Git 标签触发的自动构建、校验和与 GitHub Release 工作流。
- 本地训练服务随机 Bearer Token 鉴权、Host 校验和任务历史上限。
- 训练进程启动/取消竞态回归测试和 SIGTERM 受控退出。
### 变更
- Playwright CI 改用版本固定的 Chromium。
- TypeScript、TSX、配置和文档统一使用 Prettier 格式化。
- Python 训练服务统一使用 Ruff 检查和格式化。
## [0.6.0] - 2026-08-28
### 变更
- 将仓库重构为以 `web_platform/` 为核心的 Web 应用仓库。
- MuJoCo 运行时改为依赖官方 `@mujoco/mujoco` npm 包。
- 移除原生 C++、Python、MJX、Unity、桌面模拟器、CMake 和上游测试镜像。
- 将训练桥接服务和 Python 控制器示例提升到仓库根目录。
## [0.5.2] - 2026-08-28
### 变更
- 优化响应式工作区、可访问性、首屏加载、纹理兼容性和视口交互。
- 增加碰撞体、坐标系、关节轴、质心和惯量辅助可视化。
+15 -4
View File
@@ -10,6 +10,9 @@
- ROS `package://`、常见 URDF 兼容转换及 DAE 降级处理 - ROS `package://`、常见 URDF 兼容转换及 DAE 降级处理
- Three.js 模型、碰撞体、坐标系、关节轴、质心和惯量可视化 - Three.js 模型、碰撞体、坐标系、关节轴、质心和惯量可视化
- 播放、暂停、单步、重置、变速、关节拖动与外力交互 - 播放、暂停、单步、重置、变速、关节拖动与外力交互
- 内置平地、坡道、楼梯、随机障碍物及 9 类系统参数化地形(粗糙/波浪、金字塔阶梯、深坑、沟壑等)
- 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点
- V3 地图创作层:认证资产库支持点击添加、拖到画布落位和首个资产自动创建场景,并可通过表单与视口操纵器继续编辑、事务式应用及导出地图 ZIP
- 浏览器内 Python 控制器(Pyodide) - 浏览器内 Python 控制器(Pyodide)
- ONNX 强化学习策略推理(ONNX Runtime Web) - ONNX 强化学习策略推理(ONNX Runtime Web)
- 可选的本机 mjlab 训练桥接服务 - 可选的本机 mjlab 训练桥接服务
@@ -17,10 +20,12 @@
## 快速开始 ## 快速开始
环境要求:Node.js 24+;仅使用训练桥接服务时需要 Python 3。 环境要求:Node.js 24(版本见 `.nvmrc`)和 npm 11.17;仅使用训练桥接服务或执行 Python 检查时需要 Python 3.12。
```bash ```bash
npm install nvm use
npm install --global npm@11.17.0
npm ci
npm run dev npm run dev
``` ```
@@ -34,10 +39,16 @@ npm run build # 生产构建到 web-platform-dist/
npm run preview # 预览生产构建 npm run preview # 预览生产构建
npm run typecheck # TypeScript 检查 npm run typecheck # TypeScript 检查
npm run lint # ESLint npm run lint # ESLint
npm run check:format # Prettier 格式检查
npm test # Vitest 单元测试 npm test # Vitest 单元测试
npm run test:e2e # Playwright 浏览器测试 npm run test:coverage # 核心模块覆盖率检查
npm run test:e2e # Playwright Chromium 浏览器测试
npm run test:training-server # Python 训练桥接服务测试 npm run test:training-server # Python 训练桥接服务测试
npm run check # 除 E2E 外的完整检查 npm run check # 除 E2E 和 Ruff 外的完整检查
# 修改 training_server/ 时额外执行
python3 -m pip install -r requirements-dev.txt
npm run lint:python
``` ```
## 仓库结构 ## 仓库结构
+15 -1
View File
@@ -3,4 +3,18 @@ import globals from 'globals';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks'; import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh'; import reactRefresh from 'eslint-plugin-react-refresh';
export default tseslint.config({ignores:['web-platform-dist','node_modules']},js.configs.recommended,...tseslint.configs.recommended,{files:['web_platform/**/*.{ts,tsx}'],languageOptions:{globals:{...globals.browser,...globals.node}},plugins:{'react-hooks':reactHooks,'react-refresh':reactRefresh},rules:{...reactHooks.configs.recommended.rules,'react-refresh/only-export-components':['warn',{allowConstantExport:true}],'@typescript-eslint/no-explicit-any':'off'}}); export default tseslint.config(
{ ignores: ['web-platform-dist', 'node_modules'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['web_platform/**/*.{ts,tsx}'],
languageOptions: { globals: { ...globals.browser, ...globals.node } },
plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh },
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-explicit-any': 'off',
},
},
);
+205 -35
View File
@@ -1,12 +1,12 @@
{ {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.6.0", "version": "0.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.6.0", "version": "0.7.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
@@ -30,6 +30,7 @@
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@types/three": "^0.185.4", "@types/three": "^0.185.4",
"@vitejs/plugin-react": "^6.1.0", "@vitejs/plugin-react": "^6.1.0",
"@vitest/coverage-v8": "4.1.11",
"autoprefixer": "^10.5.4", "autoprefixer": "^10.5.4",
"eslint": "^10.8.1", "eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
@@ -37,12 +38,17 @@
"globals": "^17.11.0", "globals": "^17.11.0",
"jsdom": "^30.0.1", "jsdom": "^30.0.1",
"postcss": "^8.5.26", "postcss": "^8.5.26",
"prettier": "3.9.6",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"three": "^0.178.0", "three": "^0.178.0",
"typescript": "5.8.2", "typescript": "5.8.2",
"typescript-eslint": "^8.67.0", "typescript-eslint": "^8.67.0",
"vite": "^8.0.16", "vite": "^8.0.16",
"vitest": "^4.1.11" "vitest": "^4.1.11"
},
"engines": {
"node": ">=24 <25",
"npm": "11.17.0"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
@@ -191,17 +197,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@babel/helper-compilation-targets": { "node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
@@ -389,6 +384,16 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@bramus/specificity": { "node_modules/@bramus/specificity": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -845,17 +850,6 @@
"@jridgewell/trace-mapping": "^0.3.24" "@jridgewell/trace-mapping": "^0.3.24"
} }
}, },
"node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@jridgewell/remapping": { "node_modules/@jridgewell/remapping": {
"version": "2.3.5", "version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
@@ -867,17 +861,6 @@
"@jridgewell/trace-mapping": "^0.3.24" "@jridgewell/trace-mapping": "^0.3.24"
} }
}, },
"node_modules/@jridgewell/remapping/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@jridgewell/resolve-uri": { "node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -893,6 +876,17 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true "dev": true
}, },
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@monaco-editor/loader": { "node_modules/@monaco-editor/loader": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
@@ -1883,6 +1877,37 @@
} }
} }
}, },
"node_modules/@vitest/coverage-v8": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.11",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.11",
"vitest": "4.1.11"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.1.11", "version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
@@ -2090,6 +2115,25 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
"integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.5.4", "version": "10.5.4",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
@@ -3013,6 +3057,16 @@
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -3056,6 +3110,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
} }
}, },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3161,6 +3222,45 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true "dev": true
}, },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "1.21.7", "version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -3645,6 +3745,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/magicast": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz",
"integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/marked": { "node_modules/marked": {
"version": "14.0.0", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
@@ -4192,6 +4333,22 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -4600,6 +4757,19 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": { "node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+15 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.6.0", "version": "0.7.1",
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台", "description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -14,7 +14,12 @@
"test:e2e": "playwright test -c web_platform/playwright.config.ts", "test:e2e": "playwright test -c web_platform/playwright.config.ts",
"training-server": "python3 training_server/server.py", "training-server": "python3 training_server/server.py",
"test:training-server": "python3 -m unittest discover -s training_server/tests", "test:training-server": "python3 -m unittest discover -s training_server/tests",
"check": "npm run typecheck && npm run lint && npm run test && npm run test:training-server && npm run build" "check": "npm run typecheck && npm run lint && npm run check:format && npm run test:coverage && npm run test:training-server && npm run build",
"format": "prettier --write .",
"check:format": "prettier --check .",
"test:coverage": "vitest run --coverage --config web_platform/vite.config.ts",
"lint:python": "python3 -m ruff check training_server",
"format:python": "python3 -m ruff format training_server"
}, },
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
@@ -27,6 +32,7 @@
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@types/three": "^0.185.4", "@types/three": "^0.185.4",
"@vitejs/plugin-react": "^6.1.0", "@vitejs/plugin-react": "^6.1.0",
"@vitest/coverage-v8": "4.1.11",
"autoprefixer": "^10.5.4", "autoprefixer": "^10.5.4",
"eslint": "^10.8.1", "eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
@@ -34,6 +40,7 @@
"globals": "^17.11.0", "globals": "^17.11.0",
"jsdom": "^30.0.1", "jsdom": "^30.0.1",
"postcss": "^8.5.26", "postcss": "^8.5.26",
"prettier": "3.9.6",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"three": "^0.178.0", "three": "^0.178.0",
"typescript": "5.8.2", "typescript": "5.8.2",
@@ -55,5 +62,10 @@
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"zustand": "^5.0.15" "zustand": "^5.0.15"
} },
"engines": {
"node": ">=24 <25",
"npm": "11.17.0"
},
"packageManager": "npm@11.17.0"
} }
+11
View File
@@ -0,0 +1,11 @@
[tool.ruff]
target-version = "py312"
line-length = 100
extend-exclude = [".venv", "build"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
+1
View File
@@ -0,0 +1 @@
ruff==0.16.5
+12 -1
View File
@@ -11,11 +11,20 @@ npm run training-server -- \
--trainer-root /path/to/unitree_rl_mjlab \ --trainer-root /path/to/unitree_rl_mjlab \
--trainer-python /path/to/training-env/bin/python --trainer-python /path/to/training-env/bin/python
``` ```
如: 如:
```bash ```bash
npm run training-server -- --trainer-root /home/cen/Embodied_Workspace/unitree_rl_mjlab --trainer-python /home/cen/miniconda3/envs/unitree_rl_mjlab/bin/python npm run training-server -- --trainer-root /home/cen/Embodied_Workspace/unitree_rl_mjlab --trainer-python /home/cen/miniconda3/envs/unitree_rl_mjlab/bin/python
``` ```
服务启动时会在终端显示一个随机访问令牌。将该令牌填入前端“访问令牌”字段后再连接。令牌只保存在当前浏览器标签页的 `sessionStorage` 中。自动化启动时可固定令牌:
```bash
MUJOCO_TRAINING_TOKEN='至少十六个字符的随机令牌' npm run training-server -- \
--trainer-root /path/to/unitree_rl_mjlab
```
也可用 `UNITREE_RL_MJLAB_ROOT` 指定工程目录。默认端口是 `8765`。如果前端不是从 `localhost` 或 `127.0.0.1` 提供,可显式添加来源: 也可用 `UNITREE_RL_MJLAB_ROOT` 指定工程目录。默认端口是 `8765`。如果前端不是从 `localhost` 或 `127.0.0.1` 提供,可显式添加来源:
```bash ```bash
@@ -24,7 +33,7 @@ python training_server/server.py \
--allow-origin http://192.168.1.10:5173 --allow-origin http://192.168.1.10:5173
``` ```
服务一次只运行一个训练任务。停止服务或在界面点击“停止训练”会向整个训练进程组发送终止信号。训练请求的 W&B 模式默认为 `offline`,保留本地指标但不登录;也可以在界面选择完全禁用或在线模式。 服务一次只运行一个训练任务,最多保留 20 个任务的内存状态,每个任务最多保留 200 行最近日志。停止服务或在界面点击“停止训练”会同步终止整个训练进程组。训练请求的 W&B 模式默认为 `offline`,保留本地指标但不登录;也可以在界面选择完全禁用或在线模式。所有 API 请求都必须携带启动时生成的 Bearer Token。
## 接口 ## 接口
@@ -39,5 +48,7 @@ python training_server/server.py \
## 测试 ## 测试
```bash ```bash
python3 -m pip install -r requirements-dev.txt
npm run lint:python
npm run test:training-server npm run test:training-server
``` ```
+530 -401
View File
File diff suppressed because it is too large Load Diff
+168 -54
View File
@@ -1,20 +1,30 @@
import subprocess
import sys import sys
import tempfile import tempfile
import threading
import time import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from server import ApiError, TrainingManager # noqa: E402 from server import ( # noqa: E402
MAX_JOBS,
ApiError,
TrainingJob,
TrainingManager,
TrainingRequestHandler,
termination_signal_handler,
)
class TrainingManagerTest(unittest.TestCase): class TrainingManagerTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.temporary = tempfile.TemporaryDirectory() self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name) self.root = Path(self.temporary.name)
(self.root / "scripts").mkdir() (self.root / "scripts").mkdir()
(self.root / "scripts" / "train.py").write_text( (self.root / "scripts" / "train.py").write_text(
"""import os, pathlib, time """import os, pathlib, time
print('WANDB_MODE=' + os.environ.get('WANDB_MODE', ''), flush=True) print('WANDB_MODE=' + os.environ.get('WANDB_MODE', ''), flush=True)
print('Learning iteration 1 / 2', flush=True) print('Learning iteration 1 / 2', flush=True)
time.sleep(0.02) time.sleep(0.02)
@@ -23,58 +33,162 @@ out=pathlib.Path('logs/rsl_rl/test/run/policy.onnx')
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(b'onnx') out.write_bytes(b'onnx')
""", """,
encoding="utf-8", encoding="utf-8",
) )
self.manager = TrainingManager(self.root, sys.executable, ("Unitree-Go2-Flat",), check_environment=False) self.manager = TrainingManager(
self.root, sys.executable, ("Unitree-Go2-Flat",), check_environment=False
)
def tearDown(self): def tearDown(self):
self.temporary.cleanup() self.temporary.cleanup()
@staticmethod @staticmethod
def payload(**patch): def payload(**patch):
value = { value = {
"taskId": "Unitree-Go2-Flat", "taskId": "Unitree-Go2-Flat",
"numEnvs": 16, "numEnvs": 16,
"maxIterations": 2, "maxIterations": 2,
"seed": 42, "seed": 42,
"runName": "browser-test", "runName": "browser-test",
"device": "cpu", "device": "cpu",
"gpuIds": [], "gpuIds": [],
"wandbMode": "offline", "wandbMode": "offline",
} }
value.update(patch) value.update(patch)
return value return value
def test_validates_allowlist_and_limits(self): def test_validates_allowlist_and_limits(self):
with self.assertRaises(ApiError): with self.assertRaises(ApiError):
self.manager.parse_config(self.payload(taskId="shell injection")) self.manager.parse_config(self.payload(taskId="shell injection"))
with self.assertRaises(ApiError): with self.assertRaises(ApiError):
self.manager.parse_config(self.payload(numEnvs=0)) self.manager.parse_config(self.payload(numEnvs=0))
with self.assertRaises(ApiError): with self.assertRaises(ApiError):
self.manager.parse_config(self.payload(runName="bad name")) self.manager.parse_config(self.payload(runName="bad name"))
with self.assertRaises(ApiError): with self.assertRaises(ApiError):
self.manager.parse_config(self.payload(wandbMode="login")) self.manager.parse_config(self.payload(wandbMode="login"))
def test_builds_argument_array_without_shell(self): def test_builds_argument_array_without_shell(self):
config = self.manager.parse_config(self.payload(device="gpu", gpuIds=[0, 2])) config = self.manager.parse_config(self.payload(device="gpu", gpuIds=[0, 2]))
command = self.manager.command_for(config) command = self.manager.command_for(config)
self.assertEqual(command[:4], [sys.executable, "-u", "scripts/train.py", "Unitree-Go2-Flat"]) self.assertEqual(
self.assertEqual(command[-2:], ["--gpu-ids", "[0,2]"]) command[:4], [sys.executable, "-u", "scripts/train.py", "Unitree-Go2-Flat"]
)
self.assertEqual(command[-2:], ["--gpu-ids", "[0,2]"])
def test_runs_job_and_exposes_new_onnx_artifact(self): def test_requires_local_host_origin_and_bearer_token(self):
job = self.manager.start(self.payload()) handler = object.__new__(TrainingRequestHandler)
deadline = time.monotonic() + 5 handler.access_token = "secret-token-1234"
while time.monotonic() < deadline: handler.allowed_origins = ()
job = self.manager.get(job["id"]) handler.headers = {
if job["state"] not in ("queued", "running"): "Host": "127.0.0.1:8765",
break "Origin": "http://localhost:5173",
time.sleep(0.02) "Authorization": "Bearer secret-token-1234",
self.assertEqual(job["state"], "succeeded") }
self.assertEqual(job["iteration"], 2) handler._ensure_request()
self.assertIn("WANDB_MODE=offline", job["logs"]) handler.headers["Authorization"] = "Bearer wrong-token"
self.assertTrue(job["artifactReady"]) with self.assertRaises(ApiError) as error:
self.assertEqual(self.manager.artifact(job["id"]).read_bytes(), b"onnx") handler._ensure_request()
self.assertEqual(error.exception.status, 401)
handler.headers["Authorization"] = "Bearer secret-token-1234"
handler.headers["Host"] = "attacker.example"
with self.assertRaises(ApiError) as error:
handler._ensure_request()
self.assertEqual(error.exception.status, 403)
def test_caps_completed_job_history(self):
config = self.manager.parse_config(self.payload())
for index in range(MAX_JOBS):
job_id = f"{index:032x}"
self.manager.jobs[job_id] = TrainingJob(
id=job_id,
config=config,
state="succeeded",
)
with patch("server.threading.Thread") as thread:
created = self.manager.start(self.payload())
self.assertEqual(len(self.manager.jobs), MAX_JOBS)
self.assertNotIn(f"{0:032x}", self.manager.jobs)
self.assertIn(created["id"], self.manager.jobs)
thread.return_value.start.assert_called_once()
def test_cancel_waits_until_starting_process_is_registered(self):
entered_popen = threading.Event()
release_popen = threading.Event()
terminated = threading.Event()
class FakeStdout:
def __iter__(self):
terminated.wait(2)
return iter(())
def close(self):
pass
class FakeProcess:
pid = 1234
stdout = FakeStdout()
@staticmethod
def poll():
return -15 if terminated.is_set() else None
@staticmethod
def wait(timeout=None):
if not terminated.wait(timeout):
raise subprocess.TimeoutExpired("fake-training", timeout)
return -15
def create_process(*_args, **_kwargs):
entered_popen.set()
self.assertTrue(release_popen.wait(2))
return FakeProcess()
config = self.manager.parse_config(self.payload())
job = TrainingJob(id="a" * 32, config=config)
self.manager.jobs[job.id] = job
runner = threading.Thread(target=self.manager._run, args=(job,))
cancel_done = threading.Event()
def cancel():
self.manager.cancel(job.id)
cancel_done.set()
with (
patch("server.subprocess.Popen", side_effect=create_process),
patch("server.os.killpg", side_effect=lambda *_args: terminated.set()) as killpg,
):
runner.start()
self.assertTrue(entered_popen.wait(2))
canceller = threading.Thread(target=cancel)
canceller.start()
self.assertFalse(cancel_done.wait(0.05))
release_popen.set()
canceller.join(2)
runner.join(2)
self.assertFalse(runner.is_alive())
self.assertFalse(canceller.is_alive())
killpg.assert_called_once_with(FakeProcess.pid, 15)
self.assertEqual(self.manager.get(job.id)["state"], "cancelled")
def test_sigterm_enters_controlled_shutdown(self):
with self.assertRaises(KeyboardInterrupt):
termination_signal_handler(15, None)
def test_runs_job_and_exposes_new_onnx_artifact(self):
job = self.manager.start(self.payload())
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
job = self.manager.get(job["id"])
if job["state"] not in ("queued", "running"):
break
time.sleep(0.02)
self.assertEqual(job["state"], "succeeded")
self.assertEqual(job["iteration"], 2)
self.assertIn("WANDB_MODE=offline", job["logs"])
self.assertTrue(job["artifactReady"])
self.assertEqual(self.manager.artifact(job["id"]).read_bytes(), b"onnx")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+45 -2
View File
@@ -12,9 +12,11 @@
- Three.js primitive、mesh、材质/贴图显示与对象选择 - Three.js primitive、mesh、材质/贴图显示与对象选择
- 播放、暂停、单步、重置、0.25×–4× 速度 - 播放、暂停、单步、重置、0.25×–4× 速度
- actuator 滑杆、hinge/slide 关节拖动、动态 body 外力拖拽 - actuator 滑杆、hinge/slide 关节拖动、动态 body 外力拖拽
- 内置平地、坡道、楼梯、可复现随机障碍物及 9 类系统参数化地形,可配置尺寸、摩擦、难度、种子与高度场采样精度
- 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行 - 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行
- 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理 - 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理
- 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX - 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX
- 可配置仿真遥测记录,实时查看速度、机身姿态、位置、驱动力等指标并导出 CSV/JSON
- FPS、物理耗时和主线程步进预算提示 - FPS、物理耗时和主线程步进预算提示
## 开发 ## 开发
@@ -61,6 +63,47 @@ python3 -m http.server 8080 --directory web-platform-dist
- 默认限制:2000 个文件、单文件 128 MiB、总解压大小 512 MiB、ZIP 文件 128 MiB。 - 默认限制:2000 个文件、单文件 128 MiB、总解压大小 512 MiB、ZIP 文件 128 MiB。
- 文件夹或 ZIP 中的 `.py` 会显示在“控制 → Python 控制器”;也可以在加载模型后单独导入不超过 1 MiB 的 `.py`。 - 文件夹或 ZIP 中的 `.py` 会显示在“控制 → Python 控制器”;也可以在加载模型后单独导入不超过 1 MiB 的 `.py`。
## 地图模块
模型加载后打开右侧“地图”标签,可以选择平地、坡道、楼梯、随机障碍物、系统参数化地形或工程内地图包。参数化地形包括离散障碍、沟壑、倒金字塔阶梯、深坑、金字塔阶梯、轨道、随机粗糙、踏石和波浪地形;相同参数与随机种子会确定性生成相同碰撞层。点击“应用并重新编译”后,平台会在当前入口同目录生成临时组合 MJCF;原始工程文件不会被修改。模型编译失败时保留上一个可用仿真会话。
工程地图由 `map.json`、静态 MJCF 碰撞层和可选的自包含 GLB 视觉层组成:
```text
maps/warehouse/
├── map.json
├── physics/world.xml
├── physics/meshes/*.obj
├── visuals/scene.glb
└── authoring/map.scene.json # V3 可选创作层
```
最小描述示例:
```json
{
"schemaVersion": 1,
"id": "warehouse",
"name": "仓库",
"coordinateSystem": { "units": "m", "up": "Z", "forward": "+X" },
"physics": { "source": "physics/world.xml" },
"visual": { "source": "visuals/scene.glb" },
"spawnPoints": [{ "id": "main", "name": "主入口", "position": [0, 0, 0.35], "yawDeg": 0 }]
}
```
物理地图仅允许静态 `worldbody` 以及 mesh、heightfield、texture、material 等基础 asset,不允许 joint、mocap body、actuator、sensor、include 或 default class。OBJ/STL 应使用简化碰撞模型;高精度模型只放入 GLB。GLB 必须是 2.0 自包含文件,外部 URI 会被拒绝。地图统一使用米制、Z-up、+X 前向坐标系。
V3 可编辑地图使用 `schemaVersion: 2`,并增加 `"authoring": { "source": "authoring/map.scene.json" }`。创作层支持方盒、圆柱、胶囊、坡道、楼梯和出生点。“场景 · 资产库”中的认证资产可点击添加或拖到画布落位;没有可编辑地图时,首个资产会立即创建 Schema V2 场景草稿,不触发 MuJoCo 重编译。新增对象支持三种放置方式:自动贴地、沿世界 `-Z` 落到最高静态承载面的自动重力落位,以及禁止位姿编辑的锁定模式。只有点击“应用并重新编译”后才提交物理层。可以通过表单或视口 TransformControls 修改位置、绕 Z 轴旋转和原语尺寸,支持移动/旋转吸附、视口拾取、复制、删除及对齐地面;`W`/`E`/`S` 切换移动、旋转和缩放工具,`Delete` 删除,`Ctrl+Z`/`Ctrl+Y` 撤销重做。编辑只更新 Three.js 草稿预览,点击编辑器内“应用并重新编译”后才生成确定性的静态 MJCF。失败时保留旧仿真和草稿。浏览器不会直接写回原目录,可使用“导出地图 ZIP”下载当前已提交地图包。没有 `authoring.source` 的 V1/V2 地图默认只读;仅由 `box`、`cylinder`、`capsule` 构成且不含 asset、材质、碰撞过滤或隐藏姿态语义的静态 MJCF,可通过“创建可编辑副本”显式升级。转换会生成 `authoring/map.scene.json`、Schema V2 描述和确定性物理层;任何不可逆语义都会导致整体拒绝,不会静默丢失内容。
物理地图超过 2000 个 geom 会产生性能警告,超过 10000 个会被拒绝;GLB 超过 100 万三角面会警告,超过 300 万会被拒绝。当前原生 URDF 模式不支持地图,请切换到“转换为 MJCF”。
## 数据记录
加载模型后打开右侧“数据”标签,可以选择需要跟踪的 Body、采样频率和样本上限。内置通道包括世界系位置/速度、水平与三维速度、机身侧倾/俯仰/偏航角及角速度、累计里程、接触数、控制输入 RMS、驱动力 RMS、绝对驱动功率和广义速度 RMS。仿真重置不会删除已有数据,而是创建新分段,避免跨重置计算出错误速度;切换记录 Body 或采样配置会清空不兼容的旧数据。
记录只保留在当前浏览器会话中,达到样本上限后自动停止,可导出带稳定列名的 CSV 或包含通道元数据、摘要和样本的 Schema V1 JSON。`SimulationSession`/`PhysicsAdapter` 保留 `configureDataRecorder`、`startDataRecording`、`stopDataRecording`、`clearDataRecording`、`exportDataRecording` 接口;还可通过 `registerDataChannel({ key, label, unit, read })` 在开始记录前注册业务自定义标量通道。数据源通过 `TelemetrySource` 抽象与 MuJoCo 解耦,后续可复用于 Worker 或远端仿真。
## Python 控制器 ## Python 控制器
Python 控制器是可信的单文件脚本,必须同步定义 `step(ctx, state)`;可选定义 `NAME`、`CONTROL_HZ`(限制为 1–500 Hz)、`init(api)`、`command(name, state)`、`reset(state)` 和 `dispose(state)`。`init` 可用 `api.joint(name)`、`api.actuator(name)`、`api.sensor(name)`、`api.body(name)` 预解析 ID;`step` 可用 `ctx.qpos(id)`、`ctx.qvel(id)`、`ctx.sensor(id)`、`ctx.body_quat(id)`、`ctx.body_position(id)` 读取状态,并用 `ctx.set_control(id, value)` 写入经过有限值检查和 actuator 限幅的控制量。定义 `command` 后,界面会显示停止、前进、后退、左转、右转和起跳按钮,并分别传入 `stop`、`forward`、`backward`、`turn_left`、`turn_right`、`jump`。所有回调都必须同步;异常会自动停止控制器或显示诊断,运行期异常还会暂停仿真并清零 `ctrl`。 Python 控制器是可信的单文件脚本,必须同步定义 `step(ctx, state)`;可选定义 `NAME`、`CONTROL_HZ`(限制为 1–500 Hz)、`init(api)`、`command(name, state)`、`reset(state)` 和 `dispose(state)`。`init` 可用 `api.joint(name)`、`api.actuator(name)`、`api.sensor(name)`、`api.body(name)` 预解析 ID;`step` 可用 `ctx.qpos(id)`、`ctx.qvel(id)`、`ctx.sensor(id)`、`ctx.body_quat(id)`、`ctx.body_position(id)` 读取状态,并用 `ctx.set_control(id, value)` 写入经过有限值检查和 actuator 限幅的控制量。定义 `command` 后,界面会显示停止、前进、后退、左转、右转和起跳按钮,并分别传入 `stop`、`forward`、`backward`、`turn_left`、`turn_right`、`jump`。所有回调都必须同步;异常会自动停止控制器或显示诊断,运行期异常还会暂停仿真并清零 `ctrl`。
@@ -77,9 +120,9 @@ npm run training-server -- \
--trainer-python /path/to/training-env/bin/python --trainer-python /path/to/training-env/bin/python
``` ```
界面默认连接 `http://127.0.0.1:8765`,可选择服务端允许的任务、并行环境数、训练迭代、随机种子、CPU/GPU、GPU 编号和实验记录方式。W&B 默认为本地离线模式,无需登录或 API Key;也可完全禁用,只有明确选择在线模式时才会联网登录。训练期间页面轮询迭代进度与最近日志,可以停止任务;训练成功后点击“导入策略”,生成的 `policy.onnx` 会进入现有 ONNX 加载流程。 服务启动时会在终端输出一个随机访问令牌;在界面中填写该令牌后连接。令牌仅保存在当前标签页的 `sessionStorage`。界面默认连接 `http://127.0.0.1:8765`,可选择服务端允许的任务、并行环境数、训练迭代、随机种子、CPU/GPU、GPU 编号和实验记录方式。W&B 默认为本地离线模式,无需登录或 API Key;也可完全禁用,只有明确选择在线模式时才会联网登录。训练期间页面轮询迭代进度与最近日志,可以停止任务;训练成功后点击“导入策略”,生成的 `policy.onnx` 会进入现有 ONNX 加载流程。
桥接服务只监听本机回环地址、仅接受允许列表中的任务和经过范围校验的参数,不执行前端提供的 Shell 命令;一次只运行一个训练进程。当前任务使用 `unitree_rl_mjlab` 自带的机器人资产与环境配置,**不会自动把浏览器中临时编辑的 MJCF/URDF 作为训练环境**。自定义浏览器模型训练需要先在 mjlab 中注册对应 task。服务配置、接口和安全边界见 [`../training_server/README.md`](../training_server/README.md)。 桥接服务只监听本机回环地址,并检查 Host、Origin 和 Bearer Token;仅接受允许列表中的任务和经过范围校验的参数,不执行前端提供的 Shell 命令;一次只运行一个训练进程。当前任务使用 `unitree_rl_mjlab` 自带的机器人资产与环境配置,**不会自动把浏览器中临时编辑的 MJCF/URDF 作为训练环境**。自定义浏览器模型训练需要先在 mjlab 中注册对应 task。服务配置、接口和安全边界见 [`../training_server/README.md`](../training_server/README.md)。
## ONNX 强化学习策略 ## ONNX 强化学习策略
+629 -156
View File
@@ -1,9 +1,10 @@
import {expect, test} from '@playwright/test'; import { expect, test } from '@playwright/test';
import {readFileSync} from 'node:fs'; import { readFileSync } from 'node:fs';
import {fileURLToPath} from 'node:url'; import { fileURLToPath } from 'node:url';
import {zipSync} from 'fflate'; import { zipSync } from 'fflate';
const fixture = (relative: string) => fileURLToPath(new URL(`../fixtures/${relative}`, import.meta.url)); const fixture = (relative: string) =>
fileURLToPath(new URL(`../fixtures/${relative}`, import.meta.url));
const SIMPLE_MODEL = ` const SIMPLE_MODEL = `
<mujoco model="e2e"> <mujoco model="e2e">
@@ -19,7 +20,47 @@ const SIMPLE_MODEL = `
<actuator><motor name="motor" joint="slide" ctrlrange="-2 2"/></actuator> <actuator><motor name="motor" joint="slide" ctrlrange="-2 2"/></actuator>
</mujoco>`; </mujoco>`;
const SLIDE_DIRECTION_MODEL=`<mujoco model="drag-direction"><worldbody><body name="slider" pos="0 0 1"><joint name="screen_x" type="slide" axis="1 0 0" range="-2 2"/><geom type="box" size=".25 .25 .25" mass="1"/></body></worldbody></mujoco>`; const SLIDE_DIRECTION_MODEL = `<mujoco model="drag-direction"><worldbody><body name="slider" pos="0 0 1"><joint name="screen_x" type="slide" axis="1 0 0" range="-2 2"/><geom type="box" size=".25 .25 .25" mass="1"/></body></worldbody></mujoco>`;
function minimalGlb(): Buffer {
const json = Buffer.from(
JSON.stringify({
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
buffers: [{ byteLength: 36 }],
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 36, target: 34962 }],
accessors: [
{
bufferView: 0,
componentType: 5126,
count: 3,
type: 'VEC3',
min: [0, 0, 0],
max: [1, 1, 0],
},
],
}),
);
const jsonPadding = (4 - (json.length % 4)) % 4;
const jsonChunk = Buffer.concat([json, Buffer.alloc(jsonPadding, 0x20)]);
const positions = Buffer.from(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer);
const totalLength = 12 + 8 + jsonChunk.length + 8 + positions.length;
const glb = Buffer.alloc(totalLength);
glb.writeUInt32LE(0x46546c67, 0);
glb.writeUInt32LE(2, 4);
glb.writeUInt32LE(totalLength, 8);
glb.writeUInt32LE(jsonChunk.length, 12);
glb.writeUInt32LE(0x4e4f534a, 16);
jsonChunk.copy(glb, 20);
const binOffset = 20 + jsonChunk.length;
glb.writeUInt32LE(positions.length, binOffset);
glb.writeUInt32LE(0x004e4942, binOffset + 4);
positions.copy(glb, binOffset + 8);
return glb;
}
const LARGE_MODEL = ` const LARGE_MODEL = `
<mujoco model="soak"> <mujoco model="soak">
@@ -36,238 +77,670 @@ const LARGE_MODEL = `
</worldbody> </worldbody>
</mujoco>`; </mujoco>`;
test('显示中文平台骨架并加载单文件模型', async ({page}) => { test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`)); page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`));
page.on('pageerror', (error) => console.log(`[browser:error] ${error.message}`)); page.on('pageerror', (error) => console.log(`[browser:error] ${error.message}`));
page.on('requestfailed', (request) => console.log(`[browser:requestfailed] ${request.url()} ${request.failure()?.errorText}`)); page.on('requestfailed', (request) =>
page.on('response', (response) => { if (response.status() >= 400 || response.url().endsWith('.wasm')) console.log(`[browser:response] ${response.status()} ${response.url()} ${response.headers()['content-type'] ?? ''}`); }); console.log(`[browser:requestfailed] ${request.url()} ${request.failure()?.errorText}`),
);
page.on('response', (response) => {
if (response.status() >= 400 || response.url().endsWith('.wasm'))
console.log(
`[browser:response] ${response.status()} ${response.url()} ${response.headers()['content-type'] ?? ''}`,
);
});
await page.goto('/'); await page.goto('/');
await page.setViewportSize({width:1024,height:768}); await page.setViewportSize({ width: 1024, height: 768 });
const resetCameraBox=await page.getByRole('button',{name:'相机复位'}).boundingBox(),playBox=await page.getByRole('button',{name:'▶ 播放'}).boundingBox(); const resetCameraBox = await page.getByRole('button', { name: '相机复位' }).boundingBox(),
expect(resetCameraBox&&playBox&&resetCameraBox.x+resetCameraBox.width<=playBox.x).toBeTruthy(); playBox = await page.getByRole('button', { name: '▶ 播放' }).boundingBox();
await page.getByRole('button',{name:'更多工作台操作'}).click();await expect(page.getByRole('menuitem',{name:'工作台设置'})).toBeVisible();await page.keyboard.press('Escape'); expect(
await page.setViewportSize({width:1440,height:900}); resetCameraBox && playBox && resetCameraBox.x + resetCameraBox.width <= playBox.x,
await expect(page.getByRole('heading', {name: 'MuJoCo Web 仿真平台'})).toBeVisible(); ).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('main').getByText('拖放模型工程到此处')).toBeVisible(); await expect(page.getByRole('main').getByText('拖放模型工程到此处')).toBeVisible();
await expect(page.getByRole('img',{name:'XYZ 方向指示器'})).toBeVisible(); await expect(page.getByRole('img', { name: 'XYZ 方向指示器' })).toBeVisible();
await expect(page.getByRole('button',{name:'切换到白天主题'})).toBeVisible(); await expect(page.getByRole('button', { name: '切换到白天主题' })).toBeVisible();
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 expect(page.getByRole('dialog',{name:'工作台设置'})).toBeVisible();await page.keyboard.press('Escape'); await expect(page.getByRole('dialog', { 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.keyboard.press('Control+k'); await page.keyboard.press('Control+k');
await expect(page.getByRole('dialog',{name:'命令面板'})).toBeVisible(); await expect(page.getByRole('dialog', { name: '命令面板' })).toBeVisible();
await page.getByLabel('搜索命令').fill('复位相机'); await page.getByLabel('搜索命令').fill('复位相机');
await expect(page.getByRole('option',{name:/复位相机/})).toBeVisible(); await expect(page.getByRole('option', { name: /复位相机/ })).toBeVisible();
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await page.getByRole('button',{name:'进入全屏'}).click(); await page.getByRole('button', { name: '进入全屏' }).click();
await expect(page.getByRole('button',{name:'退出全屏'})).toBeVisible(); await expect(page.getByRole('button', { name: '退出全屏' })).toBeVisible();
await page.keyboard.press('Control+k'); await page.keyboard.press('Control+k');
await expect(page.getByRole('dialog',{name:'命令面板'})).toBeVisible(); await expect(page.getByRole('dialog', { name: '命令面板' })).toBeVisible();
await page.keyboard.press('Escape'); 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('button', { name: '切换到白天主题' }).click();
await expect(page.locator('#root > div')).toHaveClass(/theme-light/); await expect(page.locator('#root > div')).toHaveClass(/theme-light/);
await expect(page.getByRole('button',{name:'切换到黑夜主题'})).toBeVisible(); await expect(page.getByRole('button', { name: '切换到黑夜主题' })).toBeVisible();
await page.getByRole('button',{name:'切换到黑夜主题'}).click(); await page.getByRole('button', { name: '切换到黑夜主题' }).click();
await expect(page.locator('#root > div')).toHaveClass(/theme-dark/); await expect(page.locator('#root > div')).toHaveClass(/theme-dark/);
await page.locator('input[type="file"]').first().setInputFiles({ await page
name: 'model.xml', .locator('input[type="file"]')
mimeType: 'text/xml', .first()
buffer: Buffer.from(SIMPLE_MODEL), .setInputFiles({
}); name: 'model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('button',{name:'显示设置'}).click(); await page.getByRole('button', { name: '显示设置' }).click();
const displayDialog=page.getByRole('dialog',{name:'视图显示设置'});await expect(displayDialog).toBeVisible();await expect(displayDialog.getByRole('switch')).toHaveCount(7); const displayDialog = page.getByRole('dialog', { name: '视图显示设置' });
const centerOfMassSwitch=displayDialog.getByRole('switch',{name:/^质心/});await centerOfMassSwitch.click();await expect(centerOfMassSwitch).toHaveAttribute('aria-checked','true');await page.keyboard.press('Escape');await expect(displayDialog).toBeHidden(); await expect(displayDialog).toBeVisible();
await page.getByRole('button',{name:'通知中心'}).click();await expect(page.getByRole('dialog',{name:'通知中心'})).toContainText('模型加载完成');await page.getByText('事件日志').click();await expect(page.getByRole('dialog',{name:'诊断与事件日志'})).toBeVisible();await page.keyboard.press('Escape'); await expect(displayDialog.getByRole('switch')).toHaveCount(7);
await page.getByRole('button',{name:/FPS .*物理/}).click(); const centerOfMassSwitch = displayDialog.getByRole('switch', { name: /^质心/ });
await expect(page.getByRole('dialog',{name:'性能详情'})).toBeVisible(); await centerOfMassSwitch.click();
await expect(centerOfMassSwitch).toHaveAttribute('aria-checked', 'true');
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await page.getByRole('tab',{name:'控制'}).click(); await expect(displayDialog).toBeHidden();
await page.getByRole('button',{name:'Actuator'}).click(); await page.getByRole('button', { name: '通知中心' }).click();
await expect(page.getByText('motor',{exact:true})).toBeVisible(); await expect(page.getByRole('dialog', { name: '通知中心' })).toContainText('模型加载完成');
await expect(page.getByRole('tabpanel',{name:'控制'}).getByText('slide',{exact:true})).toBeVisible(); await page.getByText('事件日志').click();
await page.getByRole('tab',{name:'模型结构'}).click(); await expect(page.getByRole('dialog', { name: '诊断与事件日志' })).toBeVisible();
const structure=page.getByRole('navigation',{name:'模型结构树'});await expect(structure).toBeVisible();await structure.getByRole('treeitem',{name:/hinge/}).hover(); await page.keyboard.press('Escape');
await page.getByRole('button', { name: /FPS .*物理/ }).click();
await expect(page.getByRole('dialog', { name: '性能详情' })).toBeVisible();
await page.keyboard.press('Escape');
await page.getByRole('tab', { name: '控制' }).click();
await page.getByRole('button', { name: 'Actuator' }).click();
await expect(page.getByText('motor', { exact: true })).toBeVisible();
await expect(
page.getByRole('tabpanel', { name: '控制' }).getByText('slide', { exact: true }),
).toBeVisible();
await page.getByRole('tab', { name: '模型结构' }).click();
const structure = page.getByRole('navigation', { name: '模型结构树' });
await expect(structure).toBeVisible();
await structure.getByRole('treeitem', { name: /hinge/ }).hover();
await expect(page.getByRole('alert')).toHaveCount(0); await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.getByRole('button',{name:'重置关节'})).toBeVisible(); await expect(page.getByRole('button', { name: '重置关节' })).toBeVisible();
await page.getByRole('button',{name:'高级'}).click(); await page.getByRole('button', { name: '高级' }).click();
await expect(page.getByText('下限 -1.571 rad')).toBeVisible(); await expect(page.getByText('下限 -1.571 rad')).toBeVisible();
await expect(page.getByText('上限 1.571 rad')).toBeVisible(); await expect(page.getByText('上限 1.571 rad')).toBeVisible();
await page.getByRole('button',{name:'rad 弧度制'}).click(); await page.getByRole('button', { name: 'rad 弧度制' }).click();
await expect(page.getByText('下限 -90.000°')).toBeVisible(); await expect(page.getByText('下限 -90.000°')).toBeVisible();
await expect(page.getByText('上限 90.000°')).toBeVisible(); await expect(page.getByText('上限 90.000°')).toBeVisible();
await page.getByRole('button',{name:'忽略关节限位'}).click(); await page.getByRole('button', { name: '忽略关节限位' }).click();
await expect(page.getByRole('button',{name:'忽略关节限位'})).toHaveAttribute('aria-pressed','true'); await expect(page.getByRole('button', { name: '忽略关节限位' })).toHaveAttribute(
'aria-pressed',
'true',
);
await expect(page.getByText('已忽略').first()).toBeVisible(); await expect(page.getByText('已忽略').first()).toBeVisible();
await page.getByRole('button',{name:'重置关节'}).click(); await page.getByRole('button', { name: '重置关节' }).click();
await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible(); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
}); });
test('窄视口默认保留完整视口并可按需打开侧栏',async({page})=>{ test('窄视口默认保留完整视口并可按需打开侧栏', async ({ page }) => {
await page.setViewportSize({width:800,height:700}); await page.setViewportSize({ width: 800, height: 700 });
await page.goto('/'); await page.goto('/');
await expect(page.getByRole('main')).toBeInViewport(); await expect(page.getByRole('main')).toBeInViewport();
await expect(page.getByRole('button',{name:'显示工程面板'})).toBeVisible(); await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible();
await expect(page.getByRole('button',{name:'显示属性面板'})).toBeVisible(); await expect(page.getByRole('button', { name: '显示属性面板' })).toBeVisible();
await page.getByRole('button',{name:'显示属性面板'}).click(); 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})=>{ test('工作区布局与视口显示偏好在刷新后保留', async ({ page }) => {
await page.setViewportSize({width:1440,height:900}); await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/'); await page.goto('/');
await page.getByRole('button',{name:'隐藏工程面板'}).click(); await page.getByRole('button', { name: '隐藏工程面板' }).click();
await page.getByRole('button',{name:'显示设置'}).click(); await page.getByRole('button', { name: '显示设置' }).click();
await page.getByRole('switch',{name:/^坐标系/}).click(); await page.getByRole('switch', { name: /^坐标系/ }).click();
await page.reload(); await page.reload();
await expect(page.getByRole('button',{name:'显示工程面板'})).toBeVisible(); await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible();
await page.getByRole('button',{name:'显示设置'}).click(); await page.getByRole('button', { name: '显示设置' }).click();
await expect(page.getByRole('switch',{name:/^坐标系/})).toHaveAttribute('aria-checked','true'); await expect(page.getByRole('switch', { name: /^坐标系/ })).toHaveAttribute(
'aria-checked',
'true',
);
}); });
test('转换后的 MJCF 可编辑并重新载入', async ({page}) => { test('转换后的 MJCF 可编辑并重新载入', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles({name:'model.xml',mimeType:'text/xml',buffer:Buffer.from(SIMPLE_MODEL)}); await page
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); .locator('input[type="file"]')
await page.getByRole('button',{name:'源代码'}).click(); .first()
const dialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await expect(dialog).toBeVisible(); .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: '源代码' }).click();
const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await expect(dialog).toBeVisible();
await expect(dialog.getByText('缓存文件 · 可编辑')).toBeVisible(); await expect(dialog.getByText('缓存文件 · 可编辑')).toBeVisible();
await expect(dialog.getByText('转换后的 MJCF',{exact:true})).toBeVisible(); await expect(dialog.getByText('转换后的 MJCF', { exact: true })).toBeVisible();
const editor=dialog.locator('.monaco-editor');await editor.click({position:{x:240,y:120}}); const editor = dialog.locator('.monaco-editor');
await page.keyboard.press('Control+a');await page.keyboard.insertText(SIMPLE_MODEL.replace('model="e2e"','model="cached-edit"')); await editor.click({ position: { x: 240, y: 120 } });
await dialog.getByRole('button',{name:'保存并重新载入',exact:true}).click(); await page.keyboard.press('Control+a');
await expect(dialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000}); await page.keyboard.insertText(SIMPLE_MODEL.replace('model="e2e"', 'model="cached-edit"'));
await dialog.getByRole('button',{name:'关闭源代码编辑器'}).click(); await dialog.getByRole('button', { name: '保存并重新载入', exact: true }).click();
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); await expect(dialog.getByRole('button', { name: '保存并重新载入', exact: true })).toBeDisabled({
await expect(page.getByRole('button',{name:'导出 URDF'})).toHaveCount(0); timeout: 30_000,
await expect(page.getByRole('button',{name:'导出 MJCF'})).toHaveCount(0); });
await dialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '导出 URDF' })).toHaveCount(0);
await expect(page.getByRole('button', { name: '导出 MJCF' })).toHaveCount(0);
}); });
test('关闭已修改的 MJCF 前要求确认',async({page})=>{ test('关闭已修改的 MJCF 前要求确认', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles({name:'model.xml',mimeType:'text/xml',buffer:Buffer.from(SIMPLE_MODEL)}); await page
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); .locator('input[type="file"]')
await page.getByRole('button',{name:'源代码'}).click(); .first()
const editorDialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'}); .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) });
await editorDialog.locator('.monaco-editor').click({position:{x:240,y:120}}); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.keyboard.press('Control+End');await page.keyboard.insertText('\n'); await page.getByRole('button', { name: '源代码' }).click();
await editorDialog.getByRole('button',{name:'关闭源代码编辑器'}).click(); const editorDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
const confirm=page.getByRole('dialog',{name:'放弃未保存的修改?'}); await editorDialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
await page.keyboard.press('Control+End');
await page.keyboard.insertText('\n');
await editorDialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
const confirm = page.getByRole('dialog', { name: '放弃未保存的修改?' });
await expect(confirm).toBeVisible(); await expect(confirm).toBeVisible();
await confirm.getByRole('button',{name:'继续编辑'}).click(); await confirm.getByRole('button', { name: '继续编辑' }).click();
await expect(editorDialog).toBeVisible(); await expect(editorDialog).toBeVisible();
await editorDialog.getByRole('button',{name:'关闭源代码编辑器'}).click(); await editorDialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
await page.getByRole('dialog',{name:'放弃未保存的修改?'}).getByRole('button',{name:'放弃修改'}).click(); await page
.getByRole('dialog', { name: '放弃未保存的修改?' })
.getByRole('button', { name: '放弃修改' })
.click();
await expect(editorDialog).toHaveCount(0); await expect(editorDialog).toHaveCount(0);
}); });
test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({page}) => { test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles([ await page
fixture('mjcf_include/model.xml'), .locator('input[type="file"]')
fixture('mjcf_include/world.xml'), .first()
fixture('mjcf_include/triangle.obj'), .setInputFiles([
fixture('mjcf_include/triangle.stl'), fixture('mjcf_include/model.xml'),
fixture('mjcf_include/checker.png'), fixture('mjcf_include/world.xml'),
]); fixture('mjcf_include/triangle.obj'),
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); 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.getByText('5 个文件')).toBeVisible();
}); });
test('加载引用 OBJ 的 URDF 工程', async ({page}) => { test('加载引用 OBJ 的 URDF 工程', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles([ await page
fixture('urdf_mesh/robot.urdf'), .locator('input[type="file"]')
fixture('urdf_mesh/triangle.obj'), .first()
]); .setInputFiles([fixture('urdf_mesh/robot.urdf'), fixture('urdf_mesh/triangle.obj')]);
const options=page.getByRole('dialog',{name:'配置 URDF 仿真组件'}); const options = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
await expect(options.getByRole('checkbox',{name:/为关节添加驱动器/})).toBeChecked(); await expect(options.getByRole('checkbox', { name: /为关节添加驱动器/ })).toBeChecked();
await expect(options.getByRole('checkbox',{name:/添加传感器/})).toBeChecked(); await expect(options.getByRole('checkbox', { name: /添加传感器/ })).toBeChecked();
await options.getByRole('button',{name:'转换并加载'}).click(); await options.getByRole('button', { name: '转换并加载' }).click();
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('2 个文件')).toBeVisible(); await expect(page.getByText('2 个文件')).toBeVisible();
await page.getByRole('button',{name:'URDF 处理方式'}).click(); await page.getByRole('button', { name: 'URDF 处理方式' }).click();
await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf'); await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf');
await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating'); await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating');
await page.getByRole('button',{name:'通知中心'}).click(); await page.getByRole('button', { name: '通知中心' }).click();
const notifications=page.getByRole('dialog',{name:'通知中心'}); const notifications = page.getByRole('dialog', { name: '通知中心' });
await expect(notifications).toContainText(/模型已加载 · \d+ 项兼容调整/); await expect(notifications).toContainText(/模型已加载 · \d+ 项兼容调整/);
await expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/); await expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/);
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked(); await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked();
await page.getByRole('button',{name:'源代码'}).click(); await page.getByRole('button', { name: '源代码' }).click();
const sourceDialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible(); const sourceDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await sourceDialog.locator('.monaco-editor').click({position:{x:240,y:120}});await page.keyboard.press('Control+End');await page.keyboard.insertText('\n'); await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible();
await sourceDialog.getByRole('button',{name:'保存并重新载入',exact:true}).click();await expect(sourceDialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000}); await sourceDialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
await expect(page.getByText('WASM 已加载')).toBeVisible();await sourceDialog.getByRole('button',{name:'关闭源代码编辑器'}).click(); await page.keyboard.press('Control+End');
await page.keyboard.insertText('\n');
await sourceDialog.getByRole('button', { name: '保存并重新载入', exact: true }).click();
await expect(
sourceDialog.getByRole('button', { name: '保存并重新载入', exact: true }),
).toBeDisabled({ timeout: 30_000 });
await expect(page.getByText('WASM 已加载')).toBeVisible();
await sourceDialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
}); });
test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译',async({page})=>{ test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译', async ({ page }) => {
const urdf=`<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 = `<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>`;
await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'jointed.urdf',mimeType:'application/xml',buffer:Buffer.from(urdf)}); await page.goto('/');
await page.getByRole('dialog',{name:'配置 URDF 仿真组件'}).getByRole('button',{name:'转换并加载'}).click(); await page
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); .locator('input[type="file"]')
await expect(page.getByLabel('摄像头画面')).toBeVisible();await page.getByRole('button',{name:'隐藏画面'}).click();await page.getByRole('button',{name:'显示摄像头画面'}).click();await expect(page.getByLabel('摄像头画面')).toBeVisible(); .first()
await page.getByRole('button',{name:'通知中心'}).click(); .setInputFiles({
const notifications=page.getByRole('dialog',{name:'通知中心'}); name: 'jointed.urdf',
mimeType: 'application/xml',
buffer: Buffer.from(urdf),
});
await page
.getByRole('dialog', { name: '配置 URDF 仿真组件' })
.getByRole('button', { name: '转换并加载' })
.click();
await expect(page.getByText('WASM 已加载')).toBeVisible({ 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: '通知中心' });
await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器'); await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器');
await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm'); await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm');
await page.keyboard.press('Escape');await page.getByRole('tab',{name:'控制'}).click();await page.getByRole('button',{name:'Actuator'}).click(); await page.keyboard.press('Escape');
await expect(page.getByText('shoulder_motor')).toBeVisible();await expect(page.getByText('关节:shoulder')).toBeVisible();await expect(page.getByText('N·m',{exact:true})).toBeVisible(); await page.getByRole('tab', { name: '控制' }).click();
await page.getByText('常用参数').click();const kp=page.getByLabel(/kp(MJCF stiffness/),kv=page.getByLabel(/kv(MJCF damping/);await kp.fill('150');await kp.press('Enter');await kv.fill('15');await kv.press('Enter');await expect(kp).toHaveValue('150');await expect(kv).toHaveValue('15'); await page.getByRole('button', { name: 'Actuator' }).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();
const kp = page.getByLabel(/kp(MJCF stiffness/),
kv = page.getByLabel(/kv(MJCF damping/);
await kp.fill('150');
await kp.press('Enter');
await kv.fill('15');
await kv.press('Enter');
await expect(kp).toHaveValue('150');
await expect(kv).toHaveValue('15');
}); });
test('转换后的 MJCF 保存时保留 DAE 转换缓存资源',async({page})=>{ test('转换后的 MJCF 保存时保留 DAE 转换缓存资源', async ({ page }) => {
const zip=zipSync({'robot/urdf/robot.urdf':new Uint8Array(readFileSync(fixture('urdf_dae/robot/urdf/robot.urdf'))),'robot/dae/triangle.dae':new Uint8Array(readFileSync(fixture('urdf_dae/robot/dae/triangle.dae')))}); const zip = zipSync({
await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'dae.zip',mimeType:'application/zip',buffer:Buffer.from(zip)});await page.getByRole('dialog',{name:'配置 URDF 仿真组件'}).getByRole('button',{name:'转换并加载'}).click();await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); 'robot/urdf/robot.urdf': new Uint8Array(
await page.getByRole('button',{name:'源代码'}).click();const dialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await dialog.locator('.monaco-editor').click({position:{x:240,y:120}});await page.keyboard.press('Control+End');await page.keyboard.insertText('\n');await dialog.getByRole('button',{name:'保存并重新载入',exact:true}).click();await expect(dialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000});await expect(page.getByText('WASM 已加载')).toBeVisible();await expect(page.getByText('模型编译失败')).toHaveCount(0); readFileSync(fixture('urdf_dae/robot/urdf/robot.urdf')),
),
'robot/dae/triangle.dae': new Uint8Array(
readFileSync(fixture('urdf_dae/robot/dae/triangle.dae')),
),
});
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles({ name: 'dae.zip', mimeType: 'application/zip', buffer: Buffer.from(zip) });
await page
.getByRole('dialog', { name: '配置 URDF 仿真组件' })
.getByRole('button', { name: '转换并加载' })
.click();
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: '源代码' }).click();
const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await dialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
await page.keyboard.press('Control+End');
await page.keyboard.insertText('\n');
await dialog.getByRole('button', { name: '保存并重新载入', exact: true }).click();
await expect(dialog.getByRole('button', { name: '保存并重新载入', exact: true })).toBeDisabled({
timeout: 30_000,
});
await expect(page.getByText('WASM 已加载')).toBeVisible();
await expect(page.getByText('模型编译失败')).toHaveCount(0);
}); });
test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加',async({page})=>{ test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加', async ({ page }) => {
await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'slide.xml',mimeType:'text/xml',buffer:Buffer.from(SLIDE_DIRECTION_MODEL)});await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); await page.goto('/');
await page.getByRole('button',{name:'关节拖动'}).click();const canvas=page.locator('main canvas').first(),box=await canvas.boundingBox();expect(box).not.toBeNull();const x=box!.x+box!.width/2,y=box!.y+box!.height/2;await page.mouse.move(x,y);await page.mouse.down();await page.mouse.move(x+70,y,{steps:8});await page.mouse.up(); await page
await page.getByRole('tab',{name:'控制'}).click();const jointSection=page.getByRole('button',{name:'关节 1'});if(await jointSection.getAttribute('aria-expanded')==='false')await jointSection.click();const output=page.getByText('screen_x').locator('..').locator('output');await expect.poll(async()=>Number.parseFloat(await output.textContent()||'0')).toBeGreaterThan(0); .locator('input[type="file"]')
.first()
.setInputFiles({
name: 'slide.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SLIDE_DIRECTION_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: '关节拖动' }).click();
const canvas = page.locator('main canvas').first(),
box = await canvas.boundingBox();
expect(box).not.toBeNull();
const x = box!.x + box!.width / 2,
y = box!.y + box!.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + 70, y, { steps: 8 });
await page.mouse.up();
await page.getByRole('tab', { name: '控制' }).click();
const jointSection = page.getByRole('button', { name: '关节 1' });
if ((await jointSection.getAttribute('aria-expanded')) === 'false') await jointSection.click();
const output = page.getByText('screen_x').locator('..').locator('output');
await expect
.poll(async () => Number.parseFloat((await output.textContent()) || '0'))
.toBeGreaterThan(0);
}); });
test('可导入并启用 Python 控制器',async({page})=>{ test('可导入并启用 Python 控制器', async ({ page }) => {
await page.goto('/');await 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 page.goto('/');
await page.getByRole('tab',{name:'控制'}).click();const python=`NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`; await page
await 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 expect(page.getByText('Python / Pyodide')).toBeVisible();await page.getByRole('button',{name:'启用',exact:true}).click();await expect(page.getByText('运行中')).toBeVisible(); .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 page.getByRole('tab', { name: '控制' }).click();
const python = `NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`;
await 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 expect(page.getByText('Python / Pyodide')).toBeVisible();
await page.getByRole('button', { name: '启用', exact: true }).click();
await expect(page.getByText('运行中')).toBeVisible();
}); });
test('中等规模模型持续步进并可重复加载', async ({page}) => { test('中等规模模型持续步进并可重复加载', async ({ page }) => {
await page.goto('/'); await page.goto('/');
const input = page.locator('input[type="file"]').first(); const input = page.locator('input[type="file"]').first();
const modelFile = {name:'large.xml',mimeType:'text/xml',buffer:Buffer.from(LARGE_MODEL)}; const modelFile = { name: 'large.xml', mimeType: 'text/xml', buffer: Buffer.from(LARGE_MODEL) };
await input.setInputFiles(modelFile); await input.setInputFiles(modelFile);
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', {name:'▶ 播放'}).click(); await page.getByRole('button', { name: '▶ 播放' }).click();
await page.waitForTimeout(2_000); await page.waitForTimeout(2_000);
await expect(page.locator('footer')).not.toContainText('时间 0.000 s'); await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
// 播放过程中重置必须同时暂停底层会话,之后仍可正常播放和暂停。 // 播放过程中重置必须同时暂停底层会话,之后仍可正常播放和暂停。
await page.getByRole('button',{name:'重置',exact:true}).click(); await page.getByRole('button', { name: '重置', exact: true }).click();
await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible(); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
await expect(page.locator('footer')).toContainText('时间 0.000 s'); await expect(page.locator('footer')).toContainText('时间 0.000 s');
await page.getByRole('button',{name:'▶ 播放'}).click(); await page.getByRole('button', { name: '▶ 播放' }).click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
await page.getByRole('button',{name:'⏸ 暂停'}).click(); await page.getByRole('button', { name: '⏸ 暂停' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(200);
const pausedTime=(await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1]; const pausedTime = (await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1];
expect(Number(pausedTime)).toBeGreaterThan(0); expect(Number(pausedTime)).toBeGreaterThan(0);
await page.waitForTimeout(500); await page.waitForTimeout(500);
expect((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1]).toBe(pausedTime); expect((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1]).toBe(pausedTime);
await input.setInputFiles(modelFile); await input.setInputFiles(modelFile);
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('alert')).toHaveCount(0); await expect(page.getByRole('alert')).toHaveCount(0);
}); });
test('无效模型显示中文诊断且保留工程树', async ({page}) => { test('认证资产可点击创建场景并拖到画布落位', async ({ page }) => {
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
const library = page.getByLabel('认证资产');
await expect(library.getByText('点击添加,或按住资产拖到画布落位。')).toBeVisible();
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByText('正在加载 MuJoCo 与模型…')).toHaveCount(0);
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json', {
timeout: 30_000,
});
await expect(page.getByText('V3 地图编辑器')).toBeVisible();
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
await page.locator('[data-map-asset="ramp"]').dragTo(page.locator('main canvas'));
await expect(page.getByLabel('地图对象列表').getByText('标准坡道 · 坡道')).toBeVisible();
await expect(page.getByText('地图草稿尚未应用')).toBeVisible();
await page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒').click();
await page.getByLabel('对象放置方式').selectOption('locked');
await expect(page.getByLabel('对象位置X')).toBeDisabled();
await page.getByLabel('对象放置方式').selectOption('auto_ground');
await expect(page.getByLabel('对象位置X')).toBeEnabled();
const gizmoLine = page.getByRole('img', { name: 'XYZ 方向指示器' }).locator('line').first();
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);
await page.mouse.down({ button: 'left' });
await page.mouse.move(canvasBox!.x + 104, canvasBox!.y + 50, { steps: 8 });
await page.mouse.up({ button: 'left' });
await expect.poll(() => gizmoLine.getAttribute('x2')).not.toBe(beforeRotation);
});
test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'map-model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption('builtin');
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();
});
test('依次应用全部系统参数化地形', async ({ page }) => {
const terrains = [
['discrete_obstacles', '离散障碍地形'],
['gap', '沟壑地形'],
['inverted_pyramid_stairs', '倒金字塔阶梯'],
['pit', '深坑地形'],
['pyramid_stairs', '金字塔阶梯'],
['rails', '轨道地形'],
['rough', '随机粗糙地形'],
['stepping_stones', '踏石地形'],
['wave', '波浪地形'],
] as const;
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'terrain-model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption('builtin');
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.getByText('WASM 已加载')).toBeVisible();
});
test('导入并应用分层工程地图包', async ({ page }) => {
const mapJson = JSON.stringify({
schemaVersion: 1,
id: 'test-room',
name: '测试场景',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
visual: { source: 'visuals/scene.glb' },
spawnPoints: [{ id: 'start', name: '起点', position: [2, 0, 0.5], yawDeg: 0 }],
});
const project = zipSync({
'model.xml': Buffer.from(SIMPLE_MODEL),
'maps/test/map.json': Buffer.from(mapJson),
'maps/test/physics/world.xml': Buffer.from(
'<mujoco><worldbody><geom name="floor" type="plane" size="8 8 .1"/><geom name="wall" type="box" pos="3 0 .5" size=".1 2 .5"/></worldbody></mujoco>',
),
'maps/test/visuals/scene.glb': minimalGlb(),
});
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'map-project.zip',
mimeType: 'application/zip',
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '测试场景' });
await expect(page.getByLabel('地图出生点')).toHaveValue('start');
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByText(/已加载工程地图“测试场景”/)).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(/视觉地图加载失败/)).toHaveCount(0);
});
test('将受支持的只读物理地图转换为可编辑副本', async ({ page }) => {
const mapJson = JSON.stringify({
schemaVersion: 1,
id: 'legacy-room',
name: '旧版基础场景',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
spawnPoints: [{ id: 'start', name: '入口', position: [0, 0, 0], yawDeg: 0 }],
});
const project = zipSync({
'model.xml': Buffer.from(SIMPLE_MODEL),
'maps/legacy/map.json': Buffer.from(mapJson),
'maps/legacy/physics/world.xml': Buffer.from(
'<mujoco><worldbody><geom name="floor" type="box" pos="0 0 -.1" size="5 5 .1"/><body name="wall" pos="2 0 .5"><geom type="box" size=".1 1 .5"/></body></worldbody></mujoco>',
),
});
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'legacy-map.zip',
mimeType: 'application/zip',
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' });
await page.getByRole('button', { name: '应用并重新编译' }).click();
const editor = page.getByText('V3 地图编辑器').locator('..');
await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 });
await editor.getByRole('button', { name: '创建可编辑副本' }).click();
await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 });
await expect(editor.getByRole('button', { name: '移动工具 W' })).toBeVisible();
await expect(editor.getByRole('button', { name: /floor · 方盒/ })).toBeVisible();
await expect(editor.getByRole('button', { name: /wall · 方盒/ })).toBeVisible();
});
test('编辑 V3 地图对象并事务式应用', async ({ page }) => {
const authoring = JSON.stringify({
schemaVersion: 1,
mapId: 'editable-room',
revision: 0,
objects: [],
spawnPoints: [],
});
const mapJson = JSON.stringify({
schemaVersion: 2,
id: 'editable-room',
name: '可编辑场景',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
authoring: { source: 'authoring/map.scene.json' },
spawnPoints: [],
});
const project = zipSync({
'model.xml': Buffer.from(SIMPLE_MODEL),
'maps/edit/map.json': Buffer.from(mapJson),
'maps/edit/physics/world.xml': Buffer.from('<mujoco><worldbody/></mujoco>'),
'maps/edit/authoring/map.scene.json': Buffer.from(authoring),
});
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'editable-map.zip',
mimeType: 'application/zip',
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' });
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByText('V3 地图编辑器')).toBeVisible({ timeout: 30_000 });
const editor = page.getByText('V3 地图编辑器').locator('..');
await expect(editor.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
'aria-pressed',
'true',
);
await page.keyboard.press('e');
await expect(editor.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute(
'aria-pressed',
'true',
);
await page.keyboard.press('s');
await expect(editor.getByRole('button', { name: '缩放工具 S' })).toHaveAttribute(
'aria-pressed',
'true',
);
await editor.getByRole('button', { name: '新增', exact: true }).click();
await editor.getByLabel('对象位置X').fill('2');
await editor.getByRole('button', { name: '应用并重新编译' }).click();
await expect(editor.getByRole('button', { name: /box · 方盒/ })).toBeVisible({ timeout: 30_000 });
await expect(editor.getByText('地图草稿尚未应用')).toHaveCount(0);
await expect(page.getByText('WASM 已加载')).toBeVisible();
});
test('工程地图编译失败时保留上一仿真会话', async ({ page }) => {
const mapJson = JSON.stringify({
schemaVersion: 1,
id: 'invalid-map',
name: '动态错误地图',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'world.xml' },
spawnPoints: [],
});
const project = zipSync({
'model.xml': Buffer.from(SIMPLE_MODEL),
'maps/invalid/map.json': Buffer.from(mapJson),
'maps/invalid/world.xml': Buffer.from(
'<mujoco><worldbody><body><joint/><geom type="box" size="1 1 1"/></body></worldbody></mujoco>',
),
});
await page.goto('/');
await page.setViewportSize({ width: 1440, height: 900 });
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'invalid-map.zip',
mimeType: 'application/zip',
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '地图' }).click();
await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' });
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
await expect(page.getByLabel('地图来源')).toHaveValue('none');
await page.getByRole('button', { name: '关闭错误' }).click();
await page.getByRole('button', { name: '▶ 播放' }).click();
await page.waitForTimeout(300);
await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
});
test('无效模型显示中文诊断且保留工程树', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles(fixture('invalid.xml')); await page.locator('input[type="file"]').first().setInputFiles(fixture('invalid.xml'));
await expect(page.getByRole('alert')).toContainText('模型编译失败', {timeout: 30_000}); await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
await expect(page.getByText('invalid.xml', {exact: false}).first()).toBeVisible(); await expect(page.getByText('invalid.xml', { exact: false }).first()).toBeVisible();
}); });
+72 -1
View File
@@ -1 +1,72 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#101827"/><link rel="icon" href="data:,"/><title>MuJoCo Web 仿真平台</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html> <!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="theme-color" content="#09111e" />
<meta
name="description"
content="基于 MuJoCo WebAssembly 的本地机器人仿真、控制与地图编辑工作台"
/>
<link rel="icon" href="data:," />
<title>MuJoCo Web 仿真平台</title>
<style>
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
background: #09111e;
}
.boot-screen {
display: grid;
height: 100%;
place-items: center;
color: #f1f5f9;
font:
13px Inter,
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>
</body>
</html>
+11 -2
View File
@@ -1,2 +1,11 @@
import {defineConfig} from '@playwright/test'; import { defineConfig } from '@playwright/test';
export default defineConfig({testDir:'./e2e', timeout:120_000, use:{baseURL:'http://127.0.0.1:4173',channel:'chrome'}, webServer:{command:'npm run preview --prefix .. -- --host 127.0.0.1',url:'http://127.0.0.1:4173',reuseExistingServer:true}}); export default defineConfig({
testDir: './e2e',
timeout: 120_000,
use: { baseURL: 'http://127.0.0.1:4173' },
webServer: {
command: 'npm run preview --prefix .. -- --host 127.0.0.1',
url: 'http://127.0.0.1:4173',
reuseExistingServer: true,
},
});
+3 -1
View File
@@ -1 +1,3 @@
module.exports = {plugins: {tailwindcss: {config: './web_platform/tailwind.config.cjs'}, autoprefixer: {}}}; module.exports = {
plugins: { tailwindcss: { config: './web_platform/tailwind.config.cjs' }, autoprefixer: {} },
};
+1893 -115
View File
File diff suppressed because it is too large Load Diff
+28 -3
View File
@@ -1,3 +1,28 @@
import {Component,type ErrorInfo,type ReactNode} from 'react'; import { Component, type ErrorInfo, type ReactNode } from 'react';
import {Button} from '../components/ui'; import { Button } from '../components/ui';
export class ErrorBoundary extends Component<{children:ReactNode},{error?:Error}>{state:{error?:Error}={};static getDerivedStateFromError(error:Error){return {error};}componentDidCatch(error:Error,info:ErrorInfo){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;}} export class ErrorBoundary extends Component<{ children: ReactNode }, { error?: Error }> {
state: { error?: Error } = {};
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
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
);
}
}
@@ -1,39 +1,96 @@
import {fireEvent,render,screen} from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import {ActuatorControl} from './SidebarPanel'; import { ActuatorControl } from './SidebarPanel';
import type {ActuatorInfo} from '../../simulation/SimulationSession'; import type { ActuatorInfo } from '../../simulation/SimulationSession';
const actuator:ActuatorInfo={id:0,name:'shoulder_motor',value:.5,min:-1,max:1,limited:true,jointId:0,jointName:'shoulder',jointType:3,unit:'N·m',kind:'motor',controlCount:1,gear:2,gain:1,kp:0,kv:0,ctrlLimited:true,ctrlMin:-1,ctrlMax:1,forceLimited:true,forceMin:-20,forceMax:20}; const actuator: ActuatorInfo = {
id: 0,
name: 'shoulder_motor',
value: 0.5,
min: -1,
max: 1,
limited: true,
jointId: 0,
jointName: 'shoulder',
jointType: 3,
unit: 'N·m',
kind: 'motor',
controlCount: 1,
gear: 2,
gain: 1,
kp: 0,
kv: 0,
ctrlLimited: true,
ctrlMin: -1,
ctrlMax: 1,
forceLimited: true,
forceMin: -20,
forceMax: 20,
};
describe('ActuatorControl',()=>{ describe('ActuatorControl', () => {
it('显示对应关节和常用力矩单位',()=>{ it('显示对应关节和常用力矩单位', () => {
render(<ActuatorControl actuator={actuator} onControl={()=>{}} onParameters={()=>{}}/>); render(<ActuatorControl actuator={actuator} onControl={() => {}} onParameters={() => {}} />);
expect(screen.getByText('shoulder_motor')).toBeVisible(); expect(screen.getByText('shoulder_motor')).toBeVisible();
expect(screen.getByText('关节:shoulder')).toBeVisible(); expect(screen.getByText('关节:shoulder')).toBeVisible();
expect(screen.getByText('1.000 N·m')).toBeVisible(); expect(screen.getByText('1.000 N·m')).toBeVisible();
}); });
it('内部按 gear 换算输出,但参数面板只开放 kp、kv 等业务参数',()=>{ it('内部按 gear 换算输出,但参数面板只开放 kp、kv 等业务参数', () => {
const onControl=vi.fn(),onParameters=vi.fn(); const onControl = vi.fn(),
render(<ActuatorControl actuator={actuator} onControl={onControl} onParameters={onParameters}/>); onParameters = vi.fn();
fireEvent.change(screen.getByRole('slider'),{target:{value:'2'}}); render(
<ActuatorControl actuator={actuator} onControl={onControl} onParameters={onParameters} />,
);
fireEvent.change(screen.getByRole('slider'), { target: { value: '2' } });
expect(onControl).toHaveBeenCalledWith(1); expect(onControl).toHaveBeenCalledWith(1);
fireEvent.click(screen.getByText('常用参数')); fireEvent.click(screen.getByText('常用参数'));
expect(screen.queryByLabelText('传动比 gear')).not.toBeInTheDocument(); expect(screen.queryByLabelText('传动比 gear')).not.toBeInTheDocument();
expect(screen.queryByLabelText('固定增益 gain')).not.toBeInTheDocument(); expect(screen.queryByLabelText('固定增益 gain')).not.toBeInTheDocument();
const kp=screen.getByLabelText(/kp(MJCF stiffness/);fireEvent.change(kp,{target:{value:'3'}});fireEvent.blur(kp); const kp = screen.getByLabelText(/kp(MJCF stiffness/);
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({kp:3,ctrlLimited:true,forceLimited:true})); fireEvent.change(kp, { target: { value: '3' } });
fireEvent.blur(kp);
expect(onParameters).toHaveBeenCalledWith(
expect.objectContaining({ kp: 3, ctrlLimited: true, forceLimited: true }),
);
}); });
it('position 伺服使用角度目标并开放 kp、kv',()=>{ it('position 伺服使用角度目标并开放 kp、kv', () => {
const onParameters=vi.fn(); const onParameters = vi.fn();
render(<ActuatorControl actuator={{...actuator,name:'shoulder_servo',kind:'position',unit:'°',value:Math.PI/2,min:-Math.PI,max:Math.PI,gear:1,kp:100,kv:10,gain:100}} onControl={()=>{}} onParameters={onParameters}/>); render(
expect(screen.getByText('90.000 °')).toBeVisible();fireEvent.click(screen.getByText('常用参数')); <ActuatorControl
const kp=screen.getByLabelText(/位置增益 kp/);fireEvent.change(kp,{target:{value:'150'}});fireEvent.blur(kp); actuator={{
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({kp:150,kv:10})); ...actuator,
name: 'shoulder_servo',
kind: 'position',
unit: '°',
value: Math.PI / 2,
min: -Math.PI,
max: Math.PI,
gear: 1,
kp: 100,
kv: 10,
gain: 100,
}}
onControl={() => {}}
onParameters={onParameters}
/>,
);
expect(screen.getByText('90.000 °')).toBeVisible();
fireEvent.click(screen.getByText('常用参数'));
const kp = screen.getByLabelText(/位置增益 kp/);
fireEvent.change(kp, { target: { value: '150' } });
fireEvent.blur(kp);
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({ kp: 150, kv: 10 }));
}); });
it('非 motor 驱动器保持原始控制单位且不开放通用参数编辑',()=>{ it('非 motor 驱动器保持原始控制单位且不开放通用参数编辑', () => {
render(<ActuatorControl actuator={{...actuator,name:'custom',kind:'other',unit:'',value:.25}} onControl={()=>{}} onParameters={()=>{}}/>); render(
<ActuatorControl
actuator={{ ...actuator, name: 'custom', kind: 'other', unit: '', value: 0.25 }}
onControl={() => {}}
onParameters={() => {}}
/>,
);
expect(screen.getByText('0.250')).toBeVisible(); expect(screen.getByText('0.250')).toBeVisible();
expect(screen.queryByText('常用参数')).not.toBeInTheDocument(); expect(screen.queryByText('常用参数')).not.toBeInTheDocument();
expect(screen.getByText(/不是可直接编辑的 motor\/position/)).toBeVisible(); expect(screen.getByText(/不是可直接编辑的 motor\/position/)).toBeVisible();
@@ -1,12 +1,120 @@
import {useEffect,useId,useMemo,useRef,useState,type ReactNode} from 'react'; import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
import {Search} from 'lucide-react'; import { Search } from 'lucide-react';
import {Dialog,EmptySearchState,Kbd} from '../../components/ui'; import { Dialog, EmptySearchState, Kbd } from '../../components/ui';
export interface WorkbenchCommand{id:string;label:string;group:string;icon?:ReactNode;shortcut?:string;disabled?:boolean;run:()=>void;} export interface WorkbenchCommand {
export function CommandPalette({open,onClose,commands}:{open:boolean;onClose:()=>void;commands:WorkbenchCommand[]}){ id: string;
const [query,setQuery]=useState(''),[active,setActive]=useState(0),input=useRef<HTMLInputElement>(null),listId=useId(); label: string;
const filtered=useMemo(()=>{const needle=query.trim().toLocaleLowerCase();return commands.filter(command=>!needle||`${command.label} ${command.group}`.toLocaleLowerCase().includes(needle));},[commands,query]); group: string;
const enabled=filtered.flatMap((command,index)=>command.disabled?[]:[index]),highlighted=filtered[active]&&!filtered[active].disabled?active:(enabled[0]??-1); icon?: ReactNode;
useEffect(()=>{if(open)requestAnimationFrame(()=>input.current?.focus());},[open]); shortcut?: string;
const close=()=>{setQuery('');setActive(0);onClose();},execute=(command?:WorkbenchCommand)=>{if(!command||command.disabled)return;command.run();close();}; disabled?: boolean;
return <Dialog open={open} onClose={close} title="命令面板" className="max-w-xl"><div className="relative -m-4 mb-2 border-b border-border"><Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary"/><input ref={input} role="combobox" aria-label="搜索命令" aria-autocomplete="list" aria-expanded="true" aria-controls={listId} aria-activedescendant={highlighted>=0?`${listId}-${filtered[highlighted].id}`:undefined} value={query} onChange={event=>{setQuery(event.target.value);setActive(0);}} onKeyDown={event=>{if(!enabled.length)return;const current=Math.max(0,enabled.indexOf(highlighted));if(event.key==='ArrowDown'){event.preventDefault();setActive(enabled[(current+1)%enabled.length]);}else if(event.key==='ArrowUp'){event.preventDefault();setActive(enabled[(current-1+enabled.length)%enabled.length]);}else if(event.key==='Enter'){event.preventDefault();execute(filtered[highlighted]);}}} placeholder="输入命令名称…" className="h-11 w-full bg-input pl-11 pr-4 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"/></div><div id={listId} role="listbox" aria-label="可用命令" className="max-h-80 space-y-1 overflow-auto pt-1">{filtered.length?filtered.map((command,index)=><button key={command.id} id={`${listId}-${command.id}`} type="button" role="option" aria-selected={index===highlighted} disabled={command.disabled} onMouseEnter={()=>{if(!command.disabled)setActive(index);}} onClick={()=>execute(command)} className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs outline-none ${index===highlighted?'bg-accent-soft text-accent':'text-text-secondary hover:bg-element-hover'} disabled:opacity-40`}><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>{command.shortcut&&<Kbd>{command.shortcut}</Kbd>}</button>):<EmptySearchState label="没有匹配的命令"/>}</div></Dialog>; run: () => void;
}
export function CommandPalette({
open,
onClose,
commands,
}: {
open: boolean;
onClose: () => void;
commands: WorkbenchCommand[];
}) {
const [query, setQuery] = useState(''),
[active, setActive] = useState(0),
input = useRef<HTMLInputElement>(null),
listId = useId();
const filtered = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
return commands.filter(
(command) =>
!needle || `${command.label} ${command.group}`.toLocaleLowerCase().includes(needle),
);
}, [commands, query]);
const enabled = filtered.flatMap((command, index) => (command.disabled ? [] : [index])),
highlighted = filtered[active] && !filtered[active].disabled ? active : (enabled[0] ?? -1);
useEffect(() => {
if (open) requestAnimationFrame(() => input.current?.focus());
}, [open]);
const close = () => {
setQuery('');
setActive(0);
onClose();
},
execute = (command?: WorkbenchCommand) => {
if (!command || command.disabled) return;
command.run();
close();
};
return (
<Dialog open={open} onClose={close} title="命令面板" className="max-w-xl">
<div className="relative -m-4 mb-2 border-b border-border">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary" />
<input
ref={input}
role="combobox"
aria-label="搜索命令"
aria-autocomplete="list"
aria-expanded="true"
aria-controls={listId}
aria-activedescendant={
highlighted >= 0 ? `${listId}-${filtered[highlighted].id}` : undefined
}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActive(0);
}}
onKeyDown={(event) => {
if (!enabled.length) return;
const current = Math.max(0, enabled.indexOf(highlighted));
if (event.key === 'ArrowDown') {
event.preventDefault();
setActive(enabled[(current + 1) % enabled.length]);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setActive(enabled[(current - 1 + enabled.length) % enabled.length]);
} else if (event.key === 'Enter') {
event.preventDefault();
execute(filtered[highlighted]);
}
}}
placeholder="输入命令名称…"
className="h-11 w-full bg-input pl-11 pr-4 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
/>
</div>
<div
id={listId}
role="listbox"
aria-label="可用命令"
className="max-h-80 space-y-1 overflow-auto pt-1"
>
{filtered.length ? (
filtered.map((command, index) => (
<button
key={command.id}
id={`${listId}-${command.id}`}
type="button"
role="option"
aria-selected={index === highlighted}
disabled={command.disabled}
onMouseEnter={() => {
if (!command.disabled) setActive(index);
}}
onClick={() => execute(command)}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs outline-none ${index === highlighted ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'} disabled:opacity-40`}
>
<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>
{command.shortcut && <Kbd>{command.shortcut}</Kbd>}
</button>
))
) : (
<EmptySearchState label="没有匹配的命令" />
)}
</div>
</Dialog>
);
} }
@@ -0,0 +1,74 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { DataRecordingPanel } from './DataRecordingPanel';
import type { DataRecorderStatus } from '../../simulation/DataRecorder';
const status: DataRecorderStatus = {
recording: false,
limitReached: false,
sampleCount: 2,
segmentCount: 1,
config: { bodyId: 1, sampleRateHz: 50, maxSamples: 30_000 },
body: { id: 1, name: 'base' },
latest: {
sequence: 1,
segment: 0,
simulationTime: 0.02,
values: {
speed_horizontal: 1.25,
speed_3d: 1.3,
velocity_x: 1.25,
velocity_y: 0,
velocity_z: 0.1,
pitch: 0.1,
roll: -0.05,
yaw: 0.2,
height: 0.45,
position_x: 0.1,
position_y: 0,
contact_count: 4,
control_rms: 2,
actuator_force_rms: 3,
actuator_power_abs: 8,
},
},
summary: {
duration: 0.02,
distanceHorizontal: 0.1,
maxHorizontalSpeed: 1.25,
maxAbsRoll: 0.05,
maxAbsPitch: 0.1,
minHeight: 0.44,
maxHeight: 0.46,
},
};
describe('DataRecordingPanel', () => {
it('展示实时遥测并提供配置、记录和导出操作', () => {
const configure = vi.fn(),
start = vi.fn(),
exportData = vi.fn();
render(
<DataRecordingPanel
status={status}
bodies={[
{ id: 0, name: 'world', parentId: 0 },
{ id: 1, name: 'base', parentId: 0 },
{ id: 2, name: 'payload', parentId: 1 },
]}
onConfigure={configure}
onStart={start}
onStop={vi.fn()}
onClear={vi.fn()}
onExport={exportData}
/>,
);
expect(screen.getAllByText('1.250 m/s').length).toBeGreaterThan(0);
expect(screen.getAllByText('5.73°').length).toBeGreaterThan(0);
fireEvent.change(screen.getByLabelText('记录 Body'), { target: { value: '2' } });
expect(configure).toHaveBeenCalledWith({ bodyId: 2 });
fireEvent.click(screen.getByRole('button', { name: '开始记录' }));
expect(start).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '导出 CSV' }));
expect(exportData).toHaveBeenCalledWith('csv');
});
});
@@ -0,0 +1,198 @@
import { Circle, Download, Square, Trash2 } from 'lucide-react';
import type { BodyInfo } from '../../simulation/SimulationSession';
import type { DataRecorderConfig, DataRecorderStatus } from '../../simulation/DataRecorder';
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
const SAMPLE_RATES = [10, 20, 50, 100, 200];
const radiansToDegrees = 180 / Math.PI;
function number(value: number | undefined, digits = 3): string {
return Number.isFinite(value) ? value!.toFixed(digits) : '—';
}
function degrees(value: number | undefined): string {
return Number.isFinite(value) ? `${(value! * radiansToDegrees).toFixed(2)}°` : '—';
}
export function DataRecordingPanel({
status,
bodies,
onConfigure,
onStart,
onStop,
onClear,
onExport,
}: {
status: DataRecorderStatus;
bodies: BodyInfo[];
onConfigure: (patch: Partial<DataRecorderConfig>) => void;
onStart: () => void;
onStop: () => void;
onClear: () => void;
onExport: (format: 'csv' | 'json') => void;
}) {
const values = status.latest?.values,
availableBodies = bodies.filter(
(body) => body.id > 0 && !body.name.startsWith('__platform_map_'),
);
return (
<div className="space-y-3 p-3">
<section className="rounded-lg border border-border bg-surface p-3">
<div className="mb-3 flex items-center justify-between gap-2">
<div>
<h3 className="text-xs font-semibold text-text-primary">仿真遥测记录</h3>
<p className="mt-0.5 text-[10px] text-text-tertiary">
数据仅保存在当前浏览器会话,可导出 CSV 或 JSON。
</p>
</div>
<Badge tone={status.recording ? 'success' : status.limitReached ? 'warning' : 'neutral'}>
{status.recording ? '记录中' : status.limitReached ? '已达上限' : '已停止'}
</Badge>
</div>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">记录 Body</span>
<Select
aria-label="记录 Body"
className="w-full"
value={status.config.bodyId}
disabled={status.recording}
onChange={(event) => onConfigure({ bodyId: Number(event.target.value) })}
>
{(availableBodies.length ? availableBodies : [status.body]).map((body) => (
<option key={body.id} value={body.id}>
{body.name}
</option>
))}
</Select>
</label>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">采样频率</span>
<Select
aria-label="采样频率"
className="w-full"
value={status.config.sampleRateHz}
disabled={status.recording}
onChange={(event) => onConfigure({ sampleRateHz: Number(event.target.value) })}
>
{SAMPLE_RATES.map((rate) => (
<option key={rate} value={rate}>
{rate} Hz
</option>
))}
</Select>
</label>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">样本上限</span>
<input
aria-label="样本上限"
type="number"
min={100}
max={1_000_000}
step={1000}
className="field h-8 w-full px-2 text-xs"
value={status.config.maxSamples}
disabled={status.recording}
onChange={(event) => {
const value = Number(event.target.value);
if (Number.isFinite(value) && value >= 100) onConfigure({ maxSamples: value });
}}
/>
</label>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{status.recording ? (
<Button
variant="primary"
icon={<Square className="h-3 w-3 fill-current" />}
onClick={onStop}
>
停止记录
</Button>
) : (
<Button
variant="primary"
icon={<Circle className="h-3 w-3 fill-current" />}
onClick={onStart}
disabled={status.limitReached}
>
开始记录
</Button>
)}
<Button
variant="danger"
icon={<Trash2 className="h-3.5 w-3.5" />}
onClick={onClear}
disabled={!status.sampleCount}
>
清空
</Button>
</div>
<div className="mt-3 border-t border-border pt-2">
<PropertyRow label="样本" value={`${status.sampleCount} / ${status.config.maxSamples}`} />
<PropertyRow label="有效时长" value={`${number(status.summary.duration, 2)} s`} />
<PropertyRow label="分段" value={status.segmentCount} />
</div>
</section>
<section className="rounded-lg border border-border bg-surface p-3">
<h3 className="mb-2 text-xs font-semibold text-text-primary">实时运动状态</h3>
<PropertyRow label="水平速度" value={`${number(values?.speed_horizontal)} m/s`} />
<PropertyRow label="三维速度" value={`${number(values?.speed_3d)} m/s`} />
<PropertyRow
label="世界系速度 XYZ"
value={`${number(values?.velocity_x)}, ${number(values?.velocity_y)}, ${number(values?.velocity_z)} m/s`}
/>
<PropertyRow label="俯仰角" value={degrees(values?.pitch)} />
<PropertyRow label="侧倾角" value={degrees(values?.roll)} />
<PropertyRow label="偏航角" value={degrees(values?.yaw)} />
<PropertyRow label="机身高度" value={`${number(values?.height)} m`} />
<PropertyRow
label="世界系位置 XY"
value={`${number(values?.position_x)}, ${number(values?.position_y)} m`}
/>
<PropertyRow label="场景接触数" value={number(values?.contact_count, 0)} />
<PropertyRow label="控制输入 RMS" value={number(values?.control_rms)} />
<PropertyRow label="驱动力 RMS" value={number(values?.actuator_force_rms)} />
<PropertyRow label="绝对驱动功率" value={`${number(values?.actuator_power_abs)} W`} />
</section>
<section className="rounded-lg border border-border bg-surface p-3">
<h3 className="mb-2 text-xs font-semibold text-text-primary">本次记录摘要</h3>
<PropertyRow
label="累计水平里程"
value={`${number(status.summary.distanceHorizontal)} m`}
/>
<PropertyRow
label="最大水平速度"
value={`${number(status.summary.maxHorizontalSpeed)} m/s`}
/>
<PropertyRow label="最大俯仰角" value={degrees(status.summary.maxAbsPitch)} />
<PropertyRow label="最大侧倾角" value={degrees(status.summary.maxAbsRoll)} />
<PropertyRow
label="高度范围"
value={`${number(status.summary.minHeight)} – ${number(status.summary.maxHeight)} m`}
/>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
icon={<Download className="h-3.5 w-3.5" />}
disabled={!status.sampleCount}
onClick={() => onExport('csv')}
>
导出 CSV
</Button>
<Button
icon={<Download className="h-3.5 w-3.5" />}
disabled={!status.sampleCount}
onClick={() => onExport('json')}
>
导出 JSON
</Button>
</div>
</section>
<p className="text-[10px] leading-4 text-text-tertiary">
每次仿真重置会创建新分段,避免跨重置计算出错误速度。开发接口支持注册额外标量通道,导出列名保持稳定。
</p>
</div>
);
}
@@ -1,5 +1,50 @@
import {useState} from 'react'; import { useState } from 'react';
import {ChevronDown,TriangleAlert,X} from 'lucide-react'; import { ChevronDown, TriangleAlert, X } from 'lucide-react';
import type {AppDiagnostic} from '../../stores/useAppStore'; import type { AppDiagnostic } from '../../stores/useAppStore';
import {IconButton} from '../../components/ui'; import { IconButton } from '../../components/ui';
export function DiagnosticNotice({value,onClose}:{value:AppDiagnostic;onClose:()=>void}){const [expanded,setExpanded]=useState(false);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"><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"><TriangleAlert className="h-4 w-4"/></span><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}>路径:{value.path}</p>}<button type="button" aria-expanded={expanded} className="mt-1 flex items-center gap-1 text-xs text-danger hover:underline" onClick={()=>setExpanded(v=>!v)}>技术详情<ChevronDown className={`h-3 w-3 ${expanded?'rotate-180':''}`}/></button></div><IconButton aria-label="关闭错误" tooltip="关闭" onClick={onClose}><X className="h-4 w-4"/></IconButton></div>{expanded&&<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">{value.detail}</pre>}</section>;} export function DiagnosticNotice({
value,
onClose,
}: {
value: AppDiagnostic;
onClose: () => void;
}) {
const [expanded, setExpanded] = useState(false);
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"
>
<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">
<TriangleAlert className="h-4 w-4" />
</span>
<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}>
路径:{value.path}
</p>
)}
<button
type="button"
aria-expanded={expanded}
className="mt-1 flex items-center gap-1 text-xs text-danger hover:underline"
onClick={() => setExpanded((v) => !v)}
>
技术详情
<ChevronDown className={`h-3 w-3 ${expanded ? 'rotate-180' : ''}`} />
</button>
</div>
<IconButton aria-label="关闭错误" tooltip="关闭" onClick={onClose}>
<X className="h-4 w-4" />
</IconButton>
</div>
{expanded && (
<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">
{value.detail}
</pre>
)}
</section>
);
}
@@ -1,6 +1,97 @@
import {useState} from 'react'; import { useState } from 'react';
import {CheckCircle2,Info,TriangleAlert,XCircle} from 'lucide-react'; import { CheckCircle2, Info, TriangleAlert, XCircle } from 'lucide-react';
import {Button,CopyButton,Dialog,Tabs} from '../../components/ui'; import { Button, CopyButton, Dialog, Tabs } from '../../components/ui';
import type {WorkbenchNotification} from './NotificationCenter'; import type { WorkbenchNotification } from './NotificationCenter';
type Filter='all'|'warning'|'danger'; type Filter = 'all' | 'warning' | 'danger';
export function DiagnosticsDrawer({open,items,onClose,onClear}:{open:boolean;items:WorkbenchNotification[];onClose:()=>void;onClear:()=>void}){const [filter,setFilter]=useState<Filter>('all');const content=(value:Filter)=>{const filtered=items.filter(item=>value==='all'||item.tone===value);return <div className="space-y-2">{filtered.length?filtered.map(item=>{const Icon=item.tone==='danger'?XCircle:item.tone==='warning'?TriangleAlert:item.tone==='success'?CheckCircle2:Info;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>}</div>{item.detail&&<CopyButton value={`${item.title}\n${item.detail}`} label="复制事件详情"/>}</div></article>}):<p className="p-8 text-center text-xs text-text-tertiary">没有符合条件的事件</p>}</div>;};return <Dialog open={open} onClose={onClose} title="诊断与事件日志" className="max-w-2xl" footer={<div className="flex justify-end"><Button variant="danger" disabled={!items.length} onClick={onClear}>清空事件</Button></div>}><Tabs label="事件筛选" value={filter} onValueChange={setFilter} keepMounted={false} items={[{value:'all',label:`全部 ${items.length}`,content:content('all')},{value:'warning',label:`警告 ${items.filter(item=>item.tone==='warning').length}`,content:content('warning')},{value:'danger',label:`错误 ${items.filter(item=>item.tone==='danger').length}`,content:content('danger')}]}/></Dialog>;} export function DiagnosticsDrawer({
open,
items,
onClose,
onClear,
}: {
open: boolean;
items: WorkbenchNotification[];
onClose: () => void;
onClear: () => void;
}) {
const [filter, setFilter] = useState<Filter>('all');
const content = (value: Filter) => {
const filtered = items.filter((item) => value === 'all' || item.tone === value);
return (
<div className="space-y-2">
{filtered.length ? (
filtered.map((item) => {
const Icon =
item.tone === 'danger'
? XCircle
: item.tone === 'warning'
? TriangleAlert
: item.tone === 'success'
? CheckCircle2
: Info;
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>
)}
</div>
{item.detail && (
<CopyButton value={`${item.title}\n${item.detail}`} label="复制事件详情" />
)}
</div>
</article>
);
})
) : (
<p className="p-8 text-center text-xs text-text-tertiary">没有符合条件的事件</p>
)}
</div>
);
};
return (
<Dialog
open={open}
onClose={onClose}
title="诊断与事件日志"
className="max-w-2xl"
footer={
<div className="flex justify-end">
<Button variant="danger" disabled={!items.length} onClick={onClear}>
清空事件
</Button>
</div>
}
>
<Tabs
label="事件筛选"
value={filter}
onValueChange={setFilter}
keepMounted={false}
items={[
{ value: 'all', label: `全部 ${items.length}`, content: content('all') },
{
value: 'warning',
label: `警告 ${items.filter((item) => item.tone === 'warning').length}`,
content: content('warning'),
},
{
value: 'danger',
label: `错误 ${items.filter((item) => item.tone === 'danger').length}`,
content: content('danger'),
},
]}
/>
</Dialog>
);
}
@@ -1,4 +1,18 @@
import {render,screen} from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import {EntrySelectionDialog} from './EntrySelectionDialog'; import { EntrySelectionDialog } from './EntrySelectionDialog';
describe('EntrySelectionDialog',()=>{it('父组件重渲染时不抢走入口按钮焦点,且不暴露无效关闭动作',()=>{const entries=[{path:'a.xml',label:'模型 A'},{path:'b.xml',label:'模型 B'}],select=vi.fn();const {rerender}=render(<EntrySelectionDialog entries={entries} onSelect={select}/>);const entry=screen.getByRole('button',{name:'模型 A'});entry.focus();rerender(<EntrySelectionDialog entries={[...entries]} onSelect={select}/>);expect(entry).toHaveFocus();expect(screen.queryByRole('button',{name:'关闭'})).not.toBeInTheDocument();});}); describe('EntrySelectionDialog', () => {
it('父组件重渲染时不抢走入口按钮焦点,且不暴露无效关闭动作', () => {
const entries = [
{ path: 'a.xml', label: '模型 A' },
{ path: 'b.xml', label: '模型 B' },
],
select = vi.fn();
const { rerender } = render(<EntrySelectionDialog entries={entries} onSelect={select} />);
const entry = screen.getByRole('button', { name: '模型 A' });
entry.focus();
rerender(<EntrySelectionDialog entries={[...entries]} onSelect={select} />);
expect(entry).toHaveFocus();
expect(screen.queryByRole('button', { name: '关闭' })).not.toBeInTheDocument();
});
});
@@ -1,4 +1,28 @@
import {FileCode2} from 'lucide-react'; import { FileCode2 } from 'lucide-react';
import {Button,Dialog} from '../../components/ui'; import { Button, Dialog } from '../../components/ui';
const noop=()=>{}; const noop = () => {};
export function EntrySelectionDialog({entries,onSelect}:{entries:{path:string;label:string}[];onSelect:(path:string)=>void}){return <Dialog open={entries.length>0} onClose={noop} closable={false} title="选择模型入口"><p className="mb-4 text-sm text-text-secondary">工程包含多个可加载模型,请选择一个。</p><div className="space-y-2">{entries.map(entry=><Button key={entry.path} className="w-full justify-start overflow-hidden" onClick={()=>onSelect(entry.path)} icon={<FileCode2 className="h-4 w-4"/>}><span className="truncate">{entry.label}</span></Button>)}</div></Dialog>;} export function EntrySelectionDialog({
entries,
onSelect,
}: {
entries: { path: string; label: string }[];
onSelect: (path: string) => void;
}) {
return (
<Dialog open={entries.length > 0} onClose={noop} closable={false} title="选择模型入口">
<p className="mb-4 text-sm text-text-secondary">工程包含多个可加载模型,请选择一个。</p>
<div className="space-y-2">
{entries.map((entry) => (
<Button
key={entry.path}
className="w-full justify-start overflow-hidden"
onClick={() => onSelect(entry.path)}
icon={<FileCode2 className="h-4 w-4" />}
>
<span className="truncate">{entry.label}</span>
</Button>
))}
</div>
</Dialog>
);
}
@@ -1,5 +1,68 @@
import {useState} from 'react'; import { useState } from 'react';
import {ChevronDown,FolderTree,RefreshCw,TriangleAlert,X} from 'lucide-react'; import { ChevronDown, FolderTree, RefreshCw, TriangleAlert, X } from 'lucide-react';
import type {AppDiagnostic} from '../../stores/useAppStore'; import type { AppDiagnostic } from '../../stores/useAppStore';
import {Button,CopyButton,IconButton} from '../../components/ui'; import { Button, CopyButton, IconButton } from '../../components/ui';
export function ErrorRecoveryPanel({value,onClose,onRetry,onOpenProject}:{value:AppDiagnostic;onClose:()=>void;onRetry?:()=>void;onOpenProject:()=>void}){const [expanded,setExpanded]=useState(false);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"><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"><TriangleAlert className="h-4 w-4"/></span><div className="min-w-0 flex-1"><h2 className="text-sm font-semibold">{value.summary}</h2>{value.path&&<p className="truncate text-xs text-text-tertiary">路径:{value.path}</p>}<div className="mt-2 flex flex-wrap gap-2">{onRetry&&<Button variant="danger" onClick={onRetry} icon={<RefreshCw className="h-3.5 w-3.5"/>}>重试当前入口</Button>}<Button onClick={onOpenProject} icon={<FolderTree className="h-3.5 w-3.5"/>}>返回工程树</Button><CopyButton value={`${value.summary}\n${value.path??''}\n${value.detail}`} label="复制错误详情"/></div><button type="button" aria-expanded={expanded} className="mt-2 flex items-center gap-1 text-xs text-danger" onClick={()=>setExpanded(v=>!v)}>技术详情<ChevronDown className={`h-3 w-3 ${expanded?'rotate-180':''}`}/></button></div><IconButton aria-label="关闭错误" tooltip="关闭" onClick={onClose}><X className="h-4 w-4"/></IconButton></div>{expanded&&<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">{value.detail}</pre>}</section>;} export function ErrorRecoveryPanel({
value,
onClose,
onRetry,
onOpenProject,
}: {
value: AppDiagnostic;
onClose: () => void;
onRetry?: () => void;
onOpenProject: () => void;
}) {
const [expanded, setExpanded] = useState(false);
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"
>
<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">
<TriangleAlert className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold">{value.summary}</h2>
{value.path && <p className="truncate text-xs text-text-tertiary">路径:{value.path}</p>}
<div className="mt-2 flex flex-wrap gap-2">
{onRetry && (
<Button
variant="danger"
onClick={onRetry}
icon={<RefreshCw className="h-3.5 w-3.5" />}
>
重试当前入口
</Button>
)}
<Button onClick={onOpenProject} icon={<FolderTree className="h-3.5 w-3.5" />}>
返回工程树
</Button>
<CopyButton
value={`${value.summary}\n${value.path ?? ''}\n${value.detail}`}
label="复制错误详情"
/>
</div>
<button
type="button"
aria-expanded={expanded}
className="mt-2 flex items-center gap-1 text-xs text-danger"
onClick={() => setExpanded((v) => !v)}
>
技术详情
<ChevronDown className={`h-3 w-3 ${expanded ? 'rotate-180' : ''}`} />
</button>
</div>
<IconButton aria-label="关闭错误" tooltip="关闭" onClick={onClose}>
<X className="h-4 w-4" />
</IconButton>
</div>
{expanded && (
<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">
{value.detail}
</pre>
)}
</section>
);
}
@@ -1,5 +1,57 @@
import {fireEvent,render,screen} from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import {DiagnosticNotice} from './DiagnosticNotice'; import { DiagnosticNotice } from './DiagnosticNotice';
import {WorkspaceOverlays} from './WorkspaceOverlays'; import { WorkspaceOverlays } from './WorkspaceOverlays';
import {StatusBar} from './StatusBar'; import { StatusBar } from './StatusBar';
describe('工作台反馈组件',()=>{it('诊断详情可展开并关闭',()=>{const close=vi.fn();render(<DiagnosticNotice value={{category:'模型编译',summary:'模型编译失败',detail:'bad xml',path:'robot.xml',at:1}} onClose={close}/>);expect(screen.queryByText('bad xml')).not.toBeInTheDocument();fireEvent.click(screen.getByRole('button',{name:'技术详情'}));expect(screen.getByText('bad xml')).toBeVisible();fireEvent.click(screen.getByRole('button',{name:'关闭错误'}));expect(close).toHaveBeenCalledTimes(1);});it('加载态与空态互斥',()=>{const {rerender}=render(<WorkspaceOverlays loading={false} hasSnapshot={false}/>);expect(screen.getByText('拖放模型工程到此处')).toBeVisible();rerender(<WorkspaceOverlays loading hasSnapshot={false}/>);expect(screen.queryByText('拖放模型工程到此处')).not.toBeInTheDocument();expect(screen.getByRole('status')).toBeVisible();});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();});}); describe('工作台反馈组件', () => {
it('诊断详情可展开并关闭', () => {
const close = vi.fn();
render(
<DiagnosticNotice
value={{
category: '模型编译',
summary: '模型编译失败',
detail: 'bad xml',
path: 'robot.xml',
at: 1,
}}
onClose={close}
/>,
);
expect(screen.queryByText('bad xml')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '技术详情' }));
expect(screen.getByText('bad xml')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '关闭错误' }));
expect(close).toHaveBeenCalledTimes(1);
});
it('加载态与空态互斥,并展示真实阶段进度', () => {
const { rerender } = render(<WorkspaceOverlays loading={false} hasSnapshot={false} />);
expect(screen.getByText('拖放模型工程到此处')).toBeVisible();
rerender(
<WorkspaceOverlays
loading
hasSnapshot={false}
progress={{
title: '正在导入工程',
label: '读取工程文件',
detail: 'robot.zip',
value: 0.42,
}}
/>,
);
expect(screen.queryByText('拖放模型工程到此处')).not.toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('正在导入工程');
expect(screen.getByRole('progressbar', { name: '读取工程文件' })).toHaveAttribute(
'aria-valuenow',
'42',
);
});
it('拖入文件时显示明确落点反馈', () => {
render(<WorkspaceOverlays loading={false} hasSnapshot={false} dragActive />);
expect(screen.getByText('松开即可导入工程')).toBeVisible();
});
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();
});
});
@@ -1,12 +1,66 @@
import {fireEvent,render,screen,within} from '@testing-library/react'; import { fireEvent, render, screen, within } from '@testing-library/react';
import {DiagnosticsDrawer} from './DiagnosticsDrawer'; import { DiagnosticsDrawer } from './DiagnosticsDrawer';
import {ErrorRecoveryPanel} from './ErrorRecoveryPanel'; import { ErrorRecoveryPanel } from './ErrorRecoveryPanel';
import {ToolbarOverflowMenu} from './ToolbarOverflowMenu'; import { ToolbarOverflowMenu } from './ToolbarOverflowMenu';
import {WorkspaceOverlays} from './WorkspaceOverlays'; import { WorkspaceOverlays } from './WorkspaceOverlays';
const event={id:1,title:'编译失败',detail:'bad xml',tone:'danger' as const,at:0}; const event = { id: 1, title: '编译失败', detail: 'bad xml', tone: 'danger' as const, at: 0 };
describe('第五批工作台组件',()=>{ describe('第五批工作台组件', () => {
it('事件日志支持分类和清空',()=>{const clear=vi.fn();render(<DiagnosticsDrawer open items={[event]} onClose={()=>{}} onClear={clear}/>);expect(within(screen.getByRole('tabpanel',{name:/全部/})).getByText('bad xml')).toBeVisible();expect(screen.getAllByText('bad xml')).toHaveLength(1);fireEvent.click(screen.getByRole('button',{name:'清空事件'}));expect(clear).toHaveBeenCalled();}); it('事件日志支持分类和清空', () => {
it('错误恢复面板透传重试与工程树动作',()=>{const retry=vi.fn(),project=vi.fn();render(<ErrorRecoveryPanel value={{category:'模型编译',summary:'失败',detail:'bad',path:'a.xml',at:1}} onClose={()=>{}} onRetry={retry} onOpenProject={project}/>);fireEvent.click(screen.getByRole('button',{name:'重试当前入口'}));fireEvent.click(screen.getByRole('button',{name:'返回工程树'}));expect(retry).toHaveBeenCalled();expect(project).toHaveBeenCalled();}); const clear = vi.fn();
it('导入叠层显示阶段进度',()=>{render(<div className="relative"><WorkspaceOverlays loading hasSnapshot={false} progress={{label:'处理模型资源',value:.4}}/></div>);expect(screen.getByRole('progressbar',{name:'处理模型资源'})).toHaveAttribute('aria-valuenow','40');}); render(<DiagnosticsDrawer open items={[event]} onClose={() => {}} onClear={clear} />);
it('工具栏更多菜单提供窄桌面动作',()=>{const settings=vi.fn();render(<ToolbarOverflowMenu fullscreen={false} onCommands={()=>{}} onLayout={()=>{}} onSettings={settings} onFullscreen={()=>{}} onHelp={()=>{}} onTheme={()=>{}}/>);fireEvent.click(screen.getByRole('button',{name:'更多工作台操作'}));fireEvent.click(screen.getByRole('menuitem',{name:'工作台设置'}));expect(settings).toHaveBeenCalled();}); expect(
within(screen.getByRole('tabpanel', { name: /全部/ })).getByText('bad xml'),
).toBeVisible();
expect(screen.getAllByText('bad xml')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '清空事件' }));
expect(clear).toHaveBeenCalled();
});
it('错误恢复面板透传重试与工程树动作', () => {
const retry = vi.fn(),
project = vi.fn();
render(
<ErrorRecoveryPanel
value={{ category: '模型编译', summary: '失败', detail: 'bad', path: 'a.xml', at: 1 }}
onClose={() => {}}
onRetry={retry}
onOpenProject={project}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '重试当前入口' }));
fireEvent.click(screen.getByRole('button', { name: '返回工程树' }));
expect(retry).toHaveBeenCalled();
expect(project).toHaveBeenCalled();
});
it('导入叠层显示阶段进度', () => {
render(
<div className="relative">
<WorkspaceOverlays
loading
hasSnapshot={false}
progress={{ label: '处理模型资源', value: 0.4 }}
/>
</div>,
);
expect(screen.getByRole('progressbar', { name: '处理模型资源' })).toHaveAttribute(
'aria-valuenow',
'40',
);
});
it('工具栏更多菜单提供窄桌面动作', () => {
const settings = vi.fn();
render(
<ToolbarOverflowMenu
fullscreen={false}
onCommands={() => {}}
onLayout={() => {}}
onSettings={settings}
onFullscreen={() => {}}
onHelp={() => {}}
onTheme={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '更多工作台操作' }));
fireEvent.click(screen.getByRole('menuitem', { name: '工作台设置' }));
expect(settings).toHaveBeenCalled();
});
}); });
@@ -1,13 +1,103 @@
import {act,fireEvent,render,screen} from '@testing-library/react'; import { act, fireEvent, render, screen } from '@testing-library/react';
import {NotificationCenter,ToastViewport,type WorkbenchNotification} from './NotificationCenter'; import {
import {ProjectBreadcrumb} from './ProjectBreadcrumb'; NotificationCenter,
import {SettingsDialog} from './SettingsDialog'; ToastViewport,
import {LayoutSettingsDialog} from './LayoutSettingsDialog'; type WorkbenchNotification,
const item:WorkbenchNotification={id:1,title:'模型加载完成',detail:'完成',tone:'success',at:0}; } from './NotificationCenter';
describe('第四批工作台组件',()=>{ import { ProjectBreadcrumb } from './ProjectBreadcrumb';
it('通知中心展示、移除并清空消息',()=>{const dismiss=vi.fn(),clear=vi.fn();render(<NotificationCenter items={[item]} onDismiss={dismiss} onClear={clear}/>);fireEvent.click(screen.getByRole('button',{name:'通知中心'}));expect(screen.getByRole('dialog',{name:'通知中心'})).toHaveTextContent('模型加载完成');fireEvent.click(screen.getByRole('button',{name:'移除通知:模型加载完成'}));expect(dismiss).toHaveBeenCalledWith(1);fireEvent.click(screen.getByText('清空'));expect(clear).toHaveBeenCalled();}); import { SettingsDialog } from './SettingsDialog';
it('Toast 自动关闭',()=>{vi.useFakeTimers();const close=vi.fn();render(<ToastViewport item={item} onDismiss={close}/>);act(()=>vi.advanceTimersByTime(4000));expect(close).toHaveBeenCalledWith(1);vi.useRealTimers();}); import { LayoutSettingsDialog } from './LayoutSettingsDialog';
it('工程面包屑可切换多入口',()=>{const select=vi.fn();render(<ProjectBreadcrumb projectName="robot" selectedEntry="models/a.xml" entries={[{path:'models/a.xml',label:'A',format:'mjcf'},{path:'models/b.xml',label:'B',format:'mjcf'}]} onSelect={select}/>);fireEvent.click(screen.getByRole('button',{name:'切换模型入口'}));fireEvent.click(screen.getByRole('option',{name:/B/}));expect(select).toHaveBeenCalledWith('models/b.xml');}); const item: WorkbenchNotification = {
it('模型加载期间禁用入口切换',()=>{render(<ProjectBreadcrumb projectName="robot" loading entries={[{path:'a.xml',label:'A',format:'mjcf'},{path:'b.xml',label:'B',format:'mjcf'}]} selectedEntry="a.xml" onSelect={()=>{}}/>);expect(screen.getByRole('button',{name:'切换模型入口'})).toBeDisabled();}); id: 1,
it('设置和布局弹窗透传现有设置动作',()=>{const theme=vi.fn(),preset=vi.fn();render(<><SettingsDialog open onClose={()=>{}} theme="dark" angleUnit="rad" showCollision={false} jointAdvanced={false} forceScale={50} onTheme={theme} onAngleUnit={()=>{}} onShowCollision={()=>{}} onJointAdvanced={()=>{}} onForceScale={()=>{}}/><LayoutSettingsDialog open={false} onClose={()=>{}} leftOpen rightOpen onLeftOpen={()=>{}} onRightOpen={()=>{}} onPreset={preset} onReset={()=>{}}/></>);fireEvent.change(screen.getByLabelText('设置主题'),{target:{value:'light'}});expect(theme).toHaveBeenCalledWith('light');}); title: '模型加载完成',
detail: '完成',
tone: 'success',
at: 0,
};
describe('第四批工作台组件', () => {
it('通知中心展示、移除并清空消息', () => {
const dismiss = vi.fn(),
clear = vi.fn();
render(<NotificationCenter items={[item]} onDismiss={dismiss} onClear={clear} />);
fireEvent.click(screen.getByRole('button', { name: '通知中心' }));
expect(screen.getByRole('dialog', { name: '通知中心' })).toHaveTextContent('模型加载完成');
fireEvent.click(screen.getByRole('button', { name: '移除通知:模型加载完成' }));
expect(dismiss).toHaveBeenCalledWith(1);
fireEvent.click(screen.getByText('清空'));
expect(clear).toHaveBeenCalled();
});
it('Toast 自动关闭', () => {
vi.useFakeTimers();
const close = vi.fn();
render(<ToastViewport item={item} onDismiss={close} />);
act(() => vi.advanceTimersByTime(4000));
expect(close).toHaveBeenCalledWith(1);
vi.useRealTimers();
});
it('工程面包屑可切换多入口', () => {
const select = vi.fn();
render(
<ProjectBreadcrumb
projectName="robot"
selectedEntry="models/a.xml"
entries={[
{ path: 'models/a.xml', label: 'A', format: 'mjcf' },
{ path: 'models/b.xml', label: 'B', format: 'mjcf' },
]}
onSelect={select}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '切换模型入口' }));
fireEvent.click(screen.getByRole('option', { name: /B/ }));
expect(select).toHaveBeenCalledWith('models/b.xml');
});
it('模型加载期间禁用入口切换', () => {
render(
<ProjectBreadcrumb
projectName="robot"
loading
entries={[
{ path: 'a.xml', label: 'A', format: 'mjcf' },
{ path: 'b.xml', label: 'B', format: 'mjcf' },
]}
selectedEntry="a.xml"
onSelect={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '切换模型入口' })).toBeDisabled();
});
it('设置和布局弹窗透传现有设置动作', () => {
const theme = vi.fn(),
preset = vi.fn();
render(
<>
<SettingsDialog
open
onClose={() => {}}
theme="dark"
angleUnit="rad"
showCollision={false}
jointAdvanced={false}
forceScale={50}
onTheme={theme}
onAngleUnit={() => {}}
onShowCollision={() => {}}
onJointAdvanced={() => {}}
onForceScale={() => {}}
/>
<LayoutSettingsDialog
open={false}
onClose={() => {}}
leftOpen
rightOpen
onLeftOpen={() => {}}
onRightOpen={() => {}}
onPreset={preset}
onReset={() => {}}
/>
</>,
);
fireEvent.change(screen.getByLabelText('设置主题'), { target: { value: 'light' } });
expect(theme).toHaveBeenCalledWith('light');
});
}); });
@@ -1,7 +1,78 @@
import {Columns3,Focus,PanelLeft,PanelRight,RotateCcw} from 'lucide-react'; import { Columns3, Focus, PanelLeft, PanelRight, RotateCcw } from 'lucide-react';
import {Button,Dialog} from '../../components/ui'; import { Button, Dialog } from '../../components/ui';
export type LayoutPreset='default'|'viewport'|'project'|'control'; export type LayoutPreset = 'default' | 'viewport' | 'project' | 'control';
const presets=[{value:'default' as const,label:'默认布局',detail:'左右面板均衡显示',icon:Columns3},{value:'viewport' as const,label:'宽视口',detail:'隐藏两侧面板',icon:Focus},{value:'project' as const,label:'工程浏览',detail:'加宽工程面板',icon:PanelLeft},{value:'control' as const,label:'控制调试',detail:'加宽控制面板',icon:PanelRight}]; const presets = [
export function LayoutSettingsDialog({open,onClose,leftOpen,rightOpen,onLeftOpen,onRightOpen,onPreset,onReset}:{open:boolean;onClose:()=>void;leftOpen:boolean;rightOpen:boolean;onLeftOpen:(value:boolean)=>void;onRightOpen:(value:boolean)=>void;onPreset:(preset:LayoutPreset)=>void;onReset:()=>void}){return <Dialog open={open} onClose={onClose} title="布局设置"><div className="flex gap-2"><Button variant={leftOpen?'primary':'secondary'} aria-pressed={leftOpen} onClick={()=>onLeftOpen(!leftOpen)} icon={<PanelLeft className="h-3.5 w-3.5"/>}>工程面板</Button><Button variant={rightOpen?'primary':'secondary'} aria-pressed={rightOpen} onClick={()=>onRightOpen(!rightOpen)} icon={<PanelRight className="h-3.5 w-3.5"/>}>属性面板</Button></div><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>)}</div><Button className="mt-4 w-full" onClick={onReset} icon={<RotateCcw className="h-3.5 w-3.5"/>}>恢复默认布局与宽度</Button></Dialog>;} { value: 'default' as const, label: '默认布局', detail: '左右面板均衡显示', icon: Columns3 },
{ value: 'viewport' as const, label: '宽视口', detail: '隐藏两侧面板', icon: Focus },
{ value: 'project' as const, label: '工程浏览', detail: '加宽工程面板', icon: PanelLeft },
{ value: 'control' as const, label: '控制调试', detail: '加宽控制面板', icon: PanelRight },
];
export function LayoutSettingsDialog({
open,
onClose,
leftOpen,
rightOpen,
onLeftOpen,
onRightOpen,
onPreset,
onReset,
}: {
open: boolean;
onClose: () => void;
leftOpen: boolean;
rightOpen: boolean;
onLeftOpen: (value: boolean) => void;
onRightOpen: (value: boolean) => void;
onPreset: (preset: LayoutPreset) => void;
onReset: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} title="布局设置">
<div className="flex gap-2">
<Button
variant={leftOpen ? 'primary' : 'secondary'}
aria-pressed={leftOpen}
onClick={() => onLeftOpen(!leftOpen)}
icon={<PanelLeft className="h-3.5 w-3.5" />}
>
工程面板
</Button>
<Button
variant={rightOpen ? 'primary' : 'secondary'}
aria-pressed={rightOpen}
onClick={() => onRightOpen(!rightOpen)}
icon={<PanelRight className="h-3.5 w-3.5" />}
>
属性面板
</Button>
</div>
<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>
))}
</div>
<Button
className="mt-4 w-full"
onClick={onReset}
icon={<RotateCcw className="h-3.5 w-3.5" />}
>
恢复默认布局与宽度
</Button>
</Dialog>
);
}
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export function dispatchLayoutWidths(left:number,right:number){window.dispatchEvent(new CustomEvent('mujoco-layout-widths',{detail:{left,right}}));} export function dispatchLayoutWidths(left: number, right: number) {
window.dispatchEvent(new CustomEvent('mujoco-layout-widths', { detail: { left, right } }));
}
@@ -1,25 +1,87 @@
import {fireEvent,render,screen,waitFor} from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {beforeEach,describe,expect,it,vi} from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import {LocalTrainingPanel} from './LocalTrainingPanel'; import { LocalTrainingPanel } from './LocalTrainingPanel';
beforeEach(()=>{localStorage.clear();vi.unstubAllGlobals();}); beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.unstubAllGlobals();
});
describe('LocalTrainingPanel',()=>{ describe('LocalTrainingPanel', () => {
it('连接本地服务并从图形界面发起训练请求',async()=>{ it('连接本地服务并从图形界面发起训练请求', async () => {
const health={version:'0.1.0',ready:true,trainerRoot:'/opt/unitree_rl_mjlab',python:'/env/bin/python',tasks:['Unitree-Go2-Flat']}; const health = {
const job={id:'a'.repeat(32),state:'queued',taskId:'Unitree-Go2-Flat',createdAt:'2025-01-01T00:00:00Z',iteration:0,maxIterations:2000,progress:0,message:'等待启动',logs:[],artifactReady:false}; version: '0.1.0',
const fetchMock=vi.fn() ready: true,
.mockResolvedValueOnce(new Response(JSON.stringify(health),{status:200,headers:{'Content-Type':'application/json'}})) trainerRoot: '/opt/unitree_rl_mjlab',
.mockResolvedValueOnce(new Response(JSON.stringify(job),{status:202,headers:{'Content-Type':'application/json'}})); python: '/env/bin/python',
vi.stubGlobal('fetch',fetchMock); tasks: ['Unitree-Go2-Flat'],
render(<LocalTrainingPanel onPolicyReady={vi.fn()}/>); };
fireEvent.click(screen.getByRole('button',{name:'连接'})); const job = {
id: 'a'.repeat(32),
state: 'queued',
taskId: 'Unitree-Go2-Flat',
createdAt: '2025-01-01T00:00:00Z',
iteration: 0,
maxIterations: 2000,
progress: 0,
message: '等待启动',
logs: [],
artifactReady: false,
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify(health), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify(job), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify(health), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: '训练任务不存在或服务已重启' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), {
target: { value: 'secret-token' },
});
fireEvent.click(screen.getByRole('button', { name: '连接' }));
expect(await screen.findByText('/opt/unitree_rl_mjlab')).toBeInTheDocument(); expect(await screen.findByText('/opt/unitree_rl_mjlab')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('并行环境'),{target:{value:'32'}}); fireEvent.change(screen.getByLabelText('并行环境'), { target: { value: '32' } });
fireEvent.click(screen.getByRole('button',{name:'发起本地训练'})); fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
await waitFor(()=>expect(fetchMock).toHaveBeenCalledTimes(2)); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
const request=fetchMock.mock.calls[1][1] as RequestInit; const request = fetchMock.mock.calls[1][1] as RequestInit;
expect(JSON.parse(String(request.body))).toMatchObject({taskId:'Unitree-Go2-Flat',numEnvs:32,device:'gpu',gpuIds:[0],wandbMode:'offline'}); expect(JSON.parse(String(request.body))).toMatchObject({
taskId: 'Unitree-Go2-Flat',
numEnvs: 32,
device: 'gpu',
gpuIds: [0],
wandbMode: 'offline',
});
expect(new Headers(request.headers).get('Authorization')).toBe('Bearer secret-token');
expect(await screen.findByText('排队中')).toBeInTheDocument(); expect(await screen.findByText('排队中')).toBeInTheDocument();
const tokenInput = screen.getByLabelText('训练服务访问令牌');
expect(tokenInput).toBeEnabled();
fireEvent.change(tokenInput, { target: { value: 'new-secret-token' } });
fireEvent.click(screen.getByRole('button', { name: '连接' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4));
expect(await screen.findByRole('button', { name: '发起本地训练' })).toBeInTheDocument();
expect(sessionStorage.getItem('mujoco-local-training-token')).toBe('new-secret-token');
}); });
}); });
@@ -1,77 +1,435 @@
import {useEffect,useState,type ReactNode} from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import {Download,Link,Play,Server,Square} from 'lucide-react'; import { Download, Link, Play, Server, Square } from 'lucide-react';
import {Badge,Button,ProgressBar,PropertyRow,Select} from '../../components/ui'; import { Badge, Button, ProgressBar, PropertyRow, Select } from '../../components/ui';
import {LocalTrainingClient} from '../../training/LocalTrainingClient'; import { LocalTrainingClient } from '../../training/LocalTrainingClient';
import type {TrainingDevice,TrainingJob,TrainingServerInfo,WandbMode} from '../../training/types'; import type {
TrainingDevice,
TrainingJob,
TrainingServerInfo,
WandbMode,
} from '../../training/types';
const ENDPOINT_KEY='mujoco-local-training-endpoint',JOB_KEY='mujoco-local-training-job'; const ENDPOINT_KEY = 'mujoco-local-training-endpoint',
const DEFAULT_ENDPOINT='http://127.0.0.1:8765'; JOB_KEY = 'mujoco-local-training-job',
const ACTIVE_STATES=new Set(['queued','running']); TOKEN_KEY = 'mujoco-local-training-token';
function stored(key:string,fallback=''):string{try{return localStorage.getItem(key)??fallback;}catch{return fallback;}} const DEFAULT_ENDPOINT = 'http://127.0.0.1:8765';
function errorText(error:unknown):string{return error instanceof Error?error.message:String(error);} const ACTIVE_STATES = new Set(['queued', 'running']);
function stateLabel(state:TrainingJob['state']):string{return {queued:'排队中',running:'训练中',succeeded:'已完成',failed:'失败',cancelled:'已取消'}[state];} function stored(key: string, fallback = ''): string {
try {
export function LocalTrainingPanel({onPolicyReady}:{onPolicyReady(file:File):void}){ return localStorage.getItem(key) ?? fallback;
const [endpoint,setEndpoint]=useState(()=>stored(ENDPOINT_KEY,DEFAULT_ENDPOINT)); } catch {
const [server,setServer]=useState<TrainingServerInfo>(); return fallback;
const [job,setJob]=useState<TrainingJob>(); }
const [busy,setBusy]=useState(false),[error,setError]=useState<string>(); }
const [taskId,setTaskId]=useState('Unitree-Go2-Flat'),[numEnvs,setNumEnvs]=useState(4096),[maxIterations,setMaxIterations]=useState(2000),[seed,setSeed]=useState(42),[runName,setRunName]=useState('web'),[device,setDevice]=useState<TrainingDevice>('gpu'),[gpuIds,setGpuIds]=useState('0'),[wandbMode,setWandbMode]=useState<WandbMode>('offline'); function sessionStored(key: string): string {
try {
const connect=async()=>{ return sessionStorage.getItem(key) ?? '';
setBusy(true);setError(undefined); } catch {
try{ return '';
const client=new LocalTrainingClient(endpoint),info=await client.health(); }
setServer(info);try{localStorage.setItem(ENDPOINT_KEY,client.endpoint);}catch{/* 当前会话仍可连接 */} }
if(info.tasks.length&&!info.tasks.includes(taskId))setTaskId(info.tasks[0]); function errorText(error: unknown): string {
const remembered=info.activeJobId??stored(JOB_KEY); return error instanceof Error ? error.message : String(error);
if(remembered){try{setJob(await client.job(remembered));}catch{try{localStorage.removeItem(JOB_KEY);}catch{/* ignore */}}} }
if(!info.ready)setError(info.error??'训练服务尚未就绪'); function stateLabel(state: TrainingJob['state']): string {
}catch(value){setServer(undefined);setError(errorText(value));} return {
finally{setBusy(false);} queued: '排队中',
}; running: '训练中',
succeeded: '已完成',
const jobId=job?.id,jobState=job?.state; failed: '失败',
useEffect(()=>{ cancelled: '已取消',
if(!jobId||!jobState||!ACTIVE_STATES.has(jobState))return; }[state];
let disposed=false;
const refresh=async()=>{try{const next=await new LocalTrainingClient(endpoint).job(jobId);if(!disposed)setJob(next);}catch(value){if(!disposed)setError(errorText(value));}};
const timer=window.setInterval(()=>void refresh(),1500);return()=>{disposed=true;window.clearInterval(timer);};
},[endpoint,jobId,jobState]);
const start=async()=>{
setBusy(true);setError(undefined);
try{
const ids=device==='gpu'?gpuIds.split(/[\s,]+/).filter(Boolean).map(Number):[];
if(ids.some(id=>!Number.isInteger(id)||id<0))throw new Error('GPU 编号必须是非负整数');
const next=await new LocalTrainingClient(endpoint).start({taskId,numEnvs,maxIterations,seed,runName,device,gpuIds:ids,wandbMode});
setJob(next);try{localStorage.setItem(JOB_KEY,next.id);}catch{/* ignore */}
}catch(value){setError(errorText(value));}finally{setBusy(false);}
};
const cancel=async()=>{if(!job)return;setBusy(true);setError(undefined);try{setJob(await new LocalTrainingClient(endpoint).cancel(job.id));}catch(value){setError(errorText(value));}finally{setBusy(false);}};
const importResult=async()=>{if(!job)return;setBusy(true);setError(undefined);try{onPolicyReady(await new LocalTrainingClient(endpoint).downloadPolicy(job.id));}catch(value){setError(errorText(value));}finally{setBusy(false);}};
const active=Boolean(job&&ACTIVE_STATES.has(job.state));
return <div>
<label className="block text-xs text-text-secondary"><span className="mb-1 block">本地训练服务</span><div className="flex gap-2"><input aria-label="本地训练服务地址" className="field h-7 min-w-0 flex-1 px-2 text-xs text-text-primary" value={endpoint} disabled={active} onChange={event=>setEndpoint(event.target.value)}/><Button icon={<Link className="h-3.5 w-3.5"/>} disabled={busy||active} onClick={()=>void connect()}>连接</Button></div></label>
<div className="mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] text-text-tertiary"><span className="flex min-w-0 items-center gap-1.5 truncate"><Server className="h-3.5 w-3.5"/>{server?.trainerRoot??'请先启动本地训练服务'}</span><Badge tone={server?.ready?'success':'warning'}>{server?.ready?'可用':'离线'}</Badge></div>
{server?.ready&&!job&&<div className="mt-3 space-y-2">
<Field label="训练任务"><Select aria-label="训练任务" className="w-full" value={taskId} onChange={event=>setTaskId(event.target.value)}>{server.tasks.map(task=><option key={task} value={task}>{task}</option>)}</Select></Field>
<div className="grid grid-cols-2 gap-2"><NumberField label="并行环境" value={numEnvs} min={1} max={16384} onChange={setNumEnvs}/><NumberField label="训练迭代" value={maxIterations} min={1} max={1000000} onChange={setMaxIterations}/><NumberField label="随机种子" value={seed} min={0} max={2147483647} onChange={setSeed}/><Field label="运行名称"><input aria-label="运行名称" className="field h-7 w-full px-2 text-xs text-text-primary" value={runName} onChange={event=>setRunName(event.target.value)}/></Field></div>
<div className="grid grid-cols-2 gap-2"><Field label="计算设备"><Select aria-label="计算设备" className="w-full" value={device} onChange={event=>setDevice(event.target.value as TrainingDevice)}><option value="gpu">GPU</option><option value="cpu">CPU</option></Select></Field><Field label="GPU 编号"><input aria-label="GPU 编号" className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40" value={gpuIds} disabled={device==='cpu'} onChange={event=>setGpuIds(event.target.value)}/></Field></div>
<Field label="实验记录"><Select aria-label="W&B 模式" className="w-full" value={wandbMode} onChange={event=>setWandbMode(event.target.value as WandbMode)}><option value="offline">本地离线(默认,无需登录)</option><option value="disabled">完全禁用 W&amp;B</option><option value="online">在线 W&amp;B(需要 API Key)</option></Select></Field>
<Button variant="primary" className="w-full" icon={<Play className="h-3.5 w-3.5"/>} disabled={busy} onClick={()=>void start()}>发起本地训练</Button>
<p className="text-[10px] leading-4 text-text-tertiary">训练使用本地 mjlab 任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。</p>
</div>}
{job&&<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex items-center justify-between gap-2"><span className="truncate text-xs font-medium text-text-primary" title={job.id}>{job.taskId}</span><Badge tone={job.state==='succeeded'?'success':job.state==='failed'||job.state==='cancelled'?'warning':'accent'}>{stateLabel(job.state)}</Badge></div>
<ProgressBar value={job.progress} label="训练进度"/><div className="mt-2"><PropertyRow label="迭代" value={`${job.iteration} / ${job.maxIterations}`}/><PropertyRow label="状态" value={job.message}/></div>
{job.logs.length>0&&<details className="mt-2"><summary className="cursor-pointer text-[10px] text-text-secondary">最近日志</summary><pre className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded bg-app p-2 text-[9px] leading-4 text-text-tertiary">{job.logs.slice(-40).join('\n')}</pre></details>}
<div className="mt-3 grid grid-cols-2 gap-2">{active?<Button variant="danger" className="col-span-2" icon={<Square className="h-3.5 w-3.5"/>} disabled={busy} onClick={()=>void cancel()}>停止训练</Button>:<><Button disabled={busy||!job.artifactReady} icon={<Download className="h-3.5 w-3.5"/>} onClick={()=>void importResult()}>导入策略</Button><Button onClick={()=>{setJob(undefined);try{localStorage.removeItem(JOB_KEY);}catch{/* ignore */}}}>新建任务</Button></>}</div>
</div>}
{error&&<p role="alert" className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger">{error}</p>}
</div>;
} }
function Field({label,children}:{label:string;children:ReactNode}){return <label className="block text-[10px] text-text-tertiary"><span className="mb-1 block">{label}</span>{children}</label>;} export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File): void }) {
function NumberField({label,value,min,max,onChange}:{label:string;value:number;min:number;max:number;onChange(value:number):void}){return <Field label={label}><input aria-label={label} type="number" className="field h-7 w-full px-2 text-xs text-text-primary" value={value} min={min} max={max} onChange={event=>onChange(Number(event.target.value))}/></Field>;} const [endpoint, setEndpoint] = useState(() => stored(ENDPOINT_KEY, DEFAULT_ENDPOINT));
const [token, setToken] = useState(() => sessionStored(TOKEN_KEY));
const [server, setServer] = useState<TrainingServerInfo>();
const [job, setJob] = useState<TrainingJob>();
const [busy, setBusy] = useState(false),
[error, setError] = useState<string>();
const [taskId, setTaskId] = useState('Unitree-Go2-Flat'),
[numEnvs, setNumEnvs] = useState(4096),
[maxIterations, setMaxIterations] = useState(2000),
[seed, setSeed] = useState(42),
[runName, setRunName] = useState('web'),
[device, setDevice] = useState<TrainingDevice>('gpu'),
[gpuIds, setGpuIds] = useState('0'),
[wandbMode, setWandbMode] = useState<WandbMode>('offline');
const connect = async () => {
setBusy(true);
setError(undefined);
try {
const client = new LocalTrainingClient(endpoint, token),
info = await client.health();
setServer(info);
try {
localStorage.setItem(ENDPOINT_KEY, client.endpoint);
sessionStorage.setItem(TOKEN_KEY, client.token);
} catch {
/* 当前会话仍可连接 */
}
if (info.tasks.length && !info.tasks.includes(taskId)) setTaskId(info.tasks[0]);
const remembered = info.activeJobId ?? stored(JOB_KEY);
if (remembered) {
try {
const recovered = await client.job(remembered);
setJob(recovered);
try {
localStorage.setItem(JOB_KEY, recovered.id);
} catch {
/* ignore */
}
} catch {
setJob(undefined);
try {
localStorage.removeItem(JOB_KEY);
} catch {
/* ignore */
}
}
} else {
setJob(undefined);
}
if (!info.ready) setError(info.error ?? '训练服务尚未就绪');
} catch (value) {
setServer(undefined);
setError(errorText(value));
} finally {
setBusy(false);
}
};
const jobId = job?.id,
jobState = job?.state;
useEffect(() => {
if (!jobId || !jobState || !ACTIVE_STATES.has(jobState)) return;
let disposed = false;
const refresh = async () => {
try {
const next = await new LocalTrainingClient(endpoint, token).job(jobId);
if (!disposed) setJob(next);
} catch (value) {
if (!disposed) setError(errorText(value));
}
};
const timer = window.setInterval(() => void refresh(), 1500);
return () => {
disposed = true;
window.clearInterval(timer);
};
}, [endpoint, jobId, jobState, token]);
const start = async () => {
setBusy(true);
setError(undefined);
try {
const ids =
device === 'gpu'
? gpuIds
.split(/[\s,]+/)
.filter(Boolean)
.map(Number)
: [];
if (ids.some((id) => !Number.isInteger(id) || id < 0))
throw new Error('GPU 编号必须是非负整数');
const next = await new LocalTrainingClient(endpoint, token).start({
taskId,
numEnvs,
maxIterations,
seed,
runName,
device,
gpuIds: ids,
wandbMode,
});
setJob(next);
try {
localStorage.setItem(JOB_KEY, next.id);
} catch {
/* ignore */
}
} catch (value) {
setError(errorText(value));
} finally {
setBusy(false);
}
};
const cancel = async () => {
if (!job) return;
setBusy(true);
setError(undefined);
try {
setJob(await new LocalTrainingClient(endpoint, token).cancel(job.id));
} catch (value) {
setError(errorText(value));
} finally {
setBusy(false);
}
};
const importResult = async () => {
if (!job) return;
setBusy(true);
setError(undefined);
try {
onPolicyReady(await new LocalTrainingClient(endpoint, token).downloadPolicy(job.id));
} catch (value) {
setError(errorText(value));
} finally {
setBusy(false);
}
};
const active = Boolean(job && ACTIVE_STATES.has(job.state));
return (
<div>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">本地训练服务</span>
<div className="flex gap-2">
<input
aria-label="本地训练服务地址"
className="field h-7 min-w-0 flex-1 px-2 text-xs text-text-primary"
value={endpoint}
onChange={(event) => setEndpoint(event.target.value)}
/>
<Button
icon={<Link className="h-3.5 w-3.5" />}
disabled={busy || !token.trim()}
onClick={() => void connect()}
>
连接
</Button>
</div>
</label>
<label className="mt-2 block text-[10px] text-text-tertiary">
<span className="mb-1 block">访问令牌(服务启动时显示)</span>
<input
aria-label="训练服务访问令牌"
type="password"
autoComplete="off"
className="field h-7 w-full px-2 text-xs text-text-primary"
value={token}
onChange={(event) => setToken(event.target.value)}
/>
</label>
<div className="mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] text-text-tertiary">
<span className="flex min-w-0 items-center gap-1.5 truncate">
<Server className="h-3.5 w-3.5" />
{server?.trainerRoot ?? '请先启动本地训练服务'}
</span>
<Badge tone={server?.ready ? 'success' : 'warning'}>
{server?.ready ? '可用' : '离线'}
</Badge>
</div>
{server?.ready && !job && (
<div className="mt-3 space-y-2">
<Field label="训练任务">
<Select
aria-label="训练任务"
className="w-full"
value={taskId}
onChange={(event) => setTaskId(event.target.value)}
>
{server.tasks.map((task) => (
<option key={task} value={task}>
{task}
</option>
))}
</Select>
</Field>
<div className="grid grid-cols-2 gap-2">
<NumberField
label="并行环境"
value={numEnvs}
min={1}
max={16384}
onChange={setNumEnvs}
/>
<NumberField
label="训练迭代"
value={maxIterations}
min={1}
max={1000000}
onChange={setMaxIterations}
/>
<NumberField
label="随机种子"
value={seed}
min={0}
max={2147483647}
onChange={setSeed}
/>
<Field label="运行名称">
<input
aria-label="运行名称"
className="field h-7 w-full px-2 text-xs text-text-primary"
value={runName}
onChange={(event) => setRunName(event.target.value)}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="计算设备">
<Select
aria-label="计算设备"
className="w-full"
value={device}
onChange={(event) => setDevice(event.target.value as TrainingDevice)}
>
<option value="gpu">GPU</option>
<option value="cpu">CPU</option>
</Select>
</Field>
<Field label="GPU 编号">
<input
aria-label="GPU 编号"
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
value={gpuIds}
disabled={device === 'cpu'}
onChange={(event) => setGpuIds(event.target.value)}
/>
</Field>
</div>
<Field label="实验记录">
<Select
aria-label="W&B 模式"
className="w-full"
value={wandbMode}
onChange={(event) => setWandbMode(event.target.value as WandbMode)}
>
<option value="offline">本地离线(默认,无需登录)</option>
<option value="disabled">完全禁用 W&amp;B</option>
<option value="online">在线 W&amp;B(需要 API Key)</option>
</Select>
</Field>
<Button
variant="primary"
className="w-full"
icon={<Play className="h-3.5 w-3.5" />}
disabled={busy}
onClick={() => void start()}
>
发起本地训练
</Button>
<p className="text-[10px] leading-4 text-text-tertiary">
训练使用本地 mjlab
任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。
</p>
</div>
)}
{job && (
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-text-primary" title={job.id}>
{job.taskId}
</span>
<Badge
tone={
job.state === 'succeeded'
? 'success'
: job.state === 'failed' || job.state === 'cancelled'
? 'warning'
: 'accent'
}
>
{stateLabel(job.state)}
</Badge>
</div>
<ProgressBar value={job.progress} label="训练进度" />
<div className="mt-2">
<PropertyRow label="迭代" value={`${job.iteration} / ${job.maxIterations}`} />
<PropertyRow label="状态" value={job.message} />
</div>
{job.logs.length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-[10px] text-text-secondary">最近日志</summary>
<pre className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded bg-app p-2 text-[9px] leading-4 text-text-tertiary">
{job.logs.slice(-40).join('\n')}
</pre>
</details>
)}
<div className="mt-3 grid grid-cols-2 gap-2">
{active ? (
<Button
variant="danger"
className="col-span-2"
icon={<Square className="h-3.5 w-3.5" />}
disabled={busy}
onClick={() => void cancel()}
>
停止训练
</Button>
) : (
<>
<Button
disabled={busy || !job.artifactReady}
icon={<Download className="h-3.5 w-3.5" />}
onClick={() => void importResult()}
>
导入策略
</Button>
<Button
onClick={() => {
setJob(undefined);
try {
localStorage.removeItem(JOB_KEY);
} catch {
/* ignore */
}
}}
>
新建任务
</Button>
</>
)}
</div>
</div>
)}
{error && (
<p
role="alert"
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
>
{error}
</p>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block text-[10px] text-text-tertiary">
<span className="mb-1 block">{label}</span>
{children}
</label>
);
}
function NumberField({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange(value: number): void;
}) {
return (
<Field label={label}>
<input
aria-label={label}
type="number"
className="field h-7 w-full px-2 text-xs text-text-primary"
value={value}
min={min}
max={max}
onChange={(event) => onChange(Number(event.target.value))}
/>
</Field>
);
}
@@ -0,0 +1,51 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MAP_ASSET_DRAG_MIME, MAP_ASSET_PLACEMENT_MIME } from '../../map/editor/assetCatalog';
import { MapAssetLibrary } from './MapAssetLibrary';
const renderLibrary = (
onAdd: (type: string, placementMode: string) => void = () => {},
onSelectTerrain: (preset: string) => void = () => {},
) =>
render(
<MapAssetLibrary
disabled={false}
terrainSize={8}
onAdd={onAdd}
onSelectTerrain={onSelectTerrain}
/>,
);
describe('MapAssetLibrary', () => {
it('按所选放置方式添加认证资产', () => {
const onAdd = vi.fn();
renderLibrary(onAdd);
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
target: { value: 'gravity' },
});
fireEvent.click(screen.getByRole('button', { name: '添加基础方盒' }));
expect(onAdd).toHaveBeenCalledWith('box', 'gravity');
});
it('拖动资产时写入类型和放置方式', () => {
const values = new Map<string, string>();
const dataTransfer = {
effectAllowed: 'none',
setData: (type: string, value: string) => values.set(type, value),
} as unknown as DataTransfer;
renderLibrary();
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
target: { value: 'locked' },
});
fireEvent.dragStart(document.querySelector('[data-map-asset="ramp"]')!, { dataTransfer });
expect(values.get(MAP_ASSET_DRAG_MIME)).toBe('ramp');
expect(values.get(MAP_ASSET_PLACEMENT_MIME)).toBe('locked');
});
it('以示意图卡片选择系统参数化地形', () => {
const onSelectTerrain = vi.fn();
renderLibrary(undefined, onSelectTerrain);
expect(screen.getAllByText('8.00 × 8.00 m')).toHaveLength(9);
fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' }));
expect(onSelectTerrain).toHaveBeenCalledWith('rough');
});
});
@@ -0,0 +1,298 @@
import { useState } from 'react';
import { BadgeCheck, CheckCircle2, Mountain, Plus } from 'lucide-react';
import { Button, Select } from '../../components/ui';
import {
CERTIFIED_MAP_ASSETS,
MAP_ASSET_DRAG_MIME,
MAP_ASSET_PLACEMENT_MIME,
} from '../../map/editor/assetCatalog';
import {
MAP_OBJECT_PLACEMENT_LABELS,
type EditableMapDocument,
type EditableMapObjectType,
type MapObjectPlacementMode,
} from '../../map/editor/types';
import {
PHYSICAL_MAP_PRESET_LABELS,
SYSTEM_TERRAIN_PRESETS,
type SystemTerrainPreset,
} from '../../map/types';
function AssetPreview({ type, color }: { type: EditableMapObjectType; color: string }) {
const shape =
type === 'cylinder'
? 'h-9 w-9 rounded-full'
: type === 'capsule'
? 'h-10 w-6 rounded-full'
: type === 'ramp'
? 'h-0 w-0 border-b-[34px] border-l-[48px] border-l-transparent'
: type === 'stairs'
? '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={`${shape} shadow-[0_6px_14px_rgba(0,0,0,0.22)]`}
style={type === 'ramp' ? { borderBottomColor: color } : { backgroundColor: color }}
/>
</div>
);
}
function TerrainPreview({ preset }: { preset: SystemTerrainPreset }) {
const grid = (
<g stroke="currentColor" strokeWidth="0.45" opacity="0.18">
<path d="M14 50 60 23l46 27-46 27z" />
<path d="m26 57 46-27m-34 34 46-27m-58 6 46 27m-34-34 46 27" />
</g>
);
let shape;
if (preset === 'discrete_obstacles')
shape = (
<g fill="currentColor" fillOpacity="0.16" stroke="currentColor">
<path d="m31 48 9-5 9 5-9 5zM40 53V39l9-5v14M40 39l-9-5v14" />
<path d="m55 39 8-5 8 5-8 5zM63 44V27l8-5v17M63 27l-8-5v17" />
<path d="m77 51 10-6 10 6-10 6zM87 57V38l10-6v19M87 38l-10-6v19" />
</g>
);
else if (preset === 'gap')
shape = (
<g fill="currentColor" fillOpacity="0.12" stroke="currentColor">
<path d="m21 48 31-18 15 9-31 18zM69 40l15 9 17-10-15-9z" />
<path d="m36 57 31-18v8L36 65zM69 40l17-10v8L69 48z" fillOpacity="0.24" />
</g>
);
else if (preset === 'inverted_pyramid_stairs' || preset === 'pit')
shape = (
<g fill="currentColor" fillOpacity="0.08" stroke="currentColor">
<path d="m22 47 38-22 38 22-38 22z" />
<path d="m32 47 28-16 28 16-28 16z" />
<path d="m43 47 17-10 17 10-17 10z" fillOpacity="0.2" />
{preset === 'inverted_pyramid_stairs' && (
<path d="M32 47v7m11-7v10m17-10v16m17-16v10m11-10v7" />
)}
</g>
);
else if (preset === 'pyramid_stairs')
shape = (
<g fill="currentColor" stroke="currentColor" strokeLinejoin="round">
<path d="m25 52 12 7 52-30-12-7z" fillOpacity="0.1" />
<path d="m37 45 12 7 40-23-12-7z" fillOpacity="0.17" />
<path d="m49 38 12 7 28-16-12-7z" fillOpacity="0.24" />
<path d="m61 31 12 7 16-9-12-7z" fillOpacity="0.32" />
</g>
);
else if (preset === 'rails')
shape = (
<g fill="none" stroke="currentColor" strokeLinecap="round">
<path d="m26 58 53-31m15 40 12-7" strokeWidth="2.3" opacity="0.25" />
<path d="m26 58 53-31m15 40 12-7M33 58V45m14 5V37m30 12V27m20 39V53" strokeWidth="1.2" />
</g>
);
else if (preset === 'rough')
shape = (
<path
d="m20 54 10-18 9 9 10-22 11 17 10-13 10 20 10-8 10 15-40 18z"
fill="currentColor"
fillOpacity="0.16"
stroke="currentColor"
strokeLinejoin="round"
/>
);
else if (preset === 'stepping_stones')
shape = (
<g fill="currentColor" fillOpacity="0.2" stroke="currentColor">
<path d="m27 52 10-6 10 6-10 6zM46 39l10-6 10 6-10 6zM66 54l10-6 10 6-10 6zM83 36l10-6 10 6-10 6z" />
</g>
);
else
shape = (
<g fill="none" stroke="currentColor" strokeLinecap="round">
<path d="M19 48c10-17 20-17 30 0s20 17 30 0 20-17 29-2" strokeWidth="5" opacity="0.16" />
<path d="M19 48c10-17 20-17 30 0s20 17 30 0 20-17 29-2" strokeWidth="1.4" />
<path d="M25 59c10-17 20-17 30 0s20 17 30 0 15-13 23-7" opacity="0.55" />
</g>
);
return (
<svg viewBox="0 0 120 80" className="h-[76px] w-full text-accent" aria-hidden="true">
{grid}
{shape}
</svg>
);
}
function AddAction({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-accent">
{label}
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-accent text-white">
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
</span>
</span>
);
}
export function MapAssetLibrary({
disabled,
terrainSize,
document,
onAdd,
onSelectTerrain,
onSelectObject,
}: {
disabled: boolean;
terrainSize: number;
document?: EditableMapDocument | null;
onAdd: (
type: EditableMapObjectType,
placementMode: MapObjectPlacementMode,
) => void | Promise<void>;
onSelectTerrain: (preset: SystemTerrainPreset) => void;
onSelectObject?: (id: string) => void;
}) {
const [placementMode, setPlacementMode] = useState<MapObjectPlacementMode>('auto_ground');
return (
<section aria-label="认证资产" className="space-y-3 p-3">
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
<span>场景结构</span>
<span className="technical-value text-[10px] text-text-tertiary">
{document?.objects.length ?? 0} 个对象
</span>
</div>
{document?.objects.length ? (
<div className="mt-2 max-h-36 space-y-0.5 overflow-auto panel-scroll">
{document.objects.map((object) => (
<button
key={object.id}
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] text-text-secondary hover:bg-element-hover hover:text-text-primary"
onClick={() => onSelectObject?.(object.id)}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
<span className="min-w-0 flex-1 truncate">{object.name}</span>
<span className="text-[9px] text-text-tertiary">{object.type}</span>
</button>
))}
</div>
) : (
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
选择工程地图后,这里显示场景对象层级;可从下方资产库创建场景。
</p>
)}
</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>
<label className="w-36 shrink-0 text-[10px] text-text-secondary">
放置方式
<Select
aria-label="新增资产放置方式"
className="mt-1 w-full"
value={placementMode}
disabled={disabled}
onChange={(event) => setPlacementMode(event.target.value as MapObjectPlacementMode)}
>
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((mode) => (
<option key={mode} value={mode}>
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
</option>
))}
</Select>
</label>
</div>
<div className="grid grid-cols-2 gap-2">
{CERTIFIED_MAP_ASSETS.map((asset) => (
<article
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'}`}
onDragStart={(event) => {
if (disabled) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData(MAP_ASSET_DRAG_MIME, asset.type);
event.dataTransfer.setData(MAP_ASSET_PLACEMENT_MIME, placementMode);
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">
{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>
</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>
<div className="grid grid-cols-2 gap-2">
{SYSTEM_TERRAIN_PRESETS.map((preset) => (
<article
key={preset}
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' : 'hover:border-accent'}`}
>
<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">
{PHYSICAL_MAP_PRESET_LABELS[preset]}
</div>
<div className="mt-0.5 text-[9px] 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"
disabled={disabled}
aria-label={`添加${PHYSICAL_MAP_PRESET_LABELS[preset]}`}
onClick={() => onSelectTerrain(preset)}
>
<AddAction label="添加" />
</button>
</div>
</article>
))}
</div>
</section>
);
}
@@ -0,0 +1,106 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { EditableMapDocument } from '../../map/editor/types';
import { MapEditorPanel } from './MapEditorPanel';
const interactionProps = {
onConvert: async () => true,
canConvert: true,
onBindInteraction: () => {},
onSelectPreview: () => {},
onTransformMode: () => {},
onSnapping: () => {},
};
const document: EditableMapDocument = {
schemaVersion: 1,
mapId: 'map',
revision: 0,
objects: [],
spawnPoints: [],
};
describe('MapEditorPanel', () => {
it('新增并编辑对象后应用递增 revision', async () => {
const apply = vi.fn(async (documentValue: EditableMapDocument) => Boolean(documentValue)),
preview = vi.fn();
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={preview}
onApply={apply}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象位置X'), { target: { value: '2' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
await waitFor(() => expect(apply).toHaveBeenCalled());
expect(apply.mock.calls[0][0]).toEqual(
expect.objectContaining({
revision: 1,
objects: [
expect.objectContaining({ pose: expect.objectContaining({ position: [2, 0, 0.5] }) }),
],
}),
);
});
it('锁定放置方式后禁用位姿编辑', () => {
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={() => {}}
onApply={async () => true}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增' }));
fireEvent.change(screen.getByLabelText('对象放置方式'), { target: { value: 'locked' } });
expect(screen.getByLabelText('对象位置X')).toBeDisabled();
expect(screen.getByLabelText('对象绕Z旋转')).toBeDisabled();
expect(screen.getByRole('button', { name: '对齐地面' })).toBeDisabled();
});
it('编辑出生点并包含在应用文档中', async () => {
const apply = vi.fn(async (value: EditableMapDocument) => Boolean(value));
render(
<MapEditorPanel
{...interactionProps}
document={document}
loading={false}
onPreview={() => {}}
onApply={apply}
onExport={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '新增出生点' }));
const name = screen.getByLabelText(/出生点名称/);
fireEvent.change(name, { target: { value: '装卸区' } });
const yaw = screen.getByLabelText(/出生点.*朝向/);
fireEvent.change(yaw, { target: { value: '90' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
await waitFor(() => expect(apply).toHaveBeenCalled());
expect(apply.mock.calls[0][0].spawnPoints[0]).toMatchObject({ name: '装卸区', yawDeg: 90 });
});
it('没有 authoring 文档时可请求创建可编辑副本', async () => {
const convert = vi.fn(async () => true);
render(
<MapEditorPanel
{...interactionProps}
onConvert={convert}
document={null}
loading={false}
onPreview={() => {}}
onApply={async () => true}
onExport={() => {}}
/>,
);
expect(screen.getByText(/保持只读/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '创建可编辑副本' }));
await waitFor(() => expect(convert).toHaveBeenCalledOnce());
});
});
@@ -0,0 +1,597 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Select } from '../../components/ui';
import { MapEditSession } from '../../map/editor/MapEditSession';
import {
editableObjectGroundHeight,
MAP_OBJECT_PLACEMENT_LABELS,
type EditableMapDocument,
type EditableMapObject,
type EditableMapObjectType,
type MapEditorInteractionCallbacks,
type MapEditorTransformMode,
type MapObjectPlacementMode,
} from '../../map/editor/types';
const labels: Record<EditableMapObjectType, string> = {
box: '方盒',
cylinder: '圆柱',
capsule: '胶囊',
ramp: '坡道',
stairs: '楼梯',
};
function yawDegrees(object: EditableMapObject): number {
const [w, x, y, z] = object.pose.quaternion;
return (Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180) / Math.PI;
}
function scaledParameters(
object: EditableMapObject,
scale: [number, number, number],
): Record<string, number> {
const [sx, sy, sz] = scale.map((value) => Math.max(0.001, Math.abs(value))) as [
number,
number,
number,
];
const p = object.parameters;
if (object.type === 'box')
return { sizeX: p.sizeX * sx, sizeY: p.sizeY * sy, sizeZ: p.sizeZ * sz };
if (object.type === 'cylinder')
return { radius: p.radius * Math.max(sx, sy), height: p.height * sz };
if (object.type === 'capsule')
return { radius: p.radius * Math.max(sx, sy), length: p.length * sz };
if (object.type === 'ramp')
return {
length: p.length * sx,
width: p.width * sy,
rise: p.rise * sz,
thickness: p.thickness * sz,
};
return {
stepDepth: p.stepDepth * sx,
width: p.width * sy,
stepHeight: p.stepHeight * sz,
count: p.count,
};
}
export function MapEditorPanel({
document,
loading,
onPreview,
onApply,
onExport,
onConvert,
canConvert,
onBindInteraction,
onSelectPreview,
onTransformMode,
onSnapping,
}: {
document: EditableMapDocument | null;
loading: boolean;
onPreview: (document: EditableMapDocument | null) => void;
onApply: (document: EditableMapDocument) => Promise<boolean>;
onExport: () => void;
onConvert: () => Promise<boolean>;
canConvert: boolean;
onBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onSelectPreview: (id: string | null) => void;
onTransformMode: (mode: MapEditorTransformMode) => void;
onSnapping: (translation: number | null, rotationDegrees: number | null) => void;
}) {
const session = useMemo(() => (document ? new MapEditSession(document) : null), [document]);
const [, render] = useState(0),
[selected, setSelected] = useState<string>(),
[selectedType, setSelectedType] = useState<EditableMapObjectType>('box'),
[transformMode, setTransformMode] = useState<MapEditorTransformMode>('translate'),
[snapping, setSnapping] = useState(true),
[converting, setConverting] = useState(false);
const refresh = () => {
render((value) => value + 1);
onPreview(session?.document ?? null);
};
useEffect(() => {
onPreview(session?.document ?? null);
return () => onPreview(null);
}, [session, onPreview]);
useEffect(() => {
if (!session) {
onBindInteraction(null);
return;
}
const callbacks: MapEditorInteractionCallbacks = {
onSelect: (id) => setSelected(id ?? undefined),
onTransform: ({ id, position, quaternion, scale }) => {
const object = session.document.objects.find((item) => item.id === id);
if (!object) return;
session.update(id, {
pose: { position, quaternion },
parameters: scaledParameters(object, scale),
});
setSelected(id);
render((value) => value + 1);
onPreview(session.document);
},
onAddAsset: (type, position, placementMode) => {
const object = session.addAsset(type, position, placementMode);
setSelected(object.id);
render((value) => value + 1);
onPreview(session.document);
onSelectPreview(object.id);
},
};
onBindInteraction(callbacks);
return () => onBindInteraction(null);
}, [session, onBindInteraction, onPreview, onSelectPreview]);
useEffect(() => {
onTransformMode(transformMode);
onSnapping(snapping ? 0.1 : null, snapping ? 5 : null);
}, [transformMode, snapping, onTransformMode, onSnapping]);
useEffect(() => {
if (!session) return;
const keydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey)
return;
const key = event.key.toLowerCase();
if ((event.ctrlKey || event.metaKey) && key === 'z') {
event.preventDefault();
if (event.shiftKey) session.redo();
else session.undo();
if (selected && !session.document.objects.some((object) => object.id === selected)) {
setSelected(undefined);
onSelectPreview(null);
}
render((value) => value + 1);
onPreview(session.document);
} else if ((event.ctrlKey || event.metaKey) && key === 'y') {
event.preventDefault();
session.redo();
render((value) => value + 1);
onPreview(session.document);
} else if (!event.ctrlKey && !event.metaKey && key === 'w') setTransformMode('translate');
else if (!event.ctrlKey && !event.metaKey && key === 'e') setTransformMode('rotate');
else if (!event.ctrlKey && !event.metaKey && key === 's') setTransformMode('scale');
else if (event.key === 'Escape') {
setSelected(undefined);
onSelectPreview(null);
} else if ((event.key === 'Delete' || event.key === 'Backspace') && selected) {
event.preventDefault();
session.remove(selected);
setSelected(undefined);
render((value) => value + 1);
onPreview(session.document);
onSelectPreview(null);
}
};
window.addEventListener('keydown', keydown);
return () => window.removeEventListener('keydown', keydown);
}, [session, selected, onPreview, onSelectPreview]);
if (!session)
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">
仅由 box、cylinder、capsule 组成且不含资产、材质或碰撞过滤的静态 MJCF
可以安全转换;遇到无法表达的语义会整体拒绝,不会静默丢失内容。
</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>
<Button disabled={loading || converting} onClick={onExport}>
导出原地图 ZIP
</Button>
</div>
{!canConvert && <p className="text-warning">该地图没有 MJCF 物理层,无法转换。</p>}
</div>
);
const current = session.document;
const active = current.objects.find((object) => object.id === selected);
const poseLocked = active?.placementMode === 'locked';
const updatePosition = (index: number, value: number) => {
if (!active || !Number.isFinite(value)) return;
const position = [...active.pose.position] as [number, number, number];
position[index] = value;
session.update(active.id, { pose: { ...active.pose, position } });
refresh();
};
const updateParameter = (key: string, value: number) => {
if (!active || !Number.isFinite(value) || value <= 0) return;
session.update(active.id, {
parameters: { ...active.parameters, [key]: key === 'count' ? Math.round(value) : value },
});
refresh();
};
const updateSpawnPosition = (
id: string,
position: [number, number, number],
index: number,
value: number,
) => {
if (!Number.isFinite(value)) return;
const next = [...position] as [number, number, number];
next[index] = value;
session.updateSpawn(id, { position: next });
refresh();
};
return (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2 rounded border border-border-subtle p-2">
<Button
variant={transformMode === 'translate' ? 'primary' : 'secondary'}
aria-pressed={transformMode === 'translate'}
disabled={loading}
onClick={() => setTransformMode('translate')}
>
移动工具 W
</Button>
<Button
variant={transformMode === 'rotate' ? 'primary' : 'secondary'}
aria-pressed={transformMode === 'rotate'}
disabled={loading}
onClick={() => setTransformMode('rotate')}
>
旋转工具 E
</Button>
<Button
variant={transformMode === 'scale' ? 'primary' : 'secondary'}
aria-pressed={transformMode === 'scale'}
disabled={loading}
onClick={() => setTransformMode('scale')}
>
缩放工具 S
</Button>
<label className="col-span-3 flex items-center justify-between text-[11px] text-text-secondary">
<span>吸附(移动 0.1 m / 旋转 5°)</span>
<input
aria-label="地图编辑吸附"
type="checkbox"
checked={snapping}
disabled={loading}
onChange={(event) => setSnapping(event.target.checked)}
/>
</label>
<p className="col-span-3 text-[10px] text-text-tertiary">
在视口点击对象后拖动操纵轴;只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。
</p>
</div>
<div className="flex gap-2">
<Select
aria-label="新增地图对象类型"
className="min-w-0 flex-1"
value={selectedType}
disabled={loading}
onChange={(event) => setSelectedType(event.target.value as EditableMapObjectType)}
>
{(Object.keys(labels) as EditableMapObjectType[]).map((type) => (
<option key={type} value={type}>
{labels[type]}
</option>
))}
</Select>
<Button
disabled={loading}
onClick={() => {
const object = session.add(selectedType);
setSelected(object.id);
refresh();
onSelectPreview(object.id);
}}
>
新增
</Button>
</div>
<div className="max-h-36 space-y-1 overflow-auto" aria-label="地图对象列表">
{current.objects.map((object) => (
<button
key={object.id}
type="button"
className={`w-full rounded border px-2 py-1 text-left text-xs ${selected === object.id ? 'border-accent bg-accent/10' : 'border-border-subtle'}`}
onClick={() => {
setSelected(object.id);
onSelectPreview(object.id);
}}
>
{object.name} · {labels[object.type]}
{object.placementMode === 'locked' ? ' · 已锁定' : ''}
</button>
))}
{!current.objects.length && <div className="text-xs text-text-tertiary">暂无对象</div>}
</div>
{active && (
<div className="space-y-2 rounded border border-border-subtle p-2">
<label className="block text-[11px] text-text-secondary">
名称
<input
aria-label="对象名称"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
value={active.name}
onChange={(event) => {
if (!event.target.value.trim()) return;
session.update(active.id, { name: event.target.value });
refresh();
}}
/>
</label>
<label className="block text-[11px] text-text-secondary">
放置方式
<Select
aria-label="对象放置方式"
className="mt-1 w-full"
value={active.placementMode}
disabled={loading}
onChange={(event) => {
session.update(active.id, {
placementMode: event.target.value as MapObjectPlacementMode,
});
refresh();
}}
>
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map(
(mode) => (
<option key={mode} value={mode}>
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
</option>
),
)}
</Select>
<span className="mt-1 block text-[9px] leading-3.5 text-text-tertiary">
{active.placementMode === 'auto_ground'
? '始终将对象底部对齐 z=0。'
: active.placementMode === 'gravity'
? '沿世界 -Z 落到 XY 范围内最高的静态承载面。'
: '保持当前位姿,并禁用表单和视口变换。'}
</span>
</label>
<div className="grid grid-cols-3 gap-2">
{active.pose.position.map((value, index) => (
<label key={index} className="text-[11px] text-text-secondary">
{['X', 'Y', 'Z'][index]}
<input
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="0.1"
value={value}
disabled={loading || poseLocked || index === 2}
onChange={(event) => updatePosition(index, Number(event.target.value))}
/>
</label>
))}
</div>
<label className="block text-[11px] text-text-secondary">
绕 Z 旋转(°)
<input
aria-label="对象绕Z旋转"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="1"
value={Number(yawDegrees(active).toFixed(4))}
disabled={loading || poseLocked}
onChange={(event) => {
const value = Number(event.target.value);
if (!Number.isFinite(value)) return;
const half = (value * Math.PI) / 360;
session.update(active.id, {
pose: { ...active.pose, quaternion: [Math.cos(half), 0, 0, Math.sin(half)] },
});
refresh();
}}
/>
</label>
<div className="grid grid-cols-2 gap-2">
{Object.entries(active.parameters).map(([key, value]) => (
<label key={key} className="text-[11px] text-text-secondary">
{key}
<input
aria-label={`对象参数${key}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
min="0.001"
step={key === 'count' ? 1 : 0.1}
value={value}
onChange={(event) => updateParameter(key, Number(event.target.value))}
/>
</label>
))}
</div>
<label className="flex items-center justify-between text-[11px] text-text-secondary">
<span>启用对象</span>
<input
aria-label="启用地图对象"
type="checkbox"
checked={active.enabled}
onChange={(event) => {
session.update(active.id, { enabled: event.target.checked });
refresh();
}}
/>
</label>
<div className="flex flex-wrap gap-2">
<Button
disabled={loading || poseLocked}
onClick={() => {
session.update(active.id, {
pose: {
...active.pose,
position: [
active.pose.position[0],
active.pose.position[1],
editableObjectGroundHeight(active),
],
},
});
refresh();
}}
>
对齐地面
</Button>
<Button
disabled={loading}
onClick={() => {
const copy = session.duplicate(active.id);
setSelected(copy.id);
refresh();
onSelectPreview(copy.id);
}}
>
复制对象
</Button>
<Button
variant="danger"
disabled={loading}
onClick={() => {
session.remove(active.id);
setSelected(undefined);
refresh();
onSelectPreview(null);
}}
>
删除对象
</Button>
</div>
</div>
)}
<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}
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) => (
<label key={index} className="text-[10px] text-text-secondary">
{['X', 'Y', 'Z'][index]}
<input
aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="0.1"
value={value}
onChange={(event) =>
updateSpawnPosition(
spawn.id,
spawn.position,
index,
Number(event.target.value),
)
}
/>
</label>
))}
<label className="text-[10px] text-text-secondary">
Yaw°
<input
aria-label={`出生点${spawn.id}朝向`}
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
type="number"
step="1"
value={spawn.yawDeg}
onChange={(event) => {
const value = Number(event.target.value);
if (!Number.isFinite(value)) return;
session.updateSpawn(spawn.id, { yawDeg: value });
refresh();
}}
/>
</label>
</div>
<Button
variant="danger"
disabled={loading}
onClick={() => {
session.removeSpawn(spawn.id);
refresh();
}}
>
删除出生点
</Button>
</div>
))}
{!current.spawnPoints.length && (
<div className="text-[11px] text-text-tertiary">暂无出生点</div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<Button
disabled={loading || !session.canUndo}
onClick={() => {
session.undo();
refresh();
}}
>
撤销
</Button>
<Button
disabled={loading || !session.canRedo}
onClick={() => {
session.redo();
refresh();
}}
>
重做
</Button>
<Button
disabled={loading || !session.dirty}
onClick={() => {
session.discard();
setSelected(undefined);
refresh();
onSelectPreview(null);
}}
>
放弃草稿
</Button>
<Button disabled={loading} onClick={onExport}>
导出地图 ZIP
</Button>
</div>
<Button
className="w-full"
disabled={loading || !session.dirty}
onClick={async () => {
const latest = session.document;
const candidate = { ...latest, revision: latest.revision + 1 };
if (await onApply(candidate)) onPreview(null);
else onPreview(session.document);
}}
>
应用并重新编译
</Button>
{session.dirty && (
<div role="status" className="text-xs text-warning">
地图草稿尚未应用
</div>
)}
</div>
);
}
@@ -1,7 +1,141 @@
import {useEffect,useRef} from 'react'; import { useEffect, useRef } from 'react';
import {Bell,CheckCircle2,Info,Trash2,TriangleAlert,XCircle} from 'lucide-react'; import { Bell, CheckCircle2, Info, Trash2, TriangleAlert, XCircle } from 'lucide-react';
import {Badge,IconButton,Popover} from '../../components/ui'; import { Badge, IconButton, Popover } from '../../components/ui';
export interface WorkbenchNotification{id:number;title:string;detail?:string;tone:'success'|'warning'|'danger'|'info';at:number;} export interface WorkbenchNotification {
const icons={success:CheckCircle2,warning:TriangleAlert,danger:XCircle,info:Info}; id: number;
export function NotificationCenter({items,onDismiss,onClear,onOpenLog}:{items:WorkbenchNotification[];onDismiss:(id:number)=>void;onClear:()=>void;onOpenLog?:()=>void}){return <Popover label="通知中心" trigger={({open,toggle})=><IconButton tooltip="通知中心" aria-label="通知中心" aria-expanded={open} onClick={toggle}><Bell className="h-4 w-4"/>{items.length>0&&<span className="absolute right-0 top-0 h-1.5 w-1.5 rounded-full bg-warning"/>}</IconButton>}>{({close})=><div className="w-80 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" onClick={()=>{close();onOpenLog();}}>事件日志</button>}{items.length>0&&<button className="flex items-center gap-1 text-[10px] text-text-tertiary hover:text-danger" onClick={onClear}><Trash2 className="h-3 w-3"/>清空</button>}</div></header><div className="panel-scroll max-h-80 overflow-auto">{items.length?items.map(item=>{const Icon=icons[item.tone];return <article key={item.id} className="flex gap-2 border-b border-border px-3 py-2.5 last:border-0"><Icon className={`mt-0.5 h-4 w-4 shrink-0 ${item.tone==='success'?'text-success':item.tone==='warning'?'text-warning':item.tone==='danger'?'text-danger':'text-accent'}`}/><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><h3 className="truncate text-xs font-medium">{item.title}</h3><Badge>{new Date(item.at).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}</Badge></div>{item.detail&&<p className="mt-1 line-clamp-3 text-[10px] leading-4 text-text-tertiary">{item.detail}</p>}</div><IconButton aria-label={`移除通知:${item.title}`} tooltip="移除" onClick={()=>onDismiss(item.id)}><XCircle className="h-3.5 w-3.5"/></IconButton></article>}):<p className="p-6 text-center text-xs text-text-tertiary">当前没有通知</p>}</div></div>}</Popover>;} title: string;
export function ToastViewport({item,onDismiss}:{item?:WorkbenchNotification;onDismiss:(id:number)=>void}){const dismissRef=useRef(onDismiss);useEffect(()=>{dismissRef.current=onDismiss;},[onDismiss]);useEffect(()=>{if(!item)return;const timer=window.setTimeout(()=>dismissRef.current(item.id),4000);return()=>window.clearTimeout(timer);},[item]);if(!item)return null;const Icon=icons[item.tone];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"><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>}</div></div>;} detail?: string;
tone: 'success' | 'warning' | 'danger' | 'info';
at: number;
}
const icons = { success: CheckCircle2, warning: TriangleAlert, danger: XCircle, info: Info };
export function NotificationCenter({
items,
onDismiss,
onClear,
onOpenLog,
}: {
items: WorkbenchNotification[];
onDismiss: (id: number) => void;
onClear: () => void;
onOpenLog?: () => void;
}) {
return (
<Popover
label="通知中心"
trigger={({ open, toggle }) => (
<IconButton tooltip="通知中心" aria-label="通知中心" aria-expanded={open} onClick={toggle}>
<Bell className="h-4 w-4" />
{items.length > 0 && (
<span className="absolute right-0 top-0 h-1.5 w-1.5 rounded-full bg-warning" />
)}
</IconButton>
)}
>
{({ close }) => (
<div className="w-80 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"
onClick={() => {
close();
onOpenLog();
}}
>
事件日志
</button>
)}
{items.length > 0 && (
<button
className="flex items-center gap-1 text-[10px] text-text-tertiary hover:text-danger"
onClick={onClear}
>
<Trash2 className="h-3 w-3" />
清空
</button>
)}
</div>
</header>
<div className="panel-scroll max-h-80 overflow-auto">
{items.length ? (
items.map((item) => {
const Icon = icons[item.tone];
return (
<article
key={item.id}
className="flex gap-2 border-b border-border px-3 py-2.5 last:border-0"
>
<Icon
className={`mt-0.5 h-4 w-4 shrink-0 ${item.tone === 'success' ? 'text-success' : item.tone === 'warning' ? 'text-warning' : item.tone === 'danger' ? 'text-danger' : 'text-accent'}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-xs font-medium">{item.title}</h3>
<Badge>
{new Date(item.at).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
</Badge>
</div>
{item.detail && (
<p className="mt-1 line-clamp-3 text-[10px] leading-4 text-text-tertiary">
{item.detail}
</p>
)}
</div>
<IconButton
aria-label={`移除通知:${item.title}`}
tooltip="移除"
onClick={() => onDismiss(item.id)}
>
<XCircle className="h-3.5 w-3.5" />
</IconButton>
</article>
);
})
) : (
<p className="p-6 text-center text-xs text-text-tertiary">当前没有通知</p>
)}
</div>
</div>
)}
</Popover>
);
}
export function ToastViewport({
item,
onDismiss,
}: {
item?: WorkbenchNotification;
onDismiss: (id: number) => void;
}) {
const dismissRef = useRef(onDismiss);
useEffect(() => {
dismissRef.current = onDismiss;
}, [onDismiss]);
useEffect(() => {
if (!item) return;
const timer = window.setTimeout(() => dismissRef.current(item.id), 4000);
return () => window.clearTimeout(timer);
}, [item]);
if (!item) return null;
const Icon = icons[item.tone];
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"
>
<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>
)}
</div>
</div>
);
}
@@ -1,3 +1,68 @@
import {Activity,ChevronUp,Cpu,MemoryStick,TriangleAlert} from 'lucide-react'; import { Activity, ChevronUp, Cpu, MemoryStick, TriangleAlert } from 'lucide-react';
import {Badge,Popover,PropertyRow,Separator} from '../../components/ui'; import { Badge, Popover, PropertyRow, Separator } from '../../components/ui';
export function PerformancePopover({fps,stepMs,memoryMb,overBudget}:{fps:number;stepMs:number;memoryMb?:number;overBudget:boolean}){return <Popover label="性能详情" placement="top-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"><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>}>{()=> <div className="w-72 rounded-lg border border-border bg-surface-elevated p-3 text-xs text-text-secondary shadow-xl"><div className="mb-2 flex items-center justify-between"><h2 className="font-semibold text-text-primary">运行性能</h2><Badge tone={overBudget?'warning':'success'}>{overBudget?'预算超限':'运行正常'}</Badge></div><PropertyRow label="渲染帧率" value={`${fps.toFixed(0)} FPS`}/><PropertyRow label="物理步进" value={`${stepMs.toFixed(2)} ms`}/><PropertyRow label="浏览器内存" value={memoryMb===undefined?'不可用':`${memoryMb.toFixed(1)} MiB`}/><Separator className="my-2"/>{overBudget?<p className="flex gap-2 text-warning"><TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0"/>主线程超出步进预算,平台已限制追帧以保持交互响应。</p>:<p className="flex gap-2 text-text-tertiary"><MemoryStick className="mt-0.5 h-3.5 w-3.5 shrink-0"/>指标来自浏览器运行时,仅用于当前会话诊断。</p>}</div>}</Popover>;} export function PerformancePopover({
fps,
stepMs,
memoryMb,
overBudget,
}: {
fps: number;
stepMs: number;
memoryMb?: number;
overBudget: boolean;
}) {
return (
<Popover
label="性能详情"
placement="top-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"
>
<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>
)}
>
{() => (
<div className="w-72 rounded-lg border border-border bg-surface-elevated p-3 text-xs text-text-secondary shadow-xl">
<div className="mb-2 flex items-center justify-between">
<h2 className="font-semibold text-text-primary">运行性能</h2>
<Badge tone={overBudget ? 'warning' : 'success'}>
{overBudget ? '预算超限' : '运行正常'}
</Badge>
</div>
<PropertyRow label="渲染帧率" value={`${fps.toFixed(0)} FPS`} />
<PropertyRow label="物理步进" value={`${stepMs.toFixed(2)} ms`} />
<PropertyRow
label="浏览器内存"
value={memoryMb === undefined ? '不可用' : `${memoryMb.toFixed(1)} MiB`}
/>
<Separator className="my-2" />
{overBudget ? (
<p className="flex gap-2 text-warning">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
主线程超出步进预算,平台已限制追帧以保持交互响应。
</p>
) : (
<p className="flex gap-2 text-text-tertiary">
<MemoryStick className="mt-0.5 h-3.5 w-3.5 shrink-0" />
指标来自浏览器运行时,仅用于当前会话诊断。
</p>
)}
</div>
)}
</Popover>
);
}
@@ -0,0 +1,118 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { DEFAULT_PHYSICAL_MAP_CONFIG } from '../../map/types';
import { PhysicalMapPanel } from './PhysicalMapPanel';
const common = {
maps: [],
rootBodies: ['robot'],
loading: false,
nativeUrdf: false,
showVisualMap: true,
showMapCollision: false,
editorDocument: null,
onEditorPreview: () => {},
onEditorApply: async () => true,
onEditorExport: () => {},
onEditorConvert: async () => true,
onEditorBindInteraction: () => {},
onEditorSelect: () => {},
onEditorTransformMode: () => {},
onEditorSnapping: () => {},
onMapDisplay: () => {},
};
describe('PhysicalMapPanel', () => {
it('选择楼梯参数后请求重新编译', () => {
const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'stairs' } });
fireEvent.change(screen.getByLabelText('台阶数量'), { target: { value: '6' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith({
kind: 'builtin',
config: expect.objectContaining({ preset: 'stairs', stairCount: 6 }),
});
});
it('在右侧参数面板调整系统地形并提交', () => {
const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'rough' } });
fireEvent.change(screen.getByLabelText('地形难度(0–1)'), { target: { value: '0.8' } });
fireEvent.change(screen.getByLabelText('随机种子'), { target: { value: '9' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith({
kind: 'builtin',
config: expect.objectContaining({
preset: 'rough',
terrainDifficulty: 0.8,
seed: 9,
}),
});
});
it('可以移动并旋转资产地图', () => {
const onApply = vi.fn();
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } });
fireEvent.change(screen.getByLabelText('位置 Y(m)'), { target: { value: '-1.5' } });
fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith({
kind: 'builtin',
config: expect.objectContaining({ positionX: 2.5, positionY: -1.5, yawDeg: 45 }),
});
});
it('显示工程地图、出生点和根 Body', () => {
const onApply = vi.fn();
render(
<PhysicalMapPanel
{...common}
value={{ kind: 'none' }}
maps={[
{
descriptorPath: 'maps/warehouse/map.json',
schemaVersion: 1 as const,
id: 'warehouse',
name: '仓库',
spawnPoints: [{ id: 'door', name: '入口' }],
},
]}
rootBodies={['robot', 'payload']}
onApply={onApply}
/>,
);
fireEvent.change(screen.getByLabelText('地图来源'), {
target: { value: 'project:maps/warehouse/map.json' },
});
expect(screen.getByLabelText('地图出生点')).toHaveValue('door');
fireEvent.change(screen.getByLabelText('机器人根 Body'), { target: { value: 'robot' } });
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
expect(onApply).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'project',
descriptorPath: 'maps/warehouse/map.json',
spawnPointId: 'door',
robotRootBody: 'robot',
}),
);
});
it('原生 URDF 模式只允许移除地图', () => {
render(
<PhysicalMapPanel
{...common}
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
nativeUrdf
onApply={() => {}}
/>,
);
expect(screen.getByRole('status')).toHaveTextContent('原生 URDF');
expect(screen.getByRole('button', { name: '应用并重新编译' })).toBeDisabled();
expect(screen.getByLabelText('地图来源')).not.toBeDisabled();
});
});
@@ -0,0 +1,474 @@
import { useState } from 'react';
import { Button, Select } from '../../components/ui';
import type { MapEntry } from '../../project/types';
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
PHYSICAL_MAP_PRESET_LABELS,
isSystemTerrainPreset,
type MapSelection,
type PhysicalMapConfig,
type PhysicalMapPreset,
} from '../../map/types';
import { normalizePhysicalMapConfig } from '../../map/physicalMap';
import type {
EditableMapDocument,
MapEditorInteractionCallbacks,
MapEditorTransformMode,
} from '../../map/editor/types';
import { MapEditorPanel } from './MapEditorPanel';
function NumberField({
label,
value,
min,
max,
step = 1,
disabled,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
disabled: boolean;
onChange: (value: number) => void;
}) {
return (
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">{label}</span>
<input
type="number"
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:cursor-not-allowed disabled:opacity-50"
value={value}
min={min}
max={max}
step={step}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
);
}
function normalizeSelection(selection: MapSelection): MapSelection {
if (selection.kind === 'project') {
const friction = selection.frictionOverride;
return {
...selection,
frictionOverride:
friction === undefined || !Number.isFinite(friction)
? undefined
: Math.min(5, Math.max(0.05, friction)),
};
}
if (selection.kind !== 'builtin') return selection;
const config = normalizePhysicalMapConfig(selection.config);
return config.preset === 'none' ? { kind: 'none' } : { kind: 'builtin', config };
}
export function PhysicalMapPanel({
value,
maps,
rootBodies,
loading,
nativeUrdf,
showVisualMap,
showMapCollision,
editorDocument,
onEditorPreview,
onEditorApply,
onEditorExport,
onEditorConvert,
onEditorBindInteraction,
onEditorSelect,
onEditorTransformMode,
onEditorSnapping,
onMapDisplay,
onApply,
}: {
value: MapSelection;
maps: MapEntry[];
rootBodies: string[];
loading: boolean;
nativeUrdf: boolean;
showVisualMap: boolean;
showMapCollision: boolean;
editorDocument: EditableMapDocument | null;
onEditorPreview: (document: EditableMapDocument | null) => void;
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
onEditorExport: () => void;
onEditorConvert: () => Promise<boolean>;
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onEditorSelect: (id: string | null) => void;
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
onMapDisplay: (visual: boolean, collision: boolean) => void;
onApply: (value: MapSelection) => void;
}) {
const [draft, setDraft] = useState<MapSelection>(value);
const normalized = normalizeSelection(draft);
const changed = JSON.stringify(normalized) !== JSON.stringify(value);
const compileDisabled = loading || (nativeUrdf && normalized.kind !== 'none');
const sourceValue =
draft.kind === 'none'
? 'none'
: draft.kind === 'builtin'
? 'builtin'
: `project:${draft.descriptorPath}`;
const projectMap =
draft.kind === 'project'
? maps.find((entry) => entry.descriptorPath === draft.descriptorPath)
: undefined;
const updateBuiltin = <K extends keyof PhysicalMapConfig>(key: K, next: PhysicalMapConfig[K]) => {
if (draft.kind !== 'builtin') return;
setDraft({ kind: 'builtin', config: { ...draft.config, [key]: next } });
};
const changeSource = (source: string) => {
if (source === 'none') setDraft({ kind: 'none' });
else if (source === 'builtin')
setDraft({
kind: 'builtin',
config: {
...DEFAULT_PHYSICAL_MAP_CONFIG,
preset: value.kind === 'builtin' ? value.config.preset : 'flat',
},
});
else {
const descriptorPath = source.slice('project:'.length);
const map = maps.find((entry) => entry.descriptorPath === descriptorPath);
setDraft({
kind: 'project',
descriptorPath,
spawnPointId: map?.spawnPoints[0]?.id,
});
}
};
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>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">地图来源</span>
<Select
aria-label="地图来源"
className="w-full"
value={sourceValue}
disabled={loading}
onChange={(event) => changeSource(event.target.value)}
>
<option value="none">不使用地图</option>
<option value="builtin">内置程序地图</option>
{maps.map((map) => (
<option key={map.descriptorPath} value={`project:${map.descriptorPath}`}>
{map.name}
</option>
))}
</Select>
</label>
{draft.kind === 'builtin' && (
<>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">地图预设</span>
<Select
aria-label="物理地图预设"
className="w-full"
value={draft.config.preset}
disabled={loading}
onChange={(event) => updateBuiltin('preset', event.target.value as PhysicalMapPreset)}
>
{(Object.keys(PHYSICAL_MAP_PRESET_LABELS) as PhysicalMapPreset[])
.filter((preset) => preset !== 'none')
.map((preset) => (
<option key={preset} value={preset}>
{PHYSICAL_MAP_PRESET_LABELS[preset]}
</option>
))}
</Select>
</label>
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-text-primary">地图变换</span>
<Button
disabled={
loading ||
(draft.config.positionX === 0 &&
draft.config.positionY === 0 &&
draft.config.yawDeg === 0)
}
onClick={() =>
setDraft({
kind: 'builtin',
config: { ...draft.config, positionX: 0, positionY: 0, yawDeg: 0 },
})
}
>
复位
</Button>
</div>
<div className="grid grid-cols-3 gap-2">
<NumberField
label="位置 X(m)"
value={draft.config.positionX}
min={-100}
max={100}
step={0.1}
disabled={loading}
onChange={(next) => updateBuiltin('positionX', next)}
/>
<NumberField
label="位置 Y(m)"
value={draft.config.positionY}
min={-100}
max={100}
step={0.1}
disabled={loading}
onChange={(next) => updateBuiltin('positionY', next)}
/>
<NumberField
label="旋转 Z(°)"
value={draft.config.yawDeg}
min={-180}
max={180}
step={1}
disabled={loading}
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
label={isSystemTerrainPreset(draft.config.preset) ? '地形边长(m)' : '地图半径(m)'}
value={draft.config.size}
min={4}
max={isSystemTerrainPreset(draft.config.preset) ? 30 : 100}
disabled={loading}
onChange={(next) => updateBuiltin('size', next)}
/>
<NumberField
label="摩擦系数"
value={draft.config.friction}
min={0.05}
max={5}
step={0.05}
disabled={loading}
onChange={(next) => updateBuiltin('friction', next)}
/>
{draft.config.preset === 'slope' && (
<NumberField
label="坡度(°)"
value={draft.config.slopeAngle}
min={5}
max={30}
disabled={loading}
onChange={(next) => updateBuiltin('slopeAngle', next)}
/>
)}
{draft.config.preset === 'stairs' && (
<NumberField
label="台阶数量"
value={draft.config.stairCount}
min={2}
max={20}
disabled={loading}
onChange={(next) => updateBuiltin('stairCount', next)}
/>
)}
{draft.config.preset === 'obstacles' && (
<NumberField
label="障碍物数量"
value={draft.config.obstacleCount}
min={1}
max={30}
disabled={loading}
onChange={(next) => updateBuiltin('obstacleCount', next)}
/>
)}
{(draft.config.preset === 'obstacles' ||
isSystemTerrainPreset(draft.config.preset)) && (
<NumberField
label="随机种子"
value={draft.config.seed}
min={0}
max={2_147_483_647}
disabled={loading}
onChange={(next) => updateBuiltin('seed', next)}
/>
)}
{isSystemTerrainPreset(draft.config.preset) && (
<NumberField
label="地形难度(0–1)"
value={draft.config.terrainDifficulty}
min={0}
max={1}
step={0.05}
disabled={loading}
onChange={(next) => updateBuiltin('terrainDifficulty', next)}
/>
)}
{(draft.config.preset === 'rough' || draft.config.preset === 'wave') && (
<>
<NumberField
label="水平采样间距(m)"
value={draft.config.terrainHorizontalScale}
min={0.03}
max={1}
step={0.01}
disabled={loading}
onChange={(next) => updateBuiltin('terrainHorizontalScale', next)}
/>
<NumberField
label="垂直量化(m)"
value={draft.config.terrainVerticalScale}
min={0.001}
max={0.1}
step={0.001}
disabled={loading}
onChange={(next) => updateBuiltin('terrainVerticalScale', next)}
/>
</>
)}
</div>
</>
)}
{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>
{projectMap.spawnPoints.length > 0 && (
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">出生点</span>
<Select
aria-label="地图出生点"
className="w-full"
value={draft.spawnPointId ?? ''}
disabled={loading}
onChange={(event) =>
setDraft({ ...draft, spawnPointId: event.target.value || undefined })
}
>
<option value="">不改变机器人位置</option>
{projectMap.spawnPoints.map((spawn) => (
<option key={spawn.id} value={spawn.id}>
{spawn.name}
</option>
))}
</Select>
</label>
)}
{rootBodies.length > 1 && (
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">机器人根 Body</span>
<Select
aria-label="机器人根 Body"
className="w-full"
value={draft.robotRootBody ?? ''}
disabled={loading}
onChange={(event) =>
setDraft({ ...draft, robotRootBody: event.target.value || undefined })
}
>
<option value="">自动识别</option>
{rootBodies.map((body) => (
<option key={body} value={body}>
{body}
</option>
))}
</Select>
</label>
)}
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">覆盖摩擦系数(留空则使用地图值)</span>
<input
aria-label="地图摩擦系数覆盖"
type="number"
min={0.05}
max={5}
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"
onChange={(event) =>
setDraft({
...draft,
frictionOverride: event.target.value ? Number(event.target.value) : undefined,
})
}
/>
</label>
</div>
)}
{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">V3 地图编辑器</div>
<MapEditorPanel
document={editorDocument}
loading={loading}
onPreview={onEditorPreview}
onApply={onEditorApply}
onExport={onEditorExport}
onConvert={onEditorConvert}
canConvert={Boolean(projectMap?.physicsPath)}
onBindInteraction={onEditorBindInteraction}
onSelectPreview={onEditorSelect}
onTransformMode={onEditorTransformMode}
onSnapping={onEditorSnapping}
/>
</div>
)}
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-secondary">
<label className="flex items-center justify-between gap-3">
<span>显示视觉地图</span>
<input
type="checkbox"
checked={showVisualMap}
onChange={(event) => onMapDisplay(event.target.checked, showMapCollision)}
/>
</label>
<label className="flex items-center justify-between gap-3">
<span>显示地图碰撞层</span>
<input
type="checkbox"
checked={showMapCollision}
onChange={(event) => onMapDisplay(showVisualMap, event.target.checked)}
/>
</label>
</div>
{nativeUrdf && normalized.kind !== 'none' && (
<p
role="status"
className="rounded border border-warning/30 bg-warning/10 p-2 text-xs text-warning"
>
原生 URDF 不能注入地图。请在“属性”中切换为“转换为 MJCF”。
</p>
)}
<Button
variant="primary"
className="w-full"
disabled={compileDisabled || !changed}
onClick={() => onApply(normalized)}
>
应用并重新编译
</Button>
</div>
);
}
@@ -1,4 +1,52 @@
import {ChevronRight,FolderRoot} from 'lucide-react'; import { ChevronRight, FolderRoot } from 'lucide-react';
import type {ModelEntry} from '../../project/types'; import type { ModelEntry } from '../../project/types';
import {SearchableCombobox} from '../../components/ui'; import { SearchableCombobox } from '../../components/ui';
export function ProjectBreadcrumb({projectName,entries,selectedEntry,loading=false,onSelect}:{projectName:string;entries:ModelEntry[];selectedEntry?:string;loading?:boolean;onSelect:(path:string)=>void}){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}</span></span>)}</div>{entries.length>1&&<div className="mt-2"><SearchableCombobox label="切换模型入口" disabled={loading} value={selectedEntry} onChange={onSelect} options={entries.map(entry=>({value:entry.path,label:entry.label,description:entry.path}))}/></div>}</div>;} export function ProjectBreadcrumb({
projectName,
entries,
selectedEntry,
loading = false,
onSelect,
}: {
projectName: string;
entries: ModelEntry[];
selectedEntry?: string;
loading?: boolean;
onSelect: (path: string) => void;
}) {
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}
</span>
</span>
))}
</div>
{entries.length > 1 && (
<div className="mt-2">
<SearchableCombobox
label="切换模型入口"
disabled={loading}
value={selectedEntry}
onChange={onSelect}
options={entries.map((entry) => ({
value: entry.path,
label: entry.label,
description: entry.path,
}))}
/>
</div>
)}
</div>
);
}
@@ -1,23 +1,69 @@
import {fireEvent,render,screen} from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import {describe,expect,it,vi} from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import {PythonControllerPanel} from './PythonControllerPanel'; import { PythonControllerPanel } from './PythonControllerPanel';
const noop=()=>{}; const noop = () => {};
describe('PythonControllerPanel',()=>{ describe('PythonControllerPanel', () => {
it('向支持 command 的已启用控制器发送基本移动指令',()=>{ it('向支持 command 的已启用控制器发送基本移动指令', () => {
const onCommand=vi.fn(); const onCommand = vi.fn();
render(<PythonControllerPanel paths={[]} loading={false} status={{language:'python',path:'go2.py',name:'Go2',controlHz:200,loaded:true,enabled:true,acceptsCommands:true,activeCommand:'stop',lastStepMs:.1}} onSelectPath={noop} onLoadPath={noop} onImport={noop} onToggle={noop} onCommand={onCommand} onRemove={noop}/>); render(
fireEvent.click(screen.getByRole('button',{name:'前进'})); <PythonControllerPanel
fireEvent.click(screen.getByRole('button',{name:'左转'})); paths={[]}
fireEvent.click(screen.getByRole('button',{name:'起跳'})); loading={false}
expect(onCommand.mock.calls).toEqual([['forward'],['turn_left'],['jump']]); status={{
expect(screen.getByRole('button',{name:'移动停止'})).toHaveAttribute('aria-pressed','true'); language: 'python',
path: 'go2.py',
name: 'Go2',
controlHz: 200,
loaded: true,
enabled: true,
acceptsCommands: true,
activeCommand: 'stop',
lastStepMs: 0.1,
}}
onSelectPath={noop}
onLoadPath={noop}
onImport={noop}
onToggle={noop}
onCommand={onCommand}
onRemove={noop}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '前进' }));
fireEvent.click(screen.getByRole('button', { name: '左转' }));
fireEvent.click(screen.getByRole('button', { name: '起跳' }));
expect(onCommand.mock.calls).toEqual([['forward'], ['turn_left'], ['jump']]);
expect(screen.getByRole('button', { name: '移动停止' })).toHaveAttribute(
'aria-pressed',
'true',
);
}); });
it('控制器未启用时禁用基本移动按钮',()=>{ it('控制器未启用时禁用基本移动按钮', () => {
render(<PythonControllerPanel paths={[]} loading={false} status={{language:'python',path:'go2.py',name:'Go2',controlHz:200,loaded:true,enabled:false,acceptsCommands:true,lastStepMs:0}} onSelectPath={noop} onLoadPath={noop} onImport={noop} onToggle={noop} onCommand={noop} onRemove={noop}/>); render(
expect(screen.getByRole('button',{name:'前进'})).toBeDisabled(); <PythonControllerPanel
expect(screen.getByRole('button',{name:'起跳'})).toBeDisabled(); paths={[]}
loading={false}
status={{
language: 'python',
path: 'go2.py',
name: 'Go2',
controlHz: 200,
loaded: true,
enabled: false,
acceptsCommands: true,
lastStepMs: 0,
}}
onSelectPath={noop}
onLoadPath={noop}
onImport={noop}
onToggle={noop}
onCommand={noop}
onRemove={noop}
/>,
);
expect(screen.getByRole('button', { name: '前进' })).toBeDisabled();
expect(screen.getByRole('button', { name: '起跳' })).toBeDisabled();
}); });
}); });
@@ -1,37 +1,184 @@
import {useRef,type ChangeEvent} from 'react'; import { useRef, type ChangeEvent } from 'react';
import {ArrowDown,ArrowLeft,ArrowRight,ArrowUp,FileUp,Octagon,Power,RotateCw,Trash2} from 'lucide-react'; import {
import type {ControllerCommand,ControllerStatus} from '../../controller/types'; ArrowDown,
import {Badge,Button,PropertyRow,Select} from '../../components/ui'; ArrowLeft,
ArrowRight,
ArrowUp,
FileUp,
Octagon,
Power,
RotateCw,
Trash2,
} from 'lucide-react';
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
export interface PythonControllerPanelProps { export interface PythonControllerPanelProps {
paths:string[]; paths: string[];
selectedPath?:string; selectedPath?: string;
status?:ControllerStatus; status?: ControllerStatus;
loading:boolean; loading: boolean;
onSelectPath(path:string):void; onSelectPath(path: string): void;
onLoadPath(path:string):void; onLoadPath(path: string): void;
onImport(file:File):void; onImport(file: File): void;
onToggle(enabled:boolean):void; onToggle(enabled: boolean): void;
onCommand(command:ControllerCommand):void; onCommand(command: ControllerCommand): void;
onRemove():void; onRemove(): void;
} }
export function PythonControllerPanel({paths,selectedPath,status,loading,onSelectPath,onLoadPath,onImport,onToggle,onCommand,onRemove}:PythonControllerPanelProps){ export function PythonControllerPanel({
const input=useRef<HTMLInputElement>(null); paths,
const importFile=(event:ChangeEvent<HTMLInputElement>)=>{const file=event.target.files?.[0];if(file)onImport(file);event.target.value='';}; selectedPath,
return <div> status,
<input ref={input} className="hidden" type="file" accept=".py,text/x-python" onChange={importFile}/> loading,
{paths.length>0&&<label className="mb-3 block text-xs text-text-secondary"><span className="mb-1 block">工程内脚本</span><Select aria-label="Python 控制脚本" className="w-full" value={selectedPath??''} disabled={loading} onChange={event=>onSelectPath(event.target.value)}><option value="">选择 .py 文件</option>{paths.map(path=><option key={path} value={path}>{path}</option>)}</Select></label>} onSelectPath,
<div className="grid grid-cols-2 gap-2"> onLoadPath,
<Button icon={<FileUp className="h-3.5 w-3.5"/>} disabled={loading} onClick={()=>input.current?.click()}>导入 .py</Button> onImport,
<Button icon={<RotateCw className="h-3.5 w-3.5"/>} disabled={loading||!selectedPath} onClick={()=>selectedPath&&onLoadPath(selectedPath)}>加载脚本</Button> onToggle,
onCommand,
onRemove,
}: PythonControllerPanelProps) {
const input = useRef<HTMLInputElement>(null);
const importFile = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) onImport(file);
event.target.value = '';
};
return (
<div>
<input
ref={input}
className="hidden"
type="file"
accept=".py,text/x-python"
onChange={importFile}
/>
{paths.length > 0 && (
<label className="mb-3 block text-xs text-text-secondary">
<span className="mb-1 block">工程内脚本</span>
<Select
aria-label="Python 控制脚本"
className="w-full"
value={selectedPath ?? ''}
disabled={loading}
onChange={(event) => onSelectPath(event.target.value)}
>
<option value="">选择 .py 文件</option>
{paths.map((path) => (
<option key={path} value={path}>
{path}
</option>
))}
</Select>
</label>
)}
<div className="grid grid-cols-2 gap-2">
<Button
icon={<FileUp className="h-3.5 w-3.5" />}
disabled={loading}
onClick={() => input.current?.click()}
>
导入 .py
</Button>
<Button
icon={<RotateCw className="h-3.5 w-3.5" />}
disabled={loading || !selectedPath}
onClick={() => selectedPath && onLoadPath(selectedPath)}
>
加载脚本
</Button>
</div>
{status ? (
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<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`} />
{status.error && (
<p
role="alert"
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] 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>
<div className="grid grid-cols-3 gap-1.5">
<span />
<Button
aria-pressed={status.activeCommand === 'forward'}
disabled={!status.enabled}
icon={<ArrowUp className="h-3.5 w-3.5" />}
onClick={() => onCommand('forward')}
>
前进
</Button>
<span />
<Button
aria-pressed={status.activeCommand === 'turn_left'}
disabled={!status.enabled}
icon={<ArrowLeft className="h-3.5 w-3.5" />}
onClick={() => onCommand('turn_left')}
>
左转
</Button>
<Button
aria-label="移动停止"
aria-pressed={status.activeCommand === 'stop'}
disabled={!status.enabled}
icon={<Octagon className="h-3.5 w-3.5" />}
onClick={() => onCommand('stop')}
>
停止
</Button>
<Button
aria-pressed={status.activeCommand === 'turn_right'}
disabled={!status.enabled}
icon={<ArrowRight className="h-3.5 w-3.5" />}
onClick={() => onCommand('turn_right')}
>
右转
</Button>
<span />
<Button
aria-pressed={status.activeCommand === 'backward'}
disabled={!status.enabled}
icon={<ArrowDown className="h-3.5 w-3.5" />}
onClick={() => onCommand('backward')}
>
后退
</Button>
<Button disabled={!status.enabled} onClick={() => onCommand('jump')}>
起跳
</Button>
</div>
</div>
)}
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
variant={status.enabled ? 'secondary' : 'primary'}
icon={<Power className="h-3.5 w-3.5" />}
onClick={() => onToggle(!status.enabled)}
>
{status.enabled ? '停止' : '启用'}
</Button>
<Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5" />} onClick={onRemove}>
卸载
</Button>
</div>
</div>
) : (
<p className="mt-3 text-xs leading-5 text-text-tertiary">
加载可信的单文件 Python 控制器。脚本在每次 mj_step 前按仿真时间同步执行,默认 100 Hz。
</p>
)}
</div> </div>
{status?<div className="mt-3 rounded-lg border border-border bg-surface p-2.5"> );
<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`}/>
{status.error&&<p role="alert" className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] 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><div className="grid grid-cols-3 gap-1.5"><span/><Button aria-pressed={status.activeCommand==='forward'} disabled={!status.enabled} icon={<ArrowUp className="h-3.5 w-3.5"/>} onClick={()=>onCommand('forward')}>前进</Button><span/><Button aria-pressed={status.activeCommand==='turn_left'} disabled={!status.enabled} icon={<ArrowLeft className="h-3.5 w-3.5"/>} onClick={()=>onCommand('turn_left')}>左转</Button><Button aria-label="移动停止" aria-pressed={status.activeCommand==='stop'} disabled={!status.enabled} icon={<Octagon className="h-3.5 w-3.5"/>} onClick={()=>onCommand('stop')}>停止</Button><Button aria-pressed={status.activeCommand==='turn_right'} disabled={!status.enabled} icon={<ArrowRight className="h-3.5 w-3.5"/>} onClick={()=>onCommand('turn_right')}>右转</Button><span/><Button aria-pressed={status.activeCommand==='backward'} disabled={!status.enabled} icon={<ArrowDown className="h-3.5 w-3.5"/>} onClick={()=>onCommand('backward')}>后退</Button><Button disabled={!status.enabled} onClick={()=>onCommand('jump')}>起跳</Button></div></div>}
<div className="mt-3 grid grid-cols-2 gap-2"><Button variant={status.enabled?'secondary':'primary'} icon={<Power className="h-3.5 w-3.5"/>} onClick={()=>onToggle(!status.enabled)}>{status.enabled?'停止':'启用'}</Button><Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5"/>} onClick={onRemove}>卸载</Button></div>
</div>:<p className="mt-3 text-xs leading-5 text-text-tertiary">加载可信的单文件 Python 控制器。脚本在每次 mj_step 前按仿真时间同步执行,默认 100 Hz。</p>}
</div>;
} }
+184 -24
View File
@@ -1,30 +1,190 @@
import {useRef,type ChangeEvent} from 'react'; import { useRef, type ChangeEvent } from 'react';
import {BrainCircuit,FileUp,Power,RotateCw,Trash2} from 'lucide-react'; import { BrainCircuit, FileUp, Power, RotateCw, Trash2 } from 'lucide-react';
import type {RLCommand,RLPolicyStatus} from '../../rl/types'; import type { RLCommand, RLPolicyStatus } from '../../rl/types';
import {Badge,Button,PropertyRow,Select} from '../../components/ui'; import { Badge, Button, PropertyRow, Select } from '../../components/ui';
export interface RLPolicyPanelProps { export interface RLPolicyPanelProps {
paths:string[];selectedPath?:string;status?:RLPolicyStatus;loading:boolean; paths: string[];
onSelectPath(path:string):void;onLoadPath(path:string):void;onImport(file:File):void; selectedPath?: string;
onToggle(enabled:boolean):void;onCommand(command:RLCommand):void;onRemove():void; status?: RLPolicyStatus;
loading: boolean;
onSelectPath(path: string): void;
onLoadPath(path: string): void;
onImport(file: File): void;
onToggle(enabled: boolean): void;
onCommand(command: RLCommand): void;
onRemove(): void;
} }
export function RLPolicyPanel({paths,selectedPath,status,loading,onSelectPath,onLoadPath,onImport,onToggle,onCommand,onRemove}:RLPolicyPanelProps){ export function RLPolicyPanel({
const input=useRef<HTMLInputElement>(null); paths,
const importFile=(event:ChangeEvent<HTMLInputElement>)=>{const file=event.target.files?.[0];if(file)onImport(file);event.target.value='';}; selectedPath,
const command=status?.command??{linearX:0,linearY:0,angularZ:0}; status,
return <div> loading,
<input ref={input} className="hidden" type="file" accept=".onnx,application/octet-stream" onChange={importFile}/> onSelectPath,
{paths.length>0&&<label className="mb-3 block text-xs text-text-secondary"><span className="mb-1 block">工程内策略</span><Select aria-label="ONNX 策略" className="w-full" value={selectedPath??''} disabled={loading} onChange={event=>onSelectPath(event.target.value)}><option value="">选择 .onnx 文件</option>{paths.map(path=><option key={path} value={path}>{path}</option>)}</Select></label>} onLoadPath,
<div className="grid grid-cols-2 gap-2"><Button icon={<FileUp className="h-3.5 w-3.5"/>} disabled={loading} onClick={()=>input.current?.click()}>导入 ONNX</Button><Button icon={<RotateCw className="h-3.5 w-3.5"/>} disabled={loading||!selectedPath} onClick={()=>selectedPath&&onLoadPath(selectedPath)}>加载策略</Button></div> onImport,
{status?<div className="mt-3 rounded-lg border border-border bg-surface p-2.5"> onToggle,
<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" title={status.path}><BrainCircuit className="h-3.5 w-3.5 shrink-0 text-accent"/>{status.taskName}</span><Badge>{status.enabled?'推理中':'已停止'}</Badge></div> onCommand,
<PropertyRow label="控制频率" value={`${status.controlHz} Hz`}/><PropertyRow label="观测 / 动作" value={`${status.observationSize} / ${status.actionSize}`}/><PropertyRow label="推理次数" value={status.inferenceCount}/><PropertyRow label="上次推理" value={`${status.lastInferenceMs.toFixed(2)} ms`}/> onRemove,
<div className="mt-3 border-t border-border pt-3"><p className="mb-2 text-[10px] text-text-tertiary">速度指令(机身坐标系)</p><CommandInput label="前向 m/s" value={command.linearX} min={-0.5} max={1} onChange={linearX=>onCommand({...command,linearX})}/><CommandInput label="侧向 m/s" value={command.linearY} min={-0.5} max={0.5} onChange={linearY=>onCommand({...command,linearY})}/><CommandInput label="偏航 rad/s" value={command.angularZ} min={-1} max={1} onChange={angularZ=>onCommand({...command,angularZ})}/><Button className="mt-1 w-full" onClick={()=>onCommand({linearX:0,linearY:0,angularZ:0})}>停止移动</Button></div> }: RLPolicyPanelProps) {
{status.error&&<p role="alert" className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger">{status.error}</p>} const input = useRef<HTMLInputElement>(null);
<div className="mt-3 grid grid-cols-2 gap-2"><Button variant={status.enabled?'secondary':'primary'} icon={<Power className="h-3.5 w-3.5"/>} disabled={Boolean(status.error)} onClick={()=>onToggle(!status.enabled)}>{status.enabled?'停止':'启用'}</Button><Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5"/>} onClick={onRemove}>卸载</Button></div> const importFile = (event: ChangeEvent<HTMLInputElement>) => {
</div>:<p className="mt-3 text-xs leading-5 text-text-tertiary">加载 mjlab 导出的单输入、单动作输出 policy.onnx。首个内置任务使用 47 维 Go2 actor 观测和 12 维腿部关节位置动作;Go2-W 轮电机保持零力矩。</p>} const file = event.target.files?.[0];
</div>; if (file) onImport(file);
event.target.value = '';
};
const command = status?.command ?? { linearX: 0, linearY: 0, angularZ: 0 };
return (
<div>
<input
ref={input}
className="hidden"
type="file"
accept=".onnx,application/octet-stream"
onChange={importFile}
/>
{paths.length > 0 && (
<label className="mb-3 block text-xs text-text-secondary">
<span className="mb-1 block">工程内策略</span>
<Select
aria-label="ONNX 策略"
className="w-full"
value={selectedPath ?? ''}
disabled={loading}
onChange={(event) => onSelectPath(event.target.value)}
>
<option value="">选择 .onnx 文件</option>
{paths.map((path) => (
<option key={path} value={path}>
{path}
</option>
))}
</Select>
</label>
)}
<div className="grid grid-cols-2 gap-2">
<Button
icon={<FileUp className="h-3.5 w-3.5" />}
disabled={loading}
onClick={() => input.current?.click()}
>
导入 ONNX
</Button>
<Button
icon={<RotateCw className="h-3.5 w-3.5" />}
disabled={loading || !selectedPath}
onClick={() => selectedPath && onLoadPath(selectedPath)}
>
加载策略
</Button>
</div>
{status ? (
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<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"
title={status.path}
>
<BrainCircuit className="h-3.5 w-3.5 shrink-0 text-accent" />
{status.taskName}
</span>
<Badge>{status.enabled ? '推理中' : '已停止'}</Badge>
</div>
<PropertyRow label="控制频率" value={`${status.controlHz} Hz`} />
<PropertyRow
label="观测 / 动作"
value={`${status.observationSize} / ${status.actionSize}`}
/>
<PropertyRow label="推理次数" value={status.inferenceCount} />
<PropertyRow label="上次推理" value={`${status.lastInferenceMs.toFixed(2)} ms`} />
<div className="mt-3 border-t border-border pt-3">
<p className="mb-2 text-[10px] text-text-tertiary">速度指令(机身坐标系)</p>
<CommandInput
label="前向 m/s"
value={command.linearX}
min={-0.5}
max={1}
onChange={(linearX) => onCommand({ ...command, linearX })}
/>
<CommandInput
label="侧向 m/s"
value={command.linearY}
min={-0.5}
max={0.5}
onChange={(linearY) => onCommand({ ...command, linearY })}
/>
<CommandInput
label="偏航 rad/s"
value={command.angularZ}
min={-1}
max={1}
onChange={(angularZ) => onCommand({ ...command, angularZ })}
/>
<Button
className="mt-1 w-full"
onClick={() => onCommand({ linearX: 0, linearY: 0, angularZ: 0 })}
>
停止移动
</Button>
</div>
{status.error && (
<p
role="alert"
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
>
{status.error}
</p>
)}
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
variant={status.enabled ? 'secondary' : 'primary'}
icon={<Power className="h-3.5 w-3.5" />}
disabled={Boolean(status.error)}
onClick={() => onToggle(!status.enabled)}
>
{status.enabled ? '停止' : '启用'}
</Button>
<Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5" />} onClick={onRemove}>
卸载
</Button>
</div>
</div>
) : (
<p className="mt-3 text-xs leading-5 text-text-tertiary">
加载 mjlab 导出的单输入、单动作输出 policy.onnx。首个内置任务使用 47 维 Go2 actor 观测和
12 维腿部关节位置动作;Go2-W 轮电机保持零力矩。
</p>
)}
</div>
);
} }
function CommandInput({label,value,min,max,onChange}:{label:string;value:number;min:number;max:number;onChange(value:number):void}){return <label className="mb-2 grid grid-cols-[1fr_72px] items-center gap-2 text-[10px] text-text-tertiary"><span>{label}</span><input className="field h-7 w-full px-2 text-right text-xs text-text-primary" type="number" step="0.05" min={min} max={max} value={value} onChange={event=>onChange(Number(event.target.value))}/></label>;} function CommandInput({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange(value: number): void;
}) {
return (
<label className="mb-2 grid grid-cols-[1fr_72px] items-center gap-2 text-[10px] text-text-tertiary">
<span>{label}</span>
<input
className="field h-7 w-full px-2 text-right text-xs text-text-primary"
type="number"
step="0.05"
min={min}
max={max}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
);
}
@@ -1,15 +1,62 @@
import {fireEvent,render,screen} from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import {ShortcutHelpDialog} from './ShortcutHelpDialog'; import { ShortcutHelpDialog } from './ShortcutHelpDialog';
import {TreeSearchField} from './TreeSearchField'; import { TreeSearchField } from './TreeSearchField';
import {ViewportHUD} from './ViewportHUD'; import { ViewportHUD } from './ViewportHUD';
import {EmptyWorkspace} from './WorkspaceOverlays'; import { EmptyWorkspace } from './WorkspaceOverlays';
import {ViewerDisplayPopover} from './ViewerDisplayPopover'; import { ViewerDisplayPopover } from './ViewerDisplayPopover';
import {DEFAULT_VIEWER_DISPLAY_OPTIONS} from '../../viewer/displayOptions'; import { DEFAULT_VIEWER_DISPLAY_OPTIONS } from '../../viewer/displayOptions';
describe('第二批工作台组件',()=>{ describe('第二批工作台组件', () => {
it('搜索框发送内容并可清除',()=>{const change=vi.fn();const {rerender}=render(<TreeSearchField value="" onChange={change}/>);fireEvent.change(screen.getByRole('searchbox'),{target:{value:'arm'}});expect(change).toHaveBeenCalledWith('arm');rerender(<TreeSearchField value="arm" resultCount={2} onChange={change}/>);expect(screen.getByRole('status')).toHaveTextContent('找到 2 个匹配项');fireEvent.click(screen.getByRole('button',{name:'清除搜索'}));expect(change).toHaveBeenLastCalledWith('');}); it('搜索框发送内容并可清除', () => {
it('快捷键帮助展示说明并支持 Escape',()=>{const close=vi.fn();render(<ShortcutHelpDialog open onClose={close}/>);expect(screen.getByRole('dialog',{name:'快捷键与视口操作'})).toBeVisible();expect(screen.getByText('播放 / 暂停')).toBeVisible();fireEvent.keyDown(document,{key:'Escape'});expect(close).toHaveBeenCalledTimes(1);}); const change = vi.fn();
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('右键平移');}); const { rerender } = render(<TreeSearchField value="" onChange={change} />);
it('空工作区解释导入到仿真的三步流程',()=>{render(<EmptyWorkspace/>);expect(screen.getByRole('region',{name:'导入模型工程'})).toBeVisible();expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('导入');expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('检查与配置');expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('运行与调试');expect(screen.getByText('模型与资源仅在当前浏览器会话中处理')).toBeVisible();}); fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'arm' } });
it('显示浮窗切换碰撞体和结构辅助标记',()=>{const change=vi.fn();render(<ViewerDisplayPopover value={{...DEFAULT_VIEWER_DISPLAY_OPTIONS}} onChange={change}/>);fireEvent.click(screen.getByRole('button',{name:'显示设置'}));expect(screen.getByRole('dialog',{name:'视图显示设置'})).toBeVisible();expect(screen.getAllByRole('switch')).toHaveLength(7);fireEvent.click(screen.getByRole('switch',{name:/碰撞体/}));expect(change).toHaveBeenCalledWith({...DEFAULT_VIEWER_DISPLAY_OPTIONS,showCollision:true});}); expect(change).toHaveBeenCalledWith('arm');
rerender(<TreeSearchField value="arm" resultCount={2} onChange={change} />);
expect(screen.getByRole('status')).toHaveTextContent('找到 2 个匹配项');
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }));
expect(change).toHaveBeenLastCalledWith('');
});
it('快捷键帮助展示说明并支持 Escape', () => {
const close = vi.fn();
render(<ShortcutHelpDialog open onClose={close} />);
expect(screen.getByRole('dialog', { name: '快捷键与视口操作' })).toBeVisible();
expect(screen.getByText('播放 / 暂停')).toBeVisible();
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();
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('导入');
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('检查与配置');
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('运行与调试');
expect(screen.getByText('模型与资源仅在当前浏览器会话中处理')).toBeVisible();
});
it('显示浮窗切换碰撞体和结构辅助标记', () => {
const change = vi.fn();
render(
<ViewerDisplayPopover value={{ ...DEFAULT_VIEWER_DISPLAY_OPTIONS }} onChange={change} />,
);
fireEvent.click(screen.getByRole('button', { name: '显示设置' }));
expect(screen.getByRole('dialog', { name: '视图显示设置' })).toBeVisible();
expect(screen.getAllByRole('switch')).toHaveLength(7);
fireEvent.click(screen.getByRole('switch', { name: /碰撞体/ }));
expect(change).toHaveBeenCalledWith({ ...DEFAULT_VIEWER_DISPLAY_OPTIONS, showCollision: true });
});
}); });
@@ -1,3 +1,106 @@
import {Dialog,PropertyRow,Select} from '../../components/ui'; import { Dialog, PropertyRow, Select } from '../../components/ui';
export function SettingsDialog({open,onClose,theme,angleUnit,showCollision,jointAdvanced,forceScale,onTheme,onAngleUnit,onShowCollision,onJointAdvanced,onForceScale}:{open:boolean;onClose:()=>void;theme:'light'|'dark';angleUnit:'rad'|'deg';showCollision:boolean;jointAdvanced:boolean;forceScale:number;onTheme:(value:'light'|'dark')=>void;onAngleUnit:(value:'rad'|'deg')=>void;onShowCollision:(value:boolean)=>void;onJointAdvanced:(value:boolean)=>void;onForceScale:(value:number)=>void}){return <Dialog open={open} onClose={onClose} title="工作台设置"><div className="space-y-4"><section><h3 className="mb-2 text-xs font-semibold">外观</h3><PropertyRow label="主题" value={<Select aria-label="设置主题" value={theme} onChange={event=>onTheme(event.target.value as 'light'|'dark')}><option value="dark">黑夜</option><option value="light">白天</option></Select>}/></section><section><h3 className="mb-2 text-xs font-semibold">模型与控制</h3><PropertyRow label="角度单位" value={<Select aria-label="设置角度单位" value={angleUnit} onChange={event=>onAngleUnit(event.target.value as 'rad'|'deg')}><option value="rad">弧度</option><option value="deg">角度</option></Select>}/><Check label="显示碰撞几何" checked={showCollision} onChange={onShowCollision}/><Check label="关节高级信息" checked={jointAdvanced} onChange={onJointAdvanced}/><label className="mt-3 block text-xs text-text-tertiary"><span className="mb-1 flex justify-between"><span>外力强度</span><output>{forceScale.toFixed(0)} N</output></span><input aria-label="设置外力强度" type="range" min={5} max={200} value={forceScale} onChange={event=>onForceScale(Number(event.target.value))} className="control-slider"/></label></section></div></Dialog>;} export function SettingsDialog({
function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-2 flex items-center justify-between text-xs text-text-tertiary"><span>{label}</span><input type="checkbox" aria-label={label} checked={checked} onChange={event=>onChange(event.target.checked)} className="accent-accent focus-visible:ring-2 focus-visible:ring-accent/30"/></label>;} open,
onClose,
theme,
angleUnit,
showCollision,
jointAdvanced,
forceScale,
onTheme,
onAngleUnit,
onShowCollision,
onJointAdvanced,
onForceScale,
}: {
open: boolean;
onClose: () => void;
theme: 'light' | 'dark';
angleUnit: 'rad' | 'deg';
showCollision: boolean;
jointAdvanced: boolean;
forceScale: number;
onTheme: (value: 'light' | 'dark') => void;
onAngleUnit: (value: 'rad' | 'deg') => void;
onShowCollision: (value: boolean) => void;
onJointAdvanced: (value: boolean) => void;
onForceScale: (value: number) => void;
}) {
return (
<Dialog open={open} onClose={onClose} title="工作台设置">
<div className="space-y-4">
<section>
<h3 className="mb-2 text-xs font-semibold">外观</h3>
<PropertyRow
label="主题"
value={
<Select
aria-label="设置主题"
value={theme}
onChange={(event) => onTheme(event.target.value as 'light' | 'dark')}
>
<option value="dark">黑夜</option>
<option value="light">白天</option>
</Select>
}
/>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold">模型与控制</h3>
<PropertyRow
label="角度单位"
value={
<Select
aria-label="设置角度单位"
value={angleUnit}
onChange={(event) => onAngleUnit(event.target.value as 'rad' | 'deg')}
>
<option value="rad">弧度</option>
<option value="deg">角度</option>
</Select>
}
/>
<Check label="显示碰撞几何" checked={showCollision} onChange={onShowCollision} />
<Check label="关节高级信息" checked={jointAdvanced} onChange={onJointAdvanced} />
<label className="mt-3 block text-xs text-text-tertiary">
<span className="mb-1 flex justify-between">
<span>外力强度</span>
<output>{forceScale.toFixed(0)} N</output>
</span>
<input
aria-label="设置外力强度"
type="range"
min={5}
max={200}
value={forceScale}
onChange={(event) => onForceScale(Number(event.target.value))}
className="control-slider"
/>
</label>
</section>
</div>
</Dialog>
);
}
function Check({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-2 flex items-center justify-between text-xs text-text-tertiary">
<span>{label}</span>
<input
type="checkbox"
aria-label={label}
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="accent-accent focus-visible:ring-2 focus-visible:ring-accent/30"
/>
</label>
);
}
@@ -1,3 +1,37 @@
import {Dialog,Kbd,Separator} from '../../components/ui'; import { Dialog, Kbd, Separator } from '../../components/ui';
const shortcuts=[['Space','播放 / 暂停'],['R','重置仿真'],['1','选择模式'],['2','关节拖动'],['3','外力施加']]; const shortcuts = [
export function ShortcutHelpDialog({open,onClose}:{open:boolean;onClose:()=>void}){return <Dialog open={open} onClose={onClose} title="快捷键与视口操作"><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"><dt className="text-text-secondary">{label}</dt><dd><Kbd>{key}</Kbd></dd></div>)}</dl></section><Separator className="my-4"/><section><h3 className="mb-2 text-xs font-semibold text-text-primary">鼠标操作</h3><ul className="space-y-1.5 text-xs text-text-secondary"><li>左键拖动:旋转相机或执行当前交互工具</li><li>右键拖动:平移相机</li><li>滚轮:缩放视口</li><li>选择物体后可在右侧“属性”中查看信息</li></ul></section></Dialog>;} ['Space', '播放 / 暂停'],
['R', '重置仿真'],
['1', '选择模式'],
['2', '关节拖动'],
['3', '外力施加'],
];
export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
return (
<Dialog open={open} onClose={onClose} title="快捷键与视口操作">
<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">
<dt className="text-text-secondary">{label}</dt>
<dd>
<Kbd>{key}</Kbd>
</dd>
</div>
))}
</dl>
</section>
<Separator className="my-4" />
<section>
<h3 className="mb-2 text-xs font-semibold text-text-primary">鼠标操作</h3>
<ul className="space-y-1.5 text-xs text-text-secondary">
<li>左键拖动:旋转相机或执行当前交互工具</li>
<li>右键拖动:平移相机</li>
<li>滚轮:缩放视口</li>
<li>选择物体后可在右侧“属性”中查看信息</li>
</ul>
</section>
</Dialog>
);
}
+909 -63
View File
@@ -1,67 +1,913 @@
import {useState,type ReactNode} from 'react'; import { useState, type ReactNode } from 'react';
import {Box,FolderTree,Info,Settings2,SlidersHorizontal} from 'lucide-react'; import {
import type {ModelEntry} from '../../project/types'; Box,
import {countProjectSearchResults,ProjectTree,type ProjectTreeFile} from '../../project/ProjectTree'; Database,
import {countModelStructureSearchResults,ModelStructureTree} from '../../project/ModelStructureTree'; FolderTree,
import type {ActuatorInfo,ActuatorParameters,SimulationSnapshot} from '../../simulation/SimulationSession'; Info,
import type {UrdfBaseMode,UrdfLoadMode} from '../../simulation/PhysicsAdapter'; Map as MapIcon,
import type {ViewerSelection} from '../../viewer/MuJoCoViewer'; Settings2,
import type {ControllerCommand,ControllerStatus} from '../../controller/types'; SlidersHorizontal,
import type {RLCommand,RLPolicyStatus} from '../../rl/types'; } from 'lucide-react';
import {Badge,Button,CollapsibleSection,CopyButton,PropertyRow,ResizablePanel,Select,Tabs} from '../../components/ui'; import type { MapEntry, ModelEntry } from '../../project/types';
import {TreeSearchField} from './TreeSearchField'; import {
import {ProjectBreadcrumb} from './ProjectBreadcrumb'; countProjectSearchResults,
import {PythonControllerPanel} from './PythonControllerPanel'; ProjectTree,
import {RLPolicyPanel} from './RLPolicyPanel'; type ProjectTreeFile,
import {LocalTrainingPanel} from './LocalTrainingPanel'; } from '../../project/ProjectTree';
import {
countModelStructureSearchResults,
ModelStructureTree,
} from '../../project/ModelStructureTree';
import type {
ActuatorInfo,
ActuatorParameters,
SimulationSnapshot,
} from '../../simulation/SimulationSession';
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
import type { DataRecorderConfig } from '../../simulation/DataRecorder';
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
import {
Badge,
Button,
CollapsibleSection,
CopyButton,
PropertyRow,
ResizablePanel,
Select,
Tabs,
} from '../../components/ui';
import { TreeSearchField } from './TreeSearchField';
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { PythonControllerPanel } from './PythonControllerPanel';
import { RLPolicyPanel } from './RLPolicyPanel';
import { LocalTrainingPanel } from './LocalTrainingPanel';
import { DataRecordingPanel } from './DataRecordingPanel';
import { PhysicalMapPanel } from './PhysicalMapPanel';
import { MapAssetLibrary } from './MapAssetLibrary';
import {
DEFAULT_PHYSICAL_MAP_CONFIG,
type MapSelection,
type SystemTerrainPreset,
} from '../../map/types';
import type {
EditableMapDocument,
EditableMapObjectType,
MapEditorInteractionCallbacks,
MapEditorTransformMode,
MapObjectPlacementMode,
} from '../../map/editor/types';
export function SidebarPanel({title,side,children,visible=true}:{title:string;side:'left'|'right';children:ReactNode;visible?:boolean}){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`}><h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel px-3 text-sm font-semibold text-text-primary"><Settings2 aria-hidden="true" className="h-4 w-4 text-accent"/>{title}</h2>{children}</aside></ResizablePanel>;} export function SidebarPanel({
title,
export function ProjectSidebar({projectName,files,entries,selectedEntry,snapshot,loading,visible=true,onRemove,onSelectEntry,onJointHover}:{projectName?:string;files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string;snapshot?:SimulationSnapshot;loading:boolean;visible?:boolean;onRemove:()=>void;onSelectEntry:(path:string)=>void;onJointHover:(jointId:number|null)=>void}){const [tab,setTab]=useState<'project'|'structure'>('project'),[fileQuery,setFileQuery]=useState(''),[structureQuery,setStructureQuery]=useState('');const fileMatches=countProjectSearchResults(files,fileQuery),structureMatches=snapshot?countModelStructureSearchResults(snapshot.bodies,snapshot.joints,structureQuery):0;return <SidebarPanel title="工程资源" side="left" visible={visible}>{projectName?<><div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5"><div className="min-w-0 flex-1"><div className="truncate text-sm font-medium text-accent" title={projectName}>{projectName}</div><div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} 个文件</div></div><Button variant="danger" onClick={onRemove} disabled={loading}>移除</Button></div><ProjectBreadcrumb projectName={projectName} entries={entries} selectedEntry={selectedEntry} loading={loading} onSelect={onSelectEntry}/><Tabs label="工程侧栏" value={tab} onValueChange={setTab} items={[{value:'project',label:'工程',icon:<FolderTree className="h-3.5 w-3.5"/>,content:<><TreeSearchField value={fileQuery} onChange={setFileQuery} resultCount={fileMatches} label="搜索工程文件" placeholder="搜索文件或目录…"/><div className="px-2 pb-3"><ProjectTree key={projectName} files={files} entries={entries} selectedEntry={selectedEntry} query={fileQuery}/></div></>},{value:'structure',label:'模型结构',icon:<Box className="h-3.5 w-3.5"/>,disabled:!snapshot,content:snapshot?<><TreeSearchField value={structureQuery} onChange={setStructureQuery} resultCount={structureMatches} label="搜索模型结构" placeholder="搜索 Body 或关节…"/><div className="px-2 pb-3"><ModelStructureTree bodies={snapshot.bodies} joints={snapshot.joints} onJointHover={onJointHover} query={structureQuery}/></div></>:<p className="p-4 text-center text-xs text-text-tertiary">加载模型后显示结构</p>}]}/></>:<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示工程资源</div>}</SidebarPanel>;} side,
children,
interface ModelControlsProps{ visible = true,
snapshot?:SimulationSnapshot;selection:ViewerSelection|null;selectedFormat?:ModelEntry['format'];loading:boolean;visible?:boolean; }: {
urdfMode:UrdfLoadMode;baseMode:UrdfBaseMode;showCollision:boolean;ignoreJointLimits:boolean;jointAdvanced:boolean;angleUnit:'rad'|'deg';forceScale:number; title: string;
controllerPaths:string[];selectedControllerPath?:string;controllerStatus?:ControllerStatus; side: 'left' | 'right';
policyPaths:string[];selectedPolicyPath?:string;policyStatus?:RLPolicyStatus; children: ReactNode;
onUrdfMode:(value:UrdfLoadMode)=>void;onBaseMode:(value:UrdfBaseMode)=>void;onShowCollision:(value:boolean)=>void; visible?: boolean;
onResetJoints:()=>void;onToggleJointLimits:()=>void;onToggleAdvanced:()=>void;onToggleAngleUnit:()=>void; }) {
onActuator:(id:number,value:number)=>void;onActuatorParameters:(id:number,parameters:ActuatorParameters)=>void;onJoint:(id:number,value:number)=>void;onForceScale:(value:number)=>void; return (
onSelectControllerPath:(path:string)=>void;onLoadControllerPath:(path:string)=>void;onImportController:(file:File)=>void;onToggleController:(enabled:boolean)=>void;onControllerCommand:(command:ControllerCommand)=>void;onRemoveController:()=>void; <ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}>
onSelectPolicyPath:(path:string)=>void;onLoadPolicyPath:(path:string)=>void;onImportPolicy:(file:File)=>void;onTogglePolicy:(enabled:boolean)=>void;onPolicyCommand:(command:RLCommand)=>void;onRemovePolicy:()=>void; <aside
} className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border`}
export function ModelControlsSidebar(props:ModelControlsProps){const [tab,setTab]=useState<'properties'|'controls'>('properties'),s=props.snapshot;if(!s)return <SidebarPanel title="模型与控制" side="right" visible={props.visible}><div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div></SidebarPanel>; >
const properties=<><CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}><div><PropertyRow label="Body" value={s.model.nbody}/><PropertyRow label="Joint" value={s.model.njnt}/><PropertyRow label="Geom" value={s.model.ngeom}/><PropertyRow label="Actuator" value={s.model.nactuator}/><PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`}/></div></CollapsibleSection> <h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel/95 px-3 text-sm font-semibold tracking-tight text-text-primary">
{props.selectedFormat==='urdf'&&<CollapsibleSection title="URDF 处理方式" defaultOpen={false}><Select aria-label="URDF 处理方式" className="w-full" value={props.urdfMode} disabled={props.loading} onChange={event=>props.onUrdfMode(event.target.value as UrdfLoadMode)}><option value="mjcf">转换为 MJCF(推荐)</option><option value="native">MuJoCo 原生 URDF</option></Select><label className="mt-3 block text-xs text-text-secondary"><span className="mb-1 block">基座类型</span><Select aria-label="URDF 基座类型" className="w-full" value={props.baseMode} disabled={props.loading||props.urdfMode==='native'} onChange={event=>props.onBaseMode(event.target.value as UrdfBaseMode)}><option value="floating">浮动基座(Free Joint)</option><option value="fixed">固定基座(连接世界)</option></Select></label><p className="mt-2 text-xs text-text-tertiary">MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。</p><Check label="显示碰撞几何" checked={props.showCollision} onChange={props.onShowCollision}/></CollapsibleSection>} <span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent">
<CollapsibleSection title="当前选择" defaultOpen>{props.selection?<div className="text-xs"><PropertyRow label="Body" value={props.selection.bodyName} action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称"/>}/><PropertyRow label="标识" value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`} action={<CopyButton value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`} label="复制标识"/>}/><PropertyRow label="位置" value={props.selection.position.map(value=>value.toFixed(3)).join(', ')} action={<CopyButton value={props.selection.position.join(', ')} label="复制位置"/>}/></div>:<p className="flex items-center gap-2 text-xs text-text-tertiary"><Info className="h-3.5 w-3.5"/>在视口中单击物体</p>}</CollapsibleSection></>; <Settings2 aria-hidden="true" className="h-3.5 w-3.5" />
const controls=<><CollapsibleSection title="ONNX 强化学习策略" defaultOpen badge={s.rlPolicy?<Badge>{s.rlPolicy.enabled?'推理':'停止'}</Badge>:undefined}><RLPolicyPanel paths={props.policyPaths} selectedPath={props.selectedPolicyPath} status={props.policyStatus??s.rlPolicy} loading={props.loading} onSelectPath={props.onSelectPolicyPath} onLoadPath={props.onLoadPolicyPath} onImport={props.onImportPolicy} onToggle={props.onTogglePolicy} onCommand={props.onPolicyCommand} onRemove={props.onRemovePolicy}/></CollapsibleSection><CollapsibleSection title="本地强化学习训练" defaultOpen={false}><LocalTrainingPanel onPolicyReady={props.onImportPolicy}/></CollapsibleSection><CollapsibleSection title="Python 控制器" defaultOpen badge={s.controller?<Badge>{s.controller.enabled?'运行':'停止'}</Badge>:undefined}><PythonControllerPanel paths={props.controllerPaths} selectedPath={props.selectedControllerPath} status={props.controllerStatus??s.controller} loading={props.loading} onSelectPath={props.onSelectControllerPath} onLoadPath={props.onLoadControllerPath} onImport={props.onImportController} onToggle={props.onToggleController} onCommand={props.onControllerCommand} onRemove={props.onRemoveController}/></CollapsibleSection><CollapsibleSection title="Actuator" defaultOpen={false} badge={<Badge>{s.actuators.length}</Badge>}>{s.actuators.length?s.actuators.map(actuator=><ActuatorControl key={actuator.id} actuator={actuator} onControl={value=>props.onActuator(actuator.id,value)} onParameters={parameters=>props.onActuatorParameters(actuator.id,parameters)}/>):<p className="text-xs text-text-tertiary">模型没有驱动器</p>}</CollapsibleSection> </span>
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}><div className="mb-4 grid grid-cols-2 gap-2"><Button onClick={props.onResetJoints}>重置关节</Button><Button variant={props.ignoreJointLimits?'primary':'secondary'} aria-pressed={props.ignoreJointLimits} onClick={props.onToggleJointLimits}>忽略关节限位</Button><Button variant={props.jointAdvanced?'primary':'secondary'} aria-pressed={props.jointAdvanced} onClick={props.onToggleAdvanced}>高级</Button><Button variant={props.angleUnit==='deg'?'primary':'secondary'} aria-pressed={props.angleUnit==='deg'} onClick={props.onToggleAngleUnit}>{props.angleUnit==='rad'?'rad 弧度制':'° 角度制'}</Button></div>{s.joints.map(joint=>{const scale=joint.type===3&&props.angleUnit==='deg'?180/Math.PI:1,unit=joint.type===3?(props.angleUnit==='deg'?'°':' rad'):joint.type===2?' m':'';return <ControlSlider key={joint.id} label={`${joint.name}${joint.editable?'':'(只读)'}`} value={joint.value*scale} min={joint.min*scale} max={joint.max*scale} unit={unit} advanced={props.jointAdvanced} limited={joint.limited} limitsIgnored={joint.limitsIgnored} limitMin={joint.limitMin*scale} limitMax={joint.limitMax*scale} disabled={!joint.editable} onChange={value=>props.onJoint(joint.id,value/scale)}/>;})}</CollapsibleSection> {title}
<CollapsibleSection title="外力强度" defaultOpen={false}><ControlSlider label={`${props.forceScale.toFixed(0)} N/屏幕单位`} value={props.forceScale} min={5} max={200} onChange={props.onForceScale}/><p className="text-xs text-text-tertiary">选择“外力施加”,在动态物体上按住拖动,松开即清零。</p></CollapsibleSection></>; </h2>
return <SidebarPanel title="模型与控制" side="right" visible={props.visible}><Tabs label="模型控制侧栏" value={tab} onValueChange={setTab} items={[{value:'properties',label:'属性',icon:<Info className="h-3.5 w-3.5"/>,content:properties},{value:'controls',label:'控制',icon:<SlidersHorizontal className="h-3.5 w-3.5"/>,content:controls}]}/></SidebarPanel>; {children}
</aside>
</ResizablePanel>
);
} }
export function ActuatorControl({actuator,onControl,onParameters}:{actuator:ActuatorInfo;onControl:(value:number)=>void;onParameters:(parameters:ActuatorParameters)=>void}){ export function ProjectSidebar({
const isMotor=actuator.kind==='motor',isPosition=actuator.kind==='position',editable=isMotor||isPosition,baseTargetScale=isPosition&&actuator.jointType===3?180/Math.PI:1,targetScale=isPosition&&Math.abs(actuator.gear)>1e-9?baseTargetScale/actuator.gear:baseTargetScale; projectName,
const clampForce=(value:number)=>actuator.forceLimited?Math.min(actuator.forceMax,Math.max(actuator.forceMin,value)):value,physicalScale=actuator.gear*actuator.gain; files,
const forceA=clampForce(actuator.min*actuator.gain)*actuator.gear,forceB=clampForce(actuator.max*actuator.gain)*actuator.gear; entries,
const targetA=actuator.min*targetScale,targetB=actuator.max*targetScale,outputMin=isMotor?Math.min(forceA,forceB):Math.min(targetA,targetB),outputMax=isMotor?Math.max(forceA,forceB):Math.max(targetA,targetB); selectedEntry,
const outputValue=isMotor?clampForce(actuator.value*actuator.gain)*actuator.gear:actuator.value*targetScale,outputDisabled=(isMotor&&Math.abs(physicalScale)<=1e-9)||(isPosition&&Math.abs(actuator.gear)<=1e-9); snapshot,
const outputLabel=isMotor?(actuator.jointType===3?'输出力矩':'输出力'):isPosition?(actuator.jointType===3?'目标角度':'目标位置'):'控制输入'; loading,
const forceUnit=actuator.jointType===3?'N·m':actuator.jointType===2?'N':'',jointForceA=actuator.forceMin*actuator.gear,jointForceB=actuator.forceMax*actuator.gear,jointForceMin=Math.min(jointForceA,jointForceB),jointForceMax=Math.max(jointForceA,jointForceB); visible = true,
const update=(patch:Partial<ActuatorParameters>)=>onParameters({...actuator,...patch}),controlLabel=isPosition?(actuator.jointType===3?'角度':'位置'):'控制',gearSquared=actuator.gear*actuator.gear; nativeUrdf,
return <div className="mb-3 rounded-lg border border-border bg-surface p-2.5"> mapSelection,
<div className="mb-2 flex min-w-0 items-start justify-between gap-2"><div className="min-w-0"><div className="truncate text-xs font-medium text-text-primary" title={actuator.name}>{actuator.name}</div><div className="mt-0.5 truncate text-[10px] text-text-tertiary">{actuator.jointName?`关节:${actuator.jointName}`:'未关联标量关节'}</div></div><Badge>{actuator.unit||'u'}</Badge></div> editorDocument,
{actuator.controlCount===1?<ControlSlider label={outputLabel} value={outputValue} min={outputMin} max={outputMax} disabled={outputDisabled} unit={actuator.unit?` ${actuator.unit}`:''} onChange={value=>{if(outputDisabled)return;onControl(isMotor?value/physicalScale:value/targetScale);}}/>:<p className="mb-2 text-[10px] leading-4 text-text-tertiary">该驱动器包含 {actuator.controlCount} 个控制分量,请在 MJCF 源码或专用控制器中设置。</p>} activeTab,
{actuator.controlCount===1&&!actuator.ctrlLimited&&<div className="mb-2"><ParameterInput label={`${controlLabel}输入(不限幅)`} value={actuator.value*targetScale} onCommit={value=>onControl(value/targetScale)}/></div>} onActiveTabChange,
{editable?<details className="group border-t border-border pt-2"><summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary">常用参数</summary> onRemove,
<div className="mt-2 grid grid-cols-2 gap-2">{isPosition?<><ParameterInput label={`位置增益 kp(${forceUnit}/${actuator.jointType===3?'rad':'m'})`} value={actuator.kp*gearSquared} disabled={gearSquared<=1e-18} onCommit={kp=>update({kp:kp/gearSquared})}/><ParameterInput label={`速度增益 kv(${forceUnit}·s/${actuator.jointType===3?'rad':'m'})`} value={actuator.kv*gearSquared} disabled={gearSquared<=1e-18} onCommit={kv=>update({kv:kv/gearSquared})}/></>:<><ParameterInput label={`kp(MJCF stiffness,${forceUnit}/${actuator.jointType===3?'rad':'m'})`} value={actuator.kp} onCommit={kp=>update({kp})}/><ParameterInput label={`kv(MJCF damping,${forceUnit}·s/${actuator.jointType===3?'rad':'m'})`} value={actuator.kv} onCommit={kv=>update({kv})}/></>}</div> onSelectEntry,
<ParameterToggle label={`限制输出${actuator.jointType===3?'力矩':'力'}${forceUnit?`(${forceUnit})`:''}`} checked={actuator.forceLimited} onChange={forceLimited=>update({forceLimited})}/> onJointHover,
<div className="mt-2 grid grid-cols-2 gap-2"><ParameterInput label="输出下限" value={jointForceMin} disabled={!actuator.forceLimited||Math.abs(actuator.gear)<=1e-9} onCommit={value=>update(actuator.gear>=0?{forceMin:value/actuator.gear}:{forceMax:value/actuator.gear})}/><ParameterInput label="输出上限" value={jointForceMax} disabled={!actuator.forceLimited||Math.abs(actuator.gear)<=1e-9} onCommit={value=>update(actuator.gear>=0?{forceMax:value/actuator.gear}:{forceMin:value/actuator.gear})}/></div> onAddMapAsset,
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">{isPosition?'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。':'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'} 参数修改会立即作用于当前模型,并可随 MJCF 导出。</p> onSelectTerrain,
</details>:<p className="border-t border-border pt-2 text-[10px] leading-4 text-text-tertiary">该驱动器不是可直接编辑的 motor/position 类型,控制值按模型原始单位显示;请在 MJCF 源码中修改专用参数。</p>} onSelectMapObject,
</div>; }: {
projectName?: string;
files: ProjectTreeFile[];
entries: ModelEntry[];
selectedEntry?: string;
snapshot?: SimulationSnapshot;
loading: boolean;
visible?: boolean;
nativeUrdf: boolean;
mapSelection: MapSelection;
editorDocument: EditableMapDocument | null;
activeTab?: 'project' | 'structure' | 'assets';
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
onRemove: () => void;
onSelectEntry: (path: string) => void;
onJointHover: (jointId: number | null) => void;
onAddMapAsset: (
type: EditableMapObjectType,
placementMode: MapObjectPlacementMode,
) => void | Promise<void>;
onSelectTerrain: (preset: SystemTerrainPreset) => void;
onSelectMapObject: (id: string) => void;
}) {
const [internalTab, setInternalTab] = useState<'project' | 'structure' | 'assets'>('project'),
[fileQuery, setFileQuery] = useState(''),
[structureQuery, setStructureQuery] = useState('');
const tab = activeTab ?? internalTab,
fileMatches = countProjectSearchResults(files, fileQuery),
structureMatches = snapshot
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
: 0;
return (
<SidebarPanel title="资产与结构" side="left" visible={visible}>
{projectName ? (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-accent" title={projectName}>
{projectName}
</div>
<div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} 个文件</div>
</div>
<Button variant="danger" onClick={onRemove} disabled={loading}>
移除
</Button>
</div>
<ProjectBreadcrumb
projectName={projectName}
entries={entries}
selectedEntry={selectedEntry}
loading={loading}
onSelect={onSelectEntry}
/>
<Tabs
label="工程侧栏"
value={tab}
onValueChange={(value) => {
setInternalTab(value);
onActiveTabChange?.(value);
}}
items={[
{
value: 'project',
label: '工程',
icon: <FolderTree className="h-3.5 w-3.5" />,
content: (
<>
<TreeSearchField
value={fileQuery}
onChange={setFileQuery}
resultCount={fileMatches}
label="搜索工程文件"
placeholder="搜索文件或目录…"
/>
<div className="px-2 pb-3">
<ProjectTree
key={projectName}
files={files}
entries={entries}
selectedEntry={selectedEntry}
query={fileQuery}
/>
</div>
</>
),
},
{
value: 'structure',
label: '模型结构',
icon: <Box className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: snapshot ? (
<>
<TreeSearchField
value={structureQuery}
onChange={setStructureQuery}
resultCount={structureMatches}
label="搜索模型结构"
placeholder="搜索 Body 或关节…"
/>
<div className="px-2 pb-3">
<ModelStructureTree
bodies={snapshot.bodies}
joints={snapshot.joints}
onJointHover={onJointHover}
query={structureQuery}
/>
</div>
</>
) : (
<p className="p-4 text-center text-xs text-text-tertiary">加载模型后显示结构</p>
),
},
{
value: 'assets',
label: '资产',
icon: <MapIcon className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: (
<MapAssetLibrary
disabled={loading || nativeUrdf}
terrainSize={
mapSelection.kind === 'builtin'
? mapSelection.config.size
: DEFAULT_PHYSICAL_MAP_CONFIG.size
}
document={editorDocument}
onAdd={onAddMapAsset}
onSelectTerrain={onSelectTerrain}
onSelectObject={onSelectMapObject}
/>
),
},
]}
/>
</>
) : (
<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示工程资源</div>
)}
</SidebarPanel>
);
}
interface ModelControlsProps {
snapshot?: SimulationSnapshot;
selection: ViewerSelection | null;
selectedFormat?: ModelEntry['format'];
loading: boolean;
visible?: boolean;
urdfMode: UrdfLoadMode;
baseMode: UrdfBaseMode;
showCollision: boolean;
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
forceScale: number;
controllerPaths: string[];
selectedControllerPath?: string;
controllerStatus?: ControllerStatus;
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
mapSelection: MapSelection;
maps: MapEntry[];
showVisualMap: boolean;
showMapCollision: boolean;
editorDocument: EditableMapDocument | null;
onUrdfMode: (value: UrdfLoadMode) => void;
onBaseMode: (value: UrdfBaseMode) => void;
onShowCollision: (value: boolean) => void;
onResetJoints: () => void;
onToggleJointLimits: () => void;
onToggleAdvanced: () => void;
onToggleAngleUnit: () => void;
onActuator: (id: number, value: number) => void;
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
onJoint: (id: number, value: number) => void;
onForceScale: (value: number) => void;
onSelectControllerPath: (path: string) => void;
onLoadControllerPath: (path: string) => void;
onImportController: (file: File) => void;
onToggleController: (enabled: boolean) => void;
onControllerCommand: (command: ControllerCommand) => void;
onRemoveController: () => void;
onSelectPolicyPath: (path: string) => void;
onLoadPolicyPath: (path: string) => void;
onImportPolicy: (file: File) => void;
onTogglePolicy: (enabled: boolean) => void;
onPolicyCommand: (command: RLCommand) => void;
onRemovePolicy: () => void;
onApplyMap: (value: MapSelection) => void;
onEditorPreview: (document: EditableMapDocument | null) => void;
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
onEditorExport: () => void;
onEditorConvert: () => Promise<boolean>;
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
onEditorSelect: (id: string | null) => void;
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
onMapDisplay: (visual: boolean, collision: boolean) => void;
onMapTabOpen?: () => void;
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
onDataRecordingStart: () => void;
onDataRecordingStop: () => void;
onDataRecordingClear: () => void;
onDataRecordingExport: (format: 'csv' | 'json') => void;
}
export function ModelControlsSidebar(props: ModelControlsProps) {
const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'),
s = props.snapshot;
if (!s)
return (
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div>
</SidebarPanel>
);
const properties = (
<>
<CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}>
<div>
<PropertyRow label="Body" value={s.model.nbody} />
<PropertyRow label="Joint" value={s.model.njnt} />
<PropertyRow label="Geom" value={s.model.ngeom} />
<PropertyRow label="Actuator" value={s.model.nactuator} />
<PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`} />
</div>
</CollapsibleSection>
{props.selectedFormat === 'urdf' && (
<CollapsibleSection title="URDF 处理方式" defaultOpen={false}>
<Select
aria-label="URDF 处理方式"
className="w-full"
value={props.urdfMode}
disabled={props.loading}
onChange={(event) => props.onUrdfMode(event.target.value as UrdfLoadMode)}
>
<option value="mjcf">转换为 MJCF(推荐)</option>
<option value="native">MuJoCo 原生 URDF</option>
</Select>
<label className="mt-3 block text-xs text-text-secondary">
<span className="mb-1 block">基座类型</span>
<Select
aria-label="URDF 基座类型"
className="w-full"
value={props.baseMode}
disabled={props.loading || props.urdfMode === 'native'}
onChange={(event) => props.onBaseMode(event.target.value as UrdfBaseMode)}
>
<option value="floating">浮动基座(Free Joint)</option>
<option value="fixed">固定基座(连接世界)</option>
</Select>
</label>
<p className="mt-2 text-xs text-text-tertiary">
MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。
</p>
<Check
label="显示碰撞几何"
checked={props.showCollision}
onChange={props.onShowCollision}
/>
</CollapsibleSection>
)}
<CollapsibleSection title="当前选择" defaultOpen>
{props.selection ? (
<div className="text-xs">
<PropertyRow
label="Body"
value={props.selection.bodyName}
action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称" />}
/>
<PropertyRow
label="标识"
value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`}
action={
<CopyButton
value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`}
label="复制标识"
/>
}
/>
<PropertyRow
label="位置"
value={props.selection.position.map((value) => value.toFixed(3)).join(', ')}
action={<CopyButton value={props.selection.position.join(', ')} label="复制位置" />}
/>
</div>
) : (
<p className="flex items-center gap-2 text-xs text-text-tertiary">
<Info className="h-3.5 w-3.5" />
在视口中单击物体
</p>
)}
</CollapsibleSection>
</>
);
const controls = (
<>
<CollapsibleSection
title="ONNX 强化学习策略"
defaultOpen
badge={s.rlPolicy ? <Badge>{s.rlPolicy.enabled ? '推理' : '停止'}</Badge> : undefined}
>
<RLPolicyPanel
paths={props.policyPaths}
selectedPath={props.selectedPolicyPath}
status={props.policyStatus ?? s.rlPolicy}
loading={props.loading}
onSelectPath={props.onSelectPolicyPath}
onLoadPath={props.onLoadPolicyPath}
onImport={props.onImportPolicy}
onToggle={props.onTogglePolicy}
onCommand={props.onPolicyCommand}
onRemove={props.onRemovePolicy}
/>
</CollapsibleSection>
<CollapsibleSection title="本地强化学习训练" defaultOpen={false}>
<LocalTrainingPanel onPolicyReady={props.onImportPolicy} />
</CollapsibleSection>
<CollapsibleSection
title="Python 控制器"
defaultOpen
badge={s.controller ? <Badge>{s.controller.enabled ? '运行' : '停止'}</Badge> : undefined}
>
<PythonControllerPanel
paths={props.controllerPaths}
selectedPath={props.selectedControllerPath}
status={props.controllerStatus ?? s.controller}
loading={props.loading}
onSelectPath={props.onSelectControllerPath}
onLoadPath={props.onLoadControllerPath}
onImport={props.onImportController}
onToggle={props.onToggleController}
onCommand={props.onControllerCommand}
onRemove={props.onRemoveController}
/>
</CollapsibleSection>
<CollapsibleSection
title="Actuator"
defaultOpen={false}
badge={<Badge>{s.actuators.length}</Badge>}
>
{s.actuators.length ? (
s.actuators.map((actuator) => (
<ActuatorControl
key={actuator.id}
actuator={actuator}
onControl={(value) => props.onActuator(actuator.id, value)}
onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)}
/>
))
) : (
<p className="text-xs text-text-tertiary">模型没有驱动器</p>
)}
</CollapsibleSection>
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}>
<div className="mb-4 grid grid-cols-2 gap-2">
<Button onClick={props.onResetJoints}>重置关节</Button>
<Button
variant={props.ignoreJointLimits ? 'primary' : 'secondary'}
aria-pressed={props.ignoreJointLimits}
onClick={props.onToggleJointLimits}
>
忽略关节限位
</Button>
<Button
variant={props.jointAdvanced ? 'primary' : 'secondary'}
aria-pressed={props.jointAdvanced}
onClick={props.onToggleAdvanced}
>
高级
</Button>
<Button
variant={props.angleUnit === 'deg' ? 'primary' : 'secondary'}
aria-pressed={props.angleUnit === 'deg'}
onClick={props.onToggleAngleUnit}
>
{props.angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
</Button>
</div>
{s.joints.map((joint) => {
const scale = joint.type === 3 && props.angleUnit === 'deg' ? 180 / Math.PI : 1,
unit =
joint.type === 3
? props.angleUnit === 'deg'
? '°'
: ' rad'
: joint.type === 2
? ' m'
: '';
return (
<ControlSlider
key={joint.id}
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
value={joint.value * scale}
min={joint.min * scale}
max={joint.max * scale}
unit={unit}
advanced={props.jointAdvanced}
limited={joint.limited}
limitsIgnored={joint.limitsIgnored}
limitMin={joint.limitMin * scale}
limitMax={joint.limitMax * scale}
disabled={!joint.editable}
onChange={(value) => props.onJoint(joint.id, value / scale)}
/>
);
})}
</CollapsibleSection>
<CollapsibleSection title="外力强度" defaultOpen={false}>
<ControlSlider
label={`${props.forceScale.toFixed(0)} N/屏幕单位`}
value={props.forceScale}
min={5}
max={200}
onChange={props.onForceScale}
/>
<p className="text-xs text-text-tertiary">
选择“外力施加”,在动态物体上按住拖动,松开即清零。
</p>
</CollapsibleSection>
</>
);
return (
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
<Tabs
label="模型控制侧栏"
value={tab}
onValueChange={(value) => {
setTab(value);
if (value === 'map') props.onMapTabOpen?.();
}}
items={[
{
value: 'properties',
label: '属性',
icon: <Info className="h-3.5 w-3.5" />,
content: properties,
},
{
value: 'controls',
label: '控制',
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
content: controls,
},
{
value: 'data',
label: '数据',
icon: <Database className="h-3.5 w-3.5" />,
content: (
<DataRecordingPanel
status={s.telemetry}
bodies={s.bodies}
onConfigure={props.onDataRecorderConfigure}
onStart={props.onDataRecordingStart}
onStop={props.onDataRecordingStop}
onClear={props.onDataRecordingClear}
onExport={props.onDataRecordingExport}
/>
),
},
{
value: 'map',
label: '地图',
icon: <MapIcon className="h-3.5 w-3.5" />,
content: (
<PhysicalMapPanel
key={JSON.stringify(props.mapSelection)}
value={props.mapSelection}
maps={props.maps}
rootBodies={s.bodies
.filter(
(body) =>
body.id !== 0 &&
body.parentId === 0 &&
!body.name.startsWith('__platform_map_'),
)
.map((body) => body.name)}
loading={props.loading}
nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'}
showVisualMap={props.showVisualMap}
showMapCollision={props.showMapCollision}
editorDocument={props.editorDocument}
onEditorPreview={props.onEditorPreview}
onEditorApply={props.onEditorApply}
onEditorExport={props.onEditorExport}
onEditorConvert={props.onEditorConvert}
onEditorBindInteraction={props.onEditorBindInteraction}
onEditorSelect={props.onEditorSelect}
onEditorTransformMode={props.onEditorTransformMode}
onEditorSnapping={props.onEditorSnapping}
onMapDisplay={props.onMapDisplay}
onApply={props.onApplyMap}
/>
),
},
]}
/>
</SidebarPanel>
);
}
export function ActuatorControl({
actuator,
onControl,
onParameters,
}: {
actuator: ActuatorInfo;
onControl: (value: number) => void;
onParameters: (parameters: ActuatorParameters) => void;
}) {
const isMotor = actuator.kind === 'motor',
isPosition = actuator.kind === 'position',
editable = isMotor || isPosition,
baseTargetScale = isPosition && actuator.jointType === 3 ? 180 / Math.PI : 1,
targetScale =
isPosition && Math.abs(actuator.gear) > 1e-9
? baseTargetScale / actuator.gear
: baseTargetScale;
const clampForce = (value: number) =>
actuator.forceLimited
? Math.min(actuator.forceMax, Math.max(actuator.forceMin, value))
: value,
physicalScale = actuator.gear * actuator.gain;
const forceA = clampForce(actuator.min * actuator.gain) * actuator.gear,
forceB = clampForce(actuator.max * actuator.gain) * actuator.gear;
const targetA = actuator.min * targetScale,
targetB = actuator.max * targetScale,
outputMin = isMotor ? Math.min(forceA, forceB) : Math.min(targetA, targetB),
outputMax = isMotor ? Math.max(forceA, forceB) : Math.max(targetA, targetB);
const outputValue = isMotor
? clampForce(actuator.value * actuator.gain) * actuator.gear
: actuator.value * targetScale,
outputDisabled =
(isMotor && Math.abs(physicalScale) <= 1e-9) ||
(isPosition && Math.abs(actuator.gear) <= 1e-9);
const outputLabel = isMotor
? actuator.jointType === 3
? '输出力矩'
: '输出力'
: isPosition
? actuator.jointType === 3
? '目标角度'
: '目标位置'
: '控制输入';
const forceUnit = actuator.jointType === 3 ? 'N·m' : actuator.jointType === 2 ? 'N' : '',
jointForceA = actuator.forceMin * actuator.gear,
jointForceB = actuator.forceMax * actuator.gear,
jointForceMin = Math.min(jointForceA, jointForceB),
jointForceMax = Math.max(jointForceA, jointForceB);
const update = (patch: Partial<ActuatorParameters>) => onParameters({ ...actuator, ...patch }),
controlLabel = isPosition ? (actuator.jointType === 3 ? '角度' : '位置') : '控制',
gearSquared = actuator.gear * actuator.gear;
return (
<div className="mb-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-medium text-text-primary" title={actuator.name}>
{actuator.name}
</div>
<div className="mt-0.5 truncate text-[10px] text-text-tertiary">
{actuator.jointName ? `关节:${actuator.jointName}` : '未关联标量关节'}
</div>
</div>
<Badge>{actuator.unit || 'u'}</Badge>
</div>
{actuator.controlCount === 1 ? (
<ControlSlider
label={outputLabel}
value={outputValue}
min={outputMin}
max={outputMax}
disabled={outputDisabled}
unit={actuator.unit ? ` ${actuator.unit}` : ''}
onChange={(value) => {
if (outputDisabled) return;
onControl(isMotor ? value / physicalScale : value / targetScale);
}}
/>
) : (
<p className="mb-2 text-[10px] leading-4 text-text-tertiary">
该驱动器包含 {actuator.controlCount} 个控制分量,请在 MJCF 源码或专用控制器中设置。
</p>
)}
{actuator.controlCount === 1 && !actuator.ctrlLimited && (
<div className="mb-2">
<ParameterInput
label={`${controlLabel}输入(不限幅)`}
value={actuator.value * targetScale}
onCommit={(value) => onControl(value / targetScale)}
/>
</div>
)}
{editable ? (
<details className="group border-t border-border pt-2">
<summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary">
常用参数
</summary>
<div className="mt-2 grid grid-cols-2 gap-2">
{isPosition ? (
<>
<ParameterInput
label={`位置增益 kp(${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kp * gearSquared}
disabled={gearSquared <= 1e-18}
onCommit={(kp) => update({ kp: kp / gearSquared })}
/>
<ParameterInput
label={`速度增益 kv(${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kv * gearSquared}
disabled={gearSquared <= 1e-18}
onCommit={(kv) => update({ kv: kv / gearSquared })}
/>
</>
) : (
<>
<ParameterInput
label={`kp(MJCF stiffness,${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kp}
onCommit={(kp) => update({ kp })}
/>
<ParameterInput
label={`kv(MJCF damping,${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kv}
onCommit={(kv) => update({ kv })}
/>
</>
)}
</div>
<ParameterToggle
label={`限制输出${actuator.jointType === 3 ? '力矩' : '力'}${forceUnit ? `(${forceUnit})` : ''}`}
checked={actuator.forceLimited}
onChange={(forceLimited) => update({ forceLimited })}
/>
<div className="mt-2 grid grid-cols-2 gap-2">
<ParameterInput
label="输出下限"
value={jointForceMin}
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
onCommit={(value) =>
update(
actuator.gear >= 0
? { forceMin: value / actuator.gear }
: { forceMax: value / actuator.gear },
)
}
/>
<ParameterInput
label="输出上限"
value={jointForceMax}
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
onCommit={(value) =>
update(
actuator.gear >= 0
? { forceMax: value / actuator.gear }
: { forceMin: value / actuator.gear },
)
}
/>
</div>
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">
{isPosition
? 'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。'
: 'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'}{' '}
参数修改会立即作用于当前模型,并可随 MJCF 导出。
</p>
</details>
) : (
<p className="border-t border-border pt-2 text-[10px] leading-4 text-text-tertiary">
该驱动器不是可直接编辑的 motor/position 类型,控制值按模型原始单位显示;请在 MJCF
源码中修改专用参数。
</p>
)}
</div>
);
}
function ParameterInput({
label,
value,
onCommit,
disabled = false,
}: {
label: string;
value: number;
onCommit: (value: number) => void;
disabled?: boolean;
}) {
return (
<label className="block text-[10px] text-text-tertiary">
<span className="mb-1 block truncate">{label}</span>
<input
key={value}
type="number"
step="any"
defaultValue={Number.isFinite(value) ? value : 0}
disabled={disabled}
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
onBlur={(event) => {
const next = Number(event.currentTarget.value);
if (Number.isFinite(next) && next !== value) onCommit(next);
else event.currentTarget.value = String(value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
/>
</label>
);
}
function ParameterToggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-2 flex items-center gap-2 text-[11px] text-text-secondary">
<input
type="checkbox"
className="accent-accent"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
function Check({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-3 flex items-center gap-2 text-xs text-text-secondary">
<input
type="checkbox"
className="rounded accent-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-panel"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
function ControlSlider({
label,
value,
min,
max,
onChange,
disabled = false,
unit = '',
advanced = false,
limited = false,
limitsIgnored = false,
limitMin = 0,
limitMax = 0,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (value: number) => void;
disabled?: boolean;
unit?: string;
advanced?: boolean;
limited?: boolean;
limitsIgnored?: boolean;
limitMin?: number;
limitMax?: number;
}) {
const sane = Number.isFinite(value) ? value : 0,
format = (number: number) => `${number.toFixed(3)}${unit}`;
return (
<label className="mb-3 block text-xs">
<span className="mb-1 flex justify-between gap-2">
<span className="truncate text-text-secondary">{label}</span>
<output className="technical-value text-text-primary">{format(sane)}</output>
</span>
<input
className="control-slider rounded focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-panel"
type="range"
disabled={disabled}
value={Math.min(max, Math.max(min, sane))}
min={min}
max={max}
step={(max - min) / 500 || 0.001}
onChange={(event) => onChange(Number(event.target.value))}
/>
{advanced && (
<span className="mt-1 flex justify-between text-[10px] text-text-tertiary">
<span>下限 {limited ? format(limitMin) : '无限制'}</span>
{limitsIgnored && limited && <span className="text-warning">已忽略</span>}
<span>上限 {limited ? format(limitMax) : '无限制'}</span>
</span>
)}
</label>
);
} }
function ParameterInput({label,value,onCommit,disabled=false}:{label:string;value:number;onCommit:(value:number)=>void;disabled?:boolean}){return <label className="block text-[10px] text-text-tertiary"><span className="mb-1 block truncate">{label}</span><input key={value} type="number" step="any" defaultValue={Number.isFinite(value)?value:0} disabled={disabled} className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40" onBlur={event=>{const next=Number(event.currentTarget.value);if(Number.isFinite(next)&&next!==value)onCommit(next);else event.currentTarget.value=String(value);}} onKeyDown={event=>{if(event.key==='Enter')event.currentTarget.blur();}}/></label>;}
function ParameterToggle({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-2 flex items-center gap-2 text-[11px] text-text-secondary"><input type="checkbox" className="accent-accent" checked={checked} onChange={event=>onChange(event.target.checked)}/>{label}</label>;}
function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-3 flex items-center gap-2 text-xs text-text-secondary"><input type="checkbox" className="rounded accent-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-panel" checked={checked} onChange={event=>onChange(event.target.checked)}/>{label}</label>;}
function ControlSlider({label,value,min,max,onChange,disabled=false,unit='',advanced=false,limited=false,limitsIgnored=false,limitMin=0,limitMax=0}:{label:string;value:number;min:number;max:number;onChange:(value:number)=>void;disabled?:boolean;unit?:string;advanced?:boolean;limited?:boolean;limitsIgnored?:boolean;limitMin?:number;limitMax?:number}){const sane=Number.isFinite(value)?value:0,format=(number:number)=>`${number.toFixed(3)}${unit}`;return <label className="mb-3 block text-xs"><span className="mb-1 flex justify-between gap-2"><span className="truncate text-text-secondary">{label}</span><output className="technical-value text-text-primary">{format(sane)}</output></span><input className="control-slider rounded focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-panel" type="range" disabled={disabled} value={Math.min(max,Math.max(min,sane))} min={min} max={max} step={(max-min)/500||.001} onChange={event=>onChange(Number(event.target.value))}/>{advanced&&<span className="mt-1 flex justify-between text-[10px] text-text-tertiary"><span>下限 {limited?format(limitMin):'无限制'}</span>{limitsIgnored&&limited&&<span className="text-warning">已忽略</span>}<span>上限 {limited?format(limitMax):'无限制'}</span></span>}</label>;}
@@ -1,29 +1,248 @@
import './monacoSetup'; import './monacoSetup';
import Editor from '@monaco-editor/react'; import Editor from '@monaco-editor/react';
import {useCallback,useEffect,useRef,useState,type PointerEvent as ReactPointerEvent} from 'react'; import {
import {Check,Code2,Copy,Download,Maximize2,Minimize2,Save,X} from 'lucide-react'; useCallback,
import {downloadBytes} from '../../project/cachedFiles'; useEffect,
import {Button,ConfirmDialog,IconButton} from '../../components/ui'; useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { Check, Code2, Copy, Download, Maximize2, Minimize2, Save, X } from 'lucide-react';
import { downloadBytes } from '../../project/cachedFiles';
import { Button, ConfirmDialog, IconButton } from '../../components/ui';
function basename(path:string):string{return path.split('/').at(-1)??path;} function basename(path: string): string {
function contentSize(content:string):string{const bytes=new Blob([content]).size;return bytes<1024?`${bytes} B`:`${(bytes/1024).toFixed(1)} KB`;} return path.split('/').at(-1) ?? path;
function xmlProblem(code:string):string|undefined{const document=new DOMParser().parseFromString(code,'application/xml'),error=document.querySelector('parsererror');return error?.textContent?.split('\n')[0]||undefined;} }
function contentSize(content: string): string {
export function SourceEditorDialog({open,code:sourceCode,filePath,theme,onClose,onSave}:{open:boolean;code:string;filePath:string;theme:'light'|'dark';onClose:()=>void;onSave:(path:string,text:string)=>void|Promise<void>}){ const bytes = new Blob([content]).size;
const [code,setCode]=useState(sourceCode),[savedCode,setSavedCode]=useState(sourceCode),[saving,setSaving]=useState(false),[copied,setCopied]=useState(false),[maximized,setMaximized]=useState(false),[discardOpen,setDiscardOpen]=useState(false),[position,setPosition]=useState(()=>({x:Math.max(24,(window.innerWidth-900)/2),y:Math.max(52,(window.innerHeight-650)/2)})); return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
const drag=useRef<{x:number;y:number;left:number;top:number}|null>(null),dialog=useRef<HTMLElement>(null),previousFocus=useRef<HTMLElement|null>(null),dirty=code!==savedCode,problem=xmlProblem(code); }
const requestClose=useCallback(()=>{if(dirty)setDiscardOpen(true);else onClose();},[dirty,onClose]); function xmlProblem(code: string): string | undefined {
const save=useCallback(async()=>{if(!dirty||problem)return;setSaving(true);try{await onSave(filePath,code);setSavedCode(code);}finally{setSaving(false);}},[code,dirty,filePath,onSave,problem]); const document = new DOMParser().parseFromString(code, 'application/xml'),
useEffect(()=>{if(!open)return;previousFocus.current=document.activeElement instanceof HTMLElement?document.activeElement:null;requestAnimationFrame(()=>dialog.current?.focus());return()=>{if(previousFocus.current&&document.contains(previousFocus.current))previousFocus.current.focus();};},[open]); error = document.querySelector('parsererror');
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(discardOpen)return;if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='s'&&dirty&&!problem){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]); return error?.textContent?.split('\n')[0] || undefined;
const copy=async()=>{await navigator.clipboard.writeText(code);setCopied(true);window.setTimeout(()=>setCopied(false),1500);}; }
const download=()=>downloadBytes(new TextEncoder().encode(code),basename(filePath),'application/xml');
const pointerDown=(event:ReactPointerEvent)=>{if(maximized||event.button!==0||(event.target as HTMLElement).closest('button'))return;drag.current={x:event.clientX,y:event.clientY,left:position.x,top:position.y};event.currentTarget.setPointerCapture(event.pointerId);}; export function SourceEditorDialog({
const pointerMove=(event:ReactPointerEvent)=>{if(!drag.current)return;setPosition({x:Math.min(window.innerWidth-120,Math.max(-780,drag.current.left+event.clientX-drag.current.x)),y:Math.min(window.innerHeight-48,Math.max(0,drag.current.top+event.clientY-drag.current.y))});}; open,
if(!open)return null; code: sourceCode,
return <><div className="fixed inset-0 z-[390] pointer-events-none" role="presentation"><section ref={dialog} tabIndex={-1} role="dialog" aria-modal="false" aria-label="转换后的 MJCF 编辑器" style={maximized?undefined:{left:position.x,top:position.y,width:900,height:650}} 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'}`}> filePath,
<header className="flex h-11 shrink-0 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3" onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={()=>{drag.current=null;}} onDoubleClick={()=>setMaximized(value=>!value)}><Code2 className="h-4 w-4 shrink-0 text-accent"/><div className="min-w-0 flex-1"><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}>{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>{dirty&&<span className="rounded bg-warning-soft px-1.5 py-0.5 text-[9px] font-semibold text-warning">已修改</span>}<Button variant="primary" icon={<Save className="h-3 w-3"/>} disabled={!dirty||saving||Boolean(problem)} onClick={()=>void save()}>{saving?'重新载入中…':'保存并重新载入'}</Button><Button variant="ghost" icon={<Download className="h-3.5 w-3.5"/>} onClick={download}>下载</Button><Button variant="ghost" icon={copied?<Check className="h-3.5 w-3.5"/>:<Copy className="h-3.5 w-3.5"/>} onClick={()=>void copy()}>{copied?'已复制':'复制'}</Button><IconButton tooltip={maximized?'还原':'最大化'} aria-label={maximized?'还原':'最大化'} onClick={()=>setMaximized(value=>!value)}>{maximized?<Minimize2 className="h-4 w-4"/>:<Maximize2 className="h-4 w-4"/>}</IconButton><IconButton tooltip="关闭" aria-label="关闭源代码编辑器" onClick={requestClose}><X className="h-4 w-4"/></IconButton></header> theme,
<div className="min-h-0 flex-1 bg-input"><Editor height="100%" language="xml" theme={theme==='light'?'light':'vs-dark'} value={code} onChange={value=>setCode(value??'')} options={{automaticLayout:true,minimap:{enabled:false},fontFamily:"'JetBrains Mono','Fira Code',ui-monospace,monospace",fontSize:13,fontLigatures:true,scrollBeyondLastLine:false,wordWrap:'off',stickyScroll:{enabled:false},tabSize:2,formatOnPaste:true,formatOnType:true,lineNumbersMinChars:4,padding:{top:12,bottom:14},renderLineHighlight:'all'}}/></div> onClose,
<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'}>{problem?`XML 错误:${problem}`:'✓ XML 结构正常'}</div><div className="flex items-center gap-2 font-mono text-text-tertiary"><span>Ctrl+S 保存并重新载入</span><span>•</span><span>MJCF / XML</span></div></footer> onSave,
</section></div><ConfirmDialog open={discardOpen} title="放弃未保存的修改?" confirmLabel="放弃修改" cancelLabel="继续编辑" danger onConfirm={onClose} onClose={()=>setDiscardOpen(false)}><p className="text-sm text-text-secondary">当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。</p></ConfirmDialog></>; }: {
open: boolean;
code: string;
filePath: string;
theme: 'light' | 'dark';
onClose: () => void;
onSave: (path: string, text: string) => void | Promise<void>;
}) {
const [code, setCode] = useState(sourceCode),
[savedCode, setSavedCode] = useState(sourceCode),
[saving, setSaving] = useState(false),
[copied, setCopied] = useState(false),
[maximized, setMaximized] = useState(false),
[discardOpen, setDiscardOpen] = useState(false),
[position, setPosition] = useState(() => ({
x: Math.max(24, (window.innerWidth - 900) / 2),
y: Math.max(52, (window.innerHeight - 650) / 2),
}));
const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null),
dialog = useRef<HTMLElement>(null),
previousFocus = useRef<HTMLElement | null>(null),
dirty = code !== savedCode,
problem = xmlProblem(code);
const requestClose = useCallback(() => {
if (dirty) setDiscardOpen(true);
else onClose();
}, [dirty, onClose]);
const save = useCallback(async () => {
if (!dirty || problem) return;
setSaving(true);
try {
await onSave(filePath, code);
setSavedCode(code);
} finally {
setSaving(false);
}
}, [code, dirty, filePath, onSave, problem]);
useEffect(() => {
if (!open) return;
previousFocus.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
requestAnimationFrame(() => dialog.current?.focus());
return () => {
if (previousFocus.current && document.contains(previousFocus.current))
previousFocus.current.focus();
};
}, [open]);
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (discardOpen) return;
if (
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === 's' &&
dirty &&
!problem
) {
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]);
const copy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};
const download = () =>
downloadBytes(new TextEncoder().encode(code), basename(filePath), 'application/xml');
const pointerDown = (event: ReactPointerEvent) => {
if (maximized || event.button !== 0 || (event.target as HTMLElement).closest('button')) return;
drag.current = { x: event.clientX, y: event.clientY, left: position.x, top: position.y };
event.currentTarget.setPointerCapture(event.pointerId);
};
const pointerMove = (event: ReactPointerEvent) => {
if (!drag.current) return;
setPosition({
x: Math.min(
window.innerWidth - 120,
Math.max(-780, drag.current.left + event.clientX - drag.current.x),
),
y: Math.min(
window.innerHeight - 48,
Math.max(0, drag.current.top + event.clientY - drag.current.y),
),
});
};
if (!open) return null;
return (
<>
<div className="fixed inset-0 z-[390] pointer-events-none" role="presentation">
<section
ref={dialog}
tabIndex={-1}
role="dialog"
aria-modal="false"
aria-label="转换后的 MJCF 编辑器"
style={
maximized ? undefined : { left: position.x, top: position.y, width: 900, height: 650 }
}
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'}`}
>
<header
className="flex h-11 shrink-0 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3"
onPointerDown={pointerDown}
onPointerMove={pointerMove}
onPointerUp={() => {
drag.current = null;
}}
onDoubleClick={() => setMaximized((value) => !value)}
>
<Code2 className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<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}>
{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>
{dirty && (
<span className="rounded bg-warning-soft px-1.5 py-0.5 text-[9px] font-semibold text-warning">
已修改
</span>
)}
<Button
variant="primary"
icon={<Save className="h-3 w-3" />}
disabled={!dirty || saving || Boolean(problem)}
onClick={() => void save()}
>
{saving ? '重新载入中…' : '保存并重新载入'}
</Button>
<Button variant="ghost" icon={<Download className="h-3.5 w-3.5" />} onClick={download}>
下载
</Button>
<Button
variant="ghost"
icon={copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
onClick={() => void copy()}
>
{copied ? '已复制' : '复制'}
</Button>
<IconButton
tooltip={maximized ? '还原' : '最大化'}
aria-label={maximized ? '还原' : '最大化'}
onClick={() => setMaximized((value) => !value)}
>
{maximized ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</IconButton>
<IconButton tooltip="关闭" aria-label="关闭源代码编辑器" onClick={requestClose}>
<X className="h-4 w-4" />
</IconButton>
</header>
<div className="min-h-0 flex-1 bg-input">
<Editor
height="100%"
language="xml"
theme={theme === 'light' ? 'light' : 'vs-dark'}
value={code}
onChange={(value) => setCode(value ?? '')}
options={{
automaticLayout: true,
minimap: { enabled: false },
fontFamily: "'JetBrains Mono','Fira Code',ui-monospace,monospace",
fontSize: 13,
fontLigatures: true,
scrollBeyondLastLine: false,
wordWrap: 'off',
stickyScroll: { enabled: false },
tabSize: 2,
formatOnPaste: true,
formatOnType: true,
lineNumbersMinChars: 4,
padding: { top: 12, bottom: 14 },
renderLineHighlight: 'all',
}}
/>
</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'}>
{problem ? `XML 错误:${problem}` : '✓ XML 结构正常'}
</div>
<div className="flex items-center gap-2 font-mono text-text-tertiary">
<span>Ctrl+S 保存并重新载入</span>
<span>•</span>
<span>MJCF / XML</span>
</div>
</footer>
</section>
</div>
<ConfirmDialog
open={discardOpen}
title="放弃未保存的修改?"
confirmLabel="放弃修改"
cancelLabel="继续编辑"
danger
onConfirm={onClose}
onClose={() => setDiscardOpen(false)}
>
<p className="text-sm text-text-secondary">
当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。
</p>
</ConfirmDialog>
</>
);
} }
+68 -7
View File
@@ -1,7 +1,68 @@
import type {ReactNode} from 'react'; import type { ReactNode } from 'react';
import {Box,Clock3,MemoryStick,TriangleAlert} from 'lucide-react'; import { Box, Clock3, MemoryStick, TriangleAlert } from 'lucide-react';
import {Kbd} from '../../components/ui'; import { useShallow } from 'zustand/react/shallow';
import {PerformancePopover} from './PerformancePopover'; import { Kbd } from '../../components/ui';
export interface StatusBarProps{time?:number;fps:number;stepMs:number;memoryMb?:number;loaded:boolean;overBudget:boolean;} import { useAppStore } from '../../stores/useAppStore';
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>;} import { PerformancePopover } from './PerformancePopover';
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>{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>}<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>;} export interface StatusBarProps {
time?: number;
fps: number;
stepMs: number;
memoryMb?: number;
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>
{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>
)}
<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>
);
}
/** 仅让状态栏订阅高频性能数据,避免带动整个工作台重渲染。 */
export function StoreStatusBar() {
const metrics = useAppStore(
useShallow((state) => ({
time: state.snapshot?.time,
fps: state.fps,
stepMs: state.stepMs,
memoryMb: state.memoryMb,
loaded: Boolean(state.snapshot),
overBudget: state.overBudget,
})),
);
return <StatusBar {...metrics} />;
}
@@ -1,8 +1,31 @@
import {fireEvent,render,screen} from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import {CommandPalette,type WorkbenchCommand} from './CommandPalette'; import { CommandPalette, type WorkbenchCommand } from './CommandPalette';
import {PerformancePopover} from './PerformancePopover'; import { PerformancePopover } from './PerformancePopover';
describe('第三批工作台组件',()=>{ describe('第三批工作台组件', () => {
it('命令面板可搜索并执行现有命令',()=>{const run=vi.fn(),close=vi.fn(),commands:WorkbenchCommand[]=[{id:'reset',label:'重置仿真',group:'仿真',run},{id:'theme',label:'切换主题',group:'外观',run:vi.fn()}];render(<CommandPalette open onClose={close} commands={commands}/>);const input=screen.getByLabelText('搜索命令');fireEvent.change(input,{target:{value:'重置'}});expect(screen.queryByText('切换主题')).not.toBeInTheDocument();fireEvent.keyDown(input,{key:'Enter'});expect(run).toHaveBeenCalledTimes(1);expect(close).toHaveBeenCalledTimes(1);expect(input).toHaveAttribute('aria-controls');expect(input).toHaveAttribute('aria-activedescendant');}); it('命令面板可搜索并执行现有命令', () => {
it('状态栏性能入口展示已有指标',()=>{render(<PerformancePopover fps={59.6} stepMs={1.25} memoryMb={42.5} overBudget={false}/>);fireEvent.click(screen.getByRole('button'));expect(screen.getByRole('dialog',{name:'性能详情'})).toHaveTextContent('60 FPS');expect(screen.getByRole('dialog',{name:'性能详情'})).toHaveTextContent('42.5 MiB');fireEvent.keyDown(document,{key:'k',ctrlKey:true});expect(screen.queryByRole('dialog',{name:'性能详情'})).not.toBeInTheDocument();}); const run = vi.fn(),
close = vi.fn(),
commands: WorkbenchCommand[] = [
{ id: 'reset', label: '重置仿真', group: '仿真', run },
{ id: 'theme', label: '切换主题', group: '外观', run: vi.fn() },
];
render(<CommandPalette open onClose={close} commands={commands} />);
const input = screen.getByLabelText('搜索命令');
fireEvent.change(input, { target: { value: '重置' } });
expect(screen.queryByText('切换主题')).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: 'Enter' });
expect(run).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalledTimes(1);
expect(input).toHaveAttribute('aria-controls');
expect(input).toHaveAttribute('aria-activedescendant');
});
it('状态栏性能入口展示已有指标', () => {
render(<PerformancePopover fps={59.6} stepMs={1.25} memoryMb={42.5} overBudget={false} />);
fireEvent.click(screen.getByRole('button'));
expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('60 FPS');
expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('42.5 MiB');
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
expect(screen.queryByRole('dialog', { name: '性能详情' })).not.toBeInTheDocument();
});
}); });
@@ -1,3 +1,72 @@
import {CircleHelp,Expand,LayoutDashboard,Maximize,Search,Settings,SunMoon} from 'lucide-react'; import {
import {DropdownMenu} from '../../components/ui'; CircleHelp,
export function ToolbarOverflowMenu({fullscreen,onCommands,onLayout,onSettings,onFullscreen,onHelp,onTheme}:{fullscreen:boolean;onCommands:()=>void;onLayout:()=>void;onSettings:()=>void;onFullscreen:()=>void;onHelp:()=>void;onTheme:()=>void}){return <DropdownMenu label="更多工作台操作" className="xl:hidden" items={[{id:'commands',label:'命令面板',icon:<Search className="h-4 w-4"/>,onSelect:onCommands},{id:'layout',label:'布局设置',icon:<LayoutDashboard className="h-4 w-4"/>,onSelect:onLayout},{id:'settings',label:'工作台设置',icon:<Settings className="h-4 w-4"/>,onSelect:onSettings},{id:'fullscreen',label:fullscreen?'退出全屏':'进入全屏',icon:fullscreen?<Expand className="h-4 w-4"/>:<Maximize className="h-4 w-4"/>,onSelect:onFullscreen},{id:'help',label:'快捷键帮助',icon:<CircleHelp className="h-4 w-4"/>,onSelect:onHelp},{id:'theme',label:'切换主题',icon:<SunMoon className="h-4 w-4"/>,onSelect:onTheme}]}/>;} Expand,
LayoutDashboard,
Maximize,
Search,
Settings,
SunMoon,
} from 'lucide-react';
import { DropdownMenu } from '../../components/ui';
export function ToolbarOverflowMenu({
fullscreen,
onCommands,
onLayout,
onSettings,
onFullscreen,
onHelp,
onTheme,
}: {
fullscreen: boolean;
onCommands: () => void;
onLayout: () => void;
onSettings: () => void;
onFullscreen: () => void;
onHelp: () => void;
onTheme: () => void;
}) {
return (
<DropdownMenu
label="更多工作台操作"
className="xl:hidden"
items={[
{
id: 'commands',
label: '命令面板',
icon: <Search className="h-4 w-4" />,
onSelect: onCommands,
},
{
id: 'layout',
label: '布局设置',
icon: <LayoutDashboard className="h-4 w-4" />,
onSelect: onLayout,
},
{
id: 'settings',
label: '工作台设置',
icon: <Settings className="h-4 w-4" />,
onSelect: onSettings,
},
{
id: 'fullscreen',
label: fullscreen ? '退出全屏' : '进入全屏',
icon: fullscreen ? <Expand className="h-4 w-4" /> : <Maximize className="h-4 w-4" />,
onSelect: onFullscreen,
},
{
id: 'help',
label: '快捷键帮助',
icon: <CircleHelp className="h-4 w-4" />,
onSelect: onHelp,
},
{
id: 'theme',
label: '切换主题',
icon: <SunMoon className="h-4 w-4" />,
onSelect: onTheme,
},
]}
/>
);
}
@@ -1,3 +1,46 @@
import {Search,X} from 'lucide-react'; import { Search, X } from 'lucide-react';
import {IconButton} from '../../components/ui'; import { IconButton } from '../../components/ui';
export function TreeSearchField({value,onChange,resultCount,placeholder='搜索…',label='搜索树'}:{value:string;onChange:(value:string)=>void;resultCount?:number;placeholder?:string;label?:string}){return <div className="m-2"><div className="relative"><Search aria-hidden="true" className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-tertiary"/><input type="search" aria-label={label} value={value} onChange={event=>onChange(event.target.value)} placeholder={placeholder} className="h-8 w-full rounded-md border border-border bg-input pl-7 pr-8 text-xs text-text-primary placeholder:text-text-tertiary focus-visible:ring-2 focus-visible:ring-accent/35"/>{value&&<span className="absolute right-0.5 top-0.5"><IconButton aria-label="清除搜索" tooltip="清除搜索" onClick={()=>onChange('')}><X className="h-3.5 w-3.5"/></IconButton></span>}</div>{value&&resultCount!==undefined&&<p role="status" className="px-1 pt-1.5 text-[10px] text-text-tertiary">找到 {resultCount} 个匹配项</p>}</div>;} export function TreeSearchField({
value,
onChange,
resultCount,
placeholder = '搜索…',
label = '搜索树',
}: {
value: string;
onChange: (value: string) => void;
resultCount?: number;
placeholder?: string;
label?: string;
}) {
return (
<div className="m-2">
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-tertiary"
/>
<input
type="search"
aria-label={label}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="h-8 w-full rounded-md border border-border bg-input pl-7 pr-8 text-xs text-text-primary placeholder:text-text-tertiary focus-visible:ring-2 focus-visible:ring-accent/35"
/>
{value && (
<span className="absolute right-0.5 top-0.5">
<IconButton aria-label="清除搜索" tooltip="清除搜索" onClick={() => onChange('')}>
<X className="h-3.5 w-3.5" />
</IconButton>
</span>
)}
</div>
{value && resultCount !== undefined && (
<p role="status" className="px-1 pt-1.5 text-[10px] text-text-tertiary">
找到 {resultCount} 个匹配项
</p>
)}
</div>
);
}
@@ -1,31 +1,52 @@
import {fireEvent,render,screen} from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import {UrdfImportOptionsDialog} from './UrdfImportOptionsDialog'; import { UrdfImportOptionsDialog } from './UrdfImportOptionsDialog';
describe('UrdfImportOptionsDialog',()=>{ describe('UrdfImportOptionsDialog', () => {
it('默认选择关节驱动器和摄像头传感器',()=>{ it('默认选择关节驱动器和摄像头传感器', () => {
const onConfirm=vi.fn(); const onConfirm = vi.fn();
render(<UrdfImportOptionsDialog open path="robot.urdf" mountBodies={['base','head_link']} onConfirm={onConfirm} onSkip={()=>{}}/>); render(
expect(screen.getByRole('checkbox',{name:/为关节添加驱动器/})).toBeChecked(); <UrdfImportOptionsDialog
expect(screen.getByRole('checkbox',{name:/添加传感器/})).toBeChecked(); open
path="robot.urdf"
mountBodies={['base', 'head_link']}
onConfirm={onConfirm}
onSkip={() => {}}
/>,
);
expect(screen.getByRole('checkbox', { name: /为关节添加驱动器/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /添加传感器/ })).toBeChecked();
expect(screen.getByLabelText('摄像头固连 Body')).toHaveValue('head_link'); expect(screen.getByLabelText('摄像头固连 Body')).toHaveValue('head_link');
fireEvent.change(screen.getByLabelText('摄像头位置 X'),{target:{value:'0.2'}}); fireEvent.change(screen.getByLabelText('摄像头位置 X'), { target: { value: '0.2' } });
fireEvent.click(screen.getByRole('button',{name:'转换并加载'})); fireEvent.click(screen.getByRole('button', { name: '转换并加载' }));
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({addActuators:true,addSensors:true,sensorType:'camera',cameraMountBody:'head_link',cameraPosition:[.2,0,.05],cameraDirection:'+X'})); expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
addActuators: true,
addSensors: true,
sensorType: 'camera',
cameraMountBody: 'head_link',
cameraPosition: [0.2, 0, 0.05],
cameraDirection: '+X',
}),
);
}); });
it('允许分别关闭自动生成项',()=>{ it('允许分别关闭自动生成项', () => {
const onConfirm=vi.fn(); const onConfirm = vi.fn();
render(<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={onConfirm} onSkip={()=>{}}/>); render(
fireEvent.click(screen.getByRole('checkbox',{name:/为关节添加驱动器/})); <UrdfImportOptionsDialog open path="robot.urdf" onConfirm={onConfirm} onSkip={() => {}} />,
fireEvent.click(screen.getByRole('checkbox',{name:/添加传感器/})); );
fireEvent.click(screen.getByRole('button',{name:'转换并加载'})); fireEvent.click(screen.getByRole('checkbox', { name: /为关节添加驱动器/ }));
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({addActuators:false,addSensors:false,sensorType:'camera'})); fireEvent.click(screen.getByRole('checkbox', { name: /添加传感器/ }));
fireEvent.click(screen.getByRole('button', { name: '转换并加载' }));
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({ addActuators: false, addSensors: false, sensorType: 'camera' }),
);
}); });
it('可以不添加组件并继续加载',()=>{ it('可以不添加组件并继续加载', () => {
const onSkip=vi.fn(); const onSkip = vi.fn();
render(<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={()=>{}} onSkip={onSkip}/>); render(<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={() => {}} onSkip={onSkip} />);
fireEvent.click(screen.getByRole('button',{name:'不添加,直接加载'})); fireEvent.click(screen.getByRole('button', { name: '不添加,直接加载' }));
expect(onSkip).toHaveBeenCalledOnce(); expect(onSkip).toHaveBeenCalledOnce();
}); });
}); });
@@ -1,26 +1,178 @@
import {useState,type ReactNode} from 'react'; import { useState, type ReactNode } from 'react';
import {Camera,Settings2} from 'lucide-react'; import { Camera, Settings2 } from 'lucide-react';
import type {CameraDirection,UrdfEnhancementOptions} from '../../project/urdfToMjcf'; import type { CameraDirection, UrdfEnhancementOptions } from '../../project/urdfToMjcf';
import {Button,Dialog,Select} from '../../components/ui'; import { Button, Dialog, Select } from '../../components/ui';
function OptionCard({checked,onChange,icon,title,description,children}:{checked:boolean;onChange:(checked:boolean)=>void;icon:ReactNode;title:string;description:string;children?:ReactNode}){ function OptionCard({
return <label className={`flex cursor-pointer gap-3 rounded-lg border p-3 transition-colors ${checked?'border-accent/60 bg-accent/10':'border-border bg-surface hover:bg-element-hover'}`}> checked,
<input className="mt-0.5 h-4 w-4 accent-accent" type="checkbox" checked={checked} onChange={event=>onChange(event.target.checked)}/> onChange,
<span className="mt-0.5 text-text-secondary" aria-hidden="true">{icon}</span> icon,
<span className="min-w-0 flex-1"><span className="block text-sm font-medium text-text-primary">{title}</span><span className="mt-1 block text-xs leading-5 text-text-secondary">{description}</span>{children}</span> title,
</label>; description,
children,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
icon: ReactNode;
title: string;
description: string;
children?: ReactNode;
}) {
return (
<label
className={`flex cursor-pointer gap-3 rounded-lg border p-3 transition-colors ${checked ? 'border-accent/60 bg-accent/10' : 'border-border bg-surface hover:bg-element-hover'}`}
>
<input
className="mt-0.5 h-4 w-4 accent-accent"
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
<span className="mt-0.5 text-text-secondary" aria-hidden="true">
{icon}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-text-primary">{title}</span>
<span className="mt-1 block text-xs leading-5 text-text-secondary">{description}</span>
{children}
</span>
</label>
);
} }
export function UrdfImportOptionsDialog({open,path,mountBodies=[],onConfirm,onSkip}:{open:boolean;path?:string;mountBodies?:string[];onConfirm:(options:UrdfEnhancementOptions)=>void;onSkip:()=>void}){ export function UrdfImportOptionsDialog({
const [options,setOptions]=useState<UrdfEnhancementOptions>(()=>({addActuators:true,addSensors:true,sensorType:'camera',cameraMountBody:mountBodies.find(name=>/(head|camera|sensor|neck|头)/i.test(name))??mountBodies.at(-1),cameraPosition:[.1,0,.05],cameraDirection:'+X'})); open,
const setPosition=(axis:number,value:number)=>setOptions(current=>{const position:[number,number,number]=[...(current.cameraPosition??[.1,0,.05])];position[axis]=Number.isFinite(value)?value:0;return {...current,cameraPosition:position};}); path,
return <Dialog open={open} onClose={onSkip} closable={false} title="配置 URDF 仿真组件" className="max-w-xl" footer={<div className="flex justify-end gap-2"><Button onClick={onSkip}>不添加,直接加载</Button><Button variant="primary" onClick={()=>onConfirm(options)}>转换并加载</Button></div>}> mountBodies = [],
<p className="text-sm text-text-secondary">导入 <strong className="text-text-primary">{path}</strong> 后,是否自动补充以下仿真组件?稍后重新选择该 URDF 时仍会再次询问。</p> onConfirm,
<div className="mt-4 space-y-3"> onSkip,
<OptionCard checked={options.addActuators} onChange={addActuators=>setOptions(value=>({...value,addActuators}))} icon={<Settings2 className="h-4 w-4"/>} title="为关节添加驱动器" description="为每个 hinge/slide 关节生成控制输入不限幅的 motor 驱动器;hinge 使用 N·m、slide 使用 N。kp/kv 用于调整对应 MJCF 关节的刚度和阻尼,已有驱动器不会重复添加。"/> }: {
<OptionCard checked={options.addSensors} onChange={addSensors=>setOptions(value=>({...value,addSensors}))} icon={<Camera className="h-4 w-4"/>} title="添加传感器" description="在浮动基座添加三轴陀螺仪和三轴加速度计(6轴 IMU),并添加一台 640×480 固定摄像头。"/> open: boolean;
{options.addSensors&&<div className="rounded-lg border border-border bg-surface p-3"><div className="mb-2 text-xs font-medium text-text-primary">摄像头安装参数</div><label className="block text-[11px] text-text-secondary"><span className="mb-1 block">固连 Body</span><Select aria-label="摄像头固连 Body" className="w-full" value={options.cameraMountBody??''} onChange={event=>setOptions(value=>({...value,cameraMountBody:event.target.value||undefined}))}>{mountBodies.length?mountBodies.map(name=><option key={name} value={name}>{name}</option>):<option value="">自动选择头部/末端 Body</option>}</Select></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><input aria-label={`摄像头位置 ${axis}`} className="field h-8 w-full px-2 text-xs" type="number" step="0.01" value={(options.cameraPosition??[.1,0,.05])[index]} onChange={event=>setPosition(index,Number(event.target.value))}/></label>)}</div><label className="mt-3 block text-[11px] text-text-secondary"><span className="mb-1 block">镜头朝向(Body 局部轴)</span><Select aria-label="摄像头朝向" className="w-full" value={options.cameraDirection??'+X'} onChange={event=>setOptions(value=>({...value,cameraDirection:event.target.value as CameraDirection}))}>{(['+X','-X','+Y','-Y','+Z','-Z'] as CameraDirection[]).map(direction=><option key={direction}>{direction}</option>)}</Select></label><p className="mt-2 text-[10px] leading-4 text-text-tertiary">位置和朝向均相对于所选 Body;常见 ROS 头部摄像头使用 +X 朝前、+Z 朝上。</p></div>} path?: string;
</div> mountBodies?: string[];
<p className="mt-4 text-xs text-text-tertiary">自动组件只写入浏览器内生成的 MJCF,不会修改本地 URDF 文件;使用“原生 URDF”加载模式时不会注入这些组件。</p> onConfirm: (options: UrdfEnhancementOptions) => void;
</Dialog>; onSkip: () => void;
}) {
const [options, setOptions] = useState<UrdfEnhancementOptions>(() => ({
addActuators: true,
addSensors: true,
sensorType: 'camera',
cameraMountBody:
mountBodies.find((name) => /(head|camera|sensor|neck|头)/i.test(name)) ?? mountBodies.at(-1),
cameraPosition: [0.1, 0, 0.05],
cameraDirection: '+X',
}));
const setPosition = (axis: number, value: number) =>
setOptions((current) => {
const position: [number, number, number] = [...(current.cameraPosition ?? [0.1, 0, 0.05])];
position[axis] = Number.isFinite(value) ? value : 0;
return { ...current, cameraPosition: position };
});
return (
<Dialog
open={open}
onClose={onSkip}
closable={false}
title="配置 URDF 仿真组件"
className="max-w-xl"
footer={
<div className="flex justify-end gap-2">
<Button onClick={onSkip}>不添加,直接加载</Button>
<Button variant="primary" onClick={() => onConfirm(options)}>
转换并加载
</Button>
</div>
}
>
<p className="text-sm text-text-secondary">
导入 <strong className="text-text-primary">{path}</strong>{' '}
后,是否自动补充以下仿真组件?稍后重新选择该 URDF 时仍会再次询问。
</p>
<div className="mt-4 space-y-3">
<OptionCard
checked={options.addActuators}
onChange={(addActuators) => setOptions((value) => ({ ...value, addActuators }))}
icon={<Settings2 className="h-4 w-4" />}
title="为关节添加驱动器"
description="为每个 hinge/slide 关节生成控制输入不限幅的 motor 驱动器;hinge 使用 N·m、slide 使用 N。kp/kv 用于调整对应 MJCF 关节的刚度和阻尼,已有驱动器不会重复添加。"
/>
<OptionCard
checked={options.addSensors}
onChange={(addSensors) => setOptions((value) => ({ ...value, addSensors }))}
icon={<Camera className="h-4 w-4" />}
title="添加传感器"
description="在浮动基座添加三轴陀螺仪和三轴加速度计(6轴 IMU),并添加一台 640×480 固定摄像头。"
/>
{options.addSensors && (
<div className="rounded-lg border border-border bg-surface p-3">
<div className="mb-2 text-xs font-medium text-text-primary">摄像头安装参数</div>
<label className="block text-[11px] text-text-secondary">
<span className="mb-1 block">固连 Body</span>
<Select
aria-label="摄像头固连 Body"
className="w-full"
value={options.cameraMountBody ?? ''}
onChange={(event) =>
setOptions((value) => ({
...value,
cameraMountBody: event.target.value || undefined,
}))
}
>
{mountBodies.length ? (
mountBodies.map((name) => (
<option key={name} value={name}>
{name}
</option>
))
) : (
<option value="">自动选择头部/末端 Body</option>
)}
</Select>
</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>
<input
aria-label={`摄像头位置 ${axis}`}
className="field h-8 w-full px-2 text-xs"
type="number"
step="0.01"
value={(options.cameraPosition ?? [0.1, 0, 0.05])[index]}
onChange={(event) => setPosition(index, Number(event.target.value))}
/>
</label>
))}
</div>
<label className="mt-3 block text-[11px] text-text-secondary">
<span className="mb-1 block">镜头朝向(Body 局部轴)</span>
<Select
aria-label="摄像头朝向"
className="w-full"
value={options.cameraDirection ?? '+X'}
onChange={(event) =>
setOptions((value) => ({
...value,
cameraDirection: event.target.value as CameraDirection,
}))
}
>
{(['+X', '-X', '+Y', '-Y', '+Z', '-Z'] as CameraDirection[]).map((direction) => (
<option key={direction}>{direction}</option>
))}
</Select>
</label>
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">
位置和朝向均相对于所选 Body;常见 ROS 头部摄像头使用 +X 朝前、+Z 朝上。
</p>
</div>
)}
</div>
<p className="mt-4 text-xs text-text-tertiary">
自动组件只写入浏览器内生成的 MJCF,不会修改本地 URDF 文件;使用“原生
URDF”加载模式时不会注入这些组件。
</p>
</Dialog>
);
} }
@@ -1,30 +1,166 @@
import {Check,Eye,RotateCcw} from 'lucide-react'; import { Check, Eye, RotateCcw } from 'lucide-react';
import {IconButton,Popover} from '../../components/ui'; import { IconButton, Popover } from '../../components/ui';
import {DEFAULT_VIEWER_DISPLAY_OPTIONS,type ViewerDisplayOptions} from '../../viewer/displayOptions'; import {
DEFAULT_VIEWER_DISPLAY_OPTIONS,
type ViewerDisplayOptions,
} from '../../viewer/displayOptions';
type DisplayKey=keyof ViewerDisplayOptions; type DisplayKey = keyof ViewerDisplayOptions;
interface DisplayItem {key:DisplayKey;label:string;description:string;color:string;} interface DisplayItem {
const geometryItems:DisplayItem[]=[ key: DisplayKey;
{key:'showVisual',label:'视觉模型',description:'显示模型的外观几何与材质',color:'bg-slate-400'}, label: string;
{key:'showCollision',label:'碰撞体',description:'以青色半透明方式叠加碰撞几何',color:'bg-cyan-400'}, description: string;
color: string;
}
const geometryItems: DisplayItem[] = [
{
key: 'showVisual',
label: '视觉模型',
description: '显示模型的外观几何与材质',
color: 'bg-slate-400',
},
{
key: 'showCollision',
label: '碰撞体',
description: '以青色半透明方式叠加碰撞几何',
color: 'bg-cyan-400',
},
]; ];
const helperItems:DisplayItem[]=[ const helperItems: DisplayItem[] = [
{key:'showFrames',label:'坐标系',description:'显示每个刚体的 RGB 坐标轴',color:'bg-red-400'}, {
{key:'showJointAxes',label:'关节轴',description:'显示转动与滑动关节的正轴方向',color:'bg-red-500'}, key: 'showFrames',
{key:'showCenterOfMass',label:'质心',description:'显示各刚体的质量中心',color:'bg-yellow-400'}, label: '坐标系',
{key:'showInertia',label:'惯量',description:'显示由主惯量计算的等效惯量盒',color:'bg-cyan-300'}, description: '显示每个刚体的 RGB 坐标轴',
color: 'bg-red-400',
},
{
key: 'showJointAxes',
label: '关节轴',
description: '显示转动与滑动关节的正轴方向',
color: 'bg-red-500',
},
{
key: 'showCenterOfMass',
label: '质心',
description: '显示各刚体的质量中心',
color: 'bg-yellow-400',
},
{
key: 'showInertia',
label: '惯量',
description: '显示由主惯量计算的等效惯量盒',
color: 'bg-cyan-300',
},
]; ];
const sceneItems:DisplayItem[]=[ const sceneItems: DisplayItem[] = [
{key:'showGrid',label:'地面网格',description:'显示世界坐标系的参考网格',color:'bg-blue-400'}, {
key: 'showGrid',
label: '地面网格',
description: '显示世界坐标系的参考网格',
color: 'bg-blue-400',
},
]; ];
function DisplayRows({items,value,onChange}:{items:DisplayItem[];value:ViewerDisplayOptions;onChange:(next:ViewerDisplayOptions)=>void}){ function DisplayRows({
return <div className="space-y-0.5">{items.map(item=>{const checked=value[item.key];return <button key={item.key} type="button" role="switch" aria-checked={checked} onClick={()=>onChange({...value,[item.key]:!checked})} className="group flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"><span className={`h-2.5 w-2.5 shrink-0 rounded-sm ${item.color}`}/><span className="min-w-0 flex-1"><span className="block text-xs font-medium text-text-primary">{item.label}</span><span className="block truncate text-[10px] text-text-tertiary">{item.description}</span></span><span aria-hidden="true" className={`grid h-4 w-4 shrink-0 place-items-center rounded border ${checked?'border-accent bg-accent text-white':'border-border-strong bg-input'}`}>{checked&&<Check className="h-3 w-3"/>}</span></button>;})}</div>; items,
value,
onChange,
}: {
items: DisplayItem[];
value: ViewerDisplayOptions;
onChange: (next: ViewerDisplayOptions) => void;
}) {
return (
<div className="space-y-0.5">
{items.map((item) => {
const checked = value[item.key];
return (
<button
key={item.key}
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange({ ...value, [item.key]: !checked })}
className="group flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-sm ${item.color}`} />
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium text-text-primary">{item.label}</span>
<span className="block truncate text-[10px] text-text-tertiary">
{item.description}
</span>
</span>
<span
aria-hidden="true"
className={`grid h-4 w-4 shrink-0 place-items-center rounded border ${checked ? 'border-accent bg-accent text-white' : 'border-border-strong bg-input'}`}
>
{checked && <Check className="h-3 w-3" />}
</span>
</button>
);
})}
</div>
);
} }
export function ViewerDisplayPopover({value,onChange}:{value:ViewerDisplayOptions;onChange:(next:ViewerDisplayOptions)=>void}){ export function ViewerDisplayPopover({
const customized=Object.keys(DEFAULT_VIEWER_DISPLAY_OPTIONS).some(key=>value[key as DisplayKey]!==DEFAULT_VIEWER_DISPLAY_OPTIONS[key as DisplayKey]); value,
return <Popover placement="bottom-right" label="视图显示设置" trigger={({open,toggle})=><IconButton active={open||customized} tooltip="显示设置" aria-label="显示设置" aria-expanded={open} onClick={toggle}><Eye className="h-3.5 w-3.5"/></IconButton>}> onChange,
{()=> <div className="w-72 rounded-xl border border-border bg-surface-elevated p-2 shadow-2xl"><div className="mb-1 flex items-center justify-between px-2 py-1"><div><h2 className="text-xs font-semibold text-text-primary">视图显示</h2><p className="text-[10px] text-text-tertiary">控制模型和结构辅助标记</p></div><IconButton tooltip="恢复默认显示" aria-label="恢复默认显示" disabled={!customized} onClick={()=>onChange({...DEFAULT_VIEWER_DISPLAY_OPTIONS})}><RotateCcw className="h-3.5 w-3.5"/></IconButton></div><div className="border-t border-border pt-1"><p className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">几何</p><DisplayRows items={geometryItems} value={value} onChange={onChange}/><p className="mt-1 border-t border-border px-2 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">辅助标记</p><DisplayRows items={helperItems} value={value} onChange={onChange}/><p className="mt-1 border-t border-border px-2 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">场景</p><DisplayRows items={sceneItems} value={value} onChange={onChange}/></div></div>} }: {
</Popover>; value: ViewerDisplayOptions;
onChange: (next: ViewerDisplayOptions) => void;
}) {
const customized = Object.keys(DEFAULT_VIEWER_DISPLAY_OPTIONS).some(
(key) => value[key as DisplayKey] !== DEFAULT_VIEWER_DISPLAY_OPTIONS[key as DisplayKey],
);
return (
<Popover
placement="bottom-right"
label="视图显示设置"
trigger={({ open, toggle }) => (
<IconButton
active={open || customized}
tooltip="显示设置"
aria-label="显示设置"
aria-expanded={open}
onClick={toggle}
>
<Eye className="h-3.5 w-3.5" />
</IconButton>
)}
>
{() => (
<div className="w-72 rounded-xl border border-border bg-surface-elevated p-2 shadow-2xl">
<div className="mb-1 flex items-center justify-between px-2 py-1">
<div>
<h2 className="text-xs font-semibold text-text-primary">视图显示</h2>
<p className="text-[10px] text-text-tertiary">控制模型和结构辅助标记</p>
</div>
<IconButton
tooltip="恢复默认显示"
aria-label="恢复默认显示"
disabled={!customized}
onClick={() => onChange({ ...DEFAULT_VIEWER_DISPLAY_OPTIONS })}
>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
</div>
<div className="border-t border-border pt-1">
<p className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">
几何
</p>
<DisplayRows items={geometryItems} value={value} onChange={onChange} />
<p className="mt-1 border-t border-border px-2 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">
辅助标记
</p>
<DisplayRows items={helperItems} value={value} onChange={onChange} />
<p className="mt-1 border-t border-border px-2 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">
场景
</p>
<DisplayRows items={sceneItems} value={value} onChange={onChange} />
</div>
</div>
)}
</Popover>
);
} }
@@ -1,7 +1,33 @@
import {Crosshair,Hand,MousePointer2,RotateCcw} from 'lucide-react'; import { Crosshair, Hand, MousePointer2, RotateCcw } from 'lucide-react';
import type {InteractionMode} from '../../viewer/MuJoCoViewer'; import type { InteractionMode } from '../../viewer/MuJoCoViewer';
import type {ViewerDisplayOptions} from '../../viewer/displayOptions'; import type { ViewerDisplayOptions } from '../../viewer/displayOptions';
import {IconButton,ToolbarToggleGroup,type ToolbarItem} from '../../components/ui'; import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
import {ViewerDisplayPopover} from './ViewerDisplayPopover'; import { ViewerDisplayPopover } from './ViewerDisplayPopover';
const tools:ToolbarItem<InteractionMode>[]=[{value:'select',label:'选择',icon:MousePointer2},{value:'joint',label:'关节拖动',icon:Hand},{value:'force',label:'外力施加',icon:Crosshair}]; const tools: ToolbarItem<InteractionMode>[] = [
export function ViewerToolDock({mode,display,onModeChange,onDisplayChange,onResetCamera}:{mode:InteractionMode;display:ViewerDisplayOptions;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="视口交互模式"/><ViewerDisplayPopover value={display} onChange={onDisplayChange}/><IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}><RotateCcw className="h-3.5 w-3.5"/></IconButton></div>;} { value: 'select', label: '选择', icon: MousePointer2 },
{ value: 'joint', label: '关节拖动', icon: Hand },
{ value: 'force', label: '外力施加', icon: Crosshair },
];
export function ViewerToolDock({
mode,
display,
onModeChange,
onDisplayChange,
onResetCamera,
}: {
mode: InteractionMode;
display: ViewerDisplayOptions;
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="视口交互模式" />
<ViewerDisplayPopover value={display} onChange={onDisplayChange} />
<IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
</div>
);
}
@@ -1,6 +1,72 @@
import {CirclePause,CirclePlay,Mouse,MousePointer2} from 'lucide-react'; import { CirclePause, CirclePlay, Mouse, MousePointer2 } from 'lucide-react';
import type {InteractionMode,ViewerSelection} from '../../viewer/MuJoCoViewer'; import type { InteractionMode, ViewerSelection } from '../../viewer/MuJoCoViewer';
import {Badge,Kbd} from '../../components/ui'; import { Badge, Kbd } from '../../components/ui';
const labels:Record<InteractionMode,string>={select:'选择',joint:'关节拖动',force:'外力施加'}; const labels: Record<InteractionMode, string> = {
const primaryGestures:Record<InteractionMode,string>={select:'左键旋转',joint:'左键拖动关节',force:'左键拖动施力'}; select: '选择',
export function ViewportHUD({paused,mode,selection,ready}:{paused:boolean;mode:InteractionMode;selection:ViewerSelection|null;ready:boolean}){if(!ready)return null;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-3 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel px-3 py-1.5 text-[10px] text-text-secondary shadow-xl 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>{mode!=='select'&&<><span aria-hidden="true" className="text-border-strong">·</span><Kbd>1</Kbd><span>旋转视角</span></>}</div></>;} joint: '关节拖动',
force: '外力施加',
};
const primaryGestures: Record<InteractionMode, string> = {
select: '左键旋转',
joint: '左键拖动关节',
force: '左键拖动施力',
};
export function ViewportHUD({
paused,
mode,
selection,
ready,
}: {
paused: boolean;
mode: InteractionMode;
selection: ViewerSelection | null;
ready: boolean;
}) {
if (!ready) return null;
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-3 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel px-3 py-1.5 text-[10px] text-text-secondary shadow-xl 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>
{mode !== 'select' && (
<>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<Kbd>1</Kbd>
<span>旋转视角</span>
</>
)}
</div>
</>
);
}
@@ -1,4 +1,58 @@
import {fireEvent,render,screen} from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import {WorkbenchHeader} from './WorkbenchHeader'; import { WorkbenchHeader } from './WorkbenchHeader';
const fn=()=>{}; const fn = () => {};
describe('WorkbenchHeader',()=>{it('透传仿真动作且保留可访问名称',()=>{const pause=vi.fn(),step=vi.fn(),reset=vi.fn(),speed=vi.fn();render(<WorkbenchHeader paused ready speed={1} theme="dark" loading={false} leftOpen rightOpen fullscreen={false} center={<span>工具</span>} onFiles={fn} onFolder={fn} onTogglePause={pause} onStep={step} onReset={reset} onSpeed={speed} onToggleLeft={fn} onToggleRight={fn} onToggleTheme={fn} onHelp={fn} onCommands={fn} onToggleFullscreen={fn}/>);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).toHaveBeenCalledTimes(1);expect(step).toHaveBeenCalledTimes(1);expect(reset).toHaveBeenCalledTimes(1);expect(speed).toHaveBeenCalledWith(2);expect(screen.getByRole('button',{name:'切换到白天主题'})).toBeInTheDocument();expect(screen.getByRole('button',{name:'隐藏工程面板'})).toHaveAttribute('aria-expanded','true');expect(screen.getByRole('button',{name:'打开命令面板'})).toBeInTheDocument();expect(screen.getByRole('button',{name:'进入全屏'})).toBeInTheDocument();});}); describe('WorkbenchHeader', () => {
it('透传仿真动作且保留可访问名称', () => {
const pause = vi.fn(),
step = vi.fn(),
reset = vi.fn(),
speed = vi.fn(),
openSource = vi.fn();
render(
<WorkbenchHeader
paused
ready
speed={1}
theme="dark"
loading={false}
hasProject
leftOpen
rightOpen
fullscreen={false}
center={<span>工具</span>}
onFiles={fn}
onFolder={fn}
onOpenSource={openSource}
onTogglePause={pause}
onStep={step}
onReset={reset}
onSpeed={speed}
onToggleLeft={fn}
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('button', { name: '隐藏工程面板' })).toHaveAttribute(
'aria-expanded',
'true',
);
expect(screen.getByRole('button', { name: '打开命令面板' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '进入全屏' })).toBeInTheDocument();
});
});
@@ -1,6 +1,217 @@
import type {ChangeEvent,ReactNode} from 'react'; import type { ChangeEvent, ReactNode } from 'react';
import {CircleHelp,Code2,Expand,FolderOpen,Minimize,PanelLeft,PanelRight,Pause,Play,RotateCcw,Search,StepForward,Sun,Moon,Upload} from 'lucide-react'; import {
import {Button,IconButton,Select} from '../../components/ui'; Boxes,
const fileActionClass='inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-2 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30'; CircleHelp,
const fileActionLabelClass='hidden sm:inline'; Code2,
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="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 bg-panel px-2.5"><div className="flex min-w-0 items-center gap-1"><h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block">MuJoCo Web 仿真平台</h1><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><Upload className="h-3.5 w-3.5"/><span className={fileActionLabelClass}>打开文件</span><input id="mujoco-project-files" aria-label="打开文件" className="sr-only" type="file" disabled={loading} multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={onFiles}/></label><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><FolderOpen className="h-3.5 w-3.5"/><span className={fileActionLabelClass}>文件夹</span><input id="mujoco-project-folder" aria-label="打开文件夹" className="sr-only" type="file" disabled={loading} multiple {...({webkitdirectory:'',directory:''} as object)} onChange={onFolder}/></label><IconButton tooltip="查看和修改缓存源代码" aria-label="源代码" disabled={!hasProject||loading} onClick={onOpenSource}><Code2 className="h-4 w-4"/></IconButton></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={.25}>0.25×</option><option value={.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?'隐藏工程面板':'显示工程面板'} aria-expanded={leftOpen} onClick={onToggleLeft}><PanelLeft className="h-4 w-4"/></IconButton><IconButton tooltip={rightOpen?'隐藏属性面板':'显示属性面板'} aria-label={rightOpen?'隐藏属性面板':'显示属性面板'} aria-expanded={rightOpen} onClick={onToggleRight}><PanelRight className="h-4 w-4"/></IconButton>{endActions}{compactMenu}<IconButton className="hidden xl:inline-flex" 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>;} Expand,
FolderOpen,
Minimize,
PanelLeft,
PanelRight,
Pause,
Play,
RotateCcw,
Search,
StepForward,
Sun,
Moon,
Upload,
} from 'lucide-react';
import { Button, IconButton, Select } 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';
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="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 bg-panel/95 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">
<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 仿真平台
</h1>
</div>
<label
aria-disabled={loading}
className={`${fileActionClass} ${loading ? 'pointer-events-none opacity-40' : ''}`}
>
<Upload className="h-3.5 w-3.5" />
<span className={fileActionLabelClass}>打开文件</span>
<input
id="mujoco-project-files"
aria-label="打开文件"
className="sr-only"
type="file"
disabled={loading}
multiple
accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr"
onChange={onFiles}
/>
</label>
<label
aria-disabled={loading}
className={`${fileActionClass} ${loading ? 'pointer-events-none opacity-40' : ''}`}
>
<FolderOpen className="h-3.5 w-3.5" />
<span className={fileActionLabelClass}>文件夹</span>
<input
id="mujoco-project-folder"
aria-label="打开文件夹"
className="sr-only"
type="file"
disabled={loading}
multiple
{...({ webkitdirectory: '', directory: '' } as object)}
onChange={onFolder}
/>
</label>
<button
type="button"
className={`${fileActionClass} disabled:cursor-not-allowed disabled:opacity-40`}
title="查看和修改缓存源代码"
disabled={!hasProject || loading}
onClick={onOpenSource}
>
<Code2 className="h-3.5 w-3.5" />
<span className={fileActionLabelClass}>源代码</span>
</button>
</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 ? '隐藏工程面板' : '显示工程面板'}
aria-expanded={leftOpen}
onClick={onToggleLeft}
>
<PanelLeft className="h-4 w-4" />
</IconButton>
<IconButton
tooltip={rightOpen ? '隐藏属性面板' : '显示属性面板'}
aria-label={rightOpen ? '隐藏属性面板' : '显示属性面板'}
aria-expanded={rightOpen}
onClick={onToggleRight}
>
<PanelRight className="h-4 w-4" />
</IconButton>
{endActions}
{compactMenu}
<IconButton
className="hidden xl:inline-flex"
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>
);
}
@@ -1,6 +1,206 @@
import {Box,FolderOpen,LoaderCircle,PlayCircle,Settings2,ShieldCheck,Upload,UploadCloud} from 'lucide-react'; import {
import {ProgressBar,Skeleton} from '../../components/ui'; Box,
export interface ImportProgress{label:string;value:number;} CheckCircle2,
const workflow=[{label:'导入',detail:'URDF、MJCF 或工程包',icon:UploadCloud},{label:'检查与配置',detail:'结构、驱动器与传感器',icon:Settings2},{label:'运行与调试',detail:'控制、策略与物理状态',icon:PlayCircle}]; FileArchive,
export function EmptyWorkspace({compact=false}:{compact?:boolean}){if(compact)return <div className="m-3 rounded-xl border border-dashed border-border-strong bg-panel/90 p-4 text-center shadow-sm"><span className="mx-auto mb-3 grid h-10 w-10 place-items-center rounded-xl bg-accent-soft text-accent"><Box className="h-5 w-5"/></span><p className="text-sm font-semibold text-text-primary">拖放模型工程到此处</p><p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p></div>;return <section aria-label="导入模型工程" className="w-[min(560px,calc(100vw-32px))] rounded-2xl border border-border-strong bg-panel/90 p-6 text-center shadow-2xl backdrop-blur-md"><span className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-xl bg-accent-soft text-accent"><UploadCloud className="h-5 w-5"/></span><h2 className="text-base font-semibold text-text-primary">拖放模型工程到此处</h2><p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p><div className="mt-4 flex items-center justify-center gap-2"><label htmlFor="mujoco-project-files" className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 text-xs font-medium text-white transition-colors hover:bg-accent-hover 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-8 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-xs font-medium text-text-primary transition-colors 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-5 hidden grid-cols-3 gap-2 border-t border-border pt-4 sm:grid">{workflow.map((item,index)=><li key={item.label} className="rounded-lg bg-surface px-3 py-2.5 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-medium text-text-primary">{item.label}</span></div><span className="mt-1 block text-[10px] leading-4 text-text-tertiary">{item.detail}</span></li>)}</ol><p className="mt-3 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary"><ShieldCheck aria-hidden="true" className="h-3 w-3 text-success"/>模型与资源仅在当前浏览器会话中处理</p></section>;} FolderOpen,
export function WorkspaceOverlays({loading,hasSnapshot,progress}:{loading:boolean;hasSnapshot:boolean;progress?:ImportProgress}){return <>{!hasSnapshot&&!loading&&<div className="pointer-events-none absolute inset-0 grid place-items-center p-4"><div className="pointer-events-auto"><EmptyWorkspace/></div></div>}{loading&&<div role="status" aria-live="polite" aria-label={progress?.label??'正在加载 MuJoCo 与模型'} className="absolute inset-0 z-20 grid place-items-center bg-app/75 backdrop-blur-sm"><div className="w-80 rounded-xl border border-border bg-panel px-5 py-4 text-sm font-medium text-text-primary shadow-xl"><div className="flex items-center gap-3"><LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin text-accent"/>正在加载 MuJoCo 与模型…</div>{progress?<div className="mt-4"><ProgressBar value={progress.value} label={progress.label}/></div>:<div className="mt-4 space-y-2"><Skeleton className="h-2.5 w-full"/><Skeleton className="h-2.5 w-4/5"/></div>}</div></div>}</>;} LoaderCircle,
PlayCircle,
Settings2,
ShieldCheck,
Sparkles,
Upload,
UploadCloud,
} from 'lucide-react';
import { ProgressBar, Skeleton } from '../../components/ui';
export interface ImportProgress {
title?: string;
label: string;
detail?: string;
value: number;
}
const workflow = [
{ label: '导入', detail: 'URDF、MJCF 或工程包', icon: UploadCloud },
{ label: '检查与配置', detail: '结构、驱动器与传感器', icon: Settings2 },
{ label: '运行与调试', detail: '控制、策略与物理状态', icon: PlayCircle },
];
export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
if (compact)
return (
<div className="m-3 rounded-xl border border-dashed border-border-strong bg-panel/90 p-4 text-center shadow-sm">
<span className="mx-auto mb-3 grid h-10 w-10 place-items-center rounded-xl bg-accent-soft text-accent">
<Box className="h-5 w-5" />
</span>
<p className="text-sm font-semibold text-text-primary">拖放模型工程到此处</p>
<p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p>
</div>
);
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"
>
<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="支持格式"
>
{['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"
>
{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>
</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>
</section>
);
}
function LoadingCard({ progress, compact }: { progress?: ImportProgress; compact: boolean }) {
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`}
>
<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>
</div>
</div>
{progress ? (
<div className="mt-4">
<ProgressBar value={progress.value} label={progress.label} />
</div>
) : (
<div className="mt-4 space-y-2">
<Skeleton className="h-2.5 w-full" />
<Skeleton className="h-2.5 w-4/5" />
</div>
)}
</div>
</div>
);
}
export function WorkspaceOverlays({
loading,
hasSnapshot,
dragActive = false,
progress,
}: {
loading: boolean;
hasSnapshot: boolean;
dragActive?: boolean;
progress?: ImportProgress;
}) {
return (
<>
{!hasSnapshot && !loading && (
<div className="pointer-events-none absolute inset-0 grid place-items-center p-4">
<div className="pointer-events-auto">
<EmptyWorkspace />
</div>
</div>
)}
{loading && (
<div
role="status"
aria-live="polite"
aria-label={progress?.label ?? '正在加载 MuJoCo 与模型'}
className={
hasSnapshot
? 'pointer-events-none absolute right-4 top-4 z-30'
: 'absolute inset-0 z-30 grid place-items-center bg-app/70 p-4 backdrop-blur-[3px]'
}
>
<LoadingCard progress={progress} compact={hasSnapshot} />
</div>
)}
{dragActive && !loading && (
<div className="pointer-events-none absolute inset-3 z-40 grid place-items-center rounded-2xl border-2 border-dashed border-accent bg-accent-soft/20 shadow-[inset_0_0_80px_rgba(53,199,146,0.08)] backdrop-blur-[2px]">
<div className="rounded-2xl border border-accent/30 bg-panel/95 px-7 py-5 text-center shadow-2xl">
<span className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-xl bg-accent-soft text-accent">
<FileArchive className="h-5 w-5" />
</span>
<p className="text-sm font-semibold text-text-primary">松开即可导入工程</p>
<p className="mt-1 flex items-center justify-center gap-1.5 text-[11px] text-text-tertiary">
<CheckCircle2 className="h-3 w-3 text-success" />
文件、文件夹与 ZIP 都可以直接解析
</p>
</div>
</div>
)}
</>
);
}
@@ -1,8 +1,8 @@
import {loader} from '@monaco-editor/react'; import { loader } from '@monaco-editor/react';
import * as monaco from 'monaco-editor/editor/editor.api'; import * as monaco from 'monaco-editor/editor/editor.api';
import 'monaco-editor/languages/definitions/xml/register'; import 'monaco-editor/languages/definitions/xml/register';
import EditorWorker from 'monaco-editor/editor/editor.worker?worker'; import EditorWorker from 'monaco-editor/editor/editor.worker?worker';
type MonacoGlobal=typeof globalThis&{MonacoEnvironment?:{getWorker?:()=>Worker}}; type MonacoGlobal = typeof globalThis & { MonacoEnvironment?: { getWorker?: () => Worker } };
(globalThis as MonacoGlobal).MonacoEnvironment={getWorker:()=>new EditorWorker()}; (globalThis as MonacoGlobal).MonacoEnvironment = { getWorker: () => new EditorWorker() };
loader.config({monaco}); loader.config({ monaco });
+27 -2
View File
@@ -1,2 +1,27 @@
import type {ReactNode} from 'react'; import type { ReactNode } from 'react';
export function Badge({children,tone='neutral',className='',title}:{children:ReactNode;tone?:'neutral'|'accent'|'success'|'warning';className?:string;title?:string}){const toneClass={neutral:'border-border bg-surface text-text-secondary',accent:'border-success-border bg-accent-soft text-accent',success:'border-success-border bg-success-soft text-success',warning:'border-warning-border bg-warning-soft text-warning'}[tone];return <span title={title} className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}>{children}</span>;} export function Badge({
children,
tone = 'neutral',
className = '',
title,
}: {
children: ReactNode;
tone?: 'neutral' | 'accent' | 'success' | 'warning';
className?: string;
title?: string;
}) {
const toneClass = {
neutral: 'border-border bg-surface text-text-secondary',
accent: 'border-success-border bg-accent-soft text-accent',
success: 'border-success-border bg-success-soft text-success',
warning: 'border-warning-border bg-warning-soft text-warning',
}[tone];
return (
<span
title={title}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}
>
{children}
</span>
);
}
+39 -13
View File
@@ -1,18 +1,44 @@
import type {ButtonHTMLAttributes,ReactNode} from 'react'; import type { ButtonHTMLAttributes, ReactNode } from 'react';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>{ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?:'primary'|'secondary'|'ghost'|'danger'; variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?:'sm'|'md'|'icon'; size?: 'sm' | 'md' | 'icon';
icon?:ReactNode; icon?: ReactNode;
} }
export function Button({variant='secondary',size='sm',icon,className='',children,type='button',...props}:ButtonProps){ export function Button({
const variants={ variant = 'secondary',
primary:'border-transparent bg-accent text-white hover:bg-accent-hover', size = 'sm',
secondary:'border-border bg-surface text-text-primary hover:bg-element-hover', icon,
ghost:'border-transparent bg-transparent text-text-secondary hover:bg-element-hover hover:text-text-primary', className = '',
danger:'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-white', children,
type = 'button',
...props
}: ButtonProps) {
const variants = {
primary: 'border-transparent bg-accent text-white 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',md:'h-8 gap-2 rounded-md px-3 text-sm',icon:'h-7 w-7 rounded-md p-0'}; const sizes = {
return <button type={type} className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-colors 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()} {...props}>{icon&&<span aria-hidden="true" className="flex items-center">{icon}</span>}{children}</button>; sm: 'h-7 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-colors 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()}
{...props}
>
{icon && (
<span aria-hidden="true" className="flex items-center">
{icon}
</span>
)}
{children}
</button>
);
} }
@@ -1,3 +1,25 @@
import {fireEvent,render,screen} from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import {CollapsibleSection} from './CollapsibleSection'; import { CollapsibleSection } from './CollapsibleSection';
describe('CollapsibleSection',()=>{it('遵循默认折叠状态并可展开',()=>{render(<CollapsibleSection title="低频设置" defaultOpen={false}><span>内容</span></CollapsibleSection>);const trigger=screen.getByRole('button',{name:'低频设置'});expect(trigger).toHaveAttribute('aria-expanded','false');expect(screen.queryByText('内容')).not.toBeInTheDocument();fireEvent.click(trigger);expect(trigger).toHaveAttribute('aria-expanded','true');expect(screen.getByText('内容')).toBeVisible();});it('forceOpen 时保持内容可见',()=>{render(<CollapsibleSection title="警告" defaultOpen={false} forceOpen><span>错误详情</span></CollapsibleSection>);expect(screen.getByText('错误详情')).toBeVisible();});}); describe('CollapsibleSection', () => {
it('遵循默认折叠状态并可展开', () => {
render(
<CollapsibleSection title="低频设置" defaultOpen={false}>
<span>内容</span>
</CollapsibleSection>,
);
const trigger = screen.getByRole('button', { name: '低频设置' });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText('内容')).not.toBeInTheDocument();
fireEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('内容')).toBeVisible();
});
it('forceOpen 时保持内容可见', () => {
render(
<CollapsibleSection title="警告" defaultOpen={false} forceOpen>
<span>错误详情</span>
</CollapsibleSection>,
);
expect(screen.getByText('错误详情')).toBeVisible();
});
});
@@ -1,11 +1,36 @@
import {useState,type ReactNode} from 'react'; import { useState, type ReactNode } from 'react';
import {ChevronRight} from 'lucide-react'; import { ChevronRight } from 'lucide-react';
export function CollapsibleSection({title,children,defaultOpen=true,forceOpen=false,badge}:{title:string;children:ReactNode;defaultOpen?:boolean;forceOpen?:boolean;badge?:ReactNode}){ export function CollapsibleSection({
const [open,setOpen]=useState(defaultOpen);const expanded=forceOpen||open; title,
return <section className="border-b border-border"> children,
<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"> defaultOpen = true,
<ChevronRight aria-hidden="true" className={`h-3.5 w-3.5 transition-transform ${expanded?'rotate-90':''}`}/><span className="min-w-0 flex-1 truncate">{title}</span>{badge} forceOpen = false,
</button> badge,
{expanded&&<div className="px-3 pb-3">{children}</div>} }: {
</section>; title: string;
children: ReactNode;
defaultOpen?: boolean;
forceOpen?: boolean;
badge?: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
const expanded = forceOpen || open;
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"
>
<ChevronRight
aria-hidden="true"
className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`}
/>
<span className="min-w-0 flex-1 truncate">{title}</span>
{badge}
</button>
{expanded && <div className="px-3 pb-3">{children}</div>}
</section>
);
} }
@@ -1,4 +1,40 @@
import type {ReactNode} from 'react'; import type { ReactNode } from 'react';
import {Button} from './Button'; import { Button } from './Button';
import {Dialog} from './Dialog'; import { Dialog } from './Dialog';
export function ConfirmDialog({open,title,children,confirmLabel='确认',cancelLabel='取消',danger=false,onConfirm,onClose}:{open:boolean;title:string;children:ReactNode;confirmLabel?:string;cancelLabel?:string;danger?:boolean;onConfirm:()=>void;onClose:()=>void}){return <Dialog open={open} onClose={onClose} title={title} footer={<div className="flex justify-end gap-2"><Button onClick={onClose}>{cancelLabel}</Button><Button variant={danger?'danger':'primary'} onClick={onConfirm}>{confirmLabel}</Button></div>}>{children}</Dialog>;} export function ConfirmDialog({
open,
title,
children,
confirmLabel = '确认',
cancelLabel = '取消',
danger = false,
onConfirm,
onClose,
}: {
open: boolean;
title: string;
children: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
onConfirm: () => void;
onClose: () => void;
}) {
return (
<Dialog
open={open}
onClose={onClose}
title={title}
footer={
<div className="flex justify-end gap-2">
<Button onClick={onClose}>{cancelLabel}</Button>
<Button variant={danger ? 'danger' : 'primary'} onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
}
>
{children}
</Dialog>
);
}
+31 -4
View File
@@ -1,4 +1,31 @@
import {useEffect,useState} from 'react'; import { useEffect, useState } from 'react';
import {Check,Copy} from 'lucide-react'; import { Check, Copy } from 'lucide-react';
import {IconButton} from './IconButton'; import { IconButton } from './IconButton';
export function CopyButton({value,label='复制'}:{value:string;label?:string}){const [copied,setCopied]=useState(false);useEffect(()=>{if(!copied)return;const timer=window.setTimeout(()=>setCopied(false),1200);return()=>window.clearTimeout(timer);},[copied]);return <IconButton aria-label={copied?'已复制':label} tooltip={copied?'已复制':label} onClick={()=>void (async()=>{try{if(!navigator.clipboard?.writeText)return;await navigator.clipboard.writeText(value);setCopied(true);}catch{setCopied(false);}})()} className="h-5 w-5">{copied?<Check className="h-3 w-3 text-success"/>:<Copy className="h-3 w-3"/>}</IconButton>;} export function CopyButton({ value, label = '复制' }: { value: string; label?: string }) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const timer = window.setTimeout(() => setCopied(false), 1200);
return () => window.clearTimeout(timer);
}, [copied]);
return (
<IconButton
aria-label={copied ? '已复制' : label}
tooltip={copied ? '已复制' : label}
onClick={() =>
void (async () => {
try {
if (!navigator.clipboard?.writeText) return;
await navigator.clipboard.writeText(value);
setCopied(true);
} catch {
setCopied(false);
}
})()
}
className="h-5 w-5"
>
{copied ? <Check className="h-3 w-3 text-success" /> : <Copy className="h-3 w-3" />}
</IconButton>
);
}
+53 -5
View File
@@ -1,6 +1,54 @@
import {fireEvent,render,screen} from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import {useState} from 'react'; import { useState } from 'react';
import {Dialog} from './Dialog'; import { Dialog } from './Dialog';
function Fixture(){const [open,setOpen]=useState(false);return <><button onClick={()=>setOpen(true)}>打开</button><Dialog open={open} onClose={()=>setOpen(false)} title="入口选择"><button>第一个</button><button>最后一个</button></Dialog></>;} function Fixture() {
describe('Dialog',()=>{it('支持 Escape 关闭并恢复触发器焦点',()=>{render(<Fixture/>);const trigger=screen.getByRole('button',{name:'打开'});trigger.focus();fireEvent.click(trigger);expect(screen.getByRole('dialog')).toBeVisible();fireEvent.keyDown(document,{key:'Escape'});expect(screen.queryByRole('dialog')).not.toBeInTheDocument();expect(trigger).toHaveFocus();});it('将 Tab 焦点限制在弹窗内',()=>{render(<Fixture/>);fireEvent.click(screen.getByRole('button',{name:'打开'}));const first=screen.getByRole('button',{name:'关闭'}),last=screen.getByRole('button',{name:'最后一个'});last.focus();fireEvent.keyDown(document,{key:'Tab'});expect(first).toHaveFocus();first.focus();fireEvent.keyDown(document,{key:'Tab',shiftKey:true});expect(last).toHaveFocus();});it('全屏时将 Portal 挂载到全屏元素内部',()=>{const host=document.createElement('div');document.body.append(host);Object.defineProperty(document,'fullscreenElement',{configurable:true,value:host});const {unmount}=render(<Dialog open onClose={()=>{}} title="全屏弹窗">内容</Dialog>);expect(host).toContainElement(screen.getByRole('dialog'));unmount();Object.defineProperty(document,'fullscreenElement',{configurable:true,value:null});host.remove();});}); const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>打开</button>
<Dialog open={open} onClose={() => setOpen(false)} title="入口选择">
<button>第一个</button>
<button>最后一个</button>
</Dialog>
</>
);
}
describe('Dialog', () => {
it('支持 Escape 关闭并恢复触发器焦点', () => {
render(<Fixture />);
const trigger = screen.getByRole('button', { name: '打开' });
trigger.focus();
fireEvent.click(trigger);
expect(screen.getByRole('dialog')).toBeVisible();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
it('将 Tab 焦点限制在弹窗内', () => {
render(<Fixture />);
fireEvent.click(screen.getByRole('button', { name: '打开' }));
const first = screen.getByRole('button', { name: '关闭' }),
last = screen.getByRole('button', { name: '最后一个' });
last.focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(first).toHaveFocus();
first.focus();
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
expect(last).toHaveFocus();
});
it('全屏时将 Portal 挂载到全屏元素内部', () => {
const host = document.createElement('div');
document.body.append(host);
Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: host });
const { unmount } = render(
<Dialog open onClose={() => {}} title="全屏弹窗">
内容
</Dialog>,
);
expect(host).toContainElement(screen.getByRole('dialog'));
unmount();
Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null });
host.remove();
});
});
+103 -12
View File
@@ -1,14 +1,105 @@
import {useEffect,useId,useRef,type ReactNode} from 'react'; import { useEffect, useId, useRef, type ReactNode } from 'react';
import {createPortal} from 'react-dom'; import { createPortal } from 'react-dom';
import {X} from 'lucide-react'; import { X } from 'lucide-react';
import {IconButton} from './IconButton'; import { IconButton } from './IconButton';
const FOCUSABLE='button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; const FOCUSABLE =
export function Dialog({open,onClose,title,children,footer,className='',closable=true}:{open:boolean;onClose:()=>void;title:string;children:ReactNode;footer?:ReactNode;className?:string;closable?:boolean}){ 'button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
const ref=useRef<HTMLDivElement>(null),previous=useRef<HTMLElement|null>(null),onCloseRef=useRef(onClose),titleId=useId(); export function Dialog({
useEffect(()=>{onCloseRef.current=onClose;},[onClose]); open,
useEffect(()=>{if(!open)return;previous.current=document.activeElement instanceof HTMLElement?document.activeElement:null;ref.current?.focus();const key=(event:KeyboardEvent)=>{if(event.key==='Escape'&&closable){event.preventDefault();onCloseRef.current();return;}if(event.key!=='Tab'||!ref.current)return;const items=Array.from(ref.current.querySelectorAll<HTMLElement>(FOCUSABLE));if(!items.length){event.preventDefault();ref.current.focus();return;}const first=items[0],last=items.at(-1)!;if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);if(previous.current&&document.contains(previous.current))previous.current.focus();};},[open,closable]); onClose,
if(!open)return null; title,
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();}}/>; children,
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); footer,
className = '',
closable = true,
}: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
className?: string;
closable?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null),
previous = useRef<HTMLElement | null>(null),
onCloseRef = useRef(onClose),
titleId = useId();
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
if (!open) return;
previous.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
ref.current?.focus();
const key = (event: KeyboardEvent) => {
if (event.key === 'Escape' && closable) {
event.preventDefault();
onCloseRef.current();
return;
}
if (event.key !== 'Tab' || !ref.current) return;
const items = Array.from(ref.current.querySelectorAll<HTMLElement>(FOCUSABLE));
if (!items.length) {
event.preventDefault();
ref.current.focus();
return;
}
const first = items[0],
last = items.at(-1)!;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', key);
return () => {
document.removeEventListener('keydown', key);
if (previous.current && document.contains(previous.current)) previous.current.focus();
};
}, [open, closable]);
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();
}}
/>
);
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,
);
} }
@@ -1,7 +1,85 @@
import {useEffect,useRef,type ReactNode} from 'react'; import { useEffect, useRef, type ReactNode } from 'react';
import {MoreHorizontal} from 'lucide-react'; import { MoreHorizontal } from 'lucide-react';
import {IconButton} from './IconButton'; import { IconButton } from './IconButton';
import {Popover} from './Popover'; import { Popover } from './Popover';
export interface DropdownMenuItem{id:string;label:string;icon?:ReactNode;disabled?:boolean;onSelect:()=>void;} export interface DropdownMenuItem {
function MenuContent({items,close}:{items:DropdownMenuItem[];close:()=>void}){const refs=useRef<(HTMLButtonElement|null)[]>([]);useEffect(()=>{requestAnimationFrame(()=>refs.current.find(item=>item&&!item.disabled)?.focus());},[]);return <div role="menu" className="w-48 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl" onKeyDown={event=>{const enabled=refs.current.filter((item):item is HTMLButtonElement=>Boolean(item&&!item.disabled)),index=enabled.indexOf(document.activeElement as HTMLButtonElement);if(event.key==='ArrowDown'){event.preventDefault();enabled[(index+1)%enabled.length]?.focus();}else if(event.key==='ArrowUp'){event.preventDefault();enabled[(index-1+enabled.length)%enabled.length]?.focus();}else if(event.key==='Home'){event.preventDefault();enabled[0]?.focus();}else if(event.key==='End'){event.preventDefault();enabled.at(-1)?.focus();}}}>{items.map((item,index)=><button key={item.id} ref={node=>{refs.current[index]=node;}} role="menuitem" disabled={item.disabled} onClick={()=>{close();item.onSelect();}} className="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs text-text-secondary hover:bg-element-hover hover:text-text-primary focus:bg-element-hover focus:outline-none disabled:opacity-40"><span className="flex h-4 w-4 items-center justify-center">{item.icon}</span>{item.label}</button>)}</div>;} id: string;
export function DropdownMenu({items,label='更多操作',className=''}:{items:DropdownMenuItem[];label?:string;className?:string}){return <span className={className}><Popover label={label} trigger={({open,toggle})=><IconButton aria-label={label} aria-expanded={open} tooltip={label} onClick={toggle}><MoreHorizontal className="h-4 w-4"/></IconButton>}>{({close})=><MenuContent items={items} close={close}/>}</Popover></span>;} label: string;
icon?: ReactNode;
disabled?: boolean;
onSelect: () => void;
}
function MenuContent({ items, close }: { items: DropdownMenuItem[]; close: () => void }) {
const refs = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => {
requestAnimationFrame(() => refs.current.find((item) => item && !item.disabled)?.focus());
}, []);
return (
<div
role="menu"
className="w-48 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl"
onKeyDown={(event) => {
const enabled = refs.current.filter((item): item is HTMLButtonElement =>
Boolean(item && !item.disabled),
),
index = enabled.indexOf(document.activeElement as HTMLButtonElement);
if (event.key === 'ArrowDown') {
event.preventDefault();
enabled[(index + 1) % enabled.length]?.focus();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
enabled[(index - 1 + enabled.length) % enabled.length]?.focus();
} else if (event.key === 'Home') {
event.preventDefault();
enabled[0]?.focus();
} else if (event.key === 'End') {
event.preventDefault();
enabled.at(-1)?.focus();
}
}}
>
{items.map((item, index) => (
<button
key={item.id}
ref={(node) => {
refs.current[index] = node;
}}
role="menuitem"
disabled={item.disabled}
onClick={() => {
close();
item.onSelect();
}}
className="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs text-text-secondary hover:bg-element-hover hover:text-text-primary focus:bg-element-hover focus:outline-none disabled:opacity-40"
>
<span className="flex h-4 w-4 items-center justify-center">{item.icon}</span>
{item.label}
</button>
))}
</div>
);
}
export function DropdownMenu({
items,
label = '更多操作',
className = '',
}: {
items: DropdownMenuItem[];
label?: string;
className?: string;
}) {
return (
<span className={className}>
<Popover
label={label}
trigger={({ open, toggle }) => (
<IconButton aria-label={label} aria-expanded={open} tooltip={label} onClick={toggle}>
<MoreHorizontal className="h-4 w-4" />
</IconButton>
)}
>
{({ close }) => <MenuContent items={items} close={close} />}
</Popover>
</span>
);
}
@@ -1,2 +1,9 @@
import {SearchX} from 'lucide-react'; import { SearchX } from 'lucide-react';
export function EmptySearchState({label='没有匹配结果'}:{label?:string}){return <div className="grid place-items-center gap-2 px-3 py-6 text-center text-xs text-text-tertiary"><SearchX className="h-5 w-5"/><span>{label}</span></div>;} export function EmptySearchState({ label = '没有匹配结果' }: { label?: string }) {
return (
<div className="grid place-items-center gap-2 px-3 py-6 text-center text-xs text-text-tertiary">
<SearchX className="h-5 w-5" />
<span>{label}</span>
</div>
);
}
@@ -1,7 +1,48 @@
import {fireEvent,render,screen,waitFor} from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {DropdownMenu,LiveRegion,ProgressBar,SearchableCombobox} from './index'; import { DropdownMenu, LiveRegion, ProgressBar, SearchableCombobox } from './index';
describe('第五批基础 UI',()=>{ describe('第五批基础 UI', () => {
it('下拉菜单打开后聚焦菜单项并恢复触发器焦点',async()=>{const run=vi.fn();render(<DropdownMenu items={[{id:'a',label:'动作 A',onSelect:run}]}/>);const trigger=screen.getByRole('button',{name:'更多操作'});trigger.focus();fireEvent.click(trigger);const item=screen.getByRole('menuitem',{name:'动作 A'});await waitFor(()=>expect(item).toHaveFocus());fireEvent.click(item);expect(run).toHaveBeenCalled();expect(trigger).toHaveFocus();}); it('下拉菜单打开后聚焦菜单项并恢复触发器焦点', async () => {
it('进度条和实时区域暴露状态',()=>{render(<><ProgressBar value={.42} label="编译模型"/><LiveRegion>正在编译模型</LiveRegion></>);expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow','42');expect(screen.getByRole('status')).toHaveTextContent('正在编译模型');}); const run = vi.fn();
it('可搜索组合框筛选并选择入口',()=>{const change=vi.fn();render(<SearchableCombobox label="模型入口" value="a" onChange={change} options={[{value:'a',label:'模型 A',description:'a.xml'},{value:'b',label:'模型 B',description:'b.xml'}]}/>);fireEvent.click(screen.getByRole('button',{name:'模型入口'}));fireEvent.change(screen.getByRole('combobox',{name:'搜索模型入口'}),{target:{value:'B'}});const input=screen.getByRole('combobox',{name:'搜索模型入口'});expect(input).toHaveAttribute('aria-expanded','true');fireEvent.click(screen.getByRole('option',{name:/模型 B/}));expect(change).toHaveBeenCalledWith('b');}); render(<DropdownMenu items={[{ id: 'a', label: '动作 A', onSelect: run }]} />);
const trigger = screen.getByRole('button', { name: '更多操作' });
trigger.focus();
fireEvent.click(trigger);
const item = screen.getByRole('menuitem', { name: '动作 A' });
await waitFor(() => expect(item).toHaveFocus());
fireEvent.click(item);
expect(run).toHaveBeenCalled();
expect(trigger).toHaveFocus();
});
it('进度条和实时区域暴露状态', () => {
render(
<>
<ProgressBar value={0.42} label="编译模型" />
<LiveRegion>正在编译模型</LiveRegion>
</>,
);
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '42');
expect(screen.getByRole('status')).toHaveTextContent('正在编译模型');
});
it('可搜索组合框筛选并选择入口', () => {
const change = vi.fn();
render(
<SearchableCombobox
label="模型入口"
value="a"
onChange={change}
options={[
{ value: 'a', label: '模型 A', description: 'a.xml' },
{ value: 'b', label: '模型 B', description: 'b.xml' },
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '模型入口' }));
fireEvent.change(screen.getByRole('combobox', { name: '搜索模型入口' }), {
target: { value: 'B' },
});
const input = screen.getByRole('combobox', { name: '搜索模型入口' });
expect(input).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('option', { name: /模型 B/ }));
expect(change).toHaveBeenCalledWith('b');
});
}); });
+22 -6
View File
@@ -1,8 +1,24 @@
import type {ButtonHTMLAttributes} from 'react'; import type { ButtonHTMLAttributes } from 'react';
import {Tooltip} from './Tooltip'; import { Tooltip } from './Tooltip';
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>{active?:boolean;tooltip?:string;} export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
export function IconButton({active=false,tooltip,className='',type='button',...props}:IconButtonProps){ active?: boolean;
const button=<button type={type} aria-pressed={active||undefined} className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active?'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}/>; tooltip?: string;
return tooltip?<Tooltip content={tooltip}>{button}</Tooltip>:button; }
export function IconButton({
active = false,
tooltip,
className = '',
type = 'button',
...props
}: IconButtonProps) {
const button = (
<button
type={type}
aria-pressed={active || undefined}
className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active ? '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}
/>
);
return tooltip ? <Tooltip content={tooltip}>{button}</Tooltip> : button;
} }
+8 -2
View File
@@ -1,2 +1,8 @@
import type {ReactNode} from 'react'; import type { ReactNode } from 'react';
export function Kbd({children}:{children:ReactNode}){return <kbd className="inline-flex min-w-5 items-center justify-center rounded border border-border-strong bg-surface px-1.5 py-0.5 font-mono text-[10px] leading-4 text-text-secondary shadow-sm">{children}</kbd>;} export function Kbd({ children }: { children: ReactNode }) {
return (
<kbd className="inline-flex min-w-5 items-center justify-center rounded border border-border-strong bg-surface px-1.5 py-0.5 font-mono text-[10px] leading-4 text-text-secondary shadow-sm">
{children}
</kbd>
);
}
+19 -2
View File
@@ -1,2 +1,19 @@
import type {ReactNode} from 'react'; import type { ReactNode } from 'react';
export function LiveRegion({children,assertive=false}:{children:ReactNode;assertive?:boolean}){return <div className="sr-only" role={assertive?'alert':'status'} aria-live={assertive?'assertive':'polite'} aria-atomic="true">{children}</div>;} export function LiveRegion({
children,
assertive = false,
}: {
children: ReactNode;
assertive?: boolean;
}) {
return (
<div
className="sr-only"
role={assertive ? 'alert' : 'status'}
aria-live={assertive ? 'assertive' : 'polite'}
aria-atomic="true"
>
{children}
</div>
);
}
+65 -2
View File
@@ -1,3 +1,66 @@
/* eslint-disable react-hooks/refs -- refs are read only inside event callbacks passed to render props */ /* 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 { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
export function Popover({trigger,children,placement='bottom-right',label}:{trigger:(props:{open:boolean;toggle:()=>void})=>ReactNode;children:(props:{close:(restoreFocus?:boolean)=>void})=>ReactNode;placement?:'bottom-right'|'bottom-left'|'top-left';label:string}){const [open,setOpen]=useState(false),root=useRef<HTMLDivElement>(null),previous=useRef<HTMLElement|null>(null);const close=useCallback((restoreFocus=true)=>{setOpen(false);if(restoreFocus)previous.current?.focus();},[]);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';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>;} export function Popover({
trigger,
children,
placement = 'bottom-right',
label,
}: {
trigger: (props: { open: boolean; toggle: () => void }) => ReactNode;
children: (props: { close: (restoreFocus?: boolean) => void }) => ReactNode;
placement?: 'bottom-right' | 'bottom-left' | 'top-left';
label: string;
}) {
const [open, setOpen] = useState(false),
root = useRef<HTMLDivElement>(null),
previous = useRef<HTMLElement | null>(null);
const close = useCallback((restoreFocus = true) => {
setOpen(false);
if (restoreFocus) previous.current?.focus();
}, []);
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';
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>
);
}
+24 -1
View File
@@ -1 +1,24 @@
export function ProgressBar({value,label}:{value:number;label:string}){const percent=Math.round(Math.min(1,Math.max(0,value))*100);return <div><div className="mb-1 flex justify-between text-[10px] text-text-tertiary"><span>{label}</span><span>{percent}%</span></div><div role="progressbar" aria-label={label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent} className="h-1.5 overflow-hidden rounded-full bg-element-active"><div className="h-full rounded-full bg-accent transition-[width]" style={{width:`${percent}%`}}/></div></div>;} export function ProgressBar({ value, label }: { value: number; label: string }) {
const percent = Math.round(Math.min(1, Math.max(0, value)) * 100);
return (
<div>
<div className="mb-1 flex justify-between text-[10px] text-text-tertiary">
<span>{label}</span>
<span>{percent}%</span>
</div>
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
className="h-1.5 overflow-hidden rounded-full bg-element-active"
>
<div
className="h-full rounded-full bg-accent transition-[width]"
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
}
+20 -2
View File
@@ -1,2 +1,20 @@
import type {ReactNode} from 'react'; import type { ReactNode } from 'react';
export function PropertyRow({label,value,action}:{label:string;value:ReactNode;action?:ReactNode}){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><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}</span></div>;} export function PropertyRow({
label,
value,
action,
}: {
label: string;
value: ReactNode;
action?: ReactNode;
}) {
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>
<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}
</span>
</div>
);
}
@@ -1,8 +1,127 @@
import {useEffect,useRef,useState,type PointerEvent as ReactPointerEvent,type ReactNode} from 'react'; import {
const clamp=(value:number,min:number,max:number)=>Math.min(max,Math.max(min,value)); useEffect,
const panelMaxWidth=(minWidth:number)=>Math.max(minWidth,Math.min(576,window.innerWidth*.4)); useRef,
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));}catch{return clamp(fallback,minWidth,panelMaxWidth(minWidth));}} useState,
export function ResizablePanel({side,storageKey,visible=true,defaultWidth=288,minWidth=224,children,className=''}:{side:'left'|'right';storageKey:string;visible?:boolean;defaultWidth?:number;minWidth?:number;children:ReactNode;className?:string}){const [width,setWidth]=useState(()=>storedWidth(storageKey,defaultWidth,minWidth)),cleanupRef=useRef<()=>void>(()=>{});const update=(next:number)=>{const value=clamp(next,minWidth,panelMaxWidth(minWidth));setWidth(value);try{localStorage.setItem(storageKey,String(value));}catch{/* 无持久化权限时仍可调整 */}}; type PointerEvent as ReactPointerEvent,
useEffect(()=>{const persist=(next:number)=>{const value=clamp(next,minWidth,panelMaxWidth(minWidth));setWidth(value);try{localStorage.setItem(storageKey,String(value));}catch{/* 忽略 */}},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();};},[minWidth,side,storageKey]); type ReactNode,
const start=(event:ReactPointerEvent<HTMLButtonElement>)=>{event.preventDefault();cleanupRef.current();const origin=event.clientX,startWidth=width,pointerId=event.pointerId;const move=(moveEvent:PointerEvent)=>{if(moveEvent.pointerId===pointerId)update(startWidth+(moveEvent.clientX-origin)*(side==='left'?1:-1));};const stop=(stopEvent:PointerEvent)=>{if(stopEvent.pointerId!==pointerId)return;cleanup();};const cleanup=()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',stop);window.removeEventListener('pointercancel',stop);cleanupRef.current=()=>{};};cleanupRef.current=cleanup;window.addEventListener('pointermove',move);window.addEventListener('pointerup',stop);window.addEventListener('pointercancel',stop);}; } from 'react';
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}}>{children}<button type="button" role="separator" aria-label={side==='left'?'调整工程面板宽度':'调整属性面板宽度'} aria-orientation="vertical" aria-valuemin={minWidth} aria-valuemax={Math.round(panelMaxWidth(minWidth))} aria-valuenow={Math.round(width)} 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 return;event.preventDefault();}} className={`absolute inset-y-0 z-30 w-2 cursor-col-resize bg-transparent outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-accent focus-visible:after:w-0.5 focus-visible:after:bg-accent ${side==='left'?'-right-1':'-left-1'}`}/></div>;} 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 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),
);
} catch {
return clamp(fallback, minWidth, panelMaxWidth(minWidth));
}
}
export function ResizablePanel({
side,
storageKey,
visible = true,
defaultWidth = 288,
minWidth = 224,
children,
className = '',
}: {
side: 'left' | 'right';
storageKey: string;
visible?: boolean;
defaultWidth?: number;
minWidth?: number;
children: ReactNode;
className?: string;
}) {
const [width, setWidth] = useState(() => storedWidth(storageKey, defaultWidth, minWidth)),
cleanupRef = useRef<() => void>(() => {});
const update = (next: number) => {
const value = clamp(next, minWidth, panelMaxWidth(minWidth));
setWidth(value);
try {
localStorage.setItem(storageKey, String(value));
} catch {
/* 无持久化权限时仍可调整 */
}
};
useEffect(() => {
const persist = (next: number) => {
const value = clamp(next, minWidth, panelMaxWidth(minWidth));
setWidth(value);
try {
localStorage.setItem(storageKey, String(value));
} catch {
/* 忽略 */
}
},
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();
};
}, [minWidth, side, storageKey]);
const start = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
cleanupRef.current();
const origin = event.clientX,
startWidth = width,
pointerId = event.pointerId;
const move = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId === pointerId)
update(startWidth + (moveEvent.clientX - origin) * (side === 'left' ? 1 : -1));
};
const stop = (stopEvent: PointerEvent) => {
if (stopEvent.pointerId !== pointerId) return;
cleanup();
};
const cleanup = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
cleanupRef.current = () => {};
};
cleanupRef.current = cleanup;
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
};
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 }}
>
{children}
<button
type="button"
role="separator"
aria-label={side === 'left' ? '调整工程面板宽度' : '调整属性面板宽度'}
aria-orientation="vertical"
aria-valuemin={minWidth}
aria-valuemax={Math.round(panelMaxWidth(minWidth))}
aria-valuenow={Math.round(width)}
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 return;
event.preventDefault();
}}
className={`absolute inset-y-0 z-30 w-2 cursor-col-resize bg-transparent outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-accent focus-visible:after:w-0.5 focus-visible:after:bg-accent ${side === 'left' ? '-right-1' : '-left-1'}`}
/>
</div>
);
}
@@ -1 +1,27 @@
export function SearchHighlight({text,query}:{text:string;query:string}){const needle=query.trim().toLocaleLowerCase();if(!needle)return <>{text}</>;const parts:({text:string;match:boolean})[]=[];let start=0,index=text.toLocaleLowerCase().indexOf(needle);while(index>=0){if(index>start)parts.push({text:text.slice(start,index),match:false});parts.push({text:text.slice(index,index+needle.length),match:true});start=index+needle.length;index=text.toLocaleLowerCase().indexOf(needle,start);}if(start<text.length)parts.push({text:text.slice(start),match:false});return <>{parts.map((part,i)=>part.match?<mark key={i} className="rounded-sm bg-warning-soft px-0.5 text-warning">{part.text}</mark>:part.text)}</>;} export function SearchHighlight({ text, query }: { text: string; query: string }) {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return <>{text}</>;
const parts: { text: string; match: boolean }[] = [];
let start = 0,
index = text.toLocaleLowerCase().indexOf(needle);
while (index >= 0) {
if (index > start) parts.push({ text: text.slice(start, index), match: false });
parts.push({ text: text.slice(index, index + needle.length), match: true });
start = index + needle.length;
index = text.toLocaleLowerCase().indexOf(needle, start);
}
if (start < text.length) parts.push({ text: text.slice(start), match: false });
return (
<>
{parts.map((part, i) =>
part.match ? (
<mark key={i} className="rounded-sm bg-warning-soft px-0.5 text-warning">
{part.text}
</mark>
) : (
part.text
),
)}
</>
);
}
@@ -1,5 +1,120 @@
import {useId,useMemo,useState} from 'react'; import { useId, useMemo, useState } from 'react';
import {Check,ChevronsUpDown,Search} from 'lucide-react'; import { Check, ChevronsUpDown, Search } from 'lucide-react';
import {Popover} from './Popover'; import { Popover } from './Popover';
export interface ComboboxOption{value:string;label:string;description?:string;} export interface ComboboxOption {
export function SearchableCombobox({options,value,onChange,label,disabled=false}:{options:ComboboxOption[];value?:string;onChange:(value:string)=>void;label:string;disabled?:boolean}){const [query,setQuery]=useState(''),[active,setActive]=useState(0),listId=useId(),selected=options.find(option=>option.value===value),filtered=useMemo(()=>{const needle=query.trim().toLocaleLowerCase();return options.filter(option=>!needle||`${option.label} ${option.description??''}`.toLocaleLowerCase().includes(needle));},[options,query]);return <Popover label={label} trigger={({open,toggle})=><button type="button" disabled={disabled} 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"><span className="truncate">{selected?.label??'请选择'}</span><ChevronsUpDown className="h-3.5 w-3.5 text-text-tertiary"/></button>}>{({close})=><div className="w-72 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl"><div className="relative"><Search className="absolute left-2 top-2 h-3.5 w-3.5 text-text-tertiary"/><input autoFocus role="combobox" aria-label={`搜索${label}`} aria-expanded="true" aria-autocomplete="list" aria-controls={listId} aria-activedescendant={filtered[active]?`${listId}-${active}`:undefined} value={query} onChange={event=>{setQuery(event.target.value);setActive(0);}} onKeyDown={event=>{if(!filtered.length)return;if(event.key==='ArrowDown'){event.preventDefault();setActive(index=>(index+1)%filtered.length);}else if(event.key==='ArrowUp'){event.preventDefault();setActive(index=>(index-1+filtered.length)%filtered.length);}else if(event.key==='Enter'){event.preventDefault();onChange(filtered[active].value);setQuery('');close();}else if(event.key==='Tab')close(false);}} className="h-8 w-full rounded border border-border bg-input pl-7 pr-2 text-xs"/></div><div id={listId} role="listbox" className="panel-scroll mt-1 max-h-64 overflow-auto">{filtered.map((option,index)=><button key={option.value} id={`${listId}-${index}`} role="option" tabIndex={-1} aria-selected={option.value===value} onMouseEnter={()=>setActive(index)} onClick={()=>{onChange(option.value);setQuery('');close();}} className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs ${index===active?'bg-accent-soft':''}`}><Check className={`h-3.5 w-3.5 ${option.value===value?'text-accent':'opacity-0'}`}/><span className="min-w-0"><span className="block truncate">{option.label}</span>{option.description&&<span className="block truncate text-[10px] text-text-tertiary">{option.description}</span>}</span></button>)}</div></div>}</Popover>;} value: string;
label: string;
description?: string;
}
export function SearchableCombobox({
options,
value,
onChange,
label,
disabled = false,
}: {
options: ComboboxOption[];
value?: string;
onChange: (value: string) => void;
label: string;
disabled?: boolean;
}) {
const [query, setQuery] = useState(''),
[active, setActive] = useState(0),
listId = useId(),
selected = options.find((option) => option.value === value),
filtered = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
return options.filter(
(option) =>
!needle ||
`${option.label} ${option.description ?? ''}`.toLocaleLowerCase().includes(needle),
);
}, [options, query]);
return (
<Popover
label={label}
trigger={({ open, toggle }) => (
<button
type="button"
disabled={disabled}
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"
>
<span className="truncate">{selected?.label ?? '请选择'}</span>
<ChevronsUpDown className="h-3.5 w-3.5 text-text-tertiary" />
</button>
)}
>
{({ close }) => (
<div className="w-72 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl">
<div className="relative">
<Search className="absolute left-2 top-2 h-3.5 w-3.5 text-text-tertiary" />
<input
autoFocus
role="combobox"
aria-label={`搜索${label}`}
aria-expanded="true"
aria-autocomplete="list"
aria-controls={listId}
aria-activedescendant={filtered[active] ? `${listId}-${active}` : undefined}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActive(0);
}}
onKeyDown={(event) => {
if (!filtered.length) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setActive((index) => (index + 1) % filtered.length);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setActive((index) => (index - 1 + filtered.length) % filtered.length);
} else if (event.key === 'Enter') {
event.preventDefault();
onChange(filtered[active].value);
setQuery('');
close();
} else if (event.key === 'Tab') close(false);
}}
className="h-8 w-full rounded border border-border bg-input pl-7 pr-2 text-xs"
/>
</div>
<div id={listId} role="listbox" className="panel-scroll mt-1 max-h-64 overflow-auto">
{filtered.map((option, index) => (
<button
key={option.value}
id={`${listId}-${index}`}
role="option"
tabIndex={-1}
aria-selected={option.value === value}
onMouseEnter={() => setActive(index)}
onClick={() => {
onChange(option.value);
setQuery('');
close();
}}
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs ${index === active ? 'bg-accent-soft' : ''}`}
>
<Check
className={`h-3.5 w-3.5 ${option.value === value ? 'text-accent' : 'opacity-0'}`}
/>
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{option.description && (
<span className="block truncate text-[10px] text-text-tertiary">
{option.description}
</span>
)}
</span>
</button>
))}
</div>
</div>
)}
</Popover>
);
}
@@ -1,9 +1,60 @@
import {useState} from 'react'; import { useState } from 'react';
import {act,fireEvent,render,screen} from '@testing-library/react'; import { act, fireEvent, render, screen } from '@testing-library/react';
import {Badge,ResizablePanel,Separator,Skeleton,Tabs} from './index'; import { Badge, ResizablePanel, Separator, Skeleton, Tabs } from './index';
function TabHarness(){const [value,setValue]=useState<'a'|'b'>('a');return <Tabs label="示例" value={value} onValueChange={setValue} items={[{value:'a',label:'甲',content:<span>甲内容</span>},{value:'b',label:'乙',content:<span>乙内容</span>}]}/>;} function TabHarness() {
describe('第二批基础 UI',()=>{ const [value, setValue] = useState<'a' | 'b'>('a');
it('Tabs 保留面板 DOM并支持方向键导航',()=>{render(<TabHarness/>);const first=screen.getByRole('tab',{name:'甲'});expect(first).toHaveAttribute('tabindex','0');fireEvent.keyDown(first,{key:'ArrowRight'});expect(screen.getByRole('tab',{name:'乙'})).toHaveAttribute('aria-selected','true');expect(screen.getByText('乙内容')).toBeVisible();expect(screen.getByText('甲内容').closest('[role="tabpanel"]')).toHaveAttribute('hidden');}); return (
it('ResizablePanel 支持键盘调整、限制异常持久值并保存宽度',()=>{localStorage.setItem('test-width','9999');render(<ResizablePanel side="left" storageKey="test-width"><div>面板</div></ResizablePanel>);const handle=screen.getByRole('separator',{name:'调整工程面板宽度'});expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(Number(handle.getAttribute('aria-valuemax')));fireEvent.keyDown(handle,{key:'Home'});expect(handle).toHaveAttribute('aria-valuenow','224');expect(localStorage.getItem('test-width')).toBe('224');act(()=>window.dispatchEvent(new CustomEvent('mujoco-layout-widths',{detail:{left:320,right:360}})));expect(handle).toHaveAttribute('aria-valuenow','320');}); <Tabs
it('Badge、Separator 和 Skeleton 可渲染',()=>{render(<><Badge>状态</Badge><Separator/><Skeleton className="h-2"/></>);expect(screen.getByText('状态')).toBeVisible();expect(screen.getByRole('separator')).toBeVisible();}); label="示例"
value={value}
onValueChange={setValue}
items={[
{ value: 'a', label: '甲', content: <span>甲内容</span> },
{ value: 'b', label: '乙', content: <span>乙内容</span> },
]}
/>
);
}
describe('第二批基础 UI', () => {
it('Tabs 保留面板 DOM并支持方向键导航', () => {
render(<TabHarness />);
const first = screen.getByRole('tab', { name: '甲' });
expect(first).toHaveAttribute('tabindex', '0');
fireEvent.keyDown(first, { key: 'ArrowRight' });
expect(screen.getByRole('tab', { name: '乙' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('乙内容')).toBeVisible();
expect(screen.getByText('甲内容').closest('[role="tabpanel"]')).toHaveAttribute('hidden');
});
it('ResizablePanel 支持键盘调整、限制异常持久值并保存宽度', () => {
localStorage.setItem('test-width', '9999');
render(
<ResizablePanel side="left" storageKey="test-width">
<div>面板</div>
</ResizablePanel>,
);
const handle = screen.getByRole('separator', { name: '调整工程面板宽度' });
expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(
Number(handle.getAttribute('aria-valuemax')),
);
fireEvent.keyDown(handle, { key: 'Home' });
expect(handle).toHaveAttribute('aria-valuenow', '224');
expect(localStorage.getItem('test-width')).toBe('224');
act(() =>
window.dispatchEvent(
new CustomEvent('mujoco-layout-widths', { detail: { left: 320, right: 360 } }),
),
);
expect(handle).toHaveAttribute('aria-valuenow', '320');
});
it('Badge、Separator 和 Skeleton 可渲染', () => {
render(
<>
<Badge>状态</Badge>
<Separator />
<Skeleton className="h-2" />
</>,
);
expect(screen.getByText('状态')).toBeVisible();
expect(screen.getByRole('separator')).toBeVisible();
});
}); });
+9 -2
View File
@@ -1,2 +1,9 @@
import type {SelectHTMLAttributes} from 'react'; 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()} {...props}/>;} 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()}
{...props}
/>
);
}
+15 -1
View File
@@ -1 +1,15 @@
export function Separator({orientation='horizontal',className=''}:{orientation?:'horizontal'|'vertical';className?:string}){return <span role="separator" aria-orientation={orientation} className={`${orientation==='horizontal'?'block h-px w-full':'inline-block h-full w-px'} shrink-0 bg-border ${className}`}/>;} export function Separator({
orientation = 'horizontal',
className = '',
}: {
orientation?: 'horizontal' | 'vertical';
className?: string;
}) {
return (
<span
role="separator"
aria-orientation={orientation}
className={`${orientation === 'horizontal' ? 'block h-px w-full' : 'inline-block h-full w-px'} shrink-0 bg-border ${className}`}
/>
);
}
+8 -1
View File
@@ -1 +1,8 @@
export function Skeleton({className=''}:{className?:string}){return <span aria-hidden="true" className={`block animate-pulse rounded bg-element-active ${className}`}/>;} export function Skeleton({ className = '' }: { className?: string }) {
return (
<span
aria-hidden="true"
className={`block animate-pulse rounded bg-element-active ${className}`}
/>
);
}
+84 -3
View File
@@ -1,3 +1,84 @@
import type {KeyboardEvent,ReactNode} from 'react'; import type { KeyboardEvent, ReactNode } from 'react';
export interface TabItem<T extends string>{value:T;label:string;icon?:ReactNode;content:ReactNode;disabled?:boolean;} export interface TabItem<T extends string> {
export function Tabs<T extends string>({items,value,onValueChange,label,className='',keepMounted=true}:{items:TabItem<T>[];value:T;onValueChange:(value:T)=>void;label:string;className?:string;keepMounted?:boolean}){const active=items.find(item=>item.value===value)??items.find(item=>!item.disabled)??items[0];const navigate=(event:KeyboardEvent<HTMLButtonElement>)=>{if(!['ArrowLeft','ArrowRight','Home','End'].includes(event.key))return;const enabled=items.filter(item=>!item.disabled);if(!enabled.length)return;const current=enabled.findIndex(item=>item.value===active.value);const next=event.key==='Home'?0:event.key==='End'?enabled.length-1:event.key==='ArrowRight'?(current+1)%enabled.length:(current-1+enabled.length)%enabled.length;event.preventDefault();const item=enabled[next];onValueChange(item.value);requestAnimationFrame(()=>document.getElementById(`${label}-tab-${item.value}`)?.focus());};return <div className={`flex min-h-0 flex-1 flex-col ${className}`}><div role="tablist" aria-label={label} className="flex h-9 shrink-0 items-end gap-1 border-b border-border bg-surface px-2">{items.map(item=><button key={item.value} type="button" role="tab" id={`${label}-tab-${item.value}`} tabIndex={item.value===active.value?0:-1} aria-selected={item.value===active.value} aria-controls={`${label}-${item.value}`} 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'}`}>{item.icon}{item.label}</button>)}</div>{(keepMounted?items:[active]).map(item=><div key={item.value} id={`${label}-${item.value}`} role="tabpanel" aria-labelledby={`${label}-tab-${item.value}`} hidden={item.value!==active.value} className="panel-scroll min-h-0 flex-1 overflow-auto">{item.content}</div>)}</div>;} value: T;
label: string;
icon?: ReactNode;
content: ReactNode;
disabled?: boolean;
}
export function Tabs<T extends string>({
items,
value,
onValueChange,
label,
className = '',
keepMounted = true,
}: {
items: TabItem<T>[];
value: T;
onValueChange: (value: T) => void;
label: string;
className?: string;
keepMounted?: boolean;
}) {
const active =
items.find((item) => item.value === value) ?? items.find((item) => !item.disabled) ?? items[0];
const navigate = (event: KeyboardEvent<HTMLButtonElement>) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
const enabled = items.filter((item) => !item.disabled);
if (!enabled.length) return;
const current = enabled.findIndex((item) => item.value === active.value);
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? enabled.length - 1
: event.key === 'ArrowRight'
? (current + 1) % enabled.length
: (current - 1 + enabled.length) % enabled.length;
event.preventDefault();
const item = enabled[next];
onValueChange(item.value);
requestAnimationFrame(() => document.getElementById(`${label}-tab-${item.value}`)?.focus());
};
return (
<div className={`flex min-h-0 flex-1 flex-col ${className}`}>
<div
role="tablist"
aria-label={label}
className="flex h-9 shrink-0 items-end gap-1 border-b border-border bg-surface px-2"
>
{items.map((item) => (
<button
key={item.value}
type="button"
role="tab"
id={`${label}-tab-${item.value}`}
tabIndex={item.value === active.value ? 0 : -1}
aria-selected={item.value === active.value}
aria-controls={`${label}-${item.value}`}
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'}`}
>
{item.icon}
{item.label}
</button>
))}
</div>
{(keepMounted ? items : [active]).map((item) => (
<div
key={item.value}
id={`${label}-${item.value}`}
role="tabpanel"
aria-labelledby={`${label}-tab-${item.value}`}
hidden={item.value !== active.value}
className="panel-scroll min-h-0 flex-1 overflow-auto"
>
{item.content}
</div>
))}
</div>
);
}
@@ -1,8 +1,38 @@
import {fireEvent,render,screen,waitFor} from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {ConfirmDialog,CopyButton,Kbd,PropertyRow,SearchHighlight} from './index'; import { ConfirmDialog, CopyButton, Kbd, PropertyRow, SearchHighlight } from './index';
describe('第三批基础 UI',()=>{ describe('第三批基础 UI', () => {
it('确认弹窗区分取消和危险确认动作',()=>{const confirm=vi.fn(),close=vi.fn();render(<ConfirmDialog open title="移除工程" danger onConfirm={confirm} onClose={close}>确认内容</ConfirmDialog>);fireEvent.click(screen.getByRole('button',{name:'确认'}));expect(confirm).toHaveBeenCalledTimes(1);fireEvent.click(screen.getByRole('button',{name:'取消'}));expect(close).toHaveBeenCalledTimes(1);}); it('确认弹窗区分取消和危险确认动作', () => {
it('属性行支持复制且搜索词可高亮',async()=>{const writeText=vi.fn().mockResolvedValue(undefined);Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText}});render(<><PropertyRow label="Body" value="robot" action={<CopyButton value="robot"/>}/><SearchHighlight text="robot_arm" query="arm"/><Kbd>Ctrl+K</Kbd></>);fireEvent.click(screen.getByRole('button',{name:'复制'}));await waitFor(()=>expect(writeText).toHaveBeenCalledWith('robot'));expect(screen.getByText('arm').tagName).toBe('MARK');expect(screen.getByText('Ctrl+K')).toBeVisible();}); const confirm = vi.fn(),
it('Clipboard API 不可用时复制按钮不会抛错',()=>{Object.defineProperty(navigator,'clipboard',{configurable:true,value:undefined});render(<CopyButton value="robot"/>);expect(()=>fireEvent.click(screen.getByRole('button',{name:'复制'}))).not.toThrow();}); close = vi.fn();
render(
<ConfirmDialog open title="移除工程" danger onConfirm={confirm} onClose={close}>
确认内容
</ConfirmDialog>,
);
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(confirm).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(close).toHaveBeenCalledTimes(1);
});
it('属性行支持复制且搜索词可高亮', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
render(
<>
<PropertyRow label="Body" value="robot" action={<CopyButton value="robot" />} />
<SearchHighlight text="robot_arm" query="arm" />
<Kbd>Ctrl+K</Kbd>
</>,
);
fireEvent.click(screen.getByRole('button', { name: '复制' }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith('robot'));
expect(screen.getByText('arm').tagName).toBe('MARK');
expect(screen.getByText('Ctrl+K')).toBeVisible();
});
it('Clipboard API 不可用时复制按钮不会抛错', () => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined });
render(<CopyButton value="robot" />);
expect(() => fireEvent.click(screen.getByRole('button', { name: '复制' }))).not.toThrow();
});
}); });
@@ -1,6 +1,41 @@
import type {ComponentType} from 'react'; import type { ComponentType } from 'react';
import {IconButton} from './IconButton'; import { IconButton } from './IconButton';
export interface ToolbarItem<T extends string>{value:T;label:string;icon:ComponentType<{className?:string}>;} export interface ToolbarItem<T extends string> {
export function ToolbarToggleGroup<T extends string>({items,value,onChange,label}:{items:readonly ToolbarItem<T>[];value:T;onChange:(value:T)=>void;label:string}){ value: T;
return <div role="toolbar" aria-label={label} className="flex items-center gap-0.5 rounded-lg border border-border bg-surface/80 p-0.5 shadow-sm">{items.map(item=>{const Icon=item.icon;return <IconButton key={item.value} active={item.value===value} tooltip={item.label} aria-label={item.label} onClick={()=>onChange(item.value)}><Icon className="h-3.5 w-3.5"/></IconButton>;})}</div>; label: string;
icon: ComponentType<{ className?: string }>;
}
export function ToolbarToggleGroup<T extends string>({
items,
value,
onChange,
label,
}: {
items: readonly ToolbarItem<T>[];
value: T;
onChange: (value: T) => void;
label: string;
}) {
return (
<div
role="toolbar"
aria-label={label}
className="flex items-center gap-0.5 rounded-lg border border-border bg-surface/80 p-0.5 shadow-sm"
>
{items.map((item) => {
const Icon = item.icon;
return (
<IconButton
key={item.value}
active={item.value === value}
tooltip={item.label}
aria-label={item.label}
onClick={() => onChange(item.value)}
>
<Icon className="h-3.5 w-3.5" />
</IconButton>
);
})}
</div>
);
} }
+22 -7
View File
@@ -1,9 +1,24 @@
import type {ReactElement,ReactNode} from 'react'; import type { ReactElement, ReactNode } from 'react';
export function Tooltip({content,children,side='bottom'}:{content:ReactNode;children:ReactElement;side?:'top'|'bottom'}){ export function Tooltip({
if(!content)return children; content,
return <span className="group/tooltip relative inline-flex"> children,
{children} side = 'bottom',
<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>; content: ReactNode;
children: ReactElement;
side?: 'top' | 'bottom';
}) {
if (!content) return children;
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>
);
} }
@@ -1,3 +1,26 @@
import {fireEvent,render,screen} from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import {VirtualTreeViewport} from './VirtualTreeViewport'; import { VirtualTreeViewport } from './VirtualTreeViewport';
describe('VirtualTreeViewport',()=>{it('只渲染可视窗口并在滚动后更新行',()=>{const items=Array.from({length:1000},(_,id)=>({id,label:`节点 ${id}`}));render(<VirtualTreeViewport label="大型树" items={items} height={100} rowHeight={20} overscan={1} getKey={item=>item.id} renderRow={item=><div>{item.label}</div>}/>);const tree=screen.getByRole('tree',{name:'大型树'});expect(screen.getByText('节点 0')).toBeVisible();expect(screen.queryByText('节点 500')).not.toBeInTheDocument();Object.defineProperty(tree,'scrollTop',{configurable:true,value:10000});fireEvent.scroll(tree);expect(screen.getByText('节点 500')).toBeVisible();fireEvent.keyDown(tree,{key:'ArrowDown'});expect(tree.getAttribute('aria-activedescendant')).toContain('501');});}); describe('VirtualTreeViewport', () => {
it('只渲染可视窗口并在滚动后更新行', () => {
const items = Array.from({ length: 1000 }, (_, id) => ({ id, label: `节点 ${id}` }));
render(
<VirtualTreeViewport
label="大型树"
items={items}
height={100}
rowHeight={20}
overscan={1}
getKey={(item) => item.id}
renderRow={(item) => <div>{item.label}</div>}
/>,
);
const tree = screen.getByRole('tree', { name: '大型树' });
expect(screen.getByText('节点 0')).toBeVisible();
expect(screen.queryByText('节点 500')).not.toBeInTheDocument();
Object.defineProperty(tree, 'scrollTop', { configurable: true, value: 10000 });
fireEvent.scroll(tree);
expect(screen.getByText('节点 500')).toBeVisible();
fireEvent.keyDown(tree, { key: 'ArrowDown' });
expect(tree.getAttribute('aria-activedescendant')).toContain('501');
});
});
@@ -1,2 +1,122 @@
import {useMemo,useRef,useState,type KeyboardEvent,type ReactNode} from 'react'; import { useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
export function VirtualTreeViewport<T>({items,rowHeight=26,height=520,overscan=6,getKey,getLevel=()=>1,isExpandable=()=>false,isExpanded=()=>false,onToggle,onActiveChange,renderRow,label}:{items:T[];rowHeight?:number;height?:number;overscan?:number;getKey:(item:T)=>string|number;getLevel?:(item:T)=>number;isExpandable?:(item:T)=>boolean;isExpanded?:(item:T)=>boolean;onToggle?:(item:T)=>void;onActiveChange?:(item:T)=>void;renderRow:(item:T,index:number)=>ReactNode;label:string}){const root=useRef<HTMLDivElement>(null),[scrollTop,setScrollTop]=useState(0),[active,setActive]=useState(0),range=useMemo(()=>{const start=Math.max(0,Math.floor(scrollTop/rowHeight)-overscan),count=Math.ceil(height/rowHeight)+overscan*2;return {start,end:Math.min(items.length,start+count)};},[height,items.length,overscan,rowHeight,scrollTop]),safeActive=Math.min(active,Math.max(0,items.length-1)),activeId=items.length?`${label}-${getKey(items[safeActive])}`:undefined;const activate=(index:number)=>{const next=Math.min(items.length-1,Math.max(0,index));setActive(next);const item=items[next];if(item)onActiveChange?.(item);const viewport=root.current;if(viewport){const top=next*rowHeight;if(top<viewport.scrollTop)viewport.scrollTop=top;else if(top+rowHeight>viewport.scrollTop+height)viewport.scrollTop=top+rowHeight-height;}};const key=(event:KeyboardEvent<HTMLDivElement>)=>{if(!items.length)return;const item=items[safeActive],level=getLevel(item);if(event.key==='ArrowDown')activate(safeActive+1);else if(event.key==='ArrowUp')activate(safeActive-1);else if(event.key==='Home')activate(0);else if(event.key==='End')activate(items.length-1);else if(event.key==='ArrowRight'&&isExpandable(item)&&!isExpanded(item))onToggle?.(item);else if(event.key==='ArrowLeft'&&isExpandable(item)&&isExpanded(item))onToggle?.(item);else if(event.key==='ArrowLeft'){for(let index=safeActive-1;index>=0;index--)if(getLevel(items[index])<level){activate(index);break;}}else if((event.key==='Enter'||event.key===' ')&&isExpandable(item))onToggle?.(item);else return;event.preventDefault();};return <div ref={root} role="tree" aria-label={label} aria-activedescendant={activeId} tabIndex={0} className="panel-scroll relative overflow-auto outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30" style={{height:Math.min(height,Math.max(rowHeight,items.length*rowHeight))}} onKeyDown={key} onScroll={event=>{const top=event.currentTarget.scrollTop,first=Math.floor(top/rowHeight),last=first+Math.ceil(height/rowHeight);setScrollTop(top);if(safeActive<first||safeActive>last){setActive(first);if(items[first])onActiveChange?.(items[first]);}}}><div style={{height:items.length*rowHeight,position:'relative'}}>{items.slice(range.start,range.end).map((item,offset)=>{const index=range.start+offset,expandable=isExpandable(item);return <div id={`${label}-${getKey(item)}`} role="treeitem" aria-level={getLevel(item)} aria-expanded={expandable?isExpanded(item):undefined} key={getKey(item)} onMouseDown={()=>activate(index)} className={index===safeActive?'bg-accent-soft/60':''} style={{position:'absolute',left:0,right:0,top:index*rowHeight,height:rowHeight}}>{renderRow(item,index)}</div>;})}</div></div>;} export function VirtualTreeViewport<T>({
items,
rowHeight = 26,
height = 520,
overscan = 6,
getKey,
getLevel = () => 1,
isExpandable = () => false,
isExpanded = () => false,
onToggle,
onActiveChange,
renderRow,
label,
}: {
items: T[];
rowHeight?: number;
height?: number;
overscan?: number;
getKey: (item: T) => string | number;
getLevel?: (item: T) => number;
isExpandable?: (item: T) => boolean;
isExpanded?: (item: T) => boolean;
onToggle?: (item: T) => void;
onActiveChange?: (item: T) => void;
renderRow: (item: T, index: number) => ReactNode;
label: string;
}) {
const root = useRef<HTMLDivElement>(null),
[scrollTop, setScrollTop] = useState(0),
[active, setActive] = useState(0),
range = useMemo(() => {
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan),
count = Math.ceil(height / rowHeight) + overscan * 2;
return { start, end: Math.min(items.length, start + count) };
}, [height, items.length, overscan, rowHeight, scrollTop]),
safeActive = Math.min(active, Math.max(0, items.length - 1)),
activeId = items.length ? `${label}-${getKey(items[safeActive])}` : undefined;
const activate = (index: number) => {
const next = Math.min(items.length - 1, Math.max(0, index));
setActive(next);
const item = items[next];
if (item) onActiveChange?.(item);
const viewport = root.current;
if (viewport) {
const top = next * rowHeight;
if (top < viewport.scrollTop) viewport.scrollTop = top;
else if (top + rowHeight > viewport.scrollTop + height)
viewport.scrollTop = top + rowHeight - height;
}
};
const key = (event: KeyboardEvent<HTMLDivElement>) => {
if (!items.length) return;
const item = items[safeActive],
level = getLevel(item);
if (event.key === 'ArrowDown') activate(safeActive + 1);
else if (event.key === 'ArrowUp') activate(safeActive - 1);
else if (event.key === 'Home') activate(0);
else if (event.key === 'End') activate(items.length - 1);
else if (event.key === 'ArrowRight' && isExpandable(item) && !isExpanded(item))
onToggle?.(item);
else if (event.key === 'ArrowLeft' && isExpandable(item) && isExpanded(item)) onToggle?.(item);
else if (event.key === 'ArrowLeft') {
for (let index = safeActive - 1; index >= 0; index--)
if (getLevel(items[index]) < level) {
activate(index);
break;
}
} else if ((event.key === 'Enter' || event.key === ' ') && isExpandable(item)) onToggle?.(item);
else return;
event.preventDefault();
};
return (
<div
ref={root}
role="tree"
aria-label={label}
aria-activedescendant={activeId}
tabIndex={0}
className="panel-scroll relative overflow-auto outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30"
style={{ height: Math.min(height, Math.max(rowHeight, items.length * rowHeight)) }}
onKeyDown={key}
onScroll={(event) => {
const top = event.currentTarget.scrollTop,
first = Math.floor(top / rowHeight),
last = first + Math.ceil(height / rowHeight);
setScrollTop(top);
if (safeActive < first || safeActive > last) {
setActive(first);
if (items[first]) onActiveChange?.(items[first]);
}
}}
>
<div style={{ height: items.length * rowHeight, position: 'relative' }}>
{items.slice(range.start, range.end).map((item, offset) => {
const index = range.start + offset,
expandable = isExpandable(item);
return (
<div
id={`${label}-${getKey(item)}`}
role="treeitem"
aria-level={getLevel(item)}
aria-expanded={expandable ? isExpanded(item) : undefined}
key={getKey(item)}
onMouseDown={() => activate(index)}
className={index === safeActive ? 'bg-accent-soft/60' : ''}
style={{
position: 'absolute',
left: 0,
right: 0,
top: index * rowHeight,
height: rowHeight,
}}
>
{renderRow(item, index)}
</div>
);
})}
</div>
</div>
);
}

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