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

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

View File

@@ -203,7 +203,7 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考
历史接口先验证画布归属;无权访问、已删除画布或不存在的快照返回 404。恢复沿用画布写入限流和素材校验,在同一事务完成版本校验、备份、内容写入及历史资源保护。恢复保留当前画布 ID 和项目归属,生成新版本,不倒退版本号。升级前的内容无法补建历史。 历史接口先验证画布归属;无权访问、已删除画布或不存在的快照返回 404。恢复沿用画布写入限流和素材校验,在同一事务完成版本校验、备份、内容写入及历史资源保护。恢复保留当前画布 ID 和项目归属,生成新版本,不倒退版本号。升级前的内容无法补建历史。
平移和缩放只持久化浏览器视口,内容 PUT 忽略客户端 `viewport` 和 `remoteContentHash`。前端遇到 409/428 会保留账号隔离的本地草稿、暂停该画布自动提交,并提供加载最新版和下载草稿入口;载入最新版前草稿写入失败时停止替换。其他画布仍可保存。 平移和缩放只持久化浏览器视口,内容 PUT 忽略客户端 `viewport` 和 `remoteContentHash`。前端遇到版本冲突(409/428)会先核对云端并尝试合并;无法合并时保留账号隔离的本地草稿、暂停该画布自动提交,并提供加载最新版和版本记录查看入口。载入最新版前草稿写入失败时停止替换,当前编辑内容保持不变;其他画布仍可保存。
单画布读取会先查询归属用户的版本元数据,再判断 `If-None-Match`;命中时不会读取大型画布正文。未命中时返回完整项目 JSON。正文达到 1 KiB 且客户端声明接受 gzip 时响应使用 gzip 压缩,并带 `Vary: Accept-Encoding`;因此客户端应以 HTTP 状态码区分 `200` 正文和 `304` 未变化,不要把 `304` 当作业务错误。 单画布读取会先查询归属用户的版本元数据,再判断 `If-None-Match`;命中时不会读取大型画布正文。未命中时返回完整项目 JSON。正文达到 1 KiB 且客户端声明接受 gzip 时响应使用 gzip 压缩,并带 `Vary: Accept-Encoding`;因此客户端应以 HTTP 状态码区分 `200` 正文和 `304` 未变化,不要把 `304` 当作业务错误。

View File

@@ -15,6 +15,8 @@ export type CanvasStorageDocument = {
state: { projects: CanvasProject[] }; state: { projects: CanvasProject[] };
version: number; version: number;
storageRevision: number; storageRevision: number;
savedAt?: string;
journalTokens?: Record<string, number>;
tombstones: CanvasStorageTombstones; tombstones: CanvasStorageTombstones;
}; };
@@ -73,6 +75,8 @@ export function parseCanvasStorageDocument(value: string | null, fallback: Canva
state?: { projects?: unknown }; state?: { projects?: unknown };
version?: unknown; version?: unknown;
storageRevision?: unknown; storageRevision?: unknown;
savedAt?: unknown;
journalTokens?: unknown;
tombstones?: unknown; tombstones?: unknown;
}; };
if (!Array.isArray(parsed.state?.projects)) throw new Error("画布持久状态无效"); if (!Array.isArray(parsed.state?.projects)) throw new Error("画布持久状态无效");
@@ -80,6 +84,8 @@ export function parseCanvasStorageDocument(value: string | null, fallback: Canva
state: { projects: normalizeProjectAssetCategories(parsed.state.projects as CanvasProject[]) }, state: { projects: normalizeProjectAssetCategories(parsed.state.projects as CanvasProject[]) },
version: typeof parsed.version === "number" ? parsed.version : 0, version: typeof parsed.version === "number" ? parsed.version : 0,
storageRevision: typeof parsed.storageRevision === "number" && Number.isFinite(parsed.storageRevision) ? parsed.storageRevision : 0, storageRevision: typeof parsed.storageRevision === "number" && Number.isFinite(parsed.storageRevision) ? parsed.storageRevision : 0,
savedAt: typeof parsed.savedAt === "string" && parsed.savedAt ? parsed.savedAt : undefined,
journalTokens: recordOfNumbers(parsed.journalTokens),
tombstones: normalizeTombstones(parsed.tombstones), tombstones: normalizeTombstones(parsed.tombstones),
}; };
} }

View File

