diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index fff0977..4678829 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -44,6 +44,7 @@ import { AdminApp } from "./routes/admin/admin-app"; import { TrashPage } from "./routes/trash"; import { ModelsPage } from "./routes/models"; import { money } from "./routes/stage-config"; +import type { ProductBatchResult } from "./routes/products"; const crumbLabels: Partial> = { dashboard: "工作台", @@ -501,6 +502,40 @@ export function App() { } } + async function runProductBatch(ids: string[], work: (id: string) => Promise, successText: string): Promise { + const uniqueIds = Array.from(new Set(ids)); + if (!uniqueIds.length) return { succeededIds: [], failedIds: [] }; + if (actionInFlightRef.current) { + setNotice({ type: "error", text: "操作进行中,请稍候…" }); + return { succeededIds: [], failedIds: uniqueIds }; + } + + actionInFlightRef.current = true; + setLoading(true); + setNotice(null); + try { + const results = await Promise.allSettled(uniqueIds.map(work)); + const succeededIds = uniqueIds.filter((_, index) => results[index].status === "fulfilled"); + const failedIds = uniqueIds.filter((_, index) => results[index].status === "rejected"); + if (succeededIds.length && !failedIds.length) { + setNotice({ type: "success", text: `${successText} ${succeededIds.length} 项` }); + } else if (succeededIds.length) { + setNotice({ type: "error", text: `${successText} ${succeededIds.length} 项,失败 ${failedIds.length} 项` }); + } else { + const firstFailure = results.find((result): result is PromiseRejectedResult => result.status === "rejected"); + setNotice({ type: "error", text: firstFailure?.reason instanceof Error ? firstFailure.reason.message : "操作失败" }); + } + if (succeededIds.length) { + void loadData(); + void refreshProjectDetail(); + } + return { succeededIds, failedIds }; + } finally { + setLoading(false); + actionInFlightRef.current = false; + } + } + async function markNotificationRead(id: string) { await api.markNotificationRead(id).catch(() => undefined); await reloadNotifications(); @@ -741,6 +776,7 @@ export function App() { onCreate={(payload) => action(() => api.createProduct(payload), "")} onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "已移至垃圾桶")} + onDeleteMany={(ids) => runProductBatch(ids, (id) => api.deleteProduct(id), "已移至垃圾桶")} /> ); case "productCreateUpload": @@ -756,11 +792,12 @@ export function App() { onCreate={(payload) => action(() => api.createProduct(payload), "")} onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "已移至垃圾桶")} + onDeleteMany={(ids) => runProductBatch(ids, (id) => api.deleteProduct(id), "已移至垃圾桶")} autoOpenCreate /> ); case "productDetail": - if (!activeProduct) return navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "已移至垃圾桶")} />; + if (!activeProduct) return navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "已移至垃圾桶")} onDeleteMany={(ids) => runProductBatch(ids, (id) => api.deleteProduct(id), "已移至垃圾桶")} />; return ( action(() => api.restoreProduct(id), "已恢复到商品库")} onPurge={(id) => action(() => api.purgeProduct(id), "已彻底删除")} + onRestoreProducts={(ids) => runProductBatch(ids, (id) => api.restoreProduct(id), "已恢复到商品库")} + onPurgeProducts={(ids) => runProductBatch(ids, (id) => api.purgeProduct(id), "已彻底删除")} onChanged={() => { void loadData(); }} /> ); diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index e9e9e17..777c49d 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -29,6 +29,11 @@ type ProductPayload = { selling_points?: Array<{ title: string; detail: string; sort_order: number }>; }; +export type ProductBatchResult = { + succeededIds: string[]; + failedIds: string[]; +}; + // 复刻 mock-media productFor:按商品名关键词映射商品图 → 完整 mock 图 URL(无匹配返回 "") // 导出供平台套图工作台复用(YYX#14:左侧商品列表无真封面时也回退到 mock,与商品库一致显图) export function productMockCoverUrl(name: string): string { @@ -56,7 +61,7 @@ function resolveCoverUrl(product: Product): string { return product.cover_preview_url || firstImage?.preview_url || ""; } -export function ProductsPage({ products, projects = [], loading = false, navigate, openProduct, onCreate, onUploadImage, onDelete, autoOpenCreate = false }: { +export function ProductsPage({ products, projects = [], loading = false, navigate, openProduct, onCreate, onUploadImage, onDelete, onDeleteMany, autoOpenCreate = false }: { products: Product[]; projects?: Project[]; loading?: boolean; @@ -65,6 +70,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat onCreate: (payload: ProductPayload) => Promise | void; onUploadImage?: (productId: string, formData: FormData) => Promise | void; onDelete?: (id: string) => Promise | void; + onDeleteMany?: (ids: string[]) => Promise; autoOpenCreate?: boolean; }) { const [query, setQuery] = useState(""); @@ -94,7 +100,10 @@ export function ProductsPage({ products, projects = [], loading = false, navigat setSelected(new Set()); try { // 并发删除,缩短多选批删的体感时延(不再串行逐个等待) - await Promise.all(ids.map((id) => onDelete?.(id))); + const result = onDeleteMany + ? await onDeleteMany(ids) + : { succeededIds: ids, failedIds: [] }; + if (result.failedIds.length) setSelected(new Set(result.failedIds)); } finally { // 父组件刷新后这些 id 已不在 products 里;清理本地标记避免泄漏 setDeletingIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.delete(id)); return next; }); diff --git a/core/frontend/src/routes/trash.tsx b/core/frontend/src/routes/trash.tsx index 017bdc8..b1725da 100644 --- a/core/frontend/src/routes/trash.tsx +++ b/core/frontend/src/routes/trash.tsx @@ -22,6 +22,11 @@ type TrashSection = { rows: TrashRow[]; }; +type ProductBatchResult = { + succeededIds: string[]; + failedIds: string[]; +}; + const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties); const dateOf = (iso?: string | null): string => { @@ -94,10 +99,12 @@ const rowsFromProjects = (items: Project[]): TrashRow[] => cover: p.cover_preview_url || "" })); -export function TrashPage({ onRestore, onPurge, onChanged }: { +export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProducts, onChanged }: { navigate: NavigateFn; onRestore: (id: string) => Promise | void; onPurge: (id: string) => Promise | void; + onRestoreProducts: (ids: string[]) => Promise; + onPurgeProducts: (ids: string[]) => Promise; onChanged?: () => void; }) { const [sections, setSections] = useState([]); @@ -224,16 +231,26 @@ export function TrashPage({ onRestore, onPurge, onChanged }: { setBulkBusy("restore"); setErrText(""); const rows = [...allRows]; - const results = await Promise.allSettled(rows.map((row) => restoreRow(row))); - const failed = new Set(rows.filter((_, i) => results[i].status === "rejected").map(rowKey)); + const productRows = rows.filter((row) => row.kind === "product"); + const otherRows = rows.filter((row) => row.kind !== "product"); + const [productResult, otherResults] = await Promise.all([ + productRows.length + ? onRestoreProducts(productRows.map((row) => row.id)) + : Promise.resolve({ succeededIds: productRows.map((row) => row.id), failedIds: [] }), + Promise.allSettled(otherRows.map((row) => restoreRow(row))) + ]); + const failed = new Set([ + ...productResult.failedIds.map((id) => `product:${id}`), + ...otherRows.filter((_, index) => otherResults[index].status === "rejected").map(rowKey) + ]); setSections((list) => list .map((section) => ({ ...section, rows: section.rows.filter((row) => failed.has(rowKey(row))) })) .filter((section) => section.rows.length > 0) ); - const firstFail = results.find((r): r is PromiseRejectedResult => r.status === "rejected"); + const firstFail = otherResults.find((r): r is PromiseRejectedResult => r.status === "rejected"); if (firstFail) setErrText(firstFail.reason instanceof Error ? firstFail.reason.message : "部分恢复失败"); - if (results.some((r) => r.status === "fulfilled")) onChanged?.(); + if (productResult.succeededIds.length || otherResults.some((r) => r.status === "fulfilled")) onChanged?.(); setBulkBusy(null); } @@ -242,16 +259,26 @@ export function TrashPage({ onRestore, onPurge, onChanged }: { setBulkBusy("purge"); setErrText(""); const rows = [...allRows]; - const results = await Promise.allSettled(rows.map((row) => purgeRow(row))); - const failed = new Set(rows.filter((_, i) => results[i].status === "rejected").map(rowKey)); + const productRows = rows.filter((row) => row.kind === "product"); + const otherRows = rows.filter((row) => row.kind !== "product"); + const [productResult, otherResults] = await Promise.all([ + productRows.length + ? onPurgeProducts(productRows.map((row) => row.id)) + : Promise.resolve({ succeededIds: productRows.map((row) => row.id), failedIds: [] }), + Promise.allSettled(otherRows.map((row) => purgeRow(row))) + ]); + const failed = new Set([ + ...productResult.failedIds.map((id) => `product:${id}`), + ...otherRows.filter((_, index) => otherResults[index].status === "rejected").map(rowKey) + ]); setSections((list) => list .map((section) => ({ ...section, rows: section.rows.filter((row) => failed.has(rowKey(row))) })) .filter((section) => section.rows.length > 0) ); - const firstFail = results.find((r): r is PromiseRejectedResult => r.status === "rejected"); + const firstFail = otherResults.find((r): r is PromiseRejectedResult => r.status === "rejected"); if (firstFail) setErrText(firstFail.reason instanceof Error ? firstFail.reason.message : "部分删除失败"); - if (results.some((r) => r.status === "fulfilled")) onChanged?.(); + if (productResult.succeededIds.length || otherResults.some((r) => r.status === "fulfilled")) onChanged?.(); setBulkBusy(null); } diff --git a/docs/bug_todo/商品批量删除与恢复优化-todo.md b/docs/bug_todo/商品批量删除与恢复优化-todo.md new file mode 100644 index 0000000..4fecc75 --- /dev/null +++ b/docs/bug_todo/商品批量删除与恢复优化-todo.md @@ -0,0 +1,171 @@ +# 商品批量删除与恢复优化 TODO + +> 状态: Step 1、Step 2 已实施,等待浏览器验证 +> 页面: 商品库 `/products`、垃圾桶 +> 优先级: P1(用户选择与实际删除结果不一致,可能误以为全部已删除) + +## 1. 问题现象 + +在商品库 `/products` 的卡片视图中,同时勾选 2 个商品后点击底部操作条的「删除选中」: + +- 页面明确显示“已选 2 项”。 +- 实际只删除第 1 个被选中的商品。 +- 浏览器网络面板只观察到一条单商品删除请求: + +```http +DELETE /api/products/42d443aa-1c80-441a-aa08-ca2883482fca/ +``` + +因此,当前批量删除入口与后端单商品删除接口之间的批量处理未闭环。该问题需要同时覆盖垃圾桶的批量恢复,避免“批量删除修好、批量恢复仍只处理第一项”的同类缺口。 + +## 2. 已确认根因与影响范围 + +已知: + +- 单商品删除接口当前形态为 `DELETE /api/products/:id/`。 +- 商品删除遵循既有垃圾桶语义:普通删除进入垃圾桶,不应物理删除记录或资产文件。 +- 当前 UI 支持多选,并在底部操作条显示选中数量。 +- 商品页已将全部选中 ID 传入 `Promise.all(ids.map(...))`,选中集合没有丢失。 +- 每个商品删除都会再调用 App 级 `action(() => api.deleteProduct(id), ...)`。 +- `action()` 有全局单飞锁 `actionInFlightRef`:第一项开始后占锁;同一批次后续项命中锁时,直接返回 `null`,不调用接口。 +- `null` 是已兑现的 Promise 值,`Promise.all` 不会将它识别为失败。因此页面会误以为整批已经处理完毕。 + +已确认的调用链: + +```text +批量删除选中 ID + → ProductsPage: Promise.all(ids.map(onDelete)) + → App: action(() => api.deleteProduct(id)) + → 第 1 项取得全局 actionInFlightRef 锁并发出 DELETE + → 第 2 项及后续项被全局锁拦截,返回 null,不发 HTTP 请求 + → Promise.all 将 null 当作成功完成 +``` + +影响范围: + +- 商品库「删除选中」:选中多项时通常仅第 1 项实际发出 `DELETE /api/products/:id/`。 +- 垃圾桶「全部恢复」中的商品:商品恢复同样经过 `action(() => api.restoreProduct(id))`,第 1 个商品外的后续商品会被同一把锁拦截。 +- 垃圾桶「清空」中的商品:商品二级删除同样经过 `action(() => api.purgeProduct(id))`,也有同类风险。 +- 垃圾桶内的资产、模特、自由创作、项目操作当前直接调用对应 API,不经过该 App 级锁;本问题确认只影响其中的商品项。 + +非根因: + +- 不是多选状态只保留了首项:商品页在调用批量逻辑前已正确得到完整 ID 数组。 +- 不是后端只支持删除一项:后端单商品删除接口会正常完成软删除,且没有共享的批量限制。 +- 不需要为此新增后端批量删除或恢复接口;现有单项接口可复用。 + +## 3. 目标与边界 + +目标: + +- 勾选 N 个商品后,删除操作必须准确处理 N 个商品。 +- 垃圾桶勾选 N 个商品后,恢复操作必须准确处理 N 个商品。 +- 成功、部分失败、全部失败均给出与实际结果一致的反馈,并保留可重试项。 +- 完成后清理已成功处理商品的选择状态,失败项仍保持选中。 + +边界: + +- 只处理商品库及商品垃圾桶的“批量删除 / 批量恢复”行为;不扩展到模特、资产、项目等其他业务。 +- 不改变既有软删除、垃圾桶和二级隐藏的数据语义。 +- 不物理删除数据库记录、关联资产或对象存储文件。 +- 不借本次改动重做商品库页面视觉、卡片、多选交互或接口鉴权。 + +## 4. 推荐实施方案 + +优先复用已验证的单商品接口,并为“同一次商品批量操作”提供专用批量执行路径:直接调用 `api.deleteProduct` / `api.restoreProduct` / `api.purgeProduct`,不逐项经过全局单飞 `action()`。批量执行器负责统一的请求中状态、结果汇总、一次刷新和一次反馈;单商品入口继续保留 `action()` 的防连点保护。 + +### Step 1:抽出商品批量执行器,隔离全局单飞锁 + +- 在 App 层或商品操作模块新增仅供批量使用的执行器,接收明确的 ID 数组和单项 API 函数。 +- 批量执行器不得逐项调用 `action()`;它应自行维护本批次 loading 状态,以免用户重复点击同一批操作。 +- 使用 `Promise.allSettled` 收集每个 ID 的结果,保留成功 ID 与失败 ID,不能把 `null` 或未启动请求当作成功。 +- 整批请求完成后只刷新一次商品数据;批量过程中不让每一项各自触发全局 `loadData()`。 + +验收: + +- 传入 2 个 ID 时,执行器必定尝试 2 次单项请求。 +- 执行器的结果能精确对应每一个输入 ID,不存在静默的 `null` 成功。 +- 单商品操作仍经过既有 `action()`,保持原有防重复提交行为。 + +状态: 已实施,等待浏览器验证。 + +实施记录: + +- App 已新增商品批量执行器:整批操作只占用一次全局锁,批内直接调用每个商品的 API。 +- 执行器使用 `Promise.allSettled` 返回每个 ID 的成功 / 失败结果,仅在至少一项成功后刷新一次全局商品数据。 +- 商品库“删除选中”已接入该执行器;失败商品会重新保持选中,方便下一步重试处理。 +- 单商品删除入口仍使用原有 `action()`,未改变单项删除与软删除语义。 +- 已通过 TypeScript 编译检查:`tsc -b`。 + +### Step 1.1:商品库批量删除接入 + +- 对每个选中的商品 ID 执行删除;不得只使用首个 ID。 +- 通过 Step 1 的批量执行器调用 `api.deleteProduct`,不再从每一项进入 `action()`。 +- 等待全部请求结束后汇总结果,避免请求未完成就刷新列表或清空选择。 +- 全部成功:刷新商品列表,提示实际删除数量,清空选择。 +- 部分失败:刷新已成功删除的项目,提示“成功 X 项,失败 Y 项”,仅保留失败商品的选择状态。 +- 全部失败:保留全部选择状态,展示可理解的错误信息,不显示成功提示。 + +验收: + +- 勾选 2 个商品时,网络面板出现对应的 2 次删除处理(或 1 次包含 2 个 ID 的批量请求)。 +- 两个商品均从正常商品列表消失,并在垃圾桶可见。 +- 删除 1 项时,原有单项删除行为不回归。 + +### Step 2:修复垃圾桶中的商品批量恢复与清空 + +- 对垃圾桶“全部恢复”中的商品使用同一个批量执行器直接调用 `api.restoreProduct`,不再逐项进入 `action()`。 +- 对“清空垃圾桶”中的商品使用同一个批量执行器直接调用 `api.purgeProduct`,消除同类遗漏风险。 +- 非商品分类继续使用其原有直接 API 调用,不扩大本次改动范围。 +- 恢复成功的商品回到正常商品列表,不再显示在垃圾桶。 +- 部分失败时,失败商品仍留在垃圾桶且保持选中,可直接重试。 + +验收: + +- 垃圾桶中有 2 个商品时点击“全部恢复”,网络面板出现对应的 2 次 `POST /api/products/:id/restore/`,两个商品都回到商品库。 +- 垃圾桶中有 2 个商品时点击“清空垃圾桶”,网络面板出现对应的 2 次 `DELETE /api/products/:id/purge/`。 +- 部分失败时,成功与失败数量、列表位置和选择状态均正确。 + +状态: 已实施,等待浏览器验证。 + +实施记录: + +- 垃圾桶“全部恢复”会将商品与其他分类拆分处理:商品只调用一次专用批量执行器,其他分类保持原有直接 API 请求。 +- 垃圾桶“清空”对商品采用相同的批量执行器,避免仅第一个商品实际发出 `purge` 请求。 +- 商品失败 ID 会保留在垃圾桶;成功 ID 才会从垃圾桶页面移除。 +- 已通过 TypeScript 编译检查:`tsc -b`。 + +### Step 3:回归与防回归测试 + +- 为前端商品批量删除、全部恢复、清空补测试:2 项成功、部分失败、全部失败、单项操作。 +- 专门断言:批量操作不会调用 App 级单飞 `action()` 两次;每一个商品 ID 都会形成一个对应 API 请求。 +- 确认删除 / 恢复后卡片数量、选中数量、底部操作条和垃圾桶数量同步正确。 +- 验证重复点击防护:请求进行中禁用对应操作,避免同一商品被重复提交。 +- 执行前端类型检查和现有相关测试。 + +验收: + +- 以上四类测试通过。 +- 手动验证商品库与垃圾桶各至少一次 2 项批量操作。 +- 未改变商品删除的软删除语义,也未影响单商品删除、单商品恢复。 + +状态: 自动检查已完成;真实页面批量回归待确认。 + +验证记录: + +- 前端 TypeScript 编译检查已通过:`tsc -b`。 +- `git diff --check` 已通过。 +- 已检查本地 `http://localhost:5173/products`:当前商品库有 5 个商品;垃圾桶中没有商品记录,无法在不先删除现有商品的前提下验证“全部恢复”两个商品。 +- 后端 `ProductTrashTests` 未能启动:当前项目 `.venv` 仍指向已不存在的本机 Python 3.11 路径;未为测试重建或改写依赖环境。 +- 待确认的真实页面回归:选中 2 个商品删除并在垃圾桶全部恢复,确认各有 2 个对应请求,且两个商品均正确回到商品库。 + +### Step 3 执行结果(2026-07-13) + +- 已在本地真实页面完成批量删除回归:选择 2 个商品后确认删除,商品库由 5 个变为 3 个,两个选中商品均进入垃圾桶。 +- 已完成“全部恢复”回归:两个商品均回到商品库,且商品标题均重新可见;垃圾桶已无可恢复项目。 +- 本次“全部恢复”同时恢复了原有的 2 条自由创作记录,已事先取得用户确认。 +- 已验证批量删除与批量恢复都正确处理了 2 个商品;清空垃圾桶、部分失败、全部失败与重复点击保护分支仍待补充验证。 + +## 5. 完成定义 + +用户在商品库或垃圾桶选择任意数量的商品时,页面显示的选中数量、网络实际处理数量、成功 / 失败反馈和最终列表结果完全一致。