修复离散彩虹颜色显示问题
This commit is contained in:
913
src/app/OlMap/Controls/HistoryDataPanel.tsx
Normal file
913
src/app/OlMap/Controls/HistoryDataPanel.tsx
Normal file
@@ -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<string, number | null | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TimeSeriesPoint[]>;
|
||||||
|
/** 可选:控制浮窗显示 */
|
||||||
|
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<TimeSeriesPoint[]> => {
|
||||||
|
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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 默认返回空数组
|
||||||
|
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<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 HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({
|
||||||
|
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<Dayjs>(() =>
|
||||||
|
starttime ? dayjs(starttime) : dayjs().subtract(1, "day")
|
||||||
|
);
|
||||||
|
const [to, setTo] = useState<Dayjs>(() =>
|
||||||
|
endtime ? dayjs(endtime) : 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);
|
||||||
|
const [deviceLabels, setDeviceLabels] = useState<Record<string, string>>({});
|
||||||
|
// 清洗相关状态移除
|
||||||
|
|
||||||
|
// 滑块状态:用于图表缩放
|
||||||
|
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<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(() => {
|
||||||
|
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<GridColDef>((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 (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
py: 8,
|
||||||
|
color: "text.secondary",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
|
||||||
|
{message.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{message.subtitle}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<LineChart
|
||||||
|
dataset={filteredDataset}
|
||||||
|
height={480}
|
||||||
|
margin={{ left: 70, right: 40, top: 30, bottom: 90 }}
|
||||||
|
xAxis={[
|
||||||
|
{
|
||||||
|
dataKey: "time",
|
||||||
|
scaleType: "time",
|
||||||
|
valueFormatter: (value) =>
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 时间范围滑块 */}
|
||||||
|
<Box sx={{ px: 3, pb: 2, pt: 1 }}>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ minWidth: 60, color: "text.secondary", fontSize: "0.8rem" }}
|
||||||
|
>
|
||||||
|
时间范围
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={zoomRange}
|
||||||
|
onChange={(_, newValue) =>
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => setZoomRange([0, 100])}
|
||||||
|
sx={{ minWidth: 60, fontSize: "0.75rem" }}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
{getTimeRangeLabel() && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: "primary.main",
|
||||||
|
display: "block",
|
||||||
|
textAlign: "center",
|
||||||
|
mt: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
当前显示: {getTimeRangeLabel()} (共 {filteredDataset.length}{" "}
|
||||||
|
个数据点)
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
columnBufferPx={100}
|
||||||
|
initialState={{
|
||||||
|
pagination: {
|
||||||
|
paginationModel: { pageSize: 50, page: 0 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
pageSizeOptions={[25, 50, 100]}
|
||||||
|
sx={{
|
||||||
|
border: "none",
|
||||||
|
height: "100%",
|
||||||
|
"& .MuiDataGrid-cell": {
|
||||||
|
borderColor: "#f0f0f0",
|
||||||
|
},
|
||||||
|
"& .MuiDataGrid-columnHeaders": {
|
||||||
|
backgroundColor: "#fafafa",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
},
|
||||||
|
"& .MuiDataGrid-row:hover": {
|
||||||
|
backgroundColor: "#f5f5f5",
|
||||||
|
},
|
||||||
|
"& .MuiDataGrid-columnHeaderTitle": {
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 收起时的触发按钮 */}
|
||||||
|
{!isExpanded && hasDevices && (
|
||||||
|
<Box
|
||||||
|
className="absolute top-20 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100"
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
sx={{ zIndex: 1300 }}
|
||||||
|
>
|
||||||
|
<Box className="flex flex-col items-center py-3 px-3 gap-1">
|
||||||
|
<ShowChart className="text-[#257DD4] w-5 h-5" />
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
className="text-gray-700 font-semibold my-1 text-xs"
|
||||||
|
style={{ writingMode: "vertical-rl" }}
|
||||||
|
>
|
||||||
|
历史数据
|
||||||
|
</Typography>
|
||||||
|
<ChevronLeft className="text-gray-600 w-4 h-4" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主面板 */}
|
||||||
|
<Drawer
|
||||||
|
anchor="right"
|
||||||
|
open={isExpanded && visible}
|
||||||
|
variant="persistent"
|
||||||
|
hideBackdrop
|
||||||
|
sx={{
|
||||||
|
width: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
"& .MuiDrawer-paper": {
|
||||||
|
width: "min(920px, calc(100vw - 2rem))",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
position: "absolute",
|
||||||
|
top: 80,
|
||||||
|
right: 16,
|
||||||
|
height: "860px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
boxShadow:
|
||||||
|
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
|
opacity: 0.95,
|
||||||
|
transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out",
|
||||||
|
border: "none",
|
||||||
|
"&:hover": {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box className="flex flex-col h-full bg-white rounded-xl overflow-hidden">
|
||||||
|
{/* 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(false)}
|
||||||
|
sx={{ color: "primary.contrastText" }}
|
||||||
|
>
|
||||||
|
<ChevronRight fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<Box sx={{ p: 2, backgroundColor: "grey.50" }}>
|
||||||
|
<LocalizationProvider
|
||||||
|
dateAdapter={AdapterDayjs}
|
||||||
|
adapterLocale="zh-cn"
|
||||||
|
>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<DateTimePicker
|
||||||
|
label="开始时间"
|
||||||
|
value={from}
|
||||||
|
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" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DateTimePicker
|
||||||
|
label="结束时间"
|
||||||
|
value={to}
|
||||||
|
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" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Tooltip title="刷新数据">
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<Refresh fontSize="small" />}
|
||||||
|
disabled={!hasDevices || loadingState === "loading"}
|
||||||
|
onClick={() => handleFetch("manual")}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</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" ? renderChart() : renderTable()}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HistoryDataPanel;
|
||||||
@@ -233,21 +233,11 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
(segments: number): string[] => {
|
(segments: number): string[] => {
|
||||||
const baseColors =
|
const baseColors =
|
||||||
RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
|
RAINBOW_PALETTES[styleConfig.rainbowPaletteIndex].colors;
|
||||||
|
// 严格按顺序返回 N 个颜色
|
||||||
if (segments <= baseColors.length) {
|
|
||||||
// 如果分段数小于等于基础颜色数,均匀选取
|
|
||||||
const step = baseColors.length / segments;
|
|
||||||
return Array.from(
|
|
||||||
{ length: segments },
|
|
||||||
(_, i) => baseColors[Math.floor(i * step)]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 如果分段数大于基础颜色数,重复使用
|
|
||||||
return Array.from(
|
return Array.from(
|
||||||
{ length: segments },
|
{ length: segments },
|
||||||
(_, i) => baseColors[i % baseColors.length]
|
(_, i) => baseColors[i % baseColors.length]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[styleConfig.rainbowPaletteIndex]
|
[styleConfig.rainbowPaletteIndex]
|
||||||
);
|
);
|
||||||
@@ -954,6 +944,25 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{GRADIENT_PALETTES.map((p, idx) => {
|
{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 (
|
return (
|
||||||
<MenuItem key={idx} value={idx}>
|
<MenuItem key={idx} value={idx}>
|
||||||
<Box
|
<Box
|
||||||
@@ -965,11 +974,22 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
width: "80%",
|
width: "80%",
|
||||||
height: 16,
|
height: 16,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
background: `linear-gradient(90deg, ${p.start}, ${p.end})`,
|
display: "flex",
|
||||||
|
overflow: "hidden",
|
||||||
marginRight: 1,
|
marginRight: 1,
|
||||||
border: "1px solid #ccc",
|
border: "1px solid #ccc",
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
{previewColors.map((color, colorIdx) => (
|
||||||
|
<Box
|
||||||
|
key={colorIdx}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
);
|
);
|
||||||
@@ -997,23 +1017,13 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{RAINBOW_PALETTES.map((p, idx) => {
|
{RAINBOW_PALETTES.map((p, idx) => {
|
||||||
// 根据当前分段数生成该方案的预览颜色
|
// 根据当前分段数+1生成该方案的预览颜色
|
||||||
const baseColors = p.colors;
|
const baseColors = p.colors;
|
||||||
const segments = styleConfig.segments;
|
const numColors = styleConfig.segments + 1;
|
||||||
let previewColors: string[];
|
const previewColors = Array.from(
|
||||||
|
{ length: numColors },
|
||||||
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]
|
(_, i) => baseColors[i % baseColors.length]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem key={idx} value={idx}>
|
<MenuItem key={idx} value={idx}>
|
||||||
@@ -1356,11 +1366,10 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
{styleConfig.classificationMethod === "custom_breaks" && (
|
{styleConfig.classificationMethod === "custom_breaks" && (
|
||||||
<Box className="mt-3 p-2 bg-gray-50 rounded">
|
<Box className="mt-3 p-2 bg-gray-50 rounded">
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
手动设置区间阈值(按升序填写,最小值 >= 0)
|
手动设置区间阈值(按升序填写,最小值 {">="} 0)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box className="flex flex-col gap-2">
|
<Box className="flex flex-col gap-2">
|
||||||
{Array.from({ length: styleConfig.segments + 1 }).map(
|
{Array.from({ length: styleConfig.segments }).map((_, idx) => (
|
||||||
(_, idx) => (
|
|
||||||
<TextField
|
<TextField
|
||||||
key={idx}
|
key={idx}
|
||||||
label={`阈值 ${idx + 1}`}
|
label={`阈值 ${idx + 1}`}
|
||||||
@@ -1397,13 +1406,8 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" color="textSecondary">
|
|
||||||
注: 阈值数量由分类数量决定 (segments + 1)。例如 segments=5 将显示
|
|
||||||
6 个阈值。
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{/* 颜色方案 */}
|
{/* 颜色方案 */}
|
||||||
|
|||||||
@@ -116,10 +116,6 @@ const Toolbar: React.FC<ToolbarProps> = ({ hiddenButtons, queryType }) => {
|
|||||||
layerName: state.layerName,
|
layerName: state.layerName,
|
||||||
layerId: state.layerId,
|
layerId: state.layerId,
|
||||||
}));
|
}));
|
||||||
useEffect(() => {
|
|
||||||
console.log(layerStyleStates);
|
|
||||||
console.log("Active Legends:", activeLegendConfigs);
|
|
||||||
}, [layerStyleStates]);
|
|
||||||
// 创建高亮图层
|
// 创建高亮图层
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user