fix(canvas): 画布删除 - 删除云端记录并等待本地持久化

This commit is contained in:
ddCat
2026-08-22 12:33:34 +08:00
parent e3da713970
commit 13702e662d
4 changed files with 95 additions and 16 deletions

View File

@@ -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 { useAssetStore } from "@/stores/use-asset-store";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store"; import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
import { deleteCanvasProjectsWithRemoteSync } from "@/services/user-data-sync";
export function CanvasDeleteProjectsDialog() { export function CanvasDeleteProjectsDialog() {
const { message } = App.useApp();
const ids = useCanvasUiStore((state) => state.deleteProjectIds); const ids = useCanvasUiStore((state) => state.deleteProjectIds);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds); const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const removeSelectedIds = useCanvasUiStore((state) => state.removeSelectedProjectIds); const removeSelectedIds = useCanvasUiStore((state) => state.removeSelectedProjectIds);
const deleteProjects = useCanvasStore((state) => state.deleteProjects);
const cleanupImages = useAssetStore((state) => state.cleanupImages); const cleanupImages = useAssetStore((state) => state.cleanupImages);
const confirm = () => { const [deleting, setDeleting] = useState(false);
deleteProjects(ids); const confirm = async () => {
cleanupImages(); setDeleting(true);
removeSelectedIds(ids); try {
setDeleteIds([]); await deleteCanvasProjectsWithRemoteSync(ids);
cleanupImages();
removeSelectedIds(ids);
setDeleteIds([]);
} catch (error) {
message.error(error instanceof Error ? `删除画布失败:${error.message}` : "删除画布失败,请稍后重试");
} finally {
setDeleting(false);
}
}; };
return ( return (
@@ -26,7 +35,7 @@ export function CanvasDeleteProjectsDialog() {
footer={ footer={
<> <>
<Button onClick={() => setDeleteIds([])}>取消</Button> <Button onClick={() => setDeleteIds([])}>取消</Button>
<Button danger type="primary" onClick={confirm}> <Button danger type="primary" loading={deleting} onClick={() => void confirm()}>
删除 删除
</Button> </Button>
</> </>

View File

@@ -6,7 +6,7 @@ import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage"; import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { hydrateAssistantImages, hydrateCanvasImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation"; import { hydrateAssistantImages, hydrateCanvasImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { listAddedSkills, type Skill } from "@/services/api/skills"; 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 { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, CanvasNodeMetadata, ViewportTransform } from "@/types/canvas"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, CanvasNodeMetadata, ViewportTransform } from "@/types/canvas";
import type { CanvasHistorySnapshot } from "./use-canvas-history"; import type { CanvasHistorySnapshot } from "./use-canvas-history";
@@ -70,7 +70,6 @@ export function useCanvasProjectLifecycle({
const openProject = useCanvasStore((state) => state.openProject); const openProject = useCanvasStore((state) => state.openProject);
const updateProject = useCanvasStore((state) => state.updateProject); const updateProject = useCanvasStore((state) => state.updateProject);
const renameProject = useCanvasStore((state) => state.renameProject); const renameProject = useCanvasStore((state) => state.renameProject);
const deleteProjects = useCanvasStore((state) => state.deleteProjects);
const currentProject = useCanvasStore((state) => state.projects.find((project) => project.id === projectId)); const currentProject = useCanvasStore((state) => state.projects.find((project) => project.id === projectId));
const [addedSkills, setAddedSkills] = useState<Skill[]>([]); const [addedSkills, setAddedSkills] = useState<Skill[]>([]);
const viewportSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const viewportSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -172,16 +171,21 @@ export function useCanvasProjectLifecycle({
}); });
}, [message, navigate]); }, [message, navigate]);
const deleteCurrentProject = useCallback(() => { const deleteCurrentProject = useCallback(async () => {
const drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []); 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) { if (drawingIds.length) {
void Promise.all(drawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId))) void Promise.all(drawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId)))
.catch(() => message.warning("项目已删除,但部分本地绘图缓存清理失败")); .catch(() => message.warning("项目已删除,但部分本地绘图缓存清理失败"));
} }
deleteProjects([projectId]);
cleanupAssetImages(); cleanupAssetImages();
navigate("/canvas"); navigate("/canvas");
}, [cleanupAssetImages, deleteProjects, message, navigate, nodesRef, projectId]); }, [cleanupAssetImages, message, navigate, nodesRef, projectId]);
const renameCurrentProject = useCallback((title: string) => { const renameCurrentProject = useCallback((title: string) => {
renameProject(projectId, title); renameProject(projectId, title);

View File

@@ -5,7 +5,7 @@ import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadRe
import type { Asset } from "@/stores/use-asset-store"; import type { Asset } from "@/stores/use-asset-store";
import { useAssetStore } from "@/stores/use-asset-store"; import { useAssetStore } from "@/stores/use-asset-store";
import type { CanvasProject } from "@/stores/canvas/use-canvas-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 activeRemoteUserId = "";
let applyingRemoteState = false; 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() { export async function saveRemoteUserDataNow() {
if (!activeRemoteUserId) return; if (!activeRemoteUserId) return;
if (syncPauseCount) { if (syncPauseCount) {

View File

@@ -14,7 +14,7 @@ import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore, type Asset,
import { withGenerationAssetStorageLock } from "../src/services/generation-asset-repository"; 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 { 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 { 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"; import { apiClient } from "../src/services/api/request";
test("creation recovery observes streaming text tasks after reload", () => { 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 }); 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 });
}
});