Compare commits

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

Add regression cases observed failing before the change for interrupted downloads, cross-reload resume, and temporary HTTP failures. Verify range integrity, full-cache reuse, permanent errors, and storage fallback.
2026-10-16 18:37:24 +08:00
jiang 4a522bf42a feat(frontend): cache 3D assets and restore loading feedback
Generic Container CI/CD / test-build-publish (push) Successful in 1m11s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m11s
Persist versioned models and validated network metadata in IndexedDB. Show scene download progress and recoverable errors with the existing frosted material.

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

Verified: 32 focused unit tests; 4 navigation and scene browser checks; 7 cache browser checks including invalid-metadata recovery; isolated production build and type checks.
2026-10-16 18:13:04 +08:00
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
jiang a9b25b94d8 feat(3d): refine scene controls and camera views
Generic Container CI/CD / test-build-publish (push) Successful in 2m20s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m20s
Keep the control panel geometry stable when toggling building context, and use accessible switch controls to prevent focus-driven movement. Add browser coverage for camera framing, opacity, responsive layout, and the default 6x pipe scale.
2026-09-14 15:41:44 +08:00
jiang 69ad145e75 test(e2e): add frontend smoke coverage 2026-09-14 12:57:35 +08:00
jiang 331ea3f094 fix(3d): stabilize timeline dragging 2026-09-14 12:57:26 +08:00
jiang 08ddb4453d feat(history): align units and history data 2026-09-14 12:30:57 +08:00
71 changed files with 4664 additions and 3783 deletions
+63
View File
@@ -0,0 +1,63 @@
name: Frontend E2E
on:
pull_request:
push:
branches:
- main
- master
workflow_dispatch: {}
jobs:
playwright:
runs-on: ubuntu-22.04
container:
image: mcr.microsoft.com/playwright:v1.63.0-noble
permissions:
contents: read
defaults:
run:
shell: sh
steps:
- name: Checkout code
env:
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
GIT_USERNAME: ${{ github.actor }}
GIT_TOKEN: ${{ github.token }}
run: |
case "$SERVER_URL" in
http://*)
AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}"
;;
https://*)
AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}"
;;
*)
AUTH_SERVER_URL="$SERVER_URL"
;;
esac
if [ ! -d .git ]; then
git init .
fi
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
else
git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
fi
git fetch --depth=1 origin "$COMMIT_SHA"
git checkout --force --detach FETCH_HEAD
git clean -ffdx
- name: Install dependencies
run: npm ci
- name: Run Playwright tests
env:
CI: "true"
run: npm run test:e2e
+3
View File
@@ -8,6 +8,9 @@
# testing # testing
/coverage /coverage
/playwright-report/
/test-results/
/e2e/.auth/
# next.js # next.js
/.next/ /.next/
+32
View File
@@ -44,6 +44,7 @@ npm run dev
npm run lint npm run lint
npm test npm test
npm run test:coverage npm run test:coverage
npm run test:e2e
npm run build npm run build
npm run start npm run start
docker build -t tjwater-frontend:local . docker build -t tjwater-frontend:local .
@@ -52,6 +53,7 @@ docker build -t tjwater-frontend:local .
- `npm run lint`:运行 ESLint。 - `npm run lint`:运行 ESLint。
- `npm test`:运行 Jest。 - `npm test`:运行 Jest。
- `npm run test:coverage`:生成测试覆盖率。 - `npm run test:coverage`:生成测试覆盖率。
- `npm run test:e2e`:启动本地 Next.js 与 Playwright Chromium 烟测。
- `npm run build`:生成生产构建。 - `npm run build`:生成生产构建。
- `npm run start`:启动生产模式服务。 - `npm run start`:启动生产模式服务。
@@ -86,6 +88,36 @@ npm run build
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建和推送镜像。 Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建和推送镜像。
### Playwright E2E
首次运行先安装 Chromium:
```bash
npm run e2e:install
npm run test:e2e
```
本地测试会自动构建生产版本并启动隔离的 `http://127.0.0.1:3100`,生成仅用于测试的 NextAuth
会话,并模拟后端 API,因此不要求启动 Keycloak、Server 或 Agent。失败时可通过
`npm run test:e2e:report` 查看 HTML 报告,交互调试可使用
`npm run test:e2e:ui` 或 `npm run test:e2e:debug`。
在不能访问 Playwright CDN 的内网环境,可设置
`E2E_CHROMIUM_PATH=/absolute/path/to/chrome` 复用预装的 Chromium/Chrome。
若要对已部署环境运行测试,请传入环境地址和预先保存的管理员 Playwright 登录状态:
```bash
E2E_BASE_URL=https://example.test \
E2E_STORAGE_STATE=/absolute/path/to/storage-state.json \
E2E_USE_REAL_SERVICES=true \
npm run test:e2e
```
`E2E_BASE_URL` 会关闭本地开发服务器;`E2E_STORAGE_STATE` 避免在仓库中保存账号或
会话;`E2E_USE_REAL_SERVICES=true` 会关闭 API 模拟。Gitea 的
`.gitea/workflows/e2e.yml` 在分支推送和 PR 中运行同一组 Chromium 测试。
## 安全规则 ## 安全规则
不要提交 `.env`、`.next/`、`node_modules/`、本地缓存、私有地图/API token、客户数据或部署密钥。CI/CD 凭据应放在 Gitea secrets 中。 不要提交 `.env`、`.next/`、`node_modules/`、本地缓存、私有地图/API token、客户数据或部署密钥。CI/CD 凭据应放在 Gitea secrets 中。
+1 -1
View File
@@ -7,7 +7,7 @@
}, },
"server": { "server": {
"file": "server-v1.openapi.json", "file": "server-v1.openapi.json",
"sha256": "d0364fb08c6f18fac2ea9c9980ef21115fc01f110cd10f25f90d97d0bc0e6367" "sha256": "b565d841061c9091f48ff3118bcc0cbb1b918b0cb8c2316d177570e8b2d8ba29"
} }
} }
} }
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
# 三维场景静态资源下载与缓存
`public/three-dimensional/zjb/v29/resourceCache.mjs` 统一加载模型和管网静态元数据,不缓存实时仿真或 SCADA 数据。
- 每次请求最多下载 1 MiB,通过单区间 `Range` 请求获取资源。服务器返回 `206` 时,校验 `Content-Range` 的起止位置、总长度以及实际收到的字节数;不支持 Range 的服务器可在首次请求返回完整的 `200` 响应。
- 网络中断、连续 30 秒无数据以及临时 HTTP 错误可重试,每个分段最多尝试 3 次,等待间隔为 300、600 毫秒。永久 HTTP 错误和不合法的分段响应直接显示失败提示。
- 每个完整分段保存至 IndexedDB。刷新页面或重新载入后,从已保存的分段末尾继续;未完成的当前分段会重新下载。浏览器禁止存储或配额不足时,仍能在本次加载中下载和重试,但无法保证刷新后保留断点。
- 完整资源和部分下载均按 URL、manifest revision 匹配,版本改变后重新下载。完整资源经过解析验证后才进入正式缓存,后续打开场景直接复用。
- 进度界面显示下载、解析和断点重试状态。加载模块版本参数需要与 `preview.html`、`preview.mjs` 和 `ThreeDimensionalScene.tsx` 同步,防止浏览器沿用旧加载器。
反向代理应透传 `Range` 请求及 `206`、`Content-Range` 响应。若内网完整下载正常,而公网完整下载中断,应同时排查代理的大文件传输;客户端分段恢复不代表代理链路问题已修复。
聚焦回归:
```bash
E2E_BASE_URL=http://127.0.0.1:3000 npx playwright test e2e/three-dimensional-loading.spec.ts --workers=1
```
该命令需要已运行的前端。测试覆盖完整缓存复用、分段重试、跨刷新续传、错误分段拒绝及浏览器存储不可用的降级行为。
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from "@playwright/test";
import { mockBackend } from "./support/mockBackend";
test.beforeEach(async ({ page }) => {
await mockBackend(page);
});
test("已登录管理员可以打开审计日志页面", async ({ page }) => {
await page.goto("/audit-logs");
await expect(
page.getByRole("heading", { name: "审计日志", exact: true }),
).toBeVisible();
await expect(page.getByText("系统管理员权限已验证")).toBeVisible();
await expect(page.getByRole("heading", { name: "查询结果" })).toBeVisible();
});
+72
View File
@@ -0,0 +1,72 @@
import fs from "node:fs/promises";
import path from "node:path";
import { encode } from "next-auth/jwt";
import {
AUTH_STATE_PATH,
E2E_AUTH_SECRET,
E2E_BASE_URL,
} from "./support/environment";
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
export default async function globalSetup() {
if (process.env.E2E_STORAGE_STATE) return;
const now = Date.now();
const baseUrl = new URL(E2E_BASE_URL);
const secure = baseUrl.protocol === "https:";
const token = await encode({
secret: E2E_AUTH_SECRET,
maxAge: SESSION_MAX_AGE_SECONDS,
token: {
sub: "playwright-user",
username: "playwright",
name: "E2E 测试用户",
email: "playwright@example.invalid",
accessToken: "playwright-access-token",
accessTokenIssuedAt: now,
accessTokenExpires: now + SESSION_MAX_AGE_SECONDS * 1000,
sessionExpiresAt: now + SESSION_MAX_AGE_SECONDS * 1000,
},
});
await fs.mkdir(path.dirname(AUTH_STATE_PATH), { recursive: true });
await fs.writeFile(
AUTH_STATE_PATH,
JSON.stringify(
{
cookies: [
{
name: secure
? "__Secure-next-auth.session-token"
: "next-auth.session-token",
value: token,
domain: baseUrl.hostname,
path: "/",
expires: Math.floor(now / 1000) + SESSION_MAX_AGE_SECONDS,
httpOnly: true,
secure,
sameSite: "Lax",
},
],
origins: [
{
origin: baseUrl.origin,
localStorage: [
{ name: "active_project", value: "playwright-project" },
{ name: "MAP_WORKSPACE", value: "tjwater_e2e" },
{ name: "NETWORK_NAME", value: "tjwater_e2e" },
{
name: "MAP_EXTENT",
value: "13508801.93,3608163.35,13555650.64,3633685.14",
},
],
},
],
},
null,
2,
),
);
}
+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);
});
+15
View File
@@ -0,0 +1,15 @@
import path from "node:path";
export const E2E_BASE_URL =
process.env.E2E_BASE_URL || "http://127.0.0.1:3100";
export const E2E_AUTH_SECRET =
process.env.E2E_NEXTAUTH_SECRET ||
"tjwater-playwright-local-secret-at-least-32-characters";
export const AUTH_STATE_PATH = path.join(
process.cwd(),
"e2e",
".auth",
"user.json",
);
+63
View File
@@ -0,0 +1,63 @@
import type { Page, Route } from "@playwright/test";
const json = (route: Route, body: unknown) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
export const mockBackend = async (page: Page) => {
if (process.env.E2E_USE_REAL_SERVICES === "true") return;
await page.route("**/api/v1/**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname === "/api/v1/access-context") {
return json(route, {
user_id: "playwright-user",
username: "playwright",
system_role: "system_admin",
is_system_admin: true,
project_id: "playwright-project",
project_role: "project_admin",
permissions: [
"webgis.view",
"simulation.view",
"simulation.run",
"scada.clean",
"risk.run",
"optimization.run",
"burst.run",
"audit.view",
"environment.manage",
],
});
}
if (url.pathname === "/api/v1/projects/current") {
return json(route, {
project_id: "playwright-project",
code: "tjwater_e2e",
gs_workspace: "tjwater_e2e",
map_extent: {
bbox: [13508801.93, 3608163.35, 13555650.64, 3633685.14],
},
});
}
if (url.pathname === "/api/v1/audit-logs/count") {
return json(route, { count: 0 });
}
if (
url.pathname === "/api/v1/audit-logs" ||
url.pathname === "/api/v1/admin/users" ||
url.pathname === "/api/v1/admin/projects"
) {
return json(route, []);
}
return json(route, {});
});
};
+281
View File
@@ -0,0 +1,281 @@
import { expect, test } from "@playwright/test";
import { mockBackend } from "./support/mockBackend";
test.setTimeout(180_000);
test("首次加载和失败提示位于工具面板上方,窄屏可直接重试", async ({ page }) => {
await mockBackend(page);
await page.addInitScript(() => {
localStorage.setItem("active_project", "playwright-project");
localStorage.setItem("MAP_WORKSPACE", "zjb");
localStorage.setItem("NETWORK_NAME", "zjb");
});
let release: () => void = () => {};
const pending = new Promise<void>((resolve) => { release = resolve; });
const failedModel = "**/models/detail_roof.glb*";
await page.route(failedModel, async (route) => { await pending; await route.abort("failed"); });
await page.goto("/three-dimensional-scene");
const scene = page.frameLocator('iframe[title="高铁湛江北站供水系统三维场景"]');
await expect(scene.locator("#boot-title")).toHaveText("正在载入场景资源 9 / 10", { timeout: 60_000 });
const loadingMaterial = await scene.locator(".boot-panel").evaluate((element) => ({
background: getComputedStyle(element).backgroundImage,
blur: getComputedStyle(element).backdropFilter,
}));
for (const width of [1280, 375, 320]) {
await page.setViewportSize({ width, height: 800 });
expect(await page.evaluate(() => document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.tagName)).toBe("IFRAME");
await page.screenshot({ path: `test-results/scene-host-loading-${width}.png` });
}
release();
await expect(scene.locator("#boot-title")).toHaveText("场景资源加载失败");
await page.unroute(failedModel);
await scene.getByRole("button", { name: "重新载入场景" }).click();
await expect(scene.locator("#boot")).toBeHidden({ timeout: 60_000 });
await expect(page.getByRole("slider", { name: /建筑背景透明度/ })).toBeEnabled();
expect(await page.getByLabel("三维场景控制面板").evaluate((element) => ({
background: getComputedStyle(element).backgroundImage,
blur: getComputedStyle(element).backdropFilter,
}))).toEqual(loadingMaterial);
});
type CameraProbe = {
active: string | null;
position: number[];
target: number[];
up: number[];
};
type SceneProbe = {
errors: string[];
contextVisible?: boolean;
camera?: CameraProbe;
appearance?: { contextOpacity: number };
networkStyle?: { style: { scale: number } };
};
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");
});
await page.goto("/three-dimensional-scene");
await expect(page.getByLabel("三维场景控制面板")).toBeVisible();
await expect(page.getByRole("tab", { name: "场景" })).toHaveAttribute(
"aria-selected",
"true",
);
const opacitySlider = page.getByRole("slider", {
name: /建筑背景透明度/,
});
await expect(opacitySlider).toBeEnabled({ timeout: 60_000 });
await expect(opacitySlider).toHaveAccessibleName("建筑背景透明度 60%");
const backgroundToggle = page.getByRole("switch", { name: "建筑背景" });
await backgroundToggle.evaluate((element) =>
element.scrollIntoView({ block: "center" }),
);
const panel = page.getByLabel("三维场景控制面板");
const panelBeforeToggle = await panel.boundingBox();
const panelScrollBeforeToggle = await panel.locator(".overflow-y-auto").evaluate(
(element) => element.scrollTop,
);
await backgroundToggle.click();
await expect(backgroundToggle).toHaveAttribute("aria-checked", "false");
const panelAfterToggle = await panel.boundingBox();
const panelScrollAfterToggle = await panel.locator(".overflow-y-auto").evaluate(
(element) => element.scrollTop,
);
expect(panelAfterToggle).toEqual(panelBeforeToggle);
expect(panelScrollAfterToggle).toBe(panelScrollBeforeToggle);
await page.setViewportSize({ width: 375, height: 812 });
const mobilePanelBeforeToggle = await panel.boundingBox();
await backgroundToggle.click();
await expect(backgroundToggle).toHaveAttribute("aria-checked", "true");
const mobilePanelAfterToggle = await panel.boundingBox();
expect(mobilePanelAfterToggle).toEqual(mobilePanelBeforeToggle);
await expect(opacitySlider).toBeEnabled();
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
});
test("全部预设使用稳定且紧凑的场景视角", async ({ page }) => {
await page.goto(
"/three-dimensional/zjb/v29/preview.html?host=platform2&v=32-context-opacity-test",
);
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
const sceneProbe = () =>
page.evaluate(
() =>
(window as typeof window & { webQA: SceneProbe }).webQA,
);
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe("overview");
const initialProbe = await sceneProbe();
const overview = initialProbe.camera;
expect(overview?.up[1]).toBeGreaterThan(0.8);
expect(initialProbe.appearance?.contextOpacity).toBe(0.4);
expect(initialProbe.networkStyle?.style.scale).toBe(6);
await page.evaluate(() =>
window.postMessage(
{
channel: "tjwater:zjb-scene",
version: 2,
type: "command",
projectCode: "zjb",
modelId: "zjb-water-network-v23",
command: { name: "set-appearance", patch: { contextOpacity: 0.35 } },
},
window.location.origin,
),
);
await expect
.poll(async () => (await sceneProbe()).appearance?.contextOpacity)
.toBe(0.35);
const cameraBeforeContextToggle = (await sceneProbe()).camera;
await page.evaluate(() =>
window.postMessage(
{
channel: "tjwater:zjb-scene",
version: 2,
type: "command",
projectCode: "zjb",
modelId: "zjb-water-network-v23",
command: { name: "toggle-context" },
},
window.location.origin,
),
);
await expect.poll(async () => (await sceneProbe()).contextVisible).toBe(false);
const cameraAfterContextToggle = (await sceneProbe()).camera;
expect(cameraAfterContextToggle?.position).toEqual(cameraBeforeContextToggle?.position);
expect(cameraAfterContextToggle?.target).toEqual(cameraBeforeContextToggle?.target);
await page.evaluate(() =>
window.postMessage(
{
channel: "tjwater:zjb-scene",
version: 2,
type: "command",
projectCode: "zjb",
modelId: "zjb-water-network-v23",
command: { name: "toggle-context" },
},
window.location.origin,
),
);
await page.evaluate(() =>
window.postMessage(
{
channel: "tjwater:zjb-scene",
version: 2,
type: "command",
projectCode: "zjb",
modelId: "zjb-water-network-v23",
command: { name: "visit-camera", viewId: "plan" },
},
window.location.origin,
),
);
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe("plan");
await expect
.poll(async () => {
const camera = (await sceneProbe()).camera;
return Math.max(
Math.abs((camera?.position[0] ?? 0) - (camera?.target[0] ?? 1)),
Math.abs((camera?.position[2] ?? 0) - (camera?.target[2] ?? 1)),
);
})
.toBeLessThan(0.01);
const planProbe = await sceneProbe();
const plan = planProbe.camera;
expect(planProbe.errors).toEqual([]);
expect(Math.abs((plan?.position[0] ?? 0) - (plan?.target[0] ?? 1))).toBeLessThan(0.01);
expect(Math.abs((plan?.position[2] ?? 0) - (plan?.target[2] ?? 1))).toBeLessThan(0.01);
expect(plan?.up[0]).toBeCloseTo(0, 6);
expect(plan?.up[1]).toBeCloseTo(0, 6);
expect(plan?.up[2]).toBeCloseTo(-1, 6);
for (const viewId of [
"pump",
"hydraulic",
"singlePump",
"main",
"meter",
"crossing",
"station",
"riser",
]) {
await page.evaluate((nextViewId) =>
window.postMessage(
{
channel: "tjwater:zjb-scene",
version: 2,
type: "command",
projectCode: "zjb",
modelId: "zjb-water-network-v23",
command: { name: "visit-camera", viewId: nextViewId },
},
window.location.origin,
),
viewId,
);
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe(viewId);
const presetProbe = await sceneProbe();
expect(presetProbe.errors).toEqual([]);
expect(presetProbe.camera?.position.every(Number.isFinite)).toBe(true);
expect(presetProbe.camera?.target.every(Number.isFinite)).toBe(true);
expect(presetProbe.camera?.up[1]).toBeGreaterThan(0.99);
}
const occupancy = await page.evaluate(async () => {
const threeUrl =
"/three-dimensional/zjb/v29/vendor/three/three.module.js";
const navigationUrl =
"/three-dimensional/zjb/v29/camera-navigation.mjs?v=31-camera-presets-test";
const THREE = await import(threeUrl);
const { framePose } = await import(navigationUrl);
const aspect = 16 / 9;
const box = new THREE.Box3(
new THREE.Vector3(-330, 0, -205),
new THREE.Vector3(330, 15, 205),
);
const pose = framePose(box, aspect, {
direction: [0, 1, 0],
up: [0, 0, -1],
padding: 1.04,
});
const camera = new THREE.PerspectiveCamera(42, aspect, 0.1, 10_000);
camera.position.fromArray(pose.position);
camera.up.fromArray(pose.up);
camera.lookAt(new THREE.Vector3(...pose.target));
camera.updateProjectionMatrix();
camera.updateMatrixWorld();
const points = [
[-330, 0, -205],
[-330, 0, 205],
[-330, 15, -205],
[-330, 15, 205],
[330, 0, -205],
[330, 0, 205],
[330, 15, -205],
[330, 15, 205],
].map((point) => new THREE.Vector3(...point).project(camera));
return {
x: Math.max(...points.map((point) => Math.abs(point.x))),
y: Math.max(...points.map((point) => Math.abs(point.y))),
};
});
expect(occupancy.x).toBeLessThanOrEqual(1);
expect(occupancy.y).toBeLessThanOrEqual(1);
expect(Math.max(occupancy.x, occupancy.y)).toBeGreaterThan(0.9);
});
+304
View File
@@ -0,0 +1,304 @@
import { expect, test } from "@playwright/test";
const root = "/three-dimensional/zjb/v29/";
const preview = root + "preview.html?host=platform2&v=36-resumable-cache";
const isAsset = (url: string) => /\.glb(?:\?|$)|water-network-v28\.json/.test(url);
test.setTimeout(120_000);
test("HTTP 200 的错误元数据不会进入缓存,服务器恢复后重试成功", async ({ page }) => {
let requests = 0;
await page.route("**/water-network-v28.json*", async (route) => {
requests++;
if (requests === 1) return route.fulfill({ json: { error: "temporary proxy failure" } });
return route.continue();
});
await page.goto(preview);
await expect(page.locator("#boot")).toHaveAttribute("data-state", "error", { timeout: 60_000 });
const cached = await page.evaluate((base) => new Promise((resolve, reject) => {
const request = indexedDB.open("tjwater-scene-assets-v1", 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const database = request.result;
const read = database.transaction("resources").objectStore("resources").get(new URL(base + "water-network-v28.json", location.origin).href);
read.onsuccess = () => { resolve(Boolean(read.result)); database.close(); };
read.onerror = () => { reject(read.error); database.close(); };
};
}), root);
expect(cached).toBe(false);
await page.getByRole("button", { name: "重新载入场景" }).click();
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
expect(requests).toBe(5);
});
test("模型和元数据跨页面复用持久缓存,即使服务器资源不可用也能重开场景", async ({ page, context }) => {
const requests: string[] = [];
let block = false;
// Routing disables HTTP cache, so this checks IndexedDB rather than browser HTTP caching.
await context.route("**/three-dimensional/**", (route) => {
if (isAsset(route.request().url())) {
requests.push(route.request().url());
if (block) return route.abort("failed");
}
return route.continue();
});
const coldStart = Date.now();
await page.goto(preview);
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
const coldMs = Date.now() - coldStart;
expect(new Set(requests).size).toBe(10);
requests.length = 0;
block = true;
await page.close();
const reopened = await context.newPage();
const warmStart = Date.now();
await reopened.goto(preview);
await expect(reopened.locator("#boot")).toBeHidden({ timeout: 60_000 });
expect(await reopened.evaluate(() =>
(window as typeof window & { webQA: { camera: { active: string } } }).webQA.camera.active,
)).toBe("overview");
expect(requests).toEqual([]);
console.log(JSON.stringify({ coldMs, warmMs: Date.now() - warmStart, warmAssetRequests: requests.length }));
});
test("首次载入显示真实进度,失败保留提示,重试可以恢复", async ({ page }) => {
let release: () => void = () => {};
const pending = new Promise<void>((resolve) => { release = resolve; });
const failedModel = "**/models/detail_roof.glb*";
await page.route(failedModel, async (route) => { await pending; await route.abort("failed"); });
await page.goto(preview);
await expect(page.locator("#boot-title")).toHaveText("正在载入场景资源 9 / 10", { timeout: 60_000 });
await expect(page.getByRole("progressbar")).toHaveAttribute("value", "9");
await expect(page.locator("#boot-detail")).toContainText(/已读取 \d+\.\d MB/);
await page.screenshot({ path: "test-results/scene-loading-desktop.png" });
await page.setViewportSize({ width: 320, height: 640 });
await page.screenshot({ path: "test-results/scene-loading-mobile.png" });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
release();
await expect(page.locator("#boot-title")).toHaveText("场景资源加载失败");
await expect(page.locator("#boot-detail")).toContainText("detail_roof.glb");
await expect(page.locator("#boot")).toHaveAttribute("aria-busy", "false");
await expect(page.getByRole("button", { name: "重新载入场景" })).toBeVisible();
await page.screenshot({ path: "test-results/scene-loading-error.png" });
await page.unroute(failedModel);
await page.getByRole("button", { name: "重新载入场景" }).click();
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
});
test("同一资源合并并发请求,摘要改变后重新下载并覆盖旧缓存", async ({ page }) => {
let requests = 0;
await page.route("**/cache-probe.json*", (route) => {
requests++;
return route.fulfill({ json: { revision: new URL(route.request().url()).searchParams.get("v") } });
});
await page.goto(root + "manifest.json");
const result = await page.evaluate(async (base) => {
const moduleUrl = base + "resourceCache.mjs";
const { createSceneResourceCache } = await import(moduleUrl);
const cache = createSceneResourceCache(location.origin + base);
const decode = (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data));
const first = await Promise.all([
cache.load("cache-probe.json", "first", decode),
cache.load("cache-probe.json", "first", decode),
]);
const updated = await cache.load("cache-probe.json", "second", decode);
const freshClient = createSceneResourceCache(location.origin + base);
const cached = await freshClient.load("cache-probe.json", "second", decode);
return { first, updated, cached };
}, root);
expect(requests).toBe(2);
expect(result.first).toEqual([{ revision: "first" }, { revision: "first" }]);
expect(result.updated).toEqual({ revision: "second" });
expect(result.cached).toEqual({ revision: "second" });
});
for (const failure of ["denied", "quota"] as const) {
test(`浏览器缓存${failure === "denied" ? "被禁用" : "空间不足"}时仍可下载并解析资源`, async ({ page }) => {
await page.goto(root + "manifest.json");
await page.route("**/cache-probe.json*", (route) => route.fulfill({ json: { ok: true } }));
const result = await page.evaluate(async ({ base, failure }) => {
if (failure === "denied") {
Object.defineProperty(window, "indexedDB", { get() { throw new Error("denied"); } });
} else {
const transaction = IDBDatabase.prototype.transaction;
IDBDatabase.prototype.transaction = function (...args: Parameters<typeof transaction>) {
if (args[1] === "readwrite") throw new DOMException("Full", "QuotaExceededError");
return transaction.apply(this, args);
};
}
const moduleUrl = base + "resourceCache.mjs";
const { createSceneResourceCache } = await import(moduleUrl);
const cache = createSceneResourceCache(location.origin + base);
return cache.load("cache-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)));
}, { base: root, failure });
expect(result).toEqual({ ok: true });
});
}
test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复", async ({ page }) => {
let requests = 0;
await page.route("**/cache-probe.json*", (route) => {
requests++;
return requests === 1
? route.fulfill({ status: 404, body: "not found" })
: requests === 2
? route.fulfill({ body: "invalid json" })
: route.fulfill({ json: { ok: true } });
});
await page.goto(root + "manifest.json");
const result = await page.evaluate(async (base) => {
const moduleUrl = base + "resourceCache.mjs";
const { createSceneResourceCache } = await import(moduleUrl);
const cache = createSceneResourceCache(location.origin + base);
const decode = (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data));
const errors: string[] = [];
for (let attempt = 0; attempt < 2; attempt++) {
try { await cache.load("cache-probe.json", "first", decode); }
catch (error) { errors.push(String(error)); }
}
return { errors, recovered: await cache.load("cache-probe.json", "first", decode) };
}, root);
expect(requests).toBe(3);
expect(result.errors).toHaveLength(2);
expect(result.errors[0]).toContain("HTTP 404");
expect(result.recovered).toEqual({ ok: true });
});
test("公网整文件中断时分段下载,重试失败分段并缓存完整模型", async ({ page }) => {
const { readFile } = await import("node:fs/promises");
const { createHash } = await import("node:crypto");
const bytes = await readFile("public/three-dimensional/zjb/v29/models/detail_interior.glb");
const ranges: string[] = [];
let interrupted = false;
await page.route("**/models/detail_interior.glb*", async (route) => {
const range = route.request().headers().range;
ranges.push(range ?? "whole-file");
// The public proxy drops whole-file responses, and one partial request drops too.
if (!range) return route.abort("failed");
if (range.startsWith("bytes=1048576-") && !interrupted) {
interrupted = true;
return route.abort("failed");
}
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
const start = Number(match[1]);
const end = Math.min(Number(match[2]), bytes.length - 1);
return route.fulfill({ status: 206, headers: {
"content-type": "model/gltf-binary",
"content-range": `bytes ${start}-${end}/${bytes.length}`,
}, body: bytes.subarray(start, end + 1) });
});
await page.goto(root + "manifest.json");
const result = await page.evaluate(async (base) => {
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
const load = () => createSceneResourceCache(location.origin + base).load("models/detail_interior.glb", "range-recovery");
const data = await load();
const cached = await load();
return { length: data.byteLength, cachedLength: cached.byteLength,
sha256: Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), n => n.toString(16).padStart(2, "0")).join("") };
}, root);
expect(result).toEqual({ length: bytes.length, cachedLength: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") });
expect(ranges).toHaveLength(7);
expect(ranges[0]).toBe("bytes=0-1048575");
expect(ranges[1]).toBe(ranges[2]);
expect(interrupted).toBe(true);
});
for (const fault of ["offset", "total", "truncated", "whole-response"] as const) {
test(`无效分段响应 ${fault} 不会进入缓存,恢复后可重新下载`, async ({ page }) => {
const bytes = Buffer.alloc(1048576 + 16, 7);
let broken = true;
let requests = 0;
await page.route("**/range-probe.bin*", (route) => {
requests++;
const range = route.request().headers().range;
if (!range) return route.abort("failed");
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
const start = Number(match[1]);
const end = Math.min(Number(match[2]), bytes.length - 1);
if (broken && start > 0 && fault === "whole-response") return route.fulfill({ body: bytes });
return route.fulfill({ status: 206, headers: {
"content-range": `bytes ${broken && start > 0 && fault === "offset" ? 0 : start}-${end}/${bytes.length + (broken && start > 0 && fault === "total" ? 1 : 0)}`,
}, body: bytes.subarray(start, end + (broken && start > 0 && fault === "truncated" ? 0 : 1)) });
});
await page.goto(root + "manifest.json");
const failed = await page.evaluate(async (base) => {
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
try { await createSceneResourceCache(location.origin + base).load("range-probe.bin", "first"); return false; }
catch { return true; }
}, root);
expect(failed).toBe(true);
const before = requests;
broken = false;
const length = await page.evaluate(async (base) => {
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
return (await createSceneResourceCache(location.origin + base).load("range-probe.bin", "first")).byteLength;
}, root);
expect(length).toBe(bytes.length);
// The valid first segment survives; only the rejected final segment is fetched again.
expect(requests - before).toBe(1);
});
}
test("下载中断后刷新页面,从已保存的分段继续下载", async ({ page }) => {
const bytes = Buffer.alloc(2 * 1048576 + 16, 9);
let disconnected = true;
const ranges: string[] = [];
await page.route("**/resume-probe.bin*", (route) => {
const range = route.request().headers().range;
ranges.push(range ?? "whole-file");
if (!range) return route.abort("failed");
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
const start = Number(match[1]);
const end = Math.min(Number(match[2]), bytes.length - 1);
if (disconnected && start > 0) return route.abort("failed");
return route.fulfill({ status: 206, headers: { "content-range": `bytes ${start}-${end}/${bytes.length}` }, body: bytes.subarray(start, end + 1) });
});
await page.goto(root + "manifest.json");
const load = (base: string) => page.evaluate(async (base) => {
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
try {
const data = await createSceneResourceCache(location.origin + base).load("resume-probe.bin", "first");
return { length: data.byteLength, failed: false };
} catch { return { failed: true }; }
}, base);
expect(await load(root)).toEqual({ failed: true });
expect(ranges.filter(range => range === "bytes=1048576-2097151")).toHaveLength(3);
disconnected = false;
ranges.length = 0;
await page.reload();
expect(await load(root)).toEqual({ length: bytes.length, failed: false });
expect(ranges).toEqual(["bytes=1048576-2097151", "bytes=2097152-2097167"]);
});
test("临时 HTTP 503 自动重试,成功后才写入缓存", async ({ page }) => {
let requests = 0;
await page.route("**/retry-probe.json*", (route) => {
requests++;
return requests < 3 ? route.fulfill({ status: 503, body: "unavailable" }) : route.fulfill({ json: { ok: true } });
});
await page.goto(root + "manifest.json");
const result = await page.evaluate(async (base) => {
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
const stages: string[] = [];
const cache = createSceneResourceCache(location.origin + base, ({ stage }: { stage: string }) => stages.push(stage));
const data = await cache.load("retry-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)));
return { data, retries: stages.filter(stage => stage === "retry").length };
}, root);
expect(result).toEqual({ data: { ok: true }, retries: 2 });
expect(requests).toBe(3);
});
test("服务器返回空的完整响应时只解析一次,不循环下载", async ({ page }) => {
let requests = 0;
await page.route("**/empty-probe.json*", (route) => { requests++; return route.fulfill({ body: "" }); });
await page.goto(root + "manifest.json");
const failed = await page.evaluate(async (base) => {
const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
try {
await createSceneResourceCache(location.origin + base).load("empty-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)));
return false;
} catch { return true; }
}, root);
expect(failed).toBe(true);
expect(requests).toBe(1);
});
+12
View File
@@ -0,0 +1,12 @@
import { expect, test } from "@playwright/test";
test("未登录访问受保护页面时返回登录重定向", async ({ request }) => {
const sessionResponse = await request.get("/api/auth/session");
expect(await sessionResponse.json()).toEqual({});
const response = await request.get("/audit-logs", { maxRedirects: 0 });
const body = await response.text();
expect(response.status()).toBe(200);
expect(body).toContain("NEXT_REDIRECT;replace;/login;307;");
});
+1
View File
@@ -9,6 +9,7 @@ const createJestConfig = nextJest({
const customJestConfig = { const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom', testEnvironment: 'jest-environment-jsdom',
testPathIgnorePatterns: ['<rootDir>/e2e/'],
moduleNameMapper: { moduleNameMapper: {
'^@pages/(.*)$': '<rootDir>/pages/$1', '^@pages/(.*)$': '<rootDir>/pages/$1',
'^@/(.*)$': '<rootDir>/src/$1', '^@/(.*)$': '<rootDir>/src/$1',
+46
View File
@@ -48,6 +48,7 @@
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.63.0",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
@@ -5710,6 +5711,22 @@
"url": "https://opencollective.com/pkgr" "url": "https://opencollective.com/pkgr"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@popperjs/core": { "node_modules/@popperjs/core": {
"version": "2.11.8", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -19080,6 +19097,35 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pluralize": { "node_modules/pluralize": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
+7
View File
@@ -14,6 +14,12 @@
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage", "test:coverage": "jest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report",
"e2e:install": "playwright install chromium",
"e2e:serve": "node scripts/startE2eServer.mjs",
"api:generate": "openapi-typescript contracts/server-v1.openapi.json -o src/generated/serverApi.ts && openapi-typescript contracts/agent-v1.openapi.json -o src/generated/agentApi.ts", "api:generate": "openapi-typescript contracts/server-v1.openapi.json -o src/generated/serverApi.ts && openapi-typescript contracts/agent-v1.openapi.json -o src/generated/agentApi.ts",
"api:check": "node scripts/check-api-contracts.mjs", "api:check": "node scripts/check-api-contracts.mjs",
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh" "pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
@@ -64,6 +70,7 @@
"sharp": "0.35.3" "sharp": "0.35.3"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.63.0",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
+66
View File
@@ -0,0 +1,66 @@
import { defineConfig, devices } from "@playwright/test";
import {
AUTH_STATE_PATH,
E2E_AUTH_SECRET,
E2E_BASE_URL,
} from "./e2e/support/environment";
const externalServer = Boolean(process.env.E2E_BASE_URL);
const storageState = process.env.E2E_STORAGE_STATE || AUTH_STATE_PATH;
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
expect: { timeout: 30_000 },
reporter: process.env.CI
? [["line"], ["html", { open: "never" }]]
: [["list"], ["html", { open: "never" }]],
outputDir: "test-results",
globalSetup: "./e2e/globalSetup.ts",
use: {
baseURL: E2E_BASE_URL,
locale: "zh-CN",
timezoneId: "Asia/Shanghai",
launchOptions: process.env.E2E_CHROMIUM_PATH
? { executablePath: process.env.E2E_CHROMIUM_PATH }
: undefined,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "chromium-public",
testMatch: /unauthenticated\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "chromium-authenticated",
testIgnore: /unauthenticated\.spec\.ts/,
use: {
...devices["Desktop Chrome"],
storageState,
},
},
],
webServer: externalServer
? undefined
: {
command:
"npm run runtime:config && npm run build && npm run e2e:serve",
url: E2E_BASE_URL,
reuseExistingServer: false,
timeout: 120_000,
env: {
NEXTAUTH_URL: E2E_BASE_URL,
NEXTAUTH_SECRET: E2E_AUTH_SECRET,
KEYCLOAK_CLIENT_ID: "tjwater-e2e",
KEYCLOAK_CLIENT_SECRET: "tjwater-e2e",
KEYCLOAK_ISSUER: "http://127.0.0.1:8180/realms/tjwater-e2e",
},
},
});
@@ -85,14 +85,14 @@ export function enhanceMaterials(object){
} }
// Restore the authored appearance on every mode change. In network overview, // Restore the authored appearance on every mode change. In network overview,
// opaque buildings fade more than glass so the curtain wall remains legible. // all architectural context follows the same user-controlled opacity ceiling.
export function applyBuildingContext(material,fade){ export function applyBuildingContext(material,fade,contextOpacity=.4){
const original=material.userData.original??{opacity:material.opacity,transparent:material.transparent,depthWrite:material.depthWrite}; const original=material.userData.original??{opacity:material.opacity,transparent:material.transparent,depthWrite:material.depthWrite};
material.userData.original=original; material.userData.original=original;
const facade=/v3_glass|coatedcurtainglass/i.test(material.name); const opacity=THREE.MathUtils.clamp(contextOpacity,0,1),contextualOpacity=Math.min(original.opacity,opacity);
material.opacity=fade?(facade?Math.min(original.opacity,.38):.12):original.opacity; material.opacity=fade?contextualOpacity:original.opacity;
material.transparent=fade||original.transparent; material.transparent=fade?(contextualOpacity<1||original.transparent):original.transparent;
material.depthWrite=fade?false:original.depthWrite;material.needsUpdate=true; material.depthWrite=fade&&contextualOpacity<1?false:original.depthWrite;material.needsUpdate=true;
} }
const presets={ const presets={
@@ -102,10 +102,10 @@ export function createAssetInspector({model,networkRoot,scene,style,onLocate,onS
if(item.cad)pressureBasis='接入节点压力,非立管上端压力'; if(item.cad)pressureBasis='接入节点压力,非立管上端压力';
const pressureSource=result?.source==='scada'?'SCADA '+(result.deviceId??'监测'):pressure!=null?'在线模拟':'暂无数据'; const pressureSource=result?.source==='scada'?'SCADA '+(result.deviceId??'监测'):pressure!=null?'在线模拟':'暂无数据';
sections.push({title:'运行结果',fields:fields([ sections.push({title:'运行结果',fields:fields([
['运行状态',status(result?.status)],['压力',value(pressure,'mH₂O')], ['运行状态',status(result?.status)],['压力',value(pressure,'m')],
...(pressure!=null?[['压力依据',pressureBasis],['压力来源',pressureSource]]:[]), ...(pressure!=null?[['压力依据',pressureBasis],['压力来源',pressureSource]]:[]),
...(result?.source==='scada'&&Number.isFinite(result.simulationPressure)?[['同期模拟压力',value(result.simulationPressure,'mH₂O')]]:[]), ...(result?.source==='scada'&&Number.isFinite(result.simulationPressure)?[['同期模拟压力',value(result.simulationPressure,'m')]]:[]),
...(link?[['流速',value(result?.velocity==null?null:Math.abs(result.velocity),'m/s')],['流量',value(result?.flow,'L/s')],['流向',result?.status?.toLowerCase()==='closed'?'停流':result?.direction!=null||result?.flow!=null?((result.direction??Math.sign(result.flow))>0?'起点 → 终点':(result.direction??Math.sign(result.flow))<0?'终点 → 起点':'停流'):'暂无数据']]:[]), ...(link?[['流速',value(result?.velocity==null?null:Math.abs(result.velocity),'m/s')],['流量',value(result?.flow,'m³/h')],['流向',result?.status?.toLowerCase()==='closed'?'停流':result?.direction!=null||result?.flow!=null?((result.direction??Math.sign(result.flow))>0?'起点 → 终点':(result.direction??Math.sign(result.flow))<0?'终点 → 起点':'停流'):'暂无数据']]:[]),
])}); ])});
sections.push({title:'来源说明',fields:fields([ sections.push({title:'来源说明',fields:fields([
['拓扑来源','已核对 INP;本页不求解水力'],['位置与高度','展示调整,不代表实测埋深'], ['拓扑来源','已核对 INP;本页不求解水力'],['位置与高度','展示调整,不代表实测埋深'],
@@ -1,15 +1,56 @@
import * as THREE from 'three'; import * as THREE from 'three';
export function framePose(box,aspect,{direction=[.6,.8,1],padding=1.2,fov=42}={}){ const WORLD_UP=new THREE.Vector3(0,1,0);
if(box.isEmpty())throw Error('视角目标没有可见模型。'); function boxCorners(box){const {min,max}=box;return [
const sphere=box.getBoundingSphere(new THREE.Sphere()),angle=Math.min(fov*Math.PI/360,Math.atan(Math.tan(fov*Math.PI/360)*aspect)); new THREE.Vector3(min.x,min.y,min.z),new THREE.Vector3(min.x,min.y,max.z),new THREE.Vector3(min.x,max.y,min.z),new THREE.Vector3(min.x,max.y,max.z),
const distance=Math.max(2,sphere.radius)*padding/Math.sin(angle);return {target:sphere.center.toArray(),position:sphere.center.clone().add(new THREE.Vector3(...direction).normalize().multiplyScalar(distance)).toArray()}; new THREE.Vector3(max.x,min.y,min.z),new THREE.Vector3(max.x,min.y,max.z),new THREE.Vector3(max.x,max.y,min.z),new THREE.Vector3(max.x,max.y,max.z),
} ];}
export function createCameraNavigation({camera,controls,model,networkRoot,root,show,getMode,appearance,onChange,getDisplayMode=()=> 'global',setDisplayMode=()=>{}}){ export function networkPrimaryAxis(model){
const points=(model.nodes??[]).map(node=>node.coordinate).filter(point=>Array.isArray(point)&&point.length>=2&&point.slice(0,2).every(Number.isFinite));
if(points.length<2)return new THREE.Vector3(1,0,0);
const center=points.reduce((sum,point)=>[sum[0]+point[0]/points.length,sum[1]-point[1]/points.length],[0,0]);let xx=0,xz=0,zz=0;
for(const point of points){const x=point[0]-center[0],z=-point[1]-center[1];xx+=x*x;xz+=x*z;zz+=z*z;}
const angle=.5*Math.atan2(2*xz,xx-zz),axis=new THREE.Vector3(Math.cos(angle),0,Math.sin(angle));if(axis.x<0)axis.negate();return axis.normalize();
}
function linkAxis(link,fallback){
const points=link?.coordinates;if(!Array.isArray(points)||points.length<2)return fallback.clone();
const first=points[0],last=points.at(-1),axis=new THREE.Vector3(last[0]-first[0],0,-(last[1]-first[1]));
if(axis.lengthSq()<1e-8)return fallback.clone();if(axis.x<0)axis.negate();return axis.normalize();
}
function viewingDirection(axis,reference,elevation){
const horizontal=axis.clone().cross(WORLD_UP).normalize(),referenceHorizontal=reference.clone().cross(WORLD_UP).normalize();
if(horizontal.dot(referenceHorizontal)<0)horizontal.negate();return horizontal.addScaledVector(WORLD_UP,elevation).normalize();
}
export function framePose(box,aspect,{direction=[.6,.8,1],up=[0,1,0],padding=1.12,fov=42}={}){
if(box.isEmpty())throw Error('视角目标没有可见模型。');
const target=box.getCenter(new THREE.Vector3()),viewDirection=new THREE.Vector3(...direction).normalize();let requestedUp=new THREE.Vector3(...up).normalize();
if(Math.abs(requestedUp.dot(viewDirection))>.98)requestedUp=Math.abs(viewDirection.y)<.98?WORLD_UP.clone():new THREE.Vector3(0,0,-1);
const right=requestedUp.clone().cross(viewDirection).normalize(),projectedUp=viewDirection.clone().cross(right).normalize();
const forward=viewDirection.clone().negate(),verticalTan=Math.tan(fov*Math.PI/360),horizontalTan=verticalTan*Math.max(.1,aspect);let distance=2;
for(const corner of boxCorners(box)){const offset=corner.sub(target),depthOffset=offset.dot(forward);distance=Math.max(distance,Math.abs(offset.dot(right))/horizontalTan-depthOffset,Math.abs(offset.dot(projectedUp))/verticalTan-depthOffset);}
distance*=padding;return {target:target.toArray(),position:target.clone().add(viewDirection.multiplyScalar(distance)).toArray(),up:requestedUp.toArray()};
}
export function createCameraNavigation({camera,controls,model,networkRoot,root,show,getMode,appearance,onChange,getDisplayMode=()=> 'global',setDisplayMode=()=>{}}){
const presets=[{id:'overview',label:'供水总览',mode:'network',note:'完整管网与站区背景'},{id:'plan',label:'管网俯视',mode:'network',note:'查看管网平面关系'},{id:'pump',label:'CAD 泵房参考',mode:'pump',note:'六泵与集管剖开检查 · 隐藏外墙和楼梯'},{id:'hydraulic',label:'泵组与管网',mode:'hydraulic',note:'六泵与下沉接入 · 竖向按 CAD,平面配准待核'},{id:'main',label:'主干管段',mode:'network',note:'定位模型中最长的供水管段'},{id:'meter',label:'BJ9 水表',mode:'network',note:'定位 CAD 水表及宿主管段'},{id:'crossing',label:'交叉下穿',mode:'network',note:'查看非连通交叉的示意下穿'},{id:'station',label:'站房外观',mode:'detail',note:'站房精细模型与周边关系'}]; const presets=[{id:'overview',label:'供水总览',mode:'network',note:'完整管网与站区背景'},{id:'plan',label:'管网俯视',mode:'network',note:'查看管网平面关系'},{id:'pump',label:'CAD 泵房参考',mode:'pump',note:'六泵与集管剖开检查 · 隐藏外墙和楼梯'},{id:'hydraulic',label:'泵组与管网',mode:'hydraulic',note:'六泵与下沉接入 · 竖向按 CAD,平面配准待核'},{id:'main',label:'主干管段',mode:'network',note:'定位模型中最长的供水管段'},{id:'meter',label:'BJ9 水表',mode:'network',note:'定位 CAD 水表及宿主管段'},{id:'crossing',label:'交叉下穿',mode:'network',note:'查看非连通交叉的示意下穿'},{id:'station',label:'站房外观',mode:'detail',note:'站房精细模型与周边关系'}];
presets.splice(4,0,{id:'singlePump',label:'主泵近景',mode:'hydraulic',note:'PU_VFD_1 · 设备法兰与进出水连接'}); presets.splice(4,0,{id:'singlePump',label:'主泵近景',mode:'hydraulic',note:'PU_VFD_1 · 设备法兰与进出水连接'});
if(model.cadAttachments?.length)presets.push({id:'riser',label:'站台上层立管',mode:'network',context:false,note:'CAD DN150 · 高差 10.85 m · 上层配水未推断'}); if(model.cadAttachments?.length)presets.push({id:'riser',label:'站台上层立管',mode:'network',context:false,note:'CAD DN150 · 高差 10.85 m · 上层配水未推断'});
let request=0,flight=null,active=null;const key='zjb-camera-bookmarks-v24';let saved=[]; let request=0,flight=null,active=null;const key='zjb-camera-bookmarks-v24';let saved=[];
try{const v=JSON.parse(localStorage.getItem(key)||'[]');if(Array.isArray(v))saved=v.filter(p=>p&&typeof p.id==='string'&&typeof p.label==='string'&&p.label.length<=24&&['network','hydraulic','pump','detail','map','meters'].includes(p.mode)&&[p.position,p.target].every(a=>Array.isArray(a)&&a.length===3&&a.every(Number.isFinite))).slice(0,8);}catch{} try{const v=JSON.parse(localStorage.getItem(key)||'[]');if(Array.isArray(v))saved=v.filter(p=>p&&typeof p.id==='string'&&typeof p.label==='string'&&p.label.length<=24&&['network','hydraulic','pump','detail','map','meters'].includes(p.mode)&&[p.position,p.target].every(a=>Array.isArray(a)&&a.length===3&&a.every(Number.isFinite))&&(!p.up||(Array.isArray(p.up)&&p.up.length===3&&p.up.every(Number.isFinite)))).slice(0,8);}catch{}
const primaryAxis=networkPrimaryAxis(model),planDirection=WORLD_UP.clone(),planUp=new THREE.Vector3(0,0,-1),overviewDirection=viewingDirection(primaryAxis,primaryAxis,.92);
const links=new Map((model.links??[]).map(link=>[link.id,link]));
const linkLength=link=>(link.coordinates??[]).slice(1).reduce((sum,point,index)=>sum+Math.hypot(point[0]-link.coordinates[index][0],point[1]-link.coordinates[index][1]),0);
const mainLink=(model.links??[]).filter(link=>link.kind==='PIPES').sort((a,b)=>linkLength(b)-linkLength(a))[0];
const mainAxis=linkAxis(mainLink,primaryAxis);
const meterBinding=(model.cadEquipmentBindings??[]).find(binding=>binding.assetId==='zjb:meter:BJ9'),meterAxis=linkAxis(links.get(meterBinding?.inpLinkId),primaryAxis);
const poseOptions={
pump:{direction:viewingDirection(primaryAxis,primaryAxis,.72).toArray(),padding:1.06},
hydraulic:{direction:viewingDirection(primaryAxis,primaryAxis,.58).toArray(),padding:1.08},
singlePump:{direction:viewingDirection(primaryAxis,primaryAxis,.36).toArray(),padding:1.28},
main:{direction:viewingDirection(mainAxis,primaryAxis,.72).toArray(),padding:1.1},
meter:{direction:viewingDirection(meterAxis,primaryAxis,1.6).toArray(),padding:1.16},
crossing:{direction:viewingDirection(primaryAxis,primaryAxis,2.3).toArray(),padding:1.16},
station:{direction:viewingDirection(primaryAxis,primaryAxis,.58).toArray(),padding:1.06},
riser:{direction:viewingDirection(primaryAxis,primaryAxis,.2).toArray(),padding:1.24},
};
function emit(note){onChange({active,views:[...presets,...saved],note});} function emit(note){onChange({active,views:[...presets,...saved],note});}
function stop(){request++;flight=null;active=null;emit('自由浏览 · 可保存当前视角');} function stop(){request++;flight=null;active=null;emit('自由浏览 · 可保存当前视角');}
controls.addEventListener('start',stop); controls.addEventListener('start',stop);
@@ -17,11 +58,11 @@ export function createCameraNavigation({camera,controls,model,networkRoot,root,s
if(p.id==='overview'||p.id==='plan')box.setFromObject(networkRoot); if(p.id==='overview'||p.id==='plan')box.setFromObject(networkRoot);
else if(p.id==='pump'||p.id==='station'){for(const o of root.children)if(o.visible&&(p.id!=='pump'||/physical_|piping_reference/.test(o.userData.resourceId)))box.union(new THREE.Box3().setFromObject(o));if(p.id==='station'){const facade=root.children.find(o=>o.userData.resourceId==='detail_facade');if(facade)box.setFromObject(facade);}} else if(p.id==='pump'||p.id==='station'){for(const o of root.children)if(o.visible&&(p.id!=='pump'||/physical_|piping_reference/.test(o.userData.resourceId)))box.union(new THREE.Box3().setFromObject(o));if(p.id==='station'){const facade=root.children.find(o=>o.userData.resourceId==='detail_facade');if(facade)box.setFromObject(facade);}}
else if(p.id==='hydraulic'){for(const l of model.links.filter(l=>l.kind==='PUMPS'))for(const a of l.coordinates)box.expandByPoint(new THREE.Vector3(a[0]-513800,model.verticalCoordination?.pump.pipeCenterY??.35,-(a[1]-2344450)));networkRoot.traverse(o=>{if(o.userData.kind==='PUMPS'&&!o.isMesh)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(2);} else if(p.id==='hydraulic'){for(const l of model.links.filter(l=>l.kind==='PUMPS'))for(const a of l.coordinates)box.expandByPoint(new THREE.Vector3(a[0]-513800,model.verticalCoordination?.pump.pipeCenterY??.35,-(a[1]-2344450)));networkRoot.traverse(o=>{if(o.userData.kind==='PUMPS'&&!o.isMesh)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(2);}
else if(p.id==='riser'){networkRoot.traverse(o=>{if(o.userData.assetId===model.cadAttachments[0].assetId)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(2);} else if(p.id==='riser'){networkRoot.traverse(o=>{if(o.userData.assetId===model.cadAttachments[0].assetId)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(4);}
else if(p.id==='singlePump'){networkRoot.traverse(o=>{if(o.userData.assetId==='inp:link:PU_VFD_1'&&!o.isMesh)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(.75);} else if(p.id==='singlePump'){networkRoot.traverse(o=>{if(o.userData.assetId==='inp:link:PU_VFD_1'&&!o.isMesh)box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(1.25);}
else if(p.id==='meter'){networkRoot.traverse(o=>{if(o.userData.assetId==='zjb:meter:BJ9')box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(3);} else if(p.id==='meter'){networkRoot.traverse(o=>{if(o.userData.assetId==='zjb:meter:BJ9')box.union(new THREE.Box3().setFromObject(o));});box.expandByScalar(3.2);}
else if(p.id==='main'){const distance=l=>l.coordinates.slice(1).reduce((s,p,i)=>s+Math.hypot(p[0]-l.coordinates[i][0],p[1]-l.coordinates[i][1]),0),l=model.links.filter(l=>l.kind==='PIPES').sort((a,b)=>distance(b)-distance(a))[0];for(const p of l.coordinates)box.expandByPoint(new THREE.Vector3(p[0]-513800,.35,-(p[1]-2344450)));box.expandByScalar(5);} else if(p.id==='main'){for(const point of mainLink.coordinates)box.expandByPoint(new THREE.Vector3(point[0]-513800,.35,-(point[1]-2344450)));box.expandByScalar(6);}
else if(p.id==='crossing'){const a=model.layouts[8].pipes;let i=0;for(let j=0;j<a.length;j+=16)if(a[j+13]<a[i+13])i=j;const point=new THREE.Vector3(a[i+12],a[i+13],a[i+14]);box.setFromCenterAndSize(point,new THREE.Vector3(22,10,22));} else if(p.id==='crossing'){const a=model.layouts[8].pipes;let i=0;for(let j=0;j<a.length;j+=16)if(a[j+13]<a[i+13])i=j;const point=new THREE.Vector3(a[i+12],a[i+13],a[i+14]);box.setFromCenterAndSize(point,new THREE.Vector3(30,14,30));}
return box; return box;
} }
async function visit(id){const p=[...presets,...saved].find(p=>p.id===id);if(!p)return;const token=++request;flight=null;emit('正在准备 '+p.label+'…'); async function visit(id){const p=[...presets,...saved].find(p=>p.id===id);if(!p)return;const token=++request;flight=null;emit('正在准备 '+p.label+'…');
@@ -29,15 +70,16 @@ export function createCameraNavigation({camera,controls,model,networkRoot,root,s
for(const o of root.children)if(/roof|ceiling/.test(o.userData.resourceId))o.visible=p.roof??true; for(const o of root.children)if(/roof|ceiling/.test(o.userData.resourceId))o.visible=p.roof??true;
if(p.id==='pump')for(const o of root.children)if(['detail_pump_walls','detail_pump_access'].includes(o.userData.resourceId))o.visible=false; if(p.id==='pump')for(const o of root.children)if(['detail_pump_walls','detail_pump_access'].includes(o.userData.resourceId))o.visible=false;
if(p.layers)for(const o of root.children)if(typeof p.layers[o.userData.resourceId]==='boolean')o.visible=p.layers[o.userData.resourceId]; if(p.layers)for(const o of root.children)if(typeof p.layers[o.userData.resourceId]==='boolean')o.visible=p.layers[o.userData.resourceId];
let pose,box;if(p.position){pose=p;box=new THREE.Box3().setFromCenterAndSize(new THREE.Vector3(...p.target),new THREE.Vector3(20,20,20));}else{box=bounds(p);pose=framePose(box,camera.aspect,{direction:p.id==='plan'?[0,1,.0001]:p.id==='crossing'?[1,.8,1]:p.id==='pump'?[.5,.9,1]:[.6,.8,1]});} let pose,box;if(p.position){pose=p;box=new THREE.Box3().setFromCenterAndSize(new THREE.Vector3(...p.target),new THREE.Vector3(20,20,20));}else{box=bounds(p);pose=framePose(box,camera.aspect,p.id==='plan'?{direction:planDirection.toArray(),up:planUp.toArray(),padding:1.04}:p.id==='overview'?{direction:overviewDirection.toArray(),padding:1.08}:poseOptions[p.id]);}
camera.up.fromArray(pose.up??[0,1,0]).normalize();
appearance.frame(box,p.mode);camera.near=Math.max(.05,new THREE.Vector3(...pose.position).distanceTo(new THREE.Vector3(...pose.target))/2000);camera.far=20000;camera.updateProjectionMatrix();active=id; appearance.frame(box,p.mode);camera.near=Math.max(.05,new THREE.Vector3(...pose.position).distanceTo(new THREE.Vector3(...pose.target))/2000);camera.far=20000;camera.updateProjectionMatrix();active=id;
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(),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('视角已保存在本机浏览器');}
function remove(id){saved=saved.filter(p=>p.id!==id);localStorage.setItem(key,JSON.stringify(saved));if(active===id)active=null;emit('已移除保存的视角');} function remove(id){saved=saved.filter(p=>p.id!==id);localStorage.setItem(key,JSON.stringify(saved));if(active===id)active=null;emit('已移除保存的视角');}
emit('选择一个观察位置');return {visit,tick,save,remove,stop,presets}; emit('选择一个观察位置');return {visit,tick,save,remove,stop,presets};
} }
@@ -1,5 +1,14 @@
// Framework-independent data adapter. Does not parse or modify the project's INP. // Framework-independent data adapter. Does not parse or modify the project's INP.
export const CAD_ORIGIN=Object.freeze([513800,2344450]); export const CAD_ORIGIN=Object.freeze([513800,2344450]);
// This decoder runs before IDB writes, including when checking an existing entry.
// A parseable HTTP 200 error response must not become a permanent model cache.
export function parseNetworkMetadata(data,modelId){
const model=JSON.parse(new TextDecoder().decode(data));
const arrays=['nodes','links','pipeInstances','nodeInstances','bendInstances','cadEquipmentBindings','pumpEntityBindings','cadAttachments'];
const objects=['layouts','connectivity','coordination','coordinatedLayouts','verticalCoordination'];
if(!model||model.modelId!==modelId||!arrays.every(key=>Array.isArray(model[key]))||!objects.every(key=>model[key]&&typeof model[key]==='object'&&!Array.isArray(model[key]))||!model.nodes.length||!model.links.length)throw Error('管网元数据格式无效,请重新载入场景。');
return model;
}
export function cadToGltf(point,displayZ=0){ export function cadToGltf(point,displayZ=0){
if(!Array.isArray(point)||point.length<2||!point.slice(0,2).every(Number.isFinite)||!Number.isFinite(displayZ))throw Error('Expected finite CAD XY and an explicit display height.'); if(!Array.isArray(point)||point.length<2||!point.slice(0,2).every(Number.isFinite)||!Number.isFinite(displayZ))throw Error('Expected finite CAD XY and an explicit display height.');
return [point[0]-CAD_ORIGIN[0],displayZ,-(point[1]-CAD_ORIGIN[1])]; return [point[0]-CAD_ORIGIN[0],displayZ,-(point[1]-CAD_ORIGIN[1])];
@@ -0,0 +1,61 @@
const boot = document.getElementById('boot');
const title = document.getElementById('boot-title');
const detail = document.getElementById('boot-detail');
const progress = document.getElementById('boot-progress');
const retry = document.getElementById('boot-retry');
const formatBytes = bytes => (bytes / 1024 / 1024).toFixed(1) + ' MB';
let entries = new Map();
let failed = false;
let lastPaint = 0;
export function beginLoading(files, message = '正在载入三维场景') {
entries = new Map(files.map(file => [file, {stage:'waiting', loaded:0, total:0}]));
failed = false;
boot.hidden = false;
boot.dataset.state = 'loading';
boot.setAttribute('aria-busy', 'true');
title.textContent = message;
detail.textContent = '正在检查本地资源缓存';
progress.hidden = false;
progress.removeAttribute('value');
retry.hidden = true;
lastPaint = 0;
}
export function reportProgress(update) {
if (failed || !entries.has(update.file)) return;
entries.set(update.file, update);
// Large streamed files can emit hundreds of chunks in a frame.
if (update.stage === 'download' && performance.now() - lastPaint < 100) return;
lastPaint = performance.now();
const values = [...entries.values()];
const done = values.filter(item => item.stage === 'ready').length;
const loaded = values.reduce((sum, item) => sum + item.loaded, 0);
const cached = values.filter(item => item.stage === 'cache').length;
const retrying = values.some(item => item.stage === 'retry');
const parsing = values.some(item => item.stage === 'parse');
const downloading = values.some(item => item.stage === 'download');
progress.max = Math.max(1, values.length);
progress.value = done;
title.textContent = `正在载入场景资源 ${done} / ${values.length}`;
detail.textContent = `${retrying ? '连接中断,正在从断点重试' : parsing ? '正在解析模型' : downloading ? '正在下载资源' : cached ? '正在读取本地缓存' : '正在准备场景'} · 已读取 ${formatBytes(loaded)}`;
}
export function finishLoading() {
if (failed) return;
boot.setAttribute('aria-busy', 'false');
boot.hidden = true;
}
export function failLoading(error) {
failed = true;
boot.hidden = false;
boot.dataset.state = 'error';
boot.setAttribute('aria-busy', 'false');
title.textContent = '场景资源加载失败';
detail.textContent = '部分资源未能载入,请检查网络后重试。' + (error?.message ? '(' + error.message + ')' : '');
progress.hidden = true;
retry.hidden = false;
}
retry.addEventListener('click', () => window.location.reload());
@@ -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
@@ -1,10 +1,10 @@
import * as THREE from 'three'; import * as THREE from 'three';
export const DEFAULT_STYLE=Object.freeze({scale:8,mode:'uniform',color:'#098ed0',missingColor:'#89949d',lowColor:'#2b83ba',highColor:'#e66c37',opacity:1,roughness:.3,metalness:.22,nodes:true,direction:'none',arrowColor:'#f2b447',autoRange:true,min:0,max:3}); export const DEFAULT_STYLE=Object.freeze({scale:6,mode:'uniform',color:'#098ed0',missingColor:'#89949d',lowColor:'#2b83ba',highColor:'#e66c37',opacity:1,roughness:.3,metalness:.22,nodes:true,direction:'none',arrowColor:'#f2b447',autoRange:true,min:0,max:3});
const finite=v=>typeof v==='number'&&Number.isFinite(v); const finite=v=>typeof v==='number'&&Number.isFinite(v);
export function validateResults(input,model){ export function validateResults(input,model){
if(input?.modelId!==model.modelId)throw Error('结果文件与当前管网模型编号不一致。'); if(input?.modelId!==model.modelId)throw Error('结果文件与当前管网模型编号不一致。');
if(input.units?.velocity!=='m/s'||input.units?.pressure!=='mH2O'||input.units?.flow!=='L/s')throw Error('结果单位须明确为 m/s、mH2O、L/s。'); if(input.units?.velocity!=='m/s'||input.units?.pressure!=='m'||input.units?.flow!=='m³/h')throw Error('结果单位须明确为 m/s、m、m³/h。');
const linkIds=new Set(model.links.map(l=>l.id)),nodeIds=new Set(model.nodes.map(n=>n.id)); const linkIds=new Set(model.links.map(l=>l.id)),nodeIds=new Set(model.nodes.map(n=>n.id));
for(const [table,ids,fields] of [[input.links??{},linkIds,['velocity','flow','pressure','direction']],[input.nodes??{},nodeIds,['pressure']]]){ for(const [table,ids,fields] of [[input.links??{},linkIds,['velocity','flow','pressure','direction']],[input.nodes??{},nodeIds,['pressure']]]){
if(typeof table!=='object'||table===null||Array.isArray(table))throw Error('结果必须按编号提供对象。'); if(typeof table!=='object'||table===null||Array.isArray(table))throw Error('结果必须按编号提供对象。');
@@ -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=29-platform2"></script> <script type="module">
import {failLoading} from './loadingProgress.mjs?v=36-resumable-cache';
import('./preview.mjs?v=36-resumable-cache').catch(failLoading);
</script>
</body> </body>
</html> </html>
+43 -21
View File
@@ -1,20 +1,23 @@
import {createRenderEffects} from './render-effects.mjs'; import {createRenderEffects} from './render-effects.mjs';
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=28'; import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=36-resumable-cache';
import {createSceneResourceCache} from './resourceCache.mjs?v=36-resumable-cache';
import {beginLoading,reportProgress,finishLoading,failLoading} from './loadingProgress.mjs?v=36-resumable-cache';
import {createAssetInspector} from './asset-inspector.mjs?v=29-platform2'; import {createAssetInspector} from './asset-inspector.mjs?v=29-platform2';
import * as THREE from 'three'; import * as THREE from 'three';
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js'; import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js'; import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js'; import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js';
import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=28b'; 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=29'; 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,shadows:true,effects:true,quality:'standard'}; let appearanceState={preset:'day',exposure:.95,contextOpacity:.4,shadows:true,effects:true,quality:'standard'};
function postHost(type,detail={}){ function postHost(type,detail={}){
if(window.parent===window)return; if(window.parent===window)return;
@@ -37,11 +40,16 @@ function runtimeState(){
function postState(){if(net&&networkStyle)postHost('scene-state',{state:runtimeState()});} function postState(){if(net&&networkStyle)postHost('scene-state',{state:runtimeState()});}
function setStatus(value){sceneStatus=value;postState();} function setStatus(value){sceneStatus=value;postState();}
window.webQA={errors:[],loaded:[],mode:'network'}; 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=''){
@@ -78,6 +87,7 @@ function fit(){
if(box.isEmpty())return; if(box.isEmpty())return;
appearance.frame(box,mode); appearance.frame(box,mode);
const sphere=box.getBoundingSphere(new THREE.Sphere());controls.target.copy(sphere.center); const sphere=box.getBoundingSphere(new THREE.Sphere());controls.target.copy(sphere.center);
camera.up.copy(new THREE.Vector3(0,1,0));
camera.position.copy(sphere.center).add(new THREE.Vector3(.65,.72,1).normalize().multiplyScalar(sphere.radius*3.1/Math.min(1,camera.aspect))); camera.position.copy(sphere.center).add(new THREE.Vector3(.65,.72,1).normalize().multiplyScalar(sphere.radius*3.1/Math.min(1,camera.aspect)));
camera.near=Math.max(.005,sphere.radius/5000);camera.far=sphere.radius*30+100;camera.updateProjectionMatrix();controls.update(); camera.near=Math.max(.005,sphere.radius/5000);camera.far=sphere.radius*30+100;camera.updateProjectionMatrix();controls.update();
} }
@@ -85,42 +95,51 @@ 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));}); 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);});
}); });
if(networkRoot)networkRoot.visible=['network','hydraulic'].includes(next); if(networkRoot)networkRoot.visible=['network','hydraulic'].includes(next);
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();}
function applyAppearance(patch){ function applyAppearance(patch){
if(patch.preset!==undefined){if(!['day','studio','evening'].includes(patch.preset))throw Error('不支持的光照场景');appearance.preset(patch.preset);appearanceState.preset=patch.preset;appearanceState.exposure=appearance.state.exposure;} if(patch.preset!==undefined){if(!['day','studio','evening'].includes(patch.preset))throw Error('不支持的光照场景');appearance.preset(patch.preset);appearanceState.preset=patch.preset;appearanceState.exposure=appearance.state.exposure;}
if(patch.exposure!==undefined){if(!Number.isFinite(patch.exposure)||patch.exposure<.55||patch.exposure>1.6)throw Error('亮度超出有效范围');appearance.exposure(patch.exposure);appearanceState.exposure=patch.exposure;} if(patch.exposure!==undefined){if(!Number.isFinite(patch.exposure)||patch.exposure<.55||patch.exposure>1.6)throw Error('亮度超出有效范围');appearance.exposure(patch.exposure);appearanceState.exposure=patch.exposure;}
if(patch.contextOpacity!==undefined){if(!Number.isFinite(patch.contextOpacity)||patch.contextOpacity<0||patch.contextOpacity>1)throw Error('建筑背景透明度超出有效范围');appearanceState.contextOpacity=patch.contextOpacity;for(const object of root.children)object.traverse(mesh=>{if(mesh.isMesh)for(const material of Array.isArray(mesh.material)?mesh.material:[mesh.material])applyBuildingContext(material,['network','hydraulic'].includes(mode),appearanceState.contextOpacity);});appearance.refresh();}
if(patch.shadows!==undefined){appearance.shadows(Boolean(patch.shadows));appearanceState.shadows=Boolean(patch.shadows);} if(patch.shadows!==undefined){appearance.shadows(Boolean(patch.shadows));appearanceState.shadows=Boolean(patch.shadows);}
if(patch.effects!==undefined){effects.setEnabled(Boolean(patch.effects));appearanceState.effects=Boolean(patch.effects);} if(patch.effects!==undefined){effects.setEnabled(Boolean(patch.effects));appearanceState.effects=Boolean(patch.effects);}
if(patch.quality!==undefined){if(!['standard','high'].includes(patch.quality))throw Error('不支持的画质');effects.setQuality(patch.quality);appearanceState.quality=patch.quality;localStorage.setItem('zjb-render-quality-v27',patch.quality);} if(patch.quality!==undefined){if(!['standard','high'].includes(patch.quality))throw Error('不支持的画质');effects.setQuality(patch.quality);appearanceState.quality=patch.quality;localStorage.setItem('zjb-render-quality-v27',patch.quality);}
window.webQA.appearance={...appearanceState};
postState(); postState();
} }
function toggleContext(){contextVisible=!contextVisible;applyVisibility();appearance.refresh();fit();postState();} function toggleContext(){contextVisible=!contextVisible;window.webQA.contextVisible=contextVisible;applyVisibility();appearance.refresh();postState();}
function toggleRoof(){roofVisible=!roofVisible;applyVisibility();appearance.refresh();postState();} function toggleRoof(){roofVisible=!roofVisible;applyVisibility();appearance.refresh();postState();}
function resizeView(){const current=viewport();camera.aspect=current.width/current.height;camera.updateProjectionMatrix();renderer.setSize(current.width,current.height);effects.resize(current.width,current.height);} function resizeView(){const current=viewport();camera.aspect=current.width/current.height;camera.updateProjectionMatrix();renderer.setSize(current.width,current.height);effects.resize(current.width,current.height);}
addEventListener('resize',resizeView); addEventListener('resize',resizeView);
@@ -146,7 +165,7 @@ async function locateAsset(item){
if(mode!==targetMode)await show(targetMode,{frame:false});if(request!==locateRequest||generation!==expected)return; if(mode!==targetMode)await show(targetMode,{frame:false});if(request!==locateRequest||generation!==expected)return;
setDisplayMode(targetMode==='hydraulic'?'coordinated':'global');contextVisible=targetMode!=='hydraulic';applyVisibility(); setDisplayMode(targetMode==='hydraulic'?'coordinated':'global');contextVisible=targetMode!=='hydraulic';applyVisibility();
const box=inspector.bounds(item);if(box.isEmpty())return;box.expandByScalar(item.link?.kind==='PUMPS'?.6:1); const box=inspector.bounds(item);if(box.isEmpty())return;box.expandByScalar(item.link?.kind==='PUMPS'?.6:1);
const pose=framePose(box,camera.aspect,{padding:1.3});controls.target.fromArray(pose.target);camera.position.fromArray(pose.position);camera.far=20000;controls.update();appearance.frame(box,targetMode);inspector.highlight();postState(); const pose=framePose(box,camera.aspect,{padding:1.3});camera.up.fromArray(pose.up??[0,1,0]).normalize();controls.target.fromArray(pose.target);camera.position.fromArray(pose.position);camera.far=20000;controls.update();appearance.frame(box,targetMode);inspector.highlight();postState();
} }
let lastFrame=performance.now(),frameTimes=[]; let lastFrame=performance.now(),frameTimes=[];
@@ -155,15 +174,17 @@ renderer.setAnimationLoop(()=>{
if(frameTimes.length>=60)window.webQA.performance={samples:frameTimes.length,meanFps:1000/(frameTimes.reduce((sum,value)=>sum+value,0)/frameTimes.length),viewport:viewport(),quality:effects.state.quality}; if(frameTimes.length>=60)window.webQA.performance={samples:frameTimes.length,meanFps:1000/(frameTimes.reduce((sum,value)=>sum+value,0)/frameTimes.length),viewport:viewport(),quality:effects.state.quality};
renderer.info.reset();navigation?.tick(performance.now());controls.update();const near=Math.max(.02,camera.position.distanceTo(controls.target)/150); renderer.info.reset();navigation?.tick(performance.now());controls.update();const near=Math.max(.02,camera.position.distanceTo(controls.target)/150);
if(Math.abs(camera.near-near)>.001){camera.near=near;camera.updateProjectionMatrix();} if(Math.abs(camera.near-near)>.001){camera.near=near;camera.updateProjectionMatrix();}
effects.render(mode);window.webQA.drawCalls=renderer.info.render.calls;window.webQA.triangles=renderer.info.render.triangles;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-v23');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');}
window.webQA.network={...net.connectivity,meters:net.cadEquipmentBindings.length,modelId:net.modelId,runtimeInpRequired:false}; window.webQA.network={...net.connectivity,meters:net.cadEquipmentBindings.length,modelId:net.modelId,runtimeInpRequired:false};
window.webQA.networkStyle={...networkStyle.summary,style:networkStyle.style};window.webQA.appearance={...appearanceState};
navigation=createCameraNavigation({camera,controls,model:net,networkRoot,root,show,getMode:()=>mode,appearance,getDisplayMode:()=>networkStyle.displayMode,setDisplayMode,onChange(state){ navigation=createCameraNavigation({camera,controls,model:net,networkRoot,root,show,getMode:()=>mode,appearance,getDisplayMode:()=>networkStyle.displayMode,setDisplayMode,onChange(state){
cameraState={active:state.active,note:state.note,views:state.views.map(view=>({id:view.id,label:view.label,mode:view.mode,note:view.note,saved:view.id.startsWith('saved-')}))};postState(); cameraState={active:state.active,note:state.note,views:state.views.map(view=>({id:view.id,label:view.label,mode:view.mode,note:view.note,saved:view.id.startsWith('saved-')}))};postState();
}}); }});
@@ -185,8 +206,8 @@ window.addEventListener('message',async event=>{
else if(command.name==='visit-camera')await navigation.visit(command.viewId); else if(command.name==='visit-camera')await navigation.visit(command.viewId);
else if(command.name==='save-camera')navigation.save(command.label); else if(command.name==='save-camera')navigation.save(command.label);
else if(command.name==='remove-camera')navigation.remove(command.viewId); else if(command.name==='remove-camera')navigation.remove(command.viewId);
else if(command.name==='set-style'){networkStyle.setStyle(command.patch);localStorage.setItem('zjb-network-style-v23',JSON.stringify(networkStyle.style));updateStyle();} else if(command.name==='set-style'){networkStyle.setStyle(command.patch);localStorage.setItem('zjb-network-style-v24',JSON.stringify(networkStyle.style));updateStyle();}
else if(command.name==='reset-style'){networkStyle.setStyle(DEFAULT_STYLE);localStorage.removeItem('zjb-network-style-v23');updateStyle();} else if(command.name==='reset-style'){networkStyle.setStyle(DEFAULT_STYLE);localStorage.removeItem('zjb-network-style-v24');updateStyle();}
else if(command.name==='set-display-mode')setDisplayMode(command.mode); else if(command.name==='set-display-mode')setDisplayMode(command.mode);
else if(command.name==='set-appearance')applyAppearance(command.patch); else if(command.name==='set-appearance')applyAppearance(command.patch);
else if(command.name==='toggle-context')toggleContext(); else if(command.name==='toggle-context')toggleContext();
@@ -200,5 +221,6 @@ window.addEventListener('message',async event=>{
}); });
await navigation.visit('overview'); await navigation.visit('overview');
document.getElementById('boot').hidden=true; sceneReady=true;
completeLoading();
postHost('ready',{nodeIds:net.nodes.map(node=>String(node.id)),linkIds:net.links.map(link=>String(link.id)),state:runtimeState()}); postHost('ready',{nodeIds:net.nodes.map(node=>String(node.id)),linkIds:net.links.map(link=>String(link.id)),state:runtimeState()});
@@ -0,0 +1,188 @@
// Only static scene assets enter this database. Live simulation/SCADA data do not.
const DATABASE = 'tjwater-scene-assets-v1';
const STORE = 'resources';
const REQUEST_TIMEOUT_MS = 30_000;
const CHUNK_BYTES = 1024 * 1024;
const MAX_ATTEMPTS = 3;
function openDatabase() {
return new Promise(resolve => {
let settled = false;
const finish = value => { if (!settled) { settled = true; resolve(value); } };
try {
const request = globalThis.indexedDB.open(DATABASE, 1);
request.onupgradeneeded = () => request.result.createObjectStore(STORE, {keyPath:'url'});
request.onerror = request.onblocked = () => finish(null);
request.onsuccess = () => {
const database = request.result;
if (settled) { database.close(); return; }
database.onversionchange = () => database.close();
finish(database);
};
} catch { finish(null); }
});
}
function transact(database, mode, operation) {
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE, mode);
const request = operation(transaction.objectStore(STORE));
transaction.oncomplete = () => resolve(request.result);
transaction.onerror = transaction.onabort = () => reject(transaction.error ?? request.error);
});
}
export function createSceneResourceCache(baseUrl, onProgress = () => {}) {
// Open lazily, after synchronous WebGL setup, so database callbacks can run promptly.
let database;
const pending = new Map();
async function read(file, revision, decode) {
const url = new URL(file, baseUrl);
const key = url.href;
const report = (stage, loaded = 0, total = 0) => onProgress({file, stage, loaded, total});
const storage = await (database ??= openDatabase());
if (storage && revision) {
try {
const entry = await transact(storage, 'readonly', store => store.get(key));
if (entry?.revision === revision && entry.data instanceof ArrayBuffer) {
report('cache', entry.data.byteLength, entry.data.byteLength);
const result = await decode(entry.data);
report('ready', entry.data.byteLength, entry.data.byteLength);
return result;
}
} catch { /* Unavailable or invalid cache: fetch a fresh copy below. */ }
}
// Revisioned URLs also keep the browser's HTTP cache in sync with the manifest.
if (revision) url.searchParams.set('v', revision);
const partialKey = key + '#partial';
let chunks = [], loaded = 0, total = 0;
if (storage && revision) {
try {
const partial = await transact(storage, 'readonly', store => store.get(partialKey));
if (partial?.revision === revision && Number.isSafeInteger(partial.total) && partial.total > 0 &&
Array.isArray(partial.chunks) && partial.chunks.length &&
partial.chunks.every(chunk => chunk instanceof ArrayBuffer && chunk.byteLength > 0 && chunk.byteLength <= CHUNK_BYTES)) {
const size = partial.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
if (size <= partial.total) { chunks = partial.chunks; loaded = size; total = partial.total; }
}
} catch { /* A denied partial cache does not prevent downloading. */ }
}
async function downloadPart() {
const controller = new AbortController();
let timer;
const resetTimeout = () => {
clearTimeout(timer);
timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
};
resetTimeout();
try {
const end = total ? Math.min(loaded + CHUNK_BYTES, total) - 1 : loaded + CHUNK_BYTES - 1;
const response = await fetch(url, {signal:controller.signal, cache:'no-cache', headers:{Range:`bytes=${loaded}-${end}`}});
if (!response.ok) {
const error = Error('HTTP ' + response.status);
error.retryable = [408, 429, 500, 502, 503, 504].includes(response.status);
throw error;
}
let partTotal = 0, expected = 0;
if (response.status === 206) {
const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get('content-range') ?? '');
const start = Number(range?.[1]), last = Number(range?.[2]);
partTotal = Number(range?.[3]);
if (!range || start !== loaded || last < start || last > end || last >= partTotal ||
!Number.isSafeInteger(partTotal) || (total && total !== partTotal)) {
throw Error('资源分段响应无效');
}
expected = last - start + 1;
} else {
if (loaded) throw Error('服务器未按断点返回资源');
// Servers without Range support can still return an ordinary complete response.
partTotal = Number(response.headers.get('content-length')) || 0;
}
const buffers = [];
let size = 0;
if (response.body) {
const reader = response.body.getReader();
try {
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffers.push(value); size += value.byteLength;
if (expected && size > expected) throw Error('资源分段长度无效');
report('download', loaded + size, partTotal); resetTimeout();
}
} finally { await reader.cancel().catch(() => {}); }
} else {
const buffer = new Uint8Array(await response.arrayBuffer());
buffers.push(buffer); size = buffer.byteLength;
}
if (expected && size !== expected) throw Error('资源分段不完整');
const bytes = new Uint8Array(size);
let offset = 0;
for (const buffer of buffers) { bytes.set(buffer, offset); offset += buffer.byteLength; }
return {data:bytes.buffer, total:response.status === 206 ? partTotal : size, ranged:response.status === 206};
} catch (error) {
controller.abort();
throw error;
} finally { clearTimeout(timer); }
}
report('download', loaded, total);
try {
while (!total || loaded < total) {
let part;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
try { part = await downloadPart(); break; }
catch (error) {
// Permanent HTTP/contract errors stop; temporary transport/server errors can recover.
if (!error.retryable && !(error instanceof TypeError) && error.name !== 'AbortError' && error.message !== '资源分段不完整') throw error;
if (attempt + 1 === MAX_ATTEMPTS) throw error;
report('retry', loaded, total);
await new Promise(resolve => setTimeout(resolve, 300 * (attempt + 1)));
}
}
chunks.push(part.data); loaded += part.data.byteLength; total = part.total;
if (storage && revision && part.ranged) {
try { await transact(storage, 'readwrite', store => store.put({url:partialKey, revision, total, chunks})); }
catch { /* Storage denied/full: retain the in-memory download. */ }
}
if (!part.ranged) break;
}
} catch (error) {
throw Error(file + ':' + (error.name === 'AbortError' ? '下载超时,请重试' : error.message));
}
const bytes = new Uint8Array(loaded);
let offset = 0;
for (const chunk of chunks) { bytes.set(new Uint8Array(chunk), offset); offset += chunk.byteLength; }
const data = bytes.buffer;
if (storage) {
try { await transact(storage, 'readwrite', store => store.delete(partialKey)); }
catch { /* Completed bytes can still be parsed when storage is unavailable. */ }
}
report('parse', data.byteLength, data.byteLength);
// Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache.
const result = await decode(data);
if (storage && revision) {
try {
// One entry per URL: a successful new revision replaces the old bytes.
await transact(storage, 'readwrite', store => store.put({url:key, revision, data}));
} catch { /* Storage denied/full must not prevent scene rendering. */ }
}
report('ready', data.byteLength, data.byteLength);
return result;
}
return {
load(file, revision, decode = data => data) {
const key = file + ':' + revision;
if (!pending.has(key)) {
const task = read(file, revision, decode).finally(() => pending.delete(key));
pending.set(key, task);
}
return pending.get(key);
},
};
}
+38
View File
@@ -0,0 +1,38 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const projectDir = process.cwd();
const standaloneDir = path.join(projectDir, ".next", "standalone");
const standalonePublicDir = path.join(standaloneDir, "public");
const standaloneStaticDir = path.join(standaloneDir, ".next", "static");
fs.rmSync(standalonePublicDir, { recursive: true, force: true });
fs.rmSync(standaloneStaticDir, { recursive: true, force: true });
fs.cpSync(path.join(projectDir, "public"), standalonePublicDir, {
recursive: true,
});
fs.mkdirSync(path.dirname(standaloneStaticDir), { recursive: true });
fs.cpSync(path.join(projectDir, ".next", "static"), standaloneStaticDir, {
recursive: true,
});
const server = spawn(process.execPath, [path.join(standaloneDir, "server.js")], {
cwd: standaloneDir,
env: {
...process.env,
HOSTNAME: "127.0.0.1",
PORT: "3100",
},
stdio: "inherit",
});
const stopServer = (signal) => {
if (!server.killed) server.kill(signal);
};
process.once("SIGINT", () => stopServer("SIGINT"));
process.once("SIGTERM", () => stopServer("SIGTERM"));
server.once("exit", (code, signal) => {
process.exitCode = signal ? 1 : (code ?? 1);
});
+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));
});
});
+153 -256
View File
@@ -41,6 +41,19 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
import {
ElementHistoryResult,
ElementHistorySeries,
ElementHistoryTarget,
fetchElementHistory,
historySeriesKey,
TimeSeriesPoint,
toTimeSeriesPoints,
} from "@/lib/elementHistory";
import {
FLOW_DISPLAY_UNIT,
PRESSURE_DISPLAY_UNIT,
} from "@/utils/units";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@@ -50,13 +63,6 @@ type IUser = {
name?: string; name?: string;
}; };
export interface TimeSeriesPoint {
/** ISO8601 时间戳 */
timestamp: string;
/** 每个设备对应的值 */
values: Record<string, number | null | undefined>;
}
export interface SCADADataPanelProps { export interface SCADADataPanelProps {
/** 选中的设备 ID 列表 */ /** 选中的设备 ID 列表 */
deviceIds: string[]; deviceIds: string[];
@@ -90,164 +96,75 @@ const panelHeaderActionSx = {
}, },
}; };
/** interface ScadaDeviceMetadata {
* 从后端 API 获取 SCADA 数据 device_id: string;
*/ device_type: string;
node_id: string | null;
link_id: string | null;
}
/** 用设备元数据组装统一元素历史查询,一次返回监测与模拟数据。 */
const fetchFromBackend = async ( const fetchFromBackend = async (
deviceIds: string[], deviceIds: string[],
range: { from: Date; to: Date }, range: { from: Date; to: Date },
): Promise<TimeSeriesPoint[]> => { ): Promise<ElementHistoryResult> => {
if (deviceIds.length === 0) { if (deviceIds.length === 0) {
return []; return { points: [], series: [] };
} }
const metadataResponse = await apiFetch(
const device_ids = deviceIds.join(","); `${config.BACKEND_URL}/api/v1/scada-devices`,
const start_time = dayjs(range.from).toISOString();
const end_time = dayjs(range.to).toISOString();
// 清洗数据接口
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
// 原始数据
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
// 模拟数据接口
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
try {
// 优先查询清洗数据和模拟数据
const [cleaningRes, simulationRes] = await Promise.all([
apiFetch(cleaningDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(simulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
]);
const cleaningData = transformBackendData(cleaningRes, deviceIds);
const simulationData = transformBackendData(simulationRes, deviceIds);
// 如果清洗数据有数据,返回清洗和模拟数据
if (cleaningData.length > 0) {
return mergeTimeSeriesData(
cleaningData,
simulationData,
deviceIds,
"clean",
"sim",
);
} else {
// 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据
const rawRes = await apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null);
const rawData = transformBackendData(rawRes, deviceIds);
return mergeTimeSeriesData(
simulationData,
rawData,
deviceIds,
"sim",
"raw",
);
}
} catch (error) {
console.error("[SCADADataPanel] 从后端获取数据失败:", error);
throw error;
}
};
/**
* 转换后端数据格式
* 根据实际后端返回的数据结构进行调整
*/
const transformBackendData = (
backendData: any,
deviceIds: string[],
): TimeSeriesPoint[] => {
// 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] }
if (backendData && !Array.isArray(backendData)) {
// 检查是否是设备ID为键的对象格式
const hasDeviceKeys = deviceIds.some((id) => id in backendData);
if (hasDeviceKeys) {
// 获取所有时间点的集合
const timeMap = new Map<string, Record<string, number | null>>();
deviceIds.forEach((deviceId) => {
const deviceData = backendData[deviceId];
if (Array.isArray(deviceData)) {
deviceData.forEach((item: any) => {
const timestamp = item.time || item.timestamp || item._time;
if (timestamp) {
if (!timeMap.has(timestamp)) {
timeMap.set(timestamp, {});
}
const values = timeMap.get(timestamp)!;
values[deviceId] =
typeof item.value === "number" ? item.value : null;
}
});
}
});
// 转换为 TimeSeriesPoint 数组并按时间排序
const result = Array.from(timeMap.entries()).map(
([timestamp, values]) => ({
timestamp,
values,
}),
);
result.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);
return result;
}
}
// 默认返回空数组
console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData);
return [];
};
/**
* 合并两个时间序列数据,为每个设备添加后缀
*/
const mergeTimeSeriesData = (
data1: TimeSeriesPoint[],
data2: TimeSeriesPoint[],
deviceIds: string[],
suffix1: string,
suffix2: string,
): TimeSeriesPoint[] => {
const timeMap = new Map<string, Record<string, number | null>>();
const processData = (data: TimeSeriesPoint[], suffix: string) => {
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
deviceIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
};
processData(data1, suffix1);
processData(data2, suffix2);
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
timestamp,
values,
}));
result.sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
); );
if (!metadataResponse.ok) {
throw new Error(`SCADA 设备信息请求失败: HTTP ${metadataResponse.status}`);
}
const allDevices = (await metadataResponse.json()) as ScadaDeviceMetadata[];
const requested = new Set(deviceIds);
const devices = allDevices.filter((device) => requested.has(device.device_id));
const missing = deviceIds.filter(
(deviceId) => !devices.some((device) => device.device_id === deviceId),
);
if (missing.length > 0) {
throw new Error(`SCADA 设备不存在: ${missing.join(", ")}`);
}
return result; const targetsByElement = new Map<string, ElementHistoryTarget>();
devices.forEach((device) => {
const isFlow = ["pipe_flow", "flow"].includes(
device.device_type.toLowerCase(),
);
const elementId = isFlow ? device.link_id : device.node_id;
const elementType = isFlow ? "pipe" : "junction";
if (!elementId) {
throw new Error(`SCADA 设备 ${device.device_id} 未关联管网元素`);
}
const key = `${elementType}:${elementId}`;
const target = targetsByElement.get(key) ?? {
element_id: elementId,
element_type: elementType,
device_ids: [],
};
target.device_ids!.push(device.device_id);
targetsByElement.set(key, target);
});
const targets = Array.from(targetsByElement.values());
const result = await fetchElementHistory(
targets,
range,
"realtime_comparison",
);
const expandedSeries = result.series.flatMap((series) => {
if (series.device_id || series.source.startsWith("scada_")) return [series];
const target = targets.find(
(item) =>
item.element_id === series.element_id &&
item.element_type === series.element_type,
);
return (target?.device_ids ?? []).map((deviceId) => ({
...series,
device_id: deviceId,
}));
});
return { series: expandedSeries, points: toTimeSeriesPoints(expandedSeries) };
}; };
const formatTimestamp = (timestamp: string) => const formatTimestamp = (timestamp: string) =>
@@ -337,83 +254,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const { open } = useNotification(); const { open } = useNotification();
const { data: user } = useGetIdentity<IUser>(); const { data: user } = useGetIdentity<IUser>();
const customFetcher = useMemo(() => { const customFetcher = fetchFromBackend;
if (!showCleaning) {
return fetchFromBackend;
}
return async (
deviceIds: string[],
range: { from: Date; to: Date },
): Promise<TimeSeriesPoint[]> => {
const device_ids = deviceIds.join(",");
const start_time = dayjs(range.from).toISOString();
const end_time = dayjs(range.to).toISOString();
// 清洗数据接口
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
// 原始数据
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
// 模拟数据接口
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
try {
const [cleanRes, rawRes, simRes] = await Promise.all([
apiFetch(cleaningDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(simulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
]);
const timeMap = new Map<string, Record<string, number | null>>();
const processData = (data: any, suffix: string) => {
if (!data) return;
deviceIds.forEach((deviceId) => {
const deviceData = data[deviceId];
if (Array.isArray(deviceData)) {
deviceData.forEach((item: any) => {
const timestamp = item.time || item.timestamp || item._time;
if (timestamp) {
if (!timeMap.has(timestamp)) {
timeMap.set(timestamp, {});
}
const values = timeMap.get(timestamp)!;
values[`${deviceId}_${suffix}`] =
typeof item.value === "number" ? item.value : null;
}
});
}
});
};
processData(cleanRes, "clean");
processData(rawRes, "raw");
processData(simRes, "sim");
const result = Array.from(timeMap.entries()).map(
([timestamp, values]) => ({
timestamp,
values,
}),
);
result.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);
return result;
} catch (error) {
console.error("[SCADADataPanel] 获取三种数据失败:", error);
throw error;
}
};
}, [showCleaning]);
const [from, setFrom] = useState<Dayjs>(() => { const [from, setFrom] = useState<Dayjs>(() => {
if (start_time) { if (start_time) {
@@ -435,6 +276,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}); });
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab); const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]); const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
const [loadingState, setLoadingState] = useState<LoadingState>("idle"); const [loadingState, setLoadingState] = useState<LoadingState>("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState<boolean>(true); const [isExpanded, setIsExpanded] = useState<boolean>(true);
@@ -465,19 +307,44 @@ 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),
[timeSeries, deviceIds, fractionDigits, showCleaning], [timeSeries, deviceIds, fractionDigits, showCleaning],
); );
const seriesByKey = useMemo(
() =>
new Map(historySeries.map((series) => [historySeriesKey(series), series])),
[historySeries],
);
const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
const hasPressureSeries = historySeries.some(
(item) => item.metric === "pressure",
);
const unitForKey = useCallback(
(key: string) => seriesByKey.get(key)?.display_unit ?? "",
[seriesByKey],
);
const axisForKey = useCallback(
(key: string) =>
seriesByKey.get(key)?.metric === "pressure" && hasFlowSeries ? 1 : 0,
[hasFlowSeries, seriesByKey],
);
const handleFetch = useCallback( const handleFetch = useCallback(
async (reason: string) => { async (reason: string) => {
if (!hasDevices) { if (!hasDevices) {
setTimeSeries([]); setTimeSeries([]);
setHistorySeries([]);
setLoadingState("idle"); setLoadingState("idle");
setError(null); setError(null);
return; return;
@@ -487,18 +354,19 @@ 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(),
}); });
setTimeSeries(result); setTimeSeries(result.points);
setHistorySeries(result.series);
setLoadingState("success"); setLoadingState("success");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "未知错误"); setError(err instanceof Error ? err.message : "未知错误");
setLoadingState("error"); setLoadingState("error");
} }
}, },
[deviceIds, customFetcher, hasDevices, normalizedRange], [requestedDeviceIds, customFetcher, hasDevices, normalizedRange],
); );
// 处理数据清洗 // 处理数据清洗
@@ -583,8 +451,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
handleFetch("device-change"); handleFetch("device-change");
} else { } else {
setTimeSeries([]); setTimeSeries([]);
setHistorySeries([]);
} }
}, [deviceIdsKey, handleFetch, hasDevices]); }, [handleFetch, hasDevices]);
// 当设备数量变化时,调整数据源选择 // 当设备数量变化时,调整数据源选择
useEffect(() => { useEffect(() => {
@@ -610,7 +479,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
return deviceIds.flatMap<GridColDef>((id) => [ return deviceIds.flatMap<GridColDef>((id) => [
{ {
field: `${id}_raw`, field: `${id}_raw`,
headerName: `${id} (原始)`, headerName: `${id} (原始) [${unitForKey(`${id}_raw`)}]`,
minWidth: 140, minWidth: 140,
flex: 1, flex: 1,
valueFormatter: (value: any) => { valueFormatter: (value: any) => {
@@ -623,7 +492,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}, },
{ {
field: `${id}_clean`, field: `${id}_clean`,
headerName: `${id} (清洗)`, headerName: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`,
minWidth: 140, minWidth: 140,
flex: 1, flex: 1,
valueFormatter: (value: any) => { valueFormatter: (value: any) => {
@@ -636,7 +505,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}, },
{ {
field: `${id}_sim`, field: `${id}_sim`,
headerName: `${id} (模拟)`, headerName: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`,
minWidth: 140, minWidth: 140,
flex: 1, flex: 1,
valueFormatter: (value: any) => { valueFormatter: (value: any) => {
@@ -652,7 +521,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
// 单一数据源模式:只显示选中的数据源 // 单一数据源模式:只显示选中的数据源
return deviceIds.map<GridColDef>((id) => ({ return deviceIds.map<GridColDef>((id) => ({
field: `${id}_${selectedSource}`, field: `${id}_${selectedSource}`,
headerName: id, headerName: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`,
minWidth: 140, minWidth: 140,
flex: 1, flex: 1,
valueFormatter: (value: any) => { valueFormatter: (value: any) => {
@@ -688,7 +557,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
if (hasData) { if (hasData) {
cols.push({ cols.push({
field: fieldKey, field: fieldKey,
headerName: `${deviceName} (${name})`, headerName: `${deviceName} (${name}) [${unitForKey(fieldKey)}]`,
minWidth: 140, minWidth: 140,
flex: 1, flex: 1,
valueFormatter: (value: any) => { valueFormatter: (value: any) => {
@@ -708,7 +577,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
})(); })();
return [...base, ...dynamic]; return [...base, ...dynamic];
}, [deviceIds, fractionDigits, showCleaning, selectedSource, dataset]); }, [
deviceIds,
fractionDigits,
showCleaning,
selectedSource,
dataset,
unitForKey,
]);
const rows = useMemo( const rows = useMemo(
() => () =>
@@ -766,8 +642,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
if (selectedSource === "all") { if (selectedSource === "all") {
return deviceIds.flatMap((id, index) => [ return deviceIds.flatMap((id, index) => [
{ {
name: `${id} (原始)`, name: `${id} (原始) [${unitForKey(`${id}_raw`)}]`,
type: "line", type: "line",
yAxisIndex: axisForKey(`${id}_raw`),
symbol: "none", symbol: "none",
connectNulls: true, connectNulls: true,
sampling: "lttb", sampling: "lttb",
@@ -775,8 +652,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
data: dataset.map((item) => item[`${id}_raw`]), data: dataset.map((item) => item[`${id}_raw`]),
}, },
{ {
name: `${id} (清洗)`, name: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`,
type: "line", type: "line",
yAxisIndex: axisForKey(`${id}_clean`),
symbol: "none", symbol: "none",
connectNulls: true, connectNulls: true,
sampling: "lttb", sampling: "lttb",
@@ -784,8 +662,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
data: dataset.map((item) => item[`${id}_clean`]), data: dataset.map((item) => item[`${id}_clean`]),
}, },
{ {
name: `${id} (模拟)`, name: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`,
type: "line", type: "line",
yAxisIndex: axisForKey(`${id}_sim`),
symbol: "none", symbol: "none",
connectNulls: true, connectNulls: true,
sampling: "lttb", sampling: "lttb",
@@ -795,8 +674,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
]); ]);
} else { } else {
return deviceIds.map((id, index) => ({ return deviceIds.map((id, index) => ({
name: id, name: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`,
type: "line", type: "line",
yAxisIndex: axisForKey(`${id}_${selectedSource}`),
symbol: "none", symbol: "none",
connectNulls: true, connectNulls: true,
sampling: "lttb", sampling: "lttb",
@@ -820,8 +700,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
: suffix === "clean" : suffix === "clean"
? "清洗" ? "清洗"
: "模拟" : "模拟"
})`, }) [${unitForKey(key)}]`,
type: "line", type: "line",
yAxisIndex: axisForKey(key),
symbol: "none", symbol: "none",
connectNulls: true, connectNulls: true,
sampling: "lttb", sampling: "lttb",
@@ -908,10 +789,26 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
boundaryGap: false, boundaryGap: false,
data: xData, data: xData,
}, },
yAxis: { yAxis: [
type: "value", ...(hasFlowSeries
scale: true, ? [
}, {
type: "value",
scale: true,
name: `流量 (${FLOW_DISPLAY_UNIT})`,
},
]
: []),
...(hasPressureSeries
? [
{
type: "value",
scale: true,
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
},
]
: []),
],
dataZoom: [ dataZoom: [
{ {
type: "inside", type: "inside",
@@ -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,
@@ -24,14 +32,8 @@ const range = {
describe("fetchHistoryData", () => { describe("fetchHistoryData", () => {
beforeEach(() => jest.clearAllMocks()); beforeEach(() => jest.clearAllMocks());
it("queries SCADA readings once per selected network element", async () => { it("queries all selected elements in one batch request", async () => {
jest.mocked(apiFetch).mockImplementation(async (input) => { jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
const url = new URL(String(input));
const elementId = url.searchParams.get("element_id") ?? "";
return jsonResponse({
[elementId]: [{ time: range.from.toISOString(), value: 1 }],
});
});
await fetchHistoryData( await fetchHistoryData(
[ [
@@ -42,16 +44,20 @@ describe("fetchHistoryData", () => {
"none", "none",
); );
const elementIds = jest expect(apiFetch).toHaveBeenCalledTimes(1);
.mocked(apiFetch) const [url, init] = jest.mocked(apiFetch).mock.calls[0];
.mock.calls.map(([input]) => expect(String(url)).toContain("/element-history/query");
new URL(String(input)).searchParams.get("element_id"), expect(JSON.parse(String(init?.body))).toMatchObject({
); mode: "observed",
expect(elementIds).toEqual(["J-1", "J-2", "J-1", "J-2"]); elements: [
{ element_id: "J-1", element_type: "junction" },
{ element_id: "J-2", element_type: "junction" },
],
});
}); });
it("uses the analysis run ID for historical scheme simulation data", async () => { it("uses the analysis run ID for historical scheme simulation data", async () => {
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ "P-1": [] })); jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
await fetchHistoryData( await fetchHistoryData(
[["P-1", "pipe"]], [["P-1", "pipe"]],
@@ -60,15 +66,12 @@ describe("fetchHistoryData", () => {
"99dd4142-368b-54cb-bfca-d59ee48f6298", "99dd4142-368b-54cb-bfca-d59ee48f6298",
); );
const simulationUrls = jest expect(apiFetch).toHaveBeenCalledTimes(1);
.mocked(apiFetch) const [, init] = jest.mocked(apiFetch).mock.calls[0];
.mock.calls.map(([input]) => new URL(String(input))) expect(JSON.parse(String(init?.body))).toMatchObject({
.filter((url) => url.pathname.endsWith("/element-simulations")); mode: "analysis_comparison",
run_id: "99dd4142-368b-54cb-bfca-d59ee48f6298",
expect(simulationUrls).toHaveLength(2); });
expect(
simulationUrls.map((url) => url.searchParams.get("run_id")),
).toEqual([null, "99dd4142-368b-54cb-bfca-d59ee48f6298"]);
}); });
it("rejects oversized element selections before issuing requests", async () => { it("rejects oversized element selections before issuing requests", async () => {
@@ -84,17 +87,8 @@ describe("fetchHistoryData", () => {
expect(apiFetch).not.toHaveBeenCalled(); expect(apiFetch).not.toHaveBeenCalled();
}); });
it("limits concurrent SCADA requests for multi-element history", async () => { it("does not create N+1 requests for multi-element history", async () => {
let activeRequests = 0; jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
let peakRequests = 0;
jest.mocked(apiFetch).mockImplementation(async (input) => {
activeRequests += 1;
peakRequests = Math.max(peakRequests, activeRequests);
await new Promise((resolve) => setTimeout(resolve, 0));
activeRequests -= 1;
const elementId = new URL(String(input)).searchParams.get("element_id") ?? "";
return jsonResponse({ [elementId]: [] });
});
await fetchHistoryData( await fetchHistoryData(
Array.from( Array.from(
@@ -105,6 +99,42 @@ describe("fetchHistoryData", () => {
"none", "none",
); );
expect(peakRequests).toBeLessThanOrEqual(8); 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));
}); });
}); });
@@ -34,20 +34,23 @@ import timezone from "dayjs/plugin/timezone";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
import config from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
import {
ElementHistoryResult,
ElementHistorySeries,
fetchElementHistory,
historySeriesKey,
historySeriesLabel,
TimeSeriesPoint,
} from "@/lib/elementHistory";
import {
FLOW_DISPLAY_UNIT,
PRESSURE_DISPLAY_UNIT,
} from "@/utils/units";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
export interface TimeSeriesPoint {
/** ISO8601 时间戳 */
timestamp: string;
/** 每个设备对应的值 */
values: Record<string, number | null | undefined>;
}
export interface SCADADataPanelProps { export interface SCADADataPanelProps {
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */ /** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
featureInfos: [string, string][]; featureInfos: [string, string][];
@@ -73,7 +76,6 @@ type LoadingState = "idle" | "loading" | "success" | "error";
const MAX_HISTORY_ELEMENTS = 200; const MAX_HISTORY_ELEMENTS = 200;
const MAX_HISTORY_ELEMENT_ID_LENGTH = 128; const MAX_HISTORY_ELEMENT_ID_LENGTH = 128;
const HISTORY_SCADA_CONCURRENCY = 4;
const panelHeaderActionSx = { const panelHeaderActionSx = {
color: "primary.contrastText", color: "primary.contrastText",
@@ -83,48 +85,6 @@ const panelHeaderActionSx = {
}, },
}; };
const buildApiUrl = (
path: string,
params: Record<string, string | boolean>,
) => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
searchParams.set(key, String(value));
});
return `${config.BACKEND_URL}${path}?${searchParams.toString()}`;
};
const fetchOptionalJson = async (url: string, signal?: AbortSignal) => {
const response = await apiFetch(url, { signal });
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`历史数据请求失败: HTTP ${response.status}`);
}
return response.json();
};
const mapWithConcurrency = async <Input, Output>(
items: Input[],
limit: number,
mapper: (item: Input, index: number) => Promise<Output>,
): Promise<Output[]> => {
const results = new Array<Output>(items.length);
let nextIndex = 0;
const workerCount = Math.min(limit, items.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
}
}),
);
return results;
};
/** 从后端 API 获取管网元素的监测、实时模拟和方案模拟数据。 */ /** 从后端 API 获取管网元素的监测、实时模拟和方案模拟数据。 */
export const fetchHistoryData = async ( export const fetchHistoryData = async (
featureInfos: [string, string][], featureInfos: [string, string][],
@@ -132,9 +92,9 @@ export const fetchHistoryData = async (
type: "realtime" | "scheme" | "none", type: "realtime" | "scheme" | "none",
schemeRunId?: string, schemeRunId?: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<TimeSeriesPoint[]> => { ): Promise<ElementHistoryResult> => {
if (featureInfos.length === 0) { if (featureInfos.length === 0) {
return []; return { points: [], series: [] };
} }
if (featureInfos.length > MAX_HISTORY_ELEMENTS) { if (featureInfos.length > MAX_HISTORY_ELEMENTS) {
throw new Error(`历史数据一次最多查询 ${MAX_HISTORY_ELEMENTS} 个管网元素`); throw new Error(`历史数据一次最多查询 ${MAX_HISTORY_ELEMENTS} 个管网元素`);
@@ -149,285 +109,36 @@ export const fetchHistoryData = async (
} }
const uniqueFeatureInfos = Array.from( const uniqueFeatureInfos = Array.from(
new Map(featureInfos.map((featureInfo) => [featureInfo[0], featureInfo])).values(), new Map(
); featureInfos.map((featureInfo) => [featureInfo.join(":"), featureInfo]),
const featureIds = uniqueFeatureInfos.map(([id]) => id); ).values(),
const start_time = dayjs(range.from).toISOString();
const end_time = dayjs(range.to).toISOString();
// 将 featureInfos 转换为后端期望的格式: id1:type1,id2:type2
const feature_infos = uniqueFeatureInfos
.map(([id, type]) => `${id}:${type}`)
.join(",");
const fetchElementScadaData = async (useCleaned: boolean) => {
const results = await mapWithConcurrency(
featureIds,
HISTORY_SCADA_CONCURRENCY,
(elementId) =>
fetchOptionalJson(
buildApiUrl("/api/v1/timeseries/views/element-scada-readings", {
element_id: elementId,
start_time,
end_time,
use_cleaned: useCleaned,
}),
signal,
),
);
return Object.assign({}, ...results.filter(Boolean));
};
const simulationDataUrl = buildApiUrl(
"/api/v1/timeseries/views/element-simulations",
{ feature_infos, start_time, end_time },
); );
if (type === "scheme" && !schemeRunId) { if (type === "scheme" && !schemeRunId) {
throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据"); throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据");
} }
const schemeSimulationDataUrl = schemeRunId
? buildApiUrl("/api/v1/timeseries/views/element-simulations", {
feature_infos,
start_time,
end_time,
run_id: schemeRunId,
})
: null;
try { try {
if (type === "none") { return await fetchElementHistory(
// 查询清洗值和监测值 uniqueFeatureInfos.map(([id, elementType]) => ({
const [cleanedRes, rawRes] = await Promise.all([ element_id: id.trim(),
fetchElementScadaData(true), element_type: elementType.toLowerCase() as "pipe" | "junction",
fetchElementScadaData(false), })),
]); range,
type === "none"
const cleanedData = transformBackendData(cleanedRes, featureIds); ? "observed"
// 如果清洗数据有值,则不显示原始监测值 : type === "scheme"
const rawData = ? "analysis_comparison"
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds); : "realtime_comparison",
schemeRunId,
return mergeTimeSeriesData( signal,
cleanedData, );
rawData,
featureIds,
"clean",
"raw"
);
} else if (type === "scheme") {
// 查询策略模拟值、实时模拟值、清洗值和监测值
const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([
fetchElementScadaData(true),
fetchElementScadaData(false),
fetchOptionalJson(simulationDataUrl, signal),
fetchOptionalJson(schemeSimulationDataUrl!, signal),
]);
const cleanedData = transformBackendData(cleanedRes, featureIds);
// 如果清洗数据有值,则不显示原始监测值
const rawData =
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
const simulationData = transformBackendData(simulationRes, featureIds);
const schemeSimData = transformBackendData(schemeSimRes, featureIds);
return mergeMultipleTimeSeriesData(
[
{ data: cleanedData, suffix: "clean" },
{ data: rawData, suffix: "raw" },
{ data: simulationData, suffix: "sim" },
{ data: schemeSimData, suffix: "scheme_sim" },
],
featureIds
);
} else {
// realtime: 查询模拟值、清洗值和监测值
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
fetchElementScadaData(true),
fetchElementScadaData(false),
fetchOptionalJson(simulationDataUrl, signal),
]);
const cleanedData = transformBackendData(cleanedRes, featureIds);
// 如果清洗数据有值,则不显示原始监测值
const rawData =
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
const simulationData = transformBackendData(simulationRes, featureIds);
// 合并三组数据
const timeMap = new Map<string, Record<string, number | null>>();
[cleanedData, rawData, simulationData].forEach((data, index) => {
const suffix = ["clean", "raw", "sim"][index];
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
featureIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
});
const result = Array.from(timeMap.entries()).map(
([timestamp, values]) => ({
timestamp,
values,
})
);
result.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
}
} catch (error) { } catch (error) {
console.error("[SCADADataPanel] 从后端获取数据失败:", error); console.error("[HistoryDataPanel] 从后端获取数据失败:", error);
throw error; throw error;
} }
}; };
/**
* 转换后端数据格式
* 根据实际后端返回的数据结构进行调整
*/
const transformBackendData = (
backendData: any,
deviceIds: string[]
): TimeSeriesPoint[] => {
// 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] }
if (backendData && !Array.isArray(backendData)) {
// 检查是否是设备ID为键的对象格式
const hasDeviceKeys = deviceIds.some((id) => id in backendData);
if (hasDeviceKeys) {
// 获取所有时间点的集合
const timeMap = new Map<string, Record<string, number | null>>();
deviceIds.forEach((deviceId) => {
const deviceData = backendData[deviceId];
if (Array.isArray(deviceData)) {
deviceData.forEach((item: any) => {
const timestamp = item.time || item.timestamp || item._time;
if (timestamp) {
if (!timeMap.has(timestamp)) {
timeMap.set(timestamp, {});
}
const values = timeMap.get(timestamp)!;
values[deviceId] =
typeof item.value === "number" ? item.value : null;
}
});
}
});
// 转换为 TimeSeriesPoint 数组并按时间排序
const result = Array.from(timeMap.entries()).map(
([timestamp, values]) => ({
timestamp,
values,
})
);
result.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
}
}
// 默认返回空数组
console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData);
return [];
};
/**
* 合并两个时间序列数据,为每个设备添加后缀
*/
const mergeTimeSeriesData = (
data1: TimeSeriesPoint[],
data2: TimeSeriesPoint[],
deviceIds: string[],
suffix1: string,
suffix2: string
): TimeSeriesPoint[] => {
const timeMap = new Map<string, Record<string, number | null>>();
const processData = (data: TimeSeriesPoint[], suffix: string) => {
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
deviceIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
};
processData(data1, suffix1);
processData(data2, suffix2);
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
timestamp,
values,
}));
result.sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
};
const mergeMultipleTimeSeriesData = (
datasets: Array<{
data: TimeSeriesPoint[];
suffix: string;
}>,
deviceIds: string[]
): TimeSeriesPoint[] => {
const timeMap = new Map<string, Record<string, number | null>>();
datasets.forEach(({ data, suffix }) => {
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
deviceIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
});
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
timestamp,
values,
}));
result.sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
};
const formatTimestamp = (timestamp: string) => const formatTimestamp = (timestamp: string) =>
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm"); dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
@@ -443,7 +154,7 @@ const ensureValidRange = (
const buildDataset = ( const buildDataset = (
points: TimeSeriesPoint[], points: TimeSeriesPoint[],
deviceIds: string[], series: ElementHistorySeries[],
fractionDigits: number fractionDigits: number
) => { ) => {
return points.map((point) => { return points.map((point) => {
@@ -452,19 +163,17 @@ const buildDataset = (
label: formatTimestamp(point.timestamp), label: formatTimestamp(point.timestamp),
}; };
deviceIds.forEach((id) => { series.forEach((metadata) => {
["clean", "raw", "sim", "scheme_sim"].forEach((suffix) => { const key = historySeriesKey(metadata);
const key = `${id}_${suffix}`; const value = point.values[key];
const value = point.values[key]; if (value !== undefined && value !== null) {
if (value !== undefined && value !== null) { entry[key] =
entry[key] = typeof value === "number"
typeof value === "number" ? Number.isFinite(value)
? Number.isFinite(value) ? parseFloat(value.toFixed(fractionDigits))
? parseFloat(value.toFixed(fractionDigits)) : null
: null : value ?? null;
: value ?? null; }
}
});
}); });
return entry; return entry;
@@ -521,11 +230,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}); });
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab); const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]); const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
const [loadingState, setLoadingState] = useState<LoadingState>("idle"); const [loadingState, setLoadingState] = useState<LoadingState>("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedSource, setSelectedSource] = useState<
"raw" | "clean" | "sim" | "all"
>(() => (featureInfos.length === 1 ? "all" : "clean"));
const draggableRef = useRef<HTMLDivElement>(null); const draggableRef = useRef<HTMLDivElement>(null);
const requestControllerRef = useRef<AbortController | null>(null); const requestControllerRef = useRef<AbortController | null>(null);
@@ -551,16 +258,17 @@ 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, deviceIds, fractionDigits), () => buildDataset(timeSeries, historySeries, fractionDigits),
[timeSeries, deviceIds, fractionDigits] [timeSeries, historySeries, fractionDigits]
); );
const handleFetch = useCallback( const handleFetch = useCallback(
@@ -568,6 +276,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
if (!hasDevices) { if (!hasDevices) {
requestControllerRef.current?.abort(); requestControllerRef.current?.abort();
setTimeSeries([]); setTimeSeries([]);
setHistorySeries([]);
setLoadingState("idle"); setLoadingState("idle");
setError(null); setError(null);
return; return;
@@ -581,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(),
@@ -591,7 +300,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
requestController.signal, requestController.signal,
); );
if (requestControllerRef.current !== requestController) return; if (requestControllerRef.current !== requestController) return;
setTimeSeries(result); setTimeSeries(result.points);
setHistorySeries(result.series);
setLoadingState("success"); setLoadingState("success");
} catch (err) { } catch (err) {
if ( if (
@@ -604,7 +314,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setLoadingState("error"); setLoadingState("error");
} }
}, },
[featureInfos, hasDevices, normalizedRange, runId, type], [requestedFeatureInfos, hasDevices, normalizedRange, runId, type],
); );
useEffect( useEffect(
@@ -620,15 +330,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
handleFetch("device-change"); handleFetch("device-change");
} else { } else {
setTimeSeries([]); setTimeSeries([]);
setHistorySeries([]);
} }
}, [featureInfosKey, handleFetch, hasDevices]); }, [handleFetch, hasDevices]);
// 当设备数量变化时,调整数据源选择
useEffect(() => {
if (featureInfos.length > 1 && selectedSource === "all") {
setSelectedSource("clean");
}
}, [featureInfos.length, selectedSource]);
const columns: GridColDef[] = useMemo(() => { const columns: GridColDef[] = useMemo(() => {
const base: GridColDef[] = [ const base: GridColDef[] = [
@@ -643,45 +347,33 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const dynamic = (() => { const dynamic = (() => {
const cols: GridColDef[] = []; const cols: GridColDef[] = [];
deviceIds.forEach((id) => { historySeries.forEach((metadata) => {
// 为每个设备的每种数据类型创建列 const fieldKey = historySeriesKey(metadata);
const suffixes = [ const hasData = dataset.some(
{ key: "clean", name: "清洗值" }, (item) => item[fieldKey] !== null && item[fieldKey] !== undefined,
{ key: "raw", name: "监测值" }, );
{ key: "sim", name: "实时模拟值" }, if (hasData) {
{ key: "scheme_sim", name: "方案模拟值" }, cols.push({
]; field: fieldKey,
headerName: `${historySeriesLabel(metadata)} [${metadata.display_unit}]`,
suffixes.forEach(({ key, name }) => { minWidth: 180,
const fieldKey = `${id}_${key}`; flex: 1,
// 检查是否有该字段的数据 valueFormatter: (value: any) => {
const hasData = dataset.some( if (value === null || value === undefined) return "--";
(item) => item[fieldKey] !== null && item[fieldKey] !== undefined if (Number.isFinite(Number(value))) {
); return Number(value).toFixed(fractionDigits);
}
if (hasData) { return String(value);
cols.push({ },
field: fieldKey, });
headerName: `${id} (${name})`, }
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
if (value === null || value === undefined) return "--";
if (Number.isFinite(Number(value))) {
return Number(value).toFixed(fractionDigits);
}
return String(value);
},
});
}
});
}); });
return cols; return cols;
})(); })();
return [...base, ...dynamic]; return [...base, ...dynamic];
}, [deviceIds, fractionDigits, dataset]); }, [historySeries, fractionDigits, dataset]);
const rows = useMemo( const rows = useMemo(
() => () =>
@@ -733,91 +425,57 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
]; ];
const xData = dataset.map((item) => item.label); const xData = dataset.map((item) => item.label);
const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
const hasPressureSeries = historySeries.some(
(item) => item.metric === "pressure",
);
const getSeries = () => { const getSeries = () => {
return deviceIds.flatMap((id, index) => { return historySeries.flatMap((metadata, index) => {
const series = []; const key = historySeriesKey(metadata);
["clean", "raw", "sim", "scheme_sim"].forEach((suffix, sIndex) => { const hasSeriesData = dataset.some(
const key = `${id}_${suffix}`; (item) => item[key] !== null && item[key] !== undefined,
const hasData = dataset.some( );
(item) => item[key] !== null && item[key] !== undefined if (!hasSeriesData) return [];
); const isObserved = metadata.source.startsWith("scada_");
if (hasData) { return [
const displayName = {
suffix === "clean" name: `${historySeriesLabel(metadata)} [${metadata.display_unit}]`,
? "清洗值" type: "line",
: suffix === "raw" yAxisIndex:
? "监测值" metadata.metric === "pressure" && hasFlowSeries ? 1 : 0,
: suffix === "sim" symbol:
? "实时模拟" metadata.source === "scada_cleaned"
: "方案模拟"; ? "circle"
: metadata.source === "scada_raw"
series.push({
name: `${id} (${displayName})`,
type: "line",
symbol:
suffix === "clean"
? "circle"
: suffix === "raw"
? "diamond" ? "diamond"
: "none", : "none",
symbolSize: suffix === "clean" || suffix === "raw" ? 7 : 0, symbolSize: isObserved ? 7 : 0,
showSymbol: suffix === "clean" || suffix === "raw", showSymbol: isObserved,
sampling: "lttb",
connectNulls: suffix !== "clean" && suffix !== "raw",
itemStyle: {
color: colors[(index * 4 + sIndex) % colors.length],
},
data: dataset.map((item) => item[key]),
lineStyle:
suffix === "clean" || suffix === "raw"
? { width: 0 }
: undefined,
areaStyle:
suffix === "clean" || suffix === "raw"
? undefined
: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[(index * 4 + sIndex) % colors.length],
},
{
offset: 1,
color: "rgba(255, 255, 255, 0)",
},
]),
opacity: 0.3,
},
});
}
});
// 如果没有任何数据,则使用fallback
if (series.length === 0) {
series.push({
name: id,
type: "line",
symbol: "none",
sampling: "lttb", sampling: "lttb",
connectNulls: true, connectNulls: !isObserved,
itemStyle: { color: colors[index % colors.length] }, itemStyle: {
data: dataset.map((item) => item[id]), color: colors[index % colors.length],
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[index % colors.length],
},
{
offset: 1,
color: "rgba(255, 255, 255, 0)",
},
]),
opacity: 0.3,
}, },
}); data: dataset.map((item) => item[key]),
} lineStyle: isObserved ? { width: 0 } : undefined,
return series; areaStyle: isObserved
? undefined
: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[index % colors.length],
},
{
offset: 1,
color: "rgba(255, 255, 255, 0)",
},
]),
opacity: 0.3,
},
},
];
}); });
}; };
const option = { const option = {
@@ -853,10 +511,26 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
boundaryGap: false, boundaryGap: false,
data: xData, data: xData,
}, },
yAxis: { yAxis: [
type: "value", ...(hasFlowSeries
scale: true, ? [
}, {
type: "value",
scale: true,
name: `流量 (${FLOW_DISPLAY_UNIT})`,
},
]
: []),
...(hasPressureSeries
? [
{
type: "value",
scale: true,
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
},
]
: []),
],
dataZoom: [ dataZoom: [
{ {
type: "inside", type: "inside",
@@ -0,0 +1,38 @@
import { render, screen } from "@testing-library/react";
import type Feature from "ol/Feature";
import PropertyPanel from "./PropertyPanel";
import { buildFeatureProperties } from "./toolbarFeatureHelpers";
jest.mock("react-draggable", () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => children,
}));
jest.mock("ol/Feature", () => ({
__esModule: true,
default: class Feature {},
}));
describe("PropertyPanel junction demands", () => {
it("shows a readable demand table instead of raw GeoServer JSON", () => {
const feature = {
getId: () => "junctions.n_001",
getProperties: () => ({
id: "n_001",
elevation: 42,
base_demand: 0,
demands:
'[{"category":null,"pattern_id":"PAT_BASE","base_demand":0,"sequence_no":0}]',
}),
} as unknown as Feature;
const panelData = buildFeatureProperties(feature, {});
render(<PropertyPanel {...panelData} onClose={jest.fn()} />);
expect(screen.getByText("PAT_BASE")).toBeInTheDocument();
expect(screen.getAllByText("0.000 m³/h")).toHaveLength(2);
expect(screen.getByText("未分类")).toBeInTheDocument();
expect(screen.queryByText(/\[{"category"/)).not.toBeInTheDocument();
});
});
@@ -835,6 +835,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
onSave: handleValveSettingSave, onSave: handleValveSettingSave,
} }
: undefined, : undefined,
data?.resultUnits,
), ),
[ [
selectedFeature, selectedFeature,
@@ -848,6 +849,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
isValveStatusLoading, isValveStatusLoading,
isValveStatusSaving, isValveStatusSaving,
isValvePropertiesLoading, isValvePropertiesLoading,
data?.resultUnits,
isValveSettingSaving, isValveSettingSaving,
handleValveStatusSave, handleValveStatusSave,
handleValveSettingSave, handleValveSettingSave,
@@ -173,6 +173,52 @@ describe("getSimulationElementType", () => {
}); });
describe("buildFeatureProperties simulation values by hydraulic type", () => { describe("buildFeatureProperties simulation values by hydraulic type", () => {
it("renders GeoServer demand JSON as a readable unit-aware table", () => {
const junction = createFeature("junctions", "n_001", {
base_demand: 0,
demands:
'[{"category":null,"pattern_id":"PAT_BASE","base_demand":0,"sequence_no":0}]',
});
const result = buildFeatureProperties(
junction,
{},
undefined,
undefined,
{ flow: "LPS", pressure: "METERS", velocity: "m/s" },
);
expect(result.properties).toEqual(
expect.arrayContaining([
{
type: "table",
label: "需水配置",
columns: ["序号", "基础需水量", "模式", "类别"],
rows: [[1, "0.000 m³/h", "PAT_BASE", "未分类"]],
},
]),
);
});
it("uses the selected project's MLD model unit for demand values", () => {
const junction = createFeature("junctions", "J1", { base_demand: 1 });
const result = buildFeatureProperties(
junction,
{ actual_demand: 2, pressure: 18 },
undefined,
undefined,
{ flow: "MLD", pressure: "METERS", velocity: "m/s" },
);
expect(result.properties).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "基本需水量", value: "41.667" }),
expect.objectContaining({ label: "实际需水量", value: "83.333" }),
]),
);
});
it("shows link simulation results for a point-rendered pump", () => { it("shows link simulation results for a point-rendered pump", () => {
const pump = createFeature("pumps", "P1", { const pump = createFeature("pumps", "P1", {
node1: "J1", node1: "J1",
@@ -1,6 +1,13 @@
import Feature from "ol/Feature"; import Feature from "ol/Feature";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; import {
DEFAULT_NETWORK_RESULT_UNITS,
FLOW_DISPLAY_UNIT,
PRESSURE_DISPLAY_UNIT,
type NetworkResultUnits,
VELOCITY_DISPLAY_UNIT,
toModelDisplayValue,
} from "@utils/units";
import { import {
getValveSettingHelperText, getValveSettingHelperText,
VALVE_STATUS_OPTIONS, VALVE_STATUS_OPTIONS,
@@ -163,11 +170,70 @@ export const inferHistoryFeatureInfos = (
}) })
.filter(Boolean) as [string, string][]; .filter(Boolean) as [string, string][];
type DemandEntry = {
sequence_no?: unknown;
base_demand?: unknown;
demand?: unknown;
pattern_id?: unknown;
pattern?: unknown;
category?: unknown;
};
const parseDemandEntries = (value: unknown): DemandEntry[] => {
let parsed = value;
if (typeof parsed === "string") {
try {
parsed = JSON.parse(parsed);
} catch {
return [];
}
}
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(entry): entry is DemandEntry =>
typeof entry === "object" && entry !== null,
);
};
const buildDemandProperty = (
value: unknown,
resultUnits: NetworkResultUnits,
): ToolbarPropertyItem => {
const entries = parseDemandEntries(value);
if (entries.length === 0) {
return { label: "需水配置", value: "未配置" };
}
return {
type: "table",
label: "需水配置",
columns: ["序号", "基础需水量", "模式", "类别"],
rows: entries.map((entry, index) => {
const sourceDemand = Number(entry.base_demand ?? entry.demand);
const demand = Number.isFinite(sourceDemand)
? `${toModelDisplayValue(
sourceDemand,
"base_demand",
resultUnits,
).toFixed(3)} ${FLOW_DISPLAY_UNIT}`
: "未设置";
const sequence = Number(entry.sequence_no);
return [
Number.isInteger(sequence) ? sequence + 1 : index + 1,
demand,
String(entry.pattern_id ?? entry.pattern ?? "无"),
String(entry.category ?? "未分类"),
];
}),
};
};
export const buildFeatureProperties = ( export const buildFeatureProperties = (
highlightFeature: Feature | undefined, highlightFeature: Feature | undefined,
computedProperties: Record<string, any>, computedProperties: Record<string, any>,
valveStatus?: ValveStatusPropertyOptions, valveStatus?: ValveStatusPropertyOptions,
valveSetting?: ValveSettingPropertyOptions, valveSetting?: ValveSettingPropertyOptions,
resultUnits: NetworkResultUnits = DEFAULT_NETWORK_RESULT_UNITS,
): ToolbarPropertyPanelData => { ): ToolbarPropertyPanelData => {
if (!highlightFeature) return {}; if (!highlightFeature) return {};
@@ -182,12 +248,12 @@ export const buildFeatureProperties = (
{ key: "reaction", label: "反应", unit: "1/d" }, { key: "reaction", label: "反应", unit: "1/d" },
{ key: "setting", label: "设置", unit: "" }, { key: "setting", label: "设置", unit: "" },
{ key: "status", label: "状态", unit: "" }, { key: "status", label: "状态", unit: "" },
{ key: "velocity", label: "流速", unit: "m/s" }, { key: "velocity", label: "流速", unit: VELOCITY_DISPLAY_UNIT },
]; ];
const nodeComputedFields = [ const nodeComputedFields = [
{ key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` }, { key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` },
{ key: "total_head", label: "水头", unit: "m" }, { key: "total_head", label: "水头", unit: "m" },
{ key: "pressure", label: "压力", unit: "m" }, { key: "pressure", label: "压力", unit: PRESSURE_DISPLAY_UNIT },
{ key: "quality", label: "水质", unit: "mg/L" }, { key: "quality", label: "水质", unit: "mg/L" },
]; ];
@@ -200,7 +266,10 @@ export const buildFeatureProperties = (
let value = computedProperties[key]; let value = computedProperties[key];
if (key === "flow" && value !== undefined) { if (key === "flow" && value !== undefined) {
value = toM3h(value, "lps"); value = toModelDisplayValue(value, key, resultUnits);
}
if (key === "velocity" && value !== undefined) {
value = toModelDisplayValue(value, key, resultUnits);
} }
if ( if (
key === "unit_headloss" && key === "unit_headloss" &&
@@ -226,7 +295,10 @@ export const buildFeatureProperties = (
let value = computedProperties[key]; let value = computedProperties[key];
if (key === "actual_demand") { if (key === "actual_demand") {
value = toM3h(value, "lps"); value = toModelDisplayValue(value, key, resultUnits);
}
if (key === "pressure") {
value = toModelDisplayValue(value, key, resultUnits);
} }
result.properties?.push({ result.properties?.push({
label, label,
@@ -273,14 +345,15 @@ export const buildFeatureProperties = (
{ {
label: "基本需水量", label: "基本需水量",
value: Number.isFinite(Number(properties.base_demand)) value: Number.isFinite(Number(properties.base_demand))
? toM3h(Number(properties.base_demand), "lps").toFixed(3) ? toModelDisplayValue(
Number(properties.base_demand),
"base_demand",
resultUnits,
).toFixed(3)
: properties.base_demand, : properties.base_demand,
unit: "m³/h", unit: FLOW_DISPLAY_UNIT,
},
{
label: "需水配置",
value: properties.demands,
}, },
buildDemandProperty(properties.demands, resultUnits),
], ],
}; };
@@ -5,7 +5,10 @@ import type { FlatStyleLike } from "ol/style/flat";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { config } from "@/config/config"; import { config } from "@/config/config";
import { isLpsFlowProperty, toM3h } from "@utils/units"; import {
type NetworkResultUnits,
toModelDisplayValue,
} from "@utils/units";
import { LayerStyleController } from "../layerStyleController"; import { LayerStyleController } from "../layerStyleController";
import { useData, useMap } from "../MapComponent"; import { useData, useMap } from "../MapComponent";
@@ -56,11 +59,15 @@ const configsEqual = (left?: StyleConfig, right?: StyleConfig) =>
const hasSameTemplate = (left: StyleConfig, right: StyleConfig) => const hasSameTemplate = (left: StyleConfig, right: StyleConfig) =>
left.property === right.property && left.segments === right.segments; left.property === right.property && left.segments === right.segments;
const normalizeComputedStyleValue = (property: string, value: unknown) => { const normalizeComputedStyleValue = (
property: string,
value: unknown,
resultUnits?: NetworkResultUnits,
) => {
const numericValue = Number(value); const numericValue = Number(value);
if (!Number.isFinite(numericValue)) return Number.NaN; if (!Number.isFinite(numericValue)) return Number.NaN;
const displayValue = isLpsFlowProperty(property) const displayValue = resultUnits
? toM3h(numericValue, "lps") ? toModelDisplayValue(numericValue, property, resultUnits)
: numericValue; : numericValue;
return property === "flow" ? Math.abs(displayValue) : displayValue; return property === "flow" ? Math.abs(displayValue) : displayValue;
}; };
@@ -140,6 +147,7 @@ export const useStyleEditor = ({
const elevationRange = data?.elevationRange; const elevationRange = data?.elevationRange;
const diameterRange = data?.diameterRange; const diameterRange = data?.diameterRange;
const forceStyleAutoApplyVersion = data?.forceStyleAutoApplyVersion ?? 0; const forceStyleAutoApplyVersion = data?.forceStyleAutoApplyVersion ?? 0;
const resultUnits = data?.resultUnits;
const setJunctionText = data?.setJunctionText; const setJunctionText = data?.setJunctionText;
const setPipeText = data?.setPipeText; const setPipeText = data?.setPipeText;
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer; const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
@@ -311,10 +319,18 @@ export const useStyleEditor = ({
} }
const records = layerId === "junctions" ? currentJunctionCalData : currentPipeCalData; const records = layerId === "junctions" ? currentJunctionCalData : currentPipeCalData;
return (records || []) return (records || [])
.map((item: any) => normalizeComputedStyleValue(property, item.value)) .map((item: any) =>
normalizeComputedStyleValue(property, item.value, resultUnits),
)
.filter(Number.isFinite); .filter(Number.isFinite);
}, },
[currentJunctionCalData, currentPipeCalData, diameterRange, elevationRange], [
currentJunctionCalData,
currentPipeCalData,
diameterRange,
elevationRange,
resultUnits,
],
); );
const syncAuxiliaryLayers = useCallback( const syncAuxiliaryLayers = useCallback(
@@ -425,7 +441,11 @@ export const useStyleEditor = ({
records.forEach((record: any) => { records.forEach((record: any) => {
const id = record.ID ?? record.id; const id = record.ID ?? record.id;
if (id === undefined || id === null) return; if (id === undefined || id === null) return;
const value = normalizeComputedStyleValue(nextConfig.property, record.value); const value = normalizeComputedStyleValue(
nextConfig.property,
record.value,
resultUnits,
);
if (Number.isFinite(value)) stateById.set(String(id), value); if (Number.isFinite(value)) stateById.set(String(id), value);
}); });
const committed = await controller.applyRuntime(options, stateById); const committed = await controller.applyRuntime(options, stateById);
@@ -465,6 +485,7 @@ export const useStyleEditor = ({
getDataForMap, getDataForMap,
getMapKey, getMapKey,
getRenderLayersById, getRenderLayersById,
resultUnits,
syncContoursForStyle, syncContoursForStyle,
upsertLayerStyleState, upsertLayerStyleState,
], ],
+39 -17
View File
@@ -23,7 +23,11 @@ import { TextLayer } from "@deck.gl/layers";
import { TripsLayer } from "@deck.gl/geo-layers"; import { TripsLayer } from "@deck.gl/geo-layers";
import { CollisionFilterExtension } from "@deck.gl/extensions"; import { CollisionFilterExtension } from "@deck.gl/extensions";
import { ContourLayer } from "deck.gl"; import { ContourLayer } from "deck.gl";
import { isLpsFlowProperty, toM3h } from "@utils/units"; import {
type NetworkResultUnits,
toModelDisplayValue,
} from "@utils/units";
import { useNetworkResultUnits } from "@/hooks/useNetworkResultUnits";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { import {
cleanupTransientMapResources, cleanupTransientMapResources,
@@ -125,6 +129,7 @@ interface DataContextType {
elevationRange?: [number, number]; elevationRange?: [number, number];
forceStyleAutoApplyVersion?: number; forceStyleAutoApplyVersion?: number;
setForceStyleAutoApplyVersion?: React.Dispatch<React.SetStateAction<number>>; setForceStyleAutoApplyVersion?: React.Dispatch<React.SetStateAction<number>>;
resultUnits?: NetworkResultUnits;
} }
// 跨组件传递 // 跨组件传递
@@ -138,14 +143,13 @@ const mergeJunctionValues = (
features: any[], features: any[],
records: any[], records: any[],
property: string, property: string,
resultUnits: NetworkResultUnits,
) => { ) => {
const recordsById = indexCalculationRecords(records); const recordsById = indexCalculationRecords(records);
return features.map((feature) => { return features.map((feature) => {
const record = recordsById.get(String(feature.id)); const record = recordsById.get(String(feature.id));
if (!record) return feature; if (!record) return feature;
const value = isLpsFlowProperty(property) const value = toModelDisplayValue(record.value, property, resultUnits);
? toM3h(record.value, "lps")
: record.value;
return { ...feature, [property]: value }; return { ...feature, [property]: value };
}); });
}; };
@@ -154,13 +158,14 @@ const mergePipeValues = (
features: any[], features: any[],
records: any[], records: any[],
property: string, property: string,
resultUnits: NetworkResultUnits,
) => { ) => {
const recordsById = indexCalculationRecords(records); const recordsById = indexCalculationRecords(records);
const isFlow = property === "flow"; const isFlow = property === "flow";
return features.map((feature) => { return features.map((feature) => {
const record = recordsById.get(String(feature.id)); const record = recordsById.get(String(feature.id));
if (!record) return feature; if (!record) return feature;
const value = isFlow ? toM3h(record.value, "lps") : record.value; const value = toModelDisplayValue(record.value, property, resultUnits);
const reverseFlow = isFlow && record.value < 0; const reverseFlow = isFlow && record.value < 0;
return { return {
...feature, ...feature,
@@ -202,6 +207,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const MAP_URL = config.MAP_URL; const MAP_URL = config.MAP_URL;
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
const { durationMinutes, stepMinutes } = useTimelineTimeConfig(); const { durationMinutes, stepMinutes } = useTimelineTimeConfig();
const resultUnits = useNetworkResultUnits(project?.networkName);
const mapRef = useRef<HTMLDivElement | null>(null); const mapRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -288,37 +294,52 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// 实时合并计算结果到基础地理数据中 // 实时合并计算结果到基础地理数据中
const mergedJunctionData = useMemo( const mergedJunctionData = useMemo(
() => () =>
mergeJunctionValues(junctionData, currentJunctionCalData, junctionText), mergeJunctionValues(
[junctionData, currentJunctionCalData, junctionText], junctionData,
currentJunctionCalData,
junctionText,
resultUnits,
),
[junctionData, currentJunctionCalData, junctionText, resultUnits],
); );
const mergedPipeData = useMemo( const mergedPipeData = useMemo(
() => mergePipeValues(pipeData, currentPipeCalData, pipeText), () => mergePipeValues(pipeData, currentPipeCalData, pipeText, resultUnits),
[pipeData, currentPipeCalData, pipeText], [pipeData, currentPipeCalData, pipeText, resultUnits],
); );
const mergedPipeFragments = useMemo( const mergedPipeFragments = useMemo(
() => mergePipeValues(pipeFragments, currentPipeCalData, pipeText), () => mergePipeValues(pipeFragments, currentPipeCalData, pipeText, resultUnits),
[pipeFragments, currentPipeCalData, pipeText], [pipeFragments, currentPipeCalData, pipeText, resultUnits],
); );
const mergedCompareJunctionData = useMemo( const mergedCompareJunctionData = useMemo(
() => () =>
isCompareMode isCompareMode
? mergeJunctionValues(junctionData, compareJunctionCalData, junctionText) ? mergeJunctionValues(
junctionData,
compareJunctionCalData,
junctionText,
resultUnits,
)
: [], : [],
[isCompareMode, junctionData, compareJunctionCalData, junctionText], [isCompareMode, junctionData, compareJunctionCalData, junctionText, resultUnits],
); );
const mergedComparePipeData = useMemo( const mergedComparePipeData = useMemo(
() => () =>
isCompareMode isCompareMode
? mergePipeValues(pipeData, comparePipeCalData, pipeText) ? mergePipeValues(pipeData, comparePipeCalData, pipeText, resultUnits)
: [], : [],
[isCompareMode, pipeData, comparePipeCalData, pipeText], [isCompareMode, pipeData, comparePipeCalData, pipeText, resultUnits],
); );
const mergedComparePipeFragments = useMemo( const mergedComparePipeFragments = useMemo(
() => () =>
isCompareMode isCompareMode
? mergePipeValues(pipeFragments, comparePipeCalData, pipeText) ? mergePipeValues(
pipeFragments,
comparePipeCalData,
pipeText,
resultUnits,
)
: [], : [],
[isCompareMode, pipeFragments, comparePipeCalData, pipeText], [isCompareMode, pipeFragments, comparePipeCalData, pipeText, resultUnits],
); );
const [diameterRange, setDiameterRange] = useState< const [diameterRange, setDiameterRange] = useState<
@@ -1163,6 +1184,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
elevationRange, elevationRange,
forceStyleAutoApplyVersion, forceStyleAutoApplyVersion,
setForceStyleAutoApplyVersion, setForceStyleAutoApplyVersion,
resultUnits,
}} }}
> >
<MapContext.Provider value={map}> <MapContext.Provider value={map}>
@@ -10,7 +10,7 @@ const state: SceneRuntimeState = {
roofVisible: true, roofVisible: true,
displayMode: "global", displayMode: "global",
style: { style: {
scale: 8, scale: 6,
mode: "uniform", mode: "uniform",
color: "#098ed0", color: "#098ed0",
missingColor: "#89949d", missingColor: "#89949d",
@@ -30,6 +30,7 @@ const state: SceneRuntimeState = {
appearance: { appearance: {
preset: "day", preset: "day",
exposure: 0.95, exposure: 0.95,
contextOpacity: 0.4,
shadows: true, shadows: true,
effects: true, effects: true,
quality: "standard", quality: "standard",
@@ -49,6 +50,48 @@ const state: SceneRuntimeState = {
}; };
describe("ThreeDimensionalControls", () => { describe("ThreeDimensionalControls", () => {
it("toggles the active toolbar panel and opens a different panel", () => {
const onOpenChange = jest.fn();
const onTabChange = jest.fn();
const { rerender } = render(
<ThreeDimensionalControls
open
activeTab="scene"
ready
state={state}
selection={null}
timelineOpen
onOpenChange={onOpenChange}
onTimelineOpenChange={jest.fn()}
onTabChange={onTabChange}
onCommand={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "场景与视角" }));
expect(onOpenChange).toHaveBeenLastCalledWith(false);
expect(onTabChange).not.toHaveBeenCalled();
rerender(
<ThreeDimensionalControls
open={false}
activeTab="scene"
ready
state={state}
selection={null}
timelineOpen
onOpenChange={onOpenChange}
onTimelineOpenChange={jest.fn()}
onTabChange={onTabChange}
onCommand={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "管网样式" }));
expect(onTabChange).toHaveBeenLastCalledWith("style");
expect(onOpenChange).toHaveBeenLastCalledWith(true);
});
it("sends typed scene commands from platform controls", () => { it("sends typed scene commands from platform controls", () => {
const onCommand = jest.fn(); const onCommand = jest.fn();
const onTimelineOpenChange = jest.fn(); const onTimelineOpenChange = jest.fn();
@@ -69,10 +112,49 @@ describe("ThreeDimensionalControls", () => {
fireEvent.click(screen.getByRole("button", { name: "站房精细版" })); fireEvent.click(screen.getByRole("button", { name: "站房精细版" }));
expect(onCommand).toHaveBeenCalledWith({ name: "set-mode", mode: "detail" }); expect(onCommand).toHaveBeenCalledWith({ name: "set-mode", mode: "detail" });
fireEvent.change(screen.getByRole("slider", { name: "建筑背景透明度 60%" }), {
target: { value: "0.35" },
});
expect(onCommand).toHaveBeenCalledWith({
name: "set-appearance",
patch: { contextOpacity: 0.65 },
});
fireEvent.click(screen.getByRole("switch", { name: "建筑背景" }));
expect(onCommand).toHaveBeenCalledWith({ name: "toggle-context" });
fireEvent.click(screen.getByRole("button", { name: "时间轴" })); fireEvent.click(screen.getByRole("button", { name: "时间轴" }));
expect(onTimelineOpenChange).toHaveBeenCalledWith(false); expect(onTimelineOpenChange).toHaveBeenCalledWith(false);
}); });
it("uses the glass listbox instead of a native select", () => {
const onCommand = jest.fn();
render(
<ThreeDimensionalControls
open
activeTab="scene"
ready
state={state}
selection={null}
timelineOpen
onOpenChange={jest.fn()}
onTimelineOpenChange={jest.fn()}
onTabChange={jest.fn()}
onCommand={onCommand}
/>,
);
const displayMode = screen.getByRole("combobox", {
name: "管网展示比例",
});
expect(displayMode.tagName).not.toBe("SELECT");
fireEvent.mouseDown(displayMode);
fireEvent.click(screen.getByRole("option", { name: "泵房协调比例" }));
expect(onCommand).toHaveBeenCalledWith({
name: "set-display-mode",
mode: "coordinated",
});
});
it("shows selected asset fields and linked assets in the property tab", () => { it("shows selected asset fields and linked assets in the property tab", () => {
const onCommand = jest.fn(); const onCommand = jest.fn();
render( render(
@@ -1,10 +1,14 @@
"use client"; "use client";
import MenuItem from "@mui/material/MenuItem";
import Select from "@mui/material/Select";
import clsx from "clsx"; import clsx from "clsx";
import { useState, type ReactNode } from "react"; import { useId, useState, type ReactNode } from "react";
import { import {
FiBox, FiBox,
FiCamera, FiCamera,
FiCheck,
FiChevronDown,
FiChevronRight, FiChevronRight,
FiClock, FiClock,
FiCrosshair, FiCrosshair,
@@ -40,6 +44,90 @@ const glassSurface =
"bg-[linear-gradient(135deg,rgba(255,255,255,0.50),rgba(224,239,250,0.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,0.88),inset_0_-1px_0_rgba(112,145,168,0.18),0_18px_50px_rgba(15,43,69,0.20)] ring-1 ring-white/55"; "bg-[linear-gradient(135deg,rgba(255,255,255,0.50),rgba(224,239,250,0.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,0.88),inset_0_-1px_0_rgba(112,145,168,0.18),0_18px_50px_rgba(15,43,69,0.20)] ring-1 ring-white/55";
const fieldClass = const fieldClass =
"h-10 w-full rounded-lg border border-slate-300/70 bg-white/55 px-3 text-sm text-slate-800 outline-none transition focus:border-blue-500 focus:bg-white/80 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-50"; "h-10 w-full rounded-lg border border-slate-300/70 bg-white/55 px-3 text-sm text-slate-800 outline-none transition focus:border-blue-500 focus:bg-white/80 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-50";
const selectSx = {
height: 40,
borderRadius: "0.5rem",
backgroundColor: "rgba(255,255,255,0.55)",
color: "#1e293b",
fontSize: "0.875rem",
transition: "background-color 150ms ease, box-shadow 150ms ease",
"&:hover": {
backgroundColor: "rgba(255,255,255,0.78)",
},
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
minHeight: "0 !important",
padding: "8px 38px 8px 12px !important",
},
"& .MuiSelect-icon": {
right: 12,
color: "#64748b",
fontSize: 16,
transition: "transform 150ms ease",
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(148,163,184,0.7)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(59,130,246,0.65)",
},
"&.Mui-focused": {
backgroundColor: "rgba(255,255,255,0.82)",
boxShadow: "0 0 0 3px rgba(59,130,246,0.16)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "#3b82f6",
borderWidth: 1,
},
};
const selectMenuProps = {
disableScrollLock: true,
PaperProps: {
elevation: 0,
sx: {
mt: 0.75,
overflow: "hidden",
borderRadius: "0.75rem",
border: "1px solid rgba(255,255,255,0.72)",
background:
"linear-gradient(135deg, rgba(244,249,252,0.94), rgba(225,239,248,0.88))",
backdropFilter: "blur(24px) saturate(155%)",
WebkitBackdropFilter: "blur(24px) saturate(155%)",
boxShadow:
"inset 0 1px 0 rgba(255,255,255,0.92), 0 16px 42px rgba(15,43,69,0.22)",
},
},
MenuListProps: {
sx: {
p: 0.75,
},
},
};
const selectMenuItemSx = {
minHeight: 40,
gap: 1,
borderRadius: "0.5rem",
px: 1.25,
color: "#334155",
fontSize: "0.875rem",
transition: "background-color 120ms ease, color 120ms ease",
"&:hover": {
backgroundColor: "rgba(255,255,255,0.68)",
color: "#1d4ed8",
},
"&.Mui-selected": {
backgroundColor: "#2563eb",
color: "#fff",
},
"&.Mui-selected:hover": {
backgroundColor: "#1d4ed8",
},
"&.Mui-focusVisible": {
outline: "2px solid rgba(59,130,246,0.42)",
outlineOffset: -2,
},
};
export type ControlTab = "scene" | "style" | "properties"; export type ControlTab = "scene" | "style" | "properties";
@@ -71,6 +159,10 @@ export function ThreeDimensionalControls({
const [cameraLabel, setCameraLabel] = useState(""); const [cameraLabel, setCameraLabel] = useState("");
const selectTab = (tab: ControlTab) => { const selectTab = (tab: ControlTab) => {
if (open && activeTab === tab) {
onOpenChange(false);
return;
}
onTabChange(tab); onTabChange(tab);
onOpenChange(true); onOpenChange(true);
}; };
@@ -150,7 +242,7 @@ export function ThreeDimensionalControls({
aria-label="三维场景控制面板" aria-label="三维场景控制面板"
className={clsx( className={clsx(
glassSurface, glassSurface,
"absolute inset-x-2 bottom-2 z-30 flex max-h-[min(64dvh,560px)] flex-col overflow-hidden rounded-2xl md:inset-x-auto md:bottom-4 md:right-4 md:top-4 md:h-auto md:max-h-[760px] md:w-96", "absolute inset-x-2 bottom-2 z-30 flex h-[min(64dvh,560px)] flex-col overflow-hidden rounded-2xl md:inset-x-auto md:bottom-auto md:right-4 md:top-4 md:h-[min(760px,calc(100%-2rem))] md:w-96",
)} )}
> >
<div className="flex min-h-14 items-center gap-3 border-b border-white/45 bg-white/10 px-4"> <div className="flex min-h-14 items-center gap-3 border-b border-white/45 bg-white/10 px-4">
@@ -175,7 +267,7 @@ export function ThreeDimensionalControls({
<div <div
aria-disabled={!ready} aria-disabled={!ready}
className={clsx( className={clsx(
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 [scrollbar-color:rgba(100,116,139,.45)_transparent] [scrollbar-width:thin]", "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 [overflow-anchor:none] [scrollbar-color:rgba(100,116,139,.45)_transparent] [scrollbar-width:thin]",
!ready && "pointer-events-none opacity-50", !ready && "pointer-events-none opacity-50",
)} )}
> >
@@ -255,6 +347,15 @@ export function ThreeDimensionalControls({
<ControlSection title="场景显示"> <ControlSection title="场景显示">
<SwitchRow label="建筑背景" checked={state.contextVisible} onChange={() => onCommand({ name: "toggle-context" })} /> <SwitchRow label="建筑背景" checked={state.contextVisible} onChange={() => onCommand({ name: "toggle-context" })} />
<LabeledSlider
label={`建筑背景透明度 ${Math.round((1 - state.appearance.contextOpacity) * 100)}%`}
value={1 - state.appearance.contextOpacity}
min={0}
max={1}
step={0.05}
disabled={!state.contextVisible}
onChange={(value) => onCommand({ name: "set-appearance", patch: { contextOpacity: 1 - value } })}
/>
<SwitchRow label="屋盖与吊顶" checked={state.roofVisible} onChange={() => onCommand({ name: "toggle-roof" })} /> <SwitchRow label="屋盖与吊顶" checked={state.roofVisible} onChange={() => onCommand({ name: "toggle-roof" })} />
<SelectField <SelectField
label="管网展示比例" label="管网展示比例"
@@ -386,31 +487,64 @@ function ControlSection({ title, description, children }: { title: string; descr
} }
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }) { function SelectField({ label, value, options, onChange }: { label: string; value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }) {
const labelId = useId();
const selected = options.find((option) => option.value === value);
return ( return (
<label className="block"> <div className="block">
<span className="mb-1 block text-xs text-slate-500">{label}</span> <span id={labelId} className="mb-1 block text-xs text-slate-500">{label}</span>
<select className={fieldClass} value={value} onChange={(event) => onChange(event.target.value)}> <Select
{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)} fullWidth
</select> labelId={labelId}
</label> value={value}
renderValue={() => selected?.label ?? value}
MenuProps={selectMenuProps}
IconComponent={FiChevronDown}
onChange={(event) => onChange(String(event.target.value))}
sx={selectSx}
>
{options.map((option) => {
const optionSelected = option.value === value;
return (
<MenuItem key={option.value} value={option.value} sx={selectMenuItemSx}>
<span>{option.label}</span>
{optionSelected && <FiCheck aria-hidden="true" className="ml-auto text-base" />}
</MenuItem>
);
})}
</Select>
</div>
); );
} }
function SwitchRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) { function SwitchRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
return ( return (
<label className="flex min-h-10 cursor-pointer items-center justify-between gap-3 rounded-lg px-1 text-sm text-slate-700"> <button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="flex min-h-10 w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-1 text-sm text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
>
<span>{label}</span> <span>{label}</span>
<input type="checkbox" className="peer sr-only" checked={checked} onChange={(event) => onChange(event.target.checked)} /> <span
<span aria-hidden="true" className="relative h-6 w-11 shrink-0 rounded-full bg-slate-300/80 transition peer-checked:bg-blue-600 peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500/40 peer-focus-visible:ring-offset-2 after:absolute after:left-1 after:top-1 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform peer-checked:after:translate-x-5" /> aria-hidden="true"
</label> className={clsx(
"relative h-6 w-11 shrink-0 rounded-full transition-colors after:absolute after:left-1 after:top-1 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform",
checked
? "bg-blue-600 after:translate-x-5"
: "bg-slate-300/80",
)}
/>
</button>
); );
} }
function LabeledSlider({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) { function LabeledSlider({ label, value, min, max, step, disabled = false, onChange }: { label: string; value: number; min: number; max: number; step: number; disabled?: boolean; onChange: (value: number) => void }) {
return ( return (
<label className="block"> <label className={clsx("block", disabled && "opacity-45")}>
<span className="mb-1.5 block text-xs tabular-nums text-slate-500">{label}</span> <span className="mb-1.5 flex items-center justify-between text-xs tabular-nums text-slate-500">{label}</span>
<input type="range" aria-label={label} className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-300/75 accent-blue-600" value={value} min={min} max={max} step={step} onChange={(event) => onChange(Number(event.target.value))} /> <input type="range" aria-label={label} className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-300/75 accent-blue-600 disabled:cursor-not-allowed" value={value} min={min} max={max} step={step} disabled={disabled} onChange={(event) => onChange(Number(event.target.value))} />
</label> </label>
); );
} }
@@ -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"; const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=36-resumable-cache";
const SCENE_LOAD_TIMEOUT_MS = 30_000; const SCENE_LOAD_TIMEOUT_MS = 120_000;
const initialRuntimeState: SceneRuntimeState = { const initialRuntimeState: SceneRuntimeState = {
mode: "network", mode: "network",
@@ -48,7 +48,7 @@ const initialRuntimeState: SceneRuntimeState = {
roofVisible: true, roofVisible: true,
displayMode: "global", displayMode: "global",
style: { style: {
scale: 8, scale: 6,
mode: "uniform", mode: "uniform",
color: "#098ed0", color: "#098ed0",
missingColor: "#89949d", missingColor: "#89949d",
@@ -68,6 +68,7 @@ const initialRuntimeState: SceneRuntimeState = {
appearance: { appearance: {
preset: "day", preset: "day",
exposure: 0.95, exposure: 0.95,
contextOpacity: 0.4,
shadows: true, shadows: true,
effects: true, effects: true,
quality: "standard", quality: "standard",
@@ -128,7 +129,7 @@ export default function ThreeDimensionalScene() {
const [runtimeState, setRuntimeState] = const [runtimeState, setRuntimeState] =
useState<SceneRuntimeState>(initialRuntimeState); useState<SceneRuntimeState>(initialRuntimeState);
const [selection, setSelection] = useState<SceneAssetSelection | null>(null); const [selection, setSelection] = useState<SceneAssetSelection | null>(null);
const [controlsOpen, setControlsOpen] = useState(false); const [controlsOpen, setControlsOpen] = useState(true);
const [controlTab, setControlTab] = useState<ControlTab>("scene"); const [controlTab, setControlTab] = useState<ControlTab>("scene");
const [timelineOpen, setTimelineOpen] = useState(true); const [timelineOpen, setTimelineOpen] = useState(true);
const [pressureDevices, setPressureDevices] = useState<PressureDevice[]>([]); const [pressureDevices, setPressureDevices] = useState<PressureDevice[]>([]);
@@ -137,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);
@@ -168,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);
}, []); }, []);
@@ -197,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") {
@@ -221,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;
@@ -300,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>
@@ -323,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">
@@ -386,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}
@@ -1,11 +1,18 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { act, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type {
DraggableCoreProps,
DraggableData,
DraggableEvent,
} from "react-draggable";
const mockDraggable = jest.fn(({ children }: { children: ReactNode }) => children); const mockDraggableCore = jest.fn(
({ children }: Partial<DraggableCoreProps> & { children: ReactNode }) => children,
);
jest.mock("react-draggable", () => ({ jest.mock("react-draggable", () => ({
__esModule: true, __esModule: true,
default: (props: { children: ReactNode }) => mockDraggable(props), DraggableCore: (props: { children: ReactNode }) => mockDraggableCore(props),
})); }));
import { ThreeDimensionalTimeline } from "./ThreeDimensionalTimeline"; import { ThreeDimensionalTimeline } from "./ThreeDimensionalTimeline";
@@ -31,7 +38,7 @@ describe("ThreeDimensionalTimeline", () => {
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);
}); });
it("allows the timeline to be dragged vertically away from the bottom", () => { it("tracks the absolute pointer displacement when drag events are skipped", () => {
render( render(
<ThreeDimensionalTimeline <ThreeDimensionalTimeline
selectedDate={new Date("2026-09-11T12:00:00+08:00")} selectedDate={new Date("2026-09-11T12:00:00+08:00")}
@@ -44,9 +51,50 @@ describe("ThreeDimensionalTimeline", () => {
/>, />,
); );
expect(mockDraggable).toHaveBeenLastCalledWith( const timeline = screen.getByRole("region", { name: "三维场景时间轴" });
expect.not.objectContaining({ bounds: expect.anything() }), const dragShield = timeline.previousElementSibling as HTMLElement;
const dragProps = mockDraggableCore.mock.lastCall?.[0];
expect(dragProps).toEqual(
expect.objectContaining({
handle: ".timeline-drag-handle",
cancel: "button, input, select, [role='dialog']",
}),
); );
if (!dragProps?.onStart || !dragProps.onDrag || !dragProps.onStop) {
throw new Error("DraggableCore handlers were not configured");
}
const { onStart, onDrag, onStop } = dragProps;
const dragEvent = {} as DraggableEvent;
const dragData = (
x: number,
y: number,
deltaX: number,
deltaY: number,
): DraggableData => ({
node: timeline,
x,
y,
deltaX,
deltaY,
lastX: x - deltaX,
lastY: y - deltaY,
});
act(() => {
onStart(dragEvent, dragData(100, 200, 0, 0));
onDrag(dragEvent, dragData(360, 80, 20, -10));
});
expect(dragShield.style.display).toBe("block");
expect(timeline).toHaveStyle({
transform: "translate3d(260px, -120px, 0)",
});
act(() => {
onStop(dragEvent, dragData(360, 80, 0, 0));
});
expect(dragShield.style.display).toBe("");
}); });
it("uses the themed calendar and returns the selected day", () => { it("uses the themed calendar and returns the selected day", () => {
@@ -9,7 +9,11 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import Draggable from "react-draggable"; import {
DraggableCore,
type DraggableData,
type DraggableEvent,
} from "react-draggable";
import { import {
FiCalendar, FiCalendar,
FiChevronDown, FiChevronDown,
@@ -78,7 +82,15 @@ export function ThreeDimensionalTimeline({
onSelectedDateChange, onSelectedDateChange,
onCurrentTimeChange, onCurrentTimeChange,
}: ThreeDimensionalTimelineProps) { }: ThreeDimensionalTimelineProps) {
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLElement>(null);
const dragShieldRef = useRef<HTMLDivElement>(null);
const dragPositionRef = useRef({ x: 0, y: 0 });
const dragStartRef = useRef<{
pointerX: number;
pointerY: number;
originX: number;
originY: number;
} | null>(null);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [playIntervalMs, setPlayIntervalMs] = useState(DEFAULT_PLAY_INTERVAL_MS); const [playIntervalMs, setPlayIntervalMs] = useState(DEFAULT_PLAY_INTERVAL_MS);
const [previewTime, setPreviewTime] = useState<number | null>(null); const [previewTime, setPreviewTime] = useState<number | null>(null);
@@ -133,6 +145,58 @@ export function ThreeDimensionalTimeline({
? Math.min(100, Math.max(0, (safeCurrentTime / durationMinutes) * 100)) ? Math.min(100, Math.max(0, (safeCurrentTime / durationMinutes) * 100))
: 0; : 0;
const applyDragPosition = useCallback((x: number, y: number) => {
dragPositionRef.current = { x, y };
if (timelineRef.current) {
timelineRef.current.style.transform = `translate3d(${x}px, ${y}px, 0)`;
}
}, []);
const handleDragStart = useCallback(
(_event: DraggableEvent, data: DraggableData) => {
const { x: originX, y: originY } = dragPositionRef.current;
dragStartRef.current = {
pointerX: data.x,
pointerY: data.y,
originX,
originY,
};
if (dragShieldRef.current) {
dragShieldRef.current.style.display = "block";
}
if (timelineRef.current) {
timelineRef.current.style.willChange = "transform";
}
},
[],
);
const handleDrag = useCallback(
(_event: DraggableEvent, data: DraggableData) => {
const start = dragStartRef.current;
if (!start) return;
applyDragPosition(
start.originX + data.x - start.pointerX,
start.originY + data.y - start.pointerY,
);
},
[applyDragPosition],
);
const handleDragStop = useCallback(
(event: DraggableEvent, data: DraggableData) => {
handleDrag(event, data);
dragStartRef.current = null;
if (dragShieldRef.current) {
dragShieldRef.current.style.display = "";
}
if (timelineRef.current) {
timelineRef.current.style.willChange = "";
}
},
[handleDrag],
);
return ( return (
<div <div
className={clsx( className={clsx(
@@ -140,27 +204,35 @@ export function ThreeDimensionalTimeline({
sidePanelOpen ? "md:right-[416px]" : "md:right-4", sidePanelOpen ? "md:right-[416px]" : "md:right-4",
)} )}
> >
<Draggable <div
ref={dragShieldRef}
aria-hidden="true"
className="pointer-events-auto fixed inset-0 z-0 hidden cursor-move"
/>
<DraggableCore
nodeRef={timelineRef} nodeRef={timelineRef}
handle=".timeline-drag-handle" handle=".timeline-drag-handle"
cancel="button, input, select, [role='dialog']" cancel="button, input, select, [role='dialog']"
onStart={handleDragStart}
onDrag={handleDrag}
onStop={handleDragStop}
> >
<section <section
ref={timelineRef} ref={timelineRef}
aria-label="三维场景时间轴" aria-label="三维场景时间轴"
className={clsx( className={clsx(
glassClass, glassClass,
"pointer-events-auto relative 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>
@@ -248,7 +320,7 @@ export function ThreeDimensionalTimeline({
</div> </div>
</div> </div>
</section> </section>
</Draggable> </DraggableCore>
</div> </div>
); );
} }
@@ -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'");
}); });
}); });
@@ -76,7 +76,7 @@ describe("buildSceneFrame", () => {
}); });
expect(frame.payload?.links["P-1"]).toEqual({ expect(frame.payload?.links["P-1"]).toEqual({
velocity: 0.82, velocity: 0.82,
flow: -18.4, flow: -66.24,
direction: -1, direction: -1,
status: "open", status: "open",
}); });
@@ -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"),
@@ -196,7 +232,12 @@ describe("buildSceneFrame", () => {
const devices = await fetchPressureDevices(new AbortController().signal); const devices = await fetchPressureDevices(new AbortController().signal);
expect(devices).toEqual([ expect(devices).toEqual([
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" }, {
device_id: "S-1",
device_type: "pressure",
node_id: "J-1",
measurement_unit: "m",
},
]); ]);
expect(mockApiFetch).toHaveBeenCalledWith( expect(mockApiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/scada-devices?limit=1000&offset=0"), expect.stringContaining("/api/v1/scada-devices?limit=1000&offset=0"),
@@ -228,6 +269,10 @@ describe("buildSceneFrame", () => {
}, },
], ],
}) })
.mockResolvedValueOnce({
ok: true,
json: async () => ({ FLOW_UNITS: "LPS", PRESSURE_UNITS: "METERS" }),
})
.mockResolvedValueOnce({ .mockResolvedValueOnce({
ok: false, ok: false,
status: 503, status: 503,
@@ -252,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 监测点。",
]);
});
}); });
+78 -14
View File
@@ -1,5 +1,11 @@
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { fetchNetworkResultUnits } from "@/hooks/useNetworkResultUnits";
import { config } from "@config/config"; import { config } from "@config/config";
import {
DEFAULT_NETWORK_RESULT_UNITS,
type NetworkResultUnits,
toDisplayValue,
} from "@/utils/units";
export const ZJB_PROJECT_CODE = "zjb"; export const ZJB_PROJECT_CODE = "zjb";
export const ZJB_SCENE_MODEL_ID = "zjb-water-network-v23"; export const ZJB_SCENE_MODEL_ID = "zjb-water-network-v23";
@@ -32,8 +38,8 @@ export type SceneResultsPayload = {
modelId: string; modelId: string;
units: { units: {
velocity: "m/s"; velocity: "m/s";
pressure: "mH2O"; pressure: "m";
flow: "L/s"; flow: "m³/h";
}; };
timestamp: string; timestamp: string;
nodes: Record<string, SceneNodeResult>; nodes: Record<string, SceneNodeResult>;
@@ -61,6 +67,7 @@ export type PressureDevice = {
device_id: string; device_id: string;
device_type: string; device_type: string;
node_id: string; node_id: string;
measurement_unit?: string;
}; };
type RawPressureDevice = Omit<PressureDevice, "node_id"> & { type RawPressureDevice = Omit<PressureDevice, "node_id"> & {
@@ -150,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"] => {
@@ -192,6 +221,7 @@ export const buildSceneFrame = ({
linkRows, linkRows,
pressureDevices, pressureDevices,
scadaRows, scadaRows,
simulationUnits = DEFAULT_NETWORK_RESULT_UNITS,
}: { }: {
queryTime: Date; queryTime: Date;
model: SceneModelIndex; model: SceneModelIndex;
@@ -199,9 +229,16 @@ export const buildSceneFrame = ({
linkRows: RealtimeLinkRow[]; linkRows: RealtimeLinkRow[];
pressureDevices: PressureDevice[]; pressureDevices: PressureDevice[];
scadaRows: ScadaReadingRow[]; scadaRows: ScadaReadingRow[];
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,
@@ -223,7 +260,14 @@ export const buildSceneFrame = ({
return; return;
} }
if (isFiniteNumber(row.pressure)) { if (isFiniteNumber(row.pressure)) {
nodes[id] = { pressure: row.pressure, source: "simulation" }; nodes[id] = {
pressure: toDisplayValue(
row.pressure,
"pressure",
simulationUnits.pressure,
)!,
source: "simulation",
};
} }
}); });
@@ -234,9 +278,15 @@ export const buildSceneFrame = ({
return; return;
} }
const result: SceneLinkResult = {}; const result: SceneLinkResult = {};
if (isFiniteNumber(row.velocity)) result.velocity = row.velocity; if (isFiniteNumber(row.velocity)) {
result.velocity = toDisplayValue(
row.velocity,
"velocity",
simulationUnits.velocity,
)!;
}
if (isFiniteNumber(row.flow)) { if (isFiniteNumber(row.flow)) {
result.flow = row.flow; result.flow = toDisplayValue(row.flow, "flow", simulationUnits.flow)!;
result.direction = Math.sign(row.flow) as -1 | 0 | 1; result.direction = Math.sign(row.flow) as -1 | 0 | 1;
} }
const status = normalizeLinkStatus(row.status); const status = normalizeLinkStatus(row.status);
@@ -260,7 +310,11 @@ export const buildSceneFrame = ({
if (!isFiniteNumber(value)) return; if (!isFiniteNumber(value)) return;
const simulationPressure = nodes[device.node_id]?.pressure; const simulationPressure = nodes[device.node_id]?.pressure;
nodes[device.node_id] = { nodes[device.node_id] = {
pressure: value, pressure: toDisplayValue(
value,
"pressure",
device.measurement_unit || "m",
)!,
source: "scada", source: "scada",
deviceId: device.device_id, deviceId: device.device_id,
...(simulationPressure === undefined ? {} : { simulationPressure }), ...(simulationPressure === undefined ? {} : { simulationPressure }),
@@ -274,7 +328,7 @@ export const buildSceneFrame = ({
resultTime, resultTime,
payload: { payload: {
modelId: model.modelId, modelId: model.modelId,
units: { velocity: "m/s", pressure: "mH2O", flow: "L/s" }, units: { velocity: "m/s", pressure: "m", flow: "m³/h" },
timestamp: resultTime, timestamp: resultTime,
nodes, nodes,
links, links,
@@ -287,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 监测点。"]
: [],
}; };
}; };
@@ -301,18 +358,23 @@ const readJson = async <T>(url: string, signal: AbortSignal): Promise<T> => {
}; };
export const fetchPressureDevices = async (signal: AbortSignal) => { export const fetchPressureDevices = async (signal: AbortSignal) => {
const page = await readJson<Page<RawPressureDevice>>( const response = await readJson<Page<RawPressureDevice> | RawPressureDevice[]>(
`${config.BACKEND_URL}/api/v1/scada-devices?limit=1000&offset=0`, `${config.BACKEND_URL}/api/v1/scada-devices?limit=1000&offset=0`,
signal, signal,
); );
return page.items const items = Array.isArray(response) ? response : response.items;
return items
.filter( .filter(
(device): device is PressureDevice => (device): device is PressureDevice =>
device.device_type?.trim().toLowerCase() === "pressure" && device.device_type?.trim().toLowerCase() === "pressure" &&
typeof device.node_id === "string" && typeof device.node_id === "string" &&
device.node_id.trim().length > 0, device.node_id.trim().length > 0,
) )
.map((device) => ({ ...device, node_id: device.node_id.trim() })); .map((device) => ({
...device,
node_id: device.node_id.trim(),
measurement_unit: device.measurement_unit?.trim() || "m",
}));
}; };
export const fetchSceneFrame = async ({ export const fetchSceneFrame = async ({
@@ -337,7 +399,7 @@ export const fetchSceneFrame = async ({
pressureDevices.map((device) => device.device_id).join(","), pressureDevices.map((device) => device.device_id).join(","),
); );
const [nodeRows, linkRows] = await Promise.all([ const [nodeRows, linkRows, networkOptions] = await Promise.all([
readJson<RealtimeNodeRow[]>( readJson<RealtimeNodeRow[]>(
`${config.BACKEND_URL}/api/v1/timeseries/realtime/nodes?${range}`, `${config.BACKEND_URL}/api/v1/timeseries/realtime/nodes?${range}`,
signal, signal,
@@ -346,6 +408,7 @@ export const fetchSceneFrame = async ({
`${config.BACKEND_URL}/api/v1/timeseries/realtime/links?${range}`, `${config.BACKEND_URL}/api/v1/timeseries/realtime/links?${range}`,
signal, signal,
), ),
fetchNetworkResultUnits(ZJB_PROJECT_CODE, signal),
]); ]);
let scadaRows: ScadaReadingRow[] = []; let scadaRows: ScadaReadingRow[] = [];
@@ -370,6 +433,7 @@ export const fetchSceneFrame = async ({
linkRows: Array.isArray(linkRows) ? linkRows : [], linkRows: Array.isArray(linkRows) ? linkRows : [],
pressureDevices, pressureDevices,
scadaRows, scadaRows,
simulationUnits: networkOptions,
}); });
return { ...frame, warnings }; return { ...frame, warnings: [...frame.warnings, ...warnings] };
}; };
@@ -38,6 +38,7 @@ export type SceneNetworkStyle = {
export type SceneAppearance = { export type SceneAppearance = {
preset: SceneLightingPreset; preset: SceneLightingPreset;
exposure: number; exposure: number;
contextOpacity: number;
shadows: boolean; shadows: boolean;
effects: boolean; effects: boolean;
quality: SceneQuality; quality: SceneQuality;
@@ -144,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" }
+284 -1286
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import config from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import {
DEFAULT_NETWORK_RESULT_UNITS,
type NetworkResultUnits,
networkResultUnitsFromOptions,
} from "@/utils/units";
export const fetchNetworkResultUnits = async (
networkName: string,
signal?: AbortSignal,
) => {
const query = new URLSearchParams({ network: networkName });
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/network-options?${query}`,
{ signal },
);
if (!response.ok) {
throw new Error(`模型单位请求失败: HTTP ${response.status}`);
}
return networkResultUnitsFromOptions(
(await response.json()) as Record<string, string>,
);
};
export const useNetworkResultUnits = (networkName?: string | null) => {
const [loaded, setLoaded] = useState<{
networkName: string;
units: NetworkResultUnits;
}>();
useEffect(() => {
if (!networkName) return;
const controller = new AbortController();
void fetchNetworkResultUnits(networkName, controller.signal)
.then((units) => setLoaded({ networkName, units }))
.catch((error) => {
if (controller.signal.aborted) return;
console.error("[units] 获取项目模型单位失败:", error);
setLoaded({ networkName, units: DEFAULT_NETWORK_RESULT_UNITS });
});
return () => controller.abort();
}, [networkName]);
return loaded && loaded.networkName === networkName
? loaded.units
: DEFAULT_NETWORK_RESULT_UNITS;
};
+35
View File
@@ -0,0 +1,35 @@
import { toTimeSeriesPoints, type ElementHistorySeries } from "./elementHistory";
describe("element history display conversion", () => {
it("converts every series through the shared unit utility", () => {
const series: ElementHistorySeries[] = [
{
element_id: "P-1",
element_type: "pipe",
device_id: null,
metric: "flow",
source: "realtime_simulation",
source_unit: "MLD",
display_unit: "m³/h",
unit_inferred: false,
points: [{ time: "2026-09-01T00:00:00Z", value: 2 }],
},
{
element_id: "J-1",
element_type: "junction",
device_id: "pressure-1",
metric: "pressure",
source: "scada_cleaned",
source_unit: "KPA",
display_unit: "m",
unit_inferred: false,
points: [{ time: "2026-09-01T00:00:00Z", value: 10 }],
},
];
const points = toTimeSeriesPoints(series);
expect(points[0].timestamp).toBe("2026-09-01T00:00:00Z");
expect(points[0].values["P-1_sim"]).toBeCloseTo(83.3333333334);
expect(points[0].values["pressure-1_clean"]).toBeCloseTo(1.019716213);
});
});
+126
View File
@@ -0,0 +1,126 @@
import config from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import { toDisplayValue } from "@/utils/units";
export type HistoryElementType = "pipe" | "junction";
export type HistoryMode =
| "observed"
| "realtime_comparison"
| "analysis_comparison";
export type HistoryMetric = "flow" | "pressure";
export type HistorySource =
| "scada_raw"
| "scada_cleaned"
| "realtime_simulation"
| "analysis_simulation";
export interface ElementHistoryTarget {
element_id: string;
element_type: HistoryElementType;
device_ids?: string[];
}
export interface ElementHistorySeries {
element_id: string;
element_type: HistoryElementType;
device_id: string | null;
metric: HistoryMetric;
source: HistorySource;
source_unit: string;
display_unit: string;
unit_inferred: boolean;
points: Array<{ time: string; value: number | null }>;
}
export interface TimeSeriesPoint {
timestamp: string;
values: Record<string, number | null | undefined>;
}
export interface ElementHistoryResult {
points: TimeSeriesPoint[];
series: ElementHistorySeries[];
}
const SOURCE_SUFFIX: Record<HistorySource, string> = {
scada_raw: "raw",
scada_cleaned: "clean",
realtime_simulation: "sim",
analysis_simulation: "scheme_sim",
};
export const historySeriesKey = (series: ElementHistorySeries) =>
`${series.device_id ?? series.element_id}_${SOURCE_SUFFIX[series.source]}`;
export const historySourceLabel = (source: HistorySource) => {
switch (source) {
case "scada_raw":
return "原始监测";
case "scada_cleaned":
return "清洗监测";
case "realtime_simulation":
return "实时模拟";
case "analysis_simulation":
return "方案模拟";
}
};
export const historySeriesLabel = (series: ElementHistorySeries) =>
`${series.device_id ?? series.element_id} (${historySourceLabel(series.source)})`;
export const toTimeSeriesPoints = (
seriesList: ElementHistorySeries[],
): TimeSeriesPoint[] => {
const timeMap = new Map<string, Record<string, number | null>>();
seriesList.forEach((series) => {
const key = historySeriesKey(series);
series.points.forEach((point) => {
const values = timeMap.get(point.time) ?? {};
values[key] = toDisplayValue(
point.value,
series.metric,
series.source_unit,
);
timeMap.set(point.time, values);
});
});
return Array.from(timeMap, ([timestamp, values]) => ({ timestamp, values })).sort(
(left, right) =>
new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime(),
);
};
export const fetchElementHistory = async (
elements: ElementHistoryTarget[],
range: { from: Date; to: Date },
mode: HistoryMode,
runId?: string,
signal?: AbortSignal,
): Promise<ElementHistoryResult> => {
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/timeseries/views/element-history/query`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
start_time: range.from.toISOString(),
end_time: range.to.toISOString(),
mode,
...(runId ? { run_id: runId } : {}),
elements,
}),
signal,
},
);
if (!response.ok) {
const problem = await response.json().catch(() => null);
const detail =
typeof problem?.detail === "string"
? problem.detail
: `HTTP ${response.status}`;
throw new Error(`历史数据请求失败: ${detail}`);
}
const payload = (await response.json()) as { series?: ElementHistorySeries[] };
const series = Array.isArray(payload.series) ? payload.series : [];
return { series, points: toTimeSeriesPoints(series) };
};
+41 -6
View File
@@ -1,4 +1,11 @@
import { FLOW_DISPLAY_UNIT, isLpsFlowProperty, toM3h } from "./units"; import {
FLOW_DISPLAY_UNIT,
metricForResultProperty,
networkResultUnitsFromOptions,
toDisplayValue,
toM3h,
toModelDisplayValue,
} from "./units";
describe("flow display units", () => { describe("flow display units", () => {
it("uses cubic meters per hour as the flow display unit", () => { it("uses cubic meters per hour as the flow display unit", () => {
@@ -10,10 +17,38 @@ describe("flow display units", () => {
expect(toM3h(10, "L/s")).toBe(36); expect(toM3h(10, "L/s")).toBe(36);
}); });
it("recognizes computed properties that arrive from the backend in L/s", () => { it("centralizes model and SCADA conversion for all UI consumers", () => {
expect(isLpsFlowProperty("flow")).toBe(true); expect(toDisplayValue(2, "flow", "MLD")).toBeCloseTo(83.3333333334);
expect(isLpsFlowProperty("actual_demand")).toBe(true); expect(toDisplayValue(10, "pressure", "KPA")).toBeCloseTo(1.019716213);
expect(isLpsFlowProperty("actualdemand")).toBe(true); expect(toDisplayValue(2, "velocity", "ft/s")).toBeCloseTo(0.6096);
expect(isLpsFlowProperty("pressure")).toBe(false); expect(toDisplayValue(36, "flow", "m3/h")).toBe(36);
});
it("maps result properties to their physical metrics", () => {
expect(metricForResultProperty("flow")).toBe("flow");
expect(metricForResultProperty("base_demand")).toBe("flow");
expect(metricForResultProperty("actual_demand")).toBe("flow");
expect(metricForResultProperty("pressure")).toBe("pressure");
expect(metricForResultProperty("velocity")).toBe("velocity");
expect(metricForResultProperty("headloss")).toBeNull();
});
it("resolves legacy and v3 model options in one place", () => {
expect(
networkResultUnitsFromOptions({
FLOW_UNITS: "MLD",
PRESSURE_UNITS: "METERS",
}),
).toEqual({ flow: "MLD", pressure: "METERS", velocity: "m/s" });
expect(networkResultUnitsFromOptions({ UNITS: "GPM", PRESSURE: "PSI" }))
.toEqual({ flow: "GPM", pressure: "PSI", velocity: "ft/s" });
});
it("converts model properties using project result units", () => {
const units = { flow: "MLD", pressure: "KPA", velocity: "ft/s" };
expect(toModelDisplayValue(2, "flow", units)).toBeCloseTo(83.3333333334);
expect(toModelDisplayValue(10, "pressure", units)).toBeCloseTo(1.019716213);
expect(toModelDisplayValue(2, "velocity", units)).toBeCloseTo(0.6096);
expect(toModelDisplayValue(3, "headloss", units)).toBe(3);
}); });
}); });
+124 -9
View File
@@ -1,17 +1,132 @@
export const FLOW_DISPLAY_UNIT = "m³/h"; export const FLOW_DISPLAY_UNIT = "m³/h";
const M3H_FACTOR = 3600; export const PRESSURE_DISPLAY_UNIT = "m";
const LPS_FLOW_PROPERTIES = new Set(["flow", "actual_demand", "actualdemand"]); export const VELOCITY_DISPLAY_UNIT = "m/s";
export type MeasurementMetric = "flow" | "pressure" | "velocity";
export interface NetworkResultUnits {
flow: string;
pressure: string;
velocity: string;
}
export const isLpsFlowProperty = (property: string) => export const DEFAULT_NETWORK_RESULT_UNITS: NetworkResultUnits = {
LPS_FLOW_PROPERTIES.has(property); flow: "LPS",
pressure: "MTR",
velocity: "m/s",
};
const M3H_FACTOR = 3600;
const FLOW_PROPERTIES = new Set([
"flow",
"demand",
"base_demand",
"actual_demand",
"actualdemand",
]);
const PRESSURE_PROPERTIES = new Set(["pressure"]);
const VELOCITY_PROPERTIES = new Set(["velocity"]);
const IMPERIAL_FLOW_UNITS = new Set(["CFS", "GPM", "MGD", "IMGD", "AFD"]);
export const metricForResultProperty = (
property: string,
): MeasurementMetric | null => {
const normalizedProperty = property.trim().toLowerCase();
if (FLOW_PROPERTIES.has(normalizedProperty)) return "flow";
if (PRESSURE_PROPERTIES.has(normalizedProperty)) return "pressure";
if (VELOCITY_PROPERTIES.has(normalizedProperty)) return "velocity";
return null;
};
export const networkResultUnitsFromOptions = (
options: Record<string, string>,
): NetworkResultUnits => {
const flow = options.FLOW_UNITS || options.UNITS || "LPS";
const pressure = options.PRESSURE_UNITS || options.PRESSURE || "MTR";
return {
flow,
pressure,
velocity: IMPERIAL_FLOW_UNITS.has(flow.trim().toUpperCase())
? "ft/s"
: "m/s",
};
};
export const toM3h = (value: number, sourceUnit: string = "m³/s") => { export const toM3h = (value: number, sourceUnit: string = "m³/s") => {
if (!Number.isFinite(value)) return Number.NaN; if (!Number.isFinite(value)) return Number.NaN;
const normalizedUnit = sourceUnit.trim().toLowerCase(); const normalizedUnit = sourceUnit.trim().toUpperCase().replace("³", "3");
if (normalizedUnit === "m³/h") return value; const factors: Record<string, number> = {
if (normalizedUnit === "lps" || normalizedUnit === "l/s") return value * 3.6; CFS: 101.9406477312,
if (normalizedUnit === "m³/s") return value * M3H_FACTOR; GPM: 0.22712470704,
return value * M3H_FACTOR; MGD: 157.725491,
IMGD: 189.4204167,
AFD: 51.39507656,
LPS: 3.6,
"L/S": 3.6,
LPM: 0.06,
MLD: 41.6666666667,
CMH: 1,
"M3/H": 1,
CMD: 1 / 24,
"M3/D": 1 / 24,
"M3/S": M3H_FACTOR,
};
const factor = factors[normalizedUnit];
if (factor === undefined) throw new Error(`不支持的流量单位: ${sourceUnit}`);
return value * factor;
};
export const toMeters = (value: number, sourceUnit: string = "m") => {
if (!Number.isFinite(value)) return Number.NaN;
const normalizedUnit = sourceUnit.trim().toUpperCase();
const factors: Record<string, number> = {
METERS: 1,
METRES: 1,
MTR: 1,
M: 1,
MH2O: 1,
"M H2O": 1,
KPA: 0.1019716213,
PSI: 0.7032496149,
};
const factor = factors[normalizedUnit];
if (factor === undefined) throw new Error(`不支持的压力单位: ${sourceUnit}`);
return value * factor;
};
export const toMetersPerSecond = (
value: number,
sourceUnit: string = "m/s",
) => {
if (!Number.isFinite(value)) return Number.NaN;
const normalizedUnit = sourceUnit.trim().toUpperCase();
if (["M/S", "MPS"].includes(normalizedUnit)) return value;
if (["FT/S", "FPS"].includes(normalizedUnit)) return value * 0.3048;
throw new Error(`不支持的流速单位: ${sourceUnit}`);
};
export const toDisplayValue = (
value: number | null | undefined,
metric: MeasurementMetric,
sourceUnit: string,
) => {
if (value === null || value === undefined) return null;
switch (metric) {
case "flow":
return toM3h(value, sourceUnit);
case "pressure":
return toMeters(value, sourceUnit);
case "velocity":
return toMetersPerSecond(value, sourceUnit);
}
};
export const toModelDisplayValue = (
value: number,
property: string,
units: NetworkResultUnits,
) => {
const metric = metricForResultProperty(property);
if (!metric) return value;
return toDisplayValue(value, metric, units[metric])!;
}; };
export const toM3s = (value: number, sourceUnit: string = "m³/h") => { export const toM3s = (value: number, sourceUnit: string = "m³/h") => {