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:
Generated
+2
-2
@@ -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,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": {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(', ');
|
||||
|
||||
@@ -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: '动作',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user