fix: 修复资产库批量删除

This commit is contained in:
hh
2026-07-13 16:10:46 +08:00
parent 0a5a565088
commit 826fc124e6
3 changed files with 61 additions and 36 deletions
+1 -1
View File
@@ -877,7 +877,7 @@ export function App() {
/>
);
case "library":
return <LibraryPage onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
return <LibraryPage onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDeleteMany={(ids) => runProductBatch(ids, (id) => api.deleteAsset(id), "已移至垃圾桶")} />;
case "trash":
return (
<TrashPage
+1 -2
View File
@@ -47,8 +47,7 @@ body.edit-mode .library-page .asset-card.selected { border-color: var(--heat); b
/* 多选态隐藏单卡删除(改走 bulk-bar 批量删) */
body.edit-mode .library-page .asset-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }
/* 管理资产:图片成品「批次卡」多选(与平铺资产卡一致,复用 .card-check/.selected 视觉)。
仅 .batch-card,不波及视频「素材包卡」(它仍走 .pack-card 的单包删除按钮)。 */
/* 管理资产:图片批次与视频素材包均使用 .batch-card 多选,复用同一套勾选与选中视觉。 */
.library-page .batch-card .card-check {
position: absolute; top: 10px; left: 10px;
width: 22px; height: 22px; border-radius: 50%;
+55 -29
View File
@@ -11,6 +11,11 @@ import { Pager } from "../components/pager";
const LIB_PAGE_SIZE = 10;
type AssetBatchResult = {
succeededIds: string[];
failedIds: string[];
};
// asset.source / asset.asset_type / asset.category → 中文标签(筛选下拉 + 卡片 meta 用,不给用户看裸枚举)
const SOURCE_LABELS: Record<string, string> = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" };
const KIND_LABELS: Record<string, string> = { image: "图片", video: "视频", audio: "音频", subtitle: "字幕", document: "文档" };
@@ -97,6 +102,10 @@ function freeVideoToPack(asset: Asset): VideoPack {
};
}
function videoPackKey(pack: VideoPack): string {
return pack.project_id || pack.clips[0]?.id || pack.project_name;
}
// ─────────────────────────────────────────────────────────────────────────
// 资产详情弹窗 · 立绘大图 + 三视图/版本历史(人物)+ 简介/标签/属性 + 媒体画廊(商品)
// 用真实 Asset 字段渲染;缺字段留「-」或整段降级,不造 mock。
@@ -425,7 +434,7 @@ function UploadModal({ open, close, onSubmit }: {
);
}
export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormData) => Promise<unknown> | void; onDelete?: (id: string) => Promise<unknown> | void }) {
export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: FormData) => Promise<unknown> | void; onDeleteMany?: (ids: string[]) => Promise<AssetBatchResult> }) {
const [tab, setTab] = useState<LibTab>("tryon");
const [query, setQuery] = useState("");
const [uploadOpen, setUploadOpen] = useState(false);
@@ -440,6 +449,8 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
const [selected, setSelected] = useState<Set<string>>(new Set());
// 批次多选(图片成品 tab):按 batch_id 存;删除时展开成整批 asset id(见 bulkDeleteIds)
const [selectedBatches, setSelectedBatches] = useState<Set<string>>(new Set());
// 视频成品按项目素材包展示,选择键必须是项目包 id,删除时再展开为片段资产 id。
const [selectedVideoPacks, setSelectedVideoPacks] = useState<Set<string>>(new Set());
const [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
// 删除确认:单删传 [id],批删传选中数组
const [confirmIds, setConfirmIds] = useState<string[] | null>(null);
@@ -463,7 +474,12 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
if (next.has(batchId)) next.delete(batchId); else next.add(batchId);
return next;
});
const clearSelection = () => { setSelected(new Set()); setSelectedBatches(new Set()); };
const toggleVideoPack = (packId: string) => setSelectedVideoPacks((prev) => {
const next = new Set(prev);
if (next.has(packId)) next.delete(packId); else next.add(packId);
return next;
});
const clearSelection = () => { setSelected(new Set()); setSelectedBatches(new Set()); setSelectedVideoPacks(new Set()); };
const exitEdit = () => { setEditMode(false); clearSelection(); };
// ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)──
@@ -487,7 +503,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// 切 tab / 改筛选 / 改排序回到第 1 页
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]);
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setProductFilter(""); setMetaFilter({}); clearSelection(); }, [tab]);
// 当前 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);
@@ -495,6 +511,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
// 图片成品三类按批次成组展示;其余 tab 平铺
const isBatchTab = BATCH_TABS.includes(tab);
const isVideoCreationTab = tab === "video_creations";
const isVideoPackTab = tab === "videopacks";
const imageBatchCount = counts.tryon + counts.kits + counts.image_creations;
const resultUnit = isBatchTab ? "个批次" : (tab === "video_creations" ? "条视频" : "个资产");
@@ -564,11 +581,13 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
const shownPacks = filteredPacks.slice((packCurPage - 1) * LIB_PAGE_SIZE, packCurPage * LIB_PAGE_SIZE);
const openPackIsFreeVideo = Boolean(openPack?.project_id?.startsWith("free-video:"));
// 批量删除:批次 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));
// 批量删除:图片 tab 按批次展开,视频成品按素材包展开,其他 tab 直接使用资产 id
const selCount = isBatchTab ? selectedBatches.size : (isVideoPackTab ? selectedVideoPacks.size : selected.size);
const bulkDeleteIds = () => {
if (isBatchTab) return batches.filter((b) => selectedBatches.has(b.batch_id)).flatMap((b) => b.items.map((a) => a.id));
if (isVideoPackTab) return Array.from(new Set(packs.filter((pack) => selectedVideoPacks.has(videoPackKey(pack))).flatMap((pack) => pack.clips.map((clip) => clip.id).filter(Boolean))));
return Array.from(selected);
};
useEffect(() => {
if (!openChip) return;
@@ -590,25 +609,34 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
setReloadFlag((f) => f + 1);
}
// 删除(单 / 批):乐观隐藏 → 并发删 → 刷新计数/列表。后端为回收站软删(R108),可恢复。
// 删除(单 / 批):父级批量执行器逐项直调 API,并返回真实成功/失败 ID;后端为回收站软删可恢复。
async function doDelete() {
const ids = confirmIds || [];
setConfirmIds(null);
if (!ids.length || !onDelete) return;
if (!ids.length || !onDeleteMany) return;
setDeletingIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.add(id)); return next; });
setSelected(new Set());
setSelectedBatches(new Set());
// R108:批次弹窗内单张删除 —— 同步从打开中的批次里摘掉;删空则关掉弹窗
try {
const { succeededIds } = await onDeleteMany(ids);
const succeeded = new Set(succeededIds);
if (succeeded.size) {
setSelected((prev) => new Set([...prev].filter((id) => !succeeded.has(id))));
setSelectedBatches((prev) => new Set([...prev].filter((batchId) => {
const batch = batches.find((item) => item.batch_id === batchId);
return !batch || batch.items.some((item) => !succeeded.has(item.id));
})));
setSelectedVideoPacks((prev) => new Set([...prev].filter((packId) => {
const pack = packs.find((item) => videoPackKey(item) === packId);
return !pack || pack.clips.some((clip) => !succeeded.has(clip.id));
})));
setOpenBatch((prev) => {
if (!prev) return prev;
const items = prev.items.filter((a) => !ids.includes(a.id));
const items = prev.items.filter((item) => !succeeded.has(item.id));
if (!items.length) return null;
const cover = items[0].files?.find((f) => f.is_primary)?.preview_url || items[0].files?.[0]?.preview_url || prev.cover;
const cover = items[0].files?.find((file) => file.is_primary)?.preview_url || items[0].files?.[0]?.preview_url || prev.cover;
return { ...prev, items, count: items.length, cover };
});
try {
await Promise.all(ids.map((id) => onDelete(id)));
setReloadFlag((f) => f + 1);
setReloadFlag((flag) => flag + 1);
}
} finally {
setDeletingIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.delete(id)); return next; });
}
@@ -668,14 +696,12 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
shownPacks.map((pack) => {
const first = pack.clips[0];
// 视频成品的所有片段 id:批量删除整个素材包
const packClipIds = pack.clips.map((c) => c.id).filter(Boolean);
const packId = videoPackKey(pack);
const isSelected = selectedVideoPacks.has(packId);
const onPackClick = editMode ? () => toggleVideoPack(packId) : () => setOpenPack(pack);
return (
<article className="pack-card" key={pack.project_id || pack.project_name} onClick={() => !editMode && setOpenPack(pack)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!editMode) setOpenPack(pack); } }}>
{editMode && onDelete && packClipIds.length > 0 && (
<button className="card-del-btn" type="button" title="删除素材包" onClick={(event) => { event.stopPropagation(); setConfirmIds(packClipIds); }}>
<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>
)}
<article className={`pack-card batch-card${editMode && isSelected ? " selected" : ""}`} key={packId} onClick={onPackClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPackClick(); } }}>
<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">
{first?.url ? (
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
@@ -841,7 +867,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
return (
<article className={`pack-card batch-card${isSelected ? " selected" : ""}`} key={asset.id} onClick={onVideoClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onVideoClick(); } }}>
<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>
{editMode && onDelete && (
{editMode && onDeleteMany && (
<button className="card-del-btn" type="button" title="删除视频" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.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>
@@ -885,7 +911,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
return (
<article className={`asset-card ${asset.asset_type}${isSelected ? " selected" : ""}`} key={asset.id} onClick={onCardClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCardClick(); } }}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
{editMode && onDelete && (
{editMode && onDeleteMany && (
<button className="card-del-btn" type="button" title="删除资产" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.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>
@@ -990,7 +1016,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(null); setDetail(a); } }}
title="查看详情"
>
{onDelete && (
{onDeleteMany && (
<button className="card-del-btn" type="button" title="删除这张图片" onClick={(event) => { event.stopPropagation(); setConfirmIds([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>
@@ -1008,7 +1034,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
<div className={`lib-bulk-bar${selCount > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
<span className="ct"> <b>{selCount}</b> {isBatchTab ? "批" : "项"}</span>
<span className="ct"> <b>{selCount}</b> {isBatchTab || isVideoPackTab ? "批" : "项"}</span>
<button className="clear-sel" type="button" onClick={clearSelection}></button>
<span className="sep" />
<button className="danger" type="button" disabled={selCount === 0} onClick={() => { const ids = bulkDeleteIds(); if (ids.length) setConfirmIds(ids); }}>