新增全部清洗状态和提示;重新设计定位结果页面

This commit is contained in:
JIANG
2025-12-22 16:12:07 +08:00
parent d4f8d26f81
commit 716ff28898
4 changed files with 350 additions and 171 deletions

View File

@@ -19,7 +19,10 @@ import {
} from "@mui/icons-material"; } from "@mui/icons-material";
import AnalysisParameters from "./AnalysisParameters"; import AnalysisParameters from "./AnalysisParameters";
import SchemeQuery from "./SchemeQuery"; import SchemeQuery from "./SchemeQuery";
import LocationResults from "./LocationResults"; import LocationResults, { LocationResult } from "./LocationResults";
import axios from "axios";
import { config } from "@config/config";
import { useNotification } from "@refinedev/core";
interface SchemeDetail { interface SchemeDetail {
burst_ID: string[]; burst_ID: string[];
burst_size: number[]; burst_size: number[];
@@ -74,6 +77,10 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
// 持久化方案查询结果 // 持久化方案查询结果
const [schemes, setSchemes] = useState<SchemeRecord[]>([]); const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
// 定位结果数据
const [locationResults, setLocationResults] = useState<LocationResult[]>([]);
const { open } = useNotification();
// 使用受控或非受控状态 // 使用受控或非受控状态
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
@@ -88,6 +95,24 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setCurrentTab(newValue); setCurrentTab(newValue);
}; };
const handleLocateScheme = async (scheme: SchemeRecord) => {
try {
const response = await axios.get(
`${config.BACKEND_URL}/postgresql/burst-locate-result/${scheme.schemeName}`
);
setLocationResults(response.data);
setCurrentTab(2); // 切换到定位结果标签页
} catch (error) {
console.error("获取定位结果失败:", error);
open?.({
type: "error",
message: "获取定位结果失败",
description: "无法从服务器获取该方案的定位结果",
});
}
};
const drawerWidth = 520; const drawerWidth = 520;
return ( return (
@@ -214,19 +239,21 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
<SchemeQuery <SchemeQuery
schemes={schemes} schemes={schemes}
onSchemesChange={setSchemes} onSchemesChange={setSchemes}
onLocate={(id) => { onLocate={handleLocateScheme}
console.log("定位方案:", id);
// TODO: 在地图上定位
}}
/> />
</TabPanel> </TabPanel>
<TabPanel value={currentTab} index={2}> <TabPanel value={currentTab} index={2}>
<LocationResults <LocationResults
onLocate={(coordinates) => { results={locationResults}
console.log("定位到:", coordinates); onLocate={(result) => {
console.log("定位到:", result.locate_result);
// TODO: 地图定位到指定坐标 // TODO: 地图定位到指定坐标
}} }}
onLocateAll={(results) => {
console.log("定位全部结果:", results);
// TODO: 地图定位到所有结果坐标
}}
onViewDetail={(id) => { onViewDetail={(id) => {
console.log("查看节点详情:", id); console.log("查看节点详情:", id);
// TODO: 显示节点详细信息 // TODO: 显示节点详细信息

View File

@@ -1,132 +1,188 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { import {
Box, Box,
Typography, Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip, Chip,
IconButton, IconButton,
Tooltip, Tooltip,
Link,
} from "@mui/material"; } from "@mui/material";
import { LocationOn as LocationIcon } from "@mui/icons-material";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { useMap } from "@app/OlMap/MapComponent";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Stroke, Style, Icon } from "ol/style";
import Feature, { FeatureLike } from "ol/Feature";
import { import {
LocationOn as LocationIcon, along,
Visibility as VisibilityIcon, lineString,
} from "@mui/icons-material"; length,
toMercator,
bbox,
featureCollection,
} from "@turf/turf";
import { Point } from "ol/geom";
import { toLonLat } from "ol/proj";
import moment from "moment";
import "moment-timezone";
interface LocationResult { export interface LocationResult {
id: number; id: number;
nodeName: string; type: string;
nodeId: string; burst_incident: string;
pressure: number; leakage: number | null;
waterLevel: number; detect_time: string;
flow: number; locate_result: string[] | null;
status: "normal" | "warning" | "danger";
coordinates: [number, number];
} }
interface LocationResultsProps { interface LocationResultsProps {
onLocate?: (coordinates: [number, number]) => void; results?: LocationResult[];
onViewDetail?: (id: number) => void;
} }
const LocationResults: React.FC<LocationResultsProps> = ({ const LocationResults: React.FC<LocationResultsProps> = ({ results = [] }) => {
onLocate, const [highlightLayer, setHighlightLayer] =
onViewDetail, useState<VectorLayer<VectorSource> | null>(null);
}) => { const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const [results, setResults] = useState<LocationResult[]>([ const map = useMap();
// 示例数据
// {
// id: 1,
// nodeName: '节点A',
// nodeId: 'N001',
// pressure: 0.35,
// waterLevel: 12.5,
// flow: 85.3,
// status: 'normal',
// coordinates: [120.15, 30.25],
// },
]);
const getStatusColor = (status: string) => { // 格式化时间为 UTC+8
switch (status) { const formatTime = (timeStr: string) => {
case "normal": return moment(timeStr).utcOffset(8).format("YYYY-MM-DD HH:mm:ss");
return "success"; };
case "warning":
return "warning"; const handleLocatePipes = (pipeIds: string[]) => {
case "danger": if (pipeIds.length > 0) {
return "error"; queryFeaturesByIds(pipeIds).then((features) => {
default: if (features.length > 0) {
return "default"; // 设置高亮要素
setHighlightFeatures(features);
// 将 OpenLayers Feature 转换为 GeoJSON Feature
const geojsonFormat = new GeoJSON();
const geojsonFeatures = features.map((feature) =>
geojsonFormat.writeFeatureObject(feature)
);
const extent = bbox(featureCollection(geojsonFeatures as any));
if (extent) {
map?.getView().fit(extent, { maxZoom: 18, duration: 1000 });
}
}
});
} }
}; };
const getStatusText = (status: string) => { // 初始化管道图层和高亮图层
switch (status) { useEffect(() => {
case "normal": if (!map) return;
return "正常";
case "warning": const burstPipeStyle = function (feature: FeatureLike) {
return "预警"; const styles = [];
case "danger": // 线条样式(底层发光,主线条,内层高亮线)
return "危险"; styles.push(
default: new Style({
return "未知"; stroke: new Stroke({
color: "rgba(255, 0, 0, 0.3)",
width: 12,
}),
}),
new Style({
stroke: new Stroke({
color: "rgba(255, 0, 0, 1)",
width: 6,
lineDash: [15, 10],
}),
}),
new Style({
stroke: new Stroke({
color: "rgba(255, 102, 102, 1)",
width: 3,
lineDash: [15, 10],
}),
})
);
const geometry = feature.getGeometry();
const lineCoords =
geometry?.getType() === "LineString"
? (geometry as any).getCoordinates()
: null;
if (geometry && lineCoords) {
const lineCoordsWGS84 = lineCoords.map((coord: []) => {
const [lon, lat] = toLonLat(coord);
return [lon, lat];
});
// 计算中点
const lineStringFeature = lineString(lineCoordsWGS84);
const lineLength = length(lineStringFeature);
const midPoint = along(lineStringFeature, lineLength / 2).geometry
.coordinates;
// 在中点添加 icon 样式
const midPointMercator = toMercator(midPoint);
styles.push(
new Style({
geometry: new Point(midPointMercator),
image: new Icon({
src: "/icons/burst_pipe.svg",
scale: 0.2,
anchor: [0.5, 1],
}),
})
);
}
return styles;
};
// 创建高亮图层
const highlightLayer = new VectorLayer({
source: new VectorSource(),
style: burstPipeStyle,
maxZoom: 24,
minZoom: 12,
properties: {
name: "爆管管段高亮",
value: "burst_pipe_highlight",
},
});
map.addLayer(highlightLayer);
setHighlightLayer(highlightLayer);
return () => {
map.removeLayer(highlightLayer);
};
}, [map]);
// 高亮要素的函数
useEffect(() => {
if (!highlightLayer) {
return;
} }
}; const source = highlightLayer.getSource();
if (!source) {
return;
}
// 清除之前的高亮
source.clear();
// 添加新的高亮要素
highlightFeatures.forEach((feature) => {
if (feature instanceof Feature) {
source.addFeature(feature);
}
});
}, [highlightFeatures, highlightLayer]);
// 取第一条记录或空对象
const result = results.length > 0 ? results[0] : null;
return ( return (
<Box className="flex flex-col h-full"> <Box className="flex flex-col h-full">
{/* 统计信息 */} {/* 结果展示 */}
<Box className="mb-4 p-4 bg-gray-50 rounded"> <Box className="flex-1 overflow-auto bg-white rounded border border-gray-200">
<Typography variant="subtitle2" className="mb-2 font-medium"> {!result ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4">
</Typography>
<Box className="flex gap-4">
<Box>
<Typography variant="caption" className="text-gray-600">
</Typography>
<Typography variant="h6" className="font-bold">
{results.length}
</Typography>
</Box>
<Box>
<Typography variant="caption" className="text-gray-600">
</Typography>
<Typography variant="h6" className="font-bold text-green-600">
{results.filter((r) => r.status === "normal").length}
</Typography>
</Box>
<Box>
<Typography variant="caption" className="text-gray-600">
</Typography>
<Typography variant="h6" className="font-bold text-orange-600">
{results.filter((r) => r.status === "warning").length}
</Typography>
</Box>
<Box>
<Typography variant="caption" className="text-gray-600">
</Typography>
<Typography variant="h6" className="font-bold text-red-600">
{results.filter((r) => r.status === "danger").length}
</Typography>
</Box>
</Box>
</Box>
{/* 结果列表 */}
<Box className="flex-1 overflow-auto">
{results.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4"> <Box className="mb-4">
<svg <svg
width="80" width="80"
@@ -183,65 +239,155 @@ const LocationResults: React.FC<LocationResultsProps> = ({
</Typography> </Typography>
</Box> </Box>
) : ( ) : (
<TableContainer component={Paper} className="shadow-none"> <Box className="p-5 h-full overflow-auto">
<Table size="small" stickyHeader> {/* 头部:标识信息 */}
<TableHead> <Box className="mb-5">
<TableRow className="bg-gray-50"> <Box className="flex items-center gap-2 mb-1">
<TableCell></TableCell> <Typography
<TableCell>ID</TableCell> variant="h6"
<TableCell align="right"> (MPa)</TableCell> className="font-bold text-gray-900"
<TableCell align="right"> (m)</TableCell> title={result.burst_incident}
<TableCell align="right"> (m³/h)</TableCell> >
<TableCell align="center"></TableCell> {result.burst_incident}
<TableCell align="center"></TableCell> </Typography>
</TableRow> <Chip
</TableHead> label={result.type}
<TableBody> size="small"
{results.map((result) => ( color="primary"
<TableRow key={result.id} hover> variant="outlined"
<TableCell>{result.nodeName}</TableCell> sx={{
<TableCell>{result.nodeId}</TableCell> fontWeight: 600,
<TableCell align="right"> fontSize: "0.75rem",
{result.pressure.toFixed(3)} height: "24px",
</TableCell> }}
<TableCell align="right"> />
{result.waterLevel.toFixed(3)} </Box>
</TableCell> <Typography variant="caption" className="text-gray-500">
<TableCell align="right"> ID: {result.id}
{result.flow.toFixed(1)} </Typography>
</TableCell> </Box>
<TableCell align="center">
<Chip {/* 主要信息:三栏卡片布局 */}
label={getStatusText(result.status)} <Box className="grid grid-cols-3 gap-3 mb-5">
size="small" {/* 检测时间卡片 */}
color={getStatusColor(result.status) as any} <Box className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 border border-blue-200 shadow-sm hover:shadow-md transition-shadow">
/> <Box className="flex items-center gap-1.5 mb-2">
</TableCell> <Box className="w-1.5 h-1.5 rounded-full bg-blue-600"></Box>
<TableCell align="center"> <Typography
<Tooltip title="定位"> variant="caption"
<IconButton className="text-blue-700 font-semibold uppercase tracking-wide"
size="small" sx={{ fontSize: "0.7rem" }}
onClick={() => onLocate?.(result.coordinates)} >
color="primary"
</Typography>
</Box>
<Typography
variant="body2"
className="font-bold text-blue-900 leading-tight"
sx={{ fontSize: "0.875rem" }}
>
{formatTime(result.detect_time)}
</Typography>
</Box>
{/* 漏损量卡片 */}
<Box className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-3 border border-orange-200 shadow-sm hover:shadow-md transition-shadow">
<Box className="flex items-center gap-1.5 mb-2">
<Box className="w-1.5 h-1.5 rounded-full bg-orange-600"></Box>
<Typography
variant="caption"
className="text-orange-700 font-semibold uppercase tracking-wide"
sx={{ fontSize: "0.7rem" }}
>
</Typography>
</Box>
<Typography
variant="body2"
className="font-bold text-orange-900"
sx={{ fontSize: "0.875rem" }}
>
{result.leakage !== null
? `${result.leakage.toFixed(2)} m³/h`
: "N/A"}
</Typography>
</Box>
{/* 定位管段数量卡片 */}
<Box className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-3 border border-green-200 shadow-sm hover:shadow-md transition-shadow">
<Box className="flex items-center gap-1.5 mb-2">
<Box className="w-1.5 h-1.5 rounded-full bg-green-600"></Box>
<Typography
variant="caption"
className="text-green-700 font-semibold uppercase tracking-wide"
sx={{ fontSize: "0.7rem" }}
>
</Typography>
</Box>
<Typography
variant="body2"
className="font-bold text-green-900"
sx={{ fontSize: "0.875rem" }}
>
{result.locate_result ? result.locate_result.length : 0}{" "}
</Typography>
</Box>
</Box>
{/* 定位管段详细列表 */}
{result.locate_result && result.locate_result.length > 0 && (
<Box className="bg-white rounded-lg p-4 border-2 border-blue-200 shadow-sm">
<Box className="flex items-center justify-between mb-3">
<Typography
variant="body1"
className="text-gray-900 font-bold"
sx={{ fontSize: "0.95rem" }}
>
</Typography>
<Tooltip title="定位所有管道">
<IconButton
size="small"
onClick={() => handleLocatePipes(result.locate_result!)}
color="primary"
sx={{
backgroundColor: "rgba(37, 125, 212, 0.1)",
"&:hover": {
backgroundColor: "rgba(37, 125, 212, 0.2)",
},
}}
>
<LocationIcon sx={{ fontSize: "1.2rem" }} />
</IconButton>
</Tooltip>
</Box>
<Box className="grid grid-cols-2 gap-2">
{result.locate_result.map((pipeId, idx) => (
<Box
key={idx}
className="bg-gradient-to-r from-blue-50 to-white rounded-lg px-3 py-2 border border-blue-200 hover:border-blue-400 hover:shadow-md transition-all cursor-pointer group"
onClick={() => handleLocatePipes([pipeId])}
>
<Box className="flex items-center justify-between">
<Typography
variant="body2"
className="font-semibold text-blue-700 group-hover:text-blue-900"
> >
<LocationIcon fontSize="small" /> {pipeId}
</IconButton> </Typography>
</Tooltip> <LocationIcon
<Tooltip title="查看详情"> sx={{ fontSize: "1rem" }}
<IconButton className="text-blue-400 group-hover:text-blue-600 transition-colors"
size="small" />
onClick={() => onViewDetail?.(result.id)} </Box>
color="primary" </Box>
> ))}
<VisibilityIcon fontSize="small" /> </Box>
</IconButton> </Box>
</Tooltip> )}
</TableCell> </Box>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)} )}
</Box> </Box>
</Box> </Box>

View File

@@ -83,7 +83,7 @@ interface SchemaItem {
interface SchemeQueryProps { interface SchemeQueryProps {
schemes?: SchemeRecord[]; schemes?: SchemeRecord[];
onSchemesChange?: (schemes: SchemeRecord[]) => void; onSchemesChange?: (schemes: SchemeRecord[]) => void;
onLocate?: (id: number) => void; onLocate?: (scheme: SchemeRecord) => void;
network?: string; network?: string;
} }
@@ -486,7 +486,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
<Tooltip title="定位"> <Tooltip title="定位">
<IconButton <IconButton
size="small" size="small"
onClick={() => onLocate?.(scheme.id)} onClick={() => onLocate?.(scheme)}
color="primary" color="primary"
className="p-1" className="p-1"
> >

View File

@@ -605,6 +605,12 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
setIsCleaning(true); setIsCleaning(true);
open?.({
type: "progress",
message: "正在清洗数据,请稍候...",
undoableTimeout: 3000,
});
try { try {
const startTime = dayjs(cleanStartTime).toISOString(); const startTime = dayjs(cleanStartTime).toISOString();
const endTime = dayjs(cleanEndTime).toISOString(); const endTime = dayjs(cleanEndTime).toISOString();