"use client"; import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { Download, Image as ImageIcon, Loader2, MousePointerClick, 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 AgentPsdTemplateImageLayerSlot, type AgentPsdTemplateLayerSlot, type AgentPsdTemplateListResponse, type AgentPsdTemplateRenderResponse, type AgentPsdTemplateTextLayerSlot, } 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 }; } function getLayerLabel(layer: AgentPsdTemplateLayerSlot) { return isAgentPsdImageLayerSlot(layer) ? "可替换图片" : "可编辑文字"; } function getLayerOutlineColor(layer: AgentPsdTemplateLayerSlot) { return isAgentPsdImageLayerSlot(layer) ? "rgba(249,115,22,0.95)" : "rgba(56,189,248,0.95)"; } function buildPreviewFontFaces(template: AgentPsdTemplate | null) { if (!template) return ""; const rules = new Map(); for (const layer of template.layers) { if (!isAgentPsdTextLayerSlot(layer) || !layer.fontFamily || !layer.fontFileUrl) { continue; } if (rules.has(layer.fontFamily)) { continue; } rules.set( layer.fontFamily, `@font-face { font-family: "${layer.fontFamily}"; src: url("${layer.fontFileUrl}") format("${layer.fontContentType?.includes("woff2") ? "woff2" : layer.fontContentType?.includes("woff") ? "woff" : layer.fontContentType?.includes("otf") ? "opentype" : "truetype"}"); font-display: swap; }` ); } return Array.from(rules.values()).join("\n"); } function getPreferredSelectedLayerId(template: AgentPsdTemplate | null) { if (!template || template.layers.length === 0) { return ""; } const preferredImage = template.layers.find( (layer) => isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource ); if (preferredImage) { return preferredImage.id; } return template.layers[0]?.id || ""; } 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 [selectedLayerId, setSelectedLayerId] = useState(""); const [imageAssignments, setImageAssignments] = useState>({}); const [textAssignments, setTextAssignments] = useState>({}); const [uploadingSlotId, setUploadingSlotId] = useState(null); const [isRendering, setIsRendering] = useState(false); const [renderedResultUrl, setRenderedResultUrl] = useState(""); const [stageScale, setStageScale] = useState(1); const previewViewportRef = useRef(null); const uploadInputRef = useRef(null); 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]); const effectiveImageAssignments = useMemo(() => { const nextAssignments: Record = {}; if (!selectedTemplate) { return nextAssignments; } for (const layer of editableImageLayers) { const explicit = String(imageAssignments[layer.id] || "").trim(); if (explicit) { nextAssignments[layer.id] = explicit; continue; } if (layer.isDefaultResultSource && sourceImageUrl?.trim()) { nextAssignments[layer.id] = sourceImageUrl.trim(); } } return nextAssignments; }, [editableImageLayers, imageAssignments, selectedTemplate, sourceImageUrl]); const effectiveTextAssignments = useMemo(() => { const nextAssignments: Record = {}; if (!selectedTemplate) { return nextAssignments; } for (const layer of editableTextLayers) { const explicit = textAssignments[layer.id]; nextAssignments[layer.id] = typeof explicit === "string" ? explicit : String(layer.defaultText || ""); } return nextAssignments; }, [editableTextLayers, selectedTemplate, textAssignments]); const selectedLayer = useMemo( () => selectedTemplate?.layers.find((layer) => layer.id === selectedLayerId) || null, [selectedLayerId, selectedTemplate] ); const selectedImageLayer = selectedLayer && isAgentPsdImageLayerSlot(selectedLayer) ? selectedLayer : null; const selectedTextLayer = selectedLayer && isAgentPsdTextLayerSlot(selectedLayer) ? selectedLayer : null; const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [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); setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate)); }, [open, selectedTemplate, sourceImageUrl]); useEffect(() => { if (!selectedTemplate) { setStageScale(1); return; } const element = previewViewportRef.current; if (!element) return; const updateScale = () => { const rect = element.getBoundingClientRect(); const availableWidth = Math.max(240, rect.width - 32); const availableHeight = Math.max(240, rect.height - 32); const nextScale = Math.min( availableWidth / Math.max(1, selectedTemplate.width), availableHeight / Math.max(1, selectedTemplate.height), 1 ); setStageScale(Number.isFinite(nextScale) && nextScale > 0 ? nextScale : 1); }; updateScale(); const observer = new ResizeObserver(updateScale); observer.observe(element); return () => { observer.disconnect(); }; }, [selectedTemplate]); 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 = ""; } } function openImageUpload(slotId: string) { setSelectedLayerId(slotId); if (uploadInputRef.current) { uploadInputRef.current.value = ""; uploadInputRef.current.click(); } } 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: effectiveImageAssignments, textAssignments: effectiveTextAssignments, }), }); 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 模板 这里是直接在模板里替换图片和修改文字,不会再次调用 AI 生图模型。 {fontFaceCss ? : null}
{selectedTemplate ? ( <>
{selectedTemplate.name}
{selectedTemplate.brandName} {selectedTemplate.width} x {selectedTemplate.height} {editableImageLayers.length} 个可替换图片 {editableTextLayers.length} 个可编辑文字 {fixedLayerCount > 0 ? ( {fixedLayerCount} 个固定图层 ) : null}
操作方式更接近 PS: 直接点击模板里的图片区域去替换图片,点击模板里的文字区域直接改现有文字。
) : (
{isLoading ? "正在加载可用模板..." : normalizedBrandName ? `当前品牌“${normalizedBrandName}”下还没有可用 PSD 模板,请先去后台配置。` : "当前还没有启用的 PSD 模板,请先去后台配置。"}
)}
模板画布编辑区
{selectedTemplate ? ( <> {editableImageLayers.length} 个图片替换区域 {editableTextLayers.length} 个文字编辑区域 ) : null}
点模板上的橙色框替换图片,点蓝色框直接修改文字。
{selectedTemplate ? (
{`${selectedTemplate.name} {editableImageLayers.map((layer) => { const assignedUrl = effectiveImageAssignments[layer.id]; const isSelected = selectedLayerId === layer.id; return ( ); })} {editableTextLayers.map((layer) => { const isSelected = selectedLayerId === layer.id; const value = effectiveTextAssignments[layer.id] || ""; const sharedStyle = { left: layer.x, top: layer.y, width: layer.width, height: layer.height, fontFamily: layer.fontFamily || "sans-serif", fontSize: layer.fontSize, lineHeight: layer.lineHeight, letterSpacing: `${layer.letterSpacing}px`, color: layer.textColor, fontWeight: layer.fontWeight, textAlign: layer.textAlign, paddingLeft: layer.paddingX, paddingRight: layer.paddingX, paddingTop: layer.paddingY, paddingBottom: layer.paddingY, } as const; return (
setSelectedLayerId(layer.id)} > {isSelected ? (