diff --git a/README.md b/README.md index 9410049..cee71dc 100644 --- a/README.md +++ b/README.md @@ -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): @@ -109,15 +115,15 @@ v0.2.0 主要变更: 3. 取得发布包并校验(地址用你们自己的制品库替换; 下面是本仓库的发布地址) ```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.6.0/SHA256SUMS + 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.7.0/SHA256SUMS sha256sum -c SHA256SUMS # 期望输出 "OK" ``` 4. 解压并生成配置(交互式引导,逐项问答,口令不回显) ```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 ``` @@ -187,8 +193,8 @@ FLUSH PRIVILEGES; ### 5. 部署步骤 ```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 # 首次启动引导: 逐项问答生成 deploy/.env(见下) bash deploy/install.sh # 已以 root 登录, 不需要 sudo ``` @@ -361,12 +367,12 @@ npm run dev # http://localhost:5173(/api 与 /health 代理到 3000) cd web-server npm run contracts:check # 契约版本一致性 npm run typecheck # TypeScript 严格检查 -npm run test # 243 项单元测试 / 19 个文件(VMD 31、模型压缩包 27、 +npm run test # 245 项单元测试 / 19 个文件(VMD 31、模型压缩包 27、 # 模型检查器 25、缩略图 8、骨骼过滤 8、 # 预处理 25、init-env 16、admin-port 16、 # access-log 9、VRM 检查器 6、.env 预检 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 start:prod & # 启动 npm run test:smoke # 57 项端到端冒烟(需要数据库可连通) diff --git a/README.txt b/README.txt index fae8d46..82b42dc 100644 --- a/README.txt +++ b/README.txt @@ -1,5 +1,5 @@ ================================================================ -OpenDance Studio -- Web 管理服务端 v0.6.0 使用说明 +OpenDance Studio -- Web 管理服务端 v0.7.0 使用说明 ================================================================ 本服务端是 OpenDance Studio 三端中的第一端, 负责管理歌曲、VRM 模型与 @@ -8,6 +8,13 @@ OpenDance Studio -- Web 管理服务端 v0.6.0 使用说明 重要: 本服务端 不部署、不管理 MySQL。数据库为部署方提供的现有实例, 连接信息通过 .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) 1. 场景以 GLB 为主(不做 humanoid 重绑定、不转 VRM): .glb/.gltf 直接导入, .zip/.pmx/.fbx 由服务端转 GLB。 @@ -136,11 +143,11 @@ v0.2.1 修订 2. 准备一个现有的 MySQL / MariaDB 实例, 并建好库与账号(见下方"前置要求") 本项目不会创建数据库本身 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 sha256sum -c SHA256SUMS # 期望输出 "OK" 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 5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查) bash deploy/install.sh @@ -198,8 +205,8 @@ v0.2.1 修订 (本项目不会创建数据库本身, 也不需要 GRANT OPTION / FILE / SUPER 等管理权限) 部署步骤 - 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 # 首次启动引导: 逐项问答生成 deploy/.env(见下) bash deploy/install.sh # 已以 root 登录, 不需要 sudo @@ -374,12 +381,12 @@ deploy/.env 初始化引导(deploy/init-env.sh) cd web-server npm run contracts:check # 契约版本一致性 npm run typecheck # TypeScript 严格检查 - npm run test # 243 项单元测试 / 19 个文件(VMD 31、模型压缩包 24、 + npm run test # 245 项单元测试 / 19 个文件(VMD 31、模型压缩包 24、 # 模型检查器 25、缩略图 8、骨骼过滤 8、 # 预处理 25、init-env 16、admin-port 16、 # access-log 9、VRM 检查器 6、.env 预检 12、 # 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 start:prod & # 启动 npm run test:smoke # 57 项端到端冒烟(需要数据库可连通) diff --git a/project.yaml b/project.yaml index a1bdffc..07701ac 100644 --- a/project.yaml +++ b/project.yaml @@ -1,5 +1,5 @@ project: opendance-web-server -version: 0.6.0 +version: 0.7.0 repository: opendance-web default_branch: main contracts_version: 0.4.0 @@ -60,7 +60,7 @@ tasks: status: done deliverable: - artifact: opendance-web-v0.6.0.tar.gz + artifact: opendance-web-v0.7.0.tar.gz packaging: Linux 业务服务(系统服务托管,双端口:软件 API + 管理端,不含 MySQL、不含反向代理、不使用 Docker) includes: - web-server/ # NestJS 源码 + 构建产物(含 BVH / VMD 内置解析、ZIP 读取与模型压缩包转换) diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 6af8ca0..7feef0e 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -1,6 +1,6 @@ #!/bin/bash # ============================================================ -# 打包发布产物:opendance-web-v0.6.0.tar.gz +# 打包发布产物:opendance-web-v0.7.0.tar.gz # # 产物结构(Linux 业务服务部署,不使用 Docker、不部署反向代理): # web-server/ NestJS 源码 + 构建产物 @@ -21,7 +21,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_ROOT" -VERSION="${1:-0.6.0}" +VERSION="${1:-0.7.0}" NAME="opendance-web-v${VERSION}" OUT_DIR="$REPO_ROOT/dist-release" STAGE="$OUT_DIR/$NAME" diff --git a/web-server/admin/package.json b/web-server/admin/package.json index 0387f51..dbf4cfa 100644 --- a/web-server/admin/package.json +++ b/web-server/admin/package.json @@ -1,7 +1,7 @@ { "name": "opendance-admin", "private": true, - "version": "0.6.0", + "version": "0.7.0", "type": "module", "description": "OpenDance Studio 管理端(React + TypeScript + Vite)", "scripts": { diff --git a/web-server/admin/src/App.tsx b/web-server/admin/src/App.tsx index 7099513..a1f309a 100644 --- a/web-server/admin/src/App.tsx +++ b/web-server/admin/src/App.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { Layout } from './components/Layout'; import { DashboardPage } from './pages/Dashboard'; import { ModelsPage } from './pages/Models'; +import { ScenesPage } from './pages/Scenes'; import { MotionsPage } from './pages/Motions'; import { ReviewsPage } from './pages/Reviews'; import { SongsPage } from './pages/Songs'; @@ -14,6 +15,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/web-server/admin/src/api/endpoints.ts b/web-server/admin/src/api/endpoints.ts index d01fcc5..b90c539 100644 --- a/web-server/admin/src/api/endpoints.ts +++ b/web-server/admin/src/api/endpoints.ts @@ -2,6 +2,10 @@ import { apiDelete, apiGet, apiPatch, apiPost, apiUpload } from './client'; import type { + Scene, + SceneConfig, + ScenePage, + SceneUpdate, ContractVersion, Health, Model, @@ -12,6 +16,7 @@ import type { MotionUpdate, PendingPage, PublishStatus, + SourceType, ReviewActionBody, ReviewPage, Song, @@ -233,3 +238,54 @@ export function getHealth(): Promise { export function getCatalogVersion(): Promise { return apiGet('/api/catalog/version'); } + +/* ------------------------------------------------------------ 场景(T-WEB-015) */ + +export function listScenes( + params: ListParams & { q?: string; status?: PublishStatus | '' }, +): Promise { + return apiGet('/api/scenes', { + page: params.page, + pageSize: params.pageSize, + q: params.q, + status: params.status, + }); +} + +export function getScene(id: number): Promise { + return apiGet(`/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 { + 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('/api/scenes', form); +} + +export function updateScene(id: number, payload: SceneUpdate): Promise { + return apiPatch(`/api/scenes/${id}`, payload); +} + +export function deleteScene(id: number): Promise { + return apiDelete(`/api/scenes/${id}`); +} diff --git a/web-server/admin/src/api/types.ts b/web-server/admin/src/api/types.ts index 1efc6b1..ca0c969 100644 --- a/web-server/admin/src/api/types.ts +++ b/web-server/admin/src/api/types.ts @@ -303,3 +303,59 @@ export interface ApiErrorBody { message?: string | 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 | 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; +} diff --git a/web-server/admin/src/lib/scene-physics.ts b/web-server/admin/src/lib/scene-physics.ts new file mode 100644 index 0000000..130f90d --- /dev/null +++ b/web-server/admin/src/lib/scene-physics.ts @@ -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}`; +} diff --git a/web-server/admin/src/pages/Scenes.tsx b/web-server/admin/src/pages/Scenes.tsx new file mode 100644 index 0000000..3ca7d7b --- /dev/null +++ b/web-server/admin/src/pages/Scenes.tsx @@ -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 ( + + ); +} + +function SceneUploadForm({ onSaved }: { onSaved: () => void }): JSX.Element { + const [form, setForm] = useState(EMPTY_FORM); + const [file, setFile] = useState(null); + const [errors, setErrors] = useState({}); + const [saving, setSaving] = useState(false); + const [serverError, setServerError] = useState(null); + + const patch = (next: Partial): void => setForm((previous) => ({ ...previous, ...next })); + + const submit = async (event: FormEvent): Promise => { + 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 ( +
+
+ + setFile(event.target.files?.[0] ?? null)} /> + + + patch({ name: event.target.value })} /> + +
+ +

