diff --git a/e2e/scada-history-zoom.spec.ts b/e2e/scada-history-zoom.spec.ts
new file mode 100644
index 0000000..b6a7fee
--- /dev/null
+++ b/e2e/scada-history-zoom.spec.ts
@@ -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);
+});
diff --git a/src/components/olmap/SCADA/SCADADataPanel.test.tsx b/src/components/olmap/SCADA/SCADADataPanel.test.tsx
new file mode 100644
index 0000000..2515a77
--- /dev/null
+++ b/src/components/olmap/SCADA/SCADADataPanel.test.tsx
@@ -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();
+ await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
+
+ rerender();
+ await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
+
+ rerender();
+ 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));
+ });
+});
diff --git a/src/components/olmap/SCADA/SCADADataPanel.tsx b/src/components/olmap/SCADA/SCADADataPanel.tsx
index 4f8fb02..920e4c4 100644
--- a/src/components/olmap/SCADA/SCADADataPanel.tsx
+++ b/src/components/olmap/SCADA/SCADADataPanel.tsx
@@ -307,9 +307,15 @@ const SCADADataPanel: React.FC = ({
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 = ({
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 = ({
setLoadingState("error");
}
},
- [deviceIds, customFetcher, hasDevices, normalizedRange],
+ [requestedDeviceIds, customFetcher, hasDevices, normalizedRange],
);
// 处理数据清洗
@@ -447,7 +453,7 @@ const SCADADataPanel: React.FC = ({
setTimeSeries([]);
setHistorySeries([]);
}
- }, [deviceIdsKey, handleFetch, hasDevices]);
+ }, [handleFetch, hasDevices]);
// 当设备数量变化时,调整数据源选择
useEffect(() => {
diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.test.ts b/src/components/olmap/core/Controls/HistoryDataPanel.test.ts
index f96b81b..e5e3f79 100644
--- a/src/components/olmap/core/Controls/HistoryDataPanel.test.ts
+++ b/src/components/olmap/core/Controls/HistoryDataPanel.test.ts
@@ -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));
+ });
+});
diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx
index 83c2b8e..26bacdf 100644
--- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx
+++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx
@@ -258,12 +258,13 @@ const SCADADataPanel: React.FC = ({
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 = ({
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 = ({
setLoadingState("error");
}
},
- [featureInfos, hasDevices, normalizedRange, runId, type],
+ [requestedFeatureInfos, hasDevices, normalizedRange, runId, type],
);
useEffect(
@@ -331,7 +332,7 @@ const SCADADataPanel: React.FC = ({
setTimeSeries([]);
setHistorySeries([]);
}
- }, [featureInfosKey, handleFetch, hasDevices]);
+ }, [handleFetch, hasDevices]);
const columns: GridColDef[] = useMemo(() => {
const base: GridColDef[] = [