diff --git a/src/app/(main)/hydraulic-simulation/burst-detection/loading.tsx b/src/app/(main)/hydraulic-simulation/burst-detection/loading.tsx
new file mode 100644
index 0000000..2c57921
--- /dev/null
+++ b/src/app/(main)/hydraulic-simulation/burst-detection/loading.tsx
@@ -0,0 +1,5 @@
+import { MapSkeleton } from "@components/loading/MapSkeleton";
+
+export default function Loading() {
+ return ;
+}
diff --git a/src/app/(main)/hydraulic-simulation/burst-detection/page.tsx b/src/app/(main)/hydraulic-simulation/burst-detection/page.tsx
new file mode 100644
index 0000000..df2ddb9
--- /dev/null
+++ b/src/app/(main)/hydraulic-simulation/burst-detection/page.tsx
@@ -0,0 +1,16 @@
+"use client";
+
+import MapComponent from "@components/olmap/core/MapComponent";
+import MapToolbar from "@components/olmap/core/Controls/Toolbar";
+import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel";
+
+export default function Home() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx
index ecd54d9..b2f81f5 100644
--- a/src/app/_refine_context.tsx
+++ b/src/app/_refine_context.tsx
@@ -18,13 +18,12 @@ import { ProjectProvider } from "@/contexts/ProjectContext";
import { useAuthStore } from "@/store/authStore";
import { LiaNetworkWiredSolid } from "react-icons/lia";
-import { TbDatabaseEdit, TbLocationPin } from "react-icons/tb";
+import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
import { LuReplace } from "react-icons/lu";
import { AiOutlineSecurityScan } from "react-icons/ai";
import { AiOutlinePartition } from "react-icons/ai";
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
import {
- Analytics as AnalyticsIcon,
MyLocation as MyLocationIcon,
Search as SearchIcon,
} from "@mui/icons-material";
@@ -193,6 +192,15 @@ const App = (props: React.PropsWithChildren) => {
label: "爆管定位",
},
},
+ {
+ name: "爆管侦测",
+ list: "/hydraulic-simulation/burst-detection",
+ meta: {
+ parent: "Hydraulic Simulation",
+ icon: ,
+ label: "爆管侦测",
+ },
+ },
{
name: "DMA 漏损识别",
list: "/hydraulic-simulation/dma-leak-detection",
diff --git a/src/components/olmap/BurstDetection/AnalysisParameters.tsx b/src/components/olmap/BurstDetection/AnalysisParameters.tsx
new file mode 100644
index 0000000..96725b4
--- /dev/null
+++ b/src/components/olmap/BurstDetection/AnalysisParameters.tsx
@@ -0,0 +1,471 @@
+"use client";
+
+import React, { useMemo, useState, useCallback } from "react";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import RefreshIcon from "@mui/icons-material/Refresh";
+import {
+ Box,
+ Button,
+ CircularProgress,
+ Collapse,
+ FormControl,
+ MenuItem,
+ Select,
+ TextField,
+ Typography,
+ IconButton,
+} from "@mui/material";
+import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
+import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
+import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
+import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
+import { useNotification } from "@refinedev/core";
+import dayjs, { Dayjs } from "dayjs";
+import "dayjs/locale/zh-cn";
+import { api } from "@/lib/api";
+import { NETWORK_NAME, config } from "@config/config";
+import { BurstDetectionResult } from "./types";
+
+interface Props {
+ onResult: (result: BurstDetectionResult) => void;
+}
+
+interface SchemeItem {
+ scheme_id: number;
+ scheme_name: string;
+ scheme_type: string;
+ create_time: string;
+ scheme_start_time: string;
+ scheme_detail?: {
+ modify_total_duration: number;
+ };
+}
+
+const AnalysisParameters: React.FC = ({ onResult }) => {
+ const { open } = useNotification();
+ const [schemeName, setSchemeName] = useState(`Burst_Detection_${Date.now()}`);
+ const [dataSource, setDataSource] = useState<"monitoring" | "simulation">("monitoring");
+ const [schemes, setSchemes] = useState([]);
+ const [selectedSchemeId, setSelectedSchemeId] = useState("");
+ const [schemeLoading, setSchemeLoading] = useState(false);
+ const [scadaStart, setScadaStart] = useState(dayjs().subtract(3, "day"));
+ const [scadaEnd, setScadaEnd] = useState(dayjs());
+ const [mu, setMu] = useState(100);
+ const [pointsPerDay, setPointsPerDay] = useState(96);
+ const [nEstimators, setNEstimators] = useState(50);
+ const [contaminationInput, setContaminationInput] = useState("auto");
+ 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);
+ const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600;
+ const end = start.add(durationSeconds, "second");
+
+ setScadaStart(start);
+ setScadaEnd(end);
+ }, []);
+
+ const fetchSchemes = useCallback(
+ async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => {
+ if (schemeLoading || (!force && schemes.length > 0)) return;
+
+ setSchemeLoading(true);
+ try {
+ const response = await api.get(`${config.BACKEND_URL}/api/v1/getallschemes/`, {
+ params: { network: NETWORK_NAME },
+ });
+ const burstSchemes = (response.data as SchemeItem[]).filter(
+ (scheme) => scheme.scheme_type === "burst_analysis",
+ );
+
+ setSchemes(burstSchemes);
+
+ if (selectedSchemeId) {
+ const matchedScheme = burstSchemes.find(
+ (scheme) => scheme.scheme_id === selectedSchemeId,
+ );
+ if (matchedScheme) {
+ applySchemeTimeRange(matchedScheme);
+ } else {
+ setSelectedSchemeId("");
+ }
+ }
+
+ if (notify) {
+ open?.({
+ type: "success",
+ message: "方案列表已刷新",
+ description: `当前可选爆管分析方案 ${burstSchemes.length} 个`,
+ });
+ }
+ } catch (error: any) {
+ open?.({
+ type: "error",
+ message: "刷新方案失败",
+ description:
+ error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表",
+ });
+ } finally {
+ setSchemeLoading(false);
+ }
+ },
+ [applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId],
+ );
+
+ const handleDataSourceChange = (value: "monitoring" | "simulation") => {
+ setDataSource(value);
+ if (value === "simulation") {
+ void fetchSchemes();
+ }
+ };
+
+ const handleSchemeSelect = (schemeId: number) => {
+ setSelectedSchemeId(schemeId);
+ const scheme = schemes.find((item) => item.scheme_id === schemeId);
+ if (scheme) {
+ applySchemeTimeRange(scheme);
+ }
+ };
+
+ const timeWindowValid = useMemo(() => {
+ if (!scadaStart || !scadaEnd) return false;
+ return scadaEnd.diff(scadaStart, "day", true) >= 2;
+ }, [scadaEnd, scadaStart]);
+
+ const contaminationValue = useMemo(() => {
+ const normalized = contaminationInput.trim().toLowerCase();
+ if (!normalized || normalized === "auto") {
+ return "auto" as const;
+ }
+ const parsed = Number(normalized);
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= 0.5) {
+ return null;
+ }
+ return parsed;
+ }, [contaminationInput]);
+
+ const isValid =
+ Boolean(scadaStart && scadaEnd) &&
+ timeWindowValid &&
+ Number.isFinite(mu) &&
+ mu > 0 &&
+ Number.isFinite(pointsPerDay) &&
+ pointsPerDay > 0 &&
+ Number.isFinite(nEstimators) &&
+ nEstimators > 0 &&
+ contaminationValue !== null &&
+ (dataSource !== "simulation" || Boolean(selectedSchemeId));
+
+ const handleRun = async () => {
+ if (!isValid || !scadaStart || !scadaEnd || contaminationValue === null) {
+ open?.({
+ type: "error",
+ message: "参数不完整",
+ description: "请检查时间范围(至少2天)和高级参数是否填写正确。",
+ });
+ return;
+ }
+
+ setRunning(true);
+ open?.({
+ key: "burst-detection-analysis",
+ type: "progress",
+ message: "正在执行爆管侦测",
+ description: "正在读取数据并计算异常分数。",
+ undoableTimeout: 3,
+ });
+
+ try {
+ const selectedScheme =
+ dataSource === "simulation"
+ ? schemes.find((item) => item.scheme_id === selectedSchemeId)
+ : undefined;
+
+ const response = await api.post("/api/v1/burst-detection/detect/", {
+ network: NETWORK_NAME,
+ data_source: dataSource,
+ scheme_name: schemeName.trim() || undefined,
+ scada_start: scadaStart.toISOString(),
+ scada_end: scadaEnd.toISOString(),
+ mu,
+ points_per_day: pointsPerDay,
+ iforest_params: {
+ n_estimators: nEstimators,
+ contamination: contaminationValue,
+ },
+ simulation_scheme_name: selectedScheme?.scheme_name,
+ simulation_scheme_type: selectedScheme?.scheme_type,
+ });
+
+ onResult({
+ ...(response.data as BurstDetectionResult),
+ scheme_name: schemeName.trim() || (response.data as BurstDetectionResult).scheme_name,
+ algorithm_params: {
+ mu,
+ points_per_day: pointsPerDay,
+ iforest_params: {
+ n_estimators: nEstimators,
+ contamination: contaminationValue,
+ },
+ },
+ });
+
+ open?.({
+ key: "burst-detection-analysis",
+ type: "success",
+ message: "爆管侦测完成",
+ description: `共识别 ${response.data.summary?.anomaly_day_count ?? 0} 个异常日。`,
+ });
+ } catch (error: any) {
+ open?.({
+ key: "burst-detection-analysis",
+ type: "error",
+ message: "侦测失败",
+ description: error?.response?.data?.detail ?? error?.message ?? "请求失败",
+ });
+ } finally {
+ setRunning(false);
+ }
+ };
+
+ return (
+
+
+
+
+ 方案名称
+
+ setSchemeName(event.target.value)}
+ placeholder="请输入方案名称"
+ fullWidth
+ size="small"
+ />
+
+
+
+
+ 数据来源
+
+
+
+
+
+
+ {isSimulationMode && (
+
+
+ 选择爆管分析方案
+
+
+
+
+
+ void fetchSchemes({ force: true, notify: true })}
+ disabled={schemeLoading}
+ aria-label="刷新爆管分析方案"
+ sx={{
+ border: "1px solid",
+ borderColor: "divider",
+ borderRadius: 1,
+ }}
+ >
+ {schemeLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
+
+
+
+
+
+ 侦测开始时间
+
+
+
+
+
+ 侦测结束时间
+
+
+
+
+
+
+
+ 当前页面为展示版:手动触发一次侦测,展示异常日、最新测点排名和结果表格,不做定时轮询。
+
+
+
+ setAdvancedOpen((prev) => !prev)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ setAdvancedOpen((prev) => !prev);
+ }
+ }}
+ sx={{
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ px: 1.25,
+ py: 0.75,
+ cursor: "pointer",
+ backgroundColor: "transparent",
+ "&:hover": { backgroundColor: "action.hover" },
+ }}
+ >
+
+ 高级参数
+
+
+
+
+
+
+ setMu(Number(event.target.value))}
+ size="small"
+ fullWidth
+ inputProps={{ min: 1 }}
+ />
+ setPointsPerDay(Number(event.target.value))}
+ size="small"
+ fullWidth
+ inputProps={{ min: 1 }}
+ />
+ setNEstimators(Number(event.target.value))}
+ size="small"
+ fullWidth
+ inputProps={{ min: 1 }}
+ />
+ setContaminationInput(event.target.value)}
+ size="small"
+ fullWidth
+ helperText="填写 auto 或 0~0.5 之间的小数。"
+ error={contaminationValue === null}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default AnalysisParameters;
diff --git a/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx b/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx
new file mode 100644
index 0000000..d85182b
--- /dev/null
+++ b/src/components/olmap/BurstDetection/BurstDetectionPanel.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import React, { useCallback, useState } from "react";
+import { Box, Drawer, IconButton, Tab, Tabs, Tooltip, Typography } from "@mui/material";
+import {
+ Analytics as AnalyticsIcon,
+ ChevronLeft,
+ ChevronRight,
+ FormatListBulleted,
+ Search as SearchIcon,
+} from "@mui/icons-material";
+import AnalysisParameters from "./AnalysisParameters";
+import DetectionResults from "./DetectionResults";
+import SchemeQuery from "./SchemeQuery";
+import { BurstDetectionResult } from "./types";
+
+const TabPanel = ({
+ value,
+ index,
+ children,
+}: {
+ value: number;
+ index: number;
+ children: React.ReactNode;
+}) => (
+
+ {value === index ? {children} : null}
+
+);
+
+const BurstDetectionPanel: React.FC = () => {
+ const [open, setOpen] = useState(true);
+ const [tab, setTab] = useState(0);
+ const [result, setResult] = useState(null);
+
+ const drawerWidth = 450;
+ const panelTitle = "爆管侦测";
+
+ const handleResult = useCallback((payload: BurstDetectionResult) => {
+ setResult(payload);
+ setTab(2);
+ }, []);
+
+ return (
+ <>
+ {!open && (
+ setOpen(true)}
+ sx={{ zIndex: 1300 }}
+ >
+
+
+
+ {panelTitle}
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ {panelTitle}
+
+
+
+ setOpen(false)} sx={{ color: "primary.contrastText" }}>
+
+
+
+
+
+
+ setTab(value)}
+ variant="fullWidth"
+ sx={{
+ minHeight: 48,
+ "& .MuiTab-root": {
+ minHeight: 48,
+ textTransform: "none",
+ fontSize: "0.875rem",
+ fontWeight: 500,
+ transition: "all 0.2s",
+ },
+ "& .Mui-selected": {
+ color: "#257DD4",
+ },
+ "& .MuiTabs-indicator": {
+ backgroundColor: "#257DD4",
+ },
+ }}
+ >
+ } iconPosition="start" label="侦测参数" />
+ } iconPosition="start" label="方案查询" />
+ } iconPosition="start" label="侦测结果" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default BurstDetectionPanel;
diff --git a/src/components/olmap/BurstDetection/DetectionResults.tsx b/src/components/olmap/BurstDetection/DetectionResults.tsx
new file mode 100644
index 0000000..6b9d914
--- /dev/null
+++ b/src/components/olmap/BurstDetection/DetectionResults.tsx
@@ -0,0 +1,610 @@
+"use client";
+
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import { Box, Button, Chip, Tooltip, Typography } from "@mui/material";
+import { DataGrid, GridColDef } from "@mui/x-data-grid";
+import { zhCN } from "@mui/x-data-grid/locales";
+import {
+ FormatListBulleted,
+ InfoOutlined as InfoOutlinedIcon,
+ Room as RoomIcon,
+ ShowChart as ShowChartIcon,
+ CheckCircleOutline as CheckCircleIcon,
+ ErrorOutline as ErrorOutlineIcon,
+} from "@mui/icons-material";
+import ReactECharts from "echarts-for-react";
+import dayjs from "dayjs";
+import { useMap } from "@components/olmap/core/MapComponent";
+import { queryFeaturesByIds } from "@/utils/mapQueryService";
+import { GeoJSON } from "ol/format";
+import Feature from "ol/Feature";
+import VectorLayer from "ol/layer/Vector";
+import VectorSource from "ol/source/Vector";
+import { Circle, Fill, Stroke, Style } from "ol/style";
+import { bbox, featureCollection } from "@turf/turf";
+import { BurstDetectionResult, BurstDetectionRow } from "./types";
+
+interface Props {
+ result: BurstDetectionResult | 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 MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
+ const style = toneStyles[tone];
+ return (
+
+
+ {label}
+
+
+ {value}
+
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+ );
+};
+
+const EmptyState = () => (
+
+
+
+
+
+ 等待侦测结果
+
+
+ 提交一次爆管侦测后,这里会展示异常天数、分数趋势、最新测点排名和结果表格。
+
+
+);
+
+const getScoreLevel = (score: number) => {
+ if (score <= -0.6) return { label: "高风险", color: "error" as const };
+ if (score <= -0.2) return { label: "需关注", color: "warning" as const };
+ return { label: "正常", color: "success" as const };
+};
+
+const formatDateTime = (value?: string) => (value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-");
+
+const DetectionResults: React.FC = ({ result }) => {
+ const map = useMap();
+ const highlightLayerRef = useRef | null>(null);
+ const [highlightFeatures, setHighlightFeatures] = useState([]);
+ const [selectedDay, setSelectedDay] = useState(null);
+
+ useEffect(() => {
+ if (!map) return;
+
+ const layer = new VectorLayer({
+ source: new VectorSource(),
+ style: new Style({
+ stroke: new Stroke({ color: "#ef4444", width: 4 }),
+ image: new Circle({
+ radius: 7,
+ fill: new Fill({ color: "#ef4444" }),
+ stroke: new Stroke({ color: "#fff", width: 2 }),
+ }),
+ zIndex: 999,
+ }),
+ properties: {
+ name: "爆管侦测高亮",
+ value: "burst_detection_highlight",
+ },
+ });
+
+ map.addLayer(layer);
+ highlightLayerRef.current = layer;
+
+ return () => {
+ highlightLayerRef.current = null;
+ map.removeLayer(layer);
+ };
+ }, [map]);
+
+ useEffect(() => {
+ const source = highlightLayerRef.current?.getSource();
+ if (!source) return;
+ source.clear();
+ highlightFeatures.forEach((feature) => source.addFeature(feature));
+ }, [highlightFeatures]);
+
+ const defaultSelectedDay = useMemo(
+ () =>
+ result?.summary?.most_anomalous_day ??
+ result?.summary?.latest_day?.Day ??
+ result?.rows[0]?.Day ??
+ null,
+ [result],
+ );
+
+ const activeSelectedDay = selectedDay ?? defaultSelectedDay;
+
+ const selectedRow = useMemo(() => {
+ if (!result || activeSelectedDay === null) return null;
+ return result.rows.find((row) => row.Day === activeSelectedDay) ?? null;
+ }, [activeSelectedDay, result]);
+
+ const scoreSeries = useMemo(
+ () =>
+ result?.rows.map((row) => ({
+ value: [row.Day, Number(row.Score.toFixed(4))],
+ itemStyle: {
+ color: row.IsBurst ? "#ef4444" : row.Score <= -0.2 ? "#f59e0b" : "#10b981",
+ },
+ })) ?? [],
+ [result],
+ );
+
+ const rankingSeries = useMemo(
+ () =>
+ [...(result?.summary?.latest_sensor_rankings ?? [])]
+ .sort((a, b) => a.latest_high_frequency_value - b.latest_high_frequency_value)
+ .map((item) => ({
+ name: item.sensor_node,
+ value: Number(item.latest_high_frequency_value.toFixed(4)),
+ })),
+ [result],
+ );
+
+ const locateSensors = async (sensorIds: string[]) => {
+ if (!map || sensorIds.length === 0) return;
+
+ let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat");
+ if (features.length === 0) {
+ features = await queryFeaturesByIds(sensorIds, "geo_junctions");
+ }
+ if (features.length === 0) return;
+
+ setHighlightFeatures(features);
+
+ const geojsonFormat = new GeoJSON();
+ 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: 18,
+ duration: 1000,
+ padding: [100, 100, 100, 100],
+ });
+ };
+
+ if (!result) {
+ return ;
+ }
+
+ const latestDay = result.summary?.latest_day;
+ const latestLevel = latestDay ? getScoreLevel(latestDay.Score) : getScoreLevel(0);
+ const mostAnomalousRow = result.rows.find((row) => row.Day === result.summary?.most_anomalous_day) ?? null;
+ const mostAnomalousLevel = getScoreLevel(mostAnomalousRow?.Score ?? 0);
+ const isBurstDetected = result.summary.burst_detected;
+
+ const chartOption = {
+ tooltip: {
+ trigger: "axis",
+ formatter: (params: Array<{ data: { value: [number, number] } }>) => {
+ const point = params[0]?.data?.value;
+ if (!point) return "-";
+ return `侦测日第 ${point[0]} 天
异常分数:${point[1]}`;
+ },
+ },
+ grid: { top: 30, left: 40, right: 20, bottom: 35 },
+ xAxis: {
+ type: "category",
+ name: "侦测日",
+ data: result.rows.map((row) => row.Day),
+ axisLabel: { fontSize: 10 },
+ },
+ yAxis: {
+ type: "value",
+ name: "异常分数",
+ axisLabel: { fontSize: 10 },
+ },
+ series: [
+ {
+ type: "line",
+ smooth: true,
+ symbolSize: 8,
+ data: scoreSeries,
+ lineStyle: { color: "#2563eb", width: 2 },
+ markLine: {
+ symbol: "none",
+ lineStyle: { type: "dashed", color: "#94a3b8" },
+ data: [{ yAxis: 0 }],
+ },
+ },
+ ],
+ };
+
+ const rankingOption = {
+ tooltip: {
+ trigger: "axis",
+ axisPointer: { type: "shadow" },
+ },
+ grid: { top: 20, left: 70, right: 20, bottom: 20 },
+ xAxis: { type: "value", axisLabel: { fontSize: 10 } },
+ yAxis: {
+ type: "category",
+ data: rankingSeries.map((item) => item.name),
+ axisLabel: { fontSize: 10 },
+ },
+ series: [
+ {
+ type: "bar",
+ data: rankingSeries.map((item) => ({
+ value: item.value,
+ itemStyle: {
+ color: item.value <= -0.6 ? "#ef4444" : item.value <= -0.2 ? "#f59e0b" : "#10b981",
+ },
+ })),
+ barWidth: 14,
+ },
+ ],
+ };
+
+ const columns: GridColDef[] = [
+ {
+ field: "Day",
+ headerName: "侦测日",
+ width: 96,
+ valueFormatter: (value?: number) => (typeof value === "number" ? `第 ${value} 天` : "-"),
+ },
+ {
+ field: "Score",
+ headerName: "异常分数",
+ width: 120,
+ valueFormatter: (value?: number) => (typeof value === "number" ? value.toFixed(4) : "-"),
+ },
+ {
+ field: "IsBurst",
+ headerName: "判定结果",
+ width: 120,
+ renderCell: ({ value }) => {
+ const level = value ? { label: "爆管异常", color: "error" as const } : { label: "正常", color: "success" as const };
+ return ;
+ },
+ },
+ ];
+
+ const rows = result.rows.map((row) => ({ id: row.Day, ...row }));
+
+ return (
+
+
+ {/* Status Banner */}
+
+ {isBurstDetected ? (
+
+ ) : (
+
+ )}
+
+
+ {isBurstDetected
+ ? `侦测到异常信号 (共 ${result.summary.anomaly_day_count} 天)`
+ : "未侦测到爆管异常"}
+
+
+ {isBurstDetected
+ ? "建议检查异常日期的压力波动情况"
+ : "当前时间窗口内数据特征平稳,符合历史模式"}
+
+
+
+
+ {/* Header */}
+
+
+
+
+ {result.scheme_name || "爆管侦测结果"}
+
+
+
+ {result.username ? (
+
+ ) : null}
+ }
+ onClick={() =>
+ locateSensors(result.summary.latest_sensor_rankings.map((item) => item.sensor_node).slice(0, 5))
+ }
+ sx={{
+ height: 24,
+ minWidth: 0,
+ padding: "0 8px",
+ borderColor: "#bfdbfe",
+ color: "#2563eb",
+ fontSize: "0.75rem",
+ "&:hover": { borderColor: "#60a5fa", backgroundColor: "#eff6ff" },
+ }}
+ >
+ 定位
+
+
+
+
+ {/* Configuration Summary */}
+
+
+
+ 时间窗口:
+
+ {formatDateTime(result.scada_window?.start)} ~ {formatDateTime(result.scada_window?.end)}
+
+
+
+
+ 数据来源:
+
+ {(() => {
+ const ds = result.data_source;
+ const os = result.observed_source;
+ if (ds === "simulation") return "模拟数据";
+ if (ds === "monitoring") return "监测数据";
+ if (os === "simulation_scheme_timerange") return "模拟数据";
+ if (os === "backend_timerange") return "监测数据";
+ return os || "-";
+ })()}
+
+
+
+
+ {/* Metrics Grid */}
+
+ 0 ? "orange" : "green"}
+ />
+
+
+
+
+
+
+ {/* Score Trend Chart */}
+
+
+
+
+
+ 异常分数趋势
+
+
+
+
+
+
+
+ {
+ const day = params?.data?.value?.[0];
+ if (typeof day === "number") {
+ setSelectedDay(day);
+ }
+ },
+ }}
+ />
+
+
+
+ {/* Selected Day Interpretation */}
+
+
+
+ 选中日解读
+
+ {selectedRow ? (
+
+ ) : null}
+
+ {selectedRow ? (
+
+
+
+
+
+ 异常分数:{selectedRow.Score.toFixed(4)}
+
+
+ 模型判定:{selectedRow.IsBurst ? "异常日(Prediction = -1)" : "正常日(Prediction = 1)"}
+
+
+ 解读建议:
+ {selectedRow.Score <= -0.6
+ ? "高风险异常,建议优先复核对应测点的原始压力曲线与现场工况。"
+ : selectedRow.Score <= -0.2
+ ? "存在可疑波动,建议结合相邻测点和调度记录进一步确认。"
+ : "未见明显异常,可作为基线日参考。"}
+
+
+ ) : (
+
+ 请在趋势图或表格中选择一天查看详细解释。
+
+ )}
+
+
+ {/* Latest Sensor Rankings */}
+
+
+
+ 最新测点高频特征排名
+
+
+ 仅展示最新一天
+
+
+
+
+
+
+ {result.summary.latest_sensor_rankings.slice(0, 5).map((item) => (
+
+ ))}
+
+
+
+ {/* Results Table */}
+
+
+
+
+
+ 结果表格
+
+
+
+
+
+ setSelectedDay(Number(params.row.Day))}
+ />
+
+
+
+ );
+};
+
+export default DetectionResults;
diff --git a/src/components/olmap/BurstDetection/SchemeQuery.tsx b/src/components/olmap/BurstDetection/SchemeQuery.tsx
new file mode 100644
index 0000000..a00eaec
--- /dev/null
+++ b/src/components/olmap/BurstDetection/SchemeQuery.tsx
@@ -0,0 +1,350 @@
+"use client";
+
+import React, { useState } from "react";
+import {
+ Box,
+ Button,
+ Card,
+ CardContent,
+ Checkbox,
+ Chip,
+ Collapse,
+ FormControlLabel,
+ IconButton,
+ Tooltip,
+ Typography,
+} from "@mui/material";
+import { InfoOutlined as InfoIcon } from "@mui/icons-material";
+import { DatePicker } from "@mui/x-date-pickers/DatePicker";
+import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
+import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
+import dayjs, { Dayjs } from "dayjs";
+import "dayjs/locale/zh-cn";
+import { useNotification } from "@refinedev/core";
+import { api } from "@/lib/api";
+import { NETWORK_NAME } from "@config/config";
+import {
+ BurstDetectionResult,
+ BurstDetectionSchemeDetail,
+ BurstDetectionSchemeRecord,
+} from "./types";
+
+interface Props {
+ onViewResult: (result: BurstDetectionResult) => void;
+}
+
+const SchemeQuery: React.FC = ({ onViewResult }) => {
+ const { open } = useNotification();
+ const [queryAll, setQueryAll] = useState(true);
+ const [queryDate, setQueryDate] = useState(dayjs());
+ const [schemes, setSchemes] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [expandedId, setExpandedId] = useState(null);
+
+ const buildDisplayResult = (
+ scheme: Pick,
+ detail?: BurstDetectionSchemeDetail,
+ ): BurstDetectionResult | null => {
+ const payload = detail?.result_payload;
+ const summary = detail?.result_summary;
+ const fallbackLatestDay = summary?.latest_day;
+
+ if (!payload && !summary) return null;
+
+ return {
+ network: payload?.network ?? detail?.network ?? NETWORK_NAME,
+ sensor_nodes: payload?.sensor_nodes ?? detail?.sensor_nodes ?? [],
+ observed_source: payload?.observed_source ?? detail?.observed_source ?? "stored_scheme",
+ sample_count: payload?.sample_count ?? 0,
+ points_per_day: payload?.points_per_day ?? detail?.algorithm_params?.points_per_day ?? 1440,
+ day_count: payload?.day_count ?? payload?.rows?.length ?? 0,
+ rows: payload?.rows ?? (fallbackLatestDay ? [fallbackLatestDay] : []),
+ summary:
+ payload?.summary ??
+ (summary
+ ? summary
+ : {
+ burst_detected: false,
+ latest_day: fallbackLatestDay ?? { Day: 0, Score: 0, Prediction: 1, IsBurst: false },
+ most_anomalous_day: 0,
+ anomaly_days: [],
+ anomaly_day_count: 0,
+ latest_sensor_rankings: [],
+ }),
+ scada_window: payload?.scada_window ?? detail?.scada_window,
+ scheme_name: payload?.scheme_name ?? scheme.scheme_name,
+ username: payload?.username ?? scheme.username,
+ create_time: payload?.create_time ?? scheme.create_time,
+ algorithm_params: payload?.algorithm_params ?? detail?.algorithm_params,
+ };
+ };
+
+ const handleQuery = async () => {
+ setLoading(true);
+ try {
+ const params: Record = { network: NETWORK_NAME };
+ if (!queryAll && queryDate) {
+ params.query_date = queryDate.startOf("day").toISOString();
+ }
+
+ const response = await api.get("/api/v1/burst-detection/schemes/", { params });
+ setSchemes(response.data);
+ open?.({
+ type: "success",
+ message: "查询成功",
+ description: `共找到 ${response.data.length} 条侦测记录。`,
+ });
+ } catch (error: any) {
+ open?.({
+ type: "error",
+ message: "查询失败",
+ description: error?.response?.data?.detail ?? "无法获取侦测方案列表",
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleViewSchemeResult = async (schemeName: string) => {
+ try {
+ const response = await api.get(
+ `/api/v1/burst-detection/schemes/${encodeURIComponent(schemeName)}`,
+ { params: { network: NETWORK_NAME } },
+ );
+ const schemeRecord = response.data as BurstDetectionSchemeRecord & {
+ result_payload?: BurstDetectionResult;
+ };
+ 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: "方案加载成功",
+ description: `已加载方案:${schemeName}`,
+ });
+ } catch (error: any) {
+ open?.({
+ type: "error",
+ message: "查看详情失败",
+ description: error?.response?.data?.detail ?? error?.message ?? "无法获取方案详情",
+ });
+ }
+ };
+
+ return (
+
+
+
+
+ setQueryAll(event.target.checked)}
+ />
+ }
+ label={查询全部}
+ className="m-0"
+ />
+
+
+
+
+
+
+
+
+
+ {schemes.length === 0 ? (
+
+ 暂无侦测方案
+
+ 运行一次展示版侦测后,可在这里回看历史结果。
+
+
+ ) : (
+
+
+ 共 {schemes.length} 条记录
+
+ {schemes.map((scheme) => {
+ const summary = scheme.scheme_detail?.result_summary;
+ const payload = scheme.scheme_detail?.result_payload;
+ const isBurst = payload?.summary?.burst_detected ?? summary?.burst_detected ?? false;
+ const anomalyDayCount =
+ payload?.summary?.anomaly_day_count ?? summary?.anomaly_day_count ?? 0;
+ const mostAnomalousDay =
+ payload?.summary?.most_anomalous_day ?? summary?.most_anomalous_day ?? "-";
+ const sensorCount = payload?.sensor_nodes?.length ?? scheme.scheme_detail?.sensor_nodes?.length ?? 0;
+
+ return (
+
+
+
+
+
+
+ {scheme.scheme_name}
+
+
+
+
+ 创建时间:{dayjs(scheme.create_time).format("YYYY-MM-DD HH:mm")}
+
+
+
+
+
+ setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id)
+ }
+ color="primary"
+ className="p-1"
+ >
+
+
+
+
+
+
+
+
+
+ 异常天数
+
+
+ {anomalyDayCount}
+
+
+
+
+ 最异常日
+
+
+ {isBurst
+ ? typeof mostAnomalousDay === "number"
+ ? `第 ${mostAnomalousDay} 天`
+ : mostAnomalousDay
+ : "无"}
+
+
+
+
+ 测点数
+
+
+ {sensorCount}
+
+
+
+
+
+
+
+
+
+ 数据来源:
+
+
+ {(() => {
+ const ds = payload?.data_source;
+ const os = payload?.observed_source ?? scheme.scheme_detail?.observed_source;
+ if (ds === "simulation") return "模拟数据";
+ if (ds === "monitoring") return "监测数据";
+ if (os === "simulation_scheme_timerange") return "模拟数据";
+ if (os === "backend_timerange") return "监测数据";
+ return os || "-";
+ })()}
+
+
+
+
+ 时间窗口:
+
+
+ {payload?.scada_window?.start
+ ? `${dayjs(payload.scada_window.start).format("MM-DD HH:mm")} ~ ${dayjs(
+ payload.scada_window.end,
+ ).format("MM-DD HH:mm")}`
+ : "-"}
+
+
+
+
+ 算法参数:
+
+
+ 频域截断系数:{scheme.scheme_detail?.algorithm_params?.mu ?? payload?.algorithm_params?.mu ?? "-"}
+ ,每日采样点数:
+ {scheme.scheme_detail?.algorithm_params?.points_per_day ??
+ payload?.algorithm_params?.points_per_day ??
+ "-"}
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+};
+
+export default SchemeQuery;
diff --git a/src/components/olmap/BurstDetection/types.ts b/src/components/olmap/BurstDetection/types.ts
new file mode 100644
index 0000000..235edac
--- /dev/null
+++ b/src/components/olmap/BurstDetection/types.ts
@@ -0,0 +1,77 @@
+export interface BurstDetectionRow {
+ Day: number;
+ Score: number;
+ Prediction: number;
+ IsBurst: boolean;
+}
+
+export interface BurstDetectionSensorRanking {
+ sensor_node: string;
+ latest_high_frequency_value: number;
+}
+
+export interface BurstDetectionSummary {
+ burst_detected: boolean;
+ latest_day: BurstDetectionRow;
+ most_anomalous_day: number;
+ anomaly_days: number[];
+ anomaly_day_count: number;
+ latest_sensor_rankings: BurstDetectionSensorRanking[];
+}
+
+export interface BurstDetectionAlgorithmParams {
+ mu?: number;
+ points_per_day?: number;
+ iforest_params?: {
+ n_estimators?: number;
+ contamination?: number | "auto";
+ random_state?: number;
+ };
+}
+
+export interface BurstDetectionResult {
+ network: string;
+ sensor_nodes: string[];
+ observed_source: string;
+ sample_count: number;
+ points_per_day: number;
+ day_count: number;
+ rows: BurstDetectionRow[];
+ summary: BurstDetectionSummary;
+ scada_window?: {
+ start?: string;
+ end?: string;
+ };
+ scheme_name?: string;
+ username?: string;
+ create_time?: string;
+ data_source?: "monitoring" | "simulation";
+ simulation_scheme?: {
+ name?: string;
+ type?: string;
+ };
+ algorithm_params?: BurstDetectionAlgorithmParams;
+}
+
+export interface BurstDetectionSchemeDetail {
+ network?: string;
+ sensor_nodes?: string[];
+ observed_source?: string;
+ scada_window?: {
+ start?: string;
+ end?: string;
+ };
+ algorithm_params?: BurstDetectionAlgorithmParams;
+ result_summary?: BurstDetectionSummary;
+ result_payload?: BurstDetectionResult;
+}
+
+export interface BurstDetectionSchemeRecord {
+ scheme_id: number;
+ scheme_name: string;
+ scheme_type?: string;
+ create_time: string;
+ scheme_start_time?: string;
+ username?: string;
+ scheme_detail?: BurstDetectionSchemeDetail;
+}