Files
TJWaterServer/src/components/olmap/SCADADataPanel.tsx
2025-10-30 17:35:27 +08:00

754 lines
21 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, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Chip,
CircularProgress,
Divider,
IconButton,
Paper,
Stack,
Tab,
Tabs,
Tooltip,
Typography,
Collapse,
} from "@mui/material";
import {
Close,
Refresh,
ShowChart,
TableChart,
ExpandLess,
ExpandMore,
} from "@mui/icons-material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { LineChart } from "@mui/x-charts";
import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import clsx from "clsx";
import config from "@/config/config";
import { GeoJSON } from "ol/format";
dayjs.extend(utc);
dayjs.extend(timezone);
export interface TimeSeriesPoint {
/** ISO8601 时间戳 */
timestamp: string;
/** 每个设备对应的值 */
values: Record<string, number | null | undefined>;
}
export interface SCADADataPanelProps {
/** 选中的设备 ID 列表 */
deviceIds: string[];
/** 自定义数据获取器,默认使用后端 API */
fetchTimeSeriesData?: (
deviceIds: string[],
range: { from: Date; to: Date }
) => Promise<TimeSeriesPoint[]>;
/** 可选:控制浮窗显示 */
visible?: boolean;
/** 可选:关闭浮窗的回调 */
onClose?: () => void;
/** 默认展示的选项卡 */
defaultTab?: "chart" | "table";
/** Y 轴数值的小数位数 */
fractionDigits?: number;
}
type PanelTab = "chart" | "table";
type LoadingState = "idle" | "loading" | "success" | "error";
/**
* 从后端 API 获取 SCADA 数据
*/
const fetchFromBackend = async (
deviceIds: string[],
range: { from: Date; to: Date }
): Promise<TimeSeriesPoint[]> => {
if (deviceIds.length === 0) {
return [];
}
const ids = deviceIds.join(",");
const starttime = dayjs(range.from).format("YYYY-MM-DD HH:mm:ss");
const endtime = dayjs(range.to).format("YYYY-MM-DD HH:mm:ss");
const url = `${config.backendUrl}/querycleaningscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return transformBackendData(data, deviceIds);
} catch (error) {
console.error("[SCADADataPanel] 从后端获取数据失败:", error);
throw error;
}
};
/**
* 转换后端数据格式
* 根据实际后端返回的数据结构进行调整
*/
const transformBackendData = (
backendData: any,
deviceIds: string[]
): TimeSeriesPoint[] => {
// 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] }
if (
backendData &&
typeof backendData === "object" &&
!Array.isArray(backendData)
) {
// 检查是否是设备ID为键的对象格式
const hasDeviceKeys = deviceIds.some((id) => id in backendData);
if (hasDeviceKeys) {
// 获取所有时间点的集合
const timeMap = new Map<string, Record<string, number | null>>();
deviceIds.forEach((deviceId) => {
const deviceData = backendData[deviceId];
if (Array.isArray(deviceData)) {
deviceData.forEach((item: any) => {
const timestamp = item.time || item.timestamp || item._time;
if (timestamp) {
if (!timeMap.has(timestamp)) {
timeMap.set(timestamp, {});
}
const values = timeMap.get(timestamp)!;
values[deviceId] =
typeof item.value === "number" ? item.value : null;
}
});
}
});
// 转换为 TimeSeriesPoint 数组并按时间排序
const result = Array.from(timeMap.entries()).map(
([timestamp, values]) => ({
timestamp,
values,
})
);
result.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
}
}
// 如果后端返回的是数组格式,每个元素包含时间戳和各设备值
if (Array.isArray(backendData)) {
return backendData.map((item: any) => ({
timestamp: item.timestamp || item.time || item._time,
values: deviceIds.reduce<Record<string, number | null>>((acc, id) => {
acc[id] = item[id] ?? item.values?.[id] ?? null;
return acc;
}, {}),
}));
}
// 如果后端返回的是对象格式,包含 timestamps 和 data
if (backendData && backendData.timestamps && backendData.data) {
return backendData.timestamps.map((timestamp: string, index: number) => {
const values = deviceIds.reduce<Record<string, number | null>>(
(acc, id) => {
acc[id] = backendData.data[id]?.[index] ?? null;
return acc;
},
{}
);
return { timestamp, values };
});
}
// 默认返回空数组
console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData);
return [];
};
const defaultFetcher = fetchFromBackend;
const formatTimestamp = (timestamp: string) =>
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
const ensureValidRange = (
from: Dayjs,
to: Dayjs
): { from: Dayjs; to: Dayjs } => {
if (from.isAfter(to)) {
return { from: to, to: from };
}
return { from, to };
};
const buildDataset = (
points: TimeSeriesPoint[],
deviceIds: string[],
fractionDigits: number
) => {
return points.map((point) => {
const entry: Record<string, any> = {
time: dayjs(point.timestamp).toDate(),
label: formatTimestamp(point.timestamp),
};
deviceIds.forEach((id) => {
const value = point.values[id];
entry[id] =
typeof value === "number"
? Number.isFinite(value)
? parseFloat(value.toFixed(fractionDigits))
: null
: value ?? null;
});
return entry;
});
};
const emptyStateMessages: Record<
PanelTab,
{ title: string; subtitle: string }
> = {
chart: {
title: "暂无时序数据",
subtitle: "请选择设备并点击刷新来获取曲线",
},
table: {
title: "暂无表格数据",
subtitle: "请选择设备并点击刷新来获取记录",
},
};
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
deviceIds,
fetchTimeSeriesData = defaultFetcher,
visible = true,
onClose,
defaultTab = "chart",
fractionDigits = 2,
}) => {
const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
const [to, setTo] = useState<Dayjs>(() => dayjs());
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
const [error, setError] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState<boolean>(true);
const [deviceLabels, setDeviceLabels] = useState<Record<string, string>>({});
// 获取 SCADA 设备信息,生成 deviceLabels
useEffect(() => {
const fetchDeviceLabels = async () => {
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) return;
const json = await response.json();
const features = new GeoJSON().readFeatures(json);
const labels = features.reduce<Record<string, string>>(
(acc, feature) => {
const id = feature.get("id") || feature.getId();
const name = feature.get("name") || id;
acc[id] = name;
return acc;
},
{}
);
setDeviceLabels(labels);
} catch (error) {
console.error("[SCADADataPanel] 获取设备标签失败:", error);
}
};
fetchDeviceLabels();
}, []);
useEffect(() => {
setActiveTab(defaultTab);
}, [defaultTab]);
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
const hasDevices = deviceIds.length > 0;
const hasData = timeSeries.length > 0;
const dataset = useMemo(
() => buildDataset(timeSeries, deviceIds, fractionDigits),
[timeSeries, deviceIds, fractionDigits]
);
const handleFetch = useCallback(
async (reason: string) => {
if (!hasDevices) {
setTimeSeries([]);
setLoadingState("idle");
setError(null);
return;
}
setLoadingState("loading");
setError(null);
try {
const { from: rangeFrom, to: rangeTo } = normalizedRange;
const result = await fetchTimeSeriesData(deviceIds, {
from: rangeFrom.toDate(),
to: rangeTo.toDate(),
});
setTimeSeries(result);
setLoadingState("success");
console.debug(
`[SCADADataPanel] 数据刷新成功 (${reason}),共 ${result.length} 条记录。`
);
} catch (err) {
console.error("[SCADADataPanel] 获取时序数据失败", err);
setError(err instanceof Error ? err.message : "未知错误");
setLoadingState("error");
}
},
[deviceIds, fetchTimeSeriesData, hasDevices, normalizedRange]
);
// 设备变化时自动查询
useEffect(() => {
if (hasDevices) {
handleFetch("device-change");
} else {
setTimeSeries([]);
}
}, [deviceIds.join(","), hasDevices]);
const columns: GridColDef[] = useMemo(() => {
const base: GridColDef[] = [
{
field: "label",
headerName: "时间",
minWidth: 180,
flex: 1,
},
];
const dynamic = deviceIds.map<GridColDef>((id) => ({
field: id,
headerName: deviceLabels?.[id] ?? id,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
if (value === null || value === undefined) return "--";
if (Number.isFinite(Number(value))) {
return Number(value).toFixed(fractionDigits);
}
return String(value);
},
}));
return [...base, ...dynamic];
}, [deviceIds, deviceLabels, fractionDigits]);
const rows = useMemo(
() =>
dataset.map((item, index) => ({
id: `${
item.time instanceof Date ? item.time.getTime() : index
}-${index}`,
...item,
})),
[dataset]
);
const renderEmpty = () => {
const message = emptyStateMessages[activeTab];
return (
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
py: 8,
color: "text.secondary",
height: 420,
}}
>
<ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
{message.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{message.subtitle}
</Typography>
</Box>
);
};
const renderChart = () => {
if (!hasData) return renderEmpty();
// 为每个设备生成独特的颜色和样式
const colors = [
"#1976d2", // 蓝色
"#dc004e", // 粉红色
"#ff9800", // 橙色
"#4caf50", // 绿色
"#9c27b0", // 紫色
"#00bcd4", // 青色
"#f44336", // 红色
"#8bc34a", // 浅绿色
"#ff5722", // 深橙色
"#3f51b5", // 靛蓝色
];
return (
<Box sx={{ width: "100%", height: 420 }}>
<LineChart
dataset={dataset}
height={420}
margin={{ left: 70, right: 40, top: 30, bottom: 90 }}
xAxis={[
{
dataKey: "time",
scaleType: "time",
valueFormatter: (value) =>
value instanceof Date
? dayjs(value).format("MM-DD HH:mm")
: String(value),
tickLabelStyle: {
angle: -45,
textAnchor: "end",
fontSize: 11,
fill: "#666",
},
},
]}
yAxis={[
{
label: "数值",
labelStyle: {
fontSize: 13,
fill: "#333",
fontWeight: 500,
},
tickLabelStyle: {
fontSize: 11,
fill: "#666",
},
},
]}
series={deviceIds.map((id, index) => ({
dataKey: id,
label: deviceLabels?.[id] ?? id,
showMark: dataset.length < 50, // 数据点少时显示标记
curve: "catmullRom", // 使用平滑曲线
color: colors[index % colors.length],
valueFormatter: (value: number | null) =>
value !== null ? value.toFixed(fractionDigits) : "--",
area: false,
stack: undefined,
}))}
grid={{ vertical: true, horizontal: true }}
sx={{
"& .MuiLineElement-root": {
strokeWidth: 2.5,
strokeLinecap: "round",
strokeLinejoin: "round",
},
"& .MuiMarkElement-root": {
scale: "0.8",
strokeWidth: 2,
},
"& .MuiChartsAxis-line": {
stroke: "#e0e0e0",
strokeWidth: 1,
},
"& .MuiChartsAxis-tick": {
stroke: "#e0e0e0",
strokeWidth: 1,
},
"& .MuiChartsGrid-line": {
stroke: "#f5f5f5",
strokeWidth: 1,
strokeDasharray: "3 3",
},
}}
slotProps={{
legend: {
direction: "row",
position: { horizontal: "middle", vertical: "bottom" },
padding: { top: 20, bottom: 10, left: 0, right: 0 },
itemMarkWidth: 16,
itemMarkHeight: 3,
markGap: 8,
itemGap: 16,
labelStyle: {
fontSize: 12,
fill: "#333",
fontWeight: 500,
},
},
loadingOverlay: {
style: { backgroundColor: "rgba(255, 255, 255, 0.7)" },
},
}}
tooltip={{
trigger: "axis",
}}
/>
</Box>
);
};
const renderTable = () => {
if (!hasData) return renderEmpty();
console.debug("[SCADADataPanel] 表格数据:", {
rowsCount: rows.length,
columnsCount: columns.length,
sampleRow: rows[0],
columns: columns.map((c) => c.field),
});
return (
<DataGrid
rows={rows}
columns={columns}
columnBufferPx={100}
initialState={{
pagination: {
paginationModel: { pageSize: 50, page: 0 },
},
}}
pageSizeOptions={[25, 50, 100]}
sx={{
border: "none",
height: "420px",
"& .MuiDataGrid-cell": {
borderColor: "#f0f0f0",
},
"& .MuiDataGrid-columnHeaders": {
backgroundColor: "#fafafa",
fontWeight: 600,
fontSize: "0.875rem",
},
"& .MuiDataGrid-row:hover": {
backgroundColor: "#f5f5f5",
},
"& .MuiDataGrid-columnHeaderTitle": {
fontWeight: 600,
},
}}
disableRowSelectionOnClick
/>
);
};
return (
<Paper
className={clsx(
"absolute right-4 top-20 bg-white rounded-xl shadow-lg overflow-hidden flex flex-col transition-all duration-300",
visible ? "opacity-95 hover:opacity-100" : "opacity-0 -z-10"
)}
sx={{
width: "min(920px, calc(100vw - 2rem))",
maxHeight: "calc(100vh - 100px)",
}}
>
{/* Header */}
<Box
sx={{
p: 2,
borderBottom: 1,
borderColor: "divider",
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
>
<Stack direction="row" spacing={1} alignItems="center">
<ShowChart fontSize="small" />
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
SCADA
</Typography>
<Chip
size="small"
label={`${deviceIds.length}`}
sx={{
backgroundColor: "rgba(255,255,255,0.2)",
color: "primary.contrastText",
fontWeight: "bold",
}}
/>
</Stack>
<Stack direction="row" spacing={1}>
<Tooltip title="展开/收起">
<IconButton
size="small"
onClick={() => setIsExpanded(!isExpanded)}
sx={{ color: "primary.contrastText" }}
>
{isExpanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
</Tooltip>
<Tooltip title="关闭">
<IconButton
size="small"
onClick={onClose}
sx={{ color: "primary.contrastText" }}
>
<Close fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Stack>
</Box>
<Collapse in={isExpanded}>
{/* Controls */}
<Box sx={{ p: 2, backgroundColor: "grey.50" }}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<Stack spacing={1.5}>
<Stack direction="row" spacing={1} alignItems="center">
<DateTimePicker
label="开始时间"
value={from}
onChange={(value) =>
value && dayjs.isDayjs(value) && setFrom(value)
}
onAccept={(value) => {
if (value && dayjs.isDayjs(value) && hasDevices) {
handleFetch("date-change");
}
}}
maxDateTime={to}
slotProps={{ textField: { fullWidth: true, size: "small" } }}
/>
<DateTimePicker
label="结束时间"
value={to}
onChange={(value) =>
value && dayjs.isDayjs(value) && setTo(value)
}
onAccept={(value) => {
if (value && dayjs.isDayjs(value) && hasDevices) {
handleFetch("date-change");
}
}}
minDateTime={from}
slotProps={{ textField: { fullWidth: true, size: "small" } }}
/>
</Stack>
<Stack
direction="row"
spacing={1}
alignItems="center"
justifyContent="space-between"
>
<Tabs
value={activeTab}
onChange={(_, value: PanelTab) => setActiveTab(value)}
variant="fullWidth"
>
<Tab
value="chart"
icon={<ShowChart fontSize="small" />}
iconPosition="start"
label="曲线"
/>
<Tab
value="table"
icon={<TableChart fontSize="small" />}
iconPosition="start"
label="表格"
/>
</Tabs>
<Tooltip title="刷新数据">
<span>
<Button
variant="contained"
size="small"
color="primary"
startIcon={<Refresh fontSize="small" />}
disabled={!hasDevices || loadingState === "loading"}
onClick={() => handleFetch("manual")}
>
</Button>
</span>
</Tooltip>
</Stack>
</Stack>
</LocalizationProvider>
{!hasDevices && (
<Typography
variant="caption"
color="warning.main"
sx={{ mt: 1, display: "block" }}
>
</Typography>
)}
{error && (
<Typography
variant="caption"
color="error"
sx={{ mt: 1, display: "block" }}
>
{error}
</Typography>
)}
</Box>
<Divider />
{/* Content */}
<Box sx={{ flex: 1, position: "relative", p: 2, overflow: "auto" }}>
{loadingState === "loading" && (
<Box
sx={{
position: "absolute",
inset: 0,
backgroundColor: "rgba(255,255,255,0.6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1,
}}
>
<CircularProgress size={48} />
</Box>
)}
{activeTab === "chart" ? renderChart() : renderTable()}
</Box>
</Collapse>
</Paper>
);
};
export default SCADADataPanel;