Compare commits

...
Author SHA1 Message Date
jiang 0dc7187de2 feat(history): show event context with online simulation and SCADA
Generic Container CI/CD / test-build-publish (push) Successful in 1m2s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m2s
2026-11-03 12:40:39 +08:00
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
13 changed files with 830 additions and 158 deletions
+19
View File
@@ -0,0 +1,19 @@
# 三维场景静态资源下载与缓存
`public/three-dimensional/zjb/v29/resourceCache.mjs` 统一加载模型和管网静态元数据,不缓存实时仿真或 SCADA 数据。
- 每次请求最多下载 1 MiB,通过单区间 `Range` 请求获取资源。服务器返回 `206` 时,校验 `Content-Range` 的起止位置、总长度以及实际收到的字节数;不支持 Range 的服务器可在首次请求返回完整的 `200` 响应。
- 网络中断、连续 30 秒无数据以及临时 HTTP 错误可重试,每个分段最多尝试 3 次,等待间隔为 300、600 毫秒。永久 HTTP 错误和不合法的分段响应直接显示失败提示。
- 每个完整分段保存至 IndexedDB。刷新页面或重新载入后,从已保存的分段末尾继续;未完成的当前分段会重新下载。浏览器禁止存储或配额不足时,仍能在本次加载中下载和重试,但无法保证刷新后保留断点。
- 完整资源和部分下载均按 URL、manifest revision 匹配,版本改变后重新下载。完整资源经过解析验证后才进入正式缓存,后续打开场景直接复用。
- 进度界面显示下载、解析和断点重试状态。加载模块版本参数需要与 `preview.html`、`preview.mjs` 和 `ThreeDimensionalScene.tsx` 同步,防止浏览器沿用旧加载器。
反向代理应透传 `Range` 请求及 `206`、`Content-Range` 响应。若内网完整下载正常,而公网完整下载中断,应同时排查代理的大文件传输;客户端分段恢复不代表代理链路问题已修复。
聚焦回归:
```bash
E2E_BASE_URL=http://127.0.0.1:3000 npx playwright test e2e/three-dimensional-loading.spec.ts --workers=1
```
该命令需要已运行的前端。测试覆盖完整缓存复用、分段重试、跨刷新续传、错误分段拒绝及浏览器存储不可用的降级行为。
+144 -5
View File
@@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
const root = "/three-dimensional/zjb/v29/"; const root = "/three-dimensional/zjb/v29/";
const preview = root + "preview.html?host=platform2&v=35-resource-cache"; const preview = root + "preview.html?host=platform2&v=36-resumable-cache";
const isAsset = (url: string) => /\.glb(?:\?|$)|water-network-v28\.json/.test(url); const isAsset = (url: string) => /\.glb(?:\?|$)|water-network-v28\.json/.test(url);
test.setTimeout(120_000); test.setTimeout(120_000);
@@ -28,7 +28,7 @@ test("HTTP 200 的错误元数据不会进入缓存,服务器恢复后重试
expect(cached).toBe(false); expect(cached).toBe(false);
await page.getByRole("button", { name: "重新载入场景" }).click(); await page.getByRole("button", { name: "重新载入场景" }).click();
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 }); await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
expect(requests).toBe(2); expect(requests).toBe(5);
}); });
test("模型和元数据跨页面复用持久缓存,即使服务器资源不可用也能重开场景", async ({ page, context }) => { test("模型和元数据跨页面复用持久缓存,即使服务器资源不可用也能重开场景", async ({ page, context }) => {
@@ -46,7 +46,7 @@ test("模型和元数据跨页面复用持久缓存,即使服务器资源不
await page.goto(preview); await page.goto(preview);
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 }); await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
const coldMs = Date.now() - coldStart; const coldMs = Date.now() - coldStart;
expect(requests).toHaveLength(10); expect(new Set(requests).size).toBe(10);
requests.length = 0; requests.length = 0;
block = true; block = true;
await page.close(); await page.close();
@@ -140,7 +140,7 @@ test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复",
await page.route("**/cache-probe.json*", (route) => { await page.route("**/cache-probe.json*", (route) => {
requests++; requests++;
return requests === 1 return requests === 1
? route.fulfill({ status: 503, body: "unavailable" }) ? route.fulfill({ status: 404, body: "not found" })
: requests === 2 : requests === 2
? route.fulfill({ body: "invalid json" }) ? route.fulfill({ body: "invalid json" })
: route.fulfill({ json: { ok: true } }); : route.fulfill({ json: { ok: true } });
@@ -160,6 +160,145 @@ test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复",
}, root); }, root);
expect(requests).toBe(3); expect(requests).toBe(3);
expect(result.errors).toHaveLength(2); expect(result.errors).toHaveLength(2);
expect(result.errors[0]).toContain("HTTP 503"); expect(result.errors[0]).toContain("HTTP 404");
expect(result.recovered).toEqual({ ok: true }); 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);
});
@@ -32,12 +32,13 @@ export function reportProgress(update) {
const done = values.filter(item => item.stage === 'ready').length; const done = values.filter(item => item.stage === 'ready').length;
const loaded = values.reduce((sum, item) => sum + item.loaded, 0); const loaded = values.reduce((sum, item) => sum + item.loaded, 0);
const cached = values.filter(item => item.stage === 'cache').length; 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 parsing = values.some(item => item.stage === 'parse');
const downloading = values.some(item => item.stage === 'download'); const downloading = values.some(item => item.stage === 'download');
progress.max = Math.max(1, values.length); progress.max = Math.max(1, values.length);
progress.value = done; progress.value = done;
title.textContent = `正在载入场景资源 ${done} / ${values.length}`; title.textContent = `正在载入场景资源 ${done} / ${values.length}`;
detail.textContent = `${parsing ? '正在解析模型' : downloading ? '正在下载资源' : cached ? '正在读取本地缓存' : '正在准备场景'} · 已读取 ${formatBytes(loaded)}`; detail.textContent = `${retrying ? '连接中断,正在从断点重试' : parsing ? '正在解析模型' : downloading ? '正在下载资源' : cached ? '正在读取本地缓存' : '正在准备场景'} · 已读取 ${formatBytes(loaded)}`;
} }
export function finishLoading() { export function finishLoading() {
@@ -36,8 +36,8 @@
</div> </div>
<output id="qa" hidden></output> <output id="qa" hidden></output>
<script type="module"> <script type="module">
import {failLoading} from './loadingProgress.mjs?v=35-resource-cache'; import {failLoading} from './loadingProgress.mjs?v=36-resumable-cache';
import('./preview.mjs?v=35-resource-cache').catch(failLoading); import('./preview.mjs?v=36-resumable-cache').catch(failLoading);
</script> </script>
</body> </body>
</html> </html>
+3 -3
View File
@@ -1,7 +1,7 @@
import {createRenderEffects} from './render-effects.mjs'; import {createRenderEffects} from './render-effects.mjs';
import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=35-resource-cache'; import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=36-resumable-cache';
import {createSceneResourceCache} from './resourceCache.mjs?v=35-resource-cache'; import {createSceneResourceCache} from './resourceCache.mjs?v=36-resumable-cache';
import {beginLoading,reportProgress,finishLoading,failLoading} from './loadingProgress.mjs?v=35-resource-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';
@@ -2,6 +2,8 @@
const DATABASE = 'tjwater-scene-assets-v1'; const DATABASE = 'tjwater-scene-assets-v1';
const STORE = 'resources'; const STORE = 'resources';
const REQUEST_TIMEOUT_MS = 30_000; const REQUEST_TIMEOUT_MS = 30_000;
const CHUNK_BYTES = 1024 * 1024;
const MAX_ATTEMPTS = 3;
function openDatabase() { function openDatabase() {
return new Promise(resolve => { return new Promise(resolve => {
@@ -54,39 +56,111 @@ export function createSceneResourceCache(baseUrl, onProgress = () => {}) {
// Revisioned URLs also keep the browser's HTTP cache in sync with the manifest. // Revisioned URLs also keep the browser's HTTP cache in sync with the manifest.
if (revision) url.searchParams.set('v', revision); if (revision) url.searchParams.set('v', revision);
const controller = new AbortController(); const partialKey = key + '#partial';
let timer; let chunks = [], loaded = 0, total = 0;
const resetTimeout = () => { if (storage && revision) {
clearTimeout(timer); try {
timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); const partial = await transact(storage, 'readonly', store => store.get(partialKey));
}; if (partial?.revision === revision && Number.isSafeInteger(partial.total) && partial.total > 0 &&
report('download'); Array.isArray(partial.chunks) && partial.chunks.length &&
resetTimeout(); partial.chunks.every(chunk => chunk instanceof ArrayBuffer && chunk.byteLength > 0 && chunk.byteLength <= CHUNK_BYTES)) {
let data; const size = partial.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
try { if (size <= partial.total) { chunks = partial.chunks; loaded = size; total = partial.total; }
const response = await fetch(url, {signal:controller.signal, cache:'no-cache'});
if (!response.ok) throw Error('HTTP ' + response.status);
const total = Number(response.headers.get('content-length')) || 0;
if (response.body) {
const reader = response.body.getReader();
const chunks = [];
let loaded = 0;
while (true) {
const {done, value} = await reader.read();
if (done) break;
chunks.push(value); loaded += value.byteLength;
report('download', loaded, total); resetTimeout();
} }
const bytes = new Uint8Array(loaded); } 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; let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } for (const buffer of buffers) { bytes.set(buffer, offset); offset += buffer.byteLength; }
data = bytes.buffer; return {data:bytes.buffer, total:response.status === 206 ? partTotal : size, ranged:response.status === 206};
} else { } catch (error) {
data = await response.arrayBuffer(); 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) { } catch (error) {
throw Error(file + ':' + (controller.signal.aborted ? '下载超时,请重试' : error.message)); throw Error(file + ':' + (error.name === 'AbortError' ? '下载超时,请重试' : error.message));
} finally { clearTimeout(timer); } }
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); report('parse', data.byteLength, data.byteLength);
// Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache. // Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache.
@@ -1,21 +1,34 @@
import React from "react"; import React from "react";
import { render, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import dayjs from "dayjs";
import ReactECharts from "echarts-for-react";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { getAnalysisScheme, toAnalysisSchemeRecord } from "@/lib/analysisRuns";
import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel"; import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel";
jest.mock("@/lib/apiFetch", () => ({ jest.mock("@/lib/apiFetch", () => ({
apiFetch: jest.fn(), apiFetch: jest.fn(),
})); }));
jest.mock("@/lib/analysisRuns", () => ({
...jest.requireActual("@/lib/analysisRuns"),
getAnalysisScheme: jest.fn(),
}));
jest.mock("@components/olmap/core/MapComponent", () => ({ jest.mock("@components/olmap/core/MapComponent", () => ({
useData: () => null, useData: () => null,
})); }));
jest.mock("@mui/x-date-pickers", () => ({ jest.mock("@mui/x-date-pickers", () => ({
DateTimePicker: () => null, DateTimePicker: ({ label, value, onChange }: {
label: string; value: dayjs.Dayjs; onChange: (value: dayjs.Dayjs) => void;
}) => React.createElement("input", {
"aria-label": label,
value: value.toISOString(),
onChange: (event: React.ChangeEvent<HTMLInputElement>) => onChange(dayjs(event.target.value)),
}),
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children, LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
})); }));
jest.mock("echarts-for-react", () => () => null); jest.mock("echarts-for-react", () => jest.fn(() => null));
const jsonResponse = (payload: unknown, status = 200) => const jsonResponse = (payload: unknown, status = 200) =>
({ ({
@@ -66,14 +79,63 @@ describe("fetchHistoryData", () => {
"99dd4142-368b-54cb-bfca-d59ee48f6298", "99dd4142-368b-54cb-bfca-d59ee48f6298",
); );
expect(apiFetch).toHaveBeenCalledTimes(1); expect(apiFetch).toHaveBeenCalledTimes(2);
const [, init] = jest.mocked(apiFetch).mock.calls[0]; const bodies = jest.mocked(apiFetch).mock.calls.map(([, init]) => JSON.parse(String(init?.body)));
expect(JSON.parse(String(init?.body))).toMatchObject({ expect(bodies[0]).toMatchObject({ mode: "realtime_comparison" });
expect(bodies[0]).not.toHaveProperty("run_id");
expect(bodies[1]).toMatchObject({
mode: "analysis_comparison", mode: "analysis_comparison",
run_id: "99dd4142-368b-54cb-bfca-d59ee48f6298", run_id: "99dd4142-368b-54cb-bfca-d59ee48f6298",
}); });
}); });
it("combines element simulations without SCADA and deduplicates monitoring", async () => {
const base = {
element_id: "P-1", element_type: "pipe", device_id: null,
metric: "flow", source_unit: "L/s", display_unit: "m³/h", unit_inferred: false,
};
const observed = {
...base, device_id: "F-1", source: "scada_raw",
points: [{ time: "2026-08-17T09:30:00Z", value: 5 }],
};
const targets: [string, string][] = [["P-1", "pipe"], ["J-1", "junction"]];
jest.mocked(apiFetch).mockImplementation(async (_url, init) => {
const { mode } = JSON.parse(String(init?.body));
return jsonResponse({ series: mode === "analysis_comparison" ? [
observed,
{ ...base, source: "analysis_simulation", points: [
{ time: "2026-08-17T09:30:00Z", value: 20 },
] },
] : [
observed,
{ ...base, source: "realtime_simulation", points: [
{ time: "2026-08-17T09:00:00Z", value: 10 },
{ time: "2026-08-17T10:00:00Z", value: 12 },
] },
{ ...base, element_id: "J-1", element_type: "junction", metric: "pressure",
source_unit: "m", source: "realtime_simulation", points: [
{ time: "2026-08-17T09:00:00Z", value: 30 },
] },
] });
});
const result = await fetchHistoryData(targets, range, "scheme", "run-1");
expect(result.series).toHaveLength(4);
expect(result.points[0].values).toEqual({ '["pipe","P-1","realtime_simulation"]': 36, '["junction","J-1","realtime_simulation"]': 30 });
expect(result.points[1].values['["pipe","P-1","analysis_simulation"]']).toBe(72);
expect(result.points[2].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(43.2);
expect(apiFetch).toHaveBeenCalledTimes(2);
for (const [, init] of jest.mocked(apiFetch).mock.calls) {
expect(JSON.parse(String(init?.body)).elements).toHaveLength(2);
}
});
it("reports an aggregate request failure instead of showing an incomplete comparison", async () => {
jest.mocked(apiFetch).mockResolvedValueOnce(jsonResponse({ series: [] }))
.mockResolvedValueOnce(jsonResponse({ detail: "analysis unavailable" }, 503));
await expect(fetchHistoryData([["P-1", "pipe"]], range, "scheme", "run-1"))
.rejects.toThrow("analysis unavailable");
});
it("rejects oversized element selections before issuing requests", async () => { it("rejects oversized element selections before issuing requests", async () => {
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({})); jest.mocked(apiFetch).mockResolvedValue(jsonResponse({}));
const featureInfos = Array.from( const featureInfos = Array.from(
@@ -103,10 +165,18 @@ describe("fetchHistoryData", () => {
}); });
}); });
const eventRun = (runId = "run-1", startedAt = "2026-08-17T09:00:00Z") =>
toAnalysisSchemeRecord({
run_id: runId, name: "event", run_type: "burst_analysis", created_by: "alice",
created_at: "2026-11-03T00:00:00Z", started_at: startedAt,
status: "completed", parameters: { modify_total_duration: 3600 },
});
describe("HistoryDataPanel requests", () => { describe("HistoryDataPanel requests", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] })); jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
jest.mocked(getAnalysisScheme).mockReset().mockResolvedValue(eventRun());
}); });
afterEach(() => jest.clearAllMocks()); afterEach(() => jest.clearAllMocks());
@@ -137,4 +207,138 @@ describe("HistoryDataPanel requests", () => {
); );
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
}); });
const props = {
featureInfos: [["P-1", "pipe"]] as [string, string][],
type: "scheme" as const, runId: "run-1", onClose: jest.fn(),
};
it("initializes a historical event with one hour of context on both sides", async () => {
render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
for (const [, init] of jest.mocked(apiFetch).mock.calls) {
expect(JSON.parse(String(init?.body))).toMatchObject({
start_time: "2026-08-17T08:00:00.000Z", end_time: "2026-08-17T11:00:00.000Z",
});
}
expect(screen.getByText(/事件方案时段:2026-08-17 17:00/)).toBeInTheDocument();
expect(screen.getByLabelText("开始时间")).toHaveValue("2026-08-17T08:00:00.000Z");
});
it("keeps explicit query dates and issues one pair of requests per date change", async () => {
render(React.createElement(HistoryDataPanel, {
...props, start_time: "2026-08-17T07:00:00Z", end_time: "2026-08-17T12:00:00Z",
}));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
fireEvent.change(screen.getByLabelText("开始时间"), { target: { value: "2026-08-17T06:00:00Z" } });
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(4));
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[2][1]?.body))).toMatchObject({
start_time: "2026-08-17T06:00:00.000Z", end_time: "2026-08-17T12:00:00.000Z",
});
expect(jest.mocked(apiFetch).mock.calls[0][1]?.signal?.aborted).toBe(true);
expect(getAnalysisScheme).toHaveBeenCalledTimes(1);
});
it("ignores a stale scheme response after switching events", async () => {
let resolveOld!: (value: ReturnType<typeof eventRun>) => void;
jest.mocked(getAnalysisScheme).mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve; }))
.mockResolvedValueOnce(eventRun("run-2", "2026-08-18T09:00:00Z"));
const { rerender } = render(React.createElement(HistoryDataPanel, props));
expect(apiFetch).not.toHaveBeenCalled();
rerender(React.createElement(HistoryDataPanel, { ...props, runId: "run-2" }));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
await act(async () => { resolveOld(eventRun()); });
expect(apiFetch).toHaveBeenCalledTimes(2);
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[1][1]?.body))).toMatchObject({
run_id: "run-2", start_time: "2026-08-18T08:00:00.000Z",
});
});
it("preserves the manually chosen end date when only the start date prop changes", async () => {
const partialProps = { ...props, type: "none" as const, start_time: "2026-08-17T07:00:00Z" };
const { rerender } = render(React.createElement(HistoryDataPanel, partialProps));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByLabelText("结束时间"), { target: { value: "2026-08-17T12:00:00Z" } });
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
rerender(React.createElement(HistoryDataPanel, { ...partialProps, start_time: "2026-08-17T06:00:00Z" }));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(3));
expect(screen.getByLabelText("结束时间")).toHaveValue("2026-08-17T12:00:00.000Z");
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[2][1]?.body))).toMatchObject({
start_time: "2026-08-17T06:00:00.000Z", end_time: "2026-08-17T12:00:00.000Z",
});
});
it("queries online element readings for an analysis without hydraulic results", async () => {
jest.mocked(getAnalysisScheme).mockResolvedValue({ ...eventRun(), run_type: "burst_detection" });
render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
const body = JSON.parse(String(jest.mocked(apiFetch).mock.calls[0][1]?.body));
expect(body.mode).toBe("realtime_comparison");
expect(body).not.toHaveProperty("run_id");
});
it("does not restore stale curves after the selected elements are cleared", async () => {
const pending: Array<(value: Response) => void> = [];
jest.mocked(apiFetch).mockImplementation(() => new Promise((resolve) => { pending.push(resolve); }));
const { rerender } = render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
rerender(React.createElement(HistoryDataPanel, { ...props, featureInfos: [] }));
expect(jest.mocked(apiFetch).mock.calls[0][1]?.signal?.aborted).toBe(true);
expect(jest.mocked(apiFetch).mock.calls[1][1]?.signal?.aborted).toBe(true);
await act(async () => {
pending[0](jsonResponse({ series: [{
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
source: "realtime_simulation", source_unit: "m3/h", display_unit: "m³/h",
unit_inferred: false, points: [{ time: "2026-08-17T09:00:00Z", value: 10 }],
}] }));
pending[1](jsonResponse({ series: [] }));
});
expect(ReactECharts).not.toHaveBeenCalled();
expect(screen.getByText("尚未选择地图要素")).toBeInTheDocument();
});
it("allows a failed event metadata query to be retried", async () => {
jest.mocked(getAnalysisScheme).mockRejectedValueOnce(new Error("unavailable"));
render(React.createElement(HistoryDataPanel, props));
await screen.findByText(/无法读取事件方案/);
expect(apiFetch).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "刷新" }));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
expect(getAnalysisScheme).toHaveBeenCalledTimes(2);
});
it("plots native timestamps and explicit gaps with the event interval marked", async () => {
const series = {
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false,
};
jest.mocked(apiFetch).mockImplementation(async (_url, init) => {
const { mode } = JSON.parse(String(init?.body));
return jsonResponse({ series: [{ ...series,
source: mode === "analysis_comparison" ? "analysis_simulation" : "realtime_simulation",
points: mode === "analysis_comparison" ? [
{ time: "2026-08-17T09:15:00Z", value: 8 },
] : [
{ time: "2026-08-17T08:00:00Z", value: 10 },
{ time: "2026-08-17T09:00:00Z", value: null },
{ time: "2026-08-17T11:00:00Z", value: 12 },
],
}] });
});
render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(ReactECharts).toHaveBeenCalled());
const option = jest.mocked(ReactECharts).mock.calls.at(-1)![0].option as any;
expect(option.xAxis).toMatchObject({ type: "time", min: Date.parse("2026-08-17T08:00:00Z"), max: Date.parse("2026-08-17T11:00:00Z") });
expect(option.series[0].connectNulls).toBe(false);
expect(option.series[0].data).toEqual([
[Date.parse("2026-08-17T08:00:00Z"), 10],
[Date.parse("2026-08-17T09:00:00Z"), null],
[Date.parse("2026-08-17T11:00:00Z"), 12],
]);
expect(option.series[0].markArea.data[0]).toEqual([
{ name: "事件方案时段", xAxis: Date.parse("2026-08-17T09:00:00Z") },
{ xAxis: Date.parse("2026-08-17T10:00:00Z") },
]);
expect(option.series[1].data).toEqual([[Date.parse("2026-08-17T09:15:00Z"), 8]]);
});
}); });
@@ -41,8 +41,14 @@ import {
fetchElementHistory, fetchElementHistory,
historySeriesKey, historySeriesKey,
historySeriesLabel, historySeriesLabel,
toTimeSeriesPoints,
TimeSeriesPoint, TimeSeriesPoint,
} from "@/lib/elementHistory"; } from "@/lib/elementHistory";
import {
type AnalysisHistoryContext,
getAnalysisHistoryContext,
getAnalysisScheme,
} from "@/lib/analysisRuns";
import { import {
FLOW_DISPLAY_UNIT, FLOW_DISPLAY_UNIT,
PRESSURE_DISPLAY_UNIT, PRESSURE_DISPLAY_UNIT,
@@ -54,7 +60,7 @@ dayjs.extend(timezone);
export interface SCADADataPanelProps { export interface SCADADataPanelProps {
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */ /** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
featureInfos: [string, string][]; featureInfos: [string, string][];
/** 数据类型: realtime-查询模拟值和监测值, none-仅查询监测值, scheme-查询策略模拟值和监测值 */ /** 数据类型: realtime-在线模拟与监测, none-仅监测, scheme-方案、在线模拟与监测 */
type?: "realtime" | "scheme" | "none"; type?: "realtime" | "scheme" | "none";
/** 默认展示的选项卡 */ /** 默认展示的选项卡 */
defaultTab?: "chart" | "table"; defaultTab?: "chart" | "table";
@@ -107,6 +113,13 @@ export const fetchHistoryData = async (
) { ) {
throw new Error("历史数据包含无效的管网元素 ID"); throw new Error("历史数据包含无效的管网元素 ID");
} }
if (
featureInfos.some(
([, elementType]) => !["pipe", "junction"].includes(elementType.toLowerCase()),
)
) {
throw new Error("历史数据仅支持管道和节点");
}
const uniqueFeatureInfos = Array.from( const uniqueFeatureInfos = Array.from(
new Map( new Map(
@@ -118,25 +131,33 @@ export const fetchHistoryData = async (
throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据"); throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据");
} }
try { const elements = uniqueFeatureInfos.map(([id, elementType]) => ({
return await fetchElementHistory( element_id: id.trim(),
uniqueFeatureInfos.map(([id, elementType]) => ({ element_type: elementType.toLowerCase() as "pipe" | "junction",
element_id: id.trim(), }));
element_type: elementType.toLowerCase() as "pipe" | "junction", if (type === "scheme") {
})), const [online, analysis] = await Promise.all([
range, fetchElementHistory(
type === "none" elements, range, "realtime_comparison", undefined, signal,
? "observed" ),
: type === "scheme" fetchElementHistory(
? "analysis_comparison" elements, range, "analysis_comparison", schemeRunId, signal,
: "realtime_comparison", ),
schemeRunId, ]);
signal, // 两次聚合查询均返回监测序列,只保留在线查询中的一份。
); const series = [
} catch (error) { ...online.series,
console.error("[HistoryDataPanel] 从后端获取数据失败:", error); ...analysis.series.filter((item) => item.source === "analysis_simulation"),
throw error; ];
return { series, points: toTimeSeriesPoints(series) };
} }
return fetchElementHistory(
elements,
range,
type === "none" ? "observed" : "realtime_comparison",
undefined,
signal,
);
}; };
const formatTimestamp = (timestamp: string) => const formatTimestamp = (timestamp: string) =>
@@ -186,11 +207,11 @@ const emptyStateMessages: Record<
> = { > = {
chart: { chart: {
title: "暂无时序数据", title: "暂无时序数据",
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。", subtitle: "请调整时间范围,或确认所选元素在该时段已有数据。",
}, },
table: { table: {
title: "暂无表格数据", title: "暂无表格数据",
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。", subtitle: "请调整时间范围,或确认所选元素在该时段已有数据。",
}, },
}; };
@@ -204,30 +225,65 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
runId, runId,
onClose, onClose,
}) => { }) => {
// 从 featureInfos 中提取设备 ID 列表 const [schemeState, setSchemeState] = useState<{
const deviceIds = useMemo( runId: string;
() => featureInfos.map(([id]) => id), context?: AnalysisHistoryContext;
[featureInfos] error?: string;
); } | null>(null);
const [schemeReload, setSchemeReload] = useState(0);
const schemeReady = type !== "scheme" || !runId || schemeState?.runId === runId;
const schemeContext =
type === "scheme" && schemeState?.runId === runId
? schemeState?.context
: undefined;
const schemeError =
type === "scheme" && schemeState?.runId === runId
? schemeState?.error
: undefined;
const eventRange = schemeContext?.eventRange;
const rangeKey = JSON.stringify([type, runId]);
const defaultRange = useMemo(() => {
const now = dayjs();
const start = start_time ? dayjs(start_time) : null;
const end = end_time ? dayjs(end_time) : null;
return {
from: start?.isValid()
? start
: eventRange
? dayjs(eventRange.start).subtract(1, "hour")
: now.subtract(1, "day"),
to: end?.isValid()
? end
: eventRange
? dayjs(eventRange.end).add(1, "hour")
: now,
};
}, [start_time, end_time, eventRange]);
const [selectedRange, setSelectedRange] = useState<{
key: string;
from: Dayjs;
to: Dayjs;
startTime?: string;
endTime?: string;
} | null>(null);
const rangeForContext = selectedRange?.key === rangeKey ? selectedRange : null;
const from = rangeForContext && (
rangeForContext.startTime === start_time || !start_time || !dayjs(start_time).isValid()
) ? rangeForContext.from : defaultRange.from;
const to = rangeForContext && (
rangeForContext.endTime === end_time || !end_time || !dayjs(end_time).isValid()
) ? rangeForContext.to : defaultRange.to;
const setFrom = (value: Dayjs) =>
setSelectedRange({ key: rangeKey, from: value, to, startTime: start_time, endTime: end_time });
const setTo = (value: Dayjs) =>
setSelectedRange({ key: rangeKey, from, to: value, startTime: start_time, endTime: end_time });
const [from, setFrom] = useState<Dayjs>(() => { useEffect(() => {
if (start_time) { if (!rangeForContext || (
const parsedStart = dayjs(start_time); rangeForContext.startTime === start_time && rangeForContext.endTime === end_time
if (parsedStart.isValid()) { )) return;
return parsedStart; setSelectedRange({ key: rangeKey, from, to, startTime: start_time, endTime: end_time });
} }, [rangeForContext, rangeKey, from, to, start_time, end_time]);
}
return dayjs().subtract(1, "day");
});
const [to, setTo] = useState<Dayjs>(() => {
if (end_time) {
const parsedEnd = dayjs(end_time);
if (parsedEnd.isValid()) {
return parsedEnd;
}
}
return dayjs();
});
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 [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
@@ -241,20 +297,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}, [defaultTab]); }, [defaultTab]);
useEffect(() => { useEffect(() => {
if (!start_time && !end_time) return; if (type !== "scheme" || !runId) return;
if (start_time) { let cancelled = false;
const parsedStart = dayjs(start_time); getAnalysisScheme(runId).then(
if (parsedStart.isValid()) { (run) => {
setFrom((prev) => (parsedStart.isSame(prev) ? prev : parsedStart)); if (!cancelled) {
} setSchemeState({ runId, context: getAnalysisHistoryContext(run) });
} }
if (end_time) { },
const parsedEnd = dayjs(end_time); () => {
if (parsedEnd.isValid()) { if (!cancelled) {
setTo((prev) => (parsedEnd.isSame(prev) ? prev : parsedEnd)); setSchemeState({ runId, error: "无法读取事件方案,请刷新重试" });
} }
} },
}, [start_time, end_time]); );
return () => {
cancelled = true;
};
}, [type, runId, schemeReload]);
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]); const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
@@ -272,9 +332,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
); );
const handleFetch = useCallback( const handleFetch = useCallback(
async (reason: string) => { async () => {
if (!hasDevices) { if (!hasDevices || !schemeReady || schemeError) {
requestControllerRef.current?.abort(); requestControllerRef.current?.abort();
requestControllerRef.current = null;
setTimeSeries([]); setTimeSeries([]);
setHistorySeries([]); setHistorySeries([]);
setLoadingState("idle"); setLoadingState("idle");
@@ -295,26 +356,37 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
from: rangeFrom.toDate(), from: rangeFrom.toDate(),
to: rangeTo.toDate(), to: rangeTo.toDate(),
}, },
type, type === "scheme" && schemeContext && !schemeContext.simulationRunId
runId, ? "realtime"
: type,
schemeContext?.simulationRunId,
requestController.signal, requestController.signal,
); );
if (requestControllerRef.current !== requestController) return; if (
requestController.signal.aborted ||
requestControllerRef.current !== requestController
) return;
setTimeSeries(result.points); setTimeSeries(result.points);
setHistorySeries(result.series); setHistorySeries(result.series);
setLoadingState("success"); setLoadingState("success");
} catch (err) { } catch (err) {
if ( if (
requestControllerRef.current !== requestController || requestControllerRef.current !== requestController ||
requestController.signal.aborted ||
(err instanceof Error && err.name === "AbortError") (err instanceof Error && err.name === "AbortError")
) { ) {
return; return;
} }
setTimeSeries([]);
setHistorySeries([]);
setError(err instanceof Error ? err.message : "未知错误"); setError(err instanceof Error ? err.message : "未知错误");
setLoadingState("error"); setLoadingState("error");
} }
}, },
[requestedFeatureInfos, hasDevices, normalizedRange, runId, type], [
requestedFeatureInfos, hasDevices, normalizedRange,
schemeReady, schemeError, schemeContext, type,
],
); );
useEffect( useEffect(
@@ -324,15 +396,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
[], [],
); );
// 设备变化时自动查询 // 元素、方案或查询范围变化时自动查询。
useEffect(() => { useEffect(() => {
if (hasDevices) { void handleFetch();
handleFetch("device-change"); }, [handleFetch]);
} else {
setTimeSeries([]);
setHistorySeries([]);
}
}, [handleFetch, hasDevices]);
const columns: GridColDef[] = useMemo(() => { const columns: GridColDef[] = useMemo(() => {
const base: GridColDef[] = [ const base: GridColDef[] = [
@@ -424,7 +491,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
"#3f51b5", // 靛蓝色 "#3f51b5", // 靛蓝色
]; ];
const xData = dataset.map((item) => item.label);
const hasFlowSeries = historySeries.some((item) => item.metric === "flow"); const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
const hasPressureSeries = historySeries.some( const hasPressureSeries = historySeries.some(
(item) => item.metric === "pressure", (item) => item.metric === "pressure",
@@ -453,11 +519,17 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
symbolSize: isObserved ? 7 : 0, symbolSize: isObserved ? 7 : 0,
showSymbol: isObserved, showSymbol: isObserved,
sampling: "lttb", sampling: "lttb",
connectNulls: !isObserved, connectNulls: false,
itemStyle: { itemStyle: {
color: colors[index % colors.length], color: colors[index % colors.length],
}, },
data: dataset.map((item) => item[key]), data: timeSeries
.filter((point) =>
Object.prototype.hasOwnProperty.call(point.values, key),
)
.map((point) => [
new Date(point.timestamp).getTime(), point.values[key] ?? null,
]),
lineStyle: isObserved ? { width: 0 } : undefined, lineStyle: isObserved ? { width: 0 } : undefined,
areaStyle: isObserved areaStyle: isObserved
? undefined ? undefined
@@ -507,9 +579,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}, },
}, },
xAxis: { xAxis: {
type: "category", type: "time",
boundaryGap: false, boundaryGap: false,
data: xData, min: normalizedRange.from.valueOf(),
max: normalizedRange.to.valueOf(),
axisLabel: {
formatter: (value: number) => formatTimestamp(new Date(value).toISOString()),
},
}, },
yAxis: [ yAxis: [
...(hasFlowSeries ...(hasFlowSeries
@@ -542,7 +618,21 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
end: 100, end: 100,
}, },
], ],
series: getSeries(), series: getSeries().map((series, index) => ({
...series,
...(index === 0 && eventRange
? {
markArea: {
silent: true,
itemStyle: { color: "rgba(255, 152, 0, 0.10)" },
data: [[
{ name: "事件方案时段", xAxis: Date.parse(eventRange.start) },
{ xAxis: Date.parse(eventRange.end) },
]],
},
}
: {}),
})),
}; };
return ( return (
@@ -716,16 +806,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setFrom(value); setFrom(value);
} }
}} }}
onAccept={(value) => {
if (
value &&
dayjs.isDayjs(value) &&
value.isValid() &&
hasDevices
) {
handleFetch("date-change");
}
}}
maxDateTime={to} maxDateTime={to}
slotProps={{ slotProps={{
textField: { fullWidth: true, size: "small" }, textField: { fullWidth: true, size: "small" },
@@ -739,16 +819,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setTo(value); setTo(value);
} }
}} }}
onAccept={(value) => {
if (
value &&
dayjs.isDayjs(value) &&
value.isValid() &&
hasDevices
) {
handleFetch("date-change");
}
}}
minDateTime={from} minDateTime={from}
slotProps={{ slotProps={{
textField: { fullWidth: true, size: "small" }, textField: { fullWidth: true, size: "small" },
@@ -787,8 +857,17 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
size="small" size="small"
color="primary" color="primary"
startIcon={<Refresh fontSize="small" />} startIcon={<Refresh fontSize="small" />}
disabled={!hasDevices || loadingState === "loading"} disabled={
onClick={() => handleFetch("manual")} !hasDevices || !schemeReady || loadingState === "loading"
}
onClick={() => {
if (schemeError) {
setSchemeState(null);
setSchemeReload((value) => value + 1);
} else {
void handleFetch();
}
}}
> >
刷新 刷新
</Button> </Button>
@@ -799,13 +878,18 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
</Stack> </Stack>
</LocalizationProvider> </LocalizationProvider>
{error && ( {eventRange && (
<Typography variant="caption" sx={{ mt: 1, display: "block" }}>
事件方案时段:{formatTimestamp(eventRange.start)} ~ {formatTimestamp(eventRange.end)}
</Typography>
)}
{(schemeError || error) && (
<Typography <Typography
variant="caption" variant="caption"
color="error" color="error"
sx={{ mt: 1, display: "block" }} sx={{ mt: 1, display: "block" }}
> >
获取数据失败:{error} 获取数据失败:{schemeError || error}
</Typography> </Typography>
)} )}
</Box> </Box>
@@ -814,7 +898,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
{/* Content */} {/* Content */}
<Box sx={{ flex: 1, position: "relative", p: 2, overflow: "auto" }}> <Box sx={{ flex: 1, position: "relative", p: 2, overflow: "auto" }}>
{loadingState === "loading" && ( {(!schemeReady || loadingState === "loading") && (
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",
@@ -38,7 +38,7 @@ import {
type SceneRuntimeState, type SceneRuntimeState,
} from "./sceneProtocol"; } from "./sceneProtocol";
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=35-resource-cache"; const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=36-resumable-cache";
const SCENE_LOAD_TIMEOUT_MS = 120_000; const SCENE_LOAD_TIMEOUT_MS = 120_000;
const initialRuntimeState: SceneRuntimeState = { const initialRuntimeState: SceneRuntimeState = {
+47
View File
@@ -2,6 +2,7 @@ import { api } from "@/lib/api";
import { import {
getAnalysisResults, getAnalysisResults,
getAnalysisScheme, getAnalysisScheme,
getAnalysisHistoryContext,
listAnalysisSchemes, listAnalysisSchemes,
} from "./analysisRuns"; } from "./analysisRuns";
@@ -94,3 +95,49 @@ describe("analysis runs API adapter", () => {
); );
}); });
}); });
describe("analysis history context", () => {
const run = {
run_id: "run-1", name: "event", run_type: "burst_analysis",
created_by: "alice", created_at: "2026-11-03T05:00:00Z",
started_at: "2026-08-25T09:00:00+08:00", status: "completed", parameters: {},
};
it.each([
["burst_analysis", { modify_total_duration: 3600 }],
["flushing_analysis", { duration: 3600 }],
["contaminant_analysis", { duration: 3600 }],
])("uses the simulated interval of %s rather than its creation time", (run_type, parameters) => {
expect(getAnalysisHistoryContext({ ...run, run_type, parameters })).toEqual({
simulationRunId: "run-1",
eventRange: { start: "2026-08-25T01:00:00.000Z", end: "2026-08-25T02:00:00.000Z" },
});
});
it("does not treat a detection run as a hydraulic simulation", () => {
expect(getAnalysisHistoryContext({
...run, run_type: "burst_detection", parameters: {
scada_window: { start: "2026-08-25T01:00:00Z", end: "2026-08-25T02:00:00Z" },
},
})).toEqual({
eventRange: { start: "2026-08-25T01:00:00Z", end: "2026-08-25T02:00:00Z" },
});
});
it("uses the linked hydraulic run for a localization analysis", () => {
expect(getAnalysisHistoryContext({
...run, run_type: "burst_location", parameters: {
result_payload: {
simulation_run: { run_id: "simulation-1" },
scada_window: { burst_start: "2026-08-25T01:00:00Z", burst_end: "2026-08-25T02:00:00Z" },
},
},
}).simulationRunId).toBe("simulation-1");
});
it("rejects invalid or missing event durations without inventing an interval", () => {
expect(getAnalysisHistoryContext(run).eventRange).toBeNull();
expect(getAnalysisHistoryContext({ ...run, parameters: { duration: -1 } }).eventRange).toBeNull();
expect(getAnalysisHistoryContext({ ...run, started_at: "invalid", parameters: { duration: 3600 } }).eventRange).toBeNull();
});
});
+60
View File
@@ -11,6 +11,66 @@ export type AnalysisRun = {
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
}; };
export type AnalysisHistoryContext = {
simulationRunId?: string;
eventRange: { start: string; end: string } | null;
};
const SIMULATION_RUN_TYPES = new Set([
"burst_analysis",
"flushing_analysis",
"contaminant_analysis",
"valve_close_analysis",
"pressure_regulation",
]);
export const getAnalysisHistoryContext = (
run: AnalysisRun,
): AnalysisHistoryContext => {
const parameters = normalizeParameters(run.parameters);
if (SIMULATION_RUN_TYPES.has(run.run_type)) {
const start = Date.parse(run.started_at);
const duration = parameters.modify_total_duration ?? parameters.duration;
const end = new Date(
typeof duration === "number" ? start + duration * 1000 : NaN,
).getTime();
return {
simulationRunId: run.run_id,
eventRange:
Number.isFinite(start) && Number.isFinite(end) && end > start
? {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
}
: null,
};
}
const payload = normalizeParameters(parameters.result_payload);
const window = normalizeParameters(
parameters.scada_window ?? payload.scada_window,
);
const start = window.burst_start ?? window.start ?? parameters.scada_start;
const end = window.burst_end ?? window.end ?? parameters.scada_end;
const simulation = normalizeParameters(
payload.simulation_run ?? payload.simulation_scheme,
);
const simulationRunId = parameters.simulation_run_id ?? simulation.run_id;
return {
simulationRunId:
typeof simulationRunId === "string" && simulationRunId.trim()
? simulationRunId.trim()
: undefined,
eventRange:
typeof start === "string" &&
typeof end === "string" &&
Number.isFinite(Date.parse(start)) &&
Date.parse(end) > Date.parse(start)
? { start, end }
: null,
};
};
export type AnalysisResult = { export type AnalysisResult = {
result_id: string; result_id: string;
run_id: string; run_id: string;
+40 -2
View File
@@ -28,8 +28,46 @@ describe("element history display conversion", () => {
]; ];
const points = toTimeSeriesPoints(series); const points = toTimeSeriesPoints(series);
expect(points[0].timestamp).toBe("2026-09-01T00:00:00Z"); expect(points[0].timestamp).toBe("2026-09-01T00:00:00.000Z");
expect(points[0].values["P-1_sim"]).toBeCloseTo(83.3333333334); expect(points[0].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(83.3333333334);
expect(points[0].values["pressure-1_clean"]).toBeCloseTo(1.019716213); expect(points[0].values["pressure-1_clean"]).toBeCloseTo(1.019716213);
}); });
it("aligns equivalent timestamps and keeps same-name pipes and junctions separate", () => {
const base: ElementHistorySeries = {
element_id: "shared-id", element_type: "pipe", device_id: null,
metric: "flow", source: "realtime_simulation", source_unit: "L/s",
display_unit: "m³/h", unit_inferred: false,
points: [{ time: "2026-09-01T08:00:00+08:00", value: 10 }],
};
const points = toTimeSeriesPoints([
base,
{ ...base, element_type: "junction", metric: "pressure", source_unit: "m",
points: [{ time: "2026-09-01T00:00:00Z", value: 20 }] },
{ ...base, source: "analysis_simulation", points: [
{ time: "2026-09-01T00:00:00.000Z", value: null },
] },
]);
expect(points).toHaveLength(1);
expect(points[0].values).toEqual({
'["pipe","shared-id","realtime_simulation"]': 36,
'["junction","shared-id","realtime_simulation"]': 20,
'["pipe","shared-id","analysis_simulation"]': null,
});
});
it("keeps online IDs ending in _scheme distinct from another element's scheme series", () => {
const base: ElementHistorySeries = {
element_id: "P_scheme", element_type: "pipe", device_id: null,
metric: "flow", source: "realtime_simulation", source_unit: "m3/h",
display_unit: "m³/h", unit_inferred: false,
points: [{ time: "2026-09-01T00:00:00Z", value: 100 }],
};
const points = toTimeSeriesPoints([
base,
{ ...base, element_id: "P", source: "analysis_simulation",
points: [{ time: "2026-09-01T00:00:00Z", value: 5 }] },
]);
expect(Object.values(points[0].values).sort((a, b) => Number(a) - Number(b))).toEqual([5, 100]);
});
}); });
+12 -6
View File
@@ -49,8 +49,10 @@ const SOURCE_SUFFIX: Record<HistorySource, string> = {
analysis_simulation: "scheme_sim", analysis_simulation: "scheme_sim",
}; };
export const historySeriesKey = (series: ElementHistorySeries) => export const historySeriesKey = (series: ElementHistorySeries) => {
`${series.device_id ?? series.element_id}_${SOURCE_SUFFIX[series.source]}`; if (series.device_id) return `${series.device_id}_${SOURCE_SUFFIX[series.source]}`;
return JSON.stringify([series.element_type, series.element_id, series.source]);
};
export const historySourceLabel = (source: HistorySource) => { export const historySourceLabel = (source: HistorySource) => {
switch (source) { switch (source) {
@@ -65,8 +67,11 @@ export const historySourceLabel = (source: HistorySource) => {
} }
}; };
export const historySeriesLabel = (series: ElementHistorySeries) => export const historySeriesLabel = (series: ElementHistorySeries) => {
`${series.device_id ?? series.element_id} (${historySourceLabel(series.source)})`; const target = series.device_id ??
`${series.element_type === "pipe" ? "管道" : "节点"} ${series.element_id}`;
return `${target} (${historySourceLabel(series.source)})`;
};
export const toTimeSeriesPoints = ( export const toTimeSeriesPoints = (
seriesList: ElementHistorySeries[], seriesList: ElementHistorySeries[],
@@ -75,13 +80,14 @@ export const toTimeSeriesPoints = (
seriesList.forEach((series) => { seriesList.forEach((series) => {
const key = historySeriesKey(series); const key = historySeriesKey(series);
series.points.forEach((point) => { series.points.forEach((point) => {
const values = timeMap.get(point.time) ?? {}; const timestamp = new Date(point.time).toISOString();
const values = timeMap.get(timestamp) ?? {};
values[key] = toDisplayValue( values[key] = toDisplayValue(
point.value, point.value,
series.metric, series.metric,
series.source_unit, series.source_unit,
); );
timeMap.set(point.time, values); timeMap.set(timestamp, values);
}); });
}); });
return Array.from(timeMap, ([timestamp, values]) => ({ timestamp, values })).sort( return Array.from(timeMap, ([timestamp, values]) => ({ timestamp, values })).sort(