fix(canvas): 画布 - 防止旧内容覆盖并支持版本历史恢复

This commit is contained in:
kinoko
2026-09-17 18:40:42 +08:00
parent b6bae4f957
commit 35ebed3097
69 changed files with 3575 additions and 294 deletions

View File

@@ -2,6 +2,7 @@ import { create } from "zustand";
import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
import { nanoid } from "nanoid";
import { sameCanvasContent } from "@/lib/canvas/canvas-content";
import { DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, readCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import { parseCanvasStorageDocument, rebaseCanvasProjects, serializeCanvasStorageDocument, type CanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision";
import { localForageStorageForScope } from "@/lib/localforage-storage";
@@ -14,6 +15,8 @@ import type { TimelineProject } from "@/types/timeline";
export type CanvasProject = {
id: string;
revision?: number;
remoteContentHash?: string;
projectId?: string;
title: string;
createdAt: string;
@@ -448,6 +451,7 @@ export const useCanvasStore = create<CanvasStore>()(
const appearanceDefault = readCanvasAppearanceDefault();
const project: CanvasProject = {
id,
revision: 0,
projectId,
title,
createdAt: now,
@@ -469,6 +473,7 @@ export const useCanvasStore = create<CanvasStore>()(
const now = new Date().toISOString();
const project: CanvasProject = {
id: nanoid(),
revision: 0,
projectId: source.projectId,
title: source.title || "导入画布",
createdAt: source.createdAt || now,
@@ -490,20 +495,27 @@ export const useCanvasStore = create<CanvasStore>()(
openProject: (id) => {
return get().projects.find((item) => item.id === id) || null;
},
renameProject: (id, title) =>
set((state) => ({
projects: state.projects.map((project) => (project.id === id ? { ...project, title: title.trim() || project.title, updatedAt: new Date().toISOString() } : project)),
})),
renameProject: (id, title) => set((state) => {
const current = state.projects.find((project) => project.id === id);
const nextTitle = title.trim() || current?.title;
if (!current || current.title === nextTitle) return state;
return { projects: state.projects.map((project) => project === current ? { ...project, title: nextTitle!, updatedAt: new Date().toISOString() } : project) };
}),
deleteProjects: (ids) =>
set((state) => {
const projects = state.projects.filter((project) => !ids.includes(project.id));
return { projects };
}),
replaceProjects: (projects) => set({ projects }),
updateProject: (id, patch) =>
set((state) => ({
projects: state.projects.map((project) => (project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project)),
})),
updateProject: (id, patch) => set((state) => {
const current = state.projects.find((project) => project.id === id);
if (!current) return state;
const next = { ...current, ...patch };
const contentChanged = !sameCanvasContent(current, next);
if (!contentChanged && samePersistenceValue(current.viewport, next.viewport)) return state;
if (contentChanged) next.updatedAt = new Date().toISOString();
return { projects: state.projects.map((project) => project === current ? next : project) };
}),
}),
{
name: CANVAS_STORE_KEY,

View File

@@ -13,6 +13,7 @@ import { cleanupUnusedImages, collectImageStorageKeys, resolveImageUrl, uploadIm
import { cleanupUnusedMedia, collectMediaStorageKeys, resolveMediaUrl } from "@/services/file-storage";
import { flushGenerationAssetStorageLocks, insertOrReturnGenerationAsset, withGenerationArtifactCommitLock, withGenerationAssetStorageLock } from "@/services/generation-asset-repository";
import { CANVAS_STORE_KEY, commitPendingCanvasStorePersistenceLocked, pendingCanvasStorePersistence, withCanvasStorePersistenceLock } from "@/stores/canvas/use-canvas-store";
import { readAllCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
export type AssetKind = "text" | "image" | "video" | "audio" | "model" | "entity";
export type { AssetCategory } from "@/lib/asset-category";
@@ -414,6 +415,7 @@ export const useAssetStore = create<AssetStore>()(
await new Promise<void>((resolve, reject) => {
window.setTimeout(() =>
withGenerationArtifactCommitLock(scope, async () => {
const syncDrafts = await readAllCanvasSyncDrafts(scope);
// 固定锁序:artifact -> Canvas(释放)-> Asset,避免跨 store 锁重入。
const canvasProjects = await withCanvasStorePersistenceLock(scope, async () => {
await commitPendingCanvasStorePersistenceLocked(scope);
@@ -423,7 +425,7 @@ export const useAssetStore = create<AssetStore>()(
await withGenerationAssetStorageLock(scope, async () => {
await commitPendingAssetStorePersistenceLocked(scope);
const durableAssets = (await readPersistedAssetDocumentForScope(scope)).state.assets;
const references = { projects: canvasProjects, assets: durableAssets };
const references = { projects: canvasProjects, assets: durableAssets, syncDrafts };
const imageKeys = new Set([...frozenExtraImageKeys, ...collectImageStorageKeys(references)]);
const mediaKeys = new Set([...frozenExtraMediaKeys, ...collectMediaStorageKeys(references)]);
await cleanupUnusedImages(

View File

@@ -4,8 +4,9 @@ export type SyncProjectProgress = {
projectId: string;
total: number;
completed: number;
phase: "uploading" | "saving" | "done" | "error";
phase: "pending" | "uploading" | "saving" | "done" | "error" | "conflict";
message?: string;
draftCount?: number;
};
type SyncProgressStore = {
@@ -55,7 +56,7 @@ export const useSyncProgressStore = create<SyncProgressStore>((set, get) => ({
clearAll: () => set({ syncingProjects: {} }),
isAnySyncing: () => {
const list = Object.values(get().syncingProjects);
return list.some((item) => item.phase === "uploading" || item.phase === "saving");
return list.some((item) => item.phase !== "done");
},
}));