fix(canvas): 画布 - 防止旧内容覆盖并支持版本历史恢复

This commit is contained in:
kinoko
2026-09-17 18:40:42 +08:00
parent b6bae4f957
commit 35ebed3097
69 changed files with 3575 additions and 294 deletions

View File

@@ -1,6 +1,10 @@
import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, restoreRemoteCanvasHistory, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
import { ApiError } from "@/services/api/request";
import { canvasContentHash, sameCanvasContent } from "@/lib/canvas/canvas-content";
import { getActiveUserScope } from "@/lib/user-scope";
import { preserveCanvasSyncDraft, readCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
import { appQueryClient } from "@/lib/query-client";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { parseAssetRecordList } from "@/lib/asset-record";
@@ -13,7 +17,6 @@ import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
import { repairMissingCanvasAssets, collectCanvasMediaAssetIds, rebindInconsistentCanvasAssets, type CanvasAssetRebindResult } from "@/services/canvas-asset-repair";
import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset";
import { sameAgentCanvasContent } from "@/lib/canvas/agent-canvas-snapshot";
import { applyAgentCanvasPatch, type AgentCanvasPatch } from "@/lib/canvas/agent-canvas-patch";
let activeRemoteUserId = "";
@@ -44,34 +47,89 @@ export async function initializeRemoteUserDataSession(userId: string) {
});
}
export async function loadCanvasProjectForEditing(id: string) {
const pending = remoteProjectLoadPromises.get(id);
if (pending) return pending;
export async function loadCanvasProjectForEditing(id: string, options: { latest?: boolean; historyRestore?: { snapshotId: string; revision: number }; onLoad?: (project: CanvasProject) => void } = {}) {
const epoch = sessionEpoch;
const request = withRemoteUserDataSyncExclusive(async () => {
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
const local = useCanvasStore.getState().projects.find((project) => project.id === id);
if (!activeRemoteUserId || verifiedProjects.has(id)) return local;
const { project } = await getRemoteCanvasProject(id);
await loadReferencedAssets(collectAssetIds(project));
const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id);
if (current && !sameEntitySnapshot(acknowledgedProjects.get(id), current)) {
// 当前页面已经开始编辑本地缓存时,远端详情只建立新的冲突基线;
// 保留当前画布内容,后续保存由当前打开的画布显式覆盖远端版本,
// 避免旧缓存被永久卡在“自动重试但永远冲突”的状态。
acknowledgedProjects.set(id, project);
verifiedProjects.add(id);
return current;
requireRemoteUserDataBaseline();
const scope = getActiveUserScope();
if (options.historyRestore) {
const local = useCanvasStore.getState().openProject(id);
if (local) {
const draftCount = await preserveCanvasSyncDraft(local, scope);
useSyncProgressStore.getState().setProjectProgress(id, { draftCount });
}
if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) {
throw new Error("本地内容仍在更新,请稍后再恢复历史版本");
}
const { snapshotId, revision } = options.historyRestore;
try {
const saved = await restoreRemoteCanvasHistory(id, snapshotId, revision);
if (epoch !== sessionEpoch || getActiveUserScope() !== scope) throw new Error("账号已切换,请重新打开画布查看恢复结果");
if (saved.project.revision !== revision + 1) throw new Error("恢复结果的版本无效,请重新核对云端内容");
} catch (error) {
if (epoch === sessionEpoch && getActiveUserScope() === scope && error instanceof ApiError && (error.status === 409 || error.status === 428)) {
useSyncProgressStore.getState().setProjectProgress(id, { phase: "conflict", message: error.message });
}
throw error;
}
}
let remote: CanvasProject;
try {
remote = (await getRemoteCanvasProject(id)).project;
} catch (error) {
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
const local = useCanvasStore.getState().openProject(id);
if (error instanceof ApiError && error.status === 404 && local?.revision === 0) {
options.onLoad?.(local);
return local;
}
throw error;
}
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
await loadReferencedAssets(collectAssetIds(remote));
const hash = await canvasContentHash(remote);
if (epoch !== sessionEpoch || getActiveUserScope() !== scope) throw new Error("账号已切换,请重新打开画布");
const local = useCanvasStore.getState().openProject(id);
const cachedClean = local?.remoteContentHash && local.remoteContentHash === await canvasContentHash(local);
const dirty = local && (!verifiedProjects.has(id) && incrementalSession ? !cachedClean : !sameCanvasContent(acknowledgedProjects.get(id), local));
if (dirty && !sameCanvasContent(local, remote)) {
const draftCount = await preserveCanvasSyncDraft(local, scope);
if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) throw new Error("画布仍在更新,已保留本地内容,请稍后再加载最新版本");
useSyncProgressStore.getState().setProjectProgress(id, { draftCount });
if (!options.latest && !options.historyRestore && local.revision !== undefined) {
if (local.revision !== remote.revision) {
useSyncProgressStore.getState().setProjectProgress(id, { phase: "conflict", message: "云端画布已有更新,本地修改已保留为草稿" });
} else {
// A cached draft is not an acknowledged cloud save. Once its
// ancestor is verified, retain it as pending against that content.
acknowledgedProjects.set(id, { ...remote, remoteContentHash: hash });
verifiedProjects.add(id);
useSyncProgressStore.getState().setProjectProgress(id, { phase: "pending", message: "本地草稿等待保存到云端" });
scheduleRemoteUserDataSync();
}
options.onLoad?.(local);
return local;
}
}
// Editing/generation may continue during the network request or draft write.
// Never replace a newer local state which has not been backed up.
if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) {
throw new Error("画布仍在更新,已保留本地内容,请稍后再加载最新版本");
}
const project = { ...remote, viewport: local?.viewport || remote.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: hash };
// Align live editor refs synchronously before publishing the new revision.
// A generation callback must never see old rendered nodes paired with a new revision.
options.onLoad?.(project);
acknowledgedProjects.set(id, project);
verifiedProjects.add(id);
useCanvasStore.setState((state) => ({ projects: [...state.projects.filter((candidate) => candidate.id !== id), project] }));
useCanvasStore.setState((state) => ({ projects: local ? state.projects.map((item) => item.id === id ? project : item) : [...state.projects, project] }));
await flushCanvasStorePersistence();
useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", message: "已加载云端最新版本" });
return project;
});
remoteProjectLoadPromises.set(id, request);
const clearPending = () => {
if (remoteProjectLoadPromises.get(id) === request) remoteProjectLoadPromises.delete(id);
};
const clearPending = () => { if (remoteProjectLoadPromises.get(id) === request) remoteProjectLoadPromises.delete(id); };
void request.then(clearPending, clearPending);
return request;
}
@@ -92,13 +150,28 @@ export async function refreshCanvasAfterAgent(id: string) {
const { project } = await getRemoteCanvasProject(id);
if (epoch !== sessionEpoch) throw new Error("账号已切换");
const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id);
if (current && !sameAgentCanvasContent(acknowledgedProjects.get(id), current)) throw new Error("Agent 已更新服务端画布,但本地存在未同步编辑。已保留本地内容,请处理同步冲突后刷新。");
if (current && sameEntitySnapshot(current, project)) return current;
const projected = current ? { ...project, viewport: current.viewport } : project;
for (const listener of agentCanvasListeners) listener(projected, current);
const cachedDirty = current && incrementalSession && !verifiedProjects.has(id) && current.remoteContentHash !== await canvasContentHash(current);
if (epoch !== sessionEpoch) throw new Error("账号已切换");
if (current && (cachedDirty || !sameCanvasContent(acknowledgedProjects.get(id), current))) {
await preserveAgentConflict(current);
throw new Error("Agent 已更新服务端画布,但本地存在未同步编辑。已保留本地草稿,请加载云端最新版本。");
}
if (current && current.revision === project.revision && sameCanvasContent(current, project)) return current;
const projected = { ...project, viewport: current?.viewport || project.viewport, remoteContentHash: await canvasContentHash(project) };
if (epoch !== sessionEpoch || useCanvasStore.getState().openProject(id) !== (current || null)) throw new Error("画布仍在更新,请重新同步 Agent 结果");
try {
if (!sameCanvasContent(current, projected)) {
for (const listener of agentCanvasListeners) listener(projected, current);
}
} catch (error) {
if (current) await preserveAgentConflict(current);
throw error;
}
acknowledgedProjects.set(id, project);
verifiedProjects.add(id);
useCanvasStore.setState((state) => ({ projects: [...state.projects.filter((candidate) => candidate.id !== id), projected] }));
await flushCanvasStorePersistence();
useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", message: "已同步 Agent 画布结果" });
return projected;
});
}
@@ -110,19 +183,46 @@ export async function applyAgentCanvasPatches(id: string, patches: AgentCanvasPa
const baseline = acknowledgedProjects.get(id);
const current = useCanvasStore.getState().projects.find((project) => project.id === id);
if (!baseline || !current) throw new Error("缺少画布同步基线,需要重新读取画布");
const remote = patches.reduce(applyAgentCanvasPatch, baseline);
const projected = patches.reduce(applyAgentCanvasPatch, current);
if (projected !== current) {
for (const listener of agentCanvasListeners) listener(projected, current);
let remote = baseline;
let projected = current;
try {
if (incrementalSession && !verifiedProjects.has(id) && baseline.remoteContentHash !== await canvasContentHash(baseline)) throw new Error("本地缓存尚未核对,请加载云端最新版本");
if (current.revision !== baseline.revision || !Number.isSafeInteger(baseline.revision)) throw new Error("画布同步基线已变化");
for (const patch of patches) {
if (!Number.isSafeInteger(patch.revision) || !Number.isSafeInteger(patch.baseRevision)) throw new Error("Agent 增量缺少版本,请重新读取画布");
if (patch.revision! <= remote.revision!) continue;
if (patch.baseRevision !== remote.revision || patch.revision !== patch.baseRevision! + 1) throw new Error("Agent 画布增量版本不连续,请重新读取画布");
remote = { ...applyAgentCanvasPatch(remote, patch), revision: patch.revision };
projected = { ...applyAgentCanvasPatch(projected, patch), revision: patch.revision };
}
if (projected === current) return current;
const hash = await canvasContentHash(remote);
if (epoch !== sessionEpoch || useCanvasStore.getState().openProject(id) !== current) throw new Error("画布仍在更新,请重新同步 Agent 结果");
remote = { ...remote, remoteContentHash: hash };
projected = { ...projected, remoteContentHash: hash };
if (!sameCanvasContent(projected, current)) {
for (const listener of agentCanvasListeners) listener(projected, current);
}
} catch (error) {
if (epoch === sessionEpoch) await preserveAgentConflict(useCanvasStore.getState().openProject(id) || current);
throw error;
}
acknowledgedProjects.set(id, remote);
verifiedProjects.add(id);
if (projected === current) return current;
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project.id === id ? projected : project) }));
await flushCanvasStorePersistence();
useSyncProgressStore.getState().setProjectProgress(id, { phase: sameCanvasContent(remote, projected) ? "done" : "pending", message: "已同步 Agent 画布结果" });
return projected;
});
}
async function preserveAgentConflict(project: CanvasProject) {
useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "conflict", message: "云端画布已有更新,请保留草稿并加载最新版本" });
const draftCount = await preserveCanvasSyncDraft(project);
useSyncProgressStore.getState().setProjectProgress(project.id, { draftCount });
}
async function waitForRemoteProjectLoads() {
const pending = [...remoteProjectLoadPromises.values()];
if (pending.length) await Promise.all(pending);
@@ -184,7 +284,6 @@ const LOCAL_STORAGE_KEY_PATTERN = /^(image|video|audio|file|video-reference|audi
export async function syncRemoteUserData(userId?: string | null) {
// 登录/切换账号时,服务端快照建立新的远端基线;本地 IndexedDB 只负责首屏缓存,
// 不能把服务端已经删除或当前用户无权访问的实体重新补回去。后续增量保存必须基于这份基线做冲突校验。
let repairedCanvasAssets = false;
await withRemoteUserDataSyncExclusive(async () => {
incrementalSession = false;
activeRemoteUserId = userId || "";
@@ -199,30 +298,49 @@ export async function syncRemoteUserData(userId?: string | null) {
// 登录只拉一次聚合快照。摘要列表再逐条请求详情会把 N 条数据放大成 2N+2 个请求,
// 并且会在登录阶段同时触发大量媒体解析,任何一项失败都会污染登录结果。
const snapshot = await getRemoteUserDataSnapshot();
// 登录时服务端是实体真相。浏览器 IndexedDB 只作为首屏缓存,不能把服务端已删除的记录补回去。
// 这里只替换结构化记录,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。
const snapshotAssets = parseAssetRecordList(snapshot.assets);
useCanvasStore.getState().replaceProjects(snapshot.projects);
useAssetStore.getState().replaceAssets(snapshotAssets);
const repair = repairMissingCanvasAssets();
repairedCanvasAssets = repair.createdAssets > 0 || repair.updatedProjects > 0;
const localProjects = useCanvasStore.getState().projects;
const remoteById = new Map(snapshot.projects.map((project) => [project.id, project]));
// Archive unsynced work before replacing the active cache, including deleted remote canvases.
for (const local of localProjects) {
const clean = local.remoteContentHash && local.remoteContentHash === await canvasContentHash(local);
if (!clean && !sameCanvasContent(local, remoteById.get(local.id))) await preserveCanvasSyncDraft(local);
}
const projects = await Promise.all(snapshot.projects.map(async (project) => {
const local = localProjects.find((item) => item.id === project.id);
const draftCount = (await readCanvasSyncDrafts(project.id)).length;
useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "done", draftCount, message: "已保存到云端" });
return { ...project, viewport: local?.viewport || project.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: await canvasContentHash(project) };
}));
if (useCanvasStore.getState().projects !== localProjects) throw new Error("本地画布仍在更新,已保留本地内容,请重新同步");
useCanvasStore.getState().replaceProjects(projects);
useAssetStore.getState().replaceAssets(parseAssetRecordList(snapshot.assets));
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
acknowledgedProjects = new Map(snapshot.projects.map((project) => [project.id, project]));
acknowledgedAssets = new Map(snapshotAssets.map((asset) => [asset.id, asset]));
acknowledgedProjects = new Map(projects.map((project) => [project.id, project]));
acknowledgedAssets = new Map(parseAssetRecordList(snapshot.assets).map((asset) => [asset.id, asset]));
remoteUserDataPhase = "ready";
} catch (error) {
remoteUserDataPhase = "failed";
throw error;
}
});
if (repairedCanvasAssets) await saveRemoteUserDataNow();
}
export function installRemoteUserDataAutoSync() {
if (subscriptionsInstalled) return;
subscriptionsInstalled = true;
useCanvasStore.subscribe((state, previous) => {
if (state.projects !== previous.projects) scheduleRemoteUserDataSync();
if (state.projects === previous.projects) return;
const before = new Map(previous.projects.map((project) => [project.id, project]));
const changed = state.projects.filter((project) => !sameCanvasContent(before.get(project.id), project));
if (!changed.length && state.projects.length === previous.projects.length) return;
if (remoteUserDataPhase === "ready") {
for (const project of changed) {
if (sameCanvasContent(acknowledgedProjects.get(project.id), project)) continue;
if (useSyncProgressStore.getState().syncingProjects[project.id]?.phase === "conflict") continue;
useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "pending", message: "有修改等待保存" });
}
}
scheduleRemoteUserDataSync();
});
useAssetStore.subscribe((state, previous) => {
if (state.assets !== previous.assets) scheduleRemoteUserDataSync();
@@ -281,6 +399,9 @@ export function scheduleRemoteUserDataSync() {
export function formatLocalSavedRemotePending(localAction: string, error: unknown): string {
const detail = error instanceof Error && error.message.trim() ? error.message.trim() : "未知错误";
if (error instanceof ApiError && (error.status === 409 || error.status === 428)) {
return `${localAction},云端同步已暂停:${detail}。请保留草稿并加载最新版本。`;
}
return `${localAction},云端同步失败:${detail}。将自动重试。`;
}
@@ -295,7 +416,7 @@ export async function createCanvasProjectWithRemoteSync(title: string, projectId
if (initialContent) useCanvasStore.getState().updateProject(id, initialContent);
if (!activeRemoteUserId) return { id, syncError: new Error("尚未建立云端同步会话") };
try {
await saveRemoteUserDataNow();
await saveRemoteUserDataNow(id);
return { id };
} catch (syncError) {
scheduleRemoteUserDataSync();
@@ -436,35 +557,44 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
});
}
export async function saveRemoteUserDataNow(options: { force?: boolean } = {}) {
// 这是远端写入的总闸门:只有 phase=ready 且已建立 acknowledged 基线时才能提交。
// 本地 Zustand/localForage 写成功不等于服务端写成功,任何同步异常都必须继续抛出给调用方。
export async function saveRemoteUserDataNow(input?: string | readonly string[] | { force?: boolean }) {
const projectId = typeof input === "string" || Array.isArray(input) ? input as string | readonly string[] : undefined;
const options = input && typeof input === "object" && !Array.isArray(input) ? input as { force?: boolean } : {};
const epoch = sessionEpoch;
if (!activeRemoteUserId) return;
if (!activeRemoteUserId) throw new Error("尚未建立云端同步会话,本地内容尚未保存到云端");
requireRemoteUserDataBaseline();
// 画布先用本地缓存秒开时,远端详情校验可能仍在进行;写入必须等待校验结果。
const assertNoConflict = () => {
const ids = projectId === undefined ? useCanvasStore.getState().projects.map((project) => project.id)
: typeof projectId === "string" ? [projectId] : projectId;
if (ids.some((id) => useSyncProgressStore.getState().syncingProjects[id]?.phase === "conflict")) {
throw new ApiError("云端画布已有更新,请保留本地草稿并加载最新版本", { status: 409 });
}
};
if (projectId !== undefined) assertNoConflict();
await waitForRemoteProjectLoads();
if (syncPromise) {
syncQueued = true;
return syncPromise;
await syncPromise;
assertNoConflict();
return;
}
syncPromise = withRemoteUserDataSyncExclusive(async () => {
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
requireRemoteUserDataBaseline();
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
await drainRemoteUserDataChanges(options);
});
try {
await syncPromise;
assertNoConflict();
} finally {
syncPromise = null;
if (syncQueued) scheduleRemoteUserDataSync();
}
}
/**
* 强制覆盖保存:以本地内容为准修复画布媒体与素材的绑定(重绑到引用同一资源的素材,
* 缺失则按节点新建),再走「素材先于画布」的整体推送覆盖云端。服务端画布不变式只认
* 「节点资源被其 assetId 对应素材引用」,重绑后的本地画布可以在不破坏该不变式的前提下
* 覆盖远端。素材远端版本冲突时采纳远端为基线继续覆盖,避免显式覆盖被冲突检测卡死。
* 修复媒体与素材的绑定,再按素材先于画布的顺序保存。
* 素材修复可采用远端素材基线;画布始终携带原始 revision,不能绕过版本校验。
*/
export async function forceOverwriteRemoteCanvasSync(): Promise<CanvasAssetRebindResult> {
const epoch = sessionEpoch;
@@ -472,7 +602,7 @@ export async function forceOverwriteRemoteCanvasSync(): Promise<CanvasAssetRebin
requireRemoteUserDataBaseline();
await waitForRemoteProjectLoads();
const rebind = await withRemoteUserDataSyncExclusive(async () => {
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止强制覆盖");
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
requireRemoteUserDataBaseline();
const projects = useCanvasStore.getState().projects;
// 服务端素材记录是 guard 实际校验的事实;本地缓存可能落后,须先取回再判定绑定一致性。
@@ -503,26 +633,15 @@ async function drainRemoteUserDataChanges(options: { force?: boolean } = {}) {
async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: { force?: boolean } = {}) {
// 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。
// 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。
const changedProjectIds = new Set(useCanvasStore.getState().projects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project)).map((project) => project.id));
repairMissingCanvasAssets(incrementalSession ? changedProjectIds : undefined, incrementalSession);
const changedProjectIds = new Set(useCanvasStore.getState().projects.filter((project) => !sameCanvasContent(acknowledgedProjects.get(project.id), project)).map((project) => project.id));
repairMissingCanvasAssets(changedProjectIds, incrementalSession);
const currentProjects = useCanvasStore.getState().projects;
const currentAssets = useAssetStore.getState().assets;
const dirtyProjects = currentProjects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project));
const dirtyProjects = currentProjects.filter((project) => !sameCanvasContent(acknowledgedProjects.get(project.id), project) && useSyncProgressStore.getState().syncingProjects[project.id]?.phase !== "conflict");
const dirtyAssets = currentAssets.filter((asset) => !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset));
if (!dirtyProjects.length && !dirtyAssets.length) return;
if (incrementalSession) {
for (const source of dirtyProjects) {
const baseline = acknowledgedProjects.get(source.id);
if (!baseline || verifiedProjects.has(source.id)) continue;
const { project } = await getRemoteCanvasProject(source.id);
if (Date.parse(project.updatedAt) !== Date.parse(baseline.updatedAt)) {
// 以远端当前版本作为新的校验基线,继续提交当前打开画布的完整快照。
// 这是画布编辑态的显式覆盖策略,避免资产同步被旧缓存冲突永久阻塞。
acknowledgedProjects.set(source.id, project);
}
verifiedProjects.add(source.id);
}
for (const source of dirtyAssets) {
const baseline = acknowledgedAssets.get(source.id);
if (!baseline || verifiedAssets.has(source.id)) continue;
@@ -546,24 +665,27 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
acknowledgedAssets.set(source.id, source);
verifiedAssets.add(source.id);
}
const errors: unknown[] = [];
for (const source of dirtyProjects) {
const keysToUpload = collectLocalMediaKeys(source);
const total = keysToUpload.length;
if (total > 0) {
useSyncProgressStore.getState().setProjectProgress(source.id, {
useSyncProgressStore.getState().setProjectProgress(source.id, {
projectId: source.id,
total,
completed: 0,
phase: "uploading",
message: "正在同步媒体至云端",
phase: total > 0 ? "uploading" : "saving",
message: total > 0 ? "正在同步媒体至云端" : "正在保存画布",
});
}
const onMediaUploaded = () => {
if (total > 0) {
useSyncProgressStore.getState().incrementProjectCompleted(source.id);
}
};
try {
if (!Number.isSafeInteger(source.revision) || source.revision! < 0) {
throw new ApiError("缺少画布版本,请保留草稿并加载云端最新版本", { status: 428 });
}
const hash = await canvasContentHash(source);
const remotePayload = await ensureRemoteResourceReferences(source, uploaded, onMediaUploaded);
if (total > 0) {
useSyncProgressStore.getState().setProjectProgress(source.id, {
@@ -571,20 +693,45 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
message: "正在保存画布结构",
});
}
await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload));
acknowledgedProjects.set(source.id, source);
verifiedProjects.add(source.id);
if (total > 0) useSyncProgressStore.getState().setProjectProgress(source.id, null);
} catch (error) {
if (total > 0) {
useSyncProgressStore.getState().setProjectProgress(source.id, {
phase: "error",
message: error instanceof Error ? error.message : "云端同步失败,等待重试",
});
const { project: saved } = await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload));
if (!Number.isSafeInteger(saved.revision) || saved.revision !== source.revision! + 1) {
throw new ApiError("服务端未返回有效画布版本,请加载云端最新版本", { status: 409 });
}
throw error;
const current = useCanvasStore.getState().openProject(source.id);
if (current && current.revision !== source.revision) {
throw new ApiError("画布基线已变化,请加载云端最新版本", { status: 409 });
}
const acknowledged = { ...source, revision: saved.revision, remoteContentHash: hash };
acknowledgedProjects.set(source.id, acknowledged);
verifiedProjects.add(source.id);
if (current) {
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project === current
? { ...project, revision: saved.revision, remoteContentHash: hash } : project) }));
}
await flushCanvasStorePersistence();
const pending = !sameCanvasContent(source, useCanvasStore.getState().openProject(source.id) || undefined);
useSyncProgressStore.getState().setProjectProgress(source.id, { phase: pending ? "pending" : "done", message: pending ? "有新修改等待保存" : "已保存到云端" });
if (pending) syncQueued = true;
} catch (error) {
const conflict = error instanceof ApiError && (error.status === 409 || error.status === 428);
useSyncProgressStore.getState().setProjectProgress(source.id, {
phase: conflict ? "conflict" : "error",
message: error instanceof Error ? error.message : "云端同步失败,等待重试",
});
if (conflict) {
try {
const current = useCanvasStore.getState().openProject(source.id) || source;
const draftCount = await preserveCanvasSyncDraft(current);
useSyncProgressStore.getState().setProjectProgress(source.id, { draftCount });
} catch (draftError) {
useSyncProgressStore.getState().setProjectProgress(source.id, { message: "本地草稿保存失败,请勿关闭页面;请先下载草稿" });
errors.push(draftError);
}
}
errors.push(error);
}
}
if (errors.length) throw errors[0];
if (dirtyProjects.length) void appQueryClient.invalidateQueries({ queryKey: ["canvas-library"] });
}