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
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user