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

This commit is contained in:
ddCat
2026-09-05 13:24:10 +08:00
parent ca7ad1f9b2
commit 711d1b79eb
107 changed files with 3025 additions and 1089 deletions

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[] {