1257 lines
45 KiB
TypeScript
1257 lines
45 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";
|
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
|
import {
|
|
getAffectedNodeCount,
|
|
isAllowedAccidentPipe,
|
|
normalizeValveIsolationResult,
|
|
} from "./valveIsolationScope";
|
|
|
|
interface ValveIsolationProps {
|
|
initialPipeIds?: string[];
|
|
allowedPipeIds?: string[];
|
|
sourceSchemeName?: string;
|
|
shouldFetch?: boolean;
|
|
onFetchComplete?: () => void;
|
|
loading?: boolean;
|
|
result?: ValveIsolationResult | null;
|
|
onLoadingChange?: (loading: boolean) => void;
|
|
onResultChange?: (result: ValveIsolationResult | null) => void;
|
|
state?: ValveIsolationState;
|
|
onStateChange?: (state: ValveIsolationState) => void;
|
|
}
|
|
|
|
export interface ValveIsolationState {
|
|
selectedPipeId: string | null;
|
|
activeStep: number;
|
|
expandedResult: boolean;
|
|
disabledValves: string[];
|
|
}
|
|
|
|
export const createValveIsolationState = (): ValveIsolationState => ({
|
|
selectedPipeId: null,
|
|
activeStep: 0,
|
|
expandedResult: true,
|
|
disabledValves: [],
|
|
});
|
|
|
|
const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|
initialPipeIds = [],
|
|
allowedPipeIds,
|
|
sourceSchemeName,
|
|
shouldFetch = false,
|
|
onFetchComplete,
|
|
loading: externalLoading,
|
|
result: externalResult,
|
|
onLoadingChange,
|
|
onResultChange,
|
|
state,
|
|
onStateChange,
|
|
}) => {
|
|
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 [flowState, setFlowState, setFlowField] = useControllableObjectState(
|
|
state,
|
|
onStateChange,
|
|
createValveIsolationState(),
|
|
);
|
|
const { selectedPipeId, activeStep, expandedResult, disabledValves } =
|
|
flowState;
|
|
const [highlightFeature, setHighlightFeature] = useState<Feature | null>(null);
|
|
const [isSelecting, setIsSelecting] = useState(false);
|
|
|
|
const setSelectedPipeId = useCallback(
|
|
(value: string | null) => setFlowField("selectedPipeId", value),
|
|
[setFlowField],
|
|
);
|
|
|
|
const setActiveStep = useCallback(
|
|
(value: number) => setFlowField("activeStep", value),
|
|
[setFlowField],
|
|
);
|
|
|
|
const setExpandedResult = useCallback(
|
|
(value: boolean) => setFlowField("expandedResult", value),
|
|
[setFlowField],
|
|
);
|
|
|
|
const setDisabledValves = useCallback(
|
|
(next: string[] | ((previous: string[]) => string[])) => {
|
|
setFlowState((previous) => ({
|
|
...previous,
|
|
disabledValves:
|
|
typeof next === "function" ? next(previous.disabledValves) : next,
|
|
}));
|
|
},
|
|
[setFlowState],
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
const normalizedPipeId = String(pipeId);
|
|
if (!isAllowedAccidentPipe(normalizedPipeId, allowedPipeIds)) {
|
|
open?.({
|
|
type: "error",
|
|
message: "请选择当前爆管方案中的事故管段",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setSelectedPipeId(normalizedPipeId);
|
|
setHighlightFeature(feature);
|
|
setIsSelecting(false);
|
|
setResult(null); // 清除旧结果
|
|
}
|
|
}
|
|
},
|
|
[
|
|
allowedPipeIds,
|
|
isSelecting,
|
|
map,
|
|
open,
|
|
setResult,
|
|
setSelectedPipeId,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!map) return;
|
|
if (isSelecting) {
|
|
map.on("click", handleMapClick);
|
|
} else {
|
|
map.un("click", handleMapClick);
|
|
}
|
|
|
|
return () => {
|
|
map.un("click", handleMapClick);
|
|
};
|
|
}, [map, isSelecting, handleMapClick]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedPipeId || highlightFeature) return;
|
|
queryFeaturesByIds([selectedPipeId], "geo_pipes_mat").then((features) => {
|
|
setHighlightFeature(features[0] ?? null);
|
|
});
|
|
}, [highlightFeature, selectedPipeId]);
|
|
|
|
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 selectSchemeAccidentPipe = (pipeId: string) => {
|
|
if (!isAllowedAccidentPipe(pipeId, allowedPipeIds)) return;
|
|
setSelectedPipeId(pipeId);
|
|
setHighlightFeature(null);
|
|
setHighlightFeatures([]);
|
|
setResult(null);
|
|
setActiveStep(0);
|
|
setExpandedResult(true);
|
|
setDisabledValves([]);
|
|
};
|
|
|
|
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;
|
|
}
|
|
if (ids.some((pipeId) => !isAllowedAccidentPipe(pipeId, allowedPipeIds))) {
|
|
open?.({
|
|
type: "error",
|
|
message: "关阀分析仅限当前爆管方案中的事故管段",
|
|
});
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
const isExpandSearch = disabled.length > 0;
|
|
if (!isExpandSearch) {
|
|
setResult(null);
|
|
setDisabledValves([]);
|
|
}
|
|
try {
|
|
const params: any = {
|
|
accident_element: ids,
|
|
};
|
|
if (disabled.length > 0) {
|
|
params.disabled_valves = disabled;
|
|
}
|
|
const response = await api.post<ValveIsolationResult>(
|
|
`${config.BACKEND_URL}/api/v1/valve-isolation-analyses`,
|
|
undefined,
|
|
{
|
|
params,
|
|
paramsSerializer: {
|
|
indexes: null, // 生成格式: accident_element=P1&accident_element=P2&disabled_valves=V1&disabled_valves=V2
|
|
},
|
|
},
|
|
);
|
|
setResult(normalizeValveIsolationResult(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);
|
|
}
|
|
},
|
|
[
|
|
allowedPipeIds,
|
|
open,
|
|
setActiveStep,
|
|
setDisabledValves,
|
|
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, setSelectedPipeId]);
|
|
|
|
// 初始化高亮图层
|
|
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",
|
|
queryable: false,
|
|
},
|
|
});
|
|
|
|
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: getAffectedNodeCount(result), 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.isolatable && (
|
|
<Alert severity="info" variant="outlined">
|
|
不可隔离,未生成受影响节点清单。
|
|
</Alert>
|
|
)}
|
|
|
|
{/* 受影响节点 */}
|
|
{result.isolatable && 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">
|
|
{allowedPipeIds !== undefined ? (
|
|
<Alert severity="info" variant="outlined">
|
|
已绑定爆管方案“{sourceSchemeName || "未命名方案"}”。关阀分析仅限该方案中的事故管段,分析结果将写入对应报告。
|
|
</Alert>
|
|
) : (
|
|
<Alert severity="warning" variant="outlined">
|
|
当前为独立关阀分析,结果不会自动写入爆管分析报告。请先从“方案查询”打开对应分析报告以建立关联。
|
|
</Alert>
|
|
)}
|
|
|
|
{/* 选择管段 */}
|
|
<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>
|
|
|
|
{allowedPipeIds !== undefined && (
|
|
<Box className="mb-3 rounded border border-blue-100 bg-blue-50 p-2">
|
|
<Box className="mb-2 flex flex-wrap gap-1.5">
|
|
{allowedPipeIds.map((pipeId) => (
|
|
<Chip
|
|
key={pipeId}
|
|
label={pipeId}
|
|
size="small"
|
|
color={selectedPipeId === pipeId ? "primary" : "default"}
|
|
variant={selectedPipeId === pipeId ? "filled" : "outlined"}
|
|
onClick={() => selectSchemeAccidentPipe(pipeId)}
|
|
/>
|
|
))}
|
|
</Box>
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
fullWidth
|
|
disabled={allowedPipeIds.length === 0}
|
|
onClick={() =>
|
|
allowedPipeIds[0] &&
|
|
selectSchemeAccidentPipe(allowedPipeIds[0])
|
|
}
|
|
startIcon={<CheckCircleIcon />}
|
|
>
|
|
选择当前爆管管段
|
|
</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;
|