画布读取与加载同步版本并保留加载期间的编辑
This commit is contained in:
parent
85ff680b65
commit
2ee8bd4dea
@ -5215,8 +5215,13 @@ export default function CanvasEditorLegacyPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const beforeRead = useCanvasStore.getState();
|
||||||
const result = await canvasService.getProject(id);
|
const result = await canvasService.getProject(id);
|
||||||
if (cancelled || canvasRemoteOperationActive.current || !result.success || !result.data) return;
|
const current = useCanvasStore.getState();
|
||||||
|
if (
|
||||||
|
cancelled || canvasRemoteOperationActive.current || !result.success || !result.data ||
|
||||||
|
current.projectId !== id || current.nodes !== beforeRead.nodes || current.edges !== beforeRead.edges
|
||||||
|
) return;
|
||||||
useCanvasStore.getState().markAgentOperationRefresh();
|
useCanvasStore.getState().markAgentOperationRefresh();
|
||||||
hydrateCanvasProject(result.data);
|
hydrateCanvasProject(result.data);
|
||||||
lastLocalCanvasMutationAtRef.current = Date.now();
|
lastLocalCanvasMutationAtRef.current = Date.now();
|
||||||
|
|||||||
@ -48,8 +48,13 @@ export default function CanvasStyleProPage() {
|
|||||||
const canvasData = result.data.canvasData;
|
const canvasData = result.data.canvasData;
|
||||||
const latestState = useCanvasStore.getState();
|
const latestState = useCanvasStore.getState();
|
||||||
setProject(result.data.id, result.data.title);
|
setProject(result.data.id, result.data.title);
|
||||||
if (latestState.nodes.length === 0 && latestState.edges.length === 0) {
|
if (
|
||||||
|
latestState.projectId !== id ||
|
||||||
|
(latestState.nodes.length === 0 && latestState.edges.length === 0)
|
||||||
|
) {
|
||||||
loadCanvas(canvasData);
|
loadCanvas(canvasData);
|
||||||
|
const revision = String(result.data.updatedAt || "");
|
||||||
|
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -95,18 +95,19 @@ function normalizeRevisionText(value: unknown) {
|
|||||||
return String(value || "").trim();
|
return String(value || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncCanvasStoreRevisionFromUpdatedAt(updatedAt: unknown): void {
|
function syncCanvasStoreRevisionFromUpdatedAt(projectId: string, updatedAt: unknown): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const revision = normalizeRevisionText(updatedAt);
|
const revision = normalizeRevisionText(updatedAt);
|
||||||
if (!revision) return;
|
if (!revision) return;
|
||||||
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
const current = useCanvasStore.getState();
|
||||||
|
if (current.projectId === projectId) current.setCanvasRevision(revision, revision);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncCanvasStoreRevisionFromProject(
|
function syncCanvasStoreRevisionFromProject(
|
||||||
project?: { canvasData?: CanvasState | null; updatedAt?: Date | string } | null
|
project?: { id: string; canvasData?: CanvasState | null; updatedAt?: Date | string } | null
|
||||||
): void {
|
): void {
|
||||||
if (!project?.canvasData) return;
|
if (!project?.canvasData) return;
|
||||||
syncCanvasStoreRevisionFromUpdatedAt(project.updatedAt);
|
syncCanvasStoreRevisionFromUpdatedAt(project.id, project.updatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalizedCropRect {
|
export interface NormalizedCropRect {
|
||||||
@ -203,9 +204,6 @@ export const canvasService = {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/canvas/${id}`);
|
const response = await fetch(`/api/canvas/${id}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data?.success) {
|
|
||||||
syncCanvasStoreRevisionFromProject(data.data);
|
|
||||||
}
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: '获取画布失败' };
|
return { success: false, error: '获取画布失败' };
|
||||||
@ -335,7 +333,7 @@ export const canvasService = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (data?.success) {
|
if (data?.success) {
|
||||||
syncCanvasStoreRevisionFromUpdatedAt(data?.data?.updatedAt);
|
syncCanvasStoreRevisionFromUpdatedAt(id, data?.data?.updatedAt);
|
||||||
}
|
}
|
||||||
return data as ApiResponse;
|
return data as ApiResponse;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -808,6 +808,7 @@ test.describe("canvas chat pure helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("canvas service save uses store revision fallback when options are omitted", async () => {
|
test("canvas service save uses store revision fallback when options are omitted", async () => {
|
||||||
|
useCanvasStore.getState().setProject("11111111-1111-4111-8111-111111111111");
|
||||||
const canvasData = {
|
const canvasData = {
|
||||||
nodes: [],
|
nodes: [],
|
||||||
edges: [],
|
edges: [],
|
||||||
@ -885,7 +886,7 @@ test.describe("canvas chat pure helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("canvas service keeps store revision aligned after project load and save", async () => {
|
test("canvas revision advances after applying the fetched snapshot and saving the current project", async () => {
|
||||||
const canvasData = {
|
const canvasData = {
|
||||||
nodes: [],
|
nodes: [],
|
||||||
edges: [],
|
edges: [],
|
||||||
@ -915,6 +916,7 @@ test.describe("canvas chat pure helpers", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
useCanvasStore.getState().setProject("11111111-1111-4111-8111-111111111111");
|
||||||
useCanvasStore.getState().setCanvasRevision(null, null);
|
useCanvasStore.getState().setCanvasRevision(null, null);
|
||||||
Object.defineProperty(globalThis, "window", {
|
Object.defineProperty(globalThis, "window", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@ -929,7 +931,11 @@ test.describe("canvas chat pure helpers", () => {
|
|||||||
}) as Response) as typeof fetch;
|
}) as Response) as typeof fetch;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await canvasService.getProject("11111111-1111-4111-8111-111111111111");
|
const result = await canvasService.getProject("11111111-1111-4111-8111-111111111111");
|
||||||
|
expect(useCanvasStore.getState().canvasRevision).toBeNull();
|
||||||
|
useCanvasStore.getState().loadCanvas(result.data!.canvasData);
|
||||||
|
const revision = String(result.data!.updatedAt);
|
||||||
|
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
||||||
expect(useCanvasStore.getState().canvasRevision).toBe(
|
expect(useCanvasStore.getState().canvasRevision).toBe(
|
||||||
"2026-06-22T00:01:00.000Z"
|
"2026-06-22T00:01:00.000Z"
|
||||||
);
|
);
|
||||||
|
|||||||
@ -136,6 +136,71 @@ test("metadata changes never adopt an unseen remote canvas revision", async () =
|
|||||||
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reading a result without applying it cannot authorize an old snapshot to overwrite the result", async () => {
|
||||||
|
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||||
|
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
|
||||||
|
let remoteNodes = [node("source"), node("result")];
|
||||||
|
globalThis.fetch = (async (_url, options) => {
|
||||||
|
if (options?.method === "PUT") {
|
||||||
|
const body = JSON.parse(String(options.body));
|
||||||
|
if (body.baseCanvasRevision !== "r2") return Response.json({ success: false, error: "画布已更新" }, { status: 409 });
|
||||||
|
remoteNodes = body.canvasData.nodes;
|
||||||
|
return Response.json({ success: true, data: { updatedAt: "r3" } });
|
||||||
|
}
|
||||||
|
return Response.json({ success: true, data: { id: "project-a", updatedAt: "r2", canvasData: { nodes: remoteNodes, edges: [] } } });
|
||||||
|
}) as typeof fetch;
|
||||||
|
try {
|
||||||
|
await canvasService.getProject("project-a");
|
||||||
|
const saved = await canvasService.saveCanvasState("project-a", useCanvasStore.getState().getCanvasState());
|
||||||
|
expect(saved).toMatchObject({ success: false, status: 409 });
|
||||||
|
expect(remoteNodes.map(n => n.id)).toEqual(["source", "result"]);
|
||||||
|
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
||||||
|
} finally {
|
||||||
|
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||||
|
else Reflect.deleteProperty(globalThis, "window");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a delayed read cannot change the revision of a newly selected project", async () => {
|
||||||
|
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||||
|
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
|
||||||
|
let complete!: (response: Response) => void;
|
||||||
|
globalThis.fetch = (() => new Promise<Response>(resolve => { complete = resolve; })) as typeof fetch;
|
||||||
|
try {
|
||||||
|
const pending = canvasService.getProject("project-a");
|
||||||
|
useCanvasStore.getState().setProject("project-b");
|
||||||
|
useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] });
|
||||||
|
useCanvasStore.getState().setCanvasRevision("r9", "r9");
|
||||||
|
complete(Response.json({ success: true, data: { id: "project-a", updatedAt: "r2", canvasData: { nodes: [node("result")], edges: [] } } }));
|
||||||
|
await pending;
|
||||||
|
expect(useCanvasStore.getState().canvasRevision).toBe("r9");
|
||||||
|
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]);
|
||||||
|
} finally {
|
||||||
|
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||||
|
else Reflect.deleteProperty(globalThis, "window");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a delayed save cannot change the revision of a newly selected project", async () => {
|
||||||
|
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||||
|
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
|
||||||
|
let complete!: (response: Response) => void;
|
||||||
|
globalThis.fetch = (() => new Promise<Response>(resolve => { complete = resolve; })) as typeof fetch;
|
||||||
|
try {
|
||||||
|
const pending = canvasService.saveCanvasState("project-a", useCanvasStore.getState().getCanvasState());
|
||||||
|
useCanvasStore.getState().setProject("project-b");
|
||||||
|
useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] });
|
||||||
|
useCanvasStore.getState().setCanvasRevision("r9", "r9");
|
||||||
|
complete(Response.json({ success: true, data: { updatedAt: "r2" } }));
|
||||||
|
await pending;
|
||||||
|
expect(useCanvasStore.getState().canvasRevision).toBe("r9");
|
||||||
|
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]);
|
||||||
|
} finally {
|
||||||
|
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||||
|
else Reflect.deleteProperty(globalThis, "window");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("a result merge conflict retains both the local edit and its original revision", async () => {
|
test("a result merge conflict retains both the local edit and its original revision", async () => {
|
||||||
globalThis.fetch = (async (url) => {
|
globalThis.fetch = (async (url) => {
|
||||||
if (String(url).endsWith("/capabilities")) {
|
if (String(url).endsWith("/capabilities")) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user