diff --git a/core/backend/apps/ai/serializers.py b/core/backend/apps/ai/serializers.py index a41d2da..2b76f4b 100644 --- a/core/backend/apps/ai/serializers.py +++ b/core/backend/apps/ai/serializers.py @@ -38,6 +38,19 @@ class ImageConversationSerializer(serializers.ModelSerializer): return cached if cached is not None else obj.tasks.count() +class ImageConversationTrashSerializer(ImageConversationSerializer): + """垃圾桶专用对话契约:只额外暴露一张展示封面,不影响普通对话列表查询。""" + + cover_preview_url = serializers.SerializerMethodField() + + class Meta(ImageConversationSerializer.Meta): + fields = [*ImageConversationSerializer.Meta.fields, "cover_preview_url"] + read_only_fields = [*ImageConversationSerializer.Meta.read_only_fields, "cover_preview_url"] + + def get_cover_preview_url(self, obj) -> str: + return str(getattr(obj, "_trash_cover_preview_url", "") or "") + + class AITaskSerializer(serializers.ModelSerializer): model_config = ModelConfigSerializer(read_only=True) # 从 request_payload 抽出的分组/标签信息(由 AITaskViewSet annotate 提供;其它调用处无此注解则为 None)。 diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index 72eca52..d938a96 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -1128,6 +1128,73 @@ class ImageConversationTests(TestCase): trash_ids = {c["id"] for c in self.client.get("/api/ai/image-conversations/trash/?mode=image").json()["results"]} self.assertNotIn(str(conv.id), trash_ids) + def test_conversation_trash_uses_latest_generated_image_as_preview(self): + conv = ImageConversation.objects.create( + team=self.team, + created_by=self.user, + title="多资产对话", + mode=ImageConversation.Mode.IMAGE, + ) + provider = ModelProvider.objects.create(name="conv-cover-provider", display_name="Conv Cover Provider") + model = ModelConfig.objects.create( + provider=provider, + name="conv-cover-model", + display_name="Conv Cover Model", + capability=ModelConfig.Capability.IMAGE, + ) + assets = [] + for index, preview_url in enumerate(("http://x/older.png", "http://x/latest.png")): + task = AITask.objects.create( + team=self.team, + created_by=self.user, + conversation=conv, + task_type=AITask.Type.PRODUCT_IMAGE, + status=AITask.Status.SUCCEEDED, + model_config=model, + idempotency_key=f"conv-cover-task-{index}", + ) + asset = Asset.objects.create( + team=self.team, + created_by=self.user, + name=f"对话生成图 {index}", + asset_type=Asset.Type.IMAGE, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.FREE_CREATE, + origin_task=task, + ) + AssetFile.objects.create( + asset=asset, + object_key=f"free-create/{index}.png", + bucket="test", + content_type="image/png", + preview_url=preview_url, + is_primary=True, + ) + assets.append(asset) + + delete = self.client.delete(f"/api/ai/image-conversations/{conv.id}/") + self.assertIn(delete.status_code, (204, 200)) + rows = self.client.get("/api/ai/image-conversations/trash/?mode=image").json()["results"] + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["cover_preview_url"], "http://x/latest.png") + self.assertEqual(Asset.objects.filter(id__in=[asset.id for asset in assets]).count(), 2) + + def test_conversation_trash_without_generated_image_has_empty_preview(self): + conv = ImageConversation.objects.create( + team=self.team, + created_by=self.user, + title="无图对话", + mode=ImageConversation.Mode.IMAGE, + ) + + delete = self.client.delete(f"/api/ai/image-conversations/{conv.id}/") + self.assertIn(delete.status_code, (204, 200)) + rows = self.client.get("/api/ai/image-conversations/trash/?mode=image").json()["results"] + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["cover_preview_url"], "") + def test_direct_asset_delete_hides_image_but_keeps_conversation(self): conv = ImageConversation.objects.create( team=self.team, diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 608e46a..27903c0 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -19,7 +19,12 @@ from apps.products.models import Product from .generation_errors import classify_generation_error, public_error_for_task from .models import AITask, ImageConversation, ModelConfig -from .serializers import AITaskSerializer, ImageConversationSerializer, ModelConfigSerializer +from .serializers import ( + AITaskSerializer, + ImageConversationSerializer, + ImageConversationTrashSerializer, + ModelConfigSerializer, +) from .services import enqueue_standalone_images @@ -476,6 +481,11 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): serializer_class = ImageConversationSerializer queryset = ImageConversation.objects.select_related("product").order_by("-last_active_at") + def get_serializer_class(self): + if self.action == "trash": + return ImageConversationTrashSerializer + return super().get_serializer_class() + def get_queryset(self): queryset = super().get_queryset().annotate(_task_count=Count("tasks")) if self.action in ("trash", "restore", "purge"): @@ -524,8 +534,48 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): qs = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(qs) if page is not None: + self._attach_trash_cover_preview_urls(page) return self.get_paginated_response(self.get_serializer(page, many=True).data) - return Response(self.get_serializer(qs, many=True).data) + conversations = list(qs) + self._attach_trash_cover_preview_urls(conversations) + return Response(self.get_serializer(conversations, many=True).data) + + def _attach_trash_cover_preview_urls(self, conversations): + """给当前页对话挂一张展示封面;全部资产仍由对话统一恢复/彻底删除。""" + conversation_ids = [conversation.id for conversation in conversations] + if not conversation_ids: + return + covers = {} + assets = ( + Asset.objects.filter( + team=self.get_team(), + origin_task__conversation_id__in=conversation_ids, + origin_task__status=AITask.Status.SUCCEEDED, + origin_task__is_deleted=True, + origin_task__purged_at__isnull=True, + asset_type=Asset.Type.IMAGE, + is_deleted=True, + purged_at__isnull=True, + ) + .select_related("origin_task") + .prefetch_related("files") + .order_by("origin_task__conversation_id", "-origin_task__created_at", "created_at", "id") + ) + for asset in assets: + conversation_id = asset.origin_task.conversation_id + if conversation_id in covers: + continue + files = sorted( + asset.files.all(), + key=lambda item: (not item.is_primary, item.created_at, str(item.id)), + ) + for file in files: + preview_url = AssetFileSerializer(file).data.get("preview_url", "") + if preview_url: + covers[conversation_id] = preview_url + break + for conversation in conversations: + conversation._trash_cover_preview_url = covers.get(conversation.id, "") @action(detail=True, methods=["post"], url_path="restore") def restore(self, request, pk=None): diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 9539c8d..764ec30 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -27,6 +27,7 @@ import type { LoginSession, Invitation, ImageConversation, + ImageConversationTrash, ImageConversationTask, ModelConfig, ModelEntity, @@ -730,7 +731,7 @@ export const api = { return request>(`/api/ai/image-conversations/?${params.toString()}`); }, conversationsTrash(mode: "image" | "model" | "cover" = "image") { - return request>(`/api/ai/image-conversations/trash/?mode=${mode}&page_size=100`); + return request>(`/api/ai/image-conversations/trash/?mode=${mode}&page_size=100`); }, createConversation(payload: { mode?: "image" | "model" | "cover"; title?: string; product?: string | null }) { return request("/api/ai/image-conversations/", { method: "POST", body: JSON.stringify(payload) }); diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 030e160..3a598f4 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -738,6 +738,8 @@ export function ImageWorkbenchPage({ // 重命名:正在改名的对话 id + 草稿 const [renamingId, setRenamingId] = useState(""); const [renameDraft, setRenameDraft] = useState(""); + // 整条对话删除独立确认,不与单图/批次删除状态耦合。 + const [pendingDeleteConversation, setPendingDeleteConversation] = useState(null); // 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。 const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]); const refInputRef = useRef(null); @@ -1050,8 +1052,16 @@ export function ImageWorkbenchPage({ try { await api.renameConversation(convId, title); } catch { void loadConversations(); } } - // 删除对话(软删):从列表移除;删的是当前对话则切到剩下第一条或清空 + // 删除对话(软删):接口成功后再从列表移除;删的是当前对话则切到剩下第一条或清空。 async function handleDeleteConversation(convId: string) { + try { + await api.deleteConversation(convId); + setConvError(""); + } catch (error) { + setConvError(error instanceof Error ? error.message : "删除对话失败,请稍后重试"); + void loadConversations(); + return; + } const remaining = conversations.filter((c) => c.id !== convId); setConversations(remaining); if (convId === activeConvId) { @@ -1061,7 +1071,13 @@ export function ImageWorkbenchPage({ if (nextActive) void loadConvBatches(nextActive); else setBatches([]); } - try { await api.deleteConversation(convId); } catch { void loadConversations(); } + } + + async function confirmDeleteConversation() { + const target = pendingDeleteConversation; + setPendingDeleteConversation(null); + if (!target) return; + await handleDeleteConversation(target.id); } // 列表加载:image 模式才用对话(model/cover 走商品空间布局)。 @@ -1682,7 +1698,7 @@ export function ImageWorkbenchPage({ @@ -1823,6 +1839,16 @@ export function ImageWorkbenchPage({ + } + detail="删除后可在垃圾桶恢复,是否需要删除?" + confirmText="删除" + onCancel={() => setPendingDeleteConversation(null)} + onConfirm={confirmDeleteConversation} + /> {/* R108/R109:删除二次确认 —— 单张 / 整批共用,确认后软删进垃圾桶 */} cover: m.portrait || m.triview || "" })); -const rowsFromConversations = (items: ImageConversation[]): TrashRow[] => +const rowsFromConversations = (items: ImageConversationTrash[]): TrashRow[] => items.map((c) => ({ id: c.id, kind: "image", title: c.title || "未命名图片创作", - subtitle: `${c.task_count || 0} 个生成任务${dateOf(c.updated_at) ? ` · 删除于 ${dateOf(c.updated_at)}` : ""}` + subtitle: `${c.task_count || 0} 个生成任务${dateOf(c.updated_at) ? ` · 删除于 ${dateOf(c.updated_at)}` : ""}`, + cover: c.cover_preview_url || "" })); const IMAGE_MODE_LABEL: Record = { diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 04d7129..89ce275 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -659,6 +659,11 @@ export type ImageConversation = { updated_at: string; }; +// 垃圾桶专用:对话可含多个批次/资产,cover 只选一张用于列表展示。 +export type ImageConversationTrash = ImageConversation & { + cover_preview_url: string; +}; + // 工作台生成记录(R100):模特上身图/平台套图页从后端持久任务拉取(与任务中心同源), // 按 batch_id 归批还原批次流;assets 只含存活成图(软删的图已被后端过滤)。 export type WorkbenchTask = {