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

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

No files matched your search

@@ -79,6 +79,21 @@
line-height: 1.65;
}
.canvas-sync-panel__details {
display: grid;
gap: 7px;
margin: 0 17px 13px;
padding: 10px 11px;
border: 1px solid var(--user-border, var(--border));
border-radius: 10px;
background: color-mix(in srgb, var(--user-surface, var(--background)) 72%, transparent);
font-size: 11px;
}
.canvas-sync-panel__details > div { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.canvas-sync-panel__details span { color: var(--user-ink-soft, var(--muted-foreground)); }
.canvas-sync-panel__details strong { color: var(--user-ink, var(--foreground)); font-size: 11px; font-weight: 600; text-align: right; }
.canvas-sync-panel__details small { color: var(--user-ink-muted, var(--muted-foreground)); }
.canvas-sync-panel__callout {
display: grid;
gap: 3px;
+94 -92
View File
@@ -1,10 +1,7 @@
import { App, Button, Dropdown, Popover } from "antd";
import { CloudCheck, CloudOff, Download, FileClock, LoaderCircle, RefreshCw } from "lucide-react";
import { App, Button, Popover } from "antd";
import { CloudCheck, CloudOff, FileClock, LoaderCircle, RefreshCw } from "lucide-react";
import { useState } from "react";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
import { readAllCanvasSyncDrafts, readCanvasSyncDrafts, type CanvasSyncDraft } from "@/services/canvas-sync-drafts";
import { getActiveUserScope } from "@/lib/user-scope";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { retryRemoteUserDataSync } from "@/services/user-data-sync";
import "./canvas-sync-status.css";
@@ -16,23 +13,58 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
const [statusOpen, setStatusOpen] = useState(false);
const phase = progress?.phase;
const conflict = phase === "conflict";
const error = phase === "error";
const retryScheduled = error && progress?.message?.includes("自动重试");
const cloudError = phase === "error";
const localPhase = progress?.localPhase;
const localError = localPhase === "error";
const localPending = localPhase === "pending";
const retryScheduled = cloudError && progress?.message?.includes("自动重试");
const saving = phase === "pending" || phase === "saving" || phase === "uploading";
const reconciling = phase === "reconciling";
const tone = conflict ? "conflict" : error ? "error" : saving || reconciling ? "pending" : phase === "done" ? "done" : "idle";
const label = conflict ? "需要处理冲突" : error ? "云端保存失败" : reconciling ? "正在自动合并" : saving ? "正在同步" : phase === "done" ? "已保存到云端" : "尚未同步";
const tone = conflict || cloudError || localError ? (conflict ? "conflict" : "error") : saving || reconciling || localPending ? "pending" : phase === "done" ? "done" : localPhase === "saved" ? "done" : "idle";
const label = conflict
? "需要处理冲突"
: cloudError
? "云端保存失败"
: localError
? "本地保存失败"
: reconciling
? "正在自动合并"
: saving
? "正在同步云端"
: localPending
? "正在保存本地"
: phase === "done"
? "已保存到云端"
: localPhase === "saved"
? "已保存在本地"
: "尚未同步";
const description = conflict
? "本地与云端修改了同一字段,系统已保留本地草稿。"
: error
? progress?.message || "本地与云端存在冲突,当前编辑内容保持不变。"
: cloudError
? progress?.message || "本地内容仍然保留,云端暂未确认。"
: reconciling
? progress?.message || "正在合并云端与本地修改,完成后会自动继续保存。"
: saving
? progress?.message || "正在把本地修改同步到云端。"
: phase === "done"
? progress?.message || "当前画布与云端版本一致。"
: "本地内容会先保存到浏览器,再尝试同步到云端。";
: localError
? progress?.localError || "本地保存没有完成,请立即重试;当前编辑内容保持不变。"
: reconciling
? progress?.message || "正在合并云端与本地修改,完成后会自动继续保存。"
: saving
? progress?.message || "本地内容已保留,正在同步到云端。"
: localPending
? "正在把最近编辑写入浏览器本地存储。"
: phase === "done"
? progress?.message || "当前画布与云端版本一致。"
: localPhase === "saved"
? "当前编辑已保存到浏览器本地,登录后会继续同步到云端。"
: "本地内容会先保存到浏览器,再尝试同步到云端。";
const localStatus = localError ? progress?.localError || "保存失败" : localPending ? "正在保存" : localPhase === "saved" ? `已保存${progress?.localSavedAt ? ` · ${new Date(progress.localSavedAt).toLocaleTimeString()}` : ""}` : "尚未确认";
const cloudStatus = conflict
? "存在冲突"
: cloudError
? progress?.message || "保存失败"
: saving || reconciling
? "正在同步"
: phase === "done"
? `已保存${progress?.cloudRevision !== undefined ? ` · 版本 ${progress.cloudRevision}` : ""}`
: "尚未同步";
const run = async (operation: () => Promise<unknown>) => {
setBusy(true);
@@ -53,7 +85,7 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
content: (
<div className="canvas-sync-confirm-copy">
<p>这不是合并操作:当前编辑器内容会替换为云端版本。</p>
<p>系统会先把本地修改保存为草稿,之后可从“版本记录”查看或下载,不会直接丢失。</p>
<p>系统会先把本地修改保存为草稿,之后可从“版本记录”查看,不会直接丢失。</p>
</div>
),
okText: "保留草稿并加载",
@@ -80,7 +112,7 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
<div className={`canvas-sync-panel canvas-sync-panel--${tone}`} data-canvas-no-zoom>
<div className="canvas-sync-panel__header">
<span className="canvas-sync-panel__status-icon" aria-hidden="true">
{saving ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : conflict || error ? <CloudOff /> : <CloudCheck />}
{saving || localPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : conflict || cloudError || localError ? <CloudOff /> : <CloudCheck />}
</span>
<div className="canvas-sync-panel__heading">
<span className="canvas-sync-panel__eyebrow">云端同步</span>
@@ -90,19 +122,40 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
<p className="canvas-sync-panel__description" role="status">
{description}
</p>
{conflict ? (
<div className="canvas-sync-panel__details" aria-label="画布保存分层状态">
<div>
<span>本地浏览器</span>
<strong>{localStatus}</strong>
</div>
<div>
<span>云端画布</span>
<strong>{cloudStatus}</strong>
</div>
{progress?.pendingWrites ? <small>待写入 {progress.pendingWrites} 项</small> : null}
</div>
{progress?.draftError ? (
<div className="canvas-sync-panel__callout">
<strong>本地草稿保存未完成,已暂停版本替换</strong>
<span>{progress.draftError}</span>
</div>
) : conflict ? (
<div className="canvas-sync-panel__callout">
<strong>先保护本地,再决定版本</strong>
<span>{progress?.draftCount ? `已保留 ${progress.draftCount} 份本地草稿。` : "加载云端版前会先保留本地草稿。"} 这是替换,不是合并。</span>
</div>
) : error ? (
) : cloudError ? (
<div className="canvas-sync-panel__callout">
<strong>{retryScheduled ? "本地内容仍在,正在等待重试" : "本地内容仍在,未覆盖云端"}</strong>
<span>{retryScheduled ? "网络恢复后会自动重试;也可以点击“立即重试”。" : "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"}</span>
</div>
) : localError ? (
<div className="canvas-sync-panel__callout">
<strong>本地保存没有完成</strong>
<span>{progress?.draftError || progress?.localError || "请立即重试;当前编辑内容保持不变。"}</span>
</div>
) : null}
<div className="canvas-sync-panel__actions">
{error ? (
{cloudError ? (
<Button
type="primary"
block
@@ -114,28 +167,27 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
.catch(() => undefined)
}
>
立即重试
立即重试云端同步
</Button>
) : localError ? (
<Button
type="primary"
block
icon={<RefreshCw className="size-3.5" />}
loading={busy}
onClick={() =>
void run(() => flushCanvasStorePersistence())
.then(() => setStatusOpen(false))
.catch(() => undefined)
}
>
立即重试本地保存
</Button>
) : null}
<Button block icon={<CloudOff className="size-3.5" />} disabled={busy || saving} onClick={confirmLoadLatest}>
<Button block icon={<CloudOff className="size-3.5" />} disabled={busy || saving || localPending || localError} onClick={confirmLoadLatest}>
加载云端版
</Button>
<div className="canvas-sync-panel__secondary-actions">
<Button
type="text"
size="small"
disabled={busy}
icon={<Download className="size-3.5" />}
onClick={() =>
void run(async () => {
const project = useCanvasStore.getState().openProject(projectId);
if (project) await exportCanvasProjects([project], `${project.title}-本地副本`);
message.success("当前内容已下载,可作为新画布导入");
}).catch(() => undefined)
}
>
下载本地副本
</Button>
{onOpenVersions ? (
<Button
type="text"
@@ -161,60 +213,10 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
className={`canvas-sync-status-trigger canvas-sync-status-trigger--${tone}`}
aria-label={`画布保存状态:${label}`}
aria-expanded={statusOpen}
icon={saving ? <LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" /> : conflict || error ? <CloudOff className="size-3.5" /> : <CloudCheck className="size-3.5" />}
icon={saving || localPending ? <LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" /> : conflict || cloudError || localError ? <CloudOff className="size-3.5" /> : <CloudCheck className="size-3.5" />}
>
<span className="canvas-sync-status-label text-xs">{label}</span>
</Button>
</Popover>
);
}
export function CanvasSyncDraftMenu({ projectId }: { projectId?: string }) {
const { message } = App.useApp();
const [drafts, setDrafts] = useState<CanvasSyncDraft[]>([]);
const [draftScope, setDraftScope] = useState("");
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
return (
<Dropdown
trigger={["click"]}
onOpenChange={(open) => {
if (!open) return;
const scope = getActiveUserScope();
setDraftScope(scope);
setDrafts([]);
setLoading(true);
void (projectId ? readCanvasSyncDrafts(projectId, scope) : readAllCanvasSyncDrafts(scope))
.then((items) => {
if (getActiveUserScope() === scope) setDrafts(items.reverse());
})
.catch(() => message.error("读取本地草稿失败"))
.finally(() => setLoading(false));
}}
menu={{
items: drafts.length
? drafts.map((draft) => ({
key: draft.id,
label: `${draft.project.title} · ${new Date(draft.savedAt).toLocaleString()}`,
onClick: () => {
if (getActiveUserScope() !== draftScope) {
setDrafts([]);
message.error("账号已切换,请重新打开本地草稿");
return;
}
setExporting(true);
void exportCanvasProjects([draft.project], `${draft.project.title}-本地草稿`, { includeLocalDrawings: false })
.then(() => message.success("草稿已下载,可从画布列表导入为新画布"))
.catch(() => message.error("草稿下载失败,请重试"))
.finally(() => setExporting(false));
},
}))
: [{ key: "empty", label: loading ? "正在读取草稿…" : "暂无本地草稿", disabled: true }],
}}
>
<Button size={projectId ? "small" : "middle"} loading={exporting}>
本地草稿
</Button>
</Dropdown>
);
}
@@ -1,11 +1,10 @@
import { AppDrawer } from "@/components/ui/product/app-drawer";
import { EmptyState } from "@/components/ui/product/empty-state";
import { App, Button, Grid, Spin } from "antd";
import { Check, ChevronDown, Cloud, Download, FileClock, History, RefreshCw, X } from "lucide-react";
import { Check, ChevronDown, Cloud, FileClock, History, RefreshCw, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { getCanvasHistoryEntry, listCanvasHistory, type CanvasHistoryEntry } from "@/services/api/user-data";
import { readCanvasSyncDrafts, type CanvasSyncDraft } from "@/services/canvas-sync-drafts";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
import { getActiveUserScope } from "@/lib/user-scope";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
@@ -40,7 +39,6 @@ export function useCanvasVersionHistory(projectId: string, onRestore: (snapshotI
const [draftLoading, setDraftLoading] = useState(false);
const [restoring, setRestoring] = useState(false);
const [confirming, setConfirming] = useState(false);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState("");
const [draftError, setDraftError] = useState("");
const [reload, setReload] = useState(0);
@@ -161,20 +159,6 @@ export function useCanvasVersionHistory(projectId: string, onRestore: (snapshotI
});
};
const download = async () => {
if (!preview?.project || !isCurrentContext()) return;
setExporting(true);
try {
// Drawing strokes are not versioned; never mix today's local strokes into an old snapshot.
await exportCanvasProjects([preview.project], `${preview.project.title}-${preview.label}`, { includeLocalDrawings: false });
message.success("已下载,可从画布列表导入为新画布");
} catch (cause) {
message.error(cause instanceof Error ? cause.message : "下载失败,请重试");
} finally {
setExporting(false);
}
};
return {
open,
listOpen,
@@ -188,7 +172,6 @@ export function useCanvasVersionHistory(projectId: string, onRestore: (snapshotI
draftLoading,
restoring,
confirming,
exporting,
error,
draftError,
show: () => {
@@ -225,14 +208,13 @@ export function useCanvasVersionHistory(projectId: string, onRestore: (snapshotI
},
returnToCurrent,
restore,
download,
};
}
export type CanvasVersionHistoryController = ReturnType<typeof useCanvasVersionHistory>;
export function CanvasVersionHistory({ history }: { history: CanvasVersionHistoryController }) {
const { open, tab, entries, drafts, currentRevision, preview, loading, draftLoading, restoring, exporting, error, draftError } = history;
const { open, tab, entries, drafts, currentRevision, preview, loading, draftLoading, restoring, error, draftError } = history;
if (!open) return null;
const groups = new Map<string, CanvasHistoryEntry[]>();
for (const entry of entries) {
@@ -340,7 +322,7 @@ export function CanvasVersionHistory({ history }: { history: CanvasVersionHistor
</>
) : (
<>
<p className="canvas-version-draft-note">发生冲突或恢复版本时保留的本机备份。下载后可从画布列表导入为新画布。</p>
<p className="canvas-version-draft-note">发生冲突或恢复版本时,系统自动保留的本地内容,可在此查看。</p>
{draftError ? (
<p role="alert" className="canvas-version-error">
{draftError}
@@ -383,19 +365,16 @@ export function CanvasVersionHistory({ history }: { history: CanvasVersionHistor
</small>
) : null}
{preview.project?.nodes.some((node) => node.type === "drawing") ? <p className="canvas-version-hint">绘图仅保留已上传的预览,不含本机历史笔画。</p> : null}
<div className="canvas-version-actions">
{preview.kind === "cloud" ? (
{preview.kind === "cloud" ? (
<div className="canvas-version-actions">
<Button block type="primary" loading={restoring} disabled={!preview.project || currentRevision === undefined || loading} onClick={history.restore}>
恢复此版本
</Button>
) : null}
<Button block type={preview.kind === "draft" ? "primary" : "default"} icon={<Download size={14} />} loading={exporting} disabled={!preview.project || restoring} onClick={() => void history.download()}>
{preview.kind === "draft" ? "下载草稿" : "下载此版本"}
</Button>
</div>
</div>
) : null}
</>
) : (
<p className="canvas-version-hint">{tab === "cloud" ? "内容变化时最多每 5 分钟保留一份,保留最近 20 份。恢复前额外备份。" : "草稿仅存于此浏览器,建议及时下载保留。"}</p>
<p className="canvas-version-hint">{tab === "cloud" ? "内容变化时最多每 5 分钟保留一份,保留最近 20 份。恢复前额外备份。" : "草稿保存在此浏览器中,查看草稿不会修改当前画布。"}</p>
)}
</footer>
</div>
@@ -40,7 +40,7 @@ export function CanvasVersionPreview({ preview, onReturn, onShowVersions }: { pr
if (active) setLocalMedia((current) => ({ ...current, [node.id]: url }));
})
.catch(() => {
if (active) setMediaError("本机媒体读取失败,可下载草稿后检查素材");
if (active) setMediaError("媒体暂时无法读取,请重新打开此版本重试");
});
return () => {
active = false;
-2
View File
@@ -29,7 +29,6 @@ import { resourceFileUrl, resourceStorageKey, uploadResourceFile } from "@/servi
import { primeResourceBlobCache } from "@/services/resource-blob-cache";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { CanvasSyncDraftMenu } from "./canvas-sync-status";
import { useAppearanceStore } from "@/stores/use-appearance-store";
const CanvasDeleteProjectsDialog = lazy(() => import("@/components/canvas/canvas-delete-projects-dialog").then((module) => ({ default: module.CanvasDeleteProjectsDialog })));
@@ -416,7 +415,6 @@ export default function CanvasPage() {
meta={<span className="app-projects-header-meta">{totalProjects} 个</span>}
actions={
<div className="collection-header-actions">
<CanvasSyncDraftMenu />
<Button type="primary" disabled={!hydrated} icon={<Plus />} onClick={createAndEnter}>
新建画布
</Button>
@@ -16,6 +16,7 @@ import { generationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
import { generationFailureMetadata } from "@/lib/generation-error";
import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
import { hasRemoteUserDataSyncSession } from "@/services/user-data-sync";
import { attachNodeEffectKey } from "@/services/generation-task-materializer";
import { consumeCanvasGenerationContinuation } from "./use-canvas-operation-history";
@@ -516,9 +517,13 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
}).catch((error) => {
autoSavedTaskIdsRef.current.delete(saveKey);
if (error instanceof Error && error.name === "AbortError") return;
const detail = error instanceof Error ? error.message : "未知错误";
const content = hasRemoteUserDataSyncSession()
? `生成结果已保留,本地素材已保存,云端项目资产稍后重试:${detail}`
: "生成结果已保留在本地素材库,登录后会继续同步到云端";
message.warning({
key: `canvas-asset-sync:${projectId}`,
content: error instanceof Error ? `生成结果已保留,但项目资产同步失败:${error.message}` : "生成结果已保留,但项目资产同步失败",
content,
duration: 4,
});
});
@@ -96,7 +96,7 @@ export function useCanvasProjectLifecycle({
const observedContentRef = useRef<CanvasHistorySnapshot | null>(null);
const loadLatestRef = useRef(false);
const historyRestoreRef = useRef<{ snapshotId: string; revision: number; resolve: () => void; reject: (error: unknown) => void } | null>(null);
const pendingReloadRef = useRef<{ resolve: () => void; reject: (error: unknown) => void } | null>(null);
const pendingReloadRef = useRef<{ promise: Promise<void>; resolve: () => void; reject: (error: unknown) => void } | null>(null);
const editorReadyRef = useRef(false);
useEffect(() => {
@@ -311,11 +311,12 @@ export function useCanvasProjectLifecycle({
renameProject(projectId, title);
// 标题是画布列表和分享入口的元数据,重命名后立即提交,避免只停留在浏览器缓存。
void saveRemoteUserDataNow(projectId).catch((error) => {
message.warning(error instanceof Error ? `名称已更新到本地,云端同步将在后台重试:${error.message}` : "名称已更新到本地,云端同步将在后台重试");
// 后台同步状态统一显示在同步面板,避免每次重试都打断编辑。
void error;
});
}, [message, projectId, renameProject]);
const persistLocalEdits = useCallback(async () => {
const persistLocalEdits = useCallback(async (options: { flush?: boolean } = {}) => {
const snapshot = { nodes: nodesRef.current, connections: connectionsRef.current, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo };
if (observedContentRef.current && !sameCanvasHistorySnapshot(observedContentRef.current, snapshot)) {
updateProject(projectId, {
@@ -331,18 +332,34 @@ export function useCanvasProjectLifecycle({
observedContentRef.current = snapshot;
}
updateProject(projectId, { viewport: viewportRef.current });
await flushCanvasStorePersistence();
if (options.flush !== false) await flushCanvasStorePersistence();
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
const reloadLatestCanvasProject = useCallback(async () => {
await persistLocalEdits();
return new Promise<void>((resolve, reject) => {
pendingReloadRef.current?.reject(new Error("已有新的加载请求"));
loadLatestRef.current = true;
pendingReloadRef.current = { resolve, reject };
setLoadAttempt((value) => value + 1);
const pending = pendingReloadRef.current;
if (pending) return pending.promise;
// The load path writes a durable draft before replacing the editor. Waiting
// for the ordinary canvas queue here makes a viewport drag able to starve
// the cloud read, so only bring the latest editor snapshot into memory.
useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "reconciling", message: "正在加载云端最新版本" });
try {
await persistLocalEdits({ flush: false });
} catch (error) {
useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "准备加载云端版本失败" });
throw error;
}
let resolve!: () => void;
let reject!: (error: unknown) => void;
const promise = new Promise<void>((done, fail) => {
resolve = done;
reject = fail;
});
}, [persistLocalEdits]);
pendingReloadRef.current = { promise, resolve, reject };
loadLatestRef.current = true;
setLoadAttempt((value) => value + 1);
return promise;
}, [persistLocalEdits, projectId]);
const restoreCanvasProjectVersion = useCallback(async (snapshotId: string, revision: number) => {
await persistLocalEdits();
@@ -365,7 +382,8 @@ export function useCanvasProjectLifecycle({
message.success("画布已保存到云端");
} catch (error) {
const detail = error instanceof Error ? error.message : "未知错误";
message.warning(`本地画布布局已保存,云端同步失败:${detail}`);
// 本地保存已经完成,云端状态统一显示在同步面板,不弹出同步失败 toast。
void detail;
// Imports can retain their durable local result; sharing requires cloud success.
return options.requireRemote === false;
}