feat(admin): 场景管理页——列表/上传/物理参数编辑 + 3D 预览(T-WEB-015 补齐)
- 新增 admin/src/pages/Scenes.tsx(路由 /scenes + 侧栏「场景管理」入口):
* 列表:40px 缩略图(点击进详情)、物理摘要、体积、状态、软删除;
* 上传表单:GLB 文件 + 名称 + 来源五元组 + 物理参数(重力、风基础向量、
阵风幅度/频率、地面摩擦/坡度、碰撞体、环境光),multipart 走 POST /api/scenes;
* 详情:复用 VrmPreview(懒加载 three.js 分包)预览场景 GLB + 参数可视化编辑
并通过 PATCH /api/scenes/:id 保存;
- 新增 admin/src/lib/scene-physics.ts:DEFAULT_SCENE_CONFIG(与契约 7.1、服务端
scene-config.ts 单一真源,测试断言两端一致)与 physicsSummary();
- 场景页两栏自适应样式;types/endpoints 补齐场景五接口。
- 测试:场景用例增至 9 项;规模 19 文件 / 245 项。版本 0.7.0。
This commit is contained in:
@@ -10,7 +10,13 @@ OpenDance Studio **阶段一**: Web 管理服务端(NestJS + TypeORM + 现有 My
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
**阶段一已完成**: T-WEB-001 -> T-WEB-010 全部落地,当前版本 **v0.6.0**(契约 `rest-api 0.5.0`、`data-models 0.7.0`、`model-formats 0.5.0`、`skeleton-topology 0.2.0`、`database 0.4.0`)。
|
**阶段一已完成**: T-WEB-001 -> T-WEB-010 全部落地,当前版本 **v0.7.0**(契约 `rest-api 0.5.0`、`data-models 0.7.0`、`model-formats 0.5.0`、`skeleton-topology 0.2.0`、`database 0.4.0`)。
|
||||||
|
|
||||||
|
v0.7.0 修订(**管理端场景页**,补齐场景端到端可用):
|
||||||
|
|
||||||
|
1. **管理端「场景管理」页**(`/scenes`):列表(40px 缩略图 + **物理摘要**「重力 -9.8 | 风 5.0m/s + 阵风 2.5m/s@0.5Hz」+ 状态)、上传表单(**GLB 文件 + 名称 + 来源五元组 + 物理参数**:重力、风基础向量、阵风幅度/频率、地面摩擦/坡度、碰撞体、环境光)、详情面板(复用 3D 预览 + **参数可视化编辑并保存**);
|
||||||
|
2. 新增 `physicsSummary()` 纯函数与场景页样式(两栏自适应);路由 `/scenes` + 侧栏「场景管理」入口;
|
||||||
|
3. 新增 2 项测试;规模 **19 文件 / 245 项**。
|
||||||
|
|
||||||
v0.6.0 修订(**场景管理**,契约 rest-api -> 0.5.0、data-models -> 0.7.0、database -> 0.4.0):
|
v0.6.0 修订(**场景管理**,契约 rest-api -> 0.5.0、data-models -> 0.7.0、database -> 0.4.0):
|
||||||
|
|
||||||
@@ -109,15 +115,15 @@ v0.2.0 主要变更:
|
|||||||
3. 取得发布包并校验(地址用你们自己的制品库替换; 下面是本仓库的发布地址)
|
3. 取得发布包并校验(地址用你们自己的制品库替换; 下面是本仓库的发布地址)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.6.0/opendance-web-v0.6.0.tar.gz
|
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.0/opendance-web-v0.7.0.tar.gz
|
||||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.6.0/SHA256SUMS
|
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.0/SHA256SUMS
|
||||||
sha256sum -c SHA256SUMS # 期望输出 "OK"
|
sha256sum -c SHA256SUMS # 期望输出 "OK"
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 解压并生成配置(交互式引导,逐项问答,口令不回显)
|
4. 解压并生成配置(交互式引导,逐项问答,口令不回显)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf opendance-web-v0.6.0.tar.gz && cd opendance-web-v0.6.0
|
tar -xzf opendance-web-v0.7.0.tar.gz && cd opendance-web-v0.7.0
|
||||||
bash deploy/init-env.sh
|
bash deploy/init-env.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -187,8 +193,8 @@ FLUSH PRIVILEGES;
|
|||||||
### 5. 部署步骤
|
### 5. 部署步骤
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf opendance-web-v0.6.0.tar.gz
|
tar -xzf opendance-web-v0.7.0.tar.gz
|
||||||
cd opendance-web-v0.6.0
|
cd opendance-web-v0.7.0
|
||||||
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
|
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
|
||||||
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
|
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
|
||||||
```
|
```
|
||||||
@@ -361,12 +367,12 @@ npm run dev # http://localhost:5173(/api 与 /health 代理到 3000)
|
|||||||
cd web-server
|
cd web-server
|
||||||
npm run contracts:check # 契约版本一致性
|
npm run contracts:check # 契约版本一致性
|
||||||
npm run typecheck # TypeScript 严格检查
|
npm run typecheck # TypeScript 严格检查
|
||||||
npm run test # 243 项单元测试 / 19 个文件(VMD 31、模型压缩包 27、
|
npm run test # 245 项单元测试 / 19 个文件(VMD 31、模型压缩包 27、
|
||||||
# 模型检查器 25、缩略图 8、骨骼过滤 8、
|
# 模型检查器 25、缩略图 8、骨骼过滤 8、
|
||||||
# 预处理 25、init-env 16、admin-port 16、
|
# 预处理 25、init-env 16、admin-port 16、
|
||||||
# access-log 9、VRM 检查器 6、.env 预检 12、
|
# access-log 9、VRM 检查器 6、.env 预检 12、
|
||||||
# Assimp 定位与安装 13、stats 容量与编码 12、
|
# Assimp 定位与安装 13、stats 容量与编码 12、
|
||||||
# 缩略图贴图采样 6、MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6)
|
# 缩略图贴图采样 6、MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6、场景页 2)
|
||||||
npm run build # 生产构建
|
npm run build # 生产构建
|
||||||
npm run start:prod & # 启动
|
npm run start:prod & # 启动
|
||||||
npm run test:smoke # 57 项端到端冒烟(需要数据库可连通)
|
npm run test:smoke # 57 项端到端冒烟(需要数据库可连通)
|
||||||
|
|||||||
+14
-7
@@ -1,5 +1,5 @@
|
|||||||
================================================================
|
================================================================
|
||||||
OpenDance Studio -- Web 管理服务端 v0.6.0 使用说明
|
OpenDance Studio -- Web 管理服务端 v0.7.0 使用说明
|
||||||
================================================================
|
================================================================
|
||||||
|
|
||||||
本服务端是 OpenDance Studio 三端中的第一端, 负责管理歌曲、VRM 模型与
|
本服务端是 OpenDance Studio 三端中的第一端, 负责管理歌曲、VRM 模型与
|
||||||
@@ -8,6 +8,13 @@ OpenDance Studio -- Web 管理服务端 v0.6.0 使用说明
|
|||||||
重要: 本服务端 不部署、不管理 MySQL。数据库为部署方提供的现有实例,
|
重要: 本服务端 不部署、不管理 MySQL。数据库为部署方提供的现有实例,
|
||||||
连接信息通过 .env 注入。
|
连接信息通过 .env 注入。
|
||||||
|
|
||||||
|
v0.7.0 修订 - 管理端场景页(补齐场景端到端可用)
|
||||||
|
1. 新增「场景管理」页(/scenes): 列表(40px 缩略图 + 物理摘要 + 状态)、
|
||||||
|
上传(GLB 文件 + 名称 + 来源五元组 + 物理参数: 重力/风/阵风/地面/碰撞体/环境光)、
|
||||||
|
详情(复用 3D 预览 + 参数可视化编辑并保存)。
|
||||||
|
2. 新增 physicsSummary() 纯函数、场景页样式与侧栏入口。
|
||||||
|
3. 新增 2 项测试; 规模 19 文件 / 245 项。
|
||||||
|
|
||||||
v0.6.0 修订 - 场景管理(契约 rest-api 0.5.0 / data-models 0.7.0 / database 0.4.0)
|
v0.6.0 修订 - 场景管理(契约 rest-api 0.5.0 / data-models 0.7.0 / database 0.4.0)
|
||||||
1. 场景以 GLB 为主(不做 humanoid 重绑定、不转 VRM): .glb/.gltf 直接导入,
|
1. 场景以 GLB 为主(不做 humanoid 重绑定、不转 VRM): .glb/.gltf 直接导入,
|
||||||
.zip/.pmx/.fbx 由服务端转 GLB。
|
.zip/.pmx/.fbx 由服务端转 GLB。
|
||||||
@@ -136,11 +143,11 @@ v0.2.1 修订
|
|||||||
2. 准备一个现有的 MySQL / MariaDB 实例, 并建好库与账号(见下方"前置要求")
|
2. 准备一个现有的 MySQL / MariaDB 实例, 并建好库与账号(见下方"前置要求")
|
||||||
本项目不会创建数据库本身
|
本项目不会创建数据库本身
|
||||||
3. 取得发布包并校验(地址用你们自己的制品库替换)
|
3. 取得发布包并校验(地址用你们自己的制品库替换)
|
||||||
curl -LO https://<制品库地址>/opendance-web-v0.6.0.tar.gz
|
curl -LO https://<制品库地址>/opendance-web-v0.7.0.tar.gz
|
||||||
curl -LO https://<制品库地址>/SHA256SUMS
|
curl -LO https://<制品库地址>/SHA256SUMS
|
||||||
sha256sum -c SHA256SUMS # 期望输出 "OK"
|
sha256sum -c SHA256SUMS # 期望输出 "OK"
|
||||||
4. 解压并生成配置(交互式引导, 逐项问答, 口令不回显)
|
4. 解压并生成配置(交互式引导, 逐项问答, 口令不回显)
|
||||||
tar -xzf opendance-web-v0.6.0.tar.gz && cd opendance-web-v0.6.0
|
tar -xzf opendance-web-v0.7.0.tar.gz && cd opendance-web-v0.7.0
|
||||||
bash deploy/init-env.sh
|
bash deploy/init-env.sh
|
||||||
5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查)
|
5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查)
|
||||||
bash deploy/install.sh
|
bash deploy/install.sh
|
||||||
@@ -198,8 +205,8 @@ v0.2.1 修订
|
|||||||
(本项目不会创建数据库本身, 也不需要 GRANT OPTION / FILE / SUPER 等管理权限)
|
(本项目不会创建数据库本身, 也不需要 GRANT OPTION / FILE / SUPER 等管理权限)
|
||||||
|
|
||||||
部署步骤
|
部署步骤
|
||||||
tar -xzf opendance-web-v0.6.0.tar.gz
|
tar -xzf opendance-web-v0.7.0.tar.gz
|
||||||
cd opendance-web-v0.6.0
|
cd opendance-web-v0.7.0
|
||||||
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
|
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
|
||||||
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
|
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
|
||||||
|
|
||||||
@@ -374,12 +381,12 @@ deploy/.env 初始化引导(deploy/init-env.sh)
|
|||||||
cd web-server
|
cd web-server
|
||||||
npm run contracts:check # 契约版本一致性
|
npm run contracts:check # 契约版本一致性
|
||||||
npm run typecheck # TypeScript 严格检查
|
npm run typecheck # TypeScript 严格检查
|
||||||
npm run test # 243 项单元测试 / 19 个文件(VMD 31、模型压缩包 24、
|
npm run test # 245 项单元测试 / 19 个文件(VMD 31、模型压缩包 24、
|
||||||
# 模型检查器 25、缩略图 8、骨骼过滤 8、
|
# 模型检查器 25、缩略图 8、骨骼过滤 8、
|
||||||
# 预处理 25、init-env 16、admin-port 16、
|
# 预处理 25、init-env 16、admin-port 16、
|
||||||
# access-log 9、VRM 检查器 6、.env 预检 12、
|
# access-log 9、VRM 检查器 6、.env 预检 12、
|
||||||
# Assimp 定位与安装 13、stats 容量与编码 12、缩略图贴图 6、
|
# Assimp 定位与安装 13、stats 容量与编码 12、缩略图贴图 6、
|
||||||
# MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6)
|
# MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6、场景页 2)
|
||||||
npm run build # 生产构建
|
npm run build # 生产构建
|
||||||
npm run start:prod & # 启动
|
npm run start:prod & # 启动
|
||||||
npm run test:smoke # 57 项端到端冒烟(需要数据库可连通)
|
npm run test:smoke # 57 项端到端冒烟(需要数据库可连通)
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
project: opendance-web-server
|
project: opendance-web-server
|
||||||
version: 0.6.0
|
version: 0.7.0
|
||||||
repository: opendance-web
|
repository: opendance-web
|
||||||
default_branch: main
|
default_branch: main
|
||||||
contracts_version: 0.4.0
|
contracts_version: 0.4.0
|
||||||
@@ -60,7 +60,7 @@ tasks:
|
|||||||
status: done
|
status: done
|
||||||
|
|
||||||
deliverable:
|
deliverable:
|
||||||
artifact: opendance-web-v0.6.0.tar.gz
|
artifact: opendance-web-v0.7.0.tar.gz
|
||||||
packaging: Linux 业务服务(系统服务托管,双端口:软件 API + 管理端,不含 MySQL、不含反向代理、不使用 Docker)
|
packaging: Linux 业务服务(系统服务托管,双端口:软件 API + 管理端,不含 MySQL、不含反向代理、不使用 Docker)
|
||||||
includes:
|
includes:
|
||||||
- web-server/ # NestJS 源码 + 构建产物(含 BVH / VMD 内置解析、ZIP 读取与模型压缩包转换)
|
- web-server/ # NestJS 源码 + 构建产物(含 BVH / VMD 内置解析、ZIP 读取与模型压缩包转换)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 打包发布产物:opendance-web-v0.6.0.tar.gz
|
# 打包发布产物:opendance-web-v0.7.0.tar.gz
|
||||||
#
|
#
|
||||||
# 产物结构(Linux 业务服务部署,不使用 Docker、不部署反向代理):
|
# 产物结构(Linux 业务服务部署,不使用 Docker、不部署反向代理):
|
||||||
# web-server/ NestJS 源码 + 构建产物
|
# web-server/ NestJS 源码 + 构建产物
|
||||||
@@ -21,7 +21,7 @@ set -euo pipefail
|
|||||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
VERSION="${1:-0.6.0}"
|
VERSION="${1:-0.7.0}"
|
||||||
NAME="opendance-web-v${VERSION}"
|
NAME="opendance-web-v${VERSION}"
|
||||||
OUT_DIR="$REPO_ROOT/dist-release"
|
OUT_DIR="$REPO_ROOT/dist-release"
|
||||||
STAGE="$OUT_DIR/$NAME"
|
STAGE="$OUT_DIR/$NAME"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "opendance-admin",
|
"name": "opendance-admin",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "OpenDance Studio 管理端(React + TypeScript + Vite)",
|
"description": "OpenDance Studio 管理端(React + TypeScript + Vite)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
|||||||
import { Layout } from './components/Layout';
|
import { Layout } from './components/Layout';
|
||||||
import { DashboardPage } from './pages/Dashboard';
|
import { DashboardPage } from './pages/Dashboard';
|
||||||
import { ModelsPage } from './pages/Models';
|
import { ModelsPage } from './pages/Models';
|
||||||
|
import { ScenesPage } from './pages/Scenes';
|
||||||
import { MotionsPage } from './pages/Motions';
|
import { MotionsPage } from './pages/Motions';
|
||||||
import { ReviewsPage } from './pages/Reviews';
|
import { ReviewsPage } from './pages/Reviews';
|
||||||
import { SongsPage } from './pages/Songs';
|
import { SongsPage } from './pages/Songs';
|
||||||
@@ -14,6 +15,7 @@ export default function App() {
|
|||||||
<Route path="songs" element={<SongsPage />} />
|
<Route path="songs" element={<SongsPage />} />
|
||||||
<Route path="models" element={<ModelsPage />} />
|
<Route path="models" element={<ModelsPage />} />
|
||||||
<Route path="motions" element={<MotionsPage />} />
|
<Route path="motions" element={<MotionsPage />} />
|
||||||
|
<Route path="scenes" element={<ScenesPage />} />
|
||||||
<Route path="reviews" element={<ReviewsPage />} />
|
<Route path="reviews" element={<ReviewsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import { apiDelete, apiGet, apiPatch, apiPost, apiUpload } from './client';
|
import { apiDelete, apiGet, apiPatch, apiPost, apiUpload } from './client';
|
||||||
import type {
|
import type {
|
||||||
|
Scene,
|
||||||
|
SceneConfig,
|
||||||
|
ScenePage,
|
||||||
|
SceneUpdate,
|
||||||
ContractVersion,
|
ContractVersion,
|
||||||
Health,
|
Health,
|
||||||
Model,
|
Model,
|
||||||
@@ -12,6 +16,7 @@ import type {
|
|||||||
MotionUpdate,
|
MotionUpdate,
|
||||||
PendingPage,
|
PendingPage,
|
||||||
PublishStatus,
|
PublishStatus,
|
||||||
|
SourceType,
|
||||||
ReviewActionBody,
|
ReviewActionBody,
|
||||||
ReviewPage,
|
ReviewPage,
|
||||||
Song,
|
Song,
|
||||||
@@ -233,3 +238,54 @@ export function getHealth(): Promise<Health> {
|
|||||||
export function getCatalogVersion(): Promise<ContractVersion> {
|
export function getCatalogVersion(): Promise<ContractVersion> {
|
||||||
return apiGet<ContractVersion>('/api/catalog/version');
|
return apiGet<ContractVersion>('/api/catalog/version');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 场景(T-WEB-015) */
|
||||||
|
|
||||||
|
export function listScenes(
|
||||||
|
params: ListParams & { q?: string; status?: PublishStatus | '' },
|
||||||
|
): Promise<ScenePage> {
|
||||||
|
return apiGet<ScenePage>('/api/scenes', {
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
q: params.q,
|
||||||
|
status: params.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScene(id: number): Promise<Scene> {
|
||||||
|
return apiGet<Scene>(`/api/scenes/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneUploadInput {
|
||||||
|
file: File;
|
||||||
|
name: string;
|
||||||
|
/** 物理/环境/出生点/相机;序列化成 JSON 字符串随 multipart 提交 */
|
||||||
|
config: SceneConfig;
|
||||||
|
sourceType: SourceType;
|
||||||
|
sourceName: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
license: string;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传场景:multipart(file + name + config + 来源五元组) */
|
||||||
|
export function uploadScene(input: SceneUploadInput): Promise<Scene> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', input.file);
|
||||||
|
form.append('name', input.name);
|
||||||
|
form.append('config', JSON.stringify(input.config));
|
||||||
|
form.append('sourceType', input.sourceType);
|
||||||
|
form.append('sourceName', input.sourceName);
|
||||||
|
form.append('sourceUrl', input.sourceUrl);
|
||||||
|
form.append('license', input.license);
|
||||||
|
form.append('author', input.author);
|
||||||
|
return apiUpload<Scene>('/api/scenes', form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateScene(id: number, payload: SceneUpdate): Promise<Scene> {
|
||||||
|
return apiPatch<Scene>(`/api/scenes/${id}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteScene(id: number): Promise<void> {
|
||||||
|
return apiDelete(`/api/scenes/${id}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -303,3 +303,59 @@ export interface ApiErrorBody {
|
|||||||
message?: string | string[];
|
message?: string | string[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 场景(v0.7.0 / T-WEB-015) */
|
||||||
|
|
||||||
|
export interface Vec3 {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneWind {
|
||||||
|
base: Vec3;
|
||||||
|
gustAmplitude: number;
|
||||||
|
gustFrequency: number;
|
||||||
|
gustDirection: Vec3;
|
||||||
|
space: 'world' | 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneConfig {
|
||||||
|
physics: {
|
||||||
|
gravity: Vec3;
|
||||||
|
wind: SceneWind;
|
||||||
|
ground: { friction: number; restitution: number; slopeLimit: number };
|
||||||
|
colliders: 'auto' | 'mesh' | 'none';
|
||||||
|
};
|
||||||
|
environment: { ambientColor: string; ambientIntensity: number; fogDensity: number };
|
||||||
|
spawnPoints: Array<{ x: number; y: number; z: number; yaw: number }>;
|
||||||
|
camera: { mode: 'follow' | 'fixed' | 'orbit'; distance: number; height: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 场景:以 GLB 为主(不做 humanoid 重绑定、不转 VRM)+ 物理参数 */
|
||||||
|
export interface Scene extends SourceFields {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
glbUrl: string | null;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
fileSize: number | null;
|
||||||
|
fileSha256: string | null;
|
||||||
|
conversionNote: string | null;
|
||||||
|
config: SceneConfig | null;
|
||||||
|
stats: Record<string, unknown> | null;
|
||||||
|
status: PublishStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenePage {
|
||||||
|
items: Scene[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneUpdate {
|
||||||
|
name?: string;
|
||||||
|
config?: SceneConfig;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* 场景物理参数的展示与默认值(T-WEB-015)。
|
||||||
|
*
|
||||||
|
* 放在 `.ts` 里而不是页面组件内:管理端的纯逻辑要与页面解耦,便于在服务端的
|
||||||
|
* vitest 里直接断言(`.tsx` 需要 jsx 编译选项,服务端 tsconfig 不带)。
|
||||||
|
* 默认值必须与契约 `data-models.md` 7.1 / 服务端 `scene-config.ts` 保持一致。
|
||||||
|
*/
|
||||||
|
import type { SceneConfig } from '../api/types';
|
||||||
|
|
||||||
|
export const DEFAULT_SCENE_CONFIG: SceneConfig = {
|
||||||
|
physics: {
|
||||||
|
gravity: { x: 0, y: -9.8, z: 0 },
|
||||||
|
wind: {
|
||||||
|
base: { x: 0, y: 0, z: 0 },
|
||||||
|
gustAmplitude: 0,
|
||||||
|
gustFrequency: 0.2,
|
||||||
|
gustDirection: { x: 1, y: 0, z: 0 },
|
||||||
|
space: 'world',
|
||||||
|
},
|
||||||
|
ground: { friction: 0.6, restitution: 0, slopeLimit: 45 },
|
||||||
|
colliders: 'auto',
|
||||||
|
},
|
||||||
|
environment: { ambientColor: '#8fb4ff', ambientIntensity: 1, fogDensity: 0 },
|
||||||
|
spawnPoints: [{ x: 0, y: 0, z: 0, yaw: 0 }],
|
||||||
|
camera: { mode: 'follow', distance: 4.5, height: 1.6 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表里的物理摘要:一眼看出重力与风(服务端只存参数、仿真在客户端,
|
||||||
|
* 因此列表必须把这两个最关键的物理量显示出来)。
|
||||||
|
*/
|
||||||
|
export function physicsSummary(config: SceneConfig | null): string {
|
||||||
|
if (!config) return '—';
|
||||||
|
const { gravity, wind } = config.physics;
|
||||||
|
const windSpeed = Math.hypot(wind.base.x, wind.base.y, wind.base.z);
|
||||||
|
const gust =
|
||||||
|
wind.gustAmplitude > 0
|
||||||
|
? ` + 阵风 ${wind.gustAmplitude.toFixed(1)}m/s@${wind.gustFrequency}Hz`
|
||||||
|
: '';
|
||||||
|
return `重力 ${gravity.y.toFixed(1)} | 风 ${windSpeed.toFixed(1)}m/s${gust}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
/**
|
||||||
|
* 场景管理页(T-WEB-015)。
|
||||||
|
*
|
||||||
|
* 与模型/动作页的差异:场景**以 GLB 为主**且携带**物理参数**,因此本页有三块:
|
||||||
|
* 1. 列表:缩略图 + 物理摘要(重力 / 风)+ 状态;
|
||||||
|
* 2. 上传:GLB 文件 + 名称 + 来源五元组 + 物理参数(重力、风向动态、地面、碰撞体、环境);
|
||||||
|
* 3. 详情:复用管理端 3D 预览(GLB/glTF/VRM 同一加载器)+ 参数可视化(可编辑后保存)。
|
||||||
|
*
|
||||||
|
* 服务端只存参数、不跑物理;这里的"物理"只是**参数编辑与展示**。
|
||||||
|
*/
|
||||||
|
import { lazy, Suspense, useState, type FormEvent } from 'react';
|
||||||
|
|
||||||
|
import { deleteScene, listScenes, updateScene, uploadScene } from '../api/endpoints';
|
||||||
|
import { errorMessage } from '../api/client';
|
||||||
|
import type { PublishStatus, Scene, SceneConfig, SourceType } from '../api/types';
|
||||||
|
import { SOURCE_TYPES } from '../api/types';
|
||||||
|
import { formatBytes, formatDateTime } from '../lib/format';
|
||||||
|
import { DEFAULT_SCENE_CONFIG, physicsSummary } from '../lib/scene-physics';
|
||||||
|
import { SOURCE_TYPE_LABELS, STATUS_OPTIONS } from '../lib/labels';
|
||||||
|
import { hasErrors, requireText, validateSourceFields, type FieldErrors } from '../lib/validation';
|
||||||
|
import { useAsync } from '../lib/useAsync';
|
||||||
|
import { Empty, ErrorBox, Loading, StatusBadge } from '../components/ui';
|
||||||
|
|
||||||
|
const VrmPreview = lazy(async () => {
|
||||||
|
const module = await import('../components/VrmPreview');
|
||||||
|
return { default: module.VrmPreview };
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SceneFormState {
|
||||||
|
name: string;
|
||||||
|
sourceType: string;
|
||||||
|
sourceName: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
license: string;
|
||||||
|
author: string;
|
||||||
|
gravityY: string;
|
||||||
|
windX: string;
|
||||||
|
windY: string;
|
||||||
|
windZ: string;
|
||||||
|
gustAmplitude: string;
|
||||||
|
gustFrequency: string;
|
||||||
|
gustDirectionX: string;
|
||||||
|
groundFriction: string;
|
||||||
|
groundSlopeLimit: string;
|
||||||
|
colliders: 'auto' | 'mesh' | 'none';
|
||||||
|
ambientColor: string;
|
||||||
|
ambientIntensity: string;
|
||||||
|
cameraMode: 'follow' | 'fixed' | 'orbit';
|
||||||
|
cameraDistance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: SceneFormState = {
|
||||||
|
name: '',
|
||||||
|
sourceType: '',
|
||||||
|
sourceName: '',
|
||||||
|
sourceUrl: '',
|
||||||
|
license: '',
|
||||||
|
author: '',
|
||||||
|
gravityY: '-9.8',
|
||||||
|
windX: '0',
|
||||||
|
windY: '0',
|
||||||
|
windZ: '0',
|
||||||
|
gustAmplitude: '0',
|
||||||
|
gustFrequency: '0.2',
|
||||||
|
gustDirectionX: '1',
|
||||||
|
groundFriction: '0.6',
|
||||||
|
groundSlopeLimit: '45',
|
||||||
|
colliders: 'auto',
|
||||||
|
ambientColor: '#8fb4ff',
|
||||||
|
ambientIntensity: '1',
|
||||||
|
cameraMode: 'follow',
|
||||||
|
cameraDistance: '4.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
function numberOf(value: string, fallback: number): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表单 -> 契约 SceneConfig(越界由服务端再收敛一次) */
|
||||||
|
function toConfig(form: SceneFormState): SceneConfig {
|
||||||
|
return {
|
||||||
|
physics: {
|
||||||
|
gravity: { x: 0, y: numberOf(form.gravityY, -9.8), z: 0 },
|
||||||
|
wind: {
|
||||||
|
base: {
|
||||||
|
x: numberOf(form.windX, 0),
|
||||||
|
y: numberOf(form.windY, 0),
|
||||||
|
z: numberOf(form.windZ, 0),
|
||||||
|
},
|
||||||
|
gustAmplitude: numberOf(form.gustAmplitude, 0),
|
||||||
|
gustFrequency: numberOf(form.gustFrequency, 0.2),
|
||||||
|
gustDirection: { x: numberOf(form.gustDirectionX, 1), y: 0, z: 0 },
|
||||||
|
space: 'world',
|
||||||
|
},
|
||||||
|
ground: {
|
||||||
|
friction: numberOf(form.groundFriction, 0.6),
|
||||||
|
restitution: 0,
|
||||||
|
slopeLimit: numberOf(form.groundSlopeLimit, 45),
|
||||||
|
},
|
||||||
|
colliders: form.colliders,
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
ambientColor: /^#[0-9a-fA-F]{6}$/.test(form.ambientColor) ? form.ambientColor : '#8fb4ff',
|
||||||
|
ambientIntensity: numberOf(form.ambientIntensity, 1),
|
||||||
|
fogDensity: 0,
|
||||||
|
},
|
||||||
|
spawnPoints: [{ x: 0, y: 0, z: 0, yaw: 0 }],
|
||||||
|
camera: {
|
||||||
|
mode: form.cameraMode,
|
||||||
|
distance: numberOf(form.cameraDistance, 4.5),
|
||||||
|
height: 1.6,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
error?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
{label}
|
||||||
|
{hint ? <em className="muted small"> {hint}</em> : null}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
{error ? <span className="field-error">{error}</span> : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SceneUploadForm({ onSaved }: { onSaved: () => void }): JSX.Element {
|
||||||
|
const [form, setForm] = useState<SceneFormState>(EMPTY_FORM);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [errors, setErrors] = useState<FieldErrors>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const patch = (next: Partial<SceneFormState>): void => setForm((previous) => ({ ...previous, ...next }));
|
||||||
|
|
||||||
|
const submit = async (event: FormEvent): Promise<void> => {
|
||||||
|
event.preventDefault();
|
||||||
|
const nextErrors: FieldErrors = {};
|
||||||
|
requireText(form.name, 'name', '名称', nextErrors);
|
||||||
|
validateSourceFields(
|
||||||
|
{ ...form, sourceType: form.sourceType as SourceType | '' },
|
||||||
|
nextErrors,
|
||||||
|
);
|
||||||
|
setErrors(nextErrors);
|
||||||
|
if (hasErrors(nextErrors) || !file) return;
|
||||||
|
setSaving(true);
|
||||||
|
setServerError(null);
|
||||||
|
try {
|
||||||
|
await uploadScene({
|
||||||
|
file,
|
||||||
|
name: form.name.trim(),
|
||||||
|
config: toConfig(form),
|
||||||
|
sourceType: form.sourceType as (typeof SOURCE_TYPES)[number],
|
||||||
|
sourceName: form.sourceName.trim(),
|
||||||
|
sourceUrl: form.sourceUrl.trim(),
|
||||||
|
license: form.license.trim(),
|
||||||
|
author: form.author.trim(),
|
||||||
|
});
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setFile(null);
|
||||||
|
onSaved();
|
||||||
|
} catch (error) {
|
||||||
|
setServerError(errorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="panel-body" onSubmit={submit}>
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field label="场景文件 file" hint=".glb 为主(.gltf/.zip/.fbx/.pmx 服务端转 GLB)" error={errors.file}>
|
||||||
|
<input type="file" accept=".glb,.gltf,.zip,.fbx,.pmx" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="名称 name" error={errors.name}>
|
||||||
|
<input value={form.name} onChange={(event) => patch({ name: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="form-section">物理参数(服务端只存参数,仿真在客户端)</h4>
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field label="重力 Y (m/s²)" hint="默认 -9.8">
|
||||||
|
<input value={form.gravityY} onChange={(event) => patch({ gravityY: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="碰撞体 colliders">
|
||||||
|
<select value={form.colliders} onChange={(event) => patch({ colliders: event.target.value as SceneFormState['colliders'] })}>
|
||||||
|
<option value="auto">auto(按 GLB 命名约定)</option>
|
||||||
|
<option value="mesh">mesh(三角网)</option>
|
||||||
|
<option value="none">none(不使用)</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="风 基础向量 X/Y/Z (m/s)">
|
||||||
|
<div className="inline-inputs">
|
||||||
|
<input value={form.windX} onChange={(event) => patch({ windX: event.target.value })} />
|
||||||
|
<input value={form.windY} onChange={(event) => patch({ windY: event.target.value })} />
|
||||||
|
<input value={form.windZ} onChange={(event) => patch({ windZ: event.target.value })} />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="阵风幅度 (m/s) / 频率 (Hz)">
|
||||||
|
<div className="inline-inputs">
|
||||||
|
<input value={form.gustAmplitude} onChange={(event) => patch({ gustAmplitude: event.target.value })} />
|
||||||
|
<input value={form.gustFrequency} onChange={(event) => patch({ gustFrequency: event.target.value })} />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="地面摩擦 / 坡度上限(度)">
|
||||||
|
<div className="inline-inputs">
|
||||||
|
<input value={form.groundFriction} onChange={(event) => patch({ groundFriction: event.target.value })} />
|
||||||
|
<input value={form.groundSlopeLimit} onChange={(event) => patch({ groundSlopeLimit: event.target.value })} />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="环境光颜色 / 强度">
|
||||||
|
<div className="inline-inputs">
|
||||||
|
<input value={form.ambientColor} onChange={(event) => patch({ ambientColor: event.target.value })} />
|
||||||
|
<input value={form.ambientIntensity} onChange={(event) => patch({ ambientIntensity: event.target.value })} />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="form-section">来源五元组(必填)</h4>
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field label="来源类型 sourceType" error={errors.sourceType}>
|
||||||
|
<select value={form.sourceType} onChange={(event) => patch({ sourceType: event.target.value })}>
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{SOURCE_TYPES.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{SOURCE_TYPE_LABELS[value]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="来源名称 sourceName" error={errors.sourceName}>
|
||||||
|
<input value={form.sourceName} onChange={(event) => patch({ sourceName: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="来源链接 sourceUrl" error={errors.sourceUrl}>
|
||||||
|
<input value={form.sourceUrl} onChange={(event) => patch({ sourceUrl: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="许可 license" error={errors.license}>
|
||||||
|
<input value={form.license} onChange={(event) => patch({ license: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="作者 author" error={errors.author}>
|
||||||
|
<input value={form.author} onChange={(event) => patch({ author: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{serverError ? <ErrorBox message={serverError} /> : null}
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? '上传中…' : '上传场景(待审核)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情:3D 预览 + 参数编辑 */
|
||||||
|
function SceneDetail({ scene, onChanged }: { scene: Scene; onChanged: () => void }): JSX.Element {
|
||||||
|
const initial = scene.config ?? DEFAULT_SCENE_CONFIG;
|
||||||
|
const [form, setForm] = useState<SceneFormState>({
|
||||||
|
...EMPTY_FORM,
|
||||||
|
name: scene.name,
|
||||||
|
gravityY: String(initial.physics.gravity.y),
|
||||||
|
windX: String(initial.physics.wind.base.x),
|
||||||
|
windY: String(initial.physics.wind.base.y),
|
||||||
|
windZ: String(initial.physics.wind.base.z),
|
||||||
|
gustAmplitude: String(initial.physics.wind.gustAmplitude),
|
||||||
|
gustFrequency: String(initial.physics.wind.gustFrequency),
|
||||||
|
gustDirectionX: String(initial.physics.wind.gustDirection.x),
|
||||||
|
groundFriction: String(initial.physics.ground.friction),
|
||||||
|
groundSlopeLimit: String(initial.physics.ground.slopeLimit),
|
||||||
|
colliders: initial.physics.colliders,
|
||||||
|
ambientColor: initial.environment.ambientColor,
|
||||||
|
ambientIntensity: String(initial.environment.ambientIntensity),
|
||||||
|
cameraMode: initial.camera.mode,
|
||||||
|
cameraDistance: String(initial.camera.distance),
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const save = async (): Promise<void> => {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await updateScene(scene.id, { name: form.name.trim(), config: toConfig(form) });
|
||||||
|
setMessage('已保存');
|
||||||
|
onChanged();
|
||||||
|
} catch (cause) {
|
||||||
|
setError(errorMessage(cause));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scene-detail">
|
||||||
|
<div className="scene-preview">
|
||||||
|
{scene.glbUrl ? (
|
||||||
|
<Suspense fallback={<Loading text="正在加载 3D 预览…" />}>
|
||||||
|
<VrmPreview fileUrl={scene.glbUrl} name={scene.name} height={320} />
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<Empty text="该场景没有 GLB 文件" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="scene-params">
|
||||||
|
<div className="kv-grid">
|
||||||
|
<div className="kv">
|
||||||
|
<span className="muted">文件</span>
|
||||||
|
<code>{formatBytes(scene.fileSize)}</code>
|
||||||
|
</div>
|
||||||
|
<div className="kv">
|
||||||
|
<span className="muted">物理摘要</span>
|
||||||
|
<code>{physicsSummary(scene.config)}</code>
|
||||||
|
</div>
|
||||||
|
<div className="kv">
|
||||||
|
<span className="muted">转换说明</span>
|
||||||
|
<code className="break">{scene.conversionNote ?? '—'}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field label="名称">
|
||||||
|
<input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="重力 Y (m/s²)">
|
||||||
|
<input value={form.gravityY} onChange={(event) => setForm({ ...form, gravityY: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="风 X/Y/Z (m/s)">
|
||||||
|
<div className="inline-inputs">
|
||||||
|
<input value={form.windX} onChange={(event) => setForm({ ...form, windX: event.target.value })} />
|
||||||
|
<input value={form.windY} onChange={(event) => setForm({ ...form, windY: event.target.value })} />
|
||||||
|
<input value={form.windZ} onChange={(event) => setForm({ ...form, windZ: event.target.value })} />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="阵风幅度 / 频率">
|
||||||
|
<div className="inline-inputs">
|
||||||
|
<input value={form.gustAmplitude} onChange={(event) => setForm({ ...form, gustAmplitude: event.target.value })} />
|
||||||
|
<input value={form.gustFrequency} onChange={(event) => setForm({ ...form, gustFrequency: event.target.value })} />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="地面摩擦 / 坡度上限">
|
||||||
|
<div className="inline-inputs">
|
||||||
|
<input value={form.groundFriction} onChange={(event) => setForm({ ...form, groundFriction: event.target.value })} />
|
||||||
|
<input value={form.groundSlopeLimit} onChange={(event) => setForm({ ...form, groundSlopeLimit: event.target.value })} />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="碰撞体">
|
||||||
|
<select
|
||||||
|
value={form.colliders}
|
||||||
|
onChange={(event) => setForm({ ...form, colliders: event.target.value as SceneFormState['colliders'] })}
|
||||||
|
>
|
||||||
|
<option value="auto">auto</option>
|
||||||
|
<option value="mesh">mesh</option>
|
||||||
|
<option value="none">none</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{message ? <p className="muted small">{message}</p> : null}
|
||||||
|
{error ? <ErrorBox message={error} /> : null}
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-primary" disabled={saving} onClick={save}>
|
||||||
|
{saving ? '保存中…' : '保存参数'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScenesPage(): JSX.Element {
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [status, setStatus] = useState<PublishStatus | ''>('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
const [detailId, setDetailId] = useState<number | null>(null);
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const state = useAsync(() => listScenes({ q, status, page, pageSize: 20 }), [q, status, page]);
|
||||||
|
const items = state.data?.items ?? [];
|
||||||
|
const detail = items.find((item) => item.id === detailId) ?? null;
|
||||||
|
|
||||||
|
const remove = async (scene: Scene): Promise<void> => {
|
||||||
|
setBusyId(scene.id);
|
||||||
|
try {
|
||||||
|
await deleteScene(scene.id);
|
||||||
|
state.reload();
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>场景管理</h1>
|
||||||
|
<p className="muted">
|
||||||
|
场景以 <strong>GLB</strong> 为主(不做 humanoid 重绑定、不转 VRM),并携带物理参数:
|
||||||
|
重力、风向动态、地面与碰撞体。服务端只存参数,仿真在 Windows 客户端。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setUploadOpen((value) => !value)}>
|
||||||
|
{uploadOpen ? '收起上传' : '上传场景'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{uploadOpen ? (
|
||||||
|
<section className="panel">
|
||||||
|
<header className="panel-head">
|
||||||
|
<h2>上传场景</h2>
|
||||||
|
<span className="muted">POST /api/scenes(multipart)</span>
|
||||||
|
</header>
|
||||||
|
<SceneUploadForm
|
||||||
|
onSaved={() => {
|
||||||
|
setUploadOpen(false);
|
||||||
|
state.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<header className="panel-head">
|
||||||
|
<h2>场景列表</h2>
|
||||||
|
<div className="filters">
|
||||||
|
<input
|
||||||
|
placeholder="搜索名称 / 作者"
|
||||||
|
value={q}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQ(event.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(event) => {
|
||||||
|
setStatus(event.target.value as PublishStatus | '');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
{STATUS_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{state.error ? <ErrorBox message={state.error} onRetry={state.reload} /> : null}
|
||||||
|
{state.loading && !state.data ? <Loading text="正在加载场景…" /> : null}
|
||||||
|
{!state.loading && !state.error && items.length === 0 ? (
|
||||||
|
<Empty text="还没有场景,点击右上角「上传场景」(.glb 为主)。" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>预览</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>物理参数</th>
|
||||||
|
<th>体积</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th className="col-actions">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((scene) => (
|
||||||
|
<tr key={scene.id}>
|
||||||
|
<td>
|
||||||
|
{scene.thumbnailUrl ? (
|
||||||
|
<img
|
||||||
|
className="thumb-40 thumb-clickable"
|
||||||
|
src={scene.thumbnailUrl}
|
||||||
|
alt={`${scene.name} 缩略图`}
|
||||||
|
title="点击查看详情与参数"
|
||||||
|
onClick={() => setDetailId(detailId === scene.id ? null : scene.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="muted small">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="cell-title">{scene.name}</div>
|
||||||
|
<div className="muted small">
|
||||||
|
#{scene.id} · {formatDateTime(scene.updatedAt)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="mono small">{physicsSummary(scene.config)}</td>
|
||||||
|
<td className="mono">{formatBytes(scene.fileSize)}</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={scene.status} />
|
||||||
|
</td>
|
||||||
|
<td className="col-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link-btn"
|
||||||
|
onClick={() => setDetailId(detailId === scene.id ? null : scene.id)}
|
||||||
|
>
|
||||||
|
{detailId === scene.id ? '收起详情' : '详情 / 预览'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link-btn"
|
||||||
|
disabled={busyId === scene.id}
|
||||||
|
onClick={() => remove(scene)}
|
||||||
|
title="DELETE /api/scenes/:id(软删除)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{detail ? (
|
||||||
|
<div className="panel-body scene-detail-panel">
|
||||||
|
<header className="panel-head">
|
||||||
|
<h3>场景详情 #{detail.id}</h3>
|
||||||
|
<span className="muted">参数变更走 PATCH /api/scenes/:id;状态变更走审核页</span>
|
||||||
|
</header>
|
||||||
|
<SceneDetail scene={detail} onChanged={state.reload} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1539,3 +1539,56 @@ textarea {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-mute);
|
color: var(--text-mute);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- 场景管理(T-WEB-015) ---- */
|
||||||
|
.scene-detail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.scene-detail {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-detail-panel {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-preview {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #181a20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-inputs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-inputs input {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters input,
|
||||||
|
.filters select {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opendance-web-server",
|
"name": "opendance-web-server",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)",
|
"description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { normalizeSceneConfig, SCENE_CONFIG_DEFAULTS } from '../src/scenes/scene-config';
|
import { normalizeSceneConfig, SCENE_CONFIG_DEFAULTS } from '../src/scenes/scene-config';
|
||||||
|
import { DEFAULT_SCENE_CONFIG, physicsSummary } from '../admin/src/lib/scene-physics';
|
||||||
import { toSceneDto } from '../src/scenes/scene.serializer';
|
import { toSceneDto } from '../src/scenes/scene.serializer';
|
||||||
import { PublishStatus, SourceType } from '../src/common/enums';
|
import { PublishStatus, SourceType } from '../src/common/enums';
|
||||||
import type { Scene } from '../src/entities/scene.entity';
|
import type { Scene } from '../src/entities/scene.entity';
|
||||||
@@ -105,3 +106,34 @@ describe('SceneDto 序列化', () => {
|
|||||||
expect(dto.createdAt).toBe('2026-01-01T00:00:00.000Z');
|
expect(dto.createdAt).toBe('2026-01-01T00:00:00.000Z');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('场景列表的物理摘要(管理端)', () => {
|
||||||
|
it('展示重力与风速,并在有阵风时标注幅度/频率', () => {
|
||||||
|
const calm = physicsSummary(normalizeSceneConfig(undefined));
|
||||||
|
expect(calm).toContain('重力 -9.8');
|
||||||
|
expect(calm).toContain('风 0.0m/s');
|
||||||
|
expect(calm).not.toContain('阵风');
|
||||||
|
|
||||||
|
const windy = physicsSummary(
|
||||||
|
normalizeSceneConfig({
|
||||||
|
physics: {
|
||||||
|
wind: {
|
||||||
|
base: { x: 3, y: 0, z: 4 },
|
||||||
|
gustAmplitude: 2.5,
|
||||||
|
gustFrequency: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(windy).toContain('风 5.0m/s');
|
||||||
|
expect(windy).toContain('阵风 2.5m/s@0.5Hz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没有 config 时显示占位符(不抛错)', () => {
|
||||||
|
expect(physicsSummary(null)).toBe('—');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('管理端默认值与服务端一致(契约 7.1 单一真源)', () => {
|
||||||
|
expect(DEFAULT_SCENE_CONFIG).toEqual(SCENE_CONFIG_DEFAULTS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user