1106 lines
40 KiB
TypeScript
1106 lines
40 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useEffect, useCallback } from "react";
|
||
import {
|
||
Box,
|
||
Typography,
|
||
Chip,
|
||
CircularProgress,
|
||
IconButton,
|
||
Tooltip,
|
||
Button,
|
||
Stack,
|
||
Divider,
|
||
Stepper,
|
||
Step,
|
||
StepLabel,
|
||
StepContent,
|
||
Paper,
|
||
Alert,
|
||
} from "@mui/material";
|
||
import {
|
||
LocationOn as LocationIcon,
|
||
Close as CloseIcon,
|
||
CheckCircle as CheckCircleIcon,
|
||
Search as SearchIcon,
|
||
ExpandMore as ExpandMoreIcon,
|
||
CheckBox as CheckBoxIcon,
|
||
CheckBoxOutlineBlank as CheckBoxOutlineBlankIcon,
|
||
} from "@mui/icons-material";
|
||
import { api } from "@/lib/api";
|
||
import { config, NETWORK_NAME } from "@config/config";
|
||
import { ValveIsolationResult } from "./types";
|
||
import { useNotification } from "@refinedev/core";
|
||
import {
|
||
queryFeaturesByIds,
|
||
handleMapClickSelectFeatures,
|
||
} from "@/utils/mapQueryService";
|
||
import { useMap } from "@components/olmap/core/MapComponent";
|
||
import { GeoJSON } from "ol/format";
|
||
import VectorLayer from "ol/layer/Vector";
|
||
import VectorSource from "ol/source/Vector";
|
||
import { Circle as CircleStyle, Fill, Stroke, Style, Icon } from "ol/style";
|
||
import Feature, { FeatureLike } from "ol/Feature";
|
||
import {
|
||
bbox,
|
||
featureCollection,
|
||
along,
|
||
lineString,
|
||
length,
|
||
toMercator,
|
||
} from "@turf/turf";
|
||
import { Point } from "ol/geom";
|
||
import { toLonLat } from "ol/proj";
|
||
|
||
interface ValveIsolationProps {
|
||
initialPipeIds?: string[];
|
||
shouldFetch?: boolean;
|
||
onFetchComplete?: () => void;
|
||
loading?: boolean;
|
||
result?: ValveIsolationResult | null;
|
||
onLoadingChange?: (loading: boolean) => void;
|
||
onResultChange?: (result: ValveIsolationResult | null) => void;
|
||
}
|
||
|
||
const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||
initialPipeIds = [],
|
||
shouldFetch = false,
|
||
onFetchComplete,
|
||
loading: externalLoading,
|
||
result: externalResult,
|
||
onLoadingChange,
|
||
onResultChange,
|
||
}) => {
|
||
const [internalLoading, setInternalLoading] = useState(false);
|
||
const [internalResult, setInternalResult] =
|
||
useState<ValveIsolationResult | null>(null);
|
||
|
||
// 使用外部状态或内部状态
|
||
const loading =
|
||
externalLoading !== undefined ? externalLoading : internalLoading;
|
||
const result = externalResult !== undefined ? externalResult : internalResult;
|
||
const setLoading = onLoadingChange || setInternalLoading;
|
||
const setResult = onResultChange || setInternalResult;
|
||
|
||
const [selectedPipeId, setSelectedPipeId] = useState<string | null>(null);
|
||
const [highlightFeature, setHighlightFeature] = useState<Feature | null>(null);
|
||
const [isSelecting, setIsSelecting] = useState(false);
|
||
const [activeStep, setActiveStep] = useState(0);
|
||
const [expandedResult, setExpandedResult] = useState(true);
|
||
const [disabledValves, setDisabledValves] = useState<string[]>([]);
|
||
|
||
const { open } = useNotification();
|
||
const map = useMap();
|
||
|
||
const handleMapClick = useCallback(
|
||
async (event: any) => {
|
||
if (!isSelecting || !map) return;
|
||
|
||
const feature = await handleMapClickSelectFeatures(event, map);
|
||
if (feature) {
|
||
const pipeId = feature.get("id");
|
||
if (pipeId) {
|
||
// 确保是管道
|
||
const layerId = feature.getId()?.toString().split(".")[0] || "";
|
||
const isPipe = layerId.includes("pipe") || layerId.includes("Pipe");
|
||
|
||
if (!isPipe) {
|
||
open?.({
|
||
type: "error",
|
||
message: "请选择管道类型要素",
|
||
});
|
||
return;
|
||
}
|
||
|
||
setSelectedPipeId(pipeId);
|
||
setHighlightFeature(feature);
|
||
setIsSelecting(false);
|
||
setResult(null); // 清除旧结果
|
||
}
|
||
}
|
||
},
|
||
[isSelecting, map, open, setResult],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!map) return;
|
||
if (isSelecting) {
|
||
map.on("click", handleMapClick);
|
||
} else {
|
||
map.un("click", handleMapClick);
|
||
}
|
||
|
||
return () => {
|
||
map.un("click", handleMapClick);
|
||
};
|
||
}, [map, isSelecting, handleMapClick]);
|
||
|
||
const clearSelectedPipe = () => {
|
||
setSelectedPipeId(null);
|
||
setHighlightFeature(null);
|
||
setHighlightFeatures([]);
|
||
setResult?.(null);
|
||
setActiveStep(0);
|
||
setExpandedResult(false);
|
||
setDisabledValves([]);
|
||
};
|
||
|
||
const [highlightLayer, setHighlightLayer] =
|
||
useState<VectorLayer<VectorSource> | null>(null);
|
||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||
const [highlightType, setHighlightType] = useState<
|
||
"must_close" | "optional" | "affected_node" | "pipe"
|
||
>("affected_node");
|
||
|
||
|
||
const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => {
|
||
if (pipeIds.length > 0) {
|
||
queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => {
|
||
if (features.length > 0) {
|
||
if (highlight) {
|
||
// 设置高亮类型为管段
|
||
setHighlightType("pipe");
|
||
// 设置高亮要素
|
||
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 handleLocateNodes = (nodeIds: string[]) => {
|
||
if (nodeIds.length > 0) {
|
||
queryFeaturesByIds(nodeIds, "geo_junctions").then((features) => {
|
||
if (features.length > 0) {
|
||
// 设置高亮类型为受影响节点
|
||
setHighlightType("affected_node");
|
||
// 设置高亮要素
|
||
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 handleLocateMustCloseValves = (valveIds: string[]) => {
|
||
if (valveIds.length > 0) {
|
||
queryFeaturesByIds(valveIds, "geo_valves").then((features) => {
|
||
if (features.length > 0) {
|
||
// 设置高亮类型为必关阀门
|
||
setHighlightType("must_close");
|
||
// 设置高亮要素
|
||
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 handleLocateOptionalValves = (valveIds: string[]) => {
|
||
if (valveIds.length > 0) {
|
||
queryFeaturesByIds(valveIds, "geo_valves").then((features) => {
|
||
if (features.length > 0) {
|
||
// 设置高亮类型为可选阀门
|
||
setHighlightType("optional");
|
||
// 设置高亮要素
|
||
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 fetchAnalysis = useCallback(
|
||
async (ids: string[], disabled: string[] = []) => {
|
||
if (!ids || ids.length === 0) {
|
||
open?.({ type: "error", message: "请在地图上选择要分析的管段" });
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
const isExpandSearch = disabled.length > 0;
|
||
if (!isExpandSearch) {
|
||
setResult(null);
|
||
setDisabledValves([]);
|
||
}
|
||
try {
|
||
const params: any = {
|
||
network: NETWORK_NAME,
|
||
accident_element: ids,
|
||
};
|
||
if (disabled.length > 0) {
|
||
params.disabled_valves = disabled;
|
||
}
|
||
const response = await api.get(
|
||
`${config.BACKEND_URL}/api/v1/valve_isolation_analysis/`,
|
||
{
|
||
params,
|
||
paramsSerializer: {
|
||
indexes: null, // 生成格式: accident_element=P1&accident_element=P2&disabled_valves=V1&disabled_valves=V2
|
||
},
|
||
},
|
||
);
|
||
setResult(response.data);
|
||
if (!isExpandSearch) {
|
||
setActiveStep(1);
|
||
} else {
|
||
setActiveStep(2);
|
||
}
|
||
open?.({ type: "success", message: isExpandSearch ? "扩大搜索成功" : "分析成功" });
|
||
} catch (error) {
|
||
console.error(error);
|
||
open?.({
|
||
type: "error",
|
||
message: "分析失败",
|
||
description: "无法获取关阀分析结果",
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[open, setLoading, setResult],
|
||
);
|
||
|
||
// 监听外部传入的分析请求
|
||
useEffect(() => {
|
||
if (shouldFetch && initialPipeIds.length > 0) {
|
||
// 这里简单地取第一个作为 selectedPipeId,实际 fetchAnalysis 支持数组
|
||
setSelectedPipeId(initialPipeIds[0]);
|
||
|
||
// 尝试获取Feature以高亮 (可选)
|
||
queryFeaturesByIds(initialPipeIds, "geo_pipes_mat").then((features) => {
|
||
if (features && features.length > 0) {
|
||
setHighlightFeature(features[0]);
|
||
}
|
||
});
|
||
|
||
fetchAnalysis(initialPipeIds);
|
||
|
||
if (onFetchComplete) {
|
||
onFetchComplete();
|
||
}
|
||
}
|
||
}, [shouldFetch, initialPipeIds, fetchAnalysis, onFetchComplete]);
|
||
|
||
// 初始化高亮图层
|
||
useEffect(() => {
|
||
if (!map) return;
|
||
|
||
// 动态样式函数,根据 highlightType 和 selectedPipeId 返回不同的样式
|
||
const getHighlightStyle = (feature: FeatureLike) => {
|
||
// 如果是当前选择的爆管点feature
|
||
if (highlightFeature && feature === highlightFeature) {
|
||
const styles = [];
|
||
// 线条样式(底层发光,主线条,内层高亮线)
|
||
styles.push(
|
||
new Style({
|
||
stroke: new Stroke({
|
||
color: "rgba(255, 0, 0, 0.3)",
|
||
width: 14,
|
||
}),
|
||
}),
|
||
new Style({
|
||
stroke: new Stroke({
|
||
color: "rgba(255, 0, 0, 1)",
|
||
width: 7,
|
||
lineDash: [15, 10],
|
||
}),
|
||
}),
|
||
new Style({
|
||
stroke: new Stroke({
|
||
color: "rgba(255, 102, 102, 1)",
|
||
width: 4,
|
||
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.25,
|
||
anchor: [0.5, 1],
|
||
}),
|
||
}),
|
||
);
|
||
}
|
||
return styles;
|
||
}
|
||
|
||
if (highlightType === "pipe") {
|
||
// 管段 - 多层红色线条样式 + 中点图标
|
||
const styles = [];
|
||
// 线条样式(底层发光,主线条,内层高亮线)
|
||
styles.push(
|
||
new Style({
|
||
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;
|
||
}
|
||
|
||
// 阀门和节点的样式
|
||
let color: string;
|
||
let strokeColor: string;
|
||
let radius: number;
|
||
|
||
switch (highlightType) {
|
||
case "must_close":
|
||
// 必关阀门 - 深红色
|
||
color = "rgba(211, 47, 47, 0.6)";
|
||
strokeColor = "rgba(211, 47, 47, 1)";
|
||
radius = 10;
|
||
break;
|
||
case "optional":
|
||
// 可选阀门 - 橙色
|
||
color = "rgba(237, 108, 2, 0.6)";
|
||
strokeColor = "rgba(237, 108, 2, 1)";
|
||
radius = 10;
|
||
break;
|
||
case "affected_node":
|
||
default:
|
||
// 受影响节点 - 蓝色
|
||
color = "rgba(25, 118, 210, 0.6)";
|
||
strokeColor = "rgba(25, 118, 210, 1)";
|
||
radius = 8;
|
||
break;
|
||
}
|
||
|
||
return new Style({
|
||
image: new CircleStyle({
|
||
radius: radius,
|
||
fill: new Fill({
|
||
color: color,
|
||
}),
|
||
stroke: new Stroke({
|
||
color: strokeColor,
|
||
width: 3,
|
||
}),
|
||
}),
|
||
});
|
||
};
|
||
|
||
// 创建高亮图层
|
||
const highlightLayer = new VectorLayer({
|
||
source: new VectorSource(),
|
||
style: getHighlightStyle,
|
||
maxZoom: 24,
|
||
minZoom: 12,
|
||
properties: {
|
||
name: "阀门节点高亮",
|
||
value: "valve_node_highlight",
|
||
},
|
||
});
|
||
|
||
map.addLayer(highlightLayer);
|
||
setHighlightLayer(highlightLayer);
|
||
|
||
return () => {
|
||
map.removeLayer(highlightLayer);
|
||
};
|
||
}, [map, highlightType, highlightFeature]);
|
||
|
||
// 高亮要素的函数
|
||
useEffect(() => {
|
||
if (!highlightLayer) {
|
||
return;
|
||
}
|
||
const source = highlightLayer.getSource();
|
||
if (!source) {
|
||
return;
|
||
}
|
||
// 清除之前的高亮
|
||
source.clear();
|
||
|
||
// 如果有选中的爆管点(pipe),优先添加到source
|
||
if (highlightFeature) {
|
||
// 设置一个特殊的属性来区分
|
||
highlightFeature.set('isHighlightPipe', true);
|
||
source.addFeature(highlightFeature);
|
||
}
|
||
|
||
// 添加其他高亮要素
|
||
highlightFeatures.forEach((feature) => {
|
||
if (feature instanceof Feature) {
|
||
source.addFeature(feature);
|
||
}
|
||
});
|
||
|
||
}, [highlightFeatures, highlightLayer, highlightFeature]);
|
||
|
||
// 切换不可用阀门的选择状态
|
||
const toggleDisabledValve = (valveId: string) => {
|
||
setDisabledValves((prev) => {
|
||
if (prev.includes(valveId)) {
|
||
return prev.filter((id) => id !== valveId);
|
||
} else {
|
||
return [...prev, valveId];
|
||
}
|
||
});
|
||
};
|
||
|
||
// 渲染结果卡片
|
||
const renderResultCard = (isExpanded: boolean = false, allowSelectDisabled: boolean = false) => {
|
||
if (!result) return null;
|
||
|
||
return (
|
||
<Box className="space-y-4">
|
||
{/* 状态信息 */}
|
||
<Paper elevation={0} className="p-4 bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200">
|
||
<Box className="flex items-center justify-between mb-2">
|
||
<Box className="flex items-center gap-2">
|
||
<Typography variant="subtitle1" className="font-bold text-gray-900">
|
||
{isExpanded ? "扩大搜索结果" : "分析结果"}
|
||
</Typography>
|
||
<Chip
|
||
label={result.isolatable ? "可隔离" : "不可隔离"}
|
||
size="small"
|
||
color={result.isolatable ? "success" : "error"}
|
||
variant="filled"
|
||
sx={{ fontWeight: 700, borderRadius: "6px" }}
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* 事故管段 */}
|
||
<Box className="bg-white rounded-lg p-3 border border-red-100 mb-3">
|
||
<Box className="flex items-center justify-between mb-2">
|
||
<Typography variant="caption" className="text-red-800 font-bold flex items-center gap-1.5">
|
||
<Box component="span" className="w-1.5 h-1.5 rounded-full bg-red-600 inline-block" />
|
||
目标事故管段
|
||
</Typography>
|
||
{/* {result.accident_elements && result.accident_elements.length > 0 && (
|
||
<Tooltip title="定位全部">
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => handleLocatePipes(result.accident_elements!)}
|
||
sx={{ color: "rgb(220, 38, 38)", padding: "2px" }}
|
||
>
|
||
<LocationIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
)} */}
|
||
</Box>
|
||
<Box className="flex flex-wrap gap-2">
|
||
{result.accident_elements?.map((pipeId, idx) => (
|
||
<Chip
|
||
key={idx}
|
||
label={pipeId}
|
||
size="small"
|
||
onClick={() => handleLocatePipes([pipeId], false)}
|
||
sx={{
|
||
backgroundColor: "rgb(254, 242, 242)",
|
||
border: "1px solid rgb(252, 165, 165)",
|
||
color: "rgb(185, 28, 28)",
|
||
fontWeight: 600,
|
||
"&:hover": {
|
||
backgroundColor: "rgb(254, 226, 226)",
|
||
borderColor: "rgb(239, 68, 68)",
|
||
},
|
||
}}
|
||
/>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* 统计概览 */}
|
||
<Box className="grid grid-cols-3 gap-2">
|
||
{[
|
||
{ label: "必关阀门", value: result.must_close_valves?.length || 0, color: "red", bgInfo: "from-red-50 to-red-100", textInfo: "text-red-700" },
|
||
{ label: "可选阀门", value: result.optional_valves?.length || 0, color: "orange", bgInfo: "from-orange-50 to-orange-100", textInfo: "text-orange-700" },
|
||
{ label: "影响节点", value: result.affected_nodes?.length || 0, color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" },
|
||
].map((item, index) => (
|
||
<Box
|
||
key={index}
|
||
className={`bg-gradient-to-br ${item.bgInfo} rounded-lg p-2 border border-${item.color}-200 text-center`}
|
||
>
|
||
<Typography variant="h6" className={`font-black ${item.textInfo}`}>
|
||
{item.value}
|
||
</Typography>
|
||
<Typography variant="caption" className={`font-semibold opacity-75 ${item.textInfo}`}>
|
||
{item.label}
|
||
</Typography>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Paper>
|
||
|
||
{/* 必关阀门选择提示 - 只在流程2显示 */}
|
||
{allowSelectDisabled && result.must_close_valves && result.must_close_valves.length > 0 && (
|
||
<Alert severity="info" variant="outlined">
|
||
<Typography variant="body2" className="font-semibold mb-1">
|
||
可选择无法关闭的阀门
|
||
</Typography>
|
||
<Typography variant="caption" className="text-gray-600">
|
||
点击下方必关阀门列表中的阀门进行选择,已选择的阀门将在扩大搜索时作为不可用阀门处理
|
||
</Typography>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* 详细列表 - 可折叠 */}
|
||
<Box>
|
||
<Button
|
||
fullWidth
|
||
variant="outlined"
|
||
onClick={() => setExpandedResult(!expandedResult)}
|
||
endIcon={<ExpandMoreIcon sx={{ transform: expandedResult ? 'rotate(180deg)' : 'rotate(0)', transition: 'transform 0.3s' }} />}
|
||
sx={{ mb: 2 }}
|
||
>
|
||
{expandedResult ? "收起详细信息" : "查看详细信息"}
|
||
</Button>
|
||
|
||
{expandedResult && (
|
||
<Box className="flex flex-col gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||
{/* 必关阀门 */}
|
||
{result.must_close_valves && result.must_close_valves.length > 0 && (
|
||
<Box>
|
||
<Box className="flex items-center justify-between mb-2">
|
||
<Typography variant="caption" className="text-gray-800 font-bold border-l-4 border-red-500 pl-2">
|
||
必关阀门列表 ({result.must_close_valves.length})
|
||
{allowSelectDisabled && " - 点击勾选不可用阀门"}
|
||
</Typography>
|
||
<Tooltip title="定位全部">
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => handleLocateMustCloseValves(result.must_close_valves!)}
|
||
sx={{
|
||
color: "rgb(211, 47, 47)",
|
||
backgroundColor: "rgba(211, 47, 47, 0.1)",
|
||
"&:hover": {
|
||
backgroundColor: "rgba(211, 47, 47, 0.2)",
|
||
},
|
||
}}
|
||
>
|
||
<LocationIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Box>
|
||
<Box className="grid grid-cols-2 gap-2">
|
||
{result.must_close_valves.map((valveId, idx) => {
|
||
const isSelected = disabledValves.includes(valveId);
|
||
return (
|
||
<Box
|
||
key={idx}
|
||
className={`bg-gradient-to-r rounded-lg px-3 py-2 border transition-all cursor-pointer group ${allowSelectDisabled && isSelected
|
||
? "from-orange-50 to-white border-orange-500 hover:shadow-md"
|
||
: "from-red-50 to-white border-red-200 hover:border-red-400 hover:shadow-md"
|
||
}`}
|
||
onClick={() => handleLocateMustCloseValves([valveId])}
|
||
sx={{
|
||
"&:active": {
|
||
transform: "scale(0.98)",
|
||
},
|
||
}}
|
||
>
|
||
<Box className="flex items-center justify-between">
|
||
<Typography
|
||
variant="body2"
|
||
className={`font-semibold ${allowSelectDisabled && isSelected
|
||
? "text-orange-700 group-hover:text-orange-900"
|
||
: "text-red-700 group-hover:text-red-900"
|
||
}`}
|
||
>
|
||
{valveId}
|
||
</Typography>
|
||
{allowSelectDisabled && (
|
||
<Box
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
toggleDisabledValve(valveId);
|
||
}}
|
||
className="cursor-pointer"
|
||
>
|
||
{isSelected ? (
|
||
<CheckBoxIcon fontSize="small" className="text-orange-600" />
|
||
) : (
|
||
<CheckBoxOutlineBlankIcon fontSize="small" className="text-gray-400" />
|
||
)}
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
);
|
||
})}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
{/* 可选阀门 */}
|
||
{result.optional_valves && result.optional_valves.length > 0 && (
|
||
<Box>
|
||
<Box className="flex items-center justify-between mb-2">
|
||
<Typography variant="caption" className="text-gray-800 font-bold border-l-4 border-orange-500 pl-2">
|
||
可选阀门列表 ({result.optional_valves.length})
|
||
</Typography>
|
||
<Tooltip title="定位全部">
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => handleLocateOptionalValves(result.optional_valves!)}
|
||
sx={{
|
||
color: "rgb(237, 108, 2)",
|
||
backgroundColor: "rgba(237, 108, 2, 0.1)",
|
||
"&:hover": {
|
||
backgroundColor: "rgba(237, 108, 2, 0.2)",
|
||
},
|
||
}}
|
||
>
|
||
<LocationIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Box>
|
||
<Box className="grid grid-cols-2 gap-2">
|
||
{result.optional_valves.map((valveId, idx) => (
|
||
<Box
|
||
key={idx}
|
||
className="bg-gradient-to-r from-orange-50 to-white rounded-lg px-3 py-2 border border-orange-200 hover:border-orange-400 hover:shadow-md transition-all cursor-pointer group"
|
||
onClick={() => handleLocateOptionalValves([valveId])}
|
||
sx={{
|
||
"&:active": {
|
||
transform: "scale(0.98)",
|
||
},
|
||
}}
|
||
>
|
||
<Box className="flex items-center justify-between">
|
||
<Typography
|
||
variant="body2"
|
||
className="font-semibold text-orange-700 group-hover:text-orange-900"
|
||
>
|
||
{valveId}
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
{/* 受影响节点 */}
|
||
{result.affected_nodes && result.affected_nodes.length > 0 && (
|
||
<Box>
|
||
<Box className="flex items-center justify-between mb-2">
|
||
<Typography variant="caption" className="text-gray-800 font-bold border-l-4 border-blue-500 pl-2">
|
||
受影响节点 ({result.affected_nodes.length})
|
||
</Typography>
|
||
<Tooltip title="定位全部">
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => handleLocateNodes(result.affected_nodes!)}
|
||
sx={{
|
||
color: "rgb(25, 118, 210)",
|
||
backgroundColor: "rgba(25, 118, 210, 0.1)",
|
||
"&:hover": {
|
||
backgroundColor: "rgba(25, 118, 210, 0.2)",
|
||
},
|
||
}}
|
||
>
|
||
<LocationIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Box>
|
||
<Box className="grid grid-cols-2 gap-2">
|
||
{result.affected_nodes.map((nodeId, 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={() => handleLocateNodes([nodeId])}
|
||
sx={{
|
||
"&:active": {
|
||
transform: "scale(0.98)",
|
||
},
|
||
}}
|
||
>
|
||
<Box className="flex items-center justify-between">
|
||
<Typography
|
||
variant="body2"
|
||
className="font-semibold text-blue-700 group-hover:text-blue-900"
|
||
>
|
||
{nodeId}
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Box className="flex flex-col h-full bg-gray-50">
|
||
{/* 流程区域 */}
|
||
<Box className="flex-1 overflow-auto p-4">
|
||
<Stepper activeStep={activeStep} orientation="vertical">
|
||
{/* 流程1:选择管段并分析 */}
|
||
<Step>
|
||
<StepLabel
|
||
StepIconComponent={() => (
|
||
<Box
|
||
className={`w-8 h-8 rounded-full flex items-center justify-center font-bold ${activeStep >= 0
|
||
? "bg-blue-600 text-white"
|
||
: "bg-gray-300 text-gray-600"
|
||
}`}
|
||
>
|
||
1
|
||
</Box>
|
||
)}
|
||
>
|
||
<Typography variant="subtitle1" className="font-bold">
|
||
选择管段并进行分析
|
||
</Typography>
|
||
</StepLabel>
|
||
<StepContent>
|
||
<Box className="ml-4 pl-4 border-l-2 border-gray-200 space-y-3">
|
||
{/* 选择管段 */}
|
||
<Paper elevation={0} className="p-3 bg-white border border-gray-200">
|
||
<Box className="flex items-center justify-between mb-2">
|
||
<Typography variant="subtitle2" className="font-medium text-gray-700">
|
||
选择爆管管段
|
||
</Typography>
|
||
{!isSelecting ? (
|
||
<Button
|
||
variant="outlined"
|
||
size="small"
|
||
onClick={() => setIsSelecting(true)}
|
||
className="border-blue-500 text-blue-600 hover:bg-blue-50"
|
||
startIcon={
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
|
||
/>
|
||
</svg>
|
||
}
|
||
>
|
||
开始选择
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
variant="contained"
|
||
size="small"
|
||
onClick={() => setIsSelecting(false)}
|
||
className="bg-red-500 hover:bg-red-600"
|
||
startIcon={<CloseIcon />}
|
||
>
|
||
结束选择
|
||
</Button>
|
||
)}
|
||
</Box>
|
||
|
||
{isSelecting && (
|
||
<Box className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
|
||
💡 点击地图上的管道添加爆管点
|
||
</Box>
|
||
)}
|
||
|
||
{selectedPipeId ? (
|
||
<Box className="flex items-center gap-2 p-2 bg-green-50 rounded border border-green-200">
|
||
<CheckCircleIcon className="text-green-600" fontSize="small" />
|
||
<Typography className="flex-1 text-sm font-medium pl-1 text-gray-700">
|
||
{selectedPipeId}
|
||
</Typography>
|
||
<Typography className="text-xs text-gray-500">
|
||
已选择
|
||
</Typography>
|
||
<IconButton
|
||
size="small"
|
||
onClick={clearSelectedPipe}
|
||
>
|
||
<CloseIcon fontSize="small" />
|
||
</IconButton>
|
||
</Box>
|
||
) : (
|
||
<Box className="p-3 rounded border border-dashed border-gray-300 text-sm text-gray-400 text-center">
|
||
暂未选择管段
|
||
</Box>
|
||
)}
|
||
</Paper>
|
||
|
||
{/* 操作按钮 */}
|
||
<Box className="flex gap-2 pt-2">
|
||
<Button
|
||
variant="contained"
|
||
size="medium"
|
||
color="primary"
|
||
disabled={loading || !selectedPipeId}
|
||
onClick={() => selectedPipeId && fetchAnalysis([selectedPipeId], [])}
|
||
className="flex-1"
|
||
startIcon={loading ? <CircularProgress size={16} /> : <SearchIcon />}
|
||
>
|
||
{loading ? "正在分析..." : "开始分析"}
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
</StepContent>
|
||
</Step>
|
||
|
||
{/* 流程2:查看分析结果 */}
|
||
<Step>
|
||
<StepLabel
|
||
StepIconComponent={() => (
|
||
<Box
|
||
className={`w-8 h-8 rounded-full flex items-center justify-center font-bold ${activeStep >= 1
|
||
? "bg-blue-600 text-white"
|
||
: "bg-gray-300 text-gray-600"
|
||
}`}
|
||
>
|
||
2
|
||
</Box>
|
||
)}
|
||
>
|
||
<Typography variant="subtitle1" className="font-bold">
|
||
查看分析结果
|
||
</Typography>
|
||
</StepLabel>
|
||
<StepContent>
|
||
<Box className="ml-4 pl-4 border-l-2 border-gray-200 space-y-3">
|
||
{loading ? (
|
||
<Box className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||
<CircularProgress size={40} className="mb-4 text-blue-600" />
|
||
<Typography variant="body2" className="text-gray-600 font-medium">
|
||
分析计算中,请稍候...
|
||
</Typography>
|
||
</Box>
|
||
) : result ? (
|
||
<>
|
||
{renderResultCard(false, true)}
|
||
|
||
{/* 操作按钮 */}
|
||
<Box className="flex gap-2 pt-2">
|
||
<Button
|
||
variant="outlined"
|
||
size="medium"
|
||
onClick={() => {
|
||
clearSelectedPipe();
|
||
}}
|
||
className="flex-1"
|
||
>
|
||
重新选择管段
|
||
</Button>
|
||
<Button
|
||
variant="contained"
|
||
size="medium"
|
||
color="warning"
|
||
disabled={loading || disabledValves.length === 0}
|
||
onClick={() => selectedPipeId && fetchAnalysis([selectedPipeId], disabledValves)}
|
||
className="flex-1"
|
||
startIcon={loading ? <CircularProgress size={16} /> : <SearchIcon />}
|
||
>
|
||
扩大搜索 {disabledValves.length > 0 && `(${disabledValves.length})`}
|
||
</Button>
|
||
</Box>
|
||
</>
|
||
) : (
|
||
<Alert severity="info" variant="outlined">
|
||
请先完成流程1的分析操作
|
||
</Alert>
|
||
)}
|
||
</Box>
|
||
</StepContent>
|
||
</Step>
|
||
|
||
{/* 流程3:扩大搜索结果 */}
|
||
<Step>
|
||
<StepLabel
|
||
StepIconComponent={() => (
|
||
<Box
|
||
className={`w-8 h-8 rounded-full flex items-center justify-center font-bold ${activeStep >= 2
|
||
? "bg-blue-600 text-white"
|
||
: "bg-gray-300 text-gray-600"
|
||
}`}
|
||
>
|
||
3
|
||
</Box>
|
||
)}
|
||
>
|
||
<Typography variant="subtitle1" className="font-bold">
|
||
扩大搜索结果(可选)
|
||
</Typography>
|
||
</StepLabel>
|
||
<StepContent>
|
||
<Box className="ml-4 pl-4 border-l-2 border-gray-200 space-y-3">
|
||
{loading ? (
|
||
<Box className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||
<CircularProgress size={40} className="mb-4 text-orange-600" />
|
||
<Typography variant="body2" className="text-gray-600 font-medium">
|
||
扩大搜索中,请稍候...
|
||
</Typography>
|
||
</Box>
|
||
) : activeStep >= 2 && result ? (
|
||
<>
|
||
{renderResultCard(true, false)}
|
||
|
||
{/* 最终结果提示 */}
|
||
{result.isolatable ? (
|
||
<Alert severity="success" variant="outlined">
|
||
<Typography variant="body2" className="font-semibold mb-1">
|
||
✓ 扩大搜索成功,找到可行的隔离方案
|
||
</Typography>
|
||
<Typography variant="caption" className="text-gray-600">
|
||
已标记 {disabledValves.length} 个阀门为不可用状态,可以按照上述新的阀门配置进行隔离操作
|
||
</Typography>
|
||
</Alert>
|
||
) : (
|
||
<Alert severity="error" variant="outlined">
|
||
<Typography variant="body2" className="font-semibold mb-1">
|
||
✗ 扩大搜索后仍无法完全隔离
|
||
</Typography>
|
||
<Typography variant="caption" className="text-gray-600">
|
||
即使排除了 {disabledValves.length} 个不可用阀门,仍无法找到有效隔离方案。建议检查管网拓扑结构或阀门配置
|
||
</Typography>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* 操作按钮 */}
|
||
<Box className="flex gap-2 pt-2">
|
||
<Button
|
||
variant="outlined"
|
||
size="medium"
|
||
onClick={() => {
|
||
setActiveStep(1);
|
||
setDisabledValves([]);
|
||
}}
|
||
className="flex-1"
|
||
>
|
||
返回上一步
|
||
</Button>
|
||
<Button
|
||
variant="contained"
|
||
size="medium"
|
||
onClick={() => {
|
||
clearSelectedPipe();
|
||
}}
|
||
className="flex-1"
|
||
>
|
||
重新开始
|
||
</Button>
|
||
</Box>
|
||
</>
|
||
) : (
|
||
<Alert severity="info" variant="outlined">
|
||
请先在流程2中选择不可用阀门,然后点击“扩大搜索”按钮
|
||
</Alert>
|
||
)}
|
||
</Box>
|
||
</StepContent>
|
||
</Step>
|
||
</Stepper>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default ValveIsolation;
|