From 5e69106d8fb21c203c598d8e9d4890d1815ee9c6 Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:17:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E7=94=BB=E5=B8=83=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20-=20=E7=89=88=E6=9C=AC=E5=86=B2=E7=AA=81=E5=85=88?= =?UTF-8?q?=E6=A0=B8=E5=AF=B9=E5=B9=B6=E5=90=88=E5=B9=B6=EF=BC=8C=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E4=BF=9D=E5=AD=98=E4=B8=8E=E4=BA=91=E7=AB=AF=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=88=86=E5=BC=80=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本地持久化记录保存时间和日志令牌,同步面板区分本地与云端,不再用提示打断编辑。冲突时先尝试合并,无法合并才保留草稿并暂停自动提交;未登录时生成结果留在本地,登录后再继续上传。 --- docs/content/docs/backend/http-api.mdx | 2 +- web/src/lib/canvas/canvas-storage-revision.ts | 6 + web/src/lib/user-session.ts | 87 +++--- web/src/pages/canvas/canvas-sync-status.css | 15 ++ web/src/pages/canvas/canvas-sync-status.tsx | 186 ++++++------- .../pages/canvas/canvas-version-history.tsx | 37 +-- .../pages/canvas/canvas-version-preview.tsx | 2 +- web/src/pages/canvas/index.tsx | 2 - web/src/pages/canvas/use-canvas-generation.ts | 7 +- .../canvas/use-canvas-project-lifecycle.ts | 42 ++- web/src/services/canvas-sync-drafts.ts | 50 ++-- .../services/creation-canvas-conversation.ts | 3 +- .../diagnostics/client-diagnostics.ts | 4 + web/src/services/project-asset-sync.ts | 6 +- web/src/services/user-data-sync.ts | 63 ++++- web/src/stores/canvas/use-canvas-store.ts | 251 ++++++++++++++++-- web/src/stores/use-sync-progress-store.ts | 16 +- 17 files changed, 541 insertions(+), 238 deletions(-) diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 7b29608e2..54a7821e4 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -203,7 +203,7 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考 历史接口先验证画布归属;无权访问、已删除画布或不存在的快照返回 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` 当作业务错误。 diff --git a/web/src/lib/canvas/canvas-storage-revision.ts b/web/src/lib/canvas/canvas-storage-revision.ts index b7af11e02..832f6f690 100644 --- a/web/src/lib/canvas/canvas-storage-revision.ts +++ b/web/src/lib/canvas/canvas-storage-revision.ts @@ -15,6 +15,8 @@ export type CanvasStorageDocument = { state: { projects: CanvasProject[] }; version: number; storageRevision: number; + savedAt?: string; + journalTokens?: Record; tombstones: CanvasStorageTombstones; }; @@ -73,6 +75,8 @@ export function parseCanvasStorageDocument(value: string | null, fallback: Canva state?: { projects?: unknown }; version?: unknown; storageRevision?: unknown; + savedAt?: unknown; + journalTokens?: unknown; tombstones?: unknown; }; 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[]) }, version: typeof parsed.version === "number" ? parsed.version : 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), }; } diff --git a/web/src/lib/user-session.ts b/web/src/lib/user-session.ts index 9880b6997..bb2e8c469 100644 --- a/web/src/lib/user-session.ts +++ b/web/src/lib/user-session.ts @@ -37,27 +37,26 @@ export async function applyUserSession(payload: AuthSessionPayload) { const previousUserId = useUserStore.getState().user?.id || ""; const nextUserId = payload.user?.id || ""; 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 { - // Query key 不携带用户 ID;身份变化时必须取消并清空旧账号请求,避免跨账号复用内存数据。 - if (previousUserId !== nextUserId) appQueryClient.clear(); - await switchUserStorageScope(payload.user?.id); - // 切换存储 scope 时 user store 仍保留旧身份;此处只能先检查 generation, - // 否则登录后的新用户会被误判为过期会话,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); - }); + // 缓存恢复和同步基线必须先于工作区开放,防止生成/编辑抢跑,或被初始化误记为已同步。 + await hydrateUserSessionData(payload, generation); + } catch (error) { + if (isCurrentSession(generation, nextUserId)) console.warn("用户工作区后台初始化失败,基础页面仍可继续", error); } 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 (!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) { - try { - await initializeRemoteUserDataSession(userId); - } catch (error) { - if (isCurrentSession(generation, userId)) console.warn("远端用户数据后台同步初始化失败", error); - } + await initializeRemoteUserDataSession(userId); } else { 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)) { recordDiagnosticEvent({ category: "navigation", diff --git a/web/src/pages/canvas/canvas-sync-status.css b/web/src/pages/canvas/canvas-sync-status.css index 67d723bb4..3fea0a895 100644 --- a/web/src/pages/canvas/canvas-sync-status.css +++ b/web/src/pages/canvas/canvas-sync-status.css @@ -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; diff --git a/web/src/pages/canvas/canvas-sync-status.tsx b/web/src/pages/canvas/canvas-sync-status.tsx index acd3bdb3f..7f6cdfac3 100644 --- a/web/src/pages/canvas/canvas-sync-status.tsx +++ b/web/src/pages/canvas/canvas-sync-status.tsx @@ -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) => { setBusy(true); @@ -53,7 +85,7 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: { content: (

这不是合并操作:当前编辑器内容会替换为云端版本。

-

系统会先把本地修改保存为草稿,之后可从“版本记录”查看或下载,不会直接丢失。

+

系统会先把本地修改保存为草稿,之后可从“版本记录”查看,不会直接丢失。

), okText: "保留草稿并加载", @@ -80,7 +112,7 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
云端同步 @@ -90,19 +122,40 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {

{description}

- {conflict ? ( +
+
+ 本地浏览器 + {localStatus} +
+
+ 云端画布 + {cloudStatus} +
+ {progress?.pendingWrites ? 待写入 {progress.pendingWrites} 项 : null} +
+ {progress?.draftError ? ( +
+ 本地草稿保存未完成,已暂停版本替换 + {progress.draftError} +
+ ) : conflict ? (
先保护本地,再决定版本 {progress?.draftCount ? `已保留 ${progress.draftCount} 份本地草稿。` : "加载云端版前会先保留本地草稿。"} 这是替换,不是合并。
- ) : error ? ( + ) : cloudError ? (
{retryScheduled ? "本地内容仍在,正在等待重试" : "本地内容仍在,未覆盖云端"} {retryScheduled ? "网络恢复后会自动重试;也可以点击“立即重试”。" : "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"}
+ ) : localError ? ( +
+ 本地保存没有完成 + {progress?.draftError || progress?.localError || "请立即重试;当前编辑内容保持不变。"} +
) : null}
- {error ? ( + {cloudError ? ( + ) : localError ? ( + ) : null} -
- {onOpenVersions ? ( ); } - -export function CanvasSyncDraftMenu({ projectId }: { projectId?: string }) { - const { message } = App.useApp(); - const [drafts, setDrafts] = useState([]); - const [draftScope, setDraftScope] = useState(""); - const [loading, setLoading] = useState(false); - const [exporting, setExporting] = useState(false); - return ( - { - 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 }], - }} - > - - - ); -} diff --git a/web/src/pages/canvas/canvas-version-history.tsx b/web/src/pages/canvas/canvas-version-history.tsx index da259d914..802512d5a 100644 --- a/web/src/pages/canvas/canvas-version-history.tsx +++ b/web/src/pages/canvas/canvas-version-history.tsx @@ -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; 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(); for (const entry of entries) { @@ -340,7 +322,7 @@ export function CanvasVersionHistory({ history }: { history: CanvasVersionHistor ) : ( <> -

发生冲突或恢复版本时保留的本机备份。下载后可从画布列表导入为新画布。

+

发生冲突或恢复版本时,系统自动保留的本地内容,可在此查看。

{draftError ? (

{draftError} @@ -383,19 +365,16 @@ export function CanvasVersionHistory({ history }: { history: CanvasVersionHistor ) : null} {preview.project?.nodes.some((node) => node.type === "drawing") ?

绘图仅保留已上传的预览,不含本机历史笔画。

: null} -
- {preview.kind === "cloud" ? ( + {preview.kind === "cloud" ? ( +
- ) : null} - -
+
+ ) : null} ) : ( -

{tab === "cloud" ? "内容变化时最多每 5 分钟保留一份,保留最近 20 份。恢复前额外备份。" : "草稿仅存于此浏览器,建议及时下载保留。"}

+

{tab === "cloud" ? "内容变化时最多每 5 分钟保留一份,保留最近 20 份。恢复前额外备份。" : "草稿保存在此浏览器中,查看草稿不会修改当前画布。"}

)}
diff --git a/web/src/pages/canvas/canvas-version-preview.tsx b/web/src/pages/canvas/canvas-version-preview.tsx index 2e7ed73f9..94c4d934d 100644 --- a/web/src/pages/canvas/canvas-version-preview.tsx +++ b/web/src/pages/canvas/canvas-version-preview.tsx @@ -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; diff --git a/web/src/pages/canvas/index.tsx b/web/src/pages/canvas/index.tsx index b016b3239..c47ec723d 100644 --- a/web/src/pages/canvas/index.tsx +++ b/web/src/pages/canvas/index.tsx @@ -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={{totalProjects} 个} actions={
- diff --git a/web/src/pages/canvas/use-canvas-generation.ts b/web/src/pages/canvas/use-canvas-generation.ts index 2907b8e02..c596275c9 100644 --- a/web/src/pages/canvas/use-canvas-generation.ts +++ b/web/src/pages/canvas/use-canvas-generation.ts @@ -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, }); }); diff --git a/web/src/pages/canvas/use-canvas-project-lifecycle.ts b/web/src/pages/canvas/use-canvas-project-lifecycle.ts index 0932b240c..cd3b9e5a9 100644 --- a/web/src/pages/canvas/use-canvas-project-lifecycle.ts +++ b/web/src/pages/canvas/use-canvas-project-lifecycle.ts @@ -96,7 +96,7 @@ export function useCanvasProjectLifecycle({ const observedContentRef = useRef(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; 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((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((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; } diff --git a/web/src/services/canvas-sync-drafts.ts b/web/src/services/canvas-sync-drafts.ts index 82638c1aa..a51217cdb 100644 --- a/web/src/services/canvas-sync-drafts.ts +++ b/web/src/services/canvas-sync-drafts.ts @@ -4,6 +4,7 @@ import { getActiveUserScope } from "@/lib/user-scope"; import { sameCanvasContent } from "@/lib/canvas/canvas-content"; import { withCanvasStorePersistenceLock, type CanvasProject } from "@/stores/canvas/use-canvas-store"; import { withGenerationArtifactCommitLock } from "@/services/generation-asset-repository"; +import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; export type CanvasSyncDraft = { id: string; savedAt: string; project: CanvasProject }; 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) : []; } -export async function preserveCanvasSyncDraft(project: CanvasProject, scope = getActiveUserScope()) { - // A separate record survives replacement of the active canvas and a browser reload. - const snapshot = structuredClone(project); - return withGenerationArtifactCommitLock(scope, () => - withCanvasStorePersistenceLock(scope, async () => { - const drafts = await readCanvasSyncDrafts(snapshot.id, scope); - if (!drafts.some((draft) => sameCanvasContent(draft.project, snapshot))) { - const storage = localForageStorageForScope(scope); - const rawIndex = await storage.getItem(draftIndexKey); - const ids: string[] = rawIndex ? JSON.parse(rawIndex) : []; - if (!ids.includes(snapshot.id)) await storage.setItem(draftIndexKey, JSON.stringify([...ids, snapshot.id])); - drafts.push({ id: nanoid(), savedAt: new Date().toISOString(), project: snapshot }); - await storage.setItem(draftKey(snapshot.id), JSON.stringify(drafts)); - } - return drafts.length; - }), - ); +/** Caller must hold the scope's canvas persistence lock. Never reacquire it here. */ +export async function preserveCanvasSyncDraftLocked(snapshot: CanvasProject, scope: string) { + try { + const drafts = await readCanvasSyncDrafts(snapshot.id, scope); + if (!drafts.some((draft) => sameCanvasContent(draft.project, snapshot))) { + const storage = localForageStorageForScope(scope); + const rawIndex = await storage.getItem(draftIndexKey); + const ids: string[] = rawIndex ? JSON.parse(rawIndex) : []; + if (!ids.includes(snapshot.id)) await storage.setItem(draftIndexKey, JSON.stringify([...ids, snapshot.id])); + 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; + } 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))); } diff --git a/web/src/services/creation-canvas-conversation.ts b/web/src/services/creation-canvas-conversation.ts index f9183993f..7f37a81dd 100644 --- a/web/src/services/creation-canvas-conversation.ts +++ b/web/src/services/creation-canvas-conversation.ts @@ -52,8 +52,7 @@ export async function continueCreationConversationOnCanvas(source: SourceConvers await flushCanvasStorePersistence(); assertScope(); try { - if (!hasRemoteUserDataSyncSession()) throw new Error("尚未建立云端同步会话"); - await saveRemoteUserDataNow(); + if (hasRemoteUserDataSyncSession()) await saveRemoteUserDataNow(); } catch (cause) { syncError = cause; } } else { const session: CanvasAssistantSession = { id: sessionId, title: source.title, createdAt: source.messages[0].createdAt, updatedAt: source.updatedAt, messages }; diff --git a/web/src/services/diagnostics/client-diagnostics.ts b/web/src/services/diagnostics/client-diagnostics.ts index 2f641b925..bb5f3c554 100644 --- a/web/src/services/diagnostics/client-diagnostics.ts +++ b/web/src/services/diagnostics/client-diagnostics.ts @@ -20,6 +20,8 @@ export type ClientDiagnosticEvent = { taskId?: string; projectId?: string; canvasId?: string; + nodes_before?: number; + nodes_after?: number; stack?: string; }; @@ -133,6 +135,8 @@ export function recordDiagnosticEvent(input: DiagnosticEventInput) { taskId: safeDiagnosticId(input.taskId), projectId: safeDiagnosticId(input.projectId), 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), }; events.push(event); diff --git a/web/src/services/project-asset-sync.ts b/web/src/services/project-asset-sync.ts index 385ff82a8..7d2000db7 100644 --- a/web/src/services/project-asset-sync.ts +++ b/web/src/services/project-asset-sync.ts @@ -13,7 +13,7 @@ import { getImageBlob, resolveImageUrl, setImageBlob } from "@/services/image-st import { generationArtifactStorageKey, loadOrStoreGenerationArtifact } from "@/services/generation-artifact-sink"; import { createProviderNeutralGenerationTaskEffectStore } from "@/services/provider-neutral-generation-effects"; 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 { normalizeAssetCategory } from "@/lib/asset-category"; import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle"; @@ -120,8 +120,10 @@ async function persistCanvasNodeAsset(options: EnsureCanvasNodeAssetOptions): Pr store.updateAsset(asset.id, { category: declaredCategory }); asset = useAssetStore.getState().assets.find((item) => item.id === asset?.id) || asset; } + // 生成结果先以本地资产完成闭环;未建立云端同步会话时,后续登录同步会 + // 继续上传素材和画布,不能把一个可恢复的本地成功报告成失败。 + if (!hasRemoteUserDataSyncSession()) return { assetId: asset.id, created, linkedToProject: false }; if (!options.domainProjectId) { - // 个人画布也必须在返回成功前把素材提交到服务端,不能只依赖延迟自动同步。 await saveRemoteUserDataNow(); throwIfAborted(options.signal); return { assetId: asset.id, created, linkedToProject: false }; diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index 1290873e4..39a7d5144 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -48,7 +48,8 @@ let incrementalSession = false; let sessionEpoch = 0; const verifiedProjects = new Set(); const verifiedAssets = new Set(); -const remoteProjectLoadPromises = new Map>(); +type RemoteProjectLoad = { mode: "normal" | "latest" | "history"; promise: Promise }; +const remoteProjectLoadPromises = new Map(); const REMOTE_SYNC_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 30_000] as const; 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 } = {}) { + 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 request = withRemoteUserDataSyncExclusive(async () => { if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布"); @@ -150,12 +165,13 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest? verifiedProjects.add(id); useCanvasStore.setState((state) => ({ projects: local ? state.projects.map((item) => (item.id === id ? project : item)) : [...state.projects, project] })); await flushCanvasStorePersistence(); - useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", message: "已加载云端最新版本" }); + useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", cloudRevision: project.revision, pendingChanges: 0, message: "已加载云端最新版本" }); return project; }); - remoteProjectLoadPromises.set(id, request); + const pending = { mode, promise: request } as RemoteProjectLoad; + remoteProjectLoadPromises.set(id, pending); const clearPending = () => { - if (remoteProjectLoadPromises.get(id) === request) remoteProjectLoadPromises.delete(id); + if (remoteProjectLoadPromises.get(id) === pending) remoteProjectLoadPromises.delete(id); }; void request.then(clearPending, clearPending); return request; @@ -265,7 +281,9 @@ export async function applyAgentCanvasPatches(id: string, patches: AgentCanvasPa verifiedProjects.add(id); if (projected === current) return current; 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 画布结果" }); return projected; }); @@ -278,7 +296,7 @@ async function preserveAgentConflict(project: CanvasProject) { } async function waitForRemoteProjectLoads() { - const pending = [...remoteProjectLoadPromises.values()]; + const pending = [...remoteProjectLoadPromises.values()].map((item) => item.promise); if (pending.length) await Promise.all(pending); } @@ -360,7 +378,7 @@ export async function syncRemoteUserData(userId?: string | null) { snapshot.projects.map(async (project) => { const local = localProjects.find((item) => item.id === project.id); const draftCount = (await readCanvasSyncDrafts(project.id)).length; - useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "done", draftCount, message: "已保存到云端" }); + 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) }; }), ); @@ -533,7 +551,7 @@ export function localSavedRemotePendingMessage(localAction: string, error: unkno export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial>) { const id = useCanvasStore.getState().createProject(title, projectId); if (initialContent) useCanvasStore.getState().updateProject(id, initialContent); - if (!activeRemoteUserId) return { id, syncError: new Error("尚未建立云端同步会话") }; + if (!activeRemoteUserId) return { id }; try { await saveRemoteUserDataNow(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 options = input && typeof input === "object" && !Array.isArray(input) ? (input as { force?: boolean; repairMissingResources?: boolean }) : {}; const epoch = sessionEpoch; - if (!activeRemoteUserId) throw new Error("尚未建立云端同步会话,本地内容尚未保存到云端"); + if (!activeRemoteUserId) throw new Error("云端同步会话尚未建立,当前内容已保留在本地"); requireRemoteUserDataBaseline(); const assertNoConflict = () => { 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, options: { total, completed: 0, phase: total > 0 ? "uploading" : "saving", + errorKind: undefined, + draftError: undefined, + pendingChanges: 1, message: total > 0 ? "正在同步媒体至云端" : "正在保存画布", }); const onMediaUploaded = () => { @@ -955,7 +976,14 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { } await flushCanvasStorePersistence(); 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; saved = true; continue; @@ -974,7 +1002,7 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { verifiedProjects.add(source.id); useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === source.id ? reconciled : project)) })); 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; continue; } @@ -1005,17 +1033,26 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: { if (!saved && lastError) throw lastError; } catch (error) { 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, { phase: conflict ? "conflict" : "error", + errorKind, + draftError: undefined, + pendingChanges: 1, message: conflict ? "本地与云端存在同一字段修改,请选择保留的版本" : error instanceof Error ? error.message : "云端同步失败,等待重试", }); if (conflict) { try { const current = useCanvasStore.getState().openProject(source.id) || source; const draftCount = await preserveCanvasSyncDraft(current); - useSyncProgressStore.getState().setProjectProgress(source.id, { draftCount }); + useSyncProgressStore.getState().setProjectProgress(source.id, { draftCount, draftError: undefined }); } 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); } } diff --git a/web/src/stores/canvas/use-canvas-store.ts b/web/src/stores/canvas/use-canvas-store.ts index 648db2e75..563b01d20 100644 --- a/web/src/stores/canvas/use-canvas-store.ts +++ b/web/src/stores/canvas/use-canvas-store.ts @@ -12,6 +12,8 @@ import type { CanvasStarterMode } from "@/lib/canvas/canvas-starter"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas"; import type { PrevisScene } from "@/types/previs"; import type { TimelineProject } from "@/types/timeline"; +import { useSyncProgressStore, type SyncProjectProgress } from "@/stores/use-sync-progress-store"; +import { recordDiagnosticEvent } from "@/services/diagnostics/client-diagnostics"; export type CanvasProject = { id: string; @@ -56,13 +58,155 @@ type QueuedCanvasPersist = { baseRevision: number; state: PersistedCanvasState; token: number; + journal?: CanvasPersistenceJournal; }; type ObservedCanvasPersist = { projects: CanvasProject[]; revision: number; + savedAt?: string; }; +type CanvasPersistenceJournal = { + version: 1; + name: string; + scope: string; + writerId: string; + token: number; + savedAt: string; + baseRevision: number; + baseProjects: CanvasProject[]; + projects: CanvasProject[]; +}; + +const CANVAS_PERSISTENCE_JOURNAL_KEY = "yingce:canvas_store_journal"; +const CANVAS_JOURNAL_MAX_BYTES = 450_000; +const canvasJournalWriterId = `tab-${nanoid(10)}`; + +function canvasPersistenceJournalKey(scope: string, writerId = canvasJournalWriterId) { + return `${CANVAS_PERSISTENCE_JOURNAL_KEY}:user:${encodeURIComponent(scope)}:${writerId}`; +} + +function createCanvasPersistenceJournal(scope: string, name: string, previous: CanvasProject[], next: CanvasProject[], token: number, baseRevision: number): CanvasPersistenceJournal { + const before = new Map(previous.map((project) => [project.id, project])); + const after = new Map(next.map((project) => [project.id, project])); + const changed = new Set([...before.keys(), ...after.keys()].filter((id) => before.get(id) !== after.get(id))); + return { + version: 1, name, scope, writerId: canvasJournalWriterId, token, + savedAt: new Date().toISOString(), baseRevision, + baseProjects: previous.filter((project) => changed.has(project.id)), + projects: next.filter((project) => changed.has(project.id)), + }; +} + +function readCanvasPersistenceJournals(scope: string): CanvasPersistenceJournal[] { + try { + if (typeof window === "undefined") return []; + const storage = window.localStorage; + if (!storage) return []; + const prefix = canvasPersistenceJournalKey(scope, ""); + const keys = new Set([canvasPersistenceJournalKey(scope)]); + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key?.startsWith(prefix)) keys.add(key); + } + return [...keys].flatMap((key) => { + const raw = storage.getItem(key); + if (!raw) return []; + try { + const journal = JSON.parse(raw) as CanvasPersistenceJournal; + return journal.version === 1 && journal.scope === scope && journal.name && typeof journal.writerId === "string" + && key === canvasPersistenceJournalKey(scope, journal.writerId) && Number.isSafeInteger(journal.token) + && Array.isArray(journal.projects) && Array.isArray(journal.baseProjects) ? [journal] : []; + } catch { return []; } + }); + } catch { return []; } +} + +function writeCanvasPersistenceJournal(journal: CanvasPersistenceJournal) { + try { + if (typeof window === "undefined" || !window.localStorage) return false; + const serialized = JSON.stringify(journal); + // Large media stays in IndexedDB. Oversized recovery records trigger an + // immediate full save and remain visibly pending until that write succeeds. + if (new TextEncoder().encode(serialized).byteLength > CANVAS_JOURNAL_MAX_BYTES) return false; + window.localStorage.setItem(canvasPersistenceJournalKey(journal.scope, journal.writerId), serialized); + return true; + } catch { return false; } +} + +function clearCanvasPersistenceJournal(scope: string, token: number, writerId = canvasJournalWriterId) { + try { + if (typeof window === "undefined") return; + const key = canvasPersistenceJournalKey(scope, writerId); + const raw = window.localStorage?.getItem(key); + // A previous save must never clear edits made while IndexedDB was writing. + if (raw && (JSON.parse(raw) as CanvasPersistenceJournal).token <= token) window.localStorage.removeItem(key); + } catch { /* The durable receipt makes a leftover journal safe to replay. */ } +} + +function applyCanvasPersistenceJournal(document: CanvasStorageDocument, journal: CanvasPersistenceJournal) { + if ((document.journalTokens?.[journal.writerId] ?? -1) >= journal.token) return { document, conflicts: [] }; + return rebaseCanvasProjects({ document, baseProjects: journal.baseProjects, localProjects: journal.projects, baseRevision: journal.baseRevision }); +} + +async function preserveLocalRebaseConflicts(scope: string, projects: CanvasProject[], conflicts: ReturnType["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) { + const progress = useSyncProgressStore.getState(); + for (const project of projects) { + const current = progress.syncingProjects[project.id]; + progress.setProjectProgress(project.id, { projectId: project.id, ...(current ? {} : { phase: "local" as const }), ...patch }); + } +} + +function markCanvasLocalPersistencePending(projects: CanvasProject[], error?: string) { + setCanvasLocalPersistenceProgress(projects, { + localPhase: error ? "error" : "pending", + localError: error, + errorKind: error ? "local" : undefined, + pendingWrites: 1, + }); +} + +function markCanvasLocalPersistenceSaved(projects: CanvasProject[], savedAt: string) { + setCanvasLocalPersistenceProgress(projects, { localPhase: "saved", localSavedAt: savedAt, localError: undefined, errorKind: undefined, pendingWrites: 0 }); +} + +function recordCanvasNodePersistenceCounts(before: CanvasProject[], after: CanvasProject[]) { + const beforeById = new Map(before.map((project) => [project.id, project])); + const afterById = new Map(after.map((project) => [project.id, project])); + for (const id of new Set([...beforeById.keys(), ...afterById.keys()])) { + const nodesBefore = beforeById.get(id)?.nodes.length || 0; + const nodesAfter = afterById.get(id)?.nodes.length || 0; + if (nodesBefore === nodesAfter) continue; + recordDiagnosticEvent({ + level: nodesAfter < nodesBefore ? "error" : "info", + category: "action", + code: nodesAfter < nodesBefore ? "canvas.persistence.nodes_decreased" : "canvas.persistence.nodes_changed", + message: nodesAfter < nodesBefore ? "画布持久化后节点数量减少" : "画布持久化后节点数量变化", + projectId: id, + nodes_before: nodesBefore, + nodes_after: nodesAfter, + }); + } +} + + + let suppressCanvasStorePersistence = 0; const canvasMemoryStates = new Map(); const observedCanvasPersists = new Map(); @@ -130,10 +274,20 @@ export function canvasStoreStorageRevision(scope: string) { } export function recordCanvasStorageDocument(scope: string, document: CanvasStorageDocument) { + const savedAt = document.savedAt || new Date().toISOString(); observedCanvasPersists.set(scope, { projects: document.state.projects, revision: document.storageRevision, + savedAt, }); + if (getActiveUserScope() !== scope) return; + const queued = queuedCanvasPersists.get(scope); + if (queued || failedCanvasJournalRecoveries.has(scope)) { + if (failedCanvasJournalRecoveries.has(scope)) return; + markCanvasLocalPersistencePending(queued!.state.projects); + } else { + markCanvasLocalPersistenceSaved(document.state.projects, savedAt); + } } export async function commitPendingCanvasStorePersistenceLocked(scope: string) { @@ -150,24 +304,37 @@ export async function commitPendingCanvasStorePersistenceLocked(scope: string) { baseProjects: queued.baseProjects, localProjects: queued.state.projects, baseRevision: queued.baseRevision, - }).document; - await storage.setItem(queued.name, serializeCanvasStorageDocument(rebased)); - committed = rebased; - recordCanvasStorageDocument(scope, rebased); + }); + await preserveLocalRebaseConflicts(scope, queued.state.projects, rebased.conflicts); + const savedAt = new Date().toISOString(); + const savedDocument = { ...rebased.document, savedAt, journalTokens: { ...durable.journalTokens, [canvasJournalWriterId]: queued.token } }; + await storage.setItem(queued.name, serializeCanvasStorageDocument(savedDocument)); + committed = savedDocument; + clearCanvasPersistenceJournal(scope, queued.token); + recordCanvasNodePersistenceCounts(durable.state.projects, savedDocument.state.projects); + recordCanvasNodePersistenceCounts(queued.state.projects, savedDocument.state.projects); const latest = queuedCanvasPersists.get(scope); if (!latest || latest.token === queued.token) { if (latest?.token === queued.token) queuedCanvasPersists.delete(scope); + recordCanvasStorageDocument(scope, savedDocument); return committed; } latest.baseProjects = queued.state.projects; - latest.baseRevision = rebased.storageRevision; + latest.baseRevision = savedDocument.storageRevision; + recordCanvasStorageDocument(scope, savedDocument); } } async function writeQueuedCanvasPersist(scope: string, _token: number) { - await withCanvasStorePersistenceLock(scope, () => commitPendingCanvasStorePersistenceLocked(scope)); + try { + await withCanvasStorePersistenceLock(scope, () => commitPendingCanvasStorePersistenceLocked(scope)); + } catch (error) { + const queued = queuedCanvasPersists.get(scope); + if (queued) markCanvasLocalPersistencePending(queued.state.projects, error instanceof Error ? error.message : "本地画布保存失败"); + throw error; + } } export function pendingCanvasStorePersistence(scope: string) { @@ -379,16 +546,57 @@ export function reconcileCanvasGenerationFailure(scope: string, durableProjects: }); } +const failedCanvasJournalRecoveries = new Map(); + +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 = { getItem: async (name) => { const scope = getActiveUserScope(); - const value = await localForageStorageForScope(scope).getItem(name); - if (!value) { + const storage = localForageStorageForScope(scope); + const value = await storage.getItem(name); + let document = parseCanvasStorageDocument(value); + document = await recoverCanvasPersistenceJournal(scope, name, document); + if (!value && !document.state.projects.length) { canvasMemoryStates.set(scope, { projects: [] }); - observedCanvasPersists.set(scope, { projects: [], revision: 0 }); + observedCanvasPersists.set(scope, { projects: [], revision: document.storageRevision }); return null; } - const document = parseCanvasStorageDocument(value); const state = document.state as PersistedCanvasState; canvasMemoryStates.set(scope, state); recordCanvasStorageDocument(scope, document); @@ -397,7 +605,8 @@ const canvasStorage: PersistStorage = { setItem: (name, value) => { const scope = getActiveUserScope(); const nextState = value.state as PersistedCanvasState; - if (canvasMemoryStates.get(scope)?.projects === nextState.projects) return; + const previousState = canvasMemoryStates.get(scope); + if (previousState?.projects === nextState.projects) return; canvasMemoryStates.set(scope, nextState); if (suppressCanvasStorePersistence) return; @@ -405,29 +614,35 @@ const canvasStorage: PersistStorage = { const observed = observedCanvasPersists.get(scope); const token = (canvasPersistTokens.get(scope) || 0) + 1; canvasPersistTokens.set(scope, token); - const baseProjects = queued?.baseProjects ?? observed?.projects ?? []; + const baseProjects = queued?.baseProjects ?? observed?.projects ?? previousState?.projects ?? []; + const baseRevision = queued?.baseRevision ?? observed?.revision ?? 0; + const queuedState = ordinaryCanvasPersistenceState(scope, nextState, observed?.projects ?? baseProjects); + const journalBaseProjects = queued?.baseProjects ?? observed?.projects ?? previousState?.projects ?? []; + const journal = createCanvasPersistenceJournal(scope, name, journalBaseProjects, queuedState.projects, token, baseRevision); + const journalWritten = journal ? writeCanvasPersistenceJournal(journal) : false; queuedCanvasPersists.set(scope, { name, scope, baseProjects, - baseRevision: queued?.baseRevision ?? observed?.revision ?? 0, - // generationEffectKeys 由专用 generation durable commit 管理;普通队列只能携带已确认 stamp。 - state: ordinaryCanvasPersistenceState(scope, nextState, observed?.projects ?? baseProjects), + baseRevision, + state: queuedState, token, + journal: journalWritten ? journal || undefined : undefined, }); + markCanvasLocalPersistencePending(nextState.projects, journal && !journalWritten ? "本地快速恢复记录写入失败,正在等待完整画布保存" : undefined); clearCanvasSaveTimer(scope); const timer = setTimeout(() => { if (canvasSaveTimers.get(scope) === timer) canvasSaveTimers.delete(scope); void writeQueuedCanvasPersist(scope, token).catch((error) => { - // 自动保存无法把异常返回给原始状态更新调用方,但失败队列仍会保留给下一次写入或显式 flush 重试。 - // 自动保存失败有队列兜底,属可降级场景;在无本地存储的环境(如测试进程)不应升级为错误级日志。 + // 自动保存无法把异常返回给原始状态更新调用方,但失败队列和 journal 会保留给下一次写入或显式 flush 重试。 console.warn("画布本地持久化失败,已保留待写队列", { scope, error }); }); - }, 400); + }, journalWritten ? 400 : 0); canvasSaveTimers.set(scope, timer); }, removeItem: (name) => { const scope = getActiveUserScope(); + for (const journal of readCanvasPersistenceJournals(scope)) clearCanvasPersistenceJournal(scope, journal.token, journal.writerId); return localForageStorageForScope(scope).removeItem(name); }, }; diff --git a/web/src/stores/use-sync-progress-store.ts b/web/src/stores/use-sync-progress-store.ts index 576562919..efe4007ea 100644 --- a/web/src/stores/use-sync-progress-store.ts +++ b/web/src/stores/use-sync-progress-store.ts @@ -1,12 +1,22 @@ import { create } from "zustand"; +export type LocalCanvasPersistencePhase = "saved" | "pending" | "error"; + export type SyncProjectProgress = { projectId: string; total: number; completed: number; - phase: "pending" | "uploading" | "saving" | "reconciling" | "done" | "error" | "conflict"; + phase: "local" | "pending" | "uploading" | "saving" | "reconciling" | "done" | "error" | "conflict"; message?: string; draftCount?: number; + draftError?: string; + errorKind?: "local" | "network" | "conflict" | "draft" | "resources"; + localPhase?: LocalCanvasPersistencePhase; + localSavedAt?: string; + localError?: string; + pendingWrites?: number; + pendingChanges?: number; + cloudRevision?: number; }; type SyncProgressStore = { @@ -56,11 +66,11 @@ export const useSyncProgressStore = create((set, get) => ({ clearAll: () => set({ syncingProjects: {} }), isAnySyncing: () => { const list = Object.values(get().syncingProjects); - return list.some((item) => item.phase !== "done"); + return list.some((item) => (item.phase !== "done" && item.phase !== "local") || item.localPhase === "pending" || item.localPhase === "error" || Boolean(item.draftError)); }, })); -if (typeof window !== "undefined") { +if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("beforeunload", (event) => { if (useSyncProgressStore.getState().isAnySyncing()) { event.preventDefault();