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:
@@ -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="批量操作">
|
||||
|
||||
Reference in New Issue
Block a user