Compare commits

..
Author SHA1 Message Date
jiang 4a522bf42a feat(frontend): cache 3D assets and restore loading feedback
Generic Container CI/CD / test-build-publish (push) Successful in 1m11s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m11s
Persist versioned models and validated network metadata in IndexedDB. Show scene download progress and recoverable errors with the existing frosted material.

Connect existing skeletons to pending navigation and route boundaries while preserving the map. Previously only dynamic imports showed fallbacks, and rejected model promises prevented retries.

Verified: 32 focused unit tests; 4 navigation and scene browser checks; 7 cache browser checks including invalid-metadata recovery; isolated production build and type checks.
2026-10-16 18:13:04 +08:00
jiang c4a90be0d3 fix(scada): avoid history refetch on map zoom
Generic Container CI/CD / test-build-publish (push) Successful in 1m22s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m23s
2026-09-25 17:22:42 +08:00
jiang 1b275206a9 fix(chat): collapse long activity commands
Generic Container CI/CD / test-build-publish (push) Successful in 1m32s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m32s
2026-09-25 16:23:00 +08:00
jiang cdf97afbc4 fix(chat): preserve style defaults for omitted agent overrides
Generic Container CI/CD / test-build-publish (push) Successful in 1m6s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m6s
2026-09-25 16:02:10 +08:00
jiang ca10d8f384 fix(chat): clarify approval mode and verify permission prompts
Generic Container CI/CD / test-build-publish (push) Successful in 1m29s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m29s
2026-09-25 15:44:46 +08:00
jiang 1622eaa2da fix(3d): restore complete SCADA scene context
Generic Container CI/CD / test-build-publish (push) Successful in 1m5s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m5s
The scene previously returned early when realtime rows were absent and selected map-tier assets for network mode, hiding SCADA markers and station interiors and floors.

