fix: 修复资产库批量删除
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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:批次弹窗内单张删除 —— 同步从打开中的批次里摘掉;删空则关掉弹窗
|
||||
setOpenBatch((prev) => {
|
||||
if (!prev) return prev;
|
||||
const items = prev.items.filter((a) => !ids.includes(a.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;
|
||||
return { ...prev, items, count: items.length, cover };
|
||||
});
|
||||
try {
|
||||
await Promise.all(ids.map((id) => onDelete(id)));
|
||||
setReloadFlag((f) => f + 1);
|
||||
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((item) => !succeeded.has(item.id));
|
||||
if (!items.length) return null;
|
||||
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 };
|
||||
});
|
||||
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); }}>
|
||||
|
||||
Reference in New Issue
Block a user