Compare commits

..
Author SHA1 Message Date
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
20 changed files with 518 additions and 45 deletions
+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);
});
@@ -17006,6 +17006,16 @@
"map_roads",
"map_pump_shell"
],
"networkContext": [
"detail_roof",
"detail_facade",
"detail_interior",
"detail_ceiling",
"map_platforms",
"map_railway",
"map_roads",
"map_pump_shell"
],
"detailBase": [
"map_roof",
"map_facade",
@@ -40,6 +40,8 @@ export function attachNetworkStyle(group,model){
model.pipeInstances.forEach((p,i)=>{if(!partsById.has(p.inpId))partsById.set(p.inpId,[]);partsById.get(p.inpId).push(i);});
const owned=[];for(const mesh of [pipes,nodes,bends].filter(Boolean)){const material=new THREE.MeshPhysicalMaterial({color:0xffffff,roughness:.3,metalness:.22,clearcoat:.22,clearcoatRoughness:.38});mesh.material=material;mesh.castShadow=true;mesh.receiveShadow=true;owned.push(material);}
const geo=new THREE.ConeGeometry(1,2,12),mat=new THREE.MeshBasicMaterial({color:style.arrowColor}),arrows=new THREE.InstancedMesh(geo,mat,model.links.length);arrows.name='WaterFlowArrows';arrows.userData.instances=model.links.map(l=>({assetId:'inp:link:'+l.id,inpId:l.id,kind:l.kind}));group.add(arrows);
const markerGeo=new THREE.OctahedronGeometry(.72,0),markerMat=new THREE.MeshBasicMaterial({color:0x1d83e6,transparent:true,opacity:.96,depthTest:false,depthWrite:false}),scadaMarkers=new THREE.InstancedMesh(markerGeo,markerMat,model.nodeInstances.length);scadaMarkers.name='ScadaSensorMarkers';scadaMarkers.userData.instances=model.nodeInstances.map(n=>({assetId:'inp:node:'+n.inpId,inpId:n.inpId,kind:'SCADA'}));scadaMarkers.renderOrder=6;group.add(scadaMarkers);
const ringGeo=new THREE.TorusGeometry(1.15,.09,8,28);ringGeo.rotateX(Math.PI/2);const ringMat=new THREE.MeshBasicMaterial({color:0x1d83e6,transparent:true,opacity:.76,depthTest:false,depthWrite:false}),scadaRings=new THREE.InstancedMesh(ringGeo,ringMat,model.nodeInstances.length);scadaRings.name='ScadaSensorRings';scadaRings.userData.instances=scadaMarkers.userData.instances;scadaRings.renderOrder=5;group.add(scadaRings);
const matrix=new THREE.Matrix4(),dummy=new THREE.Object3D(),pos=new THREE.Vector3(),quat=new THREE.Quaternion(),scale=new THREE.Vector3();let summary={};
function apply(){
const layout=(displayMode==='coordinated'?model.coordinatedLayouts:model.layouts)?.[style.scale];if(!layout)throw Error('当前模型支持整数倍率 1–12。');
@@ -57,16 +59,25 @@ export function attachNetworkStyle(group,model){
group.traverse(o=>{if(o.userData.cadAttachment){const r=model.cadAttachments.find(r=>r.assetId===o.userData.assetId);o.traverse(mesh=>{if(!mesh.isMesh)return;if(mesh.userData.cadRadialScale)mesh.scale.set(style.scale,1,style.scale);if(mesh.userData.cadTerminalScale)mesh.scale.setScalar(style.scale);mesh.material.color.copy(color(style.mode==='pressure'?results?.nodes?.[r.hostNodeId]?.pressure:null));mesh.material.opacity=style.opacity;mesh.material.transparent=style.opacity<1;mesh.material.depthWrite=style.opacity===1;mesh.material.roughness=style.roughness;mesh.material.metalness=style.metalness;});}});
if(focusScope){const zero=new THREE.Matrix4().makeScale(0,0,0);for(const [mesh,recs,isNode] of [[pipes,model.pipeInstances,false],[nodes,model.nodeInstances,true],[bends,model.bendInstances,false]]){if(!mesh)continue;recs.forEach((r,i)=>{if(!(isNode?scopeNodes:scopeIds).has(r.inpId))mesh.setMatrixAt(i,zero);else if(isNode&&displayMode==='coordinated'){mesh.getMatrixAt(i,matrix);matrix.decompose(pos,quat,scale);const radius=Math.max(.075,...model.links.filter(l=>scopeIds.has(l.id)&&[l.from,l.to].includes(r.inpId)).map(l=>l.diameterMm/2000));matrix.compose(pos,quat,new THREE.Vector3(radius,radius,radius));mesh.setMatrixAt(i,matrix);}});mesh.instanceMatrix.needsUpdate=true;mesh.computeBoundingBox();mesh.computeBoundingSphere();}}
group.traverse(o=>{if(o.userData.cadAttachment)o.visible=!focusScope;if(o.userData.layoutMode&&focusScope)o.visible=false;});
nodes.visible=style.nodes;let arrowCount=0;
nodes.visible=style.nodes;let scadaCount=0;
model.nodeInstances.forEach((record,index)=>{
const visible=results?.nodes?.[record.inpId]?.source==='scada'&&(!focusScope||scopeNodes.has(record.inpId));
if(!visible){dummy.position.set(0,0,0);dummy.scale.setScalar(0);dummy.quaternion.identity();dummy.updateMatrix();scadaMarkers.setMatrixAt(index,dummy.matrix);scadaRings.setMatrixAt(index,dummy.matrix);return;}
nodes.getMatrixAt(index,matrix);matrix.decompose(pos,quat,scale);
dummy.position.copy(pos);dummy.position.y+=1.18;dummy.quaternion.identity();dummy.scale.setScalar(.72);dummy.updateMatrix();scadaMarkers.setMatrixAt(index,dummy.matrix);
dummy.position.copy(pos);dummy.position.y+=.2;dummy.quaternion.identity();dummy.scale.setScalar(.72);dummy.updateMatrix();scadaRings.setMatrixAt(index,dummy.matrix);scadaCount++;
});
for(const marker of [scadaMarkers,scadaRings]){marker.instanceMatrix.needsUpdate=true;marker.visible=scadaCount>0;marker.computeBoundingBox();marker.computeBoundingSphere();}
let arrowCount=0;
model.links.forEach((l,i)=>{
const ids=partsById.get(l.id)??[],idx=ids[Math.floor(ids.length/2)];let direction=style.direction==='topology'?1:style.direction==='results'?metricValue('direction',l,results):0;
if(!direction||idx===undefined||(focusScope&&!scopeIds.has(l.id))){dummy.position.set(0,0,0);dummy.scale.setScalar(0);dummy.quaternion.identity();}
else{pipes.getMatrixAt(idx,matrix);matrix.decompose(pos,quat,scale);dummy.position.copy(pos);dummy.quaternion.copy(quat);if(direction<0)dummy.quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0),Math.PI));const r=Math.max(.22,scale.x*1.7);dummy.scale.set(r,r*1.5,r);arrowCount++;}
dummy.updateMatrix();arrows.setMatrixAt(i,dummy.matrix);
});arrows.instanceMatrix.needsUpdate=true;arrows.visible=style.direction!=='none';arrows.material.color.set(style.arrowColor);arrows.computeBoundingBox();arrows.computeBoundingSphere();
summary={focusScope,displayMode,mode:style.mode,min,max,dataLinks:vals.length,totalLinks:model.links.length,arrows:arrowCount,directionMode:style.direction,hasResults:!!results,resultTime:results?.timestamp??null,scale:style.scale};return summary;
summary={focusScope,displayMode,mode:style.mode,min,max,dataLinks:vals.length,totalLinks:model.links.length,scadaMarkers:scadaCount,arrows:arrowCount,directionMode:style.direction,hasResults:!!results,resultTime:results?.timestamp??null,scale:style.scale};return summary;
}
const api={setFocusScope(value){focusScope=!!value;return apply();},get displayMode(){return displayMode;},setDisplayMode(value){if(!['global','coordinated'].includes(value)||value==='coordinated'&&!model.coordinatedLayouts)throw Error('不支持的展示模式');displayMode=value;return apply();},getResult(id,kind='links'){return results?.[kind]?.[id]?structuredClone(results[kind][id]):null;},get style(){return {...style};},get summary(){return {...summary};},setStyle(patch){const next={...style,...patch};if(!Number.isInteger(next.scale)||next.scale<1||next.scale>12)throw Error('倍率需为 1–12。');if(!['uniform','velocity','pressure','direction'].includes(next.mode)||!['none','topology','results'].includes(next.direction))throw Error('未知样式。');for(const k of ['color','missingColor','lowColor','highColor','arrowColor'])if(!/^#[0-9a-f]{6}$/i.test(next[k]))throw Error('颜色需为六位十六进制。');for(const k of ['opacity','roughness','metalness'])if(!finite(next[k])||next[k]<0||next[k]>1)throw Error('样式数值须在 0–1。');if(!finite(next.min)||!finite(next.max)||next.min>=next.max)throw Error('色带上限必须大于下限。');Object.assign(style,next);return apply();},setResults(input){const parsed=validateResults(input,model);results=parsed;return apply();},clearResults(){results=null;return apply();},dispose(){geo.dispose();mat.dispose();owned.forEach(m=>m.dispose());},pipes,nodes,arrows};
const api={setFocusScope(value){focusScope=!!value;return apply();},get displayMode(){return displayMode;},setDisplayMode(value){if(!['global','coordinated'].includes(value)||value==='coordinated'&&!model.coordinatedLayouts)throw Error('不支持的展示模式');displayMode=value;return apply();},getResult(id,kind='links'){return results?.[kind]?.[id]?structuredClone(results[kind][id]):null;},get style(){return {...style};},get summary(){return {...summary};},setStyle(patch){const next={...style,...patch};if(!Number.isInteger(next.scale)||next.scale<1||next.scale>12)throw Error('倍率需为 1–12。');if(!['uniform','velocity','pressure','direction'].includes(next.mode)||!['none','topology','results'].includes(next.direction))throw Error('未知样式。');for(const k of ['color','missingColor','lowColor','highColor','arrowColor'])if(!/^#[0-9a-f]{6}$/i.test(next[k]))throw Error('颜色需为六位十六进制。');for(const k of ['opacity','roughness','metalness'])if(!finite(next[k])||next[k]<0||next[k]>1)throw Error('样式数值须在 0–1。');if(!finite(next.min)||!finite(next.max)||next.min>=next.max)throw Error('色带上限必须大于下限。');Object.assign(style,next);return apply();},setResults(input){const parsed=validateResults(input,model);results=parsed;return apply();},clearResults(){results=null;return apply();},dispose(){geo.dispose();mat.dispose();markerGeo.dispose();markerMat.dispose();ringGeo.dispose();ringMat.dispose();owned.forEach(m=>m.dispose());},pipes,nodes,arrows,scadaMarkers,scadaRings};
apply();return api;
}
@@ -20,6 +20,6 @@
<body>
<div id="boot" role="status"><span><i aria-hidden="true"></i>正在载入三维模型</span></div>
<output id="qa" hidden></output>
<script type="module" src="./preview.mjs?v=32-context-opacity"></script>
<script type="module" src="./preview.mjs?v=34-detailed-network-context"></script>
</body>
</html>
+2 -2
View File
@@ -5,7 +5,7 @@ import * as THREE from 'three';
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
import {MeshoptDecoder} from './vendor/meshopt_decoder.module.js';
import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=32-default-scale';
import {attachNetworkStyle,DEFAULT_STYLE} from './network-style.mjs?v=33-scada-markers';
import {cadToGltf} from './integration.mjs';
import {createAppearance,enhanceMaterials,grain,applyBuildingContext} from './appearance.mjs?v=30-context-opacity';
@@ -96,7 +96,7 @@ async function load(id){
}
async function show(next,{frame=true}={}){
if(!['network','hydraulic','map','detail','pump','meters'].includes(next))throw Error('不支持的场景模式:'+next);
const token=++generation;mode=next;let ids=manifest.mapDefault;
const token=++generation;mode=next;let ids=next==='network'?manifest.networkContext:manifest.mapDefault;
if(next==='detail')ids=[...manifest.mapDefault.filter(id=>!['map_roof','map_facade'].includes(id)),'detail_roof','detail_facade','detail_interior','detail_ceiling'];
if(next==='pump')ids=['detail_pump_walls','detail_pump_access','detail_pump_auxiliary','detail_pump_piping_reference',...manifest.assets.filter(asset=>asset.id.startsWith('physical_')).map(asset=>asset.id)];
if(next==='meters')ids=['meter_dn150','valve_dn250','pump_symbol'];
@@ -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 {
Box,
Button,
Collapse,
IconButton,
LinearProgress,
@@ -89,6 +90,11 @@ const ActionStatusIcon = ({ status }: { status: AgentActivityAction["status"] })
const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }) => {
const elapsed = getElapsedMs(action, now);
const [targetExpanded, setTargetExpanded] = useState(false);
const targetId = React.useId();
const target = action.target ?? "";
const targetPreview = target.split(/\r?\n/u).slice(0, 3).join("\n").slice(0, 240);
const hasLongTarget = targetPreview.length < target.length;
return (
<Stack
direction="row"
@@ -111,19 +117,45 @@ const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }
</Typography>
</Stack>
{action.target ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
mt: 0.2,
fontFamily: "monospace",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}}
>
{action.target}
</Typography>
<Box sx={{ mt: 0.2 }}>
<Typography
id={targetId}
variant="caption"
color="text.secondary"
sx={{
display: !targetExpanded && hasLongTarget ? "-webkit-box" : "block",
fontFamily: "monospace",
overflow: !targetExpanded && hasLongTarget ? "hidden" : "visible",
overflowWrap: "anywhere",
whiteSpace: "pre-wrap",
...(!targetExpanded && hasLongTarget
? { WebkitBoxOrient: "vertical", WebkitLineClamp: 3 }
: {}),
}}
>
{targetExpanded ? target : targetPreview}
</Typography>
{hasLongTarget ? (
<Button
size="small"
aria-controls={targetId}
aria-expanded={targetExpanded}
onClick={() => setTargetExpanded((current) => !current)}
sx={{
mt: 0.2,
p: 0,
minWidth: 0,
color: activityAccent,
fontSize: "0.75rem",
fontWeight: 650,
lineHeight: 1.5,
textTransform: "none",
}}
>
{targetExpanded ? "收起内容" : "展开完整内容"}
</Button>
) : null}
</Box>
) : null}
{action.error ? (
<Typography
+1 -1
View File
@@ -79,7 +79,7 @@ const approvalModeOptions: ReadonlyArray<{
{
value: "always",
label: "始终允许",
description: "除明确禁止项外自动放行",
description: "常规权限自动放行,敏感路径仍需确认",
icon: WarningAmberRounded,
},
];
@@ -0,0 +1,47 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { PermissionRequestGroup } from "./AgentPermissionRequests";
jest.mock("framer-motion", () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
motion: {
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
},
}));
describe("PermissionRequestGroup", () => {
it("shows an external path approval request and forwards the user's decision", () => {
const onReply = jest.fn();
render(
<ThemeProvider theme={createTheme()}>
<PermissionRequestGroup
permissions={[
{
requestId: "external-1",
sessionId: "session-1",
permission: "external_directory",
patterns: ["/app/private/*"],
target: "/app/private/report.json",
always: ["/app/private/*"],
createdAt: 1,
status: "pending",
},
]}
isRunning
onReply={onReply}
/>
</ThemeProvider>,
);
expect(screen.getByText("访问工作区外目录")).toBeInTheDocument();
expect(screen.getByText("/app/private/report.json")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "允许一次" }));
expect(onReply).toHaveBeenCalledWith("external-1", "once");
fireEvent.click(screen.getByRole("button", { name: "保存授权" }));
expect(onReply).toHaveBeenCalledWith("external-1", "always");
});
});
@@ -1,4 +1,6 @@
import { parseApplyLayerStylePayload } from "./toolCallStyleHelpers";
import { createDefaultLayerStyleState } from "@components/olmap/core/Controls/styleEditorPresets";
import { validateStyleConfig } from "@components/olmap/core/Controls/styleEditorUtils";
describe("parseApplyLayerStylePayload", () => {
it("accepts a valid snake_case interval contract", () => {
@@ -47,4 +49,17 @@ describe("parseApplyLayerStylePayload", () => {
}),
).toMatchObject({ layerId: "junctions", styleConfig: { opacity: 0.5 } });
});
it("preserves default palette indexes when only some style fields are provided", () => {
const payload = parseApplyLayerStylePayload({
layer_id: "pipes",
style_config: { property: "flow", color_type: "gradient" },
});
expect(payload).not.toBeNull();
const merged = {
...createDefaultLayerStyleState("pipes").styleConfig,
...payload?.styleConfig,
};
expect(validateStyleConfig(merged)).toEqual({ valid: true, errors: [] });
});
});
+9 -4
View File
@@ -198,10 +198,15 @@ export const parseApplyLayerStylePayload = (
) return null;
}
const definedStyleConfig = styleConfig
? (Object.fromEntries(
Object.entries(styleConfig).filter(([, value]) => value !== undefined),
) as Partial<StyleConfig>)
: undefined;
const hasStyleOverrides =
styleConfig &&
Object.values(styleConfig).some((value) =>
Array.isArray(value) ? value.length > 0 : value !== undefined,
definedStyleConfig &&
Object.values(definedStyleConfig).some((value) =>
Array.isArray(value) ? value.length > 0 : true,
);
if (!resetToDefault && !hasStyleOverrides) {
@@ -211,7 +216,7 @@ export const parseApplyLayerStylePayload = (
return {
layerId,
resetToDefault,
styleConfig: hasStyleOverrides ? styleConfig : undefined,
styleConfig: hasStyleOverrides ? definedStyleConfig : undefined,
};
};
@@ -0,0 +1,50 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { apiFetch } from "@/lib/apiFetch";
import SCADADataPanel from "./SCADADataPanel";
jest.mock("@/lib/apiFetch", () => ({ apiFetch: jest.fn() }));
jest.mock("@refinedev/core", () => ({
useGetIdentity: () => ({ data: { id: "test-user" } }),
useNotification: () => ({ open: jest.fn() }),
}));
jest.mock("@mui/x-date-pickers", () => ({
DateTimePicker: () => null,
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
}));
jest.mock("echarts-for-react", () => () => null);
const response = (payload: unknown) =>
({ ok: true, json: async () => payload }) as Response;
describe("SCADADataPanel requests", () => {
beforeEach(() => {
jest.mocked(apiFetch).mockImplementation(async (url) =>
response(
String(url).includes("/scada-devices")
? [
{ device_id: "S-1", device_type: "pressure", node_id: "J-1", link_id: null },
{ device_id: "S-2", device_type: "pressure", node_id: "J-2", link_id: null },
]
: { series: [] },
),
);
});
afterEach(() => jest.clearAllMocks());
it("does not request history again when the same device IDs arrive in a new array", async () => {
const { rerender } = render(<SCADADataPanel deviceIds={["S-1"]} />);
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
rerender(<SCADADataPanel deviceIds={["S-1"]} />);
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
rerender(<SCADADataPanel deviceIds={["S-1", "S-2"]} />);
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(4));
expect(jest.mocked(apiFetch).mock.calls[3][1]?.body).toContain('"device_ids":["S-2"]');
fireEvent.click(screen.getByRole("button", { name: "刷新" }));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(6));
});
});
+11 -5
View File
@@ -307,9 +307,15 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
const hasDevices = deviceIds.length > 0;
// Parent map updates can pass a new array with identical IDs. Keep the
// request dependency tied to the IDs themselves, not the array identity.
const deviceIdsKey = JSON.stringify(deviceIds);
const requestedDeviceIds = useMemo(
() => JSON.parse(deviceIdsKey) as string[],
[deviceIdsKey],
);
const hasDevices = requestedDeviceIds.length > 0;
const hasData = timeSeries.length > 0;
const deviceIdsKey = useMemo(() => deviceIds.join(","), [deviceIds]);
const dataset = useMemo(
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
@@ -348,7 +354,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setError(null);
try {
const { from: rangeFrom, to: rangeTo } = normalizedRange;
const result = await customFetcher(deviceIds, {
const result = await customFetcher(requestedDeviceIds, {
from: rangeFrom.toDate(),
to: rangeTo.toDate(),
});
@@ -360,7 +366,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setLoadingState("error");
}
},
[deviceIds, customFetcher, hasDevices, normalizedRange],
[requestedDeviceIds, customFetcher, hasDevices, normalizedRange],
);
// 处理数据清洗
@@ -447,7 +453,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setTimeSeries([]);
setHistorySeries([]);
}
}, [deviceIdsKey, handleFetch, hasDevices]);
}, [handleFetch, hasDevices]);
// 当设备数量变化时,调整数据源选择
useEffect(() => {
@@ -1,5 +1,7 @@
import React from "react";
import { render, waitFor } from "@testing-library/react";
import { apiFetch } from "@/lib/apiFetch";
import { fetchHistoryData } from "./HistoryDataPanel";
import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel";
jest.mock("@/lib/apiFetch", () => ({
apiFetch: jest.fn(),
@@ -9,6 +11,12 @@ jest.mock("@components/olmap/core/MapComponent", () => ({
useData: () => null,
}));
jest.mock("@mui/x-date-pickers", () => ({
DateTimePicker: () => null,
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
}));
jest.mock("echarts-for-react", () => () => null);
const jsonResponse = (payload: unknown, status = 200) =>
({
ok: status >= 200 && status < 300,
@@ -94,3 +102,39 @@ describe("fetchHistoryData", () => {
expect(apiFetch).toHaveBeenCalledTimes(1);
});
});
describe("HistoryDataPanel requests", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
});
afterEach(() => jest.clearAllMocks());
it("does not reload unchanged elements when its parent rerenders", async () => {
const props = { type: "none" as const, onClose: jest.fn() };
const { rerender } = render(
React.createElement(HistoryDataPanel, {
...props,
featureInfos: [["J-1", "junction"]],
}),
);
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
rerender(
React.createElement(HistoryDataPanel, {
...props,
featureInfos: [["J-1", "junction"]],
}),
);
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
rerender(
React.createElement(HistoryDataPanel, {
...props,
featureInfos: [["J-2", "junction"]],
}),
);
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
});
});
@@ -258,12 +258,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
const hasDevices = deviceIds.length > 0;
const hasData = timeSeries.length > 0;
const featureInfosKey = useMemo(
() => JSON.stringify(featureInfos),
[featureInfos]
const featureInfosKey = JSON.stringify(featureInfos);
const requestedFeatureInfos = useMemo(
() => JSON.parse(featureInfosKey) as [string, string][],
[featureInfosKey],
);
const hasDevices = requestedFeatureInfos.length > 0;
const hasData = timeSeries.length > 0;
const dataset = useMemo(
() => buildDataset(timeSeries, historySeries, fractionDigits),
@@ -289,7 +290,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
try {
const { from: rangeFrom, to: rangeTo } = normalizedRange;
const result = await fetchHistoryData(
featureInfos,
requestedFeatureInfos,
{
from: rangeFrom.toDate(),
to: rangeTo.toDate(),
@@ -313,7 +314,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setLoadingState("error");
}
},
[featureInfos, hasDevices, normalizedRange, runId, type],
[requestedFeatureInfos, hasDevices, normalizedRange, runId, type],
);
useEffect(
@@ -331,7 +332,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setTimeSeries([]);
setHistorySeries([]);
}
}, [featureInfosKey, handleFetch, hasDevices]);
}, [handleFetch, hasDevices]);
const columns: GridColDef[] = useMemo(() => {
const base: GridColDef[] = [
@@ -38,7 +38,7 @@ import {
type SceneRuntimeState,
} from "./sceneProtocol";
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=32-context-opacity";
const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=34-detailed-network-context";
const SCENE_LOAD_TIMEOUT_MS = 30_000;
const initialRuntimeState: SceneRuntimeState = {
@@ -225,14 +225,14 @@ export function ThreeDimensionalTimeline({
"pointer-events-auto relative z-10 w-full max-w-[950px] rounded-2xl opacity-95 transition-opacity duration-200 hover:opacity-100",
)}
>
<div className="timeline-drag-handle relative flex h-7 cursor-move touch-none items-center justify-center rounded-t-2xl border-b border-white/40 bg-white/10">
<span aria-hidden="true" className="h-1 w-10 rounded-full bg-slate-400/60 transition-colors hover:bg-slate-500/70" />
<div className="timeline-drag-handle relative flex h-9 cursor-move touch-none items-center justify-center rounded-t-2xl border-b border-white/35 bg-white/[0.08]">
<span aria-hidden="true" className="h-1 w-10 rounded-full bg-slate-400/55 transition-colors hover:bg-slate-500/70" />
<button
type="button"
aria-label="收起时间轴"
title="收起时间轴"
onClick={onClose}
className="absolute right-1 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-lg text-slate-500 transition-[transform,color,background-color] hover:bg-white/60 hover:text-slate-800 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
className="absolute right-2 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center rounded-full bg-white/20 text-base text-slate-600 ring-1 ring-white/55 [box-shadow:inset_0_1px_0_rgba(255,255,255,0.72),0_4px_12px_rgba(15,43,69,0.08)] after:absolute after:-inset-1 after:content-[''] transition-[transform,color,background-color,box-shadow] [@media(hover:hover)]:hover:bg-white/50 [@media(hover:hover)]:hover:text-slate-900 [@media(hover:hover)]:hover:[box-shadow:inset_0_1px_0_rgba(255,255,255,0.88),0_6px_16px_rgba(15,43,69,0.14)] active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/45"
>
<FiX />
</button>
@@ -20,6 +20,7 @@ describe("ZJB three-dimensional runtime assets", () => {
packageId: string;
renderRevision: number;
assets: Array<{ file: string; sha256: string }>;
networkContext: string[];
networkModel: { file: string; metadata: string; sha256: string };
sourceCatalogFile?: string;
bindingsFile?: string;
@@ -28,6 +29,16 @@ describe("ZJB three-dimensional runtime assets", () => {
expect(manifest.packageId).toBe("zjb-web-v29");
expect(manifest.renderRevision).toBe(29);
expect(manifest.assets).toHaveLength(24);
expect(manifest.networkContext).toEqual([
"detail_roof",
"detail_facade",
"detail_interior",
"detail_ceiling",
"map_platforms",
"map_railway",
"map_roads",
"map_pump_shell",
]);
manifest.assets.forEach((asset) => {
const assetPath = join(sceneRoot, asset.file);
expect(existsSync(assetPath)).toBe(true);
@@ -73,6 +84,10 @@ describe("ZJB three-dimensional runtime assets", () => {
join(sceneRoot, "asset-inspector.mjs"),
"utf8",
);
const networkStyle = readFileSync(
join(sceneRoot, "network-style.mjs"),
"utf8",
);
expect(preview).toContain("tjwater:zjb-scene");
expect(preview).toContain("HOST_VERSION=2");
@@ -83,10 +98,13 @@ describe("ZJB three-dimensional runtime assets", () => {
expect(preview).toContain("data.type==='clear-results'");
expect(preview).toContain("data.type!=='command'");
expect(preview).toContain("postHost('selection-changed'");
expect(preview).toContain("next==='network'?manifest.networkContext:manifest.mapDefault");
expect(preview).not.toContain("window.zjbNetwork");
expect(preview).not.toContain("resultsFile");
expect(html).not.toContain("绑定运行结果");
expect(html).not.toContain("resultsFile");
expect(inspector).not.toContain("document.getElementById");
expect(networkStyle).toContain("ScadaSensorMarkers");
expect(networkStyle).toContain("source==='scada'");
});
});
@@ -149,6 +149,42 @@ describe("buildSceneFrame", () => {
expect(frame.stats.missingLinks).toBe(2);
});
it("builds a SCADA-only frame when the matching simulation frame is absent", () => {
const frame = buildSceneFrame({
queryTime: new Date("2026-09-14T09:30:00.000Z"),
model,
nodeRows: [],
linkRows: [],
pressureDevices: [
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
],
scadaRows: [
{
time: "2026-09-14T09:30:00.000Z",
device_id: "S-1",
monitored_value: 30.8,
cleaned_value: 30.9,
},
],
});
expect(frame.resultTime).toBe("2026-09-14T09:30:00.000Z");
expect(frame.payload?.nodes["J-1"]).toEqual({
pressure: 30.9,
source: "scada",
deviceId: "S-1",
});
expect(frame.payload?.links).toEqual({});
expect(frame.stats).toMatchObject({
simulationNodes: 0,
simulationLinks: 0,
scadaOverrides: 1,
});
expect(frame.warnings).toEqual([
"当前时刻无在线模拟结果,已显示 SCADA 监测点。",
]);
});
it("ignores IDs outside the scene model instead of rejecting the frame", () => {
const frame = buildSceneFrame({
queryTime: new Date("2026-09-11T03:00:00.000Z"),
@@ -261,4 +297,39 @@ describe("buildSceneFrame", () => {
"SCADA 数据暂不可用,当前仅显示模拟结果。",
]);
});
it("keeps the SCADA-only warning returned by the frame builder", async () => {
mockApiFetch
.mockResolvedValueOnce({ ok: true, json: async () => [] })
.mockResolvedValueOnce({ ok: true, json: async () => [] })
.mockResolvedValueOnce({
ok: true,
json: async () => ({ FLOW_UNITS: "LPS", PRESSURE_UNITS: "METERS" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => [
{
time: "2026-09-14T09:30:00.000Z",
device_id: "S-1",
monitored_value: 30.8,
cleaned_value: 30.9,
},
],
});
const frame = await fetchSceneFrame({
queryTime: new Date("2026-09-14T09:30:00.000Z"),
model,
pressureDevices: [
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
],
signal: new AbortController().signal,
});
expect(frame.stats.scadaOverrides).toBe(1);
expect(frame.warnings).toEqual([
"当前时刻无在线模拟结果,已显示 SCADA 监测点。",
]);
});
});
+34 -3
View File
@@ -157,6 +157,28 @@ const resolveCommonFrameTime = (
)[0];
};
const resolveNearestRowTime = <T extends { time: string }>(
queryTime: Date,
rows: T[],
) => {
const target = queryTime.getTime();
const candidates = Array.from(
new Set(
rows
.map((row) => toTimestamp(row.time))
.filter(
(time): time is number =>
time !== null &&
Math.abs(time - target) <= SCENE_FRAME_TOLERANCE_MS,
),
),
);
if (candidates.length === 0) return null;
return candidates.sort(
(left, right) => Math.abs(left - target) - Math.abs(right - target),
)[0];
};
const normalizeLinkStatus = (
value: number | null,
): SceneLinkResult["status"] => {
@@ -210,7 +232,13 @@ export const buildSceneFrame = ({
simulationUnits?: NetworkResultUnits;
}): SceneFrame => {
const selectedTime = queryTime.toISOString();
const frameTime = resolveCommonFrameTime(queryTime, nodeRows, linkRows);
const simulationFrameTime = resolveCommonFrameTime(
queryTime,
nodeRows,
linkRows,
);
const frameTime =
simulationFrameTime ?? resolveNearestRowTime(queryTime, scadaRows);
if (frameTime === null) {
return {
selectedTime,
@@ -313,7 +341,10 @@ export const buildSceneFrame = ({
missingLinks: Math.max(0, model.linkIds.size - Object.keys(links).length),
ignoredElements,
},
warnings: [],
warnings:
simulationFrameTime === null
? ["当前时刻无在线模拟结果,已显示 SCADA 监测点。"]
: [],
};
};
@@ -404,5 +435,5 @@ export const fetchSceneFrame = async ({
scadaRows,
simulationUnits: networkOptions,
});
return { ...frame, warnings };
return { ...frame, warnings: [...frame.warnings, ...warnings] };
};