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 ( {label} {motion.preprocessState === 'running' ? ); } /* ------------------------------------------------------------ 上传弹窗 */ function MotionUploadModal({ songs, songsError, onClose, onSaved, }: { songs: Song[]; songsError: string | null; onClose: () => void; onSaved: (message: string) => void; }) { const [form, setForm] = useState(EMPTY_MOTION_FORM); const [file, setFile] = useState(null); const [errors, setErrors] = useState({}); const [saving, setSaving] = useState(false); const [serverError, setServerError] = useState(null); const patch = (part: Partial): void => { setForm((previous) => ({ ...previous, ...part })); }; const onSubmit = async (event: FormEvent): Promise => { 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 ( } >
{serverError ? {serverError} : null} {songsError ? ( 曲目列表加载失败,关联曲目暂不可选:{songsError} ) : null}
setFile(event.target.files?.[0] ?? null)} /> patch({ name: event.target.value })} /> patch({ fps: event.target.value })} /> patch({ autoPreprocess: event.target.checked })} />
); } /* ------------------------------------------------------------------ 页面 */ export function MotionsPage() { const [filters, setFilters] = useState({ q: '', format: '', status: '', page: 1 }); const [qInput, setQInput] = useState(''); const [uploadOpen, setUploadOpen] = useState(false); const [previewId, setPreviewId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [busyId, setBusyId] = useState(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 => { 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 => { 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 (

动作管理

T-WEB-004/006 · 动作文件、MediaPipe33 预处理与关键点下载

{notice ? ( setNotice(null)}> {notice.text} ) : null}
{ event.preventDefault(); setFilters((previous) => ({ ...previous, q: qInput.trim(), page: 1 })); }} > setQInput(event.target.value)} />
{state.error ? : null} {state.loading && !state.data ? : null} {!state.loading && !state.error && items.length === 0 ? ( ) : null} {items.length > 0 ? (
{items.map((motion) => { const keypointsReady = motion.preprocessState === 'done'; return ( ); })}
预览 名称 关联曲目 格式 帧率 / 帧数 预处理状态 来源五元组 状态 操作
{motion.thumbnailUrl ? ( {`${motion.name} setPreviewId(previewId === motion.id ? null : motion.id)} /> ) : ( — )}
{motion.name}
#{motion.id}
{songTitle(motion.songId)} {formatNumber(motion.fps)} fps
{motion.frameCount ?? '—'} 帧
{motion.preprocessState === 'failed' && motion.preprocessError ? (
{motion.preprocessError}
) : null}
{keypointsReady ? ( 下载关键点 ) : ( 下载关键点 )}
) : null} {state.data ? ( setFilters((previous) => ({ ...previous, page }))} /> ) : null} {previewId !== null ? (

动作预览 #{previewId}

}>
) : null}
{uploadOpen ? ( setUploadOpen(false)} onSaved={onSaved} /> ) : null} {deleteTarget ? (

确认删除动作 {deleteTarget.name}(#{deleteTarget.id})?

DELETE /api/motions/{deleteTarget.id} · 服务端执行软删除。

} onConfirm={confirmDelete} onCancel={() => setDeleteTarget(null)} /> ) : null}
); }