物理参数(服务端只存参数,仿真在客户端)

+
+ + patch({ gravityY: event.target.value })} /> + + + + + +
+ patch({ windX: event.target.value })} /> + patch({ windY: event.target.value })} /> + patch({ windZ: event.target.value })} /> +
+
+ +
+ patch({ gustAmplitude: event.target.value })} /> + patch({ gustFrequency: event.target.value })} /> +
+
+ +
+ patch({ groundFriction: event.target.value })} /> + patch({ groundSlopeLimit: event.target.value })} /> +
+
+ +
+ patch({ ambientColor: event.target.value })} /> + patch({ ambientIntensity: event.target.value })} /> +
+
+
+ +

来源五元组(必填)

+
+ + + + + patch({ sourceName: event.target.value })} /> + + + patch({ sourceUrl: event.target.value })} /> + + + patch({ license: event.target.value })} /> + + + patch({ author: event.target.value })} /> + +
+ + {serverError ? : null} +
+ +
+ + ); +} + +/** 详情:3D 预览 + 参数编辑 */ +function SceneDetail({ scene, onChanged }: { scene: Scene; onChanged: () => void }): JSX.Element { + const initial = scene.config ?? DEFAULT_SCENE_CONFIG; + const [form, setForm] = useState({ + ...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(null); + const [error, setError] = useState(null); + + const save = async (): Promise => { + 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 ( +
+
+ {scene.glbUrl ? ( + }> + + + ) : ( + + )} +
+
+
+
+ 文件 + {formatBytes(scene.fileSize)} +
+
+ 物理摘要 + {physicsSummary(scene.config)} +
+
+ 转换说明 + {scene.conversionNote ?? '—'} +
+
+
+ + setForm({ ...form, name: event.target.value })} /> + + + setForm({ ...form, gravityY: event.target.value })} /> + + +
+ setForm({ ...form, windX: event.target.value })} /> + setForm({ ...form, windY: event.target.value })} /> + setForm({ ...form, windZ: event.target.value })} /> +
+
+ +
+ setForm({ ...form, gustAmplitude: event.target.value })} /> + setForm({ ...form, gustFrequency: event.target.value })} /> +
+
+ +
+ setForm({ ...form, groundFriction: event.target.value })} /> + setForm({ ...form, groundSlopeLimit: event.target.value })} /> +
+
+ + + +
+ {message ?

{message}

: null} + {error ? : null} +
+ +
+
+
+ ); +} + +export function ScenesPage(): JSX.Element { + const [q, setQ] = useState(''); + const [status, setStatus] = useState(''); + const [page, setPage] = useState(1); + const [uploadOpen, setUploadOpen] = useState(false); + const [detailId, setDetailId] = useState(null); + const [busyId, setBusyId] = useState(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 => { + setBusyId(scene.id); + try { + await deleteScene(scene.id); + state.reload(); + } finally { + setBusyId(null); + } + }; + + return ( +
+
+
+

场景管理

+

+ 场景以 GLB 为主(不做 humanoid 重绑定、不转 VRM),并携带物理参数: + 重力、风向动态、地面与碰撞体。服务端只存参数,仿真在 Windows 客户端。 +

+
+ +
+ + {uploadOpen ? ( +
+
+

上传场景

+ POST /api/scenes(multipart) +
+ { + setUploadOpen(false); + state.reload(); + }} + /> +
+ ) : null} + +
+
+

场景列表

+
+ { + setQ(event.target.value); + setPage(1); + }} + /> + +
+
+ + {state.error ? : null} + {state.loading && !state.data ? : null} + {!state.loading && !state.error && items.length === 0 ? ( + + ) : null} + + {items.length > 0 ? ( +
+ + + + + + + + + + + + + {items.map((scene) => ( + + + + + + + + + ))} + +
预览名称物理参数体积状态操作
+ {scene.thumbnailUrl ? ( + {`${scene.name} setDetailId(detailId === scene.id ? null : scene.id)} + /> + ) : ( + — + )} + +
{scene.name}
+
+ #{scene.id} · {formatDateTime(scene.updatedAt)} +
+
{physicsSummary(scene.config)}{formatBytes(scene.fileSize)} + + + + +
+
+ ) : null} + + {detail ? ( +
+
+

场景详情 #{detail.id}

+ 参数变更走 PATCH /api/scenes/:id;状态变更走审核页 +
+ +
+ ) : null} +
+
+ ); +} diff --git a/web-server/admin/src/styles.css b/web-server/admin/src/styles.css index 0ad13c6..0d70edd 100644 --- a/web-server/admin/src/styles.css +++ b/web-server/admin/src/styles.css @@ -1539,3 +1539,56 @@ textarea { font-size: 12px; 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; +} diff --git a/web-server/package.json b/web-server/package.json index 572d6c6..0f494ba 100644 --- a/web-server/package.json +++ b/web-server/package.json @@ -1,6 +1,6 @@ { "name": "opendance-web-server", - "version": "0.6.0", + "version": "0.7.0", "description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)", "license": "MIT", "private": true, diff --git a/web-server/test/scene-config.spec.ts b/web-server/test/scene-config.spec.ts index 983a21e..45c6cba 100644 --- a/web-server/test/scene-config.spec.ts +++ b/web-server/test/scene-config.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; 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 { PublishStatus, SourceType } from '../src/common/enums'; import type { Scene } from '../src/entities/scene.entity'; @@ -105,3 +106,34 @@ describe('SceneDto 序列化', () => { 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); + }); +});