1884 lines
65 KiB
TypeScript
1884 lines
65 KiB
TypeScript
"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 {
|
||
AgentWorkflowPanelNew,
|
||
type AgentWorkflowInputs,
|
||
} from "@/components/agents/AgentWorkflowPanelNew";
|
||
import { AgentGenerationWorkspaceNew } from "@/components/agents/AgentGenerationWorkspaceNew";
|
||
import { AgentWorkflowBrandModelPicker } from "@/components/agents/AgentWorkflowBrandModelPicker";
|
||
import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel";
|
||
import { AgentPresetPanelNew } from "@/components/agents/AgentPresetPanelNew";
|
||
// 深色主题组件 V2 (新版布局)
|
||
import { AgentPresetPanelDarkV2 } from "@/components/agents/AgentPresetPanelDarkV2";
|
||
import { AgentWorkflowPanelDarkV2 } from "@/components/agents/AgentWorkflowPanelDarkV2";
|
||
import { AgentGenerationWorkspaceDarkV2 } from "@/components/agents/AgentGenerationWorkspaceDarkV2";
|
||
import { AgentProjectListSheet } from "@/components/agents/AgentProjectListSheet";
|
||
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 { ClientErrorBoundary } from "@/components/ui/client-error-boundary";
|
||
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 {
|
||
createEmptyAgentWorkflowMaterialSelections,
|
||
normalizeAgentWorkflowMaterialSelections,
|
||
} from "@/lib/agents/workflow-material-config";
|
||
import {
|
||
buildAgentWorkflowComposerContext,
|
||
getAgentWorkflowProgress,
|
||
} from "@/lib/agents/workflow-composer";
|
||
import {
|
||
createEmptyAgentModeWorkspaceState,
|
||
normalizeAgentModeWorkspaceState,
|
||
} from "@/lib/agents/mode-workspace-state";
|
||
import {
|
||
createEmptyAgentGenerationProjectPersistedState,
|
||
createEmptyAgentGenerationProjectWorkspaceState,
|
||
normalizeAgentGenerationProjectPersistedState,
|
||
} from "@/lib/agents/generation-project-state";
|
||
import { agentProjectService } from "@/services/agent-projects";
|
||
import type {
|
||
AgentGenerationProjectWorkspaceState,
|
||
AgentModeWorkspaceState,
|
||
AgentWorkflowComposerContext,
|
||
AgentWorkflowMaterialSelections,
|
||
AgentWorkflowPersistedState,
|
||
AgentWorkflowSelectedBrandModel,
|
||
AgentWorkflowShowcaseConfig,
|
||
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;
|
||
|
||
function buildScopedCanvasChatId(projectId: string, scope: "agent-mode") {
|
||
const normalized = String(projectId || "").trim().toLowerCase().replace(/-/g, "");
|
||
if (!/^[0-9a-f]{32}$/.test(normalized)) {
|
||
return projectId;
|
||
}
|
||
|
||
const scopeSuffix = scope === "agent-mode" ? "a93e17a60001" : "a93e17a60000";
|
||
const scoped = `${normalized.slice(0, 20)}${scopeSuffix}`;
|
||
return [
|
||
scoped.slice(0, 8),
|
||
scoped.slice(8, 12),
|
||
scoped.slice(12, 16),
|
||
scoped.slice(16, 20),
|
||
scoped.slice(20, 32),
|
||
].join("-");
|
||
}
|
||
|
||
function buildDefaultAgentProjectTitle() {
|
||
const now = new Date();
|
||
const month = `${now.getMonth() + 1}`.padStart(2, "0");
|
||
const day = `${now.getDate()}`.padStart(2, "0");
|
||
const hour = `${now.getHours()}`.padStart(2, "0");
|
||
const minute = `${now.getMinutes()}`.padStart(2, "0");
|
||
return `生图项目 ${month}-${day} ${hour}:${minute}`;
|
||
}
|
||
|
||
function areStringArraysEqual(left: string[], right: string[]) {
|
||
if (left === right) return true;
|
||
if (left.length !== right.length) return false;
|
||
for (let index = 0; index < left.length; index += 1) {
|
||
if (left[index] !== right[index]) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
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 shouldOpenBatchProcessing = searchParams.get("batch") === "1";
|
||
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 [workflowMaterials, setWorkflowMaterials] = useState<AgentWorkflowMaterialSelections>(
|
||
() => createEmptyAgentWorkflowMaterialSelections()
|
||
);
|
||
const [workflowSelections, setWorkflowSelections] = useState<
|
||
Partial<Record<AgentWorkflowStepKey, string>>
|
||
>({});
|
||
const [selectedBrandModel, setSelectedBrandModel] =
|
||
useState<AgentWorkflowSelectedBrandModel | null>(null);
|
||
const [selectedAgentPresetId, setSelectedAgentPresetId] = useState<string | null>(null);
|
||
const [currentAgentStage, setCurrentAgentStage] = useState<"workflow" | "mode">("workflow");
|
||
const [agentModeState, setAgentModeState] = useState<AgentModeWorkspaceState>(
|
||
() => createEmptyAgentModeWorkspaceState()
|
||
);
|
||
const [agentWorkspaceState, setAgentWorkspaceState] =
|
||
useState<AgentGenerationProjectWorkspaceState>(
|
||
() => createEmptyAgentGenerationProjectWorkspaceState()
|
||
);
|
||
const [agentProjectTitle, setAgentProjectTitle] = useState("未命名生图项目");
|
||
const [agentProjectThumbnail, setAgentProjectThumbnail] = useState<string | null>(null);
|
||
const [agentProjectResultUrls, setAgentProjectResultUrls] = useState<string[]>([]);
|
||
const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false);
|
||
const [isAgentProjectSaving, setIsAgentProjectSaving] = useState(false);
|
||
const [isAgentProjectSheetOpen, setIsAgentProjectSheetOpen] = useState(false);
|
||
const [workflowHydrationSource, setWorkflowHydrationSource] = useState<
|
||
"pending" | "project" | "local" | "empty"
|
||
>("pending");
|
||
const requestedAgentProjectId = searchParams.get("agentProjectId");
|
||
const agentProjectId = requestedAgentProjectId ? requestedAgentProjectId.trim() : "";
|
||
const hasActiveAgentProject = Boolean(agentProjectId);
|
||
const agentModeChatId = useMemo(
|
||
() => buildScopedCanvasChatId(agentProjectId || id, "agent-mode"),
|
||
[agentProjectId, id]
|
||
);
|
||
|
||
const { data: siteSettingsData } = useSWR<{
|
||
siteName?: string;
|
||
siteLogo?: string | null;
|
||
siteLogoDark?: string | null;
|
||
agentEnabled?: boolean;
|
||
realtimeCanvasEnabled?: boolean;
|
||
realtimeCanvasServiceEnabled?: boolean;
|
||
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
|
||
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,
|
||
agentProjectId: null,
|
||
});
|
||
return;
|
||
}
|
||
|
||
updateCanvasSearchParams({
|
||
mode: "agent",
|
||
agentPresetId: null,
|
||
agentProjectId: agentProjectId || null,
|
||
});
|
||
setIsPanelOpen(true);
|
||
setPanelMode("chat");
|
||
}, [agentProjectId, isAgentMode, updateCanvasSearchParams]);
|
||
|
||
// 加载画布数据
|
||
useLayoutEffect(() => {
|
||
console.log('[Canvas] Loading project:', id);
|
||
|
||
const loadProject = async () => {
|
||
setIsLoading(true);
|
||
setWorkflowHydrationSource("pending");
|
||
setWorkflowInputs({
|
||
primarySku: "",
|
||
secondarySku: "",
|
||
brand: "",
|
||
store: "",
|
||
});
|
||
setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections());
|
||
setWorkflowSelections({});
|
||
setSelectedBrandModel(null);
|
||
setSelectedAgentPresetId(null);
|
||
setAgentModeState(createEmptyAgentModeWorkspaceState());
|
||
setAgentWorkspaceState(createEmptyAgentGenerationProjectWorkspaceState());
|
||
setAgentProjectTitle("未命名生图项目");
|
||
setAgentProjectThumbnail(null);
|
||
setAgentProjectResultUrls([]);
|
||
|
||
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
|
||
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 (!requestedAgentMode && 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(),
|
||
});
|
||
setWorkflowMaterials(
|
||
normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials)
|
||
);
|
||
setWorkflowSelections(persistedWorkflow.selections || {});
|
||
setSelectedBrandModel(
|
||
persistedWorkflow.selectedBrandModel
|
||
? {
|
||
...persistedWorkflow.selectedBrandModel,
|
||
brandName: String(persistedWorkflow.selectedBrandModel.brandName || "").trim(),
|
||
id: String(persistedWorkflow.selectedBrandModel.id || "").trim(),
|
||
imageUrl: String(persistedWorkflow.selectedBrandModel.imageUrl || "").trim(),
|
||
assetId:
|
||
String(persistedWorkflow.selectedBrandModel.assetId || "").trim() || null,
|
||
displayName:
|
||
String(persistedWorkflow.selectedBrandModel.displayName || "").trim() || null,
|
||
mimeType:
|
||
String(persistedWorkflow.selectedBrandModel.mimeType || "").trim() || null,
|
||
sortOrder: Number.isFinite(
|
||
Number(persistedWorkflow.selectedBrandModel.sortOrder)
|
||
)
|
||
? Math.max(
|
||
0,
|
||
Math.min(
|
||
999,
|
||
Math.round(Number(persistedWorkflow.selectedBrandModel.sortOrder))
|
||
)
|
||
)
|
||
: 0,
|
||
}
|
||
: null
|
||
);
|
||
setSelectedAgentPresetId(
|
||
String(persistedWorkflow.selectedAgentPresetId || "").trim() || null
|
||
);
|
||
setCurrentAgentStage(
|
||
persistedWorkflow.currentStage === "mode" ? "mode" : "workflow"
|
||
);
|
||
setAgentModeState(
|
||
normalizeAgentModeWorkspaceState(persistedWorkflow.agentModeState)
|
||
);
|
||
setWorkflowHydrationSource("project");
|
||
} else {
|
||
setWorkflowHydrationSource("empty");
|
||
}
|
||
} else {
|
||
setWorkflowHydrationSource("empty");
|
||
}
|
||
} else {
|
||
// 画布不存在时,创建一个新画布
|
||
console.log('[Canvas] 画布不存在,创建新画布:', id);
|
||
useCanvasStore.getState().setProject(id, requestedAgentMode ? '智能体工作台' : '未命名画布');
|
||
setWorkflowHydrationSource("empty");
|
||
if (!requestedAgentMode) {
|
||
// 保存新画布到数据库
|
||
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, requestedAgentMode]);
|
||
|
||
useEffect(() => {
|
||
const updateViewportWidth = () => setViewportWidth(window.innerWidth);
|
||
updateViewportWidth();
|
||
window.addEventListener("resize", updateViewportWidth);
|
||
return () => window.removeEventListener("resize", updateViewportWidth);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (isAgentMode) {
|
||
setIsPanelOpen(true);
|
||
setPanelMode("chat");
|
||
}
|
||
}, [isAgentMode]);
|
||
|
||
useEffect(() => {
|
||
if (shouldOpenBatchProcessing) {
|
||
setSceneFusionDialogOpen(true);
|
||
}
|
||
}, [shouldOpenBatchProcessing]);
|
||
|
||
const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]);
|
||
const buildPersistedCanvasData = useCallback(() => {
|
||
return {
|
||
...getCanvasState(),
|
||
};
|
||
}, [
|
||
getCanvasState,
|
||
]);
|
||
|
||
const buildPersistedAgentProjectState = useCallback(() => {
|
||
const workflowState: AgentWorkflowPersistedState = {
|
||
inputs: {
|
||
primarySku: workflowInputs.primarySku.trim(),
|
||
secondarySku: workflowInputs.secondarySku.trim(),
|
||
brand: workflowInputs.brand.trim(),
|
||
store: workflowInputs.store.trim(),
|
||
},
|
||
materials: workflowMaterials,
|
||
selections: workflowSelections,
|
||
selectedBrandModel,
|
||
selectedAgentPresetId: selectedAgentPresetId || null,
|
||
currentStage: currentAgentStage,
|
||
agentModeState,
|
||
};
|
||
|
||
return {
|
||
workflowState,
|
||
workspaceState: agentWorkspaceState,
|
||
resultImageUrls: agentProjectResultUrls,
|
||
};
|
||
}, [
|
||
agentModeState,
|
||
agentProjectResultUrls,
|
||
agentWorkspaceState,
|
||
currentAgentStage,
|
||
selectedAgentPresetId,
|
||
selectedBrandModel,
|
||
workflowInputs,
|
||
workflowMaterials,
|
||
workflowSelections,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (isAgentMode) return;
|
||
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(),
|
||
});
|
||
setWorkflowMaterials(normalizeAgentWorkflowMaterialSelections(parsed.materials));
|
||
setWorkflowSelections(parsed.selections || {});
|
||
setSelectedBrandModel(
|
||
parsed.selectedBrandModel
|
||
? {
|
||
...parsed.selectedBrandModel,
|
||
brandName: String(parsed.selectedBrandModel.brandName || "").trim(),
|
||
id: String(parsed.selectedBrandModel.id || "").trim(),
|
||
imageUrl: String(parsed.selectedBrandModel.imageUrl || "").trim(),
|
||
assetId: String(parsed.selectedBrandModel.assetId || "").trim() || null,
|
||
displayName: String(parsed.selectedBrandModel.displayName || "").trim() || null,
|
||
mimeType: String(parsed.selectedBrandModel.mimeType || "").trim() || null,
|
||
sortOrder: Number.isFinite(Number(parsed.selectedBrandModel.sortOrder))
|
||
? Math.max(0, Math.min(999, Math.round(Number(parsed.selectedBrandModel.sortOrder))))
|
||
: 0,
|
||
}
|
||
: null
|
||
);
|
||
setSelectedAgentPresetId(String(parsed.selectedAgentPresetId || "").trim() || null);
|
||
setCurrentAgentStage(parsed.currentStage === "mode" ? "mode" : "workflow");
|
||
setAgentModeState(normalizeAgentModeWorkspaceState(parsed.agentModeState));
|
||
} catch (error) {
|
||
console.warn("[Canvas] Failed to hydrate workflow state from localStorage:", error);
|
||
} finally {
|
||
setWorkflowHydrationSource("local");
|
||
}
|
||
}, [isAgentMode, workflowHydrationSource, workflowStorageKey]);
|
||
|
||
useEffect(() => {
|
||
if (isAgentMode) return;
|
||
if (typeof window === "undefined" || workflowHydrationSource === "pending") return;
|
||
|
||
try {
|
||
window.localStorage.setItem(
|
||
workflowStorageKey,
|
||
JSON.stringify({
|
||
inputs: workflowInputs,
|
||
materials: workflowMaterials,
|
||
selections: workflowSelections,
|
||
selectedBrandModel,
|
||
selectedAgentPresetId,
|
||
currentAgentStage,
|
||
agentModeState,
|
||
})
|
||
);
|
||
} catch (error) {
|
||
console.warn("[Canvas] Failed to persist workflow state:", error);
|
||
}
|
||
}, [
|
||
isAgentMode,
|
||
workflowHydrationSource,
|
||
workflowInputs,
|
||
workflowMaterials,
|
||
workflowSelections,
|
||
workflowStorageKey,
|
||
selectedBrandModel,
|
||
selectedAgentPresetId,
|
||
currentAgentStage,
|
||
agentModeState,
|
||
]);
|
||
|
||
const persistAgentProject = useCallback(
|
||
async (
|
||
reason: "autosave" | "manual" | "switch",
|
||
overrides?: { title?: string | null }
|
||
) => {
|
||
if (!isAgentMode || !agentProjectId) return false;
|
||
const nextTitle =
|
||
String(overrides?.title ?? agentProjectTitle ?? "").trim() ||
|
||
buildDefaultAgentProjectTitle();
|
||
|
||
const result = await agentProjectService.updateProject(agentProjectId, {
|
||
title: nextTitle,
|
||
thumbnail: agentProjectThumbnail,
|
||
projectState: buildPersistedAgentProjectState(),
|
||
});
|
||
|
||
if (!result.success) {
|
||
console.error(`[Agent Project] ${reason} save failed:`, result.error);
|
||
return false;
|
||
}
|
||
|
||
if (result.data) {
|
||
setAgentProjectTitle(result.data.title || buildDefaultAgentProjectTitle());
|
||
setAgentProjectThumbnail(result.data.thumbnail || null);
|
||
}
|
||
|
||
return true;
|
||
},
|
||
[
|
||
agentProjectId,
|
||
agentProjectThumbnail,
|
||
agentProjectTitle,
|
||
buildPersistedAgentProjectState,
|
||
isAgentMode,
|
||
]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!requestedAgentMode || !isAgentFeatureEnabled || agentProjectId) return;
|
||
|
||
let cancelled = false;
|
||
const createAgentProject = async () => {
|
||
setIsAgentProjectLoading(true);
|
||
try {
|
||
const result = await agentProjectService.createProject({
|
||
title: buildDefaultAgentProjectTitle(),
|
||
projectState: createEmptyAgentGenerationProjectPersistedState(),
|
||
});
|
||
if (!result.success || !result.data) {
|
||
throw new Error(result.error || "创建生图项目失败");
|
||
}
|
||
if (cancelled) return;
|
||
updateCanvasSearchParams({
|
||
mode: "agent",
|
||
agentProjectId: result.data.id,
|
||
});
|
||
} catch (error) {
|
||
if (!cancelled) {
|
||
toast.error(error instanceof Error ? error.message : "创建生图项目失败");
|
||
}
|
||
} finally {
|
||
if (!cancelled) {
|
||
setIsAgentProjectLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
void createAgentProject();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [agentProjectId, isAgentFeatureEnabled, requestedAgentMode, updateCanvasSearchParams]);
|
||
|
||
useEffect(() => {
|
||
if (!requestedAgentMode || !agentProjectId) return;
|
||
|
||
let cancelled = false;
|
||
const loadAgentProject = async () => {
|
||
setIsAgentProjectLoading(true);
|
||
try {
|
||
const result = await agentProjectService.getProject(agentProjectId);
|
||
if (!result.success || !result.data) {
|
||
throw new Error(result.error || "加载生图项目失败");
|
||
}
|
||
if (cancelled) return;
|
||
|
||
const persistedState = normalizeAgentGenerationProjectPersistedState(
|
||
result.data.projectState
|
||
);
|
||
const persistedWorkflow = persistedState.workflowState;
|
||
|
||
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(),
|
||
});
|
||
setWorkflowMaterials(normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials));
|
||
setWorkflowSelections(persistedWorkflow.selections || {});
|
||
setSelectedBrandModel(persistedWorkflow.selectedBrandModel || null);
|
||
setSelectedAgentPresetId(String(persistedWorkflow.selectedAgentPresetId || "").trim() || null);
|
||
setCurrentAgentStage(persistedWorkflow.currentStage === "mode" ? "mode" : "workflow");
|
||
setAgentModeState(normalizeAgentModeWorkspaceState(persistedWorkflow.agentModeState));
|
||
setAgentWorkspaceState(
|
||
persistedState.workspaceState || createEmptyAgentGenerationProjectWorkspaceState()
|
||
);
|
||
setAgentProjectTitle(result.data.title || "未命名生图项目");
|
||
setAgentProjectThumbnail(result.data.thumbnail || null);
|
||
setAgentProjectResultUrls(persistedState.resultImageUrls || []);
|
||
} catch (error) {
|
||
if (!cancelled) {
|
||
toast.error(error instanceof Error ? error.message : "加载生图项目失败");
|
||
}
|
||
} finally {
|
||
if (!cancelled) {
|
||
setIsAgentProjectLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
void loadAgentProject();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [agentProjectId, requestedAgentMode]);
|
||
|
||
useEffect(() => {
|
||
if (!isAgentMode || !agentProjectId || isAgentProjectLoading) return;
|
||
|
||
const timer = window.setTimeout(async () => {
|
||
setIsAgentProjectSaving(true);
|
||
try {
|
||
await persistAgentProject("autosave");
|
||
} finally {
|
||
setIsAgentProjectSaving(false);
|
||
}
|
||
}, 2500);
|
||
|
||
return () => window.clearTimeout(timer);
|
||
}, [
|
||
agentModeState,
|
||
agentProjectId,
|
||
agentProjectResultUrls,
|
||
agentProjectThumbnail,
|
||
agentProjectTitle,
|
||
agentWorkspaceState,
|
||
currentAgentStage,
|
||
isAgentMode,
|
||
isAgentProjectLoading,
|
||
persistAgentProject,
|
||
selectedAgentPresetId,
|
||
selectedBrandModel,
|
||
workflowInputs,
|
||
workflowMaterials,
|
||
workflowSelections,
|
||
]);
|
||
|
||
const handleManualSaveAgentProject = useCallback(async () => {
|
||
if (!agentProjectId) {
|
||
toast.error("当前还没有可保存的生图项目");
|
||
return;
|
||
}
|
||
|
||
const suggestedTitle =
|
||
String(agentProjectTitle || "").trim() || buildDefaultAgentProjectTitle();
|
||
const promptedTitle = window.prompt("请输入要保存的项目名称", suggestedTitle);
|
||
if (promptedTitle === null) {
|
||
return;
|
||
}
|
||
const nextTitle = promptedTitle.trim();
|
||
if (!nextTitle) {
|
||
toast.error("项目名称不能为空");
|
||
return;
|
||
}
|
||
|
||
setIsAgentProjectSaving(true);
|
||
try {
|
||
const success = await persistAgentProject("manual", { title: nextTitle });
|
||
if (success) {
|
||
toast.success("生图项目已保存");
|
||
} else {
|
||
toast.error("保存生图项目失败");
|
||
}
|
||
} finally {
|
||
setIsAgentProjectSaving(false);
|
||
}
|
||
}, [agentProjectId, persistAgentProject]);
|
||
|
||
const handleRenameAgentProject = useCallback(
|
||
async (targetProjectId: string, currentTitle?: string | null) => {
|
||
const suggestedTitle =
|
||
String(currentTitle || "").trim() || "未命名生图项目";
|
||
const promptedTitle = window.prompt("请输入新的项目名称", suggestedTitle);
|
||
if (promptedTitle === null) {
|
||
return;
|
||
}
|
||
const nextTitle = promptedTitle.trim();
|
||
if (!nextTitle) {
|
||
toast.error("项目名称不能为空");
|
||
return;
|
||
}
|
||
|
||
const result = await agentProjectService.updateProject(targetProjectId, {
|
||
title: nextTitle,
|
||
});
|
||
if (!result.success || !result.data) {
|
||
toast.error(result.error || "更新生图项目名称失败");
|
||
return;
|
||
}
|
||
|
||
if (targetProjectId === agentProjectId) {
|
||
setAgentProjectTitle(result.data.title || nextTitle);
|
||
}
|
||
toast.success("项目名称已更新");
|
||
},
|
||
[agentProjectId]
|
||
);
|
||
|
||
const handleCreateAgentProject = useCallback(async () => {
|
||
if (agentProjectId) {
|
||
await persistAgentProject("switch");
|
||
}
|
||
|
||
const result = await agentProjectService.createProject({
|
||
title: buildDefaultAgentProjectTitle(),
|
||
projectState: createEmptyAgentGenerationProjectPersistedState(),
|
||
});
|
||
if (!result.success || !result.data) {
|
||
toast.error(result.error || "创建生图项目失败");
|
||
return;
|
||
}
|
||
|
||
updateCanvasSearchParams({
|
||
mode: "agent",
|
||
agentProjectId: result.data.id,
|
||
});
|
||
}, [agentProjectId, persistAgentProject, updateCanvasSearchParams]);
|
||
|
||
const handleSelectAgentProject = useCallback(
|
||
async (nextProjectId: string) => {
|
||
if (!nextProjectId || nextProjectId === agentProjectId) return;
|
||
if (agentProjectId) {
|
||
await persistAgentProject("switch");
|
||
}
|
||
updateCanvasSearchParams({
|
||
mode: "agent",
|
||
agentProjectId: nextProjectId,
|
||
});
|
||
},
|
||
[agentProjectId, persistAgentProject, updateCanvasSearchParams]
|
||
);
|
||
|
||
const handleDeleteAgentProject = useCallback(
|
||
async (targetProjectId: string) => {
|
||
const result = await agentProjectService.deleteProject(targetProjectId);
|
||
if (!result.success) {
|
||
toast.error(result.error || "删除生图项目失败");
|
||
return;
|
||
}
|
||
|
||
if (targetProjectId === agentProjectId) {
|
||
const listResult = await agentProjectService.getProjects();
|
||
const nextProject = listResult.success
|
||
? listResult.data?.find((project) => project.id !== targetProjectId) || null
|
||
: null;
|
||
|
||
if (nextProject) {
|
||
updateCanvasSearchParams({
|
||
mode: "agent",
|
||
agentProjectId: nextProject.id,
|
||
});
|
||
} else {
|
||
const createResult = await agentProjectService.createProject({
|
||
title: buildDefaultAgentProjectTitle(),
|
||
projectState: createEmptyAgentGenerationProjectPersistedState(),
|
||
});
|
||
if (createResult.success && createResult.data) {
|
||
updateCanvasSearchParams({
|
||
mode: "agent",
|
||
agentProjectId: createResult.data.id,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
toast.success("生图项目已删除");
|
||
},
|
||
[agentProjectId, updateCanvasSearchParams]
|
||
);
|
||
|
||
const handleAgentResultPreviewUrlsChange = useCallback((urls: string[]) => {
|
||
setAgentProjectResultUrls((current) =>
|
||
areStringArraysEqual(current, urls) ? current : urls
|
||
);
|
||
setAgentProjectThumbnail((current) => {
|
||
const nextThumbnail = urls[0] || null;
|
||
return current === nextThumbnail ? current : nextThumbnail;
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!selectedBrandModel) return;
|
||
const normalizedBrand = workflowInputs.brand.trim().toLowerCase();
|
||
if (!normalizedBrand) {
|
||
setSelectedBrandModel(null);
|
||
return;
|
||
}
|
||
if (selectedBrandModel.brandName.toLowerCase() !== normalizedBrand) {
|
||
setSelectedBrandModel(null);
|
||
}
|
||
}, [selectedBrandModel, workflowInputs.brand]);
|
||
|
||
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 || isAgentMode) 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);
|
||
}, [isAgentMode, nodes, isLoading, persistCanvasState]);
|
||
|
||
// 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚
|
||
useEffect(() => {
|
||
if (isLoading || isAgentMode) {
|
||
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();
|
||
}
|
||
}, [isAgentMode, nodes, isLoading, persistCanvasState]);
|
||
|
||
// 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率
|
||
useEffect(() => {
|
||
if (isLoading || isAgentMode) 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, isAgentMode, isLoading]);
|
||
|
||
// 智能命名与缩略图逻辑
|
||
useEffect(() => {
|
||
if (isAgentMode) return;
|
||
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);
|
||
}, [id, isAgentMode, nodes, projectTitle, 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,
|
||
materials: workflowMaterials,
|
||
selectedBrandModel,
|
||
selections: workflowSelections,
|
||
});
|
||
}, [
|
||
resolvedWorkflowUiConfig,
|
||
selectedBrandModel,
|
||
workflowInputs,
|
||
workflowMaterials,
|
||
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;
|
||
|
||
const agentProjectNavSlot = isAgentMode ? (
|
||
<div className="flex items-center gap-2">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
disabled={isAgentProjectLoading}
|
||
className="h-9 gap-2 border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
|
||
>
|
||
{isAgentProjectSaving ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Save className="h-4 w-4" />
|
||
)}
|
||
项目管理
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="end"
|
||
className="w-44 border-white/10 bg-[#0b0d0f] text-white"
|
||
>
|
||
<DropdownMenuItem
|
||
disabled={!hasActiveAgentProject || isAgentProjectLoading || isAgentProjectSaving}
|
||
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
|
||
onSelect={(event) => {
|
||
event.preventDefault();
|
||
void handleManualSaveAgentProject();
|
||
}}
|
||
>
|
||
<Save className="mr-2 h-4 w-4" />
|
||
保存项目
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator className="bg-white/10" />
|
||
<DropdownMenuItem
|
||
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
|
||
onSelect={(event) => {
|
||
event.preventDefault();
|
||
setIsAgentProjectSheetOpen(true);
|
||
}}
|
||
>
|
||
<Grid3X3 className="mr-2 h-4 w-4" />
|
||
项目列表
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
<AgentProjectListSheet
|
||
open={isAgentProjectSheetOpen}
|
||
onOpenChange={setIsAgentProjectSheetOpen}
|
||
currentProjectId={agentProjectId || null}
|
||
onCreateProject={handleCreateAgentProject}
|
||
onSelectProject={handleSelectAgentProject}
|
||
onDeleteProject={handleDeleteAgentProject}
|
||
onRenameProject={handleRenameAgentProject}
|
||
/>
|
||
</div>
|
||
) : null;
|
||
|
||
if (isAgentMode) {
|
||
// 深色主题智能体模式 V2 - 完整功能版本
|
||
|
||
// 第一步:工作流配置(品牌/店铺/步骤选择)
|
||
if (currentAgentStage === "workflow") {
|
||
return (
|
||
<AgentWorkflowPanelDarkV2
|
||
config={resolvedWorkflowUiConfig}
|
||
inputs={workflowInputs}
|
||
onInputsChange={setWorkflowInputs}
|
||
materials={workflowMaterials}
|
||
onMaterialsChange={setWorkflowMaterials}
|
||
selections={workflowSelections}
|
||
onSelectionsChange={setWorkflowSelections}
|
||
siteName={siteSettingsData?.siteName}
|
||
siteLogo={siteSettingsData?.siteLogo}
|
||
siteLogoDark={siteSettingsData?.siteLogoDark}
|
||
showcaseConfig={siteSettingsData?.agentWorkflowShowcaseConfig}
|
||
topNavRightSlot={agentProjectNavSlot}
|
||
onNextStep={() => setCurrentAgentStage("mode")}
|
||
onSkipStep={() => setCurrentAgentStage("mode")}
|
||
onReset={() => {
|
||
setWorkflowInputs({ primarySku: "", secondarySku: "", brand: "", store: "" });
|
||
setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections());
|
||
setWorkflowSelections({});
|
||
setSelectedBrandModel(null);
|
||
setSelectedAgentPresetId(null);
|
||
setAgentModeState(createEmptyAgentModeWorkspaceState());
|
||
setAgentWorkspaceState(createEmptyAgentGenerationProjectWorkspaceState());
|
||
setCurrentAgentStage("workflow");
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// 第二步:智能体选择 + 生图工作台
|
||
return (
|
||
<AgentGenerationWorkspaceDarkV2
|
||
chatId={agentModeChatId}
|
||
projectId={id}
|
||
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
|
||
selectedBrandModel={selectedBrandModel}
|
||
workflowContext={workflowContext}
|
||
agentModeState={agentModeState}
|
||
onAgentModeStateChange={setAgentModeState}
|
||
workspaceState={agentWorkspaceState}
|
||
onWorkspaceStateChange={setAgentWorkspaceState}
|
||
onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange}
|
||
siteName={siteSettingsData?.siteName}
|
||
siteLogo={siteSettingsData?.siteLogo}
|
||
siteLogoDark={siteSettingsData?.siteLogoDark}
|
||
topNavRightSlot={agentProjectNavSlot}
|
||
syncGeneratedResultsToCanvas={false}
|
||
onBack={() => setCurrentAgentStage("workflow")}
|
||
onOpenCanvasEditor={() => {
|
||
setCurrentAgentStage("mode");
|
||
updateCanvasSearchParams({
|
||
mode: null,
|
||
});
|
||
}}
|
||
onClearPreset={() => setSelectedAgentPresetId(null)}
|
||
// 嵌入的智能体选择面板
|
||
presetPanel={
|
||
<AgentPresetPanelDarkV2
|
||
embedded
|
||
embeddedEntityLabel="生图模式"
|
||
selectedPresetId={selectedAgentPresetId}
|
||
onSelectPreset={setSelectedAgentPresetId}
|
||
/>
|
||
}
|
||
brandModelPanel={
|
||
<AgentWorkflowBrandModelPicker
|
||
brand={workflowInputs.brand}
|
||
selectedBrandModel={selectedBrandModel}
|
||
onSelectBrandModel={setSelectedBrandModel}
|
||
theme="dark"
|
||
/>
|
||
}
|
||
materialSelectionPanel={
|
||
<AgentWorkflowMaterialSelectionPanel
|
||
inputs={workflowInputs}
|
||
onInputsChange={setWorkflowInputs}
|
||
materials={workflowMaterials}
|
||
onMaterialsChange={setWorkflowMaterials}
|
||
disabled={!selectedAgentPresetId}
|
||
theme="dark"
|
||
/>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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"
|
||
agentEnabled={isAgentFeatureEnabled}
|
||
/>
|
||
</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>
|
||
|
||
{isAgentFeatureEnabled ? (
|
||
<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>
|
||
) : null}
|
||
|
||
<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>
|
||
);
|
||
}
|