From fe797c1bf306fe53b23740b7805744e351ebccae Mon Sep 17 00:00:00 2001 From: JIANG Date: Thu, 30 Oct 2025 17:35:27 +0800 Subject: [PATCH] =?UTF-8?q?SCADA=20=E9=9D=A2=E6=9D=BF=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=89=8D=E5=90=8E=E7=AB=AF=E5=AF=B9=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/network-simulation/page.tsx | 53 +-- src/app/(main)/scada-data-cleaning/page.tsx | 53 +-- src/components/olmap/SCADADataPanel.tsx | 373 +++++++++++++++----- src/components/olmap/SCADADeviceList.tsx | 29 +- 4 files changed, 312 insertions(+), 196 deletions(-) diff --git a/src/app/(main)/network-simulation/page.tsx b/src/app/(main)/network-simulation/page.tsx index fadecef..24d3048 100644 --- a/src/app/(main)/network-simulation/page.tsx +++ b/src/app/(main)/network-simulation/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import MapComponent from "@app/OlMap/MapComponent"; import Timeline from "@app/OlMap/Controls/Timeline"; 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 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() { const [selectedDeviceIds, setSelectedDeviceIds] = useState([]); const [panelVisible, setPanelVisible] = useState(false); - const devices = useMemo(() => mockDevices, []); - - const deviceLabels = useMemo( - () => - devices.reduce>((acc, device) => { - acc[device.id] = device.name; - return acc; - }, {}), - [devices] - ); - const handleSelectionChange = useCallback((ids: string[]) => { setSelectedDeviceIds(ids); setPanelVisible(ids.length > 0); @@ -80,14 +31,12 @@ export default function Home() { diff --git a/src/app/(main)/scada-data-cleaning/page.tsx b/src/app/(main)/scada-data-cleaning/page.tsx index 127b954..6d08430 100644 --- a/src/app/(main)/scada-data-cleaning/page.tsx +++ b/src/app/(main)/scada-data-cleaning/page.tsx @@ -1,65 +1,16 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import MapComponent from "@app/OlMap/MapComponent"; import MapToolbar from "@app/OlMap/Controls/Toolbar"; import SCADADeviceList from "@components/olmap/SCADADeviceList"; 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() { const [selectedDeviceIds, setSelectedDeviceIds] = useState([]); const [panelVisible, setPanelVisible] = useState(false); - const devices = useMemo(() => mockDevices, []); - - const deviceLabels = useMemo( - () => - devices.reduce>((acc, device) => { - acc[device.id] = device.name; - return acc; - }, {}), - [devices] - ); - const handleSelectionChange = useCallback((ids: string[]) => { setSelectedDeviceIds(ids); setPanelVisible(ids.length > 0); @@ -78,14 +29,12 @@ export default function Home() { diff --git a/src/components/olmap/SCADADataPanel.tsx b/src/components/olmap/SCADADataPanel.tsx index a655245..bfea23c 100644 --- a/src/components/olmap/SCADADataPanel.tsx +++ b/src/components/olmap/SCADADataPanel.tsx @@ -32,6 +32,8 @@ 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"; +import config from "@/config/config"; +import { GeoJSON } from "ol/format"; dayjs.extend(utc); dayjs.extend(timezone); @@ -46,13 +48,11 @@ export interface TimeSeriesPoint { export interface SCADADataPanelProps { /** 选中的设备 ID 列表 */ deviceIds: string[]; - /** 自定义数据获取器,默认使用本地模拟数据 */ + /** 自定义数据获取器,默认使用后端 API */ fetchTimeSeriesData?: ( deviceIds: string[], range: { from: Date; to: Date } ) => Promise; - /** 可选:为设备提供友好的显示名称 */ - deviceLabels?: Record; /** 可选:控制浮窗显示 */ visible?: boolean; /** 可选:关闭浮窗的回调 */ @@ -67,58 +67,122 @@ type PanelTab = "chart" | "table"; type LoadingState = "idle" | "loading" | "success" | "error"; -const generateMockTimeSeries = ( +/** + * 从后端 API 获取 SCADA 数据 + */ +const fetchFromBackend = async ( deviceIds: string[], - range: { from: Date; to: Date }, - points = 96 -): TimeSeriesPoint[] => { + range: { from: Date; to: Date } +): Promise => { 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 ids = deviceIds.join(","); + const starttime = dayjs(range.from).format("YYYY-MM-DD HH:mm:ss"); + const endtime = dayjs(range.to).format("YYYY-MM-DD HH:mm:ss"); - const times: TimeSeriesPoint[] = []; - let current = start; + const url = `${config.backendUrl}/querycleaningscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`; - while (current.isBefore(end) || current.isSame(end)) { - const values = deviceIds.reduce>( - (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; - }, - {} - ); + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + return transformBackendData(data, deviceIds); + } catch (error) { + console.error("[SCADADataPanel] 从后端获取数据失败:", error); + 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>(); + + 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>((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>( + (acc, id) => { + acc[id] = backendData.data[id]?.[index] ?? null; + return acc; + }, + {} + ); + return { timestamp, values }; + }); + } + + // 默认返回空数组 + console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData); + return []; }; -const defaultFetcher = async ( - deviceIds: string[], - range: { from: Date; to: Date } -): Promise => { - await new Promise((resolve) => setTimeout(resolve, 500)); - return generateMockTimeSeries(deviceIds, range); -}; +const defaultFetcher = fetchFromBackend; const formatTimestamp = (timestamp: string) => dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm"); @@ -175,7 +239,6 @@ const emptyStateMessages: Record< const SCADADataPanel: React.FC = ({ deviceIds, fetchTimeSeriesData = defaultFetcher, - deviceLabels, visible = true, onClose, defaultTab = "chart", @@ -188,6 +251,37 @@ const SCADADataPanel: React.FC = ({ const [loadingState, setLoadingState] = useState("idle"); const [error, setError] = useState(null); const [isExpanded, setIsExpanded] = useState(true); + const [deviceLabels, setDeviceLabels] = useState>({}); + + // 获取 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>( + (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(() => { setActiveTab(defaultTab); @@ -235,13 +329,14 @@ const SCADADataPanel: React.FC = ({ [deviceIds, fetchTimeSeriesData, hasDevices, normalizedRange] ); + // 设备变化时自动查询 useEffect(() => { if (hasDevices) { handleFetch("device-change"); } else { setTimeSeries([]); } - }, [hasDevices, handleFetch]); + }, [deviceIds.join(","), hasDevices]); const columns: GridColDef[] = useMemo(() => { const base: GridColDef[] = [ @@ -291,15 +386,18 @@ const SCADADataPanel: React.FC = ({ flexDirection: "column", alignItems: "center", justifyContent: "center", - py: 6, + py: 8, color: "text.secondary", - height: 376, + height: 420, }} > - + + {message.title} - {message.subtitle} + + {message.subtitle} + ); }; @@ -307,38 +405,116 @@ const SCADADataPanel: React.FC = ({ const renderChart = () => { if (!hasData) return renderEmpty(); + // 为每个设备生成独特的颜色和样式 + const colors = [ + "#1976d2", // 蓝色 + "#dc004e", // 粉红色 + "#ff9800", // 橙色 + "#4caf50", // 绿色 + "#9c27b0", // 紫色 + "#00bcd4", // 青色 + "#f44336", // 红色 + "#8bc34a", // 浅绿色 + "#ff5722", // 深橙色 + "#3f51b5", // 靛蓝色 + ]; + return ( - - 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" }, - }, - }} - /> + + + value instanceof Date + ? dayjs(value).format("MM-DD HH:mm") + : String(value), + tickLabelStyle: { + angle: -45, + textAnchor: "end", + fontSize: 11, + fill: "#666", + }, + }, + ]} + yAxis={[ + { + label: "数值", + labelStyle: { + fontSize: 13, + fill: "#333", + 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", + }} + /> + ); }; @@ -359,11 +535,28 @@ const SCADADataPanel: React.FC = ({ columnBufferPx={100} initialState={{ 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 /> ); @@ -372,9 +565,13 @@ const SCADADataPanel: React.FC = ({ return ( {/* Header */} = ({ onChange={(value) => value && dayjs.isDayjs(value) && setFrom(value) } + onAccept={(value) => { + if (value && dayjs.isDayjs(value) && hasDevices) { + handleFetch("date-change"); + } + }} maxDateTime={to} slotProps={{ textField: { fullWidth: true, size: "small" } }} /> @@ -450,6 +652,11 @@ const SCADADataPanel: React.FC = ({ onChange={(value) => value && dayjs.isDayjs(value) && setTo(value) } + onAccept={(value) => { + if (value && dayjs.isDayjs(value) && hasDevices) { + handleFetch("date-change"); + } + }} minDateTime={from} slotProps={{ textField: { fullWidth: true, size: "small" } }} /> diff --git a/src/components/olmap/SCADADeviceList.tsx b/src/components/olmap/SCADADeviceList.tsx index e099b5a..c1a0ba1 100644 --- a/src/components/olmap/SCADADeviceList.tsx +++ b/src/components/olmap/SCADADeviceList.tsx @@ -170,15 +170,6 @@ const SCADADeviceList: React.FC = ({ // 获取设备状态列表 const deviceStatuses = STATUS_OPTIONS; - // 创建设备索引 Map,使用设备 ID 作为键 - const deviceIndex = useMemo(() => { - const index = new Map(); - effectiveDevices.forEach((device) => { - index.set(device.id, device); - }); - return index; - }, [effectiveDevices]); - // 过滤设备列表 const filteredDevices = useMemo(() => { return effectiveDevices.filter((device) => { @@ -276,6 +267,12 @@ const SCADADeviceList: React.FC = ({ }); }, []); + // 清除选择 + const handleClearSelection = useCallback(() => { + setInternalSelection([]); + setPendingSelection([]); + }, []); + // 清理定时器 useEffect(() => { return () => { @@ -412,6 +409,20 @@ const SCADADeviceList: React.FC = ({ {devices.length !== filteredDevices.length && ` (共 ${effectiveDevices.length} 个设备)`} + + {/* 清除选择按钮 */} + {activeSelection.length > 0 && ( + + } + sx={{ fontWeight: "medium" }} + /> + + )}