Cen #5
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -7,6 +7,7 @@ playwright-report/
|
||||
web_platform/test-results/
|
||||
web_platform/playwright-report/
|
||||
web_platform/node_modules/.vite/
|
||||
.playwright-cli/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Python
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
AGENTS.md
|
||||
context.md
|
||||
plans/
|
||||
.git/
|
||||
.venv/
|
||||
build/
|
||||
node_modules/
|
||||
web-platform-dist/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.playwright-cli/
|
||||
web_platform/fixtures/
|
||||
web_platform/public/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"proseWrap": "preserve"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# 更新日志
|
||||
|
||||
本项目的重要变更记录在此文件中,版本标签沿用仓库现有的 `V主版本.次版本[.修订版本]` 格式。
|
||||
|
||||
## [0.7.1] - 2026-09-01
|
||||
|
||||
### 新增
|
||||
|
||||
- 增加浏览器内仿真遥测记录模块,可配置记录 Body、采样频率和样本上限。
|
||||
- 记录位置、移动速度、机身侧倾/俯仰/偏航角、角速度、累计里程、接触与驱动指标。
|
||||
- 增加实时数据与摘要面板,并支持导出稳定列结构的 CSV 和 Schema V1 JSON。
|
||||
- 提供 `TelemetrySource`、`registerDataChannel` 及记录生命周期接口,便于扩展业务指标和其他仿真后端。
|
||||
- 仿真重置使用数据分段,避免跨重置计算错误速度;达到样本上限时自动停止。
|
||||
|
||||
## [0.6.1] - 2026-08-28
|
||||
|
||||
### 新增
|
||||
|
||||
- 固定并强制使用 Node.js、npm、Prettier 和 Ruff 开发工具版本。
|
||||
- 为核心导入、仿真数学、状态管理和训练客户端增加覆盖率门槛。
|
||||
- Git 标签触发的自动构建、校验和与 GitHub Release 工作流。
|
||||
- 本地训练服务随机 Bearer Token 鉴权、Host 校验和任务历史上限。
|
||||
- 训练进程启动/取消竞态回归测试和 SIGTERM 受控退出。
|
||||
|
||||
### 变更
|
||||
|
||||
- Playwright CI 改用版本固定的 Chromium。
|
||||
- TypeScript、TSX、配置和文档统一使用 Prettier 格式化。
|
||||
- Python 训练服务统一使用 Ruff 检查和格式化。
|
||||
|
||||
## [0.6.0] - 2026-08-28
|
||||
|
||||
### 变更
|
||||
|
||||
- 将仓库重构为以 `web_platform/` 为核心的 Web 应用仓库。
|
||||
- MuJoCo 运行时改为依赖官方 `@mujoco/mujoco` npm 包。
|
||||
- 移除原生 C++、Python、MJX、Unity、桌面模拟器、CMake 和上游测试镜像。
|
||||
- 将训练桥接服务和 Python 控制器示例提升到仓库根目录。
|
||||
|
||||
## [0.5.2] - 2026-08-28
|
||||
|
||||
### 变更
|
||||
|
||||
- 优化响应式工作区、可访问性、首屏加载、纹理兼容性和视口交互。
|
||||
- 增加碰撞体、坐标系、关节轴、质心和惯量辅助可视化。
|
||||
@@ -10,6 +10,9 @@
|
||||
- ROS `package://`、常见 URDF 兼容转换及 DAE 降级处理
|
||||
- Three.js 模型、碰撞体、坐标系、关节轴、质心和惯量可视化
|
||||
- 播放、暂停、单步、重置、变速、关节拖动与外力交互
|
||||
- 内置平地、坡道、楼梯、随机障碍物及 9 类系统参数化地形(粗糙/波浪、金字塔阶梯、深坑、沟壑等)
|
||||
- 工程地图包:静态 MJCF/OBJ/STL/高度场碰撞层、GLB 视觉层和机器人出生点
|
||||
- V3 地图创作层:认证资产库支持点击添加、拖到画布落位和首个资产自动创建场景,并可通过表单与视口操纵器继续编辑、事务式应用及导出地图 ZIP
|
||||
- 浏览器内 Python 控制器(Pyodide)
|
||||
- ONNX 强化学习策略推理(ONNX Runtime Web)
|
||||
- 可选的本机 mjlab 训练桥接服务
|
||||
@@ -17,10 +20,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 +39,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
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Generated
+205
-35
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
ruff==0.16.5
|
||||
@@ -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
|
||||
```
|
||||
|
||||
+530
-401
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,30 @@
|
||||
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):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
(self.root / "scripts").mkdir()
|
||||
(self.root / "scripts" / "train.py").write_text(
|
||||
"""import os, pathlib, time
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
(self.root / "scripts").mkdir()
|
||||
(self.root / "scripts" / "train.py").write_text(
|
||||
"""import os, pathlib, time
|
||||
print('WANDB_MODE=' + os.environ.get('WANDB_MODE', ''), flush=True)
|
||||
print('Learning iteration 1 / 2', flush=True)
|
||||
time.sleep(0.02)
|
||||
@@ -23,58 +33,162 @@ out=pathlib.Path('logs/rsl_rl/test/run/policy.onnx')
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_bytes(b'onnx')
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.manager = TrainingManager(self.root, sys.executable, ("Unitree-Go2-Flat",), check_environment=False)
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.manager = TrainingManager(
|
||||
self.root, sys.executable, ("Unitree-Go2-Flat",), check_environment=False
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
@staticmethod
|
||||
def payload(**patch):
|
||||
value = {
|
||||
"taskId": "Unitree-Go2-Flat",
|
||||
"numEnvs": 16,
|
||||
"maxIterations": 2,
|
||||
"seed": 42,
|
||||
"runName": "browser-test",
|
||||
"device": "cpu",
|
||||
"gpuIds": [],
|
||||
"wandbMode": "offline",
|
||||
}
|
||||
value.update(patch)
|
||||
return value
|
||||
@staticmethod
|
||||
def payload(**patch):
|
||||
value = {
|
||||
"taskId": "Unitree-Go2-Flat",
|
||||
"numEnvs": 16,
|
||||
"maxIterations": 2,
|
||||
"seed": 42,
|
||||
"runName": "browser-test",
|
||||
"device": "cpu",
|
||||
"gpuIds": [],
|
||||
"wandbMode": "offline",
|
||||
}
|
||||
value.update(patch)
|
||||
return value
|
||||
|
||||
def test_validates_allowlist_and_limits(self):
|
||||
with self.assertRaises(ApiError):
|
||||
self.manager.parse_config(self.payload(taskId="shell injection"))
|
||||
with self.assertRaises(ApiError):
|
||||
self.manager.parse_config(self.payload(numEnvs=0))
|
||||
with self.assertRaises(ApiError):
|
||||
self.manager.parse_config(self.payload(runName="bad name"))
|
||||
with self.assertRaises(ApiError):
|
||||
self.manager.parse_config(self.payload(wandbMode="login"))
|
||||
def test_validates_allowlist_and_limits(self):
|
||||
with self.assertRaises(ApiError):
|
||||
self.manager.parse_config(self.payload(taskId="shell injection"))
|
||||
with self.assertRaises(ApiError):
|
||||
self.manager.parse_config(self.payload(numEnvs=0))
|
||||
with self.assertRaises(ApiError):
|
||||
self.manager.parse_config(self.payload(runName="bad name"))
|
||||
with self.assertRaises(ApiError):
|
||||
self.manager.parse_config(self.payload(wandbMode="login"))
|
||||
|
||||
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[-2:], ["--gpu-ids", "[0,2]"])
|
||||
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[-2:], ["--gpu-ids", "[0,2]"])
|
||||
|
||||
def test_runs_job_and_exposes_new_onnx_artifact(self):
|
||||
job = self.manager.start(self.payload())
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
job = self.manager.get(job["id"])
|
||||
if job["state"] not in ("queued", "running"):
|
||||
break
|
||||
time.sleep(0.02)
|
||||
self.assertEqual(job["state"], "succeeded")
|
||||
self.assertEqual(job["iteration"], 2)
|
||||
self.assertIn("WANDB_MODE=offline", job["logs"])
|
||||
self.assertTrue(job["artifactReady"])
|
||||
self.assertEqual(self.manager.artifact(job["id"]).read_bytes(), b"onnx")
|
||||
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
|
||||
while time.monotonic() < deadline:
|
||||
job = self.manager.get(job["id"])
|
||||
if job["state"] not in ("queued", "running"):
|
||||
break
|
||||
time.sleep(0.02)
|
||||
self.assertEqual(job["state"], "succeeded")
|
||||
self.assertEqual(job["iteration"], 2)
|
||||
self.assertIn("WANDB_MODE=offline", job["logs"])
|
||||
self.assertTrue(job["artifactReady"])
|
||||
self.assertEqual(self.manager.artifact(job["id"]).read_bytes(), b"onnx")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
unittest.main()
|
||||
|
||||
+45
-2
@@ -12,9 +12,11 @@
|
||||
- Three.js primitive、mesh、材质/贴图显示与对象选择
|
||||
- 播放、暂停、单步、重置、0.25×–4× 速度
|
||||
- actuator 滑杆、hinge/slide 关节拖动、动态 body 外力拖拽
|
||||
- 内置平地、坡道、楼梯、可复现随机障碍物及 9 类系统参数化地形,可配置尺寸、摩擦、难度、种子与高度场采样精度
|
||||
- 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行
|
||||
- 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理
|
||||
- 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX
|
||||
- 可配置仿真遥测记录,实时查看速度、机身姿态、位置、驱动力等指标并导出 CSV/JSON
|
||||
- FPS、物理耗时和主线程步进预算提示
|
||||
|
||||
## 开发
|
||||
@@ -61,6 +63,47 @@ python3 -m http.server 8080 --directory web-platform-dist
|
||||
- 默认限制:2000 个文件、单文件 128 MiB、总解压大小 512 MiB、ZIP 文件 128 MiB。
|
||||
- 文件夹或 ZIP 中的 `.py` 会显示在“控制 → Python 控制器”;也可以在加载模型后单独导入不超过 1 MiB 的 `.py`。
|
||||
|
||||
## 地图模块
|
||||
|
||||
模型加载后打开右侧“地图”标签,可以选择平地、坡道、楼梯、随机障碍物、系统参数化地形或工程内地图包。参数化地形包括离散障碍、沟壑、倒金字塔阶梯、深坑、金字塔阶梯、轨道、随机粗糙、踏石和波浪地形;相同参数与随机种子会确定性生成相同碰撞层。点击“应用并重新编译”后,平台会在当前入口同目录生成临时组合 MJCF;原始工程文件不会被修改。模型编译失败时保留上一个可用仿真会话。
|
||||
|
||||
工程地图由 `map.json`、静态 MJCF 碰撞层和可选的自包含 GLB 视觉层组成:
|
||||
|
||||
```text
|
||||
maps/warehouse/
|
||||
├── map.json
|
||||
├── physics/world.xml
|
||||
├── physics/meshes/*.obj
|
||||
├── visuals/scene.glb
|
||||
└── authoring/map.scene.json # V3 可选创作层
|
||||
```
|
||||
|
||||
最小描述示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "warehouse",
|
||||
"name": "仓库",
|
||||
"coordinateSystem": { "units": "m", "up": "Z", "forward": "+X" },
|
||||
"physics": { "source": "physics/world.xml" },
|
||||
"visual": { "source": "visuals/scene.glb" },
|
||||
"spawnPoints": [{ "id": "main", "name": "主入口", "position": [0, 0, 0.35], "yawDeg": 0 }]
|
||||
}
|
||||
```
|
||||
|
||||
物理地图仅允许静态 `worldbody` 以及 mesh、heightfield、texture、material 等基础 asset,不允许 joint、mocap body、actuator、sensor、include 或 default class。OBJ/STL 应使用简化碰撞模型;高精度模型只放入 GLB。GLB 必须是 2.0 自包含文件,外部 URI 会被拒绝。地图统一使用米制、Z-up、+X 前向坐标系。
|
||||
|
||||
V3 可编辑地图使用 `schemaVersion: 2`,并增加 `"authoring": { "source": "authoring/map.scene.json" }`。创作层支持方盒、圆柱、胶囊、坡道、楼梯和出生点。“场景 · 资产库”中的认证资产可点击添加或拖到画布落位;没有可编辑地图时,首个资产会立即创建 Schema V2 场景草稿,不触发 MuJoCo 重编译。新增对象支持三种放置方式:自动贴地、沿世界 `-Z` 落到最高静态承载面的自动重力落位,以及禁止位姿编辑的锁定模式。只有点击“应用并重新编译”后才提交物理层。可以通过表单或视口 TransformControls 修改位置、绕 Z 轴旋转和原语尺寸,支持移动/旋转吸附、视口拾取、复制、删除及对齐地面;`W`/`E`/`S` 切换移动、旋转和缩放工具,`Delete` 删除,`Ctrl+Z`/`Ctrl+Y` 撤销重做。编辑只更新 Three.js 草稿预览,点击编辑器内“应用并重新编译”后才生成确定性的静态 MJCF。失败时保留旧仿真和草稿。浏览器不会直接写回原目录,可使用“导出地图 ZIP”下载当前已提交地图包。没有 `authoring.source` 的 V1/V2 地图默认只读;仅由 `box`、`cylinder`、`capsule` 构成且不含 asset、材质、碰撞过滤或隐藏姿态语义的静态 MJCF,可通过“创建可编辑副本”显式升级。转换会生成 `authoring/map.scene.json`、Schema V2 描述和确定性物理层;任何不可逆语义都会导致整体拒绝,不会静默丢失内容。
|
||||
|
||||
物理地图超过 2000 个 geom 会产生性能警告,超过 10000 个会被拒绝;GLB 超过 100 万三角面会警告,超过 300 万会被拒绝。当前原生 URDF 模式不支持地图,请切换到“转换为 MJCF”。
|
||||
|
||||
## 数据记录
|
||||
|
||||
加载模型后打开右侧“数据”标签,可以选择需要跟踪的 Body、采样频率和样本上限。内置通道包括世界系位置/速度、水平与三维速度、机身侧倾/俯仰/偏航角及角速度、累计里程、接触数、控制输入 RMS、驱动力 RMS、绝对驱动功率和广义速度 RMS。仿真重置不会删除已有数据,而是创建新分段,避免跨重置计算出错误速度;切换记录 Body 或采样配置会清空不兼容的旧数据。
|
||||
|
||||
记录只保留在当前浏览器会话中,达到样本上限后自动停止,可导出带稳定列名的 CSV 或包含通道元数据、摘要和样本的 Schema V1 JSON。`SimulationSession`/`PhysicsAdapter` 保留 `configureDataRecorder`、`startDataRecording`、`stopDataRecording`、`clearDataRecording`、`exportDataRecording` 接口;还可通过 `registerDataChannel({ key, label, unit, read })` 在开始记录前注册业务自定义标量通道。数据源通过 `TelemetrySource` 抽象与 MuJoCo 解耦,后续可复用于 Worker 或远端仿真。
|
||||
|
||||
## Python 控制器
|
||||
|
||||
Python 控制器是可信的单文件脚本,必须同步定义 `step(ctx, state)`;可选定义 `NAME`、`CONTROL_HZ`(限制为 1–500 Hz)、`init(api)`、`command(name, state)`、`reset(state)` 和 `dispose(state)`。`init` 可用 `api.joint(name)`、`api.actuator(name)`、`api.sensor(name)`、`api.body(name)` 预解析 ID;`step` 可用 `ctx.qpos(id)`、`ctx.qvel(id)`、`ctx.sensor(id)`、`ctx.body_quat(id)`、`ctx.body_position(id)` 读取状态,并用 `ctx.set_control(id, value)` 写入经过有限值检查和 actuator 限幅的控制量。定义 `command` 后,界面会显示停止、前进、后退、左转、右转和起跳按钮,并分别传入 `stop`、`forward`、`backward`、`turn_left`、`turn_right`、`jump`。所有回调都必须同步;异常会自动停止控制器或显示诊断,运行期异常还会暂停仿真并清零 `ctrl`。
|
||||
@@ -77,9 +120,9 @@ npm run training-server -- \
|
||||
--trainer-python /path/to/training-env/bin/python
|
||||
```
|
||||
|
||||
界面默认连接 `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 强化学习策略
|
||||
|
||||
|
||||
+629
-156
@@ -1,9 +1,10 @@
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {zipSync} from 'fflate';
|
||||
import { expect, test } from '@playwright/test';
|
||||
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">
|
||||
@@ -19,7 +20,47 @@ const SIMPLE_MODEL = `
|
||||
<actuator><motor name="motor" joint="slide" ctrlrange="-2 2"/></actuator>
|
||||
</mujoco>`;
|
||||
|
||||
const SLIDE_DIRECTION_MODEL=`<mujoco model="drag-direction"><worldbody><body name="slider" pos="0 0 1"><joint name="screen_x" type="slide" axis="1 0 0" range="-2 2"/><geom type="box" size=".25 .25 .25" mass="1"/></body></worldbody></mujoco>`;
|
||||
const SLIDE_DIRECTION_MODEL = `<mujoco model="drag-direction"><worldbody><body name="slider" pos="0 0 1"><joint name="screen_x" type="slide" axis="1 0 0" range="-2 2"/><geom type="box" size=".25 .25 .25" mass="1"/></body></worldbody></mujoco>`;
|
||||
|
||||
function minimalGlb(): Buffer {
|
||||
const json = Buffer.from(
|
||||
JSON.stringify({
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ mesh: 0 }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
|
||||
buffers: [{ byteLength: 36 }],
|
||||
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 36, target: 34962 }],
|
||||
accessors: [
|
||||
{
|
||||
bufferView: 0,
|
||||
componentType: 5126,
|
||||
count: 3,
|
||||
type: 'VEC3',
|
||||
min: [0, 0, 0],
|
||||
max: [1, 1, 0],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const jsonPadding = (4 - (json.length % 4)) % 4;
|
||||
const jsonChunk = Buffer.concat([json, Buffer.alloc(jsonPadding, 0x20)]);
|
||||
const positions = Buffer.from(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer);
|
||||
const totalLength = 12 + 8 + jsonChunk.length + 8 + positions.length;
|
||||
const glb = Buffer.alloc(totalLength);
|
||||
glb.writeUInt32LE(0x46546c67, 0);
|
||||
glb.writeUInt32LE(2, 4);
|
||||
glb.writeUInt32LE(totalLength, 8);
|
||||
glb.writeUInt32LE(jsonChunk.length, 12);
|
||||
glb.writeUInt32LE(0x4e4f534a, 16);
|
||||
jsonChunk.copy(glb, 20);
|
||||
const binOffset = 20 + jsonChunk.length;
|
||||
glb.writeUInt32LE(positions.length, binOffset);
|
||||
glb.writeUInt32LE(0x004e4942, binOffset + 4);
|
||||
positions.copy(glb, binOffset + 8);
|
||||
return glb;
|
||||
}
|
||||
|
||||
const LARGE_MODEL = `
|
||||
<mujoco model="soak">
|
||||
@@ -36,238 +77,670 @@ const LARGE_MODEL = `
|
||||
</worldbody>
|
||||
</mujoco>`;
|
||||
|
||||
test('显示中文平台骨架并加载单文件模型', async ({page}) => {
|
||||
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');
|
||||
await page.setViewportSize({width:1440,height:900});
|
||||
await expect(page.getByRole('heading', {name: 'MuJoCo Web 仿真平台'})).toBeVisible();
|
||||
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');
|
||||
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 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.keyboard.press('Control+k');
|
||||
await expect(page.getByRole('dialog',{name:'命令面板'})).toBeVisible();
|
||||
await expect(page.getByRole('dialog', { name: '命令面板' })).toBeVisible();
|
||||
await page.getByLabel('搜索命令').fill('复位相机');
|
||||
await expect(page.getByRole('option',{name:/复位相机/})).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: /复位相机/ })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button',{name:'进入全屏'}).click();
|
||||
await expect(page.getByRole('button',{name:'退出全屏'})).toBeVisible();
|
||||
await page.getByRole('button', { name: '进入全屏' }).click();
|
||||
await expect(page.getByRole('button', { name: '退出全屏' })).toBeVisible();
|
||||
await page.keyboard.press('Control+k');
|
||||
await expect(page.getByRole('dialog',{name:'命令面板'})).toBeVisible();
|
||||
await expect(page.getByRole('dialog', { name: '命令面板' })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button',{name:'退出全屏'}).click();
|
||||
await page.getByRole('button',{name:'切换到白天主题'}).click();
|
||||
await page.getByRole('button', { name: '退出全屏' }).click();
|
||||
await page.getByRole('button', { name: '切换到白天主题' }).click();
|
||||
await expect(page.locator('#root > div')).toHaveClass(/theme-light/);
|
||||
await expect(page.getByRole('button',{name:'切换到黑夜主题'})).toBeVisible();
|
||||
await page.getByRole('button',{name:'切换到黑夜主题'}).click();
|
||||
await expect(page.getByRole('button', { name: '切换到黑夜主题' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '切换到黑夜主题' }).click();
|
||||
await expect(page.locator('#root > div')).toHaveClass(/theme-dark/);
|
||||
|
||||
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 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 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 page.getByRole('tab',{name:'控制'}).click();
|
||||
await page.getByRole('button',{name:'Actuator'}).click();
|
||||
await expect(page.getByText('motor',{exact:true})).toBeVisible();
|
||||
await expect(page.getByRole('tabpanel',{name:'控制'}).getByText('slide',{exact:true})).toBeVisible();
|
||||
await page.getByRole('tab',{name:'模型结构'}).click();
|
||||
const structure=page.getByRole('navigation',{name:'模型结构树'});await expect(structure).toBeVisible();await structure.getByRole('treeitem',{name:/hinge/}).hover();
|
||||
await expect(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 page.getByRole('tab', { name: '模型结构' }).click();
|
||||
const structure = page.getByRole('navigation', { name: '模型结构树' });
|
||||
await expect(structure).toBeVisible();
|
||||
await structure.getByRole('treeitem', { name: /hinge/ }).hover();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(page.getByRole('button',{name:'重置关节'})).toBeVisible();
|
||||
await page.getByRole('button',{name:'高级'}).click();
|
||||
await expect(page.getByRole('button', { name: '重置关节' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '高级' }).click();
|
||||
await expect(page.getByText('下限 -1.571 rad')).toBeVisible();
|
||||
await expect(page.getByText('上限 1.571 rad')).toBeVisible();
|
||||
await page.getByRole('button',{name:'rad 弧度制'}).click();
|
||||
await page.getByRole('button', { name: 'rad 弧度制' }).click();
|
||||
await expect(page.getByText('下限 -90.000°')).toBeVisible();
|
||||
await expect(page.getByText('上限 90.000°')).toBeVisible();
|
||||
await page.getByRole('button',{name:'忽略关节限位'}).click();
|
||||
await expect(page.getByRole('button',{name:'忽略关节限位'})).toHaveAttribute('aria-pressed','true');
|
||||
await page.getByRole('button', { name: '忽略关节限位' }).click();
|
||||
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();
|
||||
await page.getByRole('button', { name: '重置关节' }).click();
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('窄视口默认保留完整视口并可按需打开侧栏',async({page})=>{
|
||||
await page.setViewportSize({width:800,height:700});
|
||||
test('窄视口默认保留完整视口并可按需打开侧栏', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 800, height: 700 });
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('main')).toBeInViewport();
|
||||
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('button', { name: '显示工程面板' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '显示属性面板' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '显示属性面板' }).click();
|
||||
await expect(
|
||||
page.getByRole('complementary').filter({ hasText: '导入模型后显示属性' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('工作区布局与视口显示偏好在刷新后保留',async({page})=>{
|
||||
await page.setViewportSize({width:1440,height:900});
|
||||
test('工作区布局与视口显示偏好在刷新后保留', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto('/');
|
||||
await page.getByRole('button',{name:'隐藏工程面板'}).click();
|
||||
await page.getByRole('button',{name:'显示设置'}).click();
|
||||
await page.getByRole('switch',{name:/^坐标系/}).click();
|
||||
await page.getByRole('button', { name: '隐藏工程面板' }).click();
|
||||
await page.getByRole('button', { name: '显示设置' }).click();
|
||||
await page.getByRole('switch', { name: /^坐标系/ }).click();
|
||||
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('button', { name: '显示工程面板' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '显示设置' }).click();
|
||||
await expect(page.getByRole('switch', { name: /^坐标系/ })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
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 expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
|
||||
await page.getByRole('button',{name:'源代码'}).click();
|
||||
const dialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await expect(dialog).toBeVisible();
|
||||
await 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();
|
||||
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"'));
|
||||
await dialog.getByRole('button',{name:'保存并重新载入',exact:true}).click();
|
||||
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);
|
||||
await expect(page.getByRole('button',{name:'导出 MJCF'})).toHaveCount(0);
|
||||
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"'));
|
||||
await dialog.getByRole('button', { name: '保存并重新载入', exact: true }).click();
|
||||
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);
|
||||
await expect(page.getByRole('button', { name: '导出 MJCF' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
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 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 editorDialog.getByRole('button',{name:'关闭源代码编辑器'}).click();
|
||||
const confirm=page.getByRole('dialog',{name:'放弃未保存的修改?'});
|
||||
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 editorDialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
|
||||
const confirm = page.getByRole('dialog', { name: '放弃未保存的修改?' });
|
||||
await expect(confirm).toBeVisible();
|
||||
await confirm.getByRole('button',{name:'继续编辑'}).click();
|
||||
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 editorDialog.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}) => {
|
||||
test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').first().setInputFiles([
|
||||
fixture('mjcf_include/model.xml'),
|
||||
fixture('mjcf_include/world.xml'),
|
||||
fixture('mjcf_include/triangle.obj'),
|
||||
fixture('mjcf_include/triangle.stl'),
|
||||
fixture('mjcf_include/checker.png'),
|
||||
]);
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles([
|
||||
fixture('mjcf_include/model.xml'),
|
||||
fixture('mjcf_include/world.xml'),
|
||||
fixture('mjcf_include/triangle.obj'),
|
||||
fixture('mjcf_include/triangle.stl'),
|
||||
fixture('mjcf_include/checker.png'),
|
||||
]);
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('5 个文件')).toBeVisible();
|
||||
});
|
||||
|
||||
test('加载引用 OBJ 的 URDF 工程', 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'),
|
||||
]);
|
||||
const options=page.getByRole('dialog',{name:'配置 URDF 仿真组件'});
|
||||
await expect(options.getByRole('checkbox',{name:/为关节添加驱动器/})).toBeChecked();
|
||||
await expect(options.getByRole('checkbox',{name:/添加传感器/})).toBeChecked();
|
||||
await options.getByRole('button',{name:'转换并加载'}).click();
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
|
||||
await 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();
|
||||
await options.getByRole('button', { name: '转换并加载' }).click();
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('2 个文件')).toBeVisible();
|
||||
await page.getByRole('button',{name:'URDF 处理方式'}).click();
|
||||
await page.getByRole('button', { name: 'URDF 处理方式' }).click();
|
||||
await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf');
|
||||
await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating');
|
||||
await page.getByRole('button',{name:'通知中心'}).click();
|
||||
const notifications=page.getByRole('dialog',{name:'通知中心'});
|
||||
await page.getByRole('button', { name: '通知中心' }).click();
|
||||
const notifications = page.getByRole('dialog', { name: '通知中心' });
|
||||
await expect(notifications).toContainText(/模型已加载 · \d+ 项兼容调整/);
|
||||
await expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked();
|
||||
await page.getByRole('button',{name:'源代码'}).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();
|
||||
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();
|
||||
});
|
||||
|
||||
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 expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
|
||||
await expect(page.getByLabel('摄像头画面')).toBeVisible();await page.getByRole('button',{name:'隐藏画面'}).click();await page.getByRole('button',{name:'显示摄像头画面'}).click();await expect(page.getByLabel('摄像头画面')).toBeVisible();
|
||||
await page.getByRole('button',{name:'通知中心'}).click();
|
||||
const notifications=page.getByRole('dialog',{name:'通知中心'});
|
||||
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 expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByLabel('摄像头画面')).toBeVisible();
|
||||
await page.getByRole('button', { name: '隐藏画面' }).click();
|
||||
await page.getByRole('button', { name: '显示摄像头画面' }).click();
|
||||
await expect(page.getByLabel('摄像头画面')).toBeVisible();
|
||||
await page.getByRole('button', { name: '通知中心' }).click();
|
||||
const notifications = page.getByRole('dialog', { name: '通知中心' });
|
||||
await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器');
|
||||
await expect(notifications).toContainText('已将 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);
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
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();
|
||||
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();
|
||||
});
|
||||
|
||||
test('中等规模模型持续步进并可重复加载', async ({page}) => {
|
||||
test('中等规模模型持续步进并可重复加载', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const input = page.locator('input[type="file"]').first();
|
||||
const modelFile = {name:'large.xml',mimeType:'text/xml',buffer:Buffer.from(LARGE_MODEL)};
|
||||
const modelFile = { name: 'large.xml', mimeType: 'text/xml', buffer: Buffer.from(LARGE_MODEL) };
|
||||
await input.setInputFiles(modelFile);
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
|
||||
await page.getByRole('button', {name:'▶ 播放'}).click();
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('button', { name: '▶ 播放' }).click();
|
||||
await page.waitForTimeout(2_000);
|
||||
await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
|
||||
|
||||
// 播放过程中重置必须同时暂停底层会话,之后仍可正常播放和暂停。
|
||||
await page.getByRole('button',{name:'重置',exact:true}).click();
|
||||
await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible();
|
||||
await page.getByRole('button', { name: '重置', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
|
||||
await expect(page.locator('footer')).toContainText('时间 0.000 s');
|
||||
await page.getByRole('button',{name:'▶ 播放'}).click();
|
||||
await page.getByRole('button', { name: '▶ 播放' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole('button',{name:'⏸ 暂停'}).click();
|
||||
await page.getByRole('button', { name: '⏸ 暂停' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
const pausedTime=(await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1];
|
||||
const pausedTime = (await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1];
|
||||
expect(Number(pausedTime)).toBeGreaterThan(0);
|
||||
await page.waitForTimeout(500);
|
||||
expect((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1]).toBe(pausedTime);
|
||||
|
||||
await input.setInputFiles(modelFile);
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('无效模型显示中文诊断且保留工程树', async ({page}) => {
|
||||
test('认证资产可点击创建场景并拖到画布落位', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'model.xml',
|
||||
mimeType: 'text/xml',
|
||||
buffer: Buffer.from(SIMPLE_MODEL),
|
||||
});
|
||||
await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
|
||||
const library = page.getByLabel('认证资产');
|
||||
await expect(library.getByText('点击添加,或按住资产拖到画布落位。')).toBeVisible();
|
||||
await library.getByRole('button', { name: '添加基础方盒' }).click();
|
||||
await expect(page.getByText('正在加载 MuJoCo 与模型…')).toHaveCount(0);
|
||||
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json', {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText('V3 地图编辑器')).toBeVisible();
|
||||
await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible();
|
||||
|
||||
await page.locator('[data-map-asset="ramp"]').dragTo(page.locator('main canvas'));
|
||||
await expect(page.getByLabel('地图对象列表').getByText('标准坡道 · 坡道')).toBeVisible();
|
||||
await expect(page.getByText('地图草稿尚未应用')).toBeVisible();
|
||||
|
||||
await page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒').click();
|
||||
await page.getByLabel('对象放置方式').selectOption('locked');
|
||||
await expect(page.getByLabel('对象位置X')).toBeDisabled();
|
||||
await page.getByLabel('对象放置方式').selectOption('auto_ground');
|
||||
await expect(page.getByLabel('对象位置X')).toBeEnabled();
|
||||
|
||||
const gizmoLine = page.getByRole('img', { name: 'XYZ 方向指示器' }).locator('line').first();
|
||||
const beforeRotation = await gizmoLine.getAttribute('x2');
|
||||
const canvasBox = await page.locator('main canvas').first().boundingBox();
|
||||
expect(canvasBox).not.toBeNull();
|
||||
await page.mouse.move(canvasBox!.x + 24, canvasBox!.y + 24);
|
||||
await page.mouse.down({ button: 'left' });
|
||||
await page.mouse.move(canvasBox!.x + 104, canvasBox!.y + 50, { steps: 8 });
|
||||
await page.mouse.up({ button: 'left' });
|
||||
await expect.poll(() => gizmoLine.getAttribute('x2')).not.toBe(beforeRotation);
|
||||
});
|
||||
|
||||
test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'map-model.xml',
|
||||
mimeType: 'text/xml',
|
||||
buffer: Buffer.from(SIMPLE_MODEL),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption('builtin');
|
||||
await page.getByLabel('物理地图预设').selectOption('stairs');
|
||||
await page.getByLabel('台阶数量').fill('6');
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(page.getByText(/已加载楼梯物理地图/)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible();
|
||||
});
|
||||
|
||||
test('依次应用全部系统参数化地形', async ({ page }) => {
|
||||
const terrains = [
|
||||
['discrete_obstacles', '离散障碍地形'],
|
||||
['gap', '沟壑地形'],
|
||||
['inverted_pyramid_stairs', '倒金字塔阶梯'],
|
||||
['pit', '深坑地形'],
|
||||
['pyramid_stairs', '金字塔阶梯'],
|
||||
['rails', '轨道地形'],
|
||||
['rough', '随机粗糙地形'],
|
||||
['stepping_stones', '踏石地形'],
|
||||
['wave', '波浪地形'],
|
||||
] as const;
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'terrain-model.xml',
|
||||
mimeType: 'text/xml',
|
||||
buffer: Buffer.from(SIMPLE_MODEL),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption('builtin');
|
||||
for (const [preset, label] of terrains) {
|
||||
await page.getByLabel('物理地图预设').selectOption(preset);
|
||||
await page.getByLabel('地形边长(m)').fill('6');
|
||||
if (preset === 'rough' || preset === 'wave')
|
||||
await page.getByLabel('水平采样间距(m)').fill('0.25');
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(page.getByText(new RegExp(`已加载${label}物理地图`))).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible();
|
||||
});
|
||||
|
||||
test('导入并应用分层工程地图包', async ({ page }) => {
|
||||
const mapJson = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: 'test-room',
|
||||
name: '测试场景',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'physics/world.xml' },
|
||||
visual: { source: 'visuals/scene.glb' },
|
||||
spawnPoints: [{ id: 'start', name: '起点', position: [2, 0, 0.5], yawDeg: 0 }],
|
||||
});
|
||||
const project = zipSync({
|
||||
'model.xml': Buffer.from(SIMPLE_MODEL),
|
||||
'maps/test/map.json': Buffer.from(mapJson),
|
||||
'maps/test/physics/world.xml': Buffer.from(
|
||||
'<mujoco><worldbody><geom name="floor" type="plane" size="8 8 .1"/><geom name="wall" type="box" pos="3 0 .5" size=".1 2 .5"/></worldbody></mujoco>',
|
||||
),
|
||||
'maps/test/visuals/scene.glb': minimalGlb(),
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'map-project.zip',
|
||||
mimeType: 'application/zip',
|
||||
buffer: Buffer.from(project),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption({ label: '测试场景' });
|
||||
await expect(page.getByLabel('地图出生点')).toHaveValue('start');
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(page.getByText(/已加载工程地图“测试场景”/)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(/视觉地图加载失败/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('将受支持的只读物理地图转换为可编辑副本', async ({ page }) => {
|
||||
const mapJson = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: 'legacy-room',
|
||||
name: '旧版基础场景',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'physics/world.xml' },
|
||||
spawnPoints: [{ id: 'start', name: '入口', position: [0, 0, 0], yawDeg: 0 }],
|
||||
});
|
||||
const project = zipSync({
|
||||
'model.xml': Buffer.from(SIMPLE_MODEL),
|
||||
'maps/legacy/map.json': Buffer.from(mapJson),
|
||||
'maps/legacy/physics/world.xml': Buffer.from(
|
||||
'<mujoco><worldbody><geom name="floor" type="box" pos="0 0 -.1" size="5 5 .1"/><body name="wall" pos="2 0 .5"><geom type="box" size=".1 1 .5"/></body></worldbody></mujoco>',
|
||||
),
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'legacy-map.zip',
|
||||
mimeType: 'application/zip',
|
||||
buffer: Buffer.from(project),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' });
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
const editor = page.getByText('V3 地图编辑器').locator('..');
|
||||
await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 });
|
||||
await editor.getByRole('button', { name: '创建可编辑副本' }).click();
|
||||
await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(editor.getByRole('button', { name: '移动工具 W' })).toBeVisible();
|
||||
await expect(editor.getByRole('button', { name: /floor · 方盒/ })).toBeVisible();
|
||||
await expect(editor.getByRole('button', { name: /wall · 方盒/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('编辑 V3 地图对象并事务式应用', async ({ page }) => {
|
||||
const authoring = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
mapId: 'editable-room',
|
||||
revision: 0,
|
||||
objects: [],
|
||||
spawnPoints: [],
|
||||
});
|
||||
const mapJson = JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
id: 'editable-room',
|
||||
name: '可编辑场景',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'physics/world.xml' },
|
||||
authoring: { source: 'authoring/map.scene.json' },
|
||||
spawnPoints: [],
|
||||
});
|
||||
const project = zipSync({
|
||||
'model.xml': Buffer.from(SIMPLE_MODEL),
|
||||
'maps/edit/map.json': Buffer.from(mapJson),
|
||||
'maps/edit/physics/world.xml': Buffer.from('<mujoco><worldbody/></mujoco>'),
|
||||
'maps/edit/authoring/map.scene.json': Buffer.from(authoring),
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'editable-map.zip',
|
||||
mimeType: 'application/zip',
|
||||
buffer: Buffer.from(project),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' });
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(page.getByText('V3 地图编辑器')).toBeVisible({ timeout: 30_000 });
|
||||
const editor = page.getByText('V3 地图编辑器').locator('..');
|
||||
await expect(editor.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
await page.keyboard.press('e');
|
||||
await expect(editor.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
await page.keyboard.press('s');
|
||||
await expect(editor.getByRole('button', { name: '缩放工具 S' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
await editor.getByRole('button', { name: '新增', exact: true }).click();
|
||||
await editor.getByLabel('对象位置X').fill('2');
|
||||
await editor.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(editor.getByRole('button', { name: /box · 方盒/ })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(editor.getByText('地图草稿尚未应用')).toHaveCount(0);
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible();
|
||||
});
|
||||
|
||||
test('工程地图编译失败时保留上一仿真会话', async ({ page }) => {
|
||||
const mapJson = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: 'invalid-map',
|
||||
name: '动态错误地图',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'world.xml' },
|
||||
spawnPoints: [],
|
||||
});
|
||||
const project = zipSync({
|
||||
'model.xml': Buffer.from(SIMPLE_MODEL),
|
||||
'maps/invalid/map.json': Buffer.from(mapJson),
|
||||
'maps/invalid/world.xml': Buffer.from(
|
||||
'<mujoco><worldbody><body><joint/><geom type="box" size="1 1 1"/></body></worldbody></mujoco>',
|
||||
),
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name: 'invalid-map.zip',
|
||||
mimeType: 'application/zip',
|
||||
buffer: Buffer.from(project),
|
||||
});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole('tab', { name: '地图' }).click();
|
||||
await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' });
|
||||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
|
||||
await expect(page.getByLabel('地图来源')).toHaveValue('none');
|
||||
await page.getByRole('button', { name: '关闭错误' }).click();
|
||||
await page.getByRole('button', { name: '▶ 播放' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
|
||||
});
|
||||
|
||||
test('无效模型显示中文诊断且保留工程树', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').first().setInputFiles(fixture('invalid.xml'));
|
||||
await expect(page.getByRole('alert')).toContainText('模型编译失败', {timeout: 30_000});
|
||||
await expect(page.getByText('invalid.xml', {exact: false}).first()).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
|
||||
await expect(page.getByText('invalid.xml', { exact: false }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
+72
-1
@@ -1 +1,72 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#101827"/><link rel="icon" href="data:,"/><title>MuJoCo Web 仿真平台</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="theme-color" content="#09111e" />
|
||||
<meta
|
||||
name="description"
|
||||
content="基于 MuJoCo WebAssembly 的本地机器人仿真、控制与地图编辑工作台"
|
||||
/>
|
||||
<link rel="icon" href="data:," />
|
||||
<title>MuJoCo Web 仿真平台</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
background: #09111e;
|
||||
}
|
||||
.boot-screen {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
place-items: center;
|
||||
color: #f1f5f9;
|
||||
font:
|
||||
13px Inter,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
.boot-mark {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin: 0 auto 14px;
|
||||
place-items: center;
|
||||
border: 1px solid #2b604f;
|
||||
border-radius: 13px;
|
||||
background: #123b31;
|
||||
color: #38d39f;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 16px 48px rgb(0 0 0 / 35%);
|
||||
animation: boot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.boot-caption {
|
||||
color: #8fa0b5;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
}
|
||||
@keyframes boot-pulse {
|
||||
50% {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 18px 54px rgb(56 211 159 / 15%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div class="boot-screen" role="status" aria-label="正在启动仿真工作台">
|
||||
<div>
|
||||
<div class="boot-mark">M</div>
|
||||
<div class="boot-caption">正在启动本地仿真工作台…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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}});
|
||||
import { defineConfig } from '@playwright/test';
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
module.exports = {plugins: {tailwindcss: {config: './web_platform/tailwind.config.cjs'}, autoprefixer: {}}};
|
||||
module.exports = {
|
||||
plugins: { tailwindcss: { config: './web_platform/tailwind.config.cjs' }, autoprefixer: {} },
|
||||
};
|
||||
|
||||
+1893
-115
File diff suppressed because it is too large
Load Diff
@@ -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;}}
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,96 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {ActuatorControl} from './SidebarPanel';
|
||||
import type {ActuatorInfo} from '../../simulation/SimulationSession';
|
||||
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('显示对应关节和常用力矩单位',()=>{
|
||||
render(<ActuatorControl actuator={actuator} onControl={()=>{}} onParameters={()=>{}}/>);
|
||||
describe('ActuatorControl', () => {
|
||||
it('显示对应关节和常用力矩单位', () => {
|
||||
render(<ActuatorControl actuator={actuator} onControl={() => {}} onParameters={() => {}} />);
|
||||
expect(screen.getByText('shoulder_motor')).toBeVisible();
|
||||
expect(screen.getByText('关节:shoulder')).toBeVisible();
|
||||
expect(screen.getByText('1.000 N·m')).toBeVisible();
|
||||
});
|
||||
|
||||
it('内部按 gear 换算输出,但参数面板只开放 kp、kv 等业务参数',()=>{
|
||||
const onControl=vi.fn(),onParameters=vi.fn();
|
||||
render(<ActuatorControl actuator={actuator} onControl={onControl} onParameters={onParameters}/>);
|
||||
fireEvent.change(screen.getByRole('slider'),{target:{value:'2'}});
|
||||
it('内部按 gear 换算输出,但参数面板只开放 kp、kv 等业务参数', () => {
|
||||
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);
|
||||
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({kp:150,kv:10}));
|
||||
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);
|
||||
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({ kp: 150, kv: 10 }));
|
||||
});
|
||||
|
||||
it('非 motor 驱动器保持原始控制单位且不开放通用参数编辑',()=>{
|
||||
render(<ActuatorControl actuator={{...actuator,name:'custom',kind:'other',unit:'',value:.25}} onControl={()=>{}} onParameters={()=>{}}/>);
|
||||
it('非 motor 驱动器保持原始控制单位且不开放通用参数编辑', () => {
|
||||
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>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DataRecordingPanel } from './DataRecordingPanel';
|
||||
import type { DataRecorderStatus } from '../../simulation/DataRecorder';
|
||||
|
||||
const status: DataRecorderStatus = {
|
||||
recording: false,
|
||||
limitReached: false,
|
||||
sampleCount: 2,
|
||||
segmentCount: 1,
|
||||
config: { bodyId: 1, sampleRateHz: 50, maxSamples: 30_000 },
|
||||
body: { id: 1, name: 'base' },
|
||||
latest: {
|
||||
sequence: 1,
|
||||
segment: 0,
|
||||
simulationTime: 0.02,
|
||||
values: {
|
||||
speed_horizontal: 1.25,
|
||||
speed_3d: 1.3,
|
||||
velocity_x: 1.25,
|
||||
velocity_y: 0,
|
||||
velocity_z: 0.1,
|
||||
pitch: 0.1,
|
||||
roll: -0.05,
|
||||
yaw: 0.2,
|
||||
height: 0.45,
|
||||
position_x: 0.1,
|
||||
position_y: 0,
|
||||
contact_count: 4,
|
||||
control_rms: 2,
|
||||
actuator_force_rms: 3,
|
||||
actuator_power_abs: 8,
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
duration: 0.02,
|
||||
distanceHorizontal: 0.1,
|
||||
maxHorizontalSpeed: 1.25,
|
||||
maxAbsRoll: 0.05,
|
||||
maxAbsPitch: 0.1,
|
||||
minHeight: 0.44,
|
||||
maxHeight: 0.46,
|
||||
},
|
||||
};
|
||||
|
||||
describe('DataRecordingPanel', () => {
|
||||
it('展示实时遥测并提供配置、记录和导出操作', () => {
|
||||
const configure = vi.fn(),
|
||||
start = vi.fn(),
|
||||
exportData = vi.fn();
|
||||
render(
|
||||
<DataRecordingPanel
|
||||
status={status}
|
||||
bodies={[
|
||||
{ id: 0, name: 'world', parentId: 0 },
|
||||
{ id: 1, name: 'base', parentId: 0 },
|
||||
{ id: 2, name: 'payload', parentId: 1 },
|
||||
]}
|
||||
onConfigure={configure}
|
||||
onStart={start}
|
||||
onStop={vi.fn()}
|
||||
onClear={vi.fn()}
|
||||
onExport={exportData}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText('1.250 m/s').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('5.73°').length).toBeGreaterThan(0);
|
||||
fireEvent.change(screen.getByLabelText('记录 Body'), { target: { value: '2' } });
|
||||
expect(configure).toHaveBeenCalledWith({ bodyId: 2 });
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始记录' }));
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出 CSV' }));
|
||||
expect(exportData).toHaveBeenCalledWith('csv');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Circle, Download, Square, Trash2 } from 'lucide-react';
|
||||
import type { BodyInfo } from '../../simulation/SimulationSession';
|
||||
import type { DataRecorderConfig, DataRecorderStatus } from '../../simulation/DataRecorder';
|
||||
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
|
||||
|
||||
const SAMPLE_RATES = [10, 20, 50, 100, 200];
|
||||
const radiansToDegrees = 180 / Math.PI;
|
||||
|
||||
function number(value: number | undefined, digits = 3): string {
|
||||
return Number.isFinite(value) ? value!.toFixed(digits) : '—';
|
||||
}
|
||||
|
||||
function degrees(value: number | undefined): string {
|
||||
return Number.isFinite(value) ? `${(value! * radiansToDegrees).toFixed(2)}°` : '—';
|
||||
}
|
||||
|
||||
export function DataRecordingPanel({
|
||||
status,
|
||||
bodies,
|
||||
onConfigure,
|
||||
onStart,
|
||||
onStop,
|
||||
onClear,
|
||||
onExport,
|
||||
}: {
|
||||
status: DataRecorderStatus;
|
||||
bodies: BodyInfo[];
|
||||
onConfigure: (patch: Partial<DataRecorderConfig>) => void;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onClear: () => void;
|
||||
onExport: (format: 'csv' | 'json') => void;
|
||||
}) {
|
||||
const values = status.latest?.values,
|
||||
availableBodies = bodies.filter(
|
||||
(body) => body.id > 0 && !body.name.startsWith('__platform_map_'),
|
||||
);
|
||||
return (
|
||||
<div className="space-y-3 p-3">
|
||||
<section className="rounded-lg border border-border bg-surface p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-text-primary">仿真遥测记录</h3>
|
||||
<p className="mt-0.5 text-[10px] text-text-tertiary">
|
||||
数据仅保存在当前浏览器会话,可导出 CSV 或 JSON。
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone={status.recording ? 'success' : status.limitReached ? 'warning' : 'neutral'}>
|
||||
{status.recording ? '记录中' : status.limitReached ? '已达上限' : '已停止'}
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">记录 Body</span>
|
||||
<Select
|
||||
aria-label="记录 Body"
|
||||
className="w-full"
|
||||
value={status.config.bodyId}
|
||||
disabled={status.recording}
|
||||
onChange={(event) => onConfigure({ bodyId: Number(event.target.value) })}
|
||||
>
|
||||
{(availableBodies.length ? availableBodies : [status.body]).map((body) => (
|
||||
<option key={body.id} value={body.id}>
|
||||
{body.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">采样频率</span>
|
||||
<Select
|
||||
aria-label="采样频率"
|
||||
className="w-full"
|
||||
value={status.config.sampleRateHz}
|
||||
disabled={status.recording}
|
||||
onChange={(event) => onConfigure({ sampleRateHz: Number(event.target.value) })}
|
||||
>
|
||||
{SAMPLE_RATES.map((rate) => (
|
||||
<option key={rate} value={rate}>
|
||||
{rate} Hz
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">样本上限</span>
|
||||
<input
|
||||
aria-label="样本上限"
|
||||
type="number"
|
||||
min={100}
|
||||
max={1_000_000}
|
||||
step={1000}
|
||||
className="field h-8 w-full px-2 text-xs"
|
||||
value={status.config.maxSamples}
|
||||
disabled={status.recording}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (Number.isFinite(value) && value >= 100) onConfigure({ maxSamples: value });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{status.recording ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Square className="h-3 w-3 fill-current" />}
|
||||
onClick={onStop}
|
||||
>
|
||||
停止记录
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Circle className="h-3 w-3 fill-current" />}
|
||||
onClick={onStart}
|
||||
disabled={status.limitReached}
|
||||
>
|
||||
开始记录
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
onClick={onClear}
|
||||
disabled={!status.sampleCount}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-border pt-2">
|
||||
<PropertyRow label="样本" value={`${status.sampleCount} / ${status.config.maxSamples}`} />
|
||||
<PropertyRow label="有效时长" value={`${number(status.summary.duration, 2)} s`} />
|
||||
<PropertyRow label="分段" value={status.segmentCount} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border bg-surface p-3">
|
||||
<h3 className="mb-2 text-xs font-semibold text-text-primary">实时运动状态</h3>
|
||||
<PropertyRow label="水平速度" value={`${number(values?.speed_horizontal)} m/s`} />
|
||||
<PropertyRow label="三维速度" value={`${number(values?.speed_3d)} m/s`} />
|
||||
<PropertyRow
|
||||
label="世界系速度 XYZ"
|
||||
value={`${number(values?.velocity_x)}, ${number(values?.velocity_y)}, ${number(values?.velocity_z)} m/s`}
|
||||
/>
|
||||
<PropertyRow label="俯仰角" value={degrees(values?.pitch)} />
|
||||
<PropertyRow label="侧倾角" value={degrees(values?.roll)} />
|
||||
<PropertyRow label="偏航角" value={degrees(values?.yaw)} />
|
||||
<PropertyRow label="机身高度" value={`${number(values?.height)} m`} />
|
||||
<PropertyRow
|
||||
label="世界系位置 XY"
|
||||
value={`${number(values?.position_x)}, ${number(values?.position_y)} m`}
|
||||
/>
|
||||
<PropertyRow label="场景接触数" value={number(values?.contact_count, 0)} />
|
||||
<PropertyRow label="控制输入 RMS" value={number(values?.control_rms)} />
|
||||
<PropertyRow label="驱动力 RMS" value={number(values?.actuator_force_rms)} />
|
||||
<PropertyRow label="绝对驱动功率" value={`${number(values?.actuator_power_abs)} W`} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border bg-surface p-3">
|
||||
<h3 className="mb-2 text-xs font-semibold text-text-primary">本次记录摘要</h3>
|
||||
<PropertyRow
|
||||
label="累计水平里程"
|
||||
value={`${number(status.summary.distanceHorizontal)} m`}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="最大水平速度"
|
||||
value={`${number(status.summary.maxHorizontalSpeed)} m/s`}
|
||||
/>
|
||||
<PropertyRow label="最大俯仰角" value={degrees(status.summary.maxAbsPitch)} />
|
||||
<PropertyRow label="最大侧倾角" value={degrees(status.summary.maxAbsRoll)} />
|
||||
<PropertyRow
|
||||
label="高度范围"
|
||||
value={`${number(status.summary.minHeight)} – ${number(status.summary.maxHeight)} m`}
|
||||
/>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
icon={<Download className="h-3.5 w-3.5" />}
|
||||
disabled={!status.sampleCount}
|
||||
onClick={() => onExport('csv')}
|
||||
>
|
||||
导出 CSV
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Download className="h-3.5 w-3.5" />}
|
||||
disabled={!status.sampleCount}
|
||||
onClick={() => onExport('json')}
|
||||
>
|
||||
导出 JSON
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<p className="text-[10px] leading-4 text-text-tertiary">
|
||||
每次仿真重置会创建新分段,避免跨重置计算出错误速度。开发接口支持注册额外标量通道,导出列名保持稳定。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,50 @@
|
||||
import {useState} from 'react';
|
||||
import {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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,97 @@
|
||||
import {useState} from 'react';
|
||||
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>;}
|
||||
import { useState } from 'react';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import {render,screen} from '@testing-library/react';
|
||||
import {EntrySelectionDialog} from './EntrySelectionDialog';
|
||||
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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,68 @@
|
||||
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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
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();});});
|
||||
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}
|
||||
progress={{
|
||||
title: '正在导入工程',
|
||||
label: '读取工程文件',
|
||||
detail: 'robot.zip',
|
||||
value: 0.42,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('拖放模型工程到此处')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('正在导入工程');
|
||||
expect(screen.getByRole('progressbar', { name: '读取工程文件' })).toHaveAttribute(
|
||||
'aria-valuenow',
|
||||
'42',
|
||||
);
|
||||
});
|
||||
it('拖入文件时显示明确落点反馈', () => {
|
||||
render(<WorkspaceOverlays loading={false} hasSnapshot={false} dragActive />);
|
||||
expect(screen.getByText('松开即可导入工程')).toBeVisible();
|
||||
});
|
||||
it('展示格式化状态数据', () => {
|
||||
render(<StatusBar time={1.25} fps={60} stepMs={0.5} memoryMb={10} loaded overBudget={false} />);
|
||||
expect(screen.getByText(/时间 1.250 s/)).toBeVisible();
|
||||
expect(screen.getByText(/WASM 已加载/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,66 @@
|
||||
import {fireEvent,render,screen,within} from '@testing-library/react';
|
||||
import {DiagnosticsDrawer} from './DiagnosticsDrawer';
|
||||
import {ErrorRecoveryPanel} from './ErrorRecoveryPanel';
|
||||
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();});
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { DiagnosticsDrawer } from './DiagnosticsDrawer';
|
||||
import { ErrorRecoveryPanel } from './ErrorRecoveryPanel';
|
||||
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: 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 {ProjectBreadcrumb} from './ProjectBreadcrumb';
|
||||
import {SettingsDialog} from './SettingsDialog';
|
||||
import {LayoutSettingsDialog} from './LayoutSettingsDialog';
|
||||
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');});
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
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,
|
||||
};
|
||||
describe('第四批工作台组件', () => {
|
||||
it('通知中心展示、移除并清空消息', () => {
|
||||
const dismiss = vi.fn(),
|
||||
clear = vi.fn();
|
||||
render(<NotificationCenter items={[item]} onDismiss={dismiss} onClear={clear} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '通知中心' }));
|
||||
expect(screen.getByRole('dialog', { name: '通知中心' })).toHaveTextContent('模型加载完成');
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除通知:模型加载完成' }));
|
||||
expect(dismiss).toHaveBeenCalledWith(1);
|
||||
fireEvent.click(screen.getByText('清空'));
|
||||
expect(clear).toHaveBeenCalled();
|
||||
});
|
||||
it('Toast 自动关闭', () => {
|
||||
vi.useFakeTimers();
|
||||
const close = vi.fn();
|
||||
render(<ToastViewport item={item} onDismiss={close} />);
|
||||
act(() => vi.advanceTimersByTime(4000));
|
||||
expect(close).toHaveBeenCalledWith(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
it('工程面包屑可切换多入口', () => {
|
||||
const select = vi.fn();
|
||||
render(
|
||||
<ProjectBreadcrumb
|
||||
projectName="robot"
|
||||
selectedEntry="models/a.xml"
|
||||
entries={[
|
||||
{ path: 'models/a.xml', label: 'A', format: 'mjcf' },
|
||||
{ path: 'models/b.xml', label: 'B', format: 'mjcf' },
|
||||
]}
|
||||
onSelect={select}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '切换模型入口' }));
|
||||
fireEvent.click(screen.getByRole('option', { name: /B/ }));
|
||||
expect(select).toHaveBeenCalledWith('models/b.xml');
|
||||
});
|
||||
it('模型加载期间禁用入口切换', () => {
|
||||
render(
|
||||
<ProjectBreadcrumb
|
||||
projectName="robot"
|
||||
loading
|
||||
entries={[
|
||||
{ path: 'a.xml', label: 'A', format: 'mjcf' },
|
||||
{ path: 'b.xml', label: 'B', format: 'mjcf' },
|
||||
]}
|
||||
selectedEntry="a.xml"
|
||||
onSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '切换模型入口' })).toBeDisabled();
|
||||
});
|
||||
it('设置和布局弹窗透传现有设置动作', () => {
|
||||
const theme = vi.fn(),
|
||||
preset = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<SettingsDialog
|
||||
open
|
||||
onClose={() => {}}
|
||||
theme="dark"
|
||||
angleUnit="rad"
|
||||
showCollision={false}
|
||||
jointAdvanced={false}
|
||||
forceScale={50}
|
||||
onTheme={theme}
|
||||
onAngleUnit={() => {}}
|
||||
onShowCollision={() => {}}
|
||||
onJointAdvanced={() => {}}
|
||||
onForceScale={() => {}}
|
||||
/>
|
||||
<LayoutSettingsDialog
|
||||
open={false}
|
||||
onClose={() => {}}
|
||||
leftOpen
|
||||
rightOpen
|
||||
onLeftOpen={() => {}}
|
||||
onRightOpen={() => {}}
|
||||
onPreset={preset}
|
||||
onReset={() => {}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('设置主题'), { target: { value: 'light' } });
|
||||
expect(theme).toHaveBeenCalledWith('light');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,78 @@
|
||||
import {Columns3,Focus,PanelLeft,PanelRight,RotateCcw} from 'lucide-react';
|
||||
import {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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function dispatchLayoutWidths(left:number,right:number){window.dispatchEvent(new CustomEvent('mujoco-layout-widths',{detail:{left,right}}));}
|
||||
export function dispatchLayoutWidths(left: number, right: number) {
|
||||
window.dispatchEvent(new CustomEvent('mujoco-layout-widths', { detail: { left, right } }));
|
||||
}
|
||||
|
||||
@@ -1,25 +1,87 @@
|
||||
import {fireEvent,render,screen,waitFor} from '@testing-library/react';
|
||||
import {beforeEach,describe,expect,it,vi} from 'vitest';
|
||||
import {LocalTrainingPanel} from './LocalTrainingPanel';
|
||||
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'}}));
|
||||
vi.stubGlobal('fetch',fetchMock);
|
||||
render(<LocalTrainingPanel onPolicyReady={vi.fn()}/>);
|
||||
fireEvent.click(screen.getByRole('button',{name:'连接'}));
|
||||
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' },
|
||||
}),
|
||||
)
|
||||
.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'});
|
||||
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(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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,77 +1,435 @@
|
||||
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 { 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';
|
||||
|
||||
const ENDPOINT_KEY='mujoco-local-training-endpoint',JOB_KEY='mujoco-local-training-job';
|
||||
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];}
|
||||
|
||||
export function LocalTrainingPanel({onPolicyReady}:{onPolicyReady(file:File):void}){
|
||||
const [endpoint,setEndpoint]=useState(()=>stored(ENDPOINT_KEY,DEFAULT_ENDPOINT));
|
||||
const [server,setServer]=useState<TrainingServerInfo>();
|
||||
const [job,setJob]=useState<TrainingJob>();
|
||||
const [busy,setBusy]=useState(false),[error,setError]=useState<string>();
|
||||
const [taskId,setTaskId]=useState('Unitree-Go2-Flat'),[numEnvs,setNumEnvs]=useState(4096),[maxIterations,setMaxIterations]=useState(2000),[seed,setSeed]=useState(42),[runName,setRunName]=useState('web'),[device,setDevice]=useState<TrainingDevice>('gpu'),[gpuIds,setGpuIds]=useState('0'),[wandbMode,setWandbMode]=useState<WandbMode>('offline');
|
||||
|
||||
const connect=async()=>{
|
||||
setBusy(true);setError(undefined);
|
||||
try{
|
||||
const client=new LocalTrainingClient(endpoint),info=await client.health();
|
||||
setServer(info);try{localStorage.setItem(ENDPOINT_KEY,client.endpoint);}catch{/* 当前会话仍可连接 */}
|
||||
if(info.tasks.length&&!info.tasks.includes(taskId))setTaskId(info.tasks[0]);
|
||||
const remembered=info.activeJobId??stored(JOB_KEY);
|
||||
if(remembered){try{setJob(await client.job(remembered));}catch{try{localStorage.removeItem(JOB_KEY);}catch{/* ignore */}}}
|
||||
if(!info.ready)setError(info.error??'训练服务尚未就绪');
|
||||
}catch(value){setServer(undefined);setError(errorText(value));}
|
||||
finally{setBusy(false);}
|
||||
};
|
||||
|
||||
const jobId=job?.id,jobState=job?.state;
|
||||
useEffect(()=>{
|
||||
if(!jobId||!jobState||!ACTIVE_STATES.has(jobState))return;
|
||||
let disposed=false;
|
||||
const refresh=async()=>{try{const next=await new LocalTrainingClient(endpoint).job(jobId);if(!disposed)setJob(next);}catch(value){if(!disposed)setError(errorText(value));}};
|
||||
const timer=window.setInterval(()=>void refresh(),1500);return()=>{disposed=true;window.clearInterval(timer);};
|
||||
},[endpoint,jobId,jobState]);
|
||||
|
||||
const start=async()=>{
|
||||
setBusy(true);setError(undefined);
|
||||
try{
|
||||
const ids=device==='gpu'?gpuIds.split(/[\s,]+/).filter(Boolean).map(Number):[];
|
||||
if(ids.some(id=>!Number.isInteger(id)||id<0))throw new Error('GPU 编号必须是非负整数');
|
||||
const next=await new LocalTrainingClient(endpoint).start({taskId,numEnvs,maxIterations,seed,runName,device,gpuIds:ids,wandbMode});
|
||||
setJob(next);try{localStorage.setItem(JOB_KEY,next.id);}catch{/* ignore */}
|
||||
}catch(value){setError(errorText(value));}finally{setBusy(false);}
|
||||
};
|
||||
const cancel=async()=>{if(!job)return;setBusy(true);setError(undefined);try{setJob(await new LocalTrainingClient(endpoint).cancel(job.id));}catch(value){setError(errorText(value));}finally{setBusy(false);}};
|
||||
const importResult=async()=>{if(!job)return;setBusy(true);setError(undefined);try{onPolicyReady(await new LocalTrainingClient(endpoint).downloadPolicy(job.id));}catch(value){setError(errorText(value));}finally{setBusy(false);}};
|
||||
const active=Boolean(job&&ACTIVE_STATES.has(job.state));
|
||||
|
||||
return <div>
|
||||
<label className="block text-xs text-text-secondary"><span className="mb-1 block">本地训练服务</span><div className="flex gap-2"><input aria-label="本地训练服务地址" className="field h-7 min-w-0 flex-1 px-2 text-xs text-text-primary" value={endpoint} disabled={active} onChange={event=>setEndpoint(event.target.value)}/><Button icon={<Link className="h-3.5 w-3.5"/>} disabled={busy||active} onClick={()=>void connect()}>连接</Button></div></label>
|
||||
<div className="mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] text-text-tertiary"><span className="flex min-w-0 items-center gap-1.5 truncate"><Server className="h-3.5 w-3.5"/>{server?.trainerRoot??'请先启动本地训练服务'}</span><Badge tone={server?.ready?'success':'warning'}>{server?.ready?'可用':'离线'}</Badge></div>
|
||||
{server?.ready&&!job&&<div className="mt-3 space-y-2">
|
||||
<Field label="训练任务"><Select aria-label="训练任务" className="w-full" value={taskId} onChange={event=>setTaskId(event.target.value)}>{server.tasks.map(task=><option key={task} value={task}>{task}</option>)}</Select></Field>
|
||||
<div className="grid grid-cols-2 gap-2"><NumberField label="并行环境" value={numEnvs} min={1} max={16384} onChange={setNumEnvs}/><NumberField label="训练迭代" value={maxIterations} min={1} max={1000000} onChange={setMaxIterations}/><NumberField label="随机种子" value={seed} min={0} max={2147483647} onChange={setSeed}/><Field label="运行名称"><input aria-label="运行名称" className="field h-7 w-full px-2 text-xs text-text-primary" value={runName} onChange={event=>setRunName(event.target.value)}/></Field></div>
|
||||
<div className="grid grid-cols-2 gap-2"><Field label="计算设备"><Select aria-label="计算设备" className="w-full" value={device} onChange={event=>setDevice(event.target.value as TrainingDevice)}><option value="gpu">GPU</option><option value="cpu">CPU</option></Select></Field><Field label="GPU 编号"><input aria-label="GPU 编号" className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40" value={gpuIds} disabled={device==='cpu'} onChange={event=>setGpuIds(event.target.value)}/></Field></div>
|
||||
<Field label="实验记录"><Select aria-label="W&B 模式" className="w-full" value={wandbMode} onChange={event=>setWandbMode(event.target.value as WandbMode)}><option value="offline">本地离线(默认,无需登录)</option><option value="disabled">完全禁用 W&B</option><option value="online">在线 W&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>;
|
||||
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 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];
|
||||
}
|
||||
|
||||
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>;}
|
||||
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 connect = async () => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const client = new LocalTrainingClient(endpoint, token),
|
||||
info = await client.health();
|
||||
setServer(info);
|
||||
try {
|
||||
localStorage.setItem(ENDPOINT_KEY, client.endpoint);
|
||||
sessionStorage.setItem(TOKEN_KEY, client.token);
|
||||
} catch {
|
||||
/* 当前会话仍可连接 */
|
||||
}
|
||||
if (info.tasks.length && !info.tasks.includes(taskId)) setTaskId(info.tasks[0]);
|
||||
const remembered = info.activeJobId ?? stored(JOB_KEY);
|
||||
if (remembered) {
|
||||
try {
|
||||
const recovered = await client.job(remembered);
|
||||
setJob(recovered);
|
||||
try {
|
||||
localStorage.setItem(JOB_KEY, recovered.id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch {
|
||||
setJob(undefined);
|
||||
try {
|
||||
localStorage.removeItem(JOB_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setJob(undefined);
|
||||
}
|
||||
if (!info.ready) setError(info.error ?? '训练服务尚未就绪');
|
||||
} catch (value) {
|
||||
setServer(undefined);
|
||||
setError(errorText(value));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const jobId = job?.id,
|
||||
jobState = job?.state;
|
||||
useEffect(() => {
|
||||
if (!jobId || !jobState || !ACTIVE_STATES.has(jobState)) return;
|
||||
let disposed = false;
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const next = await new LocalTrainingClient(endpoint, token).job(jobId);
|
||||
if (!disposed) setJob(next);
|
||||
} catch (value) {
|
||||
if (!disposed) setError(errorText(value));
|
||||
}
|
||||
};
|
||||
const timer = window.setInterval(() => void refresh(), 1500);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [endpoint, jobId, jobState, token]);
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const ids =
|
||||
device === 'gpu'
|
||||
? gpuIds
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean)
|
||||
.map(Number)
|
||||
: [];
|
||||
if (ids.some((id) => !Number.isInteger(id) || id < 0))
|
||||
throw new Error('GPU 编号必须是非负整数');
|
||||
const next = await new LocalTrainingClient(endpoint, token).start({
|
||||
taskId,
|
||||
numEnvs,
|
||||
maxIterations,
|
||||
seed,
|
||||
runName,
|
||||
device,
|
||||
gpuIds: ids,
|
||||
wandbMode,
|
||||
});
|
||||
setJob(next);
|
||||
try {
|
||||
localStorage.setItem(JOB_KEY, next.id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch (value) {
|
||||
setError(errorText(value));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const cancel = async () => {
|
||||
if (!job) return;
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
setJob(await new LocalTrainingClient(endpoint, token).cancel(job.id));
|
||||
} catch (value) {
|
||||
setError(errorText(value));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const importResult = async () => {
|
||||
if (!job) return;
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
onPolicyReady(await new LocalTrainingClient(endpoint, token).downloadPolicy(job.id));
|
||||
} catch (value) {
|
||||
setError(errorText(value));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const active = Boolean(job && ACTIVE_STATES.has(job.state));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">本地训练服务</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
aria-label="本地训练服务地址"
|
||||
className="field h-7 min-w-0 flex-1 px-2 text-xs text-text-primary"
|
||||
value={endpoint}
|
||||
onChange={(event) => setEndpoint(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
icon={<Link className="h-3.5 w-3.5" />}
|
||||
disabled={busy || !token.trim()}
|
||||
onClick={() => void connect()}
|
||||
>
|
||||
连接
|
||||
</Button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="mt-2 block text-[10px] text-text-tertiary">
|
||||
<span className="mb-1 block">访问令牌(服务启动时显示)</span>
|
||||
<input
|
||||
aria-label="训练服务访问令牌"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className="field h-7 w-full px-2 text-xs text-text-primary"
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] text-text-tertiary">
|
||||
<span className="flex min-w-0 items-center gap-1.5 truncate">
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
{server?.trainerRoot ?? '请先启动本地训练服务'}
|
||||
</span>
|
||||
<Badge tone={server?.ready ? 'success' : 'warning'}>
|
||||
{server?.ready ? '可用' : '离线'}
|
||||
</Badge>
|
||||
</div>
|
||||
{server?.ready && !job && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<Field label="训练任务">
|
||||
<Select
|
||||
aria-label="训练任务"
|
||||
className="w-full"
|
||||
value={taskId}
|
||||
onChange={(event) => setTaskId(event.target.value)}
|
||||
>
|
||||
{server.tasks.map((task) => (
|
||||
<option key={task} value={task}>
|
||||
{task}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<NumberField
|
||||
label="并行环境"
|
||||
value={numEnvs}
|
||||
min={1}
|
||||
max={16384}
|
||||
onChange={setNumEnvs}
|
||||
/>
|
||||
<NumberField
|
||||
label="训练迭代"
|
||||
value={maxIterations}
|
||||
min={1}
|
||||
max={1000000}
|
||||
onChange={setMaxIterations}
|
||||
/>
|
||||
<NumberField
|
||||
label="随机种子"
|
||||
value={seed}
|
||||
min={0}
|
||||
max={2147483647}
|
||||
onChange={setSeed}
|
||||
/>
|
||||
<Field label="运行名称">
|
||||
<input
|
||||
aria-label="运行名称"
|
||||
className="field h-7 w-full px-2 text-xs text-text-primary"
|
||||
value={runName}
|
||||
onChange={(event) => setRunName(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="计算设备">
|
||||
<Select
|
||||
aria-label="计算设备"
|
||||
className="w-full"
|
||||
value={device}
|
||||
onChange={(event) => setDevice(event.target.value as TrainingDevice)}
|
||||
>
|
||||
<option value="gpu">GPU</option>
|
||||
<option value="cpu">CPU</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="GPU 编号">
|
||||
<input
|
||||
aria-label="GPU 编号"
|
||||
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
|
||||
value={gpuIds}
|
||||
disabled={device === 'cpu'}
|
||||
onChange={(event) => setGpuIds(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="实验记录">
|
||||
<Select
|
||||
aria-label="W&B 模式"
|
||||
className="w-full"
|
||||
value={wandbMode}
|
||||
onChange={(event) => setWandbMode(event.target.value as WandbMode)}
|
||||
>
|
||||
<option value="offline">本地离线(默认,无需登录)</option>
|
||||
<option value="disabled">完全禁用 W&B</option>
|
||||
<option value="online">在线 W&B(需要 API Key)</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
icon={<Play className="h-3.5 w-3.5" />}
|
||||
disabled={busy}
|
||||
onClick={() => void start()}
|
||||
>
|
||||
发起本地训练
|
||||
</Button>
|
||||
<p className="text-[10px] leading-4 text-text-tertiary">
|
||||
训练使用本地 mjlab
|
||||
任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{job && (
|
||||
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-medium text-text-primary" title={job.id}>
|
||||
{job.taskId}
|
||||
</span>
|
||||
<Badge
|
||||
tone={
|
||||
job.state === 'succeeded'
|
||||
? 'success'
|
||||
: job.state === 'failed' || job.state === 'cancelled'
|
||||
? 'warning'
|
||||
: 'accent'
|
||||
}
|
||||
>
|
||||
{stateLabel(job.state)}
|
||||
</Badge>
|
||||
</div>
|
||||
<ProgressBar value={job.progress} label="训练进度" />
|
||||
<div className="mt-2">
|
||||
<PropertyRow label="迭代" value={`${job.iteration} / ${job.maxIterations}`} />
|
||||
<PropertyRow label="状态" value={job.message} />
|
||||
</div>
|
||||
{job.logs.length > 0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-[10px] text-text-secondary">最近日志</summary>
|
||||
<pre className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded bg-app p-2 text-[9px] leading-4 text-text-tertiary">
|
||||
{job.logs.slice(-40).join('\n')}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{active ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
className="col-span-2"
|
||||
icon={<Square className="h-3.5 w-3.5" />}
|
||||
disabled={busy}
|
||||
onClick={() => void cancel()}
|
||||
>
|
||||
停止训练
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
disabled={busy || !job.artifactReady}
|
||||
icon={<Download className="h-3.5 w-3.5" />}
|
||||
onClick={() => void importResult()}
|
||||
>
|
||||
导入策略
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setJob(undefined);
|
||||
try {
|
||||
localStorage.removeItem(JOB_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}}
|
||||
>
|
||||
新建任务
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block text-[10px] text-text-tertiary">
|
||||
<span className="mb-1 block">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function NumberField({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange(value: number): void;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<input
|
||||
aria-label={label}
|
||||
type="number"
|
||||
className="field h-7 w-full px-2 text-xs text-text-primary"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MAP_ASSET_DRAG_MIME, MAP_ASSET_PLACEMENT_MIME } from '../../map/editor/assetCatalog';
|
||||
import { MapAssetLibrary } from './MapAssetLibrary';
|
||||
|
||||
const renderLibrary = (
|
||||
onAdd: (type: string, placementMode: string) => void = () => {},
|
||||
onSelectTerrain: (preset: string) => void = () => {},
|
||||
) =>
|
||||
render(
|
||||
<MapAssetLibrary
|
||||
disabled={false}
|
||||
terrainSize={8}
|
||||
onAdd={onAdd}
|
||||
onSelectTerrain={onSelectTerrain}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe('MapAssetLibrary', () => {
|
||||
it('按所选放置方式添加认证资产', () => {
|
||||
const onAdd = vi.fn();
|
||||
renderLibrary(onAdd);
|
||||
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
|
||||
target: { value: 'gravity' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加基础方盒' }));
|
||||
expect(onAdd).toHaveBeenCalledWith('box', 'gravity');
|
||||
});
|
||||
|
||||
it('拖动资产时写入类型和放置方式', () => {
|
||||
const values = new Map<string, string>();
|
||||
const dataTransfer = {
|
||||
effectAllowed: 'none',
|
||||
setData: (type: string, value: string) => values.set(type, value),
|
||||
} as unknown as DataTransfer;
|
||||
renderLibrary();
|
||||
fireEvent.change(screen.getByLabelText('新增资产放置方式'), {
|
||||
target: { value: 'locked' },
|
||||
});
|
||||
fireEvent.dragStart(document.querySelector('[data-map-asset="ramp"]')!, { dataTransfer });
|
||||
expect(values.get(MAP_ASSET_DRAG_MIME)).toBe('ramp');
|
||||
expect(values.get(MAP_ASSET_PLACEMENT_MIME)).toBe('locked');
|
||||
});
|
||||
|
||||
it('以示意图卡片选择系统参数化地形', () => {
|
||||
const onSelectTerrain = vi.fn();
|
||||
renderLibrary(undefined, onSelectTerrain);
|
||||
expect(screen.getAllByText('8.00 × 8.00 m')).toHaveLength(9);
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加随机粗糙地形' }));
|
||||
expect(onSelectTerrain).toHaveBeenCalledWith('rough');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useState } from 'react';
|
||||
import { BadgeCheck, CheckCircle2, Mountain, Plus } from 'lucide-react';
|
||||
import { Button, Select } from '../../components/ui';
|
||||
import {
|
||||
CERTIFIED_MAP_ASSETS,
|
||||
MAP_ASSET_DRAG_MIME,
|
||||
MAP_ASSET_PLACEMENT_MIME,
|
||||
} from '../../map/editor/assetCatalog';
|
||||
import {
|
||||
MAP_OBJECT_PLACEMENT_LABELS,
|
||||
type EditableMapDocument,
|
||||
type EditableMapObjectType,
|
||||
type MapObjectPlacementMode,
|
||||
} from '../../map/editor/types';
|
||||
import {
|
||||
PHYSICAL_MAP_PRESET_LABELS,
|
||||
SYSTEM_TERRAIN_PRESETS,
|
||||
type SystemTerrainPreset,
|
||||
} from '../../map/types';
|
||||
|
||||
function AssetPreview({ type, color }: { type: EditableMapObjectType; color: string }) {
|
||||
const shape =
|
||||
type === 'cylinder'
|
||||
? 'h-9 w-9 rounded-full'
|
||||
: type === 'capsule'
|
||||
? 'h-10 w-6 rounded-full'
|
||||
: type === 'ramp'
|
||||
? 'h-0 w-0 border-b-[34px] border-l-[48px] border-l-transparent'
|
||||
: type === 'stairs'
|
||||
? 'h-9 w-11 [clip-path:polygon(0_100%,0_66%,34%_66%,34%_33%,67%_33%,67%_0,100%_0,100%_100%)]'
|
||||
: 'h-9 w-9 rounded-sm';
|
||||
return (
|
||||
<div className="flex h-16 items-center justify-center rounded bg-black/10" aria-hidden="true">
|
||||
<div
|
||||
className={`${shape} shadow-[0_6px_14px_rgba(0,0,0,0.22)]`}
|
||||
style={type === 'ramp' ? { borderBottomColor: color } : { backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TerrainPreview({ preset }: { preset: SystemTerrainPreset }) {
|
||||
const grid = (
|
||||
<g stroke="currentColor" strokeWidth="0.45" opacity="0.18">
|
||||
<path d="M14 50 60 23l46 27-46 27z" />
|
||||
<path d="m26 57 46-27m-34 34 46-27m-58 6 46 27m-34-34 46 27" />
|
||||
</g>
|
||||
);
|
||||
let shape;
|
||||
if (preset === 'discrete_obstacles')
|
||||
shape = (
|
||||
<g fill="currentColor" fillOpacity="0.16" stroke="currentColor">
|
||||
<path d="m31 48 9-5 9 5-9 5zM40 53V39l9-5v14M40 39l-9-5v14" />
|
||||
<path d="m55 39 8-5 8 5-8 5zM63 44V27l8-5v17M63 27l-8-5v17" />
|
||||
<path d="m77 51 10-6 10 6-10 6zM87 57V38l10-6v19M87 38l-10-6v19" />
|
||||
</g>
|
||||
);
|
||||
else if (preset === 'gap')
|
||||
shape = (
|
||||
<g fill="currentColor" fillOpacity="0.12" stroke="currentColor">
|
||||
<path d="m21 48 31-18 15 9-31 18zM69 40l15 9 17-10-15-9z" />
|
||||
<path d="m36 57 31-18v8L36 65zM69 40l17-10v8L69 48z" fillOpacity="0.24" />
|
||||
</g>
|
||||
);
|
||||
else if (preset === 'inverted_pyramid_stairs' || preset === 'pit')
|
||||
shape = (
|
||||
<g fill="currentColor" fillOpacity="0.08" stroke="currentColor">
|
||||
<path d="m22 47 38-22 38 22-38 22z" />
|
||||
<path d="m32 47 28-16 28 16-28 16z" />
|
||||
<path d="m43 47 17-10 17 10-17 10z" fillOpacity="0.2" />
|
||||
{preset === 'inverted_pyramid_stairs' && (
|
||||
<path d="M32 47v7m11-7v10m17-10v16m17-16v10m11-10v7" />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
else if (preset === 'pyramid_stairs')
|
||||
shape = (
|
||||
<g fill="currentColor" stroke="currentColor" strokeLinejoin="round">
|
||||
<path d="m25 52 12 7 52-30-12-7z" fillOpacity="0.1" />
|
||||
<path d="m37 45 12 7 40-23-12-7z" fillOpacity="0.17" />
|
||||
<path d="m49 38 12 7 28-16-12-7z" fillOpacity="0.24" />
|
||||
<path d="m61 31 12 7 16-9-12-7z" fillOpacity="0.32" />
|
||||
</g>
|
||||
);
|
||||
else if (preset === 'rails')
|
||||
shape = (
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round">
|
||||
<path d="m26 58 53-31m15 40 12-7" strokeWidth="2.3" opacity="0.25" />
|
||||
<path d="m26 58 53-31m15 40 12-7M33 58V45m14 5V37m30 12V27m20 39V53" strokeWidth="1.2" />
|
||||
</g>
|
||||
);
|
||||
else if (preset === 'rough')
|
||||
shape = (
|
||||
<path
|
||||
d="m20 54 10-18 9 9 10-22 11 17 10-13 10 20 10-8 10 15-40 18z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.16"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
);
|
||||
else if (preset === 'stepping_stones')
|
||||
shape = (
|
||||
<g fill="currentColor" fillOpacity="0.2" stroke="currentColor">
|
||||
<path d="m27 52 10-6 10 6-10 6zM46 39l10-6 10 6-10 6zM66 54l10-6 10 6-10 6zM83 36l10-6 10 6-10 6z" />
|
||||
</g>
|
||||
);
|
||||
else
|
||||
shape = (
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round">
|
||||
<path d="M19 48c10-17 20-17 30 0s20 17 30 0 20-17 29-2" strokeWidth="5" opacity="0.16" />
|
||||
<path d="M19 48c10-17 20-17 30 0s20 17 30 0 20-17 29-2" strokeWidth="1.4" />
|
||||
<path d="M25 59c10-17 20-17 30 0s20 17 30 0 15-13 23-7" opacity="0.55" />
|
||||
</g>
|
||||
);
|
||||
return (
|
||||
<svg viewBox="0 0 120 80" className="h-[76px] w-full text-accent" aria-hidden="true">
|
||||
{grid}
|
||||
{shape}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AddAction({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-accent">
|
||||
{label}
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-accent text-white">
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapAssetLibrary({
|
||||
disabled,
|
||||
terrainSize,
|
||||
document,
|
||||
onAdd,
|
||||
onSelectTerrain,
|
||||
onSelectObject,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
terrainSize: number;
|
||||
document?: EditableMapDocument | null;
|
||||
onAdd: (
|
||||
type: EditableMapObjectType,
|
||||
placementMode: MapObjectPlacementMode,
|
||||
) => void | Promise<void>;
|
||||
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
||||
onSelectObject?: (id: string) => void;
|
||||
}) {
|
||||
const [placementMode, setPlacementMode] = useState<MapObjectPlacementMode>('auto_ground');
|
||||
return (
|
||||
<section aria-label="认证资产" className="space-y-3 p-3">
|
||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
|
||||
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
|
||||
<span>场景结构</span>
|
||||
<span className="technical-value text-[10px] text-text-tertiary">
|
||||
{document?.objects.length ?? 0} 个对象
|
||||
</span>
|
||||
</div>
|
||||
{document?.objects.length ? (
|
||||
<div className="mt-2 max-h-36 space-y-0.5 overflow-auto panel-scroll">
|
||||
{document.objects.map((object) => (
|
||||
<button
|
||||
key={object.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] text-text-secondary hover:bg-element-hover hover:text-text-primary"
|
||||
onClick={() => onSelectObject?.(object.id)}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
|
||||
<span className="min-w-0 flex-1 truncate">{object.name}</span>
|
||||
<span className="text-[9px] text-text-tertiary">{object.type}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
|
||||
选择工程地图后,这里显示场景对象层级;可从下方资产库创建场景。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
|
||||
<BadgeCheck className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
||||
认证资产
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||
点击添加,或按住资产拖到画布落位。
|
||||
</p>
|
||||
</div>
|
||||
<label className="w-36 shrink-0 text-[10px] text-text-secondary">
|
||||
放置方式
|
||||
<Select
|
||||
aria-label="新增资产放置方式"
|
||||
className="mt-1 w-full"
|
||||
value={placementMode}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setPlacementMode(event.target.value as MapObjectPlacementMode)}
|
||||
>
|
||||
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{CERTIFIED_MAP_ASSETS.map((asset) => (
|
||||
<article
|
||||
key={asset.type}
|
||||
draggable={!disabled}
|
||||
data-map-asset={asset.type}
|
||||
className={`overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
|
||||
onDragStart={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData(MAP_ASSET_DRAG_MIME, asset.type);
|
||||
event.dataTransfer.setData(MAP_ASSET_PLACEMENT_MIME, placementMode);
|
||||
event.dataTransfer.setData('text/plain', asset.name);
|
||||
}}
|
||||
>
|
||||
<div className="p-2 pb-0">
|
||||
<AssetPreview type={asset.type} color={asset.color} />
|
||||
</div>
|
||||
<div className="border-t border-border-subtle p-2">
|
||||
<div className="truncate text-[11px] font-semibold text-text-primary">
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[9px] text-text-tertiary">
|
||||
{asset.description}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-2 w-full"
|
||||
disabled={disabled}
|
||||
aria-label={`添加${asset.name}`}
|
||||
onClick={() => void onAdd(asset.type, placementMode)}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" aria-hidden="true" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-subtle pt-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
|
||||
<Mountain className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
||||
系统参数化地形
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||
本地确定性生成;点击“添加”后在右侧“地图”调整尺寸、难度和随机种子。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SYSTEM_TERRAIN_PRESETS.map((preset) => (
|
||||
<article
|
||||
key={preset}
|
||||
data-system-terrain={preset}
|
||||
className={`group relative overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'hover:border-accent'}`}
|
||||
>
|
||||
<div className="absolute right-2 top-2 z-10 rounded-full bg-panel-bg/90 p-0.5 text-success">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="px-1 pt-1">
|
||||
<TerrainPreview preset={preset} />
|
||||
</div>
|
||||
<div className="border-t border-border-subtle px-2 pb-2 pt-1.5">
|
||||
<div className="truncate text-[11px] font-semibold text-text-primary">
|
||||
{PHYSICAL_MAP_PRESET_LABELS[preset]}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[9px] text-text-tertiary">
|
||||
{terrainSize.toFixed(2)} × {terrainSize.toFixed(2)} m
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 flex w-full justify-end rounded outline-none focus-visible:ring-2 focus-visible:ring-accent/30"
|
||||
disabled={disabled}
|
||||
aria-label={`添加${PHYSICAL_MAP_PRESET_LABELS[preset]}`}
|
||||
onClick={() => onSelectTerrain(preset)}
|
||||
>
|
||||
<AddAction label="添加" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { EditableMapDocument } from '../../map/editor/types';
|
||||
import { MapEditorPanel } from './MapEditorPanel';
|
||||
|
||||
const interactionProps = {
|
||||
onConvert: async () => true,
|
||||
canConvert: true,
|
||||
onBindInteraction: () => {},
|
||||
onSelectPreview: () => {},
|
||||
onTransformMode: () => {},
|
||||
onSnapping: () => {},
|
||||
};
|
||||
const document: EditableMapDocument = {
|
||||
schemaVersion: 1,
|
||||
mapId: 'map',
|
||||
revision: 0,
|
||||
objects: [],
|
||||
spawnPoints: [],
|
||||
};
|
||||
|
||||
describe('MapEditorPanel', () => {
|
||||
it('新增并编辑对象后应用递增 revision', async () => {
|
||||
const apply = vi.fn(async (documentValue: EditableMapDocument) => Boolean(documentValue)),
|
||||
preview = vi.fn();
|
||||
render(
|
||||
<MapEditorPanel
|
||||
{...interactionProps}
|
||||
document={document}
|
||||
loading={false}
|
||||
onPreview={preview}
|
||||
onApply={apply}
|
||||
onExport={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增' }));
|
||||
fireEvent.change(screen.getByLabelText('对象位置X'), { target: { value: '2' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
await waitFor(() => expect(apply).toHaveBeenCalled());
|
||||
expect(apply.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
revision: 1,
|
||||
objects: [
|
||||
expect.objectContaining({ pose: expect.objectContaining({ position: [2, 0, 0.5] }) }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('锁定放置方式后禁用位姿编辑', () => {
|
||||
render(
|
||||
<MapEditorPanel
|
||||
{...interactionProps}
|
||||
document={document}
|
||||
loading={false}
|
||||
onPreview={() => {}}
|
||||
onApply={async () => true}
|
||||
onExport={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增' }));
|
||||
fireEvent.change(screen.getByLabelText('对象放置方式'), { target: { value: 'locked' } });
|
||||
expect(screen.getByLabelText('对象位置X')).toBeDisabled();
|
||||
expect(screen.getByLabelText('对象绕Z旋转')).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '对齐地面' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('编辑出生点并包含在应用文档中', async () => {
|
||||
const apply = vi.fn(async (value: EditableMapDocument) => Boolean(value));
|
||||
render(
|
||||
<MapEditorPanel
|
||||
{...interactionProps}
|
||||
document={document}
|
||||
loading={false}
|
||||
onPreview={() => {}}
|
||||
onApply={apply}
|
||||
onExport={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增出生点' }));
|
||||
const name = screen.getByLabelText(/出生点名称/);
|
||||
fireEvent.change(name, { target: { value: '装卸区' } });
|
||||
const yaw = screen.getByLabelText(/出生点.*朝向/);
|
||||
fireEvent.change(yaw, { target: { value: '90' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
await waitFor(() => expect(apply).toHaveBeenCalled());
|
||||
expect(apply.mock.calls[0][0].spawnPoints[0]).toMatchObject({ name: '装卸区', yawDeg: 90 });
|
||||
});
|
||||
|
||||
it('没有 authoring 文档时可请求创建可编辑副本', async () => {
|
||||
const convert = vi.fn(async () => true);
|
||||
render(
|
||||
<MapEditorPanel
|
||||
{...interactionProps}
|
||||
onConvert={convert}
|
||||
document={null}
|
||||
loading={false}
|
||||
onPreview={() => {}}
|
||||
onApply={async () => true}
|
||||
onExport={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/保持只读/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建可编辑副本' }));
|
||||
await waitFor(() => expect(convert).toHaveBeenCalledOnce());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,597 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Select } from '../../components/ui';
|
||||
import { MapEditSession } from '../../map/editor/MapEditSession';
|
||||
import {
|
||||
editableObjectGroundHeight,
|
||||
MAP_OBJECT_PLACEMENT_LABELS,
|
||||
type EditableMapDocument,
|
||||
type EditableMapObject,
|
||||
type EditableMapObjectType,
|
||||
type MapEditorInteractionCallbacks,
|
||||
type MapEditorTransformMode,
|
||||
type MapObjectPlacementMode,
|
||||
} from '../../map/editor/types';
|
||||
|
||||
const labels: Record<EditableMapObjectType, string> = {
|
||||
box: '方盒',
|
||||
cylinder: '圆柱',
|
||||
capsule: '胶囊',
|
||||
ramp: '坡道',
|
||||
stairs: '楼梯',
|
||||
};
|
||||
function yawDegrees(object: EditableMapObject): number {
|
||||
const [w, x, y, z] = object.pose.quaternion;
|
||||
return (Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180) / Math.PI;
|
||||
}
|
||||
function scaledParameters(
|
||||
object: EditableMapObject,
|
||||
scale: [number, number, number],
|
||||
): Record<string, number> {
|
||||
const [sx, sy, sz] = scale.map((value) => Math.max(0.001, Math.abs(value))) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
const p = object.parameters;
|
||||
if (object.type === 'box')
|
||||
return { sizeX: p.sizeX * sx, sizeY: p.sizeY * sy, sizeZ: p.sizeZ * sz };
|
||||
if (object.type === 'cylinder')
|
||||
return { radius: p.radius * Math.max(sx, sy), height: p.height * sz };
|
||||
if (object.type === 'capsule')
|
||||
return { radius: p.radius * Math.max(sx, sy), length: p.length * sz };
|
||||
if (object.type === 'ramp')
|
||||
return {
|
||||
length: p.length * sx,
|
||||
width: p.width * sy,
|
||||
rise: p.rise * sz,
|
||||
thickness: p.thickness * sz,
|
||||
};
|
||||
return {
|
||||
stepDepth: p.stepDepth * sx,
|
||||
width: p.width * sy,
|
||||
stepHeight: p.stepHeight * sz,
|
||||
count: p.count,
|
||||
};
|
||||
}
|
||||
export function MapEditorPanel({
|
||||
document,
|
||||
loading,
|
||||
onPreview,
|
||||
onApply,
|
||||
onExport,
|
||||
onConvert,
|
||||
canConvert,
|
||||
onBindInteraction,
|
||||
onSelectPreview,
|
||||
onTransformMode,
|
||||
onSnapping,
|
||||
}: {
|
||||
document: EditableMapDocument | null;
|
||||
loading: boolean;
|
||||
onPreview: (document: EditableMapDocument | null) => void;
|
||||
onApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||
onExport: () => void;
|
||||
onConvert: () => Promise<boolean>;
|
||||
canConvert: boolean;
|
||||
onBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
||||
onSelectPreview: (id: string | null) => void;
|
||||
onTransformMode: (mode: MapEditorTransformMode) => void;
|
||||
onSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
||||
}) {
|
||||
const session = useMemo(() => (document ? new MapEditSession(document) : null), [document]);
|
||||
const [, render] = useState(0),
|
||||
[selected, setSelected] = useState<string>(),
|
||||
[selectedType, setSelectedType] = useState<EditableMapObjectType>('box'),
|
||||
[transformMode, setTransformMode] = useState<MapEditorTransformMode>('translate'),
|
||||
[snapping, setSnapping] = useState(true),
|
||||
[converting, setConverting] = useState(false);
|
||||
const refresh = () => {
|
||||
render((value) => value + 1);
|
||||
onPreview(session?.document ?? null);
|
||||
};
|
||||
useEffect(() => {
|
||||
onPreview(session?.document ?? null);
|
||||
return () => onPreview(null);
|
||||
}, [session, onPreview]);
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
onBindInteraction(null);
|
||||
return;
|
||||
}
|
||||
const callbacks: MapEditorInteractionCallbacks = {
|
||||
onSelect: (id) => setSelected(id ?? undefined),
|
||||
onTransform: ({ id, position, quaternion, scale }) => {
|
||||
const object = session.document.objects.find((item) => item.id === id);
|
||||
if (!object) return;
|
||||
session.update(id, {
|
||||
pose: { position, quaternion },
|
||||
parameters: scaledParameters(object, scale),
|
||||
});
|
||||
setSelected(id);
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
},
|
||||
onAddAsset: (type, position, placementMode) => {
|
||||
const object = session.addAsset(type, position, placementMode);
|
||||
setSelected(object.id);
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
onSelectPreview(object.id);
|
||||
},
|
||||
};
|
||||
onBindInteraction(callbacks);
|
||||
return () => onBindInteraction(null);
|
||||
}, [session, onBindInteraction, onPreview, onSelectPreview]);
|
||||
useEffect(() => {
|
||||
onTransformMode(transformMode);
|
||||
onSnapping(snapping ? 0.1 : null, snapping ? 5 : null);
|
||||
}, [transformMode, snapping, onTransformMode, onSnapping]);
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey)
|
||||
return;
|
||||
const key = event.key.toLowerCase();
|
||||
if ((event.ctrlKey || event.metaKey) && key === 'z') {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) session.redo();
|
||||
else session.undo();
|
||||
if (selected && !session.document.objects.some((object) => object.id === selected)) {
|
||||
setSelected(undefined);
|
||||
onSelectPreview(null);
|
||||
}
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
} else if ((event.ctrlKey || event.metaKey) && key === 'y') {
|
||||
event.preventDefault();
|
||||
session.redo();
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
} else if (!event.ctrlKey && !event.metaKey && key === 'w') setTransformMode('translate');
|
||||
else if (!event.ctrlKey && !event.metaKey && key === 'e') setTransformMode('rotate');
|
||||
else if (!event.ctrlKey && !event.metaKey && key === 's') setTransformMode('scale');
|
||||
else if (event.key === 'Escape') {
|
||||
setSelected(undefined);
|
||||
onSelectPreview(null);
|
||||
} else if ((event.key === 'Delete' || event.key === 'Backspace') && selected) {
|
||||
event.preventDefault();
|
||||
session.remove(selected);
|
||||
setSelected(undefined);
|
||||
render((value) => value + 1);
|
||||
onPreview(session.document);
|
||||
onSelectPreview(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', keydown);
|
||||
return () => window.removeEventListener('keydown', keydown);
|
||||
}, [session, selected, onPreview, onSelectPreview]);
|
||||
if (!session)
|
||||
return (
|
||||
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-tertiary">
|
||||
<p>当前地图没有 V3 authoring.source,保持只读。</p>
|
||||
<p className="text-[10px] leading-relaxed">
|
||||
仅由 box、cylinder、capsule 组成且不含资产、材质或碰撞过滤的静态 MJCF
|
||||
可以安全转换;遇到无法表达的语义会整体拒绝,不会静默丢失内容。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={loading || converting || !canConvert}
|
||||
onClick={() => {
|
||||
setConverting(true);
|
||||
void onConvert().finally(() => setConverting(false));
|
||||
}}
|
||||
>
|
||||
{converting ? '正在转换…' : '创建可编辑副本'}
|
||||
</Button>
|
||||
<Button disabled={loading || converting} onClick={onExport}>
|
||||
导出原地图 ZIP
|
||||
</Button>
|
||||
</div>
|
||||
{!canConvert && <p className="text-warning">该地图没有 MJCF 物理层,无法转换。</p>}
|
||||
</div>
|
||||
);
|
||||
const current = session.document;
|
||||
const active = current.objects.find((object) => object.id === selected);
|
||||
const poseLocked = active?.placementMode === 'locked';
|
||||
const updatePosition = (index: number, value: number) => {
|
||||
if (!active || !Number.isFinite(value)) return;
|
||||
const position = [...active.pose.position] as [number, number, number];
|
||||
position[index] = value;
|
||||
session.update(active.id, { pose: { ...active.pose, position } });
|
||||
refresh();
|
||||
};
|
||||
const updateParameter = (key: string, value: number) => {
|
||||
if (!active || !Number.isFinite(value) || value <= 0) return;
|
||||
session.update(active.id, {
|
||||
parameters: { ...active.parameters, [key]: key === 'count' ? Math.round(value) : value },
|
||||
});
|
||||
refresh();
|
||||
};
|
||||
const updateSpawnPosition = (
|
||||
id: string,
|
||||
position: [number, number, number],
|
||||
index: number,
|
||||
value: number,
|
||||
) => {
|
||||
if (!Number.isFinite(value)) return;
|
||||
const next = [...position] as [number, number, number];
|
||||
next[index] = value;
|
||||
session.updateSpawn(id, { position: next });
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 rounded border border-border-subtle p-2">
|
||||
<Button
|
||||
variant={transformMode === 'translate' ? 'primary' : 'secondary'}
|
||||
aria-pressed={transformMode === 'translate'}
|
||||
disabled={loading}
|
||||
onClick={() => setTransformMode('translate')}
|
||||
>
|
||||
移动工具 W
|
||||
</Button>
|
||||
<Button
|
||||
variant={transformMode === 'rotate' ? 'primary' : 'secondary'}
|
||||
aria-pressed={transformMode === 'rotate'}
|
||||
disabled={loading}
|
||||
onClick={() => setTransformMode('rotate')}
|
||||
>
|
||||
旋转工具 E
|
||||
</Button>
|
||||
<Button
|
||||
variant={transformMode === 'scale' ? 'primary' : 'secondary'}
|
||||
aria-pressed={transformMode === 'scale'}
|
||||
disabled={loading}
|
||||
onClick={() => setTransformMode('scale')}
|
||||
>
|
||||
缩放工具 S
|
||||
</Button>
|
||||
<label className="col-span-3 flex items-center justify-between text-[11px] text-text-secondary">
|
||||
<span>吸附(移动 0.1 m / 旋转 5°)</span>
|
||||
<input
|
||||
aria-label="地图编辑吸附"
|
||||
type="checkbox"
|
||||
checked={snapping}
|
||||
disabled={loading}
|
||||
onChange={(event) => setSnapping(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<p className="col-span-3 text-[10px] text-text-tertiary">
|
||||
在视口点击对象后拖动操纵轴;只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
aria-label="新增地图对象类型"
|
||||
className="min-w-0 flex-1"
|
||||
value={selectedType}
|
||||
disabled={loading}
|
||||
onChange={(event) => setSelectedType(event.target.value as EditableMapObjectType)}
|
||||
>
|
||||
{(Object.keys(labels) as EditableMapObjectType[]).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{labels[type]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
const object = session.add(selectedType);
|
||||
setSelected(object.id);
|
||||
refresh();
|
||||
onSelectPreview(object.id);
|
||||
}}
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-36 space-y-1 overflow-auto" aria-label="地图对象列表">
|
||||
{current.objects.map((object) => (
|
||||
<button
|
||||
key={object.id}
|
||||
type="button"
|
||||
className={`w-full rounded border px-2 py-1 text-left text-xs ${selected === object.id ? 'border-accent bg-accent/10' : 'border-border-subtle'}`}
|
||||
onClick={() => {
|
||||
setSelected(object.id);
|
||||
onSelectPreview(object.id);
|
||||
}}
|
||||
>
|
||||
{object.name} · {labels[object.type]}
|
||||
{object.placementMode === 'locked' ? ' · 已锁定' : ''}
|
||||
</button>
|
||||
))}
|
||||
{!current.objects.length && <div className="text-xs text-text-tertiary">暂无对象</div>}
|
||||
</div>
|
||||
{active && (
|
||||
<div className="space-y-2 rounded border border-border-subtle p-2">
|
||||
<label className="block text-[11px] text-text-secondary">
|
||||
名称
|
||||
<input
|
||||
aria-label="对象名称"
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
value={active.name}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value.trim()) return;
|
||||
session.update(active.id, { name: event.target.value });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-[11px] text-text-secondary">
|
||||
放置方式
|
||||
<Select
|
||||
aria-label="对象放置方式"
|
||||
className="mt-1 w-full"
|
||||
value={active.placementMode}
|
||||
disabled={loading}
|
||||
onChange={(event) => {
|
||||
session.update(active.id, {
|
||||
placementMode: event.target.value as MapObjectPlacementMode,
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map(
|
||||
(mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</Select>
|
||||
<span className="mt-1 block text-[9px] leading-3.5 text-text-tertiary">
|
||||
{active.placementMode === 'auto_ground'
|
||||
? '始终将对象底部对齐 z=0。'
|
||||
: active.placementMode === 'gravity'
|
||||
? '沿世界 -Z 落到 XY 范围内最高的静态承载面。'
|
||||
: '保持当前位姿,并禁用表单和视口变换。'}
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{active.pose.position.map((value, index) => (
|
||||
<label key={index} className="text-[11px] text-text-secondary">
|
||||
{['X', 'Y', 'Z'][index]}
|
||||
<input
|
||||
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={value}
|
||||
disabled={loading || poseLocked || index === 2}
|
||||
onChange={(event) => updatePosition(index, Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="block text-[11px] text-text-secondary">
|
||||
绕 Z 旋转(°)
|
||||
<input
|
||||
aria-label="对象绕Z旋转"
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
step="1"
|
||||
value={Number(yawDegrees(active).toFixed(4))}
|
||||
disabled={loading || poseLocked}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (!Number.isFinite(value)) return;
|
||||
const half = (value * Math.PI) / 360;
|
||||
session.update(active.id, {
|
||||
pose: { ...active.pose, quaternion: [Math.cos(half), 0, 0, Math.sin(half)] },
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(active.parameters).map(([key, value]) => (
|
||||
<label key={key} className="text-[11px] text-text-secondary">
|
||||
{key}
|
||||
<input
|
||||
aria-label={`对象参数${key}`}
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
min="0.001"
|
||||
step={key === 'count' ? 1 : 0.1}
|
||||
value={value}
|
||||
onChange={(event) => updateParameter(key, Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center justify-between text-[11px] text-text-secondary">
|
||||
<span>启用对象</span>
|
||||
<input
|
||||
aria-label="启用地图对象"
|
||||
type="checkbox"
|
||||
checked={active.enabled}
|
||||
onChange={(event) => {
|
||||
session.update(active.id, { enabled: event.target.checked });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={loading || poseLocked}
|
||||
onClick={() => {
|
||||
session.update(active.id, {
|
||||
pose: {
|
||||
...active.pose,
|
||||
position: [
|
||||
active.pose.position[0],
|
||||
active.pose.position[1],
|
||||
editableObjectGroundHeight(active),
|
||||
],
|
||||
},
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
对齐地面
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
const copy = session.duplicate(active.id);
|
||||
setSelected(copy.id);
|
||||
refresh();
|
||||
onSelectPreview(copy.id);
|
||||
}}
|
||||
>
|
||||
复制对象
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
session.remove(active.id);
|
||||
setSelected(undefined);
|
||||
refresh();
|
||||
onSelectPreview(null);
|
||||
}}
|
||||
>
|
||||
删除对象
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 rounded border border-border-subtle p-2">
|
||||
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
|
||||
<span>出生点</span>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
session.addSpawn();
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
新增出生点
|
||||
</Button>
|
||||
</div>
|
||||
{current.spawnPoints.map((spawn) => (
|
||||
<div key={spawn.id} className="space-y-2 rounded bg-panel-muted p-2">
|
||||
<input
|
||||
aria-label={`出生点名称 ${spawn.id}`}
|
||||
className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs"
|
||||
value={spawn.name}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value.trim()) return;
|
||||
session.updateSpawn(spawn.id, { name: event.target.value });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{spawn.position.map((value, index) => (
|
||||
<label key={index} className="text-[10px] text-text-secondary">
|
||||
{['X', 'Y', 'Z'][index]}
|
||||
<input
|
||||
aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`}
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={value}
|
||||
onChange={(event) =>
|
||||
updateSpawnPosition(
|
||||
spawn.id,
|
||||
spawn.position,
|
||||
index,
|
||||
Number(event.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="text-[10px] text-text-secondary">
|
||||
Yaw°
|
||||
<input
|
||||
aria-label={`出生点${spawn.id}朝向`}
|
||||
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
||||
type="number"
|
||||
step="1"
|
||||
value={spawn.yawDeg}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (!Number.isFinite(value)) return;
|
||||
session.updateSpawn(spawn.id, { yawDeg: value });
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
session.removeSpawn(spawn.id);
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
删除出生点
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!current.spawnPoints.length && (
|
||||
<div className="text-[11px] text-text-tertiary">暂无出生点</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
disabled={loading || !session.canUndo}
|
||||
onClick={() => {
|
||||
session.undo();
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
撤销
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading || !session.canRedo}
|
||||
onClick={() => {
|
||||
session.redo();
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
重做
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading || !session.dirty}
|
||||
onClick={() => {
|
||||
session.discard();
|
||||
setSelected(undefined);
|
||||
refresh();
|
||||
onSelectPreview(null);
|
||||
}}
|
||||
>
|
||||
放弃草稿
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={onExport}>
|
||||
导出地图 ZIP
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={loading || !session.dirty}
|
||||
onClick={async () => {
|
||||
const latest = session.document;
|
||||
const candidate = { ...latest, revision: latest.revision + 1 };
|
||||
if (await onApply(candidate)) onPreview(null);
|
||||
else onPreview(session.document);
|
||||
}}
|
||||
>
|
||||
应用并重新编译
|
||||
</Button>
|
||||
{session.dirty && (
|
||||
<div role="status" className="text-xs text-warning">
|
||||
地图草稿尚未应用
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,141 @@
|
||||
import {useEffect,useRef} from 'react';
|
||||
import {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;}
|
||||
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>;}
|
||||
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;
|
||||
}
|
||||
const icons = { success: CheckCircle2, warning: TriangleAlert, danger: XCircle, info: Info };
|
||||
export function NotificationCenter({
|
||||
items,
|
||||
onDismiss,
|
||||
onClear,
|
||||
onOpenLog,
|
||||
}: {
|
||||
items: WorkbenchNotification[];
|
||||
onDismiss: (id: number) => void;
|
||||
onClear: () => void;
|
||||
onOpenLog?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Popover
|
||||
label="通知中心"
|
||||
trigger={({ open, toggle }) => (
|
||||
<IconButton tooltip="通知中心" aria-label="通知中心" aria-expanded={open} onClick={toggle}>
|
||||
<Bell className="h-4 w-4" />
|
||||
{items.length > 0 && (
|
||||
<span className="absolute right-0 top-0 h-1.5 w-1.5 rounded-full bg-warning" />
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="w-80 overflow-hidden rounded-lg border border-border bg-surface-elevated shadow-xl">
|
||||
<header className="flex h-9 items-center justify-between border-b border-border px-3">
|
||||
<h2 className="text-xs font-semibold">通知</h2>
|
||||
<div className="flex gap-2">
|
||||
{onOpenLog && (
|
||||
<button
|
||||
className="text-[10px] text-accent"
|
||||
onClick={() => {
|
||||
close();
|
||||
onOpenLog();
|
||||
}}
|
||||
>
|
||||
事件日志
|
||||
</button>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
className="flex items-center gap-1 text-[10px] text-text-tertiary hover:text-danger"
|
||||
onClick={onClear}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="panel-scroll max-h-80 overflow-auto">
|
||||
{items.length ? (
|
||||
items.map((item) => {
|
||||
const Icon = icons[item.tone];
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className="flex gap-2 border-b border-border px-3 py-2.5 last:border-0"
|
||||
>
|
||||
<Icon
|
||||
className={`mt-0.5 h-4 w-4 shrink-0 ${item.tone === 'success' ? 'text-success' : item.tone === 'warning' ? 'text-warning' : item.tone === 'danger' ? 'text-danger' : 'text-accent'}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-xs font-medium">{item.title}</h3>
|
||||
<Badge>
|
||||
{new Date(item.at).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
{item.detail && (
|
||||
<p className="mt-1 line-clamp-3 text-[10px] leading-4 text-text-tertiary">
|
||||
{item.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<IconButton
|
||||
aria-label={`移除通知:${item.title}`}
|
||||
tooltip="移除"
|
||||
onClick={() => onDismiss(item.id)}
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="p-6 text-center text-xs text-text-tertiary">当前没有通知</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
export function ToastViewport({
|
||||
item,
|
||||
onDismiss,
|
||||
}: {
|
||||
item?: WorkbenchNotification;
|
||||
onDismiss: (id: number) => void;
|
||||
}) {
|
||||
const dismissRef = useRef(onDismiss);
|
||||
useEffect(() => {
|
||||
dismissRef.current = onDismiss;
|
||||
}, [onDismiss]);
|
||||
useEffect(() => {
|
||||
if (!item) return;
|
||||
const timer = window.setTimeout(() => dismissRef.current(item.id), 4000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [item]);
|
||||
if (!item) return null;
|
||||
const Icon = icons[item.tone];
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="pointer-events-auto absolute right-4 top-4 z-30 flex w-80 gap-2 rounded-lg border border-border bg-surface-elevated p-3 shadow-xl"
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium">{item.title}</p>
|
||||
{item.detail && (
|
||||
<p className="mt-1 line-clamp-2 text-[10px] text-text-tertiary">{item.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,68 @@
|
||||
import {Activity,ChevronUp,Cpu,MemoryStick,TriangleAlert} from 'lucide-react';
|
||||
import {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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DEFAULT_PHYSICAL_MAP_CONFIG } from '../../map/types';
|
||||
import { PhysicalMapPanel } from './PhysicalMapPanel';
|
||||
|
||||
const common = {
|
||||
maps: [],
|
||||
rootBodies: ['robot'],
|
||||
loading: false,
|
||||
nativeUrdf: false,
|
||||
showVisualMap: true,
|
||||
showMapCollision: false,
|
||||
editorDocument: null,
|
||||
onEditorPreview: () => {},
|
||||
onEditorApply: async () => true,
|
||||
onEditorExport: () => {},
|
||||
onEditorConvert: async () => true,
|
||||
onEditorBindInteraction: () => {},
|
||||
onEditorSelect: () => {},
|
||||
onEditorTransformMode: () => {},
|
||||
onEditorSnapping: () => {},
|
||||
onMapDisplay: () => {},
|
||||
};
|
||||
|
||||
describe('PhysicalMapPanel', () => {
|
||||
it('选择楼梯参数后请求重新编译', () => {
|
||||
const onApply = vi.fn();
|
||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
||||
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'stairs' } });
|
||||
fireEvent.change(screen.getByLabelText('台阶数量'), { target: { value: '6' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
kind: 'builtin',
|
||||
config: expect.objectContaining({ preset: 'stairs', stairCount: 6 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('在右侧参数面板调整系统地形并提交', () => {
|
||||
const onApply = vi.fn();
|
||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
||||
fireEvent.change(screen.getByLabelText('物理地图预设'), { target: { value: 'rough' } });
|
||||
fireEvent.change(screen.getByLabelText('地形难度(0–1)'), { target: { value: '0.8' } });
|
||||
fireEvent.change(screen.getByLabelText('随机种子'), { target: { value: '9' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
kind: 'builtin',
|
||||
config: expect.objectContaining({
|
||||
preset: 'rough',
|
||||
terrainDifficulty: 0.8,
|
||||
seed: 9,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('可以移动并旋转资产地图', () => {
|
||||
const onApply = vi.fn();
|
||||
render(<PhysicalMapPanel {...common} value={{ kind: 'none' }} onApply={onApply} />);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), { target: { value: 'builtin' } });
|
||||
fireEvent.change(screen.getByLabelText('位置 X(m)'), { target: { value: '2.5' } });
|
||||
fireEvent.change(screen.getByLabelText('位置 Y(m)'), { target: { value: '-1.5' } });
|
||||
fireEvent.change(screen.getByLabelText('旋转 Z(°)'), { target: { value: '45' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
expect(onApply).toHaveBeenCalledWith({
|
||||
kind: 'builtin',
|
||||
config: expect.objectContaining({ positionX: 2.5, positionY: -1.5, yawDeg: 45 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('显示工程地图、出生点和根 Body', () => {
|
||||
const onApply = vi.fn();
|
||||
render(
|
||||
<PhysicalMapPanel
|
||||
{...common}
|
||||
value={{ kind: 'none' }}
|
||||
maps={[
|
||||
{
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
schemaVersion: 1 as const,
|
||||
id: 'warehouse',
|
||||
name: '仓库',
|
||||
spawnPoints: [{ id: 'door', name: '入口' }],
|
||||
},
|
||||
]}
|
||||
rootBodies={['robot', 'payload']}
|
||||
onApply={onApply}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('地图来源'), {
|
||||
target: { value: 'project:maps/warehouse/map.json' },
|
||||
});
|
||||
expect(screen.getByLabelText('地图出生点')).toHaveValue('door');
|
||||
fireEvent.change(screen.getByLabelText('机器人根 Body'), { target: { value: 'robot' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并重新编译' }));
|
||||
expect(onApply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'project',
|
||||
descriptorPath: 'maps/warehouse/map.json',
|
||||
spawnPointId: 'door',
|
||||
robotRootBody: 'robot',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('原生 URDF 模式只允许移除地图', () => {
|
||||
render(
|
||||
<PhysicalMapPanel
|
||||
{...common}
|
||||
value={{ kind: 'builtin', config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' } }}
|
||||
nativeUrdf
|
||||
onApply={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('原生 URDF');
|
||||
expect(screen.getByRole('button', { name: '应用并重新编译' })).toBeDisabled();
|
||||
expect(screen.getByLabelText('地图来源')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,474 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Select } from '../../components/ui';
|
||||
import type { MapEntry } from '../../project/types';
|
||||
import {
|
||||
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
PHYSICAL_MAP_PRESET_LABELS,
|
||||
isSystemTerrainPreset,
|
||||
type MapSelection,
|
||||
type PhysicalMapConfig,
|
||||
type PhysicalMapPreset,
|
||||
} from '../../map/types';
|
||||
import { normalizePhysicalMapConfig } from '../../map/physicalMap';
|
||||
import type {
|
||||
EditableMapDocument,
|
||||
MapEditorInteractionCallbacks,
|
||||
MapEditorTransformMode,
|
||||
} from '../../map/editor/types';
|
||||
import { MapEditorPanel } from './MapEditorPanel';
|
||||
|
||||
function NumberField({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
disabled: boolean;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSelection(selection: MapSelection): MapSelection {
|
||||
if (selection.kind === 'project') {
|
||||
const friction = selection.frictionOverride;
|
||||
return {
|
||||
...selection,
|
||||
frictionOverride:
|
||||
friction === undefined || !Number.isFinite(friction)
|
||||
? undefined
|
||||
: Math.min(5, Math.max(0.05, friction)),
|
||||
};
|
||||
}
|
||||
if (selection.kind !== 'builtin') return selection;
|
||||
const config = normalizePhysicalMapConfig(selection.config);
|
||||
return config.preset === 'none' ? { kind: 'none' } : { kind: 'builtin', config };
|
||||
}
|
||||
|
||||
export function PhysicalMapPanel({
|
||||
value,
|
||||
maps,
|
||||
rootBodies,
|
||||
loading,
|
||||
nativeUrdf,
|
||||
showVisualMap,
|
||||
showMapCollision,
|
||||
editorDocument,
|
||||
onEditorPreview,
|
||||
onEditorApply,
|
||||
onEditorExport,
|
||||
onEditorConvert,
|
||||
onEditorBindInteraction,
|
||||
onEditorSelect,
|
||||
onEditorTransformMode,
|
||||
onEditorSnapping,
|
||||
onMapDisplay,
|
||||
onApply,
|
||||
}: {
|
||||
value: MapSelection;
|
||||
maps: MapEntry[];
|
||||
rootBodies: string[];
|
||||
loading: boolean;
|
||||
nativeUrdf: boolean;
|
||||
showVisualMap: boolean;
|
||||
showMapCollision: boolean;
|
||||
editorDocument: EditableMapDocument | null;
|
||||
onEditorPreview: (document: EditableMapDocument | null) => void;
|
||||
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||
onEditorExport: () => void;
|
||||
onEditorConvert: () => Promise<boolean>;
|
||||
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
||||
onEditorSelect: (id: string | null) => void;
|
||||
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
|
||||
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
||||
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
||||
onApply: (value: MapSelection) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<MapSelection>(value);
|
||||
const normalized = normalizeSelection(draft);
|
||||
const changed = JSON.stringify(normalized) !== JSON.stringify(value);
|
||||
const compileDisabled = loading || (nativeUrdf && normalized.kind !== 'none');
|
||||
const sourceValue =
|
||||
draft.kind === 'none'
|
||||
? 'none'
|
||||
: draft.kind === 'builtin'
|
||||
? 'builtin'
|
||||
: `project:${draft.descriptorPath}`;
|
||||
const projectMap =
|
||||
draft.kind === 'project'
|
||||
? maps.find((entry) => entry.descriptorPath === draft.descriptorPath)
|
||||
: undefined;
|
||||
const updateBuiltin = <K extends keyof PhysicalMapConfig>(key: K, next: PhysicalMapConfig[K]) => {
|
||||
if (draft.kind !== 'builtin') return;
|
||||
setDraft({ kind: 'builtin', config: { ...draft.config, [key]: next } });
|
||||
};
|
||||
|
||||
const changeSource = (source: string) => {
|
||||
if (source === 'none') setDraft({ kind: 'none' });
|
||||
else if (source === 'builtin')
|
||||
setDraft({
|
||||
kind: 'builtin',
|
||||
config: {
|
||||
...DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
preset: value.kind === 'builtin' ? value.config.preset : 'flat',
|
||||
},
|
||||
});
|
||||
else {
|
||||
const descriptorPath = source.slice('project:'.length);
|
||||
const map = maps.find((entry) => entry.descriptorPath === descriptorPath);
|
||||
setDraft({
|
||||
kind: 'project',
|
||||
descriptorPath,
|
||||
spawnPointId: map?.spawnPoints[0]?.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-3">
|
||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-3">
|
||||
<div className="text-xs font-semibold text-text-primary">地图参数</div>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||
从左侧“资产”选择地形或添加场景对象,在这里调整物理参数与编辑属性。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">地图来源</span>
|
||||
<Select
|
||||
aria-label="地图来源"
|
||||
className="w-full"
|
||||
value={sourceValue}
|
||||
disabled={loading}
|
||||
onChange={(event) => changeSource(event.target.value)}
|
||||
>
|
||||
<option value="none">不使用地图</option>
|
||||
<option value="builtin">内置程序地图</option>
|
||||
{maps.map((map) => (
|
||||
<option key={map.descriptorPath} value={`project:${map.descriptorPath}`}>
|
||||
{map.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
{draft.kind === 'builtin' && (
|
||||
<>
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">地图预设</span>
|
||||
<Select
|
||||
aria-label="物理地图预设"
|
||||
className="w-full"
|
||||
value={draft.config.preset}
|
||||
disabled={loading}
|
||||
onChange={(event) => updateBuiltin('preset', event.target.value as PhysicalMapPreset)}
|
||||
>
|
||||
{(Object.keys(PHYSICAL_MAP_PRESET_LABELS) as PhysicalMapPreset[])
|
||||
.filter((preset) => preset !== 'none')
|
||||
.map((preset) => (
|
||||
<option key={preset} value={preset}>
|
||||
{PHYSICAL_MAP_PRESET_LABELS[preset]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-text-primary">地图变换</span>
|
||||
<Button
|
||||
disabled={
|
||||
loading ||
|
||||
(draft.config.positionX === 0 &&
|
||||
draft.config.positionY === 0 &&
|
||||
draft.config.yawDeg === 0)
|
||||
}
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
kind: 'builtin',
|
||||
config: { ...draft.config, positionX: 0, positionY: 0, yawDeg: 0 },
|
||||
})
|
||||
}
|
||||
>
|
||||
复位
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<NumberField
|
||||
label="位置 X(m)"
|
||||
value={draft.config.positionX}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={0.1}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('positionX', next)}
|
||||
/>
|
||||
<NumberField
|
||||
label="位置 Y(m)"
|
||||
value={draft.config.positionY}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={0.1}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('positionY', next)}
|
||||
/>
|
||||
<NumberField
|
||||
label="旋转 Z(°)"
|
||||
value={draft.config.yawDeg}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('yawDeg', next)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
|
||||
调整资产地图在世界坐标中的位置和朝向,应用后同步更新物理碰撞。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<NumberField
|
||||
label={isSystemTerrainPreset(draft.config.preset) ? '地形边长(m)' : '地图半径(m)'}
|
||||
value={draft.config.size}
|
||||
min={4}
|
||||
max={isSystemTerrainPreset(draft.config.preset) ? 30 : 100}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('size', next)}
|
||||
/>
|
||||
<NumberField
|
||||
label="摩擦系数"
|
||||
value={draft.config.friction}
|
||||
min={0.05}
|
||||
max={5}
|
||||
step={0.05}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('friction', next)}
|
||||
/>
|
||||
{draft.config.preset === 'slope' && (
|
||||
<NumberField
|
||||
label="坡度(°)"
|
||||
value={draft.config.slopeAngle}
|
||||
min={5}
|
||||
max={30}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('slopeAngle', next)}
|
||||
/>
|
||||
)}
|
||||
{draft.config.preset === 'stairs' && (
|
||||
<NumberField
|
||||
label="台阶数量"
|
||||
value={draft.config.stairCount}
|
||||
min={2}
|
||||
max={20}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('stairCount', next)}
|
||||
/>
|
||||
)}
|
||||
{draft.config.preset === 'obstacles' && (
|
||||
<NumberField
|
||||
label="障碍物数量"
|
||||
value={draft.config.obstacleCount}
|
||||
min={1}
|
||||
max={30}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('obstacleCount', next)}
|
||||
/>
|
||||
)}
|
||||
{(draft.config.preset === 'obstacles' ||
|
||||
isSystemTerrainPreset(draft.config.preset)) && (
|
||||
<NumberField
|
||||
label="随机种子"
|
||||
value={draft.config.seed}
|
||||
min={0}
|
||||
max={2_147_483_647}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('seed', next)}
|
||||
/>
|
||||
)}
|
||||
{isSystemTerrainPreset(draft.config.preset) && (
|
||||
<NumberField
|
||||
label="地形难度(0–1)"
|
||||
value={draft.config.terrainDifficulty}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('terrainDifficulty', next)}
|
||||
/>
|
||||
)}
|
||||
{(draft.config.preset === 'rough' || draft.config.preset === 'wave') && (
|
||||
<>
|
||||
<NumberField
|
||||
label="水平采样间距(m)"
|
||||
value={draft.config.terrainHorizontalScale}
|
||||
min={0.03}
|
||||
max={1}
|
||||
step={0.01}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('terrainHorizontalScale', next)}
|
||||
/>
|
||||
<NumberField
|
||||
label="垂直量化(m)"
|
||||
value={draft.config.terrainVerticalScale}
|
||||
min={0.001}
|
||||
max={0.1}
|
||||
step={0.001}
|
||||
disabled={loading}
|
||||
onChange={(next) => updateBuiltin('terrainVerticalScale', next)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{draft.kind === 'project' && projectMap && (
|
||||
<div className="space-y-3 rounded border border-border-subtle bg-panel-muted p-3">
|
||||
<div className="text-xs font-medium text-text-primary">{projectMap.name}</div>
|
||||
<div className="break-all text-[11px] text-text-tertiary">
|
||||
{projectMap.descriptorPath}
|
||||
</div>
|
||||
{projectMap.spawnPoints.length > 0 && (
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">出生点</span>
|
||||
<Select
|
||||
aria-label="地图出生点"
|
||||
className="w-full"
|
||||
value={draft.spawnPointId ?? ''}
|
||||
disabled={loading}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, spawnPointId: event.target.value || undefined })
|
||||
}
|
||||
>
|
||||
<option value="">不改变机器人位置</option>
|
||||
{projectMap.spawnPoints.map((spawn) => (
|
||||
<option key={spawn.id} value={spawn.id}>
|
||||
{spawn.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
{rootBodies.length > 1 && (
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">机器人根 Body</span>
|
||||
<Select
|
||||
aria-label="机器人根 Body"
|
||||
className="w-full"
|
||||
value={draft.robotRootBody ?? ''}
|
||||
disabled={loading}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, robotRootBody: event.target.value || undefined })
|
||||
}
|
||||
>
|
||||
<option value="">自动识别</option>
|
||||
{rootBodies.map((body) => (
|
||||
<option key={body} value={body}>
|
||||
{body}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">覆盖摩擦系数(留空则使用地图值)</span>
|
||||
<input
|
||||
aria-label="地图摩擦系数覆盖"
|
||||
type="number"
|
||||
min={0.05}
|
||||
max={5}
|
||||
step={0.05}
|
||||
value={draft.frictionOverride ?? ''}
|
||||
disabled={loading}
|
||||
className="h-8 w-full rounded border border-border-strong bg-input px-2 text-xs text-text-primary"
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
frictionOverride: event.target.value ? Number(event.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft.kind === 'project' &&
|
||||
draft.descriptorPath === (value.kind === 'project' ? value.descriptorPath : '') && (
|
||||
<div className="space-y-2 rounded border border-border-subtle p-3">
|
||||
<div className="text-xs font-medium text-text-primary">V3 地图编辑器</div>
|
||||
<MapEditorPanel
|
||||
document={editorDocument}
|
||||
loading={loading}
|
||||
onPreview={onEditorPreview}
|
||||
onApply={onEditorApply}
|
||||
onExport={onEditorExport}
|
||||
onConvert={onEditorConvert}
|
||||
canConvert={Boolean(projectMap?.physicsPath)}
|
||||
onBindInteraction={onEditorBindInteraction}
|
||||
onSelectPreview={onEditorSelect}
|
||||
onTransformMode={onEditorTransformMode}
|
||||
onSnapping={onEditorSnapping}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-secondary">
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span>显示视觉地图</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showVisualMap}
|
||||
onChange={(event) => onMapDisplay(event.target.checked, showMapCollision)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span>显示地图碰撞层</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showMapCollision}
|
||||
onChange={(event) => onMapDisplay(showVisualMap, event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{nativeUrdf && normalized.kind !== 'none' && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded border border-warning/30 bg-warning/10 p-2 text-xs text-warning"
|
||||
>
|
||||
原生 URDF 不能注入地图。请在“属性”中切换为“转换为 MJCF”。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
disabled={compileDisabled || !changed}
|
||||
onClick={() => onApply(normalized)}
|
||||
>
|
||||
应用并重新编译
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,52 @@
|
||||
import {ChevronRight,FolderRoot} from 'lucide-react';
|
||||
import 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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,69 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {describe,expect,it,vi} from 'vitest';
|
||||
import {PythonControllerPanel} from './PythonControllerPanel';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { PythonControllerPanel } from './PythonControllerPanel';
|
||||
|
||||
const noop=()=>{};
|
||||
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}/>);
|
||||
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');
|
||||
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: 0.1,
|
||||
}}
|
||||
onSelectPath={noop}
|
||||
onLoadPath={noop}
|
||||
onImport={noop}
|
||||
onToggle={noop}
|
||||
onCommand={onCommand}
|
||||
onRemove={noop}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '前进' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '左转' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '起跳' }));
|
||||
expect(onCommand.mock.calls).toEqual([['forward'], ['turn_left'], ['jump']]);
|
||||
expect(screen.getByRole('button', { name: '移动停止' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('控制器未启用时禁用基本移动按钮',()=>{
|
||||
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();
|
||||
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}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '前进' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '起跳' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +1,184 @@
|
||||
import {useRef,type ChangeEvent} from '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';
|
||||
import { useRef, type ChangeEvent } from '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';
|
||||
|
||||
export interface PythonControllerPanelProps {
|
||||
paths:string[];
|
||||
selectedPath?:string;
|
||||
status?:ControllerStatus;
|
||||
loading:boolean;
|
||||
onSelectPath(path:string):void;
|
||||
onLoadPath(path:string):void;
|
||||
onImport(file:File):void;
|
||||
onToggle(enabled:boolean):void;
|
||||
onCommand(command:ControllerCommand):void;
|
||||
onRemove():void;
|
||||
paths: string[];
|
||||
selectedPath?: string;
|
||||
status?: ControllerStatus;
|
||||
loading: boolean;
|
||||
onSelectPath(path: string): void;
|
||||
onLoadPath(path: string): void;
|
||||
onImport(file: File): void;
|
||||
onToggle(enabled: boolean): void;
|
||||
onCommand(command: ControllerCommand): void;
|
||||
onRemove(): void;
|
||||
}
|
||||
|
||||
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>}
|
||||
<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>
|
||||
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>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
icon={<FileUp className="h-3.5 w-3.5" />}
|
||||
disabled={loading}
|
||||
onClick={() => input.current?.click()}
|
||||
>
|
||||
导入 .py
|
||||
</Button>
|
||||
<Button
|
||||
icon={<RotateCw className="h-3.5 w-3.5" />}
|
||||
disabled={loading || !selectedPath}
|
||||
onClick={() => selectedPath && onLoadPath(selectedPath)}
|
||||
>
|
||||
加载脚本
|
||||
</Button>
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-medium text-text-primary" title={status.path}>
|
||||
{status.name}
|
||||
</span>
|
||||
<Badge>{status.enabled ? '运行中' : '已停止'}</Badge>
|
||||
</div>
|
||||
<PropertyRow label="语言" value="Python / Pyodide" />
|
||||
<PropertyRow label="控制频率" value={`${status.controlHz} Hz`} />
|
||||
<PropertyRow label="上次耗时" value={`${status.lastStepMs.toFixed(3)} ms`} />
|
||||
{status.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
|
||||
>
|
||||
{status.error}
|
||||
</p>
|
||||
)}
|
||||
{status.acceptsCommands && (
|
||||
<div className="mt-3 border-t border-border pt-3">
|
||||
<p className="mb-2 text-[10px] text-text-tertiary">基本移动指令</p>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<span />
|
||||
<Button
|
||||
aria-pressed={status.activeCommand === 'forward'}
|
||||
disabled={!status.enabled}
|
||||
icon={<ArrowUp className="h-3.5 w-3.5" />}
|
||||
onClick={() => onCommand('forward')}
|
||||
>
|
||||
前进
|
||||
</Button>
|
||||
<span />
|
||||
<Button
|
||||
aria-pressed={status.activeCommand === 'turn_left'}
|
||||
disabled={!status.enabled}
|
||||
icon={<ArrowLeft className="h-3.5 w-3.5" />}
|
||||
onClick={() => onCommand('turn_left')}
|
||||
>
|
||||
左转
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="移动停止"
|
||||
aria-pressed={status.activeCommand === 'stop'}
|
||||
disabled={!status.enabled}
|
||||
icon={<Octagon className="h-3.5 w-3.5" />}
|
||||
onClick={() => onCommand('stop')}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={status.activeCommand === 'turn_right'}
|
||||
disabled={!status.enabled}
|
||||
icon={<ArrowRight className="h-3.5 w-3.5" />}
|
||||
onClick={() => onCommand('turn_right')}
|
||||
>
|
||||
右转
|
||||
</Button>
|
||||
<span />
|
||||
<Button
|
||||
aria-pressed={status.activeCommand === 'backward'}
|
||||
disabled={!status.enabled}
|
||||
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
||||
onClick={() => onCommand('backward')}
|
||||
>
|
||||
后退
|
||||
</Button>
|
||||
<Button disabled={!status.enabled} onClick={() => onCommand('jump')}>
|
||||
起跳
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant={status.enabled ? 'secondary' : 'primary'}
|
||||
icon={<Power className="h-3.5 w-3.5" />}
|
||||
onClick={() => onToggle(!status.enabled)}
|
||||
>
|
||||
{status.enabled ? '停止' : '启用'}
|
||||
</Button>
|
||||
<Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5" />} onClick={onRemove}>
|
||||
卸载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-xs leading-5 text-text-tertiary">
|
||||
加载可信的单文件 Python 控制器。脚本在每次 mj_step 前按仿真时间同步执行,默认 100 Hz。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{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>;
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,190 @@
|
||||
import {useRef,type ChangeEvent} from 'react';
|
||||
import {BrainCircuit,FileUp,Power,RotateCw,Trash2} from 'lucide-react';
|
||||
import type {RLCommand,RLPolicyStatus} from '../../rl/types';
|
||||
import {Badge,Button,PropertyRow,Select} from '../../components/ui';
|
||||
import { useRef, type ChangeEvent } from 'react';
|
||||
import { BrainCircuit, FileUp, Power, RotateCw, Trash2 } from 'lucide-react';
|
||||
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){
|
||||
const input=useRef<HTMLInputElement>(null);
|
||||
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>;
|
||||
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 command = status?.command ?? { linearX: 0, linearY: 0, angularZ: 0 };
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={input}
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept=".onnx,application/octet-stream"
|
||||
onChange={importFile}
|
||||
/>
|
||||
{paths.length > 0 && (
|
||||
<label className="mb-3 block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">工程内策略</span>
|
||||
<Select
|
||||
aria-label="ONNX 策略"
|
||||
className="w-full"
|
||||
value={selectedPath ?? ''}
|
||||
disabled={loading}
|
||||
onChange={(event) => onSelectPath(event.target.value)}
|
||||
>
|
||||
<option value="">选择 .onnx 文件</option>
|
||||
{paths.map((path) => (
|
||||
<option key={path} value={path}>
|
||||
{path}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
icon={<FileUp className="h-3.5 w-3.5" />}
|
||||
disabled={loading}
|
||||
onClick={() => input.current?.click()}
|
||||
>
|
||||
导入 ONNX
|
||||
</Button>
|
||||
<Button
|
||||
icon={<RotateCw className="h-3.5 w-3.5" />}
|
||||
disabled={loading || !selectedPath}
|
||||
onClick={() => selectedPath && onLoadPath(selectedPath)}
|
||||
>
|
||||
加载策略
|
||||
</Button>
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span
|
||||
className="flex min-w-0 items-center gap-1.5 truncate text-xs font-medium text-text-primary"
|
||||
title={status.path}
|
||||
>
|
||||
<BrainCircuit className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
{status.taskName}
|
||||
</span>
|
||||
<Badge>{status.enabled ? '推理中' : '已停止'}</Badge>
|
||||
</div>
|
||||
<PropertyRow label="控制频率" value={`${status.controlHz} Hz`} />
|
||||
<PropertyRow
|
||||
label="观测 / 动作"
|
||||
value={`${status.observationSize} / ${status.actionSize}`}
|
||||
/>
|
||||
<PropertyRow label="推理次数" value={status.inferenceCount} />
|
||||
<PropertyRow label="上次推理" value={`${status.lastInferenceMs.toFixed(2)} ms`} />
|
||||
<div className="mt-3 border-t border-border pt-3">
|
||||
<p className="mb-2 text-[10px] text-text-tertiary">速度指令(机身坐标系)</p>
|
||||
<CommandInput
|
||||
label="前向 m/s"
|
||||
value={command.linearX}
|
||||
min={-0.5}
|
||||
max={1}
|
||||
onChange={(linearX) => onCommand({ ...command, linearX })}
|
||||
/>
|
||||
<CommandInput
|
||||
label="侧向 m/s"
|
||||
value={command.linearY}
|
||||
min={-0.5}
|
||||
max={0.5}
|
||||
onChange={(linearY) => onCommand({ ...command, linearY })}
|
||||
/>
|
||||
<CommandInput
|
||||
label="偏航 rad/s"
|
||||
value={command.angularZ}
|
||||
min={-1}
|
||||
max={1}
|
||||
onChange={(angularZ) => onCommand({ ...command, angularZ })}
|
||||
/>
|
||||
<Button
|
||||
className="mt-1 w-full"
|
||||
onClick={() => onCommand({ linearX: 0, linearY: 0, angularZ: 0 })}
|
||||
>
|
||||
停止移动
|
||||
</Button>
|
||||
</div>
|
||||
{status.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
|
||||
>
|
||||
{status.error}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant={status.enabled ? 'secondary' : 'primary'}
|
||||
icon={<Power className="h-3.5 w-3.5" />}
|
||||
disabled={Boolean(status.error)}
|
||||
onClick={() => onToggle(!status.enabled)}
|
||||
>
|
||||
{status.enabled ? '停止' : '启用'}
|
||||
</Button>
|
||||
<Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5" />} onClick={onRemove}>
|
||||
卸载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-xs leading-5 text-text-tertiary">
|
||||
加载 mjlab 导出的单输入、单动作输出 policy.onnx。首个内置任务使用 47 维 Go2 actor 观测和
|
||||
12 维腿部关节位置动作;Go2-W 轮电机保持零力矩。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({label,value,min,max,onChange}:{label:string;value:number;min:number;max:number;onChange(value:number):void}){return <label className="mb-2 grid grid-cols-[1fr_72px] items-center gap-2 text-[10px] text-text-tertiary"><span>{label}</span><input className="field h-7 w-full px-2 text-right text-xs text-text-primary" type="number" step="0.05" min={min} max={max} value={value} onChange={event=>onChange(Number(event.target.value))}/></label>;}
|
||||
function CommandInput({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange(value: number): void;
|
||||
}) {
|
||||
return (
|
||||
<label className="mb-2 grid grid-cols-[1fr_72px] items-center gap-2 text-[10px] text-text-tertiary">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
className="field h-7 w-full px-2 text-right text-xs text-text-primary"
|
||||
type="number"
|
||||
step="0.05"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,62 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {ShortcutHelpDialog} from './ShortcutHelpDialog';
|
||||
import {TreeSearchField} from './TreeSearchField';
|
||||
import {ViewportHUD} from './ViewportHUD';
|
||||
import {EmptyWorkspace} from './WorkspaceOverlays';
|
||||
import {ViewerDisplayPopover} from './ViewerDisplayPopover';
|
||||
import {DEFAULT_VIEWER_DISPLAY_OPTIONS} from '../../viewer/displayOptions';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { ShortcutHelpDialog } from './ShortcutHelpDialog';
|
||||
import { TreeSearchField } from './TreeSearchField';
|
||||
import { ViewportHUD } from './ViewportHUD';
|
||||
import { EmptyWorkspace } from './WorkspaceOverlays';
|
||||
import { ViewerDisplayPopover } from './ViewerDisplayPopover';
|
||||
import { DEFAULT_VIEWER_DISPLAY_OPTIONS } from '../../viewer/displayOptions';
|
||||
|
||||
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});});
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,913 @@
|
||||
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 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 {TreeSearchField} from './TreeSearchField';
|
||||
import {ProjectBreadcrumb} from './ProjectBreadcrumb';
|
||||
import {PythonControllerPanel} from './PythonControllerPanel';
|
||||
import {RLPolicyPanel} from './RLPolicyPanel';
|
||||
import {LocalTrainingPanel} from './LocalTrainingPanel';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Database,
|
||||
FolderTree,
|
||||
Info,
|
||||
Map as MapIcon,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import type { MapEntry, 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 type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
|
||||
import type { DataRecorderConfig } from '../../simulation/DataRecorder';
|
||||
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
|
||||
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
|
||||
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
CollapsibleSection,
|
||||
CopyButton,
|
||||
PropertyRow,
|
||||
ResizablePanel,
|
||||
Select,
|
||||
Tabs,
|
||||
} from '../../components/ui';
|
||||
import { TreeSearchField } from './TreeSearchField';
|
||||
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
|
||||
import { PythonControllerPanel } from './PythonControllerPanel';
|
||||
import { RLPolicyPanel } from './RLPolicyPanel';
|
||||
import { LocalTrainingPanel } from './LocalTrainingPanel';
|
||||
import { DataRecordingPanel } from './DataRecordingPanel';
|
||||
import { PhysicalMapPanel } from './PhysicalMapPanel';
|
||||
import { MapAssetLibrary } from './MapAssetLibrary';
|
||||
import {
|
||||
DEFAULT_PHYSICAL_MAP_CONFIG,
|
||||
type MapSelection,
|
||||
type SystemTerrainPreset,
|
||||
} from '../../map/types';
|
||||
import type {
|
||||
EditableMapDocument,
|
||||
EditableMapObjectType,
|
||||
MapEditorInteractionCallbacks,
|
||||
MapEditorTransformMode,
|
||||
MapObjectPlacementMode,
|
||||
} from '../../map/editor/types';
|
||||
|
||||
export function SidebarPanel({title,side,children,visible=true}:{title:string;side:'left'|'right';children:ReactNode;visible?:boolean}){return <ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}><aside className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side==='left'?'border-r':'border-l'} border-border`}><h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel px-3 text-sm font-semibold text-text-primary"><Settings2 aria-hidden="true" className="h-4 w-4 text-accent"/>{title}</h2>{children}</aside></ResizablePanel>;}
|
||||
|
||||
export function 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;
|
||||
}
|
||||
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 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/95 px-3 text-sm font-semibold tracking-tight text-text-primary">
|
||||
<span className="grid h-6 w-6 place-items-center rounded-md bg-accent-soft text-accent">
|
||||
<Settings2 aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</aside>
|
||||
</ResizablePanel>
|
||||
);
|
||||
}
|
||||
|
||||
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 ProjectSidebar({
|
||||
projectName,
|
||||
files,
|
||||
entries,
|
||||
selectedEntry,
|
||||
snapshot,
|
||||
loading,
|
||||
visible = true,
|
||||
nativeUrdf,
|
||||
mapSelection,
|
||||
editorDocument,
|
||||
activeTab,
|
||||
onActiveTabChange,
|
||||
onRemove,
|
||||
onSelectEntry,
|
||||
onJointHover,
|
||||
onAddMapAsset,
|
||||
onSelectTerrain,
|
||||
onSelectMapObject,
|
||||
}: {
|
||||
projectName?: string;
|
||||
files: ProjectTreeFile[];
|
||||
entries: ModelEntry[];
|
||||
selectedEntry?: string;
|
||||
snapshot?: SimulationSnapshot;
|
||||
loading: boolean;
|
||||
visible?: boolean;
|
||||
nativeUrdf: boolean;
|
||||
mapSelection: MapSelection;
|
||||
editorDocument: EditableMapDocument | null;
|
||||
activeTab?: 'project' | 'structure' | 'assets';
|
||||
onActiveTabChange?: (value: 'project' | 'structure' | 'assets') => void;
|
||||
onRemove: () => void;
|
||||
onSelectEntry: (path: string) => void;
|
||||
onJointHover: (jointId: number | null) => void;
|
||||
onAddMapAsset: (
|
||||
type: EditableMapObjectType,
|
||||
placementMode: MapObjectPlacementMode,
|
||||
) => void | Promise<void>;
|
||||
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
||||
onSelectMapObject: (id: string) => void;
|
||||
}) {
|
||||
const [internalTab, setInternalTab] = useState<'project' | 'structure' | 'assets'>('project'),
|
||||
[fileQuery, setFileQuery] = useState(''),
|
||||
[structureQuery, setStructureQuery] = useState('');
|
||||
const tab = activeTab ?? internalTab,
|
||||
fileMatches = countProjectSearchResults(files, fileQuery),
|
||||
structureMatches = snapshot
|
||||
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
|
||||
: 0;
|
||||
return (
|
||||
<SidebarPanel title="资产与结构" side="left" visible={visible}>
|
||||
{projectName ? (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-accent" title={projectName}>
|
||||
{projectName}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} 个文件</div>
|
||||
</div>
|
||||
<Button variant="danger" onClick={onRemove} disabled={loading}>
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
<ProjectBreadcrumb
|
||||
projectName={projectName}
|
||||
entries={entries}
|
||||
selectedEntry={selectedEntry}
|
||||
loading={loading}
|
||||
onSelect={onSelectEntry}
|
||||
/>
|
||||
<Tabs
|
||||
label="工程侧栏"
|
||||
value={tab}
|
||||
onValueChange={(value) => {
|
||||
setInternalTab(value);
|
||||
onActiveTabChange?.(value);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
value: 'project',
|
||||
label: '工程',
|
||||
icon: <FolderTree className="h-3.5 w-3.5" />,
|
||||
content: (
|
||||
<>
|
||||
<TreeSearchField
|
||||
value={fileQuery}
|
||||
onChange={setFileQuery}
|
||||
resultCount={fileMatches}
|
||||
label="搜索工程文件"
|
||||
placeholder="搜索文件或目录…"
|
||||
/>
|
||||
<div className="px-2 pb-3">
|
||||
<ProjectTree
|
||||
key={projectName}
|
||||
files={files}
|
||||
entries={entries}
|
||||
selectedEntry={selectedEntry}
|
||||
query={fileQuery}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'structure',
|
||||
label: '模型结构',
|
||||
icon: <Box className="h-3.5 w-3.5" />,
|
||||
disabled: !snapshot,
|
||||
content: snapshot ? (
|
||||
<>
|
||||
<TreeSearchField
|
||||
value={structureQuery}
|
||||
onChange={setStructureQuery}
|
||||
resultCount={structureMatches}
|
||||
label="搜索模型结构"
|
||||
placeholder="搜索 Body 或关节…"
|
||||
/>
|
||||
<div className="px-2 pb-3">
|
||||
<ModelStructureTree
|
||||
bodies={snapshot.bodies}
|
||||
joints={snapshot.joints}
|
||||
onJointHover={onJointHover}
|
||||
query={structureQuery}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="p-4 text-center text-xs text-text-tertiary">加载模型后显示结构</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'assets',
|
||||
label: '资产',
|
||||
icon: <MapIcon className="h-3.5 w-3.5" />,
|
||||
disabled: !snapshot,
|
||||
content: (
|
||||
<MapAssetLibrary
|
||||
disabled={loading || nativeUrdf}
|
||||
terrainSize={
|
||||
mapSelection.kind === 'builtin'
|
||||
? mapSelection.config.size
|
||||
: DEFAULT_PHYSICAL_MAP_CONFIG.size
|
||||
}
|
||||
document={editorDocument}
|
||||
onAdd={onAddMapAsset}
|
||||
onSelectTerrain={onSelectTerrain}
|
||||
onSelectObject={onSelectMapObject}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示工程资源</div>
|
||||
)}
|
||||
</SidebarPanel>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelControlsProps {
|
||||
snapshot?: SimulationSnapshot;
|
||||
selection: ViewerSelection | null;
|
||||
selectedFormat?: ModelEntry['format'];
|
||||
loading: boolean;
|
||||
visible?: boolean;
|
||||
urdfMode: UrdfLoadMode;
|
||||
baseMode: UrdfBaseMode;
|
||||
showCollision: boolean;
|
||||
ignoreJointLimits: boolean;
|
||||
jointAdvanced: boolean;
|
||||
angleUnit: 'rad' | 'deg';
|
||||
forceScale: number;
|
||||
controllerPaths: string[];
|
||||
selectedControllerPath?: string;
|
||||
controllerStatus?: ControllerStatus;
|
||||
policyPaths: string[];
|
||||
selectedPolicyPath?: string;
|
||||
policyStatus?: RLPolicyStatus;
|
||||
mapSelection: MapSelection;
|
||||
maps: MapEntry[];
|
||||
showVisualMap: boolean;
|
||||
showMapCollision: boolean;
|
||||
editorDocument: EditableMapDocument | null;
|
||||
onUrdfMode: (value: UrdfLoadMode) => void;
|
||||
onBaseMode: (value: UrdfBaseMode) => void;
|
||||
onShowCollision: (value: boolean) => void;
|
||||
onResetJoints: () => void;
|
||||
onToggleJointLimits: () => void;
|
||||
onToggleAdvanced: () => void;
|
||||
onToggleAngleUnit: () => void;
|
||||
onActuator: (id: number, value: number) => void;
|
||||
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
|
||||
onJoint: (id: number, value: number) => void;
|
||||
onForceScale: (value: number) => void;
|
||||
onSelectControllerPath: (path: string) => void;
|
||||
onLoadControllerPath: (path: string) => void;
|
||||
onImportController: (file: File) => void;
|
||||
onToggleController: (enabled: boolean) => void;
|
||||
onControllerCommand: (command: ControllerCommand) => void;
|
||||
onRemoveController: () => void;
|
||||
onSelectPolicyPath: (path: string) => void;
|
||||
onLoadPolicyPath: (path: string) => void;
|
||||
onImportPolicy: (file: File) => void;
|
||||
onTogglePolicy: (enabled: boolean) => void;
|
||||
onPolicyCommand: (command: RLCommand) => void;
|
||||
onRemovePolicy: () => void;
|
||||
onApplyMap: (value: MapSelection) => void;
|
||||
onEditorPreview: (document: EditableMapDocument | null) => void;
|
||||
onEditorApply: (document: EditableMapDocument) => Promise<boolean>;
|
||||
onEditorExport: () => void;
|
||||
onEditorConvert: () => Promise<boolean>;
|
||||
onEditorBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void;
|
||||
onEditorSelect: (id: string | null) => void;
|
||||
onEditorTransformMode: (mode: MapEditorTransformMode) => void;
|
||||
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
||||
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
||||
onMapTabOpen?: () => void;
|
||||
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
|
||||
onDataRecordingStart: () => void;
|
||||
onDataRecordingStop: () => void;
|
||||
onDataRecordingClear: () => void;
|
||||
onDataRecordingExport: (format: 'csv' | 'json') => void;
|
||||
}
|
||||
export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||
const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'),
|
||||
s = props.snapshot;
|
||||
if (!s)
|
||||
return (
|
||||
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
|
||||
<div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div>
|
||||
</SidebarPanel>
|
||||
);
|
||||
const properties = (
|
||||
<>
|
||||
<CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}>
|
||||
<div>
|
||||
<PropertyRow label="Body" value={s.model.nbody} />
|
||||
<PropertyRow label="Joint" value={s.model.njnt} />
|
||||
<PropertyRow label="Geom" value={s.model.ngeom} />
|
||||
<PropertyRow label="Actuator" value={s.model.nactuator} />
|
||||
<PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
{props.selectedFormat === 'urdf' && (
|
||||
<CollapsibleSection title="URDF 处理方式" defaultOpen={false}>
|
||||
<Select
|
||||
aria-label="URDF 处理方式"
|
||||
className="w-full"
|
||||
value={props.urdfMode}
|
||||
disabled={props.loading}
|
||||
onChange={(event) => props.onUrdfMode(event.target.value as UrdfLoadMode)}
|
||||
>
|
||||
<option value="mjcf">转换为 MJCF(推荐)</option>
|
||||
<option value="native">MuJoCo 原生 URDF</option>
|
||||
</Select>
|
||||
<label className="mt-3 block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">基座类型</span>
|
||||
<Select
|
||||
aria-label="URDF 基座类型"
|
||||
className="w-full"
|
||||
value={props.baseMode}
|
||||
disabled={props.loading || props.urdfMode === 'native'}
|
||||
onChange={(event) => props.onBaseMode(event.target.value as UrdfBaseMode)}
|
||||
>
|
||||
<option value="floating">浮动基座(Free Joint)</option>
|
||||
<option value="fixed">固定基座(连接世界)</option>
|
||||
</Select>
|
||||
</label>
|
||||
<p className="mt-2 text-xs text-text-tertiary">
|
||||
MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。
|
||||
</p>
|
||||
<Check
|
||||
label="显示碰撞几何"
|
||||
checked={props.showCollision}
|
||||
onChange={props.onShowCollision}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
<CollapsibleSection title="当前选择" defaultOpen>
|
||||
{props.selection ? (
|
||||
<div className="text-xs">
|
||||
<PropertyRow
|
||||
label="Body"
|
||||
value={props.selection.bodyName}
|
||||
action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称" />}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="标识"
|
||||
value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`}
|
||||
action={
|
||||
<CopyButton
|
||||
value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`}
|
||||
label="复制标识"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="位置"
|
||||
value={props.selection.position.map((value) => value.toFixed(3)).join(', ')}
|
||||
action={<CopyButton value={props.selection.position.join(', ')} label="复制位置" />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="flex items-center gap-2 text-xs text-text-tertiary">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
在视口中单击物体
|
||||
</p>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
const controls = (
|
||||
<>
|
||||
<CollapsibleSection
|
||||
title="ONNX 强化学习策略"
|
||||
defaultOpen
|
||||
badge={s.rlPolicy ? <Badge>{s.rlPolicy.enabled ? '推理' : '停止'}</Badge> : undefined}
|
||||
>
|
||||
<RLPolicyPanel
|
||||
paths={props.policyPaths}
|
||||
selectedPath={props.selectedPolicyPath}
|
||||
status={props.policyStatus ?? s.rlPolicy}
|
||||
loading={props.loading}
|
||||
onSelectPath={props.onSelectPolicyPath}
|
||||
onLoadPath={props.onLoadPolicyPath}
|
||||
onImport={props.onImportPolicy}
|
||||
onToggle={props.onTogglePolicy}
|
||||
onCommand={props.onPolicyCommand}
|
||||
onRemove={props.onRemovePolicy}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="本地强化学习训练" defaultOpen={false}>
|
||||
<LocalTrainingPanel onPolicyReady={props.onImportPolicy} />
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection
|
||||
title="Python 控制器"
|
||||
defaultOpen
|
||||
badge={s.controller ? <Badge>{s.controller.enabled ? '运行' : '停止'}</Badge> : undefined}
|
||||
>
|
||||
<PythonControllerPanel
|
||||
paths={props.controllerPaths}
|
||||
selectedPath={props.selectedControllerPath}
|
||||
status={props.controllerStatus ?? s.controller}
|
||||
loading={props.loading}
|
||||
onSelectPath={props.onSelectControllerPath}
|
||||
onLoadPath={props.onLoadControllerPath}
|
||||
onImport={props.onImportController}
|
||||
onToggle={props.onToggleController}
|
||||
onCommand={props.onControllerCommand}
|
||||
onRemove={props.onRemoveController}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection
|
||||
title="Actuator"
|
||||
defaultOpen={false}
|
||||
badge={<Badge>{s.actuators.length}</Badge>}
|
||||
>
|
||||
{s.actuators.length ? (
|
||||
s.actuators.map((actuator) => (
|
||||
<ActuatorControl
|
||||
key={actuator.id}
|
||||
actuator={actuator}
|
||||
onControl={(value) => props.onActuator(actuator.id, value)}
|
||||
onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-text-tertiary">模型没有驱动器</p>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}>
|
||||
<div className="mb-4 grid grid-cols-2 gap-2">
|
||||
<Button onClick={props.onResetJoints}>重置关节</Button>
|
||||
<Button
|
||||
variant={props.ignoreJointLimits ? 'primary' : 'secondary'}
|
||||
aria-pressed={props.ignoreJointLimits}
|
||||
onClick={props.onToggleJointLimits}
|
||||
>
|
||||
忽略关节限位
|
||||
</Button>
|
||||
<Button
|
||||
variant={props.jointAdvanced ? 'primary' : 'secondary'}
|
||||
aria-pressed={props.jointAdvanced}
|
||||
onClick={props.onToggleAdvanced}
|
||||
>
|
||||
高级
|
||||
</Button>
|
||||
<Button
|
||||
variant={props.angleUnit === 'deg' ? 'primary' : 'secondary'}
|
||||
aria-pressed={props.angleUnit === 'deg'}
|
||||
onClick={props.onToggleAngleUnit}
|
||||
>
|
||||
{props.angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
|
||||
</Button>
|
||||
</div>
|
||||
{s.joints.map((joint) => {
|
||||
const scale = joint.type === 3 && props.angleUnit === 'deg' ? 180 / Math.PI : 1,
|
||||
unit =
|
||||
joint.type === 3
|
||||
? props.angleUnit === 'deg'
|
||||
? '°'
|
||||
: ' rad'
|
||||
: joint.type === 2
|
||||
? ' m'
|
||||
: '';
|
||||
return (
|
||||
<ControlSlider
|
||||
key={joint.id}
|
||||
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
|
||||
value={joint.value * scale}
|
||||
min={joint.min * scale}
|
||||
max={joint.max * scale}
|
||||
unit={unit}
|
||||
advanced={props.jointAdvanced}
|
||||
limited={joint.limited}
|
||||
limitsIgnored={joint.limitsIgnored}
|
||||
limitMin={joint.limitMin * scale}
|
||||
limitMax={joint.limitMax * scale}
|
||||
disabled={!joint.editable}
|
||||
onChange={(value) => props.onJoint(joint.id, value / scale)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="外力强度" defaultOpen={false}>
|
||||
<ControlSlider
|
||||
label={`${props.forceScale.toFixed(0)} N/屏幕单位`}
|
||||
value={props.forceScale}
|
||||
min={5}
|
||||
max={200}
|
||||
onChange={props.onForceScale}
|
||||
/>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
选择“外力施加”,在动态物体上按住拖动,松开即清零。
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<SidebarPanel title="属性与参数" side="right" visible={props.visible}>
|
||||
<Tabs
|
||||
label="模型控制侧栏"
|
||||
value={tab}
|
||||
onValueChange={(value) => {
|
||||
setTab(value);
|
||||
if (value === 'map') props.onMapTabOpen?.();
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
value: 'properties',
|
||||
label: '属性',
|
||||
icon: <Info className="h-3.5 w-3.5" />,
|
||||
content: properties,
|
||||
},
|
||||
{
|
||||
value: 'controls',
|
||||
label: '控制',
|
||||
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
|
||||
content: controls,
|
||||
},
|
||||
{
|
||||
value: 'data',
|
||||
label: '数据',
|
||||
icon: <Database className="h-3.5 w-3.5" />,
|
||||
content: (
|
||||
<DataRecordingPanel
|
||||
status={s.telemetry}
|
||||
bodies={s.bodies}
|
||||
onConfigure={props.onDataRecorderConfigure}
|
||||
onStart={props.onDataRecordingStart}
|
||||
onStop={props.onDataRecordingStop}
|
||||
onClear={props.onDataRecordingClear}
|
||||
onExport={props.onDataRecordingExport}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'map',
|
||||
label: '地图',
|
||||
icon: <MapIcon className="h-3.5 w-3.5" />,
|
||||
content: (
|
||||
<PhysicalMapPanel
|
||||
key={JSON.stringify(props.mapSelection)}
|
||||
value={props.mapSelection}
|
||||
maps={props.maps}
|
||||
rootBodies={s.bodies
|
||||
.filter(
|
||||
(body) =>
|
||||
body.id !== 0 &&
|
||||
body.parentId === 0 &&
|
||||
!body.name.startsWith('__platform_map_'),
|
||||
)
|
||||
.map((body) => body.name)}
|
||||
loading={props.loading}
|
||||
nativeUrdf={props.selectedFormat === 'urdf' && props.urdfMode === 'native'}
|
||||
showVisualMap={props.showVisualMap}
|
||||
showMapCollision={props.showMapCollision}
|
||||
editorDocument={props.editorDocument}
|
||||
onEditorPreview={props.onEditorPreview}
|
||||
onEditorApply={props.onEditorApply}
|
||||
onEditorExport={props.onEditorExport}
|
||||
onEditorConvert={props.onEditorConvert}
|
||||
onEditorBindInteraction={props.onEditorBindInteraction}
|
||||
onEditorSelect={props.onEditorSelect}
|
||||
onEditorTransformMode={props.onEditorTransformMode}
|
||||
onEditorSnapping={props.onEditorSnapping}
|
||||
onMapDisplay={props.onMapDisplay}
|
||||
onApply={props.onApplyMap}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SidebarPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActuatorControl({
|
||||
actuator,
|
||||
onControl,
|
||||
onParameters,
|
||||
}: {
|
||||
actuator: ActuatorInfo;
|
||||
onControl: (value: number) => void;
|
||||
onParameters: (parameters: ActuatorParameters) => void;
|
||||
}) {
|
||||
const isMotor = actuator.kind === 'motor',
|
||||
isPosition = actuator.kind === 'position',
|
||||
editable = isMotor || isPosition,
|
||||
baseTargetScale = isPosition && actuator.jointType === 3 ? 180 / Math.PI : 1,
|
||||
targetScale =
|
||||
isPosition && Math.abs(actuator.gear) > 1e-9
|
||||
? baseTargetScale / actuator.gear
|
||||
: baseTargetScale;
|
||||
const clampForce = (value: number) =>
|
||||
actuator.forceLimited
|
||||
? Math.min(actuator.forceMax, Math.max(actuator.forceMin, value))
|
||||
: value,
|
||||
physicalScale = actuator.gear * actuator.gain;
|
||||
const forceA = clampForce(actuator.min * actuator.gain) * actuator.gear,
|
||||
forceB = clampForce(actuator.max * actuator.gain) * actuator.gear;
|
||||
const targetA = actuator.min * targetScale,
|
||||
targetB = actuator.max * targetScale,
|
||||
outputMin = isMotor ? Math.min(forceA, forceB) : Math.min(targetA, targetB),
|
||||
outputMax = isMotor ? Math.max(forceA, forceB) : Math.max(targetA, targetB);
|
||||
const outputValue = isMotor
|
||||
? clampForce(actuator.value * actuator.gain) * actuator.gear
|
||||
: actuator.value * targetScale,
|
||||
outputDisabled =
|
||||
(isMotor && Math.abs(physicalScale) <= 1e-9) ||
|
||||
(isPosition && Math.abs(actuator.gear) <= 1e-9);
|
||||
const outputLabel = isMotor
|
||||
? actuator.jointType === 3
|
||||
? '输出力矩'
|
||||
: '输出力'
|
||||
: isPosition
|
||||
? actuator.jointType === 3
|
||||
? '目标角度'
|
||||
: '目标位置'
|
||||
: '控制输入';
|
||||
const forceUnit = actuator.jointType === 3 ? 'N·m' : actuator.jointType === 2 ? 'N' : '',
|
||||
jointForceA = actuator.forceMin * actuator.gear,
|
||||
jointForceB = actuator.forceMax * actuator.gear,
|
||||
jointForceMin = Math.min(jointForceA, jointForceB),
|
||||
jointForceMax = Math.max(jointForceA, jointForceB);
|
||||
const update = (patch: Partial<ActuatorParameters>) => onParameters({ ...actuator, ...patch }),
|
||||
controlLabel = isPosition ? (actuator.jointType === 3 ? '角度' : '位置') : '控制',
|
||||
gearSquared = actuator.gear * actuator.gear;
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-border bg-surface p-2.5">
|
||||
<div className="mb-2 flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-medium text-text-primary" title={actuator.name}>
|
||||
{actuator.name}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-text-tertiary">
|
||||
{actuator.jointName ? `关节:${actuator.jointName}` : '未关联标量关节'}
|
||||
</div>
|
||||
</div>
|
||||
<Badge>{actuator.unit || 'u'}</Badge>
|
||||
</div>
|
||||
{actuator.controlCount === 1 ? (
|
||||
<ControlSlider
|
||||
label={outputLabel}
|
||||
value={outputValue}
|
||||
min={outputMin}
|
||||
max={outputMax}
|
||||
disabled={outputDisabled}
|
||||
unit={actuator.unit ? ` ${actuator.unit}` : ''}
|
||||
onChange={(value) => {
|
||||
if (outputDisabled) return;
|
||||
onControl(isMotor ? value / physicalScale : value / targetScale);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="mb-2 text-[10px] leading-4 text-text-tertiary">
|
||||
该驱动器包含 {actuator.controlCount} 个控制分量,请在 MJCF 源码或专用控制器中设置。
|
||||
</p>
|
||||
)}
|
||||
{actuator.controlCount === 1 && !actuator.ctrlLimited && (
|
||||
<div className="mb-2">
|
||||
<ParameterInput
|
||||
label={`${controlLabel}输入(不限幅)`}
|
||||
value={actuator.value * targetScale}
|
||||
onCommit={(value) => onControl(value / targetScale)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{editable ? (
|
||||
<details className="group border-t border-border pt-2">
|
||||
<summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary">
|
||||
常用参数
|
||||
</summary>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{isPosition ? (
|
||||
<>
|
||||
<ParameterInput
|
||||
label={`位置增益 kp(${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'})`}
|
||||
value={actuator.kp * gearSquared}
|
||||
disabled={gearSquared <= 1e-18}
|
||||
onCommit={(kp) => update({ kp: kp / gearSquared })}
|
||||
/>
|
||||
<ParameterInput
|
||||
label={`速度增益 kv(${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'})`}
|
||||
value={actuator.kv * gearSquared}
|
||||
disabled={gearSquared <= 1e-18}
|
||||
onCommit={(kv) => update({ kv: kv / gearSquared })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ParameterInput
|
||||
label={`kp(MJCF stiffness,${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'})`}
|
||||
value={actuator.kp}
|
||||
onCommit={(kp) => update({ kp })}
|
||||
/>
|
||||
<ParameterInput
|
||||
label={`kv(MJCF damping,${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'})`}
|
||||
value={actuator.kv}
|
||||
onCommit={(kv) => update({ kv })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ParameterToggle
|
||||
label={`限制输出${actuator.jointType === 3 ? '力矩' : '力'}${forceUnit ? `(${forceUnit})` : ''}`}
|
||||
checked={actuator.forceLimited}
|
||||
onChange={(forceLimited) => update({ forceLimited })}
|
||||
/>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<ParameterInput
|
||||
label="输出下限"
|
||||
value={jointForceMin}
|
||||
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
|
||||
onCommit={(value) =>
|
||||
update(
|
||||
actuator.gear >= 0
|
||||
? { forceMin: value / actuator.gear }
|
||||
: { forceMax: value / actuator.gear },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ParameterInput
|
||||
label="输出上限"
|
||||
value={jointForceMax}
|
||||
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
|
||||
onCommit={(value) =>
|
||||
update(
|
||||
actuator.gear >= 0
|
||||
? { forceMax: value / actuator.gear }
|
||||
: { forceMin: value / actuator.gear },
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">
|
||||
{isPosition
|
||||
? 'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。'
|
||||
: 'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'}{' '}
|
||||
参数修改会立即作用于当前模型,并可随 MJCF 导出。
|
||||
</p>
|
||||
</details>
|
||||
) : (
|
||||
<p className="border-t border-border pt-2 text-[10px] leading-4 text-text-tertiary">
|
||||
该驱动器不是可直接编辑的 motor/position 类型,控制值按模型原始单位显示;请在 MJCF
|
||||
源码中修改专用参数。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ParameterInput({
|
||||
label,
|
||||
value,
|
||||
onCommit,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onCommit: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-[10px] text-text-tertiary">
|
||||
<span className="mb-1 block truncate">{label}</span>
|
||||
<input
|
||||
key={value}
|
||||
type="number"
|
||||
step="any"
|
||||
defaultValue={Number.isFinite(value) ? value : 0}
|
||||
disabled={disabled}
|
||||
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
|
||||
onBlur={(event) => {
|
||||
const next = Number(event.currentTarget.value);
|
||||
if (Number.isFinite(next) && next !== value) onCommit(next);
|
||||
else event.currentTarget.value = String(value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function ParameterToggle({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="mt-2 flex items-center gap-2 text-[11px] text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function Check({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="mt-3 flex items-center gap-2 text-xs text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded accent-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-panel"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function ControlSlider({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
disabled = false,
|
||||
unit = '',
|
||||
advanced = false,
|
||||
limited = false,
|
||||
limitsIgnored = false,
|
||||
limitMin = 0,
|
||||
limitMax = 0,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
unit?: string;
|
||||
advanced?: boolean;
|
||||
limited?: boolean;
|
||||
limitsIgnored?: boolean;
|
||||
limitMin?: number;
|
||||
limitMax?: number;
|
||||
}) {
|
||||
const sane = Number.isFinite(value) ? value : 0,
|
||||
format = (number: number) => `${number.toFixed(3)}${unit}`;
|
||||
return (
|
||||
<label className="mb-3 block text-xs">
|
||||
<span className="mb-1 flex justify-between gap-2">
|
||||
<span className="truncate text-text-secondary">{label}</span>
|
||||
<output className="technical-value text-text-primary">{format(sane)}</output>
|
||||
</span>
|
||||
<input
|
||||
className="control-slider rounded focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-panel"
|
||||
type="range"
|
||||
disabled={disabled}
|
||||
value={Math.min(max, Math.max(min, sane))}
|
||||
min={min}
|
||||
max={max}
|
||||
step={(max - min) / 500 || 0.001}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
{advanced && (
|
||||
<span className="mt-1 flex justify-between text-[10px] text-text-tertiary">
|
||||
<span>下限 {limited ? format(limitMin) : '无限制'}</span>
|
||||
{limitsIgnored && limited && <span className="text-warning">已忽略</span>}
|
||||
<span>上限 {limited ? format(limitMax) : '无限制'}</span>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function ParameterInput({label,value,onCommit,disabled=false}:{label:string;value:number;onCommit:(value:number)=>void;disabled?:boolean}){return <label className="block text-[10px] text-text-tertiary"><span className="mb-1 block truncate">{label}</span><input key={value} type="number" step="any" defaultValue={Number.isFinite(value)?value:0} disabled={disabled} className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40" onBlur={event=>{const next=Number(event.currentTarget.value);if(Number.isFinite(next)&&next!==value)onCommit(next);else event.currentTarget.value=String(value);}} onKeyDown={event=>{if(event.key==='Enter')event.currentTarget.blur();}}/></label>;}
|
||||
function ParameterToggle({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-2 flex items-center gap-2 text-[11px] text-text-secondary"><input type="checkbox" className="accent-accent" checked={checked} onChange={event=>onChange(event.target.checked)}/>{label}</label>;}
|
||||
function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-3 flex items-center gap-2 text-xs text-text-secondary"><input type="checkbox" className="rounded accent-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-panel" checked={checked} onChange={event=>onChange(event.target.checked)}/>{label}</label>;}
|
||||
function ControlSlider({label,value,min,max,onChange,disabled=false,unit='',advanced=false,limited=false,limitsIgnored=false,limitMin=0,limitMax=0}:{label:string;value:number;min:number;max:number;onChange:(value:number)=>void;disabled?:boolean;unit?:string;advanced?:boolean;limited?:boolean;limitsIgnored?:boolean;limitMin?:number;limitMax?:number}){const sane=Number.isFinite(value)?value:0,format=(number:number)=>`${number.toFixed(3)}${unit}`;return <label className="mb-3 block text-xs"><span className="mb-1 flex justify-between gap-2"><span className="truncate text-text-secondary">{label}</span><output className="technical-value text-text-primary">{format(sane)}</output></span><input className="control-slider rounded focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-panel" type="range" disabled={disabled} value={Math.min(max,Math.max(min,sane))} min={min} max={max} step={(max-min)/500||.001} onChange={event=>onChange(Number(event.target.value))}/>{advanced&&<span className="mt-1 flex justify-between text-[10px] text-text-tertiary"><span>下限 {limited?format(limitMin):'无限制'}</span>{limitsIgnored&&limited&&<span className="text-warning">已忽略</span>}<span>上限 {limited?format(limitMax):'无限制'}</span></span>}</label>;}
|
||||
|
||||
@@ -1,29 +1,248 @@
|
||||
import './monacoSetup';
|
||||
import Editor from '@monaco-editor/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';
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,68 @@
|
||||
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>;}
|
||||
import type { ReactNode } from 'react';
|
||||
import { Box, Clock3, MemoryStick, TriangleAlert } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { Kbd } from '../../components/ui';
|
||||
import { useAppStore } from '../../stores/useAppStore';
|
||||
import { PerformancePopover } from './PerformancePopover';
|
||||
export interface StatusBarProps {
|
||||
time?: number;
|
||||
fps: number;
|
||||
stepMs: number;
|
||||
memoryMb?: number;
|
||||
loaded: boolean;
|
||||
overBudget: boolean;
|
||||
}
|
||||
function Item({
|
||||
icon: Icon,
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
icon: typeof Clock3;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={`items-center gap-1.5 ${className || 'flex'}`}>
|
||||
<Icon aria-hidden="true" className="h-3 w-3 text-text-tertiary" />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
export function StatusBar({ time, fps, stepMs, memoryMb, loaded, overBudget }: StatusBarProps) {
|
||||
return (
|
||||
<footer className="technical-value relative z-30 flex h-7 shrink-0 items-center gap-3 overflow-hidden border-t border-border bg-panel px-3 text-[11px] text-text-tertiary lg:gap-5">
|
||||
<Item icon={Clock3}>时间 {time?.toFixed(3) ?? '—'} s</Item>
|
||||
<PerformancePopover fps={fps} stepMs={stepMs} memoryMb={memoryMb} overBudget={overBudget} />
|
||||
<Item icon={MemoryStick} className="hidden items-center gap-1.5 md:flex">
|
||||
内存 {memoryMb === undefined ? '—' : `${memoryMb.toFixed(1)} MiB`}
|
||||
</Item>
|
||||
<Item icon={Box} className="hidden items-center gap-1.5 sm:flex">
|
||||
WASM {loaded ? '已加载' : '未加载'}
|
||||
</Item>
|
||||
{overBudget && (
|
||||
<span className="hidden min-w-0 items-center gap-1 truncate text-warning lg:flex">
|
||||
<TriangleAlert className="h-3 w-3 shrink-0" />
|
||||
主线程超出步进预算,已限制追帧
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto hidden items-center gap-1.5 xl:flex">
|
||||
<Kbd>Space</Kbd> 播放/暂停 · <Kbd>R</Kbd> 重置 · <Kbd>1/2/3</Kbd> 模式
|
||||
</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
/** 仅让状态栏订阅高频性能数据,避免带动整个工作台重渲染。 */
|
||||
export function StoreStatusBar() {
|
||||
const metrics = useAppStore(
|
||||
useShallow((state) => ({
|
||||
time: state.snapshot?.time,
|
||||
fps: state.fps,
|
||||
stepMs: state.stepMs,
|
||||
memoryMb: state.memoryMb,
|
||||
loaded: Boolean(state.snapshot),
|
||||
overBudget: state.overBudget,
|
||||
})),
|
||||
);
|
||||
return <StatusBar {...metrics} />;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {CommandPalette,type WorkbenchCommand} from './CommandPalette';
|
||||
import {PerformancePopover} from './PerformancePopover';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
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();});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,72 @@
|
||||
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}]}/>;}
|
||||
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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,52 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {UrdfImportOptionsDialog} from './UrdfImportOptionsDialog';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { UrdfImportOptionsDialog } from './UrdfImportOptionsDialog';
|
||||
|
||||
describe('UrdfImportOptionsDialog',()=>{
|
||||
it('默认选择关节驱动器和摄像头传感器',()=>{
|
||||
const onConfirm=vi.fn();
|
||||
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();
|
||||
describe('UrdfImportOptionsDialog', () => {
|
||||
it('默认选择关节驱动器和摄像头传感器', () => {
|
||||
const onConfirm = vi.fn();
|
||||
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'}));
|
||||
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: [0.2, 0, 0.05],
|
||||
cameraDirection: '+X',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('允许分别关闭自动生成项',()=>{
|
||||
const onConfirm=vi.fn();
|
||||
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'}));
|
||||
it('允许分别关闭自动生成项', () => {
|
||||
const onConfirm = vi.fn();
|
||||
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' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('可以不添加组件并继续加载',()=>{
|
||||
const onSkip=vi.fn();
|
||||
render(<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={()=>{}} onSkip={onSkip}/>);
|
||||
fireEvent.click(screen.getByRole('button',{name:'不添加,直接加载'}));
|
||||
it('可以不添加组件并继续加载', () => {
|
||||
const onSkip = vi.fn();
|
||||
render(<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={() => {}} onSkip={onSkip} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '不添加,直接加载' }));
|
||||
expect(onSkip).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,178 @@
|
||||
import {useState,type ReactNode} from 'react';
|
||||
import {Camera,Settings2} from 'lucide-react';
|
||||
import type {CameraDirection,UrdfEnhancementOptions} from '../../project/urdfToMjcf';
|
||||
import {Button,Dialog,Select} from '../../components/ui';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Camera, Settings2 } from 'lucide-react';
|
||||
import type { CameraDirection, UrdfEnhancementOptions } from '../../project/urdfToMjcf';
|
||||
import { Button, Dialog, Select } from '../../components/ui';
|
||||
|
||||
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>}
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-text-tertiary">自动组件只写入浏览器内生成的 MJCF,不会修改本地 URDF 文件;使用“原生 URDF”加载模式时不会注入这些组件。</p>
|
||||
</Dialog>;
|
||||
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="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 { Check, Eye, RotateCcw } from 'lucide-react';
|
||||
import { IconButton, Popover } from '../../components/ui';
|
||||
import {
|
||||
DEFAULT_VIEWER_DISPLAY_OPTIONS,
|
||||
type ViewerDisplayOptions,
|
||||
} from '../../viewer/displayOptions';
|
||||
|
||||
type DisplayKey=keyof ViewerDisplayOptions;
|
||||
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'},
|
||||
type DisplayKey = keyof ViewerDisplayOptions;
|
||||
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',
|
||||
},
|
||||
];
|
||||
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'},
|
||||
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',
|
||||
},
|
||||
];
|
||||
const sceneItems:DisplayItem[]=[
|
||||
{key:'showGrid',label:'地面网格',description:'显示世界坐标系的参考网格',color:'bg-blue-400'},
|
||||
const sceneItems: DisplayItem[] = [
|
||||
{
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
import {Crosshair,Hand,MousePointer2,RotateCcw} from 'lucide-react';
|
||||
import type {InteractionMode} from '../../viewer/MuJoCoViewer';
|
||||
import type {ViewerDisplayOptions} from '../../viewer/displayOptions';
|
||||
import {IconButton,ToolbarToggleGroup,type ToolbarItem} from '../../components/ui';
|
||||
import {ViewerDisplayPopover} from './ViewerDisplayPopover';
|
||||
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>;}
|
||||
import { Crosshair, Hand, MousePointer2, RotateCcw } from 'lucide-react';
|
||||
import type { InteractionMode } from '../../viewer/MuJoCoViewer';
|
||||
import type { ViewerDisplayOptions } from '../../viewer/displayOptions';
|
||||
import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
|
||||
import { ViewerDisplayPopover } from './ViewerDisplayPopover';
|
||||
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></>;}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,58 @@
|
||||
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();});});
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { WorkbenchHeader } from './WorkbenchHeader';
|
||||
const fn = () => {};
|
||||
describe('WorkbenchHeader', () => {
|
||||
it('透传仿真动作且保留可访问名称', () => {
|
||||
const pause = vi.fn(),
|
||||
step = vi.fn(),
|
||||
reset = vi.fn(),
|
||||
speed = vi.fn(),
|
||||
openSource = vi.fn();
|
||||
render(
|
||||
<WorkbenchHeader
|
||||
paused
|
||||
ready
|
||||
speed={1}
|
||||
theme="dark"
|
||||
loading={false}
|
||||
hasProject
|
||||
leftOpen
|
||||
rightOpen
|
||||
fullscreen={false}
|
||||
center={<span>工具</span>}
|
||||
onFiles={fn}
|
||||
onFolder={fn}
|
||||
onOpenSource={openSource}
|
||||
onTogglePause={pause}
|
||||
onStep={step}
|
||||
onReset={reset}
|
||||
onSpeed={speed}
|
||||
onToggleLeft={fn}
|
||||
onToggleRight={fn}
|
||||
onToggleTheme={fn}
|
||||
onHelp={fn}
|
||||
onCommands={fn}
|
||||
onToggleFullscreen={fn}
|
||||
/>,
|
||||
);
|
||||
const sourceButton = screen.getByRole('button', { name: '源代码' });
|
||||
expect(sourceButton).toHaveTextContent('源代码');
|
||||
fireEvent.click(sourceButton);
|
||||
fireEvent.click(screen.getByRole('button', { name: '▶ 播放' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '单步' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
||||
fireEvent.change(screen.getByLabelText('仿真速度'), { target: { value: '2' } });
|
||||
expect(openSource).toHaveBeenCalledTimes(1);
|
||||
expect(pause).toHaveBeenCalledTimes(1);
|
||||
expect(step).toHaveBeenCalledTimes(1);
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
expect(speed).toHaveBeenCalledWith(2);
|
||||
expect(screen.getByRole('button', { name: '切换到白天主题' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '隐藏工程面板' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '打开命令面板' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '进入全屏' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,217 @@
|
||||
import type {ChangeEvent,ReactNode} from 'react';
|
||||
import {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 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>;}
|
||||
import type { ChangeEvent, ReactNode } from 'react';
|
||||
import {
|
||||
Boxes,
|
||||
CircleHelp,
|
||||
Code2,
|
||||
Expand,
|
||||
FolderOpen,
|
||||
Minimize,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
Pause,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Search,
|
||||
StepForward,
|
||||
Sun,
|
||||
Moon,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { Button, IconButton, Select } from '../../components/ui';
|
||||
const fileActionClass =
|
||||
'inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface/80 px-2 text-xs font-medium text-text-primary shadow-sm transition-[background-color,border-color,transform] hover:-translate-y-px hover:border-border-strong hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
|
||||
const fileActionLabelClass = 'hidden sm:inline';
|
||||
export function WorkbenchHeader({
|
||||
paused,
|
||||
ready,
|
||||
speed,
|
||||
theme,
|
||||
loading,
|
||||
leftOpen,
|
||||
rightOpen,
|
||||
fullscreen,
|
||||
hasProject,
|
||||
center,
|
||||
endActions,
|
||||
compactMenu,
|
||||
onFiles,
|
||||
onFolder,
|
||||
onOpenSource,
|
||||
onTogglePause,
|
||||
onStep,
|
||||
onReset,
|
||||
onSpeed,
|
||||
onToggleLeft,
|
||||
onToggleRight,
|
||||
onToggleTheme,
|
||||
onHelp,
|
||||
onCommands,
|
||||
onToggleFullscreen,
|
||||
}: {
|
||||
paused: boolean;
|
||||
ready: boolean;
|
||||
speed: number;
|
||||
theme: 'light' | 'dark';
|
||||
loading: boolean;
|
||||
leftOpen: boolean;
|
||||
rightOpen: boolean;
|
||||
fullscreen: boolean;
|
||||
hasProject?: boolean;
|
||||
center: ReactNode;
|
||||
endActions?: ReactNode;
|
||||
compactMenu?: ReactNode;
|
||||
onFiles: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onFolder: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onOpenSource?: () => void;
|
||||
onTogglePause: () => void;
|
||||
onStep: () => void;
|
||||
onReset: () => void;
|
||||
onSpeed: (value: number) => void;
|
||||
onToggleLeft: () => void;
|
||||
onToggleRight: () => void;
|
||||
onToggleTheme: () => void;
|
||||
onHelp: () => void;
|
||||
onCommands: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel/95 px-2.5 shadow-[0_1px_0_rgba(255,255,255,0.02)] backdrop-blur-md">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<div className="mr-2 hidden items-center gap-2 border-r border-border pr-3 xl:flex">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg border border-accent/25 bg-accent-soft text-accent shadow-sm">
|
||||
<Boxes aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<h1 className="truncate text-sm font-semibold tracking-tight text-text-primary">
|
||||
MuJoCo Web 仿真平台
|
||||
</h1>
|
||||
</div>
|
||||
<label
|
||||
aria-disabled={loading}
|
||||
className={`${fileActionClass} ${loading ? 'pointer-events-none opacity-40' : ''}`}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<span className={fileActionLabelClass}>打开文件</span>
|
||||
<input
|
||||
id="mujoco-project-files"
|
||||
aria-label="打开文件"
|
||||
className="sr-only"
|
||||
type="file"
|
||||
disabled={loading}
|
||||
multiple
|
||||
accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr"
|
||||
onChange={onFiles}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
aria-disabled={loading}
|
||||
className={`${fileActionClass} ${loading ? 'pointer-events-none opacity-40' : ''}`}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
<span className={fileActionLabelClass}>文件夹</span>
|
||||
<input
|
||||
id="mujoco-project-folder"
|
||||
aria-label="打开文件夹"
|
||||
className="sr-only"
|
||||
type="file"
|
||||
disabled={loading}
|
||||
multiple
|
||||
{...({ webkitdirectory: '', directory: '' } as object)}
|
||||
onChange={onFolder}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={`${fileActionClass} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
title="查看和修改缓存源代码"
|
||||
disabled={!hasProject || loading}
|
||||
onClick={onOpenSource}
|
||||
>
|
||||
<Code2 className="h-3.5 w-3.5" />
|
||||
<span className={fileActionLabelClass}>源代码</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">{center}</div>
|
||||
<div className="flex min-w-0 items-center justify-end gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onTogglePause}
|
||||
disabled={!ready}
|
||||
aria-label={paused ? '▶ 播放' : '⏸ 暂停'}
|
||||
icon={paused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
|
||||
>
|
||||
{paused ? '播放' : '暂停'}
|
||||
</Button>
|
||||
<IconButton tooltip="单步" aria-label="单步" onClick={onStep} disabled={!ready || !paused}>
|
||||
<StepForward className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
<IconButton tooltip="重置" aria-label="重置" onClick={onReset} disabled={!ready}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
<Select
|
||||
aria-label="仿真速度"
|
||||
value={speed}
|
||||
disabled={loading}
|
||||
onChange={(event) => onSpeed(Number(event.target.value))}
|
||||
className="w-[70px]"
|
||||
>
|
||||
<option value={0.25}>0.25×</option>
|
||||
<option value={0.5}>0.5×</option>
|
||||
<option value={1}>1×</option>
|
||||
<option value={2}>2×</option>
|
||||
<option value={4}>4×</option>
|
||||
</Select>
|
||||
<span className="mx-1 h-5 border-l border-border" />
|
||||
<IconButton
|
||||
tooltip={leftOpen ? '隐藏工程面板' : '显示工程面板'}
|
||||
aria-label={leftOpen ? '隐藏工程面板' : '显示工程面板'}
|
||||
aria-expanded={leftOpen}
|
||||
onClick={onToggleLeft}
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
tooltip={rightOpen ? '隐藏属性面板' : '显示属性面板'}
|
||||
aria-label={rightOpen ? '隐藏属性面板' : '显示属性面板'}
|
||||
aria-expanded={rightOpen}
|
||||
onClick={onToggleRight}
|
||||
>
|
||||
<PanelRight className="h-4 w-4" />
|
||||
</IconButton>
|
||||
{endActions}
|
||||
{compactMenu}
|
||||
<IconButton
|
||||
className="hidden xl:inline-flex"
|
||||
tooltip="命令面板(Ctrl+K)"
|
||||
aria-label="打开命令面板"
|
||||
onClick={onCommands}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="hidden xl:inline-flex"
|
||||
tooltip={fullscreen ? '退出全屏' : '进入全屏'}
|
||||
aria-label={fullscreen ? '退出全屏' : '进入全屏'}
|
||||
onClick={onToggleFullscreen}
|
||||
>
|
||||
{fullscreen ? <Minimize className="h-4 w-4" /> : <Expand className="h-4 w-4" />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="hidden xl:inline-flex"
|
||||
tooltip="快捷键帮助"
|
||||
aria-label="快捷键帮助"
|
||||
onClick={onHelp}
|
||||
>
|
||||
<CircleHelp className="h-4 w-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="hidden xl:inline-flex"
|
||||
tooltip={theme === 'dark' ? '切换到白天主题' : '切换到黑夜主题'}
|
||||
aria-label={theme === 'dark' ? '切换到白天主题' : '切换到黑夜主题'}
|
||||
onClick={onToggleTheme}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</IconButton>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,206 @@
|
||||
import {Box,FolderOpen,LoaderCircle,PlayCircle,Settings2,ShieldCheck,Upload,UploadCloud} from 'lucide-react';
|
||||
import {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>}</>;}
|
||||
import {
|
||||
Box,
|
||||
CheckCircle2,
|
||||
FileArchive,
|
||||
FolderOpen,
|
||||
LoaderCircle,
|
||||
PlayCircle,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Upload,
|
||||
UploadCloud,
|
||||
} from 'lucide-react';
|
||||
import { ProgressBar, Skeleton } from '../../components/ui';
|
||||
|
||||
export interface ImportProgress {
|
||||
title?: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const workflow = [
|
||||
{ label: '导入', detail: 'URDF、MJCF 或工程包', icon: UploadCloud },
|
||||
{ label: '检查与配置', detail: '结构、驱动器与传感器', icon: Settings2 },
|
||||
{ label: '运行与调试', detail: '控制、策略与物理状态', icon: PlayCircle },
|
||||
];
|
||||
|
||||
export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
|
||||
if (compact)
|
||||
return (
|
||||
<div className="m-3 rounded-xl border border-dashed border-border-strong bg-panel/90 p-4 text-center shadow-sm">
|
||||
<span className="mx-auto mb-3 grid h-10 w-10 place-items-center rounded-xl bg-accent-soft text-accent">
|
||||
<Box className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="text-sm font-semibold text-text-primary">拖放模型工程到此处</p>
|
||||
<p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="导入模型工程"
|
||||
className="workspace-welcome relative w-[min(600px,calc(100vw-32px))] overflow-hidden rounded-[22px] border border-border-strong bg-panel/90 px-6 py-6 text-center shadow-2xl backdrop-blur-md sm:px-7"
|
||||
>
|
||||
<div aria-hidden="true" className="welcome-glow" />
|
||||
<div className="relative">
|
||||
<div className="mb-4 flex items-center justify-center gap-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-accent">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Local Simulation Workspace
|
||||
</div>
|
||||
<span className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-2xl border border-accent/25 bg-accent-soft text-accent shadow-[0_12px_32px_rgba(53,199,146,0.16)]">
|
||||
<UploadCloud className="h-6 w-6" />
|
||||
</span>
|
||||
<h2 className="text-lg font-semibold tracking-tight text-text-primary">
|
||||
拖放模型工程到此处
|
||||
</h2>
|
||||
<p className="mt-1.5 text-xs leading-5 text-text-tertiary">
|
||||
本地解析、编译并运行机器人模型,无需上传资源
|
||||
</p>
|
||||
<div
|
||||
className="mt-4 flex flex-wrap items-center justify-center gap-1.5"
|
||||
aria-label="支持格式"
|
||||
>
|
||||
{['MJCF / XML', 'URDF', 'ZIP', 'OBJ / STL / DAE'].map((format) => (
|
||||
<span
|
||||
key={format}
|
||||
className="rounded-full border border-border bg-surface/80 px-2.5 py-1 text-[10px] font-medium text-text-secondary"
|
||||
>
|
||||
{format}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-center gap-2">
|
||||
<label
|
||||
htmlFor="mujoco-project-files"
|
||||
className="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3.5 text-xs font-semibold text-white shadow-[0_8px_22px_rgba(22,131,95,0.22)] transition-[background-color,transform,box-shadow] hover:-translate-y-0.5 hover:bg-accent-hover hover:shadow-[0_10px_26px_rgba(22,131,95,0.28)] focus-within:ring-2 focus-within:ring-accent/40"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
选择文件
|
||||
</label>
|
||||
<label
|
||||
htmlFor="mujoco-project-folder"
|
||||
className="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-surface px-3.5 text-xs font-semibold text-text-primary shadow-sm transition-[background-color,transform] hover:-translate-y-0.5 hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/40"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
选择文件夹
|
||||
</label>
|
||||
</div>
|
||||
<ol
|
||||
aria-label="仿真工作流程"
|
||||
className="mt-6 hidden grid-cols-3 gap-2 border-t border-border/80 pt-4 sm:grid"
|
||||
>
|
||||
{workflow.map((item, index) => (
|
||||
<li
|
||||
key={item.label}
|
||||
className="rounded-xl border border-border/70 bg-surface/70 px-3 py-3 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="technical-value text-[10px] font-semibold text-accent">
|
||||
0{index + 1}
|
||||
</span>
|
||||
<item.icon aria-hidden="true" className="h-3.5 w-3.5 text-text-secondary" />
|
||||
<span className="text-xs font-semibold text-text-primary">{item.label}</span>
|
||||
</div>
|
||||
<span className="mt-1 block text-[10px] leading-4 text-text-tertiary">
|
||||
{item.detail}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary">
|
||||
<ShieldCheck aria-hidden="true" className="h-3.5 w-3.5 text-success" />
|
||||
模型与资源仅在当前浏览器会话中处理
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingCard({ progress, compact }: { progress?: ImportProgress; compact: boolean }) {
|
||||
const title = progress?.title ?? '正在加载 MuJoCo 与模型';
|
||||
return (
|
||||
<div
|
||||
className={`${compact ? 'w-[min(360px,calc(100vw-32px))]' : 'w-[min(400px,calc(100vw-32px))]'} overflow-hidden rounded-2xl border border-border-strong bg-panel/95 shadow-2xl backdrop-blur-md`}
|
||||
>
|
||||
<div className="h-0.5 bg-gradient-to-r from-transparent via-accent to-transparent opacity-90" />
|
||||
<div className={compact ? 'px-4 py-3.5' : 'px-5 py-5'}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="relative mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-accent-soft text-accent">
|
||||
<LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin" />
|
||||
<span className="absolute inset-0 animate-ping rounded-xl border border-accent/20" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-text-primary">{title}</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-text-tertiary">
|
||||
{progress?.detail ?? '首次运行会下载并编译本地 WebAssembly 运行时'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{progress ? (
|
||||
<div className="mt-4">
|
||||
<ProgressBar value={progress.value} label={progress.label} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Skeleton className="h-2.5 w-full" />
|
||||
<Skeleton className="h-2.5 w-4/5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceOverlays({
|
||||
loading,
|
||||
hasSnapshot,
|
||||
dragActive = false,
|
||||
progress,
|
||||
}: {
|
||||
loading: boolean;
|
||||
hasSnapshot: boolean;
|
||||
dragActive?: boolean;
|
||||
progress?: ImportProgress;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{!hasSnapshot && !loading && (
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center p-4">
|
||||
<div className="pointer-events-auto">
|
||||
<EmptyWorkspace />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={progress?.label ?? '正在加载 MuJoCo 与模型'}
|
||||
className={
|
||||
hasSnapshot
|
||||
? 'pointer-events-none absolute right-4 top-4 z-30'
|
||||
: 'absolute inset-0 z-30 grid place-items-center bg-app/70 p-4 backdrop-blur-[3px]'
|
||||
}
|
||||
>
|
||||
<LoadingCard progress={progress} compact={hasSnapshot} />
|
||||
</div>
|
||||
)}
|
||||
{dragActive && !loading && (
|
||||
<div className="pointer-events-none absolute inset-3 z-40 grid place-items-center rounded-2xl border-2 border-dashed border-accent bg-accent-soft/20 shadow-[inset_0_0_80px_rgba(53,199,146,0.08)] backdrop-blur-[2px]">
|
||||
<div className="rounded-2xl border border-accent/30 bg-panel/95 px-7 py-5 text-center shadow-2xl">
|
||||
<span className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-xl bg-accent-soft text-accent">
|
||||
<FileArchive className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="text-sm font-semibold text-text-primary">松开即可导入工程</p>
|
||||
<p className="mt-1 flex items-center justify-center gap-1.5 text-[11px] text-text-tertiary">
|
||||
<CheckCircle2 className="h-3 w-3 text-success" />
|
||||
文件、文件夹与 ZIP 都可以直接解析
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {loader} from '@monaco-editor/react';
|
||||
import { loader } from '@monaco-editor/react';
|
||||
import * as monaco from 'monaco-editor/editor/editor.api';
|
||||
import 'monaco-editor/languages/definitions/xml/register';
|
||||
import EditorWorker from 'monaco-editor/editor/editor.worker?worker';
|
||||
|
||||
type MonacoGlobal=typeof globalThis&{MonacoEnvironment?:{getWorker?:()=>Worker}};
|
||||
(globalThis as MonacoGlobal).MonacoEnvironment={getWorker:()=>new EditorWorker()};
|
||||
loader.config({monaco});
|
||||
type MonacoGlobal = typeof globalThis & { MonacoEnvironment?: { getWorker?: () => Worker } };
|
||||
(globalThis as MonacoGlobal).MonacoEnvironment = { getWorker: () => new EditorWorker() };
|
||||
loader.config({ monaco });
|
||||
|
||||
@@ -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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
import type {ButtonHTMLAttributes,ReactNode} from 'react';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>{
|
||||
variant?:'primary'|'secondary'|'ghost'|'danger';
|
||||
size?:'sm'|'md'|'icon';
|
||||
icon?:ReactNode;
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md' | 'icon';
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
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',
|
||||
danger:'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-white',
|
||||
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',
|
||||
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();});});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
</button>
|
||||
{expanded&&<div className="px-3 pb-3">{children}</div>}
|
||||
</section>;
|
||||
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}
|
||||
</button>
|
||||
{expanded && <div className="px-3 pb-3">{children}</div>}
|
||||
</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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,54 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {useState} from 'react';
|
||||
import {Dialog} from './Dialog';
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,105 @@
|
||||
import {useEffect,useId,useRef,type ReactNode} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {X} from 'lucide-react';
|
||||
import {IconButton} from './IconButton';
|
||||
import { useEffect, useId, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { IconButton } from './IconButton';
|
||||
|
||||
const FOCUSABLE='button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,85 @@
|
||||
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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');});
|
||||
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={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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import type {ButtonHTMLAttributes} from 'react';
|
||||
import {Tooltip} from './Tooltip';
|
||||
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}/>;
|
||||
return tooltip?<Tooltip content={tooltip}>{button}</Tooltip>:button;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,127 @@
|
||||
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>;}
|
||||
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 * 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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>}]}/>;}
|
||||
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();});
|
||||
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> },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}/>;}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,38 @@
|
||||
import {fireEvent,render,screen,waitFor} from '@testing-library/react';
|
||||
import {ConfirmDialog,CopyButton,Kbd,PropertyRow,SearchHighlight} from './index';
|
||||
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();});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import type {ReactElement,ReactNode} from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
|
||||
export function Tooltip({content,children,side='bottom'}:{content:ReactNode;children:ReactElement;side?:'top'|'bottom'}){
|
||||
if(!content)return children;
|
||||
return <span className="group/tooltip relative inline-flex">
|
||||
{children}
|
||||
<span role="tooltip" className={`pointer-events-none absolute left-1/2 z-[500] hidden w-max max-w-64 -translate-x-1/2 rounded-md border border-border bg-surface-elevated px-2 py-1 text-[10px] font-medium text-text-primary shadow-lg group-hover/tooltip:block group-focus-within/tooltip:block ${side==='top'?'bottom-full mb-1.5':'top-full mt-1.5'}`}>{content}</span>
|
||||
</span>;
|
||||
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">
|
||||
{children}
|
||||
<span
|
||||
role="tooltip"
|
||||
className={`pointer-events-none absolute left-1/2 z-[500] hidden w-max max-w-64 -translate-x-1/2 rounded-md border border-border bg-surface-elevated px-2 py-1 text-[10px] font-medium text-text-primary shadow-lg group-hover/tooltip:block group-focus-within/tooltip:block ${side === 'top' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'}`}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {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');});});
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user