Files
TJWaterServer/src/components/olmap/SCADADataPanel.tsx
JIANG 9d06226cb4 Implemented a Zustand-based project_id store, expanded project selection/switching to persist project_id,
and centralized backend requests via api/apiFetch (including data provider updates) to inject X-Project-ID.
2026-02-11 16:29:18 +08:00

1238 lines
37 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 { 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;