From e335821ca84d4d2635b7cec864732dc259cefc83 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Fri, 6 Nov 2026 16:54:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E7=8A=B6=E6=80=81=E5=88=A4=E5=AE=9A=E5=8F=8A=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E5=88=B7=E6=96=B0=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[id]/CanvasEditorLegacyPageClient.tsx | 37 ++++--- components/app-update-notice.tsx | 1 + components/canvas/CanvasChatPanel.tsx | 16 ++- tests/e2e/canvas-autosync.test.ts | 100 +++++++++++++++++- 4 files changed, 136 insertions(+), 18 deletions(-) diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 937083a21..2efb45b3a 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -5,6 +5,7 @@ import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/clien import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot"; import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh"; import { CanvasRemoteOperationContext, type CanvasRemoteOperation, type CanvasRemoteOperationResult } from "@/components/canvas/CanvasRemoteOperationContext"; +import equal from "fast-deep-equal"; import { AlertCircle, AlertTriangle, @@ -171,6 +172,7 @@ import { type VideoCanvasSymbolMarkerId, } from "@/lib/canvas/video-canvas-markers"; import { + isCanvasImageUploadPending, LOCAL_UPLOAD_PENDING_METADATA_KEY, LOCAL_UPLOAD_PREVIEW_METADATA_KEY, } from "@/lib/canvas/local-upload-state"; @@ -5127,13 +5129,14 @@ export default function CanvasEditorLegacyPage({ canvasPersistenceScope.current = id; const hasUnsavedCanvasChanges = useCallback(() => { if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || savedCanvasSnapshotRef.current?.id !== id) return false; + if (useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) return true; const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData as CanvasState; - const current = buildPersistedCanvasData(); + const current = JSON.parse(JSON.stringify(buildPersistedCanvasData())) as CanvasState; const content = (value: CanvasState) => [value.nodes.map(node => { const { updatedAt: _updatedAt, ...nodeData } = node; return { ...nodeData, connections: node.connections || [] }; - }), value.edges, value.suppressedMediaKeys || [], normalizeStyleProPersistedState(value.styleProState)]; - return JSON.stringify(content(saved)) !== JSON.stringify(content(current)); + }), value.edges || [], value.suppressedMediaKeys || [], normalizeStyleProPersistedState(value.styleProState)]; + return !equal(content(saved), content(current)); }, [buildPersistedCanvasData, id]); const requestCanvasLeave = useCallback((url: string) => { if (hasUnsavedCanvasChanges()) { @@ -5176,7 +5179,7 @@ export default function CanvasEditorLegacyPage({ try { let canvasData = buildPersistedCanvasData(); let snapshot = JSON.stringify({ canvasData }); - if (savedCanvasSnapshotRef.current?.id === id && savedCanvasSnapshotRef.current.snapshot === snapshot) { + if (savedCanvasSnapshotRef.current?.id === id && equal(JSON.parse(savedCanvasSnapshotRef.current.snapshot), JSON.parse(snapshot))) { clearCanvasDraft(id); return true; } @@ -5224,8 +5227,8 @@ export default function CanvasEditorLegacyPage({ snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }), }); canvasSyncErrorRef.current = result.status === 409 - ? "画布同步发生冲突,本次任务未提交,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。" - : `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"},本次任务未提交。${retained ? "本地草稿已保留,请检查网络后再次发送。" : "请保留当前页面,检查网络后再次发送,暂勿刷新。"}`; + ? "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。" + : `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"}。${retained ? "本地草稿已保留,系统会继续自动同步,也可稍后重试。" : "请保留当前页面,检查网络后重试同步,暂勿刷新。"}`; console.error(`[Canvas] ${reason} save failed:`, result.error); } else { canvasSyncErrorRef.current = ""; @@ -5258,6 +5261,10 @@ export default function CanvasEditorLegacyPage({ const finishCanvasLeave = useCallback(async (save: boolean) => { if (!canvasLeaveUrl || isSavingBeforeLeave) return; setCanvasLeaveError(""); + if (save && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) { + setCanvasLeaveError("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再保存并离开。"); + return; + } if (canvasAutosaveTimer.current) { clearTimeout(canvasAutosaveTimer.current); canvasAutosaveTimer.current = null; @@ -5321,7 +5328,7 @@ export default function CanvasEditorLegacyPage({ return canvasSaveQueue.current!.enqueueOperation( async () => { const saved = await persistCanvasStateNow("autosave"); - if (!saved) throw new Error(canvasSyncErrorRef.current || "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。"); + if (!saved) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次处理未提交。` : "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。"); return true; }, async () => { @@ -5343,20 +5350,18 @@ export default function CanvasEditorLegacyPage({ const persistCanvasBeforeAgent = useCallback(async () => { if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || externalCanvasConflictRef.current) - throw new Error(canvasSyncErrorRef.current || "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。"); + throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。"); if (savedCanvasSnapshotRef.current?.id === id) { - const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData; - const current = buildPersistedCanvasData(); - if (JSON.stringify(saved.nodes) === JSON.stringify(current.nodes) && JSON.stringify(saved.edges) === JSON.stringify(current.edges)) return true; + if (!hasUnsavedCanvasChanges()) return true; } // Flush the pending local edit once instead of racing its scheduled autosave. if (canvasAutosaveTimer.current) { clearTimeout(canvasAutosaveTimer.current); canvasAutosaveTimer.current = null; } - if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。"); + if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。"); return true; - }, [persistCanvasState, buildPersistedCanvasData, id]); + }, [persistCanvasState, hasUnsavedCanvasChanges, id]); const canvasRemoteOperationContext = useMemo(() => ({ run: runCanvasRemoteOperation, @@ -5367,6 +5372,10 @@ export default function CanvasEditorLegacyPage({ if (isVideoMode) return; const beforeRefresh = (event: Event) => { const request = (event as CustomEvent).detail; + if (!isAgentMode && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) { + request.block("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再刷新。"); + return; + } if (useCanvasStore.getState().isGenerating) { request.block("当前图片任务仍在处理中,请等待结果返回后再刷新。"); return; @@ -5543,7 +5552,7 @@ export default function CanvasEditorLegacyPage({ const handleBeforeUnload = (event: BeforeUnloadEvent) => { if (!retainDraft()) return; event.preventDefault(); - event.returnValue = ""; + event.returnValue = "true"; }; const handlePageHide = () => { try { diff --git a/components/app-update-notice.tsx b/components/app-update-notice.tsx index 492af8590..e5b49bda7 100644 --- a/components/app-update-notice.tsx +++ b/components/app-update-notice.tsx @@ -68,6 +68,7 @@ export function AppUpdateNotice() { {error ?

{error}

: null} + {error ? : null} diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 147500ff2..6db5bf306 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -8972,6 +8972,10 @@ export function CanvasChatPanel({ useEffect(() => { const beforeRefresh = (event: Event) => { const request = (event as CustomEvent).detail; + if (composerUploadProgress) { + request.block("素材仍在上传,请等待上传完成后再刷新。当前页面和输入已保留。"); + return; + } if (isCanvasAgentRunning || isClawdSubmitting || isDetailGenerating || status === "streaming" || status === "submitted" || Object.values(sendGuardRef.current).some(guard => guard.inFlight)) { request.block("当前对话任务仍在处理中,请等待结果返回后再刷新。原任务会继续执行,无需重新提交。"); return; @@ -8981,9 +8985,17 @@ export function CanvasChatPanel({ throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。"); }); }; + const beforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = "true"; + }; window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh); - return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh); - }, [activeChatId, input, attachments, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]); + if (composerUploadProgress) window.addEventListener("beforeunload", beforeUnload); + return () => { + window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh); + window.removeEventListener("beforeunload", beforeUnload); + }; + }, [activeChatId, input, attachments, composerUploadProgress, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]); const restorePendingClawdRequestToComposer = useCallback( (snapshot: PendingClawdRequestRestoreSnapshot) => { diff --git a/tests/e2e/canvas-autosync.test.ts b/tests/e2e/canvas-autosync.test.ts index 07c250689..cd165747d 100644 --- a/tests/e2e/canvas-autosync.test.ts +++ b/tests/e2e/canvas-autosync.test.ts @@ -13,9 +13,13 @@ async function setup(page: Page, failRead = false) { writes: [] as any[], metadataWrites: 0, submissions: [] as any[], capabilitySubmissions: [] as any[], appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null, nativeResultNext: false, + holdUpload: false, uploadStarted: 0, finishUpload: null as (() => void) | null, }; const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString(); - const project = () => ({ id: projectId, title: "同步验证", canvasData: state.canvasData, updatedAt: revision() }); + const project = () => ({ id: projectId, title: "同步验证", canvasData: JSON.parse(JSON.stringify(state.canvasData, (_key, value) => + value && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) + : value)), updatedAt: revision() }); const errors: string[] = []; page.on("pageerror", error => errors.push(error.message)); await page.context().addInitScript(() => localStorage.setItem("theme", "light")); @@ -24,6 +28,10 @@ async function setup(page: Page, failRead = false) { const path = new URL(request.url()).pathname; let data: unknown = {}; let status = 200; + if (path.startsWith("/api/files/") && request.method() === "POST" && state.holdUpload) { + state.uploadStarted++; + await new Promise(resolve => { state.finishUpload = resolve; }); + } if (path === `/api/canvas/${projectId}`) { if (request.method() === "GET") { status = state.failRead ? 500 : 200; @@ -108,8 +116,9 @@ async function moveImage(page: Page) { const unloadWouldBeBlocked = (page: Page) => page.evaluate(() => { const event = new Event("beforeunload", { cancelable: true }); + Object.defineProperty(event, "returnValue", { value: "", writable: true }); window.dispatchEvent(event); - return event.defaultPrevented; + return event.defaultPrevented && Boolean(event.returnValue); }); test("background saving recovers when the network returns without a manual save", async ({ page }) => { @@ -127,6 +136,18 @@ test("background saving recovers when the network returns without a manual save" expect(errors).toEqual([]); }); +test("selecting a saved image does not require another save before an Agent message", async ({ page }) => { + const { state, errors } = await setup(page); + await page.locator('[data-canvas-node-id="source"]').click(); + state.failSave = true; + const editor = page.getByLabel("描述你的设计需求", { exact: true }); + await editor.fill("分析这张已经保存的图片"); + await editor.press("Enter"); + await expect.poll(() => state.submissions.length).toBe(1); + expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source"]); + expect(errors).toEqual([]); +}); + test("only unsaved content blocks closing and beforeunload does not issue duplicate saves", async ({ page }) => { const { state, errors } = await setup(page); expect(await unloadWouldBeBlocked(page)).toBe(false); @@ -259,6 +280,7 @@ test("toolbar save failures show the cause and recover without a paid submission state.failSave = true; await page.locator('[data-tool-key="enhance"]').click(); await expect(page.getByText(/画布自动同步失败(HTTP 503)/)).toBeVisible(); + await expect(page.getByText(/本次处理未提交/)).toBeVisible(); expect(state.capabilitySubmissions).toHaveLength(0); state.failSave = false; await page.locator('[data-tool-key="enhance"]').click(); @@ -430,6 +452,70 @@ async function countRefreshes(page: Page, state: { appVersion: string }) { return () => navigations; } +test("unfinished image uploads remain on the page even after their canvas placeholder was saved", async ({ page }) => { + const { state, errors } = await setup(page); + const refreshes = await countRefreshes(page, state); + state.holdUpload = true; + await page.mouse.click(100, 90); + await page.evaluate(() => { + const bytes = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2d0AAAAASUVORK5CYII="), c => c.charCodeAt(0)); + const transfer = new DataTransfer(); + transfer.items.add(new File([bytes], "上传保护.png", { type: "image/png" })); + window.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true })); + }); + await expect.poll(() => state.uploadStarted).toBeGreaterThan(0); + await expect.poll(() => state.canvasData.nodes.filter(node => node.data.src === "").length).toBe(1); + expect(await unloadWouldBeBlocked(page)).toBe(true); + await page.getByRole("button", { name: "返回首页", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "离开前保存画布?" }); + await dialog.getByRole("button", { name: "保存并离开", exact: true }).click(); + await expect(dialog.getByRole("alert")).toContainText("还有图片未上传成功"); + await dialog.getByRole("button", { name: "继续编辑", exact: true }).click(); + await showUpdate(page, state); + await page.getByRole("button", { name: "同步并刷新", exact: true }).click(); + await expect(page.getByRole("alertdialog")).toContainText("还有图片未上传成功"); + expect(refreshes()).toBe(0); + state.holdUpload = false; + state.finishUpload?.(); + await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true); + await page.getByRole("button", { name: "同步并刷新", exact: true }).click(); + await expect.poll(refreshes).toBe(1); + expect(state.canvasData.nodes).toHaveLength(2); + expect(state.submissions).toHaveLength(0); + expect(errors).toEqual([]); +}); + +test("a composer upload blocks release refresh and closing until the image and input are retained", async ({ page }) => { + const { state, errors } = await setup(page); + const refreshes = await countRefreshes(page, state); + state.holdUpload = true; + const editor = page.getByLabel("描述你的设计需求", { exact: true }); + await editor.fill("上传后保留这条输入"); + await editor.evaluate(element => { + const transfer = new DataTransfer(); + transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "聊天上传保护.png", { type: "image/png" })); + element.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true })); + }); + await expect.poll(() => state.uploadStarted).toBeGreaterThan(0); + await expect(page.getByText("聊天上传保护.png", { exact: true })).toBeVisible(); + expect(await unloadWouldBeBlocked(page)).toBe(true); + await showUpdate(page, state); + await page.getByRole("button", { name: "同步并刷新", exact: true }).click(); + await expect(page.getByRole("alertdialog")).toContainText("素材仍在上传"); + expect(refreshes()).toBe(0); + state.holdUpload = false; + state.finishUpload?.(); + await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true); + await expect(page.getByText("聊天上传保护.png", { exact: true })).toHaveCount(1); + await page.getByRole("button", { name: "同步并刷新", exact: true }).click(); + await expect.poll(refreshes).toBe(1); + await expect(editor).toContainText("上传后保留这条输入"); + await expect(editor.getByText("聊天上传保护.png", { exact: true })).toBeVisible(); + expect(state.canvasData.nodes).toHaveLength(2); + expect(state.submissions).toHaveLength(0); + expect(errors).toEqual([]); +}); + test("an older browser without AbortSignal static helpers receives the release notice", async ({ page }) => { await page.context().addInitScript(() => { Object.defineProperty(AbortSignal, "any", { value: undefined, configurable: true }); @@ -504,9 +590,19 @@ test("an update keeps the current page when automatic synchronization fails", as await showUpdate(page, state); await page.getByRole("button", { name: "同步并刷新", exact: true }).click(); await expect(page.getByRole("alertdialog")).toContainText("HTTP 503"); + await expect(page.getByRole("alertdialog")).not.toContainText("本次任务未提交"); + await expect(page.getByRole("alertdialog")).not.toContainText("再次发送"); expect(refreshes()).toBe(0); expect(state.submissions).toHaveLength(0); + await page.screenshot({ path: test.info().outputPath("update-error-desktop.png"), animations: "disabled" }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: test.info().outputPath("update-error-mobile.png"), animations: "disabled" }); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.getByRole("button", { name: "返回处理", exact: true }).click(); + await expect(page.getByRole("alertdialog")).toHaveCount(0); + await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible(); state.failSave = false; + await showUpdate(page, state); await page.getByRole("button", { name: "同步并刷新", exact: true }).click(); await expect.poll(refreshes).toBe(1); expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160); -- 2.47.2