fix(canvas): 画布同步 - 避免版本冲突循环并合并重复提示

This commit is contained in:
ddCat
2026-09-12 00:08:57 +08:00
parent 96e0051a5c
commit ab58892586
4 changed files with 112 additions and 9 deletions

View File

@@ -521,10 +521,14 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
}).catch((error) => {
autoSavedTaskIdsRef.current.delete(saveKey);
if (error instanceof Error && error.name === "AbortError") return;
message.warning(error instanceof Error ? `生成结果已保留,但项目资产同步失败:${error.message}` : "生成结果已保留,但项目资产同步失败");
message.warning({
key: `canvas-asset-sync:${projectId}`,
content: error instanceof Error ? `生成结果已保留,但项目资产同步失败:${error.message}` : "生成结果已保留,但项目资产同步失败",
duration: 4,
});
});
});
}, [domainProjectId, message, nodes, projectLoaded, saveGeneratedAsset]);
}, [domainProjectId, message, nodes, projectId, projectLoaded, saveGeneratedAsset]);
return {
applyGenerationTaskResult,

View File

@@ -1,9 +1,10 @@
import { lazy, Suspense, type ReactNode } from "react";
import { createBrowserRouter, Navigate, Outlet } from "react-router";
import { createBrowserRouter, Navigate, Outlet, useLocation } from "react-router";
import { RequireAuth } from "@/components/auth/require-auth";
import { FullScreenLoader, WorkspaceRouteLoader } from "@/components/ui/aceternity/full-screen-loader";
import { loadAssetsPage, loadCanvasPage, loadCanvasProjectPage, loadCreatePage, loadProjectDetailPage, loadProjectsPage, loadWalletPage } from "@/lib/workspace-route-modules";
import { CanvasRefreshShell } from "@/pages/canvas/canvas-refresh-shell";
import { AuthScene } from "@/pages/auth/auth-scene";
import RouteErrorPage from "@/pages/route-error";
@@ -62,7 +63,10 @@ function fullScreenDeferred(element: ReactNode) {
}
function AuthenticatedWorkspaceLayout() {
return <RequireAuth>{fullScreenDeferred(<UserLayout><Outlet /></UserLayout>)}</RequireAuth>;
const { pathname } = useLocation();
const isCanvasProjectRoute = pathname.startsWith("/canvas/");
const fallback = isCanvasProjectRoute ? <CanvasRefreshShell /> : <FullScreenLoader label="正在打开创作空间" detail="准备当前页面" />;
return <RequireAuth><Suspense fallback={fallback}><UserLayout><Outlet /></UserLayout></Suspense></RequireAuth>;
}
/**
@@ -177,7 +181,7 @@ export const router = createBrowserRouter([
),
},
{ path: "/canvas", element: <RequireAuth>{deferred(<CanvasPage />)}</RequireAuth> },
{ path: "/canvas/:id", element: <RequireAuth>{deferred(<CanvasProjectPage />)}</RequireAuth> },
{ path: "/canvas/:id", element: <RequireAuth><CanvasProjectPage /></RequireAuth> },
{
path: "/admin",
element: <RequireAuth>{deferred(<AdminPage />)}</RequireAuth>,

View File

@@ -29,6 +29,7 @@ let incrementalSession = false;
let sessionEpoch = 0;
const verifiedProjects = new Set<string>();
const verifiedAssets = new Set<string>();
const remoteProjectLoadPromises = new Map<string, Promise<CanvasProject | undefined>>();
export async function initializeRemoteUserDataSession(userId: string) {
await withRemoteUserDataSyncExclusive(async () => {
@@ -42,8 +43,10 @@ export async function initializeRemoteUserDataSession(userId: string) {
}
export async function loadCanvasProjectForEditing(id: string) {
const pending = remoteProjectLoadPromises.get(id);
if (pending) return pending;
const epoch = sessionEpoch;
return withRemoteUserDataSyncExclusive(async () => {
const request = 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;
@@ -51,8 +54,10 @@ export async function loadCanvasProjectForEditing(id: string) {
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("画布已在其他端修改,请先导出本地修改再重新加载");
// 当前页面已经开始编辑本地缓存时,远端详情只建立新的冲突基线;
// 保留当前画布内容,后续保存由当前打开的画布显式覆盖远端版本,
// 避免旧缓存被永久卡在“自动重试但永远冲突”的状态。
acknowledgedProjects.set(id, project);
verifiedProjects.add(id);
return current;
}
@@ -61,6 +66,17 @@ export async function loadCanvasProjectForEditing(id: string) {
useCanvasStore.setState((state) => ({ projects: [...state.projects.filter((candidate) => candidate.id !== id), project] }));
return project;
});
remoteProjectLoadPromises.set(id, request);
const clearPending = () => {
if (remoteProjectLoadPromises.get(id) === request) remoteProjectLoadPromises.delete(id);
};
void request.then(clearPending, clearPending);
return request;
}
async function waitForRemoteProjectLoads() {
const pending = [...remoteProjectLoadPromises.values()];
if (pending.length) await Promise.all(pending);
}
export async function loadAssetLibraryPage(options: Parameters<typeof listRemoteAssetsPage>[0]) {
@@ -169,6 +185,7 @@ export function resetRemoteUserDataSync() {
incrementalSession = false;
verifiedProjects.clear();
verifiedAssets.clear();
remoteProjectLoadPromises.clear();
activeRemoteUserId = "";
remoteUserDataPhase = "inactive";
acknowledgedAssets.clear();
@@ -376,6 +393,8 @@ export async function saveRemoteUserDataNow() {
const epoch = sessionEpoch;
if (!activeRemoteUserId) return;
requireRemoteUserDataBaseline();
// 画布先用本地缓存秒开时,远端详情校验可能仍在进行;写入必须等待校验结果。
await waitForRemoteProjectLoads();
if (syncPromise) {
syncQueued = true;
return syncPromise;
@@ -416,7 +435,11 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>) {
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("画布远端版本已变化,已停止覆盖,请重新打开画布");
if (Date.parse(project.updatedAt) !== Date.parse(baseline.updatedAt)) {
// 以远端当前版本作为新的校验基线,继续提交当前打开画布的完整快照。
// 这是画布编辑态的显式覆盖策略,避免资产同步被旧缓存冲突永久阻塞。
acknowledgedProjects.set(source.id, project);
}
verifiedProjects.add(source.id);
}
for (const source of dirtyAssets) {

View File

@@ -4451,6 +4451,78 @@ test("incremental sessions leave cached entities untouched and fetch only the op
}
});
test("concurrent canvas opens share one remote detail request", async () => {
const originalWindow = (globalThis as { window?: unknown }).window;
const previousAdapter = apiClient.defaults.adapter;
const previousProjects = useCanvasStore.getState().projects;
const requests: string[] = [];
const project = storedCanvasProject("concurrent-open", "Concurrent");
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { setTimeout: () => 1, clearTimeout: () => undefined, localStorage: { getItem: () => null, setItem: () => undefined, removeItem: () => undefined } },
});
apiClient.defaults.adapter = async (config) => {
requests.push(`${config.method} ${config.url}`);
throw new Error("detail unavailable");
};
try {
resetRemoteUserDataSync();
useCanvasStore.setState({ projects: [project] });
await initializeRemoteUserDataSession("concurrent-open-owner");
const first = loadCanvasProjectForEditing(project.id).catch((error: unknown) => error);
const second = loadCanvasProjectForEditing(project.id).catch((error: unknown) => error);
const firstError = await first;
const secondError = await second;
expect(firstError).toBeInstanceOf(Error);
expect(secondError).toBeInstanceOf(Error);
expect((firstError as Error).message).toContain("detail unavailable");
expect((secondError as Error).message).toContain("detail unavailable");
expect(requests).toEqual(["get /canvas-projects/concurrent-open"]);
} finally {
resetRemoteUserDataSync();
useCanvasStore.setState({ projects: previousProjects });
apiClient.defaults.adapter = previousAdapter;
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
}
});
test("remote canvas conflict refreshes the baseline and overwrites once without repeating detail reads", async () => {
const originalWindow = (globalThis as { window?: unknown }).window;
const previousAdapter = apiClient.defaults.adapter;
const previousProjects = useCanvasStore.getState().projects;
const requests: string[] = [];
const cached = storedCanvasProject("latched-conflict", "Cached");
const remote = { ...cached, updatedAt: "2026-09-11T00:00:00.000Z" };
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { setTimeout: () => 1, clearTimeout: () => undefined, localStorage: { getItem: () => null, setItem: () => undefined, removeItem: () => undefined } },
});
apiClient.defaults.adapter = async (config) => {
requests.push(`${config.method} ${config.url}`);
const project = config.method === "put" ? { ...remote, ...cached, title: "Edited locally" } : remote;
return { data: { code: 0, data: { project }, msg: "" }, status: 200, statusText: "OK", headers: {}, config };
};
try {
resetRemoteUserDataSync();
useCanvasStore.setState({ projects: [cached] });
await initializeRemoteUserDataSession("latched-conflict-owner");
useCanvasStore.getState().renameProject(cached.id, "Edited locally");
await expect(saveRemoteUserDataNow()).resolves.toBeUndefined();
await expect(saveRemoteUserDataNow()).resolves.toBeUndefined();
expect(requests).toEqual([
"get /canvas-projects/latched-conflict",
"put /canvas-projects/latched-conflict",
]);
} finally {
resetRemoteUserDataSync();
useCanvasStore.setState({ projects: previousProjects });
apiClient.defaults.adapter = previousAdapter;
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
}
});
test("a remote asset page rejects incomplete records instead of patching them into the store", async () => {
const previousAdapter = apiClient.defaults.adapter;
const previousAssets = useAssetStore.getState().assets;