按《DSH 开发文档 v1.6》4.11(T-WEB-014,用户标注「重要」): 服务端: - 抽出公共 PNG 编码器 src/common/png.ts(模型缩略图改为复用,零原生依赖不变) - 新增 src/motions/motion-thumbnail.ts:关键点序列 -> 正视图骨架 PNG (复用 33 点 PARENT_TABLE 连线,包围盒按整段计算,低置信度点不画) - motions 增 thumbnail_asset_id(迁移 AddMotionThumbnail)+ AssetKind.MotionThumbnail - /preprocess 生成缩略图并落库,失败只记 warning;MotionDto 增 thumbnailUrl(契约 0.6.0) 管理端: - 新增 MotionPreview:懒加载 NDJSON -> 骨架线框 + 关节实例球体 支持播放/暂停、上一帧/下一帧、时间轴拖动、0.25x-2x 变速、循环 叠加帧号/当前时间/总时长,按整段包围盒自动取景 three.js 单独分包(lazy + Suspense),主包 241 kB 不涨 - 动作列表新增 40px 骨架缩略图列(点击开预览)+ 预览按钮 + 内联预览面板 - 新增 admin/src/lib/keypoint-sequence.ts 与 lib/skeleton-topology.ts 测试:新增 8 项;规模 18 文件 / 237 项。版本 0.5.0。
571 lines
19 KiB
TypeScript
571 lines
19 KiB
TypeScript
import { lazy, Suspense, useState, type FormEvent } from 'react';
|
||
import { apiUrl, errorMessage } from '../api/client';
|
||
import {
|
||
deleteMotion,
|
||
listMotions,
|
||
listSongs,
|
||
preprocessMotion,
|
||
uploadMotion,
|
||
} from '../api/endpoints';
|
||
import type { Motion, PublishStatus, Song } from '../api/types';
|
||
import { MOTION_FORMATS } from '../api/types';
|
||
import { formatNumber } from '../lib/format';
|
||
import { MOTION_FORMAT_LABELS, PREPROCESS_LABELS, STATUS_OPTIONS } from '../lib/labels';
|
||
import { hasErrors, parseOptionalNumber, requireText, validateSourceFields, type FieldErrors } from '../lib/validation';
|
||
import { useAsync } from '../lib/useAsync';
|
||
const MotionPreview = lazy(async () => {
|
||
const module = await import('../components/MotionPreview');
|
||
return { default: module.MotionPreview };
|
||
});
|
||
import {
|
||
ConfirmDialog,
|
||
Empty,
|
||
ErrorBox,
|
||
Field,
|
||
FormatBadge,
|
||
Loading,
|
||
Modal,
|
||
Notice,
|
||
Pagination,
|
||
SourceCell,
|
||
SourceFieldsForm,
|
||
StatusBadge,
|
||
type SourceFormValue,
|
||
} from '../components/ui';
|
||
|
||
const PAGE_SIZE = 20;
|
||
const ACCEPT_EXT = '.bvh,.vmd,.fbx,.gltf,.glb,.json';
|
||
|
||
interface MotionFilters {
|
||
q: string;
|
||
format: string;
|
||
status: PublishStatus | '';
|
||
page: number;
|
||
}
|
||
|
||
interface MotionFormState extends SourceFormValue {
|
||
name: string;
|
||
songId: string;
|
||
fps: string;
|
||
autoPreprocess: boolean;
|
||
status: PublishStatus;
|
||
}
|
||
|
||
const EMPTY_MOTION_FORM: MotionFormState = {
|
||
name: '',
|
||
songId: '',
|
||
fps: '',
|
||
autoPreprocess: true,
|
||
status: 'draft',
|
||
sourceType: '',
|
||
sourceName: '',
|
||
sourceUrl: '',
|
||
license: '',
|
||
author: '',
|
||
};
|
||
|
||
function validateMotionForm(form: MotionFormState): FieldErrors {
|
||
const errors: FieldErrors = {};
|
||
requireText(form.name, 'name', '动作名称', errors, 200);
|
||
const fps = parseOptionalNumber(form.fps);
|
||
if (fps !== undefined && (!Number.isFinite(fps) || fps <= 0 || fps > 240)) {
|
||
errors.fps = '帧率需为 0–240 之间的数字';
|
||
}
|
||
validateSourceFields(form, errors);
|
||
return errors;
|
||
}
|
||
|
||
function PreprocessBadge({ motion }: { motion: Motion }) {
|
||
const label = PREPROCESS_LABELS[motion.preprocessState] ?? motion.preprocessState;
|
||
const title =
|
||
motion.preprocessState === 'failed'
|
||
? motion.preprocessError ?? '预处理失败(无错误详情)'
|
||
: motion.preprocessState === 'done'
|
||
? `关键点帧数:${motion.keypointFrames ?? '—'} · 格式:${motion.keypointFormat ?? '—'}`
|
||
: `状态:${motion.preprocessState}`;
|
||
return (
|
||
<span className={`badge badge-pre-${motion.preprocessState}`} title={title}>
|
||
{label}
|
||
{motion.preprocessState === 'running' ? <span className="spinner spinner-xs" aria-hidden="true" /> : null}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 上传弹窗 */
|
||
|
||
function MotionUploadModal({
|
||
songs,
|
||
songsError,
|
||
onClose,
|
||
onSaved,
|
||
}: {
|
||
songs: Song[];
|
||
songsError: string | null;
|
||
onClose: () => void;
|
||
onSaved: (message: string) => void;
|
||
}) {
|
||
const [form, setForm] = useState<MotionFormState>(EMPTY_MOTION_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 = (part: Partial<MotionFormState>): void => {
|
||
setForm((previous) => ({ ...previous, ...part }));
|
||
};
|
||
|
||
const onSubmit = async (event: FormEvent): Promise<void> => {
|
||
event.preventDefault();
|
||
const nextErrors = validateMotionForm(form);
|
||
if (!file) nextErrors.file = '请选择动作文件';
|
||
setErrors(nextErrors);
|
||
if (hasErrors(nextErrors) || !file || form.sourceType === '') return;
|
||
|
||
setSaving(true);
|
||
setServerError(null);
|
||
try {
|
||
await uploadMotion({
|
||
file,
|
||
name: form.name.trim(),
|
||
songId: form.songId === '' ? undefined : Number(form.songId),
|
||
fps: parseOptionalNumber(form.fps),
|
||
autoPreprocess: form.autoPreprocess,
|
||
status: form.status,
|
||
sourceType: form.sourceType,
|
||
sourceName: form.sourceName.trim(),
|
||
sourceUrl: form.sourceUrl.trim(),
|
||
license: form.license.trim(),
|
||
author: form.author.trim(),
|
||
});
|
||
onSaved('动作已上传');
|
||
} catch (cause) {
|
||
setServerError(errorMessage(cause));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Modal
|
||
title="上传动作"
|
||
size="xl"
|
||
onClose={onClose}
|
||
footer={
|
||
<>
|
||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={saving}>
|
||
取消
|
||
</button>
|
||
<button type="submit" form="motion-upload-form" className="btn btn-primary" disabled={saving}>
|
||
{saving ? '上传中…' : '上传'}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<form id="motion-upload-form" className="form" onSubmit={onSubmit}>
|
||
{serverError ? <Notice kind="error">{serverError}</Notice> : null}
|
||
{songsError ? (
|
||
<Notice kind="info">曲目列表加载失败,关联曲目暂不可选:{songsError}</Notice>
|
||
) : null}
|
||
|
||
<div className="form-grid">
|
||
<Field label="动作文件 file" required error={errors.file} hint={`支持 ${ACCEPT_EXT}`}>
|
||
<input
|
||
type="file"
|
||
accept={ACCEPT_EXT}
|
||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||
/>
|
||
</Field>
|
||
<Field label="动作名称 name" required error={errors.name}>
|
||
<input
|
||
type="text"
|
||
value={form.name}
|
||
maxLength={200}
|
||
onChange={(event) => patch({ name: event.target.value })}
|
||
/>
|
||
</Field>
|
||
<Field label="关联曲目 songId" hint="可留空">
|
||
<select value={form.songId} onChange={(event) => patch({ songId: event.target.value })}>
|
||
<option value="">不关联</option>
|
||
{songs.map((song) => (
|
||
<option key={song.id} value={String(song.id)}>
|
||
#{song.id} {song.title} — {song.artist}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
<Field label="帧率 fps" error={errors.fps} hint="留空则服务端按缺省 30 处理">
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
min="0"
|
||
value={form.fps}
|
||
onChange={(event) => patch({ fps: event.target.value })}
|
||
/>
|
||
</Field>
|
||
<Field label="状态 status">
|
||
<select value={form.status} onChange={(event) => patch({ status: event.target.value as PublishStatus })}>
|
||
{STATUS_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
<Field label="上传后自动预处理 autoPreprocess" hint="POST /api/motions/:id/preprocess">
|
||
<input
|
||
type="checkbox"
|
||
checked={form.autoPreprocess}
|
||
onChange={(event) => patch({ autoPreprocess: event.target.checked })}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
<SourceFieldsForm value={form} errors={errors} onChange={patch} disabled={saving} />
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ 页面 */
|
||
|
||
export function MotionsPage() {
|
||
const [filters, setFilters] = useState<MotionFilters>({ q: '', format: '', status: '', page: 1 });
|
||
const [qInput, setQInput] = useState('');
|
||
const [uploadOpen, setUploadOpen] = useState(false);
|
||
const [previewId, setPreviewId] = useState<number | null>(null);
|
||
const [deleteTarget, setDeleteTarget] = useState<Motion | null>(null);
|
||
const [busyId, setBusyId] = useState<number | null>(null);
|
||
const [deleting, setDeleting] = useState(false);
|
||
const [notice, setNotice] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
|
||
|
||
const state = useAsync(
|
||
() =>
|
||
listMotions({
|
||
page: filters.page,
|
||
pageSize: PAGE_SIZE,
|
||
q: filters.q,
|
||
format: filters.format,
|
||
status: filters.status,
|
||
}),
|
||
[filters.q, filters.format, filters.status, filters.page],
|
||
);
|
||
|
||
// 关联曲目下拉 & 表格内的曲名映射
|
||
const songsState = useAsync(() => listSongs({ page: 1, pageSize: 200 }), []);
|
||
const songs = songsState.data?.items ?? [];
|
||
const songTitle = (songId: number | null): string => {
|
||
if (songId === null || songId === undefined) return '—';
|
||
const song = songs.find((item) => item.id === songId);
|
||
return song ? `#${song.id} ${song.title}` : `#${songId}`;
|
||
};
|
||
|
||
const items = state.data?.items ?? [];
|
||
|
||
const onSaved = (message: string): void => {
|
||
setUploadOpen(false);
|
||
setNotice({ kind: 'success', text: message });
|
||
state.reload();
|
||
};
|
||
|
||
const runPreprocess = async (motion: Motion): Promise<void> => {
|
||
setBusyId(motion.id);
|
||
try {
|
||
await preprocessMotion(motion.id);
|
||
setNotice({ kind: 'success', text: `已触发预处理:「${motion.name}」` });
|
||
state.reload();
|
||
} catch (cause) {
|
||
setNotice({ kind: 'error', text: errorMessage(cause) });
|
||
} finally {
|
||
setBusyId(null);
|
||
}
|
||
};
|
||
|
||
const confirmDelete = async (): Promise<void> => {
|
||
if (!deleteTarget) return;
|
||
setDeleting(true);
|
||
try {
|
||
await deleteMotion(deleteTarget.id);
|
||
setNotice({ kind: 'success', text: `已删除动作「${deleteTarget.name}」(软删除)` });
|
||
setDeleteTarget(null);
|
||
state.reload();
|
||
} catch (cause) {
|
||
setNotice({ kind: 'error', text: errorMessage(cause) });
|
||
} finally {
|
||
setDeleting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="page">
|
||
<header className="page-head">
|
||
<div>
|
||
<h1>动作管理</h1>
|
||
<p className="muted">T-WEB-004/006 · 动作文件、MediaPipe33 预处理与关键点下载</p>
|
||
</div>
|
||
<div className="page-head-actions">
|
||
<button type="button" className="btn btn-ghost" onClick={state.reload} disabled={state.loading}>
|
||
{state.loading ? '刷新中…' : '刷新'}
|
||
</button>
|
||
<button type="button" className="btn btn-primary" onClick={() => setUploadOpen(true)}>
|
||
+ 上传动作
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{notice ? (
|
||
<Notice kind={notice.kind} onClose={() => setNotice(null)}>
|
||
{notice.text}
|
||
</Notice>
|
||
) : null}
|
||
|
||
<form
|
||
className="filter-bar"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
setFilters((previous) => ({ ...previous, q: qInput.trim(), page: 1 }));
|
||
}}
|
||
>
|
||
<input
|
||
type="search"
|
||
placeholder="搜索动作名称(q)"
|
||
value={qInput}
|
||
onChange={(event) => setQInput(event.target.value)}
|
||
/>
|
||
<select
|
||
value={filters.format}
|
||
onChange={(event) =>
|
||
setFilters((previous) => ({ ...previous, format: event.target.value, page: 1 }))
|
||
}
|
||
>
|
||
<option value="">全部格式</option>
|
||
{MOTION_FORMATS.map((format) => (
|
||
<option key={format} value={format}>
|
||
{MOTION_FORMAT_LABELS[format]}({format})
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={filters.status}
|
||
onChange={(event) =>
|
||
setFilters((previous) => ({
|
||
...previous,
|
||
status: event.target.value as PublishStatus | '',
|
||
page: 1,
|
||
}))
|
||
}
|
||
>
|
||
<option value="">全部状态</option>
|
||
{STATUS_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button type="submit" className="btn btn-ghost">
|
||
查询
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost"
|
||
onClick={() => {
|
||
setQInput('');
|
||
setFilters({ q: '', format: '', status: '', page: 1 });
|
||
}}
|
||
>
|
||
重置
|
||
</button>
|
||
</form>
|
||
|
||
<section className="panel">
|
||
{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="还没有动作数据,点击右上角上传 BVH / FBX / glTF 或关键点 JSON。" />
|
||
) : null}
|
||
|
||
{items.length > 0 ? (
|
||
<div className="table-wrap">
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>预览</th>
|
||
|
||
<th>名称</th>
|
||
<th>关联曲目</th>
|
||
<th>格式</th>
|
||
<th>帧率 / 帧数</th>
|
||
<th>预处理状态</th>
|
||
<th className="col-source">来源五元组</th>
|
||
<th>状态</th>
|
||
<th className="col-actions">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{items.map((motion) => {
|
||
const keypointsReady = motion.preprocessState === 'done';
|
||
return (
|
||
<tr key={motion.id}>
|
||
<td>
|
||
|
||
{motion.thumbnailUrl ? (
|
||
|
||
<img
|
||
|
||
className="thumb-40 thumb-clickable"
|
||
|
||
src={motion.thumbnailUrl}
|
||
|
||
alt={`${motion.name} 首帧骨架`}
|
||
|
||
title="预处理时生成的骨架缩略图(点击预览)"
|
||
|
||
onClick={() => setPreviewId(previewId === motion.id ? null : motion.id)}
|
||
|
||
/>
|
||
|
||
) : (
|
||
|
||
<span className="muted small" title="预处理后生成">—</span>
|
||
|
||
)}
|
||
|
||
</td>
|
||
<td>
|
||
<div className="cell-title">{motion.name}</div>
|
||
<div className="muted small">#{motion.id}</div>
|
||
</td>
|
||
<td>{songTitle(motion.songId)}</td>
|
||
<td>
|
||
<FormatBadge format={motion.format} kind="motion" />
|
||
</td>
|
||
<td className="mono">
|
||
{formatNumber(motion.fps)} fps
|
||
<div className="muted small">{motion.frameCount ?? '—'} 帧</div>
|
||
</td>
|
||
<td>
|
||
<PreprocessBadge motion={motion} />
|
||
{motion.preprocessState === 'failed' && motion.preprocessError ? (
|
||
<div className="muted small break" title={motion.preprocessError}>
|
||
{motion.preprocessError}
|
||
</div>
|
||
) : null}
|
||
</td>
|
||
<td className="col-source">
|
||
<SourceCell source={motion} />
|
||
</td>
|
||
<td>
|
||
<StatusBadge status={motion.status} />
|
||
</td>
|
||
<td className="col-actions">
|
||
<button
|
||
|
||
type="button"
|
||
|
||
className="link-btn"
|
||
|
||
disabled={!keypointsReady}
|
||
|
||
onClick={() => setPreviewId(previewId === motion.id ? null : motion.id)}
|
||
|
||
title={keypointsReady ? '关键点 3D 回放(播放 / 逐帧 / 时间轴 / 变速 / 循环)' : '预处理完成后才可预览'}
|
||
|
||
>
|
||
|
||
{previewId === motion.id ? '收起预览' : '预览'}
|
||
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="link-btn"
|
||
disabled={busyId === motion.id || motion.preprocessState === 'running'}
|
||
onClick={() => runPreprocess(motion)}
|
||
title="POST /api/motions/:id/preprocess"
|
||
>
|
||
{busyId === motion.id ? '触发中…' : '预处理'}
|
||
</button>
|
||
{keypointsReady ? (
|
||
<a
|
||
className="link-btn"
|
||
href={apiUrl(`/api/motions/${motion.id}/keypoints`)}
|
||
download={`motion-${motion.id}-keypoints.ndjson`}
|
||
title="GET /api/motions/:id/keypoints(application/x-ndjson)"
|
||
>
|
||
下载关键点
|
||
</a>
|
||
) : (
|
||
<span className="link-btn link-disabled" title="预处理完成后才可下载(否则服务端返回 409)">
|
||
下载关键点
|
||
</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="link-btn link-danger"
|
||
onClick={() => setDeleteTarget(motion)}
|
||
>
|
||
删除
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : null}
|
||
|
||
{state.data ? (
|
||
<Pagination
|
||
page={state.data.page ?? filters.page}
|
||
pageSize={state.data.pageSize ?? PAGE_SIZE}
|
||
total={state.data.total ?? 0}
|
||
disabled={state.loading}
|
||
onChange={(page) => setFilters((previous) => ({ ...previous, page }))}
|
||
/>
|
||
) : null}
|
||
|
||
{previewId !== null ? (
|
||
<div className="panel-body motion-preview-panel">
|
||
<header className="panel-head">
|
||
<h3>动作预览 #{previewId}</h3>
|
||
<button type="button" className="link-btn" onClick={() => setPreviewId(null)}>
|
||
关闭
|
||
</button>
|
||
</header>
|
||
<Suspense fallback={<Loading text="正在加载 3D 预览…" />}>
|
||
<MotionPreview motionId={previewId} />
|
||
</Suspense>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
{uploadOpen ? (
|
||
<MotionUploadModal
|
||
songs={songs}
|
||
songsError={songsState.error}
|
||
onClose={() => setUploadOpen(false)}
|
||
onSaved={onSaved}
|
||
/>
|
||
) : null}
|
||
|
||
{deleteTarget ? (
|
||
<ConfirmDialog
|
||
title="删除动作"
|
||
danger
|
||
busy={deleting}
|
||
confirmText="删除"
|
||
message={
|
||
<>
|
||
<p>
|
||
确认删除动作 <strong>{deleteTarget.name}</strong>(#{deleteTarget.id})?
|
||
</p>
|
||
<p className="muted small">DELETE /api/motions/{deleteTarget.id} · 服务端执行软删除。</p>
|
||
</>
|
||
}
|
||
onConfirm={confirmDelete}
|
||
onCancel={() => setDeleteTarget(null)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|