import { startTransition, useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; import { App } from "antd"; import { useNavigate } from "react-router"; import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, type CanvasAppearance } from "@/lib/canvas/canvas-appearance"; import type { CanvasBackgroundMode } from "@/lib/canvas-theme"; import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage"; import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps"; import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation"; import { listAddedSkills, type Skill } from "@/services/api/skills"; import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync"; import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas"; import type { CanvasHistorySnapshot } from "./use-canvas-history"; type UseCanvasProjectLifecycleOptions = { projectId: string; projectLoaded: boolean; nodes: CanvasNodeData[]; connections: CanvasConnection[]; chatSessions: CanvasAssistantSession[]; activeChatId: string | null; canvasAppearance: CanvasAppearance; backgroundMode: CanvasBackgroundMode; showImageInfo: boolean; viewport: ViewportTransform; nodesRef: MutableRefObject; connectionsRef: MutableRefObject; viewportRef: MutableRefObject; historyPausedRef: MutableRefObject; setNodes: Dispatch>; setConnections: Dispatch>; setChatSessions: Dispatch>; setActiveChatId: Dispatch>; setCanvasAppearance: Dispatch>; setBackgroundMode: Dispatch>; setShowImageInfo: Dispatch>; setViewport: Dispatch>; setProjectLoaded: Dispatch>; resetHistory: (snapshot: CanvasHistorySnapshot) => void; cleanupAssetImages: (options?: unknown) => void; cleanupCanvasFiles: (extra?: unknown) => void; }; export function useCanvasProjectLifecycle({ projectId, projectLoaded, nodes, connections, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo, viewport, nodesRef, connectionsRef, viewportRef, historyPausedRef, setNodes, setConnections, setChatSessions, setActiveChatId, setCanvasAppearance, setBackgroundMode, setShowImageInfo, setViewport, setProjectLoaded, resetHistory, cleanupAssetImages, cleanupCanvasFiles, }: UseCanvasProjectLifecycleOptions) { const { message } = App.useApp(); const navigate = useNavigate(); const hydrated = useCanvasStore((state) => state.hydrated); const sessionHydrated = useUserStore((state) => state.hydrated); const openProject = useCanvasStore((state) => state.openProject); const updateProject = useCanvasStore((state) => state.updateProject); const renameProject = useCanvasStore((state) => state.renameProject); const currentProject = useCanvasStore((state) => state.projects.find((project) => project.id === projectId)); const [addedSkills, setAddedSkills] = useState([]); const [loadError, setLoadError] = useState(""); const [loadAttempt, setLoadAttempt] = useState(0); const viewportSaveTimerRef = useRef | null>(null); useEffect(() => { if (!hydrated || !sessionHydrated) return; let cancelled = false; setProjectLoaded(false); setLoadError(""); const load = async () => { const project = await loadCanvasProjectForEditing(projectId); if (cancelled) return; if (!project) { navigate("/canvas", { replace: true }); return; } const applyRestoredProject = (restoredNodes: CanvasNodeData[], restoredSessions: CanvasAssistantSession[]) => { if (cancelled) return; const fallbackTheme = useThemeStore.getState().theme; const restoredAppearance = project.appearance ? normalizeCanvasAppearance(project.appearance, fallbackTheme) : canvasAppearanceForTheme(fallbackTheme); const snapshot: CanvasHistorySnapshot = { nodes: restoredNodes, connections: project.connections, chatSessions: restoredSessions, activeChatId: project.activeChatId || null, canvasAppearance: restoredAppearance, backgroundMode: project.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE, showImageInfo: project.showImageInfo || false, }; nodesRef.current = snapshot.nodes; connectionsRef.current = snapshot.connections; viewportRef.current = project.viewport; setNodes(snapshot.nodes); setConnections(snapshot.connections); setChatSessions(snapshot.chatSessions); setActiveChatId(snapshot.activeChatId); setCanvasAppearance(snapshot.canvasAppearance); useThemeStore.getState().setTheme(canvasAppearanceBaseTheme(snapshot.canvasAppearance, fallbackTheme)); setBackgroundMode(snapshot.backgroundMode); setShowImageInfo(snapshot.showImageInfo); setViewport(project.viewport); resetHistory(snapshot); setProjectLoaded(true); }; const restore = async () => { const initialNodes = normalizeCanvasNodeTimestamps(resetInterruptedGeneration(project.nodes), { createdAt: project.createdAt, updatedAt: project.updatedAt, }); const initialSessions = project.chatSessions || []; // 先恢复可交互的节点和布局,媒体缓存/资源校验放到后台,避免首屏被远程资源拖住。 startTransition(() => applyRestoredProject(initialNodes, initialSessions)); // 画布媒体由节点自己的视口观察器按需加载;打开时遍历并解析全部节点会让大画布形成 N+1 资源读取。 void hydrateAssistantImages(initialSessions) .then((hydratedSessions) => { if (!cancelled) setChatSessions((current) => mergeHydratedSessions(current, hydratedSessions)); }) .catch(() => { if (!cancelled) message.warning("部分助手会话素材恢复失败,已使用项目记录继续打开"); }); }; await restore(); }; void load().catch((error) => { if (!cancelled) setLoadError(error instanceof Error ? error.message : "读取画布失败,请重试"); }); return () => { cancelled = true; }; }, [hydrated, sessionHydrated, loadAttempt, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setCanvasAppearance, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]); useEffect(() => { if (!projectLoaded) return; let cancelled = false; listAddedSkills() .then(({ skills }) => { if (!cancelled) setAddedSkills(skills); }) .catch(() => { if (!cancelled) setAddedSkills([]); }); return () => { cancelled = true; }; }, [projectLoaded]); useEffect(() => { if (!projectLoaded || historyPausedRef.current) return; updateProject(projectId, { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo }); }, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connections, historyPausedRef, nodes, projectId, projectLoaded, showImageInfo, updateProject]); useEffect(() => { if (!projectLoaded) return; if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current); viewportSaveTimerRef.current = setTimeout(() => { updateProject(projectId, { viewport: viewportRef.current }); viewportSaveTimerRef.current = null; }, 500); return () => { if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current); }; }, [projectId, projectLoaded, updateProject, viewport, viewportRef]); useEffect(() => () => { if (!projectLoaded) return; if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current); updateProject(projectId, { viewport: viewportRef.current }); }, [projectId, projectLoaded, updateProject, viewportRef]); const createAndOpenProject = useCallback(() => { void createCanvasProjectWithRemoteSync(`自由画布 ${useCanvasStore.getState().projects.length + 1}`).then(({ id, syncError }) => { if (syncError) message.warning(syncError instanceof Error ? `画布已在本地创建,云端同步失败:${syncError.message}` : "画布已在本地创建,云端同步失败"); navigate(`/canvas/${id}`); }); }, [message, navigate]); const deleteCurrentProject = useCallback(async () => { const drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []); try { await deleteCanvasProjectsWithRemoteSync([projectId]); } catch (error) { message.error(error instanceof Error ? `删除画布失败:${error.message}` : "删除画布失败,请稍后重试"); return; } if (drawingIds.length) { void Promise.all(drawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId))) .catch(() => message.warning("项目已删除,但部分本地绘图缓存清理失败")); } cleanupAssetImages(); navigate("/canvas"); }, [cleanupAssetImages, message, navigate, nodesRef, projectId]); const renameCurrentProject = useCallback((title: string) => { renameProject(projectId, title); }, [projectId, renameProject]); const saveCanvasProject = useCallback(async (): Promise => { try { updateProject(projectId, { nodes: nodesRef.current, connections: connectionsRef.current, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo, viewport: viewportRef.current, directorScenes: currentProject?.directorScenes || [], }); await flushCanvasStorePersistence(); } catch { message.error("画布保存失败,请稍后重试"); return false; } try { await saveRemoteUserDataNow(); message.success("画布布局和位置已保存"); } catch (error) { // 本地 IndexedDB 已写入;云端失败只排队重试,不能把导入方的画布状态回滚。 message.warning(localSavedRemotePendingMessage("本地画布布局已保存", error)); } return true; }, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]); const clearCanvasFiles = useCallback(() => { cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] }); }, [cleanupCanvasFiles, projectId]); return { loadError, retryLoad: () => setLoadAttempt((attempt) => attempt + 1), addedSkills, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, saveCanvasProject, updateProject, }; } function mergeHydratedSessions(currentSessions: CanvasAssistantSession[], hydratedSessions: CanvasAssistantSession[]) { const hydratedById = new Map(hydratedSessions.map((session) => [session.id, session])); return currentSessions.map((session) => { const hydrated = hydratedById.get(session.id); if (!hydrated) return session; const hydratedMessages = new Map(hydrated.messages.map((message) => [message.id, message])); return { ...session, messages: session.messages.map((message) => { const hydratedMessage = hydratedMessages.get(message.id); if (!hydratedMessage || !message.references?.length) return message; const hydratedReferences = new Map((hydratedMessage.references || []).map((reference) => [reference.id, reference])); return { ...message, references: message.references.map((reference) => { const hydratedReference = hydratedReferences.get(reference.id); return hydratedReference ? { ...reference, dataUrl: hydratedReference.dataUrl, storageKey: hydratedReference.storageKey } : reference; }), }; }), }; }); }