From e67f9542cd021b2781c494cead3ff5a46792963c Mon Sep 17 00:00:00 2001 From: Huarch Date: Tue, 3 Nov 2026 13:21:59 +0800 Subject: [PATCH] feat(history): add aligned online simulation action Reuse the existing simulation endpoint and align backfill to the hydraulic timestep. Block backfill when event metadata fails so the fallback query window cannot be calculated as event history. --- .../core/Controls/HistoryDataPanel.test.ts | 64 +++++++++ .../olmap/core/Controls/HistoryDataPanel.tsx | 131 ++++++++++++++++-- src/lib/onlineSimulation.test.ts | 86 ++++++++++++ src/lib/onlineSimulation.ts | 79 +++++++++++ 4 files changed, 349 insertions(+), 11 deletions(-) create mode 100644 src/lib/onlineSimulation.test.ts create mode 100644 src/lib/onlineSimulation.ts diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.test.ts b/src/components/olmap/core/Controls/HistoryDataPanel.test.ts index 7eb6d2c..cdd5ea4 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.test.ts +++ b/src/components/olmap/core/Controls/HistoryDataPanel.test.ts @@ -4,6 +4,8 @@ import dayjs from "dayjs"; import ReactECharts from "echarts-for-react"; import { apiFetch } from "@/lib/apiFetch"; import { getAnalysisScheme, toAnalysisSchemeRecord } from "@/lib/analysisRuns"; +import { useAccessStore } from "@/store/accessStore"; +import { useProjectStore } from "@/store/projectStore"; import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel"; jest.mock("@/lib/apiFetch", () => ({ @@ -29,6 +31,11 @@ jest.mock("@mui/x-date-pickers", () => ({ LocalizationProvider: ({ children }: { children: React.ReactNode }) => children, })); jest.mock("echarts-for-react", () => jest.fn(() => null)); +jest.mock("./useTimelineTimeConfig", () => ({ + useTimelineTimeConfig: () => ({ + durationMinutes: 1440, stepMinutes: 15, properties: { "HYDRAULIC TIMESTEP": "0:15" }, + }), +})); const jsonResponse = (payload: unknown, status = 200) => ({ @@ -177,6 +184,8 @@ describe("HistoryDataPanel requests", () => { jest.clearAllMocks(); jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] })); jest.mocked(getAnalysisScheme).mockReset().mockResolvedValue(eventRun()); + useAccessStore.setState({ permissions: ["simulation.run"], loading: false }); + useProjectStore.getState().setCurrentProject("project-1", "network-1"); }); afterEach(() => jest.clearAllMocks()); @@ -302,6 +311,7 @@ describe("HistoryDataPanel requests", () => { render(React.createElement(HistoryDataPanel, props)); await screen.findByText(/无法读取事件方案/); expect(apiFetch).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "在线模拟" })).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: "刷新" })); await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); expect(getAnalysisScheme).toHaveBeenCalledTimes(2); @@ -341,4 +351,58 @@ describe("HistoryDataPanel requests", () => { ]); expect(option.series[1].data).toEqual([[Date.parse("2026-08-17T09:15:00Z"), 8]]); }); + + it("snaps off-step event context without changing the actual event marker", async () => { + jest.mocked(getAnalysisScheme).mockResolvedValue(eventRun("run-1", "2026-07-30T05:38:00Z")); + render(React.createElement(HistoryDataPanel, props)); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); + expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[0][1]?.body))).toMatchObject({ + start_time: "2026-07-30T04:30:00.000Z", end_time: "2026-07-30T07:45:00.000Z", + }); + expect(screen.getByText(/事件方案时段:2026-07-30 13:38/)).toBeInTheDocument(); + }); + + it("runs online simulation once, shows progress, then refreshes both history sources", async () => { + let finishSimulation!: (value: Response) => void; + jest.mocked(apiFetch).mockImplementation(async (url) => { + if (String(url).endsWith("/network-options/time")) return jsonResponse({ "HYDRAULIC TIMESTEP": "0:15" }); + if (String(url).endsWith("/simulation-runs")) return new Promise((resolve) => { finishSimulation = resolve; }); + return jsonResponse({ series: [] }); + }); + render(React.createElement(HistoryDataPanel, props)); + const button = screen.getByRole("button", { name: "在线模拟" }); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); + await screen.findByText("正在计算在线模拟数据,完成后将自动刷新图表。"); + expect(screen.getByRole("button", { name: "模拟中…" })).toBeDisabled(); + await waitFor(() => expect(finishSimulation).toBeDefined()); + fireEvent.click(button); + expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/simulation-runs"))).toHaveLength(1); + await act(async () => { finishSimulation(jsonResponse({ status: "success" })); }); + await waitFor(() => expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/element-history/query"))).toHaveLength(4)); + expect(screen.getByRole("button", { name: "在线模拟" })).toBeEnabled(); + }); + + it("disables the write action for a viewer while allowing history reads", async () => { + useAccessStore.setState({ permissions: ["simulation.view"] }); + render(React.createElement(HistoryDataPanel, props)); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); + expect(screen.getByRole("button", { name: "在线模拟" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "刷新" })).toBeEnabled(); + }); + + it("shows a simulation failure without retrying or claiming data was refreshed", async () => { + jest.mocked(apiFetch).mockImplementation(async (url) => { + if (String(url).endsWith("/network-options/time")) return jsonResponse({ "HYDRAULIC TIMESTEP": "0:15" }); + if (String(url).endsWith("/simulation-runs")) return jsonResponse({ detail: "计算服务不可用" }, 503); + return jsonResponse({ series: [] }); + }); + render(React.createElement(HistoryDataPanel, props)); + const button = screen.getByRole("button", { name: "在线模拟" }); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); + expect(await screen.findByRole("alert")).toHaveTextContent("计算服务不可用"); + expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/simulation-runs"))).toHaveLength(1); + expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/element-history/query"))).toHaveLength(2); + }); }); diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index b3ab868..1e15def 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -22,7 +22,7 @@ import { Tooltip, Typography, } from "@mui/material"; -import { Close, Refresh, ShowChart, TableChart } from "@mui/icons-material"; +import { Close, PlayArrow, Refresh, ShowChart, TableChart } from "@mui/icons-material"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { zhCN } from "@mui/x-data-grid/locales"; import ReactECharts from "echarts-for-react"; @@ -49,6 +49,15 @@ import { getAnalysisHistoryContext, getAnalysisScheme, } from "@/lib/analysisRuns"; +import { + alignSimulationRange, + parseHydraulicStepSeconds, + runOnlineSimulation, +} from "@/lib/onlineSimulation"; +import { permissionCodes } from "@/lib/permissions"; +import { useAccessStore } from "@/store/accessStore"; +import { useProjectStore } from "@/store/projectStore"; +import { useTimelineTimeConfig } from "./useTimelineTimeConfig"; import { FLOW_DISPLAY_UNIT, PRESSURE_DISPLAY_UNIT, @@ -225,6 +234,13 @@ const SCADADataPanel: React.FC = ({ runId, onClose, }) => { + const timeConfig = useTimelineTimeConfig(); + const stepSeconds = parseHydraulicStepSeconds(timeConfig.properties["HYDRAULIC TIMESTEP"]) + ?? timeConfig.stepMinutes * 60; + const canRunSimulation = useAccessStore((state) => + state.permissions.includes(permissionCodes.simulationRun), + ); + const projectId = useProjectStore((state) => state.currentProjectId); const [schemeState, setSchemeState] = useState<{ runId: string; context?: AnalysisHistoryContext; @@ -246,7 +262,7 @@ const SCADADataPanel: React.FC = ({ const now = dayjs(); const start = start_time ? dayjs(start_time) : null; const end = end_time ? dayjs(end_time) : null; - return { + const range = { from: start?.isValid() ? start : eventRange @@ -258,7 +274,9 @@ const SCADADataPanel: React.FC = ({ ? dayjs(eventRange.end).add(1, "hour") : now, }; - }, [start_time, end_time, eventRange]); + const aligned = alignSimulationRange({ from: range.from.toDate(), to: range.to.toDate() }, stepSeconds); + return { from: dayjs(aligned.from), to: dayjs(aligned.to) }; + }, [start_time, end_time, eventRange, stepSeconds]); const [selectedRange, setSelectedRange] = useState<{ key: string; from: Dayjs; @@ -273,10 +291,14 @@ const SCADADataPanel: React.FC = ({ 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 setFrom = (value: Dayjs) => { + const range = alignSimulationRange({ from: value.toDate(), to: to.toDate() }, stepSeconds); + setSelectedRange({ key: rangeKey, from: dayjs(range.from), to: dayjs(range.to), startTime: start_time, endTime: end_time }); + }; + const setTo = (value: Dayjs) => { + const range = alignSimulationRange({ from: from.toDate(), to: value.toDate() }, stepSeconds); + setSelectedRange({ key: rangeKey, from: dayjs(range.from), to: dayjs(range.to), startTime: start_time, endTime: end_time }); + }; useEffect(() => { if (!rangeForContext || ( @@ -289,6 +311,9 @@ const SCADADataPanel: React.FC = ({ const [historySeries, setHistorySeries] = useState([]); const [loadingState, setLoadingState] = useState("idle"); const [error, setError] = useState(null); + const [isSimulating, setIsSimulating] = useState(false); + const [simulationError, setSimulationError] = useState(null); + const simulationControllerRef = useRef(null); const draggableRef = useRef(null); const requestControllerRef = useRef(null); @@ -316,7 +341,11 @@ const SCADADataPanel: React.FC = ({ }; }, [type, runId, schemeReload]); - const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]); + const normalizedRange = useMemo(() => { + const ordered = ensureValidRange(from, to); + const aligned = alignSimulationRange({ from: ordered.from.toDate(), to: ordered.to.toDate() }, stepSeconds); + return { from: dayjs(aligned.from), to: dayjs(aligned.to) }; + }, [from, to, stepSeconds]); const featureInfosKey = JSON.stringify(featureInfos); const requestedFeatureInfos = useMemo( @@ -325,6 +354,42 @@ const SCADADataPanel: React.FC = ({ ); const hasDevices = requestedFeatureInfos.length > 0; const hasData = timeSeries.length > 0; + const simulationContext = JSON.stringify([ + projectId, type, runId, featureInfosKey, start_time, end_time, + ]); + const simulationContextRef = useRef(simulationContext); + useEffect(() => { + simulationContextRef.current = simulationContext; + setSimulationError(null); + }, [simulationContext]); + + const handleOnlineSimulation = async () => { + if (simulationControllerRef.current || !canRunSimulation || !projectId || !hasDevices || !schemeReady || schemeError) return; + const controller = new AbortController(); + simulationControllerRef.current = controller; + setIsSimulating(true); + setSimulationError(null); + try { + const result = await runOnlineSimulation({ + from: normalizedRange.from.toDate(), to: normalizedRange.to.toDate(), + }, controller.signal); + if (controller.signal.aborted || simulationContextRef.current !== simulationContext) return; + // 更新查询范围会触发历史数据重查,读取刚保存的在线结果。 + setSelectedRange({ + key: rangeKey, from: dayjs(result.range.from), to: dayjs(result.range.to), + startTime: start_time, endTime: end_time, + }); + } catch (err) { + if (!controller.signal.aborted && simulationContextRef.current === simulationContext) { + setSimulationError(err instanceof Error ? err.message : "在线模拟失败,请刷新确认已生成的数据"); + } + } finally { + if (simulationControllerRef.current === controller) { + simulationControllerRef.current = null; + setIsSimulating(false); + } + } + }; const dataset = useMemo( () => buildDataset(timeSeries, historySeries, fractionDigits), @@ -392,6 +457,8 @@ const SCADADataPanel: React.FC = ({ useEffect( () => () => { requestControllerRef.current?.abort(); + simulationControllerRef.current?.abort(); + simulationControllerRef.current = null; }, [], ); @@ -454,6 +521,11 @@ const SCADADataPanel: React.FC = ({ ); const renderEmpty = () => { + if (isSimulating) { + return + + ; + } if (!hasDevices) { return ( = ({ boundaryGap: false, min: normalizedRange.from.valueOf(), max: normalizedRange.to.valueOf(), + minInterval: stepSeconds * 1000, axisLabel: { - formatter: (value: number) => formatTimestamp(new Date(value).toISOString()), + formatter: (value: number) => dayjs(value).tz("Asia/Shanghai").format("MM-DD\nHH:mm"), + hideOverlap: true, }, }, yAxis: [ @@ -801,6 +875,7 @@ const SCADADataPanel: React.FC = ({ { if (value && dayjs.isDayjs(value) && value.isValid()) { setFrom(value); @@ -814,6 +889,7 @@ const SCADADataPanel: React.FC = ({ { if (value && dayjs.isDayjs(value) && value.isValid()) { setTo(value); @@ -826,7 +902,7 @@ const SCADADataPanel: React.FC = ({ /> = ({ /> + {type !== "none" && ( + + + + + + )}