feat(models): 发布 v0.2.0 —— GLB/VRM 主力格式、自动缩略图与分析、模型审核与骨骼预览

需求 1:系统确定以 GLB / VRM 为模型主力格式
- 服务端新增支持库:
  - model-inspector.ts:解析 GLB/VRM 的面数、顶点、材质、贴图、形变目标、
    **骨骼树(名称/层级/是否蒙皮/humanoid 映射/子节点数)**、humanoid 覆盖率与可驱动性
  - model-thumbnail.ts:**纯 TS 软件光栅化**(z-buffer + Lambert + 完整蒙皮矩阵)
    生成 512×512 PNG 缩略图,零原生依赖、不装 headless-gl
  - vrm-writer.ts:GLB → VRM 1.0(写入 VRMC_vrm 扩展与 humanoid 映射)
  - gltf-packer.ts:glTF(.gltf + .bin + 贴图)→ 单文件 GLB
- humanoid 候选表与 Godot 客户端 humanoid_map.gd **逐字对齐**,并有单测断言两边一致
  (服务端判定的"可驱动" = 客户端实际能否驱动)

需求 2:模型管理
- .glb / .vrm / .gltf **直接导入**(不重编码,GLB 含 VRM 扩展会自动识别为 VRM)
- .pmx / .fbx 服务端转换:assimp → .glb →(失败)→ .gltf 后自行打包成 GLB
  →(MODEL_CONVERT_FORMAT=vrm 时)补 VRMC_vrm 产出 VRM 1.0;每步 assimp info 回读校验
- .zip **自动搜索包内可用模型**:.vrm → .glb → .gltf(直接导入)优先,
  其次 .pmx → .fbx(转换);同优先级内按"指名字 → 目录最浅 → 文件最大"
- 入库后自动分析(models.stats)与生成缩略图(models.thumbnail_asset_id),
  失败均不阻断入库;管理端列表显示小缩略图、预览放开到 vrm/glb/gltf

需求 3:审核功能
- 上传的模型状态**一律 pending**(不再自动发布、不再自动设默认)
- 审核界面新增模型面板:体积、面数、顶点、材质、贴图、骨骼、形变目标等指标,
  humanoid 覆盖进度、可驱动性结论(缺哪些必需骨骼)、warnings,以及
  **骨骼详细预览**(层级缩进、蒙皮/Humanoid 徽章、子节点数、搜索保留祖先链、
  仅看蒙皮、限高滚动 + 只渲染前 400 行)
- 审核通过可带 setPrimary=true 一并设为默认模型(此时才写源码种子);
  POST /api/models/:id/primary 仅允许 published(否则 409)
- scripts/import-default-model.mjs 改为"上传 → 审核通过 + 设默认"流程

其它:
- models 新增 stats 列(迁移 AddModelStats1789460000000,已在真实库执行)
- 契约升至 rest-api/data-models/model-formats 0.4.0、database 0.3.0(另两库已同步)
- 版本号 0.1.0 → 0.2.0(package.json ×2 + lockfile + project.yaml)
- 文档:README.txt / DSH.md / project.yaml / tasks 005·007·010 全面更新;
  顶层开发书更新为 v1.4

验证:168 项单测通过(10 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实端到端:MMD 压缩包 → GLB + 分析(面数 9071/骨骼 238/humanoid 0-22) + 缩略图(15KB PNG);
GLB 直接导入 sha256 与源文件一致;审核通过后 defaultModelId 由 VRM 变为该模型
This commit is contained in:
2026-09-15 20:37:53 +08:00
parent ccced34dd6
commit 53bf182476
47 changed files with 5610 additions and 628 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "opendance-admin",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opendance-admin",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@pixiv/three-vrm": "^3.5.5",
"react": "^18.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "opendance-admin",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"description": "OpenDance Studio 管理端(React + TypeScript + Vite)",
"scripts": {
+5
View File
@@ -167,6 +167,11 @@ export function updateModel(id: number, payload: ModelUpdate): Promise<Model> {
return apiPatch<Model>(`/api/models/${id}`, payload);
}
/** GET /api/models/:id —— 审核面板用它拉详情(stats 可能为 null,旧数据未分析)。 */
export function getModel(id: number): Promise<Model> {
return apiGet<Model>(`/api/models/${id}`);
}
export function deleteModel(id: number): Promise<void> {
return apiDelete(`/api/models/${id}`);
}
+53
View File
@@ -36,6 +36,10 @@ export type ReviewAction = (typeof REVIEW_ACTIONS)[number];
export const SONG_LANGUAGES = ['zh', 'en', 'ja', 'ko', 'instrumental'] as const;
export type SongLanguage = (typeof SONG_LANGUAGES)[number];
/** 模型文件实际的容器类型;unknown 表示后端无法按 glTF 解析(如 fbx 原文件)。 */
export const MODEL_CONTAINERS = ['glb', 'gltf', 'vrm', 'unknown'] as const;
export type ModelContainer = (typeof MODEL_CONTAINERS)[number];
/* ------------------------------------------------------------- 通用结构 */
/** 来源五元组:songs / motions / models 三类资产均必填。 */
@@ -106,6 +110,51 @@ export interface Motion extends SourceFields {
updatedAt: string;
}
/**
* 骨骼树节点:`ModelStats.boneTree` 是**扁平数组**,父在前、子在后(先序遍历),
* 按 `depth` 缩进即可还原成树,无需递归。
*/
export interface BoneNode {
index: number;
name: string;
/** 根为 0 */
depth: number;
parentName: string | null;
childCount: number;
/** 是否真正参与蒙皮(skins[].joints),未参与的多为辅助 / 空节点 */
inSkin: boolean;
/** 映射到的 humanoid 规范名(如 leftUpperArm);没映射到为 null */
humanoid: string | null;
}
/** GET /api/models/:id 的分析结果(后端 model-inspector 计算)。 */
export interface ModelStats {
container: ModelContainer;
vrmVersion: string | null;
fileSize: number;
meshes: number;
primitives: number;
vertices: number;
triangles: number;
materials: number;
textures: number;
images: number;
animations: number;
morphTargets: number;
nodes: number;
bones: number;
skins: number;
humanoidMapped: number;
humanoidTotal: number;
/** 驱动客户端 33 点所必需的 16 根里缺哪些 */
missingRequiredBones: string[];
drivable: boolean;
maxBoneDepth: number;
/** 扁平骨骼树,父在前、子在后,按 depth 缩进 */
boneTree: BoneNode[];
warnings: string[];
}
export interface Model extends SourceFields {
id: number;
name: string;
@@ -119,6 +168,8 @@ export interface Model extends SourceFields {
/** 模型文件 sha256 */
fileSha256: string | null;
thumbnailUrl: string | null;
/** 模型分析结果(面数 / 骨骼树 / humanoid 覆盖 / 可驱动性);旧数据可能为 null */
stats: ModelStats | null;
status: PublishStatus;
createdAt: string;
updatedAt: string;
@@ -204,6 +255,8 @@ export type PendingPage = Page<PendingItem>;
export interface ReviewActionBody {
reviewer?: string;
comment?: string;
/** 仅 targetType=model 的通过动作有效:同时把该模型设为全局默认模型。 */
setPrimary?: boolean;
}
/* ------------------------------------------------------------ 目录/健康 */
@@ -0,0 +1,373 @@
/**
* 模型审核面板(T-WEB-005 追加)。
*
* 待审列表只给 PendingItem(不含模型详情),所以这里用 item.targetId 现拉
* `GET /api/models/:id`。骨骼是后期游玩评分的基础,因此骨骼树是本面板的重点:
* 扁平数组 + depth 缩进渲染,支持按名搜索(保留祖先链)、仅看蒙皮骨骼、
* 限高滚动 + 只渲染前 N 行,千根骨骼也不会卡住页面。
*/
import { useMemo, useState, type ReactNode } from 'react';
import { getModel } from '../api/endpoints';
import type { Model, ModelStats, PendingItem } from '../api/types';
import { filterBoneTree, boneTreeStats } from '../lib/bone-tree';
import { formatBytes, formatCount } from '../lib/format';
import { MODEL_CONTAINER_LABELS, drivableSummary, type DrivableSummary } from '../lib/labels';
import { useAsync } from '../lib/useAsync';
import { ErrorBox, FormatBadge, Loading, Notice, StatusBadge } from './ui';
/** 骨骼树一次最多渲染的行数,超出部分提示「还有 X 根未显示」。 */
const BONE_ROW_LIMIT = 400;
/** 每层缩进像素。 */
const BONE_INDENT_PX = 14;
const BANNER_CLASS: Record<DrivableSummary['kind'], string> = {
ok: 'banner-ok',
warn: 'banner-warn',
error: 'banner-danger',
};
const BANNER_ICON: Record<DrivableSummary['kind'], string> = {
ok: '✅',
warn: '⚠',
error: '⛔',
};
/* ------------------------------------------------------------ 指标卡片 */
function Metric({
label,
value,
hint,
strong = false,
}: {
label: string;
value: ReactNode;
hint?: string;
strong?: boolean;
}) {
return (
<div className={`metric${strong ? ' metric-strong' : ''}`}>
<span className="metric-label">{label}</span>
<span className="metric-value">{value}</span>
{hint ? <span className="metric-hint">{hint}</span> : null}
</div>
);
}
/* ------------------------------------------------------------ 骨骼树 */
function BoneTreeSection({ stats }: { stats: ModelStats }) {
const [keyword, setKeyword] = useState('');
const [onlySkin, setOnlySkin] = useState(false);
const filtered = useMemo(
() => filterBoneTree(stats.boneTree, keyword, onlySkin),
[stats.boneTree, keyword, onlySkin],
);
const summary = useMemo(() => boneTreeStats(stats.boneTree), [stats.boneTree]);
// 大数据量保护:限高滚动之外,再只渲染前 N 行。
const shown = filtered.slice(0, BONE_ROW_LIMIT);
const hidden = filtered.length - shown.length;
return (
<section className="bone-section">
<header className="bone-head">
<h3>骨骼树</h3>
<span className="muted small">先序遍历,父在前、子在后,按层级缩进</span>
</header>
<div className="bone-toolbar">
<input
type="search"
placeholder="按骨骼名搜索(命中节点会保留祖先链)"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<label className="check-field">
<input
type="checkbox"
checked={onlySkin}
onChange={(event) => setOnlySkin(event.target.checked)}
/>
<span>仅显示蒙皮骨骼</span>
</label>
</div>
<div className="bone-stats">
<span>
总数 <strong>{formatCount(summary.total)}</strong>
</span>
<span>
蒙皮 <strong>{formatCount(summary.skin)}</strong>
</span>
<span>
已映射 humanoid <strong>{formatCount(summary.mapped)}</strong>
</span>
<span>
最大层级 <strong>{formatCount(summary.maxDepth)}</strong>
</span>
{keyword.trim() !== '' ? (
<span>
命中 <strong>{formatCount(filtered.length)}</strong>
</span>
) : null}
</div>
{filtered.length === 0 ? (
<p className="muted small">没有匹配的骨骼。</p>
) : (
<div className="bone-tree" role="tree" aria-label="骨骼树">
{shown.map((node, position) => (
<div
className="bone-row"
key={`${position}-${node.index}-${node.name}`}
role="treeitem"
aria-level={node.depth + 1}
style={{ paddingLeft: 8 + Math.max(0, node.depth) * BONE_INDENT_PX }}
>
<span className="bone-name mono">{node.name || '(无名节点)'}</span>
<span className={`badge ${node.inSkin ? 'badge-published' : 'badge-draft'}`}>
{node.inSkin ? '蒙皮' : '未使用'}
</span>
{node.humanoid ? (
<span className="badge badge-humanoid">{node.humanoid}</span>
) : (
<span className="badge">未映射</span>
)}
<span className="bone-children muted small">子 {node.childCount}</span>
</div>
))}
</div>
)}
{hidden > 0 ? (
<p className="muted small">
还有 {formatCount(hidden)} 根未显示(共 {formatCount(filtered.length)} 行,仅渲染前{' '}
{formatCount(BONE_ROW_LIMIT)} 行);可用搜索或「仅显示蒙皮骨骼」缩小范围。
</p>
) : null}
{onlySkin ? (
<p className="muted small">
已开启「仅显示蒙皮骨骼」:命中骨骼的祖先链会一并保留,因此个别「未使用」节点可能作为祖先出现。
</p>
) : null}
</section>
);
}
/* ------------------------------------------------ 分析结果(stats 区) */
function ModelStatsView({ model, stats }: { model: Model; stats: ModelStats }) {
const drivable = drivableSummary(stats);
const coverage =
stats.humanoidTotal > 0
? Math.min(100, Math.max(0, Math.round((stats.humanoidMapped / stats.humanoidTotal) * 100)))
: 0;
const humanoidNames = useMemo(() => {
const names = new Set<string>();
for (const bone of stats.boneTree) {
if (bone.humanoid !== null && bone.humanoid !== '') names.add(bone.humanoid);
}
return [...names].sort();
}, [stats.boneTree]);
const warnings = stats.warnings ?? [];
return (
<>
{/* 关键指标 */}
<div className="metric-grid">
<Metric label="转换后体积" value={formatBytes(stats.fileSize)} hint={`fileSize ${formatCount(stats.fileSize)} B`} />
<Metric label="格式 format" value={<FormatBadge format={model.format} kind="model" />} />
<Metric
label="容器 container"
value={stats.container}
hint={MODEL_CONTAINER_LABELS[stats.container]}
/>
<Metric label="VRM 版本" value={stats.vrmVersion ?? model.vrmVersion ?? '—'} />
<Metric label="面数 triangles" value={formatCount(stats.triangles)} />
<Metric label="顶点数 vertices" value={formatCount(stats.vertices)} />
<Metric label="网格数 meshes" value={formatCount(stats.meshes)} />
<Metric label="材质数 materials" value={formatCount(stats.materials)} />
<Metric label="贴图数 textures" value={formatCount(stats.textures)} />
<Metric label="骨骼数 bones" value={formatCount(stats.bones)} strong />
<Metric label="节点数 nodes" value={formatCount(stats.nodes)} />
<Metric label="形态目标 morphTargets" value={formatCount(stats.morphTargets)} />
<Metric label="动画数 animations" value={formatCount(stats.animations)} />
<Metric label="图元 primitives" value={formatCount(stats.primitives)} />
<Metric label="蒙皮 skins" value={formatCount(stats.skins)} />
<Metric label="骨骼最大层级" value={formatCount(stats.maxBoneDepth)} />
</div>
{/* 可驱动性结论 */}
<div className={`banner ${BANNER_CLASS[drivable.kind]}`} role={drivable.kind === 'error' ? 'alert' : 'status'}>
<strong>
{BANNER_ICON[drivable.kind]} {drivable.text}
</strong>
{drivable.kind === 'ok' ? (
<span className="muted">
humanoid 已映射 {formatCount(stats.humanoidMapped)}/{formatCount(stats.humanoidTotal)} 根,
骨骼树 {formatCount(stats.bones)} 根,最大层级 {formatCount(stats.maxBoneDepth)}。
</span>
) : null}
{drivable.kind === 'error' ? (
<span className="muted">驱动客户端 33 个关键点需要 16 根必需骨骼全部映射,缺失会导致无法计分。</span>
) : null}
{drivable.kind === 'warn' ? (
<span className="muted">该文件不是 glTF 容器(glb/gltf/vrm),只能审核来源与体积,无法核对骨骼。</span>
) : null}
</div>
{/* humanoid 覆盖 */}
<section className="coverage">
<header className="coverage-head">
<h3>humanoid 覆盖</h3>
<span className="mono small">
{formatCount(stats.humanoidMapped)} / {formatCount(stats.humanoidTotal)}({coverage}%)
</span>
</header>
<div className="progress" role="progressbar" aria-valuenow={coverage} aria-valuemin={0} aria-valuemax={100}>
<div className="progress-bar" style={{ width: `${coverage}%` }} />
</div>
{humanoidNames.length > 0 ? (
<div className="humanoid-names">
{humanoidNames.map((name) => (
<span className="badge badge-humanoid" key={name}>
{name}
</span>
))}
</div>
) : (
<p className="muted small">没有任何骨骼映射到 humanoid 规范名。</p>
)}
</section>
{/* warnings */}
{warnings.length > 0 ? (
<ul className="warn-list">
{warnings.map((warning, index) => (
<li key={`${index}-${warning}`}>{warning}</li>
))}
</ul>
) : null}
{/* 骨骼详细界面 */}
<BoneTreeSection stats={stats} />
</>
);
}
/* ---------------------------------------------------------------- 面板 */
export interface ModelReviewPanelProps {
item: PendingItem;
onClose: () => void;
/** 点「通过」时回调;setPrimary 为勾选的「同时设为全局默认模型」。 */
onApprove: (setPrimary: boolean) => void;
onReject: () => void;
}
export function ModelReviewPanel({ item, onClose, onApprove, onReject }: ModelReviewPanelProps) {
const state = useAsync(() => getModel(item.targetId), [item.targetId]);
const [setPrimary, setSetPrimary] = useState(false);
const [thumbFailed, setThumbFailed] = useState(false);
const model = state.data;
const stats = model?.stats ?? null;
const thumbnail = model?.thumbnailUrl && !thumbFailed ? model.thumbnailUrl : null;
return (
<section className="panel model-review">
<header className="panel-head">
<div>
<h2>模型审核 · {item.name}</h2>
<div className="muted small">
#{item.targetId} · GET /api/models/{item.targetId}
</div>
</div>
<button type="button" className="icon-btn" onClick={onClose} aria-label="关闭模型审核面板">
×
</button>
</header>
<div className="panel-body">
{state.error ? <ErrorBox message={state.error} onRetry={state.reload} /> : null}
{state.loading && !model ? <Loading text="正在加载模型详情…" /> : null}
{model ? (
<>
<div className="model-review-top">
{thumbnail ? (
<img
className="model-review-thumb"
src={thumbnail}
alt={`${model.name} 缩略图`}
onError={() => setThumbFailed(true)}
/>
) : (
<div className="model-review-thumb model-review-thumb-empty">暂无预览图</div>
)}
<ul className="kv-list model-review-basic">
<li>
<span className="muted">格式</span>
<span>
<FormatBadge format={model.format} kind="model" />
</span>
</li>
<li>
<span className="muted">状态</span>
<span>
<StatusBadge status={model.status} />
</span>
</li>
<li>
<span className="muted">文件地址</span>
<span className="break">{model.fileUrl ?? '—'}</span>
</li>
{model.conversionNote ? (
<li>
<span className="muted">转换说明</span>
<span className="break">{model.conversionNote}</span>
</li>
) : null}
</ul>
</div>
{stats ? (
<ModelStatsView model={model} stats={stats} />
) : (
<Notice kind="info">
该模型暂无分析数据(stats 为空,可能是旧数据或后端尚未完成分析),只能依据格式、体积与来源判断,
无法核对骨骼与可驱动性。
</Notice>
)}
</>
) : null}
</div>
<footer className="panel-foot">
<label className="check-field">
<input
type="checkbox"
checked={setPrimary}
disabled={!model}
onChange={(event) => setSetPrimary(event.target.checked)}
/>
<span>同时设为全局默认模型</span>
</label>
<div className="panel-foot-actions">
<button type="button" className="btn btn-danger" onClick={onReject} disabled={!model}>
驳回
</button>
<button type="button" className="btn btn-primary" onClick={() => onApprove(setPrimary)} disabled={!model}>
通过
</button>
</div>
</footer>
</section>
);
}
+100
View File
@@ -0,0 +1,100 @@
/**
* 骨骼树过滤(纯函数,无 React / DOM 依赖,便于 vitest 直接单测)。
*
* `ModelStats.boneTree` 是**扁平数组、父在前子在后**(先序遍历),所以过滤时
* 不能简单地 `filter`:一旦命中叶子,若把它到根的祖先链丢掉,渲染出来的树就断了。
* 本模块保留「命中节点 + 其全部祖先」。
*/
import type { BoneNode } from '../api/types';
/**
* 计算每个节点的父节点下标。
*
* 主算法用先序遍历的性质:父节点必定是**前面最近的 depth - 1 的节点**,
* 用一个「每层最后一个节点」的栈 O(n) 求出,比按名字找父更稳(名字可能重复)。
* depth 数据异常时(例如 depth 跳级)再退回 parentName 查找。
*/
function buildParentIndex(tree: readonly BoneNode[]): number[] {
const parentIndex: number[] = new Array<number>(tree.length).fill(-1);
const lastNameIndex = new Map<string, number>();
const lastIndexAtDepth: Array<number | undefined> = [];
for (let index = 0; index < tree.length; index += 1) {
const node = tree[index];
const depth = Number.isFinite(node.depth) ? Math.max(0, Math.trunc(node.depth)) : 0;
let parent: number | undefined = depth > 0 ? lastIndexAtDepth[depth - 1] : undefined;
if (parent === undefined && node.parentName !== null && node.parentName !== '') {
const byName = lastNameIndex.get(node.parentName);
if (byName !== undefined && byName !== index) parent = byName;
}
parentIndex[index] = parent === undefined || parent === index ? -1 : parent;
lastIndexAtDepth[depth] = index;
if (!lastNameIndex.has(node.name)) lastNameIndex.set(node.name, index);
}
return parentIndex;
}
/**
* 按名字过滤骨骼树,并保留命中节点的祖先链。
*
* @param tree 扁平骨骼树(父在前、子在后)
* @param keyword 搜索关键字(大小写不敏感,中英混排按子串匹配;空白视为空)
* @param onlySkin true 时只保留 `inSkin` 的骨骼(其祖先仍会保留,避免树断裂)
* @returns 过滤后的新数组,保持原有先后顺序,且不会与命中节点重复
*/
export function filterBoneTree(
tree: readonly BoneNode[],
keyword: string,
onlySkin = false,
): BoneNode[] {
const needle = keyword.trim().toLowerCase();
// 无条件全量:连祖先计算都省掉,直接返回副本,避免调用方改动原数组。
if (needle === '' && !onlySkin) return [...tree];
const parentIndex = buildParentIndex(tree);
// 1) 命中集合:关键字命中(空关键字即全部)且通过 onlySkin 条件。
const keep: boolean[] = new Array<boolean>(tree.length).fill(false);
const pending: number[] = [];
for (let index = 0; index < tree.length; index += 1) {
const node = tree[index];
if (onlySkin && !node.inSkin) continue;
if (needle !== '' && !node.name.toLowerCase().includes(needle)) continue;
pending.push(index);
}
// 2) 沿父链向上补齐祖先;keep 同时用于防止异常数据成环时死循环。
while (pending.length > 0) {
const index = pending.pop() as number;
if (keep[index]) continue;
keep[index] = true;
const parent = parentIndex[index];
if (parent >= 0) pending.push(parent);
}
// 3) 按原顺序输出,父仍在子之前。
return tree.filter((_node, index) => keep[index]);
}
/** 骨骼树统计(总数 / 蒙皮数 / 已映射 humanoid 数 / 最大层级),供统计条使用。 */
export function boneTreeStats(tree: readonly BoneNode[]): {
total: number;
skin: number;
mapped: number;
maxDepth: number;
} {
let skin = 0;
let mapped = 0;
let maxDepth = 0;
for (const node of tree) {
if (node.inSkin) skin += 1;
if (node.humanoid !== null && node.humanoid !== '') mapped += 1;
if (Number.isFinite(node.depth) && node.depth > maxDepth) maxDepth = node.depth;
}
return { total: tree.length, skin, mapped, maxDepth };
}
+6
View File
@@ -38,6 +38,12 @@ export function formatNumber(value: number | null | undefined, digits = 2): stri
return String(Number(value.toFixed(digits)));
}
/** 整数千分位(面数 / 顶点数等);null / 非有限值返回 '—'。 */
export function formatCount(value: number | null | undefined): string {
if (value === null || value === undefined || !Number.isFinite(value)) return '—';
return Math.round(value).toLocaleString('en-US');
}
export function tagsToText(tags: readonly string[] | null | undefined): string {
if (!tags || tags.length === 0) return '';
return tags.join(', ');
+37
View File
@@ -1,7 +1,9 @@
/** 枚举 → 中文展示文案。 */
import type {
ModelContainer,
ModelFormat,
ModelStats,
MotionFormat,
PreprocessState,
PublishStatus,
@@ -66,6 +68,41 @@ export const MODEL_FORMAT_LABELS: Record<ModelFormat, string> = {
fbx: 'FBX',
};
/** 模型分析出的实际容器类型(可能与上传格式不同,例如 .zip → 转换后的 glb)。 */
export const MODEL_CONTAINER_LABELS: Record<ModelContainer, string> = {
glb: 'GLB(二进制 glTF)',
gltf: 'glTF(JSON)',
vrm: 'VRM(glTF 扩展)',
unknown: '未知(非 glTF 容器)',
};
/** 可驱动性结论:ok / warn / error 对应绿 / 黄 / 红。 */
export interface DrivableSummary {
kind: 'ok' | 'warn' | 'error';
text: string;
}
/**
* 由 stats 归纳可驱动性结论,供审核面板与模型列表共用。
* 顺序固定:可驱动 → 绿;非 glTF 容器 → 黄;其余 → 红(缺哪些必需骨骼)。
*/
export function drivableSummary(stats: ModelStats): DrivableSummary {
if (stats.drivable) {
return { kind: 'ok', text: '可用于驱动与评分' };
}
if (stats.container === 'unknown') {
return { kind: 'warn', text: '非 glTF 容器,无法解析骨骼' };
}
const missing = stats.missingRequiredBones ?? [];
return {
kind: 'error',
text:
missing.length > 0
? `无法驱动:缺 ${missing.join('、')}`
: '无法驱动:humanoid 必需骨骼映射不完整',
};
}
export const TARGET_TYPE_LABELS: Record<TargetType, string> = {
song: '曲目',
motion: '动作',
+54 -13
View File
@@ -1,10 +1,10 @@
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 type { Model, ModelFormat, 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 { 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 {
@@ -32,6 +32,16 @@ const VrmPreview = lazy(async () => {
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;
@@ -344,8 +354,8 @@ export function ModelsPage() {
};
const previewUnsupported =
previewModel && previewModel.format === 'fbx'
? 'FBX 无法用 GLTFLoader 预览(契约中 FBX 仅归档)。'
previewModel && !PREVIEWABLE_FORMATS.includes(previewModel.format)
? `${MODEL_FORMAT_LABELS[previewModel.format]} 无法用 GLTFLoader 预览(契约中 FBX 仅归档,请先转换为 GLB/VRM)。`
: null;
return (
@@ -459,17 +469,18 @@ export function ModelsPage() {
<td>
<button
type="button"
className="thumb"
className="thumb-link"
onClick={() => setPreviewId(model.id)}
title="点击预览"
>
{model.thumbnailUrl ? (
<img src={model.thumbnailUrl} alt={`${model.name} 缩略图`} />
<img
className="thumb-sm"
src={model.thumbnailUrl}
alt={`${model.name} 缩略图`}
/>
) : (
<span className="thumb-placeholder">
{MODEL_FORMAT_LABELS[model.format]}
<em>无缩略图</em>
</span>
<FormatBadge format={model.format} kind="model" />
)}
</button>
</td>
@@ -557,9 +568,10 @@ export function ModelsPage() {
</header>
<div className="panel-body">
<p className="muted small">
通过 GLTFLoader + VRMLoaderPlugin 加载 <code>fileUrl</code>,VRM 0.x 自动执行 180° 旋转;
通过 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">
@@ -577,6 +589,35 @@ export function ModelsPage() {
<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>
+130 -70
View File
@@ -6,6 +6,7 @@ import type { PendingItem, ReviewActionBody } from '../api/types';
import { formatDateTime } from '../lib/format';
import { REVIEW_ACTION_LABELS, STATUS_LABELS, TARGET_TYPE_LABELS } from '../lib/labels';
import { useAsync } from '../lib/useAsync';
import { ModelReviewPanel } from '../components/ModelReviewPanel';
import {
Empty,
ErrorBox,
@@ -26,6 +27,8 @@ type TabKey = 'pending' | 'history';
interface ActionTarget {
item: PendingItem;
action: 'approve' | 'reject';
/** 仅模型通过时有意义:body 里带 setPrimary。 */
setPrimary?: boolean;
}
/* ------------------------------------------------------------ 审核弹窗 */
@@ -45,6 +48,7 @@ function ReviewActionModal({
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const isReject = target.action === 'reject';
const setPrimary = !isReject && target.item.targetType === 'model' && target.setPrimary === true;
const submit = async (): Promise<void> => {
if (isReject && comment.trim() === '') {
@@ -55,13 +59,16 @@ function ReviewActionModal({
setError(null);
try {
const body: ReviewActionBody = { reviewer: reviewer.trim(), comment: comment.trim() };
if (setPrimary) body.setPrimary = true;
if (isReject) {
await reviewReject(target.item.targetType, target.item.targetId, body);
} else {
await reviewApprove(target.item.targetType, target.item.targetId, body);
}
onDone(
`${isReject ? '已驳回' : '已通过'}「${target.item.name}」(${TARGET_TYPE_LABELS[target.item.targetType]} #${target.item.targetId})`,
`${isReject ? '已驳回' : '已通过'}「${target.item.name}」(${TARGET_TYPE_LABELS[target.item.targetType]} #${target.item.targetId})${
setPrimary ? ',并已设为全局默认模型' : ''
}`,
);
} catch (cause) {
setError(errorMessage(cause));
@@ -103,6 +110,12 @@ function ReviewActionModal({
<span className="muted">审核人 reviewer</span>
<span>{reviewer.trim() === '' ? '(空)' : reviewer}</span>
</li>
{setPrimary ? (
<li>
<span className="muted">附加操作</span>
<span>同时设为全局默认模型(setPrimary: true)</span>
</li>
) : null}
</div>
<Field
label="审核意见 comment"
@@ -132,6 +145,7 @@ export function ReviewsPage() {
}
});
const [actionTarget, setActionTarget] = useState<ActionTarget | null>(null);
const [modelPanel, setModelPanel] = useState<PendingItem | null>(null);
const [notice, setNotice] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
@@ -150,6 +164,7 @@ export function ReviewsPage() {
const switchTab = (next: TabKey): void => {
setPage(1);
setModelPanel(null);
setSearchParams(next === 'pending' ? {} : { tab: next });
};
@@ -214,80 +229,124 @@ export function ReviewsPage() {
</div>
{tab === 'pending' ? (
<section className="panel">
{pendingState.error ? <ErrorBox message={pendingState.error} onRetry={pendingState.reload} /> : null}
{pendingState.loading && !pendingState.data ? <Loading text="正在加载待审资产…" /> : null}
{!pendingState.loading && !pendingState.error && pendingItems.length === 0 ? (
<Empty text="当前没有待审核的资产。" />
) : null}
<div className={modelPanel ? 'review-layout' : undefined}>
<section className="panel">
{pendingState.error ? <ErrorBox message={pendingState.error} onRetry={pendingState.reload} /> : null}
{pendingState.loading && !pendingState.data ? <Loading text="正在加载待审资产…" /> : null}
{!pendingState.loading && !pendingState.error && pendingItems.length === 0 ? (
<Empty text="当前没有待审核的资产。" />
) : null}
{pendingItems.length > 0 ? (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>类型</th>
<th>名称</th>
<th>状态</th>
<th className="col-source">来源五元组</th>
<th>更新时间</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{pendingItems.map((item) => (
<tr key={`${item.targetType}-${item.targetId}`}>
<td>{TARGET_TYPE_LABELS[item.targetType] ?? item.targetType}</td>
<td>
<div className="cell-title">{item.name}</div>
<div className="muted small">
#{item.targetId} · {item.targetType}
</div>
</td>
<td>
<StatusBadge status={item.status} />
</td>
<td className="col-source">
{item.source ? (
<SourceCell source={item.source} />
) : (
<span className="muted">—</span>
)}
</td>
<td className="mono small">{formatDateTime(item.updatedAt)}</td>
<td className="col-actions">
<button
type="button"
className="link-btn"
onClick={() => setActionTarget({ item, action: 'approve' })}
>
通过
</button>
<button
type="button"
className="link-btn link-danger"
onClick={() => setActionTarget({ item, action: 'reject' })}
>
驳回
</button>
</td>
{pendingItems.length > 0 ? (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>类型</th>
<th>名称</th>
<th>状态</th>
<th className="col-source">来源五元组</th>
<th>更新时间</th>
<th className="col-actions">操作</th>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</thead>
<tbody>
{pendingItems.map((item) => {
const isModel = item.targetType === 'model';
const panelOpen =
isModel && modelPanel !== null && modelPanel.targetId === item.targetId;
return (
<tr
key={`${item.targetType}-${item.targetId}`}
className={
[isModel ? 'row-clickable' : '', panelOpen ? 'row-active' : '']
.filter(Boolean)
.join(' ') || undefined
}
onClick={isModel ? () => setModelPanel(item) : undefined}
>
<td>{TARGET_TYPE_LABELS[item.targetType] ?? item.targetType}</td>
<td>
<div className="cell-title">{item.name}</div>
<div className="muted small">
#{item.targetId} · {item.targetType}
</div>
</td>
<td>
<StatusBadge status={item.status} />
</td>
<td className="col-source">
{item.source ? (
<SourceCell source={item.source} />
) : (
<span className="muted">—</span>
)}
</td>
<td className="mono small">{formatDateTime(item.updatedAt)}</td>
<td className="col-actions">
{isModel ? (
<button
type="button"
className="link-btn"
onClick={(event) => {
event.stopPropagation();
setModelPanel(panelOpen ? null : item);
}}
>
{panelOpen ? '收起详情' : '模型详情'}
</button>
) : null}
<button
type="button"
className="link-btn"
onClick={(event) => {
event.stopPropagation();
setActionTarget({ item, action: 'approve' });
}}
>
通过
</button>
<button
type="button"
className="link-btn link-danger"
onClick={(event) => {
event.stopPropagation();
setActionTarget({ item, action: 'reject' });
}}
>
驳回
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
{pendingState.data ? (
<Pagination
page={pendingState.data.page ?? page}
pageSize={pendingState.data.pageSize ?? PAGE_SIZE}
total={pendingState.data.total ?? 0}
disabled={pendingState.loading}
onChange={setPage}
{pendingState.data ? (
<Pagination
page={pendingState.data.page ?? page}
pageSize={pendingState.data.pageSize ?? PAGE_SIZE}
total={pendingState.data.total ?? 0}
disabled={pendingState.loading}
onChange={setPage}
/>
) : null}
</section>
{modelPanel ? (
<ModelReviewPanel
item={modelPanel}
onClose={() => setModelPanel(null)}
onApprove={(setPrimary) =>
setActionTarget({ item: modelPanel, action: 'approve', setPrimary })
}
onReject={() => setActionTarget({ item: modelPanel, action: 'reject' })}
/>
) : null}
</section>
</div>
) : (
<section className="panel">
{historyState.error ? <ErrorBox message={historyState.error} onRetry={historyState.reload} /> : null}
@@ -349,6 +408,7 @@ export function ReviewsPage() {
onClose={() => setActionTarget(null)}
onDone={(message) => {
setActionTarget(null);
setModelPanel(null);
setNotice({ kind: 'success', text: message });
reloadAll();
}}
+310
View File
@@ -1175,3 +1175,313 @@ textarea {
font-size: 11px;
color: var(--text-dim);
}
/* ==========================================================================
模型审核(T-WEB-005 追加):待审列表 + 右侧模型审核面板
========================================================================== */
.review-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(420px, 620px);
gap: 14px;
align-items: start;
}
@media (max-width: 1440px) {
.review-layout {
grid-template-columns: minmax(0, 1fr);
}
}
.row-clickable {
cursor: pointer;
}
.check-field {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-dim);
cursor: pointer;
white-space: nowrap;
}
.check-field input[type='checkbox'] {
width: auto;
height: 16px;
margin: 0;
flex: 0 0 auto;
}
.panel-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
padding: 10px 16px;
border-top: 1px solid var(--border);
}
.panel-foot-actions {
display: flex;
gap: 8px;
}
/* -------------------------------------------------- 列表小缩略图 */
.thumb-link {
background: none;
border: none;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
}
.thumb-sm {
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--bg);
object-fit: cover;
display: block;
}
/* -------------------------------------------------- 审核面板骨架 */
.model-review {
position: sticky;
top: 16px;
display: flex;
flex-direction: column;
}
.model-review .panel-body {
gap: 14px;
max-height: 76vh;
overflow-y: auto;
}
.model-review-top {
display: flex;
gap: 12px;
align-items: flex-start;
}
.model-review-thumb {
width: 116px;
height: 116px;
flex: 0 0 116px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--bg);
object-fit: cover;
display: block;
}
.model-review-thumb-empty {
display: grid;
place-items: center;
text-align: center;
color: var(--text-mute);
font-size: 12px;
}
.model-review-basic {
flex: 1;
min-width: 0;
}
/* -------------------------------------------------- 关键指标卡片 */
.metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 8px;
}
.metric {
background: var(--bg-elev-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px 10px;
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.metric-strong {
border-color: rgba(76, 154, 255, 0.55);
background: rgba(76, 154, 255, 0.08);
}
.metric-label {
font-size: 11px;
color: var(--text-mute);
}
.metric-value {
font-family: var(--mono);
font-size: 14px;
font-weight: 600;
word-break: break-word;
}
.metric-hint {
font-size: 11px;
color: var(--text-mute);
}
.banner-ok {
background: rgba(63, 185, 80, 0.12);
border-color: rgba(63, 185, 80, 0.5);
color: #8fe0a0;
}
.banner-danger {
background: rgba(248, 81, 73, 0.12);
border-color: rgba(248, 81, 73, 0.5);
color: #ffb3ae;
}
/* -------------------------------------------------- humanoid 覆盖 */
.coverage {
display: flex;
flex-direction: column;
gap: 6px;
}
.coverage-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.progress {
height: 8px;
border-radius: 999px;
background: var(--bg);
border: 1px solid var(--border);
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--accent-strong), var(--accent));
transition: width 0.2s ease;
}
.humanoid-names {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-height: 120px;
overflow-y: auto;
}
.badge-humanoid {
border-color: rgba(163, 113, 247, 0.6);
color: #cbb2ff;
background: rgba(163, 113, 247, 0.12);
font-family: var(--mono);
}
.warn-list {
margin: 0;
padding-left: 18px;
color: #e8c26a;
font-size: 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
/* -------------------------------------------------- 骨骼树 */
.bone-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.bone-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.bone-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.bone-toolbar input[type='search'] {
flex: 1 1 200px;
min-width: 160px;
}
.bone-stats {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
font-size: 12px;
color: var(--text-dim);
}
.bone-stats strong {
font-family: var(--mono);
color: var(--text);
}
.bone-tree {
max-height: 60vh;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
}
.bone-row {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
min-height: 22px;
font-size: 12px;
white-space: nowrap;
border-bottom: 1px solid rgba(38, 46, 57, 0.6);
}
.bone-row:last-child {
border-bottom: none;
}
.bone-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.bone-row .badge {
padding: 0 6px;
line-height: 16px;
font-size: 10px;
}
.bone-name {
color: var(--text);
max-width: 45%;
overflow: hidden;
text-overflow: ellipsis;
}
.bone-children {
margin-left: auto;
}