Use SCADA timestamps as valid frames, render node-aligned monitor markers, and pin the full network context in the manifest regression test. Keep the timeline close action inside its drag header.
2026-09-14 16:57:41 +08:00
38 changed files with 1454 additions and 102 deletions
+96
View File
@@ -0,0 +1,96 @@
import { expect, test, type Page } from "@playwright/test";
import { mockBackend } from "./support/mockBackend";
test.setTimeout(90_000);
async function prepare(page: Page) {
await mockBackend(page);
await page.route("**/api/v1/admin/**", (route) => route.fulfill({
json: route.request().url().endsWith("/users/me")
? { id: "playwright-user", is_system_admin: true, system_role: "system_admin" }
: [],
}));
}
test("菜单点击后立即反馈,等待页面响应时显示路由骨架", async ({ page }) => {
await prepare(page);
await page.goto("/system-admin");
await expect(page.getByRole("link", { name: "审计日志", exact: true })).toBeVisible();
await page.locator("[data-page-scroll-content]").evaluate((element) => { element.scrollTop = element.scrollHeight; });
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => { release = resolve; });
let waiting = false;
await page.route("**/audit-logs?*", async (route) => {
if (route.request().headers().rsc !== "1") return route.fallback();
waiting = true;
await gate;
await route.continue();
});
try {
await page.getByRole("link", { name: "审计日志", exact: true }).click();
await expect.poll(() => waiting).toBe(true);
await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toBeVisible({ timeout: 1500 });
await expect(page.getByRole("status", { name: "正在加载审计日志", exact: true })).toBeVisible();
} finally { release(); }
await expect(page.getByRole("heading", { name: "审计日志", exact: true })).toBeVisible();
await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toHaveCount(0);
});
test("地图页面切换复用原地图,并显示目标面板的既有骨架", async ({ page }) => {
await prepare(page);
await page.route("**/ows?**", (route) => route.fulfill({ json: { type: "FeatureCollection", features: [] } }));
await page.route("**/api/v1/scada-devices", (route) => route.fulfill({ json: [] }));
await page.goto("/network-simulation");
const map = page.locator(".ol-viewport").first();
await expect(map).toBeVisible({ timeout: 60_000 });
await map.evaluate((element) => element.setAttribute("data-navigation-map", "original"));
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => { release = resolve; });
await page.route("**/scada-data-cleaning?*", async (route) => {
if (route.request().headers().rsc !== "1") return route.fallback();
await gate;
await route.continue();
});
try {
await page.getByRole("link", { name: "SCADA 数据清洗", exact: true }).click();
await expect(page.getByRole("status", { name: "正在加载数据清洗", exact: true })).toBeVisible();
await expect(page.locator('[data-navigation-map="original"]')).toBeVisible();
await expect(page.locator("[data-map-page-content]")).toHaveCSS("display", "none");
await page.screenshot({ path: "test-results/navigation-map-skeleton.png" });
} finally { release(); }
await expect(page).toHaveURL(/\/scada-data-cleaning$/);
await expect(page.locator("[data-map-page-content]")).toHaveCSS("display", "contents");
await expect(page.locator('[data-navigation-map="original"]')).toBeVisible();
await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toHaveCount(0);
await page.goBack();
await expect(page).toHaveURL(/\/network-simulation$/);
await expect(page.locator('[data-navigation-map="original"]')).toBeVisible();
await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toHaveCount(0);
});
test("移动端收起菜单后仍显示页面切换反馈,窄屏无溢出", async ({ page }) => {
await prepare(page);
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/system-admin");
await page.getByRole("button", { name: "open drawer", exact: true }).click();
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => { release = resolve; });
await page.route("**/audit-logs?*", async (route) => {
if (route.request().headers().rsc !== "1") return route.fallback();
await gate;
await route.continue();
});
try {
await page.getByRole("link", { name: "审计日志", exact: true }).click();
for (const width of [375, 320]) {
await page.setViewportSize({ width, height: 812 });
await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toBeVisible();
await expect(page.getByRole("status", { name: "正在加载审计日志", exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: `test-results/navigation-mobile-${width}.png` });
}
} finally { release(); }
await expect(page.getByRole("heading", { name: "审计日志", exact: true })).toBeVisible();
await expect(page.getByRole("progressbar", { name: "正在切换页面", exact: true })).toHaveCount(0);
});
+63
View File
@@ -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);
});
+35
View File
@@ -4,6 +4,41 @@ import { mockBackend } from "./support/mockBackend";
test.setTimeout(180_000); test.setTimeout(180_000);
test("首次加载和失败提示位于工具面板上方,窄屏可直接重试", async ({ page }) => {
await mockBackend(page);
await page.addInitScript(() => {
localStorage.setItem("active_project", "playwright-project");
localStorage.setItem("MAP_WORKSPACE", "zjb");
localStorage.setItem("NETWORK_NAME", "zjb");
});
let release: () => void = () => {};
const pending = new Promise<void>((resolve) => { release = resolve; });
const failedModel = "**/models/detail_roof.glb*";
await page.route(failedModel, async (route) => { await pending; await route.abort("failed"); });
await page.goto("/three-dimensional-scene");
const scene = page.frameLocator('iframe[title="高铁湛江北站供水系统三维场景"]');
await expect(scene.locator("#boot-title")).toHaveText("正在载入场景资源 9 / 10", { timeout: 60_000 });
const loadingMaterial = await scene.locator(".boot-panel").evaluate((element) => ({
background: getComputedStyle(element).backgroundImage,
blur: getComputedStyle(element).backdropFilter,
}));
for (const width of [1280, 375, 320]) {
await page.setViewportSize({ width, height: 800 });
expect(await page.evaluate(() => document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.tagName)).toBe("IFRAME");
await page.screenshot({ path: `test-results/scene-host-loading-${width}.png` });
}
release();
await expect(scene.locator("#boot-title")).toHaveText("场景资源加载失败");
await page.unroute(failedModel);
await scene.getByRole("button", { name: "重新载入场景" }).click();
await expect(scene.locator("#boot")).toBeHidden({ timeout: 60_000 });
await expect(page.getByRole("slider", { name: /建筑背景透明度/ })).toBeEnabled();
expect(await page.getByLabel("三维场景控制面板").evaluate((element) => ({
background: getComputedStyle(element).backgroundImage,
blur: getComputedStyle(element).backdropFilter,
}))).toEqual(loadingMaterial);
});
type CameraProbe = { type CameraProbe = {
active: string | null; active: string | null;
position: number[]; position: number[];
+165
View File
@@ -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)}; flight={start:performance.now(),from:camera.position.clone(),targetFrom:controls.target.clone(),to:new THREE.Vector3(...pose.position),targetTo:new THREE.Vector3(...pose.target)};
if(matchMedia('(prefers-reduced-motion: reduce)').matches){camera.position.copy(flight.to);controls.target.copy(flight.targetTo);flight=null;controls.update();} if(matchMedia('(prefers-reduced-motion: reduce)').matches){camera.position.copy(flight.to);controls.target.copy(flight.targetTo);flight=null;controls.update();}
emit(p.note??'已保存的相机位置'); emit(p.note??'已保存的相机位置');
}catch(e){if(token===request)emit('视角加载失败:'+e.message);} }catch(e){if(token===request)emit('视角加载失败:'+e.message);throw e;}
} }
function tick(now){if(!flight)return;const t=Math.min(1,(now-flight.start)/500),v=t*t*(3-2*t);camera.position.lerpVectors(flight.from,flight.to,v);controls.target.lerpVectors(flight.targetFrom,flight.targetTo,v);if(t===1)flight=null;} function tick(now){if(!flight)return;const t=Math.min(1,(now-flight.start)/500),v=t*t*(3-2*t);camera.position.lerpVectors(flight.from,flight.to,v);controls.target.lerpVectors(flight.targetFrom,flight.targetTo,v);if(t===1)flight=null;}
function save(label){label=label.trim();if(!label||label.length>24)throw Error('请输入 1–24 字的视角名称。');if(saved.length>=8)throw Error('最多保存 8 个视角,请先移除不用的视角。');const p={id:'saved-'+Date.now(),label,mode:getMode(),displayMode:getDisplayMode(),position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray(),context:root.visible,roof:root.children.filter(o=>/roof|ceiling/.test(o.userData.resourceId)).every(o=>o.visible)};p.layers=Object.fromEntries(root.children.map(o=>[o.userData.resourceId,o.visible]));const next=[...saved,p];localStorage.setItem(key,JSON.stringify(next));saved=next;active=p.id;emit('视角已保存在本机浏览器');} function save(label){label=label.trim();if(!label||label.length>24)throw Error('请输入 1–24 字的视角名称。');if(saved.length>=8)throw Error('最多保存 8 个视角,请先移除不用的视角。');const p={id:'saved-'+Date.now(),label,mode:getMode(),displayMode:getDisplayMode(),position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray(),context:root.visible,roof:root.children.filter(o=>/roof|ceiling/.test(o.userData.resourceId)).every(o=>o.visible)};p.layers=Object.fromEntries(root.children.map(o=>[o.userData.resourceId,o.visible]));const next=[...saved,p];localStorage.setItem(key,JSON.stringify(next));saved=next;active=p.id;emit('视角已保存在本机浏览器');}
@@ -1,5 +1,14 @@
// Framework-independent data adapter. Does not parse or modify the project's INP. // Framework-independent data adapter. Does not parse or modify the project's INP.
export const CAD_ORIGIN=Object.freeze([513800,2344450]); export const CAD_ORIGIN=Object.freeze([513800,2344450]);
// This decoder runs before IDB writes, including when checking an existing entry.
// A parseable HTTP 200 error response must not become a permanent model cache.
export function parseNetworkMetadata(data,modelId){
const model=JSON.parse(new TextDecoder().decode(data));
const arrays=['nodes','links','pipeInstances','nodeInstances','bendInstances','cadEquipmentBindings','pumpEntityBindings','cadAttachments'];
const objects=['layouts','connectivity','coordination','coordinatedLayouts','verticalCoordination'];
if(!model||model.modelId!==modelId||!arrays.every(key=>Array.isArray(model[key]))||!objects.every(key=>model[key]&&typeof model[key]==='object'&&!Array.isArray(model[key]))||!model.nodes.length||!model.links.length)throw Error('管网元数据格式无效,请重新载入场景。');
return model;
}
export function cadToGltf(point,displayZ=0){ export function cadToGltf(point,displayZ=0){
if(!Array.isArray(point)||point.length<2||!point.slice(0,2).every(Number.isFinite)||!Number.isFinite(displayZ))throw Error('Expected finite CAD XY and an explicit display height.'); if(!Array.isArray(point)||point.length<2||!point.slice(0,2).every(Number.isFinite)||!Number.isFinite(displayZ))throw Error('Expected finite CAD XY and an explicit display height.');
return [point[0]-CAD_ORIGIN[0],displayZ,-(point[1]-CAD_ORIGIN[1])]; return [point[0]-CAD_ORIGIN[0],displayZ,-(point[1]-CAD_ORIGIN[1])];
@@ -0,0 +1,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_roads",
"map_pump_shell" "map_pump_shell"
], ],
"networkContext": [
"detail_roof",
"detail_facade",
"detail_interior",
"detail_ceiling",
"map_platforms",
"map_railway",
"map_roads",
"map_pump_shell"
],
"detailBase": [ "detailBase": [
"map_roof", "map_roof",
"map_facade", "map_facade",
@@ -17084,6 +17094,7 @@
"id": "zjb-water-network-v23", "id": "zjb-water-network-v23",
"file": "models/water_network_v28.glb", "file": "models/water_network_v28.glb",
"metadata": "water-network-v28.json", "metadata": "water-network-v28.json",
"metadataSha256": "b9af91296f7f9269a51141b732ffb9fcd82906133eca75b96273a85f776c75f5",
"sha256": "c3d16fd287a2e057649e3f25a939ff7edcc5f6b152c9dc09860bee7675912768", "sha256": "c3d16fd287a2e057649e3f25a939ff7edcc5f6b152c9dc09860bee7675912768",
"runtimeInpRequired": false, "runtimeInpRequired": false,
"representationRevision": 28 "representationRevision": 28
@@ -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);}); 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 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 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={}; 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(){ function apply(){
const layout=(displayMode==='coordinated'?model.coordinatedLayouts:model.layouts)?.[style.scale];if(!layout)throw Error('当前模型支持整数倍率 1–12。'); 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;});}}); 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();}} 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;}); 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)=>{ 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; 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();} 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++;} 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); 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(); });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; apply();return api;
} }
+24 -6
View File
@@ -8,18 +8,36 @@
html,body{width:100%;height:100%;margin:0;overflow:hidden;background:#e2e8e9} html,body{width:100%;height:100%;margin:0;overflow:hidden;background:#e2e8e9}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif} body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
canvas{display:block;width:100%;height:100%;touch-action:none} canvas{display:block;width:100%;height:100%;touch-action:none}
#boot{position:absolute;inset:0;z-index:2;display:grid;place-items:center;color:#526777;background:#e2e8e9;transition:opacity 180ms cubic-bezier(.16,1,.3,1)} #boot{position:absolute;inset:0;z-index:2;display:grid;place-items:center;padding:16px;box-sizing:border-box;color:#334155;background:rgba(241,245,249,.38)}
#boot[hidden]{display:none} #boot[hidden]{display:none}
#boot span{display:flex;align-items:center;gap:10px;font-size:13px} .boot-panel{box-sizing:border-box;width:min(100%,360px);padding:24px;border-radius:16px;background:linear-gradient(135deg,rgba(255,255,255,.50),rgba(224,239,250,.28));backdrop-filter:blur(24px) saturate(170%) contrast(94%);-webkit-backdrop-filter:blur(24px) saturate(170%) contrast(94%);box-shadow:inset 0 1px 0 rgba(255,255,255,.88),inset 0 -1px 0 rgba(112,145,168,.18),0 18px 50px rgba(15,43,69,.20),0 0 0 1px rgba(255,255,255,.55)}
#boot i{width:18px;height:18px;border:2px solid rgba(37,125,212,.22);border-top-color:#257dd4;border-radius:50%;animation:spin .8s linear infinite} .boot-heading{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:600;color:#0f172a}
#boot i{flex:none;width:18px;height:18px;border:2px solid rgba(37,125,212,.22);border-top-color:#257dd4;border-radius:50%;animation:spin .8s linear infinite}
#boot-detail{margin:12px 0 0;font-size:12px;line-height:1.75;overflow-wrap:anywhere}
#boot-progress{display:block;width:100%;height:6px;margin-top:20px;accent-color:#2563eb}
#boot-progress[hidden],#boot-retry[hidden]{display:none}
#boot-retry{min-height:40px;margin-top:16px;padding:0 16px;border:0;border-radius:8px;background:#2563eb;color:white;font:inherit;font-size:13px;cursor:pointer;transition:transform 150ms ease,background-color 150ms ease}
#boot-retry:hover{background:#1d4ed8}#boot-retry:active{transform:scale(.96)}#boot-retry:focus-visible{outline:2px solid #2563eb;outline-offset:3px}
#boot[data-state=error] i{animation:none;border-color:#dc2626;background:#fee2e2}
#boot[data-state=error] #boot-title{color:#991b1b}
@keyframes spin{to{transform:rotate(360deg)}} @keyframes spin{to{transform:rotate(360deg)}}
@media(prefers-reduced-motion:reduce){#boot i{animation:none;border-top-color:rgba(37,125,212,.22)}} @media(prefers-reduced-motion:reduce){#boot i{animation:none;border-top-color:rgba(37,125,212,.22)}#boot-retry{transition:none}}
</style> </style>
<script type="importmap">{"imports":{"three":"./vendor/three/three.module.js","three/addons/":"./vendor/three/addons/"}}</script> <script type="importmap">{"imports":{"three":"./vendor/three/three.module.js","three/addons/":"./vendor/three/addons/"}}</script>
</head> </head>
<body> <body>
<div id="boot" role="status"><span><i aria-hidden="true"></i>正在载入三维模型</span></div> <div id="boot" role="status" aria-live="polite" aria-busy="true" data-state="loading">
<div class="boot-panel">
<div class="boot-heading"><i aria-hidden="true"></i><span id="boot-title">正在准备三维场景</span></div>
<p id="boot-detail">首次载入需要下载模型,后续将优先使用本地缓存。</p>
<progress id="boot-progress" aria-label="场景资源加载进度"></progress>
<button id="boot-retry" type="button" hidden>重新载入场景</button>
</div>
</div>
<output id="qa" hidden></output> <output id="qa" hidden></output>
<script type="module" src="./preview.mjs?v=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> </body>
</html> </html>
+29 -11
View File
@@ -1,18 +1,21 @@
import {createRenderEffects} from './render-effects.mjs'; import {createRenderEffects} from './render-effects.mjs';
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=31-camera-presets'; import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=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 {createAssetInspector} from './asset-inspector.mjs?v=29-platform2';
import * as THREE from 'three'; import * as THREE from 'three';
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js'; import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js'; import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js'; import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js';
import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=32-default-scale'; import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=33-scada-markers';
import {cadToGltf} from './integration.mjs'; import {cadToGltf,parseNetworkMetadata} from './integration.mjs';
import {createAppearance,enhanceMaterials,grain,applyBuildingContext} from './appearance.mjs?v=30-context-opacity'; import {createAppearance,enhanceMaterials,grain,applyBuildingContext} from './appearance.mjs?v=30-context-opacity';
const HOST_CHANNEL='tjwater:zjb-scene',HOST_VERSION=2,PROJECT_CODE='zjb',MODEL_ID='zjb-water-network-v23'; const HOST_CHANNEL='tjwater:zjb-scene',HOST_VERSION=2,PROJECT_CODE='zjb',MODEL_ID='zjb-water-network-v23';
let net,networkRoot,networkStyle,navigation,inspector; let net,networkRoot,networkStyle,navigation,inspector;
let mode='network',generation=0,contextVisible=true,roofVisible=true; let mode='network',generation=0,contextVisible=true,roofVisible=true;
let sceneStatus='正在载入三维模型'; let sceneStatus='正在载入三维模型';
let sceneReady=false;
let cameraState={active:null,note:'正在准备观察位置',views:[]}; let cameraState={active:null,note:'正在准备观察位置',views:[]};
let appearanceState={preset:'day',exposure:.95,contextOpacity:.4,shadows:true,effects:true,quality:'standard'}; let appearanceState={preset:'day',exposure:.95,contextOpacity:.4,shadows:true,effects:true,quality:'standard'};
@@ -41,7 +44,12 @@ window.webQA={errors:[],loaded:[],mode:'network',contextVisible};
window.addEventListener('error',event=>{const message=String(event.message||'三维场景运行错误');window.webQA.errors.push(message);postHost('error',{message});}); window.addEventListener('error',event=>{const message=String(event.message||'三维场景运行错误');window.webQA.errors.push(message);postHost('error',{message});});
window.addEventListener('unhandledrejection',event=>{const message=String(event.reason||'三维场景异步任务失败');window.webQA.errors.push(message);postHost('error',{message});}); window.addEventListener('unhandledrejection',event=>{const message=String(event.reason||'三维场景异步任务失败');window.webQA.errors.push(message);postHost('error',{message});});
const manifest=await (await fetch('./manifest.json',{cache:'no-store'})).json(); const manifestResponse=await fetch('./manifest.json',{cache:'no-cache',signal:AbortSignal.timeout(30000)});
if(!manifestResponse.ok)throw Error('场景清单加载失败:HTTP '+manifestResponse.status);
const manifest=await manifestResponse.json();
const resourceCache=createSceneResourceCache(import.meta.url,reportProgress);
function startLoading(files){beginLoading(files);postHost('loading',{active:true});}
function completeLoading(){finishLoading();postHost('loading',{active:false});}
const viewport=()=>({width:Math.max(240,innerWidth),height:Math.max(240,innerHeight)}); const viewport=()=>({width:Math.max(240,innerWidth),height:Math.max(240,innerHeight)});
const initialViewport=viewport(); const initialViewport=viewport();
const renderer=new THREE.WebGLRenderer({antialias:true,logarithmicDepthBuffer:false}); const renderer=new THREE.WebGLRenderer({antialias:true,logarithmicDepthBuffer:false});
@@ -52,6 +60,7 @@ const controls=new OrbitControls(camera,renderer.domElement);
const appearance=createAppearance(renderer,scene); const appearance=createAppearance(renderer,scene);
const effects=createRenderEffects(renderer,scene,camera,initialViewport.width,initialViewport.height); const effects=createRenderEffects(renderer,scene,camera,initialViewport.width,initialViewport.height);
const loader=new GLTFLoader().setMeshoptDecoder(MeshoptDecoder); const loader=new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
const loadModel=asset=>resourceCache.load(asset.file,asset.sha256,data=>loader.parseAsync(data,new URL('./',new URL(asset.file,import.meta.url)).href));
const cache=new Map(),root=new THREE.Group();scene.add(root); const cache=new Map(),root=new THREE.Group();scene.add(root);
function isBuildingResource(resourceId=''){ function isBuildingResource(resourceId=''){
@@ -86,22 +95,28 @@ async function load(id){
if(!cache.has(id)){ if(!cache.has(id)){
const asset=manifest.assets.find(item=>item.id===id); const asset=manifest.assets.find(item=>item.id===id);
if(!asset)throw Error('场景清单中不存在资源:'+id); if(!asset)throw Error('场景清单中不存在资源:'+id);
cache.set(id,loader.loadAsync(asset.file).then(gltf=>{ cache.set(id,loadModel(asset).then(gltf=>{
enhanceMaterials(gltf.scene);gltf.scene.userData.assetId=asset.logicalId??asset.assetId;gltf.scene.userData.resourceId=id; enhanceMaterials(gltf.scene);gltf.scene.userData.assetId=asset.logicalId??asset.assetId;gltf.scene.userData.resourceId=id;
gltf.scene.traverse(object=>{if(object.isMesh)for(const material of Array.isArray(object.material)?object.material:[object.material])material.userData.original={opacity:material.opacity,transparent:material.transparent,depthWrite:material.depthWrite};}); gltf.scene.traverse(object=>{if(object.isMesh)for(const material of Array.isArray(object.material)?object.material:[object.material])material.userData.original={opacity:material.opacity,transparent:material.transparent,depthWrite:material.depthWrite};});
return gltf.scene; return gltf.scene;
})); }).catch(error=>{cache.delete(id);throw error;}));
} }
return cache.get(id); return cache.get(id);
} }
async function show(next,{frame=true}={}){ async function show(next,{frame=true}={}){
if(!['network','hydraulic','map','detail','pump','meters'].includes(next))throw Error('不支持的场景模式:'+next); 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==='detail')ids=[...manifest.mapDefault.filter(id=>!['map_roof','map_facade'].includes(id)),'detail_roof','detail_facade','detail_interior','detail_ceiling'];
if(next==='pump')ids=['detail_pump_walls','detail_pump_access','detail_pump_auxiliary','detail_pump_piping_reference',...manifest.assets.filter(asset=>asset.id.startsWith('physical_')).map(asset=>asset.id)]; if(next==='pump')ids=['detail_pump_walls','detail_pump_access','detail_pump_auxiliary','detail_pump_piping_reference',...manifest.assets.filter(asset=>asset.id.startsWith('physical_')).map(asset=>asset.id)];
if(next==='meters')ids=['meter_dn150','valve_dn250','pump_symbol']; if(next==='meters')ids=['meter_dn150','valve_dn250','pump_symbol'];
if(sceneReady)startLoading(ids.map(id=>manifest.assets.find(asset=>asset.id===id).file));
setStatus('正在载入 '+ids.length+' 组场景资源'); setStatus('正在载入 '+ids.length+' 组场景资源');
const objects=await Promise.all(ids.map(load));if(token!==generation)return; let objects;
try{objects=await Promise.all(ids.map(async id=>{
const object=await load(id);const asset=manifest.assets.find(item=>item.id===id);
reportProgress({file:asset.file,stage:'ready',loaded:asset.bytes,total:asset.bytes});return object;
}));}catch(error){if(token===generation){setStatus('场景资源加载失败,请重试');failLoading(error);postHost('loading',{active:false,failed:true});}throw error;}
if(token!==generation)return;
root.clear();objects.forEach((object,index)=>{ root.clear();objects.forEach((object,index)=>{
object.position.set(next==='meters'?index*1.5:0,0,0);object.visible=true;root.add(object); object.position.set(next==='meters'?index*1.5:0,0,0);object.visible=true;root.add(object);
object.traverse(mesh=>{if(mesh.isMesh)for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])applyBuildingContext(material,['network','hydraulic'].includes(next),appearanceState.contextOpacity);}); object.traverse(mesh=>{if(mesh.isMesh)for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])applyBuildingContext(material,['network','hydraulic'].includes(next),appearanceState.contextOpacity);});
@@ -110,6 +125,7 @@ async function show(next,{frame=true}={}){
networkStyle?.setFocusScope(next==='hydraulic');applyVisibility();inspector?.highlight();if(frame)fit(); networkStyle?.setFocusScope(next==='hydraulic');applyVisibility();inspector?.highlight();if(frame)fit();
window.webQA.loaded=ids;window.webQA.mode=next; window.webQA.loaded=ids;window.webQA.mode=next;
setStatus(next==='pump'?'六泵实体与 CAD 管路参考,接口配准待核':next==='meters'?'设备样件,主管不包含在样件内':['network','hydraulic'].includes(next)?'管网实体模型,建筑为透明背景':'已载入 '+ids.length+' 组场景资源'); setStatus(next==='pump'?'六泵实体与 CAD 管路参考,接口配准待核':next==='meters'?'设备样件,主管不包含在样件内':['network','hydraulic'].includes(next)?'管网实体模型,建筑为透明背景':'已载入 '+ids.length+' 组场景资源');
completeLoading();
} }
function updateStyle(){appearance.refresh();inspector?.refresh();window.webQA.networkStyle={...networkStyle.summary,style:networkStyle.style};postState();} function updateStyle(){appearance.refresh();inspector?.refresh();window.webQA.networkStyle={...networkStyle.summary,style:networkStyle.style};postState();}
function setDisplayMode(value){networkStyle.setDisplayMode(value);navigation?.stop();updateStyle();} function setDisplayMode(value){networkStyle.setDisplayMode(value);navigation?.stop();updateStyle();}
@@ -161,8 +177,9 @@ renderer.setAnimationLoop(()=>{
effects.render(mode);window.webQA.drawCalls=renderer.info.render.calls;window.webQA.triangles=renderer.info.render.triangles;window.webQA.camera={active:cameraState.active,position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray()};document.getElementById('qa').textContent=JSON.stringify(window.webQA); effects.render(mode);window.webQA.drawCalls=renderer.info.render.calls;window.webQA.triangles=renderer.info.render.triangles;window.webQA.camera={active:cameraState.active,position:camera.position.toArray(),target:controls.target.toArray(),up:camera.up.toArray()};document.getElementById('qa').textContent=JSON.stringify(window.webQA);
}); });
net=await (await fetch(manifest.networkModel.metadata,{cache:'no-store'})).json(); startLoading([manifest.networkModel.metadata,manifest.networkModel.file,...manifest.networkContext.map(id=>manifest.assets.find(asset=>asset.id===id).file)]);
const built=await loader.loadAsync(manifest.networkModel.file+'?v='+manifest.networkModel.sha256);networkRoot=built.scene;enhanceMaterials(networkRoot);scene.add(networkRoot); net=await resourceCache.load(manifest.networkModel.metadata,manifest.networkModel.metadataSha256,data=>parseNetworkMetadata(data,manifest.networkModel.id));
const built=await loadModel(manifest.networkModel);networkRoot=built.scene;enhanceMaterials(networkRoot);scene.add(networkRoot);
networkStyle=attachNetworkStyle(networkRoot,net);grain(networkStyle.pipes.material,{frequency:140,amplitude:.00004,variation:.08}); networkStyle=attachNetworkStyle(networkRoot,net);grain(networkStyle.pipes.material,{frequency:140,amplitude:.00004,variation:.08});
try{const saved=localStorage.getItem('zjb-network-style-v24');if(saved)networkStyle.setStyle(JSON.parse(saved));}catch{} try{const saved=localStorage.getItem('zjb-network-style-v24');if(saved)networkStyle.setStyle(JSON.parse(saved));}catch{}
try{const quality=localStorage.getItem('zjb-render-quality-v27')??'standard';effects.setQuality(quality);appearanceState.quality=quality;}catch{effects.setQuality('standard');} try{const quality=localStorage.getItem('zjb-render-quality-v27')??'standard';effects.setQuality(quality);appearanceState.quality=quality;}catch{effects.setQuality('standard');}
@@ -204,5 +221,6 @@ window.addEventListener('message',async event=>{
}); });
await navigation.visit('overview'); await navigation.visit('overview');
document.getElementById('boot').hidden=true; sceneReady=true;
completeLoading();
postHost('ready',{nodeIds:net.nodes.map(node=>String(node.id)),linkIds:net.links.map(link=>String(link.id)),state:runtimeState()}); postHost('ready',{nodeIds:net.nodes.map(node=>String(node.id)),linkIds:net.links.map(link=>String(link.id)),state:runtimeState()});
@@ -0,0 +1,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);
},
};
}
+6 -1
View File
@@ -3,7 +3,12 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import MapComponent from "@components/olmap/core/MapComponent"; import MapComponent from "@components/olmap/core/MapComponent";
import { MapNavigationContent } from "@components/loading/NavigationFeedback";
export default function MapLayout({ children }: { children: ReactNode }) { export default function MapLayout({ children }: { children: ReactNode }) {
return <MapComponent>{children}</MapComponent>; return (
<MapComponent>
<MapNavigationContent>{children}</MapNavigationContent>
</MapComponent>
);
} }
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { usePathname } from "next/navigation";
import { MapRouteSkeleton } from "@components/loading/PageLoadingSkeleton";
import { useNavigationFeedback } from "@components/loading/NavigationFeedback";
export default function Loading() {
const pathname = usePathname();
const { pendingPath } = useNavigationFeedback();
return <MapRouteSkeleton pathname={pendingPath ?? pathname} />;
}
+24 -18
View File
@@ -6,6 +6,10 @@ import authOptions from "@app/api/auth/[...nextauth]/options";
import { Header } from "@components/header"; import { Header } from "@components/header";
import { Title } from "@components/title"; import { Title } from "@components/title";
import { AppSider } from "@components/sider/AppSider"; import { AppSider } from "@components/sider/AppSider";
import {
NavigationFeedbackProvider,
PageNavigationContent,
} from "@components/loading/NavigationFeedback";
import { ThemedLayout } from "@refinedev/mui"; import { ThemedLayout } from "@refinedev/mui";
import { getServerSession } from "next-auth/next"; import { getServerSession } from "next-auth/next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -32,24 +36,26 @@ export default async function MainLayout({
} }
return ( return (
<ThemedLayout <NavigationFeedbackProvider>
Header={Header} <ThemedLayout
Title={Title} Header={Header}
Sider={AppSider} Title={Title}
childrenBoxProps={{ Sider={AppSider}
sx: { childrenBoxProps={{
flex: 1, sx: {
minHeight: 0, flex: 1,
overflow: "auto", minHeight: 0,
p: 0, overflow: "hidden",
}, p: 0,
}} },
containerBoxProps={{ }}
sx: { height: "100vh", overflow: "hidden" }, containerBoxProps={{
}} sx: { height: "100vh", overflow: "hidden" },
> }}
{children} >
</ThemedLayout> <PageNavigationContent>{children}</PageNavigationContent>
</ThemedLayout>
</NavigationFeedbackProvider>
); );
} }
+5
View File
@@ -0,0 +1,5 @@
import { PageLoadingSkeleton } from "@components/loading/PageLoadingSkeleton";
export default function Loading() {
return <PageLoadingSkeleton />;
}
@@ -1,17 +1,13 @@
"use client"; "use client";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Box, CircularProgress } from "@mui/material"; import { PageLoadingSkeleton } from "@components/loading/PageLoadingSkeleton";
const ThreeDimensionalScene = dynamic( const ThreeDimensionalScene = dynamic(
() => import("@components/threeDimensional/ThreeDimensionalScene"), () => import("@components/threeDimensional/ThreeDimensionalScene"),
{ {
ssr: false, ssr: false,
loading: () => ( loading: () => <PageLoadingSkeleton pathname="/three-dimensional-scene" />,
<Box sx={{ height: "100%", display: "grid", placeItems: "center" }}>
<CircularProgress size={32} />
</Box>
),
}, },
); );
+4 -1
View File
@@ -12,6 +12,7 @@ import { usePathname } from "next/navigation";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import routerProvider from "@refinedev/nextjs-router"; import routerProvider from "@refinedev/nextjs-router";
import { NavigationLink } from "@components/loading/NavigationLink";
import { ColorModeContextProvider } from "@contexts/color-mode"; import { ColorModeContextProvider } from "@contexts/color-mode";
import { dataProvider } from "@providers/data-provider"; import { dataProvider } from "@providers/data-provider";
@@ -46,6 +47,8 @@ type RefineContextProps = {
defaultMode?: string; defaultMode?: string;
}; };
const appRouterProvider = { ...routerProvider, Link: NavigationLink };
export const RefineContext = ( export const RefineContext = (
props: React.PropsWithChildren<RefineContextProps>, props: React.PropsWithChildren<RefineContextProps>,
) => ( ) => (
@@ -377,7 +380,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
<ColorModeContextProvider defaultMode={props.defaultMode}> <ColorModeContextProvider defaultMode={props.defaultMode}>
<RefineSnackbarProvider> <RefineSnackbarProvider>
<Refine <Refine
routerProvider={routerProvider} routerProvider={appRouterProvider}
dataProvider={dataProvider} dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider} notificationProvider={useAppNotificationProvider}
authProvider={authProvider} authProvider={authProvider}
@@ -0,0 +1,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();
});
});
+45 -13
View File
@@ -3,6 +3,7 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
Box, Box,
Button,
Collapse, Collapse,
IconButton, IconButton,
LinearProgress, LinearProgress,
@@ -89,6 +90,11 @@ const ActionStatusIcon = ({ status }: { status: AgentActivityAction["status"] })
const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }) => { const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }) => {
const elapsed = getElapsedMs(action, now); 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 ( return (
<Stack <Stack
direction="row" direction="row"
@@ -111,19 +117,45 @@ const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }
</Typography> </Typography>
</Stack> </Stack>
{action.target ? ( {action.target ? (
<Typography <Box sx={{ mt: 0.2 }}>
variant="caption" <Typography
color="text.secondary" id={targetId}
sx={{ variant="caption"
display: "block", color="text.secondary"
mt: 0.2, sx={{
fontFamily: "monospace", display: !targetExpanded && hasLongTarget ? "-webkit-box" : "block",
wordBreak: "break-word", fontFamily: "monospace",
whiteSpace: "pre-wrap", overflow: !targetExpanded && hasLongTarget ? "hidden" : "visible",
}} overflowWrap: "anywhere",
> whiteSpace: "pre-wrap",
{action.target} ...(!targetExpanded && hasLongTarget
</Typography> ? { 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} ) : null}
{action.error ? ( {action.error ? (
<Typography <Typography
+1 -1
View File
@@ -79,7 +79,7 @@ const approvalModeOptions: ReadonlyArray<{
{ {
value: "always", value: "always",
label: "始终允许", label: "始终允许",
description: "除明确禁止项外自动放行", description: "常规权限自动放行,敏感路径仍需确认",
icon: WarningAmberRounded, 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 { parseApplyLayerStylePayload } from "./toolCallStyleHelpers";
import { createDefaultLayerStyleState } from "@components/olmap/core/Controls/styleEditorPresets";
import { validateStyleConfig } from "@components/olmap/core/Controls/styleEditorUtils";
describe("parseApplyLayerStylePayload", () => { describe("parseApplyLayerStylePayload", () => {
it("accepts a valid snake_case interval contract", () => { it("accepts a valid snake_case interval contract", () => {
@@ -47,4 +49,17 @@ describe("parseApplyLayerStylePayload", () => {
}), }),
).toMatchObject({ layerId: "junctions", styleConfig: { opacity: 0.5 } }); ).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: [] });
});
}); });
+9 -4
View File
@@ -198,10 +198,15 @@ export const parseApplyLayerStylePayload = (
) return null; ) return null;
} }
const definedStyleConfig = styleConfig
? (Object.fromEntries(
Object.entries(styleConfig).filter(([, value]) => value !== undefined),
) as Partial<StyleConfig>)
: undefined;
const hasStyleOverrides = const hasStyleOverrides =
styleConfig && definedStyleConfig &&
Object.values(styleConfig).some((value) => Object.values(definedStyleConfig).some((value) =>
Array.isArray(value) ? value.length > 0 : value !== undefined, Array.isArray(value) ? value.length > 0 : true,
); );
if (!resetToDefault && !hasStyleOverrides) { if (!resetToDefault && !hasStyleOverrides) {
@@ -211,7 +216,7 @@ export const parseApplyLayerStylePayload = (
return { return {
layerId, layerId,
resetToDefault, 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} />}
</>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import type { RouterProvider } from "@refinedev/core";
import Link, { useLinkStatus } from "next/link";
import { forwardRef, useEffect, useId, type ComponentProps } from "react";
import { useNavigationFeedback } from "./NavigationFeedback";
function NavigationLinkStatus({ to }: { to: string }) {
const { pending } = useLinkStatus();
const { reportPending } = useNavigationFeedback();
const id = useId();
const pathname = to.split(/[?#]/)[0];
useEffect(() => {
reportPending(id, pathname, pending);
return () => reportPending(id, pathname, false);
}, [id, pathname, pending, reportPending]);
return null;
}
export const NavigationLink = forwardRef<
HTMLAnchorElement,
ComponentProps<NonNullable<RouterProvider["Link"]>>
>(
function NavigationLink({ to, children, ...props }, ref) {
return (
<Link href={to} {...props} ref={ref}>
{children}
<NavigationLinkStatus to={to} />
</Link>
);
},
);
@@ -0,0 +1,110 @@
"use client";
import { Box, CircularProgress, Skeleton, Typography } from "@mui/material";
import { usePathname } from "next/navigation";
import { MapPanelSkeleton, MapTimelineSkeleton } from "./MapComponentSkeletons";
import {
MAP_SKELETON_CONFIGS,
type MapSkeletonVariant,
} from "./mapComponentSkeletonConfig";
const sceneLoadingSurface = {
width: "min(100%, 360px)",
p: 3,
boxSizing: "border-box",
borderRadius: "16px",
background: "linear-gradient(135deg,rgba(255,255,255,.50),rgba(224,239,250,.28))",
backdropFilter: "blur(24px) saturate(170%) contrast(94%)",
WebkitBackdropFilter: "blur(24px) saturate(170%) contrast(94%)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,.88),inset 0 -1px 0 rgba(112,145,168,.18),0 18px 50px rgba(15,43,69,.20),0 0 0 1px rgba(255,255,255,.55)",
};
const mapPaths: Record<string, MapSkeletonVariant> = {
"/network-simulation": "network-simulation",
"/scada-data-cleaning": "scada-data-cleaning",
"/health-risk-analysis": "health-risk-analysis",
"/monitoring-place-optimization": "monitoring-place-optimization",
"/hydraulic-simulation/burst-detection": "burst-detection",
"/hydraulic-simulation/burst-location": "burst-location",
"/hydraulic-simulation/burst-simulation": "burst-simulation",
"/hydraulic-simulation/contaminant-simulation": "contaminant-simulation",
"/hydraulic-simulation/dma-leak-detection": "dma-leak-detection",
"/hydraulic-simulation/flushing-analysis": "flushing-analysis",
};
export const mapSkeletonForPath = (
pathname: string,
): MapSkeletonVariant | undefined => mapPaths[pathname];
export const pageTitleForPath = (pathname: string) => {
const variant = mapSkeletonForPath(pathname);
if (variant) return MAP_SKELETON_CONFIGS[variant].title;
return ({
"/system-admin": "系统管理",
"/audit-logs": "审计日志",
"/three-dimensional-scene": "三维场景",
} as Record<string, string>)[pathname] ?? "页面";
};
export function MapRouteSkeleton({ pathname }: { pathname: string }) {
const variant = mapSkeletonForPath(pathname);
if (!variant) return null;
return (
<>
<MapPanelSkeleton variant={variant} />
{(variant === "network-simulation" || variant === "health-risk-analysis") && (
<MapTimelineSkeleton />
)}
</>
);
}
export function PageLoadingSkeleton({ pathname }: { pathname?: string }) {
const currentPath = usePathname();
const path = pathname ?? currentPath;
if (path === "/three-dimensional-scene") {
return (
<Box
role="status"
aria-live="polite"
aria-label="正在加载三维场景"
sx={{ height: "100%", minHeight: 0, display: "grid", placeItems: "center", p: 2, boxSizing: "border-box", bgcolor: "#e2e8e9" }}
>
<Box sx={sceneLoadingSurface}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25 }}>
<CircularProgress size={20} />
<Typography sx={{ fontSize: 14, fontWeight: 600, color: "#0f172a" }}>
正在准备三维场景
</Typography>
</Box>
<Typography sx={{ mt: 1.5, fontSize: 12, lineHeight: 1.75, color: "#334155" }}>
正在载入页面,随后将检查本地模型缓存。
</Typography>
</Box>
</Box>
);
}
if (mapSkeletonForPath(path)) {
return (
<Box sx={{ position: "relative", height: "100%", minHeight: 320, overflow: "hidden", bgcolor: "#f1f5f9" }}>
<MapRouteSkeleton pathname={path} />
</Box>
);
}
const title = pageTitleForPath(path);
return (
<Box
role="status"
aria-live="polite"
aria-label={`正在加载${title}`}
sx={{ p: { xs: 2, md: 3 }, height: "100%", boxSizing: "border-box", bgcolor: "background.default" }}
>
<Typography sx={{ fontSize: 16, fontWeight: 600, mb: 3 }}>正在加载{title}</Typography>
<Skeleton variant="rounded" height={40} sx={{ maxWidth: 480, mb: 3 }} />
{[0, 1, 2, 3].map((row) => (
<Skeleton key={row} variant="rounded" height={56} sx={{ mb: 1.5 }} />
))}
</Box>
);
}
@@ -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));
});
});
+11 -5
View File
@@ -307,9 +307,15 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]); 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 hasData = timeSeries.length > 0;
const deviceIdsKey = useMemo(() => deviceIds.join(","), [deviceIds]);
const dataset = useMemo( const dataset = useMemo(
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning), () => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
@@ -348,7 +354,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setError(null); setError(null);
try { try {
const { from: rangeFrom, to: rangeTo } = normalizedRange; const { from: rangeFrom, to: rangeTo } = normalizedRange;
const result = await customFetcher(deviceIds, { const result = await customFetcher(requestedDeviceIds, {
from: rangeFrom.toDate(), from: rangeFrom.toDate(),
to: rangeTo.toDate(), to: rangeTo.toDate(),
}); });
@@ -360,7 +366,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setLoadingState("error"); setLoadingState("error");
} }
}, },
[deviceIds, customFetcher, hasDevices, normalizedRange], [requestedDeviceIds, customFetcher, hasDevices, normalizedRange],
); );
// 处理数据清洗 // 处理数据清洗
@@ -447,7 +453,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setTimeSeries([]); setTimeSeries([]);
setHistorySeries([]); setHistorySeries([]);
} }
}, [deviceIdsKey, handleFetch, hasDevices]); }, [handleFetch, hasDevices]);
// 当设备数量变化时,调整数据源选择 // 当设备数量变化时,调整数据源选择
useEffect(() => { useEffect(() => {
@@ -1,5 +1,7 @@
import React from "react";
import { render, waitFor } from "@testing-library/react";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { fetchHistoryData } from "./HistoryDataPanel"; import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel";
jest.mock("@/lib/apiFetch", () => ({ jest.mock("@/lib/apiFetch", () => ({
apiFetch: jest.fn(), apiFetch: jest.fn(),
@@ -9,6 +11,12 @@ jest.mock("@components/olmap/core/MapComponent", () => ({
useData: () => null, 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) => const jsonResponse = (payload: unknown, status = 200) =>
({ ({
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
@@ -94,3 +102,39 @@ describe("fetchHistoryData", () => {
expect(apiFetch).toHaveBeenCalledTimes(1); 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 normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
const hasDevices = deviceIds.length > 0; const featureInfosKey = JSON.stringify(featureInfos);
const hasData = timeSeries.length > 0; const requestedFeatureInfos = useMemo(
const featureInfosKey = useMemo( () => JSON.parse(featureInfosKey) as [string, string][],
() => JSON.stringify(featureInfos), [featureInfosKey],
[featureInfos]
); );
const hasDevices = requestedFeatureInfos.length > 0;
const hasData = timeSeries.length > 0;
const dataset = useMemo( const dataset = useMemo(
() => buildDataset(timeSeries, historySeries, fractionDigits), () => buildDataset(timeSeries, historySeries, fractionDigits),
@@ -289,7 +290,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
try { try {
const { from: rangeFrom, to: rangeTo } = normalizedRange; const { from: rangeFrom, to: rangeTo } = normalizedRange;
const result = await fetchHistoryData( const result = await fetchHistoryData(
featureInfos, requestedFeatureInfos,
{ {
from: rangeFrom.toDate(), from: rangeFrom.toDate(),
to: rangeTo.toDate(), to: rangeTo.toDate(),
@@ -313,7 +314,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setLoadingState("error"); setLoadingState("error");
} }
}, },
[featureInfos, hasDevices, normalizedRange, runId, type], [requestedFeatureInfos, hasDevices, normalizedRange, runId, type],
); );
useEffect( useEffect(
@@ -331,7 +332,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setTimeSeries([]); setTimeSeries([]);
setHistorySeries([]); setHistorySeries([]);
} }
}, [featureInfosKey, handleFetch, hasDevices]); }, [handleFetch, hasDevices]);
const columns: GridColDef[] = useMemo(() => { const columns: GridColDef[] = useMemo(() => {
const base: GridColDef[] = [ const base: GridColDef[] = [
@@ -1,6 +1,6 @@
"use client"; "use client";
import Link from "next/link"; import { NavigationLink } from "@components/loading/NavigationLink";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
FiAlertCircle, FiAlertCircle,
@@ -38,8 +38,8 @@ import {
type SceneRuntimeState, type SceneRuntimeState,
} from "./sceneProtocol"; } from "./sceneProtocol";
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=32-context-opacity"; const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=35-resource-cache";
const SCENE_LOAD_TIMEOUT_MS = 30_000; const SCENE_LOAD_TIMEOUT_MS = 120_000;
const initialRuntimeState: SceneRuntimeState = { const initialRuntimeState: SceneRuntimeState = {
mode: "network", mode: "network",
@@ -138,6 +138,9 @@ export default function ThreeDimensionalScene() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [sceneError, setSceneError] = useState<string | null>(null); const [sceneError, setSceneError] = useState<string | null>(null);
const [sceneLoading, setSceneLoading] = useState(true);
const [sceneResourceError, setSceneResourceError] = useState(false);
const [sceneStarted, setSceneStarted] = useState(false);
const [refreshVersion, setRefreshVersion] = useState(0); const [refreshVersion, setRefreshVersion] = useState(0);
const [sceneKey, setSceneKey] = useState(0); const [sceneKey, setSceneKey] = useState(0);
@@ -169,6 +172,9 @@ export default function ThreeDimensionalScene() {
setSelection(null); setSelection(null);
setRuntimeState(initialRuntimeState); setRuntimeState(initialRuntimeState);
setSceneError(null); setSceneError(null);
setSceneLoading(true);
setSceneResourceError(false);
setSceneStarted(false);
setSceneKey((value) => value + 1); setSceneKey((value) => value + 1);
}, []); }, []);
@@ -198,6 +204,17 @@ export default function ThreeDimensionalScene() {
}); });
setRuntimeState(message.state); setRuntimeState(message.state);
setSceneError(null); setSceneError(null);
setSceneLoading(false);
setSceneResourceError(false);
return;
}
if (message.type === "loading") {
if (typeof message.active === "boolean") {
setSceneLoading(message.active);
setSceneStarted(true);
setSceneResourceError(message.failed === true);
if (message.active) setSceneError(null);
}
return; return;
} }
if (message.type === "scene-state") { if (message.type === "scene-state") {
@@ -222,12 +239,14 @@ export default function ThreeDimensionalScene() {
}, [isZjbProject, sceneKey]); }, [isZjbProject, sceneKey]);
useEffect(() => { useEffect(() => {
if (!isZjbProject || model) return; // Once the viewer starts, its per-request inactivity timeout owns recovery.
// A long but progressing first download should not hit a total-load deadline.
if (!isZjbProject || model || !sceneLoading || sceneStarted) return;
const timerId = window.setTimeout(() => { const timerId = window.setTimeout(() => {
setSceneError("三维模型载入超时,请检查静态资源后重试。"); setSceneError("三维模型载入超时,请检查静态资源后重试。");
}, SCENE_LOAD_TIMEOUT_MS); }, SCENE_LOAD_TIMEOUT_MS);
return () => window.clearTimeout(timerId); return () => window.clearTimeout(timerId);
}, [isZjbProject, model, sceneKey]); }, [isZjbProject, model, sceneKey, sceneLoading, sceneStarted]);
useEffect(() => { useEffect(() => {
if (!isZjbProject) return; if (!isZjbProject) return;
@@ -301,9 +320,9 @@ export default function ThreeDimensionalScene() {
<p className="mt-1 text-sm leading-6 text-slate-600"> <p className="mt-1 text-sm leading-6 text-slate-600">
目前仅高铁湛江北站项目提供三维模型。请切换到 ZJB 项目后再打开此页面。 目前仅高铁湛江北站项目提供三维模型。请切换到 ZJB 项目后再打开此页面。
</p> </p>
<Link href="/network-simulation" className="mt-2 inline-flex min-h-10 items-center text-sm font-medium text-blue-700 hover:text-blue-800 hover:underline"> <NavigationLink to="/network-simulation" className="mt-2 inline-flex min-h-10 items-center text-sm font-medium text-blue-700 hover:text-blue-800 hover:underline">
返回管网在线模拟 返回管网在线模拟
</Link> </NavigationLink>
</div> </div>
</section> </section>
</main> </main>
@@ -324,14 +343,7 @@ export default function ThreeDimensionalScene() {
return ( return (
<main lang="zh-CN" className="relative h-full min-h-0 overflow-hidden bg-slate-200"> <main lang="zh-CN" className="relative h-full min-h-0 overflow-hidden bg-slate-200">
<iframe key={sceneKey} ref={iframeRef} src={SCENE_URL} title="高铁湛江北站供水系统三维场景" referrerPolicy="same-origin" className="block h-full w-full border-0" /> <iframe key={sceneKey} ref={iframeRef} src={SCENE_URL} title="高铁湛江北站供水系统三维场景" referrerPolicy="same-origin" className={`block h-full w-full border-0 ${!model || sceneLoading || sceneResourceError ? "relative z-40" : ""}`} />
{!model && !sceneError && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 bg-slate-100/55 text-sm text-slate-600 backdrop-blur-sm">
<span className="h-8 w-8 animate-spin rounded-full border-[3px] border-blue-200 border-t-blue-600" />
正在载入三维模型
</div>
)}
{(loadError || sceneError) && ( {(loadError || sceneError) && (
<div role="alert" className="absolute left-1/2 top-28 z-40 flex w-[min(560px,calc(100%-24px))] -translate-x-1/2 items-center gap-3 rounded-xl border border-red-200/70 bg-red-50/80 px-4 py-3 text-sm text-red-800 shadow-xl shadow-red-950/10 backdrop-blur-xl md:top-4"> <div role="alert" className="absolute left-1/2 top-28 z-40 flex w-[min(560px,calc(100%-24px))] -translate-x-1/2 items-center gap-3 rounded-xl border border-red-200/70 bg-red-50/80 px-4 py-3 text-sm text-red-800 shadow-xl shadow-red-950/10 backdrop-blur-xl md:top-4">
@@ -387,7 +399,7 @@ export default function ThreeDimensionalScene() {
<ThreeDimensionalControls <ThreeDimensionalControls
open={controlsOpen} open={controlsOpen}
activeTab={controlTab} activeTab={controlTab}
ready={Boolean(model)} ready={Boolean(model) && !sceneLoading && !sceneResourceError}
state={runtimeState} state={runtimeState}
selection={selection} selection={selection}
timelineOpen={timelineOpen} timelineOpen={timelineOpen}
@@ -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", "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"> <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/60 transition-colors hover:bg-slate-500/70" /> <span aria-hidden="true" className="h-1 w-10 rounded-full bg-slate-400/55 transition-colors hover:bg-slate-500/70" />
<button <button
type="button" type="button"
aria-label="收起时间轴" aria-label="收起时间轴"
title="收起时间轴" title="收起时间轴"
onClick={onClose} 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 /> <FiX />
</button> </button>
@@ -20,7 +20,8 @@ describe("ZJB three-dimensional runtime assets", () => {
packageId: string; packageId: string;
renderRevision: number; renderRevision: number;
assets: Array<{ file: string; sha256: string }>; 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; sourceCatalogFile?: string;
bindingsFile?: string; bindingsFile?: string;
}; };
@@ -28,6 +29,16 @@ describe("ZJB three-dimensional runtime assets", () => {
expect(manifest.packageId).toBe("zjb-web-v29"); expect(manifest.packageId).toBe("zjb-web-v29");
expect(manifest.renderRevision).toBe(29); expect(manifest.renderRevision).toBe(29);
expect(manifest.assets).toHaveLength(24); 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) => { manifest.assets.forEach((asset) => {
const assetPath = join(sceneRoot, asset.file); const assetPath = join(sceneRoot, asset.file);
expect(existsSync(assetPath)).toBe(true); 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.file))).toBe(true);
expect(existsSync(join(sceneRoot, manifest.networkModel.metadata))).toBe(true); expect(existsSync(join(sceneRoot, manifest.networkModel.metadata))).toBe(true);
expect(createHash("sha256").update(
readFileSync(join(sceneRoot, manifest.networkModel.metadata)),
).digest("hex")).toBe(manifest.networkModel.metadataSha256);
expect(manifest.sourceCatalogFile).toBeUndefined(); expect(manifest.sourceCatalogFile).toBeUndefined();
expect(manifest.bindingsFile).toBeUndefined(); expect(manifest.bindingsFile).toBeUndefined();
@@ -56,6 +70,8 @@ describe("ZJB three-dimensional runtime assets", () => {
"network-style.mjs", "network-style.mjs",
"integration.mjs", "integration.mjs",
"render-effects.mjs", "render-effects.mjs",
"resourceCache.mjs",
"loadingProgress.mjs",
"vendor/meshopt_decoder.module.js", "vendor/meshopt_decoder.module.js",
"vendor/meshoptimizer-LICENSE.md", "vendor/meshoptimizer-LICENSE.md",
"vendor/three/LICENSE", "vendor/three/LICENSE",
@@ -73,6 +89,10 @@ describe("ZJB three-dimensional runtime assets", () => {
join(sceneRoot, "asset-inspector.mjs"), join(sceneRoot, "asset-inspector.mjs"),
"utf8", "utf8",
); );
const networkStyle = readFileSync(
join(sceneRoot, "network-style.mjs"),
"utf8",
);
expect(preview).toContain("tjwater:zjb-scene"); expect(preview).toContain("tjwater:zjb-scene");
expect(preview).toContain("HOST_VERSION=2"); 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==='clear-results'");
expect(preview).toContain("data.type!=='command'"); expect(preview).toContain("data.type!=='command'");
expect(preview).toContain("postHost('selection-changed'"); 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("window.zjbNetwork");
expect(preview).not.toContain("resultsFile"); expect(preview).not.toContain("resultsFile");
expect(html).not.toContain("绑定运行结果"); expect(html).not.toContain("绑定运行结果");
expect(html).not.toContain("resultsFile"); expect(html).not.toContain("resultsFile");
expect(inspector).not.toContain("document.getElementById"); 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); 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", () => { it("ignores IDs outside the scene model instead of rejecting the frame", () => {
const frame = buildSceneFrame({ const frame = buildSceneFrame({
queryTime: new Date("2026-09-11T03:00:00.000Z"), queryTime: new Date("2026-09-11T03:00:00.000Z"),
@@ -261,4 +297,39 @@ describe("buildSceneFrame", () => {
"SCADA 数据暂不可用,当前仅显示模拟结果。", "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 监测点。",
]);
});
}); });
+34 -3
View File
@@ -157,6 +157,28 @@ const resolveCommonFrameTime = (
)[0]; )[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 = ( const normalizeLinkStatus = (
value: number | null, value: number | null,
): SceneLinkResult["status"] => { ): SceneLinkResult["status"] => {
@@ -210,7 +232,13 @@ export const buildSceneFrame = ({
simulationUnits?: NetworkResultUnits; simulationUnits?: NetworkResultUnits;
}): SceneFrame => { }): SceneFrame => {
const selectedTime = queryTime.toISOString(); 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) { if (frameTime === null) {
return { return {
selectedTime, selectedTime,
@@ -313,7 +341,10 @@ export const buildSceneFrame = ({
missingLinks: Math.max(0, model.linkIds.size - Object.keys(links).length), missingLinks: Math.max(0, model.linkIds.size - Object.keys(links).length),
ignoredElements, ignoredElements,
}, },
warnings: [], warnings:
simulationFrameTime === null
? ["当前时刻无在线模拟结果,已显示 SCADA 监测点。"]
: [],
}; };
}; };
@@ -404,5 +435,5 @@ export const fetchSceneFrame = async ({
scadaRows, scadaRows,
simulationUnits: networkOptions, simulationUnits: networkOptions,
}); });
return { ...frame, warnings }; return { ...frame, warnings: [...frame.warnings, ...warnings] };
}; };
@@ -145,6 +145,7 @@ export type SceneRuntimeMessage = HostMessageBase &
state: SceneRuntimeState; state: SceneRuntimeState;
} }
| { type: "scene-state"; state: SceneRuntimeState } | { type: "scene-state"; state: SceneRuntimeState }
| { type: "loading"; active: boolean; failed?: boolean }
| { type: "selection-changed"; selection: SceneAssetSelection | null } | { type: "selection-changed"; selection: SceneAssetSelection | null }
| { type: "results-applied"; summary: SceneStyleSummary } | { type: "results-applied"; summary: SceneStyleSummary }
| { type: "results-cleared" } | { type: "results-cleared" }