fix(canvas): 画布保存 - 修复重生成媒体绑定死锁并新增手动保存与强制覆盖保存

This commit is contained in:
VicoHu
2026-09-16 15:41:03 +08:00
parent da0531f6eb
commit 38bba7a893
8 changed files with 393 additions and 17 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link } from "react-router";
import { Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
import { Clapperboard, CloudDownload, CloudUpload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Save, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Tooltip } from "antd";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
@@ -25,6 +25,8 @@ type CanvasTopBarProps = {
canRedo: boolean;
onCreateProject: () => void;
onDeleteProject: () => void;
onSave: () => void | Promise<void>;
onForceSave: () => void;
onImportImage: () => void;
onImportLibTV: () => void;
onImportTapNow: () => void;
@@ -52,6 +54,8 @@ export function CanvasTopBar({
canRedo,
onCreateProject,
onDeleteProject,
onSave,
onForceSave,
onImportImage,
onImportLibTV,
onImportTapNow,
@@ -105,6 +109,8 @@ export function CanvasTopBar({
{ type: "divider" },
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
{ key: "save", icon: <Save className="size-4" />, label: <MenuLabel text="保存" shortcut="⌘ S" />, onClick: () => void onSave() },
{ key: "force-save", icon: <CloudUpload className="size-4" />, label: "强制覆盖保存", onClick: onForceSave },
{ type: "divider" },
{ key: "import", icon: <Upload className="size-4" />, label: "导入素材", onClick: onImportImage },
{ key: "search", icon: <Search className="size-4" />, label: <MenuLabel text="搜索节点" shortcut="⌘ F" />, onClick: onOpenSearch },

View File

@@ -415,7 +415,7 @@ function InfiniteCanvasPage() {
[cleanupAssetImages, getHistoryCleanupContext],
);
const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, saveCanvasProject, updateProject } = useCanvasProjectLifecycle({
const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({
projectId,
projectLoaded,
nodes,
@@ -444,6 +444,18 @@ function InfiniteCanvasPage() {
cleanupCanvasFiles,
});
// 强制覆盖会改写云端版本并重绑媒体素材关联,必须让用户显式确认 destructive 语义。
const confirmForceSaveCanvas = useCallback(() => {
modal.confirm({
title: "用本地内容强制覆盖云端?",
content: "将把当前本地画布保存并覆盖云端版本,同时自动修复画布媒体与素材库的绑定(缺少素材记录时会按节点新建)。云端尚未同步到本地的改动会被覆盖。",
okText: "强制覆盖保存",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: () => forceSaveCanvasProject(),
});
}, [forceSaveCanvasProject, modal]);
const applyLibTVImport = useCallback(
async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => {
const previousNodes = nodesRef.current;
@@ -2327,6 +2339,8 @@ function InfiniteCanvasPage() {
canRedo={historyState.canRedo}
onCreateProject={createAndOpenProject}
onDeleteProject={deleteCurrentProject}
onSave={() => void saveCanvasProject()}
onForceSave={confirmForceSaveCanvas}
onImportImage={() => handleUploadRequest()}
onImportLibTV={() => setLibTVImportOpen(true)}
onImportTapNow={() => setTapNowImportOpen(true)}

View File

@@ -9,7 +9,7 @@ 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, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, forceOverwriteRemoteCanvasSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -242,7 +242,7 @@ export function useCanvasProjectLifecycle({
renameProject(projectId, title);
}, [projectId, renameProject]);
const saveCanvasProject = useCallback(async (): Promise<boolean> => {
const persistCanvasSnapshot = useCallback(async (): Promise<boolean> => {
try {
updateProject(projectId, {
nodes: nodesRef.current,
@@ -256,10 +256,15 @@ export function useCanvasProjectLifecycle({
directorScenes: currentProject?.directorScenes || [],
});
await flushCanvasStorePersistence();
return true;
} catch {
message.error("画布保存失败,请稍后重试");
return false;
}
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
const saveCanvasProject = useCallback(async (): Promise<boolean> => {
if (!(await persistCanvasSnapshot())) return false;
try {
await saveRemoteUserDataNow();
message.success("画布布局和位置已保存");
@@ -268,7 +273,18 @@ export function useCanvasProjectLifecycle({
message.warning(localSavedRemotePendingMessage("本地画布布局已保存", error));
}
return true;
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
}, [message, persistCanvasSnapshot]);
const forceSaveCanvasProject = useCallback(async (): Promise<boolean> => {
if (!(await persistCanvasSnapshot())) return false;
try {
const result = await forceOverwriteRemoteCanvasSync();
message.success(result.reboundNodes > 0 ? `已用本地内容覆盖云端,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "已用本地内容覆盖云端画布");
} catch (error) {
message.error(`强制覆盖保存失败:${error instanceof Error ? error.message : "未知错误"}`);
}
return true;
}, [message, persistCanvasSnapshot]);
const clearCanvasFiles = useCallback(() => {
cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] });
@@ -285,6 +301,7 @@ export function useCanvasProjectLifecycle({
deleteCurrentProject,
renameCurrentProject,
saveCanvasProject,
forceSaveCanvasProject,
updateProject,
};
}