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