Your Name 64cb485dfa
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: refine agent workflow flow
2026-03-17 18:23:32 +08:00

1320 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useLayoutEffect, useState, useCallback, use, useRef, useMemo } from "react";
import { useRouter, useParams, useSearchParams } from "next/navigation";
import useSWR from "swr";
import {
AlertCircle,
ArrowLeft,
Save,
Undo2,
Redo2,
ZoomIn,
ZoomOut,
MousePointer,
Hand,
Plus,
ImagePlus,
Type,
Loader2,
MessageCircle,
Download,
Upload,
Wand2,
Grid3X3,
Settings,
PenTool,
Box,
Layers,
Crosshair,
Scan,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from "sonner";
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
import { canvasService, imageGenerationService } from "@/services/canvas";
import {
AgentWorkflowPanel,
type AgentWorkflowInputs,
} from "@/components/agents/AgentWorkflowPanel";
import { AgentPresetPanel } from "@/components/agents/AgentPresetPanel";
import { CanvasWorkspace } from "@/components/canvas";
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
import { ObjectPickerOverlay } from "@/components/canvas/ObjectPickerOverlay";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn, fetcher } from "@/lib/utils";
import {
defaultAgentWorkflowUiConfig,
normalizeAgentWorkflowUiConfig,
} from "@/lib/agents/workflow-ui-config";
import {
buildAgentWorkflowComposerContext,
getAgentWorkflowProgress,
} from "@/lib/agents/workflow-composer";
import type {
AgentWorkflowComposerContext,
AgentWorkflowPersistedState,
AgentWorkflowStepKey,
} from "@/types/agent";
interface PageProps {
params: Promise<{ id: string }>;
}
// 图片尺寸预设
const SIZE_PRESETS = [
{ label: "1:1", value: "1:1", width: 1024, height: 1024 },
{ label: "3:2", value: "3:2", width: 1536, height: 1024 },
{ label: "2:3", value: "2:3", width: 1024, height: 1536 },
{ label: "4:3", value: "4:3", width: 1365, height: 1024 },
{ label: "3:4", value: "3:4", width: 1024, height: 1365 },
{ label: "16:9", value: "16:9", width: 1820, height: 1024 },
{ label: "9:16", value: "9:16", width: 1024, height: 1820 },
];
const FLOATING_PANEL_WIDTH = 460;
const FLOATING_PANEL_INSET = 16;
const DOCKED_PANEL_INSET = 0;
const FLOATING_UI_GAP = 16;
const FLOATING_BOTTOM_INSET = 20;
export default function CanvasEditorPage({ params }: PageProps) {
// 使用 useParams 确保客户端导航时能正确获取更新的 id
const routeParams = useParams();
const searchParams = useSearchParams();
const id = (routeParams?.id as string) || use(params).id;
const router = useRouter();
const mode = searchParams.get("mode");
const goHome = useCallback(() => {
if (typeof window !== "undefined") {
window.location.assign("/");
}
}, []);
// Store state
const projectTitle = useCanvasStore((state) => state.projectTitle);
const viewport = useCanvasStore(canvasSelectors.viewport);
const tool = useCanvasStore(canvasSelectors.tool);
const canUndo = useCanvasStore(canvasSelectors.canUndo);
const canRedo = useCanvasStore(canvasSelectors.canRedo);
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
const nodes = useCanvasStore(canvasSelectors.nodes);
// Store actions
const setProject = useCanvasStore((state) => state.setProject);
const setProjectTitle = useCanvasStore((state) => state.setProjectTitle);
const setTool = useCanvasStore((state) => state.setTool);
const zoomIn = useCanvasStore((state) => state.zoomIn);
const zoomOut = useCanvasStore((state) => state.zoomOut);
const undo = useCanvasStore((state) => state.undo);
const redo = useCanvasStore((state) => state.redo);
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
const clearCanvas = useCanvasStore((state) => state.clearCanvas);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const addNode = useCanvasStore((state) => state.addNode);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const updateNode = useCanvasStore((state) => state.updateNode);
const setGenerating = useCanvasStore((state) => state.setGenerating);
const setPickerMode = useCanvasStore((state) => state.setPickerMode);
const requestImageNodeAction = useCanvasStore((state) => state.requestImageNodeAction);
// Local state
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
const [panelMode, setPanelMode] = useState<'chat' | 'fileList'>('chat');
const [viewportWidth, setViewportWidth] = useState(0);
const [workflowInputs, setWorkflowInputs] = useState<AgentWorkflowInputs>({
primarySku: "",
secondarySku: "",
brand: "",
store: "",
});
const [workflowSelections, setWorkflowSelections] = useState<
Partial<Record<AgentWorkflowStepKey, string>>
>({});
const [selectedAgentPresetId, setSelectedAgentPresetId] = useState<string | null>(null);
const [currentAgentStage, setCurrentAgentStage] = useState<"workflow" | "mode">("workflow");
const [workflowHydrationSource, setWorkflowHydrationSource] = useState<
"pending" | "project" | "local" | "empty"
>("pending");
const { data: siteSettingsData } = useSWR<{
siteName?: string;
siteLogo?: string | null;
siteLogoDark?: string | null;
agentEnabled?: boolean;
realtimeCanvasEnabled?: boolean;
realtimeCanvasServiceEnabled?: boolean;
agentWorkflowUiConfig?: {
steps?: Array<{
key: AgentWorkflowStepKey;
title: string;
description?: string | null;
enabled?: boolean;
sortOrder?: number;
options?: Array<{
key: string;
buttonName: string;
description?: string | null;
previewImageUrl?: string | null;
enabled?: boolean;
sortOrder?: number;
promptFragment?: string | null;
}>;
}>;
};
}>("/api/site-settings", fetcher);
// 生成配置
const [prompt, setPrompt] = useState("");
const [negativePrompt, setNegativePrompt] = useState("");
const [aspectRatio, setAspectRatio] = useState("auto");
const [imageCount, setImageCount] = useState(1);
const previousNodeIdsRef = useRef<Set<string>>(new Set());
const requestedAgentMode = mode === "agent";
const isAgentFeatureEnabled = siteSettingsData?.agentEnabled !== false;
const isAgentMode =
isAgentFeatureEnabled && requestedAgentMode;
// 获取选中的图片节点
const selectedImageNode = selectedNodeIds.length === 1
? nodes.find(n => n.id === selectedNodeIds[0] && n.type === 'image')
: null;
const resolvedPanelWidth = viewportWidth > 0
? Math.min(FLOATING_PANEL_WIDTH, Math.max(viewportWidth - DOCKED_PANEL_INSET, 0))
: FLOATING_PANEL_WIDTH;
const panelOcclusionRight = isPanelOpen ? resolvedPanelWidth + DOCKED_PANEL_INSET : 0;
const floatingToolbarOffset = isPanelOpen ? -(panelOcclusionRight / 2) : 0;
const compactControlsLeft = FLOATING_UI_GAP;
const updateCanvasSearchParams = useCallback(
(updates: Record<string, string | null>) => {
const nextParams = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === "") {
nextParams.delete(key);
} else {
nextParams.set(key, value);
}
});
const nextQuery = nextParams.toString();
router.replace(nextQuery ? `/canvas/${id}?${nextQuery}` : `/canvas/${id}`);
},
[id, router, searchParams]
);
const handleToggleAgentMode = useCallback(() => {
if (isAgentMode) {
updateCanvasSearchParams({
mode: null,
agentPresetId: null,
});
return;
}
updateCanvasSearchParams({
mode: "agent",
agentPresetId: null,
});
setIsPanelOpen(true);
setPanelMode("chat");
}, [isAgentMode, updateCanvasSearchParams]);
// 加载画布数据
useLayoutEffect(() => {
console.log('[Canvas] Loading project:', id);
const loadProject = async () => {
setIsLoading(true);
setWorkflowHydrationSource("pending");
setWorkflowInputs({
primarySku: "",
secondarySku: "",
brand: "",
store: "",
});
setWorkflowSelections({});
setSelectedAgentPresetId(null);
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
useCanvasStore.getState().clearCanvas();
const result = await canvasService.getProject(id);
if (result.success && result.data) {
useCanvasStore.getState().setProject(id, result.data.title);
if (result.data.canvasData) {
useCanvasStore.getState().loadCanvas(result.data.canvasData);
const persistedWorkflow = result.data.canvasData.workflowState;
if (persistedWorkflow) {
setWorkflowInputs({
primarySku: String(persistedWorkflow.inputs?.primarySku || "").trim(),
secondarySku: String(persistedWorkflow.inputs?.secondarySku || "").trim(),
brand: String(persistedWorkflow.inputs?.brand || "").trim(),
store: String(persistedWorkflow.inputs?.store || "").trim(),
});
setWorkflowSelections(persistedWorkflow.selections || {});
setSelectedAgentPresetId(
String(persistedWorkflow.selectedAgentPresetId || "").trim() || null
);
setCurrentAgentStage(
persistedWorkflow.currentStage === "mode" ? "mode" : "workflow"
);
setWorkflowHydrationSource("project");
} else {
setWorkflowHydrationSource("empty");
}
} else {
setWorkflowHydrationSource("empty");
}
} else {
// 画布不存在时,创建一个新画布
console.log('[Canvas] 画布不存在,创建新画布:', id);
useCanvasStore.getState().setProject(id, '未命名画布');
setWorkflowHydrationSource("empty");
// 保存新画布到数据库
try {
await canvasService.saveCanvasState(id, { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select' });
} catch (error) {
console.error('[Canvas] 创建新画布失败:', error);
}
}
setIsLoading(false);
};
loadProject();
}, [id]); // 只依赖 id,使用 getState() 直接调用 store actions
useEffect(() => {
const updateViewportWidth = () => setViewportWidth(window.innerWidth);
updateViewportWidth();
window.addEventListener("resize", updateViewportWidth);
return () => window.removeEventListener("resize", updateViewportWidth);
}, []);
useEffect(() => {
if (isAgentMode) {
setIsPanelOpen(true);
setPanelMode("chat");
}
}, [isAgentMode]);
const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]);
const buildPersistedCanvasData = useCallback(() => {
const workflowState: AgentWorkflowPersistedState = {
inputs: {
primarySku: workflowInputs.primarySku.trim(),
secondarySku: workflowInputs.secondarySku.trim(),
brand: workflowInputs.brand.trim(),
store: workflowInputs.store.trim(),
},
selections: workflowSelections,
selectedAgentPresetId: selectedAgentPresetId || null,
currentStage: currentAgentStage,
};
return {
...getCanvasState(),
workflowState,
};
}, [getCanvasState, workflowInputs, workflowSelections, selectedAgentPresetId]);
useEffect(() => {
if (typeof window === "undefined") return;
if (workflowHydrationSource !== "empty") return;
try {
const raw = window.localStorage.getItem(workflowStorageKey);
if (!raw) {
setWorkflowHydrationSource("local");
return;
}
const parsed = JSON.parse(raw) as AgentWorkflowPersistedState;
setWorkflowInputs({
primarySku: String(parsed.inputs?.primarySku || "").trim(),
secondarySku: String(parsed.inputs?.secondarySku || "").trim(),
brand: String(parsed.inputs?.brand || "").trim(),
store: String(parsed.inputs?.store || "").trim(),
});
setWorkflowSelections(parsed.selections || {});
setSelectedAgentPresetId(String(parsed.selectedAgentPresetId || "").trim() || null);
setCurrentAgentStage(parsed.currentStage === "mode" ? "mode" : "workflow");
} catch (error) {
console.warn("[Canvas] Failed to hydrate workflow state from localStorage:", error);
} finally {
setWorkflowHydrationSource("local");
}
}, [workflowHydrationSource, workflowStorageKey]);
useEffect(() => {
if (typeof window === "undefined" || workflowHydrationSource === "pending") return;
try {
window.localStorage.setItem(
workflowStorageKey,
JSON.stringify({
inputs: workflowInputs,
selections: workflowSelections,
selectedAgentPresetId,
currentAgentStage,
})
);
} catch (error) {
console.warn("[Canvas] Failed to persist workflow state:", error);
}
}, [
workflowHydrationSource,
workflowInputs,
workflowSelections,
workflowStorageKey,
selectedAgentPresetId,
currentAgentStage,
]);
const persistCanvasState = useCallback(
async (reason: "autosave" | "delete" | "beforeunload") => {
const canvasData = buildPersistedCanvasData();
const result = await canvasService.saveCanvasState(id, canvasData);
if (!result.success) {
console.error(`[Canvas] ${reason} save failed:`, result.error);
}
return result.success;
},
[buildPersistedCanvasData, id]
);
// 自动保存逻辑
useEffect(() => {
if (isLoading) return;
const saveTimer = setTimeout(async () => {
setIsSaving(true);
try {
await persistCanvasState("autosave");
} catch (error) {
console.error("Auto save failed:", error);
}
setIsSaving(false);
}, 3000); // 3秒自动保存
return () => clearTimeout(saveTimer);
}, [nodes, workflowInputs, workflowSelections, isLoading, persistCanvasState]);
// 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚
useEffect(() => {
if (isLoading) {
previousNodeIdsRef.current = new Set(nodes.map((node) => node.id));
return;
}
const previousIds = previousNodeIdsRef.current;
const currentIds = new Set(nodes.map((node) => node.id));
const removedIds: string[] = [];
previousIds.forEach((nodeId) => {
if (!currentIds.has(nodeId)) {
removedIds.push(nodeId);
}
});
previousNodeIdsRef.current = currentIds;
if (removedIds.length > 0) {
const persistAfterDelete = async () => {
setIsSaving(true);
try {
await persistCanvasState("delete");
} catch (error) {
console.error("Delete immediate save failed:", error);
}
setIsSaving(false);
};
void persistAfterDelete();
}
}, [nodes, isLoading, persistCanvasState]);
// 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率
useEffect(() => {
if (isLoading) return;
const handleBeforeUnload = () => {
try {
const canvasData = buildPersistedCanvasData();
void fetch(`/api/canvas/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ canvasData }),
keepalive: true,
});
} catch (error) {
console.error("[Canvas] beforeunload save failed:", error);
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("pagehide", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("pagehide", handleBeforeUnload);
};
}, [buildPersistedCanvasData, id, isLoading]);
// 智能命名与缩略图逻辑
useEffect(() => {
const autoTitleAndThumbnail = async () => {
// 如果标题是默认值,且有图片节点
if ((projectTitle === "未命名画布" || projectTitle === "新画布") && nodes.length > 0) {
const imageNode = nodes.find(n => n.type === 'image' && (n.data as any)?.src);
const imageSrc = (imageNode?.data as any)?.src;
if (imageSrc) {
try {
// 1. 设置缩略图
await canvasService.updateProject(id, { thumbnail: imageSrc });
// 2. 智能命名
const res = await fetch('/api/canvas/auto-title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl: imageSrc })
});
const data = await res.json();
if (data.title) {
setProjectTitle(data.title);
await canvasService.updateProject(id, { title: data.title });
toast.success(`画布已自动命名为:${data.title}`);
}
} catch (error) {
console.error("Auto title failed:", error);
}
}
}
};
// 使用防抖,避免频繁触发
const timer = setTimeout(autoTitleAndThumbnail, 5000); // 5秒后触发,给用户一点时间操作
return () => clearTimeout(timer);
}, [nodes, projectTitle, id, setProjectTitle]);
// 保存画布 (手动触发保留作为备用,但UI上移除)
const handleSave = useCallback(async () => {
setIsSaving(true);
const canvasData = buildPersistedCanvasData();
const result = await canvasService.saveCanvasState(id, canvasData);
if (result.success) {
toast.success("保存成功");
} else {
toast.error(result.error || "保存失败");
}
setIsSaving(false);
}, [buildPersistedCanvasData, id]);
// 更新标题
const handleUpdateTitle = async () => {
if (!editTitle.trim() || editTitle === projectTitle) {
setIsEditingTitle(false);
return;
}
const result = await canvasService.updateProject(id, { title: editTitle.trim() });
if (result.success) {
setProjectTitle(editTitle.trim());
} else {
toast.error(result.error || "更新标题失败");
}
setIsEditingTitle(false);
};
// 添加文本节点
const handleAddTextNode = () => {
addNode({
type: 'text',
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
size: { width: 200, height: 100 },
data: { content: '双击编辑文本' },
connections: [],
});
};
// 添加图片节点
const handleAddImageNode = () => {
addNode({
type: 'image',
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
size: { width: 300, height: 300 },
data: {
src: '',
status: 'pending',
title: '新图片',
prompt: '',
},
connections: [],
});
};
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) {
toast.error("请先选择一张图片");
return;
}
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("canvas:image-action", {
detail: {
nodeId: selectedImageNode.id,
action,
},
}),
);
}
};
// AI 生成图片
const handleGenerate = async () => {
if (!prompt.trim()) {
toast.error("请输入提示词");
return;
}
setGenerating(true);
// 先创建占位节点(显示生成中状态)
const placeholderNodeIds: string[] = [];
for (let i = 0; i < imageCount; i++) {
const nodeId = addNode({
type: 'image',
position: {
x: 100 + (i % 3) * 320,
y: 100 + Math.floor(i / 3) * 320
},
size: { width: 300, height: 300 },
data: {
src: '',
status: 'generating',
title: prompt.slice(0, 24) || '生成中',
prompt: prompt,
},
connections: [],
});
placeholderNodeIds.push(nodeId);
}
try {
// 调用 AI 图片生成 API
const result = await imageGenerationService.generateImage({
prompt,
negativePrompt,
aspectRatio,
imageCount,
});
if (result.success && result.data?.images) {
// 更新节点为生成的图片
result.data.images.forEach((image, index) => {
if (placeholderNodeIds[index]) {
updateNode(placeholderNodeIds[index], {
data: {
src: image.originalUrl || image.thumbnailUrl,
status: 'completed',
title: `${prompt.slice(0, 20) || '生成图片'}-${index + 1}`,
prompt: prompt,
},
});
}
});
toast.success(`成功生成 ${result.data.images.length} 张图片`);
setPrompt(""); // 清空提示词
} else {
// 生成失败,更新节点状态
placeholderNodeIds.forEach(nodeId => {
updateNode(nodeId, {
data: {
src: '',
status: 'failed',
title: '生成失败',
prompt: prompt,
},
});
});
toast.error(result.error || "图片生成失败");
}
} catch (error) {
console.error("AI 图片生成失败:", error);
// 生成失败,更新节点状态
placeholderNodeIds.forEach(nodeId => {
updateNode(nodeId, {
data: {
src: '',
status: 'failed',
title: '生成失败',
prompt: prompt,
},
});
});
toast.error("图片生成失败,请重试");
} finally {
setGenerating(false);
}
};
// 键盘快捷键
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"]')
) {
return;
}
// Ctrl/Cmd + S 保存
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
// Ctrl/Cmd + A:当已选中图片时,选中画布内全部图片
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
const { nodes: currentNodes, selectedNodeIds: currentSelectedNodeIds } = useCanvasStore.getState();
const selectedHasImage = currentSelectedNodeIds.some((selectedId) =>
currentNodes.some((node) => node.id === selectedId && node.type === "image")
);
if (selectedHasImage) {
const allImageNodeIds = currentNodes
.filter((node) => node.type === "image" && Boolean((node.data as any)?.src))
.map((node) => node.id);
if (allImageNodeIds.length > 0) {
e.preventDefault();
selectNodes(allImageNodeIds);
}
}
}
// Ctrl/Cmd + Z 撤销
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
undo();
}
// Ctrl/Cmd + Shift + Z 重做
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) {
e.preventDefault();
redo();
}
// V 选择工具
if (e.key === 'v' && !e.ctrlKey && !e.metaKey) {
setTool('select');
}
// H 平移工具
if (e.key === 'h' && !e.ctrlKey && !e.metaKey) {
setTool('pan');
}
// Delete 删除选中
if (e.key === 'Delete' || e.key === 'Backspace') {
useCanvasStore.getState().deleteSelectedNodes();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSave, nodes, redo, selectNodes, selectedNodeIds, setTool, undo]);
useEffect(() => {
const html = document.documentElement;
const body = document.body;
const prevHtmlOverscrollX = html.style.overscrollBehaviorX;
const prevBodyOverscrollX = body.style.overscrollBehaviorX;
html.style.overscrollBehaviorX = "none";
body.style.overscrollBehaviorX = "none";
const preventHistorySwipe = (event: WheelEvent) => {
if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;
if (event.cancelable) {
event.preventDefault();
}
};
window.addEventListener("wheel", preventHistorySwipe, { passive: false });
return () => {
window.removeEventListener("wheel", preventHistorySwipe);
html.style.overscrollBehaviorX = prevHtmlOverscrollX;
body.style.overscrollBehaviorX = prevBodyOverscrollX;
};
}, []);
const resolvedWorkflowUiConfig = useMemo(
() => normalizeAgentWorkflowUiConfig(siteSettingsData?.agentWorkflowUiConfig),
[siteSettingsData?.agentWorkflowUiConfig]
);
const workflowContext = useMemo<AgentWorkflowComposerContext | null>(() => {
return buildAgentWorkflowComposerContext({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
inputs: workflowInputs,
selections: workflowSelections,
});
}, [resolvedWorkflowUiConfig, workflowInputs, workflowSelections]);
const workflowProgress = useMemo(
() =>
getAgentWorkflowProgress({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
selections: workflowSelections,
}),
[resolvedWorkflowUiConfig, workflowSelections]
);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center bg-muted/30">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">加载中...</span>
</div>
</div>
);
}
const brandEntry = (
<div className="absolute top-4 left-4 z-20">
<Button
variant="ghost"
onClick={goHome}
className="h-10 gap-2 rounded-none border-0 bg-transparent px-0 shadow-none hover:bg-transparent"
>
{(siteSettingsData?.siteLogo || siteSettingsData?.siteLogoDark) ? (
<img
src={siteSettingsData?.siteLogo || siteSettingsData?.siteLogoDark || ""}
alt={siteSettingsData?.siteName || "返回首页"}
className="h-7 w-7 object-contain"
draggable={false}
/>
) : (
<Grid3X3 className="h-5 w-5" />
)}
<span className="max-w-[180px] truncate text-base font-semibold text-foreground">
{siteSettingsData?.siteName || "返回首页"}
</span>
</Button>
</div>
);
const agentDisabledNotice =
requestedAgentMode && !isAgentFeatureEnabled ? (
<div className="absolute top-4 left-1/2 z-20 -translate-x-1/2">
<div className="inline-flex items-center gap-2 rounded-full border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-800 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/80 dark:text-amber-200">
<AlertCircle className="h-4 w-4" />
<span>智能体功能未开启,请联系管理员在系统设置中开启。</span>
</div>
</div>
) : null;
if (isAgentMode) {
return (
<TooltipProvider>
<div className="relative h-dvh w-full overflow-hidden bg-background">
{brandEntry}
{agentDisabledNotice}
<div className="absolute left-0 right-0 top-[72px] z-10 border-t border-black/10 dark:border-white/10" />
<div className="absolute inset-0 flex pt-[72px]">
<div className="min-w-0 flex-1 border-r bg-white dark:bg-zinc-950">
{currentAgentStage === "workflow" ? (
<div className="h-full">
<AgentWorkflowPanel
embedded
config={resolvedWorkflowUiConfig}
inputs={workflowInputs}
onInputsChange={setWorkflowInputs}
selections={workflowSelections}
onSelectionsChange={setWorkflowSelections}
onNextStep={() => setCurrentAgentStage("mode")}
onReset={() => {
setWorkflowInputs({
primarySku: "",
secondarySku: "",
brand: "",
store: "",
});
setWorkflowSelections({});
setSelectedAgentPresetId(null);
setCurrentAgentStage("workflow");
}}
/>
</div>
) : (
<div className="flex h-full flex-col">
<div className="border-b p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Wand2 className="h-4 w-4 text-amber-500" />
生图模式
</div>
<div className="mt-1 text-xs text-muted-foreground">
先完成第一步工作流,再选择具体的生图模式模板。
</div>
<div className="mt-2 inline-flex items-center rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground">
第 2 步 / 2 步
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCurrentAgentStage("workflow")}
>
<ArrowLeft className="mr-1 h-4 w-4" />
返回上一步
</Button>
</div>
{!workflowProgress.isComplete ? (
<div className="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
第一步尚未完成,请先返回补全工作流环节。
</div>
) : null}
</div>
<div className="min-h-0 flex-1">
<AgentPresetPanel
embedded
selectedPresetId={selectedAgentPresetId}
onSelectPreset={setSelectedAgentPresetId}
title="生图模式"
description="选择生图模式后,右侧对话框会按其要求显示上传槽位"
searchPlaceholder="搜索生图模式"
publicTitle="公共生图模式"
privateTitle="我的生图模式"
/>
</div>
</div>
)}
</div>
<div
className="shrink-0 border-l bg-white dark:bg-zinc-950"
style={{ width: FLOATING_PANEL_WIDTH }}
>
<div className="h-full overflow-hidden">
<CanvasChatPanel
chatId={`canvas-${id}`}
onClose={() => {}}
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
chatScope="agent"
workflowContext={workflowContext}
/>
</div>
</div>
</div>
</div>
</TooltipProvider>
);
}
return (
<TooltipProvider>
<div className="relative h-dvh w-full overflow-hidden overscroll-none bg-background">
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
<div className="absolute inset-0 z-0" key={`workspace-${id}`} data-canvas-stage="true">
<CanvasWorkspace occlusionRight={panelOcclusionRight} />
</div>
{brandEntry}
{agentDisabledNotice}
{/* 2. Floating Actions (Top Right) */}
{!isPanelOpen && (
<div className="absolute z-20" style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}>
<Button
variant="ghost"
onClick={() => setIsPanelOpen(true)}
className="h-10 rounded-full border bg-background/90 px-3 text-sm font-medium text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background"
>
<MessageCircle className="mr-1.5 h-4 w-4" />
对话
</Button>
</div>
)}
{/* 4. Floating Sidebar (Right) */}
<div className={cn(
"absolute z-10 transition-all duration-300 ease-in-out",
isPanelOpen ? "translate-x-0 opacity-100" : "translate-x-[120%] opacity-0 pointer-events-none"
)}
data-canvas-right-panel="true"
style={{
top: DOCKED_PANEL_INSET,
right: DOCKED_PANEL_INSET,
bottom: DOCKED_PANEL_INSET,
width: FLOATING_PANEL_WIDTH,
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET}px)`,
}}>
<div className="h-full w-full border-l bg-white dark:bg-zinc-950 overflow-hidden flex flex-col">
{panelMode === 'chat' ? (
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
<div className="flex-1 overflow-hidden" key={`chat-${id}`}>
<CanvasChatPanel
chatId={`canvas-${id}`}
onClose={() => setIsPanelOpen(false)}
onOpenFileList={() => setPanelMode('fileList')}
selectedAgentPresetId={null}
chatScope="canvas"
/>
</div>
) : (
// 文件列表
<CanvasFileListPanel
currentId={id}
onClose={() => setPanelMode('chat')}
/>
)}
</div>
</div>
{/* 5. Floating Toolbar (Bottom) */}
<div
className="absolute left-1/2 z-20 -translate-x-1/2"
style={{ bottom: `${FLOATING_BOTTOM_INSET}px` }}
data-canvas-floating-toolbar="true"
>
<div
className="flex items-center gap-1 rounded-[24px] border bg-white px-2.5 py-1.5 shadow-2xl dark:bg-zinc-950"
style={{ marginLeft: `${floatingToolbarOffset}px` }}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={tool === "select" ? "secondary" : "ghost"}
size="icon"
onClick={() => setTool("select")}
className={cn("h-9 w-9 rounded-xl", tool === "select" && "bg-primary text-primary-foreground hover:bg-primary/90")}
>
<MousePointer className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">选择 (V)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={tool === "pan" ? "secondary" : "ghost"}
size="icon"
onClick={() => setTool("pan")}
className={cn("h-9 w-9 rounded-xl", tool === "pan" && "bg-primary text-primary-foreground hover:bg-primary/90")}
>
<Hand className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">平移 (H)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isAgentMode ? "secondary" : "ghost"}
size="icon"
onClick={handleToggleAgentMode}
className={cn(
"h-9 w-9 rounded-xl",
isAgentMode && "bg-primary text-primary-foreground hover:bg-primary/90"
)}
>
<Wand2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">智能体模式</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setSceneFusionDialogOpen(true)}
className="h-9 w-9 rounded-xl"
>
<Layers className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">批量处理</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleAddImageNode}
className="h-9 w-9 rounded-xl"
>
<ImagePlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">添加图片</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleAddTextNode}
className="h-9 w-9 rounded-xl"
>
<Type className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">添加文本</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={!selectedImageNode}
onClick={() => {
if (!selectedImageNode) {
toast.info("请先选中一张图片");
return;
}
setPickerMode(true, selectedImageNode.id);
toast.info("点击图片中的物品进行拾取");
}}
className="h-9 w-9 rounded-xl"
>
<Crosshair className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{selectedImageNode ? "识物" : "先选中图片后识物"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={!selectedImageNode}
onClick={() => {
if (!selectedImageNode) {
toast.info("请先选中一张图片");
return;
}
requestImageNodeAction(selectedImageNode.id, "box-cutout");
}}
className="h-9 w-9 rounded-xl"
>
<Scan className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{selectedImageNode ? "框选抠图" : "先选中图片后框选抠图"}
</TooltipContent>
</Tooltip>
{siteSettingsData?.realtimeCanvasEnabled === true &&
siteSettingsData?.realtimeCanvasServiceEnabled !== false ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/canvas/${id}/realtime`)}
className="h-9 w-9 rounded-xl"
>
<PenTool className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">实时画布</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/canvas/${id}/model3d`)}
className="h-9 w-9 rounded-xl"
>
<Box className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">3D生成</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push('/canvas?new=1')}
className="h-9 w-9 rounded-xl"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">新建画布</TooltipContent>
</Tooltip>
</div>
</div>
{/* 6. Bottom Left Compact Controls */}
<div
className="absolute z-20"
style={{ bottom: `${FLOATING_BOTTOM_INSET + 4}px`, left: `${compactControlsLeft + 8}px` }}
>
<div className="flex items-center gap-1 text-muted-foreground">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={undo}
disabled={!canUndo}
className="h-6 w-6 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
>
<Undo2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">撤销</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={redo}
disabled={!canRedo}
className="h-6 w-6 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
>
<Redo2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">重做</TooltipContent>
</Tooltip>
<div className="mx-1 h-4 w-px bg-border/70" />
<Button
variant="ghost"
size="icon"
onClick={zoomOut}
className="h-6 w-6 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
>
<ZoomOut className="h-3.5 w-3.5" />
</Button>
<span className="w-10 text-center text-[10px] tabular-nums font-medium text-muted-foreground">
{Math.round(viewport.zoom * 100)}%
</span>
<Button
variant="ghost"
size="icon"
onClick={zoomIn}
className="h-6 w-6 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
>
<ZoomIn className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 7. 物品拾取模式覆盖层 */}
<ObjectPickerOverlay />
<SceneFusionDialog
open={sceneFusionDialogOpen}
onOpenChange={setSceneFusionDialogOpen}
onBatchAddToCanvas={handleBatchAddToCanvas}
/>
</div>
</TooltipProvider>
);
}