"use client"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Box, Button, Chip, CircularProgress, Divider, IconButton, Stack, Tab, Tabs, Tooltip, Typography, Drawer, } from "@mui/material"; import { Refresh, ShowChart, TableChart, CleaningServices, Close, ChevronLeft, ChevronRight, } 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"; import { useGetIdentity } from "@refinedev/core"; import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { apiFetch } from "@/lib/apiFetch"; import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; import { ElementHistoryResult, ElementHistorySeries, ElementHistoryTarget, fetchElementHistory, historySeriesKey, TimeSeriesPoint, toTimeSeriesPoints, } from "@/lib/elementHistory"; import { FLOW_DISPLAY_UNIT, PRESSURE_DISPLAY_UNIT, } from "@/utils/units"; dayjs.extend(utc); dayjs.extend(timezone); type IUser = { id: string; name?: string; }; export interface SCADADataPanelProps { /** 选中的设备 ID 列表 */ deviceIds: string[]; /** 可选:控制浮窗显示 */ visible?: boolean; /** 默认展示的选项卡 */ defaultTab?: "chart" | "table"; /** Y 轴数值的小数位数 */ fractionDigits?: number; /** 是否显示清洗功能 */ showCleaning?: boolean; /** 清洗数据的回调 */ onCleanData?: () => void; /** 外部传入开始时间(ISO8601 字符串),用于初始化并触发查询 */ start_time?: string; /** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */ end_time?: string; /** 关闭面板 */ onClose?: () => void; } type PanelTab = "chart" | "table"; type LoadingState = "idle" | "loading" | "success" | "error"; const panelHeaderActionSx = { color: "primary.contrastText", backgroundColor: "rgba(255,255,255,0.08)", "&:hover": { backgroundColor: "rgba(255,255,255,0.18)", }, }; interface ScadaDeviceMetadata { device_id: string; device_type: string; node_id: string | null; link_id: string | null; } /** 用设备元数据组装统一元素历史查询,一次返回监测与模拟数据。 */ const fetchFromBackend = async ( deviceIds: string[], range: { from: Date; to: Date }, ): Promise => { if (deviceIds.length === 0) { return { points: [], series: [] }; } const metadataResponse = await apiFetch( `${config.BACKEND_URL}/api/v1/scada-devices`, ); if (!metadataResponse.ok) { throw new Error(`SCADA 设备信息请求失败: HTTP ${metadataResponse.status}`); } const allDevices = (await metadataResponse.json()) as ScadaDeviceMetadata[]; const requested = new Set(deviceIds); const devices = allDevices.filter((device) => requested.has(device.device_id)); const missing = deviceIds.filter( (deviceId) => !devices.some((device) => device.device_id === deviceId), ); if (missing.length > 0) { throw new Error(`SCADA 设备不存在: ${missing.join(", ")}`); } const targetsByElement = new Map(); devices.forEach((device) => { const isFlow = ["pipe_flow", "flow"].includes( device.device_type.toLowerCase(), ); const elementId = isFlow ? device.link_id : device.node_id; const elementType = isFlow ? "pipe" : "junction"; if (!elementId) { throw new Error(`SCADA 设备 ${device.device_id} 未关联管网元素`); } const key = `${elementType}:${elementId}`; const target = targetsByElement.get(key) ?? { element_id: elementId, element_type: elementType, device_ids: [], }; target.device_ids!.push(device.device_id); targetsByElement.set(key, target); }); const targets = Array.from(targetsByElement.values()); const result = await fetchElementHistory( targets, range, "realtime_comparison", ); const expandedSeries = result.series.flatMap((series) => { if (series.device_id || series.source.startsWith("scada_")) return [series]; const target = targets.find( (item) => item.element_id === series.element_id && item.element_type === series.element_type, ); return (target?.device_ids ?? []).map((deviceId) => ({ ...series, device_id: deviceId, })); }); return { series: expandedSeries, points: toTimeSeriesPoints(expandedSeries) }; }; 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) => { ["raw", "clean", "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 = ({ deviceIds, visible = true, defaultTab = "chart", fractionDigits = 2, showCleaning = false, onCleanData, start_time, end_time, onClose, }) => { const { open } = useNotification(); const { data: user } = useGetIdentity(); const customFetcher = fetchFromBackend; const [from, setFrom] = useState(() => { if (start_time) { const parsedStart = dayjs(start_time); if (parsedStart.isValid()) { return parsedStart; } } return dayjs().subtract(1, "day"); }); const [to, setTo] = useState(() => { if (end_time) { const parsedEnd = dayjs(end_time); if (parsedEnd.isValid()) { return parsedEnd; } } return dayjs(); }); const [activeTab, setActiveTab] = useState(defaultTab); const [timeSeries, setTimeSeries] = useState([]); const [historySeries, setHistorySeries] = useState([]); const [loadingState, setLoadingState] = useState("idle"); const [error, setError] = useState(null); const [isExpanded, setIsExpanded] = useState(true); const [isCleaning, setIsCleaning] = useState(false); const [selectedSource, setSelectedSource] = useState< "raw" | "clean" | "sim" | "all" >(() => (deviceIds.length === 1 ? "all" : "clean")); useEffect(() => { setActiveTab(defaultTab); }, [defaultTab]); useEffect(() => { if (!start_time && !end_time) return; if (start_time) { const parsedStart = dayjs(start_time); if (parsedStart.isValid()) { setFrom((prev) => (parsedStart.isSame(prev) ? prev : parsedStart)); } } if (end_time) { const parsedEnd = dayjs(end_time); if (parsedEnd.isValid()) { setTo((prev) => (parsedEnd.isSame(prev) ? prev : parsedEnd)); } } }, [start_time, end_time]); const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]); // Parent map updates can pass a new array with identical IDs. Keep the // request dependency tied to the IDs themselves, not the array identity. const deviceIdsKey = JSON.stringify(deviceIds); const requestedDeviceIds = useMemo( () => JSON.parse(deviceIdsKey) as string[], [deviceIdsKey], ); const hasDevices = requestedDeviceIds.length > 0; const hasData = timeSeries.length > 0; const dataset = useMemo( () => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning), [timeSeries, deviceIds, fractionDigits, showCleaning], ); const seriesByKey = useMemo( () => new Map(historySeries.map((series) => [historySeriesKey(series), series])), [historySeries], ); const hasFlowSeries = historySeries.some((item) => item.metric === "flow"); const hasPressureSeries = historySeries.some( (item) => item.metric === "pressure", ); const unitForKey = useCallback( (key: string) => seriesByKey.get(key)?.display_unit ?? "", [seriesByKey], ); const axisForKey = useCallback( (key: string) => seriesByKey.get(key)?.metric === "pressure" && hasFlowSeries ? 1 : 0, [hasFlowSeries, seriesByKey], ); const handleFetch = useCallback( async (reason: string) => { if (!hasDevices) { setTimeSeries([]); setHistorySeries([]); setLoadingState("idle"); setError(null); return; } setLoadingState("loading"); setError(null); try { const { from: rangeFrom, to: rangeTo } = normalizedRange; const result = await customFetcher(requestedDeviceIds, { from: rangeFrom.toDate(), to: rangeTo.toDate(), }); setTimeSeries(result.points); setHistorySeries(result.series); setLoadingState("success"); } catch (err) { setError(err instanceof Error ? err.message : "未知错误"); setLoadingState("error"); } }, [requestedDeviceIds, customFetcher, hasDevices, normalizedRange], ); // 处理数据清洗 const handleCleanData = useCallback(async () => { if (!hasDevices) { open?.({ type: "error", message: "请先选择设备", }); return; } if (!user || !user.id) { open?.({ type: "error", message: "用户信息无效,请重新登录", }); return; } setIsCleaning(true); try { const { from: rangeFrom, to: rangeTo } = normalizedRange; const startTime = dayjs(rangeFrom).toISOString(); const endTime = dayjs(rangeTo).toISOString(); // 调用后端清洗接口 const response = await api.post( `${ config.BACKEND_URL }/api/v1/timeseries/scada-cleaning-runs?device_ids=${deviceIds.join( ",", )}&start_time=${startTime}&end_time=${endTime}`, ); 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?.detail || err.response?.data?.message || err.message || "未知错误", }); } finally { setIsCleaning(false); } }, [ deviceIds, hasDevices, normalizedRange, user, open, onCleanData, handleFetch, ]); // 设备变化时自动查询 useEffect(() => { if (hasDevices) { handleFetch("device-change"); } else { setTimeSeries([]); setHistorySeries([]); } }, [handleFetch, hasDevices]); // 当设备数量变化时,调整数据源选择 useEffect(() => { if (deviceIds.length > 1 && selectedSource === "all") { setSelectedSource("clean"); } }, [deviceIds.length, selectedSource]); 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: `${id} (原始) [${unitForKey(`${id}_raw`)}]`, 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: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`, 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: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`, 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: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`, 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 { // 非清洗模式:显示所有设备的所有有数据的列 const cols: GridColDef[] = []; deviceIds.forEach((id) => { const deviceName = id; // 为每个设备的每种数据类型创建列 const suffixes = [ { key: "raw", name: "原始值" }, { key: "clean", name: "清洗值" }, { key: "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: `${deviceName} (${name}) [${unitForKey(fieldKey)}]`, 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, showCleaning, selectedSource, dataset, unitForKey, ]); const rows = useMemo( () => dataset.map((item, index) => ({ id: `${ item.time instanceof Date ? item.time.getTime() : index }-${index}`, ...item, })), [dataset], ); const renderEmpty = () => { if (!hasDevices) { return ( } title="尚未选择设备" description="请先在设备列表中选择需要查看的 SCADA 设备。" /> ); } const message = emptyStateMessages[activeTab]; return ( : } title={message.title} description={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 = () => { if (showCleaning) { if (selectedSource === "all") { return deviceIds.flatMap((id, index) => [ { name: `${id} (原始) [${unitForKey(`${id}_raw`)}]`, type: "line", yAxisIndex: axisForKey(`${id}_raw`), symbol: "none", connectNulls: true, sampling: "lttb", itemStyle: { color: colors[index % colors.length] }, data: dataset.map((item) => item[`${id}_raw`]), }, { name: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`, type: "line", yAxisIndex: axisForKey(`${id}_clean`), symbol: "none", connectNulls: true, sampling: "lttb", itemStyle: { color: colors[(index + 3) % colors.length] }, data: dataset.map((item) => item[`${id}_clean`]), }, { name: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`, type: "line", yAxisIndex: axisForKey(`${id}_sim`), symbol: "none", connectNulls: true, sampling: "lttb", itemStyle: { color: colors[(index + 6) % colors.length] }, data: dataset.map((item) => item[`${id}_sim`]), }, ]); } else { return deviceIds.map((id, index) => ({ name: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`, type: "line", yAxisIndex: axisForKey(`${id}_${selectedSource}`), symbol: "none", connectNulls: true, sampling: "lttb", itemStyle: { color: colors[index % colors.length] }, data: dataset.map((item) => item[`${id}_${selectedSource}`]), })); } } else { return deviceIds.flatMap((id, index) => { const series = []; ["raw", "clean", "sim"].forEach((suffix, sIndex) => { const key = `${id}_${suffix}`; const hasData = dataset.some( (item) => item[key] !== null && item[key] !== undefined, ); if (hasData) { series.push({ name: `${id} (${ suffix === "raw" ? "原始" : suffix === "clean" ? "清洗" : "模拟" }) [${unitForKey(key)}]`, type: "line", yAxisIndex: axisForKey(key), symbol: "none", connectNulls: true, sampling: "lttb", itemStyle: { color: colors[(index * 3 + sIndex) % colors.length], }, data: dataset.map((item) => item[key]), areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: colors[(index * 3 + sIndex) % colors.length], }, { offset: 1, color: "rgba(255, 255, 255, 0)", }, ]), opacity: 0.3, }, }); } }); // 如果没有clean/raw/sim数据,则使用fallback if (series.length === 0) { series.push({ name: id, type: "line", symbol: "none", connectNulls: true, sampling: "lttb", 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: [ ...(hasFlowSeries ? [ { type: "value", scale: true, name: `流量 (${FLOW_DISPLAY_UNIT})`, }, ] : []), ...(hasPressureSeries ? [ { type: "value", scale: true, name: `压力 (${PRESSURE_DISPLAY_UNIT})`, }, ] : []), ], 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 ( <> {/* 收起时的触发按钮 */} {!isExpanded && hasDevices && ( setIsExpanded(true)} sx={{ zIndex: 1290 }} > 历史数据 )} {/* 主面板 */} {/* Header */} SCADA 历史数据 {onClose && ( )} setIsExpanded(false)} aria-label="收起 SCADA 历史数据面板" sx={panelHeaderActionSx} > {/* 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="表格" /> {showCleaning && ( )} {showCleaning && hasDevices && ( 数据源: setSelectedSource(value) } > {deviceIds.length === 1 && ( )} )} {error && ( 获取数据失败:{error} )} {/* Content */} {loadingState === "loading" && ( )} {activeTab === "chart" ? renderChart() : renderTable()} ); }; export default SCADADataPanel;