"use client"; import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type PointerEvent as ReactPointerEvent } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { Download, Image as ImageIcon, Loader2, Move, MousePointerClick, RefreshCcw, Sparkles, Type, Upload, ZoomIn, ZoomOut, } from "lucide-react"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { Dialog, DialogContent, } 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, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; import { Textarea } from "@/components/ui/textarea"; import { getDefaultAgentPsdImagePlacement, normalizeAgentPsdImagePlacement, resolveAgentPsdImageBaseFrame, type AgentPsdImagePlacement, type AgentPsdImageSize, } from "@/lib/agents/psd-image-placement"; import { defaultAgentPsdTextColorConfig, normalizeAgentPsdTextColorConfig, } from "@/lib/agents/psd-text-color-config"; import { isAgentPsdImageLayerSlot, isAgentPsdTextLayerSlot, parseAgentPsdTemplates, type AgentPsdTemplate, type AgentPsdTemplateImageLayerSlot, type AgentPsdTemplateLayerSlot, type AgentPsdTemplateListResponse, type AgentPsdTemplateRenderResponse, type AgentPsdTemplateTextLayerSlot, } from "@/lib/agents/psd-template-config"; import { defaultAgentPsdTemplateSecondaryCategoryConfig, normalizeAgentPsdTemplateSecondaryCategoryConfig, } from "@/lib/agents/psd-template-secondary-category-config"; import { normalizeHexColor, resolveTextLayout } from "@/lib/agents/psd-text-layout"; const fetcher = async (url: string): Promise => { try { const response = await fetch(url, { cache: "no-store" }); const payload = (await response.json().catch(() => null)) as | Partial | null; if (!response.ok || !payload || payload.success !== true || !Array.isArray(payload.templates)) { return { success: false, templates: [], textColorConfig: defaultAgentPsdTextColorConfig, secondaryCategoryConfig: defaultAgentPsdTemplateSecondaryCategoryConfig, error: typeof payload?.error === "string" ? payload.error : "PSD 模板加载失败", }; } return { success: true, templates: parseAgentPsdTemplates(payload.templates), textColorConfig: normalizeAgentPsdTextColorConfig(payload.textColorConfig), secondaryCategoryConfig: normalizeAgentPsdTemplateSecondaryCategoryConfig( payload.secondaryCategoryConfig ), }; } catch { return { success: false, templates: [], textColorConfig: defaultAgentPsdTextColorConfig, secondaryCategoryConfig: defaultAgentPsdTemplateSecondaryCategoryConfig, error: "PSD 模板加载失败", }; } }; const PREVIEW_VIEWPORT_PADDING = 56; const PREVIEW_STAGE_MAX_SCALE = 0.82; const PREVIEW_STAGE_TARGET_WIDTH = 760; const PREVIEW_STAGE_TARGET_HEIGHT = 760; const MIN_IMAGE_SCALE = 0.4; const MAX_IMAGE_SCALE = 3; function clampImageScale(value: number) { if (!Number.isFinite(value)) { return 1; } return Math.max(MIN_IMAGE_SCALE, Math.min(MAX_IMAGE_SCALE, value)); } function createInitialState(template: AgentPsdTemplate | null, sourceImageUrl: string) { const imageAssignments: Record = {}; const imagePlacements: Record = {}; const textAssignments: Record = {}; if (!template) { return { imageAssignments, imagePlacements, textAssignments }; } for (const layer of template.layers) { if (isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource && sourceImageUrl) { imageAssignments[layer.id] = sourceImageUrl; imagePlacements[layer.id] = getDefaultAgentPsdImagePlacement(); } if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) { textAssignments[layer.id] = layer.defaultText; } } return { imageAssignments, imagePlacements, 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; } const familyAliases = Array.from( new Set( [ layer.fontFamily, ...(layer.detectedFontNames || []), layer.detectedFontName, ] .map((family) => String(family || "").trim()) .filter(Boolean) ) ); for (const familyAlias of familyAliases) { if (rules.has(familyAlias)) { continue; } rules.set( familyAlias, `@font-face { font-family: "${familyAlias}"; 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 buildTextLayerFontFamily(layer: AgentPsdTemplateTextLayerSlot) { const families = Array.from( new Set( [ layer.fontFamily, ...(layer.detectedFontNames || []), layer.detectedFontName, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", "Source Han Sans SC", "sans-serif", ] .map((family) => String(family || "").trim()) .filter(Boolean) ) ); return families .map((family) => family === "sans-serif" ? family : `"${family.replace(/"/g, '\\"')}"` ) .join(", "); } function getPreferredSelectedLayerId(template: AgentPsdTemplate | null) { if (!template || template.layers.length === 0) { return ""; } const editableLayers = template.layers.filter((layer) => layer.isEditable); if (editableLayers.length === 0) { return ""; } const preferredImage = editableLayers.find( (layer) => isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource ); if (preferredImage) { return preferredImage.id; } return editableLayers[0]?.id || ""; } function getImageFit(replaceMode: AgentPsdTemplateImageLayerSlot["replaceMode"]): CSSProperties["objectFit"] { if (replaceMode === "contain") return "contain"; if (replaceMode === "stretch") return "fill"; return "cover"; } function getLayerFrameStyle( layer: AgentPsdTemplateLayerSlot, isSelected: boolean ): CSSProperties { return { left: layer.x, top: layer.y, width: layer.width, height: layer.height, border: `2px solid ${getLayerOutlineColor(layer)}`, boxShadow: isSelected ? `0 0 0 3px rgba(255,255,255,0.16), 0 0 0 6px ${getLayerOutlineColor(layer)}55` : `0 0 0 1px ${getLayerOutlineColor(layer)}66 inset`, }; } function getResolvedTextPreview(layer: AgentPsdTemplateTextLayerSlot, value: string) { return value || layer.placeholder || layer.layerName; } function getResolvedTextLayerColor( layer: AgentPsdTemplateTextLayerSlot, value?: string ) { return normalizeHexColor(value || layer.textColor, layer.textColor); } function getTextLayerRenderFrame( layer: AgentPsdTemplateTextLayerSlot, value: string ) { const resolvedLayout = resolveTextLayout({ text: getResolvedTextPreview(layer, value), width: layer.width, height: layer.height, fontSize: layer.fontSize, lineHeight: layer.lineHeight, letterSpacing: layer.letterSpacing, paddingX: layer.paddingX, paddingY: layer.paddingY, maxLines: layer.maxLines, autoFit: false, preferNoWrap: true, }); const width = Math.max(layer.width, resolvedLayout.contentWidth); const height = Math.max(layer.height, resolvedLayout.contentHeight); const offsetX = layer.textAlign === "right" ? layer.width - width : layer.textAlign === "center" ? (layer.width - width) / 2 : 0; return { ...resolvedLayout, width, height, offsetX, offsetY: -resolvedLayout.visualOffsetY, }; } function getTextLayerContentStyle( layer: AgentPsdTemplateTextLayerSlot, value: string, color: string ): CSSProperties { const resolvedLayout = getTextLayerRenderFrame(layer, value); return { width: resolvedLayout.width, height: resolvedLayout.height, fontFamily: buildTextLayerFontFamily(layer), fontSize: resolvedLayout.fontSize, lineHeight: layer.lineHeight, letterSpacing: `${layer.letterSpacing}px`, color, fontWeight: layer.fontWeight, textAlign: layer.textAlign, paddingLeft: layer.paddingX, paddingRight: layer.paddingX, paddingTop: layer.paddingY, paddingBottom: layer.paddingY, whiteSpace: "pre", wordBreak: "keep-all", overflowWrap: "normal", }; } function getAssignedImageMaskStyle(layer: AgentPsdTemplateImageLayerSlot): CSSProperties { const hasMask = Boolean(layer.maskUrl); return { WebkitMaskImage: hasMask ? `url(${layer.maskUrl})` : undefined, maskImage: hasMask ? `url(${layer.maskUrl})` : undefined, WebkitMaskSize: hasMask ? "100% 100%" : undefined, maskSize: hasMask ? "100% 100%" : undefined, WebkitMaskRepeat: hasMask ? "no-repeat" : undefined, maskRepeat: hasMask ? "no-repeat" : undefined, WebkitMaskPosition: hasMask ? "center" : undefined, maskPosition: hasMask ? "center" : undefined, }; } function getPlacedAssignedImageStyle(params: { layer: AgentPsdTemplateImageLayerSlot; imageSize: AgentPsdImageSize; placement: AgentPsdImagePlacement; }): CSSProperties { const { layer, imageSize, placement } = params; const normalizedPlacement = normalizeAgentPsdImagePlacement(placement); const baseFrame = resolveAgentPsdImageBaseFrame({ containerWidth: layer.width, containerHeight: layer.height, imageWidth: imageSize.width, imageHeight: imageSize.height, replaceMode: layer.replaceMode, contentBounds: layer.contentBounds, }); return { position: "absolute", left: baseFrame.centerX + normalizedPlacement.offsetX, top: baseFrame.centerY + normalizedPlacement.offsetY, width: baseFrame.width, height: baseFrame.height, transform: `translate(-50%, -50%) scale(${normalizedPlacement.scale})`, transformOrigin: "center center", willChange: "transform", }; } function hasEditedTextValue(layer: AgentPsdTemplateTextLayerSlot, value: string) { return String(value || "").replace(/\r\n/g, "\n") !== String(layer.defaultText || "").replace(/\r\n/g, "\n"); } function hasCustomizedTextLayerAppearance( layer: AgentPsdTemplateTextLayerSlot, value: string, color: string ) { return ( hasEditedTextValue(layer, value) || getResolvedTextLayerColor(layer, color) !== getResolvedTextLayerColor(layer) ); } function getInlineTextEditorStyle( layer: AgentPsdTemplateTextLayerSlot, preserveSourceAppearance: boolean, value: string, color: string ): CSSProperties { const baseStyle = getTextLayerContentStyle(layer, value, color); return { ...baseStyle, background: "transparent", boxSizing: "border-box", overflow: "visible", color: preserveSourceAppearance ? "transparent" : color, caretColor: color, WebkitTextFillColor: preserveSourceAppearance ? "transparent" : color, }; } function getTextLayerNameBadgeClassName() { return "pointer-events-none absolute left-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-black/78 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg"; } function getTextLayerHintBadgeClassName() { return "pointer-events-none absolute right-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-sky-500/90 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg"; } 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 textColorConfig = useMemo( () => normalizeAgentPsdTextColorConfig(data?.textColorConfig), [data?.textColorConfig] ); const secondaryCategoryConfig = useMemo( () => normalizeAgentPsdTemplateSecondaryCategoryConfig(data?.secondaryCategoryConfig), [data?.secondaryCategoryConfig] ); const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [selectedLayerId, setSelectedLayerId] = useState(""); const [imageAssignments, setImageAssignments] = useState>({}); const [imagePlacements, setImagePlacements] = useState>({}); const [imageSizes, setImageSizes] = useState>({}); const [textAssignments, setTextAssignments] = useState>({}); const [textColorAssignments, setTextColorAssignments] = useState>({}); const [uploadingSlotId, setUploadingSlotId] = useState(null); const [isRendering, setIsRendering] = useState(false); const [renderedResultUrl, setRenderedResultUrl] = useState(""); const [rightPanelView, setRightPanelView] = useState<"editor" | "export">("editor"); const [stageScale, setStageScale] = useState(1); const previewViewportRef = useRef(null); const uploadInputRef = useRef(null); const inlineTextEditorRef = useRef(null); const imageDragStateRef = useRef<{ layerId: string; pointerId: number; startClientX: number; startClientY: number; startPlacement: AgentPsdImagePlacement; } | null>(null); const selectedTemplate = useMemo( () => templates.find((template) => template.id === selectedTemplateId) || null, [selectedTemplateId, templates] ); const shouldGroupTemplatesBySecondaryCategory = useMemo( () => secondaryCategoryConfig.enabled && templates.some((template) => Boolean(String(template.secondaryCategory || "").trim())), [secondaryCategoryConfig.enabled, templates] ); const groupedTemplates = useMemo(() => { if (!shouldGroupTemplatesBySecondaryCategory) { return [] as Array<{ label: string; templates: AgentPsdTemplate[] }>; } const groups = new Map(); for (const template of templates) { const groupLabel = String(template.secondaryCategory || "").trim() || "未分组"; const currentGroup = groups.get(groupLabel) || []; currentGroup.push(template); groups.set(groupLabel, currentGroup); } return Array.from(groups.entries()).map(([label, grouped]) => ({ label, templates: grouped, })); }, [shouldGroupTemplatesBySecondaryCategory, templates]); const selectedTemplateLayers = useMemo(() => { if (!selectedTemplate || !Array.isArray(selectedTemplate.layers)) { return [] as AgentPsdTemplateLayerSlot[]; } return selectedTemplate.layers; }, [selectedTemplate]); const editableImageLayers = useMemo( () => selectedTemplateLayers.filter( (layer): layer is AgentPsdTemplateImageLayerSlot => layer.isEditable && isAgentPsdImageLayerSlot(layer) ), [selectedTemplateLayers] ); const editableTextLayers = useMemo( () => selectedTemplateLayers.filter( (layer): layer is AgentPsdTemplateTextLayerSlot => layer.isEditable && isAgentPsdTextLayerSlot(layer) ), [selectedTemplateLayers] ); const fixedLayerCount = useMemo(() => { if (!selectedTemplate) return 0; return selectedTemplateLayers.filter((layer) => !layer.isEditable).length; }, [selectedTemplate, selectedTemplateLayers]); const orderedPreviewLayers = useMemo( () => [...selectedTemplateLayers].reverse(), [selectedTemplateLayers] ); const orderedEditableLayers = useMemo( () => orderedPreviewLayers.filter((layer) => layer.isEditable), [orderedPreviewLayers] ); const orderedEditableImageLayers = useMemo( () => orderedEditableLayers.filter( (layer): layer is AgentPsdTemplateImageLayerSlot => isAgentPsdImageLayerSlot(layer) ), [orderedEditableLayers] ); const orderedEditableTextLayers = useMemo( () => orderedEditableLayers.filter( (layer): layer is AgentPsdTemplateTextLayerSlot => isAgentPsdTextLayerSlot(layer) ), [orderedEditableLayers] ); const shouldShowCompositeFallback = useMemo( () => orderedPreviewLayers.some((layer) => !String(layer.sourceUrl || "").trim()), [orderedPreviewLayers] ); 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 effectiveTextColorAssignments = useMemo(() => { const nextAssignments: Record = {}; if (!selectedTemplate) { return nextAssignments; } for (const layer of editableTextLayers) { nextAssignments[layer.id] = getResolvedTextLayerColor( layer, textColorAssignments[layer.id] ); } return nextAssignments; }, [editableTextLayers, selectedTemplate, textColorAssignments]); const effectiveImagePlacements = useMemo(() => { const nextPlacements: Record = {}; if (!selectedTemplate) { return nextPlacements; } for (const layer of editableImageLayers) { if (!String(effectiveImageAssignments[layer.id] || "").trim()) { continue; } nextPlacements[layer.id] = normalizeAgentPsdImagePlacement(imagePlacements[layer.id]); } return nextPlacements; }, [editableImageLayers, effectiveImageAssignments, imagePlacements, selectedTemplate]); const selectedLayer = useMemo( () => selectedTemplateLayers.find((layer) => layer.id === selectedLayerId) || null, [selectedLayerId, selectedTemplateLayers] ); const selectedImageLayer = selectedLayer && isAgentPsdImageLayerSlot(selectedLayer) ? selectedLayer : null; const selectedTextLayer = selectedLayer && isAgentPsdTextLayerSlot(selectedLayer) ? selectedLayer : null; const selectedImagePlacement = selectedImageLayer ? normalizeAgentPsdImagePlacement(effectiveImagePlacements[selectedImageLayer.id]) : getDefaultAgentPsdImagePlacement(); const selectedTextColor = selectedTextLayer ? effectiveTextColorAssignments[selectedTextLayer.id] || selectedTextLayer.textColor : "#111111"; const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]); const selectedTemplateSelectValue = selectedTemplate?.id; const hasTemplateOptions = templates.length > 0; useEffect(() => { if (!open) return; if (templates.length === 0) { if (selectedTemplateId) { setSelectedTemplateId(""); } setSelectedLayerId(""); return; } if (!templates.some((template) => template.id === selectedTemplateId)) { setSelectedTemplateId(templates[0].id); } }, [open, selectedTemplateId, templates]); useEffect(() => { if (!open) return; setRenderedResultUrl(""); setRightPanelView("editor"); const initialState = createInitialState(selectedTemplate, sourceImageUrl || ""); setImageAssignments(initialState.imageAssignments); setImagePlacements(initialState.imagePlacements); setTextAssignments(initialState.textAssignments); setTextColorAssignments({}); setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate)); if (!selectedTemplate || typeof window === "undefined") { return; } // 自动适配缩放比例(延迟执行确保 DOM 已渲染) const timeoutId = window.setTimeout(() => { if (previewViewportRef.current) { const viewport = previewViewportRef.current; const padding = 32; const availableWidth = viewport.clientWidth - padding; const availableHeight = viewport.clientHeight - padding; if (availableWidth > 0 && availableHeight > 0) { const scaleX = availableWidth / selectedTemplate.width; const scaleY = availableHeight / selectedTemplate.height; const fitScale = Math.min(scaleX, scaleY, 1); setStageScale(fitScale); } } }, 100); return () => { window.clearTimeout(timeoutId); }; }, [open, selectedTemplate, sourceImageUrl]); useEffect(() => { if (!selectedTemplate) { setImageSizes((current) => { if (Object.keys(current).length === 0) { return current; } return {}; }); return; } if (typeof window === "undefined" || typeof window.Image !== "function") { return; } let cancelled = false; for (const layer of editableImageLayers) { const assignedUrl = String(effectiveImageAssignments[layer.id] || "").trim(); if (!assignedUrl) { continue; } const currentSize = imageSizes[layer.id]; if (currentSize && currentSize.width > 0 && currentSize.height > 0) { continue; } const image = new window.Image(); image.onload = () => { if (cancelled) return; const nextWidth = Math.max(1, image.naturalWidth || layer.width); const nextHeight = Math.max(1, image.naturalHeight || layer.height); setImageSizes((current) => { if ( current[layer.id]?.width === nextWidth && current[layer.id]?.height === nextHeight ) { return current; } return { ...current, [layer.id]: { width: nextWidth, height: nextHeight, }, }; }); }; image.src = assignedUrl; } return () => { cancelled = true; }; }, [editableImageLayers, effectiveImageAssignments, imageSizes, selectedTemplate]); useEffect(() => { if (!selectedTemplate) { setStageScale(1); return; } const element = previewViewportRef.current; if (!element) return; const updateScale = () => { const rect = element.getBoundingClientRect(); const availableWidth = Math.max( 240, Math.min(PREVIEW_STAGE_TARGET_WIDTH, rect.width - PREVIEW_VIEWPORT_PADDING) ); const availableHeight = Math.max( 240, Math.min(PREVIEW_STAGE_TARGET_HEIGHT, rect.height - PREVIEW_VIEWPORT_PADDING) ); const nextScale = Math.min( availableWidth / Math.max(1, selectedTemplate.width), availableHeight / Math.max(1, selectedTemplate.height), PREVIEW_STAGE_MAX_SCALE ); setStageScale(Number.isFinite(nextScale) && nextScale > 0 ? nextScale : 1); }; updateScale(); let animationFrameId: number | null = null; const scheduleScaleUpdate = () => { if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { if (animationFrameId !== null) { window.cancelAnimationFrame(animationFrameId); } animationFrameId = window.requestAnimationFrame(() => { animationFrameId = null; updateScale(); }); return; } updateScale(); }; if (typeof ResizeObserver !== "undefined") { const observer = new ResizeObserver(scheduleScaleUpdate); observer.observe(element); return () => { observer.disconnect(); if (animationFrameId !== null && typeof window !== "undefined") { window.cancelAnimationFrame(animationFrameId); } }; } if (typeof window !== "undefined") { window.addEventListener("resize", scheduleScaleUpdate); } return () => { if (typeof window !== "undefined") { window.removeEventListener("resize", scheduleScaleUpdate); if (animationFrameId !== null) { window.cancelAnimationFrame(animationFrameId); } } }; }, [selectedTemplate]); useEffect(() => { if (!selectedTextLayer) { return; } const textarea = inlineTextEditorRef.current; if (!textarea) { return; } const focusTextarea = () => { textarea.focus(); const valueLength = textarea.value.length; try { textarea.setSelectionRange(valueLength, valueLength); } catch (error) { console.warn("[AgentPsdTemplateEditorDialog] setSelectionRange failed:", error); } }; if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { const frameId = window.requestAnimationFrame(focusTextarea); return () => { window.cancelAnimationFrame(frameId); }; } const timeoutId = window.setTimeout(focusTextarea, 0); return () => { window.clearTimeout(timeoutId); }; }, [selectedTextLayer?.id]); 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, })); setImagePlacements((current) => ({ ...current, [slotId]: getDefaultAgentPsdImagePlacement(), })); setImageSizes((current) => { const next = { ...current }; delete next[slotId]; return next; }); 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(); } } function updateImagePlacement( layerId: string, updater: (current: AgentPsdImagePlacement) => AgentPsdImagePlacement ) { setImagePlacements((current) => ({ ...current, [layerId]: normalizeAgentPsdImagePlacement(updater(normalizeAgentPsdImagePlacement(current[layerId]))), })); } function nudgeImageScale(layerId: string, delta: number) { updateImagePlacement(layerId, (current) => ({ ...current, scale: Number(clampImageScale(current.scale + delta).toFixed(2)), })); } function resetImagePlacement(layerId: string) { setImagePlacements((current) => ({ ...current, [layerId]: getDefaultAgentPsdImagePlacement(), })); } function handleImageDragStart( layer: AgentPsdTemplateImageLayerSlot, event: ReactPointerEvent ) { if (!String(effectiveImageAssignments[layer.id] || "").trim()) { return; } imageDragStateRef.current = { layerId: layer.id, pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, startPlacement: normalizeAgentPsdImagePlacement(effectiveImagePlacements[layer.id]), }; const nextTarget = event.currentTarget; try { nextTarget.setPointerCapture?.(event.pointerId); } catch (error) { console.warn("[AgentPsdTemplateEditorDialog] setPointerCapture failed:", error); } const handlePointerMove = (moveEvent: PointerEvent) => { const dragState = imageDragStateRef.current; if (!dragState || dragState.layerId !== layer.id) { return; } updateImagePlacement(layer.id, () => ({ ...dragState.startPlacement, offsetX: dragState.startPlacement.offsetX + (moveEvent.clientX - dragState.startClientX) / stageScale, offsetY: dragState.startPlacement.offsetY + (moveEvent.clientY - dragState.startClientY) / stageScale, })); }; const handlePointerUp = (upEvent: PointerEvent) => { if (imageDragStateRef.current?.pointerId === upEvent.pointerId) { imageDragStateRef.current = null; } window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointercancel", handlePointerUp); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); window.addEventListener("pointercancel", handlePointerUp); } 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, imagePlacements: effectiveImagePlacements, textAssignments: effectiveTextAssignments, textColorAssignments: effectiveTextColorAssignments, }), }); 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); setRightPanelView("export"); 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 ( <> : null}
{hasTemplateOptions ? ( ) : (
{isLoading ? "正在加载可用模板..." : normalizedBrandName ? `当前品牌“${normalizedBrandName}”下暂无可用模板` : "请先在工作流中选择品牌后再打开 PSD 模板。"}
)}
{selectedTemplate ? ( <>
{selectedTemplate.name}
{selectedTemplate.brandName} {selectedTemplate.width} x {selectedTemplate.height} {editableImageLayers.length} 个可替换图片 {editableTextLayers.length} 个可编辑文字 {fixedLayerCount > 0 ? ( {fixedLayerCount} 个固定图层 ) : null}
) : (
{isLoading ? "正在加载可用模板..." : normalizedBrandName ? `当前品牌“${normalizedBrandName}”下还没有可用 PSD 模板,请先去后台配置。` : "当前还没有启用的 PSD 模板,请先去后台配置。"}
)}
{selectedTemplate ? (
{shouldShowCompositeFallback ? ( {`${selectedTemplate.name} ) : null}
{orderedPreviewLayers.map((layer) => { const layerBoxStyle = { left: layer.x, top: layer.y, width: layer.width, height: layer.height, } satisfies CSSProperties; if (isAgentPsdImageLayerSlot(layer)) { const assignedUrl = effectiveImageAssignments[layer.id]; const layerImageUrl = assignedUrl || layer.sourceUrl; if (!layerImageUrl) { return null; } const assignedImageContainerStyle = assignedUrl ? { ...layerBoxStyle, ...getAssignedImageMaskStyle(layer), } : layerBoxStyle; return (
{layer.layerName}
); } const isSelected = selectedLayerId === layer.id; if (isSelected) { return null; } const value = effectiveTextAssignments[layer.id] || ""; const resolvedTextColor = effectiveTextColorAssignments[layer.id] || layer.textColor; const shouldUseEditedPreview = hasCustomizedTextLayerAppearance( layer, value, resolvedTextColor ); if (!shouldUseEditedPreview && layer.sourceUrl) { return (
{layer.layerName}
); } if (!value && !layer.placeholder) { return null; } const textFrame = getTextLayerRenderFrame( layer, value || layer.placeholder || layer.layerName ); return (
{value || layer.placeholder || layer.layerName}
); })}
{orderedEditableImageLayers.map((layer) => { const isSelected = selectedLayerId === layer.id; const frameStyle = getLayerFrameStyle(layer, isSelected); const hasAssignedImage = Boolean( String(effectiveImageAssignments[layer.id] || "").trim() ); return isSelected && hasAssignedImage ? (
setSelectedLayerId(layer.id)} onPointerDown={(event) => handleImageDragStart(layer, event)} className="absolute overflow-hidden rounded-lg transition-all" style={{ ...frameStyle, zIndex: 20, background: "rgba(249,115,22,0.08)", cursor: imageDragStateRef.current?.layerId === layer.id ? "grabbing" : "grab", touchAction: "none", }} aria-label={`${getLayerLabel(layer)} ${layer.layerName}`} title={`${getLayerLabel(layer)}:${layer.layerName}`} >
{layer.layerName}
拖动调整
) : ( ); })} {orderedEditableImageLayers.map((layer) => { const isSelected = selectedLayerId === layer.id; const hasAssignedImage = Boolean( String(effectiveImageAssignments[layer.id] || "").trim() ); if (!isSelected || !hasAssignedImage) { return null; } return (
); })} {orderedEditableTextLayers.map((layer) => { const isSelected = selectedLayerId === layer.id; const frameStyle = getLayerFrameStyle(layer, isSelected); const currentValue = effectiveTextAssignments[layer.id] || ""; const resolvedTextColor = effectiveTextColorAssignments[layer.id] || layer.textColor; const preserveSourceAppearance = !!layer.sourceUrl && !hasCustomizedTextLayerAppearance(layer, currentValue, resolvedTextColor); const editedTextFrame = getTextLayerRenderFrame(layer, currentValue); if (isSelected) { return (
{preserveSourceAppearance && layer.sourceUrl ? ( {layer.layerName} ) : null}