Compare commits

...
Author SHA1 Message Date
jiang d013f34493 fix(scene): resume chunk downloads after proxy interruptions
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
Whole-file loading still failed after adding caching because the public proxy interrupted large GLB response bodies. Download validated 1 MiB ranges, retry transient failures up to three attempts, and persist completed chunks so reloads resume from their last boundary.

Add regression cases observed failing before the change for interrupted downloads, cross-reload resume, and temporary HTTP failures. Verify range integrity, full-cache reuse, permanent errors, and storage fallback.
2026-10-16 18:37:24 +08:00
jiang 4a522bf42a feat(frontend): cache 3D assets and restore loading feedback
Generic Container CI/CD / test-build-publish (push) Successful in 1m11s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m11s
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.
2026-10-16 18:13:04 +08:00
24 changed files with 1171 additions and 59 deletions
+19
View File
@@ -0,0 +1,19 @@
# 三维场景静态资源下载与缓存
`public/three-dimensional/zjb/v29/resourceCache.mjs` 统一加载模型和管网静态元数据,不缓存实时仿真或 SCADA 数据。
- 每次请求最多下载 1 MiB,通过单区间 `Range` 请求获取资源。服务器返回 `206` 时,校验 `Content-Range` 的起止位置、总长度以及实际收到的字节数;不支持 Range 的服务器可在首次请求返回完整的 `200` 响应。
- 网络中断、连续 30 秒无数据以及临时 HTTP 错误可重试,每个分段最多尝试 3 次,等待间隔为 300、600 毫秒。永久 HTTP 错误和不合法的分段响应直接显示失败提示。
- 每个完整分段保存至 IndexedDB。刷新页面或重新载入后,从已保存的分段末尾继续;未完成的当前分段会重新下载。浏览器禁止存储或配额不足时,仍能在本次加载中下载和重试,但无法保证刷新后保留断点。
- 完整资源和部分下载均按 URL、manifest revision 匹配,版本改变后重新下载。完整资源经过解析验证后才进入正式缓存,后续打开场景直接复用。
- 进度界面显示下载、解析和断点重试状态。加载模块版本参数需要与 `preview.html`、`preview.mjs` 和 `ThreeDimensionalScene.tsx` 同步,防止浏览器沿用旧加载器。
反向代理应透传 `Range` 请求及 `206`、`Content-Range` 响应。若内网完整下载正常,而公网完整下载中断,应同时排查代理的大文件传输;客户端分段恢复不代表代理链路问题已修复。
聚焦回归:
```bash
E2E_BASE_URL=http://127.0.0.1:3000 npx playwright test e2e/three-dimensional-loading.spec.ts --workers=1
```
该命令需要已运行的前端。测试覆盖完整缓存复用、分段重试、跨刷新续传、错误分段拒绝及浏览器存储不可用的降级行为。
+96
View File
@@ -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<void>((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<void>((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<void>((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);
});
+35
View File
@@ -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<void>((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[];
+304
View File
@@ -0,0 +1,304 @@
import { expect, test } from "@playwright/test";
const root = "/three-dimensional/zjb/v29/";
const preview = root + "preview.html?host=platform2&v=36-resumable-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(5);
});
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(new Set(requests).size).toBe(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<void>((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<typeof transaction>) {
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: 404, body: "not found" })
: 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 404");
expect(result.recovered).toEqual({ ok: true });
});
test("公网整文件中断时分段下载,重试失败分段并缓存完整模型", async ({ page }) => {
const { readFile } = await import("node:fs/promises");
const { createHash } = await import("node:crypto");
const bytes = await readFile("public/three-dimensional/zjb/v29/models/detail_interior.glb");
const ranges: string[] = [];
let interrupted = false;
await page.route("**/models/detail_interior.glb*", async (route) => {
const range = route.request().headers().range;
ranges.push(range ?? "whole-file");
// The public proxy drops whole-file responses, and one partial request drops too.
if (!range) return route.abort("failed");
if (range.startsWith("bytes=1048576-") && !interrupted) {
interrupted = true;
return route.abort("failed");
}
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
const start = Number(match[1]);
const end = Math.min(Number(match[2]), bytes.length - 1);
return route.fulfill({ status: 206, headers: {
"content-type": "model/gltf-binary",
"content-range": `bytes ${start}-${end}/${bytes.length}`,
}, body: bytes.subarray(start, end + 1) });
});
await page.goto(root + "manifest.json");
const result = await page.evaluate(async (base) => {
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
const load = () => createSceneResourceCache(location.origin + base).load("models/detail_interior.glb", "range-recovery");
const data = await load();
const cached = await load();
return { length: data.byteLength, cachedLength: cached.byteLength,
sha256: Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), n => n.toString(16).padStart(2, "0")).join("") };
}, root);
expect(result).toEqual({ length: bytes.length, cachedLength: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") });
expect(ranges).toHaveLength(7);
expect(ranges[0]).toBe("bytes=0-1048575");
expect(ranges[1]).toBe(ranges[2]);
expect(interrupted).toBe(true);
});
for (const fault of ["offset", "total", "truncated", "whole-response"] as const) {
test(`无效分段响应 ${fault} 不会进入缓存,恢复后可重新下载`, async ({ page }) => {
const bytes = Buffer.alloc(1048576 + 16, 7);
let broken = true;
let requests = 0;
await page.route("**/range-probe.bin*", (route) => {
requests++;
const range = route.request().headers().range;
if (!range) return route.abort("failed");
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
const start = Number(match[1]);
const end = Math.min(Number(match[2]), bytes.length - 1);
if (broken && start > 0 && fault === "whole-response") return route.fulfill({ body: bytes });
return route.fulfill({ status: 206, headers: {
"content-range": `bytes ${broken && start > 0 && fault === "offset" ? 0 : start}-${end}/${bytes.length + (broken && start > 0 && fault === "total" ? 1 : 0)}`,
}, body: bytes.subarray(start, end + (broken && start > 0 && fault === "truncated" ? 0 : 1)) });
});
await page.goto(root + "manifest.json");
const failed = await page.evaluate(async (base) => {
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
try { await createSceneResourceCache(location.origin + base).load("range-probe.bin", "first"); return false; }
catch { return true; }
}, root);
expect(failed).toBe(true);
const before = requests;
broken = false;
const length = await page.evaluate(async (base) => {
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
return (await createSceneResourceCache(location.origin + base).load("range-probe.bin", "first")).byteLength;
}, root);
expect(length).toBe(bytes.length);
// The valid first segment survives; only the rejected final segment is fetched again.
expect(requests - before).toBe(1);
});
}
test("下载中断后刷新页面,从已保存的分段继续下载", async ({ page }) => {
const bytes = Buffer.alloc(2 * 1048576 + 16, 9);
let disconnected = true;
const ranges: string[] = [];
await page.route("**/resume-probe.bin*", (route) => {
const range = route.request().headers().range;
ranges.push(range ?? "whole-file");
if (!range) return route.abort("failed");
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
const start = Number(match[1]);
const end = Math.min(Number(match[2]), bytes.length - 1);
if (disconnected && start > 0) return route.abort("failed");
return route.fulfill({ status: 206, headers: { "content-range": `bytes ${start}-${end}/${bytes.length}` }, body: bytes.subarray(start, end + 1) });
});
await page.goto(root + "manifest.json");
const load = (base: string) => page.evaluate(async (base) => {
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
try {
const data = await createSceneResourceCache(location.origin + base).load("resume-probe.bin", "first");
return { length: data.byteLength, failed: false };
} catch { return { failed: true }; }
}, base);
expect(await load(root)).toEqual({ failed: true });
expect(ranges.filter(range => range === "bytes=1048576-2097151")).toHaveLength(3);
disconnected = false;
ranges.length = 0;
await page.reload();
expect(await load(root)).toEqual({ length: bytes.length, failed: false });
expect(ranges).toEqual(["bytes=1048576-2097151", "bytes=2097152-2097167"]);
});
test("临时 HTTP 503 自动重试,成功后才写入缓存", async ({ page }) => {
let requests = 0;
await page.route("**/retry-probe.json*", (route) => {
requests++;
return requests < 3 ? route.fulfill({ status: 503, body: "unavailable" }) : route.fulfill({ json: { ok: true } });
});
await page.goto(root + "manifest.json");
const result = await page.evaluate(async (base) => {
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
const stages: string[] = [];
const cache = createSceneResourceCache(location.origin + base, ({ stage }: { stage: string }) => stages.push(stage));
const data = await cache.load("retry-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)));
return { data, retries: stages.filter(stage => stage === "retry").length };
}, root);
expect(result).toEqual({ data: { ok: true }, retries: 2 });
expect(requests).toBe(3);
});
test("服务器返回空的完整响应时只解析一次,不循环下载", async ({ page }) => {
let requests = 0;
await page.route("**/empty-probe.json*", (route) => { requests++; return route.fulfill({ body: "" }); });
await page.goto(root + "manifest.json");
const failed = await page.evaluate(async (base) => {
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
try {
await createSceneResourceCache(location.origin + base).load("empty-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)));
return false;
} catch { return true; }
}, root);
expect(failed).toBe(true);
expect(requests).toBe(1);
});
@@ -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('视角已保存在本机浏览器');}
@@ -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])];
@@ -0,0 +1,61 @@
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 retrying = values.some(item => item.stage === 'retry');
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 = `${retrying ? '连接中断,正在从断点重试' : 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());
@@ -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
+24 -6
View File
@@ -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}}
</style>
<script type="importmap">{"imports":{"three":"./vendor/three/three.module.js","three/addons/":"./vendor/three/addons/"}}</script>
</head>
<body>
<div id="boot" role="status"><span><i aria-hidden="true"></i>正在载入三维模型</span></div>
<div id="boot" role="status" aria-live="polite" aria-busy="true" data-state="loading">
<div class="boot-panel">
<div class="boot-heading"><i aria-hidden="true"></i><span id="boot-title">正在准备三维场景</span></div>
<p id="boot-detail">首次载入需要下载模型,后续将优先使用本地缓存。</p>
<progress id="boot-progress" aria-label="场景资源加载进度"></progress>
<button id="boot-retry" type="button" hidden>重新载入场景</button>
</div>
</div>
<output id="qa" hidden></output>
<script type="module" src="./preview.mjs?v=34-detailed-network-context"></script>
<script type="module">
import {failLoading} from './loadingProgress.mjs?v=36-resumable-cache';
import('./preview.mjs?v=36-resumable-cache').catch(failLoading);
</script>
</body>
</html>
+27 -9
View File
@@ -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=36-resumable-cache';
import {createSceneResourceCache} from './resourceCache.mjs?v=36-resumable-cache';
import {beginLoading,reportProgress,finishLoading,failLoading} from './loadingProgress.mjs?v=36-resumable-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()});
@@ -0,0 +1,188 @@
// 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;
const CHUNK_BYTES = 1024 * 1024;
const MAX_ATTEMPTS = 3;
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 partialKey = key + '#partial';
let chunks = [], loaded = 0, total = 0;
if (storage && revision) {
try {
const partial = await transact(storage, 'readonly', store => store.get(partialKey));
if (partial?.revision === revision && Number.isSafeInteger(partial.total) && partial.total > 0 &&
Array.isArray(partial.chunks) && partial.chunks.length &&
partial.chunks.every(chunk => chunk instanceof ArrayBuffer && chunk.byteLength > 0 && chunk.byteLength <= CHUNK_BYTES)) {
const size = partial.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
if (size <= partial.total) { chunks = partial.chunks; loaded = size; total = partial.total; }
}
} catch { /* A denied partial cache does not prevent downloading. */ }
}
async function downloadPart() {
const controller = new AbortController();
let timer;
const resetTimeout = () => {
clearTimeout(timer);
timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
};
resetTimeout();
try {
const end = total ? Math.min(loaded + CHUNK_BYTES, total) - 1 : loaded + CHUNK_BYTES - 1;
const response = await fetch(url, {signal:controller.signal, cache:'no-cache', headers:{Range:`bytes=${loaded}-${end}`}});
if (!response.ok) {
const error = Error('HTTP ' + response.status);
error.retryable = [408, 429, 500, 502, 503, 504].includes(response.status);
throw error;
}
let partTotal = 0, expected = 0;
if (response.status === 206) {
const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get('content-range') ?? '');
const start = Number(range?.[1]), last = Number(range?.[2]);
partTotal = Number(range?.[3]);
if (!range || start !== loaded || last < start || last > end || last >= partTotal ||
!Number.isSafeInteger(partTotal) || (total && total !== partTotal)) {
throw Error('资源分段响应无效');
}
expected = last - start + 1;
} else {
if (loaded) throw Error('服务器未按断点返回资源');
// Servers without Range support can still return an ordinary complete response.
partTotal = Number(response.headers.get('content-length')) || 0;
}
const buffers = [];
let size = 0;
if (response.body) {
const reader = response.body.getReader();
try {
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffers.push(value); size += value.byteLength;
if (expected && size > expected) throw Error('资源分段长度无效');
report('download', loaded + size, partTotal); resetTimeout();
}
} finally { await reader.cancel().catch(() => {}); }
} else {
const buffer = new Uint8Array(await response.arrayBuffer());
buffers.push(buffer); size = buffer.byteLength;
}
if (expected && size !== expected) throw Error('资源分段不完整');
const bytes = new Uint8Array(size);
let offset = 0;
for (const buffer of buffers) { bytes.set(buffer, offset); offset += buffer.byteLength; }
return {data:bytes.buffer, total:response.status === 206 ? partTotal : size, ranged:response.status === 206};
} catch (error) {
controller.abort();
throw error;
} finally { clearTimeout(timer); }
}
report('download', loaded, total);
try {
while (!total || loaded < total) {
let part;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
try { part = await downloadPart(); break; }
catch (error) {
// Permanent HTTP/contract errors stop; temporary transport/server errors can recover.
if (!error.retryable && !(error instanceof TypeError) && error.name !== 'AbortError' && error.message !== '资源分段不完整') throw error;
if (attempt + 1 === MAX_ATTEMPTS) throw error;
report('retry', loaded, total);
await new Promise(resolve => setTimeout(resolve, 300 * (attempt + 1)));
}
}
chunks.push(part.data); loaded += part.data.byteLength; total = part.total;
if (storage && revision && part.ranged) {
try { await transact(storage, 'readwrite', store => store.put({url:partialKey, revision, total, chunks})); }
catch { /* Storage denied/full: retain the in-memory download. */ }
}
if (!part.ranged) break;
}
} catch (error) {
throw Error(file + ':' + (error.name === 'AbortError' ? '下载超时,请重试' : error.message));
}
const bytes = new Uint8Array(loaded);
let offset = 0;
for (const chunk of chunks) { bytes.set(new Uint8Array(chunk), offset); offset += chunk.byteLength; }
const data = bytes.buffer;
if (storage) {
try { await transact(storage, 'readwrite', store => store.delete(partialKey)); }
catch { /* Completed bytes can still be parsed when storage is unavailable. */ }
}
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);
},
};
}
+6 -1
View File
@@ -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 <MapComponent>{children}</MapComponent>;
return (
<MapComponent>
<MapNavigationContent>{children}</MapNavigationContent>
</MapComponent>
);
}
+11
View File
@@ -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 <MapRouteSkeleton pathname={pendingPath ?? pathname} />;
}
+24 -18
View File
@@ -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 (
<ThemedLayout
Header={Header}
Title={Title}
Sider={AppSider}
childrenBoxProps={{
sx: {
flex: 1,
minHeight: 0,
overflow: "auto",
p: 0,
},
}}
containerBoxProps={{
sx: { height: "100vh", overflow: "hidden" },
}}
>
{children}
</ThemedLayout>
<NavigationFeedbackProvider>
<ThemedLayout
Header={Header}
Title={Title}
Sider={AppSider}
childrenBoxProps={{
sx: {
flex: 1,
minHeight: 0,
overflow: "hidden",
p: 0,
},
}}
containerBoxProps={{
sx: { height: "100vh", overflow: "hidden" },
}}
>
<PageNavigationContent>{children}</PageNavigationContent>
</ThemedLayout>
</NavigationFeedbackProvider>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { PageLoadingSkeleton } from "@components/loading/PageLoadingSkeleton";
export default function Loading() {
return <PageLoadingSkeleton />;
}
@@ -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: () => (
<Box sx={{ height: "100%", display: "grid", placeItems: "center" }}>
<CircularProgress size={32} />
</Box>
),
loading: () => <PageLoadingSkeleton pathname="/three-dimensional-scene" />,
},
);
+4 -1
View File
@@ -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<RefineContextProps>,
) => (
@@ -377,7 +380,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
<ColorModeContextProvider defaultMode={props.defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={routerProvider}
routerProvider={appRouterProvider}
dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
@@ -0,0 +1,78 @@
import { fireEvent, render, screen } from "@testing-library/react";
import {
MapNavigationContent,
NavigationFeedbackProvider,
PageNavigationContent,
useNavigationFeedback,
} from "./NavigationFeedback";
import { mapSkeletonForPath } from "./PageLoadingSkeleton";
import { MAP_SKELETON_VARIANTS } from "./mapComponentSkeletonConfig";
let mockPathname = "/network-simulation";
jest.mock("next/navigation", () => ({ usePathname: () => mockPathname }));
function Harness() {
const { reportPending } = useNavigationFeedback();
return (
<>
<button onClick={() => reportPending("first", "/scada-data-cleaning", true)}>清洗</button>
<button onClick={() => reportPending("second", "/audit-logs", true)}>审计</button>
<button onClick={() => reportPending("first", "/scada-data-cleaning", false)}>取消旧导航</button>
<button onClick={() => reportPending("second", "/audit-logs", false)}>结束导航</button>
<PageNavigationContent>
<div data-testid="map">原有地图</div>
<MapNavigationContent><input aria-label="页面草稿" defaultValue="保留输入" /></MapNavigationContent>
</PageNavigationContent>
</>
);
}
describe("page navigation feedback", () => {
beforeEach(() => { mockPathname = "/network-simulation"; });
it("shows the destination skeleton while preserving the map and a cancelled page's draft", () => {
render(<NavigationFeedbackProvider><Harness /></NavigationFeedbackProvider>);
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(<NavigationFeedbackProvider><Harness /></NavigationFeedbackProvider>);
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(<NavigationFeedbackProvider><Harness /></NavigationFeedbackProvider>);
fireEvent.click(screen.getByText("清洗"));
expect(screen.getByRole("progressbar", { name: "正在切换页面" })).toBeVisible();
mockPathname = "/scada-data-cleaning";
rerender(<NavigationFeedbackProvider><Harness /></NavigationFeedbackProvider>);
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();
});
});
@@ -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<NavigationFeedback>({
pendingPath: null,
reportPending: () => {},
});
export const useNavigationFeedback = () => useContext(NavigationContext);
export function NavigationFeedbackProvider({ children }: { children: ReactNode }) {
const pathname = usePathname();
const [navigation, setNavigation] = useState<PendingNavigation | null>(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 (
<NavigationContext.Provider value={value}>
{children}
</NavigationContext.Provider>
);
}
export function PageNavigationContent({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { pendingPath } = useNavigationFeedback();
const withinMap = Boolean(mapSkeletonForPath(pathname) && pendingPath && mapSkeletonForPath(pendingPath));
return (
<Box
data-page-navigation-content
aria-busy={Boolean(pendingPath)}
sx={{ position: "relative", height: "100%", minHeight: 0, overflow: "hidden" }}
>
<Box
data-page-scroll-content
inert={Boolean(pendingPath) && !withinMap}
aria-hidden={Boolean(pendingPath) && !withinMap}
sx={{ height: "100%", overflow: "auto" }}
>
{children}
</Box>
{pendingPath && (
<>
<LinearProgress
aria-label="正在切换页面"
sx={{ position: "absolute", top: 0, left: 0, right: 0, zIndex: 1401, height: 3 }}
/>
{!withinMap && (
<Box sx={{ position: "absolute", inset: 0, zIndex: 1400, overflow: "hidden" }}>
<PageLoadingSkeleton pathname={pendingPath} />
</Box>
)}
</>
)}
</Box>
);
}
export function MapNavigationContent({ children }: { children: ReactNode }) {
const { pendingPath } = useNavigationFeedback();
return (
<>
<Box data-map-page-content sx={{ display: pendingPath ? "none" : "contents" }}>
{children}
</Box>
{pendingPath && <MapRouteSkeleton pathname={pendingPath} />}
</>
);
}
+33
View File
@@ -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<NonNullable<RouterProvider["Link"]>>
>(
function NavigationLink({ to, children, ...props }, ref) {
return (
<Link href={to} {...props} ref={ref}>
{children}
<NavigationLinkStatus to={to} />
</Link>
);
},
);
@@ -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<string, MapSkeletonVariant> = {
"/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<string, string>)[pathname] ?? "页面";
};
export function MapRouteSkeleton({ pathname }: { pathname: string }) {
const variant = mapSkeletonForPath(pathname);
if (!variant) return null;
return (
<>
<MapPanelSkeleton variant={variant} />
{(variant === "network-simulation" || variant === "health-risk-analysis") && (
<MapTimelineSkeleton />
)}
</>
);
}
export function PageLoadingSkeleton({ pathname }: { pathname?: string }) {
const currentPath = usePathname();
const path = pathname ?? currentPath;
if (path === "/three-dimensional-scene") {
return (
<Box
role="status"
aria-live="polite"
aria-label="正在加载三维场景"
sx={{ height: "100%", minHeight: 0, display: "grid", placeItems: "center", p: 2, boxSizing: "border-box", bgcolor: "#e2e8e9" }}
>
<Box sx={sceneLoadingSurface}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25 }}>
<CircularProgress size={20} />
<Typography sx={{ fontSize: 14, fontWeight: 600, color: "#0f172a" }}>
正在准备三维场景
</Typography>
</Box>
<Typography sx={{ mt: 1.5, fontSize: 12, lineHeight: 1.75, color: "#334155" }}>
正在载入页面,随后将检查本地模型缓存。
</Typography>
</Box>
</Box>
);
}
if (mapSkeletonForPath(path)) {
return (
<Box sx={{ position: "relative", height: "100%", minHeight: 320, overflow: "hidden", bgcolor: "#f1f5f9" }}>
<MapRouteSkeleton pathname={path} />
</Box>
);
}
const title = pageTitleForPath(path);
return (
<Box
role="status"
aria-live="polite"
aria-label={`正在加载${title}`}
sx={{ p: { xs: 2, md: 3 }, height: "100%", boxSizing: "border-box", bgcolor: "background.default" }}
>
<Typography sx={{ fontSize: 16, fontWeight: 600, mb: 3 }}>正在加载{title}</Typography>
<Skeleton variant="rounded" height={40} sx={{ maxWidth: 480, mb: 3 }} />
{[0, 1, 2, 3].map((row) => (
<Skeleton key={row} variant="rounded" height={56} sx={{ mb: 1.5 }} />
))}
</Box>
);
}
@@ -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=36-resumable-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<string | null>(null);
const [sceneError, setSceneError] = useState<string | null>(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() {
<p className="mt-1 text-sm leading-6 text-slate-600">
目前仅高铁湛江北站项目提供三维模型。请切换到 ZJB 项目后再打开此页面。
</p>
<Link href="/network-simulation" className="mt-2 inline-flex min-h-10 items-center text-sm font-medium text-blue-700 hover:text-blue-800 hover:underline">
<NavigationLink to="/network-simulation" className="mt-2 inline-flex min-h-10 items-center text-sm font-medium text-blue-700 hover:text-blue-800 hover:underline">
返回管网在线模拟
</Link>
</NavigationLink>
</div>
</section>
</main>
@@ -324,14 +343,7 @@ export default function ThreeDimensionalScene() {
return (
<main lang="zh-CN" className="relative h-full min-h-0 overflow-hidden bg-slate-200">
<iframe key={sceneKey} ref={iframeRef} src={SCENE_URL} title="高铁湛江北站供水系统三维场景" referrerPolicy="same-origin" className="block h-full w-full border-0" />
{!model && !sceneError && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 bg-slate-100/55 text-sm text-slate-600 backdrop-blur-sm">
<span className="h-8 w-8 animate-spin rounded-full border-[3px] border-blue-200 border-t-blue-600" />
正在载入三维模型
</div>
)}
<iframe key={sceneKey} ref={iframeRef} src={SCENE_URL} title="高铁湛江北站供水系统三维场景" referrerPolicy="same-origin" className={`block h-full w-full border-0 ${!model || sceneLoading || sceneResourceError ? "relative z-40" : ""}`} />
{(loadError || sceneError) && (
<div role="alert" className="absolute left-1/2 top-28 z-40 flex w-[min(560px,calc(100%-24px))] -translate-x-1/2 items-center gap-3 rounded-xl border border-red-200/70 bg-red-50/80 px-4 py-3 text-sm text-red-800 shadow-xl shadow-red-950/10 backdrop-blur-xl md:top-4">
@@ -387,7 +399,7 @@ export default function ThreeDimensionalScene() {
<ThreeDimensionalControls
open={controlsOpen}
activeTab={controlTab}
ready={Boolean(model)}
ready={Boolean(model) && !sceneLoading && !sceneResourceError}
state={runtimeState}
selection={selection}
timelineOpen={timelineOpen}
@@ -21,7 +21,7 @@ describe("ZJB three-dimensional runtime assets", () => {
renderRevision: number;
assets: Array<{ file: string; sha256: string }>;
networkContext: string[];
networkModel: { file: string; metadata: string; sha256: string };
networkModel: { file: string; metadata: string; sha256: string; metadataSha256: string };
sourceCatalogFile?: string;
bindingsFile?: string;
};
@@ -48,6 +48,9 @@ describe("ZJB three-dimensional runtime assets", () => {
});
expect(existsSync(join(sceneRoot, manifest.networkModel.file))).toBe(true);
expect(existsSync(join(sceneRoot, manifest.networkModel.metadata))).toBe(true);
expect(createHash("sha256").update(
readFileSync(join(sceneRoot, manifest.networkModel.metadata)),
).digest("hex")).toBe(manifest.networkModel.metadataSha256);
expect(manifest.sourceCatalogFile).toBeUndefined();
expect(manifest.bindingsFile).toBeUndefined();
@@ -67,6 +70,8 @@ describe("ZJB three-dimensional runtime assets", () => {
"network-style.mjs",
"integration.mjs",
"render-effects.mjs",
"resourceCache.mjs",
"loadingProgress.mjs",
"vendor/meshopt_decoder.module.js",
"vendor/meshoptimizer-LICENSE.md",
"vendor/three/LICENSE",
@@ -145,6 +145,7 @@ export type SceneRuntimeMessage = HostMessageBase &
state: SceneRuntimeState;
}
| { type: "scene-state"; state: SceneRuntimeState }
| { type: "loading"; active: boolean; failed?: boolean }
| { type: "selection-changed"; selection: SceneAssetSelection | null }
| { type: "results-applied"; summary: SceneStyleSummary }
| { type: "results-cleared" }