ai-chatbot/components/agents/AgentGenerationWorkspaceDarkV2.tsx
Your Name f764767ba4
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: refresh agent workflow dark ui
2026-03-18 00:24:32 +08:00

734 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 { motion, AnimatePresence } from "framer-motion";
import {
ArrowUp,
Download,
ImagePlus,
Loader2,
Sparkles,
Upload,
X,
Wand2,
Settings2,
ArrowLeft,
LayoutGrid,
FolderOpen,
User,
Building2,
Menu
} 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,
AgentWorkflowSelectedBrandModel,
} from "@/types/agent";
// 导航标签类型
type NavTab = "cases" | "my-works" | "personal-assets" | "company-assets";
interface AgentGenerationWorkspaceDarkV2Props {
chatId: string;
projectId: string;
selectedAgentPresetId?: string | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
workflowContext?: AgentWorkflowComposerContext | null;
onBack?: () => void;
presetPanel?: React.ReactNode;
onClearPreset?: () => void;
}
interface PickedAttachment extends Attachment {}
function buildPreviewImageUrl(url: string, width: number) {
return buildImageProxyUrl(url, { width, quality: 86, format: "webp" });
}
export function AgentGenerationWorkspaceDarkV2({
chatId,
projectId,
selectedAgentPresetId,
selectedBrandModel = null,
workflowContext = null,
onBack,
presetPanel,
onClearPreset,
}: AgentGenerationWorkspaceDarkV2Props) {
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<Array<PickedAttachment | null>>([]);
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?: any;
}>;
defaultModelId?: string | null;
agentModel?: any;
}>("/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 (!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 brandModelImages = referenceImages.filter((image) => image.source === "brandModel");
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]);
nextSlots[2] = toAttachment(brandModelImages[0]);
setAttachments((current) => {
const result: Array<PickedAttachment | null> = Array.from(
{ length: agentUploadSlots.length },
(_, index) => current[index] || null
);
for (let index = 0; index < 3; index += 1) {
if (!result[index] && nextSlots[index]) {
result[index] = nextSlots[index] as PickedAttachment;
}
}
return result;
});
}, [agentUploadSlots.length, hasAgentUploadSlots, referenceImages]);
const generatedGroups = useMemo(() => {
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: textParts.join("\n\n").replace(/!\[[^\]]*\]\([^)]+\)/g, "").trim() };
})
.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]);
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;
return slots;
});
}, [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;
}
const uploadedAttachmentCount = attachments.filter(Boolean).length;
if (hasAgentUploadSlots && uploadedAttachmentCount < 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) => {
if (!attachment) return;
parts.push({ type: "file", mediaType: attachment.contentType || "image/jpeg", name: attachment.name || "参考图", url: attachment.url, assetId: attachment.assetId });
});
let textContent = input.trim();
if (!textContent && selectedAgentPreset?.type === "text" && workflowContext?.summaryText) {
textContent = `请基于当前工作流设定生成方案:${workflowContext.summaryText}`;
}
if (!textContent && selectedAgentPreset?.type === "text" && selectedAgentPreset) {
textContent = `请按「${selectedAgentPreset.name}」输出内容`;
}
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 navTabs = [
{ id: "cases" as NavTab, label: "案例广场", icon: LayoutGrid },
{ id: "my-works" as NavTab, label: "我的作品", icon: FolderOpen },
{ id: "personal-assets" as NavTab, label: "个人资产库", icon: User },
{ id: "company-assets" as NavTab, label: "公司资产库", icon: Building2 },
];
return (
<div className="min-h-screen bg-black">
{/* 顶部固定导航栏 - 与第一步保持一致 */}
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1800px] px-6">
<div className="flex h-16 items-center justify-between">
{/* Logo & Site Name & Back */}
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/5 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-purple-600">
<Wand2 className="h-4 w-4 text-white" />
</div>
<span className="text-base font-semibold text-white">AI创作平台</span>
</div>
</div>
{/* 导航标签 */}
<nav className="flex items-center gap-1 rounded-full bg-white/5 p-1">
{navTabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
className="flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium text-white/60 transition-all duration-300 hover:text-white hover:bg-white/5"
>
<Icon className="h-4 w-4" />
{tab.label}
</button>
);
})}
</nav>
{/* 右侧占位 */}
<div className="w-20" />
</div>
</div>
</header>
{/* Main Content */}
<main className="pt-16">
<div className="mx-auto max-w-[1800px] px-6 py-6">
{/* 步骤指示器 */}
<motion.div
className="mb-6 flex items-center justify-center gap-2 text-sm"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
>
<span className="text-white/40">第一步:风格选择</span>
<span className="text-white/20">→</span>
<span className="text-emerald-400 font-medium">第二步:智能体生图</span>
</motion.div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[360px_1fr]">
{/* Left Panel - Controls */}
<div className="space-y-4">
{/* 嵌入的智能体选择面板 */}
{!selectedAgentPreset && presetPanel && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl border border-white/5 bg-white/[0.02] p-4 max-h-[400px] overflow-y-auto"
>
<h3 className="mb-3 text-sm font-semibold text-white">选择智能体</h3>
{presetPanel}
</motion.div>
)}
{/* 已选智能体信息 */}
{selectedAgentPreset && (
<motion.div
className="rounded-xl border border-white/10 bg-white/[0.03] p-4"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
>
<div className="mb-2 text-xs text-white/40">当前智能体</div>
<div className="flex items-center gap-3">
{selectedAgentPreset.avatarUrl ? (
<img
src={selectedAgentPreset.avatarUrl}
alt={selectedAgentPreset.name}
className="h-10 w-10 rounded-lg object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-violet-500/20">
<Wand2 className="h-5 w-5 text-violet-400" />
</div>
)}
<div>
<div className="font-medium text-white">{selectedAgentPreset.name}</div>
<div className="text-xs text-white/40">{selectedAgentPreset.type === "image" ? "生图" : "视频"}</div>
</div>
</div>
{presetPanel && onClearPreset && (
<button
onClick={onClearPreset}
className="mt-3 text-xs text-violet-400 hover:text-violet-300"
>
更换智能体 →
</button>
)}
</motion.div>
)}
{/* Image Slots */}
<motion.section
className="rounded-xl border border-white/5 bg-white/[0.02] p-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<ImagePlus className="h-4 w-4 text-violet-400" />
图片槽位
</h3>
<div className="grid grid-cols-3 gap-2">
{agentUploadSlots.map((slot, index) => (
<motion.button
key={slot.label}
type="button"
onClick={() => fileInputRefs.current[index]?.click()}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className={cn(
"group relative aspect-square overflow-hidden rounded-lg transition-all",
attachments[index]
? "ring-2 ring-violet-500/30"
: "border-2 border-dashed border-white/10 hover:border-violet-500/30"
)}
>
<input
ref={(el) => (fileInputRefs.current[index] = el)}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleSlotFileChange(index, e)}
/>
{attachments[index] ? (
<>
<img
src={buildPreviewImageUrl(attachments[index]!.url, 256)}
alt={attachments[index]!.name || slot.label}
className="h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1 text-[10px] text-white">
{slot.label}
</div>
<button
type="button"
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
updateAttachmentAtSlot(index, null);
}}
>
<X className="h-3 w-3" />
</button>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 text-white/30">
{slotUploading === index ? (
<Loader2 className="h-5 w-5 animate-spin text-violet-500" />
) : (
<>
<Upload className="h-5 w-5" />
<span className="text-[10px]">{slot.label}</span>
</>
)}
</div>
)}
</motion.button>
))}
</div>
</motion.section>
{/* Prompt Input */}
<motion.section
className="rounded-xl border border-white/5 bg-white/[0.02] p-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<Sparkles className="h-4 w-4 text-violet-400" />
补充要求
</h3>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="描述你想要的风格、氛围、构图..."
className="min-h-[100px] resize-none rounded-lg border-white/5 bg-black text-sm text-white placeholder:text-white/20 focus:border-violet-500/30"
/>
</motion.section>
{/* Settings */}
<motion.section
className="rounded-xl border border-white/5 bg-white/[0.02] p-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<Settings2 className="h-4 w-4 text-violet-400" />
生成参数
</h3>
<div className="space-y-3">
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger className="h-10 rounded-lg border-white/5 bg-black text-white">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent className="border-white/10 bg-zinc-900">
{selectableModels.map((model) => (
<SelectItem key={model.id} value={model.id} className="text-white">
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<ImageParamsBar
modelName={currentModel?.displayName}
initialParams={imageParams}
onParamsChange={setImageParams}
inline
providerType={currentModel?.providerType}
imageGenConfig={currentModel?.imageGenConfig || undefined}
/>
<Button
onClick={handleSend}
disabled={isGenerating || !selectedAgentPresetId}
className={cn(
"h-11 w-full gap-2 rounded-lg",
"bg-gradient-to-r from-violet-600 to-purple-600",
"text-white font-medium",
"hover:from-violet-500 hover:to-purple-500",
"disabled:opacity-30"
)}
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
生成中...
</>
) : (
<>
<ArrowUp className="h-4 w-4" />
开始生成
</>
)}
</Button>
</div>
</motion.section>
</div>
{/* Right Panel - Results */}
<div className="flex flex-col">
<motion.div
className="relative flex-1 min-h-[500px] rounded-xl border border-white/5 bg-white/[0.02] overflow-hidden"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
>
<AnimatePresence mode="wait">
{isGenerating ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full items-center justify-center"
>
<ImageGenerationLoading
imageCount={imageParams.imageCount || 1}
maxImages={6}
providerType={currentModel?.providerType}
modelName={currentModel?.displayName}
phase="processing"
/>
</motion.div>
) : displayedResultUrl ? (
<motion.div
key="result"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="relative flex h-full items-center justify-center p-6"
>
<img
src={buildPreviewImageUrl(displayedResultUrl, 1400)}
alt="生成结果"
className="max-h-full max-w-full cursor-zoom-in rounded-lg object-contain"
onClick={() => setFullscreenPreview(displayedResultUrl)}
/>
<button
onClick={() => setFullscreenPreview(displayedResultUrl)}
className="absolute bottom-6 right-6 flex h-10 items-center gap-2 rounded-full bg-white/10 px-4 text-sm text-white backdrop-blur-md transition-colors hover:bg-white/20"
>
<Download className="h-4 w-4" />
下载
</button>
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col items-center justify-center text-center"
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
<Wand2 className="h-8 w-8 text-white/20" />
</div>
<p className="text-base font-medium text-white/60">
{selectedAgentPresetId ? "准备开始生成" : "请选择生图模式"}
</p>
<p className="mt-2 text-sm text-white/30">
{selectedAgentPresetId
? "配置图片槽位后点击开始生成"
: "从左侧选择或创建一个智能体"}
</p>
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Thumbnails */}
{allGeneratedImages.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 flex gap-2 overflow-x-auto pb-2"
>
{allGeneratedImages.map((url) => (
<button
key={url}
onClick={() => setSelectedResultUrl(url)}
className={cn(
"relative flex-shrink-0 overflow-hidden rounded-lg transition-all",
selectedResultUrl === url
? "ring-2 ring-violet-500"
: "ring-1 ring-white/10 hover:ring-white/30"
)}
>
<img
src={buildPreviewImageUrl(url, 200)}
alt="缩略图"
className="h-16 w-16 object-cover"
/>
</button>
))}
</motion.div>
)}
</div>
</div>
</div>
</main>
{/* Fullscreen Preview */}
<AnimatePresence>
{fullscreenPreview && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/95 backdrop-blur-sm"
onClick={() => setFullscreenPreview(null)}
>
<img
src={fullscreenPreview}
alt="预览"
className="max-h-[95vh] max-w-[95vw] object-contain"
onClick={(e) => e.stopPropagation()}
/>
<button
onClick={() => setFullscreenPreview(null)}
className="absolute right-6 top-6 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-md transition-colors hover:bg-white/20"
>
<X className="h-6 w-6" />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}