fix: 修复商品批量删除与恢复
This commit is contained in:
@@ -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(); }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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; });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user