From 60532713521d2d79ead9d0968cca133720746c58 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 3 Nov 2026 14:39:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=B0=81=E9=9D=A2=E5=92=8C?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E6=9B=B4=E6=96=B0=E5=90=8E=E7=9A=84=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自动封面和标题更新会推进画布版本,但页面未同步,导致后续节点保存持续冲突。元数据写入返回前一版本,客户端仅在与本地版本衔接时推进版本,并与画布保存共用队列;处理结果在合并成功后才更新本地版本。视频画布导入也按节点保存、标题更新顺序执行。 验证:17 项画布能力与同步测试通过,包含封面更新后的保存、不覆盖未同步的远端改动、结果合并冲突和切换画布;TypeScript 检查通过。 --- app/api/canvas/[id]/route.ts | 6 +++- .../[id]/CanvasEditorLegacyPageClient.tsx | 22 ++++++------ lib/client/canvas-toolbar-capability.ts | 6 ++-- services/canvas/index.ts | 11 ++++++ .../routes/canvas-toolbar-capability.test.ts | 35 +++++++++++++++++++ 5 files changed, 64 insertions(+), 16 deletions(-) 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 }); +});