feat: replace agent chat panel with generation workspace
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-17 21:48:05 +08:00
parent 3443dc01d3
commit 50a2d84d2c
2 changed files with 804 additions and 4 deletions

View File

@ -62,6 +62,7 @@ import {
AgentWorkflowPanel,
type AgentWorkflowInputs,
} from "@/components/agents/AgentWorkflowPanel";
import { AgentGenerationWorkspace } from "@/components/agents/AgentGenerationWorkspace";
import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel";
import { AgentPresetPanel } from "@/components/agents/AgentPresetPanel";
import { CanvasWorkspace } from "@/components/canvas";
@ -1040,13 +1041,11 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-tl-[24px] border-t border-l bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] dark:border-white/10 dark:bg-zinc-950">
<CanvasChatPanel
<AgentGenerationWorkspace
chatId={`canvas-${id}`}
onClose={undefined}
projectId={id}
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
chatScope="agent"
workflowContext={workflowContext}
presentation="workspace"
/>
</div>
</div>

View File

@ -0,0 +1,801 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import {
ArrowUp,
Download,
ImagePlus,
Loader2,
Sparkles,
Upload,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { ImageGenerationLoading } from "@/components/image-generation-loading";
import { useCanvasStore } from "@/store/canvas";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { extractImageUrlsFromText } from "@/lib/image-url";
import type { Attachment, ChatMessage } from "@/lib/types";
import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils";
import type { AgentPreset, AgentWorkflowComposerContext } from "@/types/agent";
interface AgentGenerationWorkspaceProps {
chatId: string;
projectId: string;
selectedAgentPresetId?: string | null;
workflowContext?: AgentWorkflowComposerContext | null;
}
interface PickedAttachment extends Attachment {}
interface GeneratedImageGroup {
messageId: string;
imageUrls: string[];
text: string;
}
function buildPreviewImageUrl(url: string, width: number) {
return buildImageProxyUrl(url, {
width,
quality: 86,
format: "webp",
});
}
function buildDownloadFilename(url: string): string {
const fallback = `canvas-image-${Date.now()}.png`;
const raw = String(url || "").trim();
if (!raw) return fallback;
try {
const pathname = new URL(raw).pathname || "";
const candidate = pathname.split("/").pop() || "";
return candidate || fallback;
} catch {
const candidate = raw.split("/").pop()?.split("?")[0] || "";
return candidate || fallback;
}
}
function getImageDimensions(url: string): Promise<{
width: number;
height: number;
originalWidth: number;
originalHeight: number;
}> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const maxSize = 500;
const originalWidth = img.naturalWidth;
const originalHeight = img.naturalHeight;
let width = originalWidth;
let height = originalHeight;
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const minSize = 150;
if (width < minSize && height < minSize) {
const scale = minSize / Math.min(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
resolve({ width, height, originalWidth, originalHeight });
};
img.onerror = () => {
resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 });
};
img.src = url;
});
}
function normalizeAssistantText(value: string) {
return String(value || "")
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
.replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "")
.trim();
}
export function AgentGenerationWorkspace({
chatId,
projectId,
selectedAgentPresetId,
workflowContext = null,
}: AgentGenerationWorkspaceProps) {
const rawChatId = useMemo(
() => (chatId.startsWith("canvas-") ? chatId.replace("canvas-", "") : chatId),
[chatId]
);
const addNode = useCanvasStore((state) => state.addNode);
const nodes = useCanvasStore((state) => state.nodes);
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const hiddenPromptRef = useRef<string | null>(null);
const generatedImageKeysRef = useRef<Set<string>>(new Set());
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<PickedAttachment[]>([]);
const [slotUploading, setSlotUploading] = useState<number | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string>("auto");
const [selectedResultUrl, setSelectedResultUrl] = useState<string | null>(null);
const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null);
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
});
const { data: historyData } = useSWR<{ messages: ChatMessage[] }>(
rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
fetcher
);
const { data: modelsData } = useSWR<{
models: Array<{
id: string;
displayName: string;
providerType: string;
supportsImageGen?: boolean;
isMultimodal?: boolean;
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
} | null;
}>;
defaultModelId?: string | null;
agentModel?: {
id: string;
displayName: string;
providerType: string;
isMultimodal?: boolean;
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
} | null;
} | null;
}>("/api/models", fetcher);
const { data: agentPresetResponse } = useSWR<{ success?: boolean; data?: AgentPreset }>(
selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : null,
fetcher
);
const selectedAgentPreset = useMemo(
() => (agentPresetResponse?.success && agentPresetResponse.data ? agentPresetResponse.data : null),
[agentPresetResponse]
);
const historyMessages = useMemo(() => historyData?.messages || [], [historyData?.messages]);
const referenceImages = workflowContext?.referenceImages || [];
const hasAgentUploadSlots = Boolean(selectedAgentPreset && selectedAgentPreset.type !== "text");
const agentUploadSlots = useMemo(
() =>
hasAgentUploadSlots
? [
{ slotIndex: 0, label: "产品图1" },
{ slotIndex: 1, label: "产品图2" },
{ slotIndex: 2, label: "模特图" },
]
: [],
[hasAgentUploadSlots]
);
const requiredUploadCount = hasAgentUploadSlots ? 3 : 0;
const selectableModels = useMemo(() => {
const imageModels = (modelsData?.models || []).filter(
(model) => model.supportsImageGen || model.isMultimodal
);
const autoOption = {
id: "auto",
displayName: "自动",
providerType: "auto",
imageGenConfig: modelsData?.agentModel?.imageGenConfig || undefined,
};
return [autoOption, ...imageModels];
}, [modelsData?.agentModel?.imageGenConfig, modelsData?.models]);
const currentModel = useMemo(
() => selectableModels.find((model) => model.id === selectedModelId) || selectableModels[0],
[selectableModels, selectedModelId]
);
const { messages, setMessages, sendMessage, status } = useChat<ChatMessage>({
id: chatId,
messages: historyMessages,
experimental_throttle: 100,
generateId: generateUUID,
transport: new DefaultChatTransport({
api: "/api/chat",
fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) {
return {
body: {
id: rawChatId,
message: request.messages.at(-1),
hiddenPrompt: hiddenPromptRef.current || undefined,
agentPresetId: selectedAgentPresetId || undefined,
selectedChatModel: selectedModelId,
selectedVisibilityType: "private",
imageParams,
},
};
},
}),
});
useEffect(() => {
if (!messages.length && historyMessages.length) {
setMessages(historyMessages);
}
}, [historyMessages, messages.length, setMessages]);
useEffect(() => {
if (selectedAgentPreset?.defaultModelId) {
setSelectedModelId(selectedAgentPreset.defaultModelId);
return;
}
if (modelsData?.defaultModelId) {
setSelectedModelId(modelsData.defaultModelId);
}
}, [modelsData?.defaultModelId, selectedAgentPreset?.defaultModelId]);
useEffect(() => {
if (!hasAgentUploadSlots) {
setAttachments([]);
return;
}
const nextSlots: Array<PickedAttachment | null> = Array.from(
{ length: agentUploadSlots.length },
() => null
);
const primaryImages = referenceImages.filter((image) => image.source === "primary");
const secondaryImages = referenceImages.filter((image) => image.source === "secondary");
const toAttachment = (image: (typeof referenceImages)[number] | undefined) =>
image
? {
url: image.url,
name: image.name || image.label,
contentType: image.mimeType || "image/png",
assetId: image.assetId || undefined,
}
: null;
nextSlots[0] = toAttachment(primaryImages[0] || secondaryImages[0] || referenceImages[0]);
nextSlots[1] = toAttachment(secondaryImages[0] || primaryImages[1] || referenceImages[1]);
setAttachments((current) => {
const result: Array<PickedAttachment | null> = Array.from(
{ length: agentUploadSlots.length },
(_, index) => current[index] || null
);
for (let index = 0; index < 2; index += 1) {
if (!result[index] && nextSlots[index]) {
result[index] = nextSlots[index] as PickedAttachment;
}
}
return result.filter((item): item is PickedAttachment => Boolean(item));
});
}, [agentUploadSlots.length, hasAgentUploadSlots, referenceImages]);
const generatedGroups = useMemo<GeneratedImageGroup[]>(() => {
return messages
.filter((message) => message.role === "assistant")
.map((message) => {
const textParts = (message.parts || [])
.filter((part: any) => part.type === "text" && part.text)
.map((part: any) => String(part.text));
const imageUrls = Array.from(
new Set(textParts.flatMap((text) => extractImageUrlsFromText(text)))
);
return {
messageId: message.id,
imageUrls,
text: normalizeAssistantText(textParts.join("\n\n")),
};
})
.filter((group) => group.imageUrls.length > 0);
}, [messages]);
const allGeneratedImages = useMemo(
() => Array.from(new Set(generatedGroups.flatMap((group) => group.imageUrls))),
[generatedGroups]
);
const latestGeneratedGroup = generatedGroups[generatedGroups.length - 1] || null;
const displayedResultUrl = selectedResultUrl || latestGeneratedGroup?.imageUrls[0] || null;
const isGenerating = status === "streaming" || status === "submitted";
useEffect(() => {
if (!selectedResultUrl && latestGeneratedGroup?.imageUrls[0]) {
setSelectedResultUrl(latestGeneratedGroup.imageUrls[0]);
}
}, [latestGeneratedGroup?.imageUrls, selectedResultUrl]);
useEffect(() => {
const latestUrls = latestGeneratedGroup?.imageUrls || [];
if (latestUrls.length === 0) return;
latestUrls.forEach(async (url, index) => {
if (!url || generatedImageKeysRef.current.has(url)) return;
generatedImageKeysRef.current.add(url);
try {
const dimensions = await getImageDimensions(url);
const nodeIndex = nodes.length + index;
const spacing = 20;
const x = 120 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 120 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
addNode({
type: "image",
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: url,
status: "completed",
title: "生成结果",
prompt: latestGeneratedGroup.text || "生成结果",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
} catch (error) {
console.warn("[AgentGenerationWorkspace] Failed to add generated image to canvas:", error);
}
});
}, [addNode, latestGeneratedGroup, nodes.length]);
const updateAttachmentAtSlot = useCallback((slotIndex: number, attachment: PickedAttachment | null) => {
const slotCount = Math.max(agentUploadSlots.length, 1);
setAttachments((current) => {
const slots: Array<PickedAttachment | null> = Array.from(
{ length: slotCount },
(_, index) => current[index] ?? null
);
slots[slotIndex] = attachment;
while (slots.length > 0 && !slots[slots.length - 1]) {
slots.pop();
}
return slots.filter((item): item is PickedAttachment => Boolean(item));
});
}, [agentUploadSlots.length]);
const handleSlotFileChange = useCallback(
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
event.currentTarget.value = "";
return;
}
setSlotUploading(slotIndex);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const data = await response.json();
if (!response.ok || !data?.url) {
throw new Error(data?.error || "上传失败");
}
updateAttachmentAtSlot(slotIndex, {
url: data.url,
name: file.name,
contentType: file.type || "image/jpeg",
assetId: typeof data.assetId === "string" ? data.assetId : undefined,
});
toast.success(`${agentUploadSlots[slotIndex]?.label || `槽位${slotIndex + 1}`}上传成功`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setSlotUploading(null);
event.currentTarget.value = "";
}
},
[agentUploadSlots, updateAttachmentAtSlot]
);
const handleSend = useCallback(() => {
if (!selectedAgentPresetId) {
toast.error("请先选择生图模式");
return;
}
if (hasAgentUploadSlots && attachments.length < requiredUploadCount) {
toast.error(`当前模式需要上传 ${requiredUploadCount} 张图片`);
return;
}
const parts: Array<
| { type: "text"; text: string }
| {
type: "file";
mediaType: string;
name: string;
url: string;
assetId?: string;
}
> = [];
attachments.forEach((attachment) => {
parts.push({
type: "file",
mediaType: attachment.contentType || "image/jpeg",
name: attachment.name || "参考图",
url: attachment.url,
assetId: attachment.assetId,
});
});
let textContent = input.trim();
if (!textContent && workflowContext?.summaryText) {
textContent = `请基于当前工作流设定生成方案:${workflowContext.summaryText}`;
}
if (!textContent && selectedAgentPreset) {
textContent =
selectedAgentPreset.type === "text"
? `请按「${selectedAgentPreset.name}」输出内容`
: `请按「${selectedAgentPreset.name}」模式处理产品图1、产品图2和模特图。`;
}
if (textContent) {
parts.push({ type: "text", text: textContent });
}
if (parts.length === 0) {
toast.error("请至少补充要求或上传图片");
return;
}
hiddenPromptRef.current = workflowContext?.promptText
? `【工作流设定】\n${workflowContext.promptText}\n\n请严格遵循以上工作流设定输出内容,并保持整体结果一致。`
: null;
sendMessage({
role: "user",
parts,
});
setInput("");
}, [
attachments,
hasAgentUploadSlots,
input,
requiredUploadCount,
selectedAgentPreset,
selectedAgentPresetId,
sendMessage,
workflowContext?.promptText,
workflowContext?.summaryText,
]);
const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
const raw = String(imageUrl || "").trim();
if (!raw) {
toast.error("图片地址无效");
return;
}
const filename = buildDownloadFilename(raw);
try {
const response = await fetch(raw);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
}
}, []);
return (
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-zinc-950">
<div className="border-b px-5 py-4">
<div className="text-base font-semibold">生图工作台</div>
<div className="mt-1 text-sm text-muted-foreground">
这里是独立的生图工作台,不再使用聊天对话框。请先补齐图片槽位,再补充要求并生成。
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-5">
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-4">
<div className="rounded-3xl border bg-muted/10 p-4">
<div className="text-sm font-medium">图片槽位</div>
<div className="mt-1 text-xs text-muted-foreground">
主 SKU / 搭配 SKU 选中的图片会优先进入前两个槽位,模特图可手动上传。
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{agentUploadSlots.map((slot, index) => {
const attachment = attachments[index];
const isUploading = slotUploading === index;
return (
<button
key={`${slot.label}-${index}`}
type="button"
onClick={() => fileInputRefs.current[index]?.click()}
className={cn(
"group relative h-28 overflow-hidden rounded-2xl border bg-background text-left transition-colors",
attachment
? "border-border hover:border-primary/60"
: "border-dashed border-border hover:border-primary/60"
)}
>
<input
ref={(element) => {
fileInputRefs.current[index] = element;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void handleSlotFileChange(index, event);
}}
/>
{attachment ? (
<>
<img
src={buildPreviewImageUrl(attachment.url, 256)}
alt={attachment.name || slot.label}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1 text-[11px] text-white">
{slot.label}
</div>
<button
type="button"
className="absolute right-2 top-2 rounded-full bg-black/70 px-1.5 py-0.5 text-[11px] text-white"
onClick={(event) => {
event.stopPropagation();
updateAttachmentAtSlot(index, null);
}}
>
x
</button>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
{isUploading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Upload className="h-5 w-5" />
)}
<div className="text-xs">{slot.label}</div>
</div>
)}
</button>
);
})}
</div>
</div>
<div className="rounded-3xl border bg-muted/10 p-4">
<div className="text-sm font-medium">补充要求</div>
<div className="mt-1 text-xs text-muted-foreground">
输入本次生成要求,例如风格、镜头、氛围、颜色和生成张数。
</div>
<Textarea
value={input}
onChange={(event) => setInput(event.target.value)}
className="mt-4 min-h-[140px] resize-none bg-background"
placeholder="例如:高级家居电商质感,暖灰色空间,近景主图,生成 3 张。"
/>
<div className="mt-4 flex items-center gap-3">
<div className="min-w-0 flex-1">
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger className="w-full bg-background">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{selectableModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
size="lg"
className="rounded-full px-6"
onClick={handleSend}
disabled={isGenerating || !selectedAgentPresetId}
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span className="ml-2">生成中</span>
</>
) : (
<>
<ArrowUp className="h-4 w-4" />
<span className="ml-2">开始生成</span>
</>
)}
</Button>
</div>
<div className="mt-4">
<ImageParamsBar
modelName={currentModel?.displayName}
initialParams={imageParams}
onParamsChange={setImageParams}
inline
providerType={currentModel?.providerType}
imageGenConfig={currentModel?.imageGenConfig || undefined}
hideAdvancedOptions={false}
/>
</div>
</div>
</div>
<div className="flex min-h-[640px] flex-col rounded-[28px] border bg-[#f7f8fb] p-4 dark:bg-zinc-900/50">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">生成结果</div>
<div className="mt-1 text-xs text-muted-foreground">
这里仅展示生图结果,不再显示聊天欢迎语、快捷功能或对话气泡。
</div>
</div>
{displayedResultUrl ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void handleDownloadOriginalImage(displayedResultUrl)}
>
<Download className="mr-2 h-4 w-4" />
下载原图
</Button>
) : null}
</div>
<div className="relative flex-1 overflow-hidden rounded-[24px] border bg-white dark:bg-zinc-950">
{isGenerating ? (
<div className="flex h-full items-center justify-center">
<ImageGenerationLoading
imageCount={imageParams.imageCount || 1}
maxImages={6}
providerType={currentModel?.providerType}
modelName={currentModel?.displayName}
phase="processing"
/>
</div>
) : displayedResultUrl ? (
<div className="flex h-full items-center justify-center p-4">
<img
src={buildPreviewImageUrl(displayedResultUrl, 1400)}
alt="生成结果"
className="max-h-full max-w-full cursor-zoom-in rounded-2xl object-contain"
loading="lazy"
decoding="async"
onClick={() => setFullscreenPreview(displayedResultUrl)}
/>
</div>
) : (
<div className="flex h-full items-center justify-center p-8">
<div className="w-full max-w-xl text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-3xl bg-[#eef2ff] text-[#4f46e5]">
<Sparkles className="h-7 w-7" />
</div>
<div className="text-2xl font-semibold">
{selectedAgentPresetId ? "准备开始生成" : "请先在左侧选择生图模式"}
</div>
<div className="mt-3 text-sm leading-6 text-muted-foreground">
{selectedAgentPresetId
? "补齐图片槽位后点击开始生成,结果会显示在这里。"
: "选择生图模式后,再从左侧 SKU / SPU 获取图片并开始生成。"}
</div>
</div>
</div>
)}
</div>
<div className="mt-4 rounded-[24px] border bg-white p-3 dark:bg-zinc-950">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-medium">结果预览</div>
<div className="text-xs text-muted-foreground">
共 {allGeneratedImages.length} 张
</div>
</div>
{allGeneratedImages.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-1">
{allGeneratedImages.map((url) => (
<button
key={url}
type="button"
onClick={() => setSelectedResultUrl(url)}
className={cn(
"overflow-hidden rounded-2xl border transition-all",
selectedResultUrl === url || (!selectedResultUrl && displayedResultUrl === url)
? "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
: "border-border hover:border-primary/50"
)}
>
<img
src={buildPreviewImageUrl(url, 220)}
alt="结果缩略图"
className="h-24 w-24 object-cover"
loading="lazy"
decoding="async"
/>
</button>
))}
</div>
) : (
<div className="rounded-2xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
生成完成后,结果缩略图会固定展示在这里。
</div>
)}
</div>
</div>
</div>
</div>
{fullscreenPreview ? (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/90"
onClick={() => setFullscreenPreview(null)}
>
<img
src={fullscreenPreview}
alt="预览大图"
className="max-h-[95vh] max-w-[95vw] object-contain"
onClick={(event) => event.stopPropagation()}
/>
</div>
) : null}
</div>
);
}