From 13702e662db8ea53c28ce1ad269224fc55939eb2 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 22 Aug 2026 12:33:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E7=94=BB=E5=B8=83=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20-=20=E5=88=A0=E9=99=A4=E4=BA=91=E7=AB=AF=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=B9=B6=E7=AD=89=E5=BE=85=E6=9C=AC=E5=9C=B0=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../canvas/canvas-delete-projects-dialog.tsx | 27 ++++++---- .../canvas/use-canvas-project-lifecycle.ts | 14 +++-- web/src/services/user-data-sync.ts | 18 ++++++- ...user-scoped-generation-persistence.test.ts | 52 ++++++++++++++++++- 4 files changed, 95 insertions(+), 16 deletions(-) diff --git a/web/src/components/canvas/canvas-delete-projects-dialog.tsx b/web/src/components/canvas/canvas-delete-projects-dialog.tsx index 9b0f5f073..f13d2f79f 100644 --- a/web/src/components/canvas/canvas-delete-projects-dialog.tsx +++ b/web/src/components/canvas/canvas-delete-projects-dialog.tsx @@ -1,20 +1,29 @@ -import { Button, Modal } from "antd"; +import { App, Button, Modal } from "antd"; +import { useState } from "react"; import { useAssetStore } from "@/stores/use-asset-store"; -import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store"; +import { deleteCanvasProjectsWithRemoteSync } from "@/services/user-data-sync"; export function CanvasDeleteProjectsDialog() { + const { message } = App.useApp(); const ids = useCanvasUiStore((state) => state.deleteProjectIds); const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds); const removeSelectedIds = useCanvasUiStore((state) => state.removeSelectedProjectIds); - const deleteProjects = useCanvasStore((state) => state.deleteProjects); const cleanupImages = useAssetStore((state) => state.cleanupImages); - const confirm = () => { - deleteProjects(ids); - cleanupImages(); - removeSelectedIds(ids); - setDeleteIds([]); + const [deleting, setDeleting] = useState(false); + const confirm = async () => { + setDeleting(true); + try { + await deleteCanvasProjectsWithRemoteSync(ids); + cleanupImages(); + removeSelectedIds(ids); + setDeleteIds([]); + } catch (error) { + message.error(error instanceof Error ? `删除画布失败:${error.message}` : "删除画布失败,请稍后重试"); + } finally { + setDeleting(false); + } }; return ( @@ -26,7 +35,7 @@ export function CanvasDeleteProjectsDialog() { footer={ <> - diff --git a/web/src/pages/canvas/use-canvas-project-lifecycle.ts b/web/src/pages/canvas/use-canvas-project-lifecycle.ts index 73666f34e..db40ae164 100644 --- a/web/src/pages/canvas/use-canvas-project-lifecycle.ts +++ b/web/src/pages/canvas/use-canvas-project-lifecycle.ts @@ -6,7 +6,7 @@ import type { CanvasBackgroundMode } from "@/lib/canvas-theme"; import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage"; import { hydrateAssistantImages, hydrateCanvasImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation"; import { listAddedSkills, type Skill } from "@/services/api/skills"; -import { createCanvasProjectWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync"; +import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync"; import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, CanvasNodeMetadata, ViewportTransform } from "@/types/canvas"; import type { CanvasHistorySnapshot } from "./use-canvas-history"; @@ -70,7 +70,6 @@ export function useCanvasProjectLifecycle({ const openProject = useCanvasStore((state) => state.openProject); const updateProject = useCanvasStore((state) => state.updateProject); const renameProject = useCanvasStore((state) => state.renameProject); - const deleteProjects = useCanvasStore((state) => state.deleteProjects); const currentProject = useCanvasStore((state) => state.projects.find((project) => project.id === projectId)); const [addedSkills, setAddedSkills] = useState([]); const viewportSaveTimerRef = useRef | null>(null); @@ -172,16 +171,21 @@ export function useCanvasProjectLifecycle({ }); }, [message, navigate]); - const deleteCurrentProject = useCallback(() => { + const deleteCurrentProject = useCallback(async () => { const drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []); + try { + await deleteCanvasProjectsWithRemoteSync([projectId]); + } catch (error) { + message.error(error instanceof Error ? `删除画布失败:${error.message}` : "删除画布失败,请稍后重试"); + return; + } if (drawingIds.length) { void Promise.all(drawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId))) .catch(() => message.warning("项目已删除,但部分本地绘图缓存清理失败")); } - deleteProjects([projectId]); cleanupAssetImages(); navigate("/canvas"); - }, [cleanupAssetImages, deleteProjects, message, navigate, nodesRef, projectId]); + }, [cleanupAssetImages, message, navigate, nodesRef, projectId]); const renameCurrentProject = useCallback((title: string) => { renameProject(projectId, title); diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index 446c90592..9086d888c 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -5,7 +5,7 @@ import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadRe import type { Asset } from "@/stores/use-asset-store"; import { useAssetStore } from "@/stores/use-asset-store"; import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; -import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; +import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; let activeRemoteUserId = ""; let applyingRemoteState = false; @@ -177,6 +177,22 @@ export async function deleteAssetWithRemoteSync(id: string) { }); } +export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) { + const projectIds = [...new Set(ids.map((id) => id.trim()).filter(Boolean))]; + if (!projectIds.length) return; + await withRemoteUserDataSyncPaused(async () => { + if (activeRemoteUserId) { + for (const id of projectIds) { + await deleteRemoteCanvasProject(id); + remoteProjectVersions.delete(id); + } + } + useCanvasStore.getState().deleteProjects(projectIds); + // 删除完成后再返回,确保刷新不会读到尚未写入 IndexedDB 的旧快照。 + await flushCanvasStorePersistence(); + }); +} + export async function saveRemoteUserDataNow() { if (!activeRemoteUserId) return; if (syncPauseCount) { diff --git a/web/test/user-scoped-generation-persistence.test.ts b/web/test/user-scoped-generation-persistence.test.ts index ec1d2baad..a987887d2 100644 --- a/web/test/user-scoped-generation-persistence.test.ts +++ b/web/test/user-scoped-generation-persistence.test.ts @@ -14,7 +14,7 @@ import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore, type Asset, import { withGenerationAssetStorageLock } from "../src/services/generation-asset-repository"; import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore, withCanvasStorePersistenceLock, withCanvasStorePersistenceSuppressed, type CanvasProject } from "../src/stores/canvas/use-canvas-store"; import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas"; -import { deleteAssetWithRemoteSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncPaused } from "../src/services/user-data-sync"; +import { deleteAssetWithRemoteSync, deleteCanvasProjectsWithRemoteSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncPaused } from "../src/services/user-data-sync"; import { apiClient } from "../src/services/api/request"; test("creation recovery observes streaming text tasks after reload", () => { @@ -4057,3 +4057,53 @@ test("account scope transition drains an active remote deletion before entering else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); } }); + +test("canvas deletion removes the remote project before updating local state", async () => { + const originalWindow = (globalThis as { window?: unknown }).window; + const originalGetItem = localforage.getItem.bind(localforage); + const originalSetItem = localforage.setItem.bind(localforage); + const previousAdapter = apiClient.defaults.adapter; + const previousProjects = useCanvasStore.getState().projects; + const requestUrls: string[] = []; + const project = storedCanvasProject("canvas-delete", "待删除画布"); + let remoteProjects = [project]; + + apiClient.defaults.adapter = async (config) => { + const url = String(config.url || ""); + requestUrls.push(`${String(config.method || "get").toLowerCase()} ${url}`); + if (String(config.method || "").toLowerCase() === "delete") { + remoteProjects = remoteProjects.filter((item) => item.id !== project.id); + return { data: { code: 0, data: { id: project.id }, msg: "" }, status: 200, statusText: "OK", headers: {}, config }; + } + const data = url.includes("user-data/snapshot") ? { projects: remoteProjects, assets: [] } : { projects: [] }; + return { data: { code: 0, data, msg: "" }, status: 200, statusText: "OK", headers: {}, config }; + }; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + setTimeout: () => 1, + clearTimeout: () => undefined, + localStorage: { getItem: () => null, setItem: () => undefined, removeItem: () => undefined }, + }, + }); + localforage.getItem = (async () => null) as typeof localforage.getItem; + localforage.setItem = (async (_key: string, value: string) => value) as typeof localforage.setItem; + try { + resetRemoteUserDataSync(); + useCanvasStore.setState({ projects: [project] }); + await syncRemoteUserData("account-A"); + await deleteCanvasProjectsWithRemoteSync([project.id]); + await syncRemoteUserData("account-A"); + + expect(requestUrls).toContain(`delete /canvas-projects/${project.id}`); + expect(useCanvasStore.getState().projects.some((item) => item.id === project.id)).toBe(false); + } finally { + resetRemoteUserDataSync(); + useCanvasStore.setState({ projects: previousProjects }); + localforage.getItem = originalGetItem; + localforage.setItem = originalSetItem; + apiClient.defaults.adapter = previousAdapter; + if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window; + else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + } +});