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 (
-