fix(canvas): 画布同步 - 版本冲突先核对并合并,本地保存与云端状态分开展示
本地持久化记录保存时间和日志令牌,同步面板区分本地与云端,不再用提示打断编辑。冲突时先尝试合并,无法合并才保留草稿并暂停自动提交;未登录时生成结果留在本地,登录后再继续上传。
This commit is contained in:
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user