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" && ( + + + + + + )}