From ebb0d1514bb7cf3ec58d6d3470e0bfc2b1ba3403 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Fri, 6 Nov 2026 16:08:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A7=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E6=9B=B4=E6=96=B0=E6=8F=90=E9=86=92=E5=8F=8A=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=A6=BB=E5=BC=80=E4=BF=9D=E5=AD=98=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[id]/CanvasEditorLegacyPageClient.tsx | 125 +++++++++++++++-- components/app-update-notice.tsx | 18 ++- components/canvas/CanvasFileListPanel.tsx | 10 ++ components/canvas/CanvasSurfaceHeader.tsx | 19 --- tests/e2e/canvas-autosync.test.ts | 128 ++++++++++++++++++ 5 files changed, 261 insertions(+), 39 deletions(-) diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index ce35bb97a..362890244 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -5120,7 +5120,30 @@ export default function CanvasEditorLegacyPage({ const canvasAutosaveTimer = useRef | null>(null); const canvasPersistenceScope = useRef(id); const canvasRemoteOperationActive = useRef(false); + const canvasLeaveApprovedRef = useRef(false); + const [canvasLeaveUrl, setCanvasLeaveUrl] = useState(""); + const [isSavingBeforeLeave, setIsSavingBeforeLeave] = useState(false); + const [canvasLeaveError, setCanvasLeaveError] = useState(""); canvasPersistenceScope.current = id; + const hasUnsavedCanvasChanges = useCallback(() => { + if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || savedCanvasSnapshotRef.current?.id !== id) return false; + const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData as CanvasState; + const current = buildPersistedCanvasData(); + 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)); + }, [buildPersistedCanvasData, id]); + const requestCanvasLeave = useCallback((url: string) => { + if (hasUnsavedCanvasChanges()) { + setCanvasLeaveError(""); + setCanvasLeaveUrl(url); + return; + } + canvasLeaveApprovedRef.current = true; + window.location.assign(url); + }, [hasUnsavedCanvasChanges]); 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 }); @@ -5147,7 +5170,7 @@ export default function CanvasEditorLegacyPage({ }, [buildPersistedCanvasData, id]); const persistCanvasStateNow = useCallback( async (reason: "autosave" | "delete" | "beforeunload") => { - if (canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false; + if (canvasLeaveApprovedRef.current || canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false; if (externalCanvasConflictRef.current) return false; setIsSaving(true); try { @@ -5232,6 +5255,30 @@ export default function CanvasEditorLegacyPage({ [persistCanvasStateNow] ); + const finishCanvasLeave = useCallback(async (save: boolean) => { + if (!canvasLeaveUrl || isSavingBeforeLeave) return; + setCanvasLeaveError(""); + if (canvasAutosaveTimer.current) { + clearTimeout(canvasAutosaveTimer.current); + canvasAutosaveTimer.current = null; + } + if (save) { + setIsSavingBeforeLeave(true); + try { + if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布暂时无法同步,请保留此页并稍后重试。"); + if (hasUnsavedCanvasChanges() && !(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current); + if (hasUnsavedCanvasChanges()) throw new Error("还有改动正在同步,请稍后重试离开。"); + } catch (error) { + setCanvasLeaveError(error instanceof Error ? error.message : "画布暂时无法同步,请保留此页并稍后重试。"); + setIsSavingBeforeLeave(false); + return; + } + } + canvasLeaveApprovedRef.current = true; + clearCanvasDraft(id); + window.location.assign(canvasLeaveUrl); + }, [canvasLeaveUrl, hasUnsavedCanvasChanges, id, isSavingBeforeLeave, persistCanvasState]); + const resolveCanvasSyncConflict = useCallback((choice: "local" | "remote") => { return canvasSaveQueue.current!.enqueue(async () => { setIsSaving(true); @@ -5355,7 +5402,33 @@ export default function CanvasEditorLegacyPage({ clearTimeout(saveTimer); if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null; }; - }, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]); + }, [isAgentMode, isAgentOperationRefreshActive, nodes, edges, isLoading, persistCanvasState]); + + useEffect(() => { + if (isLoading || isAgentMode) return; + const retrySync = () => { + if (canvasLeaveApprovedRef.current || externalCanvasConflictRef.current || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || !hasUnsavedCanvasChanges()) return; + void persistCanvasState("autosave").catch(error => console.error("[Canvas] background sync failed:", error)); + }; + const onVisibilityChange = () => { + if (!canvasLeaveApprovedRef.current && document.visibilityState === "hidden" && hasUnsavedCanvasChanges()) { + writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }) }); + retrySync(); + } + }; + const retryTimer = window.setInterval(() => { + if (document.visibilityState === "visible" && navigator.onLine && canvasSyncErrorRef.current) retrySync(); + }, 20_000); + window.addEventListener("online", retrySync); + window.addEventListener("focus", retrySync); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + window.clearInterval(retryTimer); + window.removeEventListener("online", retrySync); + window.removeEventListener("focus", retrySync); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [buildPersistedCanvasData, hasUnsavedCanvasChanges, id, isAgentMode, isAgentOperationRefreshActive, isLoading, persistCanvasState]); useEffect(() => { if (isLoading || isAgentOperationRefreshActive()) return; @@ -5458,18 +5531,25 @@ export default function CanvasEditorLegacyPage({ } }, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]); - // 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率 + // 浏览器离开确认只保护尚未同步的内容;真正离开时保留草稿并发送一次保存。 useEffect(() => { if (isLoading || isAgentMode) return; - const handleBeforeUnload = () => { + const retainDraft = () => { + if (canvasLeaveApprovedRef.current || !hasUnsavedCanvasChanges()) return null; + const canvasData = buildPersistedCanvasData(); + writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData }) }); + return canvasData; + }; + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!retainDraft()) return; + event.preventDefault(); + event.returnValue = ""; + }; + const handlePageHide = () => { try { - if (!canvasReadyRef.current || isAgentOperationRefreshActive()) return; - const canvasData = buildPersistedCanvasData(); - writeCanvasDraft(id, { - base: savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : JSON.stringify({ canvasData: { nodes: [], edges: [] } }), - snapshot: JSON.stringify({ canvasData }), - }); + const canvasData = retainDraft(); + if (!canvasData || isSaving || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || externalCanvasConflictRef.current) return; const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState(); void fetch(`/api/canvas/${id}`, { method: "PUT", @@ -5488,18 +5568,20 @@ export default function CanvasEditorLegacyPage({ }; window.addEventListener("beforeunload", handleBeforeUnload); - window.addEventListener("pagehide", handleBeforeUnload); + window.addEventListener("pagehide", handlePageHide); return () => { window.removeEventListener("beforeunload", handleBeforeUnload); - window.removeEventListener("pagehide", handleBeforeUnload); + window.removeEventListener("pagehide", handlePageHide); }; }, [ activeCanvasWorkspaceType, buildPersistedCanvasData, + hasUnsavedCanvasChanges, id, isAgentOperationRefreshActive, isAgentMode, isLoading, + isSaving, ]); // 智能命名与缩略图逻辑 @@ -10509,6 +10591,20 @@ export default function CanvasEditorLegacyPage({ } > + { if (!open && !isSavingBeforeLeave) setCanvasLeaveUrl(""); }}> + { if (isSavingBeforeLeave) event.preventDefault(); }} onPointerDownOutside={event => event.preventDefault()}> + + 离开前保存画布? + 平时会自动保存。当前还有改动尚未同步到云端,可以保存后离开,或放弃这些未同步的改动。 + + {canvasLeaveError ?

{canvasLeaveError}

: null} + + + + + +
+
{canvasSyncConflicts.length > 0 ? (
@@ -11228,14 +11324,12 @@ export default function CanvasEditorLegacyPage({ {isVideoMode ? brandEntry : ( requestCanvasLeave("/")} onOpenFiles={() => { setIsPanelOpen(true); setPanelMode("fileList"); }} - onSave={() => void handleSave()} projectTitle={projectTitle} rightInset={panelOcclusionRight} /> @@ -11340,6 +11434,7 @@ export default function CanvasEditorLegacyPage({ createUrl={createNewCanvasUrl} currentId={id} onClose={() => setPanelMode("chat")} + onNavigate={requestCanvasLeave} workspaceLabel={workspaceLabel} workspaceType={activeCanvasWorkspaceType} /> diff --git a/components/app-update-notice.tsx b/components/app-update-notice.tsx index b9645e40b..492af8590 100644 --- a/components/app-update-notice.tsx +++ b/components/app-update-notice.tsx @@ -10,31 +10,39 @@ export function AppUpdateNotice() { const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(""); useEffect(() => { - const controller = new AbortController(); + let disposed = false; + let controller: AbortController | undefined; let checking = false; const check = async () => { - if (checking || document.visibilityState !== "visible") return; + if (disposed || checking || document.visibilityState !== "visible") return; checking = true; + controller = new AbortController(); + const current = controller; + const timeout = window.setTimeout(() => current.abort(), 8000); try { - const response = await fetch("/api/app-version", { cache: "no-store", signal: AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]) }); + const response = await fetch("/api/app-version", { cache: "no-store", signal: current.signal }); if (!response.ok) return; const data = await response.json(); - if (!controller.signal.aborted && typeof data.version === "string" && data.version) + if (!disposed && !current.signal.aborted && typeof data.version === "string" && data.version) setAvailableVersion(data.version !== (process.env.NEXT_PUBLIC_APP_RELEASE || "development") ? data.version : ""); } catch { // A failed version read leaves the current workspace available. } finally { + window.clearTimeout(timeout); checking = false; } }; void check(); const interval = window.setInterval(check, 60_000); window.addEventListener("focus", check); + window.addEventListener("online", check); document.addEventListener("visibilitychange", check); return () => { - controller.abort(); + disposed = true; + controller?.abort(); clearInterval(interval); window.removeEventListener("focus", check); + window.removeEventListener("online", check); document.removeEventListener("visibilitychange", check); }; }, []); diff --git a/components/canvas/CanvasFileListPanel.tsx b/components/canvas/CanvasFileListPanel.tsx index 4b326ee11..f74e017f6 100644 --- a/components/canvas/CanvasFileListPanel.tsx +++ b/components/canvas/CanvasFileListPanel.tsx @@ -19,6 +19,7 @@ const PROJECT_LIST_PAGE_SIZE = 24; interface CanvasFileListPanelProps { currentId: string; onClose: () => void; + onNavigate?: (url: string) => void; workspaceType?: CanvasWorkspaceType; workspaceLabel?: string; createUrl?: string; @@ -28,6 +29,7 @@ interface CanvasFileListPanelProps { export function CanvasFileListPanel({ currentId, onClose, + onNavigate, workspaceType = "canvas", workspaceLabel = "画布", createUrl = "/canvas?new=1", @@ -88,6 +90,10 @@ export function CanvasFileListPanel({ // 创建新画布 const handleCreateNew = async () => { + if (onNavigate) { + onNavigate(createUrl); + return; + } router.push(createUrl); }; @@ -100,6 +106,10 @@ export function CanvasFileListPanel({ } const targetUrl = buildProjectUrl(id); onClose(); // 关闭文件列表面板 + if (onNavigate) { + onNavigate(targetUrl); + return; + } // 强制整页跳转,避免仅更新 URL 不刷新 setTimeout(() => { window.location.assign(targetUrl); diff --git a/components/canvas/CanvasSurfaceHeader.tsx b/components/canvas/CanvasSurfaceHeader.tsx index fb2aff3d1..c4c015a18 100644 --- a/components/canvas/CanvasSurfaceHeader.tsx +++ b/components/canvas/CanvasSurfaceHeader.tsx @@ -2,14 +2,10 @@ import { ReferenceIcon } from "@/components/lovart/reference-icon"; -import { Loader2, Save } from "lucide-react"; - type CanvasSurfaceHeaderProps = { - isSaving: boolean; onAddImage: () => void; onHome: () => void; onOpenFiles: () => void; - onSave: () => void; projectTitle: string; rightInset: number; }; @@ -18,11 +14,9 @@ const iconButtonClass = "inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10"; export function CanvasSurfaceHeader({ - isSaving, onAddImage, onHome, onOpenFiles, - onSave, projectTitle, rightInset, }: CanvasSurfaceHeaderProps) { @@ -77,19 +71,6 @@ export function CanvasSurfaceHeader({
-
); } diff --git a/tests/e2e/canvas-autosync.test.ts b/tests/e2e/canvas-autosync.test.ts index d27bf6bdb..fbe4312e7 100644 --- a/tests/e2e/canvas-autosync.test.ts +++ b/tests/e2e/canvas-autosync.test.ts @@ -106,6 +106,112 @@ async function moveImage(page: Page) { await page.mouse.up(); } +const unloadWouldBeBlocked = (page: Page) => page.evaluate(() => { + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; +}); + +test("background saving recovers when the network returns without a manual save", async ({ page }) => { + const { state, errors } = await setup(page); + await expect(page.getByRole("button", { name: "保存", exact: true })).toHaveCount(0); + state.failSave = true; + await moveImage(page); + await expect.poll(() => state.writes.filter(body => body.canvasData).length).toBeGreaterThan(0); + expect(state.canvasData.nodes[0].position.x).toBe(160); + state.failSave = false; + await page.evaluate(() => window.dispatchEvent(new Event("online"))); + await expect.poll(() => state.canvasData.nodes[0].position.x).toBeGreaterThan(160); + await expect.poll(() => unloadWouldBeBlocked(page)).toBe(false); + expect(state.submissions).toHaveLength(0); + 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); + await page.locator('[data-canvas-node-id="source"]').click(); + expect(await unloadWouldBeBlocked(page)).toBe(false); + state.failSave = true; + await moveImage(page); + const writes = state.writes.length; + expect(await unloadWouldBeBlocked(page)).toBe(true); + expect(state.writes).toHaveLength(writes); + const draft = await page.evaluate(id => localStorage.getItem(`canvas-unsynced:${id}`), projectId); + expect(JSON.parse(JSON.parse(draft!).snapshot).canvasData.nodes[0].position.x).toBeGreaterThan(160); + expect(state.submissions).toHaveLength(0); + expect(errors).toEqual([]); +}); + +test("leaving with pending changes can continue editing or save before navigating", async ({ page }) => { + const { state, errors } = await setup(page); + await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "

已返回首页

" })); + await moveImage(page); + await page.getByRole("button", { name: "返回首页", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "离开前保存画布?" }); + await expect(dialog).toBeVisible(); + await page.screenshot({ path: test.info().outputPath("leave-dialog-desktop.png"), animations: "disabled" }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: test.info().outputPath("leave-dialog-mobile.png"), animations: "disabled" }); + await page.setViewportSize({ width: 1440, height: 900 }); + await dialog.getByRole("button", { name: "继续编辑", exact: true }).click(); + await expect(dialog).toHaveCount(0); + await moveImage(page); + await page.getByRole("button", { name: "返回首页", exact: true }).click(); + await dialog.getByRole("button", { name: "保存并离开", exact: true }).click(); + await expect(page.getByText("已返回首页")).toBeVisible(); + expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160); + expect(state.submissions).toHaveLength(0); + expect(errors).toEqual([]); +}); + +test("failed saving keeps the leave dialog and canvas until a successful retry", async ({ page }) => { + const { state, errors } = await setup(page); + await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "

已返回首页

" })); + state.failSave = true; + await moveImage(page); + 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("HTTP 503"); + await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible(); + expect(state.submissions).toHaveLength(0); + state.failSave = false; + await dialog.getByRole("button", { name: "保存并离开", exact: true }).click(); + await expect(page.getByText("已返回首页")).toBeVisible(); + expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160); + expect(errors).toEqual([]); +}); + +test("discarding pending changes leaves without saving or restoring the discarded draft", async ({ page }) => { + const { state, errors } = await setup(page); + await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "

已返回首页

" })); + state.failSave = true; + await moveImage(page); + expect(await unloadWouldBeBlocked(page)).toBe(true); + const writes = state.writes.length; + await page.getByRole("button", { name: "返回首页", exact: true }).click(); + await page.getByRole("button", { name: "不保存并离开", exact: true }).click(); + await expect(page.getByText("已返回首页")).toBeVisible(); + expect(state.writes).toHaveLength(writes); + expect(state.canvasData.nodes[0].position.x).toBe(160); + expect(await page.evaluate(id => localStorage.getItem(`canvas-unsynced:${id}`), projectId)).toBeNull(); + expect(errors).toEqual([]); +}); + +test("creating another project also offers to preserve pending canvas changes", async ({ page }) => { + const { state, errors } = await setup(page); + state.failSave = true; + await moveImage(page); + await page.getByRole("button", { name: "查看项目文件", exact: true }).click(); + await page.getByRole("button", { name: "新建画布", exact: true }).first().click(); + await expect(page.getByRole("dialog", { name: "离开前保存画布?" })).toBeVisible(); + await page.getByRole("button", { name: "继续编辑", exact: true }).click(); + await expect(page).toHaveURL(new RegExp(`/canvas/${projectId}$`)); + expect(state.submissions).toHaveLength(0); + expect(errors).toEqual([]); +}); + 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(); @@ -309,6 +415,28 @@ async function countRefreshes(page: Page, state: { appVersion: string }) { return () => navigations; } +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 }); + Object.defineProperty(AbortSignal, "timeout", { value: undefined, configurable: true }); + }); + const { state, errors } = await setup(page); + await showUpdate(page, state); + await expect(page.getByRole("button", { name: "同步并刷新", exact: true })).toBeVisible(); + expect(state.submissions).toHaveLength(0); + expect(errors).toEqual([]); +}); + +test("a release is detected by the minute timer without requiring a focus change", async ({ page }) => { + await page.clock.install(); + const { state, errors } = await setup(page); + state.appVersion = "20261106T120000Z-nextrelease1"; + await page.clock.fastForward(60_001); + await expect(page.getByRole("alertdialog")).toContainText("平台已更新"); + expect(state.submissions).toHaveLength(0); + expect(errors).toEqual([]); +}); + test("a new release requires confirmation and refreshes after preserving canvas and input", async ({ page }) => { const { state, errors } = await setup(page); const refreshes = await countRefreshes(page, state);