Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a522bf42a | ||
|
|
c4a90be0d3 | ||
|
|
1b275206a9 | ||
|
|
cdf97afbc4 | ||
|
|
ca10d8f384 | ||
|
|
1622eaa2da |
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockBackend } from "./support/mockBackend";
|
||||
|
||||
test("地图缩放不会重新请求已打开的 SCADA 历史数据", async ({ page }) => {
|
||||
await mockBackend(page);
|
||||
|
||||
await page.route("**/ows?**", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
id: "S-1",
|
||||
properties: {
|
||||
id: "S-1",
|
||||
device_type: "pressure",
|
||||
reliability: 0.95,
|
||||
transmission_frequency: "00:15:00",
|
||||
},
|
||||
geometry: { type: "Point", coordinates: [13530000, 3620000] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/v1/scada-devices", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1", link_id: null },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
let historyRequests = 0;
|
||||
await page.route("**/api/v1/timeseries/views/element-history/query", (route) => {
|
||||
historyRequests += 1;
|
||||
return route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ series: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/network-simulation");
|
||||
await page.getByText("S-1", { exact: true }).click();
|
||||
await expect(page.getByText("SCADA 历史数据")).toBeVisible();
|
||||
await expect.poll(() => historyRequests).toBe(1);
|
||||
|
||||
const zoomLabel = page.getByText(/缩放: \d+\.\d/);
|
||||
const initialZoom = await zoomLabel.textContent();
|
||||
// The history drawer covers the map controls. Invoke their real handlers
|
||||
// without targeting a covered screen coordinate.
|
||||
await page.getByRole("button", { name: "放大" }).evaluate((button: HTMLButtonElement) => button.click());
|
||||
await expect(zoomLabel).not.toHaveText(initialZoom!);
|
||||
expect(historyRequests).toBe(1);
|
||||
|
||||
await page.getByRole("button", { name: "缩小" }).evaluate((button: HTMLButtonElement) => button.click());
|
||||
await expect(zoomLabel).toHaveText(initialZoom!);
|
||||
expect(historyRequests).toBe(1);
|
||||
});
|
||||
@@ -4,6 +4,41 @@ import { mockBackend } from "./support/mockBackend";
|
||||
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test("首次加载和失败提示位于工具面板上方,窄屏可直接重试", async ({ page }) => {
|
||||
await mockBackend(page);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("active_project", "playwright-project");
|
||||
localStorage.setItem("MAP_WORKSPACE", "zjb");
|
||||
localStorage.setItem("NETWORK_NAME", "zjb");
|
||||
});
|
||||
let release: () => void = () => {};
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
const failedModel = "**/models/detail_roof.glb*";
|
||||
await page.route(failedModel, async (route) => { await pending; await route.abort("failed"); });
|
||||
await page.goto("/three-dimensional-scene");
|
||||
const scene = page.frameLocator('iframe[title="高铁湛江北站供水系统三维场景"]');
|
||||
await expect(scene.locator("#boot-title")).toHaveText("正在载入场景资源 9 / 10", { timeout: 60_000 });
|
||||
const loadingMaterial = await scene.locator(".boot-panel").evaluate((element) => ({
|
||||
background: getComputedStyle(element).backgroundImage,
|
||||
blur: getComputedStyle(element).backdropFilter,
|
||||
}));
|
||||
for (const width of [1280, 375, 320]) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
expect(await page.evaluate(() => document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.tagName)).toBe("IFRAME");
|
||||
await page.screenshot({ path: `test-results/scene-host-loading-${width}.png` });
|
||||
}
|
||||
release();
|
||||
await expect(scene.locator("#boot-title")).toHaveText("场景资源加载失败");
|
||||
await page.unroute(failedModel);
|
||||
await scene.getByRole("button", { name: "重新载入场景" }).click();
|
||||
await expect(scene.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
||||
await expect(page.getByRole("slider", { name: /建筑背景透明度/ })).toBeEnabled();
|
||||
expect(await page.getByLabel("三维场景控制面板").evaluate((element) => ({
|
||||
background: getComputedStyle(element).backgroundImage,
|
||||
blur: getComputedStyle(element).backdropFilter,
|
||||
}))).toEqual(loadingMaterial);
|
||||
});
|
||||
|
||||
type CameraProbe = {
|
||||
active: string | null;
|
||||
position: number[];
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const root = "/three-dimensional/zjb/v29/";
|
||||
const preview = root + "preview.html?host=platform2&v=35-resource-cache";
|
||||
const isAsset = (url: string) => /\.glb(?:\?|$)|water-network-v28\.json/.test(url);
|
||||
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test("HTTP 200 的错误元数据不会进入缓存,服务器恢复后重试成功", async ({ page }) => {
|
||||
let requests = 0;
|
||||
await page.route("**/water-network-v28.json*", async (route) => {
|
||||
requests++;
|
||||
if (requests === 1) return route.fulfill({ json: { error: "temporary proxy failure" } });
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto(preview);
|
||||
await expect(page.locator("#boot")).toHaveAttribute("data-state", "error", { timeout: 60_000 });
|
||||
const cached = await page.evaluate((base) => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open("tjwater-scene-assets-v1", 1);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const database = request.result;
|
||||
const read = database.transaction("resources").objectStore("resources").get(new URL(base + "water-network-v28.json", location.origin).href);
|
||||
read.onsuccess = () => { resolve(Boolean(read.result)); database.close(); };
|
||||
read.onerror = () => { reject(read.error); database.close(); };
|
||||
};
|
||||
}), root);
|
||||
expect(cached).toBe(false);
|
||||
await page.getByRole("button", { name: "重新载入场景" }).click();
|
||||
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
||||
expect(requests).toBe(2);
|
||||
});
|
||||
|
||||
test("模型和元数据跨页面复用持久缓存,即使服务器资源不可用也能重开场景", async ({ page, context }) => {
|
||||
const requests: string[] = [];
|
||||
let block = false;
|
||||
// Routing disables HTTP cache, so this checks IndexedDB rather than browser HTTP caching.
|
||||
await context.route("**/three-dimensional/**", (route) => {
|
||||
if (isAsset(route.request().url())) {
|
||||
requests.push(route.request().url());
|
||||
if (block) return route.abort("failed");
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
const coldStart = Date.now();
|
||||
await page.goto(preview);
|
||||
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
||||
const coldMs = Date.now() - coldStart;
|
||||
expect(requests).toHaveLength(10);
|
||||
requests.length = 0;
|
||||
block = true;
|
||||
await page.close();
|
||||
const reopened = await context.newPage();
|
||||
const warmStart = Date.now();
|
||||
await reopened.goto(preview);
|
||||
await expect(reopened.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
||||
expect(await reopened.evaluate(() =>
|
||||
(window as typeof window & { webQA: { camera: { active: string } } }).webQA.camera.active,
|
||||
)).toBe("overview");
|
||||
expect(requests).toEqual([]);
|
||||
console.log(JSON.stringify({ coldMs, warmMs: Date.now() - warmStart, warmAssetRequests: requests.length }));
|
||||
});
|
||||
|
||||
test("首次载入显示真实进度,失败保留提示,重试可以恢复", async ({ page }) => {
|
||||
let release: () => void = () => {};
|
||||
const pending = new Promise<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: 503, body: "unavailable" })
|
||||
: requests === 2
|
||||
? route.fulfill({ body: "invalid json" })
|
||||
: route.fulfill({ json: { ok: true } });
|
||||
});
|
||||
await page.goto(root + "manifest.json");
|
||||
const result = await page.evaluate(async (base) => {
|
||||
const moduleUrl = base + "resourceCache.mjs";
|
||||
const { createSceneResourceCache } = await import(moduleUrl);
|
||||
const cache = createSceneResourceCache(location.origin + base);
|
||||
const decode = (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data));
|
||||
const errors: string[] = [];
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try { await cache.load("cache-probe.json", "first", decode); }
|
||||
catch (error) { errors.push(String(error)); }
|
||||
}
|
||||
return { errors, recovered: await cache.load("cache-probe.json", "first", decode) };
|
||||
}, root);
|
||||
expect(requests).toBe(3);
|
||||
expect(result.errors).toHaveLength(2);
|
||||
expect(result.errors[0]).toContain("HTTP 503");
|
||||
expect(result.recovered).toEqual({ ok: true });
|
||||
});
|
||||
@@ -76,7 +76,7 @@ export function createCameraNavigation({camera,controls,model,networkRoot,root,s
|
||||
flight={start:performance.now(),from:camera.position.clone(),targetFrom:controls.target.clone(),to:new THREE.Vector3(...pose.position),targetTo:new THREE.Vector3(...pose.target)};
|
||||
if(matchMedia('(prefers-reduced-motion: reduce)').matches){camera.position.copy(flight.to);controls.target.copy(flight.targetTo);flight=null;controls.update();}
|
||||
emit(p.note??'已保存的相机位置');
|
||||
}catch(e){if(token===request)emit('视角加载失败:'+e.message);}
|
||||
}catch(e){if(token===request)emit('视角加载失败:'+e.message);throw e;}
|
||||
}
|
||||
function tick(now){if(!flight)return;const t=Math.min(1,(now-flight.start)/500),v=t*t*(3-2*t);camera.position.lerpVectors(flight.from,flight.to,v);controls.target.lerpVectors(flight.targetFrom,flight.targetTo,v);if(t===1)flight=null;}
|
||||
function save(label){label=label.trim();if(!label||label.length>24)throw Error('请输入 1–24 字的视角名称。');if(saved.length>=8)throw Error('最多保存 8 个视角,请先移除不用的视角。');const p={id:'saved-'+Date.now(),label,mode:getMode(),displayMode:getDisplayMode(),position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray(),context:root.visible,roof:root.children.filter(o=>/roof|ceiling/.test(o.userData.resourceId)).every(o=>o.visible)};p.layers=Object.fromEntries(root.children.map(o=>[o.userData.resourceId,o.visible]));const next=[...saved,p];localStorage.setItem(key,JSON.stringify(next));saved=next;active=p.id;emit('视角已保存在本机浏览器');}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
// Framework-independent data adapter. Does not parse or modify the project's INP.
|
||||
export const CAD_ORIGIN=Object.freeze([513800,2344450]);
|
||||
// This decoder runs before IDB writes, including when checking an existing entry.
|
||||
// A parseable HTTP 200 error response must not become a permanent model cache.
|
||||
export function parseNetworkMetadata(data,modelId){
|
||||
const model=JSON.parse(new TextDecoder().decode(data));
|
||||
const arrays=['nodes','links','pipeInstances','nodeInstances','bendInstances','cadEquipmentBindings','pumpEntityBindings','cadAttachments'];
|
||||
const objects=['layouts','connectivity','coordination','coordinatedLayouts','verticalCoordination'];
|
||||
if(!model||model.modelId!==modelId||!arrays.every(key=>Array.isArray(model[key]))||!objects.every(key=>model[key]&&typeof model[key]==='object'&&!Array.isArray(model[key]))||!model.nodes.length||!model.links.length)throw Error('管网元数据格式无效,请重新载入场景。');
|
||||
return model;
|
||||
}
|
||||
export function cadToGltf(point,displayZ=0){
|
||||
if(!Array.isArray(point)||point.length<2||!point.slice(0,2).every(Number.isFinite)||!Number.isFinite(displayZ))throw Error('Expected finite CAD XY and an explicit display height.');
|
||||
return [point[0]-CAD_ORIGIN[0],displayZ,-(point[1]-CAD_ORIGIN[1])];
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
const boot = document.getElementById('boot');
|
||||
const title = document.getElementById('boot-title');
|
||||
const detail = document.getElementById('boot-detail');
|
||||
const progress = document.getElementById('boot-progress');
|
||||
const retry = document.getElementById('boot-retry');
|
||||
const formatBytes = bytes => (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||
let entries = new Map();
|
||||
let failed = false;
|
||||
let lastPaint = 0;
|
||||
|
||||
export function beginLoading(files, message = '正在载入三维场景') {
|
||||
entries = new Map(files.map(file => [file, {stage:'waiting', loaded:0, total:0}]));
|
||||
failed = false;
|
||||
boot.hidden = false;
|
||||
boot.dataset.state = 'loading';
|
||||
boot.setAttribute('aria-busy', 'true');
|
||||
title.textContent = message;
|
||||
detail.textContent = '正在检查本地资源缓存';
|
||||
progress.hidden = false;
|
||||
progress.removeAttribute('value');
|
||||
retry.hidden = true;
|
||||
lastPaint = 0;
|
||||
}
|
||||
|
||||
export function reportProgress(update) {
|
||||
if (failed || !entries.has(update.file)) return;
|
||||
entries.set(update.file, update);
|
||||
// Large streamed files can emit hundreds of chunks in a frame.
|
||||
if (update.stage === 'download' && performance.now() - lastPaint < 100) return;
|
||||
lastPaint = performance.now();
|
||||
const values = [...entries.values()];
|
||||
const done = values.filter(item => item.stage === 'ready').length;
|
||||
const loaded = values.reduce((sum, item) => sum + item.loaded, 0);
|
||||
const cached = values.filter(item => item.stage === 'cache').length;
|
||||
const parsing = values.some(item => item.stage === 'parse');
|
||||
const downloading = values.some(item => item.stage === 'download');
|
||||
progress.max = Math.max(1, values.length);
|
||||
progress.value = done;
|
||||
title.textContent = `正在载入场景资源 ${done} / ${values.length}`;
|
||||
detail.textContent = `${parsing ? '正在解析模型' : downloading ? '正在下载资源' : cached ? '正在读取本地缓存' : '正在准备场景'} · 已读取 ${formatBytes(loaded)}`;
|
||||
}
|
||||
|
||||
export function finishLoading() {
|
||||
if (failed) return;
|
||||
boot.setAttribute('aria-busy', 'false');
|
||||
boot.hidden = true;
|
||||
}
|
||||
|
||||
export function failLoading(error) {
|
||||
failed = true;
|
||||
boot.hidden = false;
|
||||
boot.dataset.state = 'error';
|
||||
boot.setAttribute('aria-busy', 'false');
|
||||
title.textContent = '场景资源加载失败';
|
||||
detail.textContent = '部分资源未能载入,请检查网络后重试。' + (error?.message ? '(' + error.message + ')' : '');
|
||||
progress.hidden = true;
|
||||
retry.hidden = false;
|
||||
}
|
||||
|
||||
retry.addEventListener('click', () => window.location.reload());
|
||||
@@ -17006,6 +17006,16 @@
|
||||
"map_roads",
|
||||
"map_pump_shell"
|
||||
],
|
||||
"networkContext": [
|
||||
"detail_roof",
|
||||
"detail_facade",
|
||||
"detail_interior",
|
||||
"detail_ceiling",
|
||||
"map_platforms",
|
||||
"map_railway",
|
||||
"map_roads",
|
||||
"map_pump_shell"
|
||||
],
|
||||
"detailBase": [
|
||||
"map_roof",
|
||||
"map_facade",
|
||||
@@ -17084,6 +17094,7 @@
|
||||
"id": "zjb-water-network-v23",
|
||||
"file": "models/water_network_v28.glb",
|
||||
"metadata": "water-network-v28.json",
|
||||
"metadataSha256": "b9af91296f7f9269a51141b732ffb9fcd82906133eca75b96273a85f776c75f5",
|
||||
"sha256": "c3d16fd287a2e057649e3f25a939ff7edcc5f6b152c9dc09860bee7675912768",
|
||||
"runtimeInpRequired": false,
|
||||
"representationRevision": 28
|
||||
|
||||
@@ -40,6 +40,8 @@ export function attachNetworkStyle(group,model){
|
||||
model.pipeInstances.forEach((p,i)=>{if(!partsById.has(p.inpId))partsById.set(p.inpId,[]);partsById.get(p.inpId).push(i);});
|
||||
const owned=[];for(const mesh of [pipes,nodes,bends].filter(Boolean)){const material=new THREE.MeshPhysicalMaterial({color:0xffffff,roughness:.3,metalness:.22,clearcoat:.22,clearcoatRoughness:.38});mesh.material=material;mesh.castShadow=true;mesh.receiveShadow=true;owned.push(material);}
|
||||
const geo=new THREE.ConeGeometry(1,2,12),mat=new THREE.MeshBasicMaterial({color:style.arrowColor}),arrows=new THREE.InstancedMesh(geo,mat,model.links.length);arrows.name='WaterFlowArrows';arrows.userData.instances=model.links.map(l=>({assetId:'inp:link:'+l.id,inpId:l.id,kind:l.kind}));group.add(arrows);
|
||||
const markerGeo=new THREE.OctahedronGeometry(.72,0),markerMat=new THREE.MeshBasicMaterial({color:0x1d83e6,transparent:true,opacity:.96,depthTest:false,depthWrite:false}),scadaMarkers=new THREE.InstancedMesh(markerGeo,markerMat,model.nodeInstances.length);scadaMarkers.name='ScadaSensorMarkers';scadaMarkers.userData.instances=model.nodeInstances.map(n=>({assetId:'inp:node:'+n.inpId,inpId:n.inpId,kind:'SCADA'}));scadaMarkers.renderOrder=6;group.add(scadaMarkers);
|
||||
const ringGeo=new THREE.TorusGeometry(1.15,.09,8,28);ringGeo.rotateX(Math.PI/2);const ringMat=new THREE.MeshBasicMaterial({color:0x1d83e6,transparent:true,opacity:.76,depthTest:false,depthWrite:false}),scadaRings=new THREE.InstancedMesh(ringGeo,ringMat,model.nodeInstances.length);scadaRings.name='ScadaSensorRings';scadaRings.userData.instances=scadaMarkers.userData.instances;scadaRings.renderOrder=5;group.add(scadaRings);
|
||||
const matrix=new THREE.Matrix4(),dummy=new THREE.Object3D(),pos=new THREE.Vector3(),quat=new THREE.Quaternion(),scale=new THREE.Vector3();let summary={};
|
||||
function apply(){
|
||||
const layout=(displayMode==='coordinated'?model.coordinatedLayouts:model.layouts)?.[style.scale];if(!layout)throw Error('当前模型支持整数倍率 1–12。');
|
||||
@@ -57,16 +59,25 @@ export function attachNetworkStyle(group,model){
|
||||
group.traverse(o=>{if(o.userData.cadAttachment){const r=model.cadAttachments.find(r=>r.assetId===o.userData.assetId);o.traverse(mesh=>{if(!mesh.isMesh)return;if(mesh.userData.cadRadialScale)mesh.scale.set(style.scale,1,style.scale);if(mesh.userData.cadTerminalScale)mesh.scale.setScalar(style.scale);mesh.material.color.copy(color(style.mode==='pressure'?results?.nodes?.[r.hostNodeId]?.pressure:null));mesh.material.opacity=style.opacity;mesh.material.transparent=style.opacity<1;mesh.material.depthWrite=style.opacity===1;mesh.material.roughness=style.roughness;mesh.material.metalness=style.metalness;});}});
|
||||
if(focusScope){const zero=new THREE.Matrix4().makeScale(0,0,0);for(const [mesh,recs,isNode] of [[pipes,model.pipeInstances,false],[nodes,model.nodeInstances,true],[bends,model.bendInstances,false]]){if(!mesh)continue;recs.forEach((r,i)=>{if(!(isNode?scopeNodes:scopeIds).has(r.inpId))mesh.setMatrixAt(i,zero);else if(isNode&&displayMode==='coordinated'){mesh.getMatrixAt(i,matrix);matrix.decompose(pos,quat,scale);const radius=Math.max(.075,...model.links.filter(l=>scopeIds.has(l.id)&&[l.from,l.to].includes(r.inpId)).map(l=>l.diameterMm/2000));matrix.compose(pos,quat,new THREE.Vector3(radius,radius,radius));mesh.setMatrixAt(i,matrix);}});mesh.instanceMatrix.needsUpdate=true;mesh.computeBoundingBox();mesh.computeBoundingSphere();}}
|
||||
group.traverse(o=>{if(o.userData.cadAttachment)o.visible=!focusScope;if(o.userData.layoutMode&&focusScope)o.visible=false;});
|
||||
nodes.visible=style.nodes;let arrowCount=0;
|
||||
nodes.visible=style.nodes;let scadaCount=0;
|
||||
model.nodeInstances.forEach((record,index)=>{
|
||||
const visible=results?.nodes?.[record.inpId]?.source==='scada'&&(!focusScope||scopeNodes.has(record.inpId));
|
||||
if(!visible){dummy.position.set(0,0,0);dummy.scale.setScalar(0);dummy.quaternion.identity();dummy.updateMatrix();scadaMarkers.setMatrixAt(index,dummy.matrix);scadaRings.setMatrixAt(index,dummy.matrix);return;}
|
||||
nodes.getMatrixAt(index,matrix);matrix.decompose(pos,quat,scale);
|
||||
dummy.position.copy(pos);dummy.position.y+=1.18;dummy.quaternion.identity();dummy.scale.setScalar(.72);dummy.updateMatrix();scadaMarkers.setMatrixAt(index,dummy.matrix);
|
||||
dummy.position.copy(pos);dummy.position.y+=.2;dummy.quaternion.identity();dummy.scale.setScalar(.72);dummy.updateMatrix();scadaRings.setMatrixAt(index,dummy.matrix);scadaCount++;
|
||||
});
|
||||
for(const marker of [scadaMarkers,scadaRings]){marker.instanceMatrix.needsUpdate=true;marker.visible=scadaCount>0;marker.computeBoundingBox();marker.computeBoundingSphere();}
|
||||
let arrowCount=0;
|
||||
model.links.forEach((l,i)=>{
|
||||
const ids=partsById.get(l.id)??[],idx=ids[Math.floor(ids.length/2)];let direction=style.direction==='topology'?1:style.direction==='results'?metricValue('direction',l,results):0;
|
||||
if(!direction||idx===undefined||(focusScope&&!scopeIds.has(l.id))){dummy.position.set(0,0,0);dummy.scale.setScalar(0);dummy.quaternion.identity();}
|
||||
else{pipes.getMatrixAt(idx,matrix);matrix.decompose(pos,quat,scale);dummy.position.copy(pos);dummy.quaternion.copy(quat);if(direction<0)dummy.quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0),Math.PI));const r=Math.max(.22,scale.x*1.7);dummy.scale.set(r,r*1.5,r);arrowCount++;}
|
||||
dummy.updateMatrix();arrows.setMatrixAt(i,dummy.matrix);
|
||||
});arrows.instanceMatrix.needsUpdate=true;arrows.visible=style.direction!=='none';arrows.material.color.set(style.arrowColor);arrows.computeBoundingBox();arrows.computeBoundingSphere();
|
||||
summary={focusScope,displayMode,mode:style.mode,min,max,dataLinks:vals.length,totalLinks:model.links.length,arrows:arrowCount,directionMode:style.direction,hasResults:!!results,resultTime:results?.timestamp??null,scale:style.scale};return summary;
|
||||
summary={focusScope,displayMode,mode:style.mode,min,max,dataLinks:vals.length,totalLinks:model.links.length,scadaMarkers:scadaCount,arrows:arrowCount,directionMode:style.direction,hasResults:!!results,resultTime:results?.timestamp??null,scale:style.scale};return summary;
|
||||
}
|
||||
const api={setFocusScope(value){focusScope=!!value;return apply();},get displayMode(){return displayMode;},setDisplayMode(value){if(!['global','coordinated'].includes(value)||value==='coordinated'&&!model.coordinatedLayouts)throw Error('不支持的展示模式');displayMode=value;return apply();},getResult(id,kind='links'){return results?.[kind]?.[id]?structuredClone(results[kind][id]):null;},get style(){return {...style};},get summary(){return {...summary};},setStyle(patch){const next={...style,...patch};if(!Number.isInteger(next.scale)||next.scale<1||next.scale>12)throw Error('倍率需为 1–12。');if(!['uniform','velocity','pressure','direction'].includes(next.mode)||!['none','topology','results'].includes(next.direction))throw Error('未知样式。');for(const k of ['color','missingColor','lowColor','highColor','arrowColor'])if(!/^#[0-9a-f]{6}$/i.test(next[k]))throw Error('颜色需为六位十六进制。');for(const k of ['opacity','roughness','metalness'])if(!finite(next[k])||next[k]<0||next[k]>1)throw Error('样式数值须在 0–1。');if(!finite(next.min)||!finite(next.max)||next.min>=next.max)throw Error('色带上限必须大于下限。');Object.assign(style,next);return apply();},setResults(input){const parsed=validateResults(input,model);results=parsed;return apply();},clearResults(){results=null;return apply();},dispose(){geo.dispose();mat.dispose();owned.forEach(m=>m.dispose());},pipes,nodes,arrows};
|
||||
const api={setFocusScope(value){focusScope=!!value;return apply();},get displayMode(){return displayMode;},setDisplayMode(value){if(!['global','coordinated'].includes(value)||value==='coordinated'&&!model.coordinatedLayouts)throw Error('不支持的展示模式');displayMode=value;return apply();},getResult(id,kind='links'){return results?.[kind]?.[id]?structuredClone(results[kind][id]):null;},get style(){return {...style};},get summary(){return {...summary};},setStyle(patch){const next={...style,...patch};if(!Number.isInteger(next.scale)||next.scale<1||next.scale>12)throw Error('倍率需为 1–12。');if(!['uniform','velocity','pressure','direction'].includes(next.mode)||!['none','topology','results'].includes(next.direction))throw Error('未知样式。');for(const k of ['color','missingColor','lowColor','highColor','arrowColor'])if(!/^#[0-9a-f]{6}$/i.test(next[k]))throw Error('颜色需为六位十六进制。');for(const k of ['opacity','roughness','metalness'])if(!finite(next[k])||next[k]<0||next[k]>1)throw Error('样式数值须在 0–1。');if(!finite(next.min)||!finite(next.max)||next.min>=next.max)throw Error('色带上限必须大于下限。');Object.assign(style,next);return apply();},setResults(input){const parsed=validateResults(input,model);results=parsed;return apply();},clearResults(){results=null;return apply();},dispose(){geo.dispose();mat.dispose();markerGeo.dispose();markerMat.dispose();ringGeo.dispose();ringMat.dispose();owned.forEach(m=>m.dispose());},pipes,nodes,arrows,scadaMarkers,scadaRings};
|
||||
apply();return api;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,18 +8,36 @@
|
||||
html,body{width:100%;height:100%;margin:0;overflow:hidden;background:#e2e8e9}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
|
||||
canvas{display:block;width:100%;height:100%;touch-action:none}
|
||||
#boot{position:absolute;inset:0;z-index:2;display:grid;place-items:center;color:#526777;background:#e2e8e9;transition:opacity 180ms cubic-bezier(.16,1,.3,1)}
|
||||
#boot{position:absolute;inset:0;z-index:2;display:grid;place-items:center;padding:16px;box-sizing:border-box;color:#334155;background:rgba(241,245,249,.38)}
|
||||
#boot[hidden]{display:none}
|
||||
#boot span{display:flex;align-items:center;gap:10px;font-size:13px}
|
||||
#boot i{width:18px;height:18px;border:2px solid rgba(37,125,212,.22);border-top-color:#257dd4;border-radius:50%;animation:spin .8s linear infinite}
|
||||
.boot-panel{box-sizing:border-box;width:min(100%,360px);padding:24px;border-radius:16px;background:linear-gradient(135deg,rgba(255,255,255,.50),rgba(224,239,250,.28));backdrop-filter:blur(24px) saturate(170%) contrast(94%);-webkit-backdrop-filter:blur(24px) saturate(170%) contrast(94%);box-shadow:inset 0 1px 0 rgba(255,255,255,.88),inset 0 -1px 0 rgba(112,145,168,.18),0 18px 50px rgba(15,43,69,.20),0 0 0 1px rgba(255,255,255,.55)}
|
||||
.boot-heading{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:600;color:#0f172a}
|
||||
#boot i{flex:none;width:18px;height:18px;border:2px solid rgba(37,125,212,.22);border-top-color:#257dd4;border-radius:50%;animation:spin .8s linear infinite}
|
||||
#boot-detail{margin:12px 0 0;font-size:12px;line-height:1.75;overflow-wrap:anywhere}
|
||||
#boot-progress{display:block;width:100%;height:6px;margin-top:20px;accent-color:#2563eb}
|
||||
#boot-progress[hidden],#boot-retry[hidden]{display:none}
|
||||
#boot-retry{min-height:40px;margin-top:16px;padding:0 16px;border:0;border-radius:8px;background:#2563eb;color:white;font:inherit;font-size:13px;cursor:pointer;transition:transform 150ms ease,background-color 150ms ease}
|
||||
#boot-retry:hover{background:#1d4ed8}#boot-retry:active{transform:scale(.96)}#boot-retry:focus-visible{outline:2px solid #2563eb;outline-offset:3px}
|
||||
#boot[data-state=error] i{animation:none;border-color:#dc2626;background:#fee2e2}
|
||||
#boot[data-state=error] #boot-title{color:#991b1b}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
@media(prefers-reduced-motion:reduce){#boot i{animation:none;border-top-color:rgba(37,125,212,.22)}}
|
||||
@media(prefers-reduced-motion:reduce){#boot i{animation:none;border-top-color:rgba(37,125,212,.22)}#boot-retry{transition:none}}
|
||||
</style>
|
||||
<script type="importmap">{"imports":{"three":"./vendor/three/three.module.js","three/addons/":"./vendor/three/addons/"}}</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="boot" role="status"><span><i aria-hidden="true"></i>正在载入三维模型</span></div>
|
||||
<div id="boot" role="status" aria-live="polite" aria-busy="true" data-state="loading">
|
||||
<div class="boot-panel">
|
||||
<div class="boot-heading"><i aria-hidden="true"></i><span id="boot-title">正在准备三维场景</span></div>
|
||||
<p id="boot-detail">首次载入需要下载模型,后续将优先使用本地缓存。</p>
|
||||
<progress id="boot-progress" aria-label="场景资源加载进度"></progress>
|
||||
<button id="boot-retry" type="button" hidden>重新载入场景</button>
|
||||
</div>
|
||||
</div>
|
||||
<output id="qa" hidden></output>
|
||||
<script type="module" src="./preview.mjs?v=32-context-opacity"></script>
|
||||
<script type="module">
|
||||
import {failLoading} from './loadingProgress.mjs?v=35-resource-cache';
|
||||
import('./preview.mjs?v=35-resource-cache').catch(failLoading);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import {createRenderEffects} from './render-effects.mjs';
|
||||
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=31-camera-presets';
|
||||
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=35-resource-cache';
|
||||
import {createSceneResourceCache} from './resourceCache.mjs?v=35-resource-cache';
|
||||
import {beginLoading,reportProgress,finishLoading,failLoading} from './loadingProgress.mjs?v=35-resource-cache';
|
||||
import {createAssetInspector} from './asset-inspector.mjs?v=29-platform2';
|
||||
import * as THREE from 'three';
|
||||
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
|
||||
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
|
||||
import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js';
|
||||
import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=32-default-scale';
|
||||
import {cadToGltf} from './integration.mjs';
|
||||
import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=33-scada-markers';
|
||||
import {cadToGltf,parseNetworkMetadata} from './integration.mjs';
|
||||
import {createAppearance,enhanceMaterials,grain,applyBuildingContext} from './appearance.mjs?v=30-context-opacity';
|
||||
|
||||
const HOST_CHANNEL='tjwater:zjb-scene',HOST_VERSION=2,PROJECT_CODE='zjb',MODEL_ID='zjb-water-network-v23';
|
||||
let net,networkRoot,networkStyle,navigation,inspector;
|
||||
let mode='network',generation=0,contextVisible=true,roofVisible=true;
|
||||
let sceneStatus='正在载入三维模型';
|
||||
let sceneReady=false;
|
||||
let cameraState={active:null,note:'正在准备观察位置',views:[]};
|
||||
let appearanceState={preset:'day',exposure:.95,contextOpacity:.4,shadows:true,effects:true,quality:'standard'};
|
||||
|
||||
@@ -41,7 +44,12 @@ window.webQA={errors:[],loaded:[],mode:'network',contextVisible};
|
||||
window.addEventListener('error',event=>{const message=String(event.message||'三维场景运行错误');window.webQA.errors.push(message);postHost('error',{message});});
|
||||
window.addEventListener('unhandledrejection',event=>{const message=String(event.reason||'三维场景异步任务失败');window.webQA.errors.push(message);postHost('error',{message});});
|
||||
|
||||
const manifest=await (await fetch('./manifest.json',{cache:'no-store'})).json();
|
||||
const manifestResponse=await fetch('./manifest.json',{cache:'no-cache',signal:AbortSignal.timeout(30000)});
|
||||
if(!manifestResponse.ok)throw Error('场景清单加载失败:HTTP '+manifestResponse.status);
|
||||
const manifest=await manifestResponse.json();
|
||||
const resourceCache=createSceneResourceCache(import.meta.url,reportProgress);
|
||||
function startLoading(files){beginLoading(files);postHost('loading',{active:true});}
|
||||
function completeLoading(){finishLoading();postHost('loading',{active:false});}
|
||||
const viewport=()=>({width:Math.max(240,innerWidth),height:Math.max(240,innerHeight)});
|
||||
const initialViewport=viewport();
|
||||
const renderer=new THREE.WebGLRenderer({antialias:true,logarithmicDepthBuffer:false});
|
||||
@@ -52,6 +60,7 @@ const controls=new OrbitControls(camera,renderer.domElement);
|
||||
const appearance=createAppearance(renderer,scene);
|
||||
const effects=createRenderEffects(renderer,scene,camera,initialViewport.width,initialViewport.height);
|
||||
const loader=new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
|
||||
const loadModel=asset=>resourceCache.load(asset.file,asset.sha256,data=>loader.parseAsync(data,new URL('./',new URL(asset.file,import.meta.url)).href));
|
||||
const cache=new Map(),root=new THREE.Group();scene.add(root);
|
||||
|
||||
function isBuildingResource(resourceId=''){
|
||||
@@ -86,22 +95,28 @@ async function load(id){
|
||||
if(!cache.has(id)){
|
||||
const asset=manifest.assets.find(item=>item.id===id);
|
||||
if(!asset)throw Error('场景清单中不存在资源:'+id);
|
||||
cache.set(id,loader.loadAsync(asset.file).then(gltf=>{
|
||||
cache.set(id,loadModel(asset).then(gltf=>{
|
||||
enhanceMaterials(gltf.scene);gltf.scene.userData.assetId=asset.logicalId??asset.assetId;gltf.scene.userData.resourceId=id;
|
||||
gltf.scene.traverse(object=>{if(object.isMesh)for(const material of Array.isArray(object.material)?object.material:[object.material])material.userData.original={opacity:material.opacity,transparent:material.transparent,depthWrite:material.depthWrite};});
|
||||
return gltf.scene;
|
||||
}));
|
||||
}).catch(error=>{cache.delete(id);throw error;}));
|
||||
}
|
||||
return cache.get(id);
|
||||
}
|
||||
async function show(next,{frame=true}={}){
|
||||
if(!['network','hydraulic','map','detail','pump','meters'].includes(next))throw Error('不支持的场景模式:'+next);
|
||||
const token=++generation;mode=next;let ids=manifest.mapDefault;
|
||||
const token=++generation;mode=next;let ids=next==='network'?manifest.networkContext:manifest.mapDefault;
|
||||
if(next==='detail')ids=[...manifest.mapDefault.filter(id=>!['map_roof','map_facade'].includes(id)),'detail_roof','detail_facade','detail_interior','detail_ceiling'];
|
||||
if(next==='pump')ids=['detail_pump_walls','detail_pump_access','detail_pump_auxiliary','detail_pump_piping_reference',...manifest.assets.filter(asset=>asset.id.startsWith('physical_')).map(asset=>asset.id)];
|
||||
if(next==='meters')ids=['meter_dn150','valve_dn250','pump_symbol'];
|
||||
if(sceneReady)startLoading(ids.map(id=>manifest.assets.find(asset=>asset.id===id).file));
|
||||
setStatus('正在载入 '+ids.length+' 组场景资源');
|
||||
const objects=await Promise.all(ids.map(load));if(token!==generation)return;
|
||||
let objects;
|
||||
try{objects=await Promise.all(ids.map(async id=>{
|
||||
const object=await load(id);const asset=manifest.assets.find(item=>item.id===id);
|
||||
reportProgress({file:asset.file,stage:'ready',loaded:asset.bytes,total:asset.bytes});return object;
|
||||
}));}catch(error){if(token===generation){setStatus('场景资源加载失败,请重试');failLoading(error);postHost('loading',{active:false,failed:true});}throw error;}
|
||||
if(token!==generation)return;
|
||||
root.clear();objects.forEach((object,index)=>{
|
||||
object.position.set(next==='meters'?index*1.5:0,0,0);object.visible=true;root.add(object);
|
||||
object.traverse(mesh=>{if(mesh.isMesh)for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])applyBuildingContext(material,['network','hydraulic'].includes(next),appearanceState.contextOpacity);});
|
||||
@@ -110,6 +125,7 @@ async function show(next,{frame=true}={}){
|
||||
networkStyle?.setFocusScope(next==='hydraulic');applyVisibility();inspector?.highlight();if(frame)fit();
|
||||
window.webQA.loaded=ids;window.webQA.mode=next;
|
||||
setStatus(next==='pump'?'六泵实体与 CAD 管路参考,接口配准待核':next==='meters'?'设备样件,主管不包含在样件内':['network','hydraulic'].includes(next)?'管网实体模型,建筑为透明背景':'已载入 '+ids.length+' 组场景资源');
|
||||
completeLoading();
|
||||
}
|
||||
function updateStyle(){appearance.refresh();inspector?.refresh();window.webQA.networkStyle={...networkStyle.summary,style:networkStyle.style};postState();}
|
||||
function setDisplayMode(value){networkStyle.setDisplayMode(value);navigation?.stop();updateStyle();}
|
||||
@@ -161,8 +177,9 @@ renderer.setAnimationLoop(()=>{
|
||||
effects.render(mode);window.webQA.drawCalls=renderer.info.render.calls;window.webQA.triangles=renderer.info.render.triangles;window.webQA.camera={active:cameraState.active,position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray()};document.getElementById('qa').textContent=JSON.stringify(window.webQA);
|
||||
});
|
||||
|
||||
net=await (await fetch(manifest.networkModel.metadata,{cache:'no-store'})).json();
|
||||
const built=await loader.loadAsync(manifest.networkModel.file+'?v='+manifest.networkModel.sha256);networkRoot=built.scene;enhanceMaterials(networkRoot);scene.add(networkRoot);
|
||||
startLoading([manifest.networkModel.metadata,manifest.networkModel.file,...manifest.networkContext.map(id=>manifest.assets.find(asset=>asset.id===id).file)]);
|
||||
net=await resourceCache.load(manifest.networkModel.metadata,manifest.networkModel.metadataSha256,data=>parseNetworkMetadata(data,manifest.networkModel.id));
|
||||
const built=await loadModel(manifest.networkModel);networkRoot=built.scene;enhanceMaterials(networkRoot);scene.add(networkRoot);
|
||||
networkStyle=attachNetworkStyle(networkRoot,net);grain(networkStyle.pipes.material,{frequency:140,amplitude:.00004,variation:.08});
|
||||
try{const saved=localStorage.getItem('zjb-network-style-v24');if(saved)networkStyle.setStyle(JSON.parse(saved));}catch{}
|
||||
try{const quality=localStorage.getItem('zjb-render-quality-v27')??'standard';effects.setQuality(quality);appearanceState.quality=quality;}catch{effects.setQuality('standard');}
|
||||
@@ -204,5 +221,6 @@ window.addEventListener('message',async event=>{
|
||||
});
|
||||
|
||||
await navigation.visit('overview');
|
||||
document.getElementById('boot').hidden=true;
|
||||
sceneReady=true;
|
||||
completeLoading();
|
||||
postHost('ready',{nodeIds:net.nodes.map(node=>String(node.id)),linkIds:net.links.map(link=>String(link.id)),state:runtimeState()});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Only static scene assets enter this database. Live simulation/SCADA data do not.
|
||||
const DATABASE = 'tjwater-scene-assets-v1';
|
||||
const STORE = 'resources';
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
function openDatabase() {
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = value => { if (!settled) { settled = true; resolve(value); } };
|
||||
try {
|
||||
const request = globalThis.indexedDB.open(DATABASE, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(STORE, {keyPath:'url'});
|
||||
request.onerror = request.onblocked = () => finish(null);
|
||||
request.onsuccess = () => {
|
||||
const database = request.result;
|
||||
if (settled) { database.close(); return; }
|
||||
database.onversionchange = () => database.close();
|
||||
finish(database);
|
||||
};
|
||||
} catch { finish(null); }
|
||||
});
|
||||
}
|
||||
|
||||
function transact(database, mode, operation) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORE, mode);
|
||||
const request = operation(transaction.objectStore(STORE));
|
||||
transaction.oncomplete = () => resolve(request.result);
|
||||
transaction.onerror = transaction.onabort = () => reject(transaction.error ?? request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export function createSceneResourceCache(baseUrl, onProgress = () => {}) {
|
||||
// Open lazily, after synchronous WebGL setup, so database callbacks can run promptly.
|
||||
let database;
|
||||
const pending = new Map();
|
||||
|
||||
async function read(file, revision, decode) {
|
||||
const url = new URL(file, baseUrl);
|
||||
const key = url.href;
|
||||
const report = (stage, loaded = 0, total = 0) => onProgress({file, stage, loaded, total});
|
||||
const storage = await (database ??= openDatabase());
|
||||
if (storage && revision) {
|
||||
try {
|
||||
const entry = await transact(storage, 'readonly', store => store.get(key));
|
||||
if (entry?.revision === revision && entry.data instanceof ArrayBuffer) {
|
||||
report('cache', entry.data.byteLength, entry.data.byteLength);
|
||||
const result = await decode(entry.data);
|
||||
report('ready', entry.data.byteLength, entry.data.byteLength);
|
||||
return result;
|
||||
}
|
||||
} catch { /* Unavailable or invalid cache: fetch a fresh copy below. */ }
|
||||
}
|
||||
|
||||
// Revisioned URLs also keep the browser's HTTP cache in sync with the manifest.
|
||||
if (revision) url.searchParams.set('v', revision);
|
||||
const controller = new AbortController();
|
||||
let timer;
|
||||
const resetTimeout = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
};
|
||||
report('download');
|
||||
resetTimeout();
|
||||
let data;
|
||||
try {
|
||||
const response = await fetch(url, {signal:controller.signal, cache:'no-cache'});
|
||||
if (!response.ok) throw Error('HTTP ' + response.status);
|
||||
const total = Number(response.headers.get('content-length')) || 0;
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let loaded = 0;
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value); loaded += value.byteLength;
|
||||
report('download', loaded, total); resetTimeout();
|
||||
}
|
||||
const bytes = new Uint8Array(loaded);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
||||
data = bytes.buffer;
|
||||
} else {
|
||||
data = await response.arrayBuffer();
|
||||
}
|
||||
} catch (error) {
|
||||
throw Error(file + ':' + (controller.signal.aborted ? '下载超时,请重试' : error.message));
|
||||
} finally { clearTimeout(timer); }
|
||||
|
||||
report('parse', data.byteLength, data.byteLength);
|
||||
// Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache.
|
||||
const result = await decode(data);
|
||||
if (storage && revision) {
|
||||
try {
|
||||
// One entry per URL: a successful new revision replaces the old bytes.
|
||||
await transact(storage, 'readwrite', store => store.put({url:key, revision, data}));
|
||||
} catch { /* Storage denied/full must not prevent scene rendering. */ }
|
||||
}
|
||||
report('ready', data.byteLength, data.byteLength);
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
load(file, revision, decode = data => data) {
|
||||
const key = file + ':' + revision;
|
||||
if (!pending.has(key)) {
|
||||
const task = read(file, revision, decode).finally(() => pending.delete(key));
|
||||
pending.set(key, task);
|
||||
}
|
||||
return pending.get(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import MapComponent from "@components/olmap/core/MapComponent";
|
||||
import { MapNavigationContent } from "@components/loading/NavigationFeedback";
|
||||
|
||||
export default function MapLayout({ children }: { children: ReactNode }) {
|
||||
return <MapComponent>{children}</MapComponent>;
|
||||
return (
|
||||
<MapComponent>
|
||||
<MapNavigationContent>{children}</MapNavigationContent>
|
||||
</MapComponent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { Title } from "@components/title";
|
||||
import { AppSider } from "@components/sider/AppSider";
|
||||
import {
|
||||
NavigationFeedbackProvider,
|
||||
PageNavigationContent,
|
||||
} from "@components/loading/NavigationFeedback";
|
||||
import { ThemedLayout } from "@refinedev/mui";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -32,24 +36,26 @@ export default async function MainLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedLayout
|
||||
Header={Header}
|
||||
Title={Title}
|
||||
Sider={AppSider}
|
||||
childrenBoxProps={{
|
||||
sx: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "auto",
|
||||
p: 0,
|
||||
},
|
||||
}}
|
||||
containerBoxProps={{
|
||||
sx: { height: "100vh", overflow: "hidden" },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemedLayout>
|
||||
<NavigationFeedbackProvider>
|
||||
<ThemedLayout
|
||||
Header={Header}
|
||||
Title={Title}
|
||||
Sider={AppSider}
|
||||
childrenBoxProps={{
|
||||
sx: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
p: 0,
|
||||
},
|
||||
}}
|
||||
containerBoxProps={{
|
||||
sx: { height: "100vh", overflow: "hidden" },
|
||||
}}
|
||||
>
|
||||
<PageNavigationContent>{children}</PageNavigationContent>
|
||||
</ThemedLayout>
|
||||
</NavigationFeedbackProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoadingSkeleton } from "@components/loading/PageLoadingSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoadingSkeleton />;
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
import { PageLoadingSkeleton } from "@components/loading/PageLoadingSkeleton";
|
||||
|
||||
const ThreeDimensionalScene = dynamic(
|
||||
() => import("@components/threeDimensional/ThreeDimensionalScene"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Box sx={{ height: "100%", display: "grid", placeItems: "center" }}>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
),
|
||||
loading: () => <PageLoadingSkeleton pathname="/three-dimensional-scene" />,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { usePathname } from "next/navigation";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import routerProvider from "@refinedev/nextjs-router";
|
||||
import { NavigationLink } from "@components/loading/NavigationLink";
|
||||
|
||||
import { ColorModeContextProvider } from "@contexts/color-mode";
|
||||
import { dataProvider } from "@providers/data-provider";
|
||||
@@ -46,6 +47,8 @@ type RefineContextProps = {
|
||||
defaultMode?: string;
|
||||
};
|
||||
|
||||
const appRouterProvider = { ...routerProvider, Link: NavigationLink };
|
||||
|
||||
export const RefineContext = (
|
||||
props: React.PropsWithChildren<RefineContextProps>,
|
||||
) => (
|
||||
@@ -377,7 +380,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
<ColorModeContextProvider defaultMode={props.defaultMode}>
|
||||
<RefineSnackbarProvider>
|
||||
<Refine
|
||||
routerProvider={routerProvider}
|
||||
routerProvider={appRouterProvider}
|
||||
dataProvider={dataProvider}
|
||||
notificationProvider={useAppNotificationProvider}
|
||||
authProvider={authProvider}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { AgentActivityTimeline } from "./AgentActivityTimeline";
|
||||
|
||||
describe("AgentActivityTimeline", () => {
|
||||
it("shows only the first lines of a long tool command until expanded", () => {
|
||||
const target = [
|
||||
"cd /app/data/conversation-workspaces/conversation-example",
|
||||
"python3 - <<'EOF'",
|
||||
"import json, glob",
|
||||
"print('fourth line')",
|
||||
"EOF",
|
||||
].join("\n");
|
||||
render(
|
||||
<AgentActivityTimeline
|
||||
activities={[
|
||||
{
|
||||
id: "analysis",
|
||||
title: "处理分析数据",
|
||||
reason: "正在核查结果。",
|
||||
status: "completed",
|
||||
startedAt: 1000,
|
||||
endedAt: 2000,
|
||||
actions: [
|
||||
{
|
||||
id: "long-command",
|
||||
tool: "bash",
|
||||
title: "运行本地分析",
|
||||
status: "completed",
|
||||
target,
|
||||
startedAt: 1100,
|
||||
endedAt: 1900,
|
||||
},
|
||||
{
|
||||
id: "short-command",
|
||||
tool: "tjwater_cli",
|
||||
title: "查询后端数据",
|
||||
status: "completed",
|
||||
target: "help data",
|
||||
startedAt: 1900,
|
||||
endedAt: 1950,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
|
||||
expect(screen.getByText(/import json, glob/u)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/fourth line/u)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("help data")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "展开完整内容" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "展开完整内容" }));
|
||||
expect(screen.getByText(/fourth line/u)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "收起内容" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "收起内容" }));
|
||||
expect(screen.queryByText(/fourth line/u)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Collapse,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
@@ -89,6 +90,11 @@ const ActionStatusIcon = ({ status }: { status: AgentActivityAction["status"] })
|
||||
|
||||
const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }) => {
|
||||
const elapsed = getElapsedMs(action, now);
|
||||
const [targetExpanded, setTargetExpanded] = useState(false);
|
||||
const targetId = React.useId();
|
||||
const target = action.target ?? "";
|
||||
const targetPreview = target.split(/\r?\n/u).slice(0, 3).join("\n").slice(0, 240);
|
||||
const hasLongTarget = targetPreview.length < target.length;
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -111,19 +117,45 @@ const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }
|
||||
</Typography>
|
||||
</Stack>
|
||||
{action.target ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: "block",
|
||||
mt: 0.2,
|
||||
fontFamily: "monospace",
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{action.target}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 0.2 }}>
|
||||
<Typography
|
||||
id={targetId}
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: !targetExpanded && hasLongTarget ? "-webkit-box" : "block",
|
||||
fontFamily: "monospace",
|
||||
overflow: !targetExpanded && hasLongTarget ? "hidden" : "visible",
|
||||
overflowWrap: "anywhere",
|
||||
whiteSpace: "pre-wrap",
|
||||
...(!targetExpanded && hasLongTarget
|
||||
? { WebkitBoxOrient: "vertical", WebkitLineClamp: 3 }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{targetExpanded ? target : targetPreview}
|
||||
</Typography>
|
||||
{hasLongTarget ? (
|
||||
<Button
|
||||
size="small"
|
||||
aria-controls={targetId}
|
||||
aria-expanded={targetExpanded}
|
||||
onClick={() => setTargetExpanded((current) => !current)}
|
||||
sx={{
|
||||
mt: 0.2,
|
||||
p: 0,
|
||||
minWidth: 0,
|
||||
color: activityAccent,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 650,
|
||||
lineHeight: 1.5,
|
||||
textTransform: "none",
|
||||
}}
|
||||
>
|
||||
{targetExpanded ? "收起内容" : "展开完整内容"}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
{action.error ? (
|
||||
<Typography
|
||||
|
||||
@@ -79,7 +79,7 @@ const approvalModeOptions: ReadonlyArray<{
|
||||
{
|
||||
value: "always",
|
||||
label: "始终允许",
|
||||
description: "除明确禁止项外自动放行",
|
||||
description: "常规权限自动放行,敏感路径仍需确认",
|
||||
icon: WarningAmberRounded,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
|
||||
import { PermissionRequestGroup } from "./AgentPermissionRequests";
|
||||
|
||||
jest.mock("framer-motion", () => ({
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
motion: {
|
||||
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("PermissionRequestGroup", () => {
|
||||
it("shows an external path approval request and forwards the user's decision", () => {
|
||||
const onReply = jest.fn();
|
||||
render(
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<PermissionRequestGroup
|
||||
permissions={[
|
||||
{
|
||||
requestId: "external-1",
|
||||
sessionId: "session-1",
|
||||
permission: "external_directory",
|
||||
patterns: ["/app/private/*"],
|
||||
target: "/app/private/report.json",
|
||||
always: ["/app/private/*"],
|
||||
createdAt: 1,
|
||||
status: "pending",
|
||||
},
|
||||
]}
|
||||
isRunning
|
||||
onReply={onReply}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("访问工作区外目录")).toBeInTheDocument();
|
||||
expect(screen.getByText("/app/private/report.json")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "允许一次" }));
|
||||
expect(onReply).toHaveBeenCalledWith("external-1", "once");
|
||||
fireEvent.click(screen.getByRole("button", { name: "保存授权" }));
|
||||
expect(onReply).toHaveBeenCalledWith("external-1", "always");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { parseApplyLayerStylePayload } from "./toolCallStyleHelpers";
|
||||
import { createDefaultLayerStyleState } from "@components/olmap/core/Controls/styleEditorPresets";
|
||||
import { validateStyleConfig } from "@components/olmap/core/Controls/styleEditorUtils";
|
||||
|
||||
describe("parseApplyLayerStylePayload", () => {
|
||||
it("accepts a valid snake_case interval contract", () => {
|
||||
@@ -47,4 +49,17 @@ describe("parseApplyLayerStylePayload", () => {
|
||||
}),
|
||||
).toMatchObject({ layerId: "junctions", styleConfig: { opacity: 0.5 } });
|
||||
});
|
||||
|
||||
it("preserves default palette indexes when only some style fields are provided", () => {
|
||||
const payload = parseApplyLayerStylePayload({
|
||||
layer_id: "pipes",
|
||||
style_config: { property: "flow", color_type: "gradient" },
|
||||
});
|
||||
expect(payload).not.toBeNull();
|
||||
const merged = {
|
||||
...createDefaultLayerStyleState("pipes").styleConfig,
|
||||
...payload?.styleConfig,
|
||||
};
|
||||
expect(validateStyleConfig(merged)).toEqual({ valid: true, errors: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,10 +198,15 @@ export const parseApplyLayerStylePayload = (
|
||||
) return null;
|
||||
}
|
||||
|
||||
const definedStyleConfig = styleConfig
|
||||
? (Object.fromEntries(
|
||||
Object.entries(styleConfig).filter(([, value]) => value !== undefined),
|
||||
) as Partial<StyleConfig>)
|
||||
: undefined;
|
||||
const hasStyleOverrides =
|
||||
styleConfig &&
|
||||
Object.values(styleConfig).some((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : value !== undefined,
|
||||
definedStyleConfig &&
|
||||
Object.values(definedStyleConfig).some((value) =>
|
||||
Array.isArray(value) ? value.length > 0 : true,
|
||||
);
|
||||
|
||||
if (!resetToDefault && !hasStyleOverrides) {
|
||||
@@ -211,7 +216,7 @@ export const parseApplyLayerStylePayload = (
|
||||
return {
|
||||
layerId,
|
||||
resetToDefault,
|
||||
styleConfig: hasStyleOverrides ? styleConfig : undefined,
|
||||
styleConfig: hasStyleOverrides ? definedStyleConfig : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import SCADADataPanel from "./SCADADataPanel";
|
||||
|
||||
jest.mock("@/lib/apiFetch", () => ({ apiFetch: jest.fn() }));
|
||||
jest.mock("@refinedev/core", () => ({
|
||||
useGetIdentity: () => ({ data: { id: "test-user" } }),
|
||||
useNotification: () => ({ open: jest.fn() }),
|
||||
}));
|
||||
jest.mock("@mui/x-date-pickers", () => ({
|
||||
DateTimePicker: () => null,
|
||||
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
jest.mock("echarts-for-react", () => () => null);
|
||||
|
||||
const response = (payload: unknown) =>
|
||||
({ ok: true, json: async () => payload }) as Response;
|
||||
|
||||
describe("SCADADataPanel requests", () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(apiFetch).mockImplementation(async (url) =>
|
||||
response(
|
||||
String(url).includes("/scada-devices")
|
||||
? [
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1", link_id: null },
|
||||
{ device_id: "S-2", device_type: "pressure", node_id: "J-2", link_id: null },
|
||||
]
|
||||
: { series: [] },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it("does not request history again when the same device IDs arrive in a new array", async () => {
|
||||
const { rerender } = render(<SCADADataPanel deviceIds={["S-1"]} />);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||
|
||||
rerender(<SCADADataPanel deviceIds={["S-1"]} />);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||
|
||||
rerender(<SCADADataPanel deviceIds={["S-1", "S-2"]} />);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(4));
|
||||
expect(jest.mocked(apiFetch).mock.calls[3][1]?.body).toContain('"device_ids":["S-2"]');
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "刷新" }));
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(6));
|
||||
});
|
||||
});
|
||||
@@ -307,9 +307,15 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
|
||||
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
||||
|
||||
const hasDevices = deviceIds.length > 0;
|
||||
// Parent map updates can pass a new array with identical IDs. Keep the
|
||||
// request dependency tied to the IDs themselves, not the array identity.
|
||||
const deviceIdsKey = JSON.stringify(deviceIds);
|
||||
const requestedDeviceIds = useMemo(
|
||||
() => JSON.parse(deviceIdsKey) as string[],
|
||||
[deviceIdsKey],
|
||||
);
|
||||
const hasDevices = requestedDeviceIds.length > 0;
|
||||
const hasData = timeSeries.length > 0;
|
||||
const deviceIdsKey = useMemo(() => deviceIds.join(","), [deviceIds]);
|
||||
|
||||
const dataset = useMemo(
|
||||
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
|
||||
@@ -348,7 +354,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
setError(null);
|
||||
try {
|
||||
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
||||
const result = await customFetcher(deviceIds, {
|
||||
const result = await customFetcher(requestedDeviceIds, {
|
||||
from: rangeFrom.toDate(),
|
||||
to: rangeTo.toDate(),
|
||||
});
|
||||
@@ -360,7 +366,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
setLoadingState("error");
|
||||
}
|
||||
},
|
||||
[deviceIds, customFetcher, hasDevices, normalizedRange],
|
||||
[requestedDeviceIds, customFetcher, hasDevices, normalizedRange],
|
||||
);
|
||||
|
||||
// 处理数据清洗
|
||||
@@ -447,7 +453,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
setTimeSeries([]);
|
||||
setHistorySeries([]);
|
||||
}
|
||||
}, [deviceIdsKey, handleFetch, hasDevices]);
|
||||
}, [handleFetch, hasDevices]);
|
||||
|
||||
// 当设备数量变化时,调整数据源选择
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { fetchHistoryData } from "./HistoryDataPanel";
|
||||
import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel";
|
||||
|
||||
jest.mock("@/lib/apiFetch", () => ({
|
||||
apiFetch: jest.fn(),
|
||||
@@ -9,6 +11,12 @@ jest.mock("@components/olmap/core/MapComponent", () => ({
|
||||
useData: () => null,
|
||||
}));
|
||||
|
||||
jest.mock("@mui/x-date-pickers", () => ({
|
||||
DateTimePicker: () => null,
|
||||
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
jest.mock("echarts-for-react", () => () => null);
|
||||
|
||||
const jsonResponse = (payload: unknown, status = 200) =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
@@ -94,3 +102,39 @@ describe("fetchHistoryData", () => {
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HistoryDataPanel requests", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it("does not reload unchanged elements when its parent rerenders", async () => {
|
||||
const props = { type: "none" as const, onClose: jest.fn() };
|
||||
const { rerender } = render(
|
||||
React.createElement(HistoryDataPanel, {
|
||||
...props,
|
||||
featureInfos: [["J-1", "junction"]],
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
React.createElement(HistoryDataPanel, {
|
||||
...props,
|
||||
featureInfos: [["J-1", "junction"]],
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
React.createElement(HistoryDataPanel, {
|
||||
...props,
|
||||
featureInfos: [["J-2", "junction"]],
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,12 +258,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
|
||||
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
||||
|
||||
const hasDevices = deviceIds.length > 0;
|
||||
const hasData = timeSeries.length > 0;
|
||||
const featureInfosKey = useMemo(
|
||||
() => JSON.stringify(featureInfos),
|
||||
[featureInfos]
|
||||
const featureInfosKey = JSON.stringify(featureInfos);
|
||||
const requestedFeatureInfos = useMemo(
|
||||
() => JSON.parse(featureInfosKey) as [string, string][],
|
||||
[featureInfosKey],
|
||||
);
|
||||
const hasDevices = requestedFeatureInfos.length > 0;
|
||||
const hasData = timeSeries.length > 0;
|
||||
|
||||
const dataset = useMemo(
|
||||
() => buildDataset(timeSeries, historySeries, fractionDigits),
|
||||
@@ -289,7 +290,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
try {
|
||||
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
||||
const result = await fetchHistoryData(
|
||||
featureInfos,
|
||||
requestedFeatureInfos,
|
||||
{
|
||||
from: rangeFrom.toDate(),
|
||||
to: rangeTo.toDate(),
|
||||
@@ -313,7 +314,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
setLoadingState("error");
|
||||
}
|
||||
},
|
||||
[featureInfos, hasDevices, normalizedRange, runId, type],
|
||||
[requestedFeatureInfos, hasDevices, normalizedRange, runId, type],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
@@ -331,7 +332,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
setTimeSeries([]);
|
||||
setHistorySeries([]);
|
||||
}
|
||||
}, [featureInfosKey, handleFetch, hasDevices]);
|
||||
}, [handleFetch, hasDevices]);
|
||||
|
||||
const columns: GridColDef[] = useMemo(() => {
|
||||
const base: GridColDef[] = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { NavigationLink } from "@components/loading/NavigationLink";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
FiAlertCircle,
|
||||
@@ -38,8 +38,8 @@ import {
|
||||
type SceneRuntimeState,
|
||||
} from "./sceneProtocol";
|
||||
|
||||
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=32-context-opacity";
|
||||
const SCENE_LOAD_TIMEOUT_MS = 30_000;
|
||||
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=35-resource-cache";
|
||||
const SCENE_LOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
const initialRuntimeState: SceneRuntimeState = {
|
||||
mode: "network",
|
||||
@@ -138,6 +138,9 @@ export default function ThreeDimensionalScene() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [sceneError, setSceneError] = useState<string | null>(null);
|
||||
const [sceneLoading, setSceneLoading] = useState(true);
|
||||
const [sceneResourceError, setSceneResourceError] = useState(false);
|
||||
const [sceneStarted, setSceneStarted] = useState(false);
|
||||
const [refreshVersion, setRefreshVersion] = useState(0);
|
||||
const [sceneKey, setSceneKey] = useState(0);
|
||||
|
||||
@@ -169,6 +172,9 @@ export default function ThreeDimensionalScene() {
|
||||
setSelection(null);
|
||||
setRuntimeState(initialRuntimeState);
|
||||
setSceneError(null);
|
||||
setSceneLoading(true);
|
||||
setSceneResourceError(false);
|
||||
setSceneStarted(false);
|
||||
setSceneKey((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
@@ -198,6 +204,17 @@ export default function ThreeDimensionalScene() {
|
||||
});
|
||||
setRuntimeState(message.state);
|
||||
setSceneError(null);
|
||||
setSceneLoading(false);
|
||||
setSceneResourceError(false);
|
||||
return;
|
||||
}
|
||||
if (message.type === "loading") {
|
||||
if (typeof message.active === "boolean") {
|
||||
setSceneLoading(message.active);
|
||||
setSceneStarted(true);
|
||||
setSceneResourceError(message.failed === true);
|
||||
if (message.active) setSceneError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "scene-state") {
|
||||
@@ -222,12 +239,14 @@ export default function ThreeDimensionalScene() {
|
||||
}, [isZjbProject, sceneKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isZjbProject || model) return;
|
||||
// Once the viewer starts, its per-request inactivity timeout owns recovery.
|
||||
// A long but progressing first download should not hit a total-load deadline.
|
||||
if (!isZjbProject || model || !sceneLoading || sceneStarted) return;
|
||||
const timerId = window.setTimeout(() => {
|
||||
setSceneError("三维模型载入超时,请检查静态资源后重试。");
|
||||
}, SCENE_LOAD_TIMEOUT_MS);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, [isZjbProject, model, sceneKey]);
|
||||
}, [isZjbProject, model, sceneKey, sceneLoading, sceneStarted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isZjbProject) return;
|
||||
@@ -301,9 +320,9 @@ export default function ThreeDimensionalScene() {
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">
|
||||
目前仅高铁湛江北站项目提供三维模型。请切换到 ZJB 项目后再打开此页面。
|
||||
</p>
|
||||
<Link href="/network-simulation" className="mt-2 inline-flex min-h-10 items-center text-sm font-medium text-blue-700 hover:text-blue-800 hover:underline">
|
||||
<NavigationLink to="/network-simulation" className="mt-2 inline-flex min-h-10 items-center text-sm font-medium text-blue-700 hover:text-blue-800 hover:underline">
|
||||
返回管网在线模拟
|
||||
</Link>
|
||||
</NavigationLink>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -324,14 +343,7 @@ export default function ThreeDimensionalScene() {
|
||||
|
||||
return (
|
||||
<main lang="zh-CN" className="relative h-full min-h-0 overflow-hidden bg-slate-200">
|
||||
<iframe key={sceneKey} ref={iframeRef} src={SCENE_URL} title="高铁湛江北站供水系统三维场景" referrerPolicy="same-origin" className="block h-full w-full border-0" />
|
||||
|
||||
{!model && !sceneError && (
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 bg-slate-100/55 text-sm text-slate-600 backdrop-blur-sm">
|
||||
<span className="h-8 w-8 animate-spin rounded-full border-[3px] border-blue-200 border-t-blue-600" />
|
||||
正在载入三维模型
|
||||
</div>
|
||||
)}
|
||||
<iframe key={sceneKey} ref={iframeRef} src={SCENE_URL} title="高铁湛江北站供水系统三维场景" referrerPolicy="same-origin" className={`block h-full w-full border-0 ${!model || sceneLoading || sceneResourceError ? "relative z-40" : ""}`} />
|
||||
|
||||
{(loadError || sceneError) && (
|
||||
<div role="alert" className="absolute left-1/2 top-28 z-40 flex w-[min(560px,calc(100%-24px))] -translate-x-1/2 items-center gap-3 rounded-xl border border-red-200/70 bg-red-50/80 px-4 py-3 text-sm text-red-800 shadow-xl shadow-red-950/10 backdrop-blur-xl md:top-4">
|
||||
@@ -387,7 +399,7 @@ export default function ThreeDimensionalScene() {
|
||||
<ThreeDimensionalControls
|
||||
open={controlsOpen}
|
||||
activeTab={controlTab}
|
||||
ready={Boolean(model)}
|
||||
ready={Boolean(model) && !sceneLoading && !sceneResourceError}
|
||||
state={runtimeState}
|
||||
selection={selection}
|
||||
timelineOpen={timelineOpen}
|
||||
|
||||
@@ -225,14 +225,14 @@ export function ThreeDimensionalTimeline({
|
||||
"pointer-events-auto relative z-10 w-full max-w-[950px] rounded-2xl opacity-95 transition-opacity duration-200 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<div className="timeline-drag-handle relative flex h-7 cursor-move touch-none items-center justify-center rounded-t-2xl border-b border-white/40 bg-white/10">
|
||||
<span aria-hidden="true" className="h-1 w-10 rounded-full bg-slate-400/60 transition-colors hover:bg-slate-500/70" />
|
||||
<div className="timeline-drag-handle relative flex h-9 cursor-move touch-none items-center justify-center rounded-t-2xl border-b border-white/35 bg-white/[0.08]">
|
||||
<span aria-hidden="true" className="h-1 w-10 rounded-full bg-slate-400/55 transition-colors hover:bg-slate-500/70" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="收起时间轴"
|
||||
title="收起时间轴"
|
||||
onClick={onClose}
|
||||
className="absolute right-1 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-lg text-slate-500 transition-[transform,color,background-color] hover:bg-white/60 hover:text-slate-800 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
|
||||
className="absolute right-2 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center rounded-full bg-white/20 text-base text-slate-600 ring-1 ring-white/55 [box-shadow:inset_0_1px_0_rgba(255,255,255,0.72),0_4px_12px_rgba(15,43,69,0.08)] after:absolute after:-inset-1 after:content-[''] transition-[transform,color,background-color,box-shadow] [@media(hover:hover)]:hover:bg-white/50 [@media(hover:hover)]:hover:text-slate-900 [@media(hover:hover)]:hover:[box-shadow:inset_0_1px_0_rgba(255,255,255,0.88),0_6px_16px_rgba(15,43,69,0.14)] active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/45"
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
|
||||
@@ -20,7 +20,8 @@ describe("ZJB three-dimensional runtime assets", () => {
|
||||
packageId: string;
|
||||
renderRevision: number;
|
||||
assets: Array<{ file: string; sha256: string }>;
|
||||
networkModel: { file: string; metadata: string; sha256: string };
|
||||
networkContext: string[];
|
||||
networkModel: { file: string; metadata: string; sha256: string; metadataSha256: string };
|
||||
sourceCatalogFile?: string;
|
||||
bindingsFile?: string;
|
||||
};
|
||||
@@ -28,6 +29,16 @@ describe("ZJB three-dimensional runtime assets", () => {
|
||||
expect(manifest.packageId).toBe("zjb-web-v29");
|
||||
expect(manifest.renderRevision).toBe(29);
|
||||
expect(manifest.assets).toHaveLength(24);
|
||||
expect(manifest.networkContext).toEqual([
|
||||
"detail_roof",
|
||||
"detail_facade",
|
||||
"detail_interior",
|
||||
"detail_ceiling",
|
||||
"map_platforms",
|
||||
"map_railway",
|
||||
"map_roads",
|
||||
"map_pump_shell",
|
||||
]);
|
||||
manifest.assets.forEach((asset) => {
|
||||
const assetPath = join(sceneRoot, asset.file);
|
||||
expect(existsSync(assetPath)).toBe(true);
|
||||
@@ -37,6 +48,9 @@ describe("ZJB three-dimensional runtime assets", () => {
|
||||
});
|
||||
expect(existsSync(join(sceneRoot, manifest.networkModel.file))).toBe(true);
|
||||
expect(existsSync(join(sceneRoot, manifest.networkModel.metadata))).toBe(true);
|
||||
expect(createHash("sha256").update(
|
||||
readFileSync(join(sceneRoot, manifest.networkModel.metadata)),
|
||||
).digest("hex")).toBe(manifest.networkModel.metadataSha256);
|
||||
expect(manifest.sourceCatalogFile).toBeUndefined();
|
||||
expect(manifest.bindingsFile).toBeUndefined();
|
||||
|
||||
@@ -56,6 +70,8 @@ describe("ZJB three-dimensional runtime assets", () => {
|
||||
"network-style.mjs",
|
||||
"integration.mjs",
|
||||
"render-effects.mjs",
|
||||
"resourceCache.mjs",
|
||||
"loadingProgress.mjs",
|
||||
"vendor/meshopt_decoder.module.js",
|
||||
"vendor/meshoptimizer-LICENSE.md",
|
||||
"vendor/three/LICENSE",
|
||||
@@ -73,6 +89,10 @@ describe("ZJB three-dimensional runtime assets", () => {
|
||||
join(sceneRoot, "asset-inspector.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
const networkStyle = readFileSync(
|
||||
join(sceneRoot, "network-style.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(preview).toContain("tjwater:zjb-scene");
|
||||
expect(preview).toContain("HOST_VERSION=2");
|
||||
@@ -83,10 +103,13 @@ describe("ZJB three-dimensional runtime assets", () => {
|
||||
expect(preview).toContain("data.type==='clear-results'");
|
||||
expect(preview).toContain("data.type!=='command'");
|
||||
expect(preview).toContain("postHost('selection-changed'");
|
||||
expect(preview).toContain("next==='network'?manifest.networkContext:manifest.mapDefault");
|
||||
expect(preview).not.toContain("window.zjbNetwork");
|
||||
expect(preview).not.toContain("resultsFile");
|
||||
expect(html).not.toContain("绑定运行结果");
|
||||
expect(html).not.toContain("resultsFile");
|
||||
expect(inspector).not.toContain("document.getElementById");
|
||||
expect(networkStyle).toContain("ScadaSensorMarkers");
|
||||
expect(networkStyle).toContain("source==='scada'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,6 +149,42 @@ describe("buildSceneFrame", () => {
|
||||
expect(frame.stats.missingLinks).toBe(2);
|
||||
});
|
||||
|
||||
it("builds a SCADA-only frame when the matching simulation frame is absent", () => {
|
||||
const frame = buildSceneFrame({
|
||||
queryTime: new Date("2026-09-14T09:30:00.000Z"),
|
||||
model,
|
||||
nodeRows: [],
|
||||
linkRows: [],
|
||||
pressureDevices: [
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
|
||||
],
|
||||
scadaRows: [
|
||||
{
|
||||
time: "2026-09-14T09:30:00.000Z",
|
||||
device_id: "S-1",
|
||||
monitored_value: 30.8,
|
||||
cleaned_value: 30.9,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(frame.resultTime).toBe("2026-09-14T09:30:00.000Z");
|
||||
expect(frame.payload?.nodes["J-1"]).toEqual({
|
||||
pressure: 30.9,
|
||||
source: "scada",
|
||||
deviceId: "S-1",
|
||||
});
|
||||
expect(frame.payload?.links).toEqual({});
|
||||
expect(frame.stats).toMatchObject({
|
||||
simulationNodes: 0,
|
||||
simulationLinks: 0,
|
||||
scadaOverrides: 1,
|
||||
});
|
||||
expect(frame.warnings).toEqual([
|
||||
"当前时刻无在线模拟结果,已显示 SCADA 监测点。",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores IDs outside the scene model instead of rejecting the frame", () => {
|
||||
const frame = buildSceneFrame({
|
||||
queryTime: new Date("2026-09-11T03:00:00.000Z"),
|
||||
@@ -261,4 +297,39 @@ describe("buildSceneFrame", () => {
|
||||
"SCADA 数据暂不可用,当前仅显示模拟结果。",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the SCADA-only warning returned by the frame builder", async () => {
|
||||
mockApiFetch
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => [] })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ FLOW_UNITS: "LPS", PRESSURE_UNITS: "METERS" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
time: "2026-09-14T09:30:00.000Z",
|
||||
device_id: "S-1",
|
||||
monitored_value: 30.8,
|
||||
cleaned_value: 30.9,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const frame = await fetchSceneFrame({
|
||||
queryTime: new Date("2026-09-14T09:30:00.000Z"),
|
||||
model,
|
||||
pressureDevices: [
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
|
||||
],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(frame.stats.scadaOverrides).toBe(1);
|
||||
expect(frame.warnings).toEqual([
|
||||
"当前时刻无在线模拟结果,已显示 SCADA 监测点。",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,6 +157,28 @@ const resolveCommonFrameTime = (
|
||||
)[0];
|
||||
};
|
||||
|
||||
const resolveNearestRowTime = <T extends { time: string }>(
|
||||
queryTime: Date,
|
||||
rows: T[],
|
||||
) => {
|
||||
const target = queryTime.getTime();
|
||||
const candidates = Array.from(
|
||||
new Set(
|
||||
rows
|
||||
.map((row) => toTimestamp(row.time))
|
||||
.filter(
|
||||
(time): time is number =>
|
||||
time !== null &&
|
||||
Math.abs(time - target) <= SCENE_FRAME_TOLERANCE_MS,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (candidates.length === 0) return null;
|
||||
return candidates.sort(
|
||||
(left, right) => Math.abs(left - target) - Math.abs(right - target),
|
||||
)[0];
|
||||
};
|
||||
|
||||
const normalizeLinkStatus = (
|
||||
value: number | null,
|
||||
): SceneLinkResult["status"] => {
|
||||
@@ -210,7 +232,13 @@ export const buildSceneFrame = ({
|
||||
simulationUnits?: NetworkResultUnits;
|
||||
}): SceneFrame => {
|
||||
const selectedTime = queryTime.toISOString();
|
||||
const frameTime = resolveCommonFrameTime(queryTime, nodeRows, linkRows);
|
||||
const simulationFrameTime = resolveCommonFrameTime(
|
||||
queryTime,
|
||||
nodeRows,
|
||||
linkRows,
|
||||
);
|
||||
const frameTime =
|
||||
simulationFrameTime ?? resolveNearestRowTime(queryTime, scadaRows);
|
||||
if (frameTime === null) {
|
||||
return {
|
||||
selectedTime,
|
||||
@@ -313,7 +341,10 @@ export const buildSceneFrame = ({
|
||||
missingLinks: Math.max(0, model.linkIds.size - Object.keys(links).length),
|
||||
ignoredElements,
|
||||
},
|
||||
warnings: [],
|
||||
warnings:
|
||||
simulationFrameTime === null
|
||||
? ["当前时刻无在线模拟结果,已显示 SCADA 监测点。"]
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -404,5 +435,5 @@ export const fetchSceneFrame = async ({
|
||||
scadaRows,
|
||||
simulationUnits: networkOptions,
|
||||
});
|
||||
return { ...frame, warnings };
|
||||
return { ...frame, warnings: [...frame.warnings, ...warnings] };
|
||||
};
|
||||
|
||||
@@ -145,6 +145,7 @@ export type SceneRuntimeMessage = HostMessageBase &
|
||||
state: SceneRuntimeState;
|
||||
}
|
||||
| { type: "scene-state"; state: SceneRuntimeState }
|
||||
| { type: "loading"; active: boolean; failed?: boolean }
|
||||
| { type: "selection-changed"; selection: SceneAssetSelection | null }
|
||||
| { type: "results-applied"; summary: SceneStyleSummary }
|
||||
| { type: "results-cleared" }
|
||||
|
||||
Reference in New Issue
Block a user