feat: expand canvas batch and image workflows
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-08 16:24:28 +08:00
parent 46b8c23975
commit 21a2254428
8 changed files with 1131 additions and 121 deletions

View File

@ -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>

View 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>
);
}

View 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"}
/>
);
}

View File

@ -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>
);
}

View File

@ -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",

View File

@ -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

View File

@ -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>
);
}

View 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));
}