feat(library): 资产库重做为「成品库」— 图片成品三类 + 视频成品按项目素材包

定义:资产 = 平台产出的成品。原料(商品/模特)归各自的库、半成品(角色/三视图/场景/分镜)归项目,
资产库只装成品 —— tab 从 9 个收成 5 个。

- 后端 /api/assets/video-packs/:视频成品按项目素材包(与「导出全部」同源,取 VideoSegment.adopted_version
  真实视频资产,给 TOS 直链 url 供 <video> 播放/取首帧);summary 改只数成品(tryon/kits/creations/others)
- 资产库 _tab_q 加 others(我的上传+未归类兜底);角色/场景/商品图/素材 tab 移除(各回各家)
- 前端 library:tab→模特上身图/平台套图/自由创作/视频成品/其他;视频成品=项目包卡(<video>首帧封面)
  → 点开弹窗看该项目所有片段(可播);图片成品/其他复用原网格
- 单测 VideoPacksTests:按项目分组 + 软删片段排除

验收:后端单测绿(基线 4 失败零新增);tsc+build 绿;无头 0 报错(5 tab + 视频包弹窗 3 段)
注:图片成品「按生成批次成组」待图片趴有真实数据时接线(当前 demo 已清空)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-21 20:54:42 +08:00
parent a39d2b8603
commit 4571991deb
7 changed files with 216 additions and 22 deletions
+77 -18
View File
@@ -34,27 +34,20 @@ const UPLOAD_KINDS: Array<{ value: string; label: string; accept: string; hint:
{ value: "subtitle", label: "字幕", accept: ".srt,.vtt,.ass", hint: "SRT / VTT / ASS" }
];
// 图片趴三类(期2):tryon=模特上身图 / kits=平台套图 / creations=自由创作
// 期3:人物→角色;成片 tab→素材(视频素材,排除最终成片;最终成片隐藏不列)
type LibTab = "people" | "scenes" | "products" | "tryon" | "kits" | "creations" | "materials" | "uploads" | "unclassified";
// 资产库 = 成品库(商品/模特/半成品各回各家):
// 图片成品 tryon=模特上身图 / kits=平台套图 / creations=自由创作;视频成品 videopacks=按项目素材包;others=其他兜底
type LibTab = "tryon" | "kits" | "creations" | "videopacks" | "others";
const LIB_TABS: Array<{ key: LibTab; label: string }> = [
{ key: "people", label: "角色" }, { key: "scenes", label: "场景" }, { key: "products", label: "商品图" },
{ key: "tryon", label: "模特上身图" }, { key: "kits", label: "平台套图" }, { key: "creations", label: "自由创作" },
{ key: "materials", label: "素材" }, { key: "uploads", label: "我的上传" }, { key: "unclassified", label: "未分类" }
{ key: "videopacks", label: "视频成品" }, { key: "others", label: "其他" }
];
// 对齐 api-bridge:工具栏 chip 按 tab 显隐
// 对齐 api-bridge:工具栏 chip 按 tab 显隐(视频成品走素材包,不用这些扁平筛选)
const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
{ key: "gender", label: "性别", tabs: ["people"] },
{ key: "age", label: "年龄段", tabs: ["people"] },
{ key: "role", label: "角色标签", tabs: ["people"] },
{ key: "sceneType", label: "场景类型", tabs: ["scenes"] },
{ key: "product", label: "关联商品", tabs: ["products", "tryon", "kits"] },
{ key: "project", label: "关联项目", tabs: ["materials"] },
{ key: "duration", label: "时长", tabs: ["materials"] },
{ key: "kind", label: "资产类型", tabs: ["uploads"] },
{ key: "source", label: "来源", tabs: ["people", "scenes", "products", "tryon", "kits", "creations", "uploads"] }
{ key: "product", label: "关联商品", tabs: ["tryon", "kits"] },
{ key: "kind", label: "资产类型", tabs: ["others"] },
{ key: "source", label: "来源", tabs: ["tryon", "kits", "creations", "others"] }
];
// metadata 里可能用的中文属性键(真实存在才渲染,缺则不显;属性区不造假)
@@ -396,7 +389,7 @@ function UploadModal({ open, close, onSubmit }: {
}
export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormData) => Promise<unknown> | void; onDelete?: (id: string) => Promise<unknown> | void }) {
const [tab, setTab] = useState<LibTab>("people");
const [tab, setTab] = useState<LibTab>("tryon");
const [query, setQuery] = useState("");
const [uploadOpen, setUploadOpen] = useState(false);
const [openChip, setOpenChip] = useState("");
@@ -428,7 +421,10 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
const [items, setItems] = useState<Asset[]>([]);
const [total, setTotal] = useState(0);
const [counts, setCounts] = useState<Record<LibTab, number>>({ people: 0, scenes: 0, products: 0, tryon: 0, kits: 0, creations: 0, materials: 0, uploads: 0, unclassified: 0 });
const [counts, setCounts] = useState<Record<LibTab, number>>({ tryon: 0, kits: 0, creations: 0, videopacks: 0, others: 0 });
// 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段)
const [packs, setPacks] = useState<import("../types").VideoPack[]>([]);
const [openPack, setOpenPack] = useState<import("../types").VideoPack | null>(null);
const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]> }>({ sources: [], kinds: [], metadata: {} });
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
@@ -448,6 +444,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// 列表数据(分页 + 过滤)
const [reloadFlag, setReloadFlag] = useState(0);
useEffect(() => {
if (tab === "videopacks") { setLoading(false); return; } // 视频成品走素材包,不拉扁平资产
let alive = true;
setLoading(true);
api.assetsPage({
@@ -467,6 +464,12 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// tab 计数(徽标)+ 当前 tab 的筛选项(下拉「只列真有的」):切 tab / 上传 / 删除后刷新
useEffect(() => { api.assetSummary().then((c) => setCounts((prev) => ({ ...prev, ...c }))).catch(() => {}); }, [reloadFlag]);
// 视频成品素材包:进页/刷新就拉一次,顺带把项目包数喂给 tab 徽标
useEffect(() => {
let alive = true;
api.videoPacks().then((ps) => { if (alive) { setPacks(ps); setCounts((prev) => ({ ...prev, videopacks: ps.length })); } }).catch(() => { if (alive) setPacks([]); });
return () => { alive = false; };
}, [reloadFlag]);
useEffect(() => {
let alive = true;
api.assetFacets(tab, metaKeys).then((f) => { if (alive) setFacets(f); }).catch(() => {});
@@ -523,7 +526,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 跨项目复用 · <span id="sub-people">{counts.people}</span> 角色 · <span id="sub-scenes">{counts.scenes}</span> 景 · <span id="sub-products">{counts.products}</span> 商 · <span id="sub-materials">{counts.materials}</span> 素材</span></div>
<div className="sub"><span className="mono">// 你的成品 · 图片 {counts.tryon + counts.kits + counts.creations} · 视频 {counts.videopacks} 包</span></div>
</div>
<div className="actions">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
@@ -543,6 +546,34 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
))}
</div>
{tab === "videopacks" ? (
<div className="packs-grid">
{packs.length === 0 ? (
<div className="empty-filter">// 还没有视频成品 · 去视频项目生成片段</div>
) : (
packs.map((pack) => {
const first = pack.clips[0];
return (
<article className="pack-card" key={pack.project_id || pack.project_name} onClick={() => setOpenPack(pack)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenPack(pack); } }}>
<div className="placeholder asset-thumb pack-thumb">
{first?.url ? (
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
) : pack.product_cover ? (
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
) : (
<span className="ph-frame"></span>
)}
<span className="pack-count mono">{pack.clips.length} </span>
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</div>
<div className="asset-body"><div className="asset-name">{pack.project_name}</div><div className="asset-meta mono"></div></div>
</article>
);
})
)}
</div>
) : (
<>
<div className="toolbar">
<div className="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
@@ -696,6 +727,34 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
)}
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
</>
)}
{/* 视频素材包弹窗:看该项目所有视频片段(可播) */}
{openPack && createPortal(
<div className="pack-modal-bg" role="dialog" aria-modal="true" aria-label="视频素材包" onClick={(e) => { if (e.target === e.currentTarget) setOpenPack(null); }}>
<div className="pack-modal">
<div className="pack-modal-h">
<div>
<h2>{openPack.project_name}</h2>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 视频素材包 · {openPack.clips.length} 段</span>
</div>
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenPack(null)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</div>
<div className="pack-clip-grid">
{openPack.clips.map((c, i) => (
<div className="pack-clip" key={c.id}>
<video src={c.url} controls muted playsInline preload="metadata" />
<div className="pack-clip-name mono"> {i + 1}</div>
</div>
))}
</div>
</div>
</div>,
document.body
)}
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
<div className={`lib-bulk-bar${selected.size > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">