From 9c533c0911937086e02d60b4561a4ee0d446068f Mon Sep 17 00:00:00 2001 From: JIANG Date: Tue, 18 Nov 2025 11:58:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=A6=BB=E6=95=A3=E5=BD=A9?= =?UTF-8?q?=E8=99=B9=E9=A2=9C=E8=89=B2=E6=98=BE=E7=A4=BA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/OlMap/Controls/HistoryDataPanel.tsx | 913 ++++++++++++++++++++ src/app/OlMap/Controls/StyleEditorPanel.tsx | 160 ++-- src/app/OlMap/Controls/Toolbar.tsx | 4 - 3 files changed, 995 insertions(+), 82 deletions(-) create mode 100644 src/app/OlMap/Controls/HistoryDataPanel.tsx diff --git a/src/app/OlMap/Controls/HistoryDataPanel.tsx b/src/app/OlMap/Controls/HistoryDataPanel.tsx new file mode 100644 index 0000000..c923edd --- /dev/null +++ b/src/app/OlMap/Controls/HistoryDataPanel.tsx @@ -0,0 +1,913 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Box, + Button, + Chip, + CircularProgress, + Divider, + IconButton, + Stack, + Tab, + Tabs, + Tooltip, + Typography, + Drawer, + Slider, +} from "@mui/material"; +import { + Refresh, + ShowChart, + TableChart, + ChevronLeft, + ChevronRight, +} from "@mui/icons-material"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import { LineChart } from "@mui/x-charts"; +import "dayjs/locale/zh-cn"; // 引入中文包 +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 config, { NETWORK_NAME } from "@/config/config"; +import { GeoJSON } from "ol/format"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +type IUser = { + id: number; + name: string; +}; + +export interface TimeSeriesPoint { + /** ISO8601 时间戳 */ + timestamp: string; + /** 每个设备对应的值 */ + values: Record; +} + +export interface HistoryDataPanelProps { + /** 选中的设备 ID 列表(使用 `ids` 代替,组件不再接收 `deviceIds`) */ + /** 设备列表:每项包含 id 与可选的 type(例如 'scada' / 'simulation') */ + devices?: { id: string; type?: string }[]; + /** 可选:外部传入的开始时间(若传入会作为默认值) */ + starttime?: string | Date; + /** 可选:外部传入的结束时间(若传入会作为默认值) */ + endtime?: string | Date; + /** 可选:方案名(用于 scheme 类型的查询) */ + schemeName?: string; + /** 自定义数据获取器,默认使用后端 API */ + fetchTimeSeriesData?: ( + deviceIds: string[], + range: { from: Date; to: Date } + ) => Promise; + /** 可选:控制浮窗显示 */ + visible?: boolean; + /** 默认展示的选项卡 */ + defaultTab?: "chart" | "table"; + /** Y 轴数值的小数位数 */ + fractionDigits?: number; + // 清洗功能已移除,相关参数请通过外部面板/服务清洗后再传入 +} + +type PanelTab = "chart" | "table"; + +type LoadingState = "idle" | "loading" | "success" | "error"; + +/** + * 从后端 API 获取 SCADA 数据 + */ +const fetchFromBackend = async ( + deviceIds: string[], + range: { from: Date; to: Date } +): Promise => { + if (deviceIds.length === 0) { + return []; + } + + 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 rawSCADAUrl = `${config.BACKEND_URL}/queryscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`; + // 模拟数据接口(备用) + const simulationSCADAUrl = `${config.BACKEND_URL}/querysimulationscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`; + + try { + // 先使用原始数据接口 + let response = await fetch(rawSCADAUrl); + if (!response.ok) { + console.warn( + `[SCADADataPanel] 原始数据接口返回非 OK:${response.status}, 尝试模拟接口` + ); + // 尝试模拟接口 + response = await fetch(simulationSCADAUrl); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } + const data = await response.json(); + const transformedData = transformBackendData(data, deviceIds); + return transformedData; + } catch (error) { + console.error("[SCADADataPanel] 从后端获取数据失败:", error); + throw error; + } +}; + +/** + * 转换后端数据格式 + * 根据实际后端返回的数据结构进行调整 + */ +const transformBackendData = ( + backendData: any, + deviceIds: string[] +): TimeSeriesPoint[] => { + // 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] } + if (backendData && !Array.isArray(backendData)) { + // 检查是否是设备ID为键的对象格式 + const hasDeviceKeys = deviceIds.some((id) => id in backendData); + + 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; + } + } + // 默认返回空数组 + console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData); + return []; +}; + +const defaultFetcher = fetchFromBackend; + +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 = { + 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 HistoryDataPanel: React.FC = ({ + devices = [], + starttime, + endtime, + schemeName, + fetchTimeSeriesData = defaultFetcher, + visible = true, + defaultTab = "chart", + fractionDigits = 2, +}) => { + // 从 devices 中提取 id 列表用于渲染与查询 + const deviceIds = devices?.map((d) => d.id) ?? []; + + // 清洗功能已移除,直接使用传入的 fetcher + const customFetcher = fetchTimeSeriesData; + + const [from, setFrom] = useState(() => + starttime ? dayjs(starttime) : dayjs().subtract(1, "day") + ); + const [to, setTo] = useState(() => + endtime ? dayjs(endtime) : dayjs() + ); + const [activeTab, setActiveTab] = useState(defaultTab); + const [timeSeries, setTimeSeries] = useState([]); + const [loadingState, setLoadingState] = useState("idle"); + const [error, setError] = useState(null); + const [isExpanded, setIsExpanded] = useState(true); + const [deviceLabels, setDeviceLabels] = useState>({}); + // 清洗相关状态移除 + + // 滑块状态:用于图表缩放 + const [zoomRange, setZoomRange] = useState<[number, number]>([0, 100]); + + // 获取 SCADA 设备信息,生成 deviceLabels + useEffect(() => { + const fetchDeviceLabels = async () => { + try { + const url = `${config.MAP_URL}/${config.MAP_WORKSPACE}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${config.MAP_WORKSPACE}: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); + }, [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 filteredDataset = useMemo(() => { + if (dataset.length === 0) return dataset; + + const startIndex = Math.floor((zoomRange[0] / 100) * dataset.length); + const endIndex = Math.ceil((zoomRange[1] / 100) * dataset.length); + + return dataset.slice(startIndex, endIndex); + }, [dataset, zoomRange]); + + // 重置滑块范围当数据变化时 + useEffect(() => { + setZoomRange([0, 100]); + }, [timeSeries]); + + 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 customFetcher(deviceIds, { + from: rangeFrom.toDate(), + to: rangeTo.toDate(), + }); + setTimeSeries(result); + setLoadingState("success"); + } catch (err) { + setError(err instanceof Error ? err.message : "未知错误"); + setLoadingState("error"); + } + }, + [deviceIds, customFetcher, hasDevices, normalizedRange] + ); + + // 设备变化时自动查询 + useEffect(() => { + if (hasDevices) { + handleFetch("device-change"); + } else { + setTimeSeries([]); + } + }, [deviceIds.join(",")]); + + const columns: GridColDef[] = useMemo(() => { + const base: GridColDef[] = [ + { + field: "label", + headerName: "时间", + minWidth: 180, + flex: 1, + }, + ]; + + const dynamic = deviceIds.map((id) => ({ + field: id, + headerName: deviceLabels?.[id] ?? id, + minWidth: 140, + flex: 1, + valueFormatter: (value: any) => { + if (value === null || value === undefined) return "--"; + if (Number.isFinite(Number(value))) { + return Number(value).toFixed(fractionDigits); + } + return 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 ( + + + + {message.title} + + + {message.subtitle} + + + ); + }; + + const renderChart = () => { + if (!hasData) return renderEmpty(); + + // 为每个设备生成独特的颜色和样式 + const colors = [ + "#1976d2", // 蓝色 + "#dc004e", // 粉红色 + "#ff9800", // 橙色 + "#4caf50", // 绿色 + "#9c27b0", // 紫色 + "#00bcd4", // 青色 + "#f44336", // 红色 + "#8bc34a", // 浅绿色 + "#ff5722", // 深橙色 + "#3f51b5", // 靛蓝色 + ]; + + // 获取当前显示范围的时间边界 + const getTimeRangeLabel = () => { + if (filteredDataset.length === 0) return ""; + const firstTime = filteredDataset[0].time; + const lastTime = filteredDataset[filteredDataset.length - 1].time; + if (firstTime instanceof Date && lastTime instanceof Date) { + return `${dayjs(firstTime).format("MM-DD HH:mm")} ~ ${dayjs( + lastTime + ).format("MM-DD HH:mm")}`; + } + return ""; + }; + + return ( + + + + 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: "#d0d0d0", + strokeWidth: 0.8, + strokeDasharray: "4 4", + }, + }} + slotProps={{ + legend: { + direction: "row", + position: { horizontal: "middle", vertical: "bottom" }, + padding: { bottom: 2, 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", + }} + /> + + + {/* 时间范围滑块 */} + + + + 时间范围 + + + setZoomRange(newValue as [number, number]) + } + valueLabelDisplay="auto" + valueLabelFormat={(value) => { + const index = Math.floor((value / 100) * dataset.length); + if (dataset[index] && dataset[index].time instanceof Date) { + return dayjs(dataset[index].time).format("MM-DD HH:mm"); + } + return `${value}%`; + }} + marks={[ + { + value: 0, + label: + dataset.length > 0 && dataset[0].time instanceof Date + ? dayjs(dataset[0].time).format("MM-DD HH:mm") + : "起始", + }, + { + value: 100, + label: + dataset.length > 0 && + dataset[dataset.length - 1].time instanceof Date + ? dayjs(dataset[dataset.length - 1].time).format( + "MM-DD HH:mm" + ) + : "结束", + }, + ]} + sx={{ + flex: 1, + "& .MuiSlider-thumb": { + width: 16, + height: 16, + }, + "& .MuiSlider-markLabel": { + fontSize: "0.7rem", + color: "text.secondary", + }, + }} + /> + + + {getTimeRangeLabel() && ( + + 当前显示: {getTimeRangeLabel()} (共 {filteredDataset.length}{" "} + 个数据点) + + )} + + + ); + }; + + const renderTable = () => { + if (!hasData) return renderEmpty(); + + console.debug("[SCADADataPanel] 表格数据:", { + rowsCount: rows.length, + columnsCount: columns.length, + sampleRow: rows[0], + columns: columns.map((c) => c.field), + }); + + return ( + + ); + }; + + return ( + <> + {/* 收起时的触发按钮 */} + {!isExpanded && hasDevices && ( + setIsExpanded(true)} + sx={{ zIndex: 1300 }} + > + + + + 历史数据 + + + + + )} + + {/* 主面板 */} + + + {/* Header */} + + + + + + SCADA 历史数据 + + + + + + setIsExpanded(false)} + sx={{ color: "primary.contrastText" }} + > + + + + + + + + {/* Controls */} + + + + + + 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" }, + }} + /> + + 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" }, + }} + /> + + + setActiveTab(value)} + variant="fullWidth" + > + } + iconPosition="start" + label="曲线" + /> + } + iconPosition="start" + label="表格" + /> + + + + + + + + + + {/* 清洗功能已移除,数据源选择请通过后端或外部参数控制 */} + + + + {!hasDevices && ( + + 未选择任何设备,无法获取数据。 + + )} + {error && ( + + 获取数据失败:{error} + + )} + + + + + {/* Content */} + + {loadingState === "loading" && ( + + + + )} + + {activeTab === "chart" ? renderChart() : renderTable()} + + + + + ); +}; + +export default HistoryDataPanel; diff --git a/src/app/OlMap/Controls/StyleEditorPanel.tsx b/src/app/OlMap/Controls/StyleEditorPanel.tsx index 4483bde..fb9e1f0 100644 --- a/src/app/OlMap/Controls/StyleEditorPanel.tsx +++ b/src/app/OlMap/Controls/StyleEditorPanel.tsx @@ -233,21 +233,11 @@ const StyleEditorPanel: React.FC = ({ (segments: number): string[] => { const baseColors = RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors; - - if (segments <= baseColors.length) { - // 如果分段数小于等于基础颜色数,均匀选取 - const step = baseColors.length / segments; - return Array.from( - { length: segments }, - (_, i) => baseColors[Math.floor(i * step)] - ); - } else { - // 如果分段数大于基础颜色数,重复使用 - return Array.from( - { length: segments }, - (_, i) => baseColors[i % baseColors.length] - ); - } + // 严格按顺序返回 N 个颜色 + return Array.from( + { length: segments }, + (_, i) => baseColors[i % baseColors.length] + ); }, [styleConfig.rainbowPaletteIndex] ); @@ -954,6 +944,25 @@ const StyleEditorPanel: React.FC = ({ } > {GRADIENT_PALETTES.map((p, idx) => { + const numColors = styleConfig.segments + 1; + const previewColors = Array.from( + { length: numColors }, + (_, i) => { + const ratio = numColors > 1 ? i / (numColors - 1) : 1; + const startColor = parseColor(p.start); + const endColor = parseColor(p.end); + const r = Math.round( + startColor.r + (endColor.r - startColor.r) * ratio + ); + const g = Math.round( + startColor.g + (endColor.g - startColor.g) * ratio + ); + const b = Math.round( + startColor.b + (endColor.b - startColor.b) * ratio + ); + return `rgba(${r}, ${g}, ${b}, 1)`; + } + ); return ( = ({ width: "80%", height: 16, borderRadius: 2, - background: `linear-gradient(90deg, ${p.start}, ${p.end})`, + display: "flex", + overflow: "hidden", marginRight: 1, border: "1px solid #ccc", }} - /> + > + {previewColors.map((color, colorIdx) => ( + + ))} + ); @@ -997,23 +1017,13 @@ const StyleEditorPanel: React.FC = ({ } > {RAINBOW_PALETTES.map((p, idx) => { - // 根据当前分段数生成该方案的预览颜色 + // 根据当前分段数+1生成该方案的预览颜色 const baseColors = p.colors; - const segments = styleConfig.segments; - let previewColors: string[]; - - if (segments <= baseColors.length) { - const step = baseColors.length / segments; - previewColors = Array.from( - { length: segments }, - (_, i) => baseColors[Math.floor(i * step)] - ); - } else { - previewColors = Array.from( - { length: segments }, - (_, i) => baseColors[i % baseColors.length] - ); - } + const numColors = styleConfig.segments + 1; + const previewColors = Array.from( + { length: numColors }, + (_, i) => baseColors[i % baseColors.length] + ); return ( @@ -1356,54 +1366,48 @@ const StyleEditorPanel: React.FC = ({ {styleConfig.classificationMethod === "custom_breaks" && ( - 手动设置区间阈值(按升序填写,最小值 >= 0) + 手动设置区间阈值(按升序填写,最小值 {">="} 0) - {Array.from({ length: styleConfig.segments + 1 }).map( - (_, idx) => ( - { - const v = parseFloat(e.target.value); - setStyleConfig((prev) => { - const prevBreaks = prev.customBreaks - ? [...prev.customBreaks] - : []; - // 保证长度 - while (prevBreaks.length < styleConfig.segments + 1) - prevBreaks.push(0); - prevBreaks[idx] = isNaN(v) ? 0 : Math.max(0, v); - return { ...prev, customBreaks: prevBreaks }; - }); - }} - onBlur={() => { - // on blur 保证升序 - setStyleConfig((prev) => { - const prevBreaks = (prev.customBreaks || []).slice( - 0, - styleConfig.segments + 1 - ); - prevBreaks.sort((a, b) => a - b); - return { ...prev, customBreaks: prevBreaks }; - }); - }} - /> - ) - )} + {Array.from({ length: styleConfig.segments }).map((_, idx) => ( + { + const v = parseFloat(e.target.value); + setStyleConfig((prev) => { + const prevBreaks = prev.customBreaks + ? [...prev.customBreaks] + : []; + // 保证长度 + while (prevBreaks.length < styleConfig.segments + 1) + prevBreaks.push(0); + prevBreaks[idx] = isNaN(v) ? 0 : Math.max(0, v); + return { ...prev, customBreaks: prevBreaks }; + }); + }} + onBlur={() => { + // on blur 保证升序 + setStyleConfig((prev) => { + const prevBreaks = (prev.customBreaks || []).slice( + 0, + styleConfig.segments + 1 + ); + prevBreaks.sort((a, b) => a - b); + return { ...prev, customBreaks: prevBreaks }; + }); + }} + /> + ))} - - 注: 阈值数量由分类数量决定 (segments + 1)。例如 segments=5 将显示 - 6 个阈值。 - )} {/* 颜色方案 */} diff --git a/src/app/OlMap/Controls/Toolbar.tsx b/src/app/OlMap/Controls/Toolbar.tsx index 25c0a66..a4860dd 100644 --- a/src/app/OlMap/Controls/Toolbar.tsx +++ b/src/app/OlMap/Controls/Toolbar.tsx @@ -116,10 +116,6 @@ const Toolbar: React.FC = ({ hiddenButtons, queryType }) => { layerName: state.layerName, layerId: state.layerId, })); - useEffect(() => { - console.log(layerStyleStates); - console.log("Active Legends:", activeLegendConfigs); - }, [layerStyleStates]); // 创建高亮图层 useEffect(() => { if (!map) return;