SCADA 面板数据实现前后端对接
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import MapComponent from "@app/OlMap/MapComponent";
|
import MapComponent from "@app/OlMap/MapComponent";
|
||||||
import Timeline from "@app/OlMap/Controls/Timeline";
|
import Timeline from "@app/OlMap/Controls/Timeline";
|
||||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
||||||
@@ -8,59 +8,10 @@ import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
|||||||
import SCADADeviceList from "@components/olmap/SCADADeviceList";
|
import SCADADeviceList from "@components/olmap/SCADADeviceList";
|
||||||
import SCADADataPanel from "@components/olmap/SCADADataPanel";
|
import SCADADataPanel from "@components/olmap/SCADADataPanel";
|
||||||
|
|
||||||
const mockDevices = [
|
|
||||||
{
|
|
||||||
id: "SCADA-001",
|
|
||||||
name: "SCADA-001",
|
|
||||||
type: "pressure",
|
|
||||||
coordinates: [121.4737, 31.2304] as [number, number],
|
|
||||||
status: "在线" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "SCADA-002",
|
|
||||||
name: "SCADA-002",
|
|
||||||
type: "flow",
|
|
||||||
coordinates: [121.4807, 31.2204] as [number, number],
|
|
||||||
status: "警告" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "SCADA-003",
|
|
||||||
name: "SCADA-003",
|
|
||||||
type: "pressure",
|
|
||||||
coordinates: [121.4607, 31.2354] as [number, number],
|
|
||||||
status: "离线" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "SCADA-004",
|
|
||||||
name: "SCADA-004",
|
|
||||||
type: "demand",
|
|
||||||
coordinates: [121.4457, 31.2104] as [number, number],
|
|
||||||
status: "在线" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "SCADA-005",
|
|
||||||
name: "SCADA-005",
|
|
||||||
type: "level",
|
|
||||||
coordinates: [121.4457, 31.2104] as [number, number],
|
|
||||||
status: "在线" as const,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||||
const [panelVisible, setPanelVisible] = useState<boolean>(false);
|
const [panelVisible, setPanelVisible] = useState<boolean>(false);
|
||||||
|
|
||||||
const devices = useMemo(() => mockDevices, []);
|
|
||||||
|
|
||||||
const deviceLabels = useMemo(
|
|
||||||
() =>
|
|
||||||
devices.reduce<Record<string, string>>((acc, device) => {
|
|
||||||
acc[device.id] = device.name;
|
|
||||||
return acc;
|
|
||||||
}, {}),
|
|
||||||
[devices]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectionChange = useCallback((ids: string[]) => {
|
const handleSelectionChange = useCallback((ids: string[]) => {
|
||||||
setSelectedDeviceIds(ids);
|
setSelectedDeviceIds(ids);
|
||||||
setPanelVisible(ids.length > 0);
|
setPanelVisible(ids.length > 0);
|
||||||
@@ -80,14 +31,12 @@ export default function Home() {
|
|||||||
<MapToolbar queryType="realtime" />
|
<MapToolbar queryType="realtime" />
|
||||||
<Timeline />
|
<Timeline />
|
||||||
<SCADADeviceList
|
<SCADADeviceList
|
||||||
devices={[]}
|
|
||||||
onDeviceClick={handleDeviceClick}
|
onDeviceClick={handleDeviceClick}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
selectedDeviceIds={selectedDeviceIds}
|
selectedDeviceIds={selectedDeviceIds}
|
||||||
/>
|
/>
|
||||||
<SCADADataPanel
|
<SCADADataPanel
|
||||||
deviceIds={selectedDeviceIds}
|
deviceIds={selectedDeviceIds}
|
||||||
deviceLabels={deviceLabels}
|
|
||||||
visible={panelVisible}
|
visible={panelVisible}
|
||||||
onClose={handleClosePanel}
|
onClose={handleClosePanel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,65 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import MapComponent from "@app/OlMap/MapComponent";
|
import MapComponent from "@app/OlMap/MapComponent";
|
||||||
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
import MapToolbar from "@app/OlMap/Controls/Toolbar";
|
||||||
|
|
||||||
import SCADADeviceList from "@components/olmap/SCADADeviceList";
|
import SCADADeviceList from "@components/olmap/SCADADeviceList";
|
||||||
import SCADADataPanel from "@components/olmap/SCADADataPanel";
|
import SCADADataPanel from "@components/olmap/SCADADataPanel";
|
||||||
|
|
||||||
const mockDevices = [
|
|
||||||
{
|
|
||||||
id: "SCADA-001",
|
|
||||||
name: "SCADA-001",
|
|
||||||
type: "pressure",
|
|
||||||
coordinates: [121.4737, 31.2304] as [number, number],
|
|
||||||
status: "在线" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "SCADA-002",
|
|
||||||
name: "SCADA-002",
|
|
||||||
type: "flow",
|
|
||||||
coordinates: [121.4807, 31.2204] as [number, number],
|
|
||||||
status: "警告" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "SCADA-003",
|
|
||||||
name: "SCADA-003",
|
|
||||||
type: "pressure",
|
|
||||||
coordinates: [121.4607, 31.2354] as [number, number],
|
|
||||||
status: "离线" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "SCADA-004",
|
|
||||||
name: "SCADA-004",
|
|
||||||
type: "demand",
|
|
||||||
coordinates: [121.4457, 31.2104] as [number, number],
|
|
||||||
status: "在线" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "SCADA-005",
|
|
||||||
name: "SCADA-005",
|
|
||||||
type: "level",
|
|
||||||
coordinates: [121.4457, 31.2104] as [number, number],
|
|
||||||
status: "在线" as const,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||||
const [panelVisible, setPanelVisible] = useState<boolean>(false);
|
const [panelVisible, setPanelVisible] = useState<boolean>(false);
|
||||||
|
|
||||||
const devices = useMemo(() => mockDevices, []);
|
|
||||||
|
|
||||||
const deviceLabels = useMemo(
|
|
||||||
() =>
|
|
||||||
devices.reduce<Record<string, string>>((acc, device) => {
|
|
||||||
acc[device.id] = device.name;
|
|
||||||
return acc;
|
|
||||||
}, {}),
|
|
||||||
[devices]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectionChange = useCallback((ids: string[]) => {
|
const handleSelectionChange = useCallback((ids: string[]) => {
|
||||||
setSelectedDeviceIds(ids);
|
setSelectedDeviceIds(ids);
|
||||||
setPanelVisible(ids.length > 0);
|
setPanelVisible(ids.length > 0);
|
||||||
@@ -78,14 +29,12 @@ export default function Home() {
|
|||||||
<MapComponent>
|
<MapComponent>
|
||||||
<MapToolbar hiddenButtons={["style"]} />
|
<MapToolbar hiddenButtons={["style"]} />
|
||||||
<SCADADeviceList
|
<SCADADeviceList
|
||||||
devices={[]}
|
|
||||||
onDeviceClick={handleDeviceClick}
|
onDeviceClick={handleDeviceClick}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
selectedDeviceIds={selectedDeviceIds}
|
selectedDeviceIds={selectedDeviceIds}
|
||||||
/>
|
/>
|
||||||
<SCADADataPanel
|
<SCADADataPanel
|
||||||
deviceIds={selectedDeviceIds}
|
deviceIds={selectedDeviceIds}
|
||||||
deviceLabels={deviceLabels}
|
|
||||||
visible={panelVisible}
|
visible={panelVisible}
|
||||||
onClose={handleClosePanel}
|
onClose={handleClosePanel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import timezone from "dayjs/plugin/timezone";
|
|||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
|
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import config from "@/config/config";
|
||||||
|
import { GeoJSON } from "ol/format";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
@@ -46,13 +48,11 @@ export interface TimeSeriesPoint {
|
|||||||
export interface SCADADataPanelProps {
|
export interface SCADADataPanelProps {
|
||||||
/** 选中的设备 ID 列表 */
|
/** 选中的设备 ID 列表 */
|
||||||
deviceIds: string[];
|
deviceIds: string[];
|
||||||
/** 自定义数据获取器,默认使用本地模拟数据 */
|
/** 自定义数据获取器,默认使用后端 API */
|
||||||
fetchTimeSeriesData?: (
|
fetchTimeSeriesData?: (
|
||||||
deviceIds: string[],
|
deviceIds: string[],
|
||||||
range: { from: Date; to: Date }
|
range: { from: Date; to: Date }
|
||||||
) => Promise<TimeSeriesPoint[]>;
|
) => Promise<TimeSeriesPoint[]>;
|
||||||
/** 可选:为设备提供友好的显示名称 */
|
|
||||||
deviceLabels?: Record<string, string>;
|
|
||||||
/** 可选:控制浮窗显示 */
|
/** 可选:控制浮窗显示 */
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
/** 可选:关闭浮窗的回调 */
|
/** 可选:关闭浮窗的回调 */
|
||||||
@@ -67,58 +67,122 @@ type PanelTab = "chart" | "table";
|
|||||||
|
|
||||||
type LoadingState = "idle" | "loading" | "success" | "error";
|
type LoadingState = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
const generateMockTimeSeries = (
|
/**
|
||||||
|
* 从后端 API 获取 SCADA 数据
|
||||||
|
*/
|
||||||
|
const fetchFromBackend = async (
|
||||||
deviceIds: string[],
|
deviceIds: string[],
|
||||||
range: { from: Date; to: Date },
|
range: { from: Date; to: Date }
|
||||||
points = 96
|
): Promise<TimeSeriesPoint[]> => {
|
||||||
): TimeSeriesPoint[] => {
|
|
||||||
if (deviceIds.length === 0) {
|
if (deviceIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = dayjs(range.from);
|
const ids = deviceIds.join(",");
|
||||||
const end = dayjs(range.to);
|
const starttime = dayjs(range.from).format("YYYY-MM-DD HH:mm:ss");
|
||||||
const duration = end.diff(start, "minute");
|
const endtime = dayjs(range.to).format("YYYY-MM-DD HH:mm:ss");
|
||||||
const stepMinutes = Math.max(
|
|
||||||
Math.floor(duration / Math.max(points - 1, 1)),
|
|
||||||
15
|
|
||||||
);
|
|
||||||
|
|
||||||
const times: TimeSeriesPoint[] = [];
|
const url = `${config.backendUrl}/querycleaningscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
|
||||||
let current = start;
|
|
||||||
|
|
||||||
while (current.isBefore(end) || current.isSame(end)) {
|
try {
|
||||||
const values = deviceIds.reduce<Record<string, number>>(
|
const response = await fetch(url);
|
||||||
(acc, id, index) => {
|
if (!response.ok) {
|
||||||
const phase = (index + 1) * 0.6;
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
const base = 50 + index * 10;
|
}
|
||||||
const amplitude = 10 + index * 4;
|
const data = await response.json();
|
||||||
const noise = Math.sin(current.unix() / 180 + phase) * amplitude;
|
return transformBackendData(data, deviceIds);
|
||||||
const trend = (current.diff(start, "minute") / duration || 0) * 5;
|
} catch (error) {
|
||||||
acc[id] = parseFloat((base + noise + trend).toFixed(2));
|
console.error("[SCADADataPanel] 从后端获取数据失败:", error);
|
||||||
return acc;
|
throw error;
|
||||||
},
|
}
|
||||||
{}
|
};
|
||||||
);
|
|
||||||
|
|
||||||
times.push({
|
/**
|
||||||
timestamp: current.toISOString(),
|
* 转换后端数据格式
|
||||||
values,
|
* 根据实际后端返回的数据结构进行调整
|
||||||
});
|
*/
|
||||||
|
const transformBackendData = (
|
||||||
|
backendData: any,
|
||||||
|
deviceIds: string[]
|
||||||
|
): TimeSeriesPoint[] => {
|
||||||
|
// 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] }
|
||||||
|
if (
|
||||||
|
backendData &&
|
||||||
|
typeof backendData === "object" &&
|
||||||
|
!Array.isArray(backendData)
|
||||||
|
) {
|
||||||
|
// 检查是否是设备ID为键的对象格式
|
||||||
|
const hasDeviceKeys = deviceIds.some((id) => id in backendData);
|
||||||
|
|
||||||
current = current.add(stepMinutes, "minute");
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return times;
|
// 如果后端返回的是数组格式,每个元素包含时间戳和各设备值
|
||||||
|
if (Array.isArray(backendData)) {
|
||||||
|
return backendData.map((item: any) => ({
|
||||||
|
timestamp: item.timestamp || item.time || item._time,
|
||||||
|
values: deviceIds.reduce<Record<string, number | null>>((acc, id) => {
|
||||||
|
acc[id] = item[id] ?? item.values?.[id] ?? null;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果后端返回的是对象格式,包含 timestamps 和 data
|
||||||
|
if (backendData && backendData.timestamps && backendData.data) {
|
||||||
|
return backendData.timestamps.map((timestamp: string, index: number) => {
|
||||||
|
const values = deviceIds.reduce<Record<string, number | null>>(
|
||||||
|
(acc, id) => {
|
||||||
|
acc[id] = backendData.data[id]?.[index] ?? null;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
return { timestamp, values };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回空数组
|
||||||
|
console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData);
|
||||||
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultFetcher = async (
|
const defaultFetcher = fetchFromBackend;
|
||||||
deviceIds: string[],
|
|
||||||
range: { from: Date; to: Date }
|
|
||||||
): Promise<TimeSeriesPoint[]> => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
return generateMockTimeSeries(deviceIds, range);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) =>
|
const formatTimestamp = (timestamp: string) =>
|
||||||
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
|
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
|
||||||
@@ -175,7 +239,6 @@ const emptyStateMessages: Record<
|
|||||||
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||||
deviceIds,
|
deviceIds,
|
||||||
fetchTimeSeriesData = defaultFetcher,
|
fetchTimeSeriesData = defaultFetcher,
|
||||||
deviceLabels,
|
|
||||||
visible = true,
|
visible = true,
|
||||||
onClose,
|
onClose,
|
||||||
defaultTab = "chart",
|
defaultTab = "chart",
|
||||||
@@ -188,6 +251,37 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||||
|
const [deviceLabels, setDeviceLabels] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 获取 SCADA 设备信息,生成 deviceLabels
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDeviceLabels = async () => {
|
||||||
|
try {
|
||||||
|
const url = `${config.mapUrl}/TJWater/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=TJWater:geo_scada&outputFormat=application/json`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
const features = new GeoJSON().readFeatures(json);
|
||||||
|
|
||||||
|
const labels = features.reduce<Record<string, string>>(
|
||||||
|
(acc, feature) => {
|
||||||
|
const id = feature.get("id") || feature.getId();
|
||||||
|
const name = feature.get("name") || id;
|
||||||
|
acc[id] = name;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
setDeviceLabels(labels);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SCADADataPanel] 获取设备标签失败:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDeviceLabels();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveTab(defaultTab);
|
setActiveTab(defaultTab);
|
||||||
@@ -235,13 +329,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
[deviceIds, fetchTimeSeriesData, hasDevices, normalizedRange]
|
[deviceIds, fetchTimeSeriesData, hasDevices, normalizedRange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 设备变化时自动查询
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasDevices) {
|
if (hasDevices) {
|
||||||
handleFetch("device-change");
|
handleFetch("device-change");
|
||||||
} else {
|
} else {
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
}
|
}
|
||||||
}, [hasDevices, handleFetch]);
|
}, [deviceIds.join(","), hasDevices]);
|
||||||
|
|
||||||
const columns: GridColDef[] = useMemo(() => {
|
const columns: GridColDef[] = useMemo(() => {
|
||||||
const base: GridColDef[] = [
|
const base: GridColDef[] = [
|
||||||
@@ -291,15 +386,18 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
py: 6,
|
py: 8,
|
||||||
color: "text.secondary",
|
color: "text.secondary",
|
||||||
height: 376,
|
height: 420,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h6" gutterBottom>
|
<ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
|
||||||
{message.title}
|
{message.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">{message.subtitle}</Typography>
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{message.subtitle}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -307,38 +405,116 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const renderChart = () => {
|
const renderChart = () => {
|
||||||
if (!hasData) return renderEmpty();
|
if (!hasData) return renderEmpty();
|
||||||
|
|
||||||
|
// 为每个设备生成独特的颜色和样式
|
||||||
|
const colors = [
|
||||||
|
"#1976d2", // 蓝色
|
||||||
|
"#dc004e", // 粉红色
|
||||||
|
"#ff9800", // 橙色
|
||||||
|
"#4caf50", // 绿色
|
||||||
|
"#9c27b0", // 紫色
|
||||||
|
"#00bcd4", // 青色
|
||||||
|
"#f44336", // 红色
|
||||||
|
"#8bc34a", // 浅绿色
|
||||||
|
"#ff5722", // 深橙色
|
||||||
|
"#3f51b5", // 靛蓝色
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LineChart
|
<Box sx={{ width: "100%", height: 420 }}>
|
||||||
dataset={dataset}
|
<LineChart
|
||||||
height={376}
|
dataset={dataset}
|
||||||
margin={{ left: 50, right: 50, top: 20, bottom: 80 }}
|
height={420}
|
||||||
xAxis={[
|
margin={{ left: 70, right: 40, top: 30, bottom: 90 }}
|
||||||
{
|
xAxis={[
|
||||||
dataKey: "time",
|
{
|
||||||
scaleType: "time",
|
dataKey: "time",
|
||||||
valueFormatter: (value) =>
|
scaleType: "time",
|
||||||
value instanceof Date
|
valueFormatter: (value) =>
|
||||||
? dayjs(value).format("MM-DD HH:mm")
|
value instanceof Date
|
||||||
: String(value),
|
? dayjs(value).format("MM-DD HH:mm")
|
||||||
},
|
: String(value),
|
||||||
]}
|
tickLabelStyle: {
|
||||||
yAxis={[{ label: "值" }]}
|
angle: -45,
|
||||||
series={deviceIds.map((id) => ({
|
textAnchor: "end",
|
||||||
dataKey: id,
|
fontSize: 11,
|
||||||
label: deviceLabels?.[id] ?? id,
|
fill: "#666",
|
||||||
showMark: false,
|
},
|
||||||
curve: "linear",
|
},
|
||||||
}))}
|
]}
|
||||||
slotProps={{
|
yAxis={[
|
||||||
legend: {
|
{
|
||||||
direction: "row",
|
label: "数值",
|
||||||
position: { horizontal: "middle", vertical: "bottom" },
|
labelStyle: {
|
||||||
},
|
fontSize: 13,
|
||||||
loadingOverlay: {
|
fill: "#333",
|
||||||
style: { backgroundColor: "transparent" },
|
fontWeight: 500,
|
||||||
},
|
},
|
||||||
}}
|
tickLabelStyle: {
|
||||||
/>
|
fontSize: 11,
|
||||||
|
fill: "#666",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
series={deviceIds.map((id, index) => ({
|
||||||
|
dataKey: id,
|
||||||
|
label: deviceLabels?.[id] ?? id,
|
||||||
|
showMark: dataset.length < 50, // 数据点少时显示标记
|
||||||
|
curve: "catmullRom", // 使用平滑曲线
|
||||||
|
color: colors[index % colors.length],
|
||||||
|
valueFormatter: (value: number | null) =>
|
||||||
|
value !== null ? value.toFixed(fractionDigits) : "--",
|
||||||
|
area: false,
|
||||||
|
stack: undefined,
|
||||||
|
}))}
|
||||||
|
grid={{ vertical: true, horizontal: true }}
|
||||||
|
sx={{
|
||||||
|
"& .MuiLineElement-root": {
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
strokeLinecap: "round",
|
||||||
|
strokeLinejoin: "round",
|
||||||
|
},
|
||||||
|
"& .MuiMarkElement-root": {
|
||||||
|
scale: "0.8",
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
"& .MuiChartsAxis-line": {
|
||||||
|
stroke: "#e0e0e0",
|
||||||
|
strokeWidth: 1,
|
||||||
|
},
|
||||||
|
"& .MuiChartsAxis-tick": {
|
||||||
|
stroke: "#e0e0e0",
|
||||||
|
strokeWidth: 1,
|
||||||
|
},
|
||||||
|
"& .MuiChartsGrid-line": {
|
||||||
|
stroke: "#f5f5f5",
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeDasharray: "3 3",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
legend: {
|
||||||
|
direction: "row",
|
||||||
|
position: { horizontal: "middle", vertical: "bottom" },
|
||||||
|
padding: { top: 20, bottom: 10, left: 0, right: 0 },
|
||||||
|
itemMarkWidth: 16,
|
||||||
|
itemMarkHeight: 3,
|
||||||
|
markGap: 8,
|
||||||
|
itemGap: 16,
|
||||||
|
labelStyle: {
|
||||||
|
fontSize: 12,
|
||||||
|
fill: "#333",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
loadingOverlay: {
|
||||||
|
style: { backgroundColor: "rgba(255, 255, 255, 0.7)" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
tooltip={{
|
||||||
|
trigger: "axis",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -359,11 +535,28 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
columnBufferPx={100}
|
columnBufferPx={100}
|
||||||
initialState={{
|
initialState={{
|
||||||
pagination: {
|
pagination: {
|
||||||
paginationModel: { pageSize: 10, page: 0 },
|
paginationModel: { pageSize: 50, page: 0 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
pageSizeOptions={[25, 50, 100]}
|
||||||
|
sx={{
|
||||||
|
border: "none",
|
||||||
|
height: "420px",
|
||||||
|
"& .MuiDataGrid-cell": {
|
||||||
|
borderColor: "#f0f0f0",
|
||||||
|
},
|
||||||
|
"& .MuiDataGrid-columnHeaders": {
|
||||||
|
backgroundColor: "#fafafa",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
},
|
||||||
|
"& .MuiDataGrid-row:hover": {
|
||||||
|
backgroundColor: "#f5f5f5",
|
||||||
|
},
|
||||||
|
"& .MuiDataGrid-columnHeaderTitle": {
|
||||||
|
fontWeight: 600,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
pageSizeOptions={[5, 10, 25, 50]}
|
|
||||||
sx={{ border: "none", height: "360px" }}
|
|
||||||
disableRowSelectionOnClick
|
disableRowSelectionOnClick
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -372,9 +565,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute right-4 top-20 w-4xl h-2xl bg-white rounded-xl shadow-lg overflow-hidden flex flex-col transition-opacity duration-300",
|
"absolute right-4 top-20 bg-white rounded-xl shadow-lg overflow-hidden flex flex-col transition-all duration-300",
|
||||||
visible ? "opacity-95 hover:opacity-100" : "opacity-0 -z-10"
|
visible ? "opacity-95 hover:opacity-100" : "opacity-0 -z-10"
|
||||||
)}
|
)}
|
||||||
|
sx={{
|
||||||
|
width: "min(920px, calc(100vw - 2rem))",
|
||||||
|
maxHeight: "calc(100vh - 100px)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box
|
<Box
|
||||||
@@ -441,6 +638,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
value && dayjs.isDayjs(value) && setFrom(value)
|
value && dayjs.isDayjs(value) && setFrom(value)
|
||||||
}
|
}
|
||||||
|
onAccept={(value) => {
|
||||||
|
if (value && dayjs.isDayjs(value) && hasDevices) {
|
||||||
|
handleFetch("date-change");
|
||||||
|
}
|
||||||
|
}}
|
||||||
maxDateTime={to}
|
maxDateTime={to}
|
||||||
slotProps={{ textField: { fullWidth: true, size: "small" } }}
|
slotProps={{ textField: { fullWidth: true, size: "small" } }}
|
||||||
/>
|
/>
|
||||||
@@ -450,6 +652,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
value && dayjs.isDayjs(value) && setTo(value)
|
value && dayjs.isDayjs(value) && setTo(value)
|
||||||
}
|
}
|
||||||
|
onAccept={(value) => {
|
||||||
|
if (value && dayjs.isDayjs(value) && hasDevices) {
|
||||||
|
handleFetch("date-change");
|
||||||
|
}
|
||||||
|
}}
|
||||||
minDateTime={from}
|
minDateTime={from}
|
||||||
slotProps={{ textField: { fullWidth: true, size: "small" } }}
|
slotProps={{ textField: { fullWidth: true, size: "small" } }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -170,15 +170,6 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
// 获取设备状态列表
|
// 获取设备状态列表
|
||||||
const deviceStatuses = STATUS_OPTIONS;
|
const deviceStatuses = STATUS_OPTIONS;
|
||||||
|
|
||||||
// 创建设备索引 Map,使用设备 ID 作为键
|
|
||||||
const deviceIndex = useMemo(() => {
|
|
||||||
const index = new Map<string, SCADADevice>();
|
|
||||||
effectiveDevices.forEach((device) => {
|
|
||||||
index.set(device.id, device);
|
|
||||||
});
|
|
||||||
return index;
|
|
||||||
}, [effectiveDevices]);
|
|
||||||
|
|
||||||
// 过滤设备列表
|
// 过滤设备列表
|
||||||
const filteredDevices = useMemo(() => {
|
const filteredDevices = useMemo(() => {
|
||||||
return effectiveDevices.filter((device) => {
|
return effectiveDevices.filter((device) => {
|
||||||
@@ -276,6 +267,12 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 清除选择
|
||||||
|
const handleClearSelection = useCallback(() => {
|
||||||
|
setInternalSelection([]);
|
||||||
|
setPendingSelection([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 清理定时器
|
// 清理定时器
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -412,6 +409,20 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
{devices.length !== filteredDevices.length &&
|
{devices.length !== filteredDevices.length &&
|
||||||
` (共 ${effectiveDevices.length} 个设备)`}
|
` (共 ${effectiveDevices.length} 个设备)`}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
{/* 清除选择按钮 */}
|
||||||
|
{activeSelection.length > 0 && (
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<Chip
|
||||||
|
label={`已选择 ${activeSelection.length} 个设备`}
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
onDelete={handleClearSelection}
|
||||||
|
deleteIcon={<Clear />}
|
||||||
|
sx={{ fontWeight: "medium" }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user