Files
TJWaterFrontend_Refine/src/components/olmap/SCADADeviceList.tsx

1283 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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:0000:05:0000:10:0000:30:0001: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;