chore(web-platform): release V0.6.1 工程质量优化
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-08-28 15:38:10 +08:00
parent f4b415c54f
commit 60d3a6d68c
135 changed files with 13552 additions and 2882 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:
quality:
name: TypeScript、Lint、Unit、Build
name: TypeScript, lint, unit, build
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
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
e2e:
@@ -27,8 +34,10 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
node-version-file: .nvmrc
cache: npm
- run: npm install --global npm@11.17.0
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- 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
@@ -0,0 +1 @@
engine-strict=true
+1
View File
@@ -0,0 +1 @@
24.19.0
+14
View File
@@ -0,0 +1,14 @@
AGENTS.md
context.md
plans/
.git/
.venv/
build/
node_modules/
web-platform-dist/
coverage/
playwright-report/
test-results/
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"
}
+35
View File
@@ -0,0 +1,35 @@
# 更新日志
本项目的重要变更记录在此文件中,版本标签沿用仓库现有的 `V主版本.次版本[.修订版本]` 格式。
## [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
### 变更
- 优化响应式工作区、可访问性、首屏加载、纹理兼容性和视口交互。
- 增加碰撞体、坐标系、关节轴、质心和惯量辅助可视化。
+12 -4
View File
@@ -17,10 +17,12 @@
## 快速开始
环境要求:Node.js 24+;仅使用训练桥接服务时需要 Python 3。
环境要求:Node.js 24(版本见 `.nvmrc`)和 npm 11.17;仅使用训练桥接服务或执行 Python 检查时需要 Python 3.12。
```bash
npm install
nvm use
npm install --global npm@11.17.0
npm ci
npm run dev
```
@@ -34,10 +36,16 @@ npm run build # 生产构建到 web-platform-dist/
npm run preview # 预览生产构建
npm run typecheck # TypeScript 检查
npm run lint # ESLint
npm run check:format # Prettier 格式检查
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 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 reactHooks from 'eslint-plugin-react-hooks';
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",
"version": "0.6.0",
"version": "0.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mujoco-web-platform",
"version": "0.6.0",
"version": "0.6.1",
"license": "Apache-2.0",
"dependencies": {
"@monaco-editor/react": "^4.7.0",
@@ -30,6 +30,7 @@
"@types/react-dom": "^19.2.4",
"@types/three": "^0.185.4",
"@vitejs/plugin-react": "^6.1.0",
"@vitest/coverage-v8": "4.1.11",
"autoprefixer": "^10.5.4",
"eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -37,12 +38,17 @@
"globals": "^17.11.0",
"jsdom": "^30.0.1",
"postcss": "^8.5.26",
"prettier": "3.9.6",
"tailwindcss": "^3.4.17",
"three": "^0.178.0",
"typescript": "5.8.2",
"typescript-eslint": "^8.67.0",
"vite": "^8.0.16",
"vitest": "^4.1.11"
},
"engines": {
"node": ">=24 <25",
"npm": "11.17.0"
}
},
"node_modules/@adobe/css-tools": {
@@ -191,17 +197,6 @@
"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": {
"version": "7.29.7",
"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_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": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -845,17 +850,6 @@
"@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": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
@@ -867,17 +861,6 @@
"@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": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -893,6 +876,17 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"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": {
"version": "1.7.0",
"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": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
@@ -2090,6 +2115,25 @@
"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": {
"version": "10.5.4",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
@@ -3013,6 +3057,16 @@
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
"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": {
"version": "2.0.4",
"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_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": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3161,6 +3222,45 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"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": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -3645,6 +3745,47 @@
"@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": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
@@ -4192,6 +4333,22 @@
"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": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -4600,6 +4757,19 @@
"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": {
"version": "1.0.0",
"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",
"version": "0.6.0",
"version": "0.6.1",
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
"private": true,
"type": "module",
@@ -14,7 +14,12 @@
"test:e2e": "playwright test -c web_platform/playwright.config.ts",
"training-server": "python3 training_server/server.py",
"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",
"devDependencies": {
@@ -27,6 +32,7 @@
"@types/react-dom": "^19.2.4",
"@types/three": "^0.185.4",
"@vitejs/plugin-react": "^6.1.0",
"@vitest/coverage-v8": "4.1.11",
"autoprefixer": "^10.5.4",
"eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -34,6 +40,7 @@
"globals": "^17.11.0",
"jsdom": "^30.0.1",
"postcss": "^8.5.26",
"prettier": "3.9.6",
"tailwindcss": "^3.4.17",
"three": "^0.178.0",
"typescript": "5.8.2",
@@ -55,5 +62,10 @@
"react": "^19.2.8",
"react-dom": "^19.2.8",
"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-python /path/to/training-env/bin/python
```
如:
```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
```
服务启动时会在终端显示一个随机访问令牌。将该令牌填入前端“访问令牌”字段后再连接。令牌只保存在当前浏览器标签页的 `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` 提供,可显式添加来源:
```bash
@@ -24,7 +33,7 @@ python training_server/server.py \
--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
python3 -m pip install -r requirements-dev.txt
npm run lint:python
npm run test:training-server
```
+175 -46
View File
@@ -4,29 +4,32 @@
from __future__ import annotations
import argparse
import hmac
import json
import os
import re
import secrets
import shutil
import signal
import subprocess
import sys
import threading
import time
import uuid
from collections import deque
from contextlib import suppress
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import UTC, datetime
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlsplit
VERSION = "0.1.0"
VERSION = "0.2.0"
# 浏览器当前 ONNX 运行时只实现 Go2 的 47→12 部署契约;其他任务须由服务启动参数显式放行。
DEFAULT_TASKS = ("Unitree-Go2-Flat",)
ACTIVE_STATES = {"queued", "running"}
MAX_JOBS = 20
ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
ITERATION_PATTERNS = (
re.compile(r"(?:learning\s+)?iteration\D+(\d+)\s*/\s*(\d+)", re.I),
@@ -34,10 +37,16 @@ ITERATION_PATTERNS = (
)
RUN_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
LOCAL_ORIGIN = re.compile(r"^https?://(?:localhost|127\.0\.0\.1)(?::\d+)?$")
LOCAL_HOST = re.compile(r"^(?:localhost|127\.0\.0\.1)(?::\d+)?$")
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
return datetime.now(UTC).isoformat()
def termination_signal_handler(_signum: int, _frame: Any) -> None:
"""将 SIGTERM 转成受控退出,使 main 的 finally 能清理训练子进程。"""
raise KeyboardInterrupt
class ApiError(Exception):
@@ -95,7 +104,13 @@ class TrainingJob:
class TrainingManager:
def __init__(self, trainer_root: Path, python: str, tasks: tuple[str, ...], check_environment: bool = True):
def __init__(
self,
trainer_root: Path,
python: str,
tasks: tuple[str, ...],
check_environment: bool = True,
):
self.trainer_root = trainer_root.expanduser().resolve()
self.python = str(Path(python).expanduser()) if os.sep in python else python
self.tasks = tasks
@@ -113,11 +128,25 @@ class TrainingManager:
if not executable.is_file() and shutil.which(self.python) is None:
return f"Python 解释器不存在:{self.python}"
if self.check_environment and self._environment_error is False:
probe = "import importlib.util,sys; missing=[m for m in ('mjlab','torch','tyro') if importlib.util.find_spec(m) is None]; print(','.join(missing)); sys.exit(bool(missing))"
probe = (
"import importlib.util,sys; "
"missing=[m for m in ('mjlab','torch','tyro') "
"if importlib.util.find_spec(m) is None]; "
"print(','.join(missing)); sys.exit(bool(missing))"
)
try:
result = subprocess.run([self.python, "-c", probe], cwd=self.trainer_root, capture_output=True, text=True, timeout=15, check=False)
result = subprocess.run(
[self.python, "-c", probe],
cwd=self.trainer_root,
capture_output=True,
text=True,
timeout=15,
check=False,
)
missing = result.stdout.strip()
self._environment_error = f"训练 Python 缺少依赖:{missing}" if result.returncode else None
self._environment_error = (
f"训练 Python 缺少依赖:{missing}" if result.returncode else None
)
except (OSError, subprocess.TimeoutExpired) as error:
self._environment_error = f"无法检查训练 Python 环境:{error}"
return self._environment_error if isinstance(self._environment_error, str) else None
@@ -147,18 +176,28 @@ class TrainingManager:
def integer(name: str, minimum: int, maximum: int) -> int:
value = payload.get(name)
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
if (
isinstance(value, bool)
or not isinstance(value, int)
or not minimum <= value <= maximum
):
raise ApiError(HTTPStatus.BAD_REQUEST, f"{name} 必须在 {minimum}–{maximum} 之间")
return value
run_name = payload.get("runName", "web")
if not isinstance(run_name, str) or not RUN_NAME.fullmatch(run_name):
raise ApiError(HTTPStatus.BAD_REQUEST, "runName 只能包含字母、数字、点、下划线和连字符,最长 64 字符")
raise ApiError(
HTTPStatus.BAD_REQUEST,
"runName 只能包含字母、数字、点、下划线和连字符,最长 64 字符",
)
device = payload.get("device")
if device not in ("cpu", "gpu"):
raise ApiError(HTTPStatus.BAD_REQUEST, "device 必须是 cpu 或 gpu")
raw_gpu_ids = payload.get("gpuIds", [])
if not isinstance(raw_gpu_ids, list) or any(isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > 255 for value in raw_gpu_ids):
if not isinstance(raw_gpu_ids, list) or any(
isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > 255
for value in raw_gpu_ids
):
raise ApiError(HTTPStatus.BAD_REQUEST, "gpuIds 必须是非负整数数组")
if device == "gpu" and not raw_gpu_ids:
raise ApiError(HTTPStatus.BAD_REQUEST, "GPU 训练至少需要一个 GPU 编号")
@@ -184,9 +223,19 @@ class TrainingManager:
with self.lock:
if self.active_job_id():
raise ApiError(HTTPStatus.CONFLICT, "已有训练任务正在运行,请等待完成或先停止任务")
while len(self.jobs) >= MAX_JOBS:
completed = next(
(job_id for job_id, job in self.jobs.items() if job.state not in ACTIVE_STATES),
None,
)
if completed is None:
raise ApiError(HTTPStatus.CONFLICT, "训练任务历史已满,请稍后重试")
del self.jobs[completed]
job = TrainingJob(id=uuid.uuid4().hex, config=config)
self.jobs[job.id] = job
threading.Thread(target=self._run, args=(job,), name=f"training-{job.id[:8]}", daemon=True).start()
threading.Thread(
target=self._run, args=(job,), name=f"training-{job.id[:8]}", daemon=True
).start()
return job.public()
def get(self, job_id: str) -> dict[str, Any]:
@@ -216,10 +265,8 @@ class TrainingManager:
job.message = "正在停止训练进程"
process = job.process
if process and process.poll() is None:
try:
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
threading.Thread(target=self._kill_later, args=(process,), daemon=True).start()
return self.get(job_id)
@@ -228,14 +275,31 @@ class TrainingManager:
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
try:
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
def shutdown(self) -> None:
"""同步停止活动训练,避免服务退出后遗留子进程。"""
active = self.active_job_id()
if not active:
return
self.cancel(active)
with self.lock:
process = self.jobs[active].process
if process and process.poll() is None:
try:
process.wait(timeout=6)
except subprocess.TimeoutExpired:
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=2)
def command_for(self, config: TrainingConfig) -> list[str]:
command = [
self.python, "-u", "scripts/train.py", config.task_id,
self.python,
"-u",
"scripts/train.py",
config.task_id,
f"--env.scene.num-envs={config.num_envs}",
f"--agent.max-iterations={config.max_iterations}",
f"--agent.seed={config.seed}",
@@ -258,7 +322,9 @@ class TrainingManager:
for pattern in ITERATION_PATTERNS:
match = pattern.search(line)
if match:
job.iteration = min(job.config.max_iterations, max(job.iteration, int(match.group(1))))
job.iteration = min(
job.config.max_iterations, max(job.iteration, int(match.group(1)))
)
break
job.message = line[-240:]
@@ -266,29 +332,36 @@ class TrainingManager:
root = self.trainer_root / "logs" / "rsl_rl"
if not root.is_dir():
return {}
return {path: path.stat().st_mtime_ns for path in root.glob("**/policy.onnx") if path.is_file()}
return {
path: path.stat().st_mtime_ns for path in root.glob("**/policy.onnx") if path.is_file()
}
def _find_artifact(self, before: dict[Path, int]) -> Path | None:
root = self.trainer_root / "logs" / "rsl_rl"
if not root.is_dir():
return None
changed = [path for path in root.glob("**/policy.onnx") if path.is_file() and before.get(path) != path.stat().st_mtime_ns]
changed = [
path
for path in root.glob("**/policy.onnx")
if path.is_file() and before.get(path) != path.stat().st_mtime_ns
]
return max(changed, key=lambda path: path.stat().st_mtime_ns) if changed else None
def _run(self, job: TrainingJob) -> None:
before = self._artifact_snapshot()
command = self.command_for(job.config)
with self.lock:
if job.cancel_requested:
job.state, job.ended_at, job.message = "cancelled", now_iso(), "训练已取消"
return
job.state, job.started_at, job.message = "running", now_iso(), "本地训练进程已启动"
try:
environment = os.environ.copy()
# 默认离线记录,保留本地 W&B 指标但不要求 API Key;只有前端明确选择
# online 时才允许 wandb 发起登录/联网。
environment["WANDB_MODE"] = job.config.wandb_mode
environment["WANDB_SILENT"] = "true"
try:
# Popen 与 process 登记必须和取消检查处于同一个临界区:cancel() 要么在
# 创建前标记取消,要么在创建后取得进程并终止,不能落入二者之间。
with self.lock:
if job.cancel_requested:
job.state, job.ended_at, job.message = "cancelled", now_iso(), "训练已取消"
return
process = subprocess.Popen(
command,
cwd=self.trainer_root,
@@ -301,8 +374,12 @@ class TrainingManager:
bufsize=1,
start_new_session=True,
)
with self.lock:
job.process = process
job.state, job.started_at, job.message = (
"running",
now_iso(),
"本地训练进程已启动",
)
assert process.stdout is not None
try:
for line in process.stdout:
@@ -336,14 +413,28 @@ class TrainingManager:
class TrainingRequestHandler(BaseHTTPRequestHandler):
manager: TrainingManager
allowed_origins: tuple[str, ...] = ()
server_version = "MuJoCoLocalTraining/0.1"
access_token = ""
server_version = "MuJoCoLocalTraining/0.2"
def log_message(self, format: str, *args: Any) -> None:
sys.stderr.write(f"[{self.log_date_time_string()}] {format % args}\n")
def _origin_allowed(self) -> bool:
origin = self.headers.get("Origin")
return origin is None or bool(LOCAL_ORIGIN.fullmatch(origin)) or origin in self.allowed_origins
return (
origin is None or bool(LOCAL_ORIGIN.fullmatch(origin)) or origin in self.allowed_origins
)
def _host_allowed(self) -> bool:
host = self.headers.get("Host", "")
return bool(LOCAL_HOST.fullmatch(host))
def _authorized(self) -> bool:
authorization = self.headers.get("Authorization", "")
prefix = "Bearer "
return authorization.startswith(prefix) and hmac.compare_digest(
authorization[len(prefix) :], self.access_token
)
def _cors(self) -> None:
origin = self.headers.get("Origin")
@@ -365,19 +456,30 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
if isinstance(error, ApiError):
self._json(error.status, {"error": str(error)})
else:
self._json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": f"本地训练服务内部错误:{error}"})
self._json(
HTTPStatus.INTERNAL_SERVER_ERROR, {"error": f"本地训练服务内部错误:{error}"}
)
def _ensure_origin(self) -> None:
if not self._host_allowed():
raise ApiError(HTTPStatus.FORBIDDEN, "不允许的 Host")
if not self._origin_allowed():
raise ApiError(HTTPStatus.FORBIDDEN, "不允许的浏览器来源")
def _ensure_request(self) -> None:
self._ensure_origin()
if not self._authorized():
raise ApiError(HTTPStatus.UNAUTHORIZED, "训练服务访问令牌无效")
def _payload(self) -> Any:
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError as error:
raise ApiError(HTTPStatus.BAD_REQUEST, "Content-Length 无效") from error
if length <= 0 or length > 32 * 1024:
raise ApiError(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "训练请求体不能为空且不能超过 32 KiB")
raise ApiError(
HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "训练请求体不能为空且不能超过 32 KiB"
)
try:
return json.loads(self.rfile.read(length))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
@@ -394,7 +496,7 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
self.send_response(HTTPStatus.NO_CONTENT)
self._cors()
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
self.send_header("Access-Control-Max-Age", "600")
self.end_headers()
except Exception as error:
@@ -402,7 +504,7 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
try:
self._ensure_origin()
self._ensure_request()
path = urlsplit(self.path).path
if path == "/api/training/health":
self._json(HTTPStatus.OK, self.manager.health())
@@ -429,7 +531,7 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
try:
self._ensure_origin()
self._ensure_request()
if urlsplit(self.path).path != "/api/training/jobs":
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
self._json(HTTPStatus.ACCEPTED, self.manager.start(self._payload()))
@@ -438,7 +540,7 @@ class TrainingRequestHandler(BaseHTTPRequestHandler):
def do_DELETE(self) -> None:
try:
self._ensure_origin()
self._ensure_request()
job_id, artifact = self._route(urlsplit(self.path).path)
if not job_id or artifact:
raise ApiError(HTTPStatus.NOT_FOUND, "接口不存在")
@@ -457,34 +559,61 @@ def default_trainer_root() -> Path:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="MuJoCo Web 平台本地强化学习训练服务")
parser.add_argument("--host", default="127.0.0.1", choices=("127.0.0.1", "localhost"), help="仅允许绑定本机回环地址")
parser.add_argument(
"--host",
default="127.0.0.1",
choices=("127.0.0.1", "localhost"),
help="仅允许绑定本机回环地址",
)
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--trainer-root", type=Path, default=default_trainer_root(), help="unitree_rl_mjlab 工程目录")
parser.add_argument("--trainer-python", default=sys.executable, help="已安装 mjlab/torch 的 Python 解释器")
parser.add_argument("--task", action="append", dest="tasks", help="允许前端启动的任务 ID;可重复")
parser.add_argument("--allow-origin", action="append", default=[], help="额外允许的前端 Origin;可重复")
parser.add_argument(
"--trainer-root",
type=Path,
default=default_trainer_root(),
help="unitree_rl_mjlab 工程目录",
)
parser.add_argument(
"--trainer-python", default=sys.executable, help="已安装 mjlab/torch 的 Python 解释器"
)
parser.add_argument(
"--task", action="append", dest="tasks", help="允许前端启动的任务 ID;可重复"
)
parser.add_argument(
"--allow-origin", action="append", default=[], help="额外允许的前端 Origin;可重复"
)
parser.add_argument(
"--token",
default=os.environ.get("MUJOCO_TRAINING_TOKEN"),
help="访问令牌;默认随机生成,也可通过 MUJOCO_TRAINING_TOKEN 设置",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
manager = TrainingManager(args.trainer_root, args.trainer_python, tuple(args.tasks or DEFAULT_TASKS))
token = args.token or secrets.token_urlsafe(24)
if len(token) < 16:
raise SystemExit("训练服务访问令牌至少需要 16 个字符")
manager = TrainingManager(
args.trainer_root, args.trainer_python, tuple(args.tasks or DEFAULT_TASKS)
)
TrainingRequestHandler.manager = manager
TrainingRequestHandler.allowed_origins = tuple(args.allow_origin)
TrainingRequestHandler.access_token = token
server = ThreadingHTTPServer((args.host, args.port), TrainingRequestHandler)
print(f"本地训练服务:http://{args.host}:{args.port}")
print(f"访问令牌:{token}")
print(f"训练工程:{manager.trainer_root}")
print(f"Python:{manager.python}")
if manager.readiness_error():
print(f"警告:{manager.readiness_error()}", file=sys.stderr)
signal.signal(signal.SIGTERM, termination_signal_handler)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n正在停止本地训练服务…")
finally:
active = manager.active_job_id()
if active:
manager.cancel(active)
manager.shutdown()
server.server_close()
+117 -3
View File
@@ -1,11 +1,21 @@
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import patch
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):
@@ -25,7 +35,9 @@ out.write_bytes(b'onnx')
""",
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):
self.temporary.cleanup()
@@ -58,9 +70,111 @@ out.write_bytes(b'onnx')
def test_builds_argument_array_without_shell(self):
config = self.manager.parse_config(self.payload(device="gpu", gpuIds=[0, 2]))
command = self.manager.command_for(config)
self.assertEqual(command[:4], [sys.executable, "-u", "scripts/train.py", "Unitree-Go2-Flat"])
self.assertEqual(
command[:4], [sys.executable, "-u", "scripts/train.py", "Unitree-Go2-Flat"]
)
self.assertEqual(command[-2:], ["--gpu-ids", "[0,2]"])
def test_requires_local_host_origin_and_bearer_token(self):
handler = object.__new__(TrainingRequestHandler)
handler.access_token = "secret-token-1234"
handler.allowed_origins = ()
handler.headers = {
"Host": "127.0.0.1:8765",
"Origin": "http://localhost:5173",
"Authorization": "Bearer secret-token-1234",
}
handler._ensure_request()
handler.headers["Authorization"] = "Bearer wrong-token"
with self.assertRaises(ApiError) as error:
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
+2 -2
View File
@@ -77,9 +77,9 @@ npm run training-server -- \
--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 强化学习策略
+204 -49
View File
@@ -3,7 +3,8 @@ import {readFileSync} from 'node:fs';
import { fileURLToPath } from 'node:url';
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 = `
<mujoco model="e2e">
@@ -39,20 +40,36 @@ const LARGE_MODEL = `
test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`));
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('response', (response) => { if (response.status() >= 400 || response.url().endsWith('.wasm')) console.log(`[browser:response] ${response.status()} ${response.url()} ${response.headers()['content-type'] ?? ''}`); });
page.on('requestfailed', (request) =>
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.setViewportSize({ width: 1024, height: 768 });
const resetCameraBox=await page.getByRole('button',{name:'相机复位'}).boundingBox(),playBox=await page.getByRole('button',{name:'▶ 播放'}).boundingBox();
expect(resetCameraBox&&playBox&&resetCameraBox.x+resetCameraBox.width<=playBox.x).toBeTruthy();
await page.getByRole('button',{name:'更多工作台操作'}).click();await expect(page.getByRole('menuitem',{name:'工作台设置'})).toBeVisible();await page.keyboard.press('Escape');
const resetCameraBox = await page.getByRole('button', { name: '相机复位' }).boundingBox(),
playBox = await page.getByRole('button', { name: '▶ 播放' }).boundingBox();
expect(
resetCameraBox && playBox && resetCameraBox.x + resetCameraBox.width <= playBox.x,
).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('img', { name: 'XYZ 方向指示器' })).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 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.getByRole('button', { name: '工作台设置' }).click();
await expect(page.getByRole('dialog', { name: '工作台设置' })).toBeVisible();
await page.keyboard.press('Escape');
await page.keyboard.press('Control+k');
await expect(page.getByRole('dialog', { name: '命令面板' })).toBeVisible();
await page.getByLabel('搜索命令').fill('复位相机');
@@ -70,7 +87,10 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => {
await page.getByRole('button', { name: '切换到黑夜主题' }).click();
await expect(page.locator('#root > div')).toHaveClass(/theme-dark/);
await page.locator('input[type="file"]').first().setInputFiles({
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'model.xml',
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
@@ -78,18 +98,32 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => {
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: '显示设置' }).click();
const displayDialog=page.getByRole('dialog',{name:'视图显示设置'});await expect(displayDialog).toBeVisible();await expect(displayDialog.getByRole('switch')).toHaveCount(7);
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 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');
const displayDialog = page.getByRole('dialog', { name: '视图显示设置' });
await expect(displayDialog).toBeVisible();
await expect(displayDialog.getByRole('switch')).toHaveCount(7);
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 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 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 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();
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('button', { name: '重置关节' })).toBeVisible();
await page.getByRole('button', { name: '高级' }).click();
@@ -99,7 +133,10 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => {
await expect(page.getByText('下限 -90.000°')).toBeVisible();
await expect(page.getByText('上限 90.000°')).toBeVisible();
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 page.getByRole('button', { name: '重置关节' }).click();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
@@ -112,7 +149,9 @@ test('窄视口默认保留完整视口并可按需打开侧栏',async({page})=>
await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible();
await expect(page.getByRole('button', { name: '显示属性面板' })).toBeVisible();
await page.getByRole('button', { name: '显示属性面板' }).click();
await expect(page.getByRole('complementary').filter({hasText:'导入模型后显示属性'})).toBeVisible();
await expect(
page.getByRole('complementary').filter({ hasText: '导入模型后显示属性' }),
).toBeVisible();
});
test('工作区布局与视口显示偏好在刷新后保留', async ({ page }) => {
@@ -124,21 +163,32 @@ test('工作区布局与视口显示偏好在刷新后保留',async({page})=>{
await page.reload();
await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible();
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 }) => {
await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles({name:'model.xml',mimeType:'text/xml',buffer:Buffer.from(SIMPLE_MODEL)});
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.getByRole('button', { name: '源代码' }).click();
const dialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await expect(dialog).toBeVisible();
const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await expect(dialog).toBeVisible();
await expect(dialog.getByText('缓存文件 · 可编辑')).toBeVisible();
await expect(dialog.getByText('转换后的 MJCF', { exact: true })).toBeVisible();
const editor=dialog.locator('.monaco-editor');await editor.click({position:{x:240,y:120}});
await page.keyboard.press('Control+a');await page.keyboard.insertText(SIMPLE_MODEL.replace('model="e2e"','model="cached-edit"'));
const editor = dialog.locator('.monaco-editor');
await editor.click({ position: { x: 240, y: 120 } });
await page.keyboard.press('Control+a');
await page.keyboard.insertText(SIMPLE_MODEL.replace('model="e2e"', 'model="cached-edit"'));
await dialog.getByRole('button', { name: '保存并重新载入', exact: true }).click();
await expect(dialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000});
await expect(dialog.getByRole('button', { name: '保存并重新载入', exact: true })).toBeDisabled({
timeout: 30_000,
});
await dialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '导出 URDF' })).toHaveCount(0);
@@ -147,25 +197,35 @@ test('转换后的 MJCF 可编辑并重新载入', async ({page}) => {
test('关闭已修改的 MJCF 前要求确认', 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 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.getByRole('button', { name: '源代码' }).click();
const editorDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await editorDialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
await page.keyboard.press('Control+End');await page.keyboard.insertText('\n');
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 confirm.getByRole('button', { name: '继续编辑' }).click();
await expect(editorDialog).toBeVisible();
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);
});
test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({ page }) => {
await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles([
await page
.locator('input[type="file"]')
.first()
.setInputFiles([
fixture('mjcf_include/model.xml'),
fixture('mjcf_include/world.xml'),
fixture('mjcf_include/triangle.obj'),
@@ -178,10 +238,10 @@ test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({page}) => {
test('加载引用 OBJ 的 URDF 工程', async ({ page }) => {
await page.goto('/');
await page.locator('input[type="file"]').first().setInputFiles([
fixture('urdf_mesh/robot.urdf'),
fixture('urdf_mesh/triangle.obj'),
]);
await page
.locator('input[type="file"]')
.first()
.setInputFiles([fixture('urdf_mesh/robot.urdf'), fixture('urdf_mesh/triangle.obj')]);
const options = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
await expect(options.getByRole('checkbox', { name: /为关节添加驱动器/ })).toBeChecked();
await expect(options.getByRole('checkbox', { name: /添加传感器/ })).toBeChecked();
@@ -198,43 +258,138 @@ test('加载引用 OBJ 的 URDF 工程', async ({page}) => {
await page.keyboard.press('Escape');
await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked();
await page.getByRole('button', { name: '源代码' }).click();
const sourceDialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible();
await sourceDialog.locator('.monaco-editor').click({position:{x:240,y:120}});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();
const sourceDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible();
await sourceDialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
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 }) => {
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.getByRole('dialog',{name:'配置 URDF 仿真组件'}).getByRole('button',{name:'转换并加载'}).click();
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
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 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('已将 640×480 摄像头固连到 arm');
await page.keyboard.press('Escape');await page.getByRole('tab',{name:'控制'}).click();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');
await page.keyboard.press('Escape');
await page.getByRole('tab', { name: '控制' }).click();
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 }) => {
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')))});
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);
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')),
),
});
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 }) => {
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.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);
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.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 }) => {
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.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();
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.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 }) => {
+14 -1
View File
@@ -1 +1,14 @@
<!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="#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>
+10 -1
View File
@@ -1,2 +1,11 @@
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: {} },
};
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -1,3 +1,28 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
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
);
}
}
@@ -2,7 +2,30 @@ import {fireEvent,render,screen} from '@testing-library/react';
import { ActuatorControl } from './SidebarPanel';
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', () => {
it('显示对应关节和常用力矩单位', () => {
@@ -13,27 +36,61 @@ describe('ActuatorControl',()=>{
});
it('内部按 gear 换算输出,但参数面板只开放 kp、kv 等业务参数', () => {
const onControl=vi.fn(),onParameters=vi.fn();
render(<ActuatorControl actuator={actuator} onControl={onControl} onParameters={onParameters}/>);
const onControl = vi.fn(),
onParameters = vi.fn();
render(
<ActuatorControl actuator={actuator} onControl={onControl} onParameters={onParameters} />,
);
fireEvent.change(screen.getByRole('slider'), { target: { value: '2' } });
expect(onControl).toHaveBeenCalledWith(1);
fireEvent.click(screen.getByText('常用参数'));
expect(screen.queryByLabelText('传动比 gear')).not.toBeInTheDocument();
expect(screen.queryByLabelText('固定增益 gain')).not.toBeInTheDocument();
const kp=screen.getByLabelText(/kp(MJCF stiffness/);fireEvent.change(kp,{target:{value:'3'}});fireEvent.blur(kp);
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({kp:3,ctrlLimited:true,forceLimited:true}));
const kp = screen.getByLabelText(/kp(MJCF stiffness/);
fireEvent.change(kp, { target: { value: '3' } });
fireEvent.blur(kp);
expect(onParameters).toHaveBeenCalledWith(
expect.objectContaining({ kp: 3, ctrlLimited: true, forceLimited: true }),
);
});
it('position 伺服使用角度目标并开放 kp、kv', () => {
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}/>);
expect(screen.getByText('90.000 °')).toBeVisible();fireEvent.click(screen.getByText('常用参数'));
const kp=screen.getByLabelText(/位置增益 kp/);fireEvent.change(kp,{target:{value:'150'}});fireEvent.blur(kp);
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}
/>,
);
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 驱动器保持原始控制单位且不开放通用参数编辑', () => {
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.queryByText('常用参数')).not.toBeInTheDocument();
expect(screen.getByText(/不是可直接编辑的 motor\/position/)).toBeVisible();
@@ -1,12 +1,120 @@
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
import { Search } from 'lucide-react';
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 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>;
export interface WorkbenchCommand {
id: string;
label: string;
group: string;
icon?: ReactNode;
shortcut?: string;
disabled?: boolean;
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>
);
}
@@ -2,4 +2,49 @@ import {useState} from 'react';
import { ChevronDown, TriangleAlert, X } from 'lucide-react';
import type { AppDiagnostic } from '../../stores/useAppStore';
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>
);
}
@@ -3,4 +3,95 @@ import {CheckCircle2,Info,TriangleAlert,XCircle} from 'lucide-react';
import { Button, CopyButton, Dialog, Tabs } from '../../components/ui';
import type { WorkbenchNotification } from './NotificationCenter';
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 { 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 { Button, Dialog } from '../../components/ui';
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>
);
}
@@ -2,4 +2,67 @@ import {useState} from 'react';
import { ChevronDown, FolderTree, RefreshCw, TriangleAlert, X } from 'lucide-react';
import type { AppDiagnostic } from '../../stores/useAppStore';
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>
);
}
@@ -2,4 +2,37 @@ import {fireEvent,render,screen} from '@testing-library/react';
import { DiagnosticNotice } from './DiagnosticNotice';
import { WorkspaceOverlays } from './WorkspaceOverlays';
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} />);
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();
});
});
@@ -5,8 +5,62 @@ import {ToolbarOverflowMenu} from './ToolbarOverflowMenu';
import { WorkspaceOverlays } from './WorkspaceOverlays';
const event = { id: 1, title: '编译失败', detail: 'bad xml', tone: 'danger' as const, at: 0 };
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('错误恢复面板透传重试与工程树动作',()=>{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:.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();});
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('错误恢复面板透传重试与工程树动作', () => {
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 {NotificationCenter,ToastViewport,type WorkbenchNotification} from './NotificationCenter';
import {
NotificationCenter,
ToastViewport,
type WorkbenchNotification,
} from './NotificationCenter';
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { SettingsDialog } from './SettingsDialog';
import { LayoutSettingsDialog } from './LayoutSettingsDialog';
const item:WorkbenchNotification={id:1,title:'模型加载完成',detail:'完成',tone:'success',at:0};
const item: WorkbenchNotification = {
id: 1,
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');});
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 { Button, Dialog } from '../../components/ui';
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}];
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>;}
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 },
];
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
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 } }));
}
@@ -2,24 +2,86 @@ import {fireEvent,render,screen,waitFor} from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LocalTrainingPanel } from './LocalTrainingPanel';
beforeEach(()=>{localStorage.clear();vi.unstubAllGlobals();});
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.unstubAllGlobals();
});
describe('LocalTrainingPanel', () => {
it('连接本地服务并从图形界面发起训练请求', async () => {
const health={version:'0.1.0',ready:true,trainerRoot:'/opt/unitree_rl_mjlab',python:'/env/bin/python',tasks:['Unitree-Go2-Flat']};
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'}}));
const health = {
version: '0.1.0',
ready: true,
trainerRoot: '/opt/unitree_rl_mjlab',
python: '/env/bin/python',
tasks: ['Unitree-Go2-Flat'],
};
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();
fireEvent.change(screen.getByLabelText('并行环境'), { target: { value: '32' } });
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
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();
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');
});
});
@@ -2,76 +2,434 @@ import {useEffect,useState,type ReactNode} from 'react';
import { Download, Link, Play, Server, Square } from 'lucide-react';
import { Badge, Button, ProgressBar, PropertyRow, Select } from '../../components/ui';
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',
JOB_KEY = 'mujoco-local-training-job',
TOKEN_KEY = 'mujoco-local-training-token';
const DEFAULT_ENDPOINT = 'http://127.0.0.1:8765';
const ACTIVE_STATES = new Set(['queued', 'running']);
function stored(key:string,fallback=''):string{try{return localStorage.getItem(key)??fallback;}catch{return fallback;}}
function errorText(error:unknown):string{return error instanceof Error?error.message:String(error);}
function stateLabel(state:TrainingJob['state']):string{return {queued:'排队中',running:'训练中',succeeded:'已完成',failed:'失败',cancelled:'已取消'}[state];}
function stored(key: string, fallback = ''): string {
try {
return localStorage.getItem(key) ?? fallback;
} catch {
return fallback;
}
}
function sessionStored(key: string): string {
try {
return sessionStorage.getItem(key) ?? '';
} catch {
return '';
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function stateLabel(state: TrainingJob['state']): string {
return {
queued: '排队中',
running: '训练中',
succeeded: '已完成',
failed: '失败',
cancelled: '已取消',
}[state];
}
export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File): void }) {
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 [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);
setBusy(true);
setError(undefined);
try {
const client=new LocalTrainingClient(endpoint),info=await client.health();
setServer(info);try{localStorage.setItem(ENDPOINT_KEY,client.endpoint);}catch{/* 当前会话仍可连接 */}
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{setJob(await client.job(remembered));}catch{try{localStorage.removeItem(JOB_KEY);}catch{/* ignore */}}}
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);}
} catch (value) {
setServer(undefined);
setError(errorText(value));
} finally {
setBusy(false);
}
};
const jobId=job?.id,jobState=job?.state;
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).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 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);
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 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 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>;
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>;}
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>
);
}
@@ -1,7 +1,141 @@
import { useEffect, useRef } from 'react';
import { Bell, CheckCircle2, Info, Trash2, TriangleAlert, XCircle } from 'lucide-react';
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 {
id: number;
title: string;
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>;}
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 { 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>
);
}
@@ -1,4 +1,52 @@
import { ChevronRight, FolderRoot } from 'lucide-react';
import type { ModelEntry } from '../../project/types';
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>
);
}
@@ -7,16 +7,62 @@ const noop=()=>{};
describe('PythonControllerPanel', () => {
it('向支持 command 的已启用控制器发送基本移动指令', () => {
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(
<PythonControllerPanel
paths={[]}
loading={false}
status={{
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');
expect(screen.getByRole('button', { name: '移动停止' })).toHaveAttribute(
'aria-pressed',
'true',
);
});
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(
<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}
/>,
);
expect(screen.getByRole('button', { name: '前进' })).toBeDisabled();
expect(screen.getByRole('button', { name: '起跳' })).toBeDisabled();
});
@@ -1,5 +1,15 @@
import { useRef, type ChangeEvent } from 'react';
import {ArrowDown,ArrowLeft,ArrowRight,ArrowUp,FileUp,Octagon,Power,RotateCw,Trash2} from 'lucide-react';
import {
ArrowDown,
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';
@@ -16,22 +26,159 @@ export interface PythonControllerPanelProps {
onRemove(): void;
}
export function PythonControllerPanel({paths,selectedPath,status,loading,onSelectPath,onLoadPath,onImport,onToggle,onCommand,onRemove}:PythonControllerPanelProps){
export function PythonControllerPanel({
paths,
selectedPath,
status,
loading,
onSelectPath,
onLoadPath,
onImport,
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>}
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>
<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>;
{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>
);
}
+178 -18
View File
@@ -4,27 +4,187 @@ import type {RLCommand,RLPolicyStatus} from '../../rl/types';
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
export interface RLPolicyPanelProps {
paths:string[];selectedPath?:string;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;
paths: string[];
selectedPath?: string;
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({
paths,
selectedPath,
status,
loading,
onSelectPath,
onLoadPath,
onImport,
onToggle,
onCommand,
onRemove,
}: RLPolicyPanelProps) {
const input = useRef<HTMLInputElement>(null);
const importFile=(event:ChangeEvent<HTMLInputElement>)=>{const file=event.target.files?.[0];if(file)onImport(file);event.target.value='';};
const importFile = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
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>;
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>
);
}
@@ -7,9 +7,56 @@ import {ViewerDisplayPopover} from './ViewerDisplayPopover';
import { DEFAULT_VIEWER_DISPLAY_OPTIONS } from '../../viewer/displayOptions';
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('快捷键帮助展示说明并支持 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});});
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('快捷键帮助展示说明并支持 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';
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>;}
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>;}
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>
);
}
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';
const shortcuts=[['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>;}
const shortcuts = [
['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>
);
}
+749 -47
View File
@@ -1,67 +1,769 @@
import { useState, type ReactNode } from 'react';
import { Box, FolderTree, Info, Settings2, SlidersHorizontal } from 'lucide-react';
import type { ModelEntry } from '../../project/types';
import {countProjectSearchResults,ProjectTree,type ProjectTreeFile} from '../../project/ProjectTree';
import {countModelStructureSearchResults,ModelStructureTree} from '../../project/ModelStructureTree';
import type {ActuatorInfo,ActuatorParameters,SimulationSnapshot} from '../../simulation/SimulationSession';
import {
countProjectSearchResults,
ProjectTree,
type ProjectTreeFile,
} 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 { 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 {
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';
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,
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 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>;}
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>
);
}
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;
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;
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;
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;
}
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>
{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={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>;
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>
{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={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>
);
}
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>;
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 Editor from '@monaco-editor/react';
import {useCallback,useEffect,useRef,useState,type PointerEvent as ReactPointerEvent} from 'react';
import {
useCallback,
useEffect,
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 contentSize(content:string):string{const bytes=new Blob([content]).size;return bytes<1024?`${bytes} B`:`${(bytes/1024).toFixed(1)} KB`;}
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;}
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 [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></>;
function basename(path: string): string {
return path.split('/').at(-1) ?? path;
}
function contentSize(content: string): string {
const bytes = new Blob([content]).size;
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
}
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;
}
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 [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>
</>
);
}
+47 -3
View File
@@ -2,6 +2,50 @@ import type {ReactNode} from 'react';
import { Box, Clock3, MemoryStick, TriangleAlert } from 'lucide-react';
import { Kbd } from '../../components/ui';
import { PerformancePopover } from './PerformancePopover';
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 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>
);
}
@@ -3,6 +3,29 @@ import {CommandPalette,type WorkbenchCommand} from './CommandPalette';
import { PerformancePopover } from './PerformancePopover';
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('状态栏性能入口展示已有指标',()=>{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();});
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('状态栏性能入口展示已有指标', () => {
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 {
CircleHelp,
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}]}/>;}
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 { 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>
);
}
@@ -4,22 +4,43 @@ import {UrdfImportOptionsDialog} from './UrdfImportOptionsDialog';
describe('UrdfImportOptionsDialog', () => {
it('默认选择关节驱动器和摄像头传感器', () => {
const onConfirm = vi.fn();
render(<UrdfImportOptionsDialog open path="robot.urdf" mountBodies={['base','head_link']} onConfirm={onConfirm} onSkip={()=>{}}/>);
render(
<UrdfImportOptionsDialog
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');
fireEvent.change(screen.getByLabelText('摄像头位置 X'), { target: { value: '0.2' } });
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('允许分别关闭自动生成项', () => {
const onConfirm = vi.fn();
render(<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={onConfirm} onSkip={()=>{}}/>);
render(
<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={onConfirm} onSkip={() => {}} />,
);
fireEvent.click(screen.getByRole('checkbox', { name: /为关节添加驱动器/ }));
fireEvent.click(screen.getByRole('checkbox', { name: /添加传感器/ }));
fireEvent.click(screen.getByRole('button', { name: '转换并加载' }));
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({addActuators:false,addSensors:false,sensorType:'camera'}));
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({ addActuators: false, addSensors: false, sensorType: 'camera' }),
);
});
it('可以不添加组件并继续加载', () => {
@@ -3,24 +3,176 @@ import {Camera,Settings2} from 'lucide-react';
import type { CameraDirection, UrdfEnhancementOptions } from '../../project/urdfToMjcf';
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}){
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>;
function OptionCard({
checked,
onChange,
icon,
title,
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}){
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'}));
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};});
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??[.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>}
export function UrdfImportOptionsDialog({
open,
path,
mountBodies = [],
onConfirm,
onSkip,
}: {
open: boolean;
path?: string;
mountBodies?: string[];
onConfirm: (options: UrdfEnhancementOptions) => void;
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="mt-4 text-xs text-text-tertiary">自动组件只写入浏览器内生成的 MJCF,不会修改本地 URDF 文件;使用“原生 URDF”加载模式时不会注入这些组件。</p>
</Dialog>;
}
>
<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 { 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;
interface DisplayItem {key:DisplayKey;label:string;description:string;color:string;}
interface DisplayItem {
key: DisplayKey;
label: string;
description: string;
color: string;
}
const geometryItems: DisplayItem[] = [
{key:'showVisual',label:'视觉模型',description:'显示模型的外观几何与材质',color:'bg-slate-400'},
{key:'showCollision',label:'碰撞体',description:'以青色半透明方式叠加碰撞几何',color:'bg-cyan-400'},
{
key: 'showVisual',
label: '视觉模型',
description: '显示模型的外观几何与材质',
color: 'bg-slate-400',
},
{
key: 'showCollision',
label: '碰撞体',
description: '以青色半透明方式叠加碰撞几何',
color: 'bg-cyan-400',
},
];
const helperItems: DisplayItem[] = [
{key:'showFrames',label:'坐标系',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'},
{
key: 'showFrames',
label: '坐标系',
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[] = [
{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}){
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>;
function DisplayRows({
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}){
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>;
export function ViewerDisplayPopover({
value,
onChange,
}: {
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>
);
}
@@ -3,5 +3,31 @@ import type {InteractionMode} from '../../viewer/MuJoCoViewer';
import type { ViewerDisplayOptions } from '../../viewer/displayOptions';
import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
import { ViewerDisplayPopover } from './ViewerDisplayPopover';
const tools:ToolbarItem<InteractionMode>[]=[{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>;}
const tools: ToolbarItem<InteractionMode>[] = [
{ 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 type { InteractionMode, ViewerSelection } from '../../viewer/MuJoCoViewer';
import { Badge, Kbd } from '../../components/ui';
const labels:Record<InteractionMode,string>={select:'选择',joint:'关节拖动',force:'外力施加'};
const primaryGestures:Record<InteractionMode,string>={select:'左键旋转',joint:'左键拖动关节',force:'左键拖动施力'};
export function ViewportHUD({paused,mode,selection,ready}:{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></>;}
const labels: Record<InteractionMode, string> = {
select: '选择',
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,51 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { WorkbenchHeader } from './WorkbenchHeader';
const fn = () => {};
describe('WorkbenchHeader',()=>{it('透传仿真动作且保留可访问名称',()=>{const pause=vi.fn(),step=vi.fn(),reset=vi.fn(),speed=vi.fn();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();
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();
});
});
@@ -1,6 +1,209 @@
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 {
CircleHelp,
Code2,
Expand,
FolderOpen,
Minimize,
PanelLeft,
PanelRight,
Pause,
Play,
RotateCcw,
Search,
StepForward,
Sun,
Moon,
Upload,
} from 'lucide-react';
import { Button, IconButton, Select } from '../../components/ui';
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';
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';
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 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>;}
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={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,129 @@
import {Box,FolderOpen,LoaderCircle,PlayCircle,Settings2,ShieldCheck,Upload,UploadCloud} from 'lucide-react';
import {
Box,
FolderOpen,
LoaderCircle,
PlayCircle,
Settings2,
ShieldCheck,
Upload,
UploadCloud,
} from 'lucide-react';
import { ProgressBar, Skeleton } from '../../components/ui';
export interface ImportProgress{label: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="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>;}
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>}</>;}
export interface ImportProgress {
label: 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="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>
);
}
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>
)}
</>
);
}
+26 -1
View File
@@ -1,2 +1,27 @@
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>
);
}
+30 -4
View File
@@ -6,13 +6,39 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>{
icon?: ReactNode;
}
export function Button({variant='secondary',size='sm',icon,className='',children,type='button',...props}:ButtonProps){
export function Button({
variant = 'secondary',
size = 'sm',
icon,
className = '',
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',
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'};
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>;
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',
};
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 { 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 { ChevronRight } from 'lucide-react';
export function CollapsibleSection({title,children,defaultOpen=true,forceOpen=false,badge}:{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}
export function CollapsibleSection({
title,
children,
defaultOpen = true,
forceOpen = false,
badge,
}: {
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>;
</section>
);
}
@@ -1,4 +1,40 @@
import type { ReactNode } from 'react';
import { Button } from './Button';
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>
);
}
+28 -1
View File
@@ -1,4 +1,31 @@
import { useEffect, useState } from 'react';
import { Check, Copy } from 'lucide-react';
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>
);
}
+50 -2
View File
@@ -2,5 +2,53 @@ import {fireEvent,render,screen} from '@testing-library/react';
import { useState } from 'react';
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></>;}
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();});});
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>
</>
);
}
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();
});
});
+99 -8
View File
@@ -3,12 +3,103 @@ import {createPortal} from 'react-dom';
import { X } from 'lucide-react';
import { IconButton } from './IconButton';
const FOCUSABLE='button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
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}){
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);
const FOCUSABLE =
'button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
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;
}) {
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,
);
}
@@ -2,6 +2,84 @@ import {useEffect,useRef,type ReactNode} from 'react';
import { MoreHorizontal } from 'lucide-react';
import { IconButton } from './IconButton';
import { Popover } from './Popover';
export interface DropdownMenuItem{id:string;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>;}
export interface DropdownMenuItem {
id: string;
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';
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 { DropdownMenu, LiveRegion, ProgressBar, SearchableCombobox } from './index';
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('进度条和实时区域暴露状态',()=>{render(<><ProgressBar value={.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');});
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('进度条和实时区域暴露状态', () => {
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');
});
});
+19 -3
View File
@@ -1,8 +1,24 @@
import type { ButtonHTMLAttributes } from 'react';
import { Tooltip } from './Tooltip';
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>{active?:boolean;tooltip?:string;}
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}/>;
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
active?: boolean;
tooltip?: string;
}
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;
}
+7 -1
View File
@@ -1,2 +1,8 @@
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>
);
}
+18 -1
View File
@@ -1,2 +1,19 @@
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>
);
}
+64 -1
View File
@@ -1,3 +1,66 @@
/* 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';
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>
);
}
+19 -1
View File
@@ -1,2 +1,20 @@
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 {
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
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*.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>;}
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 { Check, ChevronsUpDown, Search } from 'lucide-react';
import { Popover } from './Popover';
export interface ComboboxOption{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>;}
export interface ComboboxOption {
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 { act, fireEvent, render, screen } from '@testing-library/react';
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() {
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> },
]}
/>
);
}
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();});
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();
});
});
+8 -1
View File
@@ -1,2 +1,9 @@
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}`}
/>
);
}
+83 -2
View File
@@ -1,3 +1,84 @@
import type { KeyboardEvent, ReactNode } from 'react';
export interface TabItem<T extends string>{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>;}
export interface TabItem<T extends string> {
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>
);
}
@@ -2,7 +2,37 @@ import {fireEvent,render,screen,waitFor} from '@testing-library/react';
import { ConfirmDialog, CopyButton, Kbd, PropertyRow, SearchHighlight } from './index';
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('属性行支持复制且搜索词可高亮',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();});
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('属性行支持复制且搜索词可高亮', 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 { IconButton } from './IconButton';
export interface ToolbarItem<T extends string>{value:T;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>;
export interface ToolbarItem<T extends string> {
value: T;
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>
);
}
+19 -4
View File
@@ -1,9 +1,24 @@
import type { ReactElement, ReactNode } from 'react';
export function Tooltip({content,children,side='bottom'}:{content:ReactNode;children:ReactElement;side?:'top'|'bottom'}){
export function Tooltip({
content,
children,
side = 'bottom',
}: {
content: ReactNode;
children: ReactElement;
side?: 'top' | 'bottom';
}) {
if (!content) return children;
return <span className="group/tooltip relative inline-flex">
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>;
<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 { 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';
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>
);
}
@@ -13,12 +13,19 @@ function pyodideIndexUrl():string {
}
export function getPythonRuntime(): Promise<PyodideInterface> {
pyodidePromise??=import('pyodide').then(({loadPyodide})=>loadPyodide({indexURL:pyodideIndexUrl()}));
pyodidePromise ??= import('pyodide').then(({ loadPyodide }) =>
loadPyodide({ indexURL: pyodideIndexUrl() }),
);
return pyodidePromise;
}
function destroyProxy(value: unknown): void {
if(value&&typeof value==='object'&&'destroy' in value&&typeof (value as {destroy?:unknown}).destroy==='function'){
if (
value &&
typeof value === 'object' &&
'destroy' in value &&
typeof (value as { destroy?: unknown }).destroy === 'function'
) {
(value as { destroy(): void }).destroy();
}
}
@@ -39,28 +46,59 @@ export class PythonControllerRuntime {
private nextControlTime = 0;
private statusValue: ControllerStatus;
private constructor(private readonly bindings:ControllerBindings,path:string,name:string,controlHz:number){
this.statusValue={language:'python',path,name,controlHz,loaded:true,enabled:false,acceptsCommands:false,lastStepMs:0};
private constructor(
private readonly bindings: ControllerBindings,
path: string,
name: string,
controlHz: number,
) {
this.statusValue = {
language: 'python',
path,
name,
controlHz,
loaded: true,
enabled: false,
acceptsCommands: false,
lastStepMs: 0,
};
}
static async load(source:string,path:string,bindings:ControllerBindings):Promise<PythonControllerRuntime>{
static async load(
source: string,
path: string,
bindings: ControllerBindings,
): Promise<PythonControllerRuntime> {
const pyodide = await getPythonRuntime();
const globals = pyodide.runPython('dict()') as PyDict;
globals.set('__name__', '__mujoco_controller__');
try {
await pyodide.runPythonAsync(source, { globals });
if (!globals.has('step')) throw new Error('Python 控制器必须定义 step(ctx, state)');
const rawHz=globals.has('CONTROL_HZ')?Number(globals.get('CONTROL_HZ')):DEFAULT_CONTROL_HZ;
const controlHz=Math.min(MAX_CONTROL_HZ,Math.max(MIN_CONTROL_HZ,Number.isFinite(rawHz)?rawHz:DEFAULT_CONTROL_HZ));
const name=globals.has('NAME')?String(globals.get('NAME')):path.split('/').at(-1)??path;
const rawHz = globals.has('CONTROL_HZ')
? Number(globals.get('CONTROL_HZ'))
: DEFAULT_CONTROL_HZ;
const controlHz = Math.min(
MAX_CONTROL_HZ,
Math.max(MIN_CONTROL_HZ, Number.isFinite(rawHz) ? rawHz : DEFAULT_CONTROL_HZ),
);
const name = globals.has('NAME')
? String(globals.get('NAME'))
: (path.split('/').at(-1) ?? path);
const runtime = new PythonControllerRuntime(bindings, path, name, controlHz);
runtime.globals = globals;
runtime.initFunction=globals.has('init')?globals.get('init') as PyCallable:undefined;
runtime.initFunction = globals.has('init') ? (globals.get('init') as PyCallable) : undefined;
runtime.stepFunction = globals.get('step') as PyCallable;
runtime.resetFunction=globals.has('reset')?globals.get('reset') as PyCallable:undefined;
runtime.commandFunction=globals.has('command')?globals.get('command') as PyCallable:undefined;
runtime.resetFunction = globals.has('reset')
? (globals.get('reset') as PyCallable)
: undefined;
runtime.commandFunction = globals.has('command')
? (globals.get('command') as PyCallable)
: undefined;
runtime.statusValue.acceptsCommands = Boolean(runtime.commandFunction);
runtime.disposeFunction=globals.has('dispose')?globals.get('dispose') as PyCallable:undefined;
runtime.disposeFunction = globals.has('dispose')
? (globals.get('dispose') as PyCallable)
: undefined;
runtime.state = runtime.initFunction?.(bindings.model);
if (runtime.state instanceof Promise) throw new Error('控制器函数必须同步执行');
return runtime;
@@ -70,7 +108,9 @@ export class PythonControllerRuntime {
}
}
status():ControllerStatus{return {...this.statusValue};}
status(): ControllerStatus {
return { ...this.statusValue };
}
setEnabled(enabled: boolean, currentTime: number): void {
if (!this.statusValue.loaded) return;
@@ -96,7 +136,8 @@ export class PythonControllerRuntime {
}
stepIfDue(time: number): void {
if(!this.statusValue.enabled||!this.stepFunction||time+1e-9<this.nextControlTime)return;
if (!this.statusValue.enabled || !this.stepFunction || time + 1e-9 < this.nextControlTime)
return;
const dt = 1 / this.statusValue.controlHz;
const started = performance.now();
try {
@@ -117,19 +158,40 @@ export class PythonControllerRuntime {
this.nextControlTime = currentTime;
this.statusValue.activeCommand = undefined;
if (!this.resetFunction) return;
try{const result=this.resetFunction(this.state);destroyProxy(result);}
catch(error){this.statusValue.enabled=false;this.statusValue.error=errorMessage(error);throw error;}
try {
const result = this.resetFunction(this.state);
destroyProxy(result);
} catch (error) {
this.statusValue.enabled = false;
this.statusValue.error = errorMessage(error);
throw error;
}
}
dispose(): void {
if (!this.statusValue.loaded) return;
this.statusValue.loaded = false;
this.statusValue.enabled = false;
try{if(this.disposeFunction){const result=this.disposeFunction(this.state);destroyProxy(result);}}
finally{
destroyProxy(this.state);this.state=undefined;
this.initFunction?.destroy();this.stepFunction?.destroy();this.resetFunction?.destroy();this.commandFunction?.destroy();this.disposeFunction?.destroy();this.globals?.destroy();
this.initFunction=undefined;this.stepFunction=undefined;this.resetFunction=undefined;this.commandFunction=undefined;this.disposeFunction=undefined;this.globals=undefined;
try {
if (this.disposeFunction) {
const result = this.disposeFunction(this.state);
destroyProxy(result);
}
} finally {
destroyProxy(this.state);
this.state = undefined;
this.initFunction?.destroy();
this.stepFunction?.destroy();
this.resetFunction?.destroy();
this.commandFunction?.destroy();
this.disposeFunction?.destroy();
this.globals?.destroy();
this.initFunction = undefined;
this.stepFunction = undefined;
this.resetFunction = undefined;
this.commandFunction = undefined;
this.disposeFunction = undefined;
this.globals = undefined;
}
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
export type ControllerCommand='stop'|'forward'|'backward'|'turn_left'|'turn_right'|'jump';
export type ControllerCommand =
'stop' | 'forward' | 'backward' | 'turn_left' | 'turn_right' | 'jump';
export interface ControllerStatus {
language: 'python';
+11 -1
View File
@@ -3,4 +3,14 @@ import {createRoot} from 'react-dom/client';
import { App } from './app/App';
import { ErrorBoundary } from './app/ErrorBoundary';
import './styles.css';
if(location.protocol==='file:')document.body.innerHTML='<main style="font-family:sans-serif;padding:2rem"><h1>需要本地 HTTP 服务器</h1><p>请运行 npm run dev,不能直接通过 file:// 打开。</p></main>';else createRoot(document.getElementById('root')!).render(<StrictMode><ErrorBoundary><App/></ErrorBoundary></StrictMode>);
if (location.protocol === 'file:')
document.body.innerHTML =
'<main style="font-family:sans-serif;padding:2rem"><h1>需要本地 HTTP 服务器</h1><p>请运行 npm run dev,不能直接通过 file:// 打开。</p></main>';
else
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
);
@@ -1,9 +1,33 @@
import { fireEvent, render, screen } from '@testing-library/react';
import {buildBodyTree,countModelStructureSearchResults,ModelStructureTree} from './ModelStructureTree';
import {
buildBodyTree,
countModelStructureSearchResults,
ModelStructureTree,
} from './ModelStructureTree';
import type { BodyInfo, JointInfo } from '../simulation/SimulationSession';
const bodies:BodyInfo[]=[{id:0,name:'world',parentId:0},{id:1,name:'base',parentId:0},{id:2,name:'arm',parentId:1}];
const joints:JointInfo[]=[{id:0,name:'arm_joint',type:3,value:0,min:-1,max:1,limitMin:-1,limitMax:1,limited:true,limitsIgnored:false,editable:true,bodyId:2,axis:[0,0,1]}];
const bodies: BodyInfo[] = [
{ id: 0, name: 'world', parentId: 0 },
{ id: 1, name: 'base', parentId: 0 },
{ id: 2, name: 'arm', parentId: 1 },
];
const joints: JointInfo[] = [
{
id: 0,
name: 'arm_joint',
type: 3,
value: 0,
min: -1,
max: 1,
limitMin: -1,
limitMax: 1,
limited: true,
limitsIgnored: false,
editable: true,
bodyId: 2,
axis: [0, 0, 1],
},
];
describe('ModelStructureTree', () => {
it('按 body 父子关系构建结构,并将关节放在所属 body 下', () => {
@@ -14,11 +38,27 @@ describe('ModelStructureTree',()=>{
});
it('鼠标进入和离开关节时通知查看器高亮', () => {
const hover=vi.fn();render(<ModelStructureTree bodies={bodies} joints={joints} onJointHover={hover}/>);expect(screen.getByRole('treeitem',{name:'base'})).toHaveAttribute('aria-expanded','true');const item=screen.getByRole('treeitem',{name:/arm_joint/});
fireEvent.mouseEnter(item);fireEvent.mouseLeave(item);expect(hover.mock.calls).toEqual([[0],[null]]);
const hover = vi.fn();
render(<ModelStructureTree bodies={bodies} joints={joints} onJointHover={hover} />);
expect(screen.getByRole('treeitem', { name: 'base' })).toHaveAttribute('aria-expanded', 'true');
const item = screen.getByRole('treeitem', { name: /arm_joint/ });
fireEvent.mouseEnter(item);
fireEvent.mouseLeave(item);
expect(hover.mock.calls).toEqual([[0], [null]]);
});
it('按 Body 或关节名称过滤并保留祖先路径', () => {
render(<ModelStructureTree bodies={bodies} joints={joints} query="arm_joint" onJointHover={()=>{}}/>);expect(screen.getByRole('treeitem',{name:'base'})).toBeVisible();expect(screen.getByRole('treeitem',{name:/arm_joint/})).toBeVisible();expect(countModelStructureSearchResults(bodies,joints,'arm_joint')).toBe(3);expect(countModelStructureSearchResults(bodies,joints,'world')).toBe(0);
render(
<ModelStructureTree
bodies={bodies}
joints={joints}
query="arm_joint"
onJointHover={() => {}}
/>,
);
expect(screen.getByRole('treeitem', { name: 'base' })).toBeVisible();
expect(screen.getByRole('treeitem', { name: /arm_joint/ })).toBeVisible();
expect(countModelStructureSearchResults(bodies, joints, 'arm_joint')).toBe(3);
expect(countModelStructureSearchResults(bodies, joints, 'world')).toBe(0);
});
});
+242 -17
View File
@@ -3,30 +3,255 @@ import {Box,Disc3} from 'lucide-react';
import type { BodyInfo, JointInfo } from '../simulation/SimulationSession';
import { EmptySearchState, SearchHighlight, VirtualTreeViewport } from '../components/ui';
interface BodyNode extends BodyInfo {children:BodyNode[];joints:JointInfo[];}
interface BodyNode extends BodyInfo {
children: BodyNode[];
joints: JointInfo[];
}
// eslint-disable-next-line react-refresh/only-export-components
export function buildBodyTree(bodies: BodyInfo[], joints: JointInfo[]): BodyNode[] {
const nodes=new Map<number,BodyNode>();for(const body of bodies)if(body.id>0)nodes.set(body.id,{...body,children:[],joints:joints.filter(joint=>joint.bodyId===body.id)});
const nodes = new Map<number, BodyNode>();
for (const body of bodies)
if (body.id > 0)
nodes.set(body.id, {
...body,
children: [],
joints: joints.filter((joint) => joint.bodyId === body.id),
});
const roots: BodyNode[] = [];
for(const node of nodes.values()){const parent=nodes.get(node.parentId);if(parent)parent.children.push(node);else roots.push(node);}
const sort=(items:BodyNode[])=>{items.sort((a,b)=>a.id-b.id);for(const item of items)sort(item.children);};sort(roots);return roots;
for (const node of nodes.values()) {
const parent = nodes.get(node.parentId);
if (parent) parent.children.push(node);
else roots.push(node);
}
const sort = (items: BodyNode[]) => {
items.sort((a, b) => a.id - b.id);
for (const item of items) sort(item.children);
};
sort(roots);
return roots;
}
function BodyBranch({node,depth,onJointHover,searching,query}:{node:BodyNode;depth:number;onJointHover:(jointId:number|null)=>void;searching:boolean;query:string}){
const hasChildren=node.joints.length>0||node.children.length>0;const [open,setOpen]=useState(depth<2),shownOpen=searching||open;
return <li role="none">{hasChildren?<details open={shownOpen} onToggle={event=>{if(!searching)setOpen(event.currentTarget.open);}}><summary role="treeitem" aria-expanded={shownOpen} tabIndex={0} onClick={event=>{if(searching)event.preventDefault();}} className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"><Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><span className="truncate"><SearchHighlight text={node.name} query={query}/></span></summary><ul role="group" className="ml-3 border-l border-border pl-1">{node.joints.map(joint=><li role="none" key={joint.id}><span role="treeitem" tabIndex={0} className="flex cursor-default items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30" onMouseEnter={()=>onJointHover(joint.id)} onMouseLeave={()=>onJointHover(null)} onFocus={()=>onJointHover(joint.id)} onBlur={()=>onJointHover(null)} title={`关节:${joint.name}`}><Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0"/><SearchHighlight text={joint.name} query={query}/></span></li>)}{node.children.map(child=><BodyBranch key={child.id} node={child} depth={depth+1} onJointHover={onJointHover} searching={searching} query={query}/>)}</ul></details>:<div role="treeitem" tabIndex={0} className="flex items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary focus-visible:ring-2 focus-visible:ring-accent/30"><Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><SearchHighlight text={node.name} query={query}/></div>}</li>;
function BodyBranch({
node,
depth,
onJointHover,
searching,
query,
}: {
node: BodyNode;
depth: number;
onJointHover: (jointId: number | null) => void;
searching: boolean;
query: string;
}) {
const hasChildren = node.joints.length > 0 || node.children.length > 0;
const [open, setOpen] = useState(depth < 2),
shownOpen = searching || open;
return (
<li role="none">
{hasChildren ? (
<details
open={shownOpen}
onToggle={(event) => {
if (!searching) setOpen(event.currentTarget.open);
}}
>
<summary
role="treeitem"
aria-expanded={shownOpen}
tabIndex={0}
onClick={(event) => {
if (searching) event.preventDefault();
}}
className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"
>
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="truncate">
<SearchHighlight text={node.name} query={query} />
</span>
</summary>
<ul role="group" className="ml-3 border-l border-border pl-1">
{node.joints.map((joint) => (
<li role="none" key={joint.id}>
<span
role="treeitem"
tabIndex={0}
className="flex cursor-default items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30"
onMouseEnter={() => onJointHover(joint.id)}
onMouseLeave={() => onJointHover(null)}
onFocus={() => onJointHover(joint.id)}
onBlur={() => onJointHover(null)}
title={`关节:${joint.name}`}
>
<Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<SearchHighlight text={joint.name} query={query} />
</span>
</li>
))}
{node.children.map((child) => (
<BodyBranch
key={child.id}
node={child}
depth={depth + 1}
onJointHover={onJointHover}
searching={searching}
query={query}
/>
))}
</ul>
</details>
) : (
<div
role="treeitem"
tabIndex={0}
className="flex items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary focus-visible:ring-2 focus-visible:ring-accent/30"
>
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
<SearchHighlight text={node.name} query={query} />
</div>
)}
</li>
);
}
function filterBodies(nodes:BodyNode[],query:string):BodyNode[]{if(!query)return nodes;return nodes.flatMap(node=>{if(node.name.toLocaleLowerCase().includes(query))return [node];const joints=node.joints.filter(joint=>joint.name.toLocaleLowerCase().includes(query)),children=filterBodies(node.children,query);return joints.length||children.length?[{...node,joints,children}]:[];});}
function countBodyNodes(nodes:BodyNode[]):number{return nodes.reduce((total,node)=>total+1+node.joints.length+countBodyNodes(node.children),0);}
function filterBodies(nodes: BodyNode[], query: string): BodyNode[] {
if (!query) return nodes;
return nodes.flatMap((node) => {
if (node.name.toLocaleLowerCase().includes(query)) return [node];
const joints = node.joints.filter((joint) => joint.name.toLocaleLowerCase().includes(query)),
children = filterBodies(node.children, query);
return joints.length || children.length ? [{ ...node, joints, children }] : [];
});
}
function countBodyNodes(nodes: BodyNode[]): number {
return nodes.reduce(
(total, node) => total + 1 + node.joints.length + countBodyNodes(node.children),
0,
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function countModelStructureSearchResults(bodies:BodyInfo[],joints:JointInfo[],query:string):number{return countBodyNodes(filterBodies(buildBodyTree(bodies,joints),query.trim().toLocaleLowerCase()));}
type FlatBodyItem={kind:'body';body:BodyNode;depth:number}|{kind:'joint';joint:JointInfo;depth:number};
function flattenBodies(nodes:BodyNode[],expanded:Set<number>,searching:boolean,depth=0):FlatBodyItem[]{return nodes.flatMap(body=>[{kind:'body' as const,body,depth},...(searching||expanded.has(body.id)?[...body.joints.map(joint=>({kind:'joint' as const,joint,depth:depth+1})),...flattenBodies(body.children,expanded,searching,depth+1)]:[])]);}
function initiallyExpanded(nodes:BodyNode[],depth=0):number[]{return nodes.flatMap(body=>[...(depth<2?[body.id]:[]),...initiallyExpanded(body.children,depth+1)]);}
export function ModelStructureTree({bodies,joints,onJointHover,query=''}:{bodies:BodyInfo[];joints:JointInfo[];onJointHover:(jointId:number|null)=>void;query?:string}){
const normalized=query.trim().toLocaleLowerCase(),roots=filterBodies(buildBodyTree(bodies,joints),normalized),[virtualExpanded,setVirtualExpanded]=useState(()=>new Set(initiallyExpanded(buildBodyTree(bodies,joints))));
if(bodies.length+joints.length>500&&roots.length){const searching=Boolean(normalized),flat=flattenBodies(roots,virtualExpanded,searching),toggle=(item:FlatBodyItem)=>{if(item.kind!=='body'||searching)return;setVirtualExpanded(current=>{const next=new Set(current);if(next.has(item.body.id))next.delete(item.body.id);else next.add(item.body.id);return next;});};return <nav aria-label="模型结构树"><VirtualTreeViewport label="虚拟化模型结构树" items={flat} getKey={item=>item.kind==='body'?`b:${item.body.id}`:`j:${item.joint.id}`} getLevel={item=>item.depth+1} isExpandable={item=>item.kind==='body'&&(item.body.joints.length>0||item.body.children.length>0)} isExpanded={item=>item.kind==='body'&&(searching||virtualExpanded.has(item.body.id))} onToggle={toggle} onActiveChange={item=>onJointHover(item.kind==='joint'?item.joint.id:null)} renderRow={item=>item.kind==='body'?<div onDoubleClick={()=>toggle(item)} className="flex h-full items-center gap-1.5 px-1.5 text-xs text-text-secondary" style={{paddingLeft:item.depth*12+6}}><Box className="h-3.5 w-3.5 text-accent"/><SearchHighlight text={item.body.name} query={query}/></div>:<div className="flex h-full items-center gap-1.5 px-1.5 text-xs text-warning" style={{paddingLeft:item.depth*12+6}} onMouseEnter={()=>onJointHover(item.joint.id)} onMouseLeave={()=>onJointHover(null)}><Disc3 className="h-3.5 w-3.5"/><SearchHighlight text={item.joint.name} query={query}/></div>}/></nav>;}
return <nav aria-label="模型结构树">{roots.length?<ul role="tree">{roots.map(root=><BodyBranch key={root.id} node={root} depth={0} onJointHover={onJointHover} searching={Boolean(normalized)} query={query}/>)}</ul>:<EmptySearchState label="没有匹配的 Body 或关节"/>}</nav>;
export function countModelStructureSearchResults(
bodies: BodyInfo[],
joints: JointInfo[],
query: string,
): number {
return countBodyNodes(
filterBodies(buildBodyTree(bodies, joints), query.trim().toLocaleLowerCase()),
);
}
type FlatBodyItem =
| { kind: 'body'; body: BodyNode; depth: number }
| { kind: 'joint'; joint: JointInfo; depth: number };
function flattenBodies(
nodes: BodyNode[],
expanded: Set<number>,
searching: boolean,
depth = 0,
): FlatBodyItem[] {
return nodes.flatMap((body) => [
{ kind: 'body' as const, body, depth },
...(searching || expanded.has(body.id)
? [
...body.joints.map((joint) => ({ kind: 'joint' as const, joint, depth: depth + 1 })),
...flattenBodies(body.children, expanded, searching, depth + 1),
]
: []),
]);
}
function initiallyExpanded(nodes: BodyNode[], depth = 0): number[] {
return nodes.flatMap((body) => [
...(depth < 2 ? [body.id] : []),
...initiallyExpanded(body.children, depth + 1),
]);
}
export function ModelStructureTree({
bodies,
joints,
onJointHover,
query = '',
}: {
bodies: BodyInfo[];
joints: JointInfo[];
onJointHover: (jointId: number | null) => void;
query?: string;
}) {
const normalized = query.trim().toLocaleLowerCase(),
roots = filterBodies(buildBodyTree(bodies, joints), normalized),
[virtualExpanded, setVirtualExpanded] = useState(
() => new Set(initiallyExpanded(buildBodyTree(bodies, joints))),
);
if (bodies.length + joints.length > 500 && roots.length) {
const searching = Boolean(normalized),
flat = flattenBodies(roots, virtualExpanded, searching),
toggle = (item: FlatBodyItem) => {
if (item.kind !== 'body' || searching) return;
setVirtualExpanded((current) => {
const next = new Set(current);
if (next.has(item.body.id)) next.delete(item.body.id);
else next.add(item.body.id);
return next;
});
};
return (
<nav aria-label="模型结构树">
<VirtualTreeViewport
label="虚拟化模型结构树"
items={flat}
getKey={(item) => (item.kind === 'body' ? `b:${item.body.id}` : `j:${item.joint.id}`)}
getLevel={(item) => item.depth + 1}
isExpandable={(item) =>
item.kind === 'body' && (item.body.joints.length > 0 || item.body.children.length > 0)
}
isExpanded={(item) =>
item.kind === 'body' && (searching || virtualExpanded.has(item.body.id))
}
onToggle={toggle}
onActiveChange={(item) => onJointHover(item.kind === 'joint' ? item.joint.id : null)}
renderRow={(item) =>
item.kind === 'body' ? (
<div
onDoubleClick={() => toggle(item)}
className="flex h-full items-center gap-1.5 px-1.5 text-xs text-text-secondary"
style={{ paddingLeft: item.depth * 12 + 6 }}
>
<Box className="h-3.5 w-3.5 text-accent" />
<SearchHighlight text={item.body.name} query={query} />
</div>
) : (
<div
className="flex h-full items-center gap-1.5 px-1.5 text-xs text-warning"
style={{ paddingLeft: item.depth * 12 + 6 }}
onMouseEnter={() => onJointHover(item.joint.id)}
onMouseLeave={() => onJointHover(null)}
>
<Disc3 className="h-3.5 w-3.5" />
<SearchHighlight text={item.joint.name} query={query} />
</div>
)
}
/>
</nav>
);
}
return (
<nav aria-label="模型结构树">
{roots.length ? (
<ul role="tree">
{roots.map((root) => (
<BodyBranch
key={root.id}
node={root}
depth={0}
onJointHover={onJointHover}
searching={Boolean(normalized)}
query={query}
/>
))}
</ul>
) : (
<EmptySearchState label="没有匹配的 Body 或关节" />
)}
</nav>
);
}
+41 -9
View File
@@ -11,17 +11,34 @@ const files=[
describe('ProjectTree', () => {
it('按路径构建多级目录,并将目录排在文件前面', () => {
const tree = buildProjectTree(files);
expect(tree.map(node=>[node.kind,node.name])).toEqual([['directory','robot'],['file','README.txt']]);
const robot=tree[0];
expect(robot.children?.map(node=>[node.kind,node.name])).toEqual([
['directory','meshes'],['directory','textures'],['file','model.xml'],
expect(tree.map((node) => [node.kind, node.name])).toEqual([
['directory', 'robot'],
['file', 'README.txt'],
]);
expect(robot.children?.[0].children?.[0]).toMatchObject({kind:'file',name:'arm.obj',path:'robot/meshes/arm.obj'});
const robot = tree[0];
expect(robot.children?.map((node) => [node.kind, node.name])).toEqual([
['directory', 'meshes'],
['directory', 'textures'],
['file', 'model.xml'],
]);
expect(robot.children?.[0].children?.[0]).toMatchObject({
kind: 'file',
name: 'arm.obj',
path: 'robot/meshes/arm.obj',
});
});
it('以可折叠目录显示文件名,而不是平铺完整路径', () => {
render(<ProjectTree files={files} entries={[{path:'robot/model.xml',format:'urdf',label:'model'}]} selectedEntry="robot/model.xml"/>);
const tree=screen.getByRole('navigation',{name:'工程文件树'}),robot=within(tree).getByText('robot'),meshes=within(tree).getByText('meshes');
render(
<ProjectTree
files={files}
entries={[{ path: 'robot/model.xml', format: 'urdf', label: 'model' }]}
selectedEntry="robot/model.xml"
/>,
);
const tree = screen.getByRole('navigation', { name: '工程文件树' }),
robot = within(tree).getByText('robot'),
meshes = within(tree).getByText('meshes');
expect(robot.closest('details')).toHaveAttribute('open');
expect(meshes.closest('details')).not.toHaveAttribute('open');
fireEvent.click(meshes);
@@ -32,8 +49,23 @@ describe('ProjectTree',()=>{
it('搜索时只保留匹配文件及其目录路径', () => {
render(<ProjectTree files={files} entries={[]} query="arm.obj" />);
expect(screen.getByText('robot')).toBeVisible();expect(screen.getByText('meshes')).toBeVisible();expect(screen.getByText('arm.obj')).toBeVisible();expect(screen.queryByText('README.txt')).not.toBeInTheDocument();expect(countProjectSearchResults(files,'meshes')).toBe(3);expect(countProjectSearchResults(files,'robot/meshes')).toBe(0);
expect(screen.getByText('robot')).toBeVisible();
expect(screen.getByText('meshes')).toBeVisible();
expect(screen.getByText('arm.obj')).toBeVisible();
expect(screen.queryByText('README.txt')).not.toBeInTheDocument();
expect(countProjectSearchResults(files, 'meshes')).toBe(3);
expect(countProjectSearchResults(files, 'robot/meshes')).toBe(0);
});
it('大型工程使用可键盘折叠的虚拟树',()=>{const large=Array.from({length:401},(_,index)=>({path:`assets/file-${index}.obj`,size:1}));render(<ProjectTree files={large} entries={[]}/>);const tree=screen.getByRole('tree',{name:'虚拟化工程文件树'});expect(tree).toHaveAttribute('aria-activedescendant',expect.stringContaining('assets'));fireEvent.keyDown(tree,{key:'ArrowLeft'});expect(screen.queryByText('file-0.obj')).not.toBeInTheDocument();});
it('大型工程使用可键盘折叠的虚拟树', () => {
const large = Array.from({ length: 401 }, (_, index) => ({
path: `assets/file-${index}.obj`,
size: 1,
}));
render(<ProjectTree files={large} entries={[]} />);
const tree = screen.getByRole('tree', { name: '虚拟化工程文件树' });
expect(tree).toHaveAttribute('aria-activedescendant', expect.stringContaining('assets'));
fireEvent.keyDown(tree, { key: 'ArrowLeft' });
expect(screen.queryByText('file-0.obj')).not.toBeInTheDocument();
});
});
+258 -20
View File
@@ -3,7 +3,10 @@ import {Box,File,FileCode2,Folder,FolderOpen} from 'lucide-react';
import type { ModelEntry } from './types';
import { EmptySearchState, SearchHighlight, VirtualTreeViewport } from '../components/ui';
export interface ProjectTreeFile {path:string;size:number;}
export interface ProjectTreeFile {
path: string;
size: number;
}
export interface ProjectTreeNode {
name: string;
@@ -37,13 +40,22 @@ export function buildProjectTree(files:ProjectTreeFile[]):ProjectTreeNode[] {
for (const part of parts.slice(0, -1)) {
const path = parent.path ? `${parent.path}/${part}` : part;
let directory = parent.directories.get(part);
if(!directory){directory={name:part,path,directories:new Map(),files:[]};parent.directories.set(part,directory);}
if (!directory) {
directory = { name: part, path, directories: new Map(), files: [] };
parent.directories.set(part, directory);
}
parent = directory;
}
parent.files.push({ name: parts.at(-1)!, path: file.path, kind: 'file', size: file.size });
}
const finish=(directory:MutableDirectory):ProjectTreeNode[]=>[
...Array.from(directory.directories.values(),child=>({name:child.name,path:child.path,kind:'directory' as const,children:finish(child)})),
const finish = (directory: MutableDirectory): ProjectTreeNode[] =>
[
...Array.from(directory.directories.values(), (child) => ({
name: child.name,
path: child.path,
kind: 'directory' as const,
children: finish(child),
})),
...directory.files,
].sort(compareNodes);
return finish(root);
@@ -55,22 +67,248 @@ function formatSize(bytes:number):string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
interface TreeNodeProps {entryFormats:Map<string,ModelEntry['format']>;selectedEntry?:string;expandedEntry?:string;searching:boolean;query:string;}
function DirectoryNode({node,entryFormats,selectedEntry,expandedEntry,searching,query}:TreeNodeProps&{node:ProjectTreeNode}){const [open,setOpen]=useState(Boolean(expandedEntry?.startsWith(`${node.path}/`)));const shownOpen=searching||open,FolderIcon=shownOpen?FolderOpen:Folder;return <li><details open={shownOpen} onToggle={event=>{if(!searching)setOpen(event.currentTarget.open);}}><summary title={node.path} onClick={event=>{if(searching)event.preventDefault();}} className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover"><FolderIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><span className="truncate"><SearchHighlight text={node.name} query={query}/></span></summary><TreeNodes nodes={node.children??[]} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={searching} query={query}/></details></li>;}
function TreeNodes({nodes,entryFormats,selectedEntry,expandedEntry,searching,query}:TreeNodeProps&{nodes:ProjectTreeNode[]}){
return <ul role="group" className="ml-3 border-l border-border pl-1">{nodes.map(node=>{if(node.kind==='directory')return <DirectoryNode key={`d:${node.path}`} node={node} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={searching} query={query}/>;const EntryIcon=entryFormats.has(node.path)?FileCode2:node.path.endsWith('.obj')||node.path.endsWith('.stl')||node.path.endsWith('.dae')?Box:File;return <li key={`f:${node.path}`} title={node.path} className={`flex min-w-0 items-center gap-1.5 rounded px-1.5 py-1 text-xs ${selectedEntry===node.path?'bg-accent-soft text-accent':'text-text-secondary hover:bg-element-hover'}`}><EntryIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0"/><span className="min-w-0 flex-1 truncate"><SearchHighlight text={node.name} query={query}/></span>{entryFormats.has(node.path)&&<span className="shrink-0 text-[10px] uppercase text-accent">{entryFormats.get(node.path)}</span>}<span className="shrink-0 text-[10px] text-text-tertiary">{formatSize(node.size??0)}</span></li>;})}</ul>;
interface TreeNodeProps {
entryFormats: Map<string, ModelEntry['format']>;
selectedEntry?: string;
expandedEntry?: string;
searching: boolean;
query: string;
}
function DirectoryNode({
node,
entryFormats,
selectedEntry,
expandedEntry,
searching,
query,
}: TreeNodeProps & { node: ProjectTreeNode }) {
const [open, setOpen] = useState(Boolean(expandedEntry?.startsWith(`${node.path}/`)));
const shownOpen = searching || open,
FolderIcon = shownOpen ? FolderOpen : Folder;
return (
<li>
<details
open={shownOpen}
onToggle={(event) => {
if (!searching) setOpen(event.currentTarget.open);
}}
>
<summary
title={node.path}
onClick={(event) => {
if (searching) event.preventDefault();
}}
className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover"
>
<FolderIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="truncate">
<SearchHighlight text={node.name} query={query} />
</span>
</summary>
<TreeNodes
nodes={node.children ?? []}
entryFormats={entryFormats}
selectedEntry={selectedEntry}
expandedEntry={expandedEntry}
searching={searching}
query={query}
/>
</details>
</li>
);
}
function TreeNodes({
nodes,
entryFormats,
selectedEntry,
expandedEntry,
searching,
query,
}: TreeNodeProps & { nodes: ProjectTreeNode[] }) {
return (
<ul role="group" className="ml-3 border-l border-border pl-1">
{nodes.map((node) => {
if (node.kind === 'directory')
return (
<DirectoryNode
key={`d:${node.path}`}
node={node}
entryFormats={entryFormats}
selectedEntry={selectedEntry}
expandedEntry={expandedEntry}
searching={searching}
query={query}
/>
);
const EntryIcon = entryFormats.has(node.path)
? FileCode2
: node.path.endsWith('.obj') || node.path.endsWith('.stl') || node.path.endsWith('.dae')
? Box
: File;
return (
<li
key={`f:${node.path}`}
title={node.path}
className={`flex min-w-0 items-center gap-1.5 rounded px-1.5 py-1 text-xs ${selectedEntry === node.path ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'}`}
>
<EntryIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={node.name} query={query} />
</span>
{entryFormats.has(node.path) && (
<span className="shrink-0 text-[10px] uppercase text-accent">
{entryFormats.get(node.path)}
</span>
)}
<span className="shrink-0 text-[10px] text-text-tertiary">
{formatSize(node.size ?? 0)}
</span>
</li>
);
})}
</ul>
);
}
function filterNodes(nodes:ProjectTreeNode[],query:string):ProjectTreeNode[]{if(!query)return nodes;return nodes.flatMap(node=>{if(node.name.toLocaleLowerCase().includes(query))return [node];if(node.kind==='file')return [];const children=filterNodes(node.children??[],query);return children.length?[{...node,children}]:[];});}
function countNodes(nodes:ProjectTreeNode[]):number{return nodes.reduce((total,node)=>total+1+(node.children?countNodes(node.children):0),0);}
// eslint-disable-next-line react-refresh/only-export-components
export function countProjectSearchResults(files:ProjectTreeFile[],query:string):number{return countNodes(filterNodes(buildProjectTree(files),query.trim().toLocaleLowerCase()));}
interface FlatProjectNode{node:ProjectTreeNode;depth:number;}
function flattenProjectNodes(nodes:ProjectTreeNode[],expanded:Set<string>,searching:boolean,depth=0):FlatProjectNode[]{return nodes.flatMap(node=>[{node,depth},...(node.kind==='directory'&&(searching||expanded.has(node.path))?flattenProjectNodes(node.children??[],expanded,searching,depth+1):[])]);}
export function ProjectTree({files,entries,selectedEntry,query=''}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string;query?:string}){
const normalized=query.trim().toLocaleLowerCase(),nodes=filterNodes(buildProjectTree(files),normalized),[virtualExpanded,setVirtualExpanded]=useState(()=>new Set(buildProjectTree(files).filter(node=>node.kind==='directory').map(node=>node.path)));
const entryFormats=new Map(entries.map(entry=>[entry.path,entry.format]));
const expandedEntry=entries.some(entry=>entry.path===selectedEntry&&entry.format==='urdf')?selectedEntry:undefined;
if(files.length>400&&nodes.length){const searching=Boolean(normalized),flat=flattenProjectNodes(nodes,virtualExpanded,searching),toggle=(item:FlatProjectNode)=>{if(item.node.kind!=='directory'||searching)return;setVirtualExpanded(current=>{const next=new Set(current);if(next.has(item.node.path))next.delete(item.node.path);else next.add(item.node.path);return next;});};return <nav aria-label="工程文件树"><VirtualTreeViewport label="虚拟化工程文件树" items={flat} getKey={item=>item.node.path} getLevel={item=>item.depth+1} isExpandable={item=>item.node.kind==='directory'&&(item.node.children?.length??0)>0} isExpanded={item=>searching||virtualExpanded.has(item.node.path)} onToggle={toggle} renderRow={({node,depth})=>{const directory=node.kind==='directory',opened=searching||virtualExpanded.has(node.path),EntryIcon=directory?(opened?FolderOpen:Folder):entryFormats.has(node.path)?FileCode2:node.path.endsWith('.obj')||node.path.endsWith('.stl')||node.path.endsWith('.dae')?Box:File;return <div title={node.path} onDoubleClick={()=>toggle({node,depth})} className={`flex h-full items-center gap-1.5 rounded px-1.5 text-xs ${selectedEntry===node.path?'bg-accent-soft text-accent':'text-text-secondary hover:bg-element-hover'}`} style={{paddingLeft:depth*12+6}}><EntryIcon className="h-3.5 w-3.5 shrink-0"/><span className="min-w-0 flex-1 truncate"><SearchHighlight text={node.name} query={query}/></span>{!directory&&<span className="text-[10px] text-text-tertiary">{formatSize(node.size??0)}</span>}</div>;}}/></nav>;}
return <nav aria-label="工程文件树">{nodes.length?<TreeNodes key={expandedEntry??'collapsed'} nodes={nodes} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={Boolean(normalized)} query={query}/>:<EmptySearchState label="没有匹配的文件"/>}</nav>;
function filterNodes(nodes: ProjectTreeNode[], query: string): ProjectTreeNode[] {
if (!query) return nodes;
return nodes.flatMap((node) => {
if (node.name.toLocaleLowerCase().includes(query)) return [node];
if (node.kind === 'file') return [];
const children = filterNodes(node.children ?? [], query);
return children.length ? [{ ...node, children }] : [];
});
}
function countNodes(nodes: ProjectTreeNode[]): number {
return nodes.reduce(
(total, node) => total + 1 + (node.children ? countNodes(node.children) : 0),
0,
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function countProjectSearchResults(files: ProjectTreeFile[], query: string): number {
return countNodes(filterNodes(buildProjectTree(files), query.trim().toLocaleLowerCase()));
}
interface FlatProjectNode {
node: ProjectTreeNode;
depth: number;
}
function flattenProjectNodes(
nodes: ProjectTreeNode[],
expanded: Set<string>,
searching: boolean,
depth = 0,
): FlatProjectNode[] {
return nodes.flatMap((node) => [
{ node, depth },
...(node.kind === 'directory' && (searching || expanded.has(node.path))
? flattenProjectNodes(node.children ?? [], expanded, searching, depth + 1)
: []),
]);
}
export function ProjectTree({
files,
entries,
selectedEntry,
query = '',
}: {
files: ProjectTreeFile[];
entries: ModelEntry[];
selectedEntry?: string;
query?: string;
}) {
const normalized = query.trim().toLocaleLowerCase(),
nodes = filterNodes(buildProjectTree(files), normalized),
[virtualExpanded, setVirtualExpanded] = useState(
() =>
new Set(
buildProjectTree(files)
.filter((node) => node.kind === 'directory')
.map((node) => node.path),
),
);
const entryFormats = new Map(entries.map((entry) => [entry.path, entry.format]));
const expandedEntry = entries.some(
(entry) => entry.path === selectedEntry && entry.format === 'urdf',
)
? selectedEntry
: undefined;
if (files.length > 400 && nodes.length) {
const searching = Boolean(normalized),
flat = flattenProjectNodes(nodes, virtualExpanded, searching),
toggle = (item: FlatProjectNode) => {
if (item.node.kind !== 'directory' || searching) return;
setVirtualExpanded((current) => {
const next = new Set(current);
if (next.has(item.node.path)) next.delete(item.node.path);
else next.add(item.node.path);
return next;
});
};
return (
<nav aria-label="工程文件树">
<VirtualTreeViewport
label="虚拟化工程文件树"
items={flat}
getKey={(item) => item.node.path}
getLevel={(item) => item.depth + 1}
isExpandable={(item) =>
item.node.kind === 'directory' && (item.node.children?.length ?? 0) > 0
}
isExpanded={(item) => searching || virtualExpanded.has(item.node.path)}
onToggle={toggle}
renderRow={({ node, depth }) => {
const directory = node.kind === 'directory',
opened = searching || virtualExpanded.has(node.path),
EntryIcon = directory
? opened
? FolderOpen
: Folder
: entryFormats.has(node.path)
? FileCode2
: node.path.endsWith('.obj') ||
node.path.endsWith('.stl') ||
node.path.endsWith('.dae')
? Box
: File;
return (
<div
title={node.path}
onDoubleClick={() => toggle({ node, depth })}
className={`flex h-full items-center gap-1.5 rounded px-1.5 text-xs ${selectedEntry === node.path ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'}`}
style={{ paddingLeft: depth * 12 + 6 }}
>
<EntryIcon className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={node.name} query={query} />
</span>
{!directory && (
<span className="text-[10px] text-text-tertiary">
{formatSize(node.size ?? 0)}
</span>
)}
</div>
);
}}
/>
</nav>
);
}
return (
<nav aria-label="工程文件树">
{nodes.length ? (
<TreeNodes
key={expandedEntry ?? 'collapsed'}
nodes={nodes}
entryFormats={entryFormats}
selectedEntry={selectedEntry}
expandedEntry={expandedEntry}
searching={Boolean(normalized)}
query={query}
/>
) : (
<EmptySearchState label="没有匹配的文件" />
)}
</nav>
);
}
+71 -7
View File
@@ -1,13 +1,77 @@
import {editableSourcePaths,exportedFileName,mergeCachedFiles,readCachedText,updateCachedText,upsertCachedMjcf} from './cachedFiles';
import {
editableSourcePaths,
exportedFileName,
mergeCachedFiles,
readCachedText,
updateCachedText,
upsertCachedMjcf,
} from './cachedFiles';
import type { ProjectManifest } from './types';
const encoder = new TextEncoder();
function fixture():ProjectManifest{const xml=encoder.encode('<mujoco/>'),png=new Uint8Array([1,2]);return {id:'p',name:'测试 工程.zip',files:[{path:'model.xml',data:xml,size:xml.byteLength,source:'zip',mimeType:'text/xml'},{path:'texture.png',data:png,size:png.byteLength,source:'zip',mimeType:'image/png'}],entries:[{path:'model.xml',format:'mjcf',label:'model'}],selectedEntry:'model.xml',totalBytes:xml.byteLength+png.byteLength};}
function fixture(): ProjectManifest {
const xml = encoder.encode('<mujoco/>'),
png = new Uint8Array([1, 2]);
return {
id: 'p',
name: '测试 工程.zip',
files: [
{ path: 'model.xml', data: xml, size: xml.byteLength, source: 'zip', mimeType: 'text/xml' },
{
path: 'texture.png',
data: png,
size: png.byteLength,
source: 'zip',
mimeType: 'image/png',
},
],
entries: [{ path: 'model.xml', format: 'mjcf', label: 'model' }],
selectedEntry: 'model.xml',
totalBytes: xml.byteLength + png.byteLength,
};
}
describe('cached source files', () => {
it('只列出可编辑文本并读取缓存',()=>{const manifest=fixture();expect(editableSourcePaths(manifest)).toEqual(['model.xml']);expect(readCachedText(manifest,'model.xml')).toBe('<mujoco/>');expect(()=>readCachedText(manifest,'texture.png')).toThrow('二进制');});
it('以不可变方式更新会话缓存和大小',()=>{const original=fixture(),updated=updateCachedText(original,'model.xml','<mujoco model="edited"/>');expect(readCachedText(updated,'model.xml')).toContain('edited');expect(readCachedText(original,'model.xml')).toBe('<mujoco/>');expect(updated.totalBytes).toBe(updated.files.reduce((sum,file)=>sum+file.size,0));});
it('合并转换生成的支持资源',()=>{const original=fixture(),obj={path:'mesh.mujoco.obj',data:encoder.encode('v 0 0 0'),size:7,source:'file' as const,mimeType:'text/plain'},updated=mergeCachedFiles(original,[obj]);expect(updated.files.map(file=>file.path)).toContain('mesh.mujoco.obj');expect(original.files.map(file=>file.path)).not.toContain('mesh.mujoco.obj');});
it('创建可重新载入的 MJCF 缓存文件和入口',()=>{const updated=upsertCachedMjcf(fixture(),'.__converted_mjcf_cache__.xml','<mujoco model="cached"/>');expect(readCachedText(updated,'.__converted_mjcf_cache__.xml')).toContain('cached');expect(updated.entries.at(-1)).toMatchObject({path:'.__converted_mjcf_cache__.xml',format:'mjcf'});});
it('生成安全的导出文件名',()=>{expect(exportedFileName('测试 工程.zip','urdf')).toBe('测试_工程.urdf');expect(exportedFileName('robot.xml','xml')).toBe('robot.xml');});
it('只列出可编辑文本并读取缓存', () => {
const manifest = fixture();
expect(editableSourcePaths(manifest)).toEqual(['model.xml']);
expect(readCachedText(manifest, 'model.xml')).toBe('<mujoco/>');
expect(() => readCachedText(manifest, 'texture.png')).toThrow('二进制');
});
it('以不可变方式更新会话缓存和大小', () => {
const original = fixture(),
updated = updateCachedText(original, 'model.xml', '<mujoco model="edited"/>');
expect(readCachedText(updated, 'model.xml')).toContain('edited');
expect(readCachedText(original, 'model.xml')).toBe('<mujoco/>');
expect(updated.totalBytes).toBe(updated.files.reduce((sum, file) => sum + file.size, 0));
});
it('合并转换生成的支持资源', () => {
const original = fixture(),
obj = {
path: 'mesh.mujoco.obj',
data: encoder.encode('v 0 0 0'),
size: 7,
source: 'file' as const,
mimeType: 'text/plain',
},
updated = mergeCachedFiles(original, [obj]);
expect(updated.files.map((file) => file.path)).toContain('mesh.mujoco.obj');
expect(original.files.map((file) => file.path)).not.toContain('mesh.mujoco.obj');
});
it('创建可重新载入的 MJCF 缓存文件和入口', () => {
const updated = upsertCachedMjcf(
fixture(),
'.__converted_mjcf_cache__.xml',
'<mujoco model="cached"/>',
);
expect(readCachedText(updated, '.__converted_mjcf_cache__.xml')).toContain('cached');
expect(updated.entries.at(-1)).toMatchObject({
path: '.__converted_mjcf_cache__.xml',
format: 'mjcf',
});
});
it('生成安全的导出文件名', () => {
expect(exportedFileName('测试 工程.zip', 'urdf')).toBe('测试_工程.urdf');
expect(exportedFileName('robot.xml', 'xml')).toBe('robot.xml');
});
});
+67 -19
View File
@@ -4,54 +4,102 @@ const TEXT_EXTENSIONS=/\.(?:xml|urdf|txt|obj|mtl|csv|json|yaml|yml)$/i;
const decoder = new TextDecoder('utf-8', { fatal: false });
const encoder = new TextEncoder();
export function isEditableSource(path:string):boolean{return TEXT_EXTENSIONS.test(path);}
export function isEditableSource(path: string): boolean {
return TEXT_EXTENSIONS.test(path);
}
export function editableSourcePaths(manifest: ProjectManifest): string[] {
return manifest.files.filter(file=>isEditableSource(file.path)).map(file=>file.path).sort((a,b)=>a.localeCompare(b));
return manifest.files
.filter((file) => isEditableSource(file.path))
.map((file) => file.path)
.sort((a, b) => a.localeCompare(b));
}
export function readCachedText(manifest: ProjectManifest, path: string): string {
const file=manifest.files.find(candidate=>candidate.path===path);
const file = manifest.files.find((candidate) => candidate.path === path);
if (!file) throw new Error(`缓存中找不到文件:${path}`);
if (!isEditableSource(path)) throw new Error(`不支持编辑二进制文件:${path}`);
return decoder.decode(file.data);
}
/** 返回只更新浏览器会话内存的新工程清单,不接触用户本地文件系统。 */
export function mergeCachedFiles(manifest:ProjectManifest,additional:ProjectManifest['files']):ProjectManifest{
export function mergeCachedFiles(
manifest: ProjectManifest,
additional: ProjectManifest['files'],
): ProjectManifest {
if (!additional.length) return manifest;
const byPath=new Map(manifest.files.map(file=>[file.path,file]));
const byPath = new Map(manifest.files.map((file) => [file.path, file]));
for (const file of additional) byPath.set(file.path, file);
const files = Array.from(byPath.values());
return { ...manifest, files, totalBytes: files.reduce((total, item) => total + item.size, 0) };
}
export function upsertCachedMjcf(manifest:ProjectManifest,path:string,text:string):ProjectManifest{
const data=encoder.encode(text),index=manifest.files.findIndex(candidate=>candidate.path===path);
export function upsertCachedMjcf(
manifest: ProjectManifest,
path: string,
text: string,
): ProjectManifest {
const data = encoder.encode(text),
index = manifest.files.findIndex((candidate) => candidate.path === path);
const files = manifest.files.slice();
const file={path,data,size:data.byteLength,source:'file' as const,mimeType:'application/xml'};
if(index<0)files.push(file);else files[index]={...files[index],...file};
const entries=manifest.entries.some(entry=>entry.path===path)?manifest.entries:[...manifest.entries,{path,format:'mjcf' as const,label:`${path} (MJCF 缓存)`}];
return {...manifest,files,entries,totalBytes:files.reduce((total,item)=>total+item.size,0)};
const file = {
path,
data,
size: data.byteLength,
source: 'file' as const,
mimeType: 'application/xml',
};
if (index < 0) files.push(file);
else files[index] = { ...files[index], ...file };
const entries = manifest.entries.some((entry) => entry.path === path)
? manifest.entries
: [...manifest.entries, { path, format: 'mjcf' as const, label: `${path} (MJCF 缓存)` }];
return {
...manifest,
files,
entries,
totalBytes: files.reduce((total, item) => total + item.size, 0),
};
}
export function updateCachedText(manifest:ProjectManifest,path:string,text:string):ProjectManifest{
const index=manifest.files.findIndex(candidate=>candidate.path===path);
export function updateCachedText(
manifest: ProjectManifest,
path: string,
text: string,
): ProjectManifest {
const index = manifest.files.findIndex((candidate) => candidate.path === path);
if (index < 0) throw new Error(`缓存中找不到文件:${path}`);
if (!isEditableSource(path)) throw new Error(`不支持编辑二进制文件:${path}`);
const data=encoder.encode(text),files=manifest.files.slice();
files[index]={...files[index],data,size:data.byteLength,mimeType:files[index].mimeType||'text/plain'};
const data = encoder.encode(text),
files = manifest.files.slice();
files[index] = {
...files[index],
data,
size: data.byteLength,
mimeType: files[index].mimeType || 'text/plain',
};
return { ...manifest, files, totalBytes: files.reduce((total, file) => total + file.size, 0) };
}
export function downloadBytes(data:Uint8Array,fileName:string,mimeType='application/xml'):void{
export function downloadBytes(
data: Uint8Array,
fileName: string,
mimeType = 'application/xml',
): void {
const blob = new Blob([data as BlobPart], { type: `${mimeType};charset=utf-8` });
const url=URL.createObjectURL(blob),anchor=document.createElement('a');
anchor.href=url;anchor.download=fileName;anchor.style.display='none';document.body.append(anchor);anchor.click();anchor.remove();
const url = URL.createObjectURL(blob),
anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
anchor.style.display = 'none';
document.body.append(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
export function exportedFileName(projectName: string, extension: 'urdf' | 'xml'): string {
const stem=projectName.replace(/\.(?:zip|xml|urdf)$/i,'').replace(/[^\p{L}\p{N}._-]+/gu,'_')||'model';
const stem =
projectName.replace(/\.(?:zip|xml|urdf)$/i, '').replace(/[^\p{L}\p{N}._-]+/gu, '_') || 'model';
return `${stem}.${extension}`;
}
+9 -5
View File
@@ -16,7 +16,8 @@ export function convertDaeToObj(data:Uint8Array,path:string):Uint8Array {
const text = new TextDecoder('utf-8').decode(data);
const xml = new DOMParser().parseFromString(text, 'application/xml');
if (xml.querySelector('parsererror')) throw new Error('Collada XML 格式无效');
const upAxis=xml.getElementsByTagName('up_axis')[0]?.textContent?.trim().toUpperCase()??'Y_UP';
const upAxis =
xml.getElementsByTagName('up_axis')[0]?.textContent?.trim().toUpperCase() ?? 'Y_UP';
// 禁用 ColladaLoader 自带的 Z-up → Y-up 旋转,改为直接统一到 MuJoCo 的 Z-up。
if (upAxis === 'Z_UP') xml.getElementsByTagName('up_axis')[0]!.textContent = 'Y_UP';
const normalized = new XMLSerializer().serializeToString(xml);
@@ -27,7 +28,7 @@ export function convertDaeToObj(data:Uint8Array,path:string):Uint8Array {
else if (upAxis === 'X_UP') scene.rotation.y -= Math.PI / 2;
scene.updateMatrixWorld(true);
let meshCount = 0;
scene.traverse(object=>{
scene.traverse((object) => {
const mesh = object as Mesh;
if (!mesh.isMesh) return;
meshCount += 1;
@@ -37,16 +38,19 @@ export function convertDaeToObj(data:Uint8Array,path:string):Uint8Array {
if (!meshCount) throw new Error('Collada 文件不包含可转换的三角网格');
try {
const output = new OBJExporter().parse(scene);
if(!/^v\s/m.test(output)||!/^f\s/m.test(output))throw new Error('Collada 文件未生成有效三角面');
if (!/^v\s/m.test(output) || !/^f\s/m.test(output))
throw new Error('Collada 文件未生成有效三角面');
return new TextEncoder().encode(output);
} finally {
scene.traverse(object=>{
scene.traverse((object) => {
const mesh = object as Mesh;
if (!mesh.isMesh) return;
mesh.geometry?.dispose();
const materials: Material[] = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
for (const material of materials) {
for(const value of Object.values(material))if(value&&typeof value==='object'&&(value as Texture).isTexture)(value as Texture).dispose();
for (const value of Object.values(material))
if (value && typeof value === 'object' && (value as Texture).isTexture)
(value as Texture).dispose();
material.dispose();
}
});

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