From ff6381d02ec8562a9701dc1456ab1b6527c5e5e2 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Fri, 6 Nov 2026 13:52:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=90=8E=E7=9A=84=E7=94=BB=E5=B8=83=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=9F=BA=E5=87=86=E5=92=8C=E5=86=B2=E7=AA=81=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[id]/CanvasEditorLegacyPageClient.tsx | 110 +++++++++++++++-- components/canvas/CanvasChatPanel.tsx | 13 +- .../canvas/CanvasRemoteOperationContext.tsx | 19 ++- lib/client/canvas-toolbar-capability.ts | 1 + lib/video-canvas/merge-snapshot.ts | 2 +- tests/e2e/canvas-autosync.test.ts | 116 +++++++++++++++++- 6 files changed, 242 insertions(+), 19 deletions(-) diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 1fb22fb55..ce35bb97a 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -4,7 +4,7 @@ import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue"; import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/client/canvas-draft"; import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot"; import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh"; -import { CanvasRemoteOperationContext } from "@/components/canvas/CanvasRemoteOperationContext"; +import { CanvasRemoteOperationContext, type CanvasRemoteOperation, type CanvasRemoteOperationResult } from "@/components/canvas/CanvasRemoteOperationContext"; import { AlertCircle, AlertTriangle, @@ -1937,6 +1937,7 @@ export default function CanvasEditorLegacyPage({ const canvasReadyRef = useRef(Boolean(initialProjectForCurrentRoute)); const savedCanvasSnapshotRef = useRef<{ id: string; snapshot: string } | null>(null); const canvasSyncErrorRef = useRef(""); + const [canvasSyncConflicts, setCanvasSyncConflicts] = useState([]); const [isEditingTitle, setIsEditingTitle] = useState(false); const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false); const [editTitle, setEditTitle] = useState(""); @@ -3378,13 +3379,17 @@ export default function CanvasEditorLegacyPage({ const base = JSON.stringify({ canvasData }); savedCanvasSnapshotRef.current = { id, snapshot: base }; externalCanvasConflictRef.current = false; + setCanvasSyncConflicts([]); const draft = readCanvasDraft(id); if (draft) { const merged = mergeCanvasSnapshots(draft.base, draft.snapshot, base); if (merged.conflicts.length) { externalCanvasConflictRef.current = true; - useCanvasStore.getState().loadCanvas(JSON.parse(draft.snapshot).canvasData); - toast.warning("同一画布内容在多处被修改,本地草稿已恢复;请保留此页并联系管理员核对冲突。"); + savedCanvasSnapshotRef.current = { id, snapshot: draft.base }; + setCanvasSyncConflicts(merged.conflicts); + canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。"; + useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData); + toast.warning(canvasSyncErrorRef.current); return; } useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData); @@ -5116,6 +5121,30 @@ export default function CanvasEditorLegacyPage({ const canvasPersistenceScope = useRef(id); const canvasRemoteOperationActive = useRef(false); canvasPersistenceScope.current = id; + const applyCanvasRemoteSnapshot = useCallback((result: CanvasRemoteOperationResult) => { + if (result.projectId !== id || canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return false; + const remoteSnapshot = JSON.stringify({ canvasData: result.canvasData }); + const merged = mergeCanvasSnapshots( + savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : remoteSnapshot, + JSON.stringify({ canvasData: buildPersistedCanvasData() }), + remoteSnapshot, + ); + if (merged.changed) useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData); + useCanvasStore.getState().setCanvasRevision(result.updatedAt, result.updatedAt); + if (merged.conflicts.length) { + canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。"; + if (!externalCanvasConflictRef.current) toast.warning(canvasSyncErrorRef.current); + externalCanvasConflictRef.current = true; + setCanvasSyncConflicts(merged.conflicts); + writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current?.snapshot || remoteSnapshot, snapshot: merged.snapshot }); + return false; + } + savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot }; + externalCanvasConflictRef.current = false; + setCanvasSyncConflicts([]); + canvasSyncErrorRef.current = ""; + return true; + }, [buildPersistedCanvasData, id]); const persistCanvasStateNow = useCallback( async (reason: "autosave" | "delete" | "beforeunload") => { if (canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false; @@ -5162,6 +5191,7 @@ export default function CanvasEditorLegacyPage({ if (canvasPersistenceScope.current !== id) return false; } else { externalCanvasConflictRef.current = true; + setCanvasSyncConflicts(merged.conflicts); } } } @@ -5171,7 +5201,7 @@ export default function CanvasEditorLegacyPage({ snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }), }); canvasSyncErrorRef.current = result.status === 409 - ? "画布同步发生冲突,本次任务未提交。当前改动未覆盖服务端内容,请保留此页并联系管理员核对。" + ? "画布同步发生冲突,本次任务未提交,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。" : `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"},本次任务未提交。${retained ? "本地草稿已保留,请检查网络后再次发送。" : "请保留当前页面,检查网络后再次发送,暂勿刷新。"}`; console.error(`[Canvas] ${reason} save failed:`, result.error); } else { @@ -5179,7 +5209,11 @@ export default function CanvasEditorLegacyPage({ savedCanvasSnapshotRef.current = { id, snapshot }; clearCanvasDraft(id); externalCanvasConflictRef.current = false; + setCanvasSyncConflicts([]); externalCanvasConflictNotifiedRef.current = false; + if (result.data?.canvasData && result.data?.updatedAt) { + return applyCanvasRemoteSnapshot({ projectId: id, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt) }); + } if (result.data?.updatedAt) useCanvasStore .getState() .setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt)); @@ -5189,7 +5223,7 @@ export default function CanvasEditorLegacyPage({ if (canvasPersistenceScope.current === id) setIsSaving(false); } }, - [activeCanvasWorkspaceType, buildPersistedCanvasData, id] + [activeCanvasWorkspaceType, applyCanvasRemoteSnapshot, buildPersistedCanvasData, id] ); const persistCanvasState = useCallback( @@ -5198,25 +5232,66 @@ export default function CanvasEditorLegacyPage({ [persistCanvasStateNow] ); + const resolveCanvasSyncConflict = useCallback((choice: "local" | "remote") => { + return canvasSaveQueue.current!.enqueue(async () => { + setIsSaving(true); + try { + const remote = await canvasService.getProject(id); + if (canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return; + if (!remote.success || !remote.data?.canvasData || savedCanvasSnapshotRef.current?.id !== id) { + throw new Error(remote.error || "画布暂时无法读取,改动已保留,请稍后重试同步。"); + } + const remoteSnapshot = JSON.stringify({ canvasData: remote.data.canvasData }); + const merged = mergeCanvasSnapshots( + savedCanvasSnapshotRef.current.snapshot, + JSON.stringify({ canvasData: buildPersistedCanvasData() }), + remoteSnapshot, + choice, + ); + savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot }; + useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData); + const revision = String(remote.data.updatedAt); + useCanvasStore.getState().setCanvasRevision(revision, revision); + externalCanvasConflictRef.current = false; + canvasSyncErrorRef.current = ""; + setCanvasSyncConflicts([]); + if (!(await persistCanvasStateNow("autosave"))) throw new Error(canvasSyncErrorRef.current); + toast.success("画布已合并同步,可以继续操作"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "画布同步失败,改动已保留,请稍后重试。"); + } finally { + if (canvasPersistenceScope.current === id) setIsSaving(false); + } + }); + }, [buildPersistedCanvasData, id, persistCanvasStateNow]); + const runCanvasRemoteOperation = useCallback( - (operation: () => Promise) => { + (operation: CanvasRemoteOperation) => { if (canvasAutosaveTimer.current) { clearTimeout(canvasAutosaveTimer.current); canvasAutosaveTimer.current = null; } return canvasSaveQueue.current!.enqueueOperation( - () => persistCanvasStateNow("autosave"), + async () => { + const saved = await persistCanvasStateNow("autosave"); + if (!saved) throw new Error(canvasSyncErrorRef.current || "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。"); + return true; + }, async () => { canvasRemoteOperationActive.current = true; try { - await operation(); + const result = await operation(); + if (result && result.projectId === id && canvasPersistenceScope.current === id) { + savedCanvasSnapshotRef.current = { id, snapshot: JSON.stringify({ canvasData: result.canvasData }) }; + canvasSyncErrorRef.current = ""; + } } finally { canvasRemoteOperationActive.current = false; } } ); }, - [persistCanvasStateNow] + [persistCanvasStateNow, id] ); const persistCanvasBeforeAgent = useCallback(async () => { @@ -5236,6 +5311,11 @@ export default function CanvasEditorLegacyPage({ return true; }, [persistCanvasState, buildPersistedCanvasData, id]); + const canvasRemoteOperationContext = useMemo(() => ({ + run: runCanvasRemoteOperation, + applySnapshot: applyCanvasRemoteSnapshot, + }), [runCanvasRemoteOperation, applyCanvasRemoteSnapshot]); + useEffect(() => { if (isVideoMode) return; const beforeRefresh = (event: Event) => { @@ -10413,7 +10493,7 @@ export default function CanvasEditorLegacyPage({ } return ( - +
+ {canvasSyncConflicts.length > 0 ? ( +
+
+

画布同步发生冲突

+

本页和云端的同一内容有不同修改。请选择保留哪边的冲突内容,其余图片和修改会合并保留。

+
+ + +
+ ) : null} {/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
0) { const refreshedStore = useCanvasStore.getState(); refreshedStore.selectNodes(createdNodeIds); @@ -8019,7 +8024,7 @@ export function CanvasChatPanel({ return false; } }, - [applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate] + [applyCanvasRemoteSnapshot, applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate] ); const { diff --git a/components/canvas/CanvasRemoteOperationContext.tsx b/components/canvas/CanvasRemoteOperationContext.tsx index 432d6d1b8..e3f35d786 100644 --- a/components/canvas/CanvasRemoteOperationContext.tsx +++ b/components/canvas/CanvasRemoteOperationContext.tsx @@ -1,11 +1,26 @@ "use client"; import { createContext, useContext } from "react"; +import type { CanvasState } from "@/types/canvas"; + +export type CanvasRemoteOperationResult = { + projectId: string; + canvasData: CanvasState; + updatedAt: string; +}; +export type CanvasRemoteOperation = (() => Promise) | (() => Promise); export const CanvasRemoteOperationContext = createContext< - ((operation: () => Promise) => Promise) | null + { + run: (operation: CanvasRemoteOperation) => Promise; + applySnapshot: (result: CanvasRemoteOperationResult) => boolean; + } | null >(null); export function useCanvasRemoteOperation() { - return useContext(CanvasRemoteOperationContext); + return useContext(CanvasRemoteOperationContext)?.run || null; +} + +export function useCanvasRemoteSnapshot() { + return useContext(CanvasRemoteOperationContext)?.applySnapshot || null; } diff --git a/lib/client/canvas-toolbar-capability.ts b/lib/client/canvas-toolbar-capability.ts index 431d71350..6800715ea 100644 --- a/lib/client/canvas-toolbar-capability.ts +++ b/lib/client/canvas-toolbar-capability.ts @@ -63,4 +63,5 @@ export async function executeCanvasToolbarCapability( } current.loadCanvas(JSON.parse(merged.snapshot).canvasData); current.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt)); + return { projectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt) }; } diff --git a/lib/video-canvas/merge-snapshot.ts b/lib/video-canvas/merge-snapshot.ts index 1d854705b..a641346a5 100644 --- a/lib/video-canvas/merge-snapshot.ts +++ b/lib/video-canvas/merge-snapshot.ts @@ -107,5 +107,5 @@ export function mergeCanvasSnapshots( (e: any) => ids.has(e.source) && ids.has(e.target), ); } - return { snapshot: JSON.stringify(snapshot), conflicts }; + return { snapshot: JSON.stringify(snapshot), conflicts, changed: !same(snapshot, local) }; } diff --git a/tests/e2e/canvas-autosync.test.ts b/tests/e2e/canvas-autosync.test.ts index 1afc1297a..d27bf6bdb 100644 --- a/tests/e2e/canvas-autosync.test.ts +++ b/tests/e2e/canvas-autosync.test.ts @@ -10,8 +10,9 @@ async function setup(page: Page, failRead = false) { const state = { canvasData: { nodes: [imageNode("source")], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" }, revision: 1, failSave: false, failRead, conflictNextSave: false, conflictSameNode: false, - writes: [] as any[], metadataWrites: 0, submissions: [] as any[], + writes: [] as any[], metadataWrites: 0, submissions: [] as any[], capabilitySubmissions: [] as any[], appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null, + nativeResultNext: false, }; const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString(); const project = () => ({ id: projectId, title: "同步验证", canvasData: state.canvasData, updatedAt: revision() }); @@ -53,11 +54,23 @@ async function setup(page: Page, failRead = false) { data = { success: true, previousCanvasRevision, data: project() }; } } + } else if (path === `/api/canvas/${projectId}/capabilities` && request.method() === "POST") { + state.capabilitySubmissions.push(request.postDataJSON()); + state.canvasData.nodes.push({ ...imageNode(`enhanced-${state.capabilitySubmissions.length}`), position: { x: 520, y: 130 } }); + state.revision++; + data = { success: true, task: { status: "succeeded" } }; } else if (path === "/api/canvas/agent-chat" && request.method() === "POST") { state.submissions.push(request.postDataJSON()); + let operation = ""; + if (state.nativeResultNext) { + state.nativeResultNext = false; + state.canvasData.nodes.push({ ...imageNode("agent-result"), position: { x: 520, y: 130 } }); + state.revision++; + operation = `data: ${JSON.stringify({ type: "data-canvasOperation", data: { status: "applied", projectId, createdNodeIds: ["agent-result"] } })}\n\n`; + } if (state.holdRequest) await new Promise(resolve => { state.finishRequest = resolve; }); await route.fulfill({ headers: { "content-type": "text/event-stream", "x-vercel-ai-ui-message-stream": "v1" }, body: - 'data: {"type":"start","messageId":"sync-assistant"}\n\ndata: {"type":"text-start","id":"text"}\n\ndata: {"type":"text-delta","id":"text","delta":"已收到请求"}\n\ndata: {"type":"text-end","id":"text"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n' }); + 'data: {"type":"start","messageId":"sync-assistant"}\n\n' + operation + 'data: {"type":"text-start","id":"text"}\n\ndata: {"type":"text-delta","id":"text","delta":"已收到请求"}\n\ndata: {"type":"text-end","id":"text"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n' }); return; } else if (path === "/api/app-version") data = { version: state.appVersion }; else if (path === "/api/files/direct-upload") { @@ -93,6 +106,72 @@ async function moveImage(page: Page) { await page.mouse.up(); } +test("an enhanced result can be moved and archived before the next save without blocking Agent", async ({ page }) => { + const { state, errors } = await setup(page); + await page.locator('[data-canvas-node-id="source"]').click(); + await page.locator('[data-tool-key="enhance"]').click(); + const result = page.locator('[data-canvas-node-id="enhanced-1"]'); + await expect(result).toBeVisible(); + const box = (await result.boundingBox())!; + await page.mouse.move(box.x + 100, box.y + 80); + await page.mouse.down(); + await page.mouse.move(box.x + 165, box.y + 110, { steps: 5 }); + await page.mouse.up(); + state.canvasData.nodes.find(node => node.id === "enhanced-1")!.data.src = `${imageOrigin}/uploads/archived-result.png`; + state.revision++; + const editor = page.getByLabel("描述你的设计需求", { exact: true }); + await editor.fill("保留增强结果的位置,继续分析"); + await editor.press("Enter"); + await expect.poll(() => state.submissions.length).toBe(1); + const saved = state.canvasData.nodes.find(node => node.id === "enhanced-1")!; + expect(saved.position.x).toBeGreaterThan(520); + expect(saved.data.src).toContain("archived-result.png"); + expect(state.capabilitySubmissions).toHaveLength(1); + await page.reload(); + await expect(page.locator('[data-canvas-node-id="enhanced-1"]')).toBeVisible(); + expect(errors).toEqual([]); +}); + +test("toolbar save failures show the cause and recover without a paid submission", async ({ page }) => { + const { state, errors } = await setup(page); + await moveImage(page); + state.failSave = true; + await page.locator('[data-tool-key="enhance"]').click(); + await expect(page.getByText(/画布自动同步失败(HTTP 503)/)).toBeVisible(); + expect(state.capabilitySubmissions).toHaveLength(0); + state.failSave = false; + await page.locator('[data-tool-key="enhance"]').click(); + await expect(page.locator('[data-canvas-node-id="enhanced-1"]')).toBeVisible(); + expect(state.capabilitySubmissions).toHaveLength(1); + expect(errors).toEqual([]); +}); + +test("native Agent results update the save baseline before moving and archiving the result", async ({ page }) => { + const { state, errors } = await setup(page); + state.nativeResultNext = true; + const editor = page.getByLabel("描述你的设计需求", { exact: true }); + await editor.fill("先分析再处理图片"); + await editor.press("Enter"); + const result = page.locator('[data-canvas-node-id="agent-result"]'); + await expect(result).toBeVisible(); + await expect(editor).toBeEditable(); + const box = (await result.boundingBox())!; + await page.mouse.move(box.x + 100, box.y + 80); + await page.mouse.down(); + await page.mouse.move(box.x + 165, box.y + 110, { steps: 5 }); + await page.mouse.up(); + state.canvasData.nodes.find(node => node.id === "agent-result")!.data.src = `${imageOrigin}/uploads/agent-archived.png`; + state.revision++; + await editor.fill("保留位置,继续分析"); + await editor.press("Enter"); + await expect.poll(() => state.submissions.length).toBe(2); + const saved = state.canvasData.nodes.find(node => node.id === "agent-result")!; + expect(saved.position.x).toBeGreaterThan(520); + expect(saved.data.src).toContain("agent-archived.png"); + expect(state.capabilitySubmissions).toHaveLength(0); + expect(errors).toEqual([]); +}); + test("upload naming and consecutive Agent messages sync automatically without manual saves", async ({ page }) => { const { state, errors } = await setup(page); const editor = page.getByLabel("描述你的设计需求", { exact: true }); @@ -169,6 +248,39 @@ test("conflicting edits keep both canvases and do not submit an Agent task", asy expect(errors).toEqual([]); }); +for (const choice of ["local", "remote"] as const) { + test(`an existing conflicting draft can keep the ${choice} edit and resume without losing other images`, async ({ page }) => { + const { state, errors } = await setup(page); + await moveImage(page); + state.conflictSameNode = true; + state.conflictNextSave = true; + const editor = page.getByLabel("描述你的设计需求", { exact: true }); + await editor.fill("继续分析图片"); + await editor.press("Enter"); + await expect(page.getByRole("button", { name: "保留本页修改", exact: true })).toBeVisible(); + state.canvasData.nodes.push(imageNode("remote-addition")); + state.revision++; + await page.reload(); + await expect(page.locator('[data-canvas-node-id="remote-addition"]')).toBeVisible(); + if (choice === "local") { + await page.screenshot({ path: test.info().outputPath("conflict-recovery-desktop.png"), animations: "disabled" }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: test.info().outputPath("conflict-recovery-mobile.png"), animations: "disabled" }); + await page.setViewportSize({ width: 1440, height: 900 }); + } + await page.getByRole("button", { name: choice === "local" ? "保留本页修改" : "保留云端修改", exact: true }).click(); + await expect(page.getByRole("button", { name: "保留本页修改", exact: true })).toHaveCount(0); + await editor.press("Enter"); + await expect.poll(() => state.submissions.length).toBe(1); + const x = state.canvasData.nodes.find(node => node.id === "source")!.position.x; + if (choice === "remote") expect(x).toBe(500); + else { expect(x).toBeGreaterThan(160); expect(x).not.toBe(500); } + expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source", "remote-addition"]); + expect(state.capabilitySubmissions).toHaveLength(0); + expect(errors).toEqual([]); + }); +} + test("a failed canvas read cannot create or save an empty replacement", async ({ page }) => { const { state, errors } = await setup(page, true); await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible(); -- 2.47.2