"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, brandName, }: { open: boolean; onOpenChange: (open: boolean) => void; sourceImageUrl: string | null; brandName?: string | null; }) { const normalizedBrandName = String(brandName || "").trim(); const { data, isLoading } = useSWR( open && normalizedBrandName ? `/api/psd-templates?brand=${encodeURIComponent(normalizedBrandName)}` : 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] ); const editableImageLayers = useMemo( () => (selectedTemplate?.layers || []).filter((layer) => isAgentPsdImageLayerSlot(layer) ), [selectedTemplate] ); const editableTextLayers = useMemo( () => (selectedTemplate?.layers || []).filter((layer) => isAgentPsdTextLayerSlot(layer) ), [selectedTemplate] ); const fixedLayerCount = useMemo(() => { if (!selectedTemplate) return 0; return Math.max(0, selectedTemplate.layerCount - selectedTemplate.layers.length); }, [selectedTemplate]); 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 模板 这里是直接把当前图片和输入文字替换进 PSD 模板,不会再次调用 AI 生图模型。
{selectedTemplate ? ( <>
{selectedTemplate.name}
{selectedTemplate.brandName} {selectedTemplate.width} x {selectedTemplate.height} {editableImageLayers.length} 个可替换图片 {editableTextLayers.length} 个可编辑文字 {fixedLayerCount > 0 ? ( {fixedLayerCount} 个固定图层 ) : null}
{selectedTemplate.description ? (
{selectedTemplate.description}
) : null}
用户侧这里只会显示后台已经开放的槽位: 图片图层显示为“可替换图片”,文字图层显示为“可编辑文字”,其余固定图层不会出现在这里。
) : (
{isLoading ? "正在加载可用模板..." : normalizedBrandName ? `当前品牌“${normalizedBrandName}”下还没有可用 PSD 模板,请先去后台配置。` : "当前还没有启用的 PSD 模板,请先去后台配置。"}
)}
槽位内容
{selectedTemplate ? ( <> {editableImageLayers.length} 个可替换图片 {editableTextLayers.length} 个可编辑文字 ) : null}
图片槽位直接替换图片,文字槽位直接修改文案,不会再次调用 AI 生图。
{selectedTemplate ? (
可替换图片
{editableImageLayers.length}
{editableImageLayers.length > 0 ? ( editableImageLayers.map((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}
); }) ) : (
这个模板当前没有开放可替换图片槽位。
)}
可编辑文字
{editableTextLayers.length}
{editableTextLayers.length > 0 ? ( editableTextLayers.map((layer) => { 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}