fix: 支持删除图片异常批次

This commit is contained in:
hh
2026-07-15 17:52:29 +08:00
parent ff0a93677a
commit 755e210951
9 changed files with 531 additions and 51 deletions
+58 -41
View File
@@ -603,6 +603,8 @@ type GenBatch = {
backendBatchId?: string;
/** 该批次已提交、尚未终态的生图任务 id:切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
pendingIds?: string[];
/** 该批全部后端任务 id:删除/恢复批次时使用稳定锚点,终态批次也必须保留。 */
taskIds?: string[];
};
/**
@@ -633,7 +635,8 @@ export function ImageWorkbenchPage({
initialProductId,
onProductChange,
unreadByProduct,
onProductViewed
onProductViewed,
onNotify
}: {
mode: WorkMode;
products: Product[];
@@ -650,6 +653,8 @@ export function ImageWorkbenchPage({
unreadByProduct?: Record<string, number>;
/** YYX#row22:查看某商品(选中)即标记该商品的生成任务已读 → 清零角标 */
onProductViewed?: (productId: string) => void;
/** 批次删除失败时复用应用现有 Toast,避免静默移除卡片。 */
onNotify?: (type: "success" | "error" | "info", text: string) => void;
}) {
const meta = MODE_META[mode];
const [productId, setProductId] = useState(initialProductId || products[0]?.id || "");
@@ -866,7 +871,8 @@ export function ImageWorkbenchPage({
list.push(t);
groups.set(key, list);
}
const TERMINAL = new Set(["succeeded", "failed", "cancelled", "compensating"]);
// 回滚中仍可能继续变更任务与资产,和其它在途状态一致,不允许删除。
const TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
const result: GenBatch[] = [];
for (const [key, list] of groups) {
// R109:成功但成图已全部删除(软删进垃圾桶)的任务不再回显 —— 生成记录与资产库数据绑定
@@ -893,6 +899,7 @@ export function ImageWorkbenchPage({
refs: refSrc.length ? refSrc.map((r) => ({ name: r.name, url: r.url, assetId: r.id })) : undefined,
// 真 batch_id 才能作重跑归属;老任务无 batch_id 时 key=任务 id,不能带给后端
backendBatchId: live[0]?.batch_id || undefined,
taskIds: live.map((t) => t.id),
});
}
// 旧批次在上、新批次在下(对话流自上而下时间序)
@@ -909,7 +916,8 @@ export function ImageWorkbenchPage({
list.push(t);
groups.set(key, list);
}
const TERMINAL = new Set(["succeeded", "failed", "cancelled", "compensating"]);
// 回滚中仍可能继续变更任务与资产,和其它在途状态一致,不允许删除。
const TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
const result: GenBatch[] = [];
for (const [key, list] of groups) {
const live = list.filter((t) => t.status !== "succeeded" || (t.assets || []).length > 0);
@@ -933,6 +941,7 @@ export function ImageWorkbenchPage({
modelId: first.model_id || undefined,
platformIds: platformKey ? [platformKey] : undefined,
backendBatchId: first.batch_id || undefined,
taskIds: live.map((t) => t.id),
// 续轮询要带上整批任务 id(含已终态的):onResume 的结果会整体替换 results,
// 只传未完成 id 会把已出的好图从结果里丢掉(终态任务首轮轮询即回带成图,秒完成)。
pendingIds: hasRunning ? live.map((t) => t.id) : undefined,
@@ -1107,7 +1116,7 @@ export function ImageWorkbenchPage({
// batch_id:重跑/补图带原批次 id → 后端沿用,记录归回原批次(刷新后不裂新聊天记录)
// onSubmitted:提交成功拿到任务 id 记进本批 pendingIds → 切走再回来由后端记录接续轮询(R100)
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds, batch_id: opts.batchId,
onSubmitted: (taskIds, submittedBatchId) => setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds, backendBatchId: b.backendBatchId || submittedBatchId } : b))) });
onSubmitted: (taskIds, submittedBatchId) => setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds, taskIds, backendBatchId: b.backendBatchId || submittedBatchId } : b))) });
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
const convId = result?.conversation_id;
if (convId && convId !== activeConvRef.current) {
@@ -1196,28 +1205,34 @@ export function ImageWorkbenchPage({
});
}
/* R108/R109:工作台的「删除」= 真删资产(软删进回收站)—— 生成记录与资产库数据绑定,
删了才不会刷新后从后端记录里"复活"。删除前二次确认(ConfirmModal),文案与资产库一致。
confirmDel.assetId 有值 = 删单张;无值 = 删整批(该批全部存活成图)。 */
/* 工作台删除:单张保持既有资产垃圾桶;整批交给后端判断。
全成功批次继续逐图进入资产垃圾桶;含失败/取消任务的批次进入“图片异常批次”,
任务与成图可整批恢复,避免失败卡刷新后复活。 */
const [confirmDel, setConfirmDel] = useState<{ batchId: string; assetId?: string } | null>(null);
function doConfirmedDelete() {
async function doConfirmedDelete() {
const target = confirmDel;
setConfirmDel(null);
if (!target) return;
const batch = batches.find((b) => b.id === target.batchId);
if (!batch) return;
const ids = (target.assetId ? [target.assetId] : batch.results.map((a) => a.id)).filter(Boolean);
ids.forEach((id) => { void api.deleteAsset(id).catch(() => undefined); });
if (target.assetId) {
// 单张:从批次里摘掉;整批删空(且不在生成中)则整卡移除
setBatches((prev) => prev.flatMap((b) => {
if (b.id !== target.batchId) return [b];
const results = b.results.filter((a) => a.id !== target.assetId);
if (!results.length && b.status === "done") return [];
return [{ ...b, results, count: Math.max(1, b.count - 1) }];
}));
} else {
try {
if (target.assetId) {
await api.deleteAsset(target.assetId);
// 单张:从批次里摘掉;整批删空(且不在生成中)则整卡移除
setBatches((prev) => prev.flatMap((b) => {
if (b.id !== target.batchId) return [b];
const results = b.results.filter((a) => a.id !== target.assetId);
if (!results.length && b.status === "done") return [];
return [{ ...b, results, count: Math.max(1, b.count - 1) }];
}));
return;
}
const anchorTaskId = batch.taskIds?.[0] || batch.pendingIds?.[0];
if (!anchorTaskId) throw new Error("当前批次暂无法删除,请刷新页面后重试");
await api.deleteWorkbenchImageBatch(anchorTaskId);
setBatches((prev) => prev.filter((b) => b.id !== target.batchId));
} catch (error) {
onNotify?.("error", error instanceof Error ? error.message : "删除失败,请稍后重试");
}
}
@@ -1434,18 +1449,19 @@ export function ImageWorkbenchPage({
<RefreshCw size={13} />
</button>
<div className="gen-batch-more">
<button className="btn btn-sm btn-ghost" type="button" title="更多">
<MoreHorizontal size={13} />
</button>
<div className="gen-bubble gen-bubble-up">
{/* R108/R109:删除整批 = 真删该批全部成图(回收站软删),先二次确认 */}
<button type="button" className="gen-bubble-item danger" onClick={() => setConfirmDel({ batchId: batch.id })}>
<Trash2 size={13} />
{!generating && (
<div className="gen-batch-more">
<button className="btn btn-sm btn-ghost" type="button" title="更多">
<MoreHorizontal size={13} />
</button>
<div className="gen-bubble gen-bubble-up">
<button type="button" className="gen-bubble-item danger" onClick={() => setConfirmDel({ batchId: batch.id })}>
<Trash2 size={13} />
</button>
</div>
</div>
</div>
)}
</div>
</div>
</Fragment>
@@ -1611,18 +1627,19 @@ export function ImageWorkbenchPage({
</button>
{/* R109:成图自动入资产库,去掉「加入资产库」按钮 */}
<div className="gen-batch-more">
<button className="btn btn-sm btn-ghost" type="button" title="更多">
<MoreHorizontal size={13} />
</button>
<div className="gen-bubble gen-bubble-up">
{/* R108/R109:删除整批 = 真删该批全部成图(回收站软删),先二次确认 */}
<button type="button" className="gen-bubble-item danger" onClick={() => setConfirmDel({ batchId: batch.id })}>
<Trash2 size={13} />
{batch.status !== "generating" && (
<div className="gen-batch-more">
<button className="btn btn-sm btn-ghost" type="button" title="更多">
<MoreHorizontal size={13} />
</button>
<div className="gen-bubble gen-bubble-up">
<button type="button" className="gen-bubble-item danger" onClick={() => setConfirmDel({ batchId: batch.id })}>
<Trash2 size={13} />
</button>
</div>
</div>
</div>
)}
</div>
</div>
))}
@@ -1706,7 +1723,7 @@ export function ImageWorkbenchPage({
title={confirmDel?.assetId ? "删除这张图片" : "删除当前批次"}
subtitle="// DELETE"
icon={<Trash2 size={16} />}
detail="删除的图片将回收到垃圾桶,是否需要删除?"
detail="删除后可在垃圾桶恢复,是否需要删除"
confirmText="删除"
onCancel={() => setConfirmDel(null)}
onConfirm={doConfirmedDelete}
@@ -2144,7 +2161,7 @@ export function ImageWorkbenchPage({
title={confirmDel?.assetId ? "删除这张图片" : "删除当前批次"}
subtitle="// DELETE"
icon={<Trash2 size={16} />}
detail="删除的图片将回收到垃圾桶,是否需要删除?"
detail="删除后可在垃圾桶恢复,是否需要删除"
confirmText="删除"
onCancel={() => setConfirmDel(null)}
onConfirm={doConfirmedDelete}
+26 -3
View File
@@ -3,10 +3,10 @@ import type { CSSProperties } from "react";
import { api } from "../api";
import { ConfirmModal } from "../components/overlays";
import type { NavigateFn } from "./route-config";
import type { Asset, FreeVideoTask, ImageConversation, ModelEntity, Product, Project } from "../types";
import type { Asset, FreeVideoTask, ImageConversation, ImageExceptionBatchTrash, ModelEntity, Product, Project } from "../types";
import "../trash-page.css";
type TrashKind = "product" | "asset" | "model" | "image" | "freeVideo" | "project";
type TrashKind = "product" | "asset" | "model" | "image" | "imageExceptionBatch" | "freeVideo" | "project";
type TrashRow = {
id: string;
@@ -81,6 +81,21 @@ const rowsFromConversations = (items: ImageConversation[]): TrashRow[] =>
subtitle: `${c.task_count || 0} 个生成任务${dateOf(c.updated_at) ? ` · 删除于 ${dateOf(c.updated_at)}` : ""}`
}));
const IMAGE_MODE_LABEL: Record<ImageExceptionBatchTrash["mode"], string> = {
image: "图片创作",
model: "模特上身图",
cover: "平台套图"
};
const rowsFromImageExceptionBatches = (items: ImageExceptionBatchTrash[]): TrashRow[] =>
items.map((batch) => ({
id: batch.id,
kind: "imageExceptionBatch",
title: batch.prompt || batch.product_title || "未命名图片生成",
subtitle: `${IMAGE_MODE_LABEL[batch.mode]} · ${batch.count}${dateOf(batch.updated_at) ? ` · 删除于 ${dateOf(batch.updated_at)}` : ""}`,
cover: batch.cover_preview_url || ""
}));
const rowsFromFreeVideos = (items: FreeVideoTask[]): TrashRow[] =>
items.map((task) => ({
id: task.id,
@@ -125,9 +140,10 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
api.assetsTrash(),
api.modelsTrash(),
api.conversationsTrash("image"),
api.imageExceptionBatchesTrash(),
api.freeVideoTrash(0, 100),
api.projectsTrash()
]).then(([products, assets, models, conversations, freeVideos, projects]) => {
]).then(([products, assets, models, conversations, imageExceptionBatches, freeVideos, projects]) => {
if (!alive) return;
const rawSections: TrashSection[] = [
{
@@ -150,6 +166,11 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
title: "自由创作图片",
rows: conversations.status === "fulfilled" ? rowsFromConversations(conversations.value.results) : []
},
{
key: "imageExceptionBatch",
title: "图片异常批次",
rows: imageExceptionBatches.status === "fulfilled" ? rowsFromImageExceptionBatches(imageExceptionBatches.value.results) : []
},
{
key: "freeVideo",
title: "自由创作视频",
@@ -185,6 +206,7 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
if (row.kind === "asset") return api.restoreAsset(row.id);
if (row.kind === "model") return api.restoreModel(row.id);
if (row.kind === "image") return api.restoreConversation(row.id);
if (row.kind === "imageExceptionBatch") return api.restoreWorkbenchImageBatch(row.id);
if (row.kind === "freeVideo") return api.restoreFreeVideo(row.id);
return api.restoreProject(row.id);
}
@@ -194,6 +216,7 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
if (row.kind === "asset") return api.purgeAsset(row.id);
if (row.kind === "model") return api.purgeModel(row.id);
if (row.kind === "image") return api.purgeConversation(row.id);
if (row.kind === "imageExceptionBatch") return api.purgeWorkbenchImageBatch(row.id);
if (row.kind === "freeVideo") return api.purgeFreeVideo(row.id);
return api.purgeProject(row.id);
}