feat(models): 期1 模特库地基 — Model 顶级实体 + 模特库页 + 审核范围扩展

后端:
- 新增 Model(模特)团队级实体:形象图+三视图+声线(声线尾期),官方模板标签,软删
- Asset.Category 枚举一次加全(model_portrait/tri_view/voice/model_tryon/platform_kit/free_create/storyboard),逐期接线
- 送审范围 person → REVIEW_CATEGORIES{person,tri_view,storyboard}(review.py + adminpanel 队列),图片趴不送审;当前行为不变(forward-compat)
- /api/models/ ModelLibraryViewSet:本团队∪官方模板、官方/我的 tab、真人上传、软删(官方不可删)、跨团队资产引用防越权
- backfill_models 命令把历史成套模特(kind=model + 项目流命名配对)归集成 Model,dry-run 默认

前端:
- 左菜单新增「模特库」顶级入口(person 图标)+ 路由 /models
- 模特库页:全部/官方模板/我的模特 tab + 真人上传 + 模特卡(形象图+三视图缩略+官方标签),仅用 design token

测试:assets 10/10 绿;tsc+build 全绿;无头走查 0 console error(全部5/官方3/我的2,官方标签达标)
基线既有 7 失败(ai/projects provider mock 漂移)零新增

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-20 22:53:46 +08:00
co-authored by Claude Opus 4.8
parent 1c9143ce61
commit 44e90233ff
23 changed files with 1003 additions and 12 deletions
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import { api } from "../api";
import type { ModelEntity } from "../types";
import "../models-page.css";
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
type Tab = "all" | "official" | "mine";
const TABS: { k: Tab; label: string }[] = [
{ k: "all", label: "全部" },
{ k: "official", label: "官方模板" },
{ k: "mine", label: "我的模特" }
];
// 模特库:顶级实体(团队级可复用)= 形象图 + 三视图 + 声线(尾期)。官方预制打「官方模板」标签。
// 图片创作(模特上身图)与视频项目(角色)都引用它。期1:看归好的成套模特 + 真人上传 + 删自建。
export function ModelsPage() {
const [tab, setTab] = useState<Tab>("all");
const [items, setItems] = useState<ModelEntity[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [confirmId, setConfirmId] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
let alive = true;
setLoading(true);
api
.listModels({ tab: tab === "all" ? undefined : tab })
.then((r) => { if (alive) setItems(r.results); })
.catch(() => { if (alive) setItems([]); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [tab]);
async function onPick(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setUploading(true);
try {
const form = new FormData();
form.append("file", file);
form.append("name", file.name.replace(/\.[^.]+$/, ""));
const created = await api.uploadModel(form);
setItems((list) => (tab === "official" ? list : [created, ...list]));
} finally {
setUploading(false);
}
}
async function remove(id: string) {
try {
await api.deleteModel(id);
setItems((list) => list.filter((m) => m.id !== id));
} finally {
setConfirmId(null);
}
}
return (
<div className="models-page">
<div className="page-head">
<div>
<h1>模特库</h1>
<div className="sub">
<span className="mono">// 团队级可复用 · 形象图 + 三视图</span>
<span>·</span>
<span>图片创作与视频项目都能引用</span>
</div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" disabled={uploading} onClick={() => fileRef.current?.click()}>
{uploading ? "上传中…" : "真人上传"}
</button>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onPick} />
</div>
</div>
<div className="tabs">
{TABS.map((t) => (
<button key={t.k} type="button" className={`tab${tab === t.k ? " active" : ""}`} onClick={() => setTab(t.k)}>
{t.label}
</button>
))}
</div>
{loading ? (
<div className="placeholder" style={{ minHeight: 200 }}><span className="ph-frame">// 加载中…</span></div>
) : items.length === 0 ? (
<div className="placeholder models-empty">
<span className="ph-frame">// 还没有模特</span>
<span className="models-empty-hint mono">点右上「真人上传」加一个,或在图片/视频流程里生成</span>
</div>
) : (
<div className="models-grid">
{items.map((m) => (
<div className="model-card" key={m.id}>
<div
className={`placeholder model-portrait${m.portrait ? " has-mock-media" : ""}`}
style={m.portrait ? mediaStyle(m.portrait) : undefined}
>
{!m.portrait && <span className="ph-frame">无图</span>}
{m.is_official && <span className="pill info model-official">官方模板</span>}
{!m.is_official &&
(confirmId === m.id ? (
<div className="model-confirm">
<button className="btn btn-sm model-del" type="button" onClick={() => void remove(m.id)}>删除</button>
<button className="btn btn-sm" type="button" onClick={() => setConfirmId(null)}>取消</button>
</div>
) : (
<button className="model-del-btn" type="button" title="删除模特" onClick={() => setConfirmId(m.id)}>×</button>
))}
</div>
<div className="model-body">
<div className="model-name" title={m.name}>{m.name}</div>
<div
className={`placeholder model-triview${m.triview ? " has-mock-media" : ""}`}
style={m.triview ? mediaStyle(m.triview) : undefined}
>
{!m.triview && <span className="ph-frame mono">// 无三视图</span>}
</div>
<div className="model-tags mono">
<span>{m.source === "upload" ? "真人上传" : "AI 生成"}</span>
<span>·</span>
<span>{m.triview ? "三视图 ✓" : "三视图 —"}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
+7
View File
@@ -7,6 +7,7 @@ import {
MessageSquare,
Package,
Settings,
UserRound,
Users,
Wallet,
WandSparkles
@@ -17,6 +18,7 @@ export type Page =
| "products"
| "productDetail"
| "productCreateUpload"
| "models"
| "projects"
| "projectWizard"
| "pipeline"
@@ -57,6 +59,7 @@ export type NavItem = { page: Page; label: string; icon: typeof Home; badge?: st
export const mainNav: NavItem[] = [
{ page: "dashboard", label: "工作台", icon: Home },
{ page: "products", label: "商品库", icon: Package },
{ page: "models", label: "模特库", icon: UserRound },
{ page: "projects", label: "视频项目", icon: FolderKanban },
{ page: "pipeline", label: "生产管线", icon: WandSparkles },
{ page: "library", label: "资产库", icon: Library },
@@ -76,6 +79,7 @@ export const routeLabels: Record<Page, string> = {
products: "商品库",
productDetail: "商品详情",
productCreateUpload: "上传创建商品",
models: "模特库",
projects: "视频项目",
projectWizard: "新建视频项目",
pipeline: "生产管线",
@@ -129,6 +133,7 @@ export function resolveRoute(): ResolvedRoute {
if (path.startsWith("/products/")) {
return { page: "productDetail", authMode: "login", productId: decodeURIComponent(path.slice("/products/".length)), hash };
}
if (path === "/models") return { page: "models", authMode: "login", hash };
if (path === "/projects") return { page: "projects", authMode: "login", hash };
if (path === "/projects/new") return { page: "projectWizard", authMode: "login", hash };
if (path === "/pipeline") {
@@ -163,6 +168,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
return "/products/new";
case "productDetail":
return options.productId ? `/products/${encodeURIComponent(options.productId)}` : "/products";
case "models":
return "/models";
case "projects":
return "/projects";
case "projectWizard":