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

565 lines
18 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,
Paper,
Typography,
List,
ListItem,
ListItemButton,
ListItemText,
ListItemIcon,
Chip,
IconButton,
Collapse,
FormControl,
InputLabel,
Select,
MenuItem,
Tooltip,
Stack,
Divider,
InputBase,
CircularProgress,
} from "@mui/material";
import {
Search,
MyLocation,
ExpandMore,
ExpandLess,
FilterList,
Clear,
DeviceHub,
} from "@mui/icons-material";
import { useMap } from "@app/OlMap/MapComponent";
import { GeoJSON } from "ol/format";
import { Point } from "ol/geom";
import config from "@/config/config";
interface SCADADevice {
id: string;
name: string;
type: string;
coordinates: [number, number];
status: "在线" | "离线" | "警告" | "错误";
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;
}
const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
devices = [],
onDeviceClick,
multiSelect = true,
selectedDeviceIds,
onSelectionChange,
}) => {
const [searchQuery, setSearchQuery] = useState<string>("");
const [selectedType, setSelectedType] = useState<string>("all");
const [selectedStatus, setSelectedStatus] = useState<string>("all");
const [isExpanded, setIsExpanded] = useState<boolean>(true);
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 debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
// 防抖更新搜索查询
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(); // 移到此处,确保在条件检查前调用
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.mapUrl}/TJWater/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=TJWater: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(),
type: feature.get("type") === "pipe_flow" ? "流量" : "压力",
status: ["在线", "离线", "警告", "错误"][
Math.floor(Math.random() * 4)
] as "在线" | "离线" | "警告" | "错误",
coordinates: (feature.getGeometry() as Point)?.getCoordinates() as [
number,
number
],
properties: feature.getProperties(),
}));
setInternalDevices(data);
console.log("Fetched SCADA devices:", 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 = useMemo(() => {
const statuses = Array.from(
new Set(effectiveDevices.map((device) => device.status))
);
return statuses.sort();
}, [effectiveDevices]);
// 创建设备索引 Map使用设备 ID 作为键
const deviceIndex = useMemo(() => {
const index = new Map<string, SCADADevice>();
effectiveDevices.forEach((device) => {
index.set(device.id, device);
});
return index;
}, [effectiveDevices]);
// 过滤设备列表
const filteredDevices = useMemo(() => {
if (
searchQuery === "" &&
selectedType === "all" &&
selectedStatus === "all"
) {
return effectiveDevices;
}
const searchLower = searchQuery.toLowerCase();
return effectiveDevices.filter((device) => {
if (searchQuery === "") return true;
const nameLower = device.name.toLowerCase();
const idLower = device.id.toLowerCase();
const matchesSearch =
nameLower.indexOf(searchLower) !== -1 ||
idLower.indexOf(searchLower) !== -1;
const matchesType =
selectedType === "all" || device.type === selectedType;
const matchesStatus =
selectedStatus === "all" || device.status === selectedStatus;
return matchesSearch && matchesType && matchesStatus;
});
}, [effectiveDevices, searchQuery, selectedType, selectedStatus]);
// 状态颜色映射
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 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) => {
map
?.getView()
.fit(new Point(device.coordinates), { maxZoom: 15, duration: 1000 });
};
// 清除搜索
const handleClearSearch = useCallback(() => {
setInputValue("");
startTransition(() => {
setSearchQuery("");
});
}, []);
// 重置所有筛选条件
const handleResetFilters = useCallback(() => {
setInputValue("");
startTransition(() => {
setSearchQuery("");
setSelectedType("all");
setSelectedStatus("all");
});
}, []);
// 清理定时器
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
return (
<Paper className="absolute left-4 top-20 w-90 max-h-[calc(100vh-100px)] bg-white rounded-xl shadow-lg overflow-hidden flex flex-col opacity-95 transition-opacity duration-200 ease-in-out hover:opacity-100">
{/* 头部控制栏 */}
<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}>
<DeviceHub 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 direction="row" spacing={1}>
<Tooltip title="展开/收起">
<IconButton
size="small"
onClick={() => setIsExpanded(!isExpanded)}
sx={{ color: "white" }}
>
{isExpanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
</Tooltip>
</Stack>
</Stack>
</Box>
<Collapse in={isExpanded}>
{/* 搜索和筛选栏 */}
<Box 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: 120 }}>
<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: 100 }}>
<InputLabel></InputLabel>
<Select
value={selectedStatus}
label="状态"
onChange={(e) => setSelectedStatus(e.target.value)}
>
<MenuItem value="all"></MenuItem>
{deviceStatuses.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</Select>
</FormControl>
<Tooltip title="重置筛选条件">
<IconButton onClick={handleResetFilters}>
<FilterList />
</IconButton>
</Tooltip>
</Stack>
{/* 筛选结果统计 */}
<Typography variant="caption" color="text.secondary">
{filteredDevices.length}
{devices.length !== filteredDevices.length &&
` (共 ${effectiveDevices.length} 个设备)`}
</Typography>
</Stack>
</Box>
<Divider />
{/* 设备列表 */}
<Box sx={{ flex: 1, overflow: "auto", maxHeight: 400 }}>
{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>
) : (
<List dense sx={{ p: 0 }}>
{filteredDevices.map((device, index) => (
<React.Fragment key={device.id}>
<ListItem disablePadding>
<ListItemButton
selected={activeSelection.includes(device.id)}
onClick={(event) => handleDeviceClick(device, event)}
sx={{
"&.Mui-selected": {
backgroundColor: "primary.50",
borderLeft: 3,
borderColor: "primary.main",
},
"&:hover": {
backgroundColor: "grey.50",
},
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Typography
variant="caption"
sx={{
color: `${getStatusColor(device.status)}.main`,
fontWeight: "bold",
fontSize: 16,
}}
>
{getStatusIcon(device.status)}
</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 }}
/>
</Stack>
}
secondary={
<Stack spacing={0.5}>
<Typography
variant="caption"
color="text.secondary"
>
ID: {device.id}
</Typography>
<Typography
variant="caption"
color="text.secondary"
>
: {device.coordinates[0].toFixed(6)},{" "}
{device.coordinates[1].toFixed(6)}
</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" />
)}
</React.Fragment>
))}
</List>
)}
</Box>
</Collapse>
</Paper>
);
};
export default SCADADeviceList;