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 = ['vrm', 'glb', 'gltf']; /** 可驱动性 → 徽标配色(绿 / 黄 / 红)。 */ const DRIVABLE_BADGE: Record = { 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(EMPTY_MODEL_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 = 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 ( } >
{serverError ? {serverError} : null}
setFile(event.target.files?.[0] ?? null)} /> patch({ name: event.target.value })} /> patch({ isPrimary: event.target.checked })} />
); } /* ------------------------------------------------------------ 编辑弹窗 */ function ModelEditModal({ model, onClose, onSaved, }: { model: Model; onClose: () => void; onSaved: (message: string) => void; }) { const [form, setForm] = useState({ 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({}); 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 = 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 ( } >
{serverError ? {serverError} : null}
patch({ name: event.target.value })} />
); } /* ------------------------------------------------------------------ 页面 */ export function ModelsPage() { const [filters, setFilters] = useState({ q: '', format: '', status: '', page: 1 }); const [qInput, setQInput] = useState(''); const [previewId, setPreviewId] = useState(null); const [uploadOpen, setUploadOpen] = useState(false); const [editTarget, setEditTarget] = 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( () => 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 => { 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 => { 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 (

模型管理

T-WEB-009 · VRM 优先,来源五元组必填,支持 3D 预览与全局默认模型

{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((model) => ( ))}
预览缩略图 名称 格式 VRM 版本 是否默认 来源 许可 状态 操作
{model.name}
#{model.id} · {formatBytes(model.fileSize)} · {formatDateTime(model.updatedAt)}
{!model.usable ? ( 不可用 ) : null} {model.vrmVersion ?? '—'} {model.isPrimary ? ( 默认 ★ ) : ( — )}
{model.license || '—'}
) : null} {state.data ? ( setFilters((previous) => ({ ...previous, page }))} /> ) : null}
} >

预览说明

通过 GLTFLoader + VRMLoaderPlugin 加载 fileUrl:VRM / GLB / glTF 同属 glTF 家族,都走这条管线(非 VRM 的 GLB 按普通 glTF 渲染,VRM 0.x 自动执行 180° 旋转); 使用 requestAnimationFrame 循环调用 vrm.update(delta), 卸载时释放渲染器、几何体、材质与贴图。FBX 不在预览范围内。

{previewModel ? (
  • 文件地址 {previewModel.fileUrl ?? '—'}
  • 格式 / VRM 版本 {MODEL_FORMAT_LABELS[previewModel.format]} / {previewModel.vrmVersion ?? '—'}
  • 是否默认 {previewModel.isPrimary ? '是' : '否'}
  • {previewModel.stats ? ( <>
  • 面数 / 顶点数 {formatCount(previewModel.stats.triangles)} 面 ·{' '} {formatCount(previewModel.stats.vertices)} 点
  • 骨骼 / 节点 {formatCount(previewModel.stats.bones)} 根 ·{' '} {formatCount(previewModel.stats.nodes)} 节点
  • 可驱动性 {drivableSummary(previewModel.stats).text}
  • ) : (
  • 分析数据 stats 为空(旧数据或后端尚未分析)
  • )} {previewModel.conversionNote ? (
  • 转换来源 {previewModel.conversionNote}
  • ) : null} {previewModel.fileSha256 ? (
  • 文件 sha256 {previewModel.fileSha256}
  • ) : null}
) : (

尚未选择模型。

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

确认删除模型 {deleteTarget.name}(#{deleteTarget.id})?

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

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