"use client"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Box, Button, Chip, CircularProgress, Divider, IconButton, Stack, Tab, Tabs, Tooltip, Typography, Drawer, Slider, } from "@mui/material"; import { Refresh, ShowChart, TableChart, CleaningServices, ChevronLeft, ChevronRight, } from "@mui/icons-material"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { LineChart } from "@mui/x-charts"; import "dayjs/locale/zh-cn"; // 引入中文包 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 config, { NETWORK_NAME } from "@/config/config"; import { GeoJSON } from "ol/format"; import { useGetIdentity } from "@refinedev/core"; import { useNotification } from "@refinedev/core"; import axios from "axios"; dayjs.extend(utc); dayjs.extend(timezone); type IUser = { id: number; name: string; }; export interface TimeSeriesPoint { /** ISO8601 时间戳 */ timestamp: string; /** 每个设备对应的值 */ values: Record; } export interface SCADADataPanelProps { /** 选中的设备 ID 列表 */ deviceIds: string[]; /** 自定义数据获取器,默认使用后端 API */ fetchTimeSeriesData?: ( deviceIds: string[], range: { from: Date; to: Date } ) => Promise; /** 可选:控制浮窗显示 */ visible?: boolean; /** 默认展示的选项卡 */ defaultTab?: "chart" | "table"; /** Y 轴数值的小数位数 */ fractionDigits?: number; /** 是否显示清洗功能 */ showCleaning?: boolean; /** 清洗数据的回调 */ onCleanData?: () => void; } type PanelTab = "chart" | "table"; type LoadingState = "idle" | "loading" | "success" | "error"; /** * 从后端 API 获取 SCADA 数据 */ const fetchFromBackend = async ( deviceIds: string[], range: { from: Date; to: Date } ): Promise => { 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 cleaningSCADAUrl = `${config.BACKEND_URL}/querycleaningscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`; // 原始数据 const rawSCADAUrl = `${config.BACKEND_URL}/queryscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`; // 模拟数据接口 const simulationSCADAUrl = `${config.BACKEND_URL}/querysimulationscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`; try { let response; response = await fetch(cleaningSCADAUrl); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); let transformedData = transformBackendData(data, deviceIds); // 如果清洗数据接口返回空结果,使用原始数据接口 if (transformedData.length === 0) { console.log("[SCADADataPanel] 清洗数据接口无结果,使用原始数据接口"); response = await fetch(rawSCADAUrl); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); transformedData = transformBackendData(data, deviceIds); } return transformedData; } catch (error) { console.error("[SCADADataPanel] 从后端获取数据失败:", error); throw error; } }; /** * 转换后端数据格式 * 根据实际后端返回的数据结构进行调整 */ const transformBackendData = ( backendData: any, deviceIds: string[] ): TimeSeriesPoint[] => { // 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] } if (backendData && !Array.isArray(backendData)) { // 检查是否是设备ID为键的对象格式 const hasDeviceKeys = deviceIds.some((id) => id in backendData); if (hasDeviceKeys) { // 获取所有时间点的集合 const timeMap = new Map>(); 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; } } // 默认返回空数组 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, showCleaning: boolean ) => { return points.map((point) => { const entry: Record = { time: dayjs(point.timestamp).toDate(), label: formatTimestamp(point.timestamp), }; if (showCleaning) { deviceIds.forEach((id) => { ["raw", "clean", "sim"].forEach((suffix) => { const key = `${id}_${suffix}`; const value = point.values[key]; entry[key] = typeof value === "number" ? Number.isFinite(value) ? parseFloat(value.toFixed(fractionDigits)) : null : value ?? null; }); }); } else { 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 = ({ deviceIds, fetchTimeSeriesData = defaultFetcher, visible = true, defaultTab = "chart", fractionDigits = 2, showCleaning = false, onCleanData, }) => { const { open } = useNotification(); const { data: user } = useGetIdentity(); const customFetcher = useMemo(() => { if (!showCleaning) { return fetchTimeSeriesData; } return async ( deviceIds: string[], range: { from: Date; to: Date } ): Promise => { 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 cleaningUrl = `${config.BACKEND_URL}/querycleaningscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`; const rawUrl = `${config.BACKEND_URL}/queryscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`; const simUrl = `${config.BACKEND_URL}/querysimulationscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`; try { const [cleanRes, rawRes, simRes] = await Promise.all([ fetch(cleaningUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), fetch(rawUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), fetch(simUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), ]); const timeMap = new Map>(); const processData = (data: any, suffix: string) => { if (!data) return; deviceIds.forEach((deviceId) => { const deviceData = data[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}_${suffix}`] = typeof item.value === "number" ? item.value : null; } }); } }); }; processData(cleanRes, "clean"); processData(rawRes, "raw"); processData(simRes, "sim"); 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; } catch (error) { console.error("[SCADADataPanel] 获取三种数据失败:", error); throw error; } }; }, [showCleaning, fetchTimeSeriesData]); const [from, setFrom] = useState(() => dayjs().subtract(1, "day")); const [to, setTo] = useState(() => dayjs()); const [activeTab, setActiveTab] = useState(defaultTab); const [timeSeries, setTimeSeries] = useState([]); const [loadingState, setLoadingState] = useState("idle"); const [error, setError] = useState(null); const [isExpanded, setIsExpanded] = useState(true); const [deviceLabels, setDeviceLabels] = useState>({}); const [isCleaning, setIsCleaning] = useState(false); const [selectedSource, setSelectedSource] = useState< "raw" | "clean" | "sim" | "all" >("all"); // 滑块状态:用于图表缩放 const [zoomRange, setZoomRange] = useState<[number, number]>([0, 100]); // 获取 SCADA 设备信息,生成 deviceLabels useEffect(() => { const fetchDeviceLabels = async () => { 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) return; const json = await response.json(); const features = new GeoJSON().readFeatures(json); const labels = features.reduce>( (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, showCleaning), [timeSeries, deviceIds, fractionDigits, showCleaning] ); // 根据滑块范围过滤数据集 const filteredDataset = useMemo(() => { if (dataset.length === 0) return dataset; const startIndex = Math.floor((zoomRange[0] / 100) * dataset.length); const endIndex = Math.ceil((zoomRange[1] / 100) * dataset.length); return dataset.slice(startIndex, endIndex); }, [dataset, zoomRange]); // 重置滑块范围当数据变化时 useEffect(() => { setZoomRange([0, 100]); }, [timeSeries]); 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 customFetcher(deviceIds, { from: rangeFrom.toDate(), to: rangeTo.toDate(), }); setTimeSeries(result); setLoadingState("success"); } catch (err) { setError(err instanceof Error ? err.message : "未知错误"); setLoadingState("error"); } }, [deviceIds, customFetcher, hasDevices, normalizedRange] ); // 处理数据清洗 const handleCleanData = useCallback(async () => { if (!hasDevices) { open?.({ type: "error", message: "请先选择设备", }); return; } if (!user || !user.name) { open?.({ type: "error", message: "用户信息无效,请重新登录", }); return; } setIsCleaning(true); try { const { from: rangeFrom, to: rangeTo } = normalizedRange; const startTime = dayjs(rangeFrom).format("YYYY-MM-DD HH:mm:ss"); const endTime = dayjs(rangeTo).format("YYYY-MM-DD HH:mm:ss"); // 调用后端清洗接口 const response = await axios.post( `${config.BACKEND_URL}/scadadevicedatacleaning/`, null, { params: { network: NETWORK_NAME, ids_list: deviceIds.join(","), // 修改:将数组转为逗号分隔字符串 start_time: startTime, end_time: endTime, user_name: user.name, }, } ); console.log("[SCADADataPanel] 清洗响应:", response.data); // 处理成功响应 if (response.data === "success" || response.data?.success === true) { open?.({ type: "success", message: "数据清洗成功", description: `已完成 ${deviceIds.length} 个设备的数据清洗`, }); // 清洗完成后自动刷新数据 await handleFetch("after-cleaning"); // 如果父组件提供了回调,也调用它 onCleanData?.(); } else { throw new Error(response.data?.message || "清洗失败"); } } catch (err: any) { console.error("[SCADADataPanel] 数据清洗失败:", err); open?.({ type: "error", message: "数据清洗失败", description: err.response?.data?.message || err.message || "未知错误", }); } finally { setIsCleaning(false); } }, [ deviceIds, hasDevices, normalizedRange, user, open, onCleanData, handleFetch, ]); // 设备变化时自动查询 useEffect(() => { if (hasDevices) { handleFetch("device-change"); } else { setTimeSeries([]); } }, [deviceIds.join(",")]); // 当设备数量变化时,调整数据源选择 useEffect(() => { if (deviceIds.length > 1 && selectedSource === "all") { setSelectedSource("clean"); } // else if (deviceIds.length === 1 && selectedSource !== "all") { // setSelectedSource("all"); // } }, [deviceIds.length]); const columns: GridColDef[] = useMemo(() => { const base: GridColDef[] = [ { field: "label", headerName: "时间", minWidth: 180, flex: 1, }, ]; const dynamic = (() => { if (showCleaning) { if (selectedSource === "all") { // 全部模式:显示所有设备的三种数据 return deviceIds.flatMap((id) => [ { field: `${id}_raw`, 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); }, }, { field: `${id}_clean`, 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); }, }, { field: `${id}_sim`, 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); }, }, ]); } else { // 单一数据源模式:只显示选中的数据源 return deviceIds.map((id) => ({ field: `${id}_${selectedSource}`, 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); }, })); } } else { return deviceIds.map((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, showCleaning, selectedSource]); 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 ( {message.title} {message.subtitle} ); }; const renderChart = () => { if (!hasData) return renderEmpty(); // 为每个设备生成独特的颜色和样式 const colors = [ "#1976d2", // 蓝色 "#dc004e", // 粉红色 "#ff9800", // 橙色 "#4caf50", // 绿色 "#9c27b0", // 紫色 "#00bcd4", // 青色 "#f44336", // 红色 "#8bc34a", // 浅绿色 "#ff5722", // 深橙色 "#3f51b5", // 靛蓝色 ]; // 获取当前显示范围的时间边界 const getTimeRangeLabel = () => { if (filteredDataset.length === 0) return ""; const firstTime = filteredDataset[0].time; const lastTime = filteredDataset[filteredDataset.length - 1].time; if (firstTime instanceof Date && lastTime instanceof Date) { return `${dayjs(firstTime).format("MM-DD HH:mm")} ~ ${dayjs( lastTime ).format("MM-DD HH:mm")}`; } return ""; }; return ( 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={(() => { if (showCleaning) { if (selectedSource === "all") { // 全部模式:显示所有设备的三种数据 return deviceIds.flatMap((id, index) => [ { dataKey: `${id}_raw`, 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, }, { dataKey: `${id}_clean`, label: `${deviceLabels?.[id] ?? id} (清洗)`, showMark: dataset.length < 50, curve: "catmullRom", color: colors[(index + 3) % colors.length], valueFormatter: (value: number | null) => value !== null ? value.toFixed(fractionDigits) : "--", area: false, stack: undefined, }, { dataKey: `${id}_sim`, label: `${deviceLabels?.[id] ?? id} (模拟)`, showMark: dataset.length < 50, curve: "catmullRom", color: colors[(index + 6) % colors.length], valueFormatter: (value: number | null) => value !== null ? value.toFixed(fractionDigits) : "--", area: false, stack: undefined, }, ]); } else { // 单一数据源模式:只显示选中的数据源 return deviceIds.map((id, index) => ({ dataKey: `${id}_${selectedSource}`, 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, })); } } else { return 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: "#d0d0d0", strokeWidth: 0.8, strokeDasharray: "4 4", }, }} slotProps={{ legend: { direction: "row", position: { horizontal: "middle", vertical: "bottom" }, padding: { bottom: 2, 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", }} /> {/* 时间范围滑块 */} 时间范围 setZoomRange(newValue as [number, number]) } valueLabelDisplay="auto" valueLabelFormat={(value) => { const index = Math.floor((value / 100) * dataset.length); if (dataset[index] && dataset[index].time instanceof Date) { return dayjs(dataset[index].time).format("MM-DD HH:mm"); } return `${value}%`; }} marks={[ { value: 0, label: dataset.length > 0 && dataset[0].time instanceof Date ? dayjs(dataset[0].time).format("MM-DD HH:mm") : "起始", }, { value: 100, label: dataset.length > 0 && dataset[dataset.length - 1].time instanceof Date ? dayjs(dataset[dataset.length - 1].time).format( "MM-DD HH:mm" ) : "结束", }, ]} sx={{ flex: 1, "& .MuiSlider-thumb": { width: 16, height: 16, }, "& .MuiSlider-markLabel": { fontSize: "0.7rem", color: "text.secondary", }, }} /> {getTimeRangeLabel() && ( 当前显示: {getTimeRangeLabel()} (共 {filteredDataset.length}{" "} 个数据点) )} ); }; 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 ( ); }; return ( <> {/* 收起时的触发按钮 */} {!isExpanded && hasDevices && ( setIsExpanded(true)} sx={{ zIndex: 1300 }} > 历史数据 )} {/* 主面板 */} {/* Header */} SCADA 历史数据 setIsExpanded(false)} sx={{ color: "primary.contrastText" }} > {/* Controls */} 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" }, }} /> 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" }, }} /> setActiveTab(value)} variant="fullWidth" > } iconPosition="start" label="曲线" /> } iconPosition="start" label="表格" /> {showCleaning && ( )} {showCleaning && hasDevices && ( 数据源: setSelectedSource(value) } > {deviceIds.length === 1 && ( )} )} {!hasDevices && ( 未选择任何设备,无法获取数据。 )} {error && ( 获取数据失败:{error} )} {/* Content */} {loadingState === "loading" && ( )} {activeTab === "chart" ? renderChart() : renderTable()} ); }; export default SCADADataPanel;