Files
TJWaterFrontend_Refine/src/components/olmap/SCADA/SCADADataPanel.tsx
T
jiang c4a90be0d3
Generic Container CI/CD / test-build-publish (push) Successful in 1m22s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m23s
fix(scada): avoid history refetch on map zoom
2026-09-25 17:22:42 +08:00

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;