and centralized backend requests via api/apiFetch (including data provider updates) to inject X-Project-ID.
1238 lines
37 KiB
TypeScript
1238 lines
37 KiB
TypeScript
"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,
|
||
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";
|
||
|
||
dayjs.extend(utc);
|
||
dayjs.extend(timezone);
|
||
|
||
type IUser = {
|
||
id: number;
|
||
name: string;
|
||
};
|
||
|
||
export interface TimeSeriesPoint {
|
||
/** ISO8601 时间戳 */
|
||
timestamp: string;
|
||
/** 每个设备对应的值 */
|
||
values: Record<string, number | null | undefined>;
|
||
}
|
||
|
||
export interface SCADADataPanelProps {
|
||
/** 选中的设备 ID 列表 */
|
||
deviceIds: string[];
|
||
/** 可选:控制浮窗显示 */
|
||
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<TimeSeriesPoint[]> => {
|
||
if (deviceIds.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const device_ids = deviceIds.join(",");
|
||
const start_time = dayjs(range.from).toISOString();
|
||
const end_time = dayjs(range.to).toISOString();
|
||
// 清洗数据接口
|
||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||
// 原始数据
|
||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||
// 模拟数据接口
|
||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||
try {
|
||
// 优先查询清洗数据和模拟数据
|
||
const [cleaningRes, simulationRes] = await Promise.all([
|
||
apiFetch(cleaningDataUrl)
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.catch(() => null),
|
||
apiFetch(simulationDataUrl)
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.catch(() => null),
|
||
]);
|
||
|
||
const cleaningData = transformBackendData(cleaningRes, deviceIds);
|
||
const simulationData = transformBackendData(simulationRes, deviceIds);
|
||
|
||
// 如果清洗数据有数据,返回清洗和模拟数据
|
||
if (cleaningData.length > 0) {
|
||
return mergeTimeSeriesData(
|
||
cleaningData,
|
||
simulationData,
|
||
deviceIds,
|
||
"clean",
|
||
"sim",
|
||
);
|
||
} else {
|
||
// 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据
|
||
const rawRes = await apiFetch(rawDataUrl)
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.catch(() => null);
|
||
const rawData = transformBackendData(rawRes, deviceIds);
|
||
return mergeTimeSeriesData(
|
||
simulationData,
|
||
rawData,
|
||
deviceIds,
|
||
"sim",
|
||
"raw",
|
||
);
|
||
}
|
||
} 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<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;
|
||
}
|
||
}
|
||
// 默认返回空数组
|
||
console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData);
|
||
return [];
|
||
};
|
||
|
||
/**
|
||
* 合并两个时间序列数据,为每个设备添加后缀
|
||
*/
|
||
const mergeTimeSeriesData = (
|
||
data1: TimeSeriesPoint[],
|
||
data2: TimeSeriesPoint[],
|
||
deviceIds: string[],
|
||
suffix1: string,
|
||
suffix2: string,
|
||
): TimeSeriesPoint[] => {
|
||
const timeMap = new Map<string, Record<string, number | null>>();
|
||
|
||
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,
|
||
showCleaning: boolean,
|
||
) => {
|
||
return points.map((point) => {
|
||
const entry: Record<string, any> = {
|
||
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<SCADADataPanelProps> = ({
|
||
deviceIds,
|
||
visible = true,
|
||
defaultTab = "chart",
|
||
fractionDigits = 2,
|
||
showCleaning = false,
|
||
onCleanData,
|
||
}) => {
|
||
const { open } = useNotification();
|
||
const { data: user } = useGetIdentity<IUser>();
|
||
|
||
const customFetcher = useMemo(() => {
|
||
if (!showCleaning) {
|
||
return fetchFromBackend;
|
||
}
|
||
|
||
return async (
|
||
deviceIds: string[],
|
||
range: { from: Date; to: Date },
|
||
): Promise<TimeSeriesPoint[]> => {
|
||
const device_ids = deviceIds.join(",");
|
||
const start_time = dayjs(range.from).toISOString();
|
||
const end_time = dayjs(range.to).toISOString();
|
||
|
||
// 清洗数据接口
|
||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||
// 原始数据
|
||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||
// 模拟数据接口
|
||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||
try {
|
||
const [cleanRes, rawRes, simRes] = await Promise.all([
|
||
apiFetch(cleaningDataUrl)
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.catch(() => null),
|
||
apiFetch(rawDataUrl)
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.catch(() => null),
|
||
apiFetch(simulationDataUrl)
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.catch(() => null),
|
||
]);
|
||
|
||
const timeMap = new Map<string, Record<string, number | null>>();
|
||
|
||
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]);
|
||
|
||
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 [isCleaning, setIsCleaning] = useState<boolean>(false);
|
||
const [selectedSource, setSelectedSource] = useState<
|
||
"raw" | "clean" | "sim" | "all"
|
||
>(() => (deviceIds.length === 1 ? "all" : "clean"));
|
||
|
||
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 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).toISOString();
|
||
const endTime = dayjs(rangeTo).toISOString();
|
||
|
||
// 调用后端清洗接口
|
||
const response = await api.post(
|
||
`${
|
||
config.BACKEND_URL
|
||
}/api/v1/composite/clean-scada?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?.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");
|
||
}
|
||
}, [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<GridColDef>((id) => [
|
||
{
|
||
field: `${id}_raw`,
|
||
headerName: `${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: `${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: `${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<GridColDef>((id) => ({
|
||
field: `${id}_${selectedSource}`,
|
||
headerName: 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 {
|
||
// 非清洗模式:显示所有设备的所有有数据的列
|
||
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})`,
|
||
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]);
|
||
|
||
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: "100%",
|
||
}}
|
||
>
|
||
<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", // 靛蓝色
|
||
];
|
||
|
||
const xData = dataset.map((item) => item.label);
|
||
|
||
const getSeries = () => {
|
||
if (showCleaning) {
|
||
if (selectedSource === "all") {
|
||
return deviceIds.flatMap((id, index) => [
|
||
{
|
||
name: `${id} (原始)`,
|
||
type: "line",
|
||
symbol: "none",
|
||
connectNulls: true,
|
||
sampling: "lttb",
|
||
itemStyle: { color: colors[index % colors.length] },
|
||
data: dataset.map((item) => item[`${id}_raw`]),
|
||
},
|
||
{
|
||
name: `${id} (清洗)`,
|
||
type: "line",
|
||
symbol: "none",
|
||
connectNulls: true,
|
||
sampling: "lttb",
|
||
itemStyle: { color: colors[(index + 3) % colors.length] },
|
||
data: dataset.map((item) => item[`${id}_clean`]),
|
||
},
|
||
{
|
||
name: `${id} (模拟)`,
|
||
type: "line",
|
||
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,
|
||
type: "line",
|
||
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"
|
||
? "清洗"
|
||
: "模拟"
|
||
})`,
|
||
type: "line",
|
||
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: {
|
||
type: "value",
|
||
scale: true,
|
||
},
|
||
dataZoom: [
|
||
{
|
||
type: "inside",
|
||
start: 0,
|
||
end: 100,
|
||
},
|
||
{
|
||
start: 0,
|
||
end: 100,
|
||
},
|
||
],
|
||
series: getSeries(),
|
||
};
|
||
|
||
return (
|
||
<Box
|
||
sx={{
|
||
width: "100%",
|
||
height: "100%",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
<ReactECharts
|
||
option={option}
|
||
style={{ height: "100%", width: "100%" }}
|
||
notMerge={true}
|
||
lazyUpdate={true}
|
||
/>
|
||
</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}
|
||
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
|
||
initialState={{
|
||
pagination: {
|
||
paginationModel: { pageSize: 50, page: 0 },
|
||
},
|
||
}}
|
||
pageSizeOptions={[25, 50, 100]}
|
||
sx={{
|
||
border: "none",
|
||
height: "100%",
|
||
"& .MuiDataGrid-cell": {
|
||
borderColor: "#f0f0f0",
|
||
},
|
||
"& .MuiDataGrid-columnHeaders": {
|
||
backgroundColor: "#fafafa",
|
||
fontWeight: 600,
|
||
fontSize: "0.875rem",
|
||
},
|
||
"& .MuiDataGrid-row:hover": {
|
||
backgroundColor: "#f5f5f5",
|
||
},
|
||
"& .MuiDataGrid-columnHeaderTitle": {
|
||
fontWeight: 600,
|
||
},
|
||
}}
|
||
disableRowSelectionOnClick
|
||
/>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{/* 收起时的触发按钮 */}
|
||
{!isExpanded && hasDevices && (
|
||
<Box
|
||
className="absolute top-20 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100"
|
||
onClick={() => setIsExpanded(true)}
|
||
sx={{ zIndex: 1300 }}
|
||
>
|
||
<Box className="flex flex-col items-center py-3 px-3 gap-1">
|
||
<ShowChart className="text-[#257DD4] w-5 h-5" />
|
||
<Typography
|
||
variant="caption"
|
||
className="text-gray-700 font-semibold my-1 text-xs"
|
||
style={{ writingMode: "vertical-rl" }}
|
||
>
|
||
历史数据
|
||
</Typography>
|
||
<ChevronLeft className="text-gray-600 w-4 h-4" />
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
{/* 主面板 */}
|
||
<Drawer
|
||
anchor="right"
|
||
open={isExpanded && visible}
|
||
variant="persistent"
|
||
hideBackdrop
|
||
sx={{
|
||
width: 0,
|
||
flexShrink: 0,
|
||
"& .MuiDrawer-paper": {
|
||
width: "min(920px, calc(100vw - 2rem))",
|
||
boxSizing: "border-box",
|
||
position: "absolute",
|
||
top: 80,
|
||
right: 16,
|
||
height: "860px",
|
||
borderRadius: "12px",
|
||
boxShadow:
|
||
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
|
||
backdropFilter: "blur(8px)",
|
||
opacity: 0.95,
|
||
transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out",
|
||
border: "none",
|
||
"&:hover": {
|
||
opacity: 1,
|
||
},
|
||
},
|
||
}}
|
||
>
|
||
<Box className="flex flex-col h-full bg-white rounded-xl overflow-hidden">
|
||
{/* 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(false)}
|
||
sx={{ color: "primary.contrastText" }}
|
||
>
|
||
<ChevronRight fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Stack>
|
||
</Stack>
|
||
</Box>
|
||
|
||
{/* Controls */}
|
||
<Box sx={{ p: 2, backgroundColor: "grey.50" }}>
|
||
<LocalizationProvider
|
||
dateAdapter={AdapterDayjs}
|
||
adapterLocale="zh-cn"
|
||
localeText={
|
||
pickerZhCN.components.MuiLocalizationProvider.defaultProps
|
||
.localeText
|
||
}
|
||
>
|
||
<Stack spacing={1.5}>
|
||
<Stack direction="row" spacing={1} alignItems="center">
|
||
<DateTimePicker
|
||
label="开始时间"
|
||
value={from}
|
||
onChange={(value) => {
|
||
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" },
|
||
}}
|
||
/>
|
||
<DateTimePicker
|
||
label="结束时间"
|
||
value={to}
|
||
onChange={(value) => {
|
||
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" },
|
||
}}
|
||
/>
|
||
</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>
|
||
<Stack direction="row" spacing={1}>
|
||
{showCleaning && (
|
||
<Tooltip title="清洗数据">
|
||
<span>
|
||
<Button
|
||
variant="outlined"
|
||
size="small"
|
||
color="secondary"
|
||
startIcon={
|
||
isCleaning ? (
|
||
<CircularProgress size={16} />
|
||
) : (
|
||
<CleaningServices fontSize="small" />
|
||
)
|
||
}
|
||
disabled={
|
||
!hasDevices ||
|
||
loadingState === "loading" ||
|
||
isCleaning
|
||
}
|
||
onClick={handleCleanData}
|
||
>
|
||
{isCleaning ? "清洗中..." : "清洗"}
|
||
</Button>
|
||
</span>
|
||
</Tooltip>
|
||
)}
|
||
<Tooltip title="刷新数据">
|
||
<span>
|
||
<Button
|
||
variant="outlined"
|
||
size="small"
|
||
color="primary"
|
||
startIcon={<Refresh fontSize="small" />}
|
||
disabled={!hasDevices || loadingState === "loading"}
|
||
onClick={() => handleFetch("manual")}
|
||
>
|
||
刷新
|
||
</Button>
|
||
</span>
|
||
</Tooltip>
|
||
</Stack>
|
||
</Stack>
|
||
{showCleaning && hasDevices && (
|
||
<Stack direction="row" spacing={1} alignItems="center">
|
||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||
数据源:
|
||
</Typography>
|
||
<Tabs
|
||
value={selectedSource}
|
||
onChange={(_, value: "raw" | "clean" | "sim" | "all") =>
|
||
setSelectedSource(value)
|
||
}
|
||
>
|
||
{deviceIds.length === 1 && (
|
||
<Tab value="all" label="全部" />
|
||
)}
|
||
<Tab value="clean" label="清洗" />
|
||
<Tab value="raw" label="原始" />
|
||
<Tab value="sim" label="模拟" />
|
||
</Tabs>
|
||
</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>
|
||
</Box>
|
||
</Drawer>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default SCADADataPanel;
|