feat(canvas): 画布同步 - 新增操作事务、预演保存与并发恢复能力

This commit is contained in:
ddcat666
2026-10-11 22:19:20 +08:00
parent 6dedc5973c
commit 5f359f2b1d
63 changed files with 3235 additions and 6193 deletions

View File

@@ -151,20 +151,19 @@ function applyCanvasPersistenceJournal(document: CanvasStorageDocument, journal:
}
async function preserveLocalRebaseConflicts(scope: string, projects: CanvasProject[], conflicts: ReturnType<typeof rebaseCanvasProjects>["conflicts"]) {
if (!conflicts.length) return;
if (!conflicts.length || getActiveUserScope() !== scope) return;
const ids = new Set(conflicts.map((conflict) => conflict.projectId || conflict.id));
const { preserveCanvasSyncDraftLocked } = await import("@/services/canvas-sync-drafts");
for (const project of projects.filter((item) => ids.has(item.id))) {
const draftCount = await preserveCanvasSyncDraftLocked(project, scope);
if (getActiveUserScope() === scope) useSyncProgressStore.getState().setProjectProgress(project.id, {
phase: "conflict", errorKind: "conflict", draftCount,
message: "其他标签页也修改了此画布,本地版本已保留为草稿",
useSyncProgressStore.getState().setProjectProgress(project.id, {
phase: "pending",
pendingChanges: 1,
errorKind: "conflict",
message: "其他标签页也修改了此画布,未确认操作会按字段重放",
});
}
}
function setCanvasLocalPersistenceProgress(projects: CanvasProject[], patch: Partial<SyncProjectProgress>) {
const progress = useSyncProgressStore.getState();
for (const project of projects) {
@@ -462,6 +461,11 @@ function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, du
if (durableKeys?.length) metadata.generationEffectKeys = [...durableKeys];
else delete metadata.generationEffectKeys;
nodes.push({ ...rolledBack, metadata });
} else {
// There is no durable ancestor for a locally-created result.
// Preserve it in the recovery snapshot; the effect key makes a
// later replay idempotent and the user must not lose the node.
nodes.push(node);
}
continue;
}
@@ -506,11 +510,14 @@ function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, du
chatSessions.push(nextSession);
}
if (durableProject && hasUnconfirmedGeneration) {
const localConnectionIds = new Set(project.connections.map((connection) => connection.id));
return {
...project,
nodes,
// Connection/active-chat records carry no effect stamp provenance, so keep them fail-closed while any generation entity is unconfirmed.
connections: durableProject.connections,
// Connection/active-chat records carry no effect stamp provenance. Keep
// local connections and append durable-only ones instead of replacing
// the whole list and dropping edits made during generation.
connections: [...project.connections, ...durableProject.connections.filter((connection) => !localConnectionIds.has(connection.id))],
chatSessions,
activeChatId: durableProject.activeChatId,
};