按《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。
676 lines
24 KiB
TypeScript
676 lines
24 KiB
TypeScript
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>
|
||
);
|
||
}
|