perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突
This commit is contained in:
@@ -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