From 755e210951a373091dcaa85780aaeabbd6d6c7ac Mon Sep 17 00:00:00 2001 From: hh <2587203630@qq.com> Date: Wed, 15 Jul 2026 17:52:29 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=BC=82=E5=B8=B8=E6=89=B9=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/tests.py | 116 ++++++++++++++ core/backend/apps/ai/views.py | 147 ++++++++++++++++- core/backend/apps/assets/views.py | 9 +- core/frontend/src/App.tsx | 6 +- core/frontend/src/api.ts | 13 ++ core/frontend/src/routes/ai-tools.tsx | 99 +++++++----- core/frontend/src/routes/trash.tsx | 29 +++- core/frontend/src/types.ts | 13 ++ .../图片生成-无法删除失败图片-bug-todo.md | 150 ++++++++++++++++++ 9 files changed, 531 insertions(+), 51 deletions(-) create mode 100644 docs/bug_todo/图片生成-无法删除失败图片-bug-todo.md diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index 6d67225..c941d7d 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -1732,6 +1732,122 @@ class WorkbenchAndUnreadTests(TestCase): self.assertEqual(body["total"], 0) self.assertEqual(body["by_product"], {}) + def test_exception_batch_delete_restore_and_purge_are_atomic(self): + """部分失败批次进入“图片异常批次”,而非与资产垃圾桶重复;恢复后整批回放。""" + tasks = self._generate(mode="cover", count=2, platform_id="douyin") + failed, succeeded = tasks + # 模拟其中一张供应商失败:失败任务无成图,另一张保持成功,构成真实的混合批次。 + Asset.objects.filter(origin_task=failed).delete() + failed.status = AITask.Status.FAILED + failed.save(update_fields=["status"]) + succeeded_asset = Asset.objects.get(origin_task=succeeded) + + deleted = self.client.delete(f"/api/ai/tasks/{failed.id}/workbench-batch/") + self.assertEqual(deleted.status_code, 200, deleted.content) + self.assertEqual(deleted.json()["storage"], "exception_batch") + failed.refresh_from_db() + succeeded.refresh_from_db() + succeeded_asset.refresh_from_db() + self.assertTrue(failed.is_deleted) + self.assertTrue(succeeded.is_deleted) + self.assertTrue(succeeded_asset.is_deleted) + self.assertEqual( + self.client.get(f"/api/ai/tasks/workbench/?mode=cover&product={self.product.id}").json()["tasks"], [] + ) + + trash = self.client.get("/api/ai/tasks/workbench-exception-batches-trash/") + self.assertEqual(trash.status_code, 200, trash.content) + rows = trash.json()["results"] + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["id"], str(failed.id)) + self.assertEqual(rows[0]["cover_preview_url"], "http://x/cover.png") + asset_trash_ids = {row["id"] for row in self.client.get("/api/assets/trash/").json()["results"]} + self.assertNotIn(str(succeeded_asset.id), asset_trash_ids) + + restored = self.client.post(f"/api/ai/tasks/{failed.id}/restore-workbench-batch/") + self.assertEqual(restored.status_code, 200, restored.content) + failed.refresh_from_db() + succeeded.refresh_from_db() + succeeded_asset.refresh_from_db() + self.assertFalse(failed.is_deleted) + self.assertFalse(succeeded.is_deleted) + self.assertFalse(succeeded_asset.is_deleted) + rows = self.client.get(f"/api/ai/tasks/workbench/?mode=cover&product={self.product.id}").json()["tasks"] + self.assertEqual({row["id"] for row in rows}, {str(failed.id), str(succeeded.id)}) + + self.client.delete(f"/api/ai/tasks/{failed.id}/workbench-batch/") + purged = self.client.delete(f"/api/ai/tasks/{failed.id}/purge-workbench-batch/") + self.assertEqual(purged.status_code, 204, purged.content) + failed.refresh_from_db() + succeeded.refresh_from_db() + succeeded_asset.refresh_from_db() + self.assertIsNotNone(failed.purged_at) + self.assertIsNotNone(succeeded.purged_at) + self.assertIsNotNone(succeeded_asset.purged_at) + self.assertEqual(self.client.get("/api/ai/tasks/workbench-exception-batches-trash/").json()["results"], []) + + def test_successful_batch_keeps_existing_asset_trash_behavior(self): + tasks = self._generate(mode="cover", count=2, platform_id="douyin") + assets = list(Asset.objects.filter(origin_task__in=tasks)) + + deleted = self.client.delete(f"/api/ai/tasks/{tasks[0].id}/workbench-batch/") + self.assertEqual(deleted.status_code, 200, deleted.content) + self.assertEqual(deleted.json()["storage"], "asset") + self.assertFalse(AITask.objects.get(id=tasks[0].id).is_deleted) + self.assertTrue(all(Asset.objects.get(id=asset.id).is_deleted for asset in assets)) + asset_trash_ids = {row["id"] for row in self.client.get("/api/assets/trash/").json()["results"]} + self.assertTrue({str(asset.id) for asset in assets}.issubset(asset_trash_ids)) + self.assertEqual(self.client.get("/api/ai/tasks/workbench-exception-batches-trash/").json()["results"], []) + + def test_image_conversation_exception_batch_hides_and_restores_only_its_tasks(self): + """图片创作走对话任务读取,也必须在异常批次删除/恢复后保持一致。""" + conversation = ImageConversation.objects.create( + team=self.team, created_by=self.user, mode=ImageConversation.Mode.IMAGE, title="失败图片创作", product=self.product + ) + task = AITask.objects.create( + team=self.team, + created_by=self.user, + conversation=conversation, + task_type=AITask.Type.PRODUCT_IMAGE, + status=AITask.Status.FAILED, + model_config=ModelConfig.objects.filter(capability=ModelConfig.Capability.IMAGE).first(), + idempotency_key="conversation-exception-batch", + request_payload={"mode": "image", "batch_id": "conversation-exception-batch", "prompt": "失败的图片创作", "product_id": str(self.product.id)}, + ) + task_rows = self.client.get(f"/api/ai/image-conversations/{conversation.id}/tasks/").json()["tasks"] + self.assertEqual([row["id"] for row in task_rows], [str(task.id)]) + + self.assertEqual(self.client.delete(f"/api/ai/tasks/{task.id}/workbench-batch/").status_code, 200) + self.assertEqual(self.client.get(f"/api/ai/image-conversations/{conversation.id}/tasks/").json()["tasks"], []) + + self.assertEqual(self.client.post(f"/api/ai/tasks/{task.id}/restore-workbench-batch/").status_code, 200) + task_rows = self.client.get(f"/api/ai/image-conversations/{conversation.id}/tasks/").json()["tasks"] + self.assertEqual([row["id"] for row in task_rows], [str(task.id)]) + + def test_inflight_batch_cannot_be_deleted(self): + tasks = self._generate(mode="cover", count=1, platform_id="douyin") + task = tasks[0] + task.status = AITask.Status.POLLING + task.save(update_fields=["status"]) + + deleted = self.client.delete(f"/api/ai/tasks/{task.id}/workbench-batch/") + self.assertEqual(deleted.status_code, 409, deleted.content) + task.refresh_from_db() + self.assertFalse(task.is_deleted) + + def test_other_team_cannot_delete_image_batch(self): + task = self._generate(mode="cover", count=1, platform_id="douyin")[0] + other = User.objects.create_user(username="other-batch-owner", password="pass") + other_team = Team.objects.create(name="Other batch team", owner=other) + TeamMember.objects.create(team=other_team, user=other, role="owner", status="active") + other_client = APIClient() + other_client.force_authenticate(other) + + deleted = other_client.delete(f"/api/ai/tasks/{task.id}/workbench-batch/") + self.assertEqual(deleted.status_code, 404, deleted.content) + task.refresh_from_db() + self.assertFalse(task.is_deleted) + def test_unread_is_team_scoped_and_mode_whitelisted(self): self._generate(mode="cover", count=1) # 非生成任务(脚本 agent 复用 mode=auto)不计入未读 diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index ca13d12..47e110b 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -1,5 +1,5 @@ from django.db import transaction -from django.db.models import Count, Exists, OuterRef +from django.db.models import Count, Exists, OuterRef, Q from django.utils import timezone from rest_framework import status from rest_framework.decorators import action @@ -9,9 +9,10 @@ from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from apps.assets.models import Asset -from apps.assets.serializers import AssetSerializer +from apps.assets.serializers import AssetFileSerializer, AssetSerializer from apps.common.api import TeamScopedViewSetMixin, get_current_team from apps.common.celery_health import require_worker +from apps.products.models import Product from .generation_errors import classify_generation_error, public_error_for_task from .models import AITask, ImageConversation, ModelConfig @@ -146,6 +147,144 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet): # YYX#row22:只有「工作台图片生成」(mode∈model/cover/image)才计入未读; # 脚本/实体抽取/故事板等流水线内部任务不进这块未读统计。 _GEN_MODES = ("model", "cover", "image") + _WORKBENCH_IMAGE_TYPES = (AITask.Type.PERSON_IMAGE, AITask.Type.PRODUCT_IMAGE) + _DELETABLE_IMAGE_BATCH_STATUSES = (AITask.Status.SUCCEEDED, AITask.Status.FAILED, AITask.Status.CANCELLED) + + def _image_batch_tasks(self, pk, *, deleted: bool): + """Resolve one standalone image batch from a user-visible task anchor. + + The browser only supplies an anchor task id. Team, mode and batch membership are always + derived server-side so one team cannot delete or restore another team's task group. + """ + base = AITask.objects.filter( + team=self.get_team(), + project__isnull=True, + task_type__in=self._WORKBENCH_IMAGE_TYPES, + is_deleted=deleted, + purged_at__isnull=True, + ) + anchor = base.filter(pk=pk).first() + if anchor is None: + return None, [] + payload = anchor.request_payload or {} + mode = str(payload.get("mode") or "").strip() + if mode not in self._GEN_MODES: + return None, [] + batch_id = str(payload.get("batch_id") or "").strip() + batch_qs = base.filter(request_payload__mode=mode) + if batch_id: + batch_qs = batch_qs.filter(request_payload__batch_id=batch_id) + else: + batch_qs = batch_qs.filter(pk=anchor.pk) + return anchor, list(batch_qs.order_by("created_at").select_for_update()) + + @staticmethod + def _is_exception_batch(tasks): + return any(task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED) for task in tasks) + + @action(detail=True, methods=["delete"], url_path="workbench-batch") + def delete_workbench_batch(self, request, pk=None): + """Delete a completed image batch. + + Fully successful batches preserve the existing asset-trash behavior. A batch containing + a failed/cancelled task is soft-deleted as one recoverable exception batch instead. + """ + with transaction.atomic(): + anchor, tasks = self._image_batch_tasks(pk, deleted=False) + if anchor is None or not tasks: + return Response({"detail": "图片批次不存在或不可删除"}, status=status.HTTP_404_NOT_FOUND) + if any(task.status not in self._DELETABLE_IMAGE_BATCH_STATUSES for task in tasks): + return Response({"detail": "批次仍在处理中,暂不能删除"}, status=status.HTTP_409_CONFLICT) + + now = timezone.now() + task_ids = [task.id for task in tasks] + Asset.objects.filter(origin_task_id__in=task_ids, purged_at__isnull=True).update(is_deleted=True, updated_at=now) + if self._is_exception_batch(tasks): + AITask.objects.filter(id__in=task_ids).update(is_deleted=True, updated_at=now) + return Response({"storage": "exception_batch", "deleted_task_count": len(task_ids)}) + return Response({"storage": "asset", "deleted_task_count": 0}) + + @action(detail=True, methods=["post"], url_path="restore-workbench-batch") + def restore_workbench_batch(self, request, pk=None): + """Restore one previously deleted exception batch, including its generated images.""" + with transaction.atomic(): + anchor, tasks = self._image_batch_tasks(pk, deleted=True) + if anchor is None or not tasks: + return Response({"detail": "图片异常批次不存在或无法恢复"}, status=status.HTTP_404_NOT_FOUND) + now = timezone.now() + task_ids = [task.id for task in tasks] + AITask.objects.filter(id__in=task_ids).update(is_deleted=False, purged_at=None, updated_at=now) + Asset.objects.filter(origin_task_id__in=task_ids, purged_at__isnull=True).update(is_deleted=False, updated_at=now) + return Response({"restored_task_count": len(task_ids)}) + + @action(detail=True, methods=["delete"], url_path="purge-workbench-batch") + def purge_workbench_batch(self, request, pk=None): + """Permanently hide one deleted exception batch and all of its generated images.""" + with transaction.atomic(): + anchor, tasks = self._image_batch_tasks(pk, deleted=True) + if anchor is None or not tasks: + return Response({"detail": "图片异常批次不存在或已彻底删除"}, status=status.HTTP_404_NOT_FOUND) + now = timezone.now() + task_ids = [task.id for task in tasks] + AITask.objects.filter(id__in=task_ids).update(is_deleted=True, purged_at=now, updated_at=now) + Asset.objects.filter(origin_task_id__in=task_ids, purged_at__isnull=True).update(is_deleted=True, purged_at=now, updated_at=now) + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=False, methods=["get"], url_path="workbench-exception-batches-trash") + def workbench_exception_batches_trash(self, request): + """List recoverable failed/cancelled image batches for the existing global trash page.""" + tasks = list( + AITask.objects.filter( + team=self.get_team(), + project__isnull=True, + task_type__in=self._WORKBENCH_IMAGE_TYPES, + is_deleted=True, + purged_at__isnull=True, + request_payload__mode__in=self._GEN_MODES, + ).filter(Q(conversation__isnull=True) | Q(conversation__is_deleted=False)).order_by("created_at") + ) + groups = {} + product_ids = set() + for task in tasks: + payload = task.request_payload or {} + key = str(payload.get("batch_id") or task.id) + groups.setdefault(key, []).append(task) + product_id = str(payload.get("product_id") or "").strip() + if product_id: + product_ids.add(product_id) + + products = Product.objects.filter( + team=self.get_team(), id__in=product_ids, status=Product.Status.ACTIVE, purged_at__isnull=True + ).select_related("cover_asset").prefetch_related("cover_asset__files") + product_by_id = {str(product.id): product for product in products} + + rows = [] + for key, group in groups.items(): + if not self._is_exception_batch(group): + continue + first = group[0] + payload = first.request_payload or {} + product = product_by_id.get(str(payload.get("product_id") or "")) + cover = "" + if product and product.cover_asset and not product.cover_asset.is_deleted and product.cover_asset.purged_at is None: + files = list(product.cover_asset.files.all()) + primary = next((item for item in files if item.is_primary), files[0] if files else None) + if primary: + cover = AssetFileSerializer(primary).data.get("preview_url", "") + intended = sum(1 for task in group if not (task.request_payload or {}).get("batch_append")) + rows.append({ + "id": str(first.id), + "batch_id": str(payload.get("batch_id") or ""), + "mode": str(payload.get("mode") or "image"), + "prompt": str(payload.get("prompt") or ""), + "count": max(1, intended), + "product_id": str(payload.get("product_id") or ""), + "product_title": product.title if product else "", + "cover_preview_url": cover, + "updated_at": max(task.updated_at for task in group), + }) + rows.sort(key=lambda row: row["updated_at"], reverse=True) + return Response({"results": rows}) def _unread_base(self): """本团队、属于图片生成、且未读(read_at is null)的任务集合(已 annotate rp_mode/rp_product_id)。 @@ -187,6 +326,8 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet): team=self.get_team(), project__isnull=True, # 工作台独立生图不挂项目;排除流水线内部任务 task_type__in=[AITask.Type.PERSON_IMAGE, AITask.Type.PRODUCT_IMAGE], + is_deleted=False, + purged_at__isnull=True, ) .defer("request_payload", "response_payload") .annotate( @@ -326,7 +467,7 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): conversation = self.get_object() tasks = ( - AITask.objects.filter(conversation=conversation) + AITask.objects.filter(conversation=conversation, is_deleted=False, purged_at__isnull=True) .prefetch_related("generated_assets", "generated_assets__files") .order_by("created_at") ) diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index b7e9697..a0604fe 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -131,7 +131,14 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): Q(origin_task__task_type="free_video", origin_task__is_deleted=True) | Q(origin_task__conversation__is_deleted=True) ) - return trash_qs.exclude(owned_by_deleted_free_creation).order_by("-updated_at") + # 图片异常批次由任务批次垃圾桶统一恢复/彻底删除;其成功图不能再作为独立资产 + # 重复出现在这里。全成功图片批次不会软删任务,仍维持既有的逐图资产垃圾桶行为。 + owned_by_deleted_image_exception_batch = Q( + origin_task__project__isnull=True, + origin_task__task_type__in=["person_image", "product_image"], + origin_task__is_deleted=True, + ) + return trash_qs.exclude(owned_by_deleted_free_creation | owned_by_deleted_image_exception_batch).order_by("-updated_at") qs = qs.filter(is_deleted=False, purged_at__isnull=True) # 软删资产不出现在资产库 p = self.request.query_params tab = p.get("tab") diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 4850585..3df45a6 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -948,11 +948,11 @@ export function App() { case "freeCreate": return setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} />; case "imageOptimize": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />; case "modelPhoto": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />; case "platformCover": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />; case "modelPhotoDemoA": return navigate("modelPhoto")} navigate={navigate} />; case "modelPhotoDemoB": diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index f6f0e60..67c7fc9 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -694,6 +694,19 @@ export const api = { if (productId) qs.set("product", productId); return request<{ tasks: import("./types").WorkbenchTask[] }>(`/api/ai/tasks/workbench/?${qs.toString()}`); }, + // 工作台图片批次:全成功时沿用资产垃圾桶;含失败/取消任务时进入“图片异常批次”并支持整批恢复。 + deleteWorkbenchImageBatch(taskId: string) { + return request<{ storage: "asset" | "exception_batch" }>(`/api/ai/tasks/${taskId}/workbench-batch/`, { method: "DELETE" }); + }, + imageExceptionBatchesTrash() { + return request<{ results: import("./types").ImageExceptionBatchTrash[] }>("/api/ai/tasks/workbench-exception-batches-trash/"); + }, + restoreWorkbenchImageBatch(taskId: string) { + return request<{ restored_task_count: number }>(`/api/ai/tasks/${taskId}/restore-workbench-batch/`, { method: "POST" }); + }, + purgeWorkbenchImageBatch(taskId: string) { + return request(`/api/ai/tasks/${taskId}/purge-workbench-batch/`, { method: "DELETE" }); + }, // YYX#row22:未读生成任务汇总(导航胶囊总数 + 按商品分组角标) aiTasksUnread() { return request<{ total: number; by_product: Record }>("/api/ai/tasks/unread/"); diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index aad72b2..404bfea 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -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; /** 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({ 重跑 -
- -
- {/* R108/R109:删除整批 = 真删该批全部成图(回收站软删),先二次确认 */} - +
+ +
-
+ )} @@ -1611,18 +1627,19 @@ export function ImageWorkbenchPage({ 重跑 {/* R109:成图自动入资产库,去掉「加入资产库」按钮 */} -
- -
- {/* R108/R109:删除整批 = 真删该批全部成图(回收站软删),先二次确认 */} - +
+ +
-
+ )} ))} @@ -1706,7 +1723,7 @@ export function ImageWorkbenchPage({ title={confirmDel?.assetId ? "删除这张图片" : "删除当前批次"} subtitle="// DELETE" icon={} - detail="删除的图片将回收到垃圾桶,是否需要删除?" + detail="删除后可在垃圾桶恢复,是否需要删除?" confirmText="删除" onCancel={() => setConfirmDel(null)} onConfirm={doConfirmedDelete} @@ -2144,7 +2161,7 @@ export function ImageWorkbenchPage({ title={confirmDel?.assetId ? "删除这张图片" : "删除当前批次"} subtitle="// DELETE" icon={} - detail="删除的图片将回收到垃圾桶,是否需要删除?" + detail="删除后可在垃圾桶恢复,是否需要删除?" confirmText="删除" onCancel={() => setConfirmDel(null)} onConfirm={doConfirmedDelete} diff --git a/core/frontend/src/routes/trash.tsx b/core/frontend/src/routes/trash.tsx index b1725da..992338e 100644 --- a/core/frontend/src/routes/trash.tsx +++ b/core/frontend/src/routes/trash.tsx @@ -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 = { + 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); } diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 54f5284..cddecfc 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -694,6 +694,19 @@ export type ImageConversationTask = { assets: Asset[]; }; +// 垃圾桶中的图片异常批次:纯失败或部分失败的同次生成,恢复时任务和成图一并回到工作台。 +export type ImageExceptionBatchTrash = { + id: string; + batch_id: string; + mode: "image" | "model" | "cover"; + prompt: string; + count: number; + product_id: string; + product_title: string; + cover_preview_url: string; + updated_at: string; +}; + export type Notification = { id: string; type: string; diff --git a/docs/bug_todo/图片生成-无法删除失败图片-bug-todo.md b/docs/bug_todo/图片生成-无法删除失败图片-bug-todo.md new file mode 100644 index 0000000..7b94ea6 --- /dev/null +++ b/docs/bug_todo/图片生成-无法删除失败图片-bug-todo.md @@ -0,0 +1,150 @@ +# 图片生成无法删除失败图片 BUG TODO + +> 状态:Step 2 方案已确认,待进入实施 +> 页面:图片生成入口 `/asset-factory` 所进入的模特上身图、平台套图、图片创作 +> 协作规则:一次只执行一个 Step;在确认问题表现前,不排查、不修改代码。 + +## 1. 待确认的问题表现 + +用户提供的三个入口截图显示: + +- 模特上身图、平台套图与图片创作都可在失败/生成批次的“更多”菜单中看到“删除当前批次”; +- 失败批次没有实际图片资产,页面只显示“这张生成失败”的占位格; +- 点击删除的目标是整个失败批次,而非某一张已生成的图片;预期应当让该失败批次不再显示,且刷新、切换商品或重新进入页面后也不应恢复。 + +## 2. 初步目标 + +让失败批次能够被可靠删除:删除后立即从当前页面消失,且后端不会再将该失败任务回放到工作台;正常成图删除、生成中任务、重跑与积分结算不受影响。 + +## 3. 后续步骤(待现象确认后执行) + +### Step 1:只读定位删除链路 + +- 定位图片生成页面中失败图片的删除入口、可见条件和请求; +- 定位后端删除接口及其对失败任务、资产和批次状态的处理; +- 先给出根因假设与最小修复范围,不修改代码。 + +状态:已完成,未改业务代码。 + +排查结论:这是“删除资产”被错误地当作“删除批次/任务”的问题,失败批次没有资产,因此没有任何后端记录被删除。 + +1. 三个图片生成入口共用 `core/frontend/src/routes/ai-tools.tsx` 的 `ImageWorkbenchPage`;失败格由“批次数量大于成功资产数”在前端补出,并不对应一张 `Asset`。 +2. 当前确认删除逻辑只收集 `batch.results.map(a => a.id)` 后调用 `DELETE /api/assets//`。纯失败批次的 `results` 为空,所以请求列表为空,**点击确认不会发送任何删除请求**;前端只是暂时把卡片从内存中移除。 +3. 模特上身图/平台套图在挂载、切换商品时会请求 `GET /api/ai/tasks/workbench/`。该接口会继续返回失败的 `AITask`,前端将其还原为 `failed` 批次,因此删除后的失败卡会在刷新、切换商品或重新进入页面时恢复。 +4. 图片创作使用对话任务恢复,但失败任务同样没有资产;当前删除路径同样不具备删除任务的能力。 + +关键位置: + +| 环节 | 位置 | 证据 | +| --- | --- | --- | +| 失败格生成 | `core/frontend/src/routes/ai-tools.tsx` `renderBatchGrid` | 无成图且批次已终态时,渲染“这张生成失败”占位 | +| 当前删除动作 | 同文件 `doConfirmedDelete` | 只调用 `api.deleteAsset()`;失败批次 `results=[]`,不会请求接口 | +| 工作台恢复 | 同文件 `batchesFromWorkbenchTasks` 与恢复 effect | 失败任务被还原成失败批次 | +| 后端回放 | `core/backend/apps/ai/views.py` `AITaskViewSet.workbench` | 查询未排除已删除任务,且当前不存在工作台任务/批次删除接口 | + +本 Step 的结论不涉及模型、生成请求、积分或视觉样式;尚未实施修复。 + +### Step 2:确认修复方案 + +- 明确要修复的状态与边界; +- 确认删除后页面、任务记录与批次卡应如何呈现; +- 经确认后再实施。 + +状态:已完成,已确认“失败/异常批次整批可恢复;全成功批次维持现状”,待实施。 + +#### 2.1 确认的行为边界 + +| 场景 | 修复后行为 | +| --- | --- | +| 纯失败批次(无成图资产) | 整批进入垃圾桶的“图片异常批次”分区;卡片立即消失,刷新/切换/重新进入后不恢复;可整批恢复 | +| 全部成功批次(有成图且无失败) | 保持当前行为:每张成图进入既有“资产”分区,按图片恢复;不创建批次垃圾桶记录 | +| 同一批含失败、成功或补图/重跑任务 | 视为异常批次:以真实 `batch_id` 为范围整体删除和整体恢复,不留下孤立失败格或补图;已成功图片不单独出现在“资产”分区 | +| 生成中、排队中、后处理中 | 不提供批次删除;不尝试取消供应商请求、不改任务状态、不触发额外退款逻辑 | +| 图片创作中的某一批 | 只删除该批任务,保留同一对话的其它批次与对话本身 | +| 单张成功图片删除 | 沿用现有资产删除与垃圾桶恢复逻辑;本次不改变 | + +`compensating` 在前端当前被视为终态,但文案为“回滚中”;本次按安全边界把它与其它在途状态一样禁止删除,待积分回滚完成并转为 `failed` 或 `cancelled` 后,用户才可删除。 + +#### 2.2 确认的最小实现方案 + +1. 在 `AITaskViewSet` 增加仅用于**异常图片批次**的“删除当前批次 / 批次垃圾桶 / 恢复批次 / 彻底删除批次”接口。前端只提交批次中的一个**锚点任务 ID**;后端从该任务自行确认团队、图片模式、是否属于独立工作台,并以其真实 `batch_id` 查出同批任务。旧数据没有 `batch_id` 时只处理该锚点任务。 +2. 接口仅接受 `succeeded`、`failed`、`cancelled` 三种已结束状态;任何在途任务返回可见的拒绝结果,且不修改数据。 +3. 后端先判断整批任务是否含 `failed` 或 `cancelled`:没有失败的全成功批次继续走现有逐张资产软删;含失败的异常批次才进入新的批次软删流程。这样全成功批次的现有删除与资产垃圾桶行为不变。 +4. 异常批次的删除、恢复和彻底删除均在同一数据库事务中处理该批全部 `AITask` 与其已生成 `Asset`:删除标记 `is_deleted=True`,恢复统一复原,彻底删除再写入 `purged_at`。失败任务没有资产时,仍作为可恢复的批次记录存在。 +5. 在现有全局垃圾桶新增“图片异常批次”分区,复用已有列表行、恢复、彻底删除、清空和全部恢复交互;不新建垃圾桶页面或视觉组件。批次行显示原提示词、模式、张数、删除时间;缩略图优先使用该批次所属商品的商品图(模特上身图、平台套图及关联商品的图片创作均适用),商品不存在或无可用商品图时回退现有“无图”占位。商品图只作展示引用,不复制、不删除、不影响商品本身。 +6. 为避免异常批次出现两个恢复入口:该批任务处于删除状态时,其成功图片不单独列入“资产”分区;只有全成功批次、资产库删除或单图删除,才继续出现在“资产”分区。异常批次恢复会恢复该批的图片和任务记录;单图恢复维持现有语义。 +7. `GET /api/ai/tasks/workbench/` 与图片创作对话的 `tasks` 读取接口均过滤 `is_deleted=False`、`purged_at is null`,防止删除的异常任务再次被回放。 +8. 前端批次数据补存完整任务 ID 列表(当前恢复批次只保留卡片 ID,无法稳定定位后端锚点);点击“删除当前批次”后先等待接口成功,再从本地列表移除。请求失败时保留卡片,并复用应用既有通知提示失败原因。 +9. “更多 → 删除当前批次”只在批次已结束时显示;现有确认弹窗文字改为“删除后可在垃圾桶恢复”。不新增弹窗、颜色、按钮或样式。 + +#### 2.3 用户操作变化 + +生成页面的主流程不变:仍是“更多 → 删除当前批次 → 确认删除”。用户只会在需要反悔时,多一个恢复路径: + +```text +全局侧栏「垃圾桶」 + → 「图片异常批次」分区 + → 恢复(整批任务与图片回到原图片生成入口) +``` + +“全部恢复”和“清空垃圾桶”会包含这个异常批次分区,行为与商品、资产等现有分区一致;全成功批次则继续由“资产”分区按图片恢复。 + +#### 2.4 明确不在本次范围 + +- 不取消或中断供应商正在执行的图片生成; +- 不改变已预扣/已结算/失败补偿的积分逻辑; +- 不新增失败图片的单格删除按钮(失败格无可删除资产,删除语义仍是“删除当前批次”); +- 不删除图片创作的整条对话,也不改变对话垃圾桶; +- 不修改模型路由、提示词、生成结果、页面视觉或共享 CSS。 + +#### 2.5 实施验收与回归 + +1. 纯失败的模特上身图、平台套图、图片创作批次均可删除;刷新、切换商品/对话及重新进入页面后不再显示。 +2. 一批中含成功图与失败图时,删除后整批不回显,并仅出现在“图片异常批次”分区;恢复后任务和全部图片一并恢复。 +3. 含“重跑/补图”的同一 `batch_id` 会整体删除,不留下孤立失败格或历史补图。 +4. 生成中、排队中、后处理中与回滚中的批次没有删除入口;任务继续按既有流程完成或失败。 +5. 非当前团队、项目内任务、非图片生成任务不可通过新接口删除。 +6. “全部恢复 / 清空垃圾桶”正确包含图片异常批次;异常批次删除的图片不会与资产分区重复出现;全成功批次仍仅出现在资产分区。 +7. 图片异常批次优先展示所属商品图;未关联商品、商品已不可用或无商品图时稳定回退“无图”占位,不影响恢复和彻底删除。 +8. 后端定向测试覆盖上述状态、团队隔离、删除→垃圾桶→恢复→工作台回放、彻底删除与重复隐藏;前端执行 TypeScript/生产构建检查。测试全部使用模拟任务,不发起真实模型调用或积分消耗。 + +### Step 3:实施与验证 + +- 按已确认方案完成最小改动; +- 验证失败图片可删除,正常图片与其他生成流程不受影响。 + +状态:已完成。 + +实施结果: + +1. 图片工作台的“删除当前批次”现在提交批次锚点任务给后端统一判断。全成功批次继续按原有逻辑将成图逐张移入“资产”垃圾桶;含 `failed` 或 `cancelled` 任务的批次会作为一个可恢复的异常批次删除。 +2. 新增“图片异常批次”的删除、恢复、彻底删除与垃圾桶读取接口。异常批次恢复时,任务记录、成功成图和失败卡会一起恢复;同一 `batch_id` 的补图/重跑任务也一起处理。删除的异常任务已从工作台和图片创作对话的历史读取中排除,刷新后不会复活。 +3. 全局垃圾桶新增“图片异常批次”分区,复用现有列表行、恢复、彻底删除、全部恢复和清空垃圾桶交互;优先显示所属商品图,无法取得时沿用“无图”占位。异常批次中的成功图不会在“资产”分区重复显示;全成功批次保持原有资产分区行为。 +4. 生成中、排队中、处理中或回滚中的批次由后端拒绝删除;前端只为已结束批次展示“删除当前批次”。单张图片删除、整条图片创作对话删除、模型调用、积分扣除与补偿逻辑未改动。 +5. 页面未新增 CSS、颜色、圆角或组件;继续使用既有垃圾桶行、缩略图、按钮、Toast 与确认弹窗。确认弹窗文案已明确说明“删除后可在垃圾桶恢复”。 + +验证: + +- 图片批次定向回归 `9 / 9` 通过:纯/部分失败批次删除、恢复、彻底删除、商品图缩略、全成功批次原行为、图片创作对话回放、在途拒删与团队隔离; +- AI 模块完整回归 `58 / 58` 通过; +- 前端 TypeScript 与生产构建通过; +- `git diff --check` 通过; +- 所有测试使用 SQLite 和模拟模型,不产生真实模型调用或积分消耗。 + +### Step 4:页面回归与交付检查 + +状态:已完成(自动化回归)。 + +- 已复查删除入口仅在批次结束后显示;`回滚中` 现与生成、排队、处理中一样视为在途状态,不显示删除入口,待最终转为 `failed` 或 `cancelled` 后才可删除。 +- 已复查垃圾桶沿用既有列表行:异常批次使用商品图缩略图,无法取得时使用现有无图占位;不新增页面、Tab、组件或 CSS。 +- 前端生产构建再次通过,`git diff --check` 通过。 +- 未创建真实失败生成任务,未消耗积分;浏览器连接在本机运行时初始化时受限,实际页面点击验收留待开发环境中已有失败批次时按 Step 2.5 的清单执行。 + +#### 真实页面验收(2026-07-15) + +状态:通过。 + +1. 模特上身图中的 1 张失败批次可通过「更多 → 删除当前批次」进入删除流程; +2. 删除后,垃圾桶出现「图片异常批次 · 1」分区。该行展示关联商品图缩略图、原提示词、`模特上身图 · 1 张` 与删除时间; +3. 点击恢复后,失败批次重新出现在原商品的模特上身图工作台,仍显示失败格; +4. 本次未点击彻底删除或清空垃圾桶,保留数据的可恢复性。