feat: expand canvas batch and image workflows
This commit is contained in:
parent
46b8c23975
commit
21a2254428
@ -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<HTMLElement>(
|
||||
'[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<string | null>(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<HTMLElement>(
|
||||
'[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) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isBatchProcessingEnabled && sceneFusionDialogOpen ? (
|
||||
<SceneFusionDialog
|
||||
onBatchAddToCanvas={handleBatchAddToCanvas}
|
||||
onOpenChange={setSceneFusionDialogOpen}
|
||||
open={sceneFusionDialogOpen}
|
||||
/>
|
||||
) : null}
|
||||
{loginDialogElement}
|
||||
{projectLoadingBadge}
|
||||
</div>
|
||||
|
||||
97
app/canvas/[id]/batch/page.tsx
Normal file
97
app/canvas/[id]/batch/page.tsx
Normal file
@ -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 (
|
||||
<div className="h-dvh w-full overflow-hidden bg-background">
|
||||
<SceneFusionView
|
||||
canvasId={id}
|
||||
initialProductImage={
|
||||
selectedImageNode ? String((selectedImageNode.data as any)?.src || "") : null
|
||||
}
|
||||
initialProductName={
|
||||
selectedImageNode ? String((selectedImageNode.data as any)?.title || "") : ""
|
||||
}
|
||||
onBack={handleBack}
|
||||
onBatchAddToCanvas={handleBatchAddToCanvas}
|
||||
persistenceKey={id ? `canvas-scene-fusion:${id}` : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
app/canvas/[id]/psd-template/page.tsx
Normal file
98
app/canvas/[id]/psd-template/page.tsx
Normal file
@ -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<CanvasPsdTemplateLaunchPayload | null>(null);
|
||||
const [activePendingImageId, setActivePendingImageId] = useState<string | null>(null);
|
||||
const [renderedResultUrls, setRenderedResultUrls] = useState<Record<string, string>>({});
|
||||
|
||||
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 (
|
||||
<div className="flex h-dvh w-full items-center justify-center bg-[#050608] px-6 text-white">
|
||||
<div className="w-full max-w-md rounded-[28px] border border-white/10 bg-white/[0.03] p-8 text-center shadow-[0_30px_80px_rgba(0,0,0,0.35)]">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full border border-white/10 bg-white/[0.04]">
|
||||
<FileImage className="h-6 w-6 text-white/70" />
|
||||
</div>
|
||||
<div className="font-semibold text-lg">没有可用的套 PSD 任务</div>
|
||||
<div className="mt-3 text-sm leading-6 text-white/55">
|
||||
当前页面没有接收到批量处理结果图。请先从批量处理结果区选择图片,再进入套 PSD 模版页面。
|
||||
</div>
|
||||
<Button className="mt-6" onClick={handleBack}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回批量处理
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentPsdTemplateEditorDialog
|
||||
activePendingImageId={activePendingImage.id}
|
||||
brandName={launchPayload.brandName}
|
||||
key={`${launchToken}:${activePendingImage.id}`}
|
||||
onActivePendingImageIdChange={setActivePendingImageId}
|
||||
onBack={handleBack}
|
||||
onOpenChange={(open) => {
|
||||
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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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<string, string | undefined> | 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 (
|
||||
<div className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.03] p-3 shadow-inner">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Images className="h-4 w-4 text-emerald-300" />
|
||||
<div className="font-semibold text-sm text-white">
|
||||
待处理图片
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-white/60">
|
||||
{normalizedPendingImages.length} 张
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{normalizedPendingImages.map((image, index) => {
|
||||
const isActive = activePendingImageId === image.id;
|
||||
const renderedResultUrl = pendingRenderedResultUrls?.[image.id];
|
||||
return (
|
||||
<button
|
||||
key={`pending-psd-image-${image.id}`}
|
||||
type="button"
|
||||
onClick={() => onActivePendingImageIdChange?.(image.id)}
|
||||
className={`flex items-center gap-3 rounded-xl border p-2 text-left transition-colors ${
|
||||
isActive
|
||||
? "border-emerald-400/40 bg-emerald-500/10"
|
||||
: "border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.05]"
|
||||
}`}
|
||||
>
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg border border-white/10 bg-black/30">
|
||||
<img
|
||||
alt={image.title || `待处理图片 ${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
src={image.imageUrl}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-[12px] text-white/88">
|
||||
{image.title || `待处理图片 ${index + 1}`}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-white/45">
|
||||
<span>{isActive ? "当前编辑中" : "点击切换"}</span>
|
||||
{renderedResultUrl ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/20 bg-emerald-500/10 px-1.5 py-0.5 text-emerald-200">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
已导出
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
@ -3152,32 +3243,41 @@ export function AgentPsdTemplateEditorDialog({
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className="flex h-[96vh] w-[96vw] max-w-[1700px] flex-col overflow-hidden overscroll-none rounded-[24px] border border-white/12 bg-[#0a0a0a] p-0 text-white shadow-[0_40px_100px_rgba(0,0,0,0.8)]"
|
||||
hideCloseButton
|
||||
onEscapeKeyDown={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
overlayClassName="bg-black/80 backdrop-blur-md"
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden overscroll-none rounded-[24px] border border-white/12 bg-[#0a0a0a] p-0 text-white shadow-[0_40px_100px_rgba(0,0,0,0.8)]">
|
||||
{fontFaceCss ? <style>{fontFaceCss}</style> : null}
|
||||
|
||||
{/* Top Header Bar */}
|
||||
<div className="flex h-16 shrink-0 items-center justify-between border-white/12 border-b bg-[#0a0a0a] px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{standalone ? (
|
||||
<Button
|
||||
className="h-9 w-9 rounded-full border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={() => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/20 text-emerald-400">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<h2 className="font-semibold text-base text-white tracking-wide">
|
||||
PSD 模板编辑器
|
||||
</h2>
|
||||
<div>
|
||||
<h2 className="font-semibold text-base text-white tracking-wide">
|
||||
{standalone ? "PSD 套版工作台" : "PSD 模板编辑器"}
|
||||
</h2>
|
||||
{standalone && normalizedPendingImages.length > 0 ? (
|
||||
<p className="mt-0.5 text-[11px] text-white/45">
|
||||
当前待处理 {normalizedPendingImages.length} 张,品牌:{normalizedBrandName || "未选择"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedTemplate && (
|
||||
@ -3233,6 +3333,8 @@ export function AgentPsdTemplateEditorDialog({
|
||||
>
|
||||
{selectedTemplate ? (
|
||||
<div className="space-y-4">
|
||||
{renderPendingImageQueue()}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
@ -3368,15 +3470,18 @@ export function AgentPsdTemplateEditorDialog({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-h-[640px] gap-5 xl:grid-cols-2">
|
||||
{renderTemplatePoolSection({
|
||||
mode: "main",
|
||||
templates: mainModeTemplates,
|
||||
})}
|
||||
{renderTemplatePoolSection({
|
||||
mode: "sku",
|
||||
templates: skuModeTemplates,
|
||||
})}
|
||||
<div className="space-y-4">
|
||||
{renderPendingImageQueue()}
|
||||
<div className="grid min-h-[640px] gap-5 xl:grid-cols-2">
|
||||
{renderTemplatePoolSection({
|
||||
mode: "main",
|
||||
templates: mainModeTemplates,
|
||||
})}
|
||||
{renderTemplatePoolSection({
|
||||
mode: "sku",
|
||||
templates: skuModeTemplates,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
@ -4425,8 +4530,38 @@ export function AgentPsdTemplateEditorDialog({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (standalone) {
|
||||
return (
|
||||
<div className="h-dvh w-full overflow-hidden bg-[#050608]">
|
||||
<div className="h-full w-full overflow-hidden p-4">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className="flex h-[96vh] w-[96vw] max-w-[1700px] flex-col overflow-hidden overscroll-none rounded-[24px] border border-white/12 bg-transparent p-0 text-white shadow-none"
|
||||
hideCloseButton
|
||||
onEscapeKeyDown={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
overlayClassName="bg-black/80 backdrop-blur-md"
|
||||
>
|
||||
{content}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1583,6 +1583,7 @@ export function CanvasWorkspace({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-canvas-workspace-root="true"
|
||||
className={cn(
|
||||
"relative w-full h-full overflow-hidden overscroll-none touch-none",
|
||||
isWorkflowCanvas ? "bg-[#050607] text-white" : "bg-background",
|
||||
|
||||
@ -41,6 +41,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
@ -1350,6 +1351,13 @@ export function ImageNode({
|
||||
tripo3dEnabled = false,
|
||||
worldlabs3dEnabled = false,
|
||||
}: ImageNodeProps) {
|
||||
const router = useRouter();
|
||||
const routeParams = useParams();
|
||||
const canvasId = Array.isArray(routeParams?.id)
|
||||
? routeParams.id[0] || ""
|
||||
: typeof routeParams?.id === "string"
|
||||
? routeParams.id
|
||||
: "";
|
||||
const data = node.data as ImageNodeData;
|
||||
const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore");
|
||||
const imageAddToChatItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAddToChat");
|
||||
@ -1404,6 +1412,14 @@ export function ImageNode({
|
||||
canvasToolbarConfig,
|
||||
"imageNodeMore"
|
||||
);
|
||||
const imageAddToChatEnabled = isCanvasToolbarItemEnabled(
|
||||
canvasToolbarConfig,
|
||||
"imageAddToChat"
|
||||
);
|
||||
const imagePickObjectEnabled = isCanvasToolbarItemEnabled(
|
||||
canvasToolbarConfig,
|
||||
"imagePickObject"
|
||||
);
|
||||
const imageRemoveBackgroundEnabled = isCanvasToolbarItemEnabled(
|
||||
canvasToolbarConfig,
|
||||
"imageRemoveBackground"
|
||||
@ -1417,6 +1433,10 @@ export function ImageNode({
|
||||
"imageEnhance"
|
||||
);
|
||||
const imageNoteEnabled = isCanvasToolbarItemEnabled(canvasToolbarConfig, "imageNote");
|
||||
const imageBoxCutoutEnabled = isCanvasToolbarItemEnabled(
|
||||
canvasToolbarConfig,
|
||||
"imageBoxCutout"
|
||||
);
|
||||
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
|
||||
canvasToolbarConfig,
|
||||
"imageLayerSplit"
|
||||
@ -1429,6 +1449,14 @@ export function ImageNode({
|
||||
canvasToolbarConfig,
|
||||
"imageMultiAngle"
|
||||
) && angleEditEnabled;
|
||||
const imageGenerate3dModelEnabled = isCanvasToolbarItemEnabled(
|
||||
canvasToolbarConfig,
|
||||
"imageGenerate3dModel"
|
||||
) && tripo3dEnabled;
|
||||
const imageGenerate3dWorldEnabled = isCanvasToolbarItemEnabled(
|
||||
canvasToolbarConfig,
|
||||
"imageGenerate3dWorld"
|
||||
) && worldlabs3dEnabled;
|
||||
const imagePromptRegenerateEnabled = isCanvasToolbarItemEnabled(
|
||||
canvasToolbarConfig,
|
||||
"imagePromptRegenerate"
|
||||
@ -1462,6 +1490,8 @@ export function ImageNode({
|
||||
canvasToolbarConfig,
|
||||
"imageDelete"
|
||||
);
|
||||
const imageLayerSplitEnabled =
|
||||
isLayerSplitEnabled && imageLayerSplitEnabledByToolbar;
|
||||
const hasHoverImageMorePrimaryActions =
|
||||
imageRemoveBackgroundEnabled ||
|
||||
imageRemoveWatermarkEnabled ||
|
||||
@ -1492,6 +1522,20 @@ export function ImageNode({
|
||||
const hasSelectedImageMoreMenu =
|
||||
imageNodeMoreEnabled &&
|
||||
(hasSelectedImageMorePrimaryActions || imageDeleteEnabled);
|
||||
const hasSelectedImagePrimaryToolbarActions =
|
||||
imagePickObjectEnabled ||
|
||||
imageEnhanceEnabled ||
|
||||
imageRemoveBackgroundEnabled ||
|
||||
imageBoxCutoutEnabled ||
|
||||
imageRemoveWatermarkEnabled ||
|
||||
imageResizeEnabled ||
|
||||
imageTextEditEnabled ||
|
||||
imageMultiAngleEnabled ||
|
||||
imageGenerate3dModelEnabled ||
|
||||
imageGenerate3dWorldEnabled ||
|
||||
imageLayerSplitEnabled;
|
||||
const hasSelectedImageSecondaryToolbarActions =
|
||||
hasSelectedImageMoreMenu || imageDownloadEnabled;
|
||||
const contentScale = useMemo(
|
||||
() =>
|
||||
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 ? (
|
||||
<Button
|
||||
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
|
||||
disabled={isProcessing || isAddingToChat}
|
||||
@ -7342,9 +7391,13 @@ export function ImageNode({
|
||||
)}
|
||||
<span className="whitespace-nowrap">{imageAddToChatItem.label}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{imageAddToChatEnabled && hasSelectedImagePrimaryToolbarActions ? (
|
||||
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
||||
) : null}
|
||||
|
||||
{imagePickObjectEnabled ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing || isPickerLoading || isThisNodePicking}
|
||||
@ -7359,7 +7412,9 @@ export function ImageNode({
|
||||
/>
|
||||
{imagePickObjectItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{imageEnhanceEnabled ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
@ -7374,7 +7429,9 @@ export function ImageNode({
|
||||
/>
|
||||
{imageEnhanceItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{imageRemoveBackgroundEnabled ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
@ -7389,7 +7446,9 @@ export function ImageNode({
|
||||
/>
|
||||
{imageRemoveBackgroundItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{imageBoxCutoutEnabled ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
@ -7404,7 +7463,9 @@ export function ImageNode({
|
||||
/>
|
||||
{imageBoxCutoutItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{imageRemoveWatermarkEnabled ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
@ -7419,7 +7480,9 @@ export function ImageNode({
|
||||
/>
|
||||
{imageRemoveWatermarkItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{imageResizeEnabled ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
@ -7434,7 +7497,9 @@ export function ImageNode({
|
||||
/>
|
||||
{imageResizeItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{imageTextEditEnabled ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
|
||||
@ -7449,7 +7514,9 @@ export function ImageNode({
|
||||
/>
|
||||
{imageTextEditItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{imageMultiAngleEnabled ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
@ -7464,8 +7531,9 @@ export function ImageNode({
|
||||
/>
|
||||
{imageMultiAngleItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{tripo3dEnabled ? (
|
||||
{imageGenerate3dModelEnabled ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
@ -7482,7 +7550,7 @@ export function ImageNode({
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{worldlabs3dEnabled ? (
|
||||
{imageGenerate3dWorldEnabled ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
@ -7499,7 +7567,7 @@ export function ImageNode({
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{isLayerSplitEnabled ? (
|
||||
{imageLayerSplitEnabled ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
@ -7516,7 +7584,10 @@ export function ImageNode({
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{hasSelectedImagePrimaryToolbarActions &&
|
||||
hasSelectedImageSecondaryToolbarActions ? (
|
||||
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
||||
) : null}
|
||||
|
||||
{hasSelectedImageMoreMenu ? (
|
||||
<DropdownMenu>
|
||||
@ -7622,6 +7693,7 @@ export function ImageNode({
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{imageDownloadEnabled ? (
|
||||
<Button
|
||||
className="h-10 w-10 rounded-xl text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
@ -7636,6 +7708,7 @@ export function ImageNode({
|
||||
item={imageDownloadItem}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>,
|
||||
document.body
|
||||
|
||||
@ -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<void>;
|
||||
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<string | null>(null);
|
||||
const [previewImageTitle, setPreviewImageTitle] =
|
||||
useState<string>("处理结果预览");
|
||||
const [selectedResultIds, setSelectedResultIds] = useState<string[]>([]);
|
||||
const [psdBrandDialogOpen, setPsdBrandDialogOpen] = useState(false);
|
||||
const [psdBrandName, setPsdBrandName] = useState("");
|
||||
|
||||
const productInputRef = useRef<HTMLInputElement>(null);
|
||||
const sceneInputRef = useRef<HTMLInputElement>(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<SceneFusionPersistedState> | 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<HTMLInputElement>) => {
|
||||
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({
|
||||
<div className="flex min-h-0 w-full shrink-0 flex-col border-r bg-background md:w-[420px]">
|
||||
<div className="flex h-16 shrink-0 items-center border-b bg-background px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{onBack ? (
|
||||
<Button
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={onBack}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Layers className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">批量处理</span>
|
||||
</div>
|
||||
@ -2017,9 +2272,36 @@ export function SceneFusionView({
|
||||
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col bg-background">
|
||||
<div className="flex h-16 shrink-0 items-center justify-between border-b bg-background px-4 pr-14">
|
||||
<h3 className="font-medium">处理结果</h3>
|
||||
<div>
|
||||
<h3 className="font-medium">处理结果</h3>
|
||||
{selectedCompletedResultItems.length > 0 ? (
|
||||
<p className="mt-0.5 text-muted-foreground text-xs">
|
||||
已选 {selectedCompletedResultItems.length} / {completedResultItems.length} 张完成结果
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{(completedResultItems.length > 0 || onBatchAddToCanvas) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{completedResultItems.length > 1 ? (
|
||||
<Button
|
||||
onClick={handleSelectAllCompletedResults}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
全选完成结果
|
||||
</Button>
|
||||
) : null}
|
||||
{hasCanvasPsdLaunch && completedResultItems.length > 0 ? (
|
||||
<Button
|
||||
disabled={selectedCompletedResultItems.length === 0}
|
||||
onClick={handleOpenPsdBrandDialog}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Layers className="mr-2 h-4 w-4" />
|
||||
套PSD模版
|
||||
</Button>
|
||||
) : null}
|
||||
{onBatchAddToCanvas && completedResultItems.length > 0 && (
|
||||
<Button
|
||||
disabled={!resultAllFinished}
|
||||
@ -2156,7 +2438,28 @@ export function SceneFusionView({
|
||||
)}
|
||||
|
||||
{item.status === "completed" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleResultSelection(item.id);
|
||||
}}
|
||||
className={cn(
|
||||
"absolute left-3 top-3 z-10 flex h-8 min-w-8 items-center justify-center rounded-full border bg-black/65 px-2 text-[11px] text-white transition-colors",
|
||||
selectedResultIds.includes(item.id)
|
||||
? "border-emerald-400/55 bg-emerald-500/80"
|
||||
: "border-white/12 hover:border-white/25"
|
||||
)}
|
||||
aria-label={selectedResultIds.includes(item.id) ? "取消选择结果图" : "选择结果图"}
|
||||
>
|
||||
{selectedResultIds.includes(item.id) ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<span>选中</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
@ -2180,7 +2483,8 @@ export function SceneFusionView({
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -2216,6 +2520,88 @@ export function SceneFusionView({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Dialog onOpenChange={setPsdBrandDialogOpen} open={psdBrandDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择套 PSD 模版品牌</DialogTitle>
|
||||
<DialogDescription>
|
||||
当前会把 {selectedCompletedResultItems.length} 张已选结果图带到 PSD 套版页面,并按这个品牌过滤模板。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{availablePsdBrands.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">快捷选择</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availablePsdBrands.map((brand) => {
|
||||
const active = psdBrandName.trim() === brand;
|
||||
return (
|
||||
<button
|
||||
key={brand}
|
||||
type="button"
|
||||
onClick={() => setPsdBrandName(brand)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-sm transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{brand}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scene-fusion-psd-brand-input">品牌名</Label>
|
||||
<Input
|
||||
id="scene-fusion-psd-brand-input"
|
||||
placeholder="请输入风格化页面套 PSD 需要的品牌名"
|
||||
value={psdBrandName}
|
||||
onChange={(event) => setPsdBrandName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/30 p-3">
|
||||
<div className="mb-2 font-medium text-sm">待带入图片</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{selectedCompletedResultItems.map((item, index) => {
|
||||
const previewUrl = item.originalUrl || item.result || item.image;
|
||||
return (
|
||||
<div
|
||||
key={`psd-selected-preview-${item.id}`}
|
||||
className="overflow-hidden rounded-lg border bg-background"
|
||||
>
|
||||
<div className="aspect-square bg-muted/20">
|
||||
<img
|
||||
alt={`已选结果 ${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
src={previewUrl}
|
||||
/>
|
||||
</div>
|
||||
<div className="truncate px-2 py-1 text-[11px] text-muted-foreground">
|
||||
结果 {index + 1}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => setPsdBrandDialogOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleLaunchPsdTemplateWorkspace}>
|
||||
打开套PSD模版页面
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
98
lib/canvas/psd-template-launch.ts
Normal file
98
lib/canvas/psd-template-launch.ts
Normal file
@ -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<CanvasPsdTemplateLaunchPayload> | 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));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user