Files
opendance-web/web-server/admin/src/pages/Models.tsx
T
opendance 39bfd390e3 feat(models): 模型一律转 VRM(契约 0.5.0)+ 可用性门槛 + 管理端版本号展示
按《DSH 开发文档 v1.6》4.9 与第十一章实现,Web 端第一步:

1. 契约先行(先改 contracts/ 再同步三端):
   * model-formats.md -> 0.5.0:模型主力格式改为 VRM,所有上传模型一律转 VRM
     (.pmx/.fbx: assimp -> GLB -> VRM;glb/gltf: 包装为 VRM);
     新增 humanoid 硬门槛与"不达标回退 GLB + usable=false"的交付表;
   * data-models.md -> 0.5.0:Model 新增 usable / unusableReason,明确
     usable=false 不进入 catalog/export;format 说明改为主力 vrm;
   * VERSION 与 contract-versions.ts 同步(三端契约文件逐字节一致)。
2. 实现:
   * MODEL_CONVERT_FORMAT 默认改为 vrm(deploy/.env.example 同步);
   * 新增 isUsableModel()/unusableReason():usable 等价于"已产出 VRM",
     原因从 stats 里带出 humanoid 覆盖度;
   * catalog/export 与 catalog/version 指纹都只统计 format=vrm 的已发布模型,
     resolveDefaultModelId 同样只在可用模型中选;
   * 管理端:模型列表与审核面板显示「不可用」及原因;
   * 管理端左下角展示当前版本号(构建期从 web-server/package.json 注入
     __APP_VERSION__,与 GET /health 同源),并去掉写死的"契约 rest-api 0.1.0"。
3. 测试:新增 test/model-usability.spec.ts 5 项;规模 16 文件 / 225 项。
4. 版本 0.3.0。
2026-09-17 00:05:02 +08:00

676 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { lazy, Suspense, useState, type FormEvent } from 'react';
import { errorMessage } from '../api/client';
import { deleteModel, listModels, setPrimaryModel, updateModel, uploadModel } from '../api/endpoints';
import type { Model, ModelFormat, ModelUpdate, PublishStatus } from '../api/types';
import { MODEL_FORMATS } from '../api/types';
import { formatBytes, formatCount, formatDateTime } from '../lib/format';
import { MODEL_FORMAT_LABELS, STATUS_OPTIONS, drivableSummary, type DrivableSummary } from '../lib/labels';
import { hasErrors, requireText, validateSourceFields, type FieldErrors } from '../lib/validation';
import { useAsync } from '../lib/useAsync';
import {
ConfirmDialog,
Empty,
ErrorBox,
Field,
FormatBadge,
Loading,
Modal,
Notice,
Pagination,
SourceCell,
SourceFieldsForm,
StatusBadge,
type SourceFormValue,
} from '../components/ui';
// three + @pixiv/three-vrm 体积较大,按需懒加载,避免拖慢其他页面首屏。
const VrmPreview = lazy(async () => {
const module = await import('../components/VrmPreview');
return { default: module.VrmPreview };
});
const PAGE_SIZE = 20;
const ACCEPT_EXT = '.vrm,.glb,.gltf,.fbx,.zip';
/** 能用 GLTFLoader 预览的格式:GLB / glTF 与 VRM 同属 glTF 家族,仅 FBX 不支持。 */
const PREVIEWABLE_FORMATS: ReadonlyArray<ModelFormat> = ['vrm', 'glb', 'gltf'];
/** 可驱动性 → 徽标配色(绿 / 黄 / 红)。 */
const DRIVABLE_BADGE: Record<DrivableSummary['kind'], string> = {
ok: 'badge-published',
warn: 'badge-pending',
error: 'badge-rejected',
};
interface ModelFilters {
q: string;
format: string;
status: PublishStatus | '';
page: number;
}
interface ModelFormState extends SourceFormValue {
name: string;
status: PublishStatus;
isPrimary: boolean;
}
interface ModelEditState extends SourceFormValue {
name: string;
status: PublishStatus;
}
const EMPTY_MODEL_FORM: ModelFormState = {
name: '',
status: 'draft',
isPrimary: false,
sourceType: '',
sourceName: '',
sourceUrl: '',
license: '',
author: '',
};
function validateModelForm(form: ModelFormState | ModelEditState): FieldErrors {
const errors: FieldErrors = {};
requireText(form.name, 'name', '模型名称', errors, 200);
validateSourceFields(form, errors);
return errors;
}
/* ------------------------------------------------------------ 上传弹窗 */
function ModelUploadModal({
onClose,
onSaved,
}: {
onClose: () => void;
onSaved: (message: string) => void;
}) {
const [form, setForm] = useState<ModelFormState>(EMPTY_MODEL_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<ModelFormState>): void => {
setForm((previous) => ({ ...previous, ...part }));
};
const onSubmit = async (event: FormEvent): Promise<void> => {
event.preventDefault();
const nextErrors = validateModelForm(form);
if (!file) nextErrors.file = '请选择模型文件';
setErrors(nextErrors);
if (hasErrors(nextErrors) || !file) return;
if (form.sourceType === '') return;
setSaving(true);
setServerError(null);
try {
await uploadModel({
file,
name: form.name.trim(),
isPrimary: form.isPrimary,
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="model-upload-form" className="btn btn-primary" disabled={saving}>
{saving ? '上传中…' : '上传'}
</button>
</>
}
>
<form id="model-upload-form" className="form" onSubmit={onSubmit}>
{serverError ? <Notice kind="error">{serverError}</Notice> : null}
<div className="form-grid">
<Field
label="模型文件 file"
required
error={errors.file}
hint={`支持 ${ACCEPT_EXT},单文件上限 512 MiB;上传 .zip(内含 .pmx 的 MMD 模型包)时服务端会自动解压并转换,转换成功即设为默认模型`}
>
<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="状态 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="设为全局默认模型 isPrimary" hint="同一时刻全库至多一个默认模型(VRM 优先)">
<input
type="checkbox"
checked={form.isPrimary}
onChange={(event) => patch({ isPrimary: event.target.checked })}
/>
</Field>
</div>
<SourceFieldsForm value={form} errors={errors} onChange={patch} disabled={saving} />
</form>
</Modal>
);
}
/* ------------------------------------------------------------ 编辑弹窗 */
function ModelEditModal({
model,
onClose,
onSaved,
}: {
model: Model;
onClose: () => void;
onSaved: (message: string) => void;
}) {
const [form, setForm] = useState<ModelEditState>({
name: model.name,
status: model.status,
sourceType: model.sourceType,
sourceName: model.sourceName,
sourceUrl: model.sourceUrl,
license: model.license,
author: model.author,
});
const [errors, setErrors] = useState<FieldErrors>({});
const [saving, setSaving] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const patch = (part: Partial<ModelEditState>): void => {
setForm((previous) => ({ ...previous, ...part }));
};
const onSubmit = async (event: FormEvent): Promise<void> => {
event.preventDefault();
const nextErrors = validateModelForm(form);
setErrors(nextErrors);
if (hasErrors(nextErrors) || form.sourceType === '') return;
setSaving(true);
setServerError(null);
try {
const payload: ModelUpdate = {
name: form.name.trim(),
status: form.status,
sourceType: form.sourceType,
sourceName: form.sourceName.trim(),
sourceUrl: form.sourceUrl.trim(),
license: form.license.trim(),
author: form.author.trim(),
};
await updateModel(model.id, payload);
onSaved('模型已更新');
} catch (cause) {
setServerError(errorMessage(cause));
} finally {
setSaving(false);
}
};
return (
<Modal
title={`编辑模型 #${model.id}`}
size="xl"
onClose={onClose}
footer={
<>
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={saving}>
取消
</button>
<button type="submit" form="model-edit-form" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</>
}
>
<form id="model-edit-form" className="form" onSubmit={onSubmit}>
{serverError ? <Notice kind="error">{serverError}</Notice> : null}
<div className="form-grid">
<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="状态 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>
</div>
<SourceFieldsForm value={form} errors={errors} onChange={patch} disabled={saving} />
</form>
</Modal>
);
}
/* ------------------------------------------------------------------ 页面 */
export function ModelsPage() {
const [filters, setFilters] = useState<ModelFilters>({ q: '', format: '', status: '', page: 1 });
const [qInput, setQInput] = useState('');
const [previewId, setPreviewId] = useState<number | null>(null);
const [uploadOpen, setUploadOpen] = useState(false);
const [editTarget, setEditTarget] = useState<Model | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Model | 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(
() =>
listModels({
page: filters.page,
pageSize: PAGE_SIZE,
q: filters.q,
format: filters.format,
status: filters.status,
}),
[filters.q, filters.format, filters.status, filters.page],
);
const items = state.data?.items ?? [];
const previewModel = items.find((model) => model.id === previewId) ?? null;
const onSaved = (message: string): void => {
setUploadOpen(false);
setEditTarget(null);
setNotice({ kind: 'success', text: message });
state.reload();
};
const makePrimary = async (model: Model): Promise<void> => {
setBusyId(model.id);
try {
await setPrimaryModel(model.id);
setNotice({ kind: 'success', text: `已将「${model.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 deleteModel(deleteTarget.id);
if (previewId === deleteTarget.id) setPreviewId(null);
setNotice({ kind: 'success', text: `已删除模型「${deleteTarget.name}」(软删除)` });
setDeleteTarget(null);
state.reload();
} catch (cause) {
setNotice({ kind: 'error', text: errorMessage(cause) });
} finally {
setDeleting(false);
}
};
const previewUnsupported =
previewModel && !PREVIEWABLE_FORMATS.includes(previewModel.format)
? `${MODEL_FORMAT_LABELS[previewModel.format]} 无法用 GLTFLoader 预览(契约中 FBX 仅归档,请先转换为 GLB/VRM)。`
: null;
return (
<div className="page">
<header className="page-head">
<div>
<h1>模型管理</h1>
<p className="muted">T-WEB-009 · VRM 优先,来源五元组必填,支持 3D 预览与全局默认模型</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>
{MODEL_FORMATS.map((format) => (
<option key={format} value={format}>
{MODEL_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>
<div className="split-layout">
<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="还没有模型,点击右上角上传第一个 VRM 模型。" />
) : null}
{items.length > 0 ? (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>预览缩略图</th>
<th className="col-name">名称</th>
<th>格式</th>
<th>VRM 版本</th>
<th>是否默认</th>
<th className="col-source">来源</th>
<th>许可</th>
<th>状态</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{items.map((model) => (
<tr key={model.id} className={model.id === previewId ? 'row-active' : undefined}>
<td>
<button
type="button"
className="thumb-link"
onClick={() => setPreviewId(model.id)}
title="点击预览"
>
{model.thumbnailUrl ? (
<img
className="thumb-sm"
src={model.thumbnailUrl}
alt={`${model.name} 缩略图`}
/>
) : (
<FormatBadge format={model.format} kind="model" />
)}
</button>
</td>
<td className="col-name">
<div className="cell-title">{model.name}</div>
<div className="muted small">
#{model.id} · {formatBytes(model.fileSize)} · {formatDateTime(model.updatedAt)}
</div>
</td>
<td>
<FormatBadge format={model.format} kind="model" />
{!model.usable ? (
<span
className="badge badge-rejected"
title={model.unusableReason ?? '未产出 VRM,不进入客户端目录'}
>
不可用
</span>
) : null}
</td>
<td className="mono">{model.vrmVersion ?? '—'}</td>
<td>
{model.isPrimary ? (
<span className="badge badge-primary">默认 ★</span>
) : (
<span className="muted">—</span>
)}
</td>
<td className="col-source">
<SourceCell source={model} />
</td>
<td>
<div className="mono small">{model.license || '—'}</div>
</td>
<td>
<StatusBadge status={model.status} />
</td>
<td className="col-actions">
<button type="button" className="link-btn" onClick={() => setPreviewId(model.id)}>
预览
</button>
<button
type="button"
className="link-btn"
onClick={() => makePrimary(model)}
disabled={model.isPrimary || busyId === model.id}
title={model.isPrimary ? '已是默认模型' : 'POST /api/models/:id/primary'}
>
{busyId === model.id ? '设置中…' : '设为默认'}
</button>
<button type="button" className="link-btn" onClick={() => setEditTarget(model)}>
编辑
</button>
<button type="button" className="link-btn link-danger" onClick={() => setDeleteTarget(model)}>
删除
</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}
</section>
<aside className="preview-column">
<Suspense
fallback={
<div className="panel">
<Loading text="正在加载 3D 预览模块…" />
</div>
}
>
<VrmPreview
fileUrl={previewModel?.fileUrl ?? null}
name={previewModel ? `${previewModel.name}(#${previewModel.id})` : ''}
unsupportedReason={previewUnsupported}
/>
</Suspense>
<div className="panel preview-meta">
<header className="panel-head">
<h2>预览说明</h2>
</header>
<div className="panel-body">
<p className="muted small">
通过 GLTFLoader + VRMLoaderPlugin 加载 <code>fileUrl</code>:VRM / GLB / glTF 同属 glTF
家族,都走这条管线(非 VRM 的 GLB 按普通 glTF 渲染,VRM 0.x 自动执行 180° 旋转);
使用 <code>requestAnimationFrame</code> 循环调用 <code>vrm.update(delta)</code>,
卸载时释放渲染器、几何体、材质与贴图。FBX 不在预览范围内。
</p>
{previewModel ? (
<ul className="kv-list">
<li>
<span className="muted">文件地址</span>
<span className="break">{previewModel.fileUrl ?? '—'}</span>
</li>
<li>
<span className="muted">格式 / VRM 版本</span>
<span>
{MODEL_FORMAT_LABELS[previewModel.format]} / {previewModel.vrmVersion ?? '—'}
</span>
</li>
<li>
<span className="muted">是否默认</span>
<span>{previewModel.isPrimary ? '是' : '否'}</span>
</li>
{previewModel.stats ? (
<>
<li>
<span className="muted">面数 / 顶点数</span>
<span className="mono">
{formatCount(previewModel.stats.triangles)} 面 ·{' '}
{formatCount(previewModel.stats.vertices)} 点
</span>
</li>
<li>
<span className="muted">骨骼 / 节点</span>
<span className="mono">
{formatCount(previewModel.stats.bones)} 根 ·{' '}
{formatCount(previewModel.stats.nodes)} 节点
</span>
</li>
<li>
<span className="muted">可驱动性</span>
<span className={`badge ${DRIVABLE_BADGE[drivableSummary(previewModel.stats).kind]}`}>
{drivableSummary(previewModel.stats).text}
</span>
</li>
</>
) : (
<li>
<span className="muted">分析数据</span>
<span className="muted">stats 为空(旧数据或后端尚未分析)</span>
</li>
)}
{previewModel.conversionNote ? (
<li>
<span className="muted">转换来源</span>
<span className="break">{previewModel.conversionNote}</span>
</li>
) : null}
{previewModel.fileSha256 ? (
<li>
<span className="muted">文件 sha256</span>
<span className="mono break">{previewModel.fileSha256}</span>
</li>
) : null}
</ul>
) : (
<p className="muted small">尚未选择模型。</p>
)}
</div>
</div>
</aside>
</div>
{uploadOpen ? <ModelUploadModal onClose={() => setUploadOpen(false)} onSaved={onSaved} /> : null}
{editTarget ? (
<ModelEditModal model={editTarget} onClose={() => setEditTarget(null)} 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/models/{deleteTarget.id} · 服务端执行软删除。</p>
</>
}
onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
) : null}
</div>
);
}