Explorar el Código

🐛 fix(设备监控): 修正悬重悬轻显示及曲线时间对齐

- 根据 td 属性列表判断是否合并,无悬轻时仅显示悬重
- 保留有效单侧数据,修复拆包及历史时间错开导致的曲线缺失
- 统一所有曲线为秒级坐标,修复同秒数据 tooltip 分离
- 单点显示标记,增加 MQTT 返回数据打印
- 同步监控看板与详情页处理逻辑
Zimo hace 3 días
padre
commit
04becaa0f3

+ 77 - 40
src/views/oli-connection/monitoring-board/chart.vue

@@ -175,7 +175,14 @@ function getPointTimestamp(value: unknown) {
   return Number.isFinite(parsedValue) ? parsedValue : Date.now()
 }
 
-const handleMessageUpdate = (_topic: string, data: any) => {
+const handleMessageUpdate = (topic: string, data: any) => {
+  console.log('[监控看板 MQTT 返回数据]', {
+    deviceCode: props.deviceCode,
+    topic,
+    receivedAt: dayjs().format('YYYY-MM-DD HH:mm:ss.SSS'),
+    data
+  })
+
   const list = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : []
   const valueMap = new Map<string, number>()
   const pointMap = new Map<string, ChartPoint>()
@@ -200,7 +207,9 @@ const handleMessageUpdate = (_topic: string, data: any) => {
 
     valueMap.set(identity, value)
     const point = {
-      ts: getPointTimestamp(item.remark || item.readTime || item.ts || item.time),
+      ts: normalizeChartTimestamp(
+        getPointTimestamp(item.remark || item.readTime || item.ts || item.time)
+      ),
       value
     }
     pointMap.set(identity, point)
@@ -238,7 +247,7 @@ const handleMessageUpdate = (_topic: string, data: any) => {
         updatedNames.add(modelName)
       }
     } else {
-      // 组合尚未完整时,不让单侧原始值进入当前值和告警判断,保留上一条完整组合结果。
+      // 当前消息没有有效的悬重或悬轻数据时,不更新组合值。
       valueMap.delete(SUSPENSION_WEIGHT_IDENTIFIER)
       valueMap.delete(SUSPENSION_LIGHT_IDENTIFIER)
       pointMap.delete(SUSPENSION_WEIGHT_IDENTIFIER)
@@ -287,19 +296,23 @@ watch(isConnected, (newVal) => {
 async function loadDimensions() {
   if (!props.id) return
   try {
-    const dimensionList =
+    const dimensionList = (
       ((await IotDeviceApi.getIotDeviceTds(Number(props.id))) as IotTdItem[]) ?? []
-    const identifiers = new Set(dimensionList.map((item) => String(item.identifier)))
+    ).map((item) => ({ ...item, identifier: String(item.identifier).trim() }))
+    const identifiers = new Set(dimensionList.map((item) => item.identifier))
 
+    // 以属性接口的完整列表决定是否合并,不能以某条 MQTT 消息是否包含悬轻来判断。
     suspensionWeightAndLightEnabled.value =
       identifiers.has(SUSPENSION_WEIGHT_IDENTIFIER) && identifiers.has(SUSPENSION_LIGHT_IDENTIFIER)
 
-    if (suspensionWeightAndLightEnabled.value) {
-      const weightDimension = dimensionList.find(
-        (item) => String(item.identifier) === SUSPENSION_WEIGHT_IDENTIFIER
-      )
+    const weightDimension = dimensionList.find(
+      (item) => item.identifier === SUSPENSION_WEIGHT_IDENTIFIER
+    )
 
-      if (weightDimension) weightDimension.modelName = SUSPENSION_COMBINED_NAME
+    if (weightDimension) {
+      weightDimension.modelName = suspensionWeightAndLightEnabled.value
+        ? SUSPENSION_COMBINED_NAME
+        : '悬重'
     }
 
     dimensions.value = dimensionList
@@ -334,19 +347,20 @@ const chartData = shallowRef<ChartData>({})
 // 如果设备把两个测点拆成两条消息上报,当前消息缺少的那一侧会被当成 0,
 // 组合曲线就会出现错误的 0 点。
 //
-// 【修复后】保存两侧最近一次真实收到的有效点。只收到一侧时使用另一侧最近值,
-// 两侧都还没有数据时不生成组合点,避免把“暂无数据”伪造成数值 0。
+// 保存两侧最近一次有效点。尚未收到另一侧时展示已收到的单侧数据,
+// 悬重为正向、悬轻为负向;两侧齐全后再计算差值,不丢弃有效的单侧上报。
 const latestSuspensionPoints = new Map<string, ChartPoint>()
 
-function normalizeSuspensionTimestamp(timestamp: number) {
-  // tooltip 只显示到秒,因此将组合曲线的毫秒归入对应的秒桶。
-  // 【修复前】14:26:26.000 和 14:26:26.500 会被当成两个不同点,
-  // 但页面都显示为 14:26:26,视觉上像同一秒出现两个值。
-  // 【修复后】同一秒只保留一个组合点,后到的结果覆盖先到的结果。
+function normalizeChartTimestamp(timestamp: number) {
+  // 所有曲线使用相同的秒级坐标,避免组合曲线与其他曲线因毫秒差拆成两个 tooltip。
   return Math.floor(timestamp / 1000) * 1000
 }
 
 function getCombinedSuspensionPoint(pointMap: Map<string, ChartPoint>) {
+  if (!pointMap.has(SUSPENSION_WEIGHT_IDENTIFIER) && !pointMap.has(SUSPENSION_LIGHT_IDENTIFIER)) {
+    return null
+  }
+
   // MQTT 可能把悬重和悬轻拆成不同消息,因此先更新最近值缓存,再计算组合值。
   for (const identifier of [SUSPENSION_WEIGHT_IDENTIFIER, SUSPENSION_LIGHT_IDENTIFIER]) {
     const point = pointMap.get(identifier)
@@ -356,12 +370,16 @@ function getCombinedSuspensionPoint(pointMap: Map<string, ChartPoint>) {
   const weightPoint = latestSuspensionPoints.get(SUSPENSION_WEIGHT_IDENTIFIER)
   const lightPoint = latestSuspensionPoints.get(SUSPENSION_LIGHT_IDENTIFIER)
 
-  // 任意一侧还没有真实数据时不计算,避免用默认 0 制造伪数据。
-  if (!weightPoint || !lightPoint) return null
+  if (!weightPoint && !lightPoint) return null
 
   return {
-    ts: normalizeSuspensionTimestamp(Math.max(weightPoint.ts, lightPoint.ts)),
-    value: weightPoint.value - lightPoint.value
+    ts: normalizeChartTimestamp(Math.max(weightPoint?.ts ?? 0, lightPoint?.ts ?? 0)),
+    value:
+      weightPoint && lightPoint
+        ? weightPoint.value - lightPoint.value
+        : weightPoint
+          ? weightPoint.value
+          : -lightPoint!.value
   }
 }
 
@@ -690,6 +708,7 @@ function updateSeriesByNames(names: string[]) {
       series: names.map((name) => ({
         id: name,
         name,
+        showSymbol: chartData.value[name]?.length === 1,
         data: buildSeriesData(name)
       }))
     },
@@ -725,6 +744,7 @@ function flushRealtimeChartUpdate() {
       series: names.map((name) => ({
         id: name,
         name,
+        showSymbol: chartData.value[name]?.length === 1,
         data: buildSeriesData(name)
       }))
     },
@@ -908,7 +928,7 @@ function render() {
         type: 'line',
         sampling: 'lttb',
         smooth: 0.24,
-        showSymbol: false,
+        showSymbol: chartData.value[item.name]?.length === 1,
         connectNulls: true,
         endLabel: {
           show: true,
@@ -943,39 +963,58 @@ const chartLoading = ref(false)
 function normalizeHistoryPoints(data: unknown): ChartPoint[] {
   if (!Array.isArray(data)) return []
 
-  return data
+  const points = data
+    .filter(
+      (item) => item.value !== null && item.value !== undefined && String(item.value).trim() !== ''
+    )
     .map((item) => ({
       ts: Number(item.ts),
       value: Number(item.value)
     }))
     .filter((item) => Number.isFinite(item.ts) && Number.isFinite(item.value))
     .sort((a, b) => a.ts - b.ts)
+
+  // 同一秒保留最后采样值,历史和实时均使用同一套坐标精度。
+  const pointsBySecond = new Map<number, ChartPoint>()
+  points.forEach((point) => {
+    const ts = normalizeChartTimestamp(point.ts)
+    pointsBySecond.set(ts, { ts, value: point.value })
+  })
+  return Array.from(pointsBySecond.values())
 }
 
 function mergeSuspensionHistory(weightPoints: ChartPoint[], lightPoints: ChartPoint[]) {
   const mergedPoints = new Map<number, { weight?: number; light?: number }>()
 
   weightPoints.forEach((point) => {
-    const timestamp = normalizeSuspensionTimestamp(point.ts)
+    const timestamp = normalizeChartTimestamp(point.ts)
     mergedPoints.set(timestamp, { ...mergedPoints.get(timestamp), weight: point.value })
   })
   lightPoints.forEach((point) => {
-    const timestamp = normalizeSuspensionTimestamp(point.ts)
+    const timestamp = normalizeChartTimestamp(point.ts)
     mergedPoints.set(timestamp, { ...mergedPoints.get(timestamp), light: point.value })
   })
 
-  return Array.from(mergedPoints, ([ts, value]) => {
-    // 【修复前】任意一侧缺失也会使用 ?? 0 计算,生成无法代表真实状态的曲线点。
-    // 【修复后】两侧不完整时跳过该点,避免看板也出现错误的 0 值。
-    if (value.weight === undefined || value.light === undefined) return null
-
-    return {
-      ts,
-      value: value.weight - value.light
-    }
-  })
-    .filter((point): point is ChartPoint => point !== null)
-    .sort((a, b) => a.ts - b.ts)
+  let latestWeight: number | undefined
+  let latestLight: number | undefined
+
+  return Array.from(mergedPoints)
+    .sort(([a], [b]) => a - b)
+    .map(([ts, value]) => {
+      // 按时间保留最近值,允许两侧分开上报;尚未收到另一侧时显示单侧值。
+      if (value.weight !== undefined) latestWeight = value.weight
+      if (value.light !== undefined) latestLight = value.light
+
+      return {
+        ts,
+        value:
+          latestWeight !== undefined && latestLight !== undefined
+            ? latestWeight - latestLight
+            : latestWeight !== undefined
+              ? latestWeight
+              : -latestLight!
+      }
+    })
 }
 
 async function initLoadChartData(real_time: boolean = true) {
@@ -1024,9 +1063,7 @@ async function initLoadChartData(real_time: boolean = true) {
 
             chartData.value[item.name] = mergeSuspensionHistory(weightPoints, lightPoints)
           } else {
-            chartData.value[item.name] = (((res as any[]) ?? []) as any[])
-              .sort((a, b) => a.ts - b.ts)
-              .map((item) => ({ ts: item.ts, value: item.value }))
+            chartData.value[item.name] = normalizeHistoryPoints(res)
           }
         } catch (error) {
           console.error(error)

+ 75 - 37
src/views/oli-connection/monitoring/detail.vue

@@ -149,8 +149,8 @@ const chartData = shallowRef<ChartData>({})
 // 如果设备把两个测点拆成两条消息上报,当前消息缺少的那一侧会被当成 0。
 // 例如:只收到悬轻 0 时,旧逻辑会计算成 0 - 0 = 0,形成错误的“掉零”点。
 //
-// 【修复后】保存两侧最近一次真实收到的有效点。后续只收到一侧时,使用另一侧最近值参与计算,
-// 在还没有拿到两侧数据之前则不生成组合点,避免用虚假的 0 代替“暂无数据”。
+// 保存两侧最近一次有效点。尚未收到另一侧时显示单侧值,
+// 悬重为正向、悬轻为负向;两侧齐全后再计算差值。
 const latestSuspensionPoints = new Map<string, ChartPoint>()
 const resizeChart = () => chart?.resize()
 
@@ -167,6 +167,7 @@ async function loadDimensions() {
     const gatewaySource = [
       ...(((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as IotTdItem[]) ?? [])
     ]
+      .map((item) => ({ ...item, identifier: String(item.identifier).trim() }))
       .sort((a, b) => (b.modelOrder ?? 0) - (a.modelOrder ?? 0))
       .map(mapToDimension)
     const gatewayIdentifiers = new Set(gatewaySource.map((item) => String(item.identifier)))
@@ -175,10 +176,17 @@ async function loadDimensions() {
       gatewayIdentifiers.has(SUSPENSION_WEIGHT_IDENTIFIER) &&
       gatewayIdentifiers.has(SUSPENSION_LIGHT_IDENTIFIER)
 
+    // 属性是否存在以 td 的完整列表为准,悬轻值为空也仍启用合并。
+    const weightDimension = gatewaySource.find(
+      (item) => item.identifier === SUSPENSION_WEIGHT_IDENTIFIER
+    )
+    if (weightDimension) {
+      weightDimension.name = suspensionWeightAndLightEnabled.value
+        ? SUSPENSION_COMBINED_NAME
+        : '悬重'
+    }
+
     if (suspensionWeightAndLightEnabled.value) {
-      const weightDimension = gatewaySource.find(
-        (item) => String(item.identifier) === SUSPENSION_WEIGHT_IDENTIFIER
-      )
       const lightDimension = gatewaySource.find(
         (item) => String(item.identifier) === SUSPENSION_LIGHT_IDENTIFIER
       )
@@ -188,8 +196,6 @@ async function loadDimensions() {
       if (weightDimension && Number.isFinite(weightValue) && Number.isFinite(lightValue)) {
         weightDimension.value = weightValue - lightValue
       }
-
-      if (weightDimension) weightDimension.name = SUSPENSION_COMBINED_NAME
     }
 
     const gateway = gatewaySource.filter(
@@ -319,15 +325,15 @@ function getPointTimestamp(value: unknown) {
   return Number.isFinite(parsedValue) ? parsedValue : Date.now()
 }
 
-function normalizeSuspensionTimestamp(timestamp: number) {
-  // 图表 tooltip 只展示到秒,因此将组合曲线的毫秒统一归入对应的秒桶。
-  // 【修复前】14:26:26.000 和 14:26:26.500 会被 appendChartPoint 视为两个不同点,
-  // 但 tooltip 都显示为 14:26:26,视觉上就像同一秒出现了两个值。
-  // 【修复后】同一秒使用同一个时间戳,后到的组合结果覆盖先到的结果。
+function normalizeChartTimestamp(timestamp: number) {
+  // 所有曲线使用相同的秒级坐标,避免组合曲线与其他曲线因毫秒差拆成两个 tooltip。
   return Math.floor(timestamp / 1000) * 1000
 }
 
 function getCombinedSuspensionPoint(pointMap: Map<string, ChartPoint>) {
+  if (!pointMap.has(SUSPENSION_WEIGHT_IDENTIFIER) && !pointMap.has(SUSPENSION_LIGHT_IDENTIFIER)) {
+    return null
+  }
   // 当前 payload 可能只包含 1001 或 1008 的其中一个,因此先更新缓存,
   // 再从缓存中读取两侧最近一次有效值,而不是只依赖当前 payload。
   for (const identifier of [SUSPENSION_WEIGHT_IDENTIFIER, SUSPENSION_LIGHT_IDENTIFIER]) {
@@ -338,12 +344,16 @@ function getCombinedSuspensionPoint(pointMap: Map<string, ChartPoint>) {
   const weightPoint = latestSuspensionPoints.get(SUSPENSION_WEIGHT_IDENTIFIER)
   const lightPoint = latestSuspensionPoints.get(SUSPENSION_LIGHT_IDENTIFIER)
 
-  // 两侧都没有数据时不能计算;返回 null 后,本次不会追加组合曲线点。
-  if (!weightPoint || !lightPoint) return null
+  if (!weightPoint && !lightPoint) return null
 
   return {
-    ts: normalizeSuspensionTimestamp(Math.max(weightPoint.ts, lightPoint.ts)),
-    value: weightPoint.value - lightPoint.value
+    ts: normalizeChartTimestamp(Math.max(weightPoint?.ts ?? 0, lightPoint?.ts ?? 0)),
+    value:
+      weightPoint && lightPoint
+        ? weightPoint.value - lightPoint.value
+        : weightPoint
+          ? weightPoint.value
+          : -lightPoint!.value
   }
 }
 
@@ -485,6 +495,7 @@ function updateSingleSeries(name: string) {
       {
         id: name,
         name,
+        showSymbol: chartData.value[name]?.length === 1,
         data: buildSeriesData(name)
       }
     ]
@@ -504,6 +515,7 @@ function updateSeriesByNames(names: string[]) {
     series: names.map((name) => ({
       id: name,
       name,
+      showSymbol: chartData.value[name]?.length === 1,
       data: buildSeriesData(name)
     }))
   })
@@ -534,6 +546,7 @@ function flushRealtimeChartUpdate() {
     series: names.map((name) => ({
       id: name,
       name,
+      showSymbol: chartData.value[name]?.length === 1,
       data: buildSeriesData(name)
     }))
   })
@@ -659,7 +672,7 @@ function renderChart() {
         type: 'line',
         sampling: 'lttb',
         smooth: 0.24,
-        showSymbol: false,
+        showSymbol: chartData.value[item.name]?.length === 1,
         connectNulls: true,
         endLabel: {
           show: true,
@@ -696,7 +709,13 @@ function updateDimensionValues(valueMap: Map<string, number>) {
   })
 }
 
-function handleMessageUpdate(_topic: string, payload: any) {
+function handleMessageUpdate(topic: string, payload: any) {
+  console.log('[监控详情 MQTT 返回数据]', {
+    deviceCode: data.value.deviceCode,
+    topic,
+    receivedAt: dayjs().format('YYYY-MM-DD HH:mm:ss.SSS'),
+    data: payload
+  })
   const list = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : []
 
   const valueMap = new Map<string, number>()
@@ -724,7 +743,9 @@ function handleMessageUpdate(_topic: string, payload: any) {
     const point = {
       // 【修复前】详情页只读取 remark;如果后端返回 readTime/ts/time,就会走 Date.now()。
       // 【修复后】按设备消息可能使用的字段依次取值,保证点的时间尽量来自设备实际上报时间。
-      ts: getPointTimestamp(item.remark || item.readTime || item.ts || item.time),
+      ts: normalizeChartTimestamp(
+        getPointTimestamp(item.remark || item.readTime || item.ts || item.time)
+      ),
       value: numberValue
     }
     pointMap.set(identity, point)
@@ -758,7 +779,7 @@ function handleMessageUpdate(_topic: string, payload: any) {
         updatedNames.add(dimension.name)
       }
     } else {
-      // 组合尚未完整时,不让当前 payload 中的单侧原始值覆盖页面上的上一条完整组合值。
+      // 当前消息没有有效的悬重或悬轻数据时,不更新组合值。
       valueMap.delete(SUSPENSION_WEIGHT_IDENTIFIER)
       valueMap.delete(SUSPENSION_LIGHT_IDENTIFIER)
     }
@@ -786,13 +807,24 @@ async function startRealtimeMqtt() {
 function normalizeHistoryPoints(data: unknown): ChartPoint[] {
   if (!Array.isArray(data)) return []
 
-  return data
+  const points = data
+    .filter(
+      (item) => item.value !== null && item.value !== undefined && String(item.value).trim() !== ''
+    )
     .map((item) => ({
       ts: Number(item.ts),
       value: Number(item.value)
     }))
     .filter((item) => Number.isFinite(item.ts) && Number.isFinite(item.value))
     .sort((a, b) => a.ts - b.ts)
+
+  // 同一秒保留最后采样值,历史和实时均使用同一套坐标精度。
+  const pointsBySecond = new Map<number, ChartPoint>()
+  points.forEach((point) => {
+    const ts = normalizeChartTimestamp(point.ts)
+    pointsBySecond.set(ts, { ts, value: point.value })
+  })
+  return Array.from(pointsBySecond.values())
 }
 
 function mergeSuspensionHistory(weightPoints: ChartPoint[], lightPoints: ChartPoint[]) {
@@ -801,26 +833,34 @@ function mergeSuspensionHistory(weightPoints: ChartPoint[], lightPoints: ChartPo
   weightPoints.forEach((point) => {
     // 历史接口的两条曲线可能存在毫秒差,例如悬重 26.100、悬轻 26.800;
     // 先按展示秒归桶,才能把同一采样秒的两侧数据配对起来。
-    const timestamp = normalizeSuspensionTimestamp(point.ts)
+    const timestamp = normalizeChartTimestamp(point.ts)
     mergedPoints.set(timestamp, { ...mergedPoints.get(timestamp), weight: point.value })
   })
   lightPoints.forEach((point) => {
-    const timestamp = normalizeSuspensionTimestamp(point.ts)
+    const timestamp = normalizeChartTimestamp(point.ts)
     mergedPoints.set(timestamp, { ...mergedPoints.get(timestamp), light: point.value })
   })
 
-  return Array.from(mergedPoints, ([ts, value]) => {
-    // 【修复前】任意一侧缺失都会使用 ?? 0 继续计算,产生无法代表真实状态的组合值。
-    // 【修复后】缺少任意一侧就不生成历史点,宁可没有点,也不显示伪造的 0。
-    if (value.weight === undefined || value.light === undefined) return null
+  let latestWeight: number | undefined
+  let latestLight: number | undefined
 
-    return {
-      ts,
-      value: value.weight - value.light
-    }
-  })
-    .filter((point): point is ChartPoint => point !== null)
-    .sort((a, b) => a.ts - b.ts)
+  return Array.from(mergedPoints)
+    .sort(([a], [b]) => a - b)
+    .map(([ts, value]) => {
+      // 按时间保留最近值,允许两侧分开上报;尚未收到另一侧时显示单侧值。
+      if (value.weight !== undefined) latestWeight = value.weight
+      if (value.light !== undefined) latestLight = value.light
+
+      return {
+        ts,
+        value:
+          latestWeight !== undefined && latestLight !== undefined
+            ? latestWeight - latestLight
+            : latestWeight !== undefined
+              ? latestWeight
+              : -latestLight!
+      }
+    })
 }
 
 async function initLoadChartData({
@@ -883,9 +923,7 @@ async function initLoadChartData({
 
             chartData.value[item.name] = mergeSuspensionHistory(weightPoints, lightPoints)
           } else {
-            chartData.value[item.name] = (((res as any[]) ?? []) as any[])
-              .sort((a, b) => a.ts - b.ts)
-              .map((item) => ({ ts: item.ts, value: item.value }))
+            chartData.value[item.name] = normalizeHistoryPoints(res)
           }
         } catch (error) {
           console.error(error)