fix: 修复图片创作删除与垃圾桶预览
This commit is contained in:
@@ -38,6 +38,19 @@ class ImageConversationSerializer(serializers.ModelSerializer):
|
|||||||
return cached if cached is not None else obj.tasks.count()
|
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):
|
class AITaskSerializer(serializers.ModelSerializer):
|
||||||
model_config = ModelConfigSerializer(read_only=True)
|
model_config = ModelConfigSerializer(read_only=True)
|
||||||
# 从 request_payload 抽出的分组/标签信息(由 AITaskViewSet annotate 提供;其它调用处无此注解则为 None)。
|
# 从 request_payload 抽出的分组/标签信息(由 AITaskViewSet annotate 提供;其它调用处无此注解则为 None)。
|
||||||
|
|||||||
@@ -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"]}
|
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)
|
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):
|
def test_direct_asset_delete_hides_image_but_keeps_conversation(self):
|
||||||
conv = ImageConversation.objects.create(
|
conv = ImageConversation.objects.create(
|
||||||
team=self.team,
|
team=self.team,
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ from apps.products.models import Product
|
|||||||
|
|
||||||
from .generation_errors import classify_generation_error, public_error_for_task
|
from .generation_errors import classify_generation_error, public_error_for_task
|
||||||
from .models import AITask, ImageConversation, ModelConfig
|
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
|
from .services import enqueue_standalone_images
|
||||||
|
|
||||||
|
|
||||||
@@ -476,6 +481,11 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
serializer_class = ImageConversationSerializer
|
serializer_class = ImageConversationSerializer
|
||||||
queryset = ImageConversation.objects.select_related("product").order_by("-last_active_at")
|
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):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset().annotate(_task_count=Count("tasks"))
|
queryset = super().get_queryset().annotate(_task_count=Count("tasks"))
|
||||||
if self.action in ("trash", "restore", "purge"):
|
if self.action in ("trash", "restore", "purge"):
|
||||||
@@ -524,8 +534,48 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
qs = self.filter_queryset(self.get_queryset())
|
qs = self.filter_queryset(self.get_queryset())
|
||||||
page = self.paginate_queryset(qs)
|
page = self.paginate_queryset(qs)
|
||||||
if page is not None:
|
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 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")
|
@action(detail=True, methods=["post"], url_path="restore")
|
||||||
def restore(self, request, pk=None):
|
def restore(self, request, pk=None):
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
LoginSession,
|
LoginSession,
|
||||||
Invitation,
|
Invitation,
|
||||||
ImageConversation,
|
ImageConversation,
|
||||||
|
ImageConversationTrash,
|
||||||
ImageConversationTask,
|
ImageConversationTask,
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
ModelEntity,
|
ModelEntity,
|
||||||
@@ -730,7 +731,7 @@ export const api = {
|
|||||||
return request<Paginated<ImageConversation>>(`/api/ai/image-conversations/?${params.toString()}`);
|
return request<Paginated<ImageConversation>>(`/api/ai/image-conversations/?${params.toString()}`);
|
||||||
},
|
},
|
||||||
conversationsTrash(mode: "image" | "model" | "cover" = "image") {
|
conversationsTrash(mode: "image" | "model" | "cover" = "image") {
|
||||||
return request<Paginated<ImageConversation>>(`/api/ai/image-conversations/trash/?mode=${mode}&page_size=100`);
|
return request<Paginated<ImageConversationTrash>>(`/api/ai/image-conversations/trash/?mode=${mode}&page_size=100`);
|
||||||
},
|
},
|
||||||
createConversation(payload: { mode?: "image" | "model" | "cover"; title?: string; product?: string | null }) {
|
createConversation(payload: { mode?: "image" | "model" | "cover"; title?: string; product?: string | null }) {
|
||||||
return request<ImageConversation>("/api/ai/image-conversations/", { method: "POST", body: JSON.stringify(payload) });
|
return request<ImageConversation>("/api/ai/image-conversations/", { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
|||||||
@@ -738,6 +738,8 @@ export function ImageWorkbenchPage({
|
|||||||
// 重命名:正在改名的对话 id + 草稿
|
// 重命名:正在改名的对话 id + 草稿
|
||||||
const [renamingId, setRenamingId] = useState("");
|
const [renamingId, setRenamingId] = useState("");
|
||||||
const [renameDraft, setRenameDraft] = useState("");
|
const [renameDraft, setRenameDraft] = useState("");
|
||||||
|
// 整条对话删除独立确认,不与单图/批次删除状态耦合。
|
||||||
|
const [pendingDeleteConversation, setPendingDeleteConversation] = useState<ImageConversation | null>(null);
|
||||||
// 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。
|
// 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。
|
||||||
const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]);
|
const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]);
|
||||||
const refInputRef = useRef<HTMLInputElement | null>(null);
|
const refInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
@@ -1050,8 +1052,16 @@ export function ImageWorkbenchPage({
|
|||||||
try { await api.renameConversation(convId, title); } catch { void loadConversations(); }
|
try { await api.renameConversation(convId, title); } catch { void loadConversations(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除对话(软删):从列表移除;删的是当前对话则切到剩下第一条或清空
|
// 删除对话(软删):接口成功后再从列表移除;删的是当前对话则切到剩下第一条或清空。
|
||||||
async function handleDeleteConversation(convId: string) {
|
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);
|
const remaining = conversations.filter((c) => c.id !== convId);
|
||||||
setConversations(remaining);
|
setConversations(remaining);
|
||||||
if (convId === activeConvId) {
|
if (convId === activeConvId) {
|
||||||
@@ -1061,7 +1071,13 @@ export function ImageWorkbenchPage({
|
|||||||
if (nextActive) void loadConvBatches(nextActive);
|
if (nextActive) void loadConvBatches(nextActive);
|
||||||
else setBatches([]);
|
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 走商品空间布局)。
|
// 列表加载:image 模式才用对话(model/cover 走商品空间布局)。
|
||||||
@@ -1682,7 +1698,7 @@ export function ImageWorkbenchPage({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="删除对话"
|
title="删除对话"
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeleteConversation(conv.id); }}
|
onClick={(e) => { e.stopPropagation(); setPendingDeleteConversation(conv); }}
|
||||||
>
|
>
|
||||||
<Trash2 size={12} />
|
<Trash2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
@@ -1823,6 +1839,16 @@ export function ImageWorkbenchPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!pendingDeleteConversation}
|
||||||
|
title="删除当前对话"
|
||||||
|
subtitle="// DELETE"
|
||||||
|
icon={<Trash2 size={16} />}
|
||||||
|
detail="删除后可在垃圾桶恢复,是否需要删除?"
|
||||||
|
confirmText="删除"
|
||||||
|
onCancel={() => setPendingDeleteConversation(null)}
|
||||||
|
onConfirm={confirmDeleteConversation}
|
||||||
|
/>
|
||||||
{/* R108/R109:删除二次确认 —— 单张 / 整批共用,确认后软删进垃圾桶 */}
|
{/* R108/R109:删除二次确认 —— 单张 / 整批共用,确认后软删进垃圾桶 */}
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={!!confirmDel}
|
open={!!confirmDel}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { CSSProperties } from "react";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { ConfirmModal } from "../components/overlays";
|
import { ConfirmModal } from "../components/overlays";
|
||||||
import type { NavigateFn } from "./route-config";
|
import type { NavigateFn } from "./route-config";
|
||||||
import type { Asset, FreeVideoTask, ImageConversation, ImageExceptionBatchTrash, ModelEntity, Product, Project } from "../types";
|
import type { Asset, FreeVideoTask, ImageConversationTrash, ImageExceptionBatchTrash, ModelEntity, Product, Project } from "../types";
|
||||||
import "../trash-page.css";
|
import "../trash-page.css";
|
||||||
|
|
||||||
type TrashKind = "product" | "asset" | "model" | "image" | "imageExceptionBatch" | "freeVideo" | "project";
|
type TrashKind = "product" | "asset" | "model" | "image" | "imageExceptionBatch" | "freeVideo" | "project";
|
||||||
@@ -73,12 +73,13 @@ const rowsFromModels = (items: ModelEntity[]): TrashRow[] =>
|
|||||||
cover: m.portrait || m.triview || ""
|
cover: m.portrait || m.triview || ""
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const rowsFromConversations = (items: ImageConversation[]): TrashRow[] =>
|
const rowsFromConversations = (items: ImageConversationTrash[]): TrashRow[] =>
|
||||||
items.map((c) => ({
|
items.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
kind: "image",
|
kind: "image",
|
||||||
title: c.title || "未命名图片创作",
|
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<ImageExceptionBatchTrash["mode"], string> = {
|
const IMAGE_MODE_LABEL: Record<ImageExceptionBatchTrash["mode"], string> = {
|
||||||
|
|||||||
@@ -659,6 +659,11 @@ export type ImageConversation = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 垃圾桶专用:对话可含多个批次/资产,cover 只选一张用于列表展示。
|
||||||
|
export type ImageConversationTrash = ImageConversation & {
|
||||||
|
cover_preview_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
// 工作台生成记录(R100):模特上身图/平台套图页从后端持久任务拉取(与任务中心同源),
|
// 工作台生成记录(R100):模特上身图/平台套图页从后端持久任务拉取(与任务中心同源),
|
||||||
// 按 batch_id 归批还原批次流;assets 只含存活成图(软删的图已被后端过滤)。
|
// 按 batch_id 归批还原批次流;assets 只含存活成图(软删的图已被后端过滤)。
|
||||||
export type WorkbenchTask = {
|
export type WorkbenchTask = {
|
||||||
|
|||||||
Reference in New Issue
Block a user