feat(history): align units and history data

This commit is contained in:
2026-09-14 12:30:57 +08:00
parent 35b6819459
commit 08ddb4453d
21 changed files with 1849 additions and 3618 deletions
+142 -251
View File
@@ -41,6 +41,19 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { apiFetch } from "@/lib/apiFetch";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
import {
ElementHistoryResult,
ElementHistorySeries,
ElementHistoryTarget,
fetchElementHistory,
historySeriesKey,
TimeSeriesPoint,
toTimeSeriesPoints,
} from "@/lib/elementHistory";
import {
FLOW_DISPLAY_UNIT,
PRESSURE_DISPLAY_UNIT,
} from "@/utils/units";
dayjs.extend(utc);
dayjs.extend(timezone);
@@ -50,13 +63,6 @@ type IUser = {
name?: string;
};
export interface TimeSeriesPoint {
/** ISO8601 时间戳 */
timestamp: string;
/** 每个设备对应的值 */
values: Record<string, number | null | undefined>;
}
export interface SCADADataPanelProps {
/** 选中的设备 ID 列表 */
deviceIds: string[];
@@ -90,164 +96,75 @@ const panelHeaderActionSx = {
},
};
/**
* 从后端 API 获取 SCADA 数据
*/
interface ScadaDeviceMetadata {
device_id: string;
device_type: string;
node_id: string | null;
link_id: string | null;
}
/** 用设备元数据组装统一元素历史查询,一次返回监测与模拟数据。 */
const fetchFromBackend = async (
deviceIds: string[],
range: { from: Date; to: Date },
): Promise<TimeSeriesPoint[]> => {
): Promise<ElementHistoryResult> => {
if (deviceIds.length === 0) {
return [];
return { points: [], series: [] };
}
const device_ids = deviceIds.join(",");
const start_time = dayjs(range.from).toISOString();
const end_time = dayjs(range.to).toISOString();
// 清洗数据接口
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
// 原始数据
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
// 模拟数据接口
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
try {
// 优先查询清洗数据和模拟数据
const [cleaningRes, simulationRes] = await Promise.all([
apiFetch(cleaningDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(simulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
]);
const cleaningData = transformBackendData(cleaningRes, deviceIds);
const simulationData = transformBackendData(simulationRes, deviceIds);
// 如果清洗数据有数据,返回清洗和模拟数据
if (cleaningData.length > 0) {
return mergeTimeSeriesData(
cleaningData,
simulationData,
deviceIds,
"clean",
"sim",
);
} else {
// 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据
const rawRes = await apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null);
const rawData = transformBackendData(rawRes, deviceIds);
return mergeTimeSeriesData(
simulationData,
rawData,
deviceIds,
"sim",
"raw",
);
}
} 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 mergeTimeSeriesData = (
data1: TimeSeriesPoint[],
data2: TimeSeriesPoint[],
deviceIds: string[],
suffix1: string,
suffix2: string,
): TimeSeriesPoint[] => {
const timeMap = new Map<string, Record<string, number | null>>();
const processData = (data: TimeSeriesPoint[], suffix: string) => {
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
deviceIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
};
processData(data1, suffix1);
processData(data2, suffix2);
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(),
const metadataResponse = await apiFetch(
`${config.BACKEND_URL}/api/v1/scada-devices`,
);
if (!metadataResponse.ok) {
throw new Error(`SCADA 设备信息请求失败: HTTP ${metadataResponse.status}`);
}
const allDevices = (await metadataResponse.json()) as ScadaDeviceMetadata[];
const requested = new Set(deviceIds);
const devices = allDevices.filter((device) => requested.has(device.device_id));
const missing = deviceIds.filter(
(deviceId) => !devices.some((device) => device.device_id === deviceId),
);
if (missing.length > 0) {
throw new Error(`SCADA 设备不存在: ${missing.join(", ")}`);
}
return result;
const targetsByElement = new Map<string, ElementHistoryTarget>();
devices.forEach((device) => {
const isFlow = ["pipe_flow", "flow"].includes(
device.device_type.toLowerCase(),
);
const elementId = isFlow ? device.link_id : device.node_id;
const elementType = isFlow ? "pipe" : "junction";
if (!elementId) {
throw new Error(`SCADA 设备 ${device.device_id} 未关联管网元素`);
}
const key = `${elementType}:${elementId}`;
const target = targetsByElement.get(key) ?? {
element_id: elementId,
element_type: elementType,
device_ids: [],
};
target.device_ids!.push(device.device_id);
targetsByElement.set(key, target);
});
const targets = Array.from(targetsByElement.values());
const result = await fetchElementHistory(
targets,
range,
"realtime_comparison",
);
const expandedSeries = result.series.flatMap((series) => {
if (series.device_id || series.source.startsWith("scada_")) return [series];
const target = targets.find(
(item) =>
item.element_id === series.element_id &&
item.element_type === series.element_type,
);
return (target?.device_ids ?? []).map((deviceId) => ({
...series,
device_id: deviceId,
}));
});
return { series: expandedSeries, points: toTimeSeriesPoints(expandedSeries) };
};
const formatTimestamp = (timestamp: string) =>
@@ -337,83 +254,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const { open } = useNotification();
const { data: user } = useGetIdentity<IUser>();
const customFetcher = useMemo(() => {
if (!showCleaning) {
return fetchFromBackend;
}
return async (
deviceIds: string[],
range: { from: Date; to: Date },
): Promise<TimeSeriesPoint[]> => {
const device_ids = deviceIds.join(",");
const start_time = dayjs(range.from).toISOString();
const end_time = dayjs(range.to).toISOString();
// 清洗数据接口
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
// 原始数据
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
// 模拟数据接口
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
try {
const [cleanRes, rawRes, simRes] = await Promise.all([
apiFetch(cleaningDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(simulationDataUrl)
.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]);
const customFetcher = fetchFromBackend;
const [from, setFrom] = useState<Dayjs>(() => {
if (start_time) {
@@ -435,6 +276,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
});
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
const [error, setError] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState<boolean>(true);
@@ -473,11 +315,30 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
[timeSeries, deviceIds, fractionDigits, showCleaning],
);
const seriesByKey = useMemo(
() =>
new Map(historySeries.map((series) => [historySeriesKey(series), series])),
[historySeries],
);
const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
const hasPressureSeries = historySeries.some(
(item) => item.metric === "pressure",
);
const unitForKey = useCallback(
(key: string) => seriesByKey.get(key)?.display_unit ?? "",
[seriesByKey],
);
const axisForKey = useCallback(
(key: string) =>
seriesByKey.get(key)?.metric === "pressure" && hasFlowSeries ? 1 : 0,
[hasFlowSeries, seriesByKey],
);
const handleFetch = useCallback(
async (reason: string) => {
if (!hasDevices) {
setTimeSeries([]);
setHistorySeries([]);
setLoadingState("idle");
setError(null);
return;
@@ -491,7 +352,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
from: rangeFrom.toDate(),
to: rangeTo.toDate(),
});
setTimeSeries(result);
setTimeSeries(result.points);
setHistorySeries(result.series);
setLoadingState("success");
} catch (err) {
setError(err instanceof Error ? err.message : "未知错误");
@@ -583,6 +445,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
handleFetch("device-change");
} else {
setTimeSeries([]);
setHistorySeries([]);
}
}, [deviceIdsKey, handleFetch, hasDevices]);
@@ -610,7 +473,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
return deviceIds.flatMap<GridColDef>((id) => [
{
field: `${id}_raw`,
headerName: `${id} (原始)`,
headerName: `${id} (原始) [${unitForKey(`${id}_raw`)}]`,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
@@ -623,7 +486,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
},
{
field: `${id}_clean`,
headerName: `${id} (清洗)`,
headerName: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
@@ -636,7 +499,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
},
{
field: `${id}_sim`,
headerName: `${id} (模拟)`,
headerName: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
@@ -652,7 +515,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
// 单一数据源模式:只显示选中的数据源
return deviceIds.map<GridColDef>((id) => ({
field: `${id}_${selectedSource}`,
headerName: id,
headerName: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
@@ -688,7 +551,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
if (hasData) {
cols.push({
field: fieldKey,
headerName: `${deviceName} (${name})`,
headerName: `${deviceName} (${name}) [${unitForKey(fieldKey)}]`,
minWidth: 140,
flex: 1,
valueFormatter: (value: any) => {
@@ -708,7 +571,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
})();
return [...base, ...dynamic];
}, [deviceIds, fractionDigits, showCleaning, selectedSource, dataset]);
}, [
deviceIds,
fractionDigits,
showCleaning,
selectedSource,
dataset,
unitForKey,
]);
const rows = useMemo(
() =>
@@ -766,8 +636,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
if (selectedSource === "all") {
return deviceIds.flatMap((id, index) => [
{
name: `${id} (原始)`,
name: `${id} (原始) [${unitForKey(`${id}_raw`)}]`,
type: "line",
yAxisIndex: axisForKey(`${id}_raw`),
symbol: "none",
connectNulls: true,
sampling: "lttb",
@@ -775,8 +646,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
data: dataset.map((item) => item[`${id}_raw`]),
},
{
name: `${id} (清洗)`,
name: `${id} (清洗) [${unitForKey(`${id}_clean`)}]`,
type: "line",
yAxisIndex: axisForKey(`${id}_clean`),
symbol: "none",
connectNulls: true,
sampling: "lttb",
@@ -784,8 +656,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
data: dataset.map((item) => item[`${id}_clean`]),
},
{
name: `${id} (模拟)`,
name: `${id} (模拟) [${unitForKey(`${id}_sim`)}]`,
type: "line",
yAxisIndex: axisForKey(`${id}_sim`),
symbol: "none",
connectNulls: true,
sampling: "lttb",
@@ -795,8 +668,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
]);
} else {
return deviceIds.map((id, index) => ({
name: id,
name: `${id} [${unitForKey(`${id}_${selectedSource}`)}]`,
type: "line",
yAxisIndex: axisForKey(`${id}_${selectedSource}`),
symbol: "none",
connectNulls: true,
sampling: "lttb",
@@ -820,8 +694,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
: suffix === "clean"
? "清洗"
: "模拟"
})`,
}) [${unitForKey(key)}]`,
type: "line",
yAxisIndex: axisForKey(key),
symbol: "none",
connectNulls: true,
sampling: "lttb",
@@ -908,10 +783,26 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
boundaryGap: false,
data: xData,
},
yAxis: {
type: "value",
scale: true,
},
yAxis: [
...(hasFlowSeries
? [
{
type: "value",
scale: true,
name: `流量 (${FLOW_DISPLAY_UNIT})`,
},
]
: []),
...(hasPressureSeries
? [
{
type: "value",
scale: true,
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
},
]
: []),
],
dataZoom: [
{
type: "inside",