diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index b879396..96f2d60 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -1,10 +1,9 @@ "use client"; -import React, { useCallback, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import RefreshIcon from "@mui/icons-material/Refresh"; import { - Alert, Box, Button, CircularProgress, @@ -70,6 +69,7 @@ const AnalysisParameters: React.FC = ({ onResult }) => { const [basicPressure, setBasicPressure] = useState(10); const [advancedOpen, setAdvancedOpen] = useState(false); const [running, setRunning] = useState(false); + const isSimulationMode = dataSource === "simulation"; const applySchemeTimeRange = useCallback((scheme: SchemeItem) => { const start = dayjs(scheme.scheme_start_time); @@ -78,10 +78,16 @@ const AnalysisParameters: React.FC = ({ onResult }) => { setBurstStartTime(start); setBurstEndTime(end); - setNormalStartTime(start.subtract(2, "hour")); - setNormalEndTime(start.subtract(10, "minute")); + setNormalStartTime(start); + setNormalEndTime(end); }, []); + useEffect(() => { + if (!isSimulationMode) return; + setNormalStartTime(burstStartTime); + setNormalEndTime(burstEndTime); + }, [burstEndTime, burstStartTime, isSimulationMode]); + const fetchSchemes = useCallback( async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => { if (schemeLoading || (!force && schemes.length > 0)) return; @@ -262,7 +268,7 @@ const AnalysisParameters: React.FC = ({ onResult }) => { - {dataSource === "simulation" && ( + {isSimulationMode && ( 选择爆管分析方案 @@ -323,6 +329,7 @@ const AnalysisParameters: React.FC = ({ onResult }) => { value={burstStartTime} onChange={setBurstStartTime} maxDateTime={burstEndTime ?? undefined} + disabled={isSimulationMode} format="YYYY-MM-DD HH:mm" slotProps={{ textField: { size: "small", fullWidth: true } }} /> @@ -335,6 +342,7 @@ const AnalysisParameters: React.FC = ({ onResult }) => { value={burstEndTime} onChange={setBurstEndTime} minDateTime={burstStartTime ?? undefined} + disabled={isSimulationMode} format="YYYY-MM-DD HH:mm" slotProps={{ textField: { size: "small", fullWidth: true } }} /> @@ -347,6 +355,7 @@ const AnalysisParameters: React.FC = ({ onResult }) => { value={normalStartTime} onChange={setNormalStartTime} maxDateTime={normalEndTime ?? undefined} + disabled={isSimulationMode} format="YYYY-MM-DD HH:mm" slotProps={{ textField: { size: "small", fullWidth: true } }} /> @@ -359,6 +368,7 @@ const AnalysisParameters: React.FC = ({ onResult }) => { value={normalEndTime} onChange={setNormalEndTime} minDateTime={normalStartTime ?? undefined} + disabled={isSimulationMode} format="YYYY-MM-DD HH:mm" slotProps={{ textField: { size: "small", fullWidth: true } }} /> diff --git a/src/components/olmap/BurstLocation/LocationResults.tsx b/src/components/olmap/BurstLocation/LocationResults.tsx index 98d54d7..35a983d 100644 --- a/src/components/olmap/BurstLocation/LocationResults.tsx +++ b/src/components/olmap/BurstLocation/LocationResults.tsx @@ -1,20 +1,22 @@ "use client"; import React, { useEffect, useMemo, useState } from "react"; -import { - Box, - Button, - Chip, - Divider, - IconButton, - Paper, - Tooltip, - Typography +import { + Box, + Typography, + Chip, + IconButton, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Button, } from "@mui/material"; import { FormatListBulleted, - LocationOn as LocationOnIcon, - Map as MapIcon + LocationOn as LocationOnIcon, + Map as MapIcon, } from "@mui/icons-material"; import dayjs from "dayjs"; import { useMap } from "@app/OlMap/MapComponent"; @@ -25,24 +27,102 @@ 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"; +import { BurstCandidate, BurstLocationResult } from "./types"; +import { DMA_FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils"; interface Props { result: BurstLocationResult | null; } +interface MetricCardProps { + label: string; + value: string; + hint?: string; + tone: "blue" | "orange" | "purple" | "green"; +} + +const toneStyles: Record< + MetricCardProps["tone"], + { bg: string; border: string; text: string; darkText: string } +> = { + blue: { + bg: "from-blue-50 to-blue-100", + border: "border-blue-200", + text: "text-blue-700", + darkText: "text-blue-900", + }, + orange: { + bg: "from-orange-50 to-orange-100", + border: "border-orange-200", + text: "text-orange-700", + darkText: "text-orange-900", + }, + purple: { + bg: "from-purple-50 to-purple-100", + border: "border-purple-200", + text: "text-purple-700", + darkText: "text-purple-900", + }, + green: { + bg: "from-green-50 to-green-100", + border: "border-green-200", + text: "text-green-700", + darkText: "text-green-900", + }, +}; + +const formatDateTime = (value?: string) => + value ? dayjs(value).format("MM-DD HH:mm") : "-"; + +const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => { + const style = toneStyles[tone]; + return ( + + + {label} + + + {value} + + {hint ? ( + + {hint} + + ) : null} + + ); +}; + +const EmptyState = () => ( + + + + + + 等待定位结果 + + + 请先提交爆管定位分析,结果面板将展示定位摘要、时间窗、采样情况和候选管段。 + + +); + const LocationResults: React.FC = ({ result }) => { const map = useMap(); - const [highlightLayer, setHighlightLayer] = - useState | null>(null); + const [highlightLayer, setHighlightLayer] = useState | null>(null); const [highlightFeatures, setHighlightFeatures] = useState([]); - const candidatePipes = useMemo(() => { + 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 [{ pipe_id: result.located_pipe, similarity: 1 }, ...base]; } return base; }, [result]); @@ -86,133 +166,124 @@ const LocationResults: React.FC = ({ result }) => { 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 geojsonFeatures = features.map((feature) => geojsonFormat.writeFeatureObject(feature)); + // @ts-ignore turf typing with ol geojson objects 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); + } catch (error) { + console.error("Locate failed", error); } }; if (!result) { - return ( - - - - - 等待定位 - - 请在左侧面板配置传感器参数与时间范围,点击“开始定位”获取结果。 - - - ); + return ; } + const burstSamples = result.pressure_samples?.burst ?? 0; + const normalSamples = result.pressure_samples?.normal ?? 0; + const elapsedText = + result.elapsed_seconds && result.elapsed_seconds > 0 + ? `${result.elapsed_seconds.toFixed(1)} s` + : "-"; + const bestSimilarity = candidatePipes[0]?.similarity ?? 0; + const burstTime = result.scada_window?.burst_start + ? formatDateTime(result.scada_window.burst_start) + : "-"; + return ( - - {/* 1. 冠军卡片 */} - - - - - {result.scheme_name || "爆管定位结果"} - - - {result.username && ( - - )} - - - {/* 2. 统计数据 */} - - {/* 方案时间/创建时间 */} - - - 方案时间 - - - {result.create_time ? dayjs(result.create_time).format("MM-DD HH:mm") : "-"} - - - - {/* 漏损量 */} - - - 漏损量 - - - {result.burst_leakage.toFixed(1)} L/s - - - - {/* 最佳匹配 */} - - - - - 最佳匹配管段 - - - {result.located_pipe} - - - 置信度: {(candidatePipes[0]?.similarity * 100 || 0).toFixed(1)}% · 模式: {result.similarity_mode} - - - - - - - - {/* 3. 候选列表 */} - - + + {/* Header & Metrics */} + + - + + + {result.scheme_name || "爆管定位结果"} + + + + {result.username ? ( + + ) : null} + + + + + + + + + + + + + {/* Candidate List */} + + + + 候选管段列表 @@ -230,35 +301,84 @@ const LocationResults: React.FC = ({ result }) => { }} /> - - {candidatePipes.map((candidate, idx) => ( - - - - {idx + 1} - - - - {candidate.pipe_id} - - - 相似度: {(candidate.similarity * 100).toFixed(2)}% - - - - locatePipes([candidate.pipe_id])} - className="text-gray-400 hover:text-blue-600" + + + + + 排名 + + + 管段 ID + + + 相似度 + + + 操作 + + + + + {candidatePipes.map((candidate, index) => { + const similarityPercent = candidate.similarity * 100; + const isTop = index === 0; + return ( + - - - - ))} - + + + {index + 1} + + + + + {candidate.pipe_id} + + + + + + {similarityPercent.toFixed(2)}% + + + + + + + + locatePipes([candidate.pipe_id])} + className="text-blue-600 hover:bg-blue-50" + title="定位" + > + + + + + ); + })} + +
); diff --git a/src/components/olmap/BurstLocation/SchemeQuery.tsx b/src/components/olmap/BurstLocation/SchemeQuery.tsx index f24dbb1..4aa7296 100644 --- a/src/components/olmap/BurstLocation/SchemeQuery.tsx +++ b/src/components/olmap/BurstLocation/SchemeQuery.tsx @@ -23,7 +23,12 @@ 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"; +import { + BurstLocationResult, + BurstLocationSchemeDetail, + BurstSchemeRecord, +} from "./types"; +import { DMA_FLOW_DISPLAY_UNIT } from "../DMALeakDetection/utils"; interface Props { onViewResult: (result: BurstLocationResult) => void; @@ -37,6 +42,39 @@ const SchemeQuery: React.FC = ({ onViewResult }) => { const [loading, setLoading] = useState(false); const [expandedId, setExpandedId] = useState(null); + const buildDisplayResult = ( + scheme: Pick, + detail?: BurstLocationSchemeDetail, + ): BurstLocationResult | null => { + const payload = detail?.result_payload; + const locatedPipe = payload?.located_pipe ?? detail?.result_summary?.located_pipe; + if (!locatedPipe) return null; + + return { + located_pipe: locatedPipe, + burst_leakage: payload?.burst_leakage ?? detail?.algorithm_params?.burst_leakage ?? 0, + elapsed_seconds: payload?.elapsed_seconds ?? 0, + min_dpressure: payload?.min_dpressure ?? detail?.algorithm_params?.min_dpressure, + basic_pressure: payload?.basic_pressure ?? detail?.algorithm_params?.basic_pressure, + simulation_times: payload?.simulation_times ?? detail?.result_summary?.simulation_times ?? 0, + top_candidates: payload?.top_candidates ?? [], + similarity_mode: + payload?.similarity_mode ?? detail?.result_summary?.similarity_mode ?? "-", + scheme_name: payload?.scheme_name ?? scheme.scheme_name, + username: payload?.username ?? scheme.username, + network: payload?.network ?? detail?.network, + data_source: payload?.data_source, + observed_source: payload?.observed_source ?? detail?.observed_source, + pressure_scada_ids: payload?.pressure_scada_ids ?? detail?.pressure_scada_ids, + flow_scada_ids: payload?.flow_scada_ids ?? detail?.flow_scada_ids, + create_time: payload?.create_time ?? scheme.create_time, + scada_window: payload?.scada_window ?? detail?.scada_window, + pressure_samples: payload?.pressure_samples, + flow_samples: payload?.flow_samples, + simulation_scheme: payload?.simulation_scheme, + }; + }; + const handleQuery = async () => { setLoading(true); try { @@ -47,7 +85,7 @@ const SchemeQuery: React.FC = ({ onViewResult }) => { if (!queryAll && queryDate) { params.query_date = queryDate.startOf("day").toISOString(); } - + const response = await api.get(url, { params }); setSchemes(response.data); open?.({ @@ -73,10 +111,23 @@ const SchemeQuery: React.FC = ({ onViewResult }) => { `${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); + const schemeRecord = response.data as BurstSchemeRecord & { + result_payload?: BurstLocationResult; + }; + const normalizedResult = + schemeRecord.result_payload ?? + buildDisplayResult( + { + scheme_name: schemeRecord.scheme_name, + username: schemeRecord.username, + create_time: schemeRecord.create_time, + }, + schemeRecord.scheme_detail, + ); + if (!normalizedResult) { + throw new Error("方案详情缺少定位结果数据"); + } + onViewResult(normalizedResult); open?.({ type: "success", message: "方案加载成功", @@ -169,80 +220,118 @@ const SchemeQuery: React.FC = ({ onViewResult }) => { 共 {schemes.length} 条记录 - {schemes.map((scheme) => ( - - - - - - - {scheme.scheme_name} + {schemes.map((scheme) => { + const summary = scheme.scheme_detail?.result_summary; + const payload = scheme.scheme_detail?.result_payload; + const locatedPipe = payload?.located_pipe ?? summary?.located_pipe ?? "-"; + const leakage = + payload?.burst_leakage ?? scheme.scheme_detail?.algorithm_params?.burst_leakage; + + return ( + + + + + + + {scheme.scheme_name} + + + + {payload?.data_source === "simulation" && + payload?.simulation_scheme?.name ? ( + + 方案: {payload.simulation_scheme.name} + + ) : null} + + ID: {scheme.scheme_id} · 日期:{" "} + {dayjs(scheme.create_time).format("MM-DD HH:mm")} - - - ID: {scheme.scheme_id} · 日期: {dayjs(scheme.create_time).format("MM-DD HH:mm")} - - - - - setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id)} - color="primary" - className="p-1" - > - - - - - - - - - {/* Summary details */} - - - 定位管段: - - - {scheme.scheme_detail?.located_pipe || "-"} - - - - - 漏损量: - - - {scheme.scheme_detail?.burst_leakage ? `${scheme.scheme_detail.burst_leakage} L/s` : "-"} - - - - - 用户: - - - {scheme.username || "-"} - - - - - + + + + setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id) + } + color="primary" + className="p-1" + > + + + - - - - ))} + + + + + + 定位管段: + + + {locatedPipe} + + + + + 漏损量: + + + {typeof leakage === "number" ? `${leakage} ${DMA_FLOW_DISPLAY_UNIT}` : "-"} + + + + + 用户: + + + {scheme.username || "-"} + + + + + + + + + + + ); + })}
)}
diff --git a/src/components/olmap/BurstLocation/types.ts b/src/components/olmap/BurstLocation/types.ts index da03b0a..0500a36 100644 --- a/src/components/olmap/BurstLocation/types.ts +++ b/src/components/olmap/BurstLocation/types.ts @@ -13,15 +13,63 @@ export interface BurstLocationResult { scheme_name?: string; username?: string; observed_source?: string; + network?: string; + data_source?: string; + min_dpressure?: number; + basic_pressure?: number; pressure_scada_ids?: string[]; flow_scada_ids?: string[]; create_time?: string; + scada_window?: { + burst_start?: string; + burst_end?: string; + normal_start?: string; + normal_end?: string; + }; + pressure_samples?: { + burst?: number; + normal?: number; + }; + flow_samples?: { + burst?: number; + normal?: number; + }; + simulation_scheme?: { + name?: string; + type?: string; + }; +} + +export interface BurstLocationSchemeDetail { + network?: string; + pressure_scada_ids?: string[]; + flow_scada_ids?: string[]; + observed_source?: string; + algorithm_params?: { + burst_leakage?: number; + min_dpressure?: number; + basic_pressure?: number; + }; + scada_window?: { + burst_start?: string; + burst_end?: string; + normal_start?: string; + normal_end?: string; + }; + result_summary?: { + located_pipe?: string; + simulation_times?: number; + similarity_mode?: string; + }; + result_payload?: BurstLocationResult; } export interface BurstSchemeRecord { scheme_id: number; scheme_name: string; + scheme_type?: string; create_time: string; + scheme_start_time?: string; username?: string; - scheme_detail?: BurstLocationResult; + scheme_detail?: BurstLocationSchemeDetail; }