"use client"; import React, { useState, useEffect, useMemo, useCallback, useRef, startTransition, } from "react"; import { Box, Typography, ListItem, ListItemButton, ListItemText, ListItemIcon, Chip, IconButton, FormControl, InputLabel, Select, MenuItem, Tooltip, Stack, Divider, InputBase, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Alert, Drawer, } from "@mui/material"; import { Search, MyLocation, FilterList, Clear, DeviceHub, TouchApp, CleaningServices, Sensors, ChevronLeft, ChevronRight, } from "@mui/icons-material"; import { FixedSizeList } from "react-window"; import { useNotification } from "@refinedev/core"; import axios from "axios"; import { useGetIdentity } from "@refinedev/core"; import config, { NETWORK_NAME } from "@/config/config"; import { useMap } from "@app/OlMap/MapComponent"; import { GeoJSON } from "ol/format"; import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; import { Stroke, Style, Circle, Fill } from "ol/style"; import Feature from "ol/Feature"; import { Point } from "ol/geom"; import { getVectorContext } from "ol/render"; import { unByKey } from "ol/Observable"; import "dayjs/locale/zh-cn"; // 引入中文包 import dayjs, { Dayjs } from "dayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; const STATUS_OPTIONS: { value: "online" | "offline" | "warning" | "error"; name: "在线" | "离线" | "警告" | "错误"; }[] = [ { value: "online", name: "在线" }, { value: "offline", name: "离线" }, { value: "warning", name: "警告" }, { value: "error", name: "错误" }, ]; interface SCADADevice { id: string; name: string; transmission_frequency: string; reliability: number; type: string; status: { value: "online" | "offline" | "warning" | "error"; name: "在线" | "离线" | "警告" | "错误"; }; coordinates: [number, number]; properties?: Record; } interface SCADADeviceListProps { devices?: SCADADevice[]; onDeviceClick?: (device: SCADADevice) => void; onZoomToDevice?: (coordinates: [number, number]) => void; multiSelect?: boolean; selectedDeviceIds?: string[]; onSelectionChange?: (ids: string[]) => void; showCleaning?: boolean; onCleanAllData?: (from: Date, to: Date) => void; } type IUser = { id: number; name: string; }; const SCADADeviceList: React.FC = ({ devices = [], onDeviceClick, multiSelect = true, selectedDeviceIds, onSelectionChange, showCleaning = false, onCleanAllData, }) => { const [searchQuery, setSearchQuery] = useState(""); const [selectedType, setSelectedType] = useState("all"); const [selectedStatus, setSelectedStatus] = useState("all"); const [selectedReliability, setSelectedReliability] = useState("all"); const [isExpanded, setIsExpanded] = useState(true); const [isSelecting, setIsSelecting] = useState(false); const [internalSelection, setInternalSelection] = useState([]); const [pendingSelection, setPendingSelection] = useState( null ); const [internalDevices, setInternalDevices] = useState([]); const [loading, setLoading] = useState(true); const [inputValue, setInputValue] = useState(""); const [highlightLayer, setHighlightLayer] = useState | null>(null); const [highlightFeatures, setHighlightFeatures] = useState([]); const blinkListenerKeyRef = useRef(null); const debounceTimerRef = useRef(null); const filterBoxRef = useRef(null); const [listHeight, setListHeight] = useState(600); // 清洗对话框状态 const [cleanDialogOpen, setCleanDialogOpen] = useState(false); const [cleanStartTime, setCleanStartTime] = useState(() => dayjs().subtract(1, "week") ); const [cleanEndTime, setCleanEndTime] = useState(() => dayjs()); const [timeRangeError, setTimeRangeError] = useState(""); // 防抖更新搜索查询 const debouncedSetSearchQuery = useCallback((value: string) => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } // 根据输入长度调整防抖延迟:短输入延迟更长,长输入响应更快 const delay = value.length <= 2 ? 200 : 100; debounceTimerRef.current = setTimeout(() => { setSearchQuery(value); }, delay); }, []); const activeSelection = selectedDeviceIds ?? internalSelection; const map = useMap(); // 移到此处,确保在条件检查前调用 const { open } = useNotification(); const { data: user } = useGetIdentity(); useEffect(() => { if (selectedDeviceIds) { setInternalSelection(selectedDeviceIds); } }, [selectedDeviceIds]); // 添加 useEffect 来延迟调用 onSelectionChange,避免在渲染时触发父组件的 setState useEffect(() => { if (pendingSelection !== null) { onSelectionChange?.(pendingSelection); setPendingSelection(null); } }, [pendingSelection, onSelectionChange]); // 初始化 SCADA 设备列表 useEffect(() => { const fetchScadaDevices = async () => { setLoading(true); try { const url = `${config.MAP_URL}/${config.MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${config.MAP_WORKSPACE}:geo_scada&outputFormat=application/json`; const response = await fetch(url); if (!response.ok) throw new Error("Failed to fetch SCADA devices"); const json = await response.json(); const features = new GeoJSON().readFeatures(json); const data = features.map((feature) => ({ id: feature.get("id") || feature.getId(), name: feature.get("id") || feature.getId(), transmission_frequency: feature.get("transmission_frequency"), reliability: feature.get("reliability"), type: feature.get("type") === "pipe_flow" ? "流量" : "压力", status: STATUS_OPTIONS[Math.floor(Math.random() * 4)], coordinates: (feature.getGeometry() as Point)?.getCoordinates() as [ number, number ], properties: feature.getProperties(), })); setInternalDevices(data); } catch (error) { console.error("Error fetching SCADA devices:", error); } finally { setLoading(false); } }; fetchScadaDevices(); }, []); const effectiveDevices = devices.length > 0 ? devices : internalDevices; // 获取设备类型列表 const deviceTypes = useMemo(() => { const types = Array.from( new Set(effectiveDevices.map((device) => device.type)) ); return types.sort(); }, [effectiveDevices]); // 获取设备状态列表 const deviceStatuses = STATUS_OPTIONS; // 可靠度文字映射 const getReliability = (reliability: number) => { switch (reliability) { case 1: return "高"; case 2: return "中"; case 3: return "低"; default: return "未知"; } }; // 过滤设备列表 const filteredDevices = useMemo(() => { return effectiveDevices.filter((device) => { const searchLower = searchQuery.toLowerCase(); const nameLower = device.name.toLowerCase(); const idLower = device.id.toLowerCase(); const matchesSearch = searchQuery === "" || nameLower.indexOf(searchLower) !== -1 || idLower.indexOf(searchLower) !== -1; const matchesType = selectedType === "all" || device.type === selectedType; const matchesStatus = selectedStatus === "all" || device.status.value === selectedStatus; const matchesReliability = selectedReliability === "all" || getReliability(device.reliability) === selectedReliability; return ( matchesSearch && matchesType && matchesStatus && matchesReliability ); }); }, [ effectiveDevices, searchQuery, selectedType, selectedStatus, selectedReliability, ]); // 状态颜色映射 const getStatusColor = (status: string) => { switch (status) { case "online": return "success"; case "offline": return "default"; case "warning": return "warning"; case "error": return "error"; default: return "default"; } }; // 状态图标映射 const getStatusIcon = (status: string) => { switch (status) { case "online": return "●"; case "offline": return "○"; case "warning": return "▲"; case "error": return "✕"; default: return "●"; } }; // 传输频率文字对应 const getTransmissionFrequency = (transmission_frequency: string) => { // 传输频率文本:00:01:00,00:05:00,00:10:00,00:30:00,01:00:00,转换为分钟数 const parts = transmission_frequency.split(":"); if (parts.length !== 3) return transmission_frequency; const hours = parseInt(parts[0], 10); const minutes = parseInt(parts[1], 10); const seconds = parseInt(parts[2], 10); const totalMinutes = hours * 60 + minutes + (seconds >= 30 ? 1 : 0); return totalMinutes; }; // 处理设备点击 const handleDeviceClick = (device: SCADADevice, event?: React.MouseEvent) => { onDeviceClick?.(device); setInternalSelection((prev) => { const exists = prev.includes(device.id); const nextSelection = multiSelect ? exists ? prev.filter((id) => id !== device.id) : [...prev, device.id] : exists ? [] : [device.id]; setPendingSelection(nextSelection); // 设置待处理的 selection,延迟调用 return nextSelection; }); }; // 处理缩放到设备 const handleZoomToDevice = (device: SCADADevice) => { if (!map) return; // 缩放到设备位置 map.getView().fit(new Point(device.coordinates), { maxZoom: 18, duration: 1000, }); // 创建闪烁效果 createBlinkingEffect(device.coordinates); }; // 创建闪烁效果 const createBlinkingEffect = (coordinates: [number, number]) => { if (!map || !highlightLayer) return; // 清除之前的闪烁效果 if (blinkListenerKeyRef.current) { unByKey(blinkListenerKeyRef.current); blinkListenerKeyRef.current = null; } // 创建闪烁点要素 const blinkFeature = new Feature({ geometry: new Point(coordinates), }); const duration = 2000; // 闪烁持续时间 const start = Date.now(); // 使用图层的 postrender 事件实现闪烁动画 const listenerKey = highlightLayer.on("postrender", (event) => { const elapsed = Date.now() - start; if (elapsed > duration) { // 动画结束 unByKey(listenerKey); blinkListenerKeyRef.current = null; map.render(); // 最后渲染一次以清除效果 return; } const vectorContext = getVectorContext(event); const flashGeom = blinkFeature.getGeometry(); if (!flashGeom) return; // 计算闪烁效果 const progress = elapsed / duration; // 使用正弦波创建脉冲效果,增加频率以产生更快的闪烁 const rawOpacity = Math.abs(Math.sin(progress * Math.PI * 6)) * (1 - progress); // 确保 opacity 不会太小,避免科学计数法导致的颜色解析错误 const opacity = Math.max(rawOpacity, 0.01); const radius = 10 + (1 - progress) * 10; // 从20逐渐减小到10 // 当透明度太低时直接跳过渲染 if (opacity < 0.02) { map.render(); return; } // 绘制外圈(黄色光晕) vectorContext.setStyle( new Style({ image: new Circle({ radius: radius * 1.5, fill: new Fill({ color: `rgba(255, 255, 0, ${(opacity * 0.3).toFixed(3)})`, }), }), }) ); vectorContext.drawGeometry(flashGeom); // 绘制中圈(亮黄色) vectorContext.setStyle( new Style({ image: new Circle({ radius: radius, fill: new Fill({ color: `rgba(255, 255, 0, ${(opacity * 0.6).toFixed(3)})`, }), stroke: new Stroke({ color: `rgba(255, 200, 0, ${opacity.toFixed(3)})`, width: 2, }), }), }) ); vectorContext.drawGeometry(flashGeom); // 绘制内核(白色中心) vectorContext.setStyle( new Style({ image: new Circle({ radius: radius * 0.3, fill: new Fill({ color: `rgba(255, 255, 255, ${opacity.toFixed(3)})`, }), }), }) ); vectorContext.drawGeometry(flashGeom); // 继续渲染下一帧 map.render(); }); blinkListenerKeyRef.current = listenerKey; }; // 清除搜索 const handleClearSearch = useCallback(() => { setInputValue(""); startTransition(() => { setSearchQuery(""); }); }, []); // 重置所有筛选条件 const handleResetFilters = useCallback(() => { setInputValue(""); startTransition(() => { setSearchQuery(""); setSelectedType("all"); setSelectedStatus("all"); setSelectedReliability("all"); }); }, []); // 清除选择 const handleClearSelection = useCallback(() => { setInternalSelection([]); setHighlightFeatures([]); setPendingSelection([]); }, []); // 地图点击选择 SCADA 设备事件处理函数 const handleMapClickSelectFeatures = useCallback( async (event: { coordinate: number[] }) => { if (!map) return; const feature = await mapClickSelectFeatures(event, map); const layer = feature?.getId()?.toString().split(".")[0]; if (!feature) return; if (layer !== "geo_scada_mat" && layer !== "geo_scada") { open?.({ type: "error", message: "请选择 SCADA 设备。", }); return; } const featureId = feature.getProperties().id; // 在设备列表中查找对应的设备 const device = effectiveDevices.find((d) => d.id === featureId); if (!device) { open?.({ type: "error", message: "未找到对应的 SCADA 设备。", }); return; } // 更新选择状态 setInternalSelection((prev) => { const exists = prev.includes(featureId); const nextSelection = multiSelect ? exists ? prev.filter((id) => id !== featureId) : [...prev, featureId] : exists ? [] : [featureId]; setPendingSelection(nextSelection); return nextSelection; }); // 更新高亮要素 setHighlightFeatures((prev) => { const existingIndex = prev.findIndex( (f) => f.getProperties().id === featureId ); if (existingIndex !== -1) { // 如果已存在,移除 return prev.filter((_, i) => i !== existingIndex); } else { // 如果不存在,添加 return [...prev, feature]; } }); }, [map, effectiveDevices, multiSelect, open] ); // 处理清洗对话框关闭 const handleCleanDialogClose = useCallback(() => { setCleanDialogOpen(false); setTimeRangeError(""); }, []); // 验证时间范围 const validateTimeRange = useCallback((start: Dayjs, end: Dayjs): string => { if (start.isAfter(end)) { return "开始时间不能晚于结束时间"; } const daysDiff = end.diff(start, "day"); if (daysDiff > 14) { return "时间范围不能超过两周(14天)"; } return ""; }, []); // 处理开始时间变化 const handleCleanStartTimeChange = useCallback( (newValue: Dayjs | Date | null) => { if (newValue) { const dayjsValue = dayjs.isDayjs(newValue) ? newValue : dayjs(newValue); setCleanStartTime(dayjsValue); const error = validateTimeRange(dayjsValue, cleanEndTime); setTimeRangeError(error); } }, [cleanEndTime, validateTimeRange] ); // 处理结束时间变化 const handleCleanEndTimeChange = useCallback( (newValue: Dayjs | Date | null) => { if (newValue) { const dayjsValue = dayjs.isDayjs(newValue) ? newValue : dayjs(newValue); setCleanEndTime(dayjsValue); const error = validateTimeRange(cleanStartTime, dayjsValue); setTimeRangeError(error); } }, [cleanStartTime, validateTimeRange] ); // 确认清洗 const handleConfirmClean = useCallback(async () => { const error = validateTimeRange(cleanStartTime, cleanEndTime); if (error) { setTimeRangeError(error); return; } // 获取所有设备ID const allDeviceIds = internalDevices.map((d) => d.id); if (allDeviceIds.length === 0) { open?.({ type: "error", message: "无设备可清洗", }); return; } if (!user || !user.name) { open?.({ type: "error", message: "用户信息无效,请重新登录", }); return; } try { const startTime = dayjs(cleanStartTime).toISOString(); const endTime = dayjs(cleanEndTime).toISOString(); // 调用后端清洗接口 const response = await axios.post( `${config.BACKEND_URL}/timescaledb/composite/clean-scada?device_ids=all&start_time=${startTime}&end_time=${endTime}` ); // 处理成功响应 if (response.data === "success" || response.data?.success === true) { open?.({ type: "success", message: "全部数据清洗成功", description: `已完成 ${allDeviceIds.length} 个设备的数据清洗`, }); // 如果父组件提供了回调,也调用它 onCleanAllData?.(cleanStartTime.toDate(), cleanEndTime.toDate()); } else { throw new Error(response.data?.message || "清洗失败"); } } catch (err: any) { console.error("[SCADADeviceList] 数据清洗失败:", err); open?.({ type: "error", message: "数据清洗失败", description: err.response?.data?.message || err.message || "未知错误", }); } handleCleanDialogClose(); }, [ cleanStartTime, cleanEndTime, validateTimeRange, internalDevices, user, open, onCleanAllData, handleCleanDialogClose, ]); // 开始选择 SCADA 设备 const handleStartSelection = useCallback(() => { if (!map) return; setIsSelecting(true); // 注册点击事件 map.on("click", handleMapClickSelectFeatures); }, [map, handleMapClickSelectFeatures]); // 结束选择 SCADA 设备 const handleEndSelection = useCallback(() => { if (!map) return; setIsSelecting(false); // 移除点击事件 map.un("click", handleMapClickSelectFeatures); }, [map, handleMapClickSelectFeatures]); // 组件卸载时清理地图事件 useEffect(() => { return () => { if (map && isSelecting) { map.un("click", handleMapClickSelectFeatures); } }; }, [map, isSelecting, handleMapClickSelectFeatures]); // 初始化管道图层和高亮图层 useEffect(() => { if (!map) return; // 获取地图的目标容器 const SCADASelectedStyle = () => { return new Style({ image: new Circle({ stroke: new Stroke({ color: "yellow", width: 2 }), radius: 15, }), }); }; // 创建高亮图层 const highlightLayer = new VectorLayer({ source: new VectorSource(), style: SCADASelectedStyle, maxZoom: 24, minZoom: 12, properties: { name: "SCADA 选中高亮", value: "scada_selected_highlight", }, }); map.addLayer(highlightLayer); setHighlightLayer(highlightLayer); return () => { // 清除闪烁效果监听器 if (blinkListenerKeyRef.current) { unByKey(blinkListenerKeyRef.current); blinkListenerKeyRef.current = null; } map.removeLayer(highlightLayer); }; }, [map]); // 高亮要素的函数 useEffect(() => { if (!highlightLayer) { return; } const source = highlightLayer.getSource(); if (!source) { return; } // 清除之前的高亮 source.clear(); // 添加新的高亮要素 highlightFeatures.forEach((feature) => { if (feature instanceof Feature) { source.addFeature(feature); } }); }, [selectedDeviceIds, highlightFeatures]); // 清理定时器 useEffect(() => { return () => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } }; }, []); // 动态计算列表高度 useEffect(() => { const updateListHeight = () => { if (filterBoxRef.current) { const drawerHeight = 860; // Drawer 总高度 const headerHeight = 73; // 头部高度(估算) const dividerHeight = 1; // 分隔线高度 const filterBoxHeight = filterBoxRef.current.offsetHeight; const availableHeight = drawerHeight - headerHeight - filterBoxHeight - dividerHeight - 8; // 减去一些边距 setListHeight(Math.max(availableHeight, 260)); // 最小高度 260 } }; updateListHeight(); // 使用 ResizeObserver 监听筛选框高度变化 const resizeObserver = new ResizeObserver(updateListHeight); if (filterBoxRef.current) { resizeObserver.observe(filterBoxRef.current); } return () => { resizeObserver.disconnect(); }; }, [ activeSelection.length, searchQuery, selectedType, selectedStatus, selectedReliability, showCleaning, ]); return ( <> {/* 收起时的触发按钮 */} {!isExpanded && ( setIsExpanded(true)} > SCADA设备 )} {/* 主面板 */} {/* 头部控制栏 */} setIsExpanded(false)} sx={{ color: "white" }} > SCADA 设备列表 {/* 搜索和筛选栏 */} {/* 搜索框 */} { setInputValue(e.target.value); debouncedSetSearchQuery(e.target.value); }} inputProps={{ "aria-label": "search devices" }} /> {searchQuery && ( <> )} {/* 筛选器 */} 设备类型 状态 可靠度 {/* 筛选结果统计 */} 共找到 {filteredDevices.length} 个设备 {devices.length !== filteredDevices.length && ` (共 ${effectiveDevices.length} 个设备)`} {/* 地图选择按钮 */} {/* 清洗全部数据按钮 */} {showCleaning && ( setCleanDialogOpen(true)} sx={{ border: 1, borderColor: "secondary.main", "&:hover": { backgroundColor: "secondary.50", }, }} > )} {/* 清除选择按钮 */} {activeSelection.length > 0 && ( } sx={{ fontWeight: "medium" }} /> )} {/* 设备列表 */} {loading ? ( ) : filteredDevices.length === 0 ? ( {searchQuery || selectedType !== "all" || selectedStatus !== "all" ? "未找到匹配的设备" : "暂无 SCADA 设备"} ) : ( {({ index, style, }: { index: number; style: React.CSSProperties; }) => { const device = filteredDevices[index]; return (
handleDeviceClick(device, event)} sx={{ "&.Mui-selected": { backgroundColor: "primary.50", borderColor: "primary.main", }, "&:hover": { backgroundColor: "grey.50", }, }} > {getStatusIcon(device.status.value)} {device.name} } secondary={ ID: {device.id} 传输频率:{" "} {getTransmissionFrequency( device.transmission_frequency )}{" "} 分钟 } slotProps={{ secondary: { component: "div", // 使其支持多行 }, }} /> { event.stopPropagation(); handleZoomToDevice(device); }} sx={{ ml: 1, color: "primary.main", "&:hover": { backgroundColor: "primary.50", }, }} > {index < filteredDevices.length - 1 && ( )}
); }}
)}
{/* 清洗数据时间段选择对话框 */} 清洗全部数据 请选择要清洗数据的时间段,最长不超过两周(14天)。 {timeRangeError && ( {timeRangeError} )} 时间范围: {cleanStartTime.format("YYYY-MM-DD HH:mm")} 至{" "} {cleanEndTime.format("YYYY-MM-DD HH:mm")} 跨度: {cleanEndTime.diff(cleanStartTime, "day")} 天{" "} {cleanEndTime.diff(cleanStartTime, "hour") % 24} 小时
); }; export default SCADADeviceList;