perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export type GenerationTask = {
|
||||
receiptRecorded?: boolean;
|
||||
previewUrl?: string;
|
||||
previewKind?: "image" | "video";
|
||||
previewPosterUrl?: string;
|
||||
inputJson?: string;
|
||||
resultJson?: string;
|
||||
resultState?: GenerationTaskResultState;
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user