新增设备多个数据源,并在设备多选时提供数据源切换功能

This commit is contained in:
JIANG
2025-11-06 16:21:59 +08:00
parent 2f7c82cf08
commit eebac8f8b0

View File

@@ -93,26 +93,28 @@ const fetchFromBackend = async (
const cleaningSCADAUrl = `${config.backendUrl}/querycleaningscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`; const cleaningSCADAUrl = `${config.backendUrl}/querycleaningscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`;
// 原始数据 // 原始数据
const rawSCADAUrl = `${config.backendUrl}/queryscadadatabydeviceidandtimerange/?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 { try {
const response = await fetch(cleaningSCADAUrl); let response;
response = await fetch(cleaningSCADAUrl);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const data = await response.json(); const data = await response.json();
const transformedData = transformBackendData(data, deviceIds); let transformedData = transformBackendData(data, deviceIds);
// 如果清洗数据接口返回空结果,使用原始数据接口 // 如果清洗数据接口返回空结果,使用原始数据接口
if (transformedData.length === 0) { if (transformedData.length === 0) {
console.log("[SCADADataPanel] 清洗数据接口无结果,使用原始数据接口"); console.log("[SCADADataPanel] 清洗数据接口无结果,使用原始数据接口");
const rawResponse = await fetch(rawSCADAUrl); response = await fetch(rawSCADAUrl);
if (!rawResponse.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${rawResponse.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const originData = await rawResponse.json(); const data = await response.json();
return transformBackendData(originData, deviceIds); transformedData = transformBackendData(data, deviceIds);
} }
return transformedData; return transformedData;
} catch (error) { } catch (error) {
console.error("[SCADADataPanel] 从后端获取数据失败:", error); console.error("[SCADADataPanel] 从后端获取数据失败:", error);
@@ -129,11 +131,7 @@ const transformBackendData = (
deviceIds: string[] deviceIds: string[]
): TimeSeriesPoint[] => { ): TimeSeriesPoint[] => {
// 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] } // 处理后端返回的对象格式: { deviceId: [{time: "...", value: ...}] }
if ( if (backendData && !Array.isArray(backendData)) {
backendData &&
typeof backendData === "object" &&
!Array.isArray(backendData)
) {
// 检查是否是设备ID为键的对象格式 // 检查是否是设备ID为键的对象格式
const hasDeviceKeys = deviceIds.some((id) => id in backendData); const hasDeviceKeys = deviceIds.some((id) => id in backendData);
@@ -174,32 +172,6 @@ const transformBackendData = (
return result; 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); console.warn("[SCADADataPanel] 未知的后端数据格式:", backendData);
return []; return [];
@@ -223,7 +195,8 @@ const ensureValidRange = (
const buildDataset = ( const buildDataset = (
points: TimeSeriesPoint[], points: TimeSeriesPoint[],
deviceIds: string[], deviceIds: string[],
fractionDigits: number fractionDigits: number,
showCleaning: boolean
) => { ) => {
return points.map((point) => { return points.map((point) => {
const entry: Record<string, any> = { const entry: Record<string, any> = {
@@ -231,15 +204,30 @@ const buildDataset = (
label: formatTimestamp(point.timestamp), label: formatTimestamp(point.timestamp),
}; };
deviceIds.forEach((id) => { if (showCleaning) {
const value = point.values[id]; deviceIds.forEach((id) => {
entry[id] = ["raw", "clean", "sim"].forEach((suffix) => {
typeof value === "number" const key = `${id}_${suffix}`;
? Number.isFinite(value) const value = point.values[key];
? parseFloat(value.toFixed(fractionDigits)) entry[key] =
: null typeof value === "number"
: value ?? null; ? 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; return entry;
}); });
@@ -271,6 +259,82 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const { open } = useNotification(); const { open } = useNotification();
const { data: user } = useGetIdentity<IUser>(); 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 [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
const [to, setTo] = useState<Dayjs>(() => dayjs()); const [to, setTo] = useState<Dayjs>(() => dayjs());
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab); const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
@@ -280,6 +344,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const [isExpanded, setIsExpanded] = useState<boolean>(true); const [isExpanded, setIsExpanded] = useState<boolean>(true);
const [deviceLabels, setDeviceLabels] = useState<Record<string, string>>({}); const [deviceLabels, setDeviceLabels] = useState<Record<string, string>>({});
const [isCleaning, setIsCleaning] = useState<boolean>(false); const [isCleaning, setIsCleaning] = useState<boolean>(false);
const [selectedSource, setSelectedSource] = useState<"raw" | "clean" | "sim">(
"raw"
);
// 获取 SCADA 设备信息,生成 deviceLabels // 获取 SCADA 设备信息,生成 deviceLabels
useEffect(() => { useEffect(() => {
@@ -321,8 +388,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const hasData = timeSeries.length > 0; const hasData = timeSeries.length > 0;
const dataset = useMemo( const dataset = useMemo(
() => buildDataset(timeSeries, deviceIds, fractionDigits), () => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
[timeSeries, deviceIds, fractionDigits] [timeSeries, deviceIds, fractionDigits, showCleaning]
); );
const handleFetch = useCallback( const handleFetch = useCallback(
@@ -339,7 +406,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
try { try {
const { from: rangeFrom, to: rangeTo } = normalizedRange; const { from: rangeFrom, to: rangeTo } = normalizedRange;
const result = await fetchTimeSeriesData(deviceIds, { const result = await customFetcher(deviceIds, {
from: rangeFrom.toDate(), from: rangeFrom.toDate(),
to: rangeTo.toDate(), to: rangeTo.toDate(),
}); });
@@ -350,7 +417,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
setLoadingState("error"); 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) => ({ const dynamic = (() => {
field: id, if (showCleaning) {
headerName: deviceLabels?.[id] ?? id, if (deviceIds.length === 1) {
minWidth: 140, return deviceIds.flatMap<GridColDef>((id) => [
flex: 1, {
valueFormatter: (value: any) => { field: `${id}_raw`,
if (value === null || value === undefined) return "--"; headerName: `${deviceLabels?.[id] ?? id} (原始)`,
if (Number.isFinite(Number(value))) { minWidth: 140,
return Number(value).toFixed(fractionDigits); 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]; return [...base, ...dynamic];
}, [deviceIds, deviceLabels, fractionDigits]); }, [deviceIds, deviceLabels, fractionDigits, showCleaning, selectedSource]);
const rows = useMemo( const rows = useMemo(
() => () =>
@@ -557,17 +686,71 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}, },
}, },
]} ]}
series={deviceIds.map((id, index) => ({ series={(() => {
dataKey: id, if (showCleaning) {
label: deviceLabels?.[id] ?? id, if (deviceIds.length === 1) {
showMark: dataset.length < 50, // 数据点少时显示标记 return deviceIds.flatMap((id, index) => [
curve: "catmullRom", // 使用平滑曲线 {
color: colors[index % colors.length], dataKey: `${id}_raw`,
valueFormatter: (value: number | null) => label: `${deviceLabels?.[id] ?? id} (原始)`,
value !== null ? value.toFixed(fractionDigits) : "--", showMark: dataset.length < 50,
area: false, curve: "catmullRom",
stack: undefined, 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 }} grid={{ vertical: true, horizontal: true }}
sx={{ sx={{
"& .MuiLineElement-root": { "& .MuiLineElement-root": {
@@ -664,8 +847,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
); );
}; };
const drawerWidth = 920;
return ( return (
<> <>
{/* 收起时的触发按钮 */} {/* 收起时的触发按钮 */}
@@ -704,7 +885,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
position: "absolute", position: "absolute",
top: 80, top: 80,
right: 16, right: 16,
height: "760px", height: showCleaning && deviceIds.length >= 2 ? "820px" : "760px",
borderRadius: "12px", borderRadius: "12px",
boxShadow: boxShadow:
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", "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> </Tooltip>
</Stack> </Stack>
</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> </Stack>
</LocalizationProvider> </LocalizationProvider>