添加爆管定位功能及相关组件
This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
FormControl,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import { api } from "@/lib/api";
|
||||
import { NETWORK_NAME, config } from "@config/config";
|
||||
import { DMA_FLOW_DISPLAY_UNIT, toM3s } from "../DMALeakDetection/utils";
|
||||
import { BurstLocationResult } from "./types";
|
||||
|
||||
interface Props {
|
||||
onResult: (result: BurstLocationResult) => void;
|
||||
}
|
||||
|
||||
interface SchemeItem {
|
||||
scheme_id: number;
|
||||
scheme_name: string;
|
||||
scheme_type: string;
|
||||
create_time: string;
|
||||
scheme_start_time: string;
|
||||
scheme_detail?: {
|
||||
modify_total_duration: number;
|
||||
};
|
||||
}
|
||||
|
||||
type DataSource = "monitoring" | "simulation";
|
||||
|
||||
const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||||
const { open } = useNotification();
|
||||
const [schemeName, setSchemeName] = useState(`Burst_Locate_${Date.now()}`);
|
||||
const [dataSource, setDataSource] = useState<DataSource>("monitoring");
|
||||
const [schemes, setSchemes] = useState<SchemeItem[]>([]);
|
||||
const [selectedSchemeId, setSelectedSchemeId] = useState<number | "">("");
|
||||
const [schemeLoading, setSchemeLoading] = useState(false);
|
||||
const [burstLeakage, setBurstLeakage] = useState<number>(1440);
|
||||
const [enableFlow, setEnableFlow] = useState(false);
|
||||
const [burstStartTime, setBurstStartTime] = useState<Dayjs | null>(
|
||||
dayjs().subtract(20, "minute"),
|
||||
);
|
||||
const [burstEndTime, setBurstEndTime] = useState<Dayjs | null>(
|
||||
dayjs().subtract(5, "minute"),
|
||||
);
|
||||
const [normalStartTime, setNormalStartTime] = useState<Dayjs | null>(
|
||||
dayjs().subtract(2, "hour"),
|
||||
);
|
||||
const [normalEndTime, setNormalEndTime] = useState<Dayjs | null>(
|
||||
dayjs().subtract(90, "minute"),
|
||||
);
|
||||
const [minDpressure, setMinDpressure] = useState<number>(2);
|
||||
const [basicPressure, setBasicPressure] = useState<number>(10);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
|
||||
const start = dayjs(scheme.scheme_start_time);
|
||||
const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600;
|
||||
const end = start.add(durationSeconds, "second");
|
||||
|
||||
setBurstStartTime(start);
|
||||
setBurstEndTime(end);
|
||||
setNormalStartTime(start.subtract(2, "hour"));
|
||||
setNormalEndTime(start.subtract(10, "minute"));
|
||||
}, []);
|
||||
|
||||
const fetchSchemes = useCallback(
|
||||
async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => {
|
||||
if (schemeLoading || (!force && schemes.length > 0)) return;
|
||||
|
||||
setSchemeLoading(true);
|
||||
try {
|
||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/getallschemes/`, {
|
||||
params: { network: NETWORK_NAME },
|
||||
});
|
||||
const burstSchemes = (response.data as SchemeItem[]).filter(
|
||||
(scheme) => scheme.scheme_type === "burst_analysis",
|
||||
);
|
||||
|
||||
setSchemes(burstSchemes);
|
||||
|
||||
if (selectedSchemeId) {
|
||||
const matchedScheme = burstSchemes.find(
|
||||
(scheme) => scheme.scheme_id === selectedSchemeId,
|
||||
);
|
||||
if (matchedScheme) {
|
||||
applySchemeTimeRange(matchedScheme);
|
||||
} else {
|
||||
setSelectedSchemeId("");
|
||||
}
|
||||
}
|
||||
|
||||
if (notify) {
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "方案列表已刷新",
|
||||
description: `当前可选爆管分析方案 ${burstSchemes.length} 个`,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "刷新方案失败",
|
||||
description:
|
||||
error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表",
|
||||
});
|
||||
} finally {
|
||||
setSchemeLoading(false);
|
||||
}
|
||||
},
|
||||
[applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId],
|
||||
);
|
||||
|
||||
const handleDataSourceChange = (value: DataSource) => {
|
||||
setDataSource(value);
|
||||
if (value === "simulation") {
|
||||
void fetchSchemes();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSchemeSelect = (schemeId: number) => {
|
||||
setSelectedSchemeId(schemeId);
|
||||
const scheme = schemes.find((item) => item.scheme_id === schemeId);
|
||||
if (scheme) {
|
||||
applySchemeTimeRange(scheme);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (!Number.isFinite(burstLeakage) || burstLeakage <= 0) return false;
|
||||
if (!burstStartTime || !burstEndTime || !normalStartTime || !normalEndTime) {
|
||||
return false;
|
||||
}
|
||||
if (dataSource === "simulation" && !selectedSchemeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
burstStartTime.isBefore(burstEndTime) &&
|
||||
normalStartTime.isBefore(normalEndTime)
|
||||
);
|
||||
}, [
|
||||
burstLeakage,
|
||||
burstStartTime,
|
||||
burstEndTime,
|
||||
normalStartTime,
|
||||
normalEndTime,
|
||||
dataSource,
|
||||
selectedSchemeId,
|
||||
]);
|
||||
|
||||
const handleRun = async () => {
|
||||
if (
|
||||
!isValid ||
|
||||
!burstStartTime ||
|
||||
!burstEndTime ||
|
||||
!normalStartTime ||
|
||||
!normalEndTime
|
||||
) {
|
||||
open?.({ type: "error", message: "请完善参数并确认时间范围合法" });
|
||||
return;
|
||||
}
|
||||
|
||||
setRunning(true);
|
||||
open?.({
|
||||
key: "burst-location-analysis",
|
||||
type: "progress",
|
||||
message: "方案提交分析中",
|
||||
undoableTimeout: 3,
|
||||
});
|
||||
|
||||
try {
|
||||
const selectedScheme =
|
||||
dataSource === "simulation"
|
||||
? schemes.find((item) => item.scheme_id === selectedSchemeId)
|
||||
: undefined;
|
||||
|
||||
const response = await api.post(
|
||||
`${config.BACKEND_URL}/api/v1/burst-location/locate/`,
|
||||
{
|
||||
network: NETWORK_NAME,
|
||||
data_source: dataSource,
|
||||
scheme_name: schemeName.trim() || undefined,
|
||||
burst_leakage: toM3s(burstLeakage, DMA_FLOW_DISPLAY_UNIT),
|
||||
min_dpressure: minDpressure,
|
||||
basic_pressure: basicPressure,
|
||||
scada_burst_start: burstStartTime.toISOString(),
|
||||
scada_burst_end: burstEndTime.toISOString(),
|
||||
scada_normal_start: normalStartTime.toISOString(),
|
||||
scada_normal_end: normalEndTime.toISOString(),
|
||||
use_scada_flow: enableFlow || undefined,
|
||||
simulation_scheme_name: selectedScheme?.scheme_name,
|
||||
simulation_scheme_type: selectedScheme?.scheme_type,
|
||||
},
|
||||
);
|
||||
|
||||
onResult(response.data as BurstLocationResult);
|
||||
open?.({
|
||||
key: "burst-location-analysis",
|
||||
type: "success",
|
||||
message: "爆管定位成功",
|
||||
description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
open?.({
|
||||
key: "burst-location-analysis",
|
||||
type: "error",
|
||||
message: "提交分析失败",
|
||||
description: error?.response?.data?.detail ?? error?.message ?? "请求失败",
|
||||
});
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col flex-1 min-h-0">
|
||||
<Box className="flex flex-col gap-3">
|
||||
<Alert severity="info">
|
||||
选择模拟方案将自动填充爆管发生时段,监测数据模式下可手动调整时间范围。
|
||||
</Alert>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
方案名称
|
||||
</Typography>
|
||||
<TextField
|
||||
value={schemeName}
|
||||
onChange={(e) => setSchemeName(e.target.value)}
|
||||
placeholder="请输入方案名称"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
SCADA 数据来源
|
||||
</Typography>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={dataSource}
|
||||
onChange={(e) => handleDataSourceChange(e.target.value as DataSource)}
|
||||
>
|
||||
<MenuItem value="monitoring">监测数据</MenuItem>
|
||||
<MenuItem value="simulation">模拟方案</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{dataSource === "simulation" && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
选择爆管分析方案
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={selectedSchemeId}
|
||||
onChange={(e) => handleSchemeSelect(Number(e.target.value))}
|
||||
disabled={schemeLoading}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="" disabled>
|
||||
请选择方案
|
||||
</MenuItem>
|
||||
{schemes.map((scheme) => (
|
||||
<MenuItem key={scheme.scheme_id} value={scheme.scheme_id}>
|
||||
{scheme.scheme_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => void fetchSchemes({ force: true, notify: true })}
|
||||
disabled={schemeLoading}
|
||||
aria-label="刷新爆管分析方案"
|
||||
sx={{
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{schemeLoading ? (
|
||||
<CircularProgress size={18} color="inherit" />
|
||||
) : (
|
||||
<RefreshIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<LocalizationProvider
|
||||
dateAdapter={AdapterDayjs}
|
||||
adapterLocale="zh-cn"
|
||||
localeText={
|
||||
pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText
|
||||
}
|
||||
>
|
||||
<Box className="grid grid-cols-2 gap-2">
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
爆管开始时间
|
||||
</Typography>
|
||||
<DateTimePicker
|
||||
value={burstStartTime}
|
||||
onChange={setBurstStartTime}
|
||||
maxDateTime={burstEndTime ?? undefined}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
爆管结束时间
|
||||
</Typography>
|
||||
<DateTimePicker
|
||||
value={burstEndTime}
|
||||
onChange={setBurstEndTime}
|
||||
minDateTime={burstStartTime ?? undefined}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
正常开始时间
|
||||
</Typography>
|
||||
<DateTimePicker
|
||||
value={normalStartTime}
|
||||
onChange={setNormalStartTime}
|
||||
maxDateTime={normalEndTime ?? undefined}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
正常结束时间
|
||||
</Typography>
|
||||
<DateTimePicker
|
||||
value={normalEndTime}
|
||||
onChange={setNormalEndTime}
|
||||
minDateTime={normalStartTime ?? undefined}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</LocalizationProvider>
|
||||
|
||||
<Box className="flex flex-col gap-2">
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
总漏损流量 ({DMA_FLOW_DISPLAY_UNIT})
|
||||
</Typography>
|
||||
<TextField
|
||||
type="number"
|
||||
size="small"
|
||||
value={burstLeakage}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
setBurstLeakage(Number.isNaN(value) ? 1440 : Math.max(0, value));
|
||||
}}
|
||||
fullWidth
|
||||
inputProps={{ min: 0, step: 10 }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
border: "1px solid",
|
||||
borderColor: "grey.200",
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setAdvancedOpen((prev) => !prev);
|
||||
}}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 1.25,
|
||||
py: 0.75,
|
||||
cursor: "pointer",
|
||||
backgroundColor: "transparent",
|
||||
"&:hover": { backgroundColor: "action.hover" },
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
高级选项
|
||||
</Typography>
|
||||
<ExpandMoreIcon
|
||||
sx={{
|
||||
transform: advancedOpen ? "rotate(180deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.2s ease",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Collapse in={advancedOpen} timeout="auto" unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.25,
|
||||
pt: 1.25,
|
||||
pb: 1.25,
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
>
|
||||
<Box className="flex flex-col gap-3">
|
||||
<Box>
|
||||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||||
流量校核
|
||||
</Typography>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={enableFlow ? "enabled" : "disabled"}
|
||||
onChange={(e) => setEnableFlow(e.target.value === "enabled")}
|
||||
>
|
||||
<MenuItem value="disabled">禁用</MenuItem>
|
||||
<MenuItem value="enabled">启用(使用流量计)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
type="number"
|
||||
label="最小压降 (m)"
|
||||
size="small"
|
||||
value={minDpressure}
|
||||
onChange={(e) => setMinDpressure(Number(e.target.value))}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
label="基础压力 (m)"
|
||||
size="small"
|
||||
value={basicPressure}
|
||||
onChange={(e) => setBasicPressure(Number(e.target.value))}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="mt-auto pt-3">
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={handleRun}
|
||||
disabled={!isValid || running}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{running ? "定位中..." : "开始定位"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalysisParameters;
|
||||
Reference in New Issue
Block a user