修复图片工具提交前的画布同步与结果合并
新导入或新生成的图片在自动保存前点击去水印,会因服务端缺少节点而失败。工具栏统一先保存当前画布,保存成功后再调用处理能力;处理与自动保存共用队列,结果合并保留等待期间的本地编辑并更新保存版本。适用于去水印、抠图、增强、扩图和图层拆分。 验证:14 项画布能力与提交同步测试通过,覆盖保存失败不提交、执行期间编辑保留、上游失败和切换画布;TypeScript 检查通过。
This commit is contained in:
parent
c431a574c8
commit
6b7b24a86e
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
11
components/canvas/CanvasRemoteOperationContext.tsx
Normal file
11
components/canvas/CanvasRemoteOperationContext.tsx
Normal 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);
|
||||
}
|
||||
@ -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 =
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
40
lib/client/canvas-toolbar-capability.ts
Normal file
40
lib/client/canvas-toolbar-capability.ts
Normal 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));
|
||||
}
|
||||
2
tests/canvas-toolbar.config.ts
Normal file
2
tests/canvas-toolbar.config.ts
Normal 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" });
|
||||
81
tests/routes/canvas-toolbar-capability.test.ts
Normal file
81
tests/routes/canvas-toolbar-capability.test.ts
Normal 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");
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user