diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 4678829..87b8b89 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -877,7 +877,7 @@ export function App() { /> ); case "library": - return action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />; + return action(() => api.uploadAsset(formData), "资产已上传")} onDeleteMany={(ids) => runProductBatch(ids, (id) => api.deleteAsset(id), "已移至垃圾桶")} />; case "trash": return ( = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" }; const KIND_LABELS: Record = { 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 | void; onDelete?: (id: string) => Promise | void }) { +export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: FormData) => Promise | void; onDeleteMany?: (ids: string[]) => Promise }) { const [tab, setTab] = useState("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>(new Set()); // 批次多选(图片成品 tab):按 batch_id 存;删除时展开成整批 asset id(见 bulkDeleteIds) const [selectedBatches, setSelectedBatches] = useState>(new Set()); + // 视频成品按项目素材包展示,选择键必须是项目包 id,删除时再展开为片段资产 id。 + const [selectedVideoPacks, setSelectedVideoPacks] = useState>(new Set()); const [metaFilter, setMetaFilter] = useState>({}); // 删除确认:单删传 [id],批删传选中数组 const [confirmIds, setConfirmIds] = useState(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 ( -
!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 && ( - - )} +
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPackClick(); } }}> +
{first?.url ? (