fix(canvas): 画布同步 - 版本冲突先核对并合并,本地保存与云端状态分开展示

本地持久化记录保存时间和日志令牌,同步面板区分本地与云端,不再用提示打断编辑。冲突时先尝试合并,无法合并才保留草稿并暂停自动提交;未登录时生成结果留在本地,登录后再继续上传。
This commit is contained in:
ddcat666 committed 2026-10-09 17:17:38 +08:00
1 parent 631a85b4fe
commit 5e69106d8f
17 files changed
+539 -236

No files matched your search

+233 -18
View File
@@ -12,6 +12,8 @@ import type { CanvasStarterMode } from "@/lib/canvas/canvas-starter";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
import type { PrevisScene } from "@/types/previs";
import type { TimelineProject } from "@/types/timeline";
import { useSyncProgressStore, type SyncProjectProgress } from "@/stores/use-sync-progress-store";
import { recordDiagnosticEvent } from "@/services/diagnostics/client-diagnostics";
export type CanvasProject = {
id: string;
@@ -56,13 +58,155 @@ type QueuedCanvasPersist = {
baseRevision: number;
state: PersistedCanvasState;
token: number;
journal?: CanvasPersistenceJournal;
};
type ObservedCanvasPersist = {
projects: CanvasProject[];
revision: number;
savedAt?: string;
};
type CanvasPersistenceJournal = {
version: 1;
name: string;
scope: string;
writerId: string;
token: number;
savedAt: string;
baseRevision: number;
baseProjects: CanvasProject[];
projects: CanvasProject[];
};
const CANVAS_PERSISTENCE_JOURNAL_KEY = "yingce:canvas_store_journal";
const CANVAS_JOURNAL_MAX_BYTES = 450_000;
const canvasJournalWriterId = `tab-${nanoid(10)}`;
function canvasPersistenceJournalKey(scope: string, writerId = canvasJournalWriterId) {
return `${CANVAS_PERSISTENCE_JOURNAL_KEY}:user:${encodeURIComponent(scope)}:${writerId}`;
}
function createCanvasPersistenceJournal(scope: string, name: string, previous: CanvasProject[], next: CanvasProject[], token: number, baseRevision: number): CanvasPersistenceJournal {
const before = new Map(previous.map((project) => [project.id, project]));
const after = new Map(next.map((project) => [project.id, project]));
const changed = new Set([...before.keys(), ...after.keys()].filter((id) => before.get(id) !== after.get(id)));
return {
version: 1, name, scope, writerId: canvasJournalWriterId, token,
savedAt: new Date().toISOString(), baseRevision,
baseProjects: previous.filter((project) => changed.has(project.id)),
projects: next.filter((project) => changed.has(project.id)),
};
}
function readCanvasPersistenceJournals(scope: string): CanvasPersistenceJournal[] {
try {
if (typeof window === "undefined") return [];
const storage = window.localStorage;
if (!storage) return [];
const prefix = canvasPersistenceJournalKey(scope, "");
const keys = new Set([canvasPersistenceJournalKey(scope)]);
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key?.startsWith(prefix)) keys.add(key);
}
return [...keys].flatMap((key) => {
const raw = storage.getItem(key);
if (!raw) return [];
try {
const journal = JSON.parse(raw) as CanvasPersistenceJournal;
return journal.version === 1 && journal.scope === scope && journal.name && typeof journal.writerId === "string"
&& key === canvasPersistenceJournalKey(scope, journal.writerId) && Number.isSafeInteger(journal.token)
&& Array.isArray(journal.projects) && Array.isArray(journal.baseProjects) ? [journal] : [];
} catch { return []; }
});
} catch { return []; }
}
function writeCanvasPersistenceJournal(journal: CanvasPersistenceJournal) {
try {
if (typeof window === "undefined" || !window.localStorage) return false;
const serialized = JSON.stringify(journal);
// Large media stays in IndexedDB. Oversized recovery records trigger an
// immediate full save and remain visibly pending until that write succeeds.
if (new TextEncoder().encode(serialized).byteLength > CANVAS_JOURNAL_MAX_BYTES) return false;
window.localStorage.setItem(canvasPersistenceJournalKey(journal.scope, journal.writerId), serialized);
return true;
} catch { return false; }
}
function clearCanvasPersistenceJournal(scope: string, token: number, writerId = canvasJournalWriterId) {
try {
if (typeof window === "undefined") return;
const key = canvasPersistenceJournalKey(scope, writerId);
const raw = window.localStorage?.getItem(key);
// A previous save must never clear edits made while IndexedDB was writing.
if (raw && (JSON.parse(raw) as CanvasPersistenceJournal).token <= token) window.localStorage.removeItem(key);
} catch { /* The durable receipt makes a leftover journal safe to replay. */ }
}
function applyCanvasPersistenceJournal(document: CanvasStorageDocument, journal: CanvasPersistenceJournal) {
if ((document.journalTokens?.[journal.writerId] ?? -1) >= journal.token) return { document, conflicts: [] };
return rebaseCanvasProjects({ document, baseProjects: journal.baseProjects, localProjects: journal.projects, baseRevision: journal.baseRevision });
}
async function preserveLocalRebaseConflicts(scope: string, projects: CanvasProject[], conflicts: ReturnType<typeof rebaseCanvasProjects>["conflicts"]) {
if (!conflicts.length) 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: "其他标签页也修改了此画布,本地版本已保留为草稿",
});
}
}
function setCanvasLocalPersistenceProgress(projects: CanvasProject[], patch: Partial<SyncProjectProgress>) {
const progress = useSyncProgressStore.getState();
for (const project of projects) {
const current = progress.syncingProjects[project.id];
progress.setProjectProgress(project.id, { projectId: project.id, ...(current ? {} : { phase: "local" as const }), ...patch });
}
}
function markCanvasLocalPersistencePending(projects: CanvasProject[], error?: string) {
setCanvasLocalPersistenceProgress(projects, {
localPhase: error ? "error" : "pending",
localError: error,
errorKind: error ? "local" : undefined,
pendingWrites: 1,
});
}
function markCanvasLocalPersistenceSaved(projects: CanvasProject[], savedAt: string) {
setCanvasLocalPersistenceProgress(projects, { localPhase: "saved", localSavedAt: savedAt, localError: undefined, errorKind: undefined, pendingWrites: 0 });
}
function recordCanvasNodePersistenceCounts(before: CanvasProject[], after: CanvasProject[]) {
const beforeById = new Map(before.map((project) => [project.id, project]));
const afterById = new Map(after.map((project) => [project.id, project]));
for (const id of new Set([...beforeById.keys(), ...afterById.keys()])) {
const nodesBefore = beforeById.get(id)?.nodes.length || 0;
const nodesAfter = afterById.get(id)?.nodes.length || 0;
if (nodesBefore === nodesAfter) continue;
recordDiagnosticEvent({
level: nodesAfter < nodesBefore ? "error" : "info",
category: "action",
code: nodesAfter < nodesBefore ? "canvas.persistence.nodes_decreased" : "canvas.persistence.nodes_changed",
message: nodesAfter < nodesBefore ? "画布持久化后节点数量减少" : "画布持久化后节点数量变化",
projectId: id,
nodes_before: nodesBefore,
nodes_after: nodesAfter,
});
}
}
let suppressCanvasStorePersistence = 0;
const canvasMemoryStates = new Map<string, PersistedCanvasState>();
const observedCanvasPersists = new Map<string, ObservedCanvasPersist>();
@@ -130,10 +274,20 @@ export function canvasStoreStorageRevision(scope: string) {
}
export function recordCanvasStorageDocument(scope: string, document: CanvasStorageDocument) {
const savedAt = document.savedAt || new Date().toISOString();
observedCanvasPersists.set(scope, {
projects: document.state.projects,
revision: document.storageRevision,
savedAt,
});
if (getActiveUserScope() !== scope) return;
const queued = queuedCanvasPersists.get(scope);
if (queued || failedCanvasJournalRecoveries.has(scope)) {
if (failedCanvasJournalRecoveries.has(scope)) return;
markCanvasLocalPersistencePending(queued!.state.projects);
} else {
markCanvasLocalPersistenceSaved(document.state.projects, savedAt);
}
}
export async function commitPendingCanvasStorePersistenceLocked(scope: string) {
@@ -150,24 +304,37 @@ export async function commitPendingCanvasStorePersistenceLocked(scope: string) {
baseProjects: queued.baseProjects,
localProjects: queued.state.projects,
baseRevision: queued.baseRevision,
}).document;
await storage.setItem(queued.name, serializeCanvasStorageDocument(rebased));
committed = rebased;
recordCanvasStorageDocument(scope, rebased);
});
await preserveLocalRebaseConflicts(scope, queued.state.projects, rebased.conflicts);
const savedAt = new Date().toISOString();
const savedDocument = { ...rebased.document, savedAt, journalTokens: { ...durable.journalTokens, [canvasJournalWriterId]: queued.token } };
await storage.setItem(queued.name, serializeCanvasStorageDocument(savedDocument));
committed = savedDocument;
clearCanvasPersistenceJournal(scope, queued.token);
recordCanvasNodePersistenceCounts(durable.state.projects, savedDocument.state.projects);
recordCanvasNodePersistenceCounts(queued.state.projects, savedDocument.state.projects);
const latest = queuedCanvasPersists.get(scope);
if (!latest || latest.token === queued.token) {
if (latest?.token === queued.token) queuedCanvasPersists.delete(scope);
recordCanvasStorageDocument(scope, savedDocument);
return committed;
}
latest.baseProjects = queued.state.projects;
latest.baseRevision = rebased.storageRevision;
latest.baseRevision = savedDocument.storageRevision;
recordCanvasStorageDocument(scope, savedDocument);
}
}
async function writeQueuedCanvasPersist(scope: string, _token: number) {
await withCanvasStorePersistenceLock(scope, () => commitPendingCanvasStorePersistenceLocked(scope));
try {
await withCanvasStorePersistenceLock(scope, () => commitPendingCanvasStorePersistenceLocked(scope));
} catch (error) {
const queued = queuedCanvasPersists.get(scope);
if (queued) markCanvasLocalPersistencePending(queued.state.projects, error instanceof Error ? error.message : "本地画布保存失败");
throw error;
}
}
export function pendingCanvasStorePersistence(scope: string) {
@@ -379,16 +546,57 @@ export function reconcileCanvasGenerationFailure(scope: string, durableProjects:
});
}
const failedCanvasJournalRecoveries = new Map<string, string>();
async function recoverCanvasPersistenceJournalLocked(scope: string, name: string, initial: CanvasStorageDocument) {
const journals = readCanvasPersistenceJournals(scope).filter((journal) => journal.name === name)
.sort((left, right) => left.savedAt.localeCompare(right.savedAt) || left.writerId.localeCompare(right.writerId) || left.token - right.token);
if (!journals.length) return initial;
const storage = localForageStorageForScope(scope);
let durable = parseCanvasStorageDocument(await storage.getItem(name), initial.state.projects);
for (const journal of journals) {
const rebased = applyCanvasPersistenceJournal(durable, journal);
await preserveLocalRebaseConflicts(scope, journal.projects, rebased.conflicts);
const saved = { ...rebased.document, savedAt: new Date().toISOString(), journalTokens: { ...rebased.document.journalTokens, [journal.writerId]: Math.max(journal.token, durable.journalTokens?.[journal.writerId] ?? 0) } };
await storage.setItem(name, serializeCanvasStorageDocument(saved));
clearCanvasPersistenceJournal(scope, journal.token, journal.writerId);
recordCanvasNodePersistenceCounts(durable.state.projects, saved.state.projects);
durable = saved;
}
failedCanvasJournalRecoveries.delete(scope);
return durable;
}
async function recoverCanvasPersistenceJournal(scope: string, name: string, initial: CanvasStorageDocument) {
try {
return await withCanvasStorePersistenceLock(scope, () => recoverCanvasPersistenceJournalLocked(scope, name, initial));
} catch (error) {
failedCanvasJournalRecoveries.set(scope, name);
const projects = new Map(initial.state.projects.map((project) => [project.id, project]));
// Keep unsaved branches visible when recovery itself cannot be persisted.
// Journals stay intact; a later flush retries their original three-way merge.
for (const journal of readCanvasPersistenceJournals(scope).filter((item) => item.name === name)) {
if ((initial.journalTokens?.[journal.writerId] ?? -1) >= journal.token) continue;
for (const project of journal.projects) projects.set(project.id, project);
}
const recoveredProjects = [...projects.values()];
if (getActiveUserScope() === scope) markCanvasLocalPersistencePending(recoveredProjects, error instanceof Error ? error.message : "本地恢复记录写入失败");
return { ...initial, state: { projects: recoveredProjects } };
}
}
const canvasStorage: PersistStorage<CanvasStore> = {
getItem: async (name) => {
const scope = getActiveUserScope();
const value = await localForageStorageForScope(scope).getItem(name);
if (!value) {
const storage = localForageStorageForScope(scope);
const value = await storage.getItem(name);
let document = parseCanvasStorageDocument(value);
document = await recoverCanvasPersistenceJournal(scope, name, document);
if (!value && !document.state.projects.length) {
canvasMemoryStates.set(scope, { projects: [] });
observedCanvasPersists.set(scope, { projects: [], revision: 0 });
observedCanvasPersists.set(scope, { projects: [], revision: document.storageRevision });
return null;
}
const document = parseCanvasStorageDocument(value);
const state = document.state as PersistedCanvasState;
canvasMemoryStates.set(scope, state);
recordCanvasStorageDocument(scope, document);
@@ -397,7 +605,8 @@ const canvasStorage: PersistStorage<CanvasStore> = {
setItem: (name, value) => {
const scope = getActiveUserScope();
const nextState = value.state as PersistedCanvasState;
if (canvasMemoryStates.get(scope)?.projects === nextState.projects) return;
const previousState = canvasMemoryStates.get(scope);
if (previousState?.projects === nextState.projects) return;
canvasMemoryStates.set(scope, nextState);
if (suppressCanvasStorePersistence) return;
@@ -405,29 +614,35 @@ const canvasStorage: PersistStorage<CanvasStore> = {
const observed = observedCanvasPersists.get(scope);
const token = (canvasPersistTokens.get(scope) || 0) + 1;
canvasPersistTokens.set(scope, token);
const baseProjects = queued?.baseProjects ?? observed?.projects ?? [];
const baseProjects = queued?.baseProjects ?? observed?.projects ?? previousState?.projects ?? [];
const baseRevision = queued?.baseRevision ?? observed?.revision ?? 0;
const queuedState = ordinaryCanvasPersistenceState(scope, nextState, observed?.projects ?? baseProjects);
const journalBaseProjects = queued?.baseProjects ?? observed?.projects ?? previousState?.projects ?? [];
const journal = createCanvasPersistenceJournal(scope, name, journalBaseProjects, queuedState.projects, token, baseRevision);
const journalWritten = journal ? writeCanvasPersistenceJournal(journal) : false;
queuedCanvasPersists.set(scope, {
name,
scope,
baseProjects,
baseRevision: queued?.baseRevision ?? observed?.revision ?? 0,
// generationEffectKeys 由专用 generation durable commit 管理;普通队列只能携带已确认 stamp。
state: ordinaryCanvasPersistenceState(scope, nextState, observed?.projects ?? baseProjects),
baseRevision,
state: queuedState,
token,
journal: journalWritten ? journal || undefined : undefined,
});
markCanvasLocalPersistencePending(nextState.projects, journal && !journalWritten ? "本地快速恢复记录写入失败,正在等待完整画布保存" : undefined);
clearCanvasSaveTimer(scope);
const timer = setTimeout(() => {
if (canvasSaveTimers.get(scope) === timer) canvasSaveTimers.delete(scope);
void writeQueuedCanvasPersist(scope, token).catch((error) => {
// 自动保存无法把异常返回给原始状态更新调用方,但失败队列仍会保留给下一次写入或显式 flush 重试。
// 自动保存失败有队列兜底,属可降级场景;在无本地存储的环境(如测试进程)不应升级为错误级日志。
// 自动保存无法把异常返回给原始状态更新调用方,但失败队列和 journal 会保留给下一次写入或显式 flush 重试。
console.warn("画布本地持久化失败,已保留待写队列", { scope, error });
});
}, 400);
}, journalWritten ? 400 : 0);
canvasSaveTimers.set(scope, timer);
},
removeItem: (name) => {
const scope = getActiveUserScope();
for (const journal of readCanvasPersistenceJournals(scope)) clearCanvasPersistenceJournal(scope, journal.token, journal.writerId);
return localForageStorageForScope(scope).removeItem(name);
},
};
+13 -3
View File
@@ -1,12 +1,22 @@
import { create } from "zustand";
export type LocalCanvasPersistencePhase = "saved" | "pending" | "error";
export type SyncProjectProgress = {
projectId: string;
total: number;
completed: number;
phase: "pending" | "uploading" | "saving" | "reconciling" | "done" | "error" | "conflict";
phase: "local" | "pending" | "uploading" | "saving" | "reconciling" | "done" | "error" | "conflict";
message?: string;
draftCount?: number;
draftError?: string;
errorKind?: "local" | "network" | "conflict" | "draft" | "resources";
localPhase?: LocalCanvasPersistencePhase;
localSavedAt?: string;
localError?: string;
pendingWrites?: number;
pendingChanges?: number;
cloudRevision?: number;
};
type SyncProgressStore = {
@@ -56,11 +66,11 @@ export const useSyncProgressStore = create<SyncProgressStore>((set, get) => ({
clearAll: () => set({ syncingProjects: {} }),
isAnySyncing: () => {
const list = Object.values(get().syncingProjects);
return list.some((item) => item.phase !== "done");
return list.some((item) => (item.phase !== "done" && item.phase !== "local") || item.localPhase === "pending" || item.localPhase === "error" || Boolean(item.draftError));
},
}));
if (typeof window !== "undefined") {
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
window.addEventListener("beforeunload", (event) => {
if (useSyncProgressStore.getState().isAnySyncing()) {
event.preventDefault();