feat(web): T-WEB-008/009 管理端界面(歌曲/模型/动作/审核 + VRM 预览)

This commit is contained in:
2026-09-14 19:00:55 +08:00
parent 0f66b53480
commit 640736c77b
30 changed files with 6704 additions and 0 deletions
+609
View File
@@ -0,0 +1,609 @@
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, ModelUpdate, PublishStatus } from '../api/types';
import { MODEL_FORMATS } from '../api/types';
import { formatBytes, formatDateTime } from '../lib/format';
import { MODEL_FORMAT_LABELS, STATUS_OPTIONS } 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';
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`}>
<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 && previewModel.format === 'fbx'
? 'FBX 无法用 GLTFLoader 预览(契约中 FBX 仅归档)。'
: 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"
onClick={() => setPreviewId(model.id)}
title="点击预览"
>
{model.thumbnailUrl ? (
<img src={model.thumbnailUrl} alt={`${model.name} 缩略图`} />
) : (
<span className="thumb-placeholder">
{MODEL_FORMAT_LABELS[model.format]}
<em>无缩略图</em>
</span>
)}
</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" />
</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 0.x 自动执行 180° 旋转;
使用 <code>requestAnimationFrame</code> 循环调用 <code>vrm.update(delta)</code>,
卸载时释放渲染器、几何体、材质与贴图。
</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>
</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>
);
}