fix: YYX测试清单16条收口——资产库弹窗层级/批次多选删/商品筛选/模特卡单卡化/垃圾桶清空等

- 资产库:批次弹窗降层(under-confirm 990<999)修单图删除确认弹窗被压;编辑态批次卡多选批删;facets 新增 products 关联商品筛选(前后端,+2 测试);视频成品 tab 补搜索/排序/分页
- 平台套图工作台:「更多」气泡与 icon 左对齐+鼠标移出自动收起;模特卡改单卡(空态「+」占位,点卡进模特库单选回填)
- 工作台:删余额条,冻结/共计移至小标题行右侧(.dash-funds-mono)
- 模特库:点模特卡弹详情(形象图/标签/三视图+灯箱放大)
- 垃圾桶:新增「全部恢复」/「清空垃圾桶」(清空二次确认)
- 视频项目页:字重/阴影等轻量对齐设计规范
- api.assetFacets 返回体逐键兜底:旧后端缺 products 键会致资产库整页 undefined.find 白屏(前后端部署时序防护,浏览器已复现验证)

验证:tsc --noEmit / vite build / apps.assets 测试全过;暂存树已独立编译验证,不依赖工作区其余在途改动

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-07-02 18:03:26 +08:00
co-authored by Claude Fable 5
parent a048c32527
commit 5ec69ad91f
14 changed files with 496 additions and 176 deletions
+111 -32
View File
@@ -415,10 +415,14 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
const [openChip, setOpenChip] = useState("");
const [srcFilter, setSrcFilter] = useState("");
const [kindFilter, setKindFilter] = useState("");
// 关联商品筛选(仅图片成品 tab):存商品 id,选中后把 product=<id> 传进列表/批次请求;选项走 facets.products
const [productFilter, setProductFilter] = useState("");
const [sortDesc, setSortDesc] = useState(true);
// 编辑模式 + 多选 + 元数据筛选(性别/年龄/角色/场景类型/关联/时长 走 asset.metadata,真实存在才有可选项)
const [editMode, setEditMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
// 批次多选(图片成品 tab):按 batch_id 存;删除时展开成整批 asset id(见 bulkDeleteIds)
const [selectedBatches, setSelectedBatches] = useState<Set<string>>(new Set());
const [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
// 删除确认:单删传 [id],批删传选中数组
const [confirmIds, setConfirmIds] = useState<string[] | null>(null);
@@ -436,7 +440,14 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
// 批次多选:整卡点选 = 选中/取消该批次(与「管理项目」一致)
const toggleBatch = (batchId: string) => setSelectedBatches((prev) => {
const next = new Set(prev);
if (next.has(batchId)) next.delete(batchId); else next.add(batchId);
return next;
});
const clearSelection = () => { setSelected(new Set()); setSelectedBatches(new Set()); };
const exitEdit = () => { setEditMode(false); clearSelection(); };
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
const [items, setItems] = useState<Asset[]>([]);
@@ -448,7 +459,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段)
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 [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]>; products: { id: string; title: string }[] }>({ sources: [], kinds: [], metadata: {}, products: [] });
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
@@ -457,12 +468,12 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
useEffect(() => { const id = setTimeout(() => setDebouncedQuery(query), 400); return () => clearTimeout(id); }, [query]);
// 切 tab / 改筛选 / 改排序回到第 1 页
useEffect(() => { setPage(1); }, [tab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc]);
// 切 tab 时清空与该 tab 无关的筛选 + 退出多选(避免跨 tab 误删)
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); setSelected(new Set()); }, [tab]);
useEffect(() => { setPage(1); }, [tab, debouncedQuery, srcFilter, kindFilter, productFilter, metaFilter, sortDesc]);
// 切 tab 时清空与该 tab 无关的筛选 + 退出多选(避免跨 tab 误删/误筛)
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setProductFilter(""); setMetaFilter({}); setSelected(new Set()); setSelectedBatches(new Set()); }, [tab]);
// 当前 tab 的 metadata 筛选键(来源/类型走独立字段,不算 metadata)
const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind").map((c) => c.key);
// 当前 tab 的 metadata 筛选键(来源/类型/关联商品走独立字段与 facets,不算 metadata)
const metaKeys = LIB_CHIPS.filter((c) => c.tabs.includes(tab) && c.key !== "source" && c.key !== "kind" && c.key !== "product").map((c) => c.key);
// 图片成品三类按批次成组展示;其余 tab 平铺
const isBatchTab = BATCH_TABS.includes(tab);
@@ -477,6 +488,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
tab,
q: debouncedQuery || undefined,
source: srcFilter || undefined,
product: productFilter || undefined, // 后端 assetsPage/batches 两条通道都按 ?product= 三路过滤
meta: metaFilter,
ordering: sortDesc ? "-created_at" : "created_at",
page,
@@ -495,7 +507,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
.finally(() => { if (alive) setLoading(false); });
}
return () => { alive = false; };
}, [tab, isBatchTab, debouncedQuery, srcFilter, kindFilter, metaFilter, sortDesc, page, reloadFlag]);
}, [tab, isBatchTab, debouncedQuery, srcFilter, kindFilter, productFilter, metaFilter, sortDesc, page, reloadFlag]);
// tab 计数(徽标)+ 当前 tab 的筛选项(下拉「只列真有的」):切 tab / 上传 / 删除后刷新
useEffect(() => { api.assetSummary().then((c) => setCounts((prev) => ({ ...prev, ...c }))).catch(() => {}); }, [reloadFlag]);
@@ -515,12 +527,28 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
const srcOptions = facets.sources;
const kindOptions = facets.kinds;
const metaOptions = (key: string) => facets.metadata[key] || [];
const hasFilter = Boolean(debouncedQuery || srcFilter || kindFilter || Object.values(metaFilter).some(Boolean));
const hasFilter = Boolean(debouncedQuery || srcFilter || kindFilter || productFilter || Object.values(metaFilter).some(Boolean));
const totalPages = Math.max(1, Math.ceil(total / LIB_PAGE_SIZE));
const curPage = Math.min(page, totalPages);
// 乐观隐藏后真正渲染的列表
const shown = items.filter((a) => !deletingIds.has(a.id));
// 视频成品:前端全量数组按搜索(项目名)过滤 + 排序(按片段数)+ 分页切片(10/页,共享 Pager)
const filteredPacks = useMemo(() => {
const q = debouncedQuery.trim().toLowerCase();
const arr = q ? packs.filter((p) => (p.project_name || "").toLowerCase().includes(q)) : packs.slice();
return arr.sort((a, b) => (sortDesc ? b.clips.length - a.clips.length : a.clips.length - b.clips.length));
}, [packs, debouncedQuery, sortDesc]);
const packTotalPages = Math.max(1, Math.ceil(filteredPacks.length / LIB_PAGE_SIZE));
const packCurPage = Math.min(page, packTotalPages);
const shownPacks = filteredPacks.slice((packCurPage - 1) * LIB_PAGE_SIZE, packCurPage * LIB_PAGE_SIZE);
// 批量删除:批次 tab 走 selectedBatches(展开成整批 asset id),其余 tab 走 selected(asset id)
const selCount = isBatchTab ? selectedBatches.size : selected.size;
const bulkDeleteIds = () => (isBatchTab
? batches.filter((b) => selectedBatches.has(b.batch_id)).flatMap((b) => b.items.map((a) => a.id))
: Array.from(selected));
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => {
@@ -548,6 +576,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
if (!ids.length || !onDelete) return;
setDeletingIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.add(id)); return next; });
setSelected(new Set());
setSelectedBatches(new Set());
// R108:批次弹窗内单张删除 —— 同步从打开中的批次里摘掉;删空则关掉弹窗
setOpenBatch((prev) => {
if (!prev) return prev;
@@ -587,11 +616,35 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
</div>
{tab === "videopacks" ? (
<>
<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>
<input className="input" placeholder="搜索项目名" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<span className="spacer"></span>
<div className={`chip-wrap${openChip === "sort" ? " open" : ""}`} data-key="sort">
<button className="chip" type="button" onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
<span className="chip-label">{sortDesc ? "片段最多" : "片段最少"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu align-right">
<div className={`mi${sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(true); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>片段最多
</div>
<div className={`mi${!sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(false); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>片段最少
</div>
</div>
</div>
</div>
<div className="result-meta">// 显示 <span className="count">{shownPacks.length}</span> / {filteredPacks.length} 个素材包{debouncedQuery ? "(已筛选)" : ""}</div>
<div className="packs-grid">
{packs.length === 0 ? (
<div className="empty-filter">// 还没有视频成品 · 去视频项目生成片段</div>
{filteredPacks.length === 0 ? (
<div className="empty-filter">// {debouncedQuery ? "没有匹配的视频成品" : "还没有视频成品 · 去视频项目生成片段"}</div>
) : (
packs.map((pack) => {
shownPacks.map((pack) => {
const first = pack.clips[0];
// 视频成品的所有片段 id:批量删除整个素材包
const packClipIds = pack.clips.map((c) => c.id).filter(Boolean);
@@ -619,6 +672,8 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
})
)}
</div>
<Pager page={packCurPage} total={filteredPacks.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
</>
) : (
<>
<div className="toolbar">
@@ -668,6 +723,28 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
</div>
);
}
if (chip.key === "product") {
// 关联商品:选项走 facets.products(本 tab 下真实关联到的商品),选中后 product=<id> 进列表/批次请求
const curTitle = facets.products.find((p) => p.id === productFilter)?.title || "";
return (
<div className={`chip-wrap${openChip === "product" ? " open" : ""}`} data-key="product" key="product">
<button className={`chip${productFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
<span className="chip-label">{productFilter ? (curTitle || "关联商品") : "关联商品"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!productFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setProductFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>全部商品
</div>
{facets.products.length > 0 && <div className="mi-sep" />}
{facets.products.map((p) => (
<div className={`mi${productFilter === p.id ? " selected" : ""}`} key={p.id} role="button" tabIndex={0} onClick={() => { setProductFilter(p.id); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{p.title}
</div>
))}
</div>
</div>
);
}
// 其余维度走 asset.metadata 真实派生:有标记数据才出可选项,否则只「全部」
const opts = metaOptions(chip.key);
const cur = metaFilter[chip.key] || "";
@@ -711,20 +788,21 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
{isBatchTab ? (
batches.length ? (
<div className="packs-grid" id="batch-grid">
{batches.map((batch) => (
<article className="pack-card" key={batch.batch_id} onClick={() => !editMode && setOpenBatch(batch)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!editMode) setOpenBatch(batch); } }}>
{editMode && onDelete && (
<button className="card-del-btn" type="button" title="删除整批" onClick={(event) => { event.stopPropagation(); setConfirmIds(batch.items.map((a) => a.id)); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div className="placeholder asset-thumb pack-thumb">
{batch.cover ? <img src={batch.cover} alt={batch.name} loading="lazy" /> : <span className="ph-frame">无图</span>}
<span className="pack-count mono">{batch.count} 张</span>
</div>
<div className="asset-body"><div className="asset-name">{batch.name}</div><div className="asset-meta mono">生成批次</div></div>
</article>
))}
{batches.map((batch) => {
const isSel = selectedBatches.has(batch.batch_id);
// 编辑态:整卡点击 = 选中/取消该批次(走 lib-bulk-bar 批量删);非编辑态:点开批次弹窗
const onBatchClick = editMode ? () => toggleBatch(batch.batch_id) : () => setOpenBatch(batch);
return (
<article className={`pack-card batch-card${editMode && isSel ? " selected" : ""}`} key={batch.batch_id} onClick={onBatchClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onBatchClick(); } }}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
<div className="placeholder asset-thumb pack-thumb">
{batch.cover ? <img src={batch.cover} alt={batch.name} loading="lazy" /> : <span className="ph-frame">无图</span>}
<span className="pack-count mono">{batch.count} 张</span>
</div>
<div className="asset-body"><div className="asset-name">{batch.name}</div><div className="asset-meta mono">生成批次</div></div>
</article>
);
})}
</div>
) : loading ? (
<SkeletonGrid count={8} />
@@ -826,9 +904,10 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
document.body
)}
{/* 生成批次弹窗:看该批次所有图片(点单图开详情/大图) */}
{/* 生成批次弹窗:看该批次所有图片(点单图开详情/大图)。
under-confirm:批次弹窗内点单图删除会弹 ConfirmModal(.modal-bg),故批次层必须降到 .modal-bg 之下,否则二次确认被压住看不见(R109 修复) */}
{openBatch && createPortal(
<div className="pack-modal-bg" role="dialog" aria-modal="true" aria-label="生成批次" onClick={(e) => { if (e.target === e.currentTarget) setOpenBatch(null); }}>
<div className="pack-modal-bg under-confirm" role="dialog" aria-modal="true" aria-label="生成批次" onClick={(e) => { if (e.target === e.currentTarget) setOpenBatch(null); }}>
<div className="pack-modal">
<div className="pack-modal-h">
<div>
@@ -870,11 +949,11 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
)}
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
<div className={`lib-bulk-bar${selected.size > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
<span className="ct">已选 <b>{selected.size}</b> 项</span>
<button className="clear-sel" type="button" onClick={() => setSelected(new Set())}>清空</button>
<div className={`lib-bulk-bar${selCount > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
<span className="ct">已选 <b>{selCount}</b> {isBatchTab ? "批" : "项"}</span>
<button className="clear-sel" type="button" onClick={clearSelection}>清空</button>
<span className="sep" />
<button className="danger" type="button" disabled={selected.size === 0} onClick={() => selected.size && setConfirmIds(Array.from(selected))}>
<button className="danger" type="button" disabled={selCount === 0} onClick={() => { const ids = bulkDeleteIds(); if (ids.length) setConfirmIds(ids); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
删除选中
</button>