完成管网在线模拟页面组件基本样式和布局
This commit is contained in:
528
src/components/olmap/SCADADataPanel.tsx
Normal file
528
src/components/olmap/SCADADataPanel.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Collapse,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Close,
|
||||
Refresh,
|
||||
ShowChart,
|
||||
TableChart,
|
||||
ExpandLess,
|
||||
ExpandMore,
|
||||
} from "@mui/icons-material";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import { LineChart } from "@mui/x-charts";
|
||||
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 clsx from "clsx";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
/** ISO8601 时间戳 */
|
||||
timestamp: string;
|
||||
/** 每个设备对应的值 */
|
||||
values: Record<string, number | null | undefined>;
|
||||
}
|
||||
|
||||
export interface SCADADataPanelProps {
|
||||
/** 选中的设备 ID 列表 */
|
||||
deviceIds: string[];
|
||||
/** 自定义数据获取器,默认使用本地模拟数据 */
|
||||
fetchTimeSeriesData?: (
|
||||
deviceIds: string[],
|
||||
range: { from: Date; to: Date }
|
||||
) => Promise<TimeSeriesPoint[]>;
|
||||
/** 可选:为设备提供友好的显示名称 */
|
||||
deviceLabels?: Record<string, string>;
|
||||
/** 可选:控制浮窗显示 */
|
||||
visible?: boolean;
|
||||
/** 可选:关闭浮窗的回调 */
|
||||
onClose?: () => void;
|
||||
/** 默认展示的选项卡 */
|
||||
defaultTab?: "chart" | "table";
|
||||
/** Y 轴数值的小数位数 */
|
||||
fractionDigits?: number;
|
||||
}
|
||||
|
||||
type PanelTab = "chart" | "table";
|
||||
|
||||
type LoadingState = "idle" | "loading" | "success" | "error";
|
||||
|
||||
const generateMockTimeSeries = (
|
||||
deviceIds: string[],
|
||||
range: { from: Date; to: Date },
|
||||
points = 96
|
||||
): TimeSeriesPoint[] => {
|
||||
if (deviceIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const start = dayjs(range.from);
|
||||
const end = dayjs(range.to);
|
||||
const duration = end.diff(start, "minute");
|
||||
const stepMinutes = Math.max(
|
||||
Math.floor(duration / Math.max(points - 1, 1)),
|
||||
15
|
||||
);
|
||||
|
||||
const times: TimeSeriesPoint[] = [];
|
||||
let current = start;
|
||||
|
||||
while (current.isBefore(end) || current.isSame(end)) {
|
||||
const values = deviceIds.reduce<Record<string, number>>(
|
||||
(acc, id, index) => {
|
||||
const phase = (index + 1) * 0.6;
|
||||
const base = 50 + index * 10;
|
||||
const amplitude = 10 + index * 4;
|
||||
const noise = Math.sin(current.unix() / 180 + phase) * amplitude;
|
||||
const trend = (current.diff(start, "minute") / duration || 0) * 5;
|
||||
acc[id] = parseFloat((base + noise + trend).toFixed(2));
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
times.push({
|
||||
timestamp: current.toISOString(),
|
||||
values,
|
||||
});
|
||||
|
||||
current = current.add(stepMinutes, "minute");
|
||||
}
|
||||
|
||||
return times;
|
||||
};
|
||||
|
||||
const defaultFetcher = async (
|
||||
deviceIds: string[],
|
||||
range: { from: Date; to: Date }
|
||||
): Promise<TimeSeriesPoint[]> => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return generateMockTimeSeries(deviceIds, range);
|
||||
};
|
||||
|
||||
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
|
||||
) => {
|
||||
return points.map((point) => {
|
||||
const entry: Record<string, any> = {
|
||||
time: dayjs(point.timestamp).toDate(),
|
||||
label: formatTimestamp(point.timestamp),
|
||||
};
|
||||
|
||||
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,
|
||||
deviceLabels,
|
||||
visible = true,
|
||||
onClose,
|
||||
defaultTab = "chart",
|
||||
fractionDigits = 2,
|
||||
}) => {
|
||||
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);
|
||||
|
||||
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),
|
||||
[timeSeries, deviceIds, fractionDigits]
|
||||
);
|
||||
|
||||
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 fetchTimeSeriesData(deviceIds, {
|
||||
from: rangeFrom.toDate(),
|
||||
to: rangeTo.toDate(),
|
||||
});
|
||||
setTimeSeries(result);
|
||||
setLoadingState("success");
|
||||
console.debug(
|
||||
`[SCADADataPanel] 数据刷新成功 (${reason}),共 ${result.length} 条记录。`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[SCADADataPanel] 获取时序数据失败", err);
|
||||
setError(err instanceof Error ? err.message : "未知错误");
|
||||
setLoadingState("error");
|
||||
}
|
||||
},
|
||||
[deviceIds, fetchTimeSeriesData, hasDevices, normalizedRange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasDevices) {
|
||||
handleFetch("device-change");
|
||||
} else {
|
||||
setTimeSeries([]);
|
||||
}
|
||||
}, [hasDevices, handleFetch]);
|
||||
|
||||
const columns: GridColDef[] = useMemo(() => {
|
||||
const base: GridColDef[] = [
|
||||
{
|
||||
field: "label",
|
||||
headerName: "时间",
|
||||
minWidth: 180,
|
||||
flex: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const dynamic = deviceIds.map<GridColDef>((id) => ({
|
||||
field: id,
|
||||
headerName: deviceLabels?.[id] ?? id,
|
||||
minWidth: 140,
|
||||
flex: 1,
|
||||
valueFormatter: (params) => {
|
||||
const value = (params as any).value;
|
||||
return value === null || value === undefined
|
||||
? "--"
|
||||
: Number.isFinite(Number(value))
|
||||
? Number(value).toFixed(fractionDigits)
|
||||
: String(value);
|
||||
},
|
||||
}));
|
||||
|
||||
return [...base, ...dynamic];
|
||||
}, [deviceIds, deviceLabels, fractionDigits]);
|
||||
|
||||
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: 6,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{message.title}
|
||||
</Typography>
|
||||
<Typography variant="body2">{message.subtitle}</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const chartSection = hasData ? (
|
||||
<LineChart
|
||||
dataset={dataset}
|
||||
height={360}
|
||||
margin={{ left: 50, right: 50, top: 20, bottom: 80 }}
|
||||
xAxis={[
|
||||
{
|
||||
dataKey: "time",
|
||||
scaleType: "time",
|
||||
valueFormatter: (value) =>
|
||||
value instanceof Date
|
||||
? dayjs(value).format("MM-DD HH:mm")
|
||||
: String(value),
|
||||
},
|
||||
]}
|
||||
yAxis={[{ label: "值" }]}
|
||||
series={deviceIds.map((id) => ({
|
||||
dataKey: id,
|
||||
label: deviceLabels?.[id] ?? id,
|
||||
showMark: false,
|
||||
curve: "linear",
|
||||
}))}
|
||||
slotProps={{
|
||||
legend: {
|
||||
direction: "row",
|
||||
position: { horizontal: "middle", vertical: "bottom" },
|
||||
},
|
||||
loadingOverlay: {
|
||||
style: { backgroundColor: "transparent" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
renderEmpty()
|
||||
);
|
||||
|
||||
const tableSection = hasData ? (
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
columnBufferPx={100}
|
||||
sx={{ border: "none", height: "360px" }}
|
||||
/>
|
||||
) : (
|
||||
renderEmpty()
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className={clsx(
|
||||
"absolute right-4 top-20 w-4xl h-2xl bg-white/95 backdrop-blur-[10px] rounded-xl shadow-lg overflow-hidden flex flex-col transition-opacity duration-300",
|
||||
visible ? "opacity-95" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
{/* 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(!isExpanded)}
|
||||
sx={{ color: "primary.contrastText" }}
|
||||
>
|
||||
{isExpanded ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="关闭">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
sx={{ color: "primary.contrastText" }}
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
{/* Controls */}
|
||||
<Box sx={{ p: 2, backgroundColor: "grey.50" }}>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<DateTimePicker
|
||||
label="开始时间"
|
||||
value={from}
|
||||
onChange={(value) =>
|
||||
value && dayjs.isDayjs(value) && setFrom(value)
|
||||
}
|
||||
maxDateTime={to}
|
||||
slotProps={{ textField: { fullWidth: true, size: "small" } }}
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="结束时间"
|
||||
value={to}
|
||||
onChange={(value) =>
|
||||
value && dayjs.isDayjs(value) && setTo(value)
|
||||
}
|
||||
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>
|
||||
<Tooltip title="刷新数据">
|
||||
<span>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
color="primary"
|
||||
startIcon={<Refresh fontSize="small" />}
|
||||
disabled={!hasDevices || loadingState === "loading"}
|
||||
onClick={() => handleFetch("manual")}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</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" ? chartSection : tableSection}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SCADADataPanel;
|
||||
Reference in New Issue
Block a user