From 4a522bf42aff8daa00c4ecc8302923b81ac6a21d Mon Sep 17 00:00:00 2001 From: Huarch Date: Fri, 16 Oct 2026 18:13:04 +0800 Subject: [PATCH] feat(frontend): cache 3D assets and restore loading feedback Persist versioned models and validated network metadata in IndexedDB. Show scene download progress and recoverable errors with the existing frosted material. Connect existing skeletons to pending navigation and route boundaries while preserving the map. Previously only dynamic imports showed fallbacks, and rejected model promises prevented retries. Verified: 32 focused unit tests; 4 navigation and scene browser checks; 7 cache browser checks including invalid-metadata recovery; isolated production build and type checks. --- e2e/page-navigation.spec.ts | 96 ++++++++++ e2e/three-dimensional-camera.spec.ts | 35 ++++ e2e/three-dimensional-loading.spec.ts | 165 ++++++++++++++++++ .../zjb/v29/camera-navigation.mjs | 2 +- .../three-dimensional/zjb/v29/integration.mjs | 9 + .../zjb/v29/loadingProgress.mjs | 60 +++++++ .../three-dimensional/zjb/v29/manifest.json | 1 + public/three-dimensional/zjb/v29/preview.html | 30 +++- public/three-dimensional/zjb/v29/preview.mjs | 36 +++- .../zjb/v29/resourceCache.mjs | 114 ++++++++++++ src/app/(main)/(map)/layout.tsx | 7 +- src/app/(main)/(map)/loading.tsx | 11 ++ src/app/(main)/layout.tsx | 42 +++-- src/app/(main)/loading.tsx | 5 + .../(main)/three-dimensional-scene/page.tsx | 8 +- src/app/RefineContext.tsx | 5 +- .../loading/NavigationFeedback.test.tsx | 78 +++++++++ src/components/loading/NavigationFeedback.tsx | 98 +++++++++++ src/components/loading/NavigationLink.tsx | 33 ++++ .../loading/PageLoadingSkeleton.tsx | 110 ++++++++++++ .../ThreeDimensionalScene.tsx | 44 +++-- .../threeDimensional/sceneAssets.test.ts | 7 +- .../threeDimensional/sceneProtocol.ts | 1 + 23 files changed, 938 insertions(+), 59 deletions(-) create mode 100644 e2e/page-navigation.spec.ts create mode 100644 e2e/three-dimensional-loading.spec.ts create mode 100644 public/three-dimensional/zjb/v29/loadingProgress.mjs create mode 100644 public/three-dimensional/zjb/v29/resourceCache.mjs create mode 100644 src/app/(main)/(map)/loading.tsx create mode 100644 src/app/(main)/loading.tsx create mode 100644 src/components/loading/NavigationFeedback.test.tsx create mode 100644 src/components/loading/NavigationFeedback.tsx create mode 100644 src/components/loading/NavigationLink.tsx create mode 100644 src/components/loading/PageLoadingSkeleton.tsx diff --git a/e2e/page-navigation.spec.ts b/e2e/page-navigation.spec.ts new file mode 100644 index 0000000..dda1590 --- /dev/null +++ b/e2e/page-navigation.spec.ts @@ -0,0 +1,96 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { mockBackend } from "./support/mockBackend"; + +test.setTimeout(90_000); + +async function prepare(page: Page) { + await mockBackend(page); + await page.route("**/api/v1/admin/**", (route) => route.fulfill({ + json: route.request().url().endsWith("/users/me") + ? { id: "playwright-user", is_system_admin: true, system_role: "system_admin" } + : [], + })); +} + +test("菜单点击后立即反馈,等待页面响应时显示路由骨架", async ({ page }) => { + await prepare(page); + await page.goto("/system-admin"); + await expect(page.getByRole("link", { name: "审计日志", exact: true })).toBeVisible(); + await page.locator("[data-page-scroll-content]").evaluate((element) => { element.scrollTop = element.scrollHeight; }); + let release: () => void = () => {}; + const gate = new Promise((resolve) => { release = resolve; }); + let waiting = false; + await page.route("**/audit-logs?*", async (route) => { + if (route.request().headers().rsc !== "1") return route.fallback(); + waiting = true; + await gate; + await route.continue(); + }); + try { + await page.getByRole("link", { name: "审计日志", exact: true }).click(); + await expect.poll(() => waiting).toBe(true); + await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toBeVisible({ timeout: 1500 }); + await expect(page.getByRole("status", { name: "正在加载审计日志", exact: true })).toBeVisible(); + } finally { release(); } + await expect(page.getByRole("heading", { name: "审计日志", exact: true })).toBeVisible(); + await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toHaveCount(0); +}); + +test("地图页面切换复用原地图,并显示目标面板的既有骨架", async ({ page }) => { + await prepare(page); + await page.route("**/ows?**", (route) => route.fulfill({ json: { type: "FeatureCollection", features: [] } })); + await page.route("**/api/v1/scada-devices", (route) => route.fulfill({ json: [] })); + await page.goto("/network-simulation"); + const map = page.locator(".ol-viewport").first(); + await expect(map).toBeVisible({ timeout: 60_000 }); + await map.evaluate((element) => element.setAttribute("data-navigation-map", "original")); + let release: () => void = () => {}; + const gate = new Promise((resolve) => { release = resolve; }); + await page.route("**/scada-data-cleaning?*", async (route) => { + if (route.request().headers().rsc !== "1") return route.fallback(); + await gate; + await route.continue(); + }); + try { + await page.getByRole("link", { name: "SCADA 数据清洗", exact: true }).click(); + await expect(page.getByRole("status", { name: "正在加载数据清洗", exact: true })).toBeVisible(); + await expect(page.locator('[data-navigation-map="original"]')).toBeVisible(); + await expect(page.locator("[data-map-page-content]")).toHaveCSS("display", "none"); + await page.screenshot({ path: "test-results/navigation-map-skeleton.png" }); + } finally { release(); } + await expect(page).toHaveURL(/\/scada-data-cleaning$/); + await expect(page.locator("[data-map-page-content]")).toHaveCSS("display", "contents"); + await expect(page.locator('[data-navigation-map="original"]')).toBeVisible(); + await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toHaveCount(0); + await page.goBack(); + await expect(page).toHaveURL(/\/network-simulation$/); + await expect(page.locator('[data-navigation-map="original"]')).toBeVisible(); + await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toHaveCount(0); +}); + +test("移动端收起菜单后仍显示页面切换反馈,窄屏无溢出", async ({ page }) => { + await prepare(page); + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/system-admin"); + await page.getByRole("button", { name: "open drawer", exact: true }).click(); + let release: () => void = () => {}; + const gate = new Promise((resolve) => { release = resolve; }); + await page.route("**/audit-logs?*", async (route) => { + if (route.request().headers().rsc !== "1") return route.fallback(); + await gate; + await route.continue(); + }); + try { + await page.getByRole("link", { name: "审计日志", exact: true }).click(); + for (const width of [375, 320]) { + await page.setViewportSize({ width, height: 812 }); + await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toBeVisible(); + await expect(page.getByRole("status", { name: "正在加载审计日志", exact: true })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: `test-results/navigation-mobile-${width}.png` }); + } + } finally { release(); } + await expect(page.getByRole("heading", { name: "审计日志", exact: true })).toBeVisible(); + await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toHaveCount(0); +}); diff --git a/e2e/three-dimensional-camera.spec.ts b/e2e/three-dimensional-camera.spec.ts index da86b5a..3b1afeb 100644 --- a/e2e/three-dimensional-camera.spec.ts +++ b/e2e/three-dimensional-camera.spec.ts @@ -4,6 +4,41 @@ import { mockBackend } from "./support/mockBackend"; test.setTimeout(180_000); +test("首次加载和失败提示位于工具面板上方,窄屏可直接重试", async ({ page }) => { + await mockBackend(page); + await page.addInitScript(() => { + localStorage.setItem("active_project", "playwright-project"); + localStorage.setItem("MAP_WORKSPACE", "zjb"); + localStorage.setItem("NETWORK_NAME", "zjb"); + }); + let release: () => void = () => {}; + const pending = new Promise((resolve) => { release = resolve; }); + const failedModel = "**/models/detail_roof.glb*"; + await page.route(failedModel, async (route) => { await pending; await route.abort("failed"); }); + await page.goto("/three-dimensional-scene"); + const scene = page.frameLocator('iframe[title="高铁湛江北站供水系统三维场景"]'); + await expect(scene.locator("#boot-title")).toHaveText("正在载入场景资源 9 / 10", { timeout: 60_000 }); + const loadingMaterial = await scene.locator(".boot-panel").evaluate((element) => ({ + background: getComputedStyle(element).backgroundImage, + blur: getComputedStyle(element).backdropFilter, + })); + for (const width of [1280, 375, 320]) { + await page.setViewportSize({ width, height: 800 }); + expect(await page.evaluate(() => document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.tagName)).toBe("IFRAME"); + await page.screenshot({ path: `test-results/scene-host-loading-${width}.png` }); + } + release(); + await expect(scene.locator("#boot-title")).toHaveText("场景资源加载失败"); + await page.unroute(failedModel); + await scene.getByRole("button", { name: "重新载入场景" }).click(); + await expect(scene.locator("#boot")).toBeHidden({ timeout: 60_000 }); + await expect(page.getByRole("slider", { name: /建筑背景透明度/ })).toBeEnabled(); + expect(await page.getByLabel("三维场景控制面板").evaluate((element) => ({ + background: getComputedStyle(element).backgroundImage, + blur: getComputedStyle(element).backdropFilter, + }))).toEqual(loadingMaterial); +}); + type CameraProbe = { active: string | null; position: number[]; diff --git a/e2e/three-dimensional-loading.spec.ts b/e2e/three-dimensional-loading.spec.ts new file mode 100644 index 0000000..d0040c6 --- /dev/null +++ b/e2e/three-dimensional-loading.spec.ts @@ -0,0 +1,165 @@ +import { expect, test } from "@playwright/test"; + +const root = "/three-dimensional/zjb/v29/"; +const preview = root + "preview.html?host=platform2&v=35-resource-cache"; +const isAsset = (url: string) => /\.glb(?:\?|$)|water-network-v28\.json/.test(url); + +test.setTimeout(120_000); + +test("HTTP 200 的错误元数据不会进入缓存,服务器恢复后重试成功", async ({ page }) => { + let requests = 0; + await page.route("**/water-network-v28.json*", async (route) => { + requests++; + if (requests === 1) return route.fulfill({ json: { error: "temporary proxy failure" } }); + return route.continue(); + }); + await page.goto(preview); + await expect(page.locator("#boot")).toHaveAttribute("data-state", "error", { timeout: 60_000 }); + const cached = await page.evaluate((base) => new Promise((resolve, reject) => { + const request = indexedDB.open("tjwater-scene-assets-v1", 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const database = request.result; + const read = database.transaction("resources").objectStore("resources").get(new URL(base + "water-network-v28.json", location.origin).href); + read.onsuccess = () => { resolve(Boolean(read.result)); database.close(); }; + read.onerror = () => { reject(read.error); database.close(); }; + }; + }), root); + expect(cached).toBe(false); + await page.getByRole("button", { name: "重新载入场景" }).click(); + await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 }); + expect(requests).toBe(2); +}); + +test("模型和元数据跨页面复用持久缓存,即使服务器资源不可用也能重开场景", async ({ page, context }) => { + const requests: string[] = []; + let block = false; + // Routing disables HTTP cache, so this checks IndexedDB rather than browser HTTP caching. + await context.route("**/three-dimensional/**", (route) => { + if (isAsset(route.request().url())) { + requests.push(route.request().url()); + if (block) return route.abort("failed"); + } + return route.continue(); + }); + const coldStart = Date.now(); + await page.goto(preview); + await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 }); + const coldMs = Date.now() - coldStart; + expect(requests).toHaveLength(10); + requests.length = 0; + block = true; + await page.close(); + const reopened = await context.newPage(); + const warmStart = Date.now(); + await reopened.goto(preview); + await expect(reopened.locator("#boot")).toBeHidden({ timeout: 60_000 }); + expect(await reopened.evaluate(() => + (window as typeof window & { webQA: { camera: { active: string } } }).webQA.camera.active, + )).toBe("overview"); + expect(requests).toEqual([]); + console.log(JSON.stringify({ coldMs, warmMs: Date.now() - warmStart, warmAssetRequests: requests.length })); +}); + +test("首次载入显示真实进度,失败保留提示,重试可以恢复", async ({ page }) => { + let release: () => void = () => {}; + const pending = new Promise((resolve) => { release = resolve; }); + const failedModel = "**/models/detail_roof.glb*"; + await page.route(failedModel, async (route) => { await pending; await route.abort("failed"); }); + await page.goto(preview); + await expect(page.locator("#boot-title")).toHaveText("正在载入场景资源 9 / 10", { timeout: 60_000 }); + await expect(page.getByRole("progressbar")).toHaveAttribute("value", "9"); + await expect(page.locator("#boot-detail")).toContainText(/已读取 \d+\.\d MB/); + await page.screenshot({ path: "test-results/scene-loading-desktop.png" }); + await page.setViewportSize({ width: 320, height: 640 }); + await page.screenshot({ path: "test-results/scene-loading-mobile.png" }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + release(); + await expect(page.locator("#boot-title")).toHaveText("场景资源加载失败"); + await expect(page.locator("#boot-detail")).toContainText("detail_roof.glb"); + await expect(page.locator("#boot")).toHaveAttribute("aria-busy", "false"); + await expect(page.getByRole("button", { name: "重新载入场景" })).toBeVisible(); + await page.screenshot({ path: "test-results/scene-loading-error.png" }); + await page.unroute(failedModel); + await page.getByRole("button", { name: "重新载入场景" }).click(); + await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 }); +}); + +test("同一资源合并并发请求,摘要改变后重新下载并覆盖旧缓存", async ({ page }) => { + let requests = 0; + await page.route("**/cache-probe.json*", (route) => { + requests++; + return route.fulfill({ json: { revision: new URL(route.request().url()).searchParams.get("v") } }); + }); + await page.goto(root + "manifest.json"); + const result = await page.evaluate(async (base) => { + const moduleUrl = base + "resourceCache.mjs"; + const { createSceneResourceCache } = await import(moduleUrl); + const cache = createSceneResourceCache(location.origin + base); + const decode = (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)); + const first = await Promise.all([ + cache.load("cache-probe.json", "first", decode), + cache.load("cache-probe.json", "first", decode), + ]); + const updated = await cache.load("cache-probe.json", "second", decode); + const freshClient = createSceneResourceCache(location.origin + base); + const cached = await freshClient.load("cache-probe.json", "second", decode); + return { first, updated, cached }; + }, root); + expect(requests).toBe(2); + expect(result.first).toEqual([{ revision: "first" }, { revision: "first" }]); + expect(result.updated).toEqual({ revision: "second" }); + expect(result.cached).toEqual({ revision: "second" }); +}); + +for (const failure of ["denied", "quota"] as const) { + test(`浏览器缓存${failure === "denied" ? "被禁用" : "空间不足"}时仍可下载并解析资源`, async ({ page }) => { + await page.goto(root + "manifest.json"); + await page.route("**/cache-probe.json*", (route) => route.fulfill({ json: { ok: true } })); + const result = await page.evaluate(async ({ base, failure }) => { + if (failure === "denied") { + Object.defineProperty(window, "indexedDB", { get() { throw new Error("denied"); } }); + } else { + const transaction = IDBDatabase.prototype.transaction; + IDBDatabase.prototype.transaction = function (...args: Parameters) { + if (args[1] === "readwrite") throw new DOMException("Full", "QuotaExceededError"); + return transaction.apply(this, args); + }; + } + const moduleUrl = base + "resourceCache.mjs"; + const { createSceneResourceCache } = await import(moduleUrl); + const cache = createSceneResourceCache(location.origin + base); + return cache.load("cache-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data))); + }, { base: root, failure }); + expect(result).toEqual({ ok: true }); + }); +} + +test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复", async ({ page }) => { + let requests = 0; + await page.route("**/cache-probe.json*", (route) => { + requests++; + return requests === 1 + ? route.fulfill({ status: 503, body: "unavailable" }) + : requests === 2 + ? route.fulfill({ body: "invalid json" }) + : route.fulfill({ json: { ok: true } }); + }); + await page.goto(root + "manifest.json"); + const result = await page.evaluate(async (base) => { + const moduleUrl = base + "resourceCache.mjs"; + const { createSceneResourceCache } = await import(moduleUrl); + const cache = createSceneResourceCache(location.origin + base); + const decode = (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)); + const errors: string[] = []; + for (let attempt = 0; attempt < 2; attempt++) { + try { await cache.load("cache-probe.json", "first", decode); } + catch (error) { errors.push(String(error)); } + } + return { errors, recovered: await cache.load("cache-probe.json", "first", decode) }; + }, root); + expect(requests).toBe(3); + expect(result.errors).toHaveLength(2); + expect(result.errors[0]).toContain("HTTP 503"); + expect(result.recovered).toEqual({ ok: true }); +}); diff --git a/public/three-dimensional/zjb/v29/camera-navigation.mjs b/public/three-dimensional/zjb/v29/camera-navigation.mjs index b76bf49..8ad0b76 100644 --- a/public/three-dimensional/zjb/v29/camera-navigation.mjs +++ b/public/three-dimensional/zjb/v29/camera-navigation.mjs @@ -76,7 +76,7 @@ export function createCameraNavigation({camera,controls,model,networkRoot,root,s flight={start:performance.now(),from:camera.position.clone(),targetFrom:controls.target.clone(),to:new THREE.Vector3(...pose.position),targetTo:new THREE.Vector3(...pose.target)}; if(matchMedia('(prefers-reduced-motion: reduce)').matches){camera.position.copy(flight.to);controls.target.copy(flight.targetTo);flight=null;controls.update();} emit(p.note??'已保存的相机位置'); - }catch(e){if(token===request)emit('视角加载失败:'+e.message);} + }catch(e){if(token===request)emit('视角加载失败:'+e.message);throw e;} } function tick(now){if(!flight)return;const t=Math.min(1,(now-flight.start)/500),v=t*t*(3-2*t);camera.position.lerpVectors(flight.from,flight.to,v);controls.target.lerpVectors(flight.targetFrom,flight.targetTo,v);if(t===1)flight=null;} function save(label){label=label.trim();if(!label||label.length>24)throw Error('请输入 1–24 字的视角名称。');if(saved.length>=8)throw Error('最多保存 8 个视角,请先移除不用的视角。');const p={id:'saved-'+Date.now(),label,mode:getMode(),displayMode:getDisplayMode(),position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray(),context:root.visible,roof:root.children.filter(o=>/roof|ceiling/.test(o.userData.resourceId)).every(o=>o.visible)};p.layers=Object.fromEntries(root.children.map(o=>[o.userData.resourceId,o.visible]));const next=[...saved,p];localStorage.setItem(key,JSON.stringify(next));saved=next;active=p.id;emit('视角已保存在本机浏览器');} diff --git a/public/three-dimensional/zjb/v29/integration.mjs b/public/three-dimensional/zjb/v29/integration.mjs index 28f83ae..b83dee9 100644 --- a/public/three-dimensional/zjb/v29/integration.mjs +++ b/public/three-dimensional/zjb/v29/integration.mjs @@ -1,5 +1,14 @@ // Framework-independent data adapter. Does not parse or modify the project's INP. export const CAD_ORIGIN=Object.freeze([513800,2344450]); +// This decoder runs before IDB writes, including when checking an existing entry. +// A parseable HTTP 200 error response must not become a permanent model cache. +export function parseNetworkMetadata(data,modelId){ + const model=JSON.parse(new TextDecoder().decode(data)); + const arrays=['nodes','links','pipeInstances','nodeInstances','bendInstances','cadEquipmentBindings','pumpEntityBindings','cadAttachments']; + const objects=['layouts','connectivity','coordination','coordinatedLayouts','verticalCoordination']; + if(!model||model.modelId!==modelId||!arrays.every(key=>Array.isArray(model[key]))||!objects.every(key=>model[key]&&typeof model[key]==='object'&&!Array.isArray(model[key]))||!model.nodes.length||!model.links.length)throw Error('管网元数据格式无效,请重新载入场景。'); + return model; +} export function cadToGltf(point,displayZ=0){ if(!Array.isArray(point)||point.length<2||!point.slice(0,2).every(Number.isFinite)||!Number.isFinite(displayZ))throw Error('Expected finite CAD XY and an explicit display height.'); return [point[0]-CAD_ORIGIN[0],displayZ,-(point[1]-CAD_ORIGIN[1])]; diff --git a/public/three-dimensional/zjb/v29/loadingProgress.mjs b/public/three-dimensional/zjb/v29/loadingProgress.mjs new file mode 100644 index 0000000..cda783d --- /dev/null +++ b/public/three-dimensional/zjb/v29/loadingProgress.mjs @@ -0,0 +1,60 @@ +const boot = document.getElementById('boot'); +const title = document.getElementById('boot-title'); +const detail = document.getElementById('boot-detail'); +const progress = document.getElementById('boot-progress'); +const retry = document.getElementById('boot-retry'); +const formatBytes = bytes => (bytes / 1024 / 1024).toFixed(1) + ' MB'; +let entries = new Map(); +let failed = false; +let lastPaint = 0; + +export function beginLoading(files, message = '正在载入三维场景') { + entries = new Map(files.map(file => [file, {stage:'waiting', loaded:0, total:0}])); + failed = false; + boot.hidden = false; + boot.dataset.state = 'loading'; + boot.setAttribute('aria-busy', 'true'); + title.textContent = message; + detail.textContent = '正在检查本地资源缓存'; + progress.hidden = false; + progress.removeAttribute('value'); + retry.hidden = true; + lastPaint = 0; +} + +export function reportProgress(update) { + if (failed || !entries.has(update.file)) return; + entries.set(update.file, update); + // Large streamed files can emit hundreds of chunks in a frame. + if (update.stage === 'download' && performance.now() - lastPaint < 100) return; + lastPaint = performance.now(); + const values = [...entries.values()]; + const done = values.filter(item => item.stage === 'ready').length; + const loaded = values.reduce((sum, item) => sum + item.loaded, 0); + const cached = values.filter(item => item.stage === 'cache').length; + const parsing = values.some(item => item.stage === 'parse'); + const downloading = values.some(item => item.stage === 'download'); + progress.max = Math.max(1, values.length); + progress.value = done; + title.textContent = `正在载入场景资源 ${done} / ${values.length}`; + detail.textContent = `${parsing ? '正在解析模型' : downloading ? '正在下载资源' : cached ? '正在读取本地缓存' : '正在准备场景'} · 已读取 ${formatBytes(loaded)}`; +} + +export function finishLoading() { + if (failed) return; + boot.setAttribute('aria-busy', 'false'); + boot.hidden = true; +} + +export function failLoading(error) { + failed = true; + boot.hidden = false; + boot.dataset.state = 'error'; + boot.setAttribute('aria-busy', 'false'); + title.textContent = '场景资源加载失败'; + detail.textContent = '部分资源未能载入,请检查网络后重试。' + (error?.message ? '(' + error.message + ')' : ''); + progress.hidden = true; + retry.hidden = false; +} + +retry.addEventListener('click', () => window.location.reload()); diff --git a/public/three-dimensional/zjb/v29/manifest.json b/public/three-dimensional/zjb/v29/manifest.json index 33046aa..4a14969 100644 --- a/public/three-dimensional/zjb/v29/manifest.json +++ b/public/three-dimensional/zjb/v29/manifest.json @@ -17094,6 +17094,7 @@ "id": "zjb-water-network-v23", "file": "models/water_network_v28.glb", "metadata": "water-network-v28.json", + "metadataSha256": "b9af91296f7f9269a51141b732ffb9fcd82906133eca75b96273a85f776c75f5", "sha256": "c3d16fd287a2e057649e3f25a939ff7edcc5f6b152c9dc09860bee7675912768", "runtimeInpRequired": false, "representationRevision": 28 diff --git a/public/three-dimensional/zjb/v29/preview.html b/public/three-dimensional/zjb/v29/preview.html index ae7304f..25b3050 100644 --- a/public/three-dimensional/zjb/v29/preview.html +++ b/public/three-dimensional/zjb/v29/preview.html @@ -8,18 +8,36 @@ html,body{width:100%;height:100%;margin:0;overflow:hidden;background:#e2e8e9} body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif} canvas{display:block;width:100%;height:100%;touch-action:none} - #boot{position:absolute;inset:0;z-index:2;display:grid;place-items:center;color:#526777;background:#e2e8e9;transition:opacity 180ms cubic-bezier(.16,1,.3,1)} + #boot{position:absolute;inset:0;z-index:2;display:grid;place-items:center;padding:16px;box-sizing:border-box;color:#334155;background:rgba(241,245,249,.38)} #boot[hidden]{display:none} - #boot span{display:flex;align-items:center;gap:10px;font-size:13px} - #boot i{width:18px;height:18px;border:2px solid rgba(37,125,212,.22);border-top-color:#257dd4;border-radius:50%;animation:spin .8s linear infinite} + .boot-panel{box-sizing:border-box;width:min(100%,360px);padding:24px;border-radius:16px;background:linear-gradient(135deg,rgba(255,255,255,.50),rgba(224,239,250,.28));backdrop-filter:blur(24px) saturate(170%) contrast(94%);-webkit-backdrop-filter:blur(24px) saturate(170%) contrast(94%);box-shadow:inset 0 1px 0 rgba(255,255,255,.88),inset 0 -1px 0 rgba(112,145,168,.18),0 18px 50px rgba(15,43,69,.20),0 0 0 1px rgba(255,255,255,.55)} + .boot-heading{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:600;color:#0f172a} + #boot i{flex:none;width:18px;height:18px;border:2px solid rgba(37,125,212,.22);border-top-color:#257dd4;border-radius:50%;animation:spin .8s linear infinite} + #boot-detail{margin:12px 0 0;font-size:12px;line-height:1.75;overflow-wrap:anywhere} + #boot-progress{display:block;width:100%;height:6px;margin-top:20px;accent-color:#2563eb} + #boot-progress[hidden],#boot-retry[hidden]{display:none} + #boot-retry{min-height:40px;margin-top:16px;padding:0 16px;border:0;border-radius:8px;background:#2563eb;color:white;font:inherit;font-size:13px;cursor:pointer;transition:transform 150ms ease,background-color 150ms ease} + #boot-retry:hover{background:#1d4ed8}#boot-retry:active{transform:scale(.96)}#boot-retry:focus-visible{outline:2px solid #2563eb;outline-offset:3px} + #boot[data-state=error] i{animation:none;border-color:#dc2626;background:#fee2e2} + #boot[data-state=error] #boot-title{color:#991b1b} @keyframes spin{to{transform:rotate(360deg)}} - @media(prefers-reduced-motion:reduce){#boot i{animation:none;border-top-color:rgba(37,125,212,.22)}} + @media(prefers-reduced-motion:reduce){#boot i{animation:none;border-top-color:rgba(37,125,212,.22)}#boot-retry{transition:none}} -
正在载入三维模型
+
+
+
正在准备三维场景
+

首次载入需要下载模型,后续将优先使用本地缓存。

+ + +
+
- + diff --git a/public/three-dimensional/zjb/v29/preview.mjs b/public/three-dimensional/zjb/v29/preview.mjs index b56d45d..cf0fbb8 100644 --- a/public/three-dimensional/zjb/v29/preview.mjs +++ b/public/three-dimensional/zjb/v29/preview.mjs @@ -1,18 +1,21 @@ import {createRenderEffects} from './render-effects.mjs'; -import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=31-camera-presets'; +import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=35-resource-cache'; +import {createSceneResourceCache} from './resourceCache.mjs?v=35-resource-cache'; +import {beginLoading,reportProgress,finishLoading,failLoading} from './loadingProgress.mjs?v=35-resource-cache'; import {createAssetInspector} from './asset-inspector.mjs?v=29-platform2'; import * as THREE from 'three'; import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js'; import {OrbitControls} from 'three/addons/controls/OrbitControls.js'; import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js'; import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=33-scada-markers'; -import {cadToGltf} from './integration.mjs'; +import {cadToGltf,parseNetworkMetadata} from './integration.mjs'; import {createAppearance,enhanceMaterials,grain,applyBuildingContext} from './appearance.mjs?v=30-context-opacity'; const HOST_CHANNEL='tjwater:zjb-scene',HOST_VERSION=2,PROJECT_CODE='zjb',MODEL_ID='zjb-water-network-v23'; let net,networkRoot,networkStyle,navigation,inspector; let mode='network',generation=0,contextVisible=true,roofVisible=true; let sceneStatus='正在载入三维模型'; +let sceneReady=false; let cameraState={active:null,note:'正在准备观察位置',views:[]}; let appearanceState={preset:'day',exposure:.95,contextOpacity:.4,shadows:true,effects:true,quality:'standard'}; @@ -41,7 +44,12 @@ window.webQA={errors:[],loaded:[],mode:'network',contextVisible}; window.addEventListener('error',event=>{const message=String(event.message||'三维场景运行错误');window.webQA.errors.push(message);postHost('error',{message});}); window.addEventListener('unhandledrejection',event=>{const message=String(event.reason||'三维场景异步任务失败');window.webQA.errors.push(message);postHost('error',{message});}); -const manifest=await (await fetch('./manifest.json',{cache:'no-store'})).json(); +const manifestResponse=await fetch('./manifest.json',{cache:'no-cache',signal:AbortSignal.timeout(30000)}); +if(!manifestResponse.ok)throw Error('场景清单加载失败:HTTP '+manifestResponse.status); +const manifest=await manifestResponse.json(); +const resourceCache=createSceneResourceCache(import.meta.url,reportProgress); +function startLoading(files){beginLoading(files);postHost('loading',{active:true});} +function completeLoading(){finishLoading();postHost('loading',{active:false});} const viewport=()=>({width:Math.max(240,innerWidth),height:Math.max(240,innerHeight)}); const initialViewport=viewport(); const renderer=new THREE.WebGLRenderer({antialias:true,logarithmicDepthBuffer:false}); @@ -52,6 +60,7 @@ const controls=new OrbitControls(camera,renderer.domElement); const appearance=createAppearance(renderer,scene); const effects=createRenderEffects(renderer,scene,camera,initialViewport.width,initialViewport.height); const loader=new GLTFLoader().setMeshoptDecoder(MeshoptDecoder); +const loadModel=asset=>resourceCache.load(asset.file,asset.sha256,data=>loader.parseAsync(data,new URL('./',new URL(asset.file,import.meta.url)).href)); const cache=new Map(),root=new THREE.Group();scene.add(root); function isBuildingResource(resourceId=''){ @@ -86,11 +95,11 @@ async function load(id){ if(!cache.has(id)){ const asset=manifest.assets.find(item=>item.id===id); if(!asset)throw Error('场景清单中不存在资源:'+id); - cache.set(id,loader.loadAsync(asset.file).then(gltf=>{ + cache.set(id,loadModel(asset).then(gltf=>{ enhanceMaterials(gltf.scene);gltf.scene.userData.assetId=asset.logicalId??asset.assetId;gltf.scene.userData.resourceId=id; gltf.scene.traverse(object=>{if(object.isMesh)for(const material of Array.isArray(object.material)?object.material:[object.material])material.userData.original={opacity:material.opacity,transparent:material.transparent,depthWrite:material.depthWrite};}); return gltf.scene; - })); + }).catch(error=>{cache.delete(id);throw error;})); } return cache.get(id); } @@ -100,8 +109,14 @@ async function show(next,{frame=true}={}){ if(next==='detail')ids=[...manifest.mapDefault.filter(id=>!['map_roof','map_facade'].includes(id)),'detail_roof','detail_facade','detail_interior','detail_ceiling']; if(next==='pump')ids=['detail_pump_walls','detail_pump_access','detail_pump_auxiliary','detail_pump_piping_reference',...manifest.assets.filter(asset=>asset.id.startsWith('physical_')).map(asset=>asset.id)]; if(next==='meters')ids=['meter_dn150','valve_dn250','pump_symbol']; + if(sceneReady)startLoading(ids.map(id=>manifest.assets.find(asset=>asset.id===id).file)); setStatus('正在载入 '+ids.length+' 组场景资源'); - const objects=await Promise.all(ids.map(load));if(token!==generation)return; + let objects; + try{objects=await Promise.all(ids.map(async id=>{ + const object=await load(id);const asset=manifest.assets.find(item=>item.id===id); + reportProgress({file:asset.file,stage:'ready',loaded:asset.bytes,total:asset.bytes});return object; + }));}catch(error){if(token===generation){setStatus('场景资源加载失败,请重试');failLoading(error);postHost('loading',{active:false,failed:true});}throw error;} + if(token!==generation)return; root.clear();objects.forEach((object,index)=>{ object.position.set(next==='meters'?index*1.5:0,0,0);object.visible=true;root.add(object); object.traverse(mesh=>{if(mesh.isMesh)for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])applyBuildingContext(material,['network','hydraulic'].includes(next),appearanceState.contextOpacity);}); @@ -110,6 +125,7 @@ async function show(next,{frame=true}={}){ networkStyle?.setFocusScope(next==='hydraulic');applyVisibility();inspector?.highlight();if(frame)fit(); window.webQA.loaded=ids;window.webQA.mode=next; setStatus(next==='pump'?'六泵实体与 CAD 管路参考,接口配准待核':next==='meters'?'设备样件,主管不包含在样件内':['network','hydraulic'].includes(next)?'管网实体模型,建筑为透明背景':'已载入 '+ids.length+' 组场景资源'); + completeLoading(); } function updateStyle(){appearance.refresh();inspector?.refresh();window.webQA.networkStyle={...networkStyle.summary,style:networkStyle.style};postState();} function setDisplayMode(value){networkStyle.setDisplayMode(value);navigation?.stop();updateStyle();} @@ -161,8 +177,9 @@ renderer.setAnimationLoop(()=>{ effects.render(mode);window.webQA.drawCalls=renderer.info.render.calls;window.webQA.triangles=renderer.info.render.triangles;window.webQA.camera={active:cameraState.active,position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray()};document.getElementById('qa').textContent=JSON.stringify(window.webQA); }); -net=await (await fetch(manifest.networkModel.metadata,{cache:'no-store'})).json(); -const built=await loader.loadAsync(manifest.networkModel.file+'?v='+manifest.networkModel.sha256);networkRoot=built.scene;enhanceMaterials(networkRoot);scene.add(networkRoot); +startLoading([manifest.networkModel.metadata,manifest.networkModel.file,...manifest.networkContext.map(id=>manifest.assets.find(asset=>asset.id===id).file)]); +net=await resourceCache.load(manifest.networkModel.metadata,manifest.networkModel.metadataSha256,data=>parseNetworkMetadata(data,manifest.networkModel.id)); +const built=await loadModel(manifest.networkModel);networkRoot=built.scene;enhanceMaterials(networkRoot);scene.add(networkRoot); networkStyle=attachNetworkStyle(networkRoot,net);grain(networkStyle.pipes.material,{frequency:140,amplitude:.00004,variation:.08}); try{const saved=localStorage.getItem('zjb-network-style-v24');if(saved)networkStyle.setStyle(JSON.parse(saved));}catch{} try{const quality=localStorage.getItem('zjb-render-quality-v27')??'standard';effects.setQuality(quality);appearanceState.quality=quality;}catch{effects.setQuality('standard');} @@ -204,5 +221,6 @@ window.addEventListener('message',async event=>{ }); await navigation.visit('overview'); -document.getElementById('boot').hidden=true; +sceneReady=true; +completeLoading(); postHost('ready',{nodeIds:net.nodes.map(node=>String(node.id)),linkIds:net.links.map(link=>String(link.id)),state:runtimeState()}); diff --git a/public/three-dimensional/zjb/v29/resourceCache.mjs b/public/three-dimensional/zjb/v29/resourceCache.mjs new file mode 100644 index 0000000..291685d --- /dev/null +++ b/public/three-dimensional/zjb/v29/resourceCache.mjs @@ -0,0 +1,114 @@ +// Only static scene assets enter this database. Live simulation/SCADA data do not. +const DATABASE = 'tjwater-scene-assets-v1'; +const STORE = 'resources'; +const REQUEST_TIMEOUT_MS = 30_000; + +function openDatabase() { + return new Promise(resolve => { + let settled = false; + const finish = value => { if (!settled) { settled = true; resolve(value); } }; + try { + const request = globalThis.indexedDB.open(DATABASE, 1); + request.onupgradeneeded = () => request.result.createObjectStore(STORE, {keyPath:'url'}); + request.onerror = request.onblocked = () => finish(null); + request.onsuccess = () => { + const database = request.result; + if (settled) { database.close(); return; } + database.onversionchange = () => database.close(); + finish(database); + }; + } catch { finish(null); } + }); +} + +function transact(database, mode, operation) { + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE, mode); + const request = operation(transaction.objectStore(STORE)); + transaction.oncomplete = () => resolve(request.result); + transaction.onerror = transaction.onabort = () => reject(transaction.error ?? request.error); + }); +} + +export function createSceneResourceCache(baseUrl, onProgress = () => {}) { + // Open lazily, after synchronous WebGL setup, so database callbacks can run promptly. + let database; + const pending = new Map(); + + async function read(file, revision, decode) { + const url = new URL(file, baseUrl); + const key = url.href; + const report = (stage, loaded = 0, total = 0) => onProgress({file, stage, loaded, total}); + const storage = await (database ??= openDatabase()); + if (storage && revision) { + try { + const entry = await transact(storage, 'readonly', store => store.get(key)); + if (entry?.revision === revision && entry.data instanceof ArrayBuffer) { + report('cache', entry.data.byteLength, entry.data.byteLength); + const result = await decode(entry.data); + report('ready', entry.data.byteLength, entry.data.byteLength); + return result; + } + } catch { /* Unavailable or invalid cache: fetch a fresh copy below. */ } + } + + // Revisioned URLs also keep the browser's HTTP cache in sync with the manifest. + if (revision) url.searchParams.set('v', revision); + const controller = new AbortController(); + let timer; + const resetTimeout = () => { + clearTimeout(timer); + timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + }; + report('download'); + resetTimeout(); + let data; + try { + const response = await fetch(url, {signal:controller.signal, cache:'no-cache'}); + if (!response.ok) throw Error('HTTP ' + response.status); + const total = Number(response.headers.get('content-length')) || 0; + if (response.body) { + const reader = response.body.getReader(); + const chunks = []; + let loaded = 0; + while (true) { + const {done, value} = await reader.read(); + if (done) break; + chunks.push(value); loaded += value.byteLength; + report('download', loaded, total); resetTimeout(); + } + const bytes = new Uint8Array(loaded); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + data = bytes.buffer; + } else { + data = await response.arrayBuffer(); + } + } catch (error) { + throw Error(file + ':' + (controller.signal.aborted ? '下载超时,请重试' : error.message)); + } finally { clearTimeout(timer); } + + report('parse', data.byteLength, data.byteLength); + // Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache. + const result = await decode(data); + if (storage && revision) { + try { + // One entry per URL: a successful new revision replaces the old bytes. + await transact(storage, 'readwrite', store => store.put({url:key, revision, data})); + } catch { /* Storage denied/full must not prevent scene rendering. */ } + } + report('ready', data.byteLength, data.byteLength); + return result; + } + + return { + load(file, revision, decode = data => data) { + const key = file + ':' + revision; + if (!pending.has(key)) { + const task = read(file, revision, decode).finally(() => pending.delete(key)); + pending.set(key, task); + } + return pending.get(key); + }, + }; +} diff --git a/src/app/(main)/(map)/layout.tsx b/src/app/(main)/(map)/layout.tsx index 18c0bae..d3d8cd0 100644 --- a/src/app/(main)/(map)/layout.tsx +++ b/src/app/(main)/(map)/layout.tsx @@ -3,7 +3,12 @@ import type { ReactNode } from "react"; import MapComponent from "@components/olmap/core/MapComponent"; +import { MapNavigationContent } from "@components/loading/NavigationFeedback"; export default function MapLayout({ children }: { children: ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/src/app/(main)/(map)/loading.tsx b/src/app/(main)/(map)/loading.tsx new file mode 100644 index 0000000..d57c5c0 --- /dev/null +++ b/src/app/(main)/(map)/loading.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { MapRouteSkeleton } from "@components/loading/PageLoadingSkeleton"; +import { useNavigationFeedback } from "@components/loading/NavigationFeedback"; + +export default function Loading() { + const pathname = usePathname(); + const { pendingPath } = useNavigationFeedback(); + return ; +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index b78c33a..6bb9023 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -6,6 +6,10 @@ import authOptions from "@app/api/auth/[...nextauth]/options"; import { Header } from "@components/header"; import { Title } from "@components/title"; import { AppSider } from "@components/sider/AppSider"; +import { + NavigationFeedbackProvider, + PageNavigationContent, +} from "@components/loading/NavigationFeedback"; import { ThemedLayout } from "@refinedev/mui"; import { getServerSession } from "next-auth/next"; import { redirect } from "next/navigation"; @@ -32,24 +36,26 @@ export default async function MainLayout({ } return ( - - {children} - + + + {children} + + ); } diff --git a/src/app/(main)/loading.tsx b/src/app/(main)/loading.tsx new file mode 100644 index 0000000..846a9dd --- /dev/null +++ b/src/app/(main)/loading.tsx @@ -0,0 +1,5 @@ +import { PageLoadingSkeleton } from "@components/loading/PageLoadingSkeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/(main)/three-dimensional-scene/page.tsx b/src/app/(main)/three-dimensional-scene/page.tsx index 03b3117..9877ffe 100644 --- a/src/app/(main)/three-dimensional-scene/page.tsx +++ b/src/app/(main)/three-dimensional-scene/page.tsx @@ -1,17 +1,13 @@ "use client"; import dynamic from "next/dynamic"; -import { Box, CircularProgress } from "@mui/material"; +import { PageLoadingSkeleton } from "@components/loading/PageLoadingSkeleton"; const ThreeDimensionalScene = dynamic( () => import("@components/threeDimensional/ThreeDimensionalScene"), { ssr: false, - loading: () => ( - - - - ), + loading: () => , }, ); diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx index 64fa123..00135d1 100644 --- a/src/app/RefineContext.tsx +++ b/src/app/RefineContext.tsx @@ -12,6 +12,7 @@ import { usePathname } from "next/navigation"; import React, { useEffect } from "react"; import routerProvider from "@refinedev/nextjs-router"; +import { NavigationLink } from "@components/loading/NavigationLink"; import { ColorModeContextProvider } from "@contexts/color-mode"; import { dataProvider } from "@providers/data-provider"; @@ -46,6 +47,8 @@ type RefineContextProps = { defaultMode?: string; }; +const appRouterProvider = { ...routerProvider, Link: NavigationLink }; + export const RefineContext = ( props: React.PropsWithChildren, ) => ( @@ -377,7 +380,7 @@ export const App = (props: React.PropsWithChildren) => { ({ usePathname: () => mockPathname })); + +function Harness() { + const { reportPending } = useNavigationFeedback(); + return ( + <> + + + + + +
原有地图
+ +
+ + ); +} + +describe("page navigation feedback", () => { + beforeEach(() => { mockPathname = "/network-simulation"; }); + + it("shows the destination skeleton while preserving the map and a cancelled page's draft", () => { + render(); + const map = screen.getByTestId("map"); + const draft = screen.getByRole("textbox", { name: "页面草稿" }); + fireEvent.click(screen.getByText("清洗")); + expect(screen.getByRole("status", { name: "正在加载数据清洗" })).toBeVisible(); + expect(screen.getByTestId("map")).toBe(map); + expect(map).toBeVisible(); + expect(draft).not.toBeVisible(); + fireEvent.click(screen.getByText("取消旧导航")); + expect(draft).toBeVisible(); + expect(draft).toHaveValue("保留输入"); + expect(screen.queryByRole("progressbar", { name: "正在切换页面" })).not.toBeInTheDocument(); + }); + + it("does not dismiss a newer destination when the previous link settles", () => { + render(); + fireEvent.click(screen.getByText("清洗")); + fireEvent.click(screen.getByText("审计")); + fireEvent.click(screen.getByText("取消旧导航")); + expect(screen.getByRole("status", { name: "正在加载审计日志" })).toBeVisible(); + expect(screen.getByRole("progressbar", { name: "正在切换页面" })).toBeVisible(); + fireEvent.click(screen.getByText("结束导航")); + expect(screen.queryByRole("status", { name: "正在加载审计日志" })).not.toBeInTheDocument(); + }); + + it("hands off to route/component loading boundaries as soon as history commits", () => { + const { rerender } = render(); + fireEvent.click(screen.getByText("清洗")); + expect(screen.getByRole("progressbar", { name: "正在切换页面" })).toBeVisible(); + mockPathname = "/scada-data-cleaning"; + rerender(); + expect(screen.queryByRole("progressbar", { name: "正在切换页面" })).not.toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "页面草稿" })).toBeVisible(); + }); + + it("connects every existing map skeleton to its exact public route", () => { + const paths = [ + "/network-simulation", "/scada-data-cleaning", "/health-risk-analysis", "/monitoring-place-optimization", + ...["burst-detection", "burst-location", "burst-simulation", "contaminant-simulation", "dma-leak-detection", "flushing-analysis"].map((name) => "/hydraulic-simulation/" + name), + ]; + expect(paths.map(mapSkeletonForPath).sort()).toEqual([...MAP_SKELETON_VARIANTS].sort()); + expect(mapSkeletonForPath("/system-admin")).toBeUndefined(); + }); +}); diff --git a/src/components/loading/NavigationFeedback.tsx b/src/components/loading/NavigationFeedback.tsx new file mode 100644 index 0000000..e992f39 --- /dev/null +++ b/src/components/loading/NavigationFeedback.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Box, LinearProgress } from "@mui/material"; +import { usePathname } from "next/navigation"; +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +import { + mapSkeletonForPath, + MapRouteSkeleton, + PageLoadingSkeleton, +} from "./PageLoadingSkeleton"; + +type PendingNavigation = { id: string; pathname: string }; +type NavigationFeedback = { + pendingPath: string | null; + reportPending: (id: string, pathname: string, pending: boolean) => void; +}; + +const NavigationContext = createContext({ + pendingPath: null, + reportPending: () => {}, +}); + +export const useNavigationFeedback = () => useContext(NavigationContext); + +export function NavigationFeedbackProvider({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const [navigation, setNavigation] = useState(null); + const reportPending = useCallback((id: string, target: string, pending: boolean) => { + setNavigation((current) => { + if (!pending) return current?.id === id ? null : current; + return current?.id === id && current.pathname === target ? current : { id, pathname: target }; + }); + }, []); + // History commits can precede a page's lazy component. Its loading.tsx and + // dynamic fallback take over at that point, without an artificial delay. + const pendingPath = navigation && navigation.pathname !== pathname ? navigation.pathname : null; + const value = useMemo(() => ({ pendingPath, reportPending }), [pendingPath, reportPending]); + return ( + + {children} + + ); +} + +export function PageNavigationContent({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const { pendingPath } = useNavigationFeedback(); + const withinMap = Boolean(mapSkeletonForPath(pathname) && pendingPath && mapSkeletonForPath(pendingPath)); + return ( + + + {children} + + {pendingPath && ( + <> + + {!withinMap && ( + + + + )} + + )} + + ); +} + +export function MapNavigationContent({ children }: { children: ReactNode }) { + const { pendingPath } = useNavigationFeedback(); + return ( + <> + + {children} + + {pendingPath && } + + ); +} diff --git a/src/components/loading/NavigationLink.tsx b/src/components/loading/NavigationLink.tsx new file mode 100644 index 0000000..58a8d32 --- /dev/null +++ b/src/components/loading/NavigationLink.tsx @@ -0,0 +1,33 @@ +"use client"; + +import type { RouterProvider } from "@refinedev/core"; +import Link, { useLinkStatus } from "next/link"; +import { forwardRef, useEffect, useId, type ComponentProps } from "react"; + +import { useNavigationFeedback } from "./NavigationFeedback"; + +function NavigationLinkStatus({ to }: { to: string }) { + const { pending } = useLinkStatus(); + const { reportPending } = useNavigationFeedback(); + const id = useId(); + const pathname = to.split(/[?#]/)[0]; + useEffect(() => { + reportPending(id, pathname, pending); + return () => reportPending(id, pathname, false); + }, [id, pathname, pending, reportPending]); + return null; +} + +export const NavigationLink = forwardRef< + HTMLAnchorElement, + ComponentProps> +>( + function NavigationLink({ to, children, ...props }, ref) { + return ( + + {children} + + + ); + }, +); diff --git a/src/components/loading/PageLoadingSkeleton.tsx b/src/components/loading/PageLoadingSkeleton.tsx new file mode 100644 index 0000000..eb1acbd --- /dev/null +++ b/src/components/loading/PageLoadingSkeleton.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Box, CircularProgress, Skeleton, Typography } from "@mui/material"; +import { usePathname } from "next/navigation"; + +import { MapPanelSkeleton, MapTimelineSkeleton } from "./MapComponentSkeletons"; +import { + MAP_SKELETON_CONFIGS, + type MapSkeletonVariant, +} from "./mapComponentSkeletonConfig"; + +const sceneLoadingSurface = { + width: "min(100%, 360px)", + p: 3, + boxSizing: "border-box", + borderRadius: "16px", + background: "linear-gradient(135deg,rgba(255,255,255,.50),rgba(224,239,250,.28))", + backdropFilter: "blur(24px) saturate(170%) contrast(94%)", + WebkitBackdropFilter: "blur(24px) saturate(170%) contrast(94%)", + boxShadow: "inset 0 1px 0 rgba(255,255,255,.88),inset 0 -1px 0 rgba(112,145,168,.18),0 18px 50px rgba(15,43,69,.20),0 0 0 1px rgba(255,255,255,.55)", +}; + +const mapPaths: Record = { + "/network-simulation": "network-simulation", + "/scada-data-cleaning": "scada-data-cleaning", + "/health-risk-analysis": "health-risk-analysis", + "/monitoring-place-optimization": "monitoring-place-optimization", + "/hydraulic-simulation/burst-detection": "burst-detection", + "/hydraulic-simulation/burst-location": "burst-location", + "/hydraulic-simulation/burst-simulation": "burst-simulation", + "/hydraulic-simulation/contaminant-simulation": "contaminant-simulation", + "/hydraulic-simulation/dma-leak-detection": "dma-leak-detection", + "/hydraulic-simulation/flushing-analysis": "flushing-analysis", +}; + +export const mapSkeletonForPath = ( + pathname: string, +): MapSkeletonVariant | undefined => mapPaths[pathname]; + +export const pageTitleForPath = (pathname: string) => { + const variant = mapSkeletonForPath(pathname); + if (variant) return MAP_SKELETON_CONFIGS[variant].title; + return ({ + "/system-admin": "系统管理", + "/audit-logs": "审计日志", + "/three-dimensional-scene": "三维场景", + } as Record)[pathname] ?? "页面"; +}; + +export function MapRouteSkeleton({ pathname }: { pathname: string }) { + const variant = mapSkeletonForPath(pathname); + if (!variant) return null; + return ( + <> + + {(variant === "network-simulation" || variant === "health-risk-analysis") && ( + + )} + + ); +} + +export function PageLoadingSkeleton({ pathname }: { pathname?: string }) { + const currentPath = usePathname(); + const path = pathname ?? currentPath; + if (path === "/three-dimensional-scene") { + return ( + + + + + + 正在准备三维场景 + + + + 正在载入页面,随后将检查本地模型缓存。 + + + + ); + } + if (mapSkeletonForPath(path)) { + return ( + + + + ); + } + const title = pageTitleForPath(path); + return ( + + 正在加载{title} + + {[0, 1, 2, 3].map((row) => ( + + ))} + + ); +} diff --git a/src/components/threeDimensional/ThreeDimensionalScene.tsx b/src/components/threeDimensional/ThreeDimensionalScene.tsx index 89de5da..5527fb8 100644 --- a/src/components/threeDimensional/ThreeDimensionalScene.tsx +++ b/src/components/threeDimensional/ThreeDimensionalScene.tsx @@ -1,6 +1,6 @@ "use client"; -import Link from "next/link"; +import { NavigationLink } from "@components/loading/NavigationLink"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FiAlertCircle, @@ -38,8 +38,8 @@ import { type SceneRuntimeState, } from "./sceneProtocol"; -const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=34-detailed-network-context"; -const SCENE_LOAD_TIMEOUT_MS = 30_000; +const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=35-resource-cache"; +const SCENE_LOAD_TIMEOUT_MS = 120_000; const initialRuntimeState: SceneRuntimeState = { mode: "network", @@ -138,6 +138,9 @@ export default function ThreeDimensionalScene() { const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [sceneError, setSceneError] = useState(null); + const [sceneLoading, setSceneLoading] = useState(true); + const [sceneResourceError, setSceneResourceError] = useState(false); + const [sceneStarted, setSceneStarted] = useState(false); const [refreshVersion, setRefreshVersion] = useState(0); const [sceneKey, setSceneKey] = useState(0); @@ -169,6 +172,9 @@ export default function ThreeDimensionalScene() { setSelection(null); setRuntimeState(initialRuntimeState); setSceneError(null); + setSceneLoading(true); + setSceneResourceError(false); + setSceneStarted(false); setSceneKey((value) => value + 1); }, []); @@ -198,6 +204,17 @@ export default function ThreeDimensionalScene() { }); setRuntimeState(message.state); setSceneError(null); + setSceneLoading(false); + setSceneResourceError(false); + return; + } + if (message.type === "loading") { + if (typeof message.active === "boolean") { + setSceneLoading(message.active); + setSceneStarted(true); + setSceneResourceError(message.failed === true); + if (message.active) setSceneError(null); + } return; } if (message.type === "scene-state") { @@ -222,12 +239,14 @@ export default function ThreeDimensionalScene() { }, [isZjbProject, sceneKey]); useEffect(() => { - if (!isZjbProject || model) return; + // Once the viewer starts, its per-request inactivity timeout owns recovery. + // A long but progressing first download should not hit a total-load deadline. + if (!isZjbProject || model || !sceneLoading || sceneStarted) return; const timerId = window.setTimeout(() => { setSceneError("三维模型载入超时,请检查静态资源后重试。"); }, SCENE_LOAD_TIMEOUT_MS); return () => window.clearTimeout(timerId); - }, [isZjbProject, model, sceneKey]); + }, [isZjbProject, model, sceneKey, sceneLoading, sceneStarted]); useEffect(() => { if (!isZjbProject) return; @@ -301,9 +320,9 @@ export default function ThreeDimensionalScene() {

目前仅高铁湛江北站项目提供三维模型。请切换到 ZJB 项目后再打开此页面。

- + 返回管网在线模拟 - + @@ -324,14 +343,7 @@ export default function ThreeDimensionalScene() { return (
-