diff --git a/services/canvas/index.ts b/services/canvas/index.ts index 21bb0e72c..feff71ada 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -47,6 +47,8 @@ interface ApiResponse { data?: T; error?: string; status?: number; + code?: string; + canvasUpdatedAt?: string; pagination?: { page: number; limit: number; @@ -54,6 +56,22 @@ interface ApiResponse { }; } +function normalizeRevisionText(value: unknown) { + return String(value || "").trim(); +} + +function syncCanvasStoreRevisionFromUpdatedAt(updatedAt: unknown): void { + if (typeof window === "undefined") return; + const revision = normalizeRevisionText(updatedAt); + if (!revision) return; + useCanvasStore.getState().setCanvasRevision(revision, revision); +} + +function syncCanvasStoreRevisionFromProject(project?: Partial | null): void { + if (!project?.canvasData) return; + syncCanvasStoreRevisionFromUpdatedAt(project.updatedAt); +} + export interface NormalizedCropRect { x: number; y: number; @@ -147,6 +165,9 @@ export const canvasService = { try { const response = await fetch(`/api/canvas/${id}`); const data = await response.json(); + if (data?.success) { + syncCanvasStoreRevisionFromProject(data.data); + } return data; } catch (error) { return { success: false, error: '获取画布失败' }; @@ -165,6 +186,9 @@ export const canvasService = { body: JSON.stringify({ ...project, workspaceType }), }); const data = await response.json(); + if (data?.success) { + syncCanvasStoreRevisionFromProject(data.data); + } return data; } catch (error) { return { success: false, error: '创建画布失败' }; @@ -243,8 +267,16 @@ export const canvasService = { success: false, error: String(data?.error || data?.message || '保存画布状态失败'), status: response.status, + code: typeof data?.code === "string" ? data.code : undefined, + canvasUpdatedAt: + typeof data?.canvasUpdatedAt === "string" + ? data.canvasUpdatedAt + : undefined, }; } + if (data?.success) { + syncCanvasStoreRevisionFromUpdatedAt(data?.data?.updatedAt); + } return data as ApiResponse; } catch (error) { return { success: false, error: '保存画布状态失败' }; diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 785ab8c45..c74c5f753 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -514,10 +514,139 @@ test.describe("canvas chat pure helpers", () => { canvasData, workspaceType: "canvas", baseCanvasRevision: "2026-06-22T00:00:12.000Z", - baseCanvasUpdatedAt: "2026-06-22T00:00:10.000Z", + baseCanvasUpdatedAt: "2026-06-22T00:00:11.000Z", }); }); + test("canvas service keeps store revision aligned after project load and save", async () => { + const canvasData = { + nodes: [], + edges: [], + selectedNodeIds: [], + viewport: { x: 0, y: 0, zoom: 1 }, + tool: "select" as const, + }; + const originalFetch = globalThis.fetch; + const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window"); + const originalWindow = (globalThis as typeof globalThis & { window?: unknown }) + .window; + const responses = [ + { + success: true, + data: { + id: "11111111-1111-4111-8111-111111111111", + title: "测试画布", + canvasData, + updatedAt: "2026-06-22T00:01:00.000Z", + }, + }, + { + success: true, + data: { + updatedAt: "2026-06-22T00:01:05.000Z", + }, + }, + ]; + + useCanvasStore.getState().setCanvasRevision(null, null); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: {}, + writable: true, + }); + globalThis.fetch = (async () => + ({ + ok: true, + status: 200, + json: async () => responses.shift()!, + }) as Response) as typeof fetch; + + try { + await canvasService.getProject("11111111-1111-4111-8111-111111111111"); + expect(useCanvasStore.getState().canvasRevision).toBe( + "2026-06-22T00:01:00.000Z" + ); + + await canvasService.saveCanvasState( + "11111111-1111-4111-8111-111111111111", + canvasData + ); + expect(useCanvasStore.getState().canvasRevision).toBe( + "2026-06-22T00:01:05.000Z" + ); + expect(useCanvasStore.getState().canvasUpdatedAt).toBe( + "2026-06-22T00:01:05.000Z" + ); + } finally { + globalThis.fetch = originalFetch; + if (hadWindow) { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + writable: true, + }); + } else { + Reflect.deleteProperty(globalThis, "window"); + } + useCanvasStore.getState().setCanvasRevision(null, null); + } + }); + + test("canvas service exposes stale save conflict details", async () => { + const originalFetch = globalThis.fetch; + const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window"); + const originalWindow = (globalThis as typeof globalThis & { window?: unknown }) + .window; + + Object.defineProperty(globalThis, "window", { + configurable: true, + value: {}, + writable: true, + }); + globalThis.fetch = (async () => + ({ + ok: false, + status: 409, + json: async () => ({ + success: false, + error: "画布已更新,请刷新后再保存", + code: "STALE_CANVAS_REVISION", + canvasUpdatedAt: "2026-06-22T00:02:00.000Z", + }), + }) as Response) as typeof fetch; + + try { + const result = await canvasService.saveCanvasState( + "11111111-1111-4111-8111-111111111111", + { + nodes: [], + edges: [], + selectedNodeIds: [], + viewport: { x: 0, y: 0, zoom: 1 }, + tool: "select", + } + ); + + expect(result).toMatchObject({ + success: false, + status: 409, + code: "STALE_CANVAS_REVISION", + canvasUpdatedAt: "2026-06-22T00:02:00.000Z", + }); + } finally { + globalThis.fetch = originalFetch; + if (hadWindow) { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + writable: true, + }); + } else { + Reflect.deleteProperty(globalThis, "window"); + } + } + }); + const plannerContext: CanvasAgentContext = { version: 1, source: "canvas",