@@ -37,27 +37,26 @@ export async function applyUserSession(payload: AuthSessionPayload) {
const previousUserId = useUserStore.getState().user?.id || ""; const previousUserId = useUserStore.getState().user?.id || "";
const nextUserId = payload.user?.id || ""; const nextUserId = payload.user?.id || "";
useUserStore.getState().setHydrated(false); useUserStore.getState().setHydrated(false);
// Query key 不携带用户 ID;身份变化时必须取消并清空旧账号请求,避免跨账号复用内存数据。
if (previousUserId !== nextUserId) appQueryClient.clear();
await switchUserStorageScope(payload.user?.id);
// 切换存储 scope 时 user store 仍保留旧身份;此处只能先检查 generation。
if (!isCurrentGeneration(generation)) return;
withCanvasStorePersistenceSuppressed(() => withAssetStorePersistenceSuppressed(() => withUserScopedPersistenceSuppressed(() => {
resetUserScopedMemory();
})));
useUserStore.getState().setUser(payload.user);
useUserStore.getState().setRuntimeLimits(payload.runtimeLimits);
useUserStore.getState().setDrawingEngine(payload.drawingEngine);
useUserStore.getState().setFeatures(payload.features);
installRemoteUserDataAutoSync();
try { try {
// Query key 不携带用户 ID;身份变化时必须取消并清空旧账号请求,避免跨账号复用内存数据。 // 缓存恢复和同步基线必须先于工作区开放,防止生成/编辑抢跑,或被初始化误记为已同步。
if (previousUserId !== nextUserId) appQueryClient.clear(); await hydrateUserSessionData(payload, generation);
await switchUserStorageScope(payload.user?.id); } catch (error) {
// 切换存储 scope 时 user store 仍保留旧身份;此处只能先检查 generation, if (isCurrentSession(generation, nextUserId)) console.warn("用户工作区后台初始化失败,基础页面仍可继续", error);
// 否则登录后的新用户会被误判为过期会话,hydrated 永远无法解除。
if (!isCurrentGeneration(generation)) return;
withCanvasStorePersistenceSuppressed(() => withAssetStorePersistenceSuppressed(() => withUserScopedPersistenceSuppressed(() => {
resetUserScopedMemory();
})));
useUserStore.getState().setUser(payload.user);
useUserStore.getState().setRuntimeLimits(payload.runtimeLimits);
useUserStore.getState().setDrawingEngine(payload.drawingEngine);
useUserStore.getState().setFeatures(payload.features);
installRemoteUserDataAutoSync();
useUserStore.getState().setHydrated(true);
void hydrateUserSessionData(payload, generation).catch((error) => {
if (isCurrentSession(generation, nextUserId)) console.warn("用户工作区后台初始化失败,基础页面仍可继续", error);
});
} finally { } finally {
if (useUserStore.getState().hydrated === false && isCurrentGeneration(generation)) useUserStore.getState().setHydrated(true); if (isCurrentSession(generation, nextUserId)) useUserStore.getState().setHydrated(true);
} }
} }
@@ -98,36 +97,34 @@ async function hydrateUserSessionData(payload: AuthSessionPayload, generation: n
if (!hasPersistedValue(plugins)) usePluginStore.setState({ installations: [], runtimeStatuses: {}, pluginStates: {} }); if (!hasPersistedValue(plugins)) usePluginStore.setState({ installations: [], runtimeStatuses: {}, pluginStates: {} });
if (!persistedCreationPreferences) useCreationPreferencesStore.setState({ preferences: {} }); if (!persistedCreationPreferences) useCreationPreferencesStore.setState({ preferences: {} });
try {
const catalog = await getModelCatalog();
if (!isCurrentSession(generation, userId)) return;
if (!persistedConfig) {
// 只有首次配置缺失时才生成能力推荐;已有配置中的空数组代表用户明确清空。
const initialSystemConfig = {
...defaultConfig,
channels: modelCatalogChannels(catalog),
imageModels: undefined,
videoModels: undefined,
textModels: undefined,
audioModels: undefined,
};
useConfigStore.getState().replaceConfig(normalizeConfigSnapshot({ config: initialSystemConfig }).config);
} else {
useConfigStore.getState().mergeSystemChannels(modelCatalogChannels(catalog));
}
} catch (error) {
if (isCurrentSession(generation, userId)) console.warn("模型目录后台刷新失败,保留本地配置", error);
}
if (userId) { if (userId) {
try { await initializeRemoteUserDataSession(userId);
await initializeRemoteUserDataSession(userId);
} catch (error) {
if (isCurrentSession(generation, userId)) console.warn("远端用户数据后台同步初始化失败", error);
}
} else { } else {
resetRemoteUserDataSync(); resetRemoteUserDataSync();
} }
void (async () => {
try {
const catalog = await getModelCatalog();
if (!isCurrentSession(generation, userId)) return;
if (!persistedConfig) {
// 只有首次配置缺失时才生成能力推荐;已有配置中的空数组代表用户明确清空。
const initialSystemConfig = {
...defaultConfig,
channels: modelCatalogChannels(catalog),
imageModels: undefined,
videoModels: undefined,
textModels: undefined,
audioModels: undefined,
};
useConfigStore.getState().replaceConfig(normalizeConfigSnapshot({ config: initialSystemConfig }).config);
} else {
useConfigStore.getState().mergeSystemChannels(modelCatalogChannels(catalog));
}
} catch (error) {
if (isCurrentSession(generation, userId)) console.warn("模型目录后台刷新失败,保留本地配置", error);
}
})();
if (isCurrentSession(generation, userId)) { if (isCurrentSession(generation, userId)) {
recordDiagnosticEvent({ recordDiagnosticEvent({
category: "navigation", category: "navigation",

View File

@@ -79,6 +79,21 @@
line-height: 1.65; 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 { .canvas-sync-panel__callout {
display: grid; display: grid;
gap: 3px; gap: 3px;

View File

@@ -1,10 +1,7 @@
import { App, Button, Dropdown, Popover } from "antd"; import { App, Button, Popover } from "antd";
import { CloudCheck, CloudOff, Download, FileClock, LoaderCircle, RefreshCw } from "lucide-react"; import { CloudCheck, CloudOff, FileClock, LoaderCircle, RefreshCw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export"; import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
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 { useSyncProgressStore } from "@/stores/use-sync-progress-store"; import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { retryRemoteUserDataSync } from "@/services/user-data-sync"; import { retryRemoteUserDataSync } from "@/services/user-data-sync";
import "./canvas-sync-status.css"; import "./canvas-sync-status.css";
@@ -16,23 +13,58 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
const [statusOpen, setStatusOpen] = useState(false); const [statusOpen, setStatusOpen] = useState(false);
const phase = progress?.phase; const phase = progress?.phase;
const conflict = phase === "conflict"; const conflict = phase === "conflict";
const error = phase === "error"; const cloudError = phase === "error";
const retryScheduled = error && progress?.message?.includes("自动重试"); 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 saving = phase === "pending" || phase === "saving" || phase === "uploading";
const reconciling = phase === "reconciling"; const reconciling = phase === "reconciling";
const tone = conflict ? "conflict" : error ? "error" : saving || reconciling ? "pending" : phase === "done" ? "done" : "idle"; const tone = conflict || cloudError || localError ? (conflict ? "conflict" : "error") : saving || reconciling || localPending ? "pending" : phase === "done" ? "done" : localPhase === "saved" ? "done" : "idle";
const label = conflict ? "需要处理冲突" : error ? "云端保存失败" : reconciling ? "正在自动合并" : saving ? "正在同步" : phase === "done" ? "已保存到云端" : "尚未同步"; const label = conflict
? "需要处理冲突"
: cloudError
? "云端保存失败"
: localError
? "本地保存失败"
: reconciling
? "正在自动合并"
: saving
? "正在同步云端"
: localPending
? "正在保存本地"
: phase === "done"
? "已保存到云端"
: localPhase === "saved"
? "已保存在本地"
: "尚未同步";
const description = conflict const description = conflict
? "本地与云端修改了同一字段,系统已保留本地草稿。" ? progress?.message || "本地与云端存在冲突,当前编辑内容保持不变。"
: error : cloudError
? progress?.message || "本地内容仍然保留,云端暂未确认。" ? progress?.message || "本地内容仍然保留,云端暂未确认。"
: reconciling : localError
? progress?.message || "正在合并云端与本地修改,完成后会自动继续保存。" ? progress?.localError || "本地保存没有完成,请立即重试;当前编辑内容保持不变。"
: saving : reconciling
? progress?.message || "正在把本地修改同步到云端。" ? progress?.message || "正在合并云端与本地修改,完成后会自动继续保存。"
: phase === "done" : saving
? progress?.message || "当前画布与云端版本一致。" ? 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>) => { const run = async (operation: () => Promise<unknown>) => {
setBusy(true); setBusy(true);
@@ -53,7 +85,7 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
content: ( content: (
<div className="canvas-sync-confirm-copy"> <div className="canvas-sync-confirm-copy">
<p>这不是合并操作:当前编辑器内容会替换为云端版本。</p> <p>这不是合并操作:当前编辑器内容会替换为云端版本。</p>
<p>系统会先把本地修改保存为草稿,之后可从“版本记录”查看或下载,不会直接丢失。</p> <p>系统会先把本地修改保存为草稿,之后可从“版本记录”查看,不会直接丢失。</p>
</div> </div>
), ),
okText: "保留草稿并加载", 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 canvas-sync-panel--${tone}`} data-canvas-no-zoom>
<div className="canvas-sync-panel__header"> <div className="canvas-sync-panel__header">
<span className="canvas-sync-panel__status-icon" aria-hidden="true"> <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> </span>
<div className="canvas-sync-panel__heading"> <div className="canvas-sync-panel__heading">
<span className="canvas-sync-panel__eyebrow">云端同步</span> <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"> <p className="canvas-sync-panel__description" role="status">
{description} {description}
</p> </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"> <div className="canvas-sync-panel__callout">
<strong>先保护本地,再决定版本</strong> <strong>先保护本地,再决定版本</strong>
<span>{progress?.draftCount ? `已保留 ${progress.draftCount} 份本地草稿。` : "加载云端版前会先保留本地草稿。"} 这是替换,不是合并。</span> <span>{progress?.draftCount ? `已保留 ${progress.draftCount} 份本地草稿。` : "加载云端版前会先保留本地草稿。"} 这是替换,不是合并。</span>
</div> </div>
) : error ? ( ) : cloudError ? (
<div className="canvas-sync-panel__callout"> <div className="canvas-sync-panel__callout">
<strong>{retryScheduled ? "本地内容仍在,正在等待重试" : "本地内容仍在,未覆盖云端"}</strong> <strong>{retryScheduled ? "本地内容仍在,正在等待重试" : "本地内容仍在,未覆盖云端"}</strong>
<span>{retryScheduled ? "网络恢复后会自动重试;也可以点击“立即重试”。" : "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"}</span> <span>{retryScheduled ? "网络恢复后会自动重试;也可以点击“立即重试”。" : "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"}</span>
</div> </div>
) : localError ? (
<div className="canvas-sync-panel__callout">
<strong>本地保存没有完成</strong>
<span>{progress?.draftError || progress?.localError || "请立即重试;当前编辑内容保持不变。"}</span>
</div>
) : null} ) : null}
<div className="canvas-sync-panel__actions"> <div className="canvas-sync-panel__actions">
{error ? ( {cloudError ? (
<Button <Button
type="primary" type="primary"
block block
@@ -114,28 +167,27 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
.catch(() => undefined) .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> </Button>
) : null} ) : 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> </Button>
<div className="canvas-sync-panel__secondary-actions"> <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 ? ( {onOpenVersions ? (
<Button <Button
type="text" type="text"
@@ -161,60 +213,10 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
className={`canvas-sync-status-trigger canvas-sync-status-trigger--${tone}`} className={`canvas-sync-status-trigger canvas-sync-status-trigger--${tone}`}
aria-label={`画布保存状态:${label}`} aria-label={`画布保存状态:${label}`}
aria-expanded={statusOpen} 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> <span className="canvas-sync-status-label text-xs">{label}</span>
</Button> </Button>
</Popover> </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>
);
}

View File

@@ -1,11 +1,10 @@
import { AppDrawer } from "@/components/ui/product/app-drawer"; import { AppDrawer } from "@/components/ui/product/app-drawer";
import { EmptyState } from "@/components/ui/product/empty-state"; import { EmptyState } from "@/components/ui/product/empty-state";
import { App, Button, Grid, Spin } from "antd"; 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 { useCallback, useEffect, useRef, useState } from "react";
import { getCanvasHistoryEntry, listCanvasHistory, type CanvasHistoryEntry } from "@/services/api/user-data"; import { getCanvasHistoryEntry, listCanvasHistory, type CanvasHistoryEntry } from "@/services/api/user-data";
import { readCanvasSyncDrafts, type CanvasSyncDraft } from "@/services/canvas-sync-drafts"; import { readCanvasSyncDrafts, type CanvasSyncDraft } from "@/services/canvas-sync-drafts";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
import { getActiveUserScope } from "@/lib/user-scope"; import { getActiveUserScope } from "@/lib/user-scope";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useSyncProgressStore } from "@/stores/use-sync-progress-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 [draftLoading, setDraftLoading] = useState(false);
const [restoring, setRestoring] = useState(false); const [restoring, setRestoring] = useState(false);
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [draftError, setDraftError] = useState(""); const [draftError, setDraftError] = useState("");
const [reload, setReload] = useState(0); 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 { return {
open, open,
listOpen, listOpen,
@@ -188,7 +172,6 @@ export function useCanvasVersionHistory(projectId: string, onRestore: (snapshotI
draftLoading, draftLoading,
restoring, restoring,
confirming, confirming,
exporting,
error, error,
draftError, draftError,
show: () => { show: () => {
@@ -225,14 +208,13 @@ export function useCanvasVersionHistory(projectId: string, onRestore: (snapshotI
}, },
returnToCurrent, returnToCurrent,
restore, restore,
download,
}; };
} }
export type CanvasVersionHistoryController = ReturnType<typeof useCanvasVersionHistory>; export type CanvasVersionHistoryController = ReturnType<typeof useCanvasVersionHistory>;
export function CanvasVersionHistory({ history }: { history: CanvasVersionHistoryController }) { 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; if (!open) return null;
const groups = new Map<string, CanvasHistoryEntry[]>(); const groups = new Map<string, CanvasHistoryEntry[]>();
for (const entry of entries) { 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 ? ( {draftError ? (
<p role="alert" className="canvas-version-error"> <p role="alert" className="canvas-version-error">
{draftError} {draftError}
@@ -383,19 +365,16 @@ export function CanvasVersionHistory({ history }: { history: CanvasVersionHistor
</small> </small>
) : null} ) : null}
{preview.project?.nodes.some((node) => node.type === "drawing") ? <p className="canvas-version-hint">绘图仅保留已上传的预览,不含本机历史笔画。</p> : 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 block type="primary" loading={restoring} disabled={!preview.project || currentRevision === undefined || loading} onClick={history.restore}>
恢复此版本 恢复此版本
</Button> </Button>
) : null} </div>
<Button block type={preview.kind === "draft" ? "primary" : "default"} icon={<Download size={14} />} loading={exporting} disabled={!preview.project || restoring} onClick={() => void history.download()}> ) : null}
{preview.kind === "draft" ? "下载草稿" : "下载此版本"}
</Button>
</div>
</> </>
) : ( ) : (
<p className="canvas-version-hint">{tab === "cloud" ? "内容变化时最多每 5 分钟保留一份,保留最近 20 份。恢复前额外备份。" : "草稿仅存于此浏览器,建议及时下载保留。"}</p> <p className="canvas-version-hint">{tab === "cloud" ? "内容变化时最多每 5 分钟保留一份,保留最近 20 份。恢复前额外备份。" : "草稿保存在此浏览器中,查看草稿不会修改当前画布。"}</p>
)} )}
</footer> </footer>
</div> </div>

View File

@@ -40,7 +40,7 @@ export function CanvasVersionPreview({ preview, onReturn, onShowVersions }: { pr
if (active) setLocalMedia((current) => ({ ...current, [node.id]: url })); if (active) setLocalMedia((current) => ({ ...current, [node.id]: url }));
}) })
.catch(() => { .catch(() => {
if (active) setMediaError("本机媒体读取失败,可下载草稿后检查素材"); if (active) setMediaError("媒体暂时无法读取,请重新打开此版本重试");
}); });
return () => { return () => {
active = false; active = false;

View File

@@ -29,7 +29,6 @@ import { resourceFileUrl, resourceStorageKey, uploadResourceFile } from "@/servi
import { primeResourceBlobCache } from "@/services/resource-blob-cache"; import { primeResourceBlobCache } from "@/services/resource-blob-cache";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { CanvasSyncDraftMenu } from "./canvas-sync-status";
import { useAppearanceStore } from "@/stores/use-appearance-store"; import { useAppearanceStore } from "@/stores/use-appearance-store";
const CanvasDeleteProjectsDialog = lazy(() => import("@/components/canvas/canvas-delete-projects-dialog").then((module) => ({ default: module.CanvasDeleteProjectsDialog }))); 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>} meta={<span className="app-projects-header-meta">{totalProjects} 个</span>}
actions={ actions={
<div className="collection-header-actions"> <div className="collection-header-actions">
<CanvasSyncDraftMenu />
<Button type="primary" disabled={!hydrated} icon={<Plus />} onClick={createAndEnter}> <Button type="primary" disabled={!hydrated} icon={<Plus />} onClick={createAndEnter}>
新建画布 新建画布
</Button> </Button>

View File

@@ -16,6 +16,7 @@ import { generationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract"; import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
import { generationFailureMetadata } from "@/lib/generation-error"; import { generationFailureMetadata } from "@/lib/generation-error";
import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle"; import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
import { hasRemoteUserDataSyncSession } from "@/services/user-data-sync";
import { attachNodeEffectKey } from "@/services/generation-task-materializer"; import { attachNodeEffectKey } from "@/services/generation-task-materializer";
import { consumeCanvasGenerationContinuation } from "./use-canvas-operation-history"; import { consumeCanvasGenerationContinuation } from "./use-canvas-operation-history";
@@ -516,9 +517,13 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
}).catch((error) => { }).catch((error) => {
autoSavedTaskIdsRef.current.delete(saveKey); autoSavedTaskIdsRef.current.delete(saveKey);
if (error instanceof Error && error.name === "AbortError") return; if (error instanceof Error && error.name === "AbortError") return;
const detail = error instanceof Error ? error.message : "未知错误";
const content = hasRemoteUserDataSyncSession()
? `生成结果已保留,本地素材已保存,云端项目资产稍后重试:${detail}`
: "生成结果已保留在本地素材库,登录后会继续同步到云端";
message.warning({ message.warning({
key: `canvas-asset-sync:${projectId}`, key: `canvas-asset-sync:${projectId}`,
content: error instanceof Error ? `生成结果已保留,但项目资产同步失败:${error.message}` : "生成结果已保留,但项目资产同步失败", content,
duration: 4, duration: 4,
}); });
}); });

View File

@@ -96,7 +96,7 @@ export function useCanvasProjectLifecycle({
const observedContentRef = useRef<CanvasHistorySnapshot | null>(null); const observedContentRef = useRef<CanvasHistorySnapshot | null>(null);
const loadLatestRef = useRef(false); const loadLatestRef = useRef(false);
const historyRestoreRef = useRef<{ snapshotId: string; revision: number; resolve: () => void; reject: (error: unknown) => void } | null>(null); 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); const editorReadyRef = useRef(false);
useEffect(() => { useEffect(() => {
@@ -311,11 +311,12 @@ export function useCanvasProjectLifecycle({
renameProject(projectId, title); renameProject(projectId, title);
// 标题是画布列表和分享入口的元数据,重命名后立即提交,避免只停留在浏览器缓存。 // 标题是画布列表和分享入口的元数据,重命名后立即提交,避免只停留在浏览器缓存。
void saveRemoteUserDataNow(projectId).catch((error) => { void saveRemoteUserDataNow(projectId).catch((error) => {
message.warning(error instanceof Error ? `名称已更新到本地,云端同步将在后台重试:${error.message}` : "名称已更新到本地,云端同步将在后台重试"); // 后台同步状态统一显示在同步面板,避免每次重试都打断编辑。
void error;
}); });
}, [message, projectId, renameProject]); }, [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 }; const snapshot = { nodes: nodesRef.current, connections: connectionsRef.current, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo };
if (observedContentRef.current && !sameCanvasHistorySnapshot(observedContentRef.current, snapshot)) { if (observedContentRef.current && !sameCanvasHistorySnapshot(observedContentRef.current, snapshot)) {
updateProject(projectId, { updateProject(projectId, {
@@ -331,18 +332,34 @@ export function useCanvasProjectLifecycle({
observedContentRef.current = snapshot; observedContentRef.current = snapshot;
} }
updateProject(projectId, { viewport: viewportRef.current }); updateProject(projectId, { viewport: viewportRef.current });
await flushCanvasStorePersistence(); if (options.flush !== false) await flushCanvasStorePersistence();
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, nodesRef, projectId, showImageInfo, updateProject, viewportRef]); }, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
const reloadLatestCanvasProject = useCallback(async () => { const reloadLatestCanvasProject = useCallback(async () => {
await persistLocalEdits(); const pending = pendingReloadRef.current;
return new Promise<void>((resolve, reject) => { if (pending) return pending.promise;
pendingReloadRef.current?.reject(new Error("已有新的加载请求"));
loadLatestRef.current = true; // The load path writes a durable draft before replacing the editor. Waiting
pendingReloadRef.current = { resolve, reject }; // for the ordinary canvas queue here makes a viewport drag able to starve
setLoadAttempt((value) => value + 1); // 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) => { const restoreCanvasProjectVersion = useCallback(async (snapshotId: string, revision: number) => {
await persistLocalEdits(); await persistLocalEdits();
@@ -365,7 +382,8 @@ export function useCanvasProjectLifecycle({
message.success("画布已保存到云端"); message.success("画布已保存到云端");
} catch (error) { } catch (error) {
const detail = error instanceof Error ? error.message : "未知错误"; const detail = error instanceof Error ? error.message : "未知错误";
message.warning(`本地画布布局已保存,云端同步失败:${detail}`); // 本地保存已经完成,云端状态统一显示在同步面板,不弹出同步失败 toast。
void detail;
// Imports can retain their durable local result; sharing requires cloud success. // Imports can retain their durable local result; sharing requires cloud success.
return options.requireRemote === false; return options.requireRemote === false;
} }

View File

@@ -4,6 +4,7 @@ import { getActiveUserScope } from "@/lib/user-scope";
import { sameCanvasContent } from "@/lib/canvas/canvas-content"; import { sameCanvasContent } from "@/lib/canvas/canvas-content";
import { withCanvasStorePersistenceLock, type CanvasProject } from "@/stores/canvas/use-canvas-store"; import { withCanvasStorePersistenceLock, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { withGenerationArtifactCommitLock } from "@/services/generation-asset-repository"; import { withGenerationArtifactCommitLock } from "@/services/generation-asset-repository";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
export type CanvasSyncDraft = { id: string; savedAt: string; project: CanvasProject }; export type CanvasSyncDraft = { id: string; savedAt: string; project: CanvasProject };
const draftKey = (projectId: string) => `yingce:sync-drafts:${projectId}`; const draftKey = (projectId: string) => `yingce:sync-drafts:${projectId}`;
@@ -20,21 +21,36 @@ export async function readCanvasSyncDrafts(projectId: string, scope = getActiveU
return raw ? JSON.parse(raw) : []; return raw ? JSON.parse(raw) : [];
} }
export async function preserveCanvasSyncDraft(project: CanvasProject, scope = getActiveUserScope()) { /** Caller must hold the scope's canvas persistence lock. Never reacquire it here. */
// A separate record survives replacement of the active canvas and a browser reload. export async function preserveCanvasSyncDraftLocked(snapshot: CanvasProject, scope: string) {
const snapshot = structuredClone(project); try {
return withGenerationArtifactCommitLock(scope, () => const drafts = await readCanvasSyncDrafts(snapshot.id, scope);
withCanvasStorePersistenceLock(scope, async () => { if (!drafts.some((draft) => sameCanvasContent(draft.project, snapshot))) {
const drafts = await readCanvasSyncDrafts(snapshot.id, scope); const storage = localForageStorageForScope(scope);
if (!drafts.some((draft) => sameCanvasContent(draft.project, snapshot))) { const rawIndex = await storage.getItem(draftIndexKey);
const storage = localForageStorageForScope(scope); const ids: string[] = rawIndex ? JSON.parse(rawIndex) : [];
const rawIndex = await storage.getItem(draftIndexKey); if (!ids.includes(snapshot.id)) await storage.setItem(draftIndexKey, JSON.stringify([...ids, snapshot.id]));
const ids: string[] = rawIndex ? JSON.parse(rawIndex) : []; drafts.push({ id: nanoid(), savedAt: new Date().toISOString(), project: snapshot });
if (!ids.includes(snapshot.id)) await storage.setItem(draftIndexKey, JSON.stringify([...ids, snapshot.id])); await storage.setItem(draftKey(snapshot.id), JSON.stringify(drafts));
drafts.push({ id: nanoid(), savedAt: new Date().toISOString(), project: snapshot }); }
await storage.setItem(draftKey(snapshot.id), JSON.stringify(drafts)); if (getActiveUserScope() === scope) useSyncProgressStore.getState().setProjectProgress(snapshot.id, { draftCount: drafts.length, draftError: undefined });
} return drafts.length;
return drafts.length; } catch (error) {
}), if (getActiveUserScope() === scope) {
); const progress = useSyncProgressStore.getState();
const current = progress.syncingProjects[snapshot.id];
progress.setProjectProgress(snapshot.id, {
phase: current?.phase === "conflict" ? "conflict" : "error",
errorKind: "draft",
draftError: error instanceof Error ? error.message : "本地草稿保存失败",
message: "保存暂未完成,已暂停版本替换,当前编辑内容保持不变",
});
}
throw error;
}
}
export async function preserveCanvasSyncDraft(project: CanvasProject, scope = getActiveUserScope()) {
const snapshot = structuredClone(project);
return withGenerationArtifactCommitLock(scope, () => withCanvasStorePersistenceLock(scope, () => preserveCanvasSyncDraftLocked(snapshot, scope)));
} }

View File

@@ -52,8 +52,7 @@ export async function continueCreationConversationOnCanvas(source: SourceConvers
await flushCanvasStorePersistence(); await flushCanvasStorePersistence();
assertScope(); assertScope();
try { try {
if (!hasRemoteUserDataSyncSession()) throw new Error("尚未建立云端同步会话"); if (hasRemoteUserDataSyncSession()) await saveRemoteUserDataNow();
await saveRemoteUserDataNow();
} catch (cause) { syncError = cause; } } catch (cause) { syncError = cause; }
} else { } else {
const session: CanvasAssistantSession = { id: sessionId, title: source.title, createdAt: source.messages[0].createdAt, updatedAt: source.updatedAt, messages }; const session: CanvasAssistantSession = { id: sessionId, title: source.title, createdAt: source.messages[0].createdAt, updatedAt: source.updatedAt, messages };

View File

@@ -20,6 +20,8 @@ export type ClientDiagnosticEvent = {
taskId?: string; taskId?: string;
projectId?: string; projectId?: string;
canvasId?: string; canvasId?: string;
nodes_before?: number;
nodes_after?: number;
stack?: string; stack?: string;
}; };
@@ -133,6 +135,8 @@ export function recordDiagnosticEvent(input: DiagnosticEventInput) {
taskId: safeDiagnosticId(input.taskId), taskId: safeDiagnosticId(input.taskId),
projectId: safeDiagnosticId(input.projectId), projectId: safeDiagnosticId(input.projectId),
canvasId: safeDiagnosticId(input.canvasId), canvasId: safeDiagnosticId(input.canvasId),
nodes_before: input.nodes_before === undefined ? undefined : clampNumber(input.nodes_before, 0, 10_000_000),
nodes_after: input.nodes_after === undefined ? undefined : clampNumber(input.nodes_after, 0, 10_000_000),
stack: redactClientText(input.stack, 4000), stack: redactClientText(input.stack, 4000),
}; };
events.push(event); events.push(event);

View File

@@ -13,7 +13,7 @@ import { getImageBlob, resolveImageUrl, setImageBlob } from "@/services/image-st
import { generationArtifactStorageKey, loadOrStoreGenerationArtifact } from "@/services/generation-artifact-sink"; import { generationArtifactStorageKey, loadOrStoreGenerationArtifact } from "@/services/generation-artifact-sink";
import { createProviderNeutralGenerationTaskEffectStore } from "@/services/provider-neutral-generation-effects"; import { createProviderNeutralGenerationTaskEffectStore } from "@/services/provider-neutral-generation-effects";
import { getCachedResourceBlob } from "@/services/resource-blob-cache"; import { getCachedResourceBlob } from "@/services/resource-blob-cache";
import { loadAssetsForUse, saveRemoteUserDataNow } from "@/services/user-data-sync"; import { hasRemoteUserDataSyncSession, loadAssetsForUse, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { getActiveUserScope } from "@/lib/user-scope"; import { getActiveUserScope } from "@/lib/user-scope";
import { normalizeAssetCategory } from "@/lib/asset-category"; import { normalizeAssetCategory } from "@/lib/asset-category";
import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle"; import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
@@ -120,8 +120,10 @@ async function persistCanvasNodeAsset(options: EnsureCanvasNodeAssetOptions): Pr
store.updateAsset(asset.id, { category: declaredCategory }); store.updateAsset(asset.id, { category: declaredCategory });
asset = useAssetStore.getState().assets.find((item) => item.id === asset?.id) || asset; asset = useAssetStore.getState().assets.find((item) => item.id === asset?.id) || asset;
} }
// 生成结果先以本地资产完成闭环;未建立云端同步会话时,后续登录同步会
// 继续上传素材和画布,不能把一个可恢复的本地成功报告成失败。
if (!hasRemoteUserDataSyncSession()) return { assetId: asset.id, created, linkedToProject: false };
if (!options.domainProjectId) { if (!options.domainProjectId) {
// 个人画布也必须在返回成功前把素材提交到服务端,不能只依赖延迟自动同步。
await saveRemoteUserDataNow(); await saveRemoteUserDataNow();
throwIfAborted(options.signal); throwIfAborted(options.signal);
return { assetId: asset.id, created, linkedToProject: false }; return { assetId: asset.id, created, linkedToProject: false };

View File

@@ -48,7 +48,8 @@ let incrementalSession = false;
let sessionEpoch = 0; let sessionEpoch = 0;
const verifiedProjects = new Set<string>(); const verifiedProjects = new Set<string>();
const verifiedAssets = new Set<string>(); const verifiedAssets = new Set<string>();
const remoteProjectLoadPromises = new Map<string, Promise<CanvasProject | undefined>>(); type RemoteProjectLoad = { mode: "normal" | "latest" | "history"; promise: Promise<CanvasProject | undefined> };
const remoteProjectLoadPromises = new Map<string, RemoteProjectLoad>();
const REMOTE_SYNC_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 30_000] as const; const REMOTE_SYNC_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 30_000] as const;
export async function initializeRemoteUserDataSession(userId: string) { export async function initializeRemoteUserDataSession(userId: string) {
@@ -70,6 +71,20 @@ function liveCanvasIfUnchanged(id: string, snapshot: CanvasProject | null | unde
} }
export async function loadCanvasProjectForEditing(id: string, options: { latest?: boolean; historyRestore?: { snapshotId: string; revision: number }; onLoad?: (project: CanvasProject) => void } = {}) { export async function loadCanvasProjectForEditing(id: string, options: { latest?: boolean; historyRestore?: { snapshotId: string; revision: number }; onLoad?: (project: CanvasProject) => void } = {}) {
const mode = options.historyRestore ? "history" : options.latest ? "latest" : "normal";
const existing = remoteProjectLoadPromises.get(id);
const existingRequest = existing?.promise;
if (existingRequest) {
if (existing?.mode !== mode) {
await existingRequest.catch(() => undefined);
return loadCanvasProjectForEditing(id, options);
}
if (mode !== "normal") return existingRequest;
return existingRequest.then(async (loaded) => {
if (loaded) options.onLoad?.(loaded);
return loaded;
});
}
const epoch = sessionEpoch; const epoch = sessionEpoch;
const request = withRemoteUserDataSyncExclusive(async () => { const request = withRemoteUserDataSyncExclusive(async () => {
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布"); if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
@@ -150,12 +165,13 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest?
verifiedProjects.add(id); verifiedProjects.add(id);
useCanvasStore.setState((state) => ({ projects: local ? state.projects.map((item) => (item.id === id ? project : item)) : [...state.projects, project] })); useCanvasStore.setState((state) => ({ projects: local ? state.projects.map((item) => (item.id === id ? project : item)) : [...state.projects, project] }));
await flushCanvasStorePersistence(); await flushCanvasStorePersistence();
useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", message: "已加载云端最新版本" }); useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", cloudRevision: project.revision, pendingChanges: 0, message: "已加载云端最新版本" });
return project; return project;
}); });
remoteProjectLoadPromises.set(id, request); const pending = { mode, promise: request } as RemoteProjectLoad;
remoteProjectLoadPromises.set(id, pending);
const clearPending = () => { const clearPending = () => {
if (remoteProjectLoadPromises.get(id) === request) remoteProjectLoadPromises.delete(id); if (remoteProjectLoadPromises.get(id) === pending) remoteProjectLoadPromises.delete(id);
}; };
void request.then(clearPending, clearPending); void request.then(clearPending, clearPending);
return request; return request;
@@ -265,7 +281,9 @@ export async function applyAgentCanvasPatches(id: string, patches: AgentCanvasPa
verifiedProjects.add(id); verifiedProjects.add(id);
if (projected === current) return current; if (projected === current) return current;
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === id ? projected : project)) })); useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === id ? projected : project)) }));
await flushCanvasStorePersistence(); // Zustand persistence already coalesces store writes behind a short timer.
// Agent output can arrive in bursts; waiting here would serialize every
// patch batch behind a full-canvas IndexedDB write and compete with paint.
useSyncProgressStore.getState().setProjectProgress(id, { phase: sameCanvasContent(remote, projected) ? "done" : "pending", message: "已同步 Agent 画布结果" }); useSyncProgressStore.getState().setProjectProgress(id, { phase: sameCanvasContent(remote, projected) ? "done" : "pending", message: "已同步 Agent 画布结果" });
return projected; return projected;
}); });
@@ -278,7 +296,7 @@ async function preserveAgentConflict(project: CanvasProject) {
} }
async function waitForRemoteProjectLoads() { async function waitForRemoteProjectLoads() {
const pending = [...remoteProjectLoadPromises.values()]; const pending = [...remoteProjectLoadPromises.values()].map((item) => item.promise);
if (pending.length) await Promise.all(pending); if (pending.length) await Promise.all(pending);
} }
@@ -360,7 +378,7 @@ export async function syncRemoteUserData(userId?: string | null) {
snapshot.projects.map(async (project) => { snapshot.projects.map(async (project) => {
const local = localProjects.find((item) => item.id === project.id); const local = localProjects.find((item) => item.id === project.id);
const draftCount = (await readCanvasSyncDrafts(project.id)).length; const draftCount = (await readCanvasSyncDrafts(project.id)).length;
useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "done", draftCount, message: "已保存到云端" }); useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "done", cloudRevision: project.revision, pendingChanges: 0, draftCount, message: "已保存到云端" });
return { ...project, viewport: local?.viewport || project.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: await canvasContentHash(project) }; return { ...project, viewport: local?.viewport || project.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: await canvasContentHash(project) };
}), }),
); );
@@ -533,7 +551,7 @@ export function localSavedRemotePendingMessage(localAction: string, error: unkno
export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId">>) { export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId">>) {
const id = useCanvasStore.getState().createProject(title, projectId); const id = useCanvasStore.getState().createProject(title, projectId);
if (initialContent) useCanvasStore.getState().updateProject(id, initialContent); if (initialContent) useCanvasStore.getState().updateProject(id, initialContent);
if (!activeRemoteUserId) return { id, syncError: new Error("尚未建立云端同步会话") }; if (!activeRemoteUserId) return { id };
try { try {
await saveRemoteUserDataNow(id); await saveRemoteUserDataNow(id);
return { id }; return { id };
@@ -690,7 +708,7 @@ export async function saveRemoteUserDataNow(input?: string | readonly string[] |
const projectId = typeof input === "string" || Array.isArray(input) ? (input as string | readonly string[]) : undefined; 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; repairMissingResources?: boolean }) : {}; const options = input && typeof input === "object" && !Array.isArray(input) ? (input as { force?: boolean; repairMissingResources?: boolean }) : {};
const epoch = sessionEpoch; const epoch = sessionEpoch;
if (!activeRemoteUserId) throw new Error("尚未建立云端同步会话,本地内容尚未保存到云端"); if (!activeRemoteUserId) throw new Error("云端同步会话尚未建立,当前内容已保留在本地");
requireRemoteUserDataBaseline(); requireRemoteUserDataBaseline();
const assertNoConflict = () => { const assertNoConflict = () => {
const ids = projectId === undefined ? useCanvasStore.getState().projects.map((project) => project.id) : typeof projectId === "string" ? [projectId] : projectId; const ids = projectId === undefined ? useCanvasStore.getState().projects.map((project) => project.id) : typeof projectId === "string" ? [projectId] : projectId;
@@ -915,6 +933,9 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
total, total,
completed: 0, completed: 0,
phase: total > 0 ? "uploading" : "saving", phase: total > 0 ? "uploading" : "saving",
errorKind: undefined,
draftError: undefined,
pendingChanges: 1,
message: total > 0 ? "正在同步媒体至云端" : "正在保存画布", message: total > 0 ? "正在同步媒体至云端" : "正在保存画布",
}); });
const onMediaUploaded = () => { const onMediaUploaded = () => {
@@ -955,7 +976,14 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
} }
await flushCanvasStorePersistence(); await flushCanvasStorePersistence();
const pending = !sameCanvasContent(candidate, useCanvasStore.getState().openProject(source.id) || undefined); const pending = !sameCanvasContent(candidate, useCanvasStore.getState().openProject(source.id) || undefined);
useSyncProgressStore.getState().setProjectProgress(source.id, { phase: pending ? "pending" : "done", message: pending ? "有新修改等待保存" : "已保存到云端" }); useSyncProgressStore.getState().setProjectProgress(source.id, {
phase: pending ? "pending" : "done",
cloudRevision: savedProject.revision,
pendingChanges: pending ? 1 : 0,
errorKind: undefined,
draftError: undefined,
message: pending ? "有新修改等待保存" : "已保存到云端",
});
if (pending) syncQueued = true; if (pending) syncQueued = true;
saved = true; saved = true;
continue; continue;
@@ -974,7 +1002,7 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
verifiedProjects.add(source.id); verifiedProjects.add(source.id);
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === source.id ? reconciled : project)) })); useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === source.id ? reconciled : project)) }));
await flushCanvasStorePersistence(); await flushCanvasStorePersistence();
useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "done", message: "云端内容一致,已自动校准版本" }); useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "done", cloudRevision: reconciled.revision, pendingChanges: 0, errorKind: undefined, draftError: undefined, message: "云端内容一致,已自动校准版本" });
saved = true; saved = true;
continue; continue;
} }
@@ -1005,17 +1033,26 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
if (!saved && lastError) throw lastError; if (!saved && lastError) throw lastError;
} catch (error) { } catch (error) {
const conflict = error instanceof ApiError && error.reason !== "canvas_history_resources_missing" && (error.status === 409 || error.status === 428); const conflict = error instanceof ApiError && error.reason !== "canvas_history_resources_missing" && (error.status === 409 || error.status === 428);
const resourcesMissing = error instanceof ApiError && error.reason === "canvas_history_resources_missing";
const errorKind = conflict ? "conflict" : resourcesMissing ? "resources" : error instanceof ApiError && error.retryable ? "network" : "local";
useSyncProgressStore.getState().setProjectProgress(source.id, { useSyncProgressStore.getState().setProjectProgress(source.id, {
phase: conflict ? "conflict" : "error", phase: conflict ? "conflict" : "error",
errorKind,
draftError: undefined,
pendingChanges: 1,
message: conflict ? "本地与云端存在同一字段修改,请选择保留的版本" : error instanceof Error ? error.message : "云端同步失败,等待重试", message: conflict ? "本地与云端存在同一字段修改,请选择保留的版本" : error instanceof Error ? error.message : "云端同步失败,等待重试",
}); });
if (conflict) { if (conflict) {
try { try {
const current = useCanvasStore.getState().openProject(source.id) || source; const current = useCanvasStore.getState().openProject(source.id) || source;
const draftCount = await preserveCanvasSyncDraft(current); const draftCount = await preserveCanvasSyncDraft(current);
useSyncProgressStore.getState().setProjectProgress(source.id, { draftCount }); useSyncProgressStore.getState().setProjectProgress(source.id, { draftCount, draftError: undefined });
} catch (draftError) { } catch (draftError) {
useSyncProgressStore.getState().setProjectProgress(source.id, { message: "本地草稿保存失败,请勿关闭页面;请先下载草稿" }); useSyncProgressStore.getState().setProjectProgress(source.id, {
draftError: draftError instanceof Error ? draftError.message : "本地草稿保存失败",
errorKind: "draft",
message: "保存暂未完成,已暂停版本替换,当前编辑内容保持不变",
});
errors.push(draftError); errors.push(draftError);
} }
} }

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 { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
import type { PrevisScene } from "@/types/previs"; import type { PrevisScene } from "@/types/previs";
import type { TimelineProject } from "@/types/timeline"; 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 = { export type CanvasProject = {
id: string; id: string;
@@ -56,13 +58,155 @@ type QueuedCanvasPersist = {
baseRevision: number; baseRevision: number;
state: PersistedCanvasState; state: PersistedCanvasState;
token: number; token: number;
journal?: CanvasPersistenceJournal;
}; };
type ObservedCanvasPersist = { type ObservedCanvasPersist = {
projects: CanvasProject[]; projects: CanvasProject[];
revision: number; 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; let suppressCanvasStorePersistence = 0;
const canvasMemoryStates = new Map<string, PersistedCanvasState>(); const canvasMemoryStates = new Map<string, PersistedCanvasState>();
const observedCanvasPersists = new Map<string, ObservedCanvasPersist>(); const observedCanvasPersists = new Map<string, ObservedCanvasPersist>();
@@ -130,10 +274,20 @@ export function canvasStoreStorageRevision(scope: string) {
} }
export function recordCanvasStorageDocument(scope: string, document: CanvasStorageDocument) { export function recordCanvasStorageDocument(scope: string, document: CanvasStorageDocument) {
const savedAt = document.savedAt || new Date().toISOString();
observedCanvasPersists.set(scope, { observedCanvasPersists.set(scope, {
projects: document.state.projects, projects: document.state.projects,
revision: document.storageRevision, 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) { export async function commitPendingCanvasStorePersistenceLocked(scope: string) {
@@ -150,24 +304,37 @@ export async function commitPendingCanvasStorePersistenceLocked(scope: string) {
baseProjects: queued.baseProjects, baseProjects: queued.baseProjects,
localProjects: queued.state.projects, localProjects: queued.state.projects,
baseRevision: queued.baseRevision, baseRevision: queued.baseRevision,
}).document; });
await storage.setItem(queued.name, serializeCanvasStorageDocument(rebased)); await preserveLocalRebaseConflicts(scope, queued.state.projects, rebased.conflicts);
committed = rebased; const savedAt = new Date().toISOString();
recordCanvasStorageDocument(scope, rebased); 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); const latest = queuedCanvasPersists.get(scope);
if (!latest || latest.token === queued.token) { if (!latest || latest.token === queued.token) {
if (latest?.token === queued.token) queuedCanvasPersists.delete(scope); if (latest?.token === queued.token) queuedCanvasPersists.delete(scope);
recordCanvasStorageDocument(scope, savedDocument);
return committed; return committed;
} }
latest.baseProjects = queued.state.projects; latest.baseProjects = queued.state.projects;
latest.baseRevision = rebased.storageRevision; latest.baseRevision = savedDocument.storageRevision;
recordCanvasStorageDocument(scope, savedDocument);
} }
} }
async function writeQueuedCanvasPersist(scope: string, _token: number) { 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) { 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> = { const canvasStorage: PersistStorage<CanvasStore> = {
getItem: async (name) => { getItem: async (name) => {
const scope = getActiveUserScope(); const scope = getActiveUserScope();
const value = await localForageStorageForScope(scope).getItem(name); const storage = localForageStorageForScope(scope);
if (!value) { 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: [] }); canvasMemoryStates.set(scope, { projects: [] });
observedCanvasPersists.set(scope, { projects: [], revision: 0 }); observedCanvasPersists.set(scope, { projects: [], revision: document.storageRevision });
return null; return null;
} }
const document = parseCanvasStorageDocument(value);
const state = document.state as PersistedCanvasState; const state = document.state as PersistedCanvasState;
canvasMemoryStates.set(scope, state); canvasMemoryStates.set(scope, state);
recordCanvasStorageDocument(scope, document); recordCanvasStorageDocument(scope, document);
@@ -397,7 +605,8 @@ const canvasStorage: PersistStorage<CanvasStore> = {
setItem: (name, value) => { setItem: (name, value) => {
const scope = getActiveUserScope(); const scope = getActiveUserScope();
const nextState = value.state as PersistedCanvasState; 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); canvasMemoryStates.set(scope, nextState);
if (suppressCanvasStorePersistence) return; if (suppressCanvasStorePersistence) return;
@@ -405,29 +614,35 @@ const canvasStorage: PersistStorage<CanvasStore> = {
const observed = observedCanvasPersists.get(scope); const observed = observedCanvasPersists.get(scope);
const token = (canvasPersistTokens.get(scope) || 0) + 1; const token = (canvasPersistTokens.get(scope) || 0) + 1;
canvasPersistTokens.set(scope, token); 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, { queuedCanvasPersists.set(scope, {
name, name,
scope, scope,
baseProjects, baseProjects,
baseRevision: queued?.baseRevision ?? observed?.revision ?? 0, baseRevision,
// generationEffectKeys 由专用 generation durable commit 管理;普通队列只能携带已确认 stamp。 state: queuedState,
state: ordinaryCanvasPersistenceState(scope, nextState, observed?.projects ?? baseProjects),
token, token,
journal: journalWritten ? journal || undefined : undefined,
}); });
markCanvasLocalPersistencePending(nextState.projects, journal && !journalWritten ? "本地快速恢复记录写入失败,正在等待完整画布保存" : undefined);
clearCanvasSaveTimer(scope); clearCanvasSaveTimer(scope);
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (canvasSaveTimers.get(scope) === timer) canvasSaveTimers.delete(scope); if (canvasSaveTimers.get(scope) === timer) canvasSaveTimers.delete(scope);
void writeQueuedCanvasPersist(scope, token).catch((error) => { void writeQueuedCanvasPersist(scope, token).catch((error) => {
// 自动保存无法把异常返回给原始状态更新调用方,但失败队列仍会保留给下一次写入或显式 flush 重试。 // 自动保存无法把异常返回给原始状态更新调用方,但失败队列和 journal 会保留给下一次写入或显式 flush 重试。
// 自动保存失败有队列兜底,属可降级场景;在无本地存储的环境(如测试进程)不应升级为错误级日志。
console.warn("画布本地持久化失败,已保留待写队列", { scope, error }); console.warn("画布本地持久化失败,已保留待写队列", { scope, error });
}); });
}, 400); }, journalWritten ? 400 : 0);
canvasSaveTimers.set(scope, timer); canvasSaveTimers.set(scope, timer);
}, },
removeItem: (name) => { removeItem: (name) => {
const scope = getActiveUserScope(); const scope = getActiveUserScope();
for (const journal of readCanvasPersistenceJournals(scope)) clearCanvasPersistenceJournal(scope, journal.token, journal.writerId);
return localForageStorageForScope(scope).removeItem(name); return localForageStorageForScope(scope).removeItem(name);
}, },
}; };

View File

@@ -1,12 +1,22 @@
import { create } from "zustand"; import { create } from "zustand";
export type LocalCanvasPersistencePhase = "saved" | "pending" | "error";
export type SyncProjectProgress = { export type SyncProjectProgress = {
projectId: string; projectId: string;
total: number; total: number;
completed: number; completed: number;
phase: "pending" | "uploading" | "saving" | "reconciling" | "done" | "error" | "conflict"; phase: "local" | "pending" | "uploading" | "saving" | "reconciling" | "done" | "error" | "conflict";
message?: string; message?: string;
draftCount?: number; draftCount?: number;
draftError?: string;
errorKind?: "local" | "network" | "conflict" | "draft" | "resources";
localPhase?: LocalCanvasPersistencePhase;
localSavedAt?: string;
localError?: string;
pendingWrites?: number;
pendingChanges?: number;
cloudRevision?: number;
}; };
type SyncProgressStore = { type SyncProgressStore = {
@@ -56,11 +66,11 @@ export const useSyncProgressStore = create<SyncProgressStore>((set, get) => ({
clearAll: () => set({ syncingProjects: {} }), clearAll: () => set({ syncingProjects: {} }),
isAnySyncing: () => { isAnySyncing: () => {
const list = Object.values(get().syncingProjects); 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) => { window.addEventListener("beforeunload", (event) => {
if (useSyncProgressStore.getState().isAnySyncing()) { if (useSyncProgressStore.getState().isAnySyncing()) {
event.preventDefault(); event.preventDefault();