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:
@@ -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": {
|
||||
|
||||
@@ -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() {
|
||||
<Route path="songs" element={<SongsPage />} />
|
||||
<Route path="models" element={<ModelsPage />} />
|
||||
<Route path="motions" element={<MotionsPage />} />
|
||||
<Route path="scenes" element={<ScenesPage />} />
|
||||
<Route path="reviews" element={<ReviewsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -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<Health> {
|
||||
export function getCatalogVersion(): Promise<ContractVersion> {
|
||||
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[];
|
||||
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;
|
||||
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",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user