"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import Draggable from "react-draggable"; import { Box, Button, Chip, CircularProgress, Divider, Stack, Tab, Tabs, Tooltip, Typography, } from "@mui/material"; import { Refresh, ShowChart, TableChart } from "@mui/icons-material"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { zhCN } from "@mui/x-data-grid/locales"; import ReactECharts from "echarts-for-react"; import * as echarts from "echarts"; 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 { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; import config from "@/config/config"; dayjs.extend(utc); dayjs.extend(timezone); export interface TimeSeriesPoint { /** ISO8601 时间戳 */ timestamp: string; /** 每个设备对应的值 */ values: Record; } export interface SCADADataPanelProps { /** 选中的要素信息列表,格式为 [[id, type], [id, type]] */ featureInfos: [string, string][]; /** 数据类型: realtime-查询模拟值和监测值, none-仅查询监测值, scheme-查询策略模拟值和监测值 */ type?: "realtime" | "scheme" | "none"; /** 策略类型 */ scheme_type?: string; /** 策略名称 */ scheme_name?: string; /** 默认展示的选项卡 */ defaultTab?: "chart" | "table"; /** Y 轴数值的小数位数 */ fractionDigits?: number; } type PanelTab = "chart" | "table"; type LoadingState = "idle" | "loading" | "success" | "error"; /** * 从后端 API 获取 SCADA 数据 */ const fetchFromBackend = async ( featureInfos: [string, string][], range: { from: Date; to: Date }, type: "realtime" | "scheme" | "none", scheme_type?: string, scheme_name?: string ): Promise => { if (featureInfos.length === 0) { return []; } // 提取设备 ID 列表 const featureIds = featureInfos.map(([id]) => id); const feature_ids = featureIds.join(","); const start_time = dayjs(range.from).toISOString(); const end_time = dayjs(range.to).toISOString(); // 将 featureInfos 转换为后端期望的格式: id1:type1,id2:type2 const feature_infos = featureInfos .map(([id, type]) => `${id}:${type}`) .join(","); // 监测值数据接口(use_cleaned=false) const rawDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-scada?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=false`; // 清洗数据接口(use_cleaned=true) const cleanedDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-scada?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=true`; // 模拟数据接口 const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-simulation?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}`; // 策略模拟数据接口 const schemeSimulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-simulation?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}&scheme_type=${scheme_type}&scheme_name=${scheme_name}`; try { if (type === "none") { // 查询清洗值和监测值 const [cleanedRes, rawRes] = await Promise.all([ fetch(cleanedDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), fetch(rawDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), ]); const cleanedData = transformBackendData(cleanedRes, featureIds); // 如果清洗数据有值,则不显示原始监测值 const rawData = cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds); return mergeTimeSeriesData( cleanedData, rawData, featureIds, "clean", "raw" ); } else if (type === "scheme") { // 查询策略模拟值、清洗值和监测值 const [cleanedRes, rawRes, schemeSimRes] = await Promise.all([ fetch(cleanedDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), fetch(rawDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), fetch(schemeSimulationDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), ]); const cleanedData = transformBackendData(cleanedRes, featureIds); // 如果清洗数据有值,则不显示原始监测值 const rawData = cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds); const schemeSimData = transformBackendData(schemeSimRes, featureIds); // 合并三组数据 const timeMap = new Map>(); [cleanedData, rawData, schemeSimData].forEach((data, index) => { const suffix = ["clean", "raw", "scheme_sim"][index]; data.forEach((point) => { if (!timeMap.has(point.timestamp)) { timeMap.set(point.timestamp, {}); } const values = timeMap.get(point.timestamp)!; featureIds.forEach((deviceId) => { const value = point.values[deviceId]; if (value !== undefined) { values[`${deviceId}_${suffix}`] = value; } }); }); }); 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; } else { // realtime: 查询模拟值、清洗值和监测值 const [cleanedRes, rawRes, simulationRes] = await Promise.all([ fetch(cleanedDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), fetch(rawDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), fetch(simulationDataUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), ]); const cleanedData = transformBackendData(cleanedRes, featureIds); // 如果清洗数据有值,则不显示原始监测值 const rawData = cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds); const simulationData = transformBackendData(simulationRes, featureIds); // 合并三组数据 const timeMap = new Map>(); [cleanedData, rawData, simulationData].forEach((data, index) => { const suffix = ["clean", "raw", "sim"][index]; data.forEach((point) => { if (!timeMap.has(point.timestamp)) { timeMap.set(point.timestamp, {}); } const values = timeMap.get(point.timestamp)!; featureIds.forEach((deviceId) => { const value = point.values[deviceId]; if (value !== undefined) { values[`${deviceId}_${suffix}`] = value; } }); }); }); 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; } }; /** * 转换后端数据格式 * 根据实际后端返回的数据结构进行调整 */ 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 mergeTimeSeriesData = ( data1: TimeSeriesPoint[], data2: TimeSeriesPoint[], deviceIds: string[], suffix1: string, suffix2: string ): TimeSeriesPoint[] => { const timeMap = new Map>(); const processData = (data: TimeSeriesPoint[], suffix: string) => { data.forEach((point) => { if (!timeMap.has(point.timestamp)) { timeMap.set(point.timestamp, {}); } const values = timeMap.get(point.timestamp)!; deviceIds.forEach((deviceId) => { const value = point.values[deviceId]; if (value !== undefined) { values[`${deviceId}_${suffix}`] = value; } }); }); }; processData(data1, suffix1); processData(data2, suffix2); 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; }; 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 = { time: dayjs(point.timestamp).toDate(), label: formatTimestamp(point.timestamp), }; deviceIds.forEach((id) => { ["clean", "raw", "sim", "scheme_sim"].forEach((suffix) => { const key = `${id}_${suffix}`; const value = point.values[key]; if (value !== undefined && value !== null) { entry[key] = 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 = ({ featureInfos, type = "none", scheme_type = "burst_Analysis", scheme_name, defaultTab = "chart", fractionDigits = 2, }) => { // 从 featureInfos 中提取设备 ID 列表 const deviceIds = useMemo( () => featureInfos.map(([id]) => id), [featureInfos] ); 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 [selectedSource, setSelectedSource] = useState< "raw" | "clean" | "sim" | "all" >(() => (featureInfos.length === 1 ? "all" : "clean")); const draggableRef = useRef(null); 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 fetchFromBackend( featureInfos, { from: rangeFrom.toDate(), to: rangeTo.toDate(), }, type, scheme_type, scheme_name ); setTimeSeries(result); setLoadingState("success"); } catch (err) { setError(err instanceof Error ? err.message : "未知错误"); setLoadingState("error"); } }, [featureInfos, hasDevices, normalizedRange, type, scheme_type, scheme_name] ); // 设备变化时自动查询 useEffect(() => { if (hasDevices) { handleFetch("device-change"); } else { setTimeSeries([]); } }, [JSON.stringify(featureInfos)]); // 当设备数量变化时,调整数据源选择 useEffect(() => { if (featureInfos.length > 1 && selectedSource === "all") { setSelectedSource("clean"); } }, [featureInfos.length, selectedSource]); const columns: GridColDef[] = useMemo(() => { const base: GridColDef[] = [ { field: "label", headerName: "时间", minWidth: 180, flex: 1, }, ]; const dynamic = (() => { const cols: GridColDef[] = []; deviceIds.forEach((id) => { // 为每个设备的每种数据类型创建列 const suffixes = [ { key: "clean", name: "清洗值" }, { key: "raw", name: "监测值" }, { key: "sim", name: "模拟值" }, { key: "scheme_sim", name: "方案模拟值" }, ]; suffixes.forEach(({ key, name }) => { const fieldKey = `${id}_${key}`; // 检查是否有该字段的数据 const hasData = dataset.some( (item) => item[fieldKey] !== null && item[fieldKey] !== undefined ); if (hasData) { cols.push({ field: fieldKey, headerName: `${id} (${name})`, 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 cols; })(); return [...base, ...dynamic]; }, [deviceIds, fractionDigits, dataset]); 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 xData = dataset.map((item) => item.label); const getSeries = () => { return deviceIds.flatMap((id, index) => { const series = []; ["clean", "raw", "sim", "scheme_sim"].forEach((suffix, sIndex) => { const key = `${id}_${suffix}`; const hasData = dataset.some( (item) => item[key] !== null && item[key] !== undefined ); if (hasData) { const displayName = suffix === "clean" ? "清洗值" : suffix === "raw" ? "监测值" : suffix === "sim" ? "模拟" : "方案模拟"; series.push({ name: `${id} (${displayName})`, type: "line", symbol: "none", sampling: "lttb", connectNulls: true, itemStyle: { color: colors[(index * 4 + sIndex) % colors.length], }, data: dataset.map((item) => item[key]), areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: colors[(index * 4 + sIndex) % colors.length], }, { offset: 1, color: "rgba(255, 255, 255, 0)", }, ]), opacity: 0.3, }, }); } }); // 如果没有任何数据,则使用fallback if (series.length === 0) { series.push({ name: id, type: "line", symbol: "none", sampling: "lttb", connectNulls: true, itemStyle: { color: colors[index % colors.length] }, data: dataset.map((item) => item[id]), areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: colors[index % colors.length], }, { offset: 1, color: "rgba(255, 255, 255, 0)", }, ]), opacity: 0.3, }, }); } return series; }); }; const option = { // animation: false, animationDuration: 500, tooltip: { trigger: "axis", confine: true, position: function (pt: any[]) { return [pt[0], "10%"]; }, }, legend: { top: "top", }, grid: { left: "5%", right: "5%", bottom: "11%", containLabel: true, }, toolbox: { feature: { dataZoom: { yAxisIndex: "none", }, restore: {}, saveAsImage: {}, }, }, xAxis: { type: "category", boundaryGap: false, data: xData, }, yAxis: { type: "value", scale: true, }, dataZoom: [ { type: "inside", start: 0, end: 100, }, { start: 0, end: 100, }, ], series: getSeries(), }; return ( ); }; 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 ( <> {/* 主面板 */} {/* Header */} 历史数据 {/* Controls */} { if (value && dayjs.isDayjs(value) && value.isValid()) { setFrom(value); } }} onAccept={(value) => { if ( value && dayjs.isDayjs(value) && value.isValid() && hasDevices ) { handleFetch("date-change"); } }} maxDateTime={to} slotProps={{ textField: { fullWidth: true, size: "small" }, }} /> { if (value && dayjs.isDayjs(value) && value.isValid()) { setTo(value); } }} onAccept={(value) => { if ( value && dayjs.isDayjs(value) && value.isValid() && hasDevices ) { handleFetch("date-change"); } }} minDateTime={from} slotProps={{ textField: { fullWidth: true, size: "small" }, }} /> setActiveTab(value)} variant="fullWidth" > } iconPosition="start" label="曲线" /> } iconPosition="start" label="表格" /> {!hasDevices && ( 请选择一个要素以查看其历史数据。 )} {error && ( 获取数据失败:{error} )} {/* Content */} {loadingState === "loading" && ( )} {activeTab === "chart" ? renderChart() : renderTable()} ); }; export default SCADADataPanel;