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