541 lines
18 KiB
TypeScript
541 lines
18 KiB
TypeScript
"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 { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
|
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
|
import { BurstLocationResult } from "./types";
|
|
import { getBurstLocationErrorNotice } from "./burstLocationError";
|
|
|
|
interface Props {
|
|
onResult: (result: BurstLocationResult) => void;
|
|
state?: BurstLocationAnalysisParametersState;
|
|
onStateChange?: (state: BurstLocationAnalysisParametersState) => void;
|
|
}
|
|
|
|
export interface SchemeItem {
|
|
scheme_id: number;
|
|
scheme_name: string;
|
|
scheme_type: string;
|
|
create_time: string;
|
|
scheme_start_time: string;
|
|
scheme_detail?: {
|
|
modify_total_duration: number;
|
|
burst_ID?: string[] | string;
|
|
};
|
|
}
|
|
|
|
type DataSource = "monitoring" | "simulation";
|
|
|
|
export interface BurstLocationAnalysisParametersState {
|
|
schemeName: string;
|
|
dataSource: DataSource;
|
|
schemes: SchemeItem[];
|
|
selectedSchemeId: number | "";
|
|
burstLeakage: number;
|
|
enableFlow: boolean;
|
|
burstStartTime: Dayjs | null;
|
|
burstEndTime: Dayjs | null;
|
|
minDpressure: number;
|
|
basicPressure: number;
|
|
advancedOpen: boolean;
|
|
}
|
|
|
|
export const createBurstLocationAnalysisParametersState =
|
|
(): BurstLocationAnalysisParametersState => ({
|
|
schemeName: `Burst_Locate_${Date.now()}`,
|
|
dataSource: "monitoring",
|
|
schemes: [],
|
|
selectedSchemeId: "",
|
|
burstLeakage: 1440,
|
|
enableFlow: false,
|
|
burstStartTime: dayjs().subtract(20, "minute"),
|
|
burstEndTime: dayjs().subtract(5, "minute"),
|
|
minDpressure: 2,
|
|
basicPressure: 10,
|
|
advancedOpen: false,
|
|
});
|
|
|
|
const AnalysisParameters: React.FC<Props> = ({
|
|
onResult,
|
|
state,
|
|
onStateChange,
|
|
}) => {
|
|
const { open } = useNotification();
|
|
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
|
|
state,
|
|
onStateChange,
|
|
createBurstLocationAnalysisParametersState(),
|
|
);
|
|
const {
|
|
schemeName,
|
|
dataSource,
|
|
schemes,
|
|
selectedSchemeId,
|
|
burstLeakage,
|
|
enableFlow,
|
|
burstStartTime,
|
|
burstEndTime,
|
|
minDpressure,
|
|
basicPressure,
|
|
advancedOpen,
|
|
} = parametersState;
|
|
const [schemeLoading, setSchemeLoading] = useState(false);
|
|
const [running, setRunning] = useState(false);
|
|
const isSimulationMode = dataSource === "simulation";
|
|
|
|
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");
|
|
|
|
setParametersState((previous) => ({
|
|
...previous,
|
|
burstStartTime: start,
|
|
burstEndTime: end,
|
|
}));
|
|
}, [setParametersState]);
|
|
|
|
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/schemes`, {
|
|
params: { scheme_type: "burst_analysis" },
|
|
});
|
|
const burstSchemes = (response.data as SchemeItem[]).filter(
|
|
(scheme) => scheme.scheme_type === "burst_analysis",
|
|
).sort(
|
|
(a, b) =>
|
|
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
|
|
);
|
|
|
|
setFormField("schemes", burstSchemes);
|
|
|
|
if (selectedSchemeId) {
|
|
const matchedScheme = burstSchemes.find(
|
|
(scheme) => scheme.scheme_id === selectedSchemeId,
|
|
);
|
|
if (matchedScheme) {
|
|
applySchemeTimeRange(matchedScheme);
|
|
} else {
|
|
setFormField("selectedSchemeId", "");
|
|
}
|
|
}
|
|
|
|
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, setFormField],
|
|
);
|
|
|
|
const handleDataSourceChange = (value: DataSource) => {
|
|
setFormField("dataSource", value);
|
|
if (value === "simulation") {
|
|
void fetchSchemes();
|
|
}
|
|
};
|
|
|
|
const handleSchemeSelect = (schemeId: number) => {
|
|
setFormField("selectedSchemeId", 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) {
|
|
return false;
|
|
}
|
|
if (dataSource === "simulation" && !selectedSchemeId) {
|
|
return false;
|
|
}
|
|
|
|
return burstStartTime.isBefore(burstEndTime);
|
|
}, [
|
|
burstLeakage,
|
|
burstStartTime,
|
|
burstEndTime,
|
|
dataSource,
|
|
selectedSchemeId,
|
|
]);
|
|
|
|
const handleRun = async () => {
|
|
if (!isValid || !burstStartTime || !burstEndTime) {
|
|
open?.({ type: "error", message: "请完善参数并确认时间范围合法" });
|
|
return;
|
|
}
|
|
|
|
setRunning(true);
|
|
open?.({
|
|
key: "burst-location-analysis-progress",
|
|
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-locations`,
|
|
{
|
|
data_source: dataSource,
|
|
scheme_name: schemeName.trim() || undefined,
|
|
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
|
|
min_dpressure: minDpressure,
|
|
basic_pressure: basicPressure,
|
|
scada_burst_start: burstStartTime.toISOString(),
|
|
scada_burst_end: burstEndTime.toISOString(),
|
|
use_scada_flow: enableFlow || undefined,
|
|
simulation_scheme_name: selectedScheme?.scheme_name,
|
|
simulation_scheme_type: selectedScheme?.scheme_type,
|
|
},
|
|
);
|
|
|
|
const resultPayload = response.data as BurstLocationResult;
|
|
const selectedBurstIds = normalizeBurstIds(selectedScheme?.scheme_detail?.burst_ID);
|
|
onResult(
|
|
selectedBurstIds.length > 0
|
|
? {
|
|
...resultPayload,
|
|
simulation_scheme: {
|
|
...resultPayload.simulation_scheme,
|
|
name: resultPayload.simulation_scheme?.name ?? selectedScheme?.scheme_name,
|
|
type: resultPayload.simulation_scheme?.type ?? selectedScheme?.scheme_type,
|
|
burst_ids:
|
|
resultPayload.simulation_scheme?.burst_ids?.length
|
|
? resultPayload.simulation_scheme.burst_ids
|
|
: selectedBurstIds,
|
|
},
|
|
}
|
|
: resultPayload,
|
|
);
|
|
open?.({
|
|
key: "burst-location-analysis-success",
|
|
type: "success",
|
|
message: "爆管定位成功",
|
|
description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`,
|
|
});
|
|
} catch (error: any) {
|
|
const notice = getBurstLocationErrorNotice(error);
|
|
open?.({
|
|
key: "burst-location-analysis-error",
|
|
type: "error",
|
|
message: notice.message,
|
|
description: notice.description,
|
|
});
|
|
} finally {
|
|
setRunning(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Box className="flex flex-col flex-1 min-h-0">
|
|
<Box className="flex flex-col gap-3">
|
|
<Box>
|
|
<Typography variant="subtitle2" className="mb-1 font-medium">
|
|
方案名称
|
|
</Typography>
|
|
<TextField
|
|
value={schemeName}
|
|
onChange={(e) => setFormField("schemeName", 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>
|
|
<Alert
|
|
severity={isSimulationMode ? "warning" : "info"}
|
|
variant="outlined"
|
|
sx={{
|
|
mt: 1,
|
|
alignItems: "flex-start",
|
|
borderColor: isSimulationMode ? "warning.light" : "info.light",
|
|
backgroundColor: isSimulationMode ? "#fff8e1" : "#eff6ff",
|
|
"& .MuiAlert-message": { width: "100%" },
|
|
}}
|
|
>
|
|
<Typography
|
|
variant="caption"
|
|
className="block font-semibold"
|
|
sx={{ color: isSimulationMode ? "warning.dark" : "info.dark" }}
|
|
>
|
|
{isSimulationMode ? "模拟方案分析前置条件" : "数据选择规则"}
|
|
</Typography>
|
|
<Typography
|
|
variant="caption"
|
|
className="block leading-5"
|
|
sx={{ color: isSimulationMode ? "warning.dark" : "info.dark" }}
|
|
>
|
|
{isSimulationMode
|
|
? "爆管数据取所选方案,正常数据取同一时间窗的实时模拟结果。所选时间窗必须已有实时模拟正常基线,否则无法分析;系统不会自动补数据。"
|
|
: "当前为监测数据:爆管数据取所选监测时间窗,正常数据默认取前一天同一时段的监测数据。"}
|
|
</Typography>
|
|
</Alert>
|
|
</Box>
|
|
|
|
{isSimulationMode && (
|
|
<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={(value) => setFormField("burstStartTime", value)}
|
|
maxDateTime={burstEndTime ?? undefined}
|
|
disabled={isSimulationMode}
|
|
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={(value) => setFormField("burstEndTime", value)}
|
|
minDateTime={burstStartTime ?? undefined}
|
|
disabled={isSimulationMode}
|
|
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">
|
|
爆管漏损流量 ({FLOW_DISPLAY_UNIT})
|
|
</Typography>
|
|
<TextField
|
|
type="number"
|
|
size="small"
|
|
value={burstLeakage}
|
|
onChange={(e) => {
|
|
const value = Number(e.target.value);
|
|
setFormField("burstLeakage", 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={() => setFormField("advancedOpen", !advancedOpen)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") setFormField("advancedOpen", !advancedOpen);
|
|
}}
|
|
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) => setFormField("enableFlow", 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) => setFormField("minDpressure", Number(e.target.value))}
|
|
/>
|
|
<TextField
|
|
type="number"
|
|
label="基础压力 (m)"
|
|
size="small"
|
|
value={basicPressure}
|
|
onChange={(e) => setFormField("basicPressure", 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;
|
|
|
|
const normalizeBurstIds = (value: string[] | string | undefined) => {
|
|
if (!value) return [];
|
|
const values = Array.isArray(value) ? value : [value];
|
|
return Array.from(
|
|
new Set(values.map((item) => String(item).trim()).filter(Boolean)),
|
|
);
|
|
};
|