Files
TJWaterServer/src/components/olmap/SCADADataPanel.tsx
2025-11-19 17:46:16 +08:00

1132 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 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 config, { NETWORK_NAME } from "@/config/config";
import { GeoJSON } from "ol/format";
import { useGetIdentity } from "@refinedev/core";
import { useNotification } from "@refinedev/core";
import axios from "axios";
dayjs.extend(utc);
dayjs.extend(timezone);
type IUser = {
id: number;
name: string;
};
export interface TimeSeriesPoint {
/** ISO8601 时间戳 */
timestamp: string;
/** 每个设备对应的值 */
values: Record<string, number | null | undefined>;
}
export interface SCADADataPanelProps {
/** 选中的设备 ID 列表 */
deviceIds: string[];
/** 自定义数据获取器,默认使用后端 API */
fetchTimeSeriesData?: (
deviceIds: string[],
range: { from: Date; to: Date }
) => Promise<TimeSeriesPoint[]>;
/** 可选:控制浮窗显示 */
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 ids = deviceIds.join(",");
const starttime = dayjs(range.from).format("YYYY-MM-DD HH:mm:ss");
const endtime = dayjs(range.to).format("YYYY-MM-DD HH:mm:ss");
// 清洗数据接口
const cleaningSCADAUrl = `${config.BACKEND_URL}/querycleaningscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
// 原始数据
const rawSCADAUrl = `${config.BACKEND_URL}/queryscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
// 模拟数据接口
const simulationSCADAUrl = `${config.BACKEND_URL}/querysimulationscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
try {
let response;
response = await fetch(cleaningSCADAUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
let transformedData = transformBackendData(data, deviceIds);
// 如果清洗数据接口返回空结果,使用原始数据接口
if (transformedData.length === 0) {
console.log("[SCADADataPanel] 清洗数据接口无结果,使用原始数据接口");
response = await fetch(rawSCADAUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
transformedData = transformBackendData(data, deviceIds);
}
return transformedData;
} catch (error) {
console.error("[SCADADataPanel] 从后端获取数据失败:", error);
throw error;
}
};
/**
* 转换后端数据格式
* 根据实际后端返回的数据结构进行调整
*/
const transformBackendData = (
backendData: any,
deviceIds: string[]
): TimeSeriesPoint[] => {
// 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] }
if (backendData && !Array.isArray(backendData)) {
// 检查是否是设备ID为键的对象格式
const hasDeviceKeys = deviceIds.some((id) => id in backendData);
if (hasDeviceKeys) {
// 获取所有时间点的集合
const timeMap = new Map<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 defaultFetcher = fetchFromBackend;
const formatTimestamp = (timestamp: string) =>
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
const ensureValidRange = (
from: Dayjs,
to: Dayjs
): { from: Dayjs; to: Dayjs } => {
if (from.isAfter(to)) {
return { from: to, to: from };
}
return { from, to };
};
const buildDataset = (
points: TimeSeriesPoint[],
deviceIds: string[],
fractionDigits: number,
showCleaning: boolean
) => {
return points.map((point) => {
const entry: Record<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) => {
const value = point.values[id];
entry[id] =
typeof value === "number"
? Number.isFinite(value)
? parseFloat(value.toFixed(fractionDigits))
: null
: value ?? null;
});
}
return entry;
});
};
const emptyStateMessages: Record<
PanelTab,
{ title: string; subtitle: string }
> = {
chart: {
title: "暂无时序数据",
subtitle: "请切换时间段来获取曲线",
},
table: {
title: "暂无表格数据",
subtitle: "请切换时间段来获取记录",
},
};
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
deviceIds,
fetchTimeSeriesData = defaultFetcher,
visible = true,
defaultTab = "chart",
fractionDigits = 2,
showCleaning = false,
onCleanData,
}) => {
const { open } = useNotification();
const { data: user } = useGetIdentity<IUser>();
const customFetcher = useMemo(() => {
if (!showCleaning) {
return fetchTimeSeriesData;
}
return async (
deviceIds: string[],
range: { from: Date; to: Date }
): Promise<TimeSeriesPoint[]> => {
const ids = deviceIds.join(",");
const starttime = dayjs(range.from).format("YYYY-MM-DD HH:mm:ss");
const endtime = dayjs(range.to).format("YYYY-MM-DD HH:mm:ss");
const cleaningUrl = `${config.BACKEND_URL}/querycleaningscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
const rawUrl = `${config.BACKEND_URL}/queryscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
const simUrl = `${config.BACKEND_URL}/querysimulationscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
try {
const [cleanRes, rawRes, simRes] = await Promise.all([
fetch(cleaningUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
fetch(rawUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
fetch(simUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
]);
const timeMap = new Map<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, fetchTimeSeriesData]);
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 [deviceLabels, setDeviceLabels] = useState<Record<string, string>>({});
const [isCleaning, setIsCleaning] = useState<boolean>(false);
const [selectedSource, setSelectedSource] = useState<
"raw" | "clean" | "sim" | "all"
>(() => (deviceIds.length === 1 ? "all" : "clean"));
// 获取 SCADA 设备信息,生成 deviceLabels
useEffect(() => {
const fetchDeviceLabels = async () => {
try {
const url = `${config.MAP_URL}/${config.MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${config.MAP_WORKSPACE}:geo_scada&outputFormat=application/json`;
const response = await fetch(url);
if (!response.ok) return;
const json = await response.json();
const features = new GeoJSON().readFeatures(json);
const labels = features.reduce<Record<string, string>>(
(acc, feature) => {
const id = feature.get("id") || feature.getId();
const name = feature.get("name") || id;
acc[id] = name;
return acc;
},
{}
);
setDeviceLabels(labels);
} catch (error) {
console.error("[SCADADataPanel] 获取设备标签失败:", error);
}
};
fetchDeviceLabels();
}, []);
useEffect(() => {
setActiveTab(defaultTab);
}, [defaultTab]);
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
const hasDevices = deviceIds.length > 0;
const hasData = timeSeries.length > 0;
const dataset = useMemo(
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
[timeSeries, deviceIds, fractionDigits, showCleaning]
);
const handleFetch = useCallback(
async (reason: string) => {
if (!hasDevices) {
setTimeSeries([]);
setLoadingState("idle");
setError(null);
return;
}
setLoadingState("loading");
setError(null);
try {
const { from: rangeFrom, to: rangeTo } = normalizedRange;
const result = await customFetcher(deviceIds, {
from: rangeFrom.toDate(),
to: rangeTo.toDate(),
});
setTimeSeries(result);
setLoadingState("success");
} catch (err) {
setError(err instanceof Error ? err.message : "未知错误");
setLoadingState("error");
}
},
[deviceIds, customFetcher, hasDevices, normalizedRange]
);
// 处理数据清洗
const handleCleanData = useCallback(async () => {
if (!hasDevices) {
open?.({
type: "error",
message: "请先选择设备",
});
return;
}
if (!user || !user.name) {
open?.({
type: "error",
message: "用户信息无效,请重新登录",
});
return;
}
setIsCleaning(true);
try {
const { from: rangeFrom, to: rangeTo } = normalizedRange;
const startTime = dayjs(rangeFrom).format("YYYY-MM-DD HH:mm:ss");
const endTime = dayjs(rangeTo).format("YYYY-MM-DD HH:mm:ss");
// 调用后端清洗接口
const response = await axios.post(
`${config.BACKEND_URL}/scadadevicedatacleaning/`,
null,
{
params: {
network: NETWORK_NAME,
ids_list: deviceIds.join(","), // 修改:将数组转为逗号分隔字符串
start_time: startTime,
end_time: endTime,
user_name: user.name,
},
}
);
console.log("[SCADADataPanel] 清洗响应:", response.data);
// 处理成功响应
if (response.data === "success" || response.data?.success === true) {
open?.({
type: "success",
message: "数据清洗成功",
description: `已完成 ${deviceIds.length} 个设备的数据清洗`,
});
// 清洗完成后自动刷新数据
await handleFetch("after-cleaning");
// 如果父组件提供了回调,也调用它
onCleanData?.();
} else {
throw new Error(response.data?.message || "清洗失败");
}
} catch (err: any) {
console.error("[SCADADataPanel] 数据清洗失败:", err);
open?.({
type: "error",
message: "数据清洗失败",
description: err.response?.data?.message || err.message || "未知错误",
});
} finally {
setIsCleaning(false);
}
}, [
deviceIds,
hasDevices,
normalizedRange,
user,
open,
onCleanData,
handleFetch,
]);
// 设备变化时自动查询
useEffect(() => {
if (hasDevices) {
handleFetch("device-change");
} else {
setTimeSeries([]);
}
}, [deviceIds.join(",")]);
// 当设备数量变化时,调整数据源选择
useEffect(() => {
if (deviceIds.length > 1 && selectedSource === "all") {
setSelectedSource("clean");
}
}, [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: `${deviceLabels?.[id] ?? id} (原始)`,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
if (value === null || value === undefined) return "--";
if (Number.isFinite(Number(value))) {
return Number(value).toFixed(fractionDigits);
}
return String(value);
},
},
{
field: `${id}_clean`,
headerName: `${deviceLabels?.[id] ?? id} (清洗)`,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
if (value === null || value === undefined) return "--";
if (Number.isFinite(Number(value))) {
return Number(value).toFixed(fractionDigits);
}
return String(value);
},
},
{
field: `${id}_sim`,
headerName: `${deviceLabels?.[id] ?? id} (模拟)`,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
if (value === null || value === undefined) return "--";
if (Number.isFinite(Number(value))) {
return Number(value).toFixed(fractionDigits);
}
return String(value);
},
},
]);
} else {
// 单一数据源模式:只显示选中的数据源
return deviceIds.map<GridColDef>((id) => ({
field: `${id}_${selectedSource}`,
headerName: deviceLabels?.[id] ?? id,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
if (value === null || value === undefined) return "--";
if (Number.isFinite(Number(value))) {
return Number(value).toFixed(fractionDigits);
}
return String(value);
},
}));
}
} else {
return deviceIds.map<GridColDef>((id) => ({
field: id,
headerName: deviceLabels?.[id] ?? id,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
if (value === null || value === undefined) return "--";
if (Number.isFinite(Number(value))) {
return Number(value).toFixed(fractionDigits);
}
return String(value);
},
}));
}
})();
return [...base, ...dynamic];
}, [deviceIds, deviceLabels, fractionDigits, showCleaning, selectedSource]);
const rows = useMemo(
() =>
dataset.map((item, index) => ({
id: `${
item.time instanceof Date ? item.time.getTime() : index
}-${index}`,
...item,
})),
[dataset]
);
const renderEmpty = () => {
const message = emptyStateMessages[activeTab];
return (
<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: `${deviceLabels?.[id] ?? id} (原始)`,
type: "line",
symbol: "none",
sampling: "lttb",
itemStyle: { color: colors[index % colors.length] },
data: dataset.map((item) => item[`${id}_raw`]),
},
{
name: `${deviceLabels?.[id] ?? id} (清洗)`,
type: "line",
symbol: "none",
sampling: "lttb",
itemStyle: { color: colors[(index + 3) % colors.length] },
data: dataset.map((item) => item[`${id}_clean`]),
},
{
name: `${deviceLabels?.[id] ?? id} (模拟)`,
type: "line",
symbol: "none",
sampling: "lttb",
itemStyle: { color: colors[(index + 6) % colors.length] },
data: dataset.map((item) => item[`${id}_sim`]),
},
]);
} else {
return deviceIds.map((id, index) => ({
name: deviceLabels?.[id] ?? id,
type: "line",
symbol: "none",
sampling: "lttb",
itemStyle: { color: colors[index % colors.length] },
data: dataset.map((item) => item[`${id}_${selectedSource}`]),
}));
}
} else {
return deviceIds.map((id, index) => ({
name: deviceLabels?.[id] ?? id,
type: "line",
symbol: "none",
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,
},
}));
}
};
const option = {
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}
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"
>
<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;