新增设备多个数据源,并在设备多选时提供数据源切换功能
This commit is contained in:
@@ -93,26 +93,28 @@ const fetchFromBackend = async (
|
||||
const cleaningSCADAUrl = `${config.backendUrl}/querycleaningscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
|
||||
// 原始数据
|
||||
const rawSCADAUrl = `${config.backendUrl}/queryscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
|
||||
// 模拟数据接口
|
||||
const simulationSCADAUrl = `${config.backendUrl}/querysimulationscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(cleaningSCADAUrl);
|
||||
let response;
|
||||
response = await fetch(cleaningSCADAUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const transformedData = transformBackendData(data, deviceIds);
|
||||
let transformedData = transformBackendData(data, deviceIds);
|
||||
|
||||
// 如果清洗数据接口返回空结果,使用原始数据接口
|
||||
if (transformedData.length === 0) {
|
||||
console.log("[SCADADataPanel] 清洗数据接口无结果,使用原始数据接口");
|
||||
const rawResponse = await fetch(rawSCADAUrl);
|
||||
if (!rawResponse.ok) {
|
||||
throw new Error(`HTTP error! status: ${rawResponse.status}`);
|
||||
response = await fetch(rawSCADAUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const originData = await rawResponse.json();
|
||||
return transformBackendData(originData, deviceIds);
|
||||
const data = await response.json();
|
||||
transformedData = transformBackendData(data, deviceIds);
|
||||
}
|
||||
|
||||
return transformedData;
|
||||
} catch (error) {
|
||||
console.error("[SCADADataPanel] 从后端获取数据失败:", error);
|
||||
@@ -129,11 +131,7 @@ const transformBackendData = (
|
||||
deviceIds: string[]
|
||||
): TimeSeriesPoint[] => {
|
||||
// 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] }
|
||||
if (
|
||||
backendData &&
|
||||
typeof backendData === "object" &&
|
||||
!Array.isArray(backendData)
|
||||
) {
|
||||
if (backendData && !Array.isArray(backendData)) {
|
||||
// 检查是否是设备ID为键的对象格式
|
||||
const hasDeviceKeys = deviceIds.some((id) => id in backendData);
|
||||
|
||||
@@ -174,32 +172,6 @@ const transformBackendData = (
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果后端返回的是数组格式,每个元素包含时间戳和各设备值
|
||||
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 [];
|
||||
@@ -223,7 +195,8 @@ const ensureValidRange = (
|
||||
const buildDataset = (
|
||||
points: TimeSeriesPoint[],
|
||||
deviceIds: string[],
|
||||
fractionDigits: number
|
||||
fractionDigits: number,
|
||||
showCleaning: boolean
|
||||
) => {
|
||||
return points.map((point) => {
|
||||
const entry: Record<string, any> = {
|
||||
@@ -231,15 +204,30 @@ const buildDataset = (
|
||||
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;
|
||||
});
|
||||
if (showCleaning) {
|
||||
deviceIds.forEach((id) => {
|
||||
["raw", "clean", "sim"].forEach((suffix) => {
|
||||
const key = `${id}_${suffix}`;
|
||||
const value = point.values[key];
|
||||
entry[key] =
|
||||
typeof value === "number"
|
||||
? Number.isFinite(value)
|
||||
? parseFloat(value.toFixed(fractionDigits))
|
||||
: null
|
||||
: value ?? null;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
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;
|
||||
});
|
||||
@@ -271,6 +259,82 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const { open } = useNotification();
|
||||
const { data: user } = useGetIdentity<IUser>();
|
||||
|
||||
const customFetcher = useMemo(() => {
|
||||
if (!showCleaning) {
|
||||
return fetchTimeSeriesData;
|
||||
}
|
||||
|
||||
return async (
|
||||
deviceIds: string[],
|
||||
range: { from: Date; to: Date }
|
||||
): Promise<TimeSeriesPoint[]> => {
|
||||
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 cleaningUrl = `${config.backendUrl}/querycleaningscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
|
||||
const rawUrl = `${config.backendUrl}/queryscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
|
||||
const simUrl = `${config.backendUrl}/querysimulationscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
|
||||
|
||||
try {
|
||||
const [cleanRes, rawRes, simRes] = await Promise.all([
|
||||
fetch(cleaningUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
fetch(rawUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
fetch(simUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
const timeMap = new Map<string, Record<string, number | null>>();
|
||||
|
||||
const processData = (data: any, suffix: string) => {
|
||||
if (!data) return;
|
||||
deviceIds.forEach((deviceId) => {
|
||||
const deviceData = data[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}_${suffix}`] =
|
||||
typeof item.value === "number" ? item.value : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
processData(cleanRes, "clean");
|
||||
processData(rawRes, "raw");
|
||||
processData(simRes, "sim");
|
||||
|
||||
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;
|
||||
} catch (error) {
|
||||
console.error("[SCADADataPanel] 获取三种数据失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}, [showCleaning, fetchTimeSeriesData]);
|
||||
|
||||
const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
|
||||
const [to, setTo] = useState<Dayjs>(() => dayjs());
|
||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||
@@ -280,6 +344,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||
const [deviceLabels, setDeviceLabels] = useState<Record<string, string>>({});
|
||||
const [isCleaning, setIsCleaning] = useState<boolean>(false);
|
||||
const [selectedSource, setSelectedSource] = useState<"raw" | "clean" | "sim">(
|
||||
"raw"
|
||||
);
|
||||
|
||||
// 获取 SCADA 设备信息,生成 deviceLabels
|
||||
useEffect(() => {
|
||||
@@ -321,8 +388,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const hasData = timeSeries.length > 0;
|
||||
|
||||
const dataset = useMemo(
|
||||
() => buildDataset(timeSeries, deviceIds, fractionDigits),
|
||||
[timeSeries, deviceIds, fractionDigits]
|
||||
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
|
||||
[timeSeries, deviceIds, fractionDigits, showCleaning]
|
||||
);
|
||||
|
||||
const handleFetch = useCallback(
|
||||
@@ -339,7 +406,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
|
||||
try {
|
||||
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
||||
const result = await fetchTimeSeriesData(deviceIds, {
|
||||
const result = await customFetcher(deviceIds, {
|
||||
from: rangeFrom.toDate(),
|
||||
to: rangeTo.toDate(),
|
||||
});
|
||||
@@ -350,7 +417,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
setLoadingState("error");
|
||||
}
|
||||
},
|
||||
[deviceIds, fetchTimeSeriesData, hasDevices, normalizedRange]
|
||||
[deviceIds, customFetcher, hasDevices, normalizedRange]
|
||||
);
|
||||
|
||||
// 处理数据清洗
|
||||
@@ -450,22 +517,84 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
},
|
||||
];
|
||||
|
||||
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);
|
||||
const dynamic = (() => {
|
||||
if (showCleaning) {
|
||||
if (deviceIds.length === 1) {
|
||||
return deviceIds.flatMap<GridColDef>((id) => [
|
||||
{
|
||||
field: `${id}_raw`,
|
||||
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);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: `${id}_clean`,
|
||||
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);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: `${id}_sim`,
|
||||
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);
|
||||
},
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
return deviceIds.map<GridColDef>((id) => ({
|
||||
field: `${id}_${selectedSource}`,
|
||||
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 String(value);
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
return 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]);
|
||||
}, [deviceIds, deviceLabels, fractionDigits, showCleaning, selectedSource]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
@@ -557,17 +686,71 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
},
|
||||
},
|
||||
]}
|
||||
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,
|
||||
}))}
|
||||
series={(() => {
|
||||
if (showCleaning) {
|
||||
if (deviceIds.length === 1) {
|
||||
return deviceIds.flatMap((id, index) => [
|
||||
{
|
||||
dataKey: `${id}_raw`,
|
||||
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,
|
||||
},
|
||||
{
|
||||
dataKey: `${id}_clean`,
|
||||
label: `${deviceLabels?.[id] ?? id} (清洗)`,
|
||||
showMark: dataset.length < 50,
|
||||
curve: "catmullRom",
|
||||
color: colors[(index + 3) % colors.length],
|
||||
valueFormatter: (value: number | null) =>
|
||||
value !== null ? value.toFixed(fractionDigits) : "--",
|
||||
area: false,
|
||||
stack: undefined,
|
||||
},
|
||||
{
|
||||
dataKey: `${id}_sim`,
|
||||
label: `${deviceLabels?.[id] ?? id} (模拟)`,
|
||||
showMark: dataset.length < 50,
|
||||
curve: "catmullRom",
|
||||
color: colors[(index + 6) % colors.length],
|
||||
valueFormatter: (value: number | null) =>
|
||||
value !== null ? value.toFixed(fractionDigits) : "--",
|
||||
area: false,
|
||||
stack: undefined,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
return deviceIds.map((id, index) => ({
|
||||
dataKey: `${id}_${selectedSource}`,
|
||||
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,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
return 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": {
|
||||
@@ -664,8 +847,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const drawerWidth = 920;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 收起时的触发按钮 */}
|
||||
@@ -704,7 +885,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
position: "absolute",
|
||||
top: 80,
|
||||
right: 16,
|
||||
height: "760px",
|
||||
height: showCleaning && deviceIds.length >= 2 ? "820px" : "760px",
|
||||
borderRadius: "12px",
|
||||
boxShadow:
|
||||
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
|
||||
@@ -868,6 +1049,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{showCleaning && deviceIds.length >= 2 && (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
数据源:
|
||||
</Typography>
|
||||
<Tabs
|
||||
value={selectedSource}
|
||||
onChange={(_, value: "raw" | "clean" | "sim") =>
|
||||
setSelectedSource(value)
|
||||
}
|
||||
>
|
||||
<Tab value="clean" label="清洗" />
|
||||
<Tab value="raw" label="原始" />
|
||||
<Tab value="sim" label="模拟" />
|
||||
</Tabs>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</LocalizationProvider>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user