"use client"; import { ArrowLeft, ChevronDown, ChevronUp, CheckCircle2, Eye, EyeOff, Expand, Image as ImageIcon, Images, Loader2, Move, Pointer, 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, { preload } 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, } 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 { AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG, defaultAgentPsdTemplateCategoryTagConfig, normalizeAgentPsdTemplateCategoryTagConfig, normalizeAgentPsdTemplateCategoryTags, } from "@/lib/agents/psd-template-category-tag-config"; import { type AgentPsdTemplate, type AgentPsdTemplateImageLayerSlot, type AgentPsdTemplateLayerSlot, type AgentPsdTemplateListResponse, type AgentPsdTemplateMode, type AgentPsdTemplateMutationResponse, type AgentPsdTemplateRenderResponse, type AgentPsdTemplateTextLayerSlot, isAgentPsdImageLayerSlot, isAgentPsdTextLayerSlot, parseAgentPsdTemplates, resolveAgentPsdTextLayerEffectiveAlign, } from "@/lib/agents/psd-template-config"; import { defaultAgentPsdTemplateSecondaryCategoryConfig, normalizeAgentPsdTemplateSecondaryCategoryConfig, } from "@/lib/agents/psd-template-secondary-category-config"; import { defaultAgentPsdTextColorConfig, normalizeAgentPsdTextColorConfig, resolveAgentPsdTextColorConfigForBrand, } from "@/lib/agents/psd-text-color-config"; import { normalizeHexColor, resolveTextLayout, } from "@/lib/agents/psd-text-layout"; import { requestLongTaskResult } from "@/lib/client/long-task"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import type { CanvasPsdTemplatePendingImage } from "@/lib/canvas/psd-template-launch"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { getFriendlyRateLimitMessage, getUserFacingRequestErrorMessage, } from "@/lib/request-error"; function buildTemplateListRequestUrl(params?: { brandName?: string | null; summaryOnly?: boolean; }) { const searchParams = new URLSearchParams(); if (params?.summaryOnly !== false) { searchParams.set("summary", "1"); } const normalizedBrandName = String(params?.brandName || "").trim(); if (normalizedBrandName) { searchParams.set("brand", normalizedBrandName); } const queryString = searchParams.toString(); return queryString ? `/api/psd-templates?${queryString}` : "/api/psd-templates"; } 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 templateDetailFetcher = async ( url: string ): Promise => { try { const response = await fetch(url, { cache: "default" }); const payload = (await response .json() .catch(() => null)) as Partial | null; if ( !response.ok || !payload || payload.success !== true || !payload.template ) { return { success: false, error: typeof payload?.error === "string" ? payload.error : "PSD 模板详情加载失败", }; } return { success: true, template: parseAgentPsdTemplates([payload.template])[0], }; } catch { return { success: false, error: "PSD 模板详情加载失败", }; } }; async function parseActionJsonResponse( response: Response, fallbackMessage: string, rateLimitMessage?: string ): Promise { const payload = await response .clone() .json() .catch(() => null); if (!response.ok) { let responseText = ""; if (payload == null) { responseText = await response .text() .then((value) => value.trim()) .catch(() => ""); } const requestError = new Error( getUserFacingRequestErrorMessage( { status: response.status, data: payload, message: responseText, }, fallbackMessage, { rateLimitMessage, } ) ) as Error & { status?: number; data?: unknown; }; requestError.status = response.status; requestError.data = payload ?? (responseText ? { message: responseText, } : null); throw requestError; } return payload as T; } 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 SELECTED_LAYER_INTERACTION_Z_BOOST = 1; const SELECTED_LAYER_CONTROLS_Z_BOOST = 1000; const MIN_TEXT_POSITION_OFFSET = -5000; const MAX_TEXT_POSITION_OFFSET = 5000; const UNASSIGNED_BRAND_LABEL = "未分类"; const IMAGE_EXTENSION_REGEX = /\.([a-z0-9]{2,5})$/i; const HIDDEN_LAYER_SCROLL_STEP = 180; type AgentPsdTextPositionOffset = { offsetX: number; offsetY: number; }; type AgentPsdLayerPositionOffset = AgentPsdTextPositionOffset; function clampTextPositionOffsetValue(value: number) { if (!Number.isFinite(value)) { return 0; } return Math.max( MIN_TEXT_POSITION_OFFSET, Math.min(MAX_TEXT_POSITION_OFFSET, value) ); } function normalizeTextPositionOffset( value?: Partial | null ): AgentPsdTextPositionOffset { return { offsetX: Number( clampTextPositionOffsetValue(Number(value?.offsetX || 0)).toFixed(2) ), offsetY: Number( clampTextPositionOffsetValue(Number(value?.offsetY || 0)).toFixed(2) ), }; } function hasTextPositionOffset( value?: Partial | null ) { const normalized = normalizeTextPositionOffset(value); return ( Math.abs(normalized.offsetX) > 0.01 || Math.abs(normalized.offsetY) > 0.01 ); } function normalizeLayerPositionOffset( value?: Partial | null ) { return normalizeTextPositionOffset(value); } function hasLayerPositionOffset( value?: Partial | null ) { return hasTextPositionOffset(value); } function buildTemplatePreviewThumbUrl( previewUrl: string, params?: { width?: number; quality?: number; } ) { const normalizedUrl = String(previewUrl || "").trim(); if (!normalizedUrl) { return ""; } const width = Math.max(80, Math.round(params?.width || 160)); const quality = Math.max(40, Math.round(params?.quality || 72)); const directOptimizedUrl = buildImageProxyUrl(normalizedUrl, { width, quality, format: "webp", }); if (directOptimizedUrl !== normalizedUrl) { return directOptimizedUrl; } return buildImageProxyUrl(normalizedUrl, { width, quality, format: "webp", delivery: "proxy", }); } function buildPsdLayerPreviewUrl( imageUrl: string, params?: { width?: number; quality?: number; } ) { return buildTemplatePreviewThumbUrl(imageUrl, { width: Math.max(96, Math.min(1280, Math.round(params?.width || 720))), quality: Math.max(52, Math.min(82, Math.round(params?.quality || 68))), }); } function buildTemplatePreviewFallbackUrls( template: Pick, params?: { width?: number; quality?: number; preferRenderBase?: boolean; } ) { const sourceUrls = ( params?.preferRenderBase ? [template.renderBaseUrl, template.previewUrl] : [template.previewUrl, template.renderBaseUrl] ) .map((url) => String(url || "").trim()) .filter(Boolean); const candidates = [ ...sourceUrls.map((url) => buildTemplatePreviewThumbUrl(url, { width: params?.width, quality: params?.quality, }) ), ...sourceUrls, ]; const seen = new Set(); return candidates.filter((url) => { if (!url || seen.has(url)) { return false; } seen.add(url); return true; }); } function TemplatePreviewImage({ template, alt, className, width, quality, preferRenderBase, decoding = "async", fetchPriority, loading = "lazy", }: { template: Pick; alt?: string; className?: string; width?: number; quality?: number; preferRenderBase?: boolean; decoding?: "async" | "auto" | "sync"; fetchPriority?: "auto" | "high" | "low"; loading?: "eager" | "lazy"; }) { const fallbackUrls = useMemo( () => buildTemplatePreviewFallbackUrls(template, { width, quality, preferRenderBase, }), [ preferRenderBase, quality, template.previewUrl, template.renderBaseUrl, width, ] ); const fallbackUrlKey = fallbackUrls.join("\n"); const [activeUrlIndex, setActiveUrlIndex] = useState(0); useEffect(() => { setActiveUrlIndex(0); }, [fallbackUrlKey]); const src = fallbackUrls[activeUrlIndex] || ""; if (!src) { return (
); } const intrinsicSize = Math.max(1, Math.round(width || 1)); return ( // biome-ignore lint/a11y/noNoninteractiveElementInteractions: the image swaps to the next remote fallback when a thumbnail URL fails. // biome-ignore lint/performance/noImgElement: these dynamic PSD previews already use the app image proxy and need browser-level error fallback. {alt setActiveUrlIndex((current) => current + 1 < fallbackUrls.length ? current + 1 : current ) } src={src} width={intrinsicSize} /> ); } function getScrollAreaViewportElement(root: HTMLDivElement | null) { return ( (root?.querySelector( "[data-radix-scroll-area-viewport]" ) as HTMLDivElement | null) || root ); } function isLikelyStableRenderImageUrl(url: string) { const normalized = String(url || "").trim(); if (!normalized) { return true; } if ( normalized.startsWith("data:") || normalized.startsWith("blob:") || normalized.startsWith("/api/image-proxy") ) { return true; } if (typeof window === "undefined") { return false; } try { const parsed = new URL(normalized, window.location.origin); if (parsed.origin === window.location.origin) { return true; } const hostname = parsed.hostname.toLowerCase(); return ( hostname.includes("aliyuncs.com") || hostname.includes("oss-cn-") || hostname.includes("cloudfront.net") || hostname.includes("r2.dev") ); } catch { return false; } } function inferImageExtension(url: string, contentType: string) { const normalizedContentType = String(contentType || "").toLowerCase(); if (normalizedContentType.includes("png")) { return "png"; } if (normalizedContentType.includes("webp")) { return "webp"; } if ( normalizedContentType.includes("jpeg") || normalizedContentType.includes("jpg") ) { return "jpg"; } if (normalizedContentType.includes("gif")) { return "gif"; } if (normalizedContentType.includes("avif")) { return "avif"; } try { const pathname = new URL(url, "https://placeholder.invalid").pathname; const matched = pathname.match(IMAGE_EXTENSION_REGEX); if (matched?.[1]) { return matched[1].toLowerCase(); } } catch { // Ignore malformed URLs and fall back to png. } return "png"; } function normalizeManagedBrandName(value?: string | null) { const normalized = String(value || "").trim(); if (!normalized || normalized === "未分配品牌") { return ""; } return normalized; } function matchesTemplateBrand(template: AgentPsdTemplate, brandName?: string | null) { const normalizedBrandName = normalizeManagedBrandName(brandName).toLowerCase(); if (!normalizedBrandName) { return true; } return normalizeManagedBrandName(template.brandName).toLowerCase() === normalizedBrandName; } 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 getTemplateCategoryTagLabels(template: AgentPsdTemplate) { return normalizeAgentPsdTemplateCategoryTags(template.categoryTags) .map((tag) => tag === AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG ? "适合所有" : tag ) .filter(Boolean); } function renderTemplateCategoryTagRow( template: AgentPsdTemplate, params?: { compact?: boolean; } ) { const compact = params?.compact === true; const labels = getTemplateCategoryTagLabels(template); if (labels.length === 0) { return null; } const visibleLabels = compact ? labels.slice(0, 2) : labels.slice(0, 3); const hiddenCount = Math.max(0, labels.length - visibleLabels.length); return (
{visibleLabels.map((label) => ( {label} ))} {hiddenCount > 0 ? ( +{hiddenCount} ) : null}
); } 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}
{metaLabel ? (
{metaLabel}
) : null} {renderTemplateCategoryTagRow(template, { compact })}
); } function renderTemplatePickerCardContent( template: AgentPsdTemplate, params?: { showSecondaryCategory?: boolean; showBrand?: boolean; emphasize?: boolean; eager?: boolean; } ) { const showSecondaryCategory = params?.showSecondaryCategory === true; const showBrand = params?.showBrand === true; const emphasize = params?.emphasize === true; const eager = params?.eager === true; const metaLabel = getTemplateMetaLabel( template, showSecondaryCategory, showBrand ); return (
{template.name}
{metaLabel ? (
{metaLabel}
) : null} {renderTemplateCategoryTagRow(template)}
); } 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 layerOrderIds: string[] = []; const layerPositionOffsets: Record = {}; const textAssignments: Record = {}; const textPositionOffsets: Record = {}; if (!template) { return { imageAssignments, imagePlacements, layerOrderIds, layerPositionOffsets, textAssignments, textPositionOffsets, }; } for (const layer of template.layers) { layerOrderIds.push(layer.id); 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, layerOrderIds, layerPositionOffsets, textAssignments, textPositionOffsets, }; } function normalizeLayerTransferKey(value: string) { return String(value || "") .trim() .toLowerCase(); } function getLayerTransferKeys(layer: AgentPsdTemplateLayerSlot) { const keys = new Set(); const addKey = (value?: string | null) => { const normalized = normalizeLayerTransferKey(String(value || "")); if (normalized) { keys.add(normalized); } }; addKey(layer.id); addKey(layer.layerId); addKey(layer.layerPath); addKey(layer.layerName); if (isAgentPsdImageLayerSlot(layer)) { addKey(`image:${layer.layerPath}`); addKey(`image:${layer.layerName}`); addKey(`image:${layer.layerPath}:${layer.width}x${layer.height}`); addKey(`image:${layer.layerName}:${layer.width}x${layer.height}`); if (layer.isDefaultResultSource) { addKey("__default_result_source__"); } } if (isAgentPsdTextLayerSlot(layer)) { addKey(`text:${layer.layerPath}`); addKey(`text:${layer.layerName}`); addKey(`text:${layer.layerPath}:${layer.width}x${layer.height}`); addKey(`text:${layer.layerName}:${layer.width}x${layer.height}`); } return Array.from(keys); } function findMatchingLayer(params: { nextLayer: TLayer; previousLayers: TLayer[]; usedPreviousLayerIds?: Set; }) { const { nextLayer, previousLayers, usedPreviousLayerIds } = params; const nextLayerKeySet = new Set(getLayerTransferKeys(nextLayer)); for (const previousLayer of previousLayers) { if (usedPreviousLayerIds?.has(previousLayer.id)) { continue; } const previousKeys = getLayerTransferKeys(previousLayer); if (previousKeys.some((key) => nextLayerKeySet.has(key))) { return previousLayer; } } return null; } function resolveTransferredSelectedLayerId(params: { nextTemplate: AgentPsdTemplate | null; previousTemplate: AgentPsdTemplate | null; previousSelectedLayerId: string; }) { const { nextTemplate, previousTemplate, previousSelectedLayerId } = params; if (!nextTemplate) { return ""; } if (!previousTemplate || !previousSelectedLayerId) { return getPreferredSelectedLayerId(nextTemplate); } const previousLayer = previousTemplate.layers.find( (layer) => layer.id === previousSelectedLayerId ) || null; if (!previousLayer) { return getPreferredSelectedLayerId(nextTemplate); } const nextCandidateLayers = nextTemplate.layers.filter( (layer) => layer.isEditable && ((isAgentPsdImageLayerSlot(previousLayer) && isAgentPsdImageLayerSlot(layer)) || (isAgentPsdTextLayerSlot(previousLayer) && isAgentPsdTextLayerSlot(layer))) ); const previousLayerKeySet = new Set(getLayerTransferKeys(previousLayer)); const matchedLayer = nextCandidateLayers.find((layer) => getLayerTransferKeys(layer).some((key) => previousLayerKeySet.has(key)) ); return matchedLayer?.id || getPreferredSelectedLayerId(nextTemplate); } function createTemplateEditorState(params: { nextTemplate: AgentPsdTemplate | null; previousTemplate: AgentPsdTemplate | null; sourceImageUrl: string; previousImageAssignments: Record; previousImagePlacements: Record; previousLayerPositionOffsets: Record; previousTextAssignments: Record; previousTextColorAssignments: Record; previousTextPositionOffsets: Record; }) { const { nextTemplate, previousTemplate, sourceImageUrl, previousImageAssignments, previousImagePlacements, previousLayerPositionOffsets, previousTextAssignments, previousTextColorAssignments, previousTextPositionOffsets, } = params; const initialState = createInitialState(nextTemplate, sourceImageUrl); const nextState = { imageAssignments: { ...initialState.imageAssignments }, imagePlacements: { ...initialState.imagePlacements }, layerOrderIds: [...initialState.layerOrderIds], layerPositionOffsets: { ...initialState.layerPositionOffsets }, textAssignments: { ...initialState.textAssignments }, textColorAssignments: {} as Record, textPositionOffsets: { ...initialState.textPositionOffsets }, }; if (!nextTemplate || !previousTemplate) { return nextState; } const usedPreviousLayerIds = new Set(); for (const nextLayer of nextTemplate.layers) { const matchedPreviousLayer = findMatchingLayer({ nextLayer, previousLayers: previousTemplate.layers, usedPreviousLayerIds, }); if (!matchedPreviousLayer) { continue; } const previousLayerPositionOffset = previousLayerPositionOffsets[matchedPreviousLayer.id]; if (hasLayerPositionOffset(previousLayerPositionOffset)) { nextState.layerPositionOffsets[nextLayer.id] = normalizeLayerPositionOffset(previousLayerPositionOffset); } usedPreviousLayerIds.add(matchedPreviousLayer.id); } const previousEditableImageLayers = previousTemplate.layers.filter( (layer): layer is AgentPsdTemplateImageLayerSlot => layer.isEditable && isAgentPsdImageLayerSlot(layer) ); const nextEditableImageLayers = nextTemplate.layers.filter( (layer): layer is AgentPsdTemplateImageLayerSlot => layer.isEditable && isAgentPsdImageLayerSlot(layer) ); const usedPreviousImageLayerIds = new Set(); const usedNextImageLayerIds = new Set(); for (const nextLayer of nextEditableImageLayers) { const matchedPreviousLayer = findMatchingLayer({ nextLayer, previousLayers: previousEditableImageLayers, usedPreviousLayerIds: usedPreviousImageLayerIds, }); if (!matchedPreviousLayer) { continue; } const previousImageUrl = String( previousImageAssignments[matchedPreviousLayer.id] || "" ).trim(); if (!previousImageUrl) { continue; } nextState.imageAssignments[nextLayer.id] = previousImageUrl; nextState.imagePlacements[nextLayer.id] = normalizeAgentPsdImagePlacement( previousImagePlacements[matchedPreviousLayer.id] ); usedPreviousImageLayerIds.add(matchedPreviousLayer.id); usedNextImageLayerIds.add(nextLayer.id); } const unmatchedPreviousImageLayers = previousEditableImageLayers.filter( (layer) => !usedPreviousImageLayerIds.has(layer.id) && Boolean(String(previousImageAssignments[layer.id] || "").trim()) ); if (unmatchedPreviousImageLayers.length === 1) { const fallbackNextLayer = nextEditableImageLayers.find( (layer) => !usedNextImageLayerIds.has(layer.id) && layer.isDefaultResultSource ) || nextEditableImageLayers.find( (layer) => !usedNextImageLayerIds.has(layer.id) ); if (fallbackNextLayer) { const fallbackPreviousLayer = unmatchedPreviousImageLayers[0]; nextState.imageAssignments[fallbackNextLayer.id] = String( previousImageAssignments[fallbackPreviousLayer.id] || "" ).trim(); nextState.imagePlacements[fallbackNextLayer.id] = normalizeAgentPsdImagePlacement( previousImagePlacements[fallbackPreviousLayer.id] ); } } const previousEditableTextLayers = previousTemplate.layers.filter( (layer): layer is AgentPsdTemplateTextLayerSlot => layer.isEditable && isAgentPsdTextLayerSlot(layer) ); const nextEditableTextLayers = nextTemplate.layers.filter( (layer): layer is AgentPsdTemplateTextLayerSlot => layer.isEditable && isAgentPsdTextLayerSlot(layer) ); const usedPreviousTextLayerIds = new Set(); for (const nextLayer of nextEditableTextLayers) { const matchedPreviousLayer = findMatchingLayer({ nextLayer, previousLayers: previousEditableTextLayers, usedPreviousLayerIds: usedPreviousTextLayerIds, }); if (!matchedPreviousLayer) { continue; } const previousTextValue = previousTextAssignments[matchedPreviousLayer.id]; if (typeof previousTextValue === "string") { nextState.textAssignments[nextLayer.id] = previousTextValue; } const previousTextColor = String( previousTextColorAssignments[matchedPreviousLayer.id] || "" ).trim(); if (previousTextColor) { nextState.textColorAssignments[nextLayer.id] = previousTextColor; } const previousTextPositionOffset = previousTextPositionOffsets[matchedPreviousLayer.id]; if (hasTextPositionOffset(previousTextPositionOffset)) { nextState.textPositionOffsets[nextLayer.id] = normalizeTextPositionOffset( previousTextPositionOffset ); } usedPreviousTextLayerIds.add(matchedPreviousLayer.id); } return nextState; } 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, positionOffset?: Partial | null ): CSSProperties { const normalizedOffset = normalizeLayerPositionOffset(positionOffset); return { left: layer.x + normalizedOffset.offsetX, top: layer.y + normalizedOffset.offsetY, 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 typeof value === "string" ? value : layer.placeholder || layer.layerName; } function getResolvedTextLayerColor( layer: AgentPsdTemplateTextLayerSlot, value?: string ) { const fallbackColor = normalizeHexColor(layer.textColor, "#111111"); return normalizeHexColor(value || fallbackColor, fallbackColor); } 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 textAlign = resolveAgentPsdTextLayerEffectiveAlign(layer); const offsetX = textAlign === "right" ? layer.width - width : textAlign === "center" ? (layer.width - width) / 2 : 0; return { ...resolvedLayout, width, height, offsetX, offsetY: -resolvedLayout.visualOffsetY, }; } function renderTextLayerPreviewContent( layer: AgentPsdTemplateTextLayerSlot, value: string, color: string ) { const resolvedText = getResolvedTextPreview(layer, value); const resolvedLayout = getTextLayerRenderFrame(layer, resolvedText); const textAlign = resolveAgentPsdTextLayerEffectiveAlign(layer); const textAnchor = textAlign === "center" ? "middle" : textAlign === "right" ? "end" : "start"; const textX = textAlign === "center" ? resolvedLayout.width / 2 : textAlign === "right" ? resolvedLayout.width - layer.paddingX : layer.paddingX; return ( {resolvedLayout.lines.map((line, index) => ( {line || "\u00a0"} ))}
 
); } 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, positionOffset?: Partial | null ) { return ( hasEditedTextValue(layer, value) || getResolvedTextLayerColor(layer, color) !== getResolvedTextLayerColor(layer) || hasTextPositionOffset(positionOffset) ); } function getTextLayerNameBadgeClassName() { return "pointer-events-none absolute left-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-black/70 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg backdrop-blur-md"; } 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 backdrop-blur-md"; } export function AgentPsdTemplateEditorDialog({ open, onOpenChange, sourceImageUrl, brandName, selectedCategoryNames, sizeInfoText, templateMode, onDerivedImageCreated, onRenderedResultCreated, standalone = false, onBack, pendingImages, activePendingImageId, onActivePendingImageIdChange, pendingRenderedResultUrls, }: { open: boolean; onOpenChange: (open: boolean) => void; sourceImageUrl: string | null; brandName?: string | null; selectedCategoryNames?: string[] | null; sizeInfoText?: string | null; templateMode?: AgentPsdTemplateMode | null; onDerivedImageCreated?: (imageUrl: string) => void; onRenderedResultCreated?: (imageUrl: string) => void; standalone?: boolean; onBack?: () => void; pendingImages?: CanvasPsdTemplatePendingImage[] | null; activePendingImageId?: string | null; onActivePendingImageIdChange?: (imageId: string) => void; pendingRenderedResultUrls?: Record | 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 normalizedPendingImages = useMemo( () => Array.isArray(pendingImages) ? pendingImages.filter( (item) => typeof item?.id === "string" && item.id.trim().length > 0 && typeof item?.imageUrl === "string" && item.imageUrl.trim().length > 0 ) : [], [pendingImages] ); const templateListPrefetchUrl = useMemo( () => buildTemplateListRequestUrl({ brandName: normalizedBrandName, summaryOnly: true, }), [normalizedBrandName] ); const templateListRequestUrl = useMemo(() => { if (!open) { return null; } return templateListPrefetchUrl; }, [open, templateListPrefetchUrl]); const { data, isLoading } = useSWR( templateListRequestUrl, fetcher, { dedupingInterval: 30_000, keepPreviousData: true, revalidateOnFocus: false, } ); 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) => matchesTemplateBrand(template, normalizedBrandName)) .filter((template) => matchesTemplateSelectedCategories( template, normalizedSelectedCategoryNames, activeCategoryTags ) ), [ activeCategoryTags, allTemplates, normalizedBrandName, normalizedSelectedCategoryNames, ] ); const [selectedTemplateId, setSelectedTemplateId] = useState(""); const selectedTemplateDetailRequestUrl = useMemo(() => { if (!open || !selectedTemplateId) { return null; } return `/api/psd-templates/${encodeURIComponent(selectedTemplateId)}`; }, [open, selectedTemplateId]); const { data: selectedTemplateDetailData, isLoading: isSelectedTemplateDetailLoading, } = useSWR( selectedTemplateDetailRequestUrl, templateDetailFetcher, { dedupingInterval: 60_000, keepPreviousData: true, revalidateOnFocus: false, } ); const [selectedLayerId, setSelectedLayerId] = useState(""); const [imageAssignments, setImageAssignments] = useState< Record >({}); const [imagePlacements, setImagePlacements] = useState< Record >({}); const [imageSizes, setImageSizes] = useState< Record >({}); const [layerOrderIds, setLayerOrderIds] = useState([]); const [layerPositionOffsets, setLayerPositionOffsets] = useState< Record >({}); const [textAssignments, setTextAssignments] = useState< Record >({}); const [textColorAssignments, setTextColorAssignments] = useState< Record >({}); const [textPositionOffsets, setTextPositionOffsets] = useState< Record >({}); const [hiddenLayerIds, setHiddenLayerIds] = useState([]); const [uploadingSlotId, setUploadingSlotId] = useState(null); const [extendingSlotId, setExtendingSlotId] = useState(null); const [isRendering, setIsRendering] = useState(false); const [renderedResultUrl, setRenderedResultUrl] = useState(""); const [rightPanelView, setRightPanelView] = useState<"editor" | "export">( "editor" ); const [stageScale, setStageScale] = useState(1); const [canScrollHiddenLayersUp, setCanScrollHiddenLayersUp] = useState(false); const [canScrollHiddenLayersDown, setCanScrollHiddenLayersDown] = useState(false); const previewViewportRef = useRef(null); const hiddenLayerScrollAreaRef = useRef(null); const uploadInputRef = useRef(null); const previousSelectedTemplateRef = useRef(null); const layerDragStateRef = useRef<{ layerId: string; pointerId: number; startClientX: number; startClientY: number; startOffset: AgentPsdLayerPositionOffset; } | null>(null); const textDragStateRef = useRef<{ layerId: string; pointerId: number; startClientX: number; startClientY: number; startOffset: AgentPsdTextPositionOffset; } | null>(null); const imageAssignmentsRef = useRef(imageAssignments); const imagePlacementsRef = useRef(imagePlacements); const layerPositionOffsetsRef = useRef(layerPositionOffsets); const textAssignmentsRef = useRef(textAssignments); const textColorAssignmentsRef = useRef(textColorAssignments); const textPositionOffsetsRef = useRef(textPositionOffsets); const archivedRenderImageUrlCacheRef = useRef>({}); const selectedLayerIdRef = useRef(selectedLayerId); imageAssignmentsRef.current = imageAssignments; imagePlacementsRef.current = imagePlacements; layerPositionOffsetsRef.current = layerPositionOffsets; textAssignmentsRef.current = textAssignments; textColorAssignmentsRef.current = textColorAssignments; textPositionOffsetsRef.current = textPositionOffsets; selectedLayerIdRef.current = selectedLayerId; useEffect(() => { if (!open) { archivedRenderImageUrlCacheRef.current = {}; } }, [open]); useEffect(() => { if (!open || typeof document === "undefined") { return; } const html = document.documentElement; const body = document.body; const previousHtmlOverflow = html.style.overflow; const previousBodyOverflow = body.style.overflow; const previousHtmlOverscrollBehavior = html.style.overscrollBehavior; const previousBodyOverscrollBehavior = body.style.overscrollBehavior; const previousHtmlBackgroundColor = html.style.backgroundColor; const previousBodyBackgroundColor = body.style.backgroundColor; const previousBodyPaddingRight = body.style.paddingRight; const scrollbarWidth = Math.max(0, window.innerWidth - html.clientWidth); const currentBodyPaddingRight = Number.parseFloat( window.getComputedStyle(body).paddingRight || "0" ); html.style.overflow = "hidden"; body.style.overflow = "hidden"; html.style.overscrollBehavior = "none"; body.style.overscrollBehavior = "none"; html.style.backgroundColor = "#050505"; body.style.backgroundColor = "#050505"; if (scrollbarWidth > 0) { body.style.paddingRight = `${currentBodyPaddingRight + scrollbarWidth}px`; } return () => { html.style.overflow = previousHtmlOverflow; body.style.overflow = previousBodyOverflow; html.style.overscrollBehavior = previousHtmlOverscrollBehavior; body.style.overscrollBehavior = previousBodyOverscrollBehavior; html.style.backgroundColor = previousHtmlBackgroundColor; body.style.backgroundColor = previousBodyBackgroundColor; body.style.paddingRight = previousBodyPaddingRight; }; }, [open]); const selectedTemplateSummary = useMemo( () => templates.find((template) => template.id === selectedTemplateId) || null, [selectedTemplateId, templates] ); const selectedTemplateDetail = useMemo(() => { if ( !selectedTemplateDetailData?.success || !selectedTemplateDetailData.template ) { return null; } return selectedTemplateDetailData.template.id === selectedTemplateId ? selectedTemplateDetailData.template : null; }, [selectedTemplateDetailData, selectedTemplateId]); const selectedTemplate = useMemo( () => selectedTemplateDetail || selectedTemplateSummary, [selectedTemplateDetail, selectedTemplateSummary] ); const selectedTemplateDetailError = selectedTemplateDetailData?.success === false ? String(selectedTemplateDetailData.error || "").trim() : ""; const isSelectedTemplateLoading = Boolean(selectedTemplateSummary) && (selectedTemplateSummary?.layers.length || 0) === 0 && isSelectedTemplateDetailLoading; 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 preferredInitialTemplate = useMemo(() => { if (normalizedTemplateMode === "sku" && skuModeTemplates.length > 0) { return skuModeTemplates[0] || null; } if (normalizedTemplateMode === "main" && mainModeTemplates.length > 0) { return mainModeTemplates[0] || null; } if (mainModeTemplates.length > 0) { return mainModeTemplates[0] || null; } if (skuModeTemplates.length > 0) { return skuModeTemplates[0] || null; } return templates[0] || null; }, [mainModeTemplates, normalizedTemplateMode, skuModeTemplates, templates]); 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 orderedTemplateLayers = useMemo(() => { if (selectedTemplateLayers.length === 0) { return [] as AgentPsdTemplateLayerSlot[]; } const layerMap = new Map( selectedTemplateLayers.map((layer) => [layer.id, layer]) ); const normalizedOrder = [ ...layerOrderIds .map((layerId) => layerMap.get(layerId) || null) .filter((layer): layer is AgentPsdTemplateLayerSlot => Boolean(layer)), ...selectedTemplateLayers.filter((layer) => !layerOrderIds.includes(layer.id)), ]; return normalizedOrder; }, [layerOrderIds, selectedTemplateLayers]); const orderedPreviewLayers = useMemo( () => [...orderedTemplateLayers].reverse(), [orderedTemplateLayers] ); const hiddenLayerIdSet = useMemo( () => new Set( hiddenLayerIds .map((layerId) => String(layerId || "").trim()) .filter(Boolean) ), [hiddenLayerIds] ); useEffect(() => { const viewport = getScrollAreaViewportElement( hiddenLayerScrollAreaRef.current ); if (!viewport) { setCanScrollHiddenLayersUp(false); setCanScrollHiddenLayersDown(false); return; } const updateScrollButtons = () => { const nextCanScrollUp = viewport.scrollTop > 4; const nextCanScrollDown = viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight - 4; setCanScrollHiddenLayersUp(nextCanScrollUp); setCanScrollHiddenLayersDown(nextCanScrollDown); }; updateScrollButtons(); viewport.addEventListener("scroll", updateScrollButtons, { passive: true }); const animationFrameId = window.requestAnimationFrame(updateScrollButtons); return () => { window.cancelAnimationFrame(animationFrameId); viewport.removeEventListener("scroll", updateScrollButtons); }; }, [ hiddenLayerIds.length, open, orderedPreviewLayers.length, selectedTemplate?.id, ]); const orderedVisiblePreviewLayers = useMemo( () => orderedPreviewLayers.filter((layer) => !hiddenLayerIdSet.has(layer.id)), [hiddenLayerIdSet, orderedPreviewLayers] ); 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 layerStackZIndexById = useMemo(() => { return new Map( orderedPreviewLayers.map((layer, index) => [layer.id, index + 1]) ); }, [orderedPreviewLayers]); const shouldShowCompositeFallback = useMemo( () => orderedVisiblePreviewLayers.length === 0 || orderedVisiblePreviewLayers.some( (layer) => !String(layer.sourceUrl || "").trim() ), [orderedVisiblePreviewLayers] ); 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 effectiveTextPositionOffsets = useMemo(() => { const nextOffsets: Record = {}; if (!selectedTemplate) { return nextOffsets; } for (const layer of editableTextLayers) { nextOffsets[layer.id] = normalizeTextPositionOffset( textPositionOffsets[layer.id] ); } return nextOffsets; }, [editableTextLayers, selectedTemplate, textPositionOffsets]); const effectiveLayerPositionOffsets = useMemo(() => { const nextOffsets: Record = {}; if (!selectedTemplate) { return nextOffsets; } for (const layer of selectedTemplateLayers) { nextOffsets[layer.id] = normalizeLayerPositionOffset( layerPositionOffsets[layer.id] ); } return nextOffsets; }, [layerPositionOffsets, selectedTemplate, selectedTemplateLayers]); 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 isSelectedLayerHidden = Boolean( selectedLayer && hiddenLayerIdSet.has(selectedLayer.id) ); 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 selectedTextPositionOffset = selectedTextLayer ? effectiveTextPositionOffsets[selectedTextLayer.id] || normalizeTextPositionOffset() : normalizeTextPositionOffset(); const isSelectedImageExtending = Boolean( selectedImageLayer && extendingSlotId === selectedImageLayer.id ); function hideLayerTemporarily(layerId: string) { const normalizedLayerId = String(layerId || "").trim(); if (!normalizedLayerId) { return; } setHiddenLayerIds((current) => current.includes(normalizedLayerId) ? current : [...current, normalizedLayerId] ); } function restoreHiddenLayer(layerId: string) { const normalizedLayerId = String(layerId || "").trim(); if (!normalizedLayerId) { return; } setHiddenLayerIds((current) => current.filter((currentLayerId) => currentLayerId !== normalizedLayerId) ); } const selectedLayerPositionPanel = selectedLayer ? (
图层层级
选中图层后可调整与其他图层的上下关系。
) : null; 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-500 to-emerald-400" step={0.01} thumbClassName="h-4 w-4 border-2 border-[#1a1a1a] bg-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.3)] hover:scale-110 transition-transform" trackClassName="h-1.5 rounded-full bg-white/10" value={[selectedImagePlacement.scale]} />
40% 100% 300%
槽位内图片位置
图层整体拖动已经改到中间预览区;这里保留槽位内图片的细调。
{ const nextValue = Number(event.target.value); if (!Number.isFinite(nextValue)) return; updateImagePlacement(selectedImageLayer.id, (current) => ({ ...current, offsetX: nextValue, })); }} type="number" value={Math.round(selectedImagePlacement.offsetX)} />
{ const nextValue = Number(event.target.value); if (!Number.isFinite(nextValue)) return; updateImagePlacement(selectedImageLayer.id, (current) => ({ ...current, offsetY: nextValue, })); }} type="number" value={Math.round(selectedImagePlacement.offsetY)} />
) : null}
) : null; const textLayerQuickColorPanel = editableTextLayers.length > 0 ? (
文字颜色快捷编辑

这里会显示当前模板里所有可编辑文字,直接改色;点击文字名称可进入位置微调。

{editableTextLayers.length} 个文字
{editableTextLayers.map((layer) => { const currentColor = getResolvedTextLayerColor( layer, effectiveTextColorAssignments[layer.id] || layer.textColor ); const previewText = getResolvedTextPreview( layer, effectiveTextAssignments[layer.id] ); const isActive = selectedLayerId === layer.id; return (
updateTextColorAssignment(layer.id, event.target.value) } type="color" value={currentColor} />
); })}
) : null; const fontFaceCss = useMemo( () => buildPreviewFontFaces(selectedTemplate), [selectedTemplate] ); const selectedTemplateSelectValue = selectedTemplate?.id; const hasTemplateOptions = templates.length > 0; useEffect(() => { if (typeof window === "undefined" || !templateListPrefetchUrl) { return; } const preloadTemplates = () => { void preload(templateListPrefetchUrl, fetcher); }; if (typeof window.requestIdleCallback === "function") { const idleId = window.requestIdleCallback(preloadTemplates, { timeout: 1200, }); return () => window.cancelIdleCallback?.(idleId); } const timeoutId = window.setTimeout(preloadTemplates, 300); return () => window.clearTimeout(timeoutId); }, [templateListPrefetchUrl]); useEffect(() => { if (!preferredInitialTemplate) { return; } const detailUrl = `/api/psd-templates/${encodeURIComponent(preferredInitialTemplate.id)}`; void preload(detailUrl, templateDetailFetcher); }, [preferredInitialTemplate]); useEffect(() => { if (open) { return; } setSelectedTemplateId(""); setSelectedLayerId(""); setImageAssignments({}); setImagePlacements({}); setImageSizes({}); setTextAssignments({}); setTextColorAssignments({}); setTextPositionOffsets({}); setHiddenLayerIds([]); setRenderedResultUrl(""); setRightPanelView("editor"); previousSelectedTemplateRef.current = null; }, [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 = createTemplateEditorState({ nextTemplate: selectedTemplate, previousTemplate: previousSelectedTemplateRef.current, sourceImageUrl: sourceImageUrl || "", previousImageAssignments: imageAssignmentsRef.current, previousImagePlacements: imagePlacementsRef.current, previousLayerPositionOffsets: layerPositionOffsetsRef.current, previousTextAssignments: textAssignmentsRef.current, previousTextColorAssignments: textColorAssignmentsRef.current, previousTextPositionOffsets: textPositionOffsetsRef.current, }); setImageAssignments(initialState.imageAssignments); setImagePlacements(initialState.imagePlacements); setLayerOrderIds(initialState.layerOrderIds); setLayerPositionOffsets(initialState.layerPositionOffsets); setTextAssignments(initialState.textAssignments); setTextColorAssignments(initialState.textColorAssignments); setTextPositionOffsets(initialState.textPositionOffsets); setHiddenLayerIds([]); setSelectedLayerId( resolveTransferredSelectedLayerId({ nextTemplate: selectedTemplate, previousTemplate: previousSelectedTemplateRef.current, previousSelectedLayerId: selectedLayerIdRef.current, }) ); 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 (hiddenLayerIds.length === 0) { return; } const validLayerIds = new Set( selectedTemplateLayers.map((layer) => layer.id) ); setHiddenLayerIds((current) => current.filter((layerId) => validLayerIds.has(layerId)) ); }, [hiddenLayerIds.length, selectedTemplateLayers]); useEffect(() => { previousSelectedTemplateRef.current = selectedTemplate; }, [selectedTemplate]); 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]); 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 parseActionJsonResponse<{ url?: string }>( response, "素材上传失败", getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试") ); if (!payload?.url) { throw new Error("图片上传失败"); } const uploadedImageUrl = payload.url; setImageAssignments((current) => ({ ...current, [slotId]: uploadedImageUrl, })); setImagePlacements((current) => ({ ...current, [slotId]: getDefaultAgentPsdImagePlacement(), })); setImageSizes((current) => { const next = { ...current }; delete next[slotId]; return next; }); toast.success("替换图片已上传"); } catch (error) { toast.error( getUserFacingRequestErrorMessage(error, "素材上传失败", { rateLimitMessage: getFriendlyRateLimitMessage( "当前上传过于频繁,请稍后再试" ), }) ); } finally { setUploadingSlotId(null); event.target.value = ""; } } async function handleImageExtend(slotId: string, imageUrl: string) { const normalizedImageUrl = String(imageUrl || "").trim(); if (!normalizedImageUrl) { toast.error("请先选择或上传一张替换图片"); return; } try { setExtendingSlotId(slotId); const response = await fetch("/api/extend", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ image_url: normalizedImageUrl, prompt: "PSD 套版图片拓展处理", includeBase64: false, }), }); const payload = await parseActionJsonResponse<{ original_url?: string; thumbnail_url?: string; }>( response, "图片拓展失败", getFriendlyRateLimitMessage("当前图片拓展请求过于频繁,请稍后再试") ); const nextImageUrl = String( payload?.original_url || payload?.thumbnail_url || "" ).trim(); if (!nextImageUrl) { throw new Error("图片拓展成功,但未返回可用图片地址"); } setImageAssignments((current) => ({ ...current, [slotId]: nextImageUrl, })); setImagePlacements((current) => ({ ...current, [slotId]: getDefaultAgentPsdImagePlacement(), })); setImageSizes((current) => { const next = { ...current }; delete next[slotId]; return next; }); onDerivedImageCreated?.(nextImageUrl); toast.success( onDerivedImageCreated ? "图片拓展完成,已替换当前槽位并同步保存到结果区" : "图片拓展完成,已替换当前槽位" ); } catch (error) { toast.error( getUserFacingRequestErrorMessage(error, "图片拓展失败", { rateLimitMessage: getFriendlyRateLimitMessage( "当前图片拓展请求过于频繁,请稍后再试" ), }) ); } finally { setExtendingSlotId(null); } } 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 nudgeImagePlacementOffset( layerId: string, deltaX: number, deltaY: number ) { updateImagePlacement(layerId, (current) => ({ ...current, offsetX: Number(((current.offsetX || 0) + deltaX).toFixed(2)), offsetY: Number(((current.offsetY || 0) + deltaY).toFixed(2)), })); } function resetImagePlacement(layerId: string) { setImagePlacements((current) => ({ ...current, [layerId]: getDefaultAgentPsdImagePlacement(), })); } function updateLayerPositionOffset( layerId: string, updater: ( current: AgentPsdLayerPositionOffset ) => Partial ) { setLayerPositionOffsets((current) => ({ ...current, [layerId]: normalizeLayerPositionOffset({ ...normalizeLayerPositionOffset(current[layerId]), ...updater(normalizeLayerPositionOffset(current[layerId])), }), })); } function moveLayerOrder( layerId: string, direction: "forward" | "backward" | "front" | "back" ) { const normalizedLayerId = String(layerId || "").trim(); if (!normalizedLayerId) { return; } setLayerOrderIds((current) => { const fallbackOrder = selectedTemplateLayers.map((layer) => layer.id); const baseOrder = current.length > 0 ? [...current] : fallbackOrder; const currentIndex = baseOrder.indexOf(normalizedLayerId); if (currentIndex < 0) { return current; } const nextOrder = [...baseOrder]; const [removed] = nextOrder.splice(currentIndex, 1); if (!removed) { return current; } if (direction === "front") { nextOrder.push(removed); } else if (direction === "back") { nextOrder.unshift(removed); } else if (direction === "forward") { nextOrder.splice(Math.min(baseOrder.length - 1, currentIndex + 1), 0, removed); } else { nextOrder.splice(Math.max(0, currentIndex - 1), 0, removed); } return nextOrder; }); } function updateTextColorAssignment(layerId: string, color: string) { setTextColorAssignments((current) => ({ ...current, [layerId]: normalizeHexColor(color, current[layerId] || "#111111"), })); } function updateTextPositionOffset( layerId: string, updater: ( current: AgentPsdTextPositionOffset ) => Partial ) { setTextPositionOffsets((current) => ({ ...current, [layerId]: normalizeTextPositionOffset({ ...normalizeTextPositionOffset(current[layerId]), ...updater(normalizeTextPositionOffset(current[layerId])), }), })); } function nudgeTextPositionOffset( layerId: string, deltaX: number, deltaY: number ) { updateTextPositionOffset(layerId, (current) => ({ offsetX: current.offsetX + deltaX, offsetY: current.offsetY + deltaY, })); } function resetTextPositionOffset(layerId: string) { setTextPositionOffsets((current) => { const next = { ...current }; delete next[layerId]; return next; }); } function handleHiddenLayerListScroll(direction: "up" | "down") { const viewport = getScrollAreaViewportElement( hiddenLayerScrollAreaRef.current ); if (!viewport) { return; } viewport.scrollBy({ top: direction === "up" ? -HIDDEN_LAYER_SCROLL_STEP : HIDDEN_LAYER_SCROLL_STEP, behavior: "smooth", }); } async function archiveRenderImageUrl( rawUrl: string, label: string ): Promise { const normalizedUrl = String(rawUrl || "").trim(); if (!normalizedUrl || isLikelyStableRenderImageUrl(normalizedUrl)) { return normalizedUrl; } const cachedUrl = archivedRenderImageUrlCacheRef.current[normalizedUrl]; if (cachedUrl) { return cachedUrl; } const fetchCandidates = Array.from( new Set( [ normalizedUrl, buildImageProxyUrl(normalizedUrl, { delivery: "proxy" }), ].filter(Boolean) ) ); let lastError: unknown = null; for (const candidateUrl of fetchCandidates) { try { const response = await fetch(candidateUrl, { method: "GET", cache: "no-store", }); if (!response.ok) { throw new Error(`${label}读取失败(${response.status})`); } const blob = await response.blob(); if (!blob.size) { throw new Error(`${label}读取结果为空`); } const extension = inferImageExtension( normalizedUrl, blob.type || response.headers.get("content-type") || "" ); const formData = new FormData(); formData.set( "file", new File( [blob], `psd-render-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`, { type: blob.type || "image/png", } ) ); const uploadResponse = await fetchUploadWithRetry(formData); const uploadPayload = await parseActionJsonResponse<{ url?: string }>( uploadResponse, `${label}转存失败`, getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试") ); const uploadedUrl = String(uploadPayload?.url || "").trim(); if (!uploadedUrl) { throw new Error(`${label}转存失败`); } archivedRenderImageUrlCacheRef.current[normalizedUrl] = uploadedUrl; return uploadedUrl; } catch (error) { lastError = error; } } throw new Error( getUserFacingRequestErrorMessage( lastError, `${label}无法转存到本站存储,请重新上传图片后再试` ) ); } async function normalizeRenderImageInputs() { const nextSourceImageUrl = await archiveRenderImageUrl( sourceImageUrl || "", "PSD 套版主图" ); const nextImageAssignments = { ...effectiveImageAssignments }; const layerNameById = new Map( editableImageLayers.map((layer) => [layer.id, layer.layerName]) ); for (const [layerId, layerUrl] of Object.entries(nextImageAssignments)) { nextImageAssignments[layerId] = await archiveRenderImageUrl( layerUrl, `PSD 图层素材「${layerNameById.get(layerId) || layerId}」` ); } return { sourceImageUrl: nextSourceImageUrl, imageAssignments: nextImageAssignments, }; } function handleLayerDragStart( layer: AgentPsdTemplateLayerSlot, event: ReactPointerEvent ) { event.preventDefault(); event.stopPropagation(); setSelectedLayerId(layer.id); layerDragStateRef.current = { layerId: layer.id, pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, startOffset: normalizeLayerPositionOffset( effectiveLayerPositionOffsets[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 = layerDragStateRef.current; if (!dragState || dragState.layerId !== layer.id) { return; } updateLayerPositionOffset(layer.id, () => ({ offsetX: dragState.startOffset.offsetX + (moveEvent.clientX - dragState.startClientX) / stageScale, offsetY: dragState.startOffset.offsetY + (moveEvent.clientY - dragState.startClientY) / stageScale, })); }; const handlePointerUp = (upEvent: PointerEvent) => { if (layerDragStateRef.current?.pointerId === upEvent.pointerId) { layerDragStateRef.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); } function handleTextDragStart( layer: AgentPsdTemplateTextLayerSlot, event: ReactPointerEvent ) { event.preventDefault(); event.stopPropagation(); setSelectedLayerId(layer.id); textDragStateRef.current = { layerId: layer.id, pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, startOffset: normalizeTextPositionOffset( effectiveTextPositionOffsets[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 = textDragStateRef.current; if (!dragState || dragState.layerId !== layer.id) { return; } updateTextPositionOffset(layer.id, () => ({ offsetX: dragState.startOffset.offsetX + (moveEvent.clientX - dragState.startClientX) / stageScale, offsetY: dragState.startOffset.offsetY + (moveEvent.clientY - dragState.startClientY) / stageScale, })); }; const handlePointerUp = (upEvent: PointerEvent) => { if (textDragStateRef.current?.pointerId === upEvent.pointerId) { textDragStateRef.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; } if (selectedTemplate.layers.length === 0) { toast.error("模板详情仍在加载,请稍候再试"); return; } try { setIsRendering(true); setRenderedResultUrl(""); const normalizedRenderInputs = await normalizeRenderImageInputs(); if (normalizedRenderInputs.sourceImageUrl !== (sourceImageUrl || "")) { const defaultSourceLayer = editableImageLayers.find( (layer) => layer.isDefaultResultSource ); if (defaultSourceLayer) { setImageAssignments((current) => ({ ...current, [defaultSourceLayer.id]: normalizedRenderInputs.sourceImageUrl, })); } } setImageAssignments((current) => ({ ...current, ...normalizedRenderInputs.imageAssignments, })); const payload = (await requestLongTaskResult({ endpoint: "/api/psd-templates/render", timeoutMs: 8 * 60 * 1000, maxPollFailures: 8, payload: { templateId: selectedTemplate.id, sourceImageUrl: normalizedRenderInputs.sourceImageUrl, hiddenLayerIds, layerOrderIds, imageAssignments: normalizedRenderInputs.imageAssignments, imagePlacements: effectiveImagePlacements, textAssignments: effectiveTextAssignments, textColorAssignments: effectiveTextColorAssignments, layerPositionOffsets: effectiveLayerPositionOffsets, textPositionOffsets: effectiveTextPositionOffsets, }, })) as AgentPsdTemplateRenderResponse; if (!payload?.success || !payload.result?.url) { throw new Error(payload?.error || "PSD 模板渲染失败"); } setRenderedResultUrl(payload.result.url); setRightPanelView("export"); onRenderedResultCreated?.(payload.result.url); toast.success( onRenderedResultCreated ? "模板替换结果已生成,并已加入结果区" : "模板替换结果已生成" ); } catch (error) { toast.error( getUserFacingRequestErrorMessage(error, "PSD 模板渲染失败", { rateLimitMessage: getFriendlyRateLimitMessage( "当前 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); } function renderTemplatePoolSection({ mode, templates: poolTemplates, }: { mode: AgentPsdTemplateMode; templates: AgentPsdTemplate[]; }) { const isSkuMode = mode === "sku"; const title = isSkuMode ? "SKU图模式模板" : "主图模式模板"; const description = selectedCategorySummaryText ? `当前按品类“${selectedCategorySummaryText}”筛选,同时保留“适合所有”的模板。` : normalizedBrandName ? `这里集中展示当前品牌下的${isSkuMode ? " SKU 图" : "主图"}模式模板。` : `当前未选择品牌,这里展示全部品牌下的${isSkuMode ? " SKU 图" : "主图"}模式模板。`; const emptyMessage = selectedCategorySummaryText ? `当前品类“${selectedCategorySummaryText}”下暂无${isSkuMode ? " SKU 图" : "主图"}模式模板` : normalizedBrandName ? `当前品牌“${normalizedBrandName}”下暂无${isSkuMode ? " SKU 图" : "主图"}模式模板` : `当前暂无可用的${isSkuMode ? " SKU 图" : "主图"}模式模板,请先去后台配置。`; const loadingMessage = `正在加载${isSkuMode ? " SKU 图" : "主图"}模式模板...`; const isCurrentResultMode = normalizedTemplateMode === mode; return (

{title}

{isCurrentResultMode ? ( 当前模式 ) : null}

{description}

{poolTemplates.length} 个模板
{isLoading ? (
{Array.from({ length: 3 }).map((_, index) => (
{loadingMessage}
))}
) : poolTemplates.length > 0 ? (
{poolTemplates.map((template, index) => ( ))}
) : (
{emptyMessage}
)}
); } function renderPendingImageQueue() { if (normalizedPendingImages.length === 0) { return null; } return (
待处理图片
{normalizedPendingImages.length} 张
{normalizedPendingImages.map((image, index) => { const isActive = activePendingImageId === image.id; const renderedResultUrl = pendingRenderedResultUrls?.[image.id]; return ( ); })}
); } const content = ( <> : null} {/* Top Header Bar */}
{standalone ? ( ) : null}

{standalone ? "PSD 套版工作台" : "PSD 模板编辑器"}

{standalone && normalizedPendingImages.length > 0 ? (

当前待处理 {normalizedPendingImages.length} 张,品牌:{normalizedBrandName || "未选择"}

) : null}
{selectedTemplate && ( )}
{selectedTemplate ? (
{renderPendingImageQueue()}
选择模板
{selectedCategorySummaryText ? (
{`当前按品类“${selectedCategorySummaryText}”筛选模板,同时保留“适合所有”的模板。`}
) : null} {hasTemplateOptions ? (
) : null}
{sourceImageUrl ? null : (
当前未从生图结果带入图片。请先在下方图片图层中点击“上传替换图片”,再开始套用 PSD 模板。
)}
) : (
{renderPendingImageQueue()}
{renderTemplatePoolSection({ mode: "main", templates: mainModeTemplates, })} {renderTemplatePoolSection({ mode: "sku", templates: skuModeTemplates, })}
)}
{selectedTemplate ? ( selectedTemplateLayers.length === 0 ? (
{selectedTemplateDetailError ? ( <>
模板详情加载失败
{selectedTemplateDetailError}
) : ( <>
正在加载模板图层与编辑配置
模板卡片已经优先显示,当前正在按需加载这个模板的完整图层、字体和编辑底图。
)}
) : (
{shouldShowCompositeFallback && orderedVisiblePreviewLayers.length > 0 ? ( ) : null}
{orderedVisiblePreviewLayers.map((layer) => { const layerPositionOffset = effectiveLayerPositionOffsets[layer.id] || normalizeLayerPositionOffset(); const baseZIndex = layerStackZIndexById.get(layer.id) || 1; const layerBoxStyle = { left: layer.x + layerPositionOffset.offsetX, top: layer.y + layerPositionOffset.offsetY, 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 (
{ event.stopPropagation(); setSelectedLayerId(layer.id); }} onPointerDown={(event) => handleLayerDragStart(layer, event) } style={{ ...assignedImageContainerStyle, zIndex: baseZIndex, cursor: layerDragStateRef.current?.layerId === layer.id ? "grabbing" : "grab", touchAction: "none", }} title={`${getLayerLabel(layer)}:${layer.layerName}`} > {layer.layerName}
); } const value = effectiveTextAssignments[layer.id] ?? ""; const resolvedTextColor = effectiveTextColorAssignments[layer.id] || layer.textColor; const textPositionOffset = effectiveTextPositionOffsets[layer.id] || normalizeTextPositionOffset(); const shouldUseEditedPreview = hasCustomizedTextLayerAppearance( layer, value, resolvedTextColor, textPositionOffset ); if (!shouldUseEditedPreview && layer.sourceUrl) { return (
{ event.stopPropagation(); setSelectedLayerId(layer.id); }} onPointerDown={(event) => handleTextDragStart(layer, event) } style={{ ...layerBoxStyle, zIndex: baseZIndex, cursor: textDragStateRef.current?.layerId === layer.id ? "grabbing" : "grab", touchAction: "none", }} title={`${getLayerLabel(layer)}:${layer.layerName}`} > {layer.layerName}
); } if (!value && !layer.placeholder) { return null; } const textFrame = getTextLayerRenderFrame( layer, value ); return (
{ event.stopPropagation(); setSelectedLayerId(layer.id); }} onPointerDown={(event) => handleTextDragStart(layer, event) } style={{ left: layer.x + layerPositionOffset.offsetX + textPositionOffset.offsetX + textFrame.offsetX, top: layer.y + layerPositionOffset.offsetY + textPositionOffset.offsetY + textFrame.offsetY, width: textFrame.width, height: textFrame.height, zIndex: baseZIndex, cursor: textDragStateRef.current?.layerId === layer.id ? "grabbing" : "grab", touchAction: "none", }} title={`${getLayerLabel(layer)}:${layer.layerName}`} > {renderTextLayerPreviewContent( layer, value, resolvedTextColor )}
); })}
{orderedVisiblePreviewLayers .filter((layer) => !layer.isEditable) .map((layer) => { const isSelected = selectedLayerId === layer.id; const baseZIndex = layerStackZIndexById.get(layer.id) || 1; const frameStyle = getLayerFrameStyle( layer, isSelected, effectiveLayerPositionOffsets[layer.id] ); return (
{layer.layerName}
拖动图层
); })} {orderedEditableImageLayers.map((layer) => { if (hiddenLayerIdSet.has(layer.id)) { return null; } const isSelected = selectedLayerId === layer.id; const baseZIndex = layerStackZIndexById.get(layer.id) || 1; const frameStyle = getLayerFrameStyle( layer, isSelected, effectiveLayerPositionOffsets[layer.id] ); const hasAssignedImage = Boolean( String( effectiveImageAssignments[layer.id] || "" ).trim() ); return isSelected && hasAssignedImage ? (
{layer.layerName}
拖动图层
) : ( ); })} {orderedEditableImageLayers.map((layer) => { if (hiddenLayerIdSet.has(layer.id)) { return null; } const isSelected = selectedLayerId === layer.id; const hasAssignedImage = Boolean( String( effectiveImageAssignments[layer.id] || "" ).trim() ); const baseZIndex = layerStackZIndexById.get(layer.id) || 1; if (!isSelected || !hasAssignedImage) { return null; } return (
); })} {orderedEditableTextLayers.map((layer) => { if (hiddenLayerIdSet.has(layer.id)) { return null; } const isSelected = selectedLayerId === layer.id; const baseZIndex = layerStackZIndexById.get(layer.id) || 1; const textPositionOffset = effectiveTextPositionOffsets[layer.id] || normalizeTextPositionOffset(); const layerPositionOffset = effectiveLayerPositionOffsets[layer.id] || normalizeLayerPositionOffset(); const frameStyle = { ...getLayerFrameStyle( layer, isSelected, layerPositionOffset ), left: layer.x + layerPositionOffset.offsetX + textPositionOffset.offsetX, top: layer.y + layerPositionOffset.offsetY + textPositionOffset.offsetY, } satisfies CSSProperties; const currentValue = effectiveTextAssignments[layer.id] ?? ""; const resolvedTextColor = effectiveTextColorAssignments[layer.id] || layer.textColor; const preserveSourceAppearance = !!layer.sourceUrl && !hasCustomizedTextLayerAppearance( layer, currentValue, resolvedTextColor, textPositionOffset ); const editedTextFrame = getTextLayerRenderFrame( layer, currentValue ); if (isSelected) { return (
{preserveSourceAppearance && layer.sourceUrl ? ( {layer.layerName} ) : null} {preserveSourceAppearance ? null : (
{renderTextLayerPreviewContent( layer, currentValue, resolvedTextColor )}
)}
{layer.layerName}
拖动文字 / 右侧微调
); } return ( ); })}
) ) : (
SKU图模式模板
{normalizedTemplateMode === "sku" ? "当前结果属于 SKU 图模式,可直接从这里选择并进入编辑。" : selectedCategorySummaryText ? `当前按品类“${selectedCategorySummaryText}”筛选,同时展示“适合所有”的 SKU 图模式模板。` : normalizedBrandName ? "这里集中展示当前品牌下的 SKU 图模式模板。" : "当前未选择品牌,这里展示全部品牌下的 SKU 图模式模板。"}
{skuModeTemplates.length} 个模板
{isLoading ? (
{Array.from({ length: 4 }).map((_, index) => (
正在加载 SKU 图模式模板...
))}
) : skuModeTemplates.length > 0 ? (
{skuModeTemplates.map((template, index) => ( ))}
) : (
{selectedCategorySummaryText ? `当前品类“${selectedCategorySummaryText}”下暂无 SKU 图模式模板` : normalizedBrandName ? `当前品牌“${normalizedBrandName}”下暂无 SKU 图模式模板` : "当前暂无可用的 SKU 图模式模板,请先去后台配置。"}
)}
)}
{renderedResultUrl ? (
) : null} { if (!selectedImageLayer) return; void handleSlotUpload(selectedImageLayer.id, event); }} ref={uploadInputRef} type="file" /> {renderedResultUrl && rightPanelView === "export" ? ( ) : (
{isSelectedLayerHidden && selectedLayer ? (
图层已临时删除
当前会话里不会再预览或导出图层“{selectedLayer.layerName} ”,但不会改动模板本身。
) : selectedImageLayer ? (
{selectedImageAdjustmentPanel}
{selectedLayerPositionPanel} {textLayerQuickColorPanel}
) : selectedTextLayer ? (
{selectedLayerPositionPanel}