1283 lines
41 KiB
TypeScript
1283 lines
41 KiB
TypeScript
"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<string, any>;
|
||
}
|
||
|
||
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<SCADADeviceListProps> = ({
|
||
devices = [],
|
||
onDeviceClick,
|
||
multiSelect = true,
|
||
selectedDeviceIds,
|
||
onSelectionChange,
|
||
showCleaning = false,
|
||
onCleanAllData,
|
||
}) => {
|
||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||
const [selectedType, setSelectedType] = useState<string>("all");
|
||
const [selectedStatus, setSelectedStatus] = useState<string>("all");
|
||
const [selectedReliability, setSelectedReliability] = useState<string>("all");
|
||
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
||
const [internalSelection, setInternalSelection] = useState<string[]>([]);
|
||
const [pendingSelection, setPendingSelection] = useState<string[] | null>(
|
||
null
|
||
);
|
||
const [internalDevices, setInternalDevices] = useState<SCADADevice[]>([]);
|
||
const [loading, setLoading] = useState<boolean>(true);
|
||
const [inputValue, setInputValue] = useState<string>("");
|
||
|
||
const [highlightLayer, setHighlightLayer] =
|
||
useState<VectorLayer<VectorSource> | null>(null);
|
||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||
const blinkListenerKeyRef = useRef<any>(null);
|
||
|
||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||
const filterBoxRef = useRef<HTMLDivElement | null>(null);
|
||
const [listHeight, setListHeight] = useState<number>(600);
|
||
|
||
// 清洗对话框状态
|
||
const [cleanDialogOpen, setCleanDialogOpen] = useState<boolean>(false);
|
||
const [cleanStartTime, setCleanStartTime] = useState<Dayjs>(() =>
|
||
dayjs().subtract(1, "week")
|
||
);
|
||
const [cleanEndTime, setCleanEndTime] = useState<Dayjs>(() => dayjs());
|
||
const [timeRangeError, setTimeRangeError] = useState<string>("");
|
||
|
||
// 防抖更新搜索查询
|
||
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<IUser>();
|
||
|
||
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 && (
|
||
<Box
|
||
className="absolute top-20 left-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100"
|
||
onClick={() => setIsExpanded(true)}
|
||
>
|
||
<Box className="flex flex-col items-center py-3 px-3 gap-1">
|
||
<Sensors className="text-[#1976d2] w-5 h-5" />
|
||
<Typography
|
||
variant="caption"
|
||
className="text-gray-700 font-semibold my-1 text-xs"
|
||
style={{ writingMode: "vertical-rl" }}
|
||
>
|
||
SCADA设备
|
||
</Typography>
|
||
<ChevronRight className="text-gray-600 w-4 h-4" />
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
{/* 主面板 */}
|
||
<Drawer
|
||
anchor="left"
|
||
open={isExpanded}
|
||
variant="persistent"
|
||
hideBackdrop
|
||
sx={{
|
||
width: isExpanded ? 360 : 0,
|
||
flexShrink: 0,
|
||
"& .MuiDrawer-paper": {
|
||
width: 360,
|
||
height: "860px",
|
||
boxSizing: "border-box",
|
||
position: "absolute",
|
||
top: 80,
|
||
left: 16,
|
||
borderRadius: "12px",
|
||
boxShadow:
|
||
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
|
||
backdropFilter: "blur(8px)",
|
||
opacity: 0.95,
|
||
transition: "all 0.3s ease-in-out",
|
||
border: "none",
|
||
"&:hover": {
|
||
opacity: 1,
|
||
},
|
||
},
|
||
}}
|
||
>
|
||
<Box className="flex flex-col h-full bg-white rounded-xl overflow-hidden">
|
||
{/* 头部控制栏 */}
|
||
<Box
|
||
sx={{
|
||
p: 2,
|
||
borderBottom: 1,
|
||
borderColor: "divider",
|
||
backgroundColor: "primary.main",
|
||
color: "white",
|
||
}}
|
||
>
|
||
<Stack
|
||
direction="row"
|
||
alignItems="center"
|
||
justifyContent="space-between"
|
||
>
|
||
<Stack direction="row" alignItems="center" spacing={1}>
|
||
<Tooltip title="收起">
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => setIsExpanded(false)}
|
||
sx={{ color: "white" }}
|
||
>
|
||
<ChevronLeft fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
<Sensors fontSize="small" />
|
||
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
|
||
SCADA 设备列表
|
||
</Typography>
|
||
<Chip
|
||
label={filteredDevices.length}
|
||
size="small"
|
||
sx={{
|
||
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||
color: "white",
|
||
fontWeight: "bold",
|
||
}}
|
||
/>
|
||
</Stack>
|
||
</Stack>
|
||
</Box>
|
||
|
||
{/* 搜索和筛选栏 */}
|
||
<Box ref={filterBoxRef} sx={{ p: 2, backgroundColor: "grey.50" }}>
|
||
<Stack spacing={2}>
|
||
{/* 搜索框 */}
|
||
<Box className="h-10 flex items-center border border-gray-300 rounded-md p-0.5">
|
||
<InputBase
|
||
sx={{ ml: 1, flex: 1 }}
|
||
placeholder="搜索设备名称、ID..."
|
||
value={inputValue}
|
||
onChange={(e) => {
|
||
setInputValue(e.target.value);
|
||
debouncedSetSearchQuery(e.target.value);
|
||
}}
|
||
inputProps={{ "aria-label": "search devices" }}
|
||
/>
|
||
<IconButton type="button" sx={{ p: "6px" }} aria-label="search">
|
||
<Search fontSize="small" />
|
||
</IconButton>
|
||
{searchQuery && (
|
||
<>
|
||
<Divider
|
||
sx={{ height: 28, m: 0.5 }}
|
||
orientation="vertical"
|
||
/>
|
||
<IconButton
|
||
color="primary"
|
||
sx={{ p: "6px" }}
|
||
onClick={handleClearSearch}
|
||
aria-label="clear"
|
||
>
|
||
<Clear fontSize="small" />
|
||
</IconButton>
|
||
</>
|
||
)}
|
||
</Box>
|
||
|
||
{/* 筛选器 */}
|
||
<Stack direction="row" spacing={2}>
|
||
<FormControl size="small" sx={{ minWidth: 80 }}>
|
||
<InputLabel>设备类型</InputLabel>
|
||
<Select
|
||
value={selectedType}
|
||
label="设备类型"
|
||
onChange={(e) => setSelectedType(e.target.value)}
|
||
>
|
||
<MenuItem value="all">全部</MenuItem>
|
||
{deviceTypes.map((type) => (
|
||
<MenuItem key={type} value={type}>
|
||
{type}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
|
||
<FormControl size="small" sx={{ minWidth: 80 }}>
|
||
<InputLabel>状态</InputLabel>
|
||
<Select
|
||
value={selectedStatus}
|
||
label="状态"
|
||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||
>
|
||
<MenuItem value="all">全部</MenuItem>
|
||
{deviceStatuses.map((status) => (
|
||
<MenuItem key={status.value} value={status.value}>
|
||
{status.name}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
|
||
<FormControl size="small" sx={{ minWidth: 80 }}>
|
||
<InputLabel>可靠度</InputLabel>
|
||
<Select
|
||
value={selectedReliability}
|
||
label="可靠度"
|
||
onChange={(e) => setSelectedReliability(e.target.value)}
|
||
>
|
||
<MenuItem value="all">全部</MenuItem>
|
||
<MenuItem value="高">高</MenuItem>
|
||
<MenuItem value="中">中</MenuItem>
|
||
<MenuItem value="低">低</MenuItem>
|
||
</Select>
|
||
</FormControl>
|
||
|
||
<Tooltip title="重置筛选条件">
|
||
<IconButton onClick={handleResetFilters}>
|
||
<FilterList />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Stack>
|
||
|
||
{/* 筛选结果统计 */}
|
||
<Stack direction="row" alignItems="center" spacing={2}>
|
||
<Typography variant="caption" color="text.secondary">
|
||
共找到 {filteredDevices.length} 个设备
|
||
{devices.length !== filteredDevices.length &&
|
||
` (共 ${effectiveDevices.length} 个设备)`}
|
||
</Typography>
|
||
|
||
{/* 地图选择按钮 */}
|
||
<Tooltip
|
||
title={isSelecting ? "结束地图选择" : "从地图选择设备"}
|
||
>
|
||
<IconButton
|
||
size="small"
|
||
color={isSelecting ? "primary" : "default"}
|
||
onClick={
|
||
isSelecting ? handleEndSelection : handleStartSelection
|
||
}
|
||
sx={{
|
||
border: 1,
|
||
borderColor: isSelecting ? "primary.main" : "divider",
|
||
backgroundColor: isSelecting
|
||
? "primary.50"
|
||
: "transparent",
|
||
"&:hover": {
|
||
backgroundColor: isSelecting
|
||
? "primary.100"
|
||
: "grey.100",
|
||
},
|
||
}}
|
||
>
|
||
<TouchApp fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
|
||
{/* 清洗全部数据按钮 */}
|
||
{showCleaning && (
|
||
<Tooltip title="清洗全部数据">
|
||
<IconButton
|
||
size="small"
|
||
color="secondary"
|
||
onClick={() => setCleanDialogOpen(true)}
|
||
sx={{
|
||
border: 1,
|
||
borderColor: "secondary.main",
|
||
"&:hover": {
|
||
backgroundColor: "secondary.50",
|
||
},
|
||
}}
|
||
>
|
||
<CleaningServices fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
)}
|
||
</Stack>
|
||
|
||
{/* 清除选择按钮 */}
|
||
{activeSelection.length > 0 && (
|
||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||
<Chip
|
||
label={`已选择 ${activeSelection.length} 个设备`}
|
||
color="primary"
|
||
size="small"
|
||
onDelete={handleClearSelection}
|
||
deleteIcon={<Clear />}
|
||
sx={{ fontWeight: "medium" }}
|
||
/>
|
||
</Box>
|
||
)}
|
||
</Stack>
|
||
</Box>
|
||
|
||
<Divider />
|
||
|
||
{/* 设备列表 */}
|
||
<Box sx={{ flex: 1, overflow: "hidden" }}>
|
||
{loading ? (
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
justifyContent: "center",
|
||
alignItems: "center",
|
||
height: 200,
|
||
}}
|
||
>
|
||
<CircularProgress />
|
||
</Box>
|
||
) : filteredDevices.length === 0 ? (
|
||
<Box
|
||
sx={{
|
||
p: 4,
|
||
textAlign: "center",
|
||
color: "text.secondary",
|
||
}}
|
||
>
|
||
<DeviceHub sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
|
||
<Typography variant="body2">
|
||
{searchQuery ||
|
||
selectedType !== "all" ||
|
||
selectedStatus !== "all"
|
||
? "未找到匹配的设备"
|
||
: "暂无 SCADA 设备"}
|
||
</Typography>
|
||
</Box>
|
||
) : (
|
||
<FixedSizeList
|
||
height={listHeight}
|
||
itemCount={filteredDevices.length}
|
||
itemSize={92}
|
||
width="100%"
|
||
>
|
||
{({
|
||
index,
|
||
style,
|
||
}: {
|
||
index: number;
|
||
style: React.CSSProperties;
|
||
}) => {
|
||
const device = filteredDevices[index];
|
||
return (
|
||
<div style={style}>
|
||
<ListItem disablePadding>
|
||
<ListItemButton
|
||
selected={activeSelection.includes(device.id)}
|
||
onClick={(event) => handleDeviceClick(device, event)}
|
||
sx={{
|
||
"&.Mui-selected": {
|
||
backgroundColor: "primary.50",
|
||
borderColor: "primary.main",
|
||
},
|
||
"&:hover": {
|
||
backgroundColor: "grey.50",
|
||
},
|
||
}}
|
||
>
|
||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{
|
||
color: `${getStatusColor(
|
||
device.status.value
|
||
)}.main`,
|
||
fontWeight: "bold",
|
||
fontSize: 16,
|
||
}}
|
||
>
|
||
{getStatusIcon(device.status.value)}
|
||
</Typography>
|
||
</ListItemIcon>
|
||
|
||
<ListItemText
|
||
primary={
|
||
<Stack
|
||
direction="row"
|
||
alignItems="center"
|
||
spacing={1}
|
||
>
|
||
<Typography
|
||
variant="body2"
|
||
sx={{ fontWeight: "medium" }}
|
||
>
|
||
{device.name}
|
||
</Typography>
|
||
<Chip
|
||
label={device.type}
|
||
size="small"
|
||
variant="outlined"
|
||
sx={{ fontSize: "0.7rem", height: 20 }}
|
||
/>
|
||
<Chip
|
||
label={`可靠度: ${getReliability(
|
||
device.reliability
|
||
)}`}
|
||
size="small"
|
||
variant="outlined"
|
||
sx={{ fontSize: "0.7rem", height: 20 }}
|
||
/>
|
||
</Stack>
|
||
}
|
||
secondary={
|
||
<Stack spacing={0.5}>
|
||
<Typography
|
||
variant="caption"
|
||
color="text.secondary"
|
||
>
|
||
ID: {device.id}
|
||
</Typography>
|
||
<Typography
|
||
variant="caption"
|
||
color="text.secondary"
|
||
>
|
||
传输频率:{" "}
|
||
{getTransmissionFrequency(
|
||
device.transmission_frequency
|
||
)}{" "}
|
||
分钟
|
||
</Typography>
|
||
</Stack>
|
||
}
|
||
slotProps={{
|
||
secondary: {
|
||
component: "div", // 使其支持多行
|
||
},
|
||
}}
|
||
/>
|
||
|
||
<Tooltip title="缩放到设备位置">
|
||
<IconButton
|
||
size="small"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
handleZoomToDevice(device);
|
||
}}
|
||
sx={{
|
||
ml: 1,
|
||
color: "primary.main",
|
||
"&:hover": {
|
||
backgroundColor: "primary.50",
|
||
},
|
||
}}
|
||
>
|
||
<MyLocation fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</ListItemButton>
|
||
</ListItem>
|
||
{index < filteredDevices.length - 1 && (
|
||
<Divider variant="inset" />
|
||
)}
|
||
</div>
|
||
);
|
||
}}
|
||
</FixedSizeList>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* 清洗数据时间段选择对话框 */}
|
||
<Dialog
|
||
open={cleanDialogOpen}
|
||
onClose={handleCleanDialogClose}
|
||
maxWidth="sm"
|
||
fullWidth
|
||
>
|
||
<DialogTitle>
|
||
<Stack direction="row" alignItems="center" spacing={1}>
|
||
<CleaningServices color="secondary" />
|
||
<Typography variant="h6">清洗全部数据</Typography>
|
||
</Stack>
|
||
</DialogTitle>
|
||
<DialogContent>
|
||
<Stack spacing={3} sx={{ mt: 2 }}>
|
||
<Alert severity="info">
|
||
请选择要清洗数据的时间段,最长不超过两周(14天)。
|
||
</Alert>
|
||
|
||
<LocalizationProvider
|
||
dateAdapter={AdapterDayjs}
|
||
adapterLocale="zh-cn"
|
||
>
|
||
<DateTimePicker
|
||
label="开始时间"
|
||
value={cleanStartTime}
|
||
onChange={handleCleanStartTimeChange}
|
||
maxDateTime={cleanEndTime}
|
||
slotProps={{
|
||
textField: {
|
||
fullWidth: true,
|
||
error: !!timeRangeError,
|
||
},
|
||
}}
|
||
/>
|
||
|
||
<DateTimePicker
|
||
label="结束时间"
|
||
value={cleanEndTime}
|
||
onChange={handleCleanEndTimeChange}
|
||
minDateTime={cleanStartTime}
|
||
maxDateTime={dayjs()}
|
||
slotProps={{
|
||
textField: {
|
||
fullWidth: true,
|
||
error: !!timeRangeError,
|
||
},
|
||
}}
|
||
/>
|
||
</LocalizationProvider>
|
||
|
||
{timeRangeError && (
|
||
<Alert severity="error">{timeRangeError}</Alert>
|
||
)}
|
||
|
||
<Box sx={{ p: 2, backgroundColor: "grey.50", borderRadius: 1 }}>
|
||
<Typography variant="body2" color="text.secondary">
|
||
<strong>时间范围:</strong>
|
||
{cleanStartTime.format("YYYY-MM-DD HH:mm")} 至{" "}
|
||
{cleanEndTime.format("YYYY-MM-DD HH:mm")}
|
||
</Typography>
|
||
<Typography
|
||
variant="body2"
|
||
color="text.secondary"
|
||
sx={{ mt: 1 }}
|
||
>
|
||
<strong>跨度:</strong>
|
||
{cleanEndTime.diff(cleanStartTime, "day")} 天{" "}
|
||
{cleanEndTime.diff(cleanStartTime, "hour") % 24} 小时
|
||
</Typography>
|
||
</Box>
|
||
</Stack>
|
||
</DialogContent>
|
||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||
<Button onClick={handleCleanDialogClose}>取消</Button>
|
||
<Button
|
||
variant="contained"
|
||
color="secondary"
|
||
onClick={handleConfirmClean}
|
||
disabled={!!timeRangeError}
|
||
startIcon={<CleaningServices />}
|
||
>
|
||
确认清洗
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
</Drawer>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default SCADADeviceList;
|