Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d013f34493 | ||
|
|
4a522bf42a |
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令需要已运行的前端。测试覆盖完整缓存复用、分段重试、跨刷新续传、错误分段拒绝及浏览器存储不可用的降级行为。
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -4,6 +4,41 @@ import { mockBackend } from "./support/mockBackend";
|
|||||||
|
|
||||||
test.setTimeout(180_000);
|
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 = {
|
type CameraProbe = {
|
||||||
active: string | null;
|
active: string | null;
|
||||||
position: number[];
|
position: number[];
|
||||||
|
|||||||
@@ -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)};
|
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();}
|
if(matchMedia('(prefers-reduced-motion: reduce)').matches){camera.position.copy(flight.to);controls.target.copy(flight.targetTo);flight=null;controls.update();}
|
||||||
emit(p.note??'已保存的相机位置');
|
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 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('视角已保存在本机浏览器');}
|
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.
|
// Framework-independent data adapter. Does not parse or modify the project's INP.
|
||||||
export const CAD_ORIGIN=Object.freeze([513800,2344450]);
|
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){
|
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.');
|
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])];
|
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",
|
"id": "zjb-water-network-v23",
|
||||||
"file": "models/water_network_v28.glb",
|
"file": "models/water_network_v28.glb",
|
||||||
"metadata": "water-network-v28.json",
|
"metadata": "water-network-v28.json",
|
||||||
|
"metadataSha256": "b9af91296f7f9269a51141b732ffb9fcd82906133eca75b96273a85f776c75f5",
|
||||||
"sha256": "c3d16fd287a2e057649e3f25a939ff7edcc5f6b152c9dc09860bee7675912768",
|
"sha256": "c3d16fd287a2e057649e3f25a939ff7edcc5f6b152c9dc09860bee7675912768",
|
||||||
"runtimeInpRequired": false,
|
"runtimeInpRequired": false,
|
||||||
"representationRevision": 28
|
"representationRevision": 28
|
||||||
|
|||||||
@@ -8,18 +8,36 @@
|
|||||||
html,body{width:100%;height:100%;margin:0;overflow:hidden;background:#e2e8e9}
|
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}
|
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
|
||||||
canvas{display:block;width:100%;height:100%;touch-action:none}
|
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[hidden]{display:none}
|
||||||
#boot span{display:flex;align-items:center;gap:10px;font-size:13px}
|
.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 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-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)}}
|
@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>
|
</style>
|
||||||
<script type="importmap">{"imports":{"three":"./vendor/three/three.module.js","three/addons/":"./vendor/three/addons/"}}</script>
|
<script type="importmap">{"imports":{"three":"./vendor/three/three.module.js","three/addons/":"./vendor/three/addons/"}}</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import {createRenderEffects} from './render-effects.mjs';
|
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 {createAssetInspector} from './asset-inspector.mjs?v=29-platform2';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
|
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
|
||||||
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
|
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
|
||||||
import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js';
|
import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js';
|
||||||
import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=33-scada-markers';
|
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';
|
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';
|
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 net,networkRoot,networkStyle,navigation,inspector;
|
||||||
let mode='network',generation=0,contextVisible=true,roofVisible=true;
|
let mode='network',generation=0,contextVisible=true,roofVisible=true;
|
||||||
let sceneStatus='正在载入三维模型';
|
let sceneStatus='正在载入三维模型';
|
||||||
|
let sceneReady=false;
|
||||||
let cameraState={active:null,note:'正在准备观察位置',views:[]};
|
let cameraState={active:null,note:'正在准备观察位置',views:[]};
|
||||||
let appearanceState={preset:'day',exposure:.95,contextOpacity:.4,shadows:true,effects:true,quality:'standard'};
|
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('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});});
|
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 viewport=()=>({width:Math.max(240,innerWidth),height:Math.max(240,innerHeight)});
|
||||||
const initialViewport=viewport();
|
const initialViewport=viewport();
|
||||||
const renderer=new THREE.WebGLRenderer({antialias:true,logarithmicDepthBuffer:false});
|
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 appearance=createAppearance(renderer,scene);
|
||||||
const effects=createRenderEffects(renderer,scene,camera,initialViewport.width,initialViewport.height);
|
const effects=createRenderEffects(renderer,scene,camera,initialViewport.width,initialViewport.height);
|
||||||
const loader=new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
|
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);
|
const cache=new Map(),root=new THREE.Group();scene.add(root);
|
||||||
|
|
||||||
function isBuildingResource(resourceId=''){
|
function isBuildingResource(resourceId=''){
|
||||||
@@ -86,11 +95,11 @@ async function load(id){
|
|||||||
if(!cache.has(id)){
|
if(!cache.has(id)){
|
||||||
const asset=manifest.assets.find(item=>item.id===id);
|
const asset=manifest.assets.find(item=>item.id===id);
|
||||||
if(!asset)throw Error('场景清单中不存在资源:'+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;
|
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};});
|
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;
|
return gltf.scene;
|
||||||
}));
|
}).catch(error=>{cache.delete(id);throw error;}));
|
||||||
}
|
}
|
||||||
return cache.get(id);
|
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==='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==='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(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+' 组场景资源');
|
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)=>{
|
root.clear();objects.forEach((object,index)=>{
|
||||||
object.position.set(next==='meters'?index*1.5:0,0,0);object.visible=true;root.add(object);
|
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);});
|
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();
|
networkStyle?.setFocusScope(next==='hydraulic');applyVisibility();inspector?.highlight();if(frame)fit();
|
||||||
window.webQA.loaded=ids;window.webQA.mode=next;
|
window.webQA.loaded=ids;window.webQA.mode=next;
|
||||||
setStatus(next==='pump'?'六泵实体与 CAD 管路参考,接口配准待核':next==='meters'?'设备样件,主管不包含在样件内':['network','hydraulic'].includes(next)?'管网实体模型,建筑为透明背景':'已载入 '+ids.length+' 组场景资源');
|
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 updateStyle(){appearance.refresh();inspector?.refresh();window.webQA.networkStyle={...networkStyle.summary,style:networkStyle.style};postState();}
|
||||||
function setDisplayMode(value){networkStyle.setDisplayMode(value);navigation?.stop();updateStyle();}
|
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);
|
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();
|
startLoading([manifest.networkModel.metadata,manifest.networkModel.file,...manifest.networkContext.map(id=>manifest.assets.find(asset=>asset.id===id).file)]);
|
||||||
const built=await loader.loadAsync(manifest.networkModel.file+'?v='+manifest.networkModel.sha256);networkRoot=built.scene;enhanceMaterials(networkRoot);scene.add(networkRoot);
|
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});
|
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 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');}
|
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');
|
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()});
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,7 +3,12 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import MapComponent from "@components/olmap/core/MapComponent";
|
import MapComponent from "@components/olmap/core/MapComponent";
|
||||||
|
import { MapNavigationContent } from "@components/loading/NavigationFeedback";
|
||||||
|
|
||||||
export default function MapLayout({ children }: { children: ReactNode }) {
|
export default function MapLayout({ children }: { children: ReactNode }) {
|
||||||
return <MapComponent>{children}</MapComponent>;
|
return (
|
||||||
|
<MapComponent>
|
||||||
|
<MapNavigationContent>{children}</MapNavigationContent>
|
||||||
|
</MapComponent>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -6,6 +6,10 @@ import authOptions from "@app/api/auth/[...nextauth]/options";
|
|||||||
import { Header } from "@components/header";
|
import { Header } from "@components/header";
|
||||||
import { Title } from "@components/title";
|
import { Title } from "@components/title";
|
||||||
import { AppSider } from "@components/sider/AppSider";
|
import { AppSider } from "@components/sider/AppSider";
|
||||||
|
import {
|
||||||
|
NavigationFeedbackProvider,
|
||||||
|
PageNavigationContent,
|
||||||
|
} from "@components/loading/NavigationFeedback";
|
||||||
import { ThemedLayout } from "@refinedev/mui";
|
import { ThemedLayout } from "@refinedev/mui";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -32,24 +36,26 @@ export default async function MainLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemedLayout
|
<NavigationFeedbackProvider>
|
||||||
Header={Header}
|
<ThemedLayout
|
||||||
Title={Title}
|
Header={Header}
|
||||||
Sider={AppSider}
|
Title={Title}
|
||||||
childrenBoxProps={{
|
Sider={AppSider}
|
||||||
sx: {
|
childrenBoxProps={{
|
||||||
flex: 1,
|
sx: {
|
||||||
minHeight: 0,
|
flex: 1,
|
||||||
overflow: "auto",
|
minHeight: 0,
|
||||||
p: 0,
|
overflow: "hidden",
|
||||||
},
|
p: 0,
|
||||||
}}
|
},
|
||||||
containerBoxProps={{
|
}}
|
||||||
sx: { height: "100vh", overflow: "hidden" },
|
containerBoxProps={{
|
||||||
}}
|
sx: { height: "100vh", overflow: "hidden" },
|
||||||
>
|
}}
|
||||||
{children}
|
>
|
||||||
</ThemedLayout>
|
<PageNavigationContent>{children}</PageNavigationContent>
|
||||||
|
</ThemedLayout>
|
||||||
|
</NavigationFeedbackProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageLoadingSkeleton } from "@components/loading/PageLoadingSkeleton";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <PageLoadingSkeleton />;
|
||||||
|
}
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Box, CircularProgress } from "@mui/material";
|
import { PageLoadingSkeleton } from "@components/loading/PageLoadingSkeleton";
|
||||||
|
|
||||||
const ThreeDimensionalScene = dynamic(
|
const ThreeDimensionalScene = dynamic(
|
||||||
() => import("@components/threeDimensional/ThreeDimensionalScene"),
|
() => import("@components/threeDimensional/ThreeDimensionalScene"),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => <PageLoadingSkeleton pathname="/three-dimensional-scene" />,
|
||||||
<Box sx={{ height: "100%", display: "grid", placeItems: "center" }}>
|
|
||||||
<CircularProgress size={32} />
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { usePathname } from "next/navigation";
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
import routerProvider from "@refinedev/nextjs-router";
|
import routerProvider from "@refinedev/nextjs-router";
|
||||||
|
import { NavigationLink } from "@components/loading/NavigationLink";
|
||||||
|
|
||||||
import { ColorModeContextProvider } from "@contexts/color-mode";
|
import { ColorModeContextProvider } from "@contexts/color-mode";
|
||||||
import { dataProvider } from "@providers/data-provider";
|
import { dataProvider } from "@providers/data-provider";
|
||||||
@@ -46,6 +47,8 @@ type RefineContextProps = {
|
|||||||
defaultMode?: string;
|
defaultMode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const appRouterProvider = { ...routerProvider, Link: NavigationLink };
|
||||||
|
|
||||||
export const RefineContext = (
|
export const RefineContext = (
|
||||||
props: React.PropsWithChildren<RefineContextProps>,
|
props: React.PropsWithChildren<RefineContextProps>,
|
||||||
) => (
|
) => (
|
||||||
@@ -377,7 +380,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
<ColorModeContextProvider defaultMode={props.defaultMode}>
|
<ColorModeContextProvider defaultMode={props.defaultMode}>
|
||||||
<RefineSnackbarProvider>
|
<RefineSnackbarProvider>
|
||||||
<Refine
|
<Refine
|
||||||
routerProvider={routerProvider}
|
routerProvider={appRouterProvider}
|
||||||
dataProvider={dataProvider}
|
dataProvider={dataProvider}
|
||||||
notificationProvider={useAppNotificationProvider}
|
notificationProvider={useAppNotificationProvider}
|
||||||
authProvider={authProvider}
|
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} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { NavigationLink } from "@components/loading/NavigationLink";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
FiAlertCircle,
|
FiAlertCircle,
|
||||||
@@ -38,8 +38,8 @@ import {
|
|||||||
type SceneRuntimeState,
|
type SceneRuntimeState,
|
||||||
} from "./sceneProtocol";
|
} from "./sceneProtocol";
|
||||||
|
|
||||||
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=34-detailed-network-context";
|
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=36-resumable-cache";
|
||||||
const SCENE_LOAD_TIMEOUT_MS = 30_000;
|
const SCENE_LOAD_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
const initialRuntimeState: SceneRuntimeState = {
|
const initialRuntimeState: SceneRuntimeState = {
|
||||||
mode: "network",
|
mode: "network",
|
||||||
@@ -138,6 +138,9 @@ export default function ThreeDimensionalScene() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [sceneError, setSceneError] = 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 [refreshVersion, setRefreshVersion] = useState(0);
|
||||||
const [sceneKey, setSceneKey] = useState(0);
|
const [sceneKey, setSceneKey] = useState(0);
|
||||||
|
|
||||||
@@ -169,6 +172,9 @@ export default function ThreeDimensionalScene() {
|
|||||||
setSelection(null);
|
setSelection(null);
|
||||||
setRuntimeState(initialRuntimeState);
|
setRuntimeState(initialRuntimeState);
|
||||||
setSceneError(null);
|
setSceneError(null);
|
||||||
|
setSceneLoading(true);
|
||||||
|
setSceneResourceError(false);
|
||||||
|
setSceneStarted(false);
|
||||||
setSceneKey((value) => value + 1);
|
setSceneKey((value) => value + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -198,6 +204,17 @@ export default function ThreeDimensionalScene() {
|
|||||||
});
|
});
|
||||||
setRuntimeState(message.state);
|
setRuntimeState(message.state);
|
||||||
setSceneError(null);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type === "scene-state") {
|
if (message.type === "scene-state") {
|
||||||
@@ -222,12 +239,14 @@ export default function ThreeDimensionalScene() {
|
|||||||
}, [isZjbProject, sceneKey]);
|
}, [isZjbProject, sceneKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
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(() => {
|
const timerId = window.setTimeout(() => {
|
||||||
setSceneError("三维模型载入超时,请检查静态资源后重试。");
|
setSceneError("三维模型载入超时,请检查静态资源后重试。");
|
||||||
}, SCENE_LOAD_TIMEOUT_MS);
|
}, SCENE_LOAD_TIMEOUT_MS);
|
||||||
return () => window.clearTimeout(timerId);
|
return () => window.clearTimeout(timerId);
|
||||||
}, [isZjbProject, model, sceneKey]);
|
}, [isZjbProject, model, sceneKey, sceneLoading, sceneStarted]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isZjbProject) return;
|
if (!isZjbProject) return;
|
||||||
@@ -301,9 +320,9 @@ export default function ThreeDimensionalScene() {
|
|||||||
<p className="mt-1 text-sm leading-6 text-slate-600">
|
<p className="mt-1 text-sm leading-6 text-slate-600">
|
||||||
目前仅高铁湛江北站项目提供三维模型。请切换到 ZJB 项目后再打开此页面。
|
目前仅高铁湛江北站项目提供三维模型。请切换到 ZJB 项目后再打开此页面。
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -324,14 +343,7 @@ export default function ThreeDimensionalScene() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main lang="zh-CN" className="relative h-full min-h-0 overflow-hidden bg-slate-200">
|
<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" />
|
<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" : ""}`} />
|
||||||
|
|
||||||
{!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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(loadError || sceneError) && (
|
{(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">
|
<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
|
<ThreeDimensionalControls
|
||||||
open={controlsOpen}
|
open={controlsOpen}
|
||||||
activeTab={controlTab}
|
activeTab={controlTab}
|
||||||
ready={Boolean(model)}
|
ready={Boolean(model) && !sceneLoading && !sceneResourceError}
|
||||||
state={runtimeState}
|
state={runtimeState}
|
||||||
selection={selection}
|
selection={selection}
|
||||||
timelineOpen={timelineOpen}
|
timelineOpen={timelineOpen}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe("ZJB three-dimensional runtime assets", () => {
|
|||||||
renderRevision: number;
|
renderRevision: number;
|
||||||
assets: Array<{ file: string; sha256: string }>;
|
assets: Array<{ file: string; sha256: string }>;
|
||||||
networkContext: string[];
|
networkContext: string[];
|
||||||
networkModel: { file: string; metadata: string; sha256: string };
|
networkModel: { file: string; metadata: string; sha256: string; metadataSha256: string };
|
||||||
sourceCatalogFile?: string;
|
sourceCatalogFile?: string;
|
||||||
bindingsFile?: 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.file))).toBe(true);
|
||||||
expect(existsSync(join(sceneRoot, manifest.networkModel.metadata))).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.sourceCatalogFile).toBeUndefined();
|
||||||
expect(manifest.bindingsFile).toBeUndefined();
|
expect(manifest.bindingsFile).toBeUndefined();
|
||||||
|
|
||||||
@@ -67,6 +70,8 @@ describe("ZJB three-dimensional runtime assets", () => {
|
|||||||
"network-style.mjs",
|
"network-style.mjs",
|
||||||
"integration.mjs",
|
"integration.mjs",
|
||||||
"render-effects.mjs",
|
"render-effects.mjs",
|
||||||
|
"resourceCache.mjs",
|
||||||
|
"loadingProgress.mjs",
|
||||||
"vendor/meshopt_decoder.module.js",
|
"vendor/meshopt_decoder.module.js",
|
||||||
"vendor/meshoptimizer-LICENSE.md",
|
"vendor/meshoptimizer-LICENSE.md",
|
||||||
"vendor/three/LICENSE",
|
"vendor/three/LICENSE",
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export type SceneRuntimeMessage = HostMessageBase &
|
|||||||
state: SceneRuntimeState;
|
state: SceneRuntimeState;
|
||||||
}
|
}
|
||||||
| { type: "scene-state"; state: SceneRuntimeState }
|
| { type: "scene-state"; state: SceneRuntimeState }
|
||||||
|
| { type: "loading"; active: boolean; failed?: boolean }
|
||||||
| { type: "selection-changed"; selection: SceneAssetSelection | null }
|
| { type: "selection-changed"; selection: SceneAssetSelection | null }
|
||||||
| { type: "results-applied"; summary: SceneStyleSummary }
|
| { type: "results-applied"; summary: SceneStyleSummary }
|
||||||
| { type: "results-cleared" }
|
| { type: "results-cleared" }
|
||||||
|
|||||||
Reference in New Issue
Block a user