添加爆管定位功能及相关组件

This commit is contained in:
JIANG
2026-03-07 10:50:07 +08:00
parent 9beba1cf6f
commit 5ed6740a24
11 changed files with 1247 additions and 14 deletions
@@ -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;
@@ -0,0 +1,162 @@
"use client";
import React, { useCallback, useState } from "react";
import { Box, Drawer, IconButton, Tab, Tabs, Tooltip, Typography } from "@mui/material";
import {
Analytics as AnalyticsIcon,
ChevronLeft,
ChevronRight,
FormatListBulleted,
Search as SearchIcon,
} from "@mui/icons-material";
import AnalysisParameters from "./AnalysisParameters";
import LocationResults from "./LocationResults";
import SchemeQuery from "./SchemeQuery";
import { BurstLocationResult } from "./types";
const TabPanel = ({
value,
index,
children,
}: {
value: number;
index: number;
children: React.ReactNode;
}) => (
<div role="tabpanel" hidden={value !== index} className="flex-1 overflow-hidden flex flex-col">
{value === index ? <Box className="flex-1 overflow-auto p-4 flex flex-col">{children}</Box> : null}
</div>
);
const BurstLocationPanel: React.FC = () => {
const [open, setOpen] = useState(true);
const [tab, setTab] = useState(0);
const [result, setResult] = useState<BurstLocationResult | null>(null);
const drawerWidth = 450;
const panelTitle = "爆管定位";
const handleResult = useCallback((payload: BurstLocationResult) => {
setResult(payload);
setTab(2);
}, []);
const handleViewResult = useCallback((payload: BurstLocationResult) => {
setResult(payload);
setTab(2);
}, []);
return (
<>
{!open && (
<Box
className="absolute top-4 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100"
onClick={() => setOpen(true)}
sx={{ zIndex: 1300 }}
>
<Box className="flex flex-col items-center py-3 px-3 gap-1">
<AnalyticsIcon className="text-[#257DD4] w-5 h-5" />
<Typography
variant="caption"
className="text-gray-700 font-semibold my-1 text-xs"
style={{ writingMode: "vertical-rl" }}
>
{panelTitle}
</Typography>
<ChevronLeft className="text-gray-600 w-4 h-4" />
</Box>
</Box>
)}
<Drawer
anchor="right"
open={open}
variant="persistent"
hideBackdrop
sx={{
width: 0,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: drawerWidth,
boxSizing: "border-box",
position: "absolute",
top: 16,
right: 16,
height: "calc(100vh - 32px)",
maxHeight: "850px",
borderRadius: "12px",
boxShadow:
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
backdropFilter: "blur(8px)",
opacity: 0.95,
transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out",
border: "none",
"&:hover": {
opacity: 1,
},
},
}}
>
<Box className="flex flex-col h-full bg-white rounded-xl overflow-hidden">
<Box className="flex items-center justify-between px-5 py-4 bg-[#257DD4] text-white">
<Box className="flex items-center gap-2">
<AnalyticsIcon className="w-5 h-5" />
<Typography variant="h6" className="text-lg font-semibold">
{panelTitle}
</Typography>
</Box>
<Tooltip title="收起">
<IconButton
size="small"
onClick={() => setOpen(false)}
sx={{ color: "primary.contrastText" }}
>
<ChevronRight fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box className="border-b border-gray-200 bg-white">
<Tabs
value={tab}
onChange={(_, value) => setTab(value)}
variant="fullWidth"
sx={{
minHeight: 48,
"& .MuiTab-root": {
minHeight: 48,
textTransform: "none",
fontSize: "0.875rem",
fontWeight: 500,
transition: "all 0.2s",
},
"& .Mui-selected": {
color: "#257DD4",
},
"& .MuiTabs-indicator": {
backgroundColor: "#257DD4",
},
}}
>
<Tab icon={<AnalyticsIcon fontSize="small" />} iconPosition="start" label="定位参数" />
<Tab icon={<SearchIcon fontSize="small" />} iconPosition="start" label="方案查询" />
<Tab icon={<FormatListBulleted fontSize="small" />} iconPosition="start" label="定位结果" />
</Tabs>
</Box>
<TabPanel value={tab} index={0}>
<AnalysisParameters onResult={handleResult} />
</TabPanel>
<TabPanel value={tab} index={1}>
<SchemeQuery onViewResult={handleViewResult} />
</TabPanel>
<TabPanel value={tab} index={2}>
<LocationResults result={result} />
</TabPanel>
</Box>
</Drawer>
</>
);
};
export default BurstLocationPanel;
@@ -0,0 +1,267 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Chip,
Divider,
IconButton,
Paper,
Tooltip,
Typography
} from "@mui/material";
import {
FormatListBulleted,
LocationOn as LocationOnIcon,
Map as MapIcon
} from "@mui/icons-material";
import dayjs from "dayjs";
import { useMap } from "@app/OlMap/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { GeoJSON } from "ol/format";
import Feature from "ol/Feature";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Stroke, Style, Circle, Fill } from "ol/style";
import { bbox, featureCollection } from "@turf/turf";
import { BurstLocationResult } from "./types";
interface Props {
result: BurstLocationResult | null;
}
const LocationResults: React.FC<Props> = ({ result }) => {
const map = useMap();
const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const candidatePipes = useMemo(() => {
if (!result) return [];
const base = result.top_candidates ?? [];
const hasLocated = base.some((item) => item.pipe_id === result.located_pipe);
if (result.located_pipe && !hasLocated) {
return [{ pipe_id: result.located_pipe, similarity: 1.0 }, ...base];
}
return base;
}, [result]);
useEffect(() => {
if (!map) return;
const layer = new VectorLayer({
source: new VectorSource(),
style: new Style({
stroke: new Stroke({
color: "#ef4444",
width: 6,
}),
image: new Circle({
radius: 8,
fill: new Fill({ color: "#ef4444" }),
stroke: new Stroke({ color: "#fff", width: 2 }),
}),
zIndex: 999,
}),
properties: {
name: "爆管定位高亮",
value: "burst_location_highlight",
},
});
map.addLayer(layer);
setHighlightLayer(layer);
return () => {
map.removeLayer(layer);
};
}, [map]);
useEffect(() => {
const source = highlightLayer?.getSource();
if (!source) return;
source.clear();
highlightFeatures.forEach((feature) => source.addFeature(feature));
}, [highlightFeatures, highlightLayer]);
const locatePipes = async (pipeIds: string[]) => {
if (!pipeIds.length || !map) return;
try {
// 尝试两个图层
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
if (features.length === 0) {
features = await queryFeaturesByIds(pipeIds, "geo_pipes");
}
if (features.length === 0) return;
setHighlightFeatures(features);
const geojsonFormat = new GeoJSON();
const geojsonFeatures = features.map((feature) =>
geojsonFormat.writeFeatureObject(feature),
);
// @ts-ignore
const extent = bbox(featureCollection(geojsonFeatures));
map.getView().fit(extent, {
maxZoom: 19,
duration: 1000,
padding: [100, 100, 100, 100],
});
} catch (e) {
console.error("Locate failed", e);
}
};
if (!result) {
return (
<Box className="flex flex-col items-center justify-center h-full bg-gray-50/50 p-6 text-center">
<Box className="bg-white p-6 rounded-full shadow-sm mb-4">
<MapIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
</Box>
<Typography variant="h6" className="text-gray-700 font-bold mb-1"></Typography>
<Typography variant="body2" className="text-gray-500 max-w-xs">
</Typography>
</Box>
);
}
return (
<Box className="h-full overflow-y-auto bg-gray-50 p-3 space-y-3">
{/* 1. 冠军卡片 */}
<Box className="flex items-center justify-between px-1 mb-2">
<Box className="flex items-center gap-2">
<Box className="w-1 h-4 bg-blue-600 rounded-full" />
<Typography
variant="h6"
className="font-bold text-gray-900 truncate"
sx={{ fontSize: "1.1rem" }}
title={result.scheme_name || "Burst Location Result"}
>
{result.scheme_name || "爆管定位结果"}
</Typography>
</Box>
{result.username && (
<Chip
label={result.username}
size="small"
sx={{
height: 24,
backgroundColor: "#f3f4f6",
color: "#4b5563",
border: "none",
fontWeight: 500
}}
/>
)}
</Box>
{/* 2. 统计数据 */}
<Box className="grid grid-cols-2 gap-3 mb-4">
{/* 方案时间/创建时间 */}
<Box className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 border border-blue-200 shadow-sm">
<Typography variant="caption" className="text-blue-700 font-semibold block mb-1 text-xs uppercase tracking-wide">
</Typography>
<Typography variant="body2" className="font-bold text-blue-900">
{result.create_time ? dayjs(result.create_time).format("MM-DD HH:mm") : "-"}
</Typography>
</Box>
{/* 漏损量 */}
<Box className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-3 border border-orange-200 shadow-sm">
<Typography variant="caption" className="text-orange-700 font-semibold block mb-1 text-xs uppercase tracking-wide">
</Typography>
<Typography variant="body2" className="font-bold text-orange-900">
{result.burst_leakage.toFixed(1)} L/s
</Typography>
</Box>
{/* 最佳匹配 */}
<Box className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-3 border border-purple-200 shadow-sm col-span-2">
<Box className="flex items-center justify-between">
<Box>
<Typography variant="caption" className="text-purple-700 font-semibold block mb-1 text-xs uppercase tracking-wide">
</Typography>
<Typography variant="h6" className="font-bold text-purple-900">
{result.located_pipe}
</Typography>
<Typography variant="caption" className="text-purple-600">
: {(candidatePipes[0]?.similarity * 100 || 0).toFixed(1)}% · : {result.similarity_mode}
</Typography>
</Box>
<Button
size="small"
variant="contained"
color="secondary"
startIcon={<LocationOnIcon />}
onClick={() => locatePipes([result.located_pipe])}
sx={{ backgroundColor: "#9333ea", "&:hover": { backgroundColor: "#7e22ce" } }}
>
</Button>
</Box>
</Box>
</Box>
{/* 3. 候选列表 */}
<Box className="rounded-xl border border-gray-100 bg-white shadow-sm overflow-hidden">
<Box className="px-4 py-3 border-b border-gray-100 flex items-center justify-between bg-white">
<Box className="flex items-center gap-2">
<FormatListBulleted className="text-blue-600 w-5 h-5" />
<Typography variant="subtitle1" className="font-bold text-gray-800">
</Typography>
</Box>
<Chip
size="small"
label={`${candidatePipes.length}`}
sx={{
height: 22,
backgroundColor: "rgba(37, 99, 235, 0.08)",
color: "#2563eb",
fontWeight: 600,
fontSize: "0.75rem",
border: "none",
}}
/>
</Box>
<Box className="max-h-64 overflow-y-auto">
{candidatePipes.map((candidate, idx) => (
<Box
key={candidate.pipe_id}
className="flex items-center justify-between px-4 py-3 border-b border-gray-50 last:border-0 hover:bg-gray-50 transition-colors"
>
<Box className="flex items-center gap-3">
<Box className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${idx === 0 ? "bg-yellow-100 text-yellow-700" : idx === 1 ? "bg-gray-100 text-gray-600" : idx === 2 ? "bg-orange-50 text-orange-600" : "bg-transparent text-gray-400"}`}>
{idx + 1}
</Box>
<Box>
<Typography variant="body2" className="font-bold text-gray-700">
{candidate.pipe_id}
</Typography>
<Typography variant="caption" className="text-gray-400">
: {(candidate.similarity * 100).toFixed(2)}%
</Typography>
</Box>
</Box>
<IconButton
size="small"
onClick={() => locatePipes([candidate.pipe_id])}
className="text-gray-400 hover:text-blue-600"
>
<LocationOnIcon fontSize="small" />
</IconButton>
</Box>
))}
</Box>
</Box>
</Box>
);
};
export default LocationResults;
@@ -0,0 +1,253 @@
"use client";
import React, { useState } from "react";
import {
Box,
Button,
Card,
CardContent,
Chip,
Collapse,
FormControlLabel,
Checkbox,
IconButton,
Tooltip,
Typography,
} from "@mui/material";
import { Info as InfoIcon } from "@mui/icons-material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import "dayjs/locale/zh-cn";
import dayjs, { Dayjs } from "dayjs";
import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { NETWORK_NAME, config } from "@config/config";
import { BurstLocationResult, BurstSchemeRecord } from "./types";
interface Props {
onViewResult: (result: BurstLocationResult) => void;
}
const SchemeQuery: React.FC<Props> = ({ onViewResult }) => {
const { open } = useNotification();
const [queryAll, setQueryAll] = useState(true);
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs());
const [schemes, setSchemes] = useState<BurstSchemeRecord[]>([]);
const [loading, setLoading] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const handleQuery = async () => {
setLoading(true);
try {
// API call to fetch schemes
// Adjust URL as needed
let url = `${config.BACKEND_URL}/api/v1/burst-location/schemes/`;
const params: Record<string, string> = { network: NETWORK_NAME };
if (!queryAll && queryDate) {
params.query_date = queryDate.startOf("day").toISOString();
}
const response = await api.get(url, { params });
setSchemes(response.data);
open?.({
type: "success",
message: "查询成功",
description: `共找到 ${response.data.length} 条记录`,
});
} catch (error: any) {
console.error(error);
open?.({
type: "error",
message: "查询失败",
description: error?.response?.data?.detail ?? "无法获取方案列表",
});
} finally {
setLoading(false);
}
};
const handleViewSchemeResult = async (schemeName: string) => {
try {
const response = await api.get(
`${config.BACKEND_URL}/api/v1/burst-location/schemes/${encodeURIComponent(schemeName)}`,
{ params: { network: NETWORK_NAME } },
);
// The backend returns { scheme_detail: ... } inside the response or just the result?
// Based on burst_location.py: get_burst_location_scheme_detail returns the stored detail.
// Let's assume response.data is the BurstLocationResult
onViewResult(response.data as BurstLocationResult);
open?.({
type: "success",
message: "方案加载成功",
description: `已加载方案: ${schemeName}`,
});
} catch (error: any) {
open?.({
type: "error",
message: "查看详情失败",
description: error?.response?.data?.detail ?? "无法获取方案详情",
});
}
};
return (
<Box className="flex flex-col h-full">
<Box className="mb-2 p-2 bg-gray-50 rounded">
<Box className="flex items-center gap-2 justify-between">
<Box className="flex items-center gap-2">
<FormControlLabel
control={
<Checkbox
size="small"
checked={queryAll}
onChange={(e) => setQueryAll(e.target.checked)}
/>
}
label={<Typography variant="body2"></Typography>}
className="m-0"
/>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker
value={queryDate}
onChange={setQueryDate}
disabled={queryAll}
format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
/>
</LocalizationProvider>
</Box>
<Button
variant="contained"
onClick={handleQuery}
disabled={loading}
size="small"
className="bg-blue-600 hover:bg-blue-700"
sx={{ minWidth: 80 }}
>
{loading ? "查询中..." : "查询"}
</Button>
</Box>
</Box>
<Box className="flex-1 overflow-auto">
{schemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
{schemes.length}
</Typography>
{schemes.map((scheme) => (
<Card key={scheme.scheme_id} variant="outlined" className="hover:shadow-md transition-shadow">
<CardContent className="p-3 pb-2 last:pb-3">
<Box className="flex items-start justify-between gap-2 mb-2">
<Box className="flex-1 min-w-0">
<Box className="flex items-center gap-2 mb-1">
<Typography variant="body2" className="font-medium truncate" title={scheme.scheme_name}>
{scheme.scheme_name}
</Typography>
<Chip size="small" variant="outlined" color="primary" label="爆管定位" className="h-5" />
</Box>
<Typography variant="caption" className="text-gray-500 block">
ID: {scheme.scheme_id} · : {dayjs(scheme.create_time).format("MM-DD HH:mm")}
</Typography>
</Box>
<Box className="flex gap-1 ml-2">
<Tooltip title={expandedId === scheme.scheme_id ? "收起详情" : "查看详情"}>
<IconButton
size="small"
onClick={() => setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id)}
color="primary"
className="p-1"
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
<Collapse in={expandedId === scheme.scheme_id}>
<Box className="mt-2 pt-3 border-t border-gray-200">
<Box className="mb-3 rounded-md bg-gray-50 px-3 py-2 space-y-2">
{/* Summary details */}
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
<Typography variant="caption" className="text-gray-600">
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{scheme.scheme_detail?.located_pipe || "-"}
</Typography>
</Box>
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
<Typography variant="caption" className="text-gray-600">
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{scheme.scheme_detail?.burst_leakage ? `${scheme.scheme_detail.burst_leakage} L/s` : "-"}
</Typography>
</Box>
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
<Typography variant="caption" className="text-gray-600">
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{scheme.username || "-"}
</Typography>
</Box>
</Box>
<Box className="pt-2 border-t border-gray-100">
<Button
variant="contained"
fullWidth
size="small"
className="bg-blue-600 hover:bg-blue-700"
sx={{ textTransform: "none", fontWeight: 500 }}
onClick={() => handleViewSchemeResult(scheme.scheme_name)}
>
</Button>
</Box>
</Box>
</Collapse>
</CardContent>
</Card>
))}
</Box>
)}
</Box>
</Box>
);
};
export default SchemeQuery;
@@ -0,0 +1,27 @@
export interface BurstCandidate {
pipe_id: string;
similarity: number;
}
export interface BurstLocationResult {
located_pipe: string;
burst_leakage: number;
elapsed_seconds: number;
simulation_times: number;
top_candidates: BurstCandidate[];
similarity_mode: string;
scheme_name?: string;
username?: string;
observed_source?: string;
pressure_scada_ids?: string[];
flow_scada_ids?: string[];
create_time?: string;
}
export interface BurstSchemeRecord {
scheme_id: number;
scheme_name: string;
create_time: string;
username?: string;
scheme_detail?: BurstLocationResult;
}
@@ -78,7 +78,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
key: "dma-leak-analysis",
type: "success",
message: "方案分析成功",
description: "DMA漏损识别完成,请在方案查询中查看结果。",
description: "DMA 漏损识别完成,请在方案查询中查看结果。",
});
} catch (error: any) {
open?.({
@@ -54,7 +54,7 @@ const DMALeakDetectionPanel: React.FC = () => {
const [loadedResult, setLoadedResult] = useState<LeakageResultDetail | null>(null);
const drawerWidth = 450;
const panelTitle = "DMA漏损识别";
const panelTitle = "DMA 漏损识别";
const activeAreas = loadedResult?.areas ?? [];
const legendColors = useMemo(
() => activeAreas.map((area) => getAreaColor(area.area_id)),
@@ -11,7 +11,7 @@ export const AREA_COLORS = [
"#be123c",
];
export const DMA_FLOW_DISPLAY_UNIT = "m3/h";
export const DMA_FLOW_DISPLAY_UNIT = "m³/h";
const M3H_FACTOR = 3600;
export const getAreaColor = (areaId: string | number | undefined) => {
@@ -23,16 +23,16 @@ export const getAreaColor = (areaId: string | number | undefined) => {
return AREA_COLORS[hash % AREA_COLORS.length];
};
export const toM3h = (value: number, sourceUnit: string = "m3/s") => {
export const toM3h = (value: number, sourceUnit: string = "m³/s") => {
if (!Number.isFinite(value)) return Number.NaN;
const normalizedUnit = sourceUnit.trim().toLowerCase();
if (normalizedUnit === "m3/h") return value;
if (normalizedUnit === "m³/h") return value;
return value * M3H_FACTOR;
};
export const toM3s = (value: number, sourceUnit: string = "m3/h") => {
export const toM3s = (value: number, sourceUnit: string = "m³/h") => {
if (!Number.isFinite(value)) return Number.NaN;
const normalizedUnit = sourceUnit.trim().toLowerCase();
if (normalizedUnit === "m3/s") return value;
if (normalizedUnit === "m³/s") return value;
return value / M3H_FACTOR;
};