修复图片工具提交前的画布同步与结果合并

新导入或新生成的图片在自动保存前点击去水印,会因服务端缺少节点而失败。工具栏统一先保存当前画布,保存成功后再调用处理能力;处理与自动保存共用队列,结果合并保留等待期间的本地编辑并更新保存版本。适用于去水印、抠图、增强、扩图和图层拆分。

验证:14 项画布能力与提交同步测试通过,覆盖保存失败不提交、执行期间编辑保留、上游失败和切换画布;TypeScript 检查通过。
This commit is contained in:
WuYongJie 2026-11-03 14:23:59 +08:00
parent c431a574c8
commit 6b7b24a86e
7 changed files with 185 additions and 22 deletions

View File

@ -1,6 +1,7 @@
"use client";
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
import { CanvasRemoteOperationContext } from "@/components/canvas/CanvasRemoteOperationContext";
import {
AlertCircle,
AlertTriangle,
@ -5080,9 +5081,10 @@ export default function CanvasEditorLegacyPage({
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const canvasPersistenceScope = useRef(id);
const canvasRemoteOperationActive = useRef(false);
canvasPersistenceScope.current = id;
const persistCanvasState = useCallback(
(reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => {
const persistCanvasStateNow = useCallback(
async (reason: "autosave" | "delete" | "beforeunload") => {
if (canvasPersistenceScope.current !== id) return false;
setIsSaving(true);
try {
@ -5122,10 +5124,37 @@ export default function CanvasEditorLegacyPage({
} finally {
if (canvasPersistenceScope.current === id) setIsSaving(false);
}
}),
},
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
);
const persistCanvasState = useCallback(
(reason: "autosave" | "delete" | "beforeunload") =>
canvasSaveQueue.current!.enqueue(() => persistCanvasStateNow(reason)),
[persistCanvasStateNow]
);
const runCanvasRemoteOperation = useCallback(
(operation: () => Promise<void>) => {
if (canvasAutosaveTimer.current) {
clearTimeout(canvasAutosaveTimer.current);
canvasAutosaveTimer.current = null;
}
return canvasSaveQueue.current!.enqueueOperation(
() => persistCanvasStateNow("autosave"),
async () => {
canvasRemoteOperationActive.current = true;
try {
await operation();
} finally {
canvasRemoteOperationActive.current = false;
}
}
);
},
[persistCanvasStateNow]
);
const persistCanvasBeforeAgent = useCallback(() => {
// Flush the pending local edit once instead of racing its scheduled autosave.
if (canvasAutosaveTimer.current) {
@ -5168,6 +5197,7 @@ export default function CanvasEditorLegacyPage({
const refreshIfExternallyUpdated = async () => {
if (
cancelled ||
canvasRemoteOperationActive.current ||
document.visibilityState !== "visible" ||
isSaving ||
externalCanvasConflictRef.current ||
@ -5197,7 +5227,7 @@ export default function CanvasEditorLegacyPage({
}
const result = await canvasService.getProject(id);
if (cancelled || !result.success || !result.data) return;
if (cancelled || canvasRemoteOperationActive.current || !result.success || !result.data) return;
useCanvasStore.getState().markAgentOperationRefresh();
hydrateCanvasProject(result.data);
lastLocalCanvasMutationAtRef.current = Date.now();
@ -10281,6 +10311,7 @@ export default function CanvasEditorLegacyPage({
}
return (
<CanvasRemoteOperationContext.Provider value={runCanvasRemoteOperation}>
<TooltipProvider>
<div
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
@ -12079,5 +12110,6 @@ export default function CanvasEditorLegacyPage({
{projectLoadingBadge}
</div>
</TooltipProvider>
</CanvasRemoteOperationContext.Provider>
);
}

View File

@ -0,0 +1,11 @@
"use client";
import { createContext, useContext } from "react";
export const CanvasRemoteOperationContext = createContext<
((operation: () => Promise<void>) => Promise<void>) | null
>(null);
export function useCanvasRemoteOperation() {
return useContext(CanvasRemoteOperationContext);
}

View File

@ -232,6 +232,8 @@ import {
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability";
import { useCanvasRemoteOperation } from "@/components/canvas/CanvasRemoteOperationContext";
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
import { cn, fetcher } from "@/lib/utils";
@ -2078,7 +2080,7 @@ export function ImageNode({
const selectNode = useCanvasStore((state) => state.selectNode);
const projectId = useCanvasStore((state) => state.projectId);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
const runCanvasRemoteOperation = useCanvasRemoteOperation();
const addPendingChatAttachment = useCanvasStore(
(state) => state.addPendingChatAttachment
);
@ -6708,7 +6710,7 @@ export function ImageNode({
const executeSharedToolbarCapability = async (
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
) => {
if (!projectId) {
if (!projectId || !runCanvasRemoteOperation) {
toast.error("画布还在加载,请稍后再试");
return;
}
@ -6727,21 +6729,7 @@ export function ImageNode({
projectId,
source: "toolbar",
});
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(command),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true) {
throw new Error(payload?.error || `${capability.title}失败`);
}
const projectResult = await canvasService.getProject(projectId);
if (!projectResult.success || !projectResult.data?.canvasData) {
throw new Error(projectResult.error || "结果已生成,但画布同步失败");
}
loadCanvas(projectResult.data.canvasData);
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
toast.success(`${capability.title}完成`);
} catch (error) {
const message =

View File

@ -1,11 +1,20 @@
/** Serialize local saves so each request reads the revision returned by the last. */
export function createCanvasSaveQueue() {
let pending: Promise<unknown> = Promise.resolve();
return {
const queue = {
enqueue<T>(save: () => Promise<T>): Promise<T> {
const next = pending.then(save, save);
pending = next.catch(() => undefined);
return next;
},
enqueueOperation(save: () => Promise<boolean>, operation: () => Promise<void>) {
return queue.enqueue(async () => {
if (!(await save())) {
throw new Error("画布尚未保存成功,请确认网络或画布同步状态后重试");
}
await operation();
});
},
};
return queue;
}

View File

@ -0,0 +1,40 @@
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
import { canvasService } from "@/services/canvas";
import { useCanvasStore } from "@/store/canvas";
/** Runs after the editor has saved and while its save queue is reserved. */
export async function executeCanvasToolbarCapability(
command: CanvasCapabilityClientCommand,
) {
const projectId = command.projectId!;
const before = useCanvasStore.getState();
if (before.projectId !== projectId) throw new Error("画布已切换,请重新选择图片");
const base = JSON.stringify({ canvasData: before.getCanvasState() });
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(command),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true) {
throw new Error(payload?.error || "图片处理失败");
}
const result = await canvasService.getProject(projectId);
if (!result.success || !result.data?.canvasData) {
throw new Error(result.error || "结果已生成,但画布同步失败");
}
const current = useCanvasStore.getState();
if (current.projectId !== projectId) return;
const merged = mergeCanvasSnapshots(
base,
JSON.stringify({ canvasData: current.getCanvasState() }),
JSON.stringify({ canvasData: result.data.canvasData }),
);
if (merged.conflicts.length > 0) {
throw new Error("结果已生成,但画布有冲突;请保留本地改动后刷新查看结果");
}
current.loadCanvas(JSON.parse(merged.snapshot).canvasData);
current.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
}

View File

@ -0,0 +1,2 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({ testDir: "./routes", testMatch: /canvas-toolbar-capability\.test\.ts|canvas-capability-runtime\.test\.ts/, workers: 1, reporter: "list" });

View File

@ -0,0 +1,81 @@
import { expect, test } from "@playwright/test";
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { useCanvasStore } from "@/store/canvas";
const originalFetch = globalThis.fetch;
const node = (id: string) => ({ id, type: "image", position: { x: 0, y: 0 },
size: { width: 100, height: 100 }, data: { src: `https://example.com/${id}.png` } });
const command = () => createCanvasCapabilityClientCommand({
projectId: "project-a", capabilityId: "image.remove_watermark", nodeIds: ["source"], source: "toolbar",
});
test.beforeEach(() => {
useCanvasStore.getState().clearCanvas();
useCanvasStore.getState().setProject("project-a");
useCanvasStore.getState().loadCanvas({ nodes: [node("source") as any], edges: [] });
useCanvasStore.getState().setCanvasRevision("r1", "r1");
});
test.afterEach(() => { globalThis.fetch = originalFetch; });
test("waits for the selected image to be saved before submitting and keeps subsequent saves behind the result", async () => {
const queue = createCanvasSaveQueue();
const events: string[] = [];
let finishSave!: () => void;
const pendingSave = new Promise<void>(resolve => { finishSave = resolve; });
const pending = queue.enqueueOperation(async () => {
events.push("saving"); await pendingSave; events.push("saved"); return true;
}, async () => { events.push("submitted"); });
const autosave = queue.enqueue(async () => { events.push("autosaved"); });
await Promise.resolve();
expect(events).toEqual(["saving"]);
finishSave();
await Promise.all([pending, autosave]);
expect(events).toEqual(["saving", "saved", "submitted", "autosaved"]);
});
test("a failed save does not submit a paid image task or block later saves", async () => {
const queue = createCanvasSaveQueue();
let submissions = 0;
await expect(queue.enqueueOperation(async () => false, async () => { submissions++; })).rejects.toThrow("尚未保存成功");
expect(submissions).toBe(0);
expect(await queue.enqueue(async () => "saved")).toBe("saved");
});
test("merges the result with edits made while processing and advances the save revision", async () => {
globalThis.fetch = (async (url) => {
if (String(url).endsWith("/capabilities")) {
useCanvasStore.getState().moveNode("source", { x: 50, y: 80 });
return Response.json({ success: true });
}
return Response.json({ success: true, data: {
updatedAt: "r2", canvasData: { nodes: [node("source"), node("result")], edges: [] },
} });
}) as typeof fetch;
await executeCanvasToolbarCapability(command());
const state = useCanvasStore.getState();
expect(state.nodes.map(n => n.id)).toEqual(["source", "result"]);
expect(state.nodes[0].position).toEqual({ x: 50, y: 80 });
expect(state.canvasRevision).toBe("r2");
});
test("a provider failure is shown without reloading or changing the canvas", async () => {
globalThis.fetch = (async () => Response.json({ success: false, error: "去水印服务繁忙" }, { status: 503 })) as typeof fetch;
await expect(executeCanvasToolbarCapability(command())).rejects.toThrow("去水印服务繁忙");
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source"]);
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
});
test("switching projects during processing does not load the old result into the new project", async () => {
globalThis.fetch = (async (url) => {
if (String(url).endsWith("/capabilities")) {
useCanvasStore.getState().setProject("project-b");
useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] });
return Response.json({ success: true });
}
return Response.json({ success: true, data: { updatedAt: "r2", canvasData: { nodes: [node("result")], edges: [] } } });
}) as typeof fetch;
await executeCanvasToolbarCapability(command());
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]);
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
});