SCADA 面板数据实现前后端对接

This commit is contained in:
JIANG
2025-10-30 17:35:27 +08:00
parent 265ecdc795
commit fe797c1bf3
4 changed files with 312 additions and 196 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import MapComponent from "@app/OlMap/MapComponent";
import Timeline from "@app/OlMap/Controls/Timeline";
import MapToolbar from "@app/OlMap/Controls/Toolbar";
@@ -8,59 +8,10 @@ import MapToolbar from "@app/OlMap/Controls/Toolbar";
import SCADADeviceList from "@components/olmap/SCADADeviceList";
import SCADADataPanel from "@components/olmap/SCADADataPanel";
const mockDevices = [
{
id: "SCADA-001",
name: "SCADA-001",
type: "pressure",
coordinates: [121.4737, 31.2304] as [number, number],
status: "在线" as const,
},
{
id: "SCADA-002",
name: "SCADA-002",
type: "flow",
coordinates: [121.4807, 31.2204] as [number, number],
status: "警告" as const,
},
{
id: "SCADA-003",
name: "SCADA-003",
type: "pressure",
coordinates: [121.4607, 31.2354] as [number, number],
status: "离线" as const,
},
{
id: "SCADA-004",
name: "SCADA-004",
type: "demand",
coordinates: [121.4457, 31.2104] as [number, number],
status: "在线" as const,
},
{
id: "SCADA-005",
name: "SCADA-005",
type: "level",
coordinates: [121.4457, 31.2104] as [number, number],
status: "在线" as const,
},
];
export default function Home() {
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
const [panelVisible, setPanelVisible] = useState<boolean>(false);
const devices = useMemo(() => mockDevices, []);
const deviceLabels = useMemo(
() =>
devices.reduce<Record<string, string>>((acc, device) => {
acc[device.id] = device.name;
return acc;
}, {}),
[devices]
);
const handleSelectionChange = useCallback((ids: string[]) => {
setSelectedDeviceIds(ids);
setPanelVisible(ids.length > 0);
@@ -80,14 +31,12 @@ export default function Home() {
<MapToolbar queryType="realtime" />
<Timeline />
<SCADADeviceList
devices={[]}
onDeviceClick={handleDeviceClick}
onSelectionChange={handleSelectionChange}
selectedDeviceIds={selectedDeviceIds}
/>
<SCADADataPanel
deviceIds={selectedDeviceIds}
deviceLabels={deviceLabels}
visible={panelVisible}
onClose={handleClosePanel}
/>

View File

@@ -1,65 +1,16 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import MapComponent from "@app/OlMap/MapComponent";
import MapToolbar from "@app/OlMap/Controls/Toolbar";
import SCADADeviceList from "@components/olmap/SCADADeviceList";
import SCADADataPanel from "@components/olmap/SCADADataPanel";
const mockDevices = [
{
id: "SCADA-001",
name: "SCADA-001",
type: "pressure",
coordinates: [121.4737, 31.2304] as [number, number],
status: "在线" as const,
},
{
id: "SCADA-002",
name: "SCADA-002",
type: "flow",
coordinates: [121.4807, 31.2204] as [number, number],
status: "警告" as const,
},
{
id: "SCADA-003",
name: "SCADA-003",
type: "pressure",
coordinates: [121.4607, 31.2354] as [number, number],
status: "离线" as const,
},
{
id: "SCADA-004",
name: "SCADA-004",
type: "demand",
coordinates: [121.4457, 31.2104] as [number, number],
status: "在线" as const,
},
{
id: "SCADA-005",
name: "SCADA-005",
type: "level",
coordinates: [121.4457, 31.2104] as [number, number],
status: "在线" as const,
},
];
export default function Home() {
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
const [panelVisible, setPanelVisible] = useState<boolean>(false);
const devices = useMemo(() => mockDevices, []);
const deviceLabels = useMemo(
() =>
devices.reduce<Record<string, string>>((acc, device) => {
acc[device.id] = device.name;
return acc;
}, {}),
[devices]
);
const handleSelectionChange = useCallback((ids: string[]) => {
setSelectedDeviceIds(ids);
setPanelVisible(ids.length > 0);
@@ -78,14 +29,12 @@ export default function Home() {
<MapComponent>
<MapToolbar hiddenButtons={["style"]} />
<SCADADeviceList
devices={[]}
onDeviceClick={handleDeviceClick}
onSelectionChange={handleSelectionChange}
selectedDeviceIds={selectedDeviceIds}
/>
<SCADADataPanel
deviceIds={selectedDeviceIds}
deviceLabels={deviceLabels}
visible={panelVisible}
onClose={handleClosePanel}
/>

View File

@@ -32,6 +32,8 @@ 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);
@@ -46,13 +48,11 @@ export interface TimeSeriesPoint {
export interface SCADADataPanelProps {
/** 选中的设备 ID 列表 */
deviceIds: string[];
/** 自定义数据获取器,默认使用本地模拟数据 */
/** 自定义数据获取器,默认使用后端 API */
fetchTimeSeriesData?: (
deviceIds: string[],
range: { from: Date; to: Date }
) => Promise<TimeSeriesPoint[]>;
/** 可选:为设备提供友好的显示名称 */
deviceLabels?: Record<string, string>;
/** 可选:控制浮窗显示 */
visible?: boolean;
/** 可选:关闭浮窗的回调 */
@@ -67,58 +67,122 @@ type PanelTab = "chart" | "table";
type LoadingState = "idle" | "loading" | "success" | "error";
const generateMockTimeSeries = (
/**
* 从后端 API 获取 SCADA 数据
*/
const fetchFromBackend = async (
deviceIds: string[],
range: { from: Date; to: Date },
points = 96
): TimeSeriesPoint[] => {
range: { from: Date; to: Date }
): Promise<TimeSeriesPoint[]> => {
if (deviceIds.length === 0) {
return [];
}
const start = dayjs(range.from);
const end = dayjs(range.to);
const duration = end.diff(start, "minute");
const stepMinutes = Math.max(
Math.floor(duration / Math.max(points - 1, 1)),
15
);
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 times: TimeSeriesPoint[] = [];
let current = start;
const url = `${config.backendUrl}/querycleaningscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
while (current.isBefore(end) || current.isSame(end)) {
const values = deviceIds.reduce<Record<string, number>>(
(acc, id, index) => {
const phase = (index + 1) * 0.6;
const base = 50 + index * 10;
const amplitude = 10 + index * 4;
const noise = Math.sin(current.unix() / 180 + phase) * amplitude;
const trend = (current.diff(start, "minute") / duration || 0) * 5;
acc[id] = parseFloat((base + noise + trend).toFixed(2));
return acc;
},
{}
);
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;
}
};
times.push({
timestamp: current.toISOString(),
values,
});
/**
* 转换后端数据格式
* 根据实际后端返回的数据结构进行调整
*/
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);
current = current.add(stepMinutes, "minute");
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;
}
}
return times;
// 如果后端返回的是数组格式,每个元素包含时间戳和各设备值
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 = async (
deviceIds: string[],
range: { from: Date; to: Date }
): Promise<TimeSeriesPoint[]> => {
await new Promise((resolve) => setTimeout(resolve, 500));
return generateMockTimeSeries(deviceIds, range);
};
const defaultFetcher = fetchFromBackend;
const formatTimestamp = (timestamp: string) =>
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
@@ -175,7 +239,6 @@ const emptyStateMessages: Record<
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
deviceIds,
fetchTimeSeriesData = defaultFetcher,
deviceLabels,
visible = true,
onClose,
defaultTab = "chart",
@@ -188,6 +251,37 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
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);
@@ -235,13 +329,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
[deviceIds, fetchTimeSeriesData, hasDevices, normalizedRange]
);
// 设备变化时自动查询
useEffect(() => {
if (hasDevices) {
handleFetch("device-change");
} else {
setTimeSeries([]);
}
}, [hasDevices, handleFetch]);
}, [deviceIds.join(","), hasDevices]);
const columns: GridColDef[] = useMemo(() => {
const base: GridColDef[] = [
@@ -291,15 +386,18 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
py: 6,
py: 8,
color: "text.secondary",
height: 376,
height: 420,
}}
>
<Typography variant="h6" gutterBottom>
<ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
{message.title}
</Typography>
<Typography variant="body2">{message.subtitle}</Typography>
<Typography variant="body2" color="text.secondary">
{message.subtitle}
</Typography>
</Box>
);
};
@@ -307,38 +405,116 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const renderChart = () => {
if (!hasData) return renderEmpty();
// 为每个设备生成独特的颜色和样式
const colors = [
"#1976d2", // 蓝色
"#dc004e", // 粉红色
"#ff9800", // 橙色
"#4caf50", // 绿色
"#9c27b0", // 紫色
"#00bcd4", // 青色
"#f44336", // 红色
"#8bc34a", // 浅绿色
"#ff5722", // 深橙色
"#3f51b5", // 靛蓝色
];
return (
<LineChart
dataset={dataset}
height={376}
margin={{ left: 50, right: 50, top: 20, bottom: 80 }}
xAxis={[
{
dataKey: "time",
scaleType: "time",
valueFormatter: (value) =>
value instanceof Date
? dayjs(value).format("MM-DD HH:mm")
: String(value),
},
]}
yAxis={[{ label: "值" }]}
series={deviceIds.map((id) => ({
dataKey: id,
label: deviceLabels?.[id] ?? id,
showMark: false,
curve: "linear",
}))}
slotProps={{
legend: {
direction: "row",
position: { horizontal: "middle", vertical: "bottom" },
},
loadingOverlay: {
style: { backgroundColor: "transparent" },
},
}}
/>
<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>
);
};
@@ -359,11 +535,28 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
columnBufferPx={100}
initialState={{
pagination: {
paginationModel: { pageSize: 10, page: 0 },
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,
},
}}
pageSizeOptions={[5, 10, 25, 50]}
sx={{ border: "none", height: "360px" }}
disableRowSelectionOnClick
/>
);
@@ -372,9 +565,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
return (
<Paper
className={clsx(
"absolute right-4 top-20 w-4xl h-2xl bg-white rounded-xl shadow-lg overflow-hidden flex flex-col transition-opacity duration-300",
"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
@@ -441,6 +638,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
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" } }}
/>
@@ -450,6 +652,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
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" } }}
/>

View File

@@ -170,15 +170,6 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
// 获取设备状态列表
const deviceStatuses = STATUS_OPTIONS;
// 创建设备索引 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(() => {
return effectiveDevices.filter((device) => {
@@ -276,6 +267,12 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
});
}, []);
// 清除选择
const handleClearSelection = useCallback(() => {
setInternalSelection([]);
setPendingSelection([]);
}, []);
// 清理定时器
useEffect(() => {
return () => {
@@ -412,6 +409,20 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
{devices.length !== filteredDevices.length &&
` (共 ${effectiveDevices.length} 个设备)`}
</Typography>
{/* 清除选择按钮 */}
{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>