diff --git a/web/src/pages/canvas/use-canvas-generation.ts b/web/src/pages/canvas/use-canvas-generation.ts index 0bd146b0d..515abe920 100644 --- a/web/src/pages/canvas/use-canvas-generation.ts +++ b/web/src/pages/canvas/use-canvas-generation.ts @@ -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, diff --git a/web/src/router.tsx b/web/src/router.tsx index 90660bde9..3bd0395fc 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -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 {fullScreenDeferred()}; + const { pathname } = useLocation(); + const isCanvasProjectRoute = pathname.startsWith("/canvas/"); + const fallback = isCanvasProjectRoute ? : ; + return ; } /** @@ -177,7 +181,7 @@ export const router = createBrowserRouter([ ), }, { path: "/canvas", element: {deferred()} }, - { path: "/canvas/:id", element: {deferred()} }, + { path: "/canvas/:id", element: }, { path: "/admin", element: {deferred()}, diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index 5d7821eee..9b03fc3b2 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -29,6 +29,7 @@ let incrementalSession = false; let sessionEpoch = 0; const verifiedProjects = new Set(); const verifiedAssets = new Set(); +const remoteProjectLoadPromises = new Map>(); 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[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) { 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) { diff --git a/web/test/user-scoped-generation-persistence.test.ts b/web/test/user-scoped-generation-persistence.test.ts index 384b3ac7f..7224acca8 100644 --- a/web/test/user-scoped-generation-persistence.test.ts +++ b/web/test/user-scoped-generation-persistence.test.ts @@ -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;