perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突

This commit is contained in:
ddCat
2026-09-05 13:24:10 +08:00
parent 165b4443ab
commit 58f01d1eb6
107 changed files with 3025 additions and 1089 deletions

View File

@@ -42,6 +42,8 @@ type BackendGenerationTaskOptions = {
metadata?: Record<string, unknown>;
onTaskUpdate?: (task: GenerationTask) => void;
onTextDelta?: (text: string) => void;
streamText?: boolean;
enableThinking?: boolean;
localIdempotencyKey?: string;
localResumeOnly?: boolean;
clientOperationId?: string;
@@ -90,6 +92,9 @@ export async function runBackendGenerationTask(
signal,
metadata,
onTaskUpdate,
onTextDelta,
streamText,
enableThinking,
localIdempotencyKey,
localResumeOnly,
clientOperationId,
@@ -104,14 +109,14 @@ export async function runBackendGenerationTask(
await dependencies.ensureLocalDreaminaReady?.(signal);
throwIfAborted(signal);
return await runLocalDreaminaGeneration(
{ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, textHistory, mask, signal, metadata, onTaskUpdate, localIdempotencyKey, localResumeOnly, clientOperationId, retryOf, attemptGroupId },
{ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, textHistory, mask, signal, metadata, onTaskUpdate, onTextDelta, streamText, enableThinking, localIdempotencyKey, localResumeOnly, clientOperationId, retryOf, attemptGroupId },
dependencies,
);
}
assertBackendRuntimeConfigured(config, mode);
const prepared = await prepareGenerationReferences({ referenceImages, referenceVideos, referenceAudios, mask });
throwIfAborted(signal);
return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, textHistory, signal, metadata, onTaskUpdate }, prepared, dependencies);
return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, textHistory, signal, metadata, onTaskUpdate, onTextDelta, streamText, enableThinking }, prepared, dependencies);
}
// 分镜等后台生产流程只需要可靠提交任务;任务状态与产物由项目工作区轮询和
@@ -439,6 +444,7 @@ async function createBackendGenerationTask(options: BackendGenerationTaskOptions
config: backendProviderConfig(config, mode),
capabilityOptions: logicalModelId ? logicalCapabilityOptions(config, mode) : undefined,
textHistory: options.textHistory,
...(mode === "text" ? { textOptions: { stream: options.streamText !== false, thinking: options.enableThinking === true } } : {}),
referenceImages: prepared.referenceImages,
referenceVideos: prepared.referenceVideos,
referenceAudios: prepared.referenceAudios,

View File

@@ -12,14 +12,16 @@ export class ApiError extends Error {
readonly status?: number;
readonly code?: number;
readonly retryable: boolean;
readonly retryAfterMs?: number;
readonly cause?: unknown;
constructor(message: string, options: { status?: number; code?: number; retryable?: boolean; cause?: unknown } = {}) {
constructor(message: string, options: { status?: number; code?: number; retryable?: boolean; retryAfterMs?: number; cause?: unknown } = {}) {
super(message);
this.name = "ApiError";
this.status = options.status;
this.code = options.code;
this.retryable = options.retryable ?? isRetryableStatus(options.status ?? options.code);
this.retryAfterMs = options.retryAfterMs;
this.cause = options.cause;
}
}
@@ -28,7 +30,7 @@ export class ApiError extends Error {
export const apiBaseURL = import.meta.env.VITE_CANVAS_BACKEND_URL || "/api";
export const apiClient = axios.create({ baseURL: apiBaseURL, withCredentials: true });
export async function request<T>(promise: Promise<{ data: BackendEnvelope<T>; status?: number }>) {
export async function request<T>(promise: Promise<{ data: BackendEnvelope<T>; status?: number; headers?: unknown }>) {
try {
const response = await promise;
if (response.data.code !== 0) {
@@ -36,6 +38,7 @@ export async function request<T>(promise: Promise<{ data: BackendEnvelope<T>; st
status: response.status,
code: response.data.code,
retryable: isRetryableStatus(response.status) || isRetryableStatus(response.data.code),
retryAfterMs: retryAfterMilliseconds(response.headers),
});
}
return response.data.data;
@@ -50,6 +53,7 @@ export async function request<T>(promise: Promise<{ data: BackendEnvelope<T>; st
status,
code,
retryable: isRetryableStatus(status) || isRetryableStatus(code),
retryAfterMs: retryAfterMilliseconds(error.response?.headers),
cause: error,
});
}
@@ -61,6 +65,21 @@ function isRetryableStatus(status?: number) {
return status === 408 || status === 425 || status === 429 || (status !== undefined && status >= 500 && status <= 599);
}
function retryAfterMilliseconds(headers: unknown) {
if (!headers || typeof headers !== "object") return undefined;
const headerBag = headers as { get?: (name: string) => unknown; [key: string]: unknown };
const rawValue = headerBag.get?.("retry-after") ?? headerBag["retry-after"] ?? headerBag["Retry-After"];
const value = Array.isArray(rawValue) ? rawValue[0] : rawValue;
if (value === undefined || value === null) return undefined;
const text = String(value).trim();
if (!text) return undefined;
const seconds = Number(text);
if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1000);
const retryAt = Date.parse(text);
if (!Number.isFinite(retryAt)) return undefined;
return Math.max(0, retryAt - Date.now());
}
export function compactApiParams(params: ApiParams) {
return Object.fromEntries(Object.entries(params).filter(([, value]) => value !== "" && value !== undefined && (!Array.isArray(value) || value.length > 0))) as ApiParams;
}

View File

@@ -53,6 +53,7 @@ export type GenerationTask = {
receiptRecorded?: boolean;
previewUrl?: string;
previewKind?: "image" | "video";
previewPosterUrl?: string;
inputJson?: string;
resultJson?: string;
resultState?: GenerationTaskResultState;

View File

@@ -37,6 +37,18 @@ export type RemoteUserDataSnapshot = {
projects: CanvasProject[];
};
export type CanvasLibrarySummary = Pick<CanvasProject, "id" | "projectId" | "title" | "createdAt" | "updatedAt"> & {
nodeCount: number;
previewNodes: CanvasProject["nodes"];
};
export function listRemoteCanvasProjectsPage(options: { page: number; pageSize: number; projectId?: string; query?: string; sort?: string; signal?: AbortSignal }) {
return request<{ projects: CanvasLibrarySummary[]; page: number; pageSize: number; total: number; hasMore: boolean }>(api.get("/canvas-projects", {
signal: options.signal,
params: compactApiParams({ page: options.page, page_size: options.pageSize, project_id: options.projectId, q: options.query, sort: options.sort }),
}));
}
export function getRemoteUserDataSnapshot() {
return request<RemoteUserDataSnapshot>(api.get("/user-data/snapshot"));
}
@@ -85,6 +97,10 @@ export function getRemoteAsset(id: string) {
return request<{ asset: Asset }>(api.get(`/assets/${encodeURIComponent(id)}`));
}
export function getRemoteAssetsByIds(ids: string[]) {
return request<{ assets: Asset[] }>(api.post("/assets/batch", { ids }));
}
export function upsertRemoteAsset(asset: Asset) {
return request<{ asset: RemoteUserDataSummary }>(api.put(`/assets/${encodeURIComponent(asset.id)}`, { asset }));
}

View File

@@ -9,7 +9,7 @@ export type CanvasAssetRepairResult = {
updatedProjects: number;
};
export function repairMissingCanvasAssets(): CanvasAssetRepairResult {
export function repairMissingCanvasAssets(projectIds?: Set<string>, partialAssets = false): CanvasAssetRepairResult {
const assetStore = useAssetStore.getState();
const canvasStore = useCanvasStore.getState();
const assetIdByStorageKey = new Map<string, string>();
@@ -27,6 +27,21 @@ export function repairMissingCanvasAssets(): CanvasAssetRepairResult {
let createdAssets = 0;
let updatedProjects = 0;
for (const project of canvasStore.projects) {
if (projectIds && !projectIds.has(project.id)) continue;
if (partialAssets) {
for (const node of project.nodes) {
if (node.metadata?.assetId) {
knownAssetIds.add(node.metadata.assetId);
if (node.metadata.storageKey) storageKeyByAssetId.set(node.metadata.assetId, node.metadata.storageKey);
}
}
for (const clip of project.timeline?.clips || []) {
if (clip.directMedia?.assetId) {
knownAssetIds.add(clip.directMedia.assetId);
if (clip.directMedia.storageKey) storageKeyByAssetId.set(clip.directMedia.assetId, clip.directMedia.storageKey);
}
}
}
const repaired = repairProject(project, knownAssetIds, assetIdByStorageKey, storageKeyByAssetId, (node) => {
const input = canvasNodeToAsset(node, { canvasId: project.id, source: "canvas-upload" });
if (!input) return undefined;

View File

@@ -438,6 +438,12 @@ function rebaseCommittedCanvasGenerationOntoLiveProject(scope: string, projectId
export async function persistCanvasGenerationEffect(input: CanvasGenerationEffectInput) {
throwIfAborted(input.signal);
const scope = getActiveUserScope();
if (!useCanvasStore.getState().projects.some((project) => project.id === input.projectId)) {
const { loadCanvasProjectForEditing } = await import("@/services/user-data-sync");
await loadCanvasProjectForEditing(input.projectId);
throwIfAborted(input.signal);
if (scope !== getActiveUserScope()) throw new Error("账号已切换,无法写入生成结果");
}
const baseRevision = canvasStoreStorageRevision(scope);
const memoryProjects = useCanvasStore.getState().projects;
const memoryProject = memoryProjects.find((candidate) => candidate.id === input.projectId);

View File

@@ -1,5 +1,3 @@
import "@/lib/plugins/builtin";
import { getRegisteredPlugin } from "@/lib/plugins/plugin-registry";
import { pluginStorageFor } from "@/lib/plugins/plugin-storage";
import type { Asset } from "@/stores/use-asset-store";

View File

@@ -2,6 +2,7 @@ import { canvasNodeToAsset, declaredCanvasNodeAssetCategory, findCanvasNodeAsset
import { canvasVideoAssetPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { readImageMeta } from "@/lib/image-utils";
import { parseBackendGenerationResult, type BackendGenerationResult } from "@/services/api/generation-task";
import { ApiError } from "@/services/api/request";
import { linkProjectAsset, moveProjectAsset, updateProjectAssetCategory } from "@/services/api/projects";
import type { GenerationTask, GenerationTaskOutput } from "@/services/api/task-center";
import { getMediaBlob, resolveMediaUrl, setMediaBlob } from "@/services/file-storage";
@@ -41,6 +42,47 @@ export type CanvasNodeAssetResult = {
};
const pendingAssetSyncs = new Map<string, Promise<CanvasNodeAssetResult>>();
const DEFAULT_RATE_LIMIT_RETRY_MS = 60_000;
const MAX_RATE_LIMIT_RETRY_MS = 5 * 60_000;
type CanvasAssetSyncRetryOptions = {
signal?: AbortSignal;
maxRetries?: number;
wait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
};
function waitForCanvasAssetSyncRetry(delayMs: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException("The operation was aborted", "AbortError"));
return;
}
const timer = globalThis.setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, delayMs);
const onAbort = () => {
globalThis.clearTimeout(timer);
reject(new DOMException("The operation was aborted", "AbortError"));
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
export async function retryCanvasAssetSyncAfterRateLimit<T>(operation: () => Promise<T>, options: CanvasAssetSyncRetryOptions = {}): Promise<T> {
const maxRetries = Math.max(0, options.maxRetries ?? 2);
const wait = options.wait ?? waitForCanvasAssetSyncRetry;
for (let attempt = 0; ; attempt += 1) {
throwIfAborted(options.signal);
try {
return await operation();
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 429 || attempt >= maxRetries) throw error;
const delayMs = Math.min(MAX_RATE_LIMIT_RETRY_MS, Math.max(0, error.retryAfterMs ?? DEFAULT_RATE_LIMIT_RETRY_MS));
await wait(delayMs, options.signal);
}
}
}
export function ensureCanvasNodeAsset(options: EnsureCanvasNodeAssetOptions) {
const scope = getActiveUserScope();

View File

@@ -18,6 +18,7 @@ const metaStore = localforage.createInstance({ name: "infinite-canvas", storeNam
const objectUrls = new Map<string, string>();
const sessionBlobs = new Map<string, Blob>();
const inFlight = new Map<string, Promise<string>>();
const scheduled = new Set<string>();
const downloadQueue: Array<() => void> = [];
let activeDownloads = 0;
let persistQueue: Promise<void> = Promise.resolve();
@@ -47,6 +48,25 @@ export async function cacheResourceObjectUrl(storageKey: string) {
return task;
}
/**
* 播放器先使用支持 Range 的资源 URL 起播;确认用户实际播放后,再延迟下载完整 Blob。
* 这样不会让 IndexedDB 缓存阻塞首帧,同时后续打开可直接复用本地 Object URL。
*/
export function scheduleResourceBlobCache(storageKey: string, delayMs = 4_000) {
if (!resourceIdFromStorageKey(storageKey) || scheduled.has(storageKey)) return;
scheduled.add(storageKey);
const run = () => {
void cacheResourceObjectUrl(storageKey)
.catch(() => "")
.finally(() => scheduled.delete(storageKey));
};
if (typeof window === "undefined") {
run();
return;
}
window.setTimeout(run, Math.max(0, delayMs));
}
function withDownloadSlot<T>(task: () => Promise<T>) {
return new Promise<T>((resolve, reject) => {
downloadQueue.push(() => {

View File

@@ -1,6 +1,7 @@
import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteUserDataSnapshot, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
import { appQueryClient } from "@/lib/query-client";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { assetForRemoteSync } from "@/lib/asset-remote-sync";
import type { Asset } from "@/stores/use-asset-store";
@@ -22,12 +23,100 @@ let remoteOperationTail: Promise<void> = Promise.resolve();
let subscriptionsInstalled = false;
let acknowledgedAssets = new Map<string, Asset>();
let acknowledgedProjects = new Map<string, CanvasProject>();
let incrementalSession = false;
let sessionEpoch = 0;
const verifiedProjects = new Set<string>();
const verifiedAssets = new Set<string>();
export async function initializeRemoteUserDataSession(userId: string) {
await withRemoteUserDataSyncExclusive(async () => {
resetRemoteUserDataSync();
activeRemoteUserId = userId;
incrementalSession = true;
acknowledgedProjects = new Map(useCanvasStore.getState().projects.map((project) => [project.id, project]));
acknowledgedAssets = new Map(useAssetStore.getState().assets.map((asset) => [asset.id, asset]));
remoteUserDataPhase = "ready";
});
}
export async function loadCanvasProjectForEditing(id: string) {
const epoch = sessionEpoch;
return withRemoteUserDataSyncExclusive(async () => {
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
const local = useCanvasStore.getState().projects.find((project) => project.id === id);
if (!activeRemoteUserId || verifiedProjects.has(id)) return local;
const { project } = await getRemoteCanvasProject(id);
await loadReferencedAssets(collectAssetIds(project));
const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id);
if (current && !sameEntitySnapshot(acknowledgedProjects.get(id), current)) {
const baseline = acknowledgedProjects.get(id);
if (baseline && Date.parse(baseline.updatedAt) !== Date.parse(project.updatedAt)) throw new Error("画布已在其他端修改,请先导出本地修改再重新加载");
verifiedProjects.add(id);
return current;
}
acknowledgedProjects.set(id, project);
verifiedProjects.add(id);
useCanvasStore.setState((state) => ({ projects: [...state.projects.filter((candidate) => candidate.id !== id), project] }));
return project;
});
}
export async function loadAssetLibraryPage(options: Parameters<typeof listRemoteAssetsPage>[0]) {
const epoch = sessionEpoch;
const result = await listRemoteAssetsPage(options);
await withRemoteUserDataSyncExclusive(async () => {
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新读取素材");
acceptRemoteAssets(result.assets);
});
return result;
}
function acceptRemoteAssets(assets: Asset[]) {
const current = new Map(useAssetStore.getState().assets.map((asset) => [asset.id, asset]));
for (const asset of assets) {
const local = current.get(asset.id);
if (local && !sameEntitySnapshot(acknowledgedAssets.get(asset.id), local)) continue;
acknowledgedAssets.set(asset.id, asset);
verifiedAssets.add(asset.id);
current.set(asset.id, asset);
}
useAssetStore.setState({ assets: [...current.values()] });
}
function collectAssetIds(value: unknown, ids = new Set<string>()): Set<string> {
if (!value || typeof value !== "object") return ids;
for (const [key, child] of Object.entries(value)) {
if (key === "assetId" && typeof child === "string" && child) ids.add(child);
else if (child && typeof child === "object") collectAssetIds(child, ids);
}
return ids;
}
async function loadReferencedAssets(ids: Iterable<string>) {
const pending = [...new Set(ids)].filter((id) => !verifiedAssets.has(id));
for (let offset = 0; offset < pending.length; offset += 100) {
const { assets } = await getRemoteAssetsByIds(pending.slice(offset, offset + 100));
acceptRemoteAssets(assets);
}
}
export async function loadAssetsForUse(ids: Iterable<string>) {
const epoch = sessionEpoch;
const requestedIds = [...new Set(ids)];
await withRemoteUserDataSyncExclusive(async () => {
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新读取素材");
if (activeRemoteUserId) await loadReferencedAssets(requestedIds);
const available = new Set(useAssetStore.getState().assets.map((asset) => asset.id));
if (requestedIds.some((id) => !available.has(id) || (activeRemoteUserId && !verifiedAssets.has(id)))) throw new Error("部分素材不存在或无权访问,请重新选择素材");
});
}
const LOCAL_STORAGE_KEY_PATTERN = /^(image|video|audio|file|video-reference|audio-reference):/;
export async function syncRemoteUserData(userId?: string | null) {
let repairedCanvasAssets = false;
await withRemoteUserDataSyncExclusive(async () => {
incrementalSession = false;
activeRemoteUserId = userId || "";
acknowledgedProjects.clear();
acknowledgedAssets.clear();
@@ -70,6 +159,10 @@ export function installRemoteUserDataAutoSync() {
}
export function resetRemoteUserDataSync() {
sessionEpoch += 1;
incrementalSession = false;
verifiedProjects.clear();
verifiedAssets.clear();
activeRemoteUserId = "";
remoteUserDataPhase = "inactive";
acknowledgedAssets.clear();
@@ -122,9 +215,11 @@ export async function createCanvasProjectWithRemoteSync(title: string, projectId
}
export async function deleteAssetWithRemoteSync(id: string) {
const epoch = sessionEpoch;
const assetId = id.trim();
if (!assetId) throw new Error("素材 ID 不能为空");
await withRemoteUserDataSyncExclusive(async () => {
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新选择要删除的素材");
if (activeRemoteUserId) {
requireRemoteUserDataBaseline();
await deleteRemoteAsset(assetId);
@@ -136,9 +231,14 @@ export async function deleteAssetWithRemoteSync(id: string) {
}
export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
const epoch = sessionEpoch;
const projectIds = [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
if (!projectIds.length) return;
if (incrementalSession) {
for (const id of projectIds) await loadCanvasProjectForEditing(id);
}
await withRemoteUserDataSyncExclusive(async () => {
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新选择要删除的画布");
if (activeRemoteUserId) requireRemoteUserDataBaseline();
const currentProjects = useCanvasStore.getState().projects;
const projectById = new Map(currentProjects.map((project) => [project.id, project]));
@@ -164,6 +264,12 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
}
if (deletedProjectObjects.length > 0) useCanvasHistoryStore.getState().recordDeletedProjects(deletedProjectObjects);
if (incrementalSession) {
void appQueryClient.invalidateQueries({ queryKey: ["canvas-library"] });
if (deletionError) throw deletionError;
return;
}
// 将属于被删除画布的所有媒体节点安全归档至素材库回收站 (status = "archived")
const currentAssets = useAssetStore.getState().assets;
const remainingProjects = useCanvasStore.getState().projects;
@@ -296,6 +402,7 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
}
export async function saveRemoteUserDataNow() {
const epoch = sessionEpoch;
if (!activeRemoteUserId) return;
requireRemoteUserDataBaseline();
if (syncPromise) {
@@ -303,6 +410,7 @@ export async function saveRemoteUserDataNow() {
return syncPromise;
}
syncPromise = withRemoteUserDataSyncExclusive(async () => {
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
requireRemoteUserDataBaseline();
await drainRemoteUserDataChanges();
});
@@ -324,16 +432,30 @@ async function drainRemoteUserDataChanges() {
async function saveRemoteUserDataBatch(uploaded: Map<string, string>) {
// 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。
// 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。
repairMissingCanvasAssets();
const changedProjectIds = new Set(useCanvasStore.getState().projects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project)).map((project) => project.id));
repairMissingCanvasAssets(incrementalSession ? changedProjectIds : undefined, incrementalSession);
const currentProjects = useCanvasStore.getState().projects;
const currentAssets = useAssetStore.getState().assets;
const dirtyProjects = currentProjects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project));
const dirtyAssets = currentAssets.filter((asset) => !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset));
const currentProjectIds = new Set(currentProjects.map((project) => project.id));
const currentAssetIds = new Set(currentAssets.map((asset) => asset.id));
const deletedProjectIds = [...acknowledgedProjects.keys()].filter((id) => !currentProjectIds.has(id));
const deletedAssetIds = [...acknowledgedAssets.keys()].filter((id) => !currentAssetIds.has(id));
if (!dirtyProjects.length && !dirtyAssets.length && !deletedProjectIds.length && !deletedAssetIds.length) return;
if (!dirtyProjects.length && !dirtyAssets.length) return;
if (incrementalSession) {
for (const source of dirtyProjects) {
const baseline = acknowledgedProjects.get(source.id);
if (!baseline || verifiedProjects.has(source.id)) continue;
const { project } = await getRemoteCanvasProject(source.id);
if (Date.parse(project.updatedAt) !== Date.parse(baseline.updatedAt)) throw new Error("画布远端版本已变化,已停止覆盖,请重新打开画布");
verifiedProjects.add(source.id);
}
for (const source of dirtyAssets) {
const baseline = acknowledgedAssets.get(source.id);
if (!baseline || verifiedAssets.has(source.id)) continue;
const { asset } = await getRemoteAsset(source.id);
if (Date.parse(asset.updatedAt) !== Date.parse(baseline.updatedAt)) throw new Error("素材远端版本已变化,已停止覆盖,请重新打开素材库");
verifiedAssets.add(source.id);
}
}
// 转换后的 resource: 引用只属于发往服务端的 payload,不能反写整份实时 store。
// 已确认快照记录的是本次上传所依据的本地实体;上传期间的新编辑会在下一轮继续提交。
@@ -343,6 +465,7 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>) {
const remotePayload = await ensureRemoteResourceReferences(assetForRemoteSync(source), uploaded);
await upsertRemoteAsset(remotePayload);
acknowledgedAssets.set(source.id, source);
verifiedAssets.add(source.id);
}
for (const source of dirtyProjects) {
const keysToUpload = collectLocalMediaKeys(source);
@@ -371,6 +494,7 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>) {
}
await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload));
acknowledgedProjects.set(source.id, source);
verifiedProjects.add(source.id);
if (total > 0) useSyncProgressStore.getState().setProjectProgress(source.id, null);
} catch (error) {
if (total > 0) {
@@ -382,14 +506,7 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>) {
throw error;
}
}
for (const id of deletedProjectIds) {
await deleteRemoteCanvasProject(id);
acknowledgedProjects.delete(id);
}
for (const id of deletedAssetIds) {
await deleteRemoteAsset(id);
acknowledgedAssets.delete(id);
}
if (dirtyProjects.length) void appQueryClient.invalidateQueries({ queryKey: ["canvas-library"] });
}
function collectLocalMediaKeys(value: unknown, set = new Set<string>()): string[] {