同步封面和标题更新后的画布版本

自动封面和标题更新会推进画布版本,但页面未同步,导致后续节点保存持续冲突。元数据写入返回前一版本,客户端仅在与本地版本衔接时推进版本,并与画布保存共用队列;处理结果在合并成功后才更新本地版本。视频画布导入也按节点保存、标题更新顺序执行。

验证:17 项画布能力与同步测试通过,包含封面更新后的保存、不覆盖未同步的远端改动、结果合并冲突和切换画布;TypeScript 检查通过。
This commit is contained in:
WuYongJie 2026-11-03 14:39:48 +08:00
parent 6b7b24a86e
commit 6053271352
5 changed files with 64 additions and 16 deletions

View File

@ -207,7 +207,11 @@ export async function PUT(
),
)
.returning();
return NextResponse.json({ success: true, data: saved });
return NextResponse.json({
success: true,
data: saved,
previousCanvasRevision: existingProject?.updatedAt.toISOString() ?? null,
});
});
} catch (error) {
console.error("更新/创建画布失败:", error);

View File

@ -3947,20 +3947,18 @@ export default function CanvasEditorLegacyPage({
loadCanvas(normalized.canvasData);
setProjectTitle(nextTitle);
const [saveResult, updateResult] = await Promise.all([
canvasService.saveCanvasState(
const saveResult = await canvasService.saveCanvasState(
id,
normalized.canvasData,
activeCanvasWorkspaceType
),
canvasService.updateProject(
);
const updateResult = await canvasService.updateProject(
id,
{
title: nextTitle,
},
activeCanvasWorkspaceType
),
]);
);
if (!saveResult.success) {
throw new Error(saveResult.error || "导入后保存失败");
@ -5350,11 +5348,11 @@ export default function CanvasEditorLegacyPage({
try {
if (autoThumbnailSyncedRef.current !== autoTitleKey) {
await canvasService.updateProject(
await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
id,
{ thumbnail: imageSrc },
activeCanvasWorkspaceType
);
));
autoThumbnailSyncedRef.current = autoTitleKey;
}
@ -5370,11 +5368,11 @@ export default function CanvasEditorLegacyPage({
const data = await res.json();
if (data.title) {
setProjectTitle(data.title);
await canvasService.updateProject(
await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
id,
{ title: data.title },
activeCanvasWorkspaceType
);
));
toast.success(`${workspaceLabel}已自动命名为:${data.title}`);
}
} catch (error) {
@ -5425,11 +5423,11 @@ export default function CanvasEditorLegacyPage({
return;
}
const result = await canvasService.updateProject(
const result = await canvasSaveQueue.current!.enqueue(() => canvasService.updateProject(
id,
{ title: editTitle.trim() },
activeCanvasWorkspaceType
);
));
if (result.success) {
setProjectTitle(editTitle.trim());
} else {

View File

@ -1,6 +1,5 @@
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. */
@ -21,8 +20,9 @@ export async function executeCanvasToolbarCapability(
throw new Error(payload?.error || "图片处理失败");
}
const result = await canvasService.getProject(projectId);
if (!result.success || !result.data?.canvasData) {
const projectResponse = await fetch(`/api/canvas/${projectId}`);
const result = await projectResponse.json();
if (!projectResponse.ok || !result.success || !result.data?.canvasData) {
throw new Error(result.error || "结果已生成,但画布同步失败");
}
const current = useCanvasStore.getState();

View File

@ -248,6 +248,17 @@ export const canvasService = {
),
});
const data = await response.json();
if (data?.success && data?.previousCanvasRevision && data?.data?.updatedAt) {
const current = useCanvasStore.getState();
// Metadata-only writes advance the same revision used by canvas saves.
// Adopt it only when no unseen canvas revision lies between them.
if (
current.projectId === id &&
current.canvasRevision === data.previousCanvasRevision
) {
current.setCanvasRevision(String(data.data.updatedAt), String(data.data.updatedAt));
}
}
return data;
} catch (error) {
return { success: false, error: '更新画布失败' };

View File

@ -3,6 +3,7 @@ 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";
import { canvasService } from "@/services/canvas";
const originalFetch = globalThis.fetch;
const node = (id: string) => ({ id, type: "image", position: { x: 0, y: 0 },
@ -79,3 +80,37 @@ test("switching projects during processing does not load the old result into the
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]);
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
});
test("cover updates advance the local revision before the next queued save", async () => {
const queue = createCanvasSaveQueue();
globalThis.fetch = (async () => Response.json({
success: true, previousCanvasRevision: "r1", data: { updatedAt: "r2" },
})) as typeof fetch;
const cover = queue.enqueue(() => canvasService.updateProject("project-a", { thumbnail: "https://example.com/source.png" }));
const nextSave = queue.enqueue(async () => useCanvasStore.getState().canvasRevision);
await cover;
expect(await nextSave).toBe("r2");
});
test("metadata changes never adopt an unseen remote canvas revision", async () => {
globalThis.fetch = (async () => Response.json({
success: true, previousCanvasRevision: "external-edit", data: { updatedAt: "r3" },
})) as typeof fetch;
await canvasService.updateProject("project-a", { title: "Renamed" });
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
});
test("a result merge conflict retains both the local edit and its original 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"), position: { x: 200, y: 80 } }, node("result")], edges: [],
} } });
}) as typeof fetch;
await expect(executeCanvasToolbarCapability(command())).rejects.toThrow("画布有冲突");
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
expect(useCanvasStore.getState().nodes[0].position).toEqual({ x: 50, y: 80 });
});