Compare commits

..
Author SHA1 Message Date
jiang 7f1ab4a79c style(history): clarify event comparison curves
Generic Container CI/CD / test-build-publish (push) Successful in 1m3s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m3s
Remove simulation area fills, distinguish scheme curves with dashed lines, and soften the event interval. Improve chart spacing, axis labels, tooltips and the range slider while retaining observed SCADA markers.

Validation: 19 focused HistoryDataPanel tests and scoped ESLint pass in an isolated checkout. Browser chart previews cover wide, compact and mixed flow/SCADA pressure states.
2026-11-03 14:16:48 +08:00
jiang e67f9542cd feat(history): add aligned online simulation action
Generic Container CI/CD / test-build-publish (push) Successful in 59s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 59s
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.
2026-11-03 13:21:59 +08:00
4 changed files with 411 additions and 38 deletions
@@ -4,6 +4,8 @@ import dayjs from "dayjs";
import ReactECharts from "echarts-for-react"; import ReactECharts from "echarts-for-react";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { getAnalysisScheme, toAnalysisSchemeRecord } from "@/lib/analysisRuns"; import { getAnalysisScheme, toAnalysisSchemeRecord } from "@/lib/analysisRuns";
import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel"; import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel";
jest.mock("@/lib/apiFetch", () => ({ jest.mock("@/lib/apiFetch", () => ({
@@ -29,6 +31,11 @@ jest.mock("@mui/x-date-pickers", () => ({
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children, LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
})); }));
jest.mock("echarts-for-react", () => jest.fn(() => null)); 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) => const jsonResponse = (payload: unknown, status = 200) =>
({ ({
@@ -177,6 +184,8 @@ describe("HistoryDataPanel requests", () => {
jest.clearAllMocks(); jest.clearAllMocks();
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] })); jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
jest.mocked(getAnalysisScheme).mockReset().mockResolvedValue(eventRun()); jest.mocked(getAnalysisScheme).mockReset().mockResolvedValue(eventRun());
useAccessStore.setState({ permissions: ["simulation.run"], loading: false });
useProjectStore.getState().setCurrentProject("project-1", "network-1");
}); });
afterEach(() => jest.clearAllMocks()); afterEach(() => jest.clearAllMocks());
@@ -302,6 +311,7 @@ describe("HistoryDataPanel requests", () => {
render(React.createElement(HistoryDataPanel, props)); render(React.createElement(HistoryDataPanel, props));
await screen.findByText(/无法读取事件方案/); await screen.findByText(/无法读取事件方案/);
expect(apiFetch).not.toHaveBeenCalled(); expect(apiFetch).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "在线模拟" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "刷新" })); fireEvent.click(screen.getByRole("button", { name: "刷新" }));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
expect(getAnalysisScheme).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]]); 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);
});
}); });
@@ -22,11 +22,10 @@ import {
Tooltip, Tooltip,
Typography, Typography,
} from "@mui/material"; } 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 { DataGrid, GridColDef } from "@mui/x-data-grid";
import { zhCN } from "@mui/x-data-grid/locales"; import { zhCN } from "@mui/x-data-grid/locales";
import ReactECharts from "echarts-for-react"; import ReactECharts from "echarts-for-react";
import * as echarts from "echarts";
import "dayjs/locale/zh-cn"; // 引入中文包 import "dayjs/locale/zh-cn"; // 引入中文包
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
@@ -49,6 +48,15 @@ import {
getAnalysisHistoryContext, getAnalysisHistoryContext,
getAnalysisScheme, getAnalysisScheme,
} from "@/lib/analysisRuns"; } 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 { import {
FLOW_DISPLAY_UNIT, FLOW_DISPLAY_UNIT,
PRESSURE_DISPLAY_UNIT, PRESSURE_DISPLAY_UNIT,
@@ -225,6 +233,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
runId, runId,
onClose, 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<{ const [schemeState, setSchemeState] = useState<{
runId: string; runId: string;
context?: AnalysisHistoryContext; context?: AnalysisHistoryContext;
@@ -246,7 +261,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const now = dayjs(); const now = dayjs();
const start = start_time ? dayjs(start_time) : null; const start = start_time ? dayjs(start_time) : null;
const end = end_time ? dayjs(end_time) : null; const end = end_time ? dayjs(end_time) : null;
return { const range = {
from: start?.isValid() from: start?.isValid()
? start ? start
: eventRange : eventRange
@@ -258,7 +273,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
? dayjs(eventRange.end).add(1, "hour") ? dayjs(eventRange.end).add(1, "hour")
: now, : 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<{ const [selectedRange, setSelectedRange] = useState<{
key: string; key: string;
from: Dayjs; from: Dayjs;
@@ -273,10 +290,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const to = rangeForContext && ( const to = rangeForContext && (
rangeForContext.endTime === end_time || !end_time || !dayjs(end_time).isValid() rangeForContext.endTime === end_time || !end_time || !dayjs(end_time).isValid()
) ? rangeForContext.to : defaultRange.to; ) ? rangeForContext.to : defaultRange.to;
const setFrom = (value: Dayjs) => const setFrom = (value: Dayjs) => {
setSelectedRange({ key: rangeKey, from: value, to, startTime: start_time, endTime: end_time }); const range = alignSimulationRange({ from: value.toDate(), to: to.toDate() }, stepSeconds);
const setTo = (value: Dayjs) => setSelectedRange({ key: rangeKey, from: dayjs(range.from), to: dayjs(range.to), startTime: start_time, endTime: end_time });
setSelectedRange({ key: rangeKey, from, to: value, 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(() => { useEffect(() => {
if (!rangeForContext || ( if (!rangeForContext || (
@@ -289,6 +310,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]); const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
const [loadingState, setLoadingState] = useState<LoadingState>("idle"); const [loadingState, setLoadingState] = useState<LoadingState>("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSimulating, setIsSimulating] = useState(false);
const [simulationError, setSimulationError] = useState<string | null>(null);
const simulationControllerRef = useRef<AbortController | null>(null);
const draggableRef = useRef<HTMLDivElement>(null); const draggableRef = useRef<HTMLDivElement>(null);
const requestControllerRef = useRef<AbortController | null>(null); const requestControllerRef = useRef<AbortController | null>(null);
@@ -316,7 +340,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}; };
}, [type, runId, schemeReload]); }, [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 featureInfosKey = JSON.stringify(featureInfos);
const requestedFeatureInfos = useMemo( const requestedFeatureInfos = useMemo(
@@ -325,6 +353,42 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
); );
const hasDevices = requestedFeatureInfos.length > 0; const hasDevices = requestedFeatureInfos.length > 0;
const hasData = timeSeries.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( const dataset = useMemo(
() => buildDataset(timeSeries, historySeries, fractionDigits), () => buildDataset(timeSeries, historySeries, fractionDigits),
@@ -392,6 +456,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
useEffect( useEffect(
() => () => { () => () => {
requestControllerRef.current?.abort(); requestControllerRef.current?.abort();
simulationControllerRef.current?.abort();
simulationControllerRef.current = null;
}, },
[], [],
); );
@@ -454,6 +520,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
); );
const renderEmpty = () => { const renderEmpty = () => {
if (isSimulating) {
return <Box sx={{ display: "grid", placeItems: "center", height: "100%" }}>
<CircularProgress size={32} />
</Box>;
}
if (!hasDevices) { if (!hasDevices) {
return ( return (
<PanelEmptyState <PanelEmptyState
@@ -515,8 +586,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
? "circle" ? "circle"
: metadata.source === "scada_raw" : metadata.source === "scada_raw"
? "diamond" ? "diamond"
: "none", : "circle",
symbolSize: isObserved ? 7 : 0, symbolSize: isObserved ? 7 : 5,
showSymbol: isObserved, showSymbol: isObserved,
sampling: "lttb", sampling: "lttb",
connectNulls: false, connectNulls: false,
@@ -530,21 +601,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
.map((point) => [ .map((point) => [
new Date(point.timestamp).getTime(), point.values[key] ?? null, new Date(point.timestamp).getTime(), point.values[key] ?? null,
]), ]),
lineStyle: isObserved ? { width: 0 } : undefined, lineStyle: {
areaStyle: isObserved width: isObserved ? 0 : 2,
? undefined type: metadata.source === "analysis_simulation" ? "dashed" : "solid",
: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[index % colors.length],
},
{
offset: 1,
color: "rgba(255, 255, 255, 0)",
},
]),
opacity: 0.3,
}, },
}, },
]; ];
@@ -556,20 +615,34 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
tooltip: { tooltip: {
trigger: "axis", trigger: "axis",
confine: true, confine: true,
position: function (pt: any[]) { axisPointer: { type: "line", lineStyle: { color: "#94a3b8", type: "dashed" } },
return [pt[0], "10%"]; valueFormatter: (value: number | string) => typeof value === "number"
}, ? value.toLocaleString("zh-CN", { maximumSignificantDigits: 6, useGrouping: false })
: String(value ?? "--"),
}, },
legend: { legend: {
top: "top", type: "scroll",
top: 8,
left: 20,
width: "90%",
itemWidth: 20,
itemHeight: 8,
itemGap: 20,
textStyle: { color: "#475569", fontSize: 12 },
}, },
grid: { grid: {
left: "5%", top: 120,
right: "5%", left: 20,
bottom: "11%", right: 24,
bottom: 76,
containLabel: true, containLabel: true,
}, },
toolbox: { toolbox: {
top: 36,
right: 16,
itemSize: 15,
itemGap: 16,
iconStyle: { borderColor: "#64748b" },
feature: { feature: {
dataZoom: { dataZoom: {
yAxisIndex: "none", yAxisIndex: "none",
@@ -583,9 +656,18 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
boundaryGap: false, boundaryGap: false,
min: normalizedRange.from.valueOf(), min: normalizedRange.from.valueOf(),
max: normalizedRange.to.valueOf(), max: normalizedRange.to.valueOf(),
minInterval: stepSeconds * 1000,
axisLabel: { axisLabel: {
formatter: (value: number) => formatTimestamp(new Date(value).toISOString()), formatter: (value: number) => dayjs(value).tz("Asia/Shanghai").format("MM-DD\nHH:mm"),
hideOverlap: true,
color: "#64748b",
fontSize: 12,
lineHeight: 16,
margin: 12,
}, },
splitNumber: 6,
axisLine: { lineStyle: { color: "#cbd5e1" } },
axisTick: { show: false },
}, },
yAxis: [ yAxis: [
...(hasFlowSeries ...(hasFlowSeries
@@ -594,6 +676,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
type: "value", type: "value",
scale: true, scale: true,
name: `流量 (${FLOW_DISPLAY_UNIT})`, name: `流量 (${FLOW_DISPLAY_UNIT})`,
nameTextStyle: { color: "#64748b", fontSize: 12 },
axisLabel: { color: "#64748b", fontSize: 12 },
splitLine: { lineStyle: { color: "#e2e8f0", type: "dashed" } },
}, },
] ]
: []), : []),
@@ -603,6 +688,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
type: "value", type: "value",
scale: true, scale: true,
name: `压力 (${PRESSURE_DISPLAY_UNIT})`, name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
nameTextStyle: { color: "#64748b", fontSize: 12 },
axisLabel: { color: "#64748b", fontSize: 12 },
splitLine: { show: !hasFlowSeries, lineStyle: { color: "#e2e8f0", type: "dashed" } },
}, },
] ]
: []), : []),
@@ -614,8 +702,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
end: 100, end: 100,
}, },
{ {
type: "slider",
start: 0, start: 0,
end: 100, end: 100,
bottom: 12,
height: 24,
showDetail: false,
borderColor: "transparent",
backgroundColor: "#f5f7fa",
fillerColor: "rgba(25, 118, 210, 0.08)",
dataBackground: {
lineStyle: { color: "#94a3b8" },
areaStyle: { opacity: 0 },
},
selectedDataBackground: {
lineStyle: { color: "#1976d2" },
areaStyle: { opacity: 0 },
},
}, },
], ],
series: getSeries().map((series, index) => ({ series: getSeries().map((series, index) => ({
@@ -624,7 +727,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
? { ? {
markArea: { markArea: {
silent: true, silent: true,
itemStyle: { color: "rgba(255, 152, 0, 0.10)" }, itemStyle: { color: "rgba(255, 152, 0, 0.06)" },
label: {
position: "insideTop",
distance: 10,
color: "#996515",
fontSize: 11,
},
data: [[ data: [[
{ name: "事件方案时段", xAxis: Date.parse(eventRange.start) }, { name: "事件方案时段", xAxis: Date.parse(eventRange.start) },
{ xAxis: Date.parse(eventRange.end) }, { xAxis: Date.parse(eventRange.end) },
@@ -801,6 +910,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
<DateTimePicker <DateTimePicker
label="开始时间" label="开始时间"
value={from} value={from}
disabled={isSimulating}
onChange={(value) => { onChange={(value) => {
if (value && dayjs.isDayjs(value) && value.isValid()) { if (value && dayjs.isDayjs(value) && value.isValid()) {
setFrom(value); setFrom(value);
@@ -814,6 +924,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
<DateTimePicker <DateTimePicker
label="结束时间" label="结束时间"
value={to} value={to}
disabled={isSimulating}
onChange={(value) => { onChange={(value) => {
if (value && dayjs.isDayjs(value) && value.isValid()) { if (value && dayjs.isDayjs(value) && value.isValid()) {
setTo(value); setTo(value);
@@ -826,7 +937,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
/> />
</Stack> </Stack>
<Stack <Stack
direction="row" direction={{ xs: "column", sm: "row" }}
spacing={1} spacing={1}
alignItems="center" alignItems="center"
justifyContent="space-between" justifyContent="space-between"
@@ -850,6 +961,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
/> />
</Tabs> </Tabs>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
{type !== "none" && (
<Tooltip title={canRunSimulation
? "按管网水力步长计算当前查询时段的在线模拟数据"
: "需要在线模拟运行权限"}>
<span>
<Button
variant="contained"
size="small"
startIcon={isSimulating ? <CircularProgress size={16} color="inherit" /> : <PlayArrow fontSize="small" />}
disabled={!canRunSimulation || !projectId || !hasDevices || !schemeReady || Boolean(schemeError) || isSimulating || loadingState === "loading"}
onClick={() => { void handleOnlineSimulation(); }}
>
{isSimulating ? "模拟中…" : "在线模拟"}
</Button>
</span>
</Tooltip>
)}
<Tooltip title="刷新数据"> <Tooltip title="刷新数据">
<span> <span>
<Button <Button
@@ -858,7 +986,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
color="primary" color="primary"
startIcon={<Refresh fontSize="small" />} startIcon={<Refresh fontSize="small" />}
disabled={ disabled={
!hasDevices || !schemeReady || loadingState === "loading" !hasDevices || !schemeReady || isSimulating || loadingState === "loading"
} }
onClick={() => { onClick={() => {
if (schemeError) { if (schemeError) {
@@ -883,6 +1011,22 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
事件方案时段:{formatTimestamp(eventRange.start)} ~ {formatTimestamp(eventRange.end)} 事件方案时段:{formatTimestamp(eventRange.start)} ~ {formatTimestamp(eventRange.end)}
</Typography> </Typography>
)} )}
{isSimulating && (
<Typography role="status" variant="caption" sx={{ mt: 1, display: "block" }}>
正在计算在线模拟数据,完成后将自动刷新图表。
</Typography>
)}
{!isSimulating && type !== "none" && hasDevices && loadingState === "success" &&
!historySeries.some((series) => series.source === "realtime_simulation" && series.points.some((point) => point.value !== null)) && (
<Typography variant="caption" sx={{ mt: 1, display: "block" }}>
当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""}
</Typography>
)}
{simulationError && (
<Typography role="alert" variant="caption" color="error" sx={{ mt: 1, display: "block" }}>
在线模拟失败:{simulationError}
</Typography>
)}
{(schemeError || error) && ( {(schemeError || error) && (
<Typography <Typography
variant="caption" variant="caption"
+86
View File
@@ -0,0 +1,86 @@
import { apiFetch } from "@/lib/apiFetch";
import { useProjectStore } from "@/store/projectStore";
import { alignSimulationRange, parseHydraulicStepSeconds, runOnlineSimulation } from "./onlineSimulation";
jest.mock("@/lib/apiFetch", () => ({ apiFetch: jest.fn() }));
const response = (body: unknown, ok = true) => ({
ok, json: async () => body,
}) as Response;
describe("online simulation time alignment", () => {
it.each([["0:15", 900], ["00:05:00", 300], ["1:00", 3600], ["0:00:30", 30]])(
"reads the configured step %s", (clock, seconds) => {
expect(parseHydraulicStepSeconds(clock)).toBe(seconds);
},
);
it.each(["0:00", "0:60", "0:00:60", "15", "invalid", undefined])(
"does not invent a step from invalid config %s", (clock) => {
expect(parseHydraulicStepSeconds(clock)).toBeUndefined();
},
);
it("aligns the screenshot's range to 15-minute clock boundaries", () => {
const range = alignSimulationRange({
from: new Date("2026-07-30T12:38:42+08:00"),
to: new Date("2026-07-30T15:38:42+08:00"),
}, 900);
expect(range.from.toISOString()).toBe("2026-07-30T04:30:00.000Z");
expect(range.to.toISOString()).toBe("2026-07-30T07:45:00.000Z");
});
it("keeps aligned endpoints and handles ranges crossing midnight", () => {
const range = alignSimulationRange({
from: new Date("2026-07-30T23:50:00+08:00"),
to: new Date("2026-07-31T00:10:00+08:00"),
}, 900);
expect(range.from.toISOString()).toBe("2026-07-30T15:45:00.000Z");
expect(range.to.toISOString()).toBe("2026-07-30T16:15:00.000Z");
expect(alignSimulationRange(range, 900)).toEqual(range);
});
});
describe("online simulation request", () => {
const range = {
from: new Date("2026-07-30T12:38:00+08:00"),
to: new Date("2026-07-30T15:38:00+08:00"),
};
beforeEach(() => {
jest.mocked(apiFetch).mockReset();
useProjectStore.getState().setCurrentProject("project-1", "network-1");
});
it("runs the full snapped range including the right endpoint, without network fields", async () => {
jest.mocked(apiFetch).mockResolvedValueOnce(response({ times: { "HYDRAULIC TIMESTEP": "0:15" } }))
.mockResolvedValueOnce(response({ status: "success" }));
const result = await runOnlineSimulation(range);
expect(result.range.to.toISOString()).toBe("2026-07-30T07:45:00.000Z");
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[1][1]?.body))).toEqual({
start_time: "2026-07-30T04:30:00.000Z", duration: 210,
});
expect(String(jest.mocked(apiFetch).mock.calls[1][0])).toContain("/api/v1/simulation-runs");
});
it("refuses to calculate with a missing hydraulic step", async () => {
jest.mocked(apiFetch).mockResolvedValue(response({}));
await expect(runOnlineSimulation(range)).rejects.toThrow("水力时间步长无效");
expect(apiFetch).toHaveBeenCalledTimes(1);
});
it("does not submit a write if the project changed during the config read", async () => {
jest.mocked(apiFetch).mockImplementationOnce(async () => {
useProjectStore.getState().setCurrentProject("project-2", "network-2");
return response({ "HYDRAULIC TIMESTEP": "0:15" });
});
await expect(runOnlineSimulation(range)).rejects.toThrow("项目已切换");
expect(apiFetch).toHaveBeenCalledTimes(1);
});
it("does not retry or treat a queued acknowledgment as completion", async () => {
jest.mocked(apiFetch).mockResolvedValueOnce(response({ "HYDRAULIC TIMESTEP": "0:15" }))
.mockResolvedValueOnce(response({ status: "queued" }));
await expect(runOnlineSimulation(range)).rejects.toThrow("未确认完成");
expect(apiFetch).toHaveBeenCalledTimes(2);
});
});
+79
View File
@@ -0,0 +1,79 @@
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import config from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import { useProjectStore } from "@/store/projectStore";
dayjs.extend(utc);
dayjs.extend(timezone);
export type SimulationRange = { from: Date; to: Date };
export const parseHydraulicStepSeconds = (value: unknown): number | undefined => {
if (typeof value !== "string" || !/^\d+:\d{1,2}(:\d{1,2})?$/.test(value.trim())) {
return undefined;
}
const [hours, minutes, seconds = 0] = value.trim().split(":").map(Number);
const step = hours * 3600 + minutes * 60 + seconds;
return minutes < 60 && seconds < 60 && step > 0 ? step : undefined;
};
/** 与地图时间轴一致,以北京时间零点为基准向外取整,跨日仍保持同一步长。 */
export const alignSimulationRange = (
range: SimulationRange,
stepSeconds: number,
): SimulationRange => {
if (!Number.isFinite(stepSeconds) || stepSeconds <= 0 ||
!Number.isFinite(range.from.getTime()) || !Number.isFinite(range.to.getTime())) {
throw new Error("查询时间或水力时间步长无效");
}
const start = Math.min(range.from.getTime(), range.to.getTime());
const end = Math.max(range.from.getTime(), range.to.getTime());
const origin = dayjs(start).tz("Asia/Shanghai").startOf("day").valueOf();
const step = stepSeconds * 1000;
const from = origin + Math.floor((start - origin) / step) * step;
const to = Math.max(from + step, origin + Math.ceil((end - origin) / step) * step);
return { from: new Date(from), to: new Date(to) };
};
/** 复用同步在线模拟接口,成功响应表示计算和保存已完成;不自动重试写请求。 */
export const runOnlineSimulation = async (
range: SimulationRange,
signal?: AbortSignal,
): Promise<{ range: SimulationRange; stepSeconds: number }> => {
const projectId = useProjectStore.getState().currentProjectId;
if (!projectId) throw new Error("请先选择项目,未启动在线模拟");
const projectHeaders = { "X-Project-Id": projectId };
const timeResponse = await apiFetch(
`${config.BACKEND_URL}/api/v1/network-options/time`, {
signal, headers: projectHeaders, projectHeaderMode: "omit",
},
);
if (!timeResponse.ok) throw new Error("无法读取管网时间步长,未启动在线模拟");
const payload = await timeResponse.json();
const stepSeconds = parseHydraulicStepSeconds(
(payload?.times ?? payload)?.["HYDRAULIC TIMESTEP"],
);
if (!stepSeconds) throw new Error("管网水力时间步长无效,未启动在线模拟");
if (signal?.aborted || useProjectStore.getState().currentProjectId !== projectId) {
throw new Error("项目已切换或面板已关闭,未启动在线模拟");
}
const alignedRange = alignSimulationRange(range, stepSeconds);
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/simulation-runs`, {
method: "POST",
headers: { ...projectHeaders, "Content-Type": "application/json" },
projectHeaderMode: "omit",
// 后端循环的结束时间不包含在计算内,多算一个步长以覆盖图表右端点。
body: JSON.stringify({
start_time: alignedRange.from.toISOString(),
duration: Math.ceil((alignedRange.to.getTime() - alignedRange.from.getTime() + stepSeconds * 1000) / 60000),
}),
signal,
});
const result = await response.json().catch(() => null);
if (!response.ok || result?.status !== "success") {
throw new Error(typeof result?.detail === "string" ? result.detail : "在线模拟未确认完成,请刷新查看已生成的数据");
}
return { range: alignedRange, stepSeconds };
};