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
+3
View File
@@ -41,6 +41,7 @@ import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./r
import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config";
import { AdminApp } from "./routes/admin/admin-app";
import { TrashPage } from "./routes/trash";
import { ModelsPage } from "./routes/models";
import { money } from "./routes/stage-config";
const crumbLabels: Partial<Record<Page, string>> = {
@@ -743,6 +744,8 @@ export function App() {
</div>
</div>
);
case "models":
return <ModelsPage />;
case "library":
return <LibraryPage onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
case "trash":
+17
View File
@@ -21,6 +21,7 @@ import type {
LoginSession,
Invitation,
ModelConfig,
ModelEntity,
Notification,
NotificationList,
Paginated,
@@ -478,6 +479,22 @@ export const api = {
updateAsset(id: string, payload: { name?: string; description?: string }) {
return request<Asset>(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
// 模特库:列出(本团队 官方模板);tab=official 只看官方、mine 只看自建
listModels(params: { tab?: "official" | "mine"; q?: string; pageSize?: number } = {}) {
const qs = new URLSearchParams();
if (params.tab) qs.set("tab", params.tab);
if (params.q) qs.set("q", params.q);
qs.set("page_size", String(params.pageSize ?? 200));
return request<Paginated<ModelEntity>>(`/api/models/?${qs.toString()}`);
},
// 真人上传:一张人像图 → 建 model_portrait 资产 + Model 实体
uploadModel(formData: FormData) {
return request<ModelEntity>("/api/models/upload/", { method: "POST", body: formData });
},
// 删除模特(软删;官方模特不可删)
deleteModel(id: string) {
return request<void>(`/api/models/${id}/`, { method: "DELETE" });
},
billingSummary() {
return request<BillingSummary>("/api/billing/summary/");
},
@@ -4,6 +4,7 @@ const iconPaths: Record<string, string> = {
layoutDashboard: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
package: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>',
model: '<circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>',
clapperboard: '<path d="m12.3 3.5 3 4"/><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
sparkles: '<path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3Z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15Z"/>',
images: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
@@ -190,6 +190,7 @@ type NavDef = { id: string; page: Page; label: string; icon: string; badge?: num
const NAV: NavDef[] = [
{ id: "dashboard", page: "dashboard", label: "工作台", icon: "dashboard" },
{ id: "products", page: "products", label: "商品库", icon: "package" },
{ id: "models", page: "models", label: "模特库", icon: "model" },
{ id: "projects", page: "projects", label: "视频项目", icon: "clapperboard" },
{ id: "asset-factory", page: "assetFactory", label: "图片生成", icon: "sparkles" },
{ id: "library", page: "library", label: "资产库", icon: "library" },
@@ -204,6 +205,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
products: "products",
productDetail: "products",
productCreateUpload: "products",
models: "models",
projects: "projects",
projectWizard: "projects",
pipeline: "projects",
+98
View File
@@ -0,0 +1,98 @@
/* 模特库 · 顶级实体(形象图 + 三视图)· 仅用 design-restraint token,不写裸 hex */
.models-empty { min-height: 240px; flex-direction: column; gap: 8px; }
.models-empty-hint { font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
.models-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.model-card {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
transition: border-color .15s;
}
.model-card:hover { border-color: var(--border-loud); }
/* 形象图(9:16 氛围正面图)· 主视觉 */
.model-portrait {
position: relative;
width: 100%;
aspect-ratio: 3 / 4;
border-radius: 0;
}
/* 官方模板标签:复用 .pill.info(单橙锚点),定位左上 */
.model-official {
position: absolute;
top: 8px;
left: 8px;
pointer-events: none;
}
/* 删除按钮:默认半隐,hover 卡片才显;crimson 危险态 */
.model-del-btn {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--r-sm);
background: var(--black-alpha-48);
color: var(--accent-white);
font-size: 16px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity .15s, background .15s;
}
.model-card:hover .model-del-btn { opacity: 1; }
.model-del-btn:hover { background: var(--accent-crimson); }
.model-confirm {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--black-alpha-56);
}
.model-del { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); }
.model-del:hover { filter: brightness(1.06); }
.model-body { padding: 10px 12px 12px; }
.model-name {
font-size: 13.5px;
font-weight: 500;
color: var(--accent-black);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 三视图(16:9 白底)· 成套证明 */
.model-triview {
width: 100%;
aspect-ratio: 16 / 9;
margin-top: 8px;
border-radius: var(--r-sm);
}
.model-triview .ph-frame { font-size: 11px; }
.model-tags {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 11.5px;
color: var(--black-alpha-48);
letter-spacing: .02em;
}
+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":
+18
View File
@@ -230,6 +230,24 @@ export type Asset = {
updated_at: string;
};
// 模特库实体(顶级,团队级可复用)= 形象图 + 三视图 + 声线(声线尾期)。官方预制打 is_official 标签。
export type ModelEntity = {
id: string;
name: string;
is_official: boolean;
source: "ai" | "upload";
description: string;
metadata?: Record<string, unknown>;
portrait_asset: string | null;
triview_asset: string | null;
voice_asset: string | null;
portrait: string; // 形象图缩略图直链
triview: string; // 三视图缩略图直链
has_voice: boolean;
created_at: string;
updated_at: string;
};
export type ProjectStage = {
id: string;
stage: string;