diff --git a/app/api/canvas/[id]/route.ts b/app/api/canvas/[id]/route.ts index f2e7338b8..69a799641 100644 --- a/app/api/canvas/[id]/route.ts +++ b/app/api/canvas/[id]/route.ts @@ -207,7 +207,11 @@ export async function PUT( ), ) .returning(); - return NextResponse.json({ success: true, data: saved }); + return NextResponse.json({ + success: true, + data: saved, + previousCanvasRevision: existingProject?.updatedAt.toISOString() ?? null, + }); }); } catch (error) { console.error("更新/创建画布失败:", error); diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index b511dda7c..1a0254020 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -3947,20 +3947,18 @@ export default function CanvasEditorLegacyPage({ loadCanvas(normalized.canvasData); setProjectTitle(nextTitle); - const [saveResult, updateResult] = await Promise.all([ - canvasService.saveCanvasState( + const saveResult = await canvasService.saveCanvasState( id, normalized.canvasData, activeCanvasWorkspaceType - ), - canvasService.updateProject( + ); + const updateResult = await canvasService.updateProject( id, { title: nextTitle, }, activeCanvasWorkspaceType - ), - ]); + ); if (!saveResult.success) { throw new Error(saveResult.error || "导入后保存失败"); @@ -5350,11 +5348,11 @@ export default function CanvasEditorLegacyPage({ try { if (autoThumbnailSyncedRef.current !== autoTitleKey) { - await canvasService.updateProject( + await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject( id, { thumbnail: imageSrc }, activeCanvasWorkspaceType - ); + )); autoThumbnailSyncedRef.current = autoTitleKey; } @@ -5370,11 +5368,11 @@ export default function CanvasEditorLegacyPage({ const data = await res.json(); if (data.title) { setProjectTitle(data.title); - await canvasService.updateProject( + await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject( id, { title: data.title }, activeCanvasWorkspaceType - ); + )); toast.success(`${workspaceLabel}已自动命名为:${data.title}`); } } catch (error) { @@ -5425,11 +5423,11 @@ export default function CanvasEditorLegacyPage({ return; } - const result = await canvasService.updateProject( + const result = await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject( id, { title: editTitle.trim() }, activeCanvasWorkspaceType - ); + )); if (result.success) { setProjectTitle(editTitle.trim()); } else { diff --git a/lib/client/canvas-toolbar-capability.ts b/lib/client/canvas-toolbar-capability.ts index c31e101a8..b3980057d 100644 --- a/lib/client/canvas-toolbar-capability.ts +++ b/lib/client/canvas-toolbar-capability.ts @@ -1,6 +1,5 @@ import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot"; -import { canvasService } from "@/services/canvas"; import { useCanvasStore } from "@/store/canvas"; /** Runs after the editor has saved and while its save queue is reserved. */ @@ -21,8 +20,9 @@ export async function executeCanvasToolbarCapability( throw new Error(payload?.error || "图片处理失败"); } - const result = await canvasService.getProject(projectId); - if (!result.success || !result.data?.canvasData) { + const projectResponse = await fetch(`/api/canvas/${projectId}`); + const result = await projectResponse.json(); + if (!projectResponse.ok || !result.success || !result.data?.canvasData) { throw new Error(result.error || "结果已生成,但画布同步失败"); } const current = useCanvasStore.getState(); diff --git a/services/canvas/index.ts b/services/canvas/index.ts index 28097eb3d..597f7c623 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -248,6 +248,17 @@ export const canvasService = { ), }); const data = await response.json(); + if (data?.success && data?.previousCanvasRevision && data?.data?.updatedAt) { + const current = useCanvasStore.getState(); + // Metadata-only writes advance the same revision used by canvas saves. + // Adopt it only when no unseen canvas revision lies between them. + if ( + current.projectId === id && + current.canvasRevision === data.previousCanvasRevision + ) { + current.setCanvasRevision(String(data.data.updatedAt), String(data.data.updatedAt)); + } + } return data; } catch (error) { return { success: false, error: '更新画布失败' }; diff --git a/tests/routes/canvas-toolbar-capability.test.ts b/tests/routes/canvas-toolbar-capability.test.ts index bb4a03b76..c5d7d72ee 100644 --- a/tests/routes/canvas-toolbar-capability.test.ts +++ b/tests/routes/canvas-toolbar-capability.test.ts @@ -3,6 +3,7 @@ import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue"; import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability"; import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; import { useCanvasStore } from "@/store/canvas"; +import { canvasService } from "@/services/canvas"; const originalFetch = globalThis.fetch; const node = (id: string) => ({ id, type: "image", position: { x: 0, y: 0 }, @@ -79,3 +80,37 @@ test("switching projects during processing does not load the old result into the expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]); expect(useCanvasStore.getState().canvasRevision).toBe("r1"); }); + +test("cover updates advance the local revision before the next queued save", async () => { + const queue = createCanvasSaveQueue(); + globalThis.fetch = (async () => Response.json({ + success: true, previousCanvasRevision: "r1", data: { updatedAt: "r2" }, + })) as typeof fetch; + const cover = queue.enqueue(() => canvasService.updateProject("project-a", { thumbnail: "https://example.com/source.png" })); + const nextSave = queue.enqueue(async () => useCanvasStore.getState().canvasRevision); + await cover; + expect(await nextSave).toBe("r2"); +}); + +test("metadata changes never adopt an unseen remote canvas revision", async () => { + globalThis.fetch = (async () => Response.json({ + success: true, previousCanvasRevision: "external-edit", data: { updatedAt: "r3" }, + })) as typeof fetch; + await canvasService.updateProject("project-a", { title: "Renamed" }); + expect(useCanvasStore.getState().canvasRevision).toBe("r1"); +}); + +test("a result merge conflict retains both the local edit and its original revision", async () => { + globalThis.fetch = (async (url) => { + if (String(url).endsWith("/capabilities")) { + useCanvasStore.getState().moveNode("source", { x: 50, y: 80 }); + return Response.json({ success: true }); + } + return Response.json({ success: true, data: { updatedAt: "r2", canvasData: { + nodes: [{ ...node("source"), position: { x: 200, y: 80 } }, node("result")], edges: [], + } } }); + }) as typeof fetch; + await expect(executeCanvasToolbarCapability(command())).rejects.toThrow("画布有冲突"); + expect(useCanvasStore.getState().canvasRevision).toBe("r1"); + expect(useCanvasStore.getState().nodes[0].position).toEqual({ x: 50, y: 80 }); +});