完成管网在线模拟页面组件基本样式和布局

This commit is contained in:
JIANG
2025-09-30 17:55:15 +08:00
parent fc84b255ea
commit 5c888b60f0
13 changed files with 2028 additions and 54 deletions

View 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;