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

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;

View File

@@ -0,0 +1,430 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import {
Box,
Paper,
TextField,
Typography,
List,
ListItem,
ListItemButton,
ListItemText,
ListItemIcon,
Chip,
IconButton,
Collapse,
InputAdornment,
FormControl,
InputLabel,
Select,
MenuItem,
Tooltip,
Stack,
Divider,
InputBase,
} from "@mui/material";
import {
Search,
MyLocation,
ExpandMore,
ExpandLess,
FilterList,
Clear,
Visibility,
VisibilityOff,
DeviceHub,
} from "@mui/icons-material";
interface SCADADevice {
id: string;
name: string;
type: string;
coordinates: [number, number];
status: "online" | "offline" | "warning" | "error";
properties?: Record<string, any>;
}
interface SCADADeviceListProps {
devices?: SCADADevice[];
onDeviceClick?: (device: SCADADevice) => void;
onZoomToDevice?: (coordinates: [number, number]) => void;
multiSelect?: boolean;
selectedDeviceIds?: string[];
onSelectionChange?: (ids: string[]) => void;
}
const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
devices = [],
onDeviceClick,
onZoomToDevice,
multiSelect = true,
selectedDeviceIds,
onSelectionChange,
}) => {
const [searchQuery, setSearchQuery] = useState<string>("");
const [selectedType, setSelectedType] = useState<string>("all");
const [selectedStatus, setSelectedStatus] = useState<string>("all");
const [isExpanded, setIsExpanded] = useState<boolean>(true);
const [internalSelection, setInternalSelection] = useState<string[]>([]);
const activeSelection = selectedDeviceIds ?? internalSelection;
useEffect(() => {
if (selectedDeviceIds) {
setInternalSelection(selectedDeviceIds);
}
}, [selectedDeviceIds]);
// 获取设备类型列表
const deviceTypes = useMemo(() => {
const types = Array.from(new Set(devices.map((device) => device.type)));
return types.sort();
}, [devices]);
// 获取设备状态列表
const deviceStatuses = useMemo(() => {
const statuses = Array.from(
new Set(devices.map((device) => device.status))
);
return statuses.sort();
}, [devices]);
// 过滤设备列表
const filteredDevices = useMemo(() => {
return devices.filter((device) => {
const matchesSearch =
searchQuery === "" ||
device.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
device.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
device.type.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType =
selectedType === "all" || device.type === selectedType;
const matchesStatus =
selectedStatus === "all" || device.status === selectedStatus;
return matchesSearch && matchesType && matchesStatus;
});
}, [devices, searchQuery, selectedType, selectedStatus]);
// 状态颜色映射
const getStatusColor = (status: string) => {
switch (status) {
case "online":
return "success";
case "offline":
return "default";
case "warning":
return "warning";
case "error":
return "error";
default:
return "default";
}
};
// 状态图标映射
const getStatusIcon = (status: string) => {
switch (status) {
case "online":
return "●";
case "offline":
return "○";
case "warning":
return "▲";
case "error":
return "✕";
default:
return "●";
}
};
// 处理设备点击
const handleDeviceClick = (device: SCADADevice, event?: React.MouseEvent) => {
onDeviceClick?.(device);
setInternalSelection((prev) => {
const exists = prev.includes(device.id);
const nextSelection = multiSelect
? exists
? prev.filter((id) => id !== device.id)
: [...prev, device.id]
: exists
? []
: [device.id];
onSelectionChange?.(nextSelection);
return nextSelection;
});
};
// 处理缩放到设备
const handleZoomToDevice = (device: SCADADevice, event: React.MouseEvent) => {
event.stopPropagation();
onZoomToDevice?.(device.coordinates);
};
// 清除搜索
const handleClearSearch = () => {
setSearchQuery("");
};
// 重置所有筛选条件
const handleResetFilters = () => {
setSearchQuery("");
setSelectedType("all");
setSelectedStatus("all");
};
return (
<Paper className="absolute left-4 top-20 w-90 max-h-[calc(100vh-100px)] bg-white/95 backdrop-blur-[10px] rounded-xl shadow-lg overflow-hidden flex flex-col opacity-95 transition-opacity duration-200 ease-in-out hover:opacity-100">
{/* 头部控制栏 */}
<Box
sx={{
p: 2,
borderBottom: 1,
borderColor: "divider",
backgroundColor: "primary.main",
color: "white",
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
>
<Stack direction="row" alignItems="center" spacing={1}>
<DeviceHub fontSize="small" />
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
SCADA
</Typography>
<Chip
label={filteredDevices.length}
size="small"
sx={{
backgroundColor: "rgba(255, 255, 255, 0.2)",
color: "white",
fontWeight: "bold",
}}
/>
</Stack>
<Stack direction="row" spacing={1}>
<Tooltip title="展开/收起">
<IconButton
size="small"
onClick={() => setIsExpanded(!isExpanded)}
sx={{ color: "white" }}
>
{isExpanded ? <ExpandLess /> : <ExpandMore />}
</IconButton>
</Tooltip>
</Stack>
</Stack>
</Box>
<Collapse in={isExpanded}>
{/* 搜索和筛选栏 */}
<Box sx={{ p: 2, backgroundColor: "grey.50" }}>
<Stack spacing={2}>
{/* 搜索框 */}
<Box className="h-10 flex items-center border border-gray-300 rounded-lg p-0.5">
<InputBase
sx={{ ml: 1, flex: 1 }}
placeholder="搜索设备名称、ID 或类型..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
inputProps={{ "aria-label": "search devices" }}
/>
<IconButton type="button" sx={{ p: "6px" }} aria-label="search">
<Search fontSize="small" />
</IconButton>
{searchQuery && (
<>
<Divider sx={{ height: 28, m: 0.5 }} orientation="vertical" />
<IconButton
color="primary"
sx={{ p: "6px" }}
onClick={handleClearSearch}
aria-label="clear"
>
<Clear fontSize="small" />
</IconButton>
</>
)}
</Box>
{/* 筛选器 */}
<Stack direction="row" spacing={2}>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel></InputLabel>
<Select
value={selectedType}
label="设备类型"
onChange={(e) => setSelectedType(e.target.value)}
>
<MenuItem value="all"></MenuItem>
{deviceTypes.map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel></InputLabel>
<Select
value={selectedStatus}
label="状态"
onChange={(e) => setSelectedStatus(e.target.value)}
>
<MenuItem value="all"></MenuItem>
{deviceStatuses.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</Select>
</FormControl>
<Tooltip title="重置筛选条件">
<IconButton onClick={handleResetFilters}>
<FilterList />
</IconButton>
</Tooltip>
</Stack>
{/* 筛选结果统计 */}
<Typography variant="caption" color="text.secondary">
{filteredDevices.length}
{devices.length !== filteredDevices.length &&
` (共 ${devices.length} 个设备)`}
</Typography>
</Stack>
</Box>
<Divider />
{/* 设备列表 */}
<Box sx={{ flex: 1, overflow: "auto", maxHeight: 400 }}>
{filteredDevices.length === 0 ? (
<Box
sx={{
p: 4,
textAlign: "center",
color: "text.secondary",
}}
>
<DeviceHub sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
<Typography variant="body2">
{searchQuery ||
selectedType !== "all" ||
selectedStatus !== "all"
? "未找到匹配的设备"
: "暂无 SCADA 设备"}
</Typography>
</Box>
) : (
<List dense sx={{ p: 0 }}>
{filteredDevices.map((device, index) => (
<React.Fragment key={device.id}>
<ListItem disablePadding>
<ListItemButton
selected={activeSelection.includes(device.id)}
onClick={(event) => handleDeviceClick(device, event)}
sx={{
"&.Mui-selected": {
backgroundColor: "primary.50",
borderLeft: 3,
borderColor: "primary.main",
},
"&:hover": {
backgroundColor: "grey.50",
},
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Typography
variant="caption"
sx={{
color: `${getStatusColor(device.status)}.main`,
fontWeight: "bold",
fontSize: 16,
}}
>
{getStatusIcon(device.status)}
</Typography>
</ListItemIcon>
<ListItemText
primary={
<Stack
direction="row"
alignItems="center"
spacing={1}
>
<Typography
variant="body2"
sx={{ fontWeight: "medium" }}
>
{device.name}
</Typography>
<Chip
label={device.type}
size="small"
variant="outlined"
sx={{ fontSize: "0.7rem", height: 20 }}
/>
</Stack>
}
secondary={
<Stack spacing={0.5}>
<Typography
variant="caption"
color="text.secondary"
>
ID: {device.id}
</Typography>
<Typography
variant="caption"
color="text.secondary"
>
: {device.coordinates[0].toFixed(6)},{" "}
{device.coordinates[1].toFixed(6)}
</Typography>
</Stack>
}
/>
<Tooltip title="缩放到设备位置">
<IconButton
size="small"
onClick={(e) => handleZoomToDevice(device, e)}
sx={{
ml: 1,
color: "primary.main",
"&:hover": {
backgroundColor: "primary.50",
},
}}
>
<MyLocation fontSize="small" />
</IconButton>
</Tooltip>
</ListItemButton>
</ListItem>
{index < filteredDevices.length - 1 && (
<Divider variant="inset" />
)}
</React.Fragment>
))}
</List>
)}
</Box>
</Collapse>
</Paper>
);
};
export default SCADADeviceList;