From 21a2254428c257c0d73930c5abdcc143ca11c3b0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 8 May 2026 16:24:28 +0800 Subject: [PATCH] feat: expand canvas batch and image workflows --- .../[id]/CanvasEditorLegacyPageClient.tsx | 290 +++++++++---- app/canvas/[id]/batch/page.tsx | 97 +++++ app/canvas/[id]/psd-template/page.tsx | 98 +++++ .../agents/AgentPsdTemplateEditorDialog.tsx | 195 +++++++-- components/canvas/CanvasWorkspace.tsx | 1 + components/canvas/nodes/ImageNode.tsx | 81 +++- components/scene-fusion-view.tsx | 392 +++++++++++++++++- lib/canvas/psd-template-launch.ts | 98 +++++ 8 files changed, 1131 insertions(+), 121 deletions(-) create mode 100644 app/canvas/[id]/batch/page.tsx create mode 100644 app/canvas/[id]/psd-template/page.tsx create mode 100644 lib/canvas/psd-template-launch.ts diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index ef00877e5..1d4a9bf40 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -299,17 +299,6 @@ const CanvasFileListPanel = dynamic( } ); -const SceneFusionDialog = dynamic( - () => - import("@/components/scene-fusion-dialog").then( - (module) => module.SceneFusionDialog - ), - { - loading: () => null, - ssr: false, - } -); - const AgentCanvasTopNavDark = dynamic( () => import("@/components/agents/AgentCanvasTopNavDark").then( @@ -1631,6 +1620,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { const setTool = useCanvasStore((state) => state.setTool); const zoomIn = useCanvasStore((state) => state.zoomIn); const zoomOut = useCanvasStore((state) => state.zoomOut); + const setViewport = useCanvasStore((state) => state.setViewport); const undo = useCanvasStore((state) => state.undo); const redo = useCanvasStore((state) => state.redo); const loadCanvas = useCanvasStore((state) => state.loadCanvas); @@ -1649,6 +1639,88 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { const [isSaving, setIsSaving] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false); const [editTitle, setEditTitle] = useState(""); + + const isCanvasTextEntryTarget = useCallback((target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) { + return false; + } + + if (target instanceof HTMLInputElement) { + const inputType = String(target.type || "").toLowerCase(); + const isTextInput = + inputType === "text" || + inputType === "search" || + inputType === "url" || + inputType === "email" || + inputType === "password" || + inputType === "number"; + if (isTextInput) { + return true; + } + } + + return ( + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + target.isContentEditable || + Boolean(target.closest('[contenteditable="true"]')) + ); + }, []); + + const applyCanvasViewportZoom = useCallback( + ( + zoomFactor: number, + anchorPoint?: { + clientX?: number; + clientY?: number; + } + ) => { + const { viewport: currentViewport } = useCanvasStore.getState(); + const nextZoom = Math.max( + 0.1, + Math.min(4, currentViewport.zoom * zoomFactor) + ); + + if (nextZoom === currentViewport.zoom) { + return; + } + + const workspaceElement = + typeof document !== "undefined" + ? document.querySelector( + '[data-canvas-workspace-root="true"]' + ) + : null; + + let anchorX = window.innerWidth / 2; + let anchorY = window.innerHeight / 2; + + if (workspaceElement) { + const rect = workspaceElement.getBoundingClientRect(); + const hasClientPoint = + typeof anchorPoint?.clientX === "number" && + typeof anchorPoint?.clientY === "number"; + + anchorX = hasClientPoint + ? Math.min(Math.max(anchorPoint.clientX - rect.left, 0), rect.width) + : rect.width / 2; + anchorY = hasClientPoint + ? Math.min(Math.max(anchorPoint.clientY - rect.top, 0), rect.height) + : rect.height / 2; + } + + const scaleChange = nextZoom / currentViewport.zoom; + const nextX = anchorX - (anchorX - currentViewport.x) * scaleChange; + const nextY = anchorY - (anchorY - currentViewport.y) * scaleChange; + + setViewport({ + x: nextX, + y: nextY, + zoom: nextZoom, + }); + }, + [setViewport] + ); const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = useState(null); const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode); @@ -1673,7 +1745,6 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { useState(false); const [isVideoCanvasMarkerPanelOpen, setIsVideoCanvasMarkerPanelOpen] = useState(false); - const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false); // 面板模式: 'chat' 聊天面板, 'fileList' 文件列表 const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat"); const [viewportWidth, setViewportWidth] = useState(0); @@ -3068,15 +3139,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { useEffect(() => { if (shouldOpenBatchProcessing && isBatchProcessingEnabled) { - setSceneFusionDialogOpen(true); + router.replace(`/canvas/${id}/batch`); } - }, [isBatchProcessingEnabled, shouldOpenBatchProcessing]); - - useEffect(() => { - if (!isBatchProcessingEnabled) { - setSceneFusionDialogOpen(false); - } - }, [isBatchProcessingEnabled]); + }, [id, isBatchProcessingEnabled, router, shouldOpenBatchProcessing]); const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]); const buildPersistedCanvasData = useCallback(() => { @@ -8080,43 +8145,6 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { workflowGraph, ]); - const handleBatchAddToCanvas = useCallback( - (imageUrls: string[]) => { - if (!imageUrls || imageUrls.length === 0) { - toast.error("没有可添加到画布的图片"); - return; - } - - const columns = Math.min(4, imageUrls.length); - const startX = 120; - const startY = 120; - const createdNodeIds: string[] = []; - - for (const [index, imageUrl] of imageUrls.entries()) { - const x = startX + (index % columns) * 320; - const y = startY + Math.floor(index / columns) * 320; - const nodeId = addNode({ - type: "image", - position: { x, y }, - size: { width: 300, height: 300 }, - data: { - src: imageUrl, - status: "completed", - title: `批量结果-${index + 1}`, - prompt: "批量处理结果", - }, - connections: [], - }); - createdNodeIds.push(nodeId); - } - - if (createdNodeIds.length > 0) { - selectNodes(createdNodeIds); - } - }, - [addNode, selectNodes] - ); - // 处理图片操作(通过事件分发到选中图片节点执行) const handleAction = (action: string) => { if (!selectedImageNode) { @@ -8229,25 +8257,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // 如果正在输入则不触发快捷键(包括 input、textarea 和 contentEditable 元素) - const target = e.target as HTMLElement; - if (target instanceof HTMLInputElement) { - const inputType = String(target.type || "").toLowerCase(); - const isTextInput = - inputType === "text" || - inputType === "search" || - inputType === "url" || - inputType === "email" || - inputType === "password" || - inputType === "number"; - if (isTextInput) { - return; - } - } - if ( - target instanceof HTMLTextAreaElement || - target.isContentEditable || - target.closest('[contenteditable="true"]') - ) { + if (isCanvasTextEntryTarget(e.target)) { return; } @@ -8310,6 +8320,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { return () => window.removeEventListener("keydown", handleKeyDown); }, [ handleSave, + isCanvasTextEntryTarget, isVideoMode, nodes, redo, @@ -8319,6 +8330,124 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { undo, ]); + useEffect(() => { + const isBrowserZoomShortcut = (event: KeyboardEvent) => { + if (!(event.ctrlKey || event.metaKey)) { + return null; + } + + const { code, key } = event; + if ( + code === "Equal" || + code === "NumpadAdd" || + key === "+" || + key === "=" + ) { + return "in"; + } + if ( + code === "Minus" || + code === "NumpadSubtract" || + key === "-" || + key === "_" + ) { + return "out"; + } + if (code === "Digit0" || code === "Numpad0" || key === "0") { + return "reset"; + } + + return null; + }; + + const handleBrowserZoomKeyDown = (event: KeyboardEvent) => { + const zoomAction = isBrowserZoomShortcut(event); + if (!zoomAction) { + return; + } + + if (event.cancelable) { + event.preventDefault(); + } + + if (isCanvasTextEntryTarget(event.target)) { + return; + } + + if (zoomAction === "in") { + applyCanvasViewportZoom(1.2); + return; + } + + if (zoomAction === "out") { + applyCanvasViewportZoom(1 / 1.2); + return; + } + + setViewport({ x: 0, y: 0, zoom: 1 }); + }; + + const handleBrowserZoomWheel = (event: WheelEvent) => { + if (!(event.ctrlKey || event.metaKey)) { + return; + } + + if (event.cancelable) { + event.preventDefault(); + } + + if (isCanvasTextEntryTarget(event.target)) { + return; + } + + const workspaceElement = document.querySelector( + '[data-canvas-workspace-root="true"]' + ); + + if ( + workspaceElement && + event.target instanceof Node && + workspaceElement.contains(event.target) + ) { + return; + } + + applyCanvasViewportZoom(event.deltaY > 0 ? 0.95 : 1.05, { + clientX: event.clientX, + clientY: event.clientY, + }); + }; + + const handleGesture = (event: Event) => { + event.preventDefault(); + }; + + window.addEventListener("keydown", handleBrowserZoomKeyDown, { + capture: true, + }); + window.addEventListener("wheel", handleBrowserZoomWheel, { + passive: false, + capture: true, + }); + window.addEventListener("gesturestart", handleGesture, { + passive: false, + }); + window.addEventListener("gesturechange", handleGesture, { + passive: false, + }); + + return () => { + window.removeEventListener("keydown", handleBrowserZoomKeyDown, { + capture: true, + }); + window.removeEventListener("wheel", handleBrowserZoomWheel, { + capture: true, + }); + window.removeEventListener("gesturestart", handleGesture); + window.removeEventListener("gesturechange", handleGesture); + }; + }, [applyCanvasViewportZoom, isCanvasTextEntryTarget, setViewport]); + useEffect(() => { const html = document.documentElement; const body = document.body; @@ -10045,7 +10174,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { ? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS : "h-9 w-9 rounded-xl" )} - onClick={() => setSceneFusionDialogOpen(true)} + onClick={() => router.push(`/canvas/${id}/batch`)} size="icon" variant="ghost" > @@ -10595,13 +10724,6 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { - {isBatchProcessingEnabled && sceneFusionDialogOpen ? ( - - ) : null} {loginDialogElement} {projectLoadingBadge} diff --git a/app/canvas/[id]/batch/page.tsx b/app/canvas/[id]/batch/page.tsx new file mode 100644 index 000000000..af20f8192 --- /dev/null +++ b/app/canvas/[id]/batch/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; +import { useCallback } from "react"; +import { toast } from "sonner"; +import { SceneFusionView } from "@/components/scene-fusion-view"; +import { canvasService } from "@/services/canvas"; +import { canvasSelectors, useCanvasStore } from "@/store/canvas"; + +export default function CanvasBatchProcessingPage() { + const router = useRouter(); + const routeParams = useParams(); + const id = String(routeParams?.id || ""); + + const viewport = useCanvasStore(canvasSelectors.viewport); + const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds); + const nodes = useCanvasStore(canvasSelectors.nodes); + const addNode = useCanvasStore((state) => state.addNode); + const selectNodes = useCanvasStore((state) => state.selectNodes); + const getCanvasState = useCanvasStore((state) => state.getCanvasState); + + const selectedImageNode = + selectedNodeIds.length === 1 + ? nodes.find( + (node) => node.id === selectedNodeIds[0] && node.type === "image" + ) + : null; + + const handleBack = useCallback(() => { + router.push(`/canvas/${id}`); + }, [id, router]); + + const handleBatchAddToCanvas = useCallback( + async (imageUrls: string[]) => { + if (!imageUrls || imageUrls.length === 0) { + toast.error("没有可添加到画布的图片"); + return; + } + + const columns = Math.min(4, imageUrls.length); + const viewportX = viewport.x || 0; + const viewportY = viewport.y || 0; + const viewportZoom = viewport.zoom || 1; + const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1440; + const stageHeight = typeof window !== "undefined" ? window.innerHeight : 900; + const startX = (stageWidth * 0.22 - viewportX) / viewportZoom; + const startY = (stageHeight * 0.18 - viewportY) / viewportZoom; + const createdNodeIds: string[] = []; + + for (const [index, imageUrl] of imageUrls.entries()) { + const x = startX + (index % columns) * 320; + const y = startY + Math.floor(index / columns) * 320; + const nodeId = addNode({ + type: "image", + position: { x, y }, + size: { width: 300, height: 300 }, + data: { + src: imageUrl, + status: "completed", + title: `批量结果-${index + 1}`, + prompt: "批量处理结果", + }, + connections: [], + }); + createdNodeIds.push(nodeId); + } + + if (createdNodeIds.length > 0) { + selectNodes(createdNodeIds); + } + + try { + await canvasService.saveCanvasState(id, getCanvasState()); + } catch (error) { + console.error("[CanvasBatchProcessingPage] 保存批量处理结果失败:", error); + } + }, + [addNode, getCanvasState, id, selectNodes, viewport.x, viewport.y, viewport.zoom] + ); + + return ( +
+ +
+ ); +} diff --git a/app/canvas/[id]/psd-template/page.tsx b/app/canvas/[id]/psd-template/page.tsx new file mode 100644 index 000000000..93c0fbc2c --- /dev/null +++ b/app/canvas/[id]/psd-template/page.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { ArrowLeft, FileImage } from "lucide-react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog"; +import { Button } from "@/components/ui/button"; +import { + readCanvasPsdTemplateLaunchPayload, + type CanvasPsdTemplateLaunchPayload, +} from "@/lib/canvas/psd-template-launch"; + +export default function CanvasPsdTemplatePage() { + const router = useRouter(); + const routeParams = useParams(); + const searchParams = useSearchParams(); + const canvasId = String(routeParams?.id || ""); + const launchToken = searchParams.get("launch") || ""; + + const [launchPayload, setLaunchPayload] = useState(null); + const [activePendingImageId, setActivePendingImageId] = useState(null); + const [renderedResultUrls, setRenderedResultUrls] = useState>({}); + + useEffect(() => { + if (!launchToken) { + setLaunchPayload(null); + return; + } + + const nextPayload = readCanvasPsdTemplateLaunchPayload(launchToken); + setLaunchPayload(nextPayload); + setActivePendingImageId(nextPayload?.activeImageId || nextPayload?.pendingImages[0]?.id || null); + }, [launchToken]); + + const handleBack = useCallback(() => { + const returnTo = launchPayload?.returnTo?.trim(); + if (returnTo) { + router.push(returnTo); + return; + } + router.push(`/canvas/${canvasId}/batch`); + }, [canvasId, launchPayload?.returnTo, router]); + + const activePendingImage = useMemo( + () => + launchPayload?.pendingImages.find((image) => image.id === activePendingImageId) || + launchPayload?.pendingImages[0] || + null, + [activePendingImageId, launchPayload?.pendingImages] + ); + + if (!launchPayload || !activePendingImage) { + return ( +
+
+
+ +
+
没有可用的套 PSD 任务
+
+ 当前页面没有接收到批量处理结果图。请先从批量处理结果区选择图片,再进入套 PSD 模版页面。 +
+ +
+
+ ); + } + + return ( + { + if (!open) { + handleBack(); + } + }} + onRenderedResultCreated={(imageUrl) => { + setRenderedResultUrls((current) => ({ + ...current, + [activePendingImage.id]: imageUrl, + })); + }} + open + pendingImages={launchPayload.pendingImages} + pendingRenderedResultUrls={renderedResultUrls} + sourceImageUrl={activePendingImage.imageUrl} + standalone + templateMode={launchPayload.templateMode || "main"} + /> + ); +} diff --git a/components/agents/AgentPsdTemplateEditorDialog.tsx b/components/agents/AgentPsdTemplateEditorDialog.tsx index 100cdaf07..cf0f838e1 100644 --- a/components/agents/AgentPsdTemplateEditorDialog.tsx +++ b/components/agents/AgentPsdTemplateEditorDialog.tsx @@ -1,12 +1,15 @@ "use client"; import { + ArrowLeft, ChevronDown, ChevronUp, + CheckCircle2, Eye, EyeOff, Expand, Image as ImageIcon, + Images, Loader2, Move, Pointer, @@ -86,6 +89,7 @@ import { } 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, @@ -1387,6 +1391,12 @@ export function AgentPsdTemplateEditorDialog({ templateMode, onDerivedImageCreated, onRenderedResultCreated, + standalone = false, + onBack, + pendingImages, + activePendingImageId, + onActivePendingImageIdChange, + pendingRenderedResultUrls, }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -1397,6 +1407,12 @@ export function AgentPsdTemplateEditorDialog({ 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( @@ -1408,6 +1424,19 @@ export function AgentPsdTemplateEditorDialog({ ); 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({ @@ -3127,7 +3156,69 @@ export function AgentPsdTemplateEditorDialog({ ); } - return ( + 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}
-

- PSD 模板编辑器 -

+
+

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

+ {standalone && normalizedPendingImages.length > 0 ? ( +

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

+ ) : null} +
{selectedTemplate && ( @@ -3233,6 +3333,8 @@ export function AgentPsdTemplateEditorDialog({ > {selectedTemplate ? (
+ {renderPendingImageQueue()} +
@@ -3368,15 +3470,18 @@ export function AgentPsdTemplateEditorDialog({ )}
) : ( -
- {renderTemplatePoolSection({ - mode: "main", - templates: mainModeTemplates, - })} - {renderTemplatePoolSection({ - mode: "sku", - templates: skuModeTemplates, - })} +
+ {renderPendingImageQueue()} +
+ {renderTemplatePoolSection({ + mode: "main", + templates: mainModeTemplates, + })} + {renderTemplatePoolSection({ + mode: "sku", + templates: skuModeTemplates, + })} +
)} @@ -4425,8 +4530,38 @@ export function AgentPsdTemplateEditorDialog({ )}
- - +
); + + if (standalone) { + return ( +
+
+ {content} +
+
+ ); + } + + return ( + + { + event.preventDefault(); + }} + onInteractOutside={(event) => { + event.preventDefault(); + }} + onPointerDownOutside={(event) => { + event.preventDefault(); + }} + overlayClassName="bg-black/80 backdrop-blur-md" + > + {content} + + + ); } diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 2447cbcff..108097a08 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -1583,6 +1583,7 @@ export function CanvasWorkspace({ return (
getNodeContentScale(node.size, { @@ -4625,7 +4669,11 @@ export function ImageNode({ toast.info("批量处理功能未开启"); return; } - setSceneFusionDialogOpen(true); + if (!canvasId) { + toast.error("当前画布上下文缺失,暂时无法打开批量处理页面"); + return; + } + router.push(`/canvas/${canvasId}/batch`); return; } if (action === "resize") { @@ -7323,6 +7371,7 @@ export function ImageNode({ scrollbarWidth: "none", }} > + {imageAddToChatEnabled ? ( + ) : null} + {imageAddToChatEnabled && hasSelectedImagePrimaryToolbarActions ? (
+ ) : null} + {imagePickObjectEnabled ? ( + ) : null} + {imageEnhanceEnabled ? ( + ) : null} + {imageRemoveBackgroundEnabled ? ( + ) : null} + {imageBoxCutoutEnabled ? ( + ) : null} + {imageRemoveWatermarkEnabled ? ( + ) : null} + {imageResizeEnabled ? ( + ) : null} + {imageTextEditEnabled ? ( + ) : null} + {imageMultiAngleEnabled ? ( + ) : null} - {tripo3dEnabled ? ( + {imageGenerate3dModelEnabled ? ( + ) : null}
, document.body diff --git a/components/scene-fusion-view.tsx b/components/scene-fusion-view.tsx index 18478a8f9..57de12bb0 100644 --- a/components/scene-fusion-view.tsx +++ b/components/scene-fusion-view.tsx @@ -1,6 +1,8 @@ "use client"; import { + ArrowLeft, + Check, Download, Eraser, Image as ImageIcon, @@ -16,9 +18,18 @@ import { } from "lucide-react"; import type React from "react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { toast } from "sonner"; import useSWR from "swr"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -28,6 +39,7 @@ import { SelectItem, SelectTrigger, } from "@/components/ui/select"; +import { createCanvasPsdTemplateLaunchToken, saveCanvasPsdTemplateLaunchPayload } from "@/lib/canvas/psd-template-launch"; import { requestLongTaskResult } from "@/lib/client/long-task"; import { SCENE_FUSION_ASPECT_RATIO_OPTIONS, @@ -70,8 +82,28 @@ interface SceneFusionViewProps { initialProductName?: string; onBatchAddToCanvas?: (imageUrls: string[]) => void | Promise; onBack?: () => void; + canvasId?: string | null; + persistenceKey?: string | null; } +type SceneFusionPersistedState = { + mode: BatchMode; + aspectRatio: string; + imageSize: string; + productImage: string | null; + productName: string; + sceneItems: ProcessItem[]; + multiProductItems: ProcessItem[]; + referenceSceneImage: string | null; + referenceTargetName: string; + referenceCustomPrompt: string; + randomItems: ProcessItem[]; + randomCount: number; + randomPrompt: string; + batchItems: ProcessItem[]; + selectedResultIds: string[]; +}; + const randomBackgroundStyles = [ "现代简约客厅", "北欧卧室", @@ -163,7 +195,11 @@ export function SceneFusionView({ initialProductImage, initialProductName, onBatchAddToCanvas, + onBack, + canvasId, + persistenceKey, }: SceneFusionViewProps) { + const router = useRouter(); const { data: configData } = useSWR("/api/scene-fusion/config", fetcher); const config = configData as | { @@ -238,6 +274,9 @@ export function SceneFusionView({ const [previewImageUrl, setPreviewImageUrl] = useState(null); const [previewImageTitle, setPreviewImageTitle] = useState("处理结果预览"); + const [selectedResultIds, setSelectedResultIds] = useState([]); + const [psdBrandDialogOpen, setPsdBrandDialogOpen] = useState(false); + const [psdBrandName, setPsdBrandName] = useState(""); const productInputRef = useRef(null); const sceneInputRef = useRef(null); @@ -297,6 +336,30 @@ export function SceneFusionView({ }), [aspectRatio, imageSize, showAspectRatioSetting, showImageSizeSetting] ); + const { data: psdTemplateData } = useSWR<{ + success?: boolean; + templates?: Array<{ brandName?: string | null }>; + }>( + psdBrandDialogOpen ? "/api/psd-templates?summary=1" : null, + fetcher + ); + const availablePsdBrands = useMemo( + () => + Array.from( + new Set( + (psdTemplateData?.templates || []) + .map((template) => String(template?.brandName || "").trim()) + .filter(Boolean) + ) + ).sort((left, right) => left.localeCompare(right, "zh-CN")), + [psdTemplateData?.templates] + ); + const selectedCompletedResultItems = useMemo( + () => + completedResultItems.filter((item) => selectedResultIds.includes(item.id)), + [completedResultItems, selectedResultIds] + ); + const hasCanvasPsdLaunch = Boolean(canvasId); const updateProductPreview = ( previewUrl: string | null, @@ -324,6 +387,73 @@ export function SceneFusionView({ } }, [initialProductImage]); + useEffect(() => { + if (!persistenceKey || typeof window === "undefined") { + return; + } + + const raw = window.sessionStorage.getItem(persistenceKey); + if (!raw) { + return; + } + + try { + const parsed = JSON.parse(raw) as Partial | null; + if (!parsed) { + return; + } + + if (parsed.mode) { + setMode(parsed.mode); + } + if (typeof parsed.aspectRatio === "string") { + setAspectRatio(parsed.aspectRatio); + } + if (typeof parsed.imageSize === "string") { + setImageSize(parsed.imageSize); + } + if (typeof parsed.productImage === "string" && parsed.productImage.trim()) { + setProductImage(parsed.productImage); + updateProductPreview(parsed.productImage); + } + if (typeof parsed.productName === "string") { + setProductName(parsed.productName); + } + if (Array.isArray(parsed.sceneItems)) { + setSceneItems(parsed.sceneItems); + } + if (Array.isArray(parsed.multiProductItems)) { + setMultiProductItems(parsed.multiProductItems); + } + if (typeof parsed.referenceSceneImage === "string") { + setReferenceSceneImage(parsed.referenceSceneImage || null); + } + if (typeof parsed.referenceTargetName === "string") { + setReferenceTargetName(parsed.referenceTargetName); + } + if (typeof parsed.referenceCustomPrompt === "string") { + setReferenceCustomPrompt(parsed.referenceCustomPrompt); + } + if (Array.isArray(parsed.randomItems)) { + setRandomItems(parsed.randomItems); + } + if (typeof parsed.randomCount === "number" && Number.isFinite(parsed.randomCount)) { + setRandomCount(Math.max(1, Math.min(MAX_BATCH_ITEMS, parsed.randomCount))); + } + if (typeof parsed.randomPrompt === "string") { + setRandomPrompt(parsed.randomPrompt); + } + if (Array.isArray(parsed.batchItems)) { + setBatchItems(parsed.batchItems); + } + if (Array.isArray(parsed.selectedResultIds)) { + setSelectedResultIds(parsed.selectedResultIds.map((value) => String(value || "").trim()).filter(Boolean)); + } + } catch (error) { + console.warn("[SceneFusionView] Failed to restore persisted state:", error); + } + }, [persistenceKey]); + useEffect(() => { return () => { if (productPreviewObjectUrlRef.current) { @@ -381,6 +511,54 @@ export function SceneFusionView({ showImageSizeSetting, ]); + useEffect(() => { + const validCompletedIds = new Set(completedResultItems.map((item) => item.id)); + setSelectedResultIds((current) => current.filter((id) => validCompletedIds.has(id))); + }, [completedResultItems]); + + useEffect(() => { + if (!persistenceKey || typeof window === "undefined") { + return; + } + + const snapshot: SceneFusionPersistedState = { + mode, + aspectRatio, + imageSize, + productImage, + productName, + sceneItems, + multiProductItems, + referenceSceneImage, + referenceTargetName, + referenceCustomPrompt, + randomItems, + randomCount, + randomPrompt, + batchItems, + selectedResultIds, + }; + + window.sessionStorage.setItem(persistenceKey, JSON.stringify(snapshot)); + }, [ + aspectRatio, + batchItems, + imageSize, + mode, + multiProductItems, + persistenceKey, + productImage, + productName, + randomCount, + randomItems, + randomPrompt, + referenceCustomPrompt, + referenceSceneImage, + referenceTargetName, + sceneItems, + selectedResultIds, + ]); + const handleProductUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; @@ -1377,6 +1555,73 @@ export function SceneFusionView({ } }; + const toggleResultSelection = (itemId: string) => { + setSelectedResultIds((current) => + current.includes(itemId) + ? current.filter((id) => id !== itemId) + : [...current, itemId] + ); + }; + + const handleSelectAllCompletedResults = () => { + setSelectedResultIds(completedResultItems.map((item) => item.id)); + }; + + const handleOpenPsdBrandDialog = () => { + if (!hasCanvasPsdLaunch) { + toast.error("当前批量处理页面暂时无法打开 PSD 套版工作台"); + return; + } + if (selectedCompletedResultItems.length === 0) { + toast.error("请先选择至少一张处理完成的结果图"); + return; + } + if (!psdBrandName.trim() && availablePsdBrands.length === 1) { + setPsdBrandName(availablePsdBrands[0] || ""); + } + setPsdBrandDialogOpen(true); + }; + + const handleLaunchPsdTemplateWorkspace = () => { + const normalizedBrandName = psdBrandName.trim(); + if (!canvasId) { + toast.error("当前未绑定画布,暂时无法打开 PSD 套版页面"); + return; + } + if (!normalizedBrandName) { + toast.error("请先选择品牌信息"); + return; + } + if (selectedCompletedResultItems.length === 0) { + toast.error("请先选择至少一张处理完成的结果图"); + return; + } + + const token = createCanvasPsdTemplateLaunchToken(); + const returnTo = + typeof window !== "undefined" + ? `${window.location.pathname}${window.location.search}` + : `/canvas/${canvasId}/batch`; + + saveCanvasPsdTemplateLaunchPayload(token, { + brandName: normalizedBrandName, + pendingImages: selectedCompletedResultItems + .map((item, index) => ({ + id: item.id, + imageUrl: String(item.originalUrl || item.result || "").trim(), + title: `批量结果 ${index + 1}`, + })) + .filter((item) => item.imageUrl), + activeImageId: selectedCompletedResultItems[0]?.id || null, + returnTo, + templateMode: "main", + createdAt: Date.now(), + }); + + setPsdBrandDialogOpen(false); + router.push(`/canvas/${canvasId}/psd-template?launch=${encodeURIComponent(token)}`); + }; + const openResultPreview = (imageUrl: string, title: string) => { setPreviewImageUrl(imageUrl); setPreviewImageTitle(title); @@ -1392,6 +1637,16 @@ export function SceneFusionView({
+ {onBack ? ( + + ) : null} 批量处理
@@ -2017,9 +2272,36 @@ export function SceneFusionView({
-

处理结果

+
+

处理结果

+ {selectedCompletedResultItems.length > 0 ? ( +

+ 已选 {selectedCompletedResultItems.length} / {completedResultItems.length} 张完成结果 +

+ ) : null} +
{(completedResultItems.length > 0 || onBatchAddToCanvas) && (
+ {completedResultItems.length > 1 ? ( + + ) : null} + {hasCanvasPsdLaunch && completedResultItems.length > 0 ? ( + + ) : null} {onBatchAddToCanvas && completedResultItems.length > 0 && ( +
-
+
+ )}
@@ -2216,6 +2520,88 @@ export function SceneFusionView({
)} + + + + 选择套 PSD 模版品牌 + + 当前会把 {selectedCompletedResultItems.length} 张已选结果图带到 PSD 套版页面,并按这个品牌过滤模板。 + + +
+ {availablePsdBrands.length > 0 ? ( +
+ +
+ {availablePsdBrands.map((brand) => { + const active = psdBrandName.trim() === brand; + return ( + + ); + })} +
+
+ ) : null} +
+ + setPsdBrandName(event.target.value)} + /> +
+
+
待带入图片
+
+ {selectedCompletedResultItems.map((item, index) => { + const previewUrl = item.originalUrl || item.result || item.image; + return ( +
+
+ {`已选结果 +
+
+ 结果 {index + 1} +
+
+ ); + })} +
+
+
+ + + + +
+
); } diff --git a/lib/canvas/psd-template-launch.ts b/lib/canvas/psd-template-launch.ts new file mode 100644 index 000000000..2b6502026 --- /dev/null +++ b/lib/canvas/psd-template-launch.ts @@ -0,0 +1,98 @@ +export type CanvasPsdTemplatePendingImage = { + id: string; + imageUrl: string; + title?: string | null; +}; + +export type CanvasPsdTemplateLaunchPayload = { + brandName: string; + pendingImages: CanvasPsdTemplatePendingImage[]; + activeImageId?: string | null; + returnTo?: string | null; + templateMode?: "main" | "sku" | null; + createdAt: number; +}; + +const STORAGE_PREFIX = "canvas-psd-template-launch:"; + +export function getCanvasPsdTemplateLaunchStorageKey(token: string) { + return `${STORAGE_PREFIX}${token}`; +} + +export function createCanvasPsdTemplateLaunchToken() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + return `launch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +export function saveCanvasPsdTemplateLaunchPayload( + token: string, + payload: CanvasPsdTemplateLaunchPayload +) { + if (typeof window === "undefined") { + return; + } + + window.sessionStorage.setItem( + getCanvasPsdTemplateLaunchStorageKey(token), + JSON.stringify(payload) + ); +} + +export function readCanvasPsdTemplateLaunchPayload( + token: string +): CanvasPsdTemplateLaunchPayload | null { + if (typeof window === "undefined") { + return null; + } + + const raw = window.sessionStorage.getItem( + getCanvasPsdTemplateLaunchStorageKey(token) + ); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as Partial | null; + const pendingImages = Array.isArray(parsed?.pendingImages) + ? parsed.pendingImages + .map((item) => ({ + id: String(item?.id || "").trim(), + imageUrl: String(item?.imageUrl || "").trim(), + title: String(item?.title || "").trim() || null, + })) + .filter((item) => item.id && item.imageUrl) + : []; + + const brandName = String(parsed?.brandName || "").trim(); + if (!brandName || pendingImages.length === 0) { + return null; + } + + return { + brandName, + pendingImages, + activeImageId: String(parsed?.activeImageId || "").trim() || pendingImages[0]?.id || null, + returnTo: String(parsed?.returnTo || "").trim() || null, + templateMode: + parsed?.templateMode === "sku" ? "sku" : parsed?.templateMode === "main" ? "main" : null, + createdAt: + typeof parsed?.createdAt === "number" && Number.isFinite(parsed.createdAt) + ? parsed.createdAt + : Date.now(), + }; + } catch { + return null; + } +} + +export function removeCanvasPsdTemplateLaunchPayload(token: string) { + if (typeof window === "undefined") { + return; + } + + window.sessionStorage.removeItem(getCanvasPsdTemplateLaunchStorageKey(token)); +}