"use client"; import { useEffect, useMemo, useState, type ChangeEvent } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { Download, Image as ImageIcon, Loader2, Sparkles, Type, Upload, } from "lucide-react"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { isAgentPsdImageLayerSlot, isAgentPsdTextLayerSlot, type AgentPsdTemplate, type AgentPsdTemplateListResponse, type AgentPsdTemplateRenderResponse, } from "@/lib/agents/psd-template-config"; const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json()); function createInitialState(template: AgentPsdTemplate | null, sourceImageUrl: string) { const imageAssignments: Record = {}; const textAssignments: Record = {}; if (!template) { return { imageAssignments, textAssignments }; } for (const layer of template.layers) { if (isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource && sourceImageUrl) { imageAssignments[layer.id] = sourceImageUrl; } if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) { textAssignments[layer.id] = layer.defaultText; } } return { imageAssignments, textAssignments }; } export function AgentPsdTemplateEditorDialog({ open, onOpenChange, sourceImageUrl, }: { open: boolean; onOpenChange: (open: boolean) => void; sourceImageUrl: string | null; }) { const { data, isLoading } = useSWR( open ? "/api/psd-templates" : null, fetcher ); const templates = useMemo(() => (data?.success ? data.templates : []), [data]); const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [imageAssignments, setImageAssignments] = useState>({}); const [textAssignments, setTextAssignments] = useState>({}); const [uploadingSlotId, setUploadingSlotId] = useState(null); const [isRendering, setIsRendering] = useState(false); const [renderedResultUrl, setRenderedResultUrl] = useState(""); const selectedTemplate = useMemo( () => templates.find((template) => template.id === selectedTemplateId) || null, [selectedTemplateId, templates] ); useEffect(() => { if (!open) return; if (templates.length > 0 && !templates.some((template) => template.id === selectedTemplateId)) { setSelectedTemplateId(templates[0].id); } }, [open, selectedTemplateId, templates]); useEffect(() => { if (!open) return; setRenderedResultUrl(""); const initialState = createInitialState(selectedTemplate, sourceImageUrl || ""); setImageAssignments(initialState.imageAssignments); setTextAssignments(initialState.textAssignments); }, [open, selectedTemplate, sourceImageUrl]); async function handleSlotUpload(slotId: string, event: ChangeEvent) { const file = event.target.files?.[0]; if (!file) return; try { setUploadingSlotId(slotId); const formData = new FormData(); formData.set("file", file); const response = await fetchUploadWithRetry(formData); const payload = await response.json(); if (!response.ok || !payload.url) { throw new Error(payload.error || "图片上传失败"); } setImageAssignments((current) => ({ ...current, [slotId]: payload.url, })); toast.success("素材已上传"); } catch (error) { toast.error(error instanceof Error ? error.message : "素材上传失败"); } finally { setUploadingSlotId(null); event.target.value = ""; } } async function handleRender() { if (!selectedTemplate) { toast.error("请先选择 PSD 模板"); return; } try { setIsRendering(true); const response = await fetch("/api/psd-templates/render", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ templateId: selectedTemplate.id, sourceImageUrl: sourceImageUrl || "", imageAssignments, textAssignments, }), }); const payload = (await response.json()) as AgentPsdTemplateRenderResponse; if (!response.ok || !payload.success || !payload.result?.url) { throw new Error(payload.error || "PSD 模板渲染失败"); } setRenderedResultUrl(payload.result.url); toast.success("套版结果已生成"); } catch (error) { toast.error(error instanceof Error ? error.message : "PSD 模板渲染失败"); } finally { setIsRendering(false); } } function handleDownload() { if (!renderedResultUrl) return; window.open(renderedResultUrl, "_blank", "noopener,noreferrer"); } return ( 套用 PSD 模板 当前结果图会自动填入默认图片槽位,文字槽位可直接输入,其他图片槽位可继续上传素材替换。
{selectedTemplate ? ( <>
{selectedTemplate.name}
{selectedTemplate.width} x {selectedTemplate.height} { selectedTemplate.layers.filter((layer) => layer.slotType === "image").length }{" "} 个图片槽位 { selectedTemplate.layers.filter((layer) => layer.slotType === "text").length }{" "} 个文字槽位
{selectedTemplate.description ? (
{selectedTemplate.description}
) : null} ) : (
{isLoading ? "正在加载可用模板..." : "当前还没有启用的 PSD 模板,请先去后台配置。"}
)}
槽位内容
图片槽位支持上传和套用当前结果图,文字槽位直接输入即可。
{selectedTemplate?.layers.length ? ( selectedTemplate.layers.map((layer) => { if (isAgentPsdImageLayerSlot(layer)) { const assignedUrl = imageAssignments[layer.id] || ""; const slotUploadInputId = `psd-slot-upload-${layer.id}`; return (
{layer.layerName}
图片槽位 {layer.isRequired ? 必传 : null} {layer.isDefaultResultSource ? ( 默认结果图 ) : null} {layer.width} x {layer.height}
{layer.layerPath}
{assignedUrl ? (
{layer.layerName}
) : (
暂未分配素材
)}
{sourceImageUrl ? ( ) : null} {layer.allowUserUploadOverride ? ( <> handleSlotUpload(layer.id, event)} /> ) : null} {assignedUrl ? ( ) : null}
); } const textValue = textAssignments[layer.id] || ""; return (
{layer.layerName}
文字槽位 {layer.isRequired ? 必填 : null} {layer.width} x {layer.height} {layer.fontLabel || layer.fontFamily ? ( {layer.fontLabel || layer.fontFamily} ) : null}
{layer.layerPath}