"use client"; import { Image as ImageIcon, Loader2, Move, RefreshCcw, Sparkles, Type, Upload, ZoomIn, ZoomOut, } from "lucide-react"; import { type ChangeEvent, type CSSProperties, type PointerEvent as ReactPointerEvent, useEffect, useMemo, useRef, useState, } from "react"; import { toast } from "sonner"; import useSWR from "swr"; import { AgentPsdTemplateExportPreview } from "@/components/agents/agent-psd-template-export-preview"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent } from "@/components/ui/dialog"; 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 { type AgentPsdImagePlacement, type AgentPsdImageSize, getDefaultAgentPsdImagePlacement, normalizeAgentPsdImagePlacement, resolveAgentPsdImageBaseFrame, } from "@/lib/agents/psd-image-placement"; import { type AgentPsdTemplate, type AgentPsdTemplateImageLayerSlot, type AgentPsdTemplateLayerSlot, type AgentPsdTemplateListResponse, type AgentPsdTemplateMode, type AgentPsdTemplateRenderResponse, type AgentPsdTemplateTextLayerSlot, isAgentPsdImageLayerSlot, isAgentPsdTextLayerSlot, parseAgentPsdTemplates, } from "@/lib/agents/psd-template-config"; import { defaultAgentPsdTemplateSecondaryCategoryConfig, normalizeAgentPsdTemplateSecondaryCategoryConfig, } from "@/lib/agents/psd-template-secondary-category-config"; import { AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG, defaultAgentPsdTemplateCategoryTagConfig, normalizeAgentPsdTemplateCategoryTagConfig, normalizeAgentPsdTemplateCategoryTags, } from "@/lib/agents/psd-template-category-tag-config"; import { defaultAgentPsdTextColorConfig, normalizeAgentPsdTextColorConfig, resolveAgentPsdTextColorConfigForBrand, } from "@/lib/agents/psd-text-color-config"; import { normalizeHexColor, resolveTextLayout, } from "@/lib/agents/psd-text-layout"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; 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, categoryTagConfig: defaultAgentPsdTemplateCategoryTagConfig, error: typeof payload?.error === "string" ? payload.error : "PSD 模板加载失败", }; } return { success: true, templates: parseAgentPsdTemplates(payload.templates), textColorConfig: normalizeAgentPsdTextColorConfig( payload.textColorConfig ), secondaryCategoryConfig: normalizeAgentPsdTemplateSecondaryCategoryConfig( payload.secondaryCategoryConfig ), categoryTagConfig: normalizeAgentPsdTemplateCategoryTagConfig( payload.categoryTagConfig ), }; } catch { return { success: false, templates: [], textColorConfig: defaultAgentPsdTextColorConfig, secondaryCategoryConfig: defaultAgentPsdTemplateSecondaryCategoryConfig, categoryTagConfig: defaultAgentPsdTemplateCategoryTagConfig, 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; const UNASSIGNED_BRAND_LABEL = "未分类"; function normalizeManagedBrandName(value?: string | null) { const normalized = String(value || "").trim(); if (!normalized || normalized === "未分配品牌") { return ""; } return normalized; } function getEffectiveTemplateCategoryTags( template: AgentPsdTemplate, activeCategoryTags: string[] ) { const templateCategoryTags = normalizeAgentPsdTemplateCategoryTags( template.categoryTags ); if (templateCategoryTags.length === 0) { return []; } const activeCategoryTagSet = new Set( activeCategoryTags.map((tag) => tag.trim().toLowerCase()).filter(Boolean) ); if (activeCategoryTagSet.size === 0) { return templateCategoryTags; } return templateCategoryTags.filter( (tag) => tag === AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG || activeCategoryTagSet.has(tag.trim().toLowerCase()) ); } function matchesTemplateSelectedCategories( template: AgentPsdTemplate, selectedCategoryNames: string[], activeCategoryTags: string[] ) { if (selectedCategoryNames.length === 0) { return true; } const rawTemplateCategoryTags = normalizeAgentPsdTemplateCategoryTags( template.categoryTags ); if (rawTemplateCategoryTags.length === 0) { return false; } const templateCategoryTags = getEffectiveTemplateCategoryTags( template, activeCategoryTags ); if (templateCategoryTags.length === 0) { return false; } if (templateCategoryTags.includes(AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG)) { return true; } const selectedCategorySet = new Set( selectedCategoryNames.map((tag) => tag.trim().toLowerCase()).filter(Boolean) ); return templateCategoryTags.some((tag) => selectedCategorySet.has(tag.trim().toLowerCase()) ); } function getTemplateMetaLabel( template: AgentPsdTemplate, showSecondaryCategory: boolean, showBrand = false ) { const metaParts = [ showBrand ? normalizeManagedBrandName(template.brandName) || UNASSIGNED_BRAND_LABEL : "", `${template.width} x ${template.height}`, showSecondaryCategory ? String(template.secondaryCategory || "").trim() : "", ].filter(Boolean); return metaParts.join(" · "); } function renderTemplateSelectContent( template: AgentPsdTemplate, params?: { showSecondaryCategory?: boolean; showBrand?: boolean; compact?: boolean; } ) { const showSecondaryCategory = params?.showSecondaryCategory === true; const showBrand = params?.showBrand === true; const compact = params?.compact === true; const metaLabel = getTemplateMetaLabel(template, showSecondaryCategory, showBrand); return (
{template.name}
{template.name}
{metaLabel ? (
{metaLabel}
) : null}
); } function renderTemplatePickerCardContent( template: AgentPsdTemplate, params?: { showSecondaryCategory?: boolean; showBrand?: boolean; emphasize?: boolean; } ) { const showSecondaryCategory = params?.showSecondaryCategory === true; const showBrand = params?.showBrand === true; const emphasize = params?.emphasize === true; const metaLabel = getTemplateMetaLabel(template, showSecondaryCategory, showBrand); return (
{template.name}
{template.name}
{metaLabel ? (
{metaLabel}
) : null}
); } 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", objectFit: getImageFit(layer.replaceMode), maxWidth: "none", maxHeight: "none", 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, selectedCategoryNames, sizeInfoText, templateMode, }: { open: boolean; onOpenChange: (open: boolean) => void; sourceImageUrl: string | null; brandName?: string | null; selectedCategoryNames?: string[] | null; sizeInfoText?: string | null; templateMode?: AgentPsdTemplateMode | null; }) { const normalizedBrandName = String(brandName || "").trim(); const normalizedSelectedCategoryNames = useMemo( () => normalizeAgentPsdTemplateCategoryTags(selectedCategoryNames).filter( (tag) => tag !== AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG ), [selectedCategoryNames] ); const normalizedTemplateMode = templateMode === "sku" ? "sku" : templateMode === "main" ? "main" : ""; const templateListRequestUrl = useMemo(() => { if (!open) { return null; } const searchParams = new URLSearchParams(); if (normalizedBrandName) { searchParams.set("brand", normalizedBrandName); } return searchParams.size > 0 ? `/api/psd-templates?${searchParams.toString()}` : "/api/psd-templates"; }, [open, normalizedBrandName]); const { data, isLoading } = useSWR( templateListRequestUrl, fetcher ); const allTemplates = useMemo( () => (data?.success ? data.templates : []), [data] ); const rawTextColorConfig = useMemo( () => normalizeAgentPsdTextColorConfig(data?.textColorConfig), [data?.textColorConfig] ); const secondaryCategoryConfig = useMemo( () => normalizeAgentPsdTemplateSecondaryCategoryConfig( data?.secondaryCategoryConfig ), [data?.secondaryCategoryConfig] ); const categoryTagConfig = useMemo( () => normalizeAgentPsdTemplateCategoryTagConfig(data?.categoryTagConfig), [data?.categoryTagConfig] ); const activeCategoryTags = useMemo( () => categoryTagConfig.tags, [categoryTagConfig.tags] ); const templates = useMemo( () => allTemplates.filter((template) => matchesTemplateSelectedCategories( template, normalizedSelectedCategoryNames, activeCategoryTags ) ), [activeCategoryTags, allTemplates, normalizedSelectedCategoryNames] ); const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [selectedLayerId, setSelectedLayerId] = useState(""); const [imageAssignments, setImageAssignments] = useState< Record >({}); const [imagePlacements, setImagePlacements] = useState< Record >({}); const [imageSizes, setImageSizes] = useState< Record >({}); const [textAssignments, setTextAssignments] = useState< Record >({}); const [textColorAssignments, setTextColorAssignments] = useState< Record >({}); 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 effectiveTemplateBrandName = useMemo( () => normalizedBrandName || String(selectedTemplate?.brandName || "").trim(), [normalizedBrandName, selectedTemplate?.brandName] ); const textColorConfig = useMemo( () => resolveAgentPsdTextColorConfigForBrand( rawTextColorConfig, effectiveTemplateBrandName ), [effectiveTemplateBrandName, rawTextColorConfig] ); const shouldShowTemplateBrand = useMemo( () => !normalizedBrandName && templates.some((template) => Boolean(normalizeManagedBrandName(template.brandName))), [normalizedBrandName, templates] ); const selectedCategorySummaryText = useMemo( () => normalizedSelectedCategoryNames.join(" / "), [normalizedSelectedCategoryNames] ); const shouldGroupTemplatesBySecondaryCategory = useMemo( () => secondaryCategoryConfig.enabled && templates.some((template) => Boolean(String(template.secondaryCategory || "").trim()) ), [secondaryCategoryConfig.enabled, templates] ); const mainModeTemplates = useMemo( () => templates.filter((template) => template.mode === "main"), [templates] ); const skuModeTemplates = useMemo( () => templates.filter((template) => template.mode === "sku"), [templates] ); const selectedTemplateLayers = useMemo(() => { if (!selectedTemplate || !Array.isArray(selectedTemplate.layers)) { return [] as AgentPsdTemplateLayerSlot[]; } return selectedTemplate.layers; }, [selectedTemplate]); const selectedTemplateMode: AgentPsdTemplateMode = selectedTemplate?.mode === "sku" ? "sku" : "main"; const selectedModeTemplates = useMemo( () => selectedTemplateMode === "sku" ? skuModeTemplates : mainModeTemplates, [mainModeTemplates, selectedTemplateMode, skuModeTemplates] ); const groupedSelectedModeTemplates = useMemo(() => { if (!shouldGroupTemplatesBySecondaryCategory) { return [] as Array<{ label: string; templates: AgentPsdTemplate[] }>; } const groups = new Map(); for (const template of selectedModeTemplates) { 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, })); }, [selectedModeTemplates, shouldGroupTemplatesBySecondaryCategory]); 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 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 selectedImageAssignmentUrl = selectedImageLayer ? String(effectiveImageAssignments[selectedImageLayer.id] || "").trim() : ""; const selectedImagePlacement = selectedImageLayer ? normalizeAgentPsdImagePlacement( effectiveImagePlacements[selectedImageLayer.id] ) : getDefaultAgentPsdImagePlacement(); const selectedTextColor = selectedTextLayer ? effectiveTextColorAssignments[selectedTextLayer.id] || selectedTextLayer.textColor : "#111111"; const selectedImageAdjustmentPanel = selectedImageLayer ? (
{sourceImageUrl ? ( ) : null}
{selectedImageAssignmentUrl ? ( <>
缩放
{Math.round(selectedImagePlacement.scale * 100)}%
{ const newScale = clampImageScale(value[0] || 1); updateImagePlacement(selectedImageLayer.id, (current) => ({ ...current, scale: newScale, })); }} rangeClassName="bg-gradient-to-r from-emerald-300 via-green-400 to-teal-400 shadow-[0_0_12px_rgba(16,185,129,0.38)]" step={0.01} thumbClassName="h-5 w-5 border-2 border-emerald-100 bg-[#0a0a0d] shadow-[0_0_0_5px_rgba(16,185,129,0.2)]" trackClassName="h-2.5 rounded-full border border-white/20 bg-[#0f1014]" value={[selectedImagePlacement.scale]} />
40% 100% 300%
) : null}
) : null; const fontFaceCss = useMemo( () => buildPreviewFontFaces(selectedTemplate), [selectedTemplate] ); const selectedTemplateSelectValue = selectedTemplate?.id; const hasTemplateOptions = templates.length > 0; useEffect(() => { if (open) { return; } setSelectedTemplateId(""); setSelectedLayerId(""); }, [open]); useEffect(() => { if (!open) return; if (templates.length === 0) { if (selectedTemplateId) { setSelectedTemplateId(""); } setSelectedLayerId(""); return; } if (!templates.some((template) => template.id === selectedTemplateId)) { setSelectedTemplateId(""); } }, [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?.id]); 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 handleSwitchSelectedTemplateMode(nextMode: AgentPsdTemplateMode) { if (selectedTemplateMode === nextMode) { return; } const nextTemplates = nextMode === "sku" ? skuModeTemplates : mainModeTemplates; if (nextTemplates.length === 0) { toast.error( selectedCategorySummaryText ? nextMode === "sku" ? `当前品类“${selectedCategorySummaryText}”下暂无 SKU 图模式模板` : `当前品类“${selectedCategorySummaryText}”下暂无主图模式模板` : nextMode === "sku" ? "当前品牌下暂无 SKU 图模式模板" : "当前品牌下暂无主图模式模板" ); return; } setSelectedTemplateId(nextTemplates[0].id); } return ( <> : null}
{selectedCategorySummaryText ? (
当前按品类“{selectedCategorySummaryText}”筛选模板,同时保留“适合所有”的模板。
) : null} {selectedTemplate && hasTemplateOptions ? (
) : (
{isLoading ? ( <> {Array.from({ length: 2 }).map((_, index) => (
正在加载主图模式模板...
))} ) : mainModeTemplates.length > 0 ? ( mainModeTemplates.map((template) => ( )) ) : (
{selectedCategorySummaryText ? `当前品类“${selectedCategorySummaryText}”下暂无主图模式模板` : normalizedBrandName ? `当前品牌“${normalizedBrandName}”下暂无主图模式模板` : "当前暂无可用的主图模式模板,请先去后台配置。"}
)}
)}
{selectedTemplate ? ( <>
{selectedTemplate.name}
{selectedImageAdjustmentPanel} ) : (
{isLoading ? "正在加载可用模板..." : hasTemplateOptions ? "请从左侧主图模式模板或右侧 SKU 图模式模板中选择一个模板开始编辑。" : selectedCategorySummaryText && normalizedBrandName ? `当前品牌“${normalizedBrandName}”下没有命中品类“${selectedCategorySummaryText}”的 PSD 模板,请先去后台配置。` : selectedCategorySummaryText ? `当前品类“${selectedCategorySummaryText}”下还没有可用 PSD 模板,请先去后台配置。` : 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) } role="button" style={{ ...frameStyle, zIndex: 20, background: "rgba(249,115,22,0.08)", cursor: imageDragStateRef.current?.layerId === layer.id ? "grabbing" : "grab", touchAction: "none", }} tabIndex={0} 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}