fix: 修复商品批量删除与恢复

This commit is contained in:
hh
2026-07-13 14:31:26 +08:00
parent f803948143
commit bd3b484fde
4 changed files with 258 additions and 12 deletions
+40 -1
View File
@@ -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<Record<Page, string>> = {
dashboard: "工作台",
@@ -501,6 +502,40 @@ export function App() {
}
}
async function runProductBatch<T>(ids: string[], work: (id: string) => Promise<T>, successText: string): Promise<ProductBatchResult> {
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 <ProductsPage products={products} loading={!dataLoaded} navigate={navigate} openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "已移至垃圾桶")} />;
if (!activeProduct) return <ProductsPage products={products} loading={!dataLoaded} navigate={navigate} openProduct={(productId, tab) => 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 (
<ProductDetailPage
product={activeProduct}
@@ -847,6 +884,8 @@ export function App() {
navigate={navigate}
onRestore={(id) => 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(); }}
/>
);
+11 -2
View File
@@ -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<Product | null | undefined> | void;
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | void;
onDelete?: (id: string) => Promise<unknown> | void;
onDeleteMany?: (ids: string[]) => Promise<ProductBatchResult>;
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; });
+36 -9
View File
@@ -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<unknown> | void;
onPurge: (id: string) => Promise<unknown> | void;
onRestoreProducts: (ids: string[]) => Promise<ProductBatchResult>;
onPurgeProducts: (ids: string[]) => Promise<ProductBatchResult>;
onChanged?: () => void;
}) {
const [sections, setSections] = useState<TrashSection[]>([]);
@@ -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<ProductBatchResult>({ 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<ProductBatchResult>({ 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);
}
@@ -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. 完成定义
用户在商品库或垃圾桶选择任意数量的商品时,页面显示的选中数量、网络实际处理数量、成功 / 失败反馈和最终列表结果完全一致。