1190 lines
36 KiB
TypeScript
1190 lines
36 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,
|
|
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<ElementHistoryResult> => {
|
|
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<string, ElementHistoryTarget>();
|
|
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<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,
|
|
start_time,
|
|
end_time,
|
|
onClose,
|
|
}) => {
|
|
const { open } = useNotification();
|
|
const { data: user } = useGetIdentity<IUser>();
|
|
|
|
const customFetcher = fetchFromBackend;
|
|
|
|
const [from, setFrom] = useState<Dayjs>(() => {
|
|
if (start_time) {
|
|
const parsedStart = dayjs(start_time);
|
|
if (parsedStart.isValid()) {
|
|
return parsedStart;
|
|
}
|
|
}
|
|
return dayjs().subtract(1, "day");
|
|
});
|
|
const [to, setTo] = useState<Dayjs>(() => {
|
|
if (end_time) {
|
|
const parsedEnd = dayjs(end_time);
|
|
if (parsedEnd.isValid()) {
|
|
return parsedEnd;
|
|
}
|
|
}
|
|
return dayjs();
|
|
});
|
|
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
|
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
|
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
|
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]);
|
|
|
|
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<GridColDef>((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<GridColDef>((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 (
|
|
<PanelEmptyState
|
|
icon={<ShowChart />}
|
|
title="尚未选择设备"
|
|
description="请先在设备列表中选择需要查看的 SCADA 设备。"
|
|
/>
|
|
);
|
|
}
|
|
|
|
const message = emptyStateMessages[activeTab];
|
|
return (
|
|
<PanelEmptyState
|
|
icon={activeTab === "chart" ? <ShowChart /> : <TableChart />}
|
|
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 (
|
|
<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: 1290 }}
|
|
>
|
|
<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}>
|
|
{onClose && (
|
|
<Tooltip title="关闭">
|
|
<IconButton
|
|
size="small"
|
|
onClick={onClose}
|
|
aria-label="关闭 SCADA 历史数据面板"
|
|
sx={panelHeaderActionSx}
|
|
>
|
|
<Close fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip title="收起">
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => setIsExpanded(false)}
|
|
aria-label="收起 SCADA 历史数据面板"
|
|
sx={panelHeaderActionSx}
|
|
>
|
|
<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>
|
|
|
|
{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;
|