diff --git a/app/api/psd-templates/[id]/route.ts b/app/api/psd-templates/[id]/route.ts index 9c64be110..24ac1a9c8 100644 --- a/app/api/psd-templates/[id]/route.ts +++ b/app/api/psd-templates/[id]/route.ts @@ -1,14 +1,19 @@ import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; +import { upsertSystemSetting } from "@/lib/db/queries"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; import { + AGENT_PSD_TEMPLATES_KEY, type AgentPsdTemplateMutationResponse, getEditableAgentPsdTemplateLayers, isAgentPsdTemplateReady, isAgentPsdTemplateRuntimeReady, isAgentPsdTextLayerSlot, } from "@/lib/agents/psd-template-config"; -import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-repair"; +import { + repairAgentPsdTemplateAssetsIfNeeded, + repairAgentPsdTemplateFontBindings, +} from "@/lib/agents/psd-template-repair"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; export const runtime = "nodejs"; @@ -48,19 +53,37 @@ export async function GET( return jsonError(404, "未找到 PSD 模板"); } - if (!isAgentPsdTemplateReady(currentTemplate)) { + const repairedTemplate = await repairAgentPsdTemplateAssetsIfNeeded( + currentTemplate + ); + if (repairedTemplate !== currentTemplate) { + await upsertSystemSetting({ + key: AGENT_PSD_TEMPLATES_KEY, + value: templates.map((entry) => + entry.id === currentTemplate.id ? repairedTemplate : entry + ), + updatedBy: session.user.id, + }).catch((error) => { + console.warn("[PSD Templates] failed to persist repaired detail assets:", { + templateId: currentTemplate.id, + error, + }); + }); + } + + if (!isAgentPsdTemplateReady(repairedTemplate)) { return jsonError(409, "PSD 模板尚未完成解析"); } - if (!isAgentPsdTemplateRuntimeReady(currentTemplate)) { + if (!isAgentPsdTemplateRuntimeReady(repairedTemplate)) { return jsonError(409, "PSD 模板资源尚未准备完成,请稍后重试"); } - if (getEditableAgentPsdTemplateLayers(currentTemplate).length === 0) { + if (getEditableAgentPsdTemplateLayers(repairedTemplate).length === 0) { return jsonError(409, "当前模板没有可编辑图层"); } - const template = repairAgentPsdTemplateFontBindings(currentTemplate, fonts); + const template = repairAgentPsdTemplateFontBindings(repairedTemplate, fonts); const normalizedTemplate = { ...template, layers: template.layers.map((layer) => { diff --git a/components/agents/AgentPsdTemplateEditorDialog.tsx b/components/agents/AgentPsdTemplateEditorDialog.tsx index dd5e72356..bb75d1454 100644 --- a/components/agents/AgentPsdTemplateEditorDialog.tsx +++ b/components/agents/AgentPsdTemplateEditorDialog.tsx @@ -268,6 +268,7 @@ type AgentPsdTextPositionOffset = { offsetX: number; offsetY: number; }; +type AgentPsdLayerPositionOffset = AgentPsdTextPositionOffset; function clampTextPositionOffsetValue(value: number) { if (!Number.isFinite(value)) { @@ -301,6 +302,18 @@ function hasTextPositionOffset( ); } +function normalizeLayerPositionOffset( + value?: Partial | null +) { + return normalizeTextPositionOffset(value); +} + +function hasLayerPositionOffset( + value?: Partial | null +) { + return hasTextPositionOffset(value); +} + function buildTemplatePreviewThumbUrl( previewUrl: string, params?: { @@ -789,6 +802,8 @@ function createInitialState( ) { const imageAssignments: Record = {}; const imagePlacements: Record = {}; + const layerOrderIds: string[] = []; + const layerPositionOffsets: Record = {}; const textAssignments: Record = {}; const textPositionOffsets: Record = {}; @@ -796,12 +811,15 @@ function createInitialState( return { imageAssignments, imagePlacements, + layerOrderIds, + layerPositionOffsets, textAssignments, textPositionOffsets, }; } for (const layer of template.layers) { + layerOrderIds.push(layer.id); if ( isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource && @@ -818,6 +836,8 @@ function createInitialState( return { imageAssignments, imagePlacements, + layerOrderIds, + layerPositionOffsets, textAssignments, textPositionOffsets, }; @@ -928,6 +948,7 @@ function createTemplateEditorState(params: { sourceImageUrl: string; previousImageAssignments: Record; previousImagePlacements: Record; + previousLayerPositionOffsets: Record; previousTextAssignments: Record; previousTextColorAssignments: Record; previousTextPositionOffsets: Record; @@ -938,6 +959,7 @@ function createTemplateEditorState(params: { sourceImageUrl, previousImageAssignments, previousImagePlacements, + previousLayerPositionOffsets, previousTextAssignments, previousTextColorAssignments, previousTextPositionOffsets, @@ -947,6 +969,8 @@ function createTemplateEditorState(params: { const nextState = { imageAssignments: { ...initialState.imageAssignments }, imagePlacements: { ...initialState.imagePlacements }, + layerOrderIds: [...initialState.layerOrderIds], + layerPositionOffsets: { ...initialState.layerPositionOffsets }, textAssignments: { ...initialState.textAssignments }, textColorAssignments: {} as Record, textPositionOffsets: { ...initialState.textPositionOffsets }, @@ -956,6 +980,27 @@ function createTemplateEditorState(params: { 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) @@ -1168,11 +1213,13 @@ function getImageFit( function getLayerFrameStyle( layer: AgentPsdTemplateLayerSlot, - isSelected: boolean + isSelected: boolean, + positionOffset?: Partial | null ): CSSProperties { + const normalizedOffset = normalizeLayerPositionOffset(positionOffset); return { - left: layer.x, - top: layer.y, + left: layer.x + normalizedOffset.offsetX, + top: layer.y + normalizedOffset.offsetY, width: layer.width, height: layer.height, border: `2px solid ${getLayerOutlineColor(layer)}`, @@ -1534,6 +1581,10 @@ export function AgentPsdTemplateEditorDialog({ const [imageSizes, setImageSizes] = useState< Record >({}); + const [layerOrderIds, setLayerOrderIds] = useState([]); + const [layerPositionOffsets, setLayerPositionOffsets] = useState< + Record + >({}); const [textAssignments, setTextAssignments] = useState< Record >({}); @@ -1560,12 +1611,12 @@ export function AgentPsdTemplateEditorDialog({ const hiddenLayerScrollAreaRef = useRef(null); const uploadInputRef = useRef(null); const previousSelectedTemplateRef = useRef(null); - const imageDragStateRef = useRef<{ + const layerDragStateRef = useRef<{ layerId: string; pointerId: number; startClientX: number; startClientY: number; - startPlacement: AgentPsdImagePlacement; + startOffset: AgentPsdLayerPositionOffset; } | null>(null); const textDragStateRef = useRef<{ layerId: string; @@ -1577,6 +1628,7 @@ export function AgentPsdTemplateEditorDialog({ const imageAssignmentsRef = useRef(imageAssignments); const imagePlacementsRef = useRef(imagePlacements); + const layerPositionOffsetsRef = useRef(layerPositionOffsets); const textAssignmentsRef = useRef(textAssignments); const textColorAssignmentsRef = useRef(textColorAssignments); const textPositionOffsetsRef = useRef(textPositionOffsets); @@ -1585,6 +1637,7 @@ export function AgentPsdTemplateEditorDialog({ imageAssignmentsRef.current = imageAssignments; imagePlacementsRef.current = imagePlacements; + layerPositionOffsetsRef.current = layerPositionOffsets; textAssignmentsRef.current = textAssignments; textColorAssignmentsRef.current = textColorAssignments; textPositionOffsetsRef.current = textPositionOffsets; @@ -1775,9 +1828,26 @@ export function AgentPsdTemplateEditorDialog({ ), [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( - () => [...selectedTemplateLayers].reverse(), - [selectedTemplateLayers] + () => [...orderedTemplateLayers].reverse(), + [orderedTemplateLayers] ); const hiddenLayerIdSet = useMemo( () => @@ -1845,6 +1915,12 @@ export function AgentPsdTemplateEditorDialog({ ), [orderedEditableLayers] ); + const layerStackZIndexById = useMemo(() => { + const total = orderedPreviewLayers.length; + return new Map( + orderedPreviewLayers.map((layer, index) => [layer.id, total - index]) + ); + }, [orderedPreviewLayers]); const shouldShowCompositeFallback = useMemo( () => orderedVisiblePreviewLayers.length === 0 || @@ -1920,6 +1996,20 @@ export function AgentPsdTemplateEditorDialog({ 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 = {}; @@ -1977,6 +2067,10 @@ export function AgentPsdTemplateEditorDialog({ ? effectiveTextPositionOffsets[selectedTextLayer.id] || normalizeTextPositionOffset() : normalizeTextPositionOffset(); + const selectedLayerPositionOffset = selectedLayer + ? effectiveLayerPositionOffsets[selectedLayer.id] || + normalizeLayerPositionOffset() + : normalizeLayerPositionOffset(); const isSelectedImageExtending = Boolean( selectedImageLayer && extendingSlotId === selectedImageLayer.id ); @@ -1999,9 +2093,138 @@ export function AgentPsdTemplateEditorDialog({ return; } setHiddenLayerIds((current) => - current.filter((currentLayerId) => currentLayerId !== normalizedLayerId) + current.filter((currentLayerId) => currentLayerId !== normalizedLayerId) ); } + const selectedLayerPositionPanel = selectedLayer ? ( +
+
+
+
图层位置
+
+ 现在所有图层都支持在中间预览区直接拖动,这里也能做数值微调。 +
+
+ +
+
+ + + + +
+
+
+ + { + const nextValue = Number(event.target.value); + if (!Number.isFinite(nextValue)) return; + updateLayerPositionOffset(selectedLayer.id, () => ({ + offsetX: nextValue, + })); + }} + type="number" + value={Math.round(selectedLayerPositionOffset.offsetX)} + /> +
+
+ + { + const nextValue = Number(event.target.value); + if (!Number.isFinite(nextValue)) return; + updateLayerPositionOffset(selectedLayer.id, () => ({ + offsetY: nextValue, + })); + }} + type="number" + value={Math.round(selectedLayerPositionOffset.offsetY)} + /> +
+
+
+
+ +
+ + + +
+ +
+
+
+ ) : null; const selectedImageAdjustmentPanel = selectedImageLayer ? (
@@ -2171,6 +2394,116 @@ export function AgentPsdTemplateEditorDialog({
+
+
+
+
+ 槽位内图片位置 +
+
+ 图层整体拖动已经改到中间预览区;这里保留槽位内图片的细调。 +
+
+ +
+
+
+ + { + 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)} + /> +
+
+
+
+ +
+ + + +
+ +
+
+
+
+ ); + })} + {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 + isSelected, + effectiveLayerPositionOffsets[layer.id] ); const hasAssignedImage = Boolean( String( @@ -3734,15 +4210,15 @@ export function AgentPsdTemplateEditorDialog({ key={layer.id} onClick={() => setSelectedLayerId(layer.id)} onPointerDown={(event) => - handleImageDragStart(layer, event) + handleLayerDragStart(layer, event) } role="button" style={{ ...frameStyle, - zIndex: 20, + zIndex: baseZIndex + 120, background: "rgba(249,115,22,0.08)", cursor: - imageDragStateRef.current?.layerId === + layerDragStateRef.current?.layerId === layer.id ? "grabbing" : "grab", @@ -3756,7 +4232,7 @@ export function AgentPsdTemplateEditorDialog({
- 拖动调整 + 拖动图层
) : ( @@ -3765,12 +4241,21 @@ export function AgentPsdTemplateEditorDialog({ className="absolute overflow-hidden rounded-lg transition-all" key={layer.id} onClick={() => setSelectedLayerId(layer.id)} + onPointerDown={(event) => + handleLayerDragStart(layer, event) + } style={{ ...frameStyle, - zIndex: isSelected ? 20 : 10, + zIndex: baseZIndex + (isSelected ? 120 : 0), background: isSelected ? "rgba(249,115,22,0.08)" : "transparent", + cursor: + layerDragStateRef.current?.layerId === + layer.id + ? "grabbing" + : "grab", + touchAction: "none", }} title={`${getLayerLabel(layer)}:${layer.layerName}`} type="button" @@ -3780,8 +4265,9 @@ export function AgentPsdTemplateEditorDialog({
{hasAssignedImage ? null : ( -
- 点击替换图片 +
+ + 拖动图层 / 点击替换
)} @@ -3798,6 +4284,8 @@ export function AgentPsdTemplateEditorDialog({ effectiveImageAssignments[layer.id] || "" ).trim() ); + const baseZIndex = + layerStackZIndexById.get(layer.id) || 1; if (!isSelected || !hasAssignedImage) { return null; } @@ -3807,9 +4295,24 @@ export function AgentPsdTemplateEditorDialog({ className="absolute flex items-center gap-1 rounded-full border border-white/10 bg-black/80 p-1 text-white shadow-xl backdrop-blur-md" key={`${layer.id}-controls`} style={{ - left: layer.x + layer.width - 112, - top: Math.max(8, layer.y - 44), - zIndex: 25, + left: + layer.x + + ( + effectiveLayerPositionOffsets[layer.id] + ?.offsetX || 0 + ) + + layer.width - + 112, + top: Math.max( + 8, + layer.y + + ( + effectiveLayerPositionOffsets[layer.id] + ?.offsetY || 0 + ) - + 44 + ), + zIndex: baseZIndex + 140, }} >