fix: 支持删除图片异常批次
This commit is contained in:
@@ -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)不计入未读
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -948,11 +948,11 @@ export function App() {
|
||||
case "freeCreate":
|
||||
return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} />;
|
||||
case "imageOptimize":
|
||||
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
case "modelPhoto":
|
||||
return <ImageWorkbenchPage mode="model" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
return <ImageWorkbenchPage mode="model" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
case "platformCover":
|
||||
return <ImageWorkbenchPage mode="cover" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
return <ImageWorkbenchPage mode="cover" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
case "modelPhotoDemoA":
|
||||
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
|
||||
case "modelPhotoDemoB":
|
||||
|
||||
@@ -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<void>(`/api/ai/tasks/${taskId}/purge-workbench-batch/`, { method: "DELETE" });
|
||||
},
|
||||
// YYX#row22:未读生成任务汇总(导航胶囊总数 + 按商品分组角标)
|
||||
aiTasksUnread() {
|
||||
return request<{ total: number; by_product: Record<string, number> }>("/api/ai/tasks/unread/");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user