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:
co-authored by
Claude Opus 4.8
parent
1c9143ce61
commit
44e90233ff
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user