statusPush.js 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. /**
  2. * 状态推送的**增量合并**(票 15)——纯函数层。
  3. *
  4. * 为什么单独成一层:合并规则(按时间取舍、点位状态改写、不重建视图)是这张票的核心判断,
  5. * 必须能脱浏览器断言;连接/重连那些副作用留在 `services/runtimeSocket.js`。
  6. *
  7. * 三条纪律(全部来自本票验收原句与 CONTEXT.md):
  8. * 1. **旧消息被忽略**(验收第 3 条):按 `serverUpdatedAt` 比大小,非更新的帧一律丢弃。
  9. * 时间认不出的帧**不采纳**(宁可漏一次状态,也不让乱序的旧值盖掉新值)。
  10. * 2. **点位状态只按后端指名的点位改写**(CONTEXT.md「前端只渲染,不重算」):
  11. * 帧里给了 `monitoringPointCode` 才动那个点位;没给就只更新设备维度。
  12. * 绝不由「设备 ALARM」推导「点位 ALARM」——那是重算业务状态。
  13. * 3. **就地更新,不重建**(验收第 4 条):返回的是"哪些对象被改了",消费方据此
  14. * 原地替换那一条,不整表重画(面板状态/滚动位置因此天然不变)。
  15. *
  16. * 关于 `serverUpdatedAt` 的比较口径:后端给的是带偏移的 ISO-8601(`+08:00`),
  17. * 但**偏移可能不同**(同一时刻可能是 `Z`)。所以先转成 epoch 毫秒再比,
  18. * 不拿字符串比大小 —— 字符串比在跨偏移时会给出错误结论。
  19. */
  20. /** 一条增量是否比已记录的更新(未知时间一律不采纳)。 */
  21. export function isNewerDelta(delta, seenAt) {
  22. const mine = parseStamp(delta && delta.serverUpdatedAt)
  23. if (mine === null) return false
  24. const previous = parseStamp(seenAt)
  25. if (previous === null) return true
  26. return mine > previous
  27. }
  28. /**
  29. * 解析时间戳 → epoch 毫秒;认不出的回 null(不猜、不当成 0)。
  30. * `Date.parse` 对带偏移的 ISO 串可靠;对空串/垃圾串回 NaN,这里统一收敛成 null。
  31. */
  32. export function parseStamp(value) {
  33. if (typeof value !== 'string' || !value.trim()) return null
  34. const ms = Date.parse(value)
  35. return Number.isFinite(ms) ? ms : null
  36. }
  37. /**
  38. * 把一条增量并进状态表。
  39. *
  40. * @param {object} params
  41. * @param {object} params.state 现态 `{ deviceStates: {code: {status, serverUpdatedAt, ...}}, seenAt }`
  42. * @param {object} params.delta `toDeviceStatusEvent()` 的结果
  43. * @returns {{state: object, accepted: boolean, reason: string, deviceCode: string, pointCode: string, status: string}}
  44. * `accepted:false` 时 `state` **原样返回同一个引用**(调用方据此跳过重渲染)
  45. */
  46. export function mergeDeviceStatus({ state, delta } = {}) {
  47. const current = normalizeState(state)
  48. const event = delta || {}
  49. const deviceCode = String(event.deviceCode || '').trim()
  50. const status = String(event.status || '').trim()
  51. const rejected = (reason) => ({
  52. state: current.source,
  53. accepted: false,
  54. reason,
  55. deviceCode,
  56. pointCode: '',
  57. status,
  58. })
  59. // 没有设备编码的帧无法回联任何对象(`toDeviceStatusEvent` 已挡一道,这里再挡)
  60. if (!deviceCode) return rejected('NO_DEVICE_CODE')
  61. // 状态认不出的帧不采纳:不把空状态写进表(那会把已知状态抹成"未发布")
  62. if (!status) return rejected('NO_STATUS')
  63. const previous = current.deviceStates[deviceCode]
  64. if (!isNewerDelta(event, previous && previous.serverUpdatedAt)) {
  65. return rejected(previous ? 'STALE' : 'NO_TIMESTAMP')
  66. }
  67. const nextDeviceStates = {
  68. ...current.deviceStates,
  69. [deviceCode]: {
  70. deviceCode,
  71. status,
  72. serverUpdatedAt: event.serverUpdatedAt || '',
  73. statusTime: event.statusTime || '',
  74. reason: event.reason || '',
  75. },
  76. }
  77. // 点位状态:**只在后端指名点位时**改写(纪律 2)。
  78. const pointCode = String(event.monitoringPointCode || '').trim()
  79. const nextPointStates = { ...current.pointStates }
  80. if (pointCode) {
  81. nextPointStates[pointCode] = {
  82. pointCode,
  83. status,
  84. serverUpdatedAt: event.serverUpdatedAt || '',
  85. deviceCode,
  86. // 来源如实标注:这是推送来的点位状态,不是 REST 快照里的那一份。
  87. source: 'push',
  88. }
  89. }
  90. return {
  91. state: {
  92. deviceStates: nextDeviceStates,
  93. pointStates: nextPointStates,
  94. seenAt: event.serverUpdatedAt || current.seenAt,
  95. source: current.source,
  96. },
  97. accepted: true,
  98. reason: '',
  99. deviceCode,
  100. pointCode,
  101. status,
  102. }
  103. }
  104. /** 现态归一(缺字段补空,避免调用方每次自己兜)。 */
  105. function normalizeState(state) {
  106. const raw = state && typeof state === 'object' ? state : {}
  107. return {
  108. source: raw,
  109. deviceStates: raw.deviceStates && typeof raw.deviceStates === 'object' ? raw.deviceStates : {},
  110. pointStates: raw.pointStates && typeof raw.pointStates === 'object' ? raw.pointStates : {},
  111. seenAt: raw.seenAt || '',
  112. }
  113. }
  114. /** 空态(运行时服务与单测共用同一形状)。 */
  115. export function emptyStatusState() {
  116. return { deviceStates: {}, pointStates: {}, seenAt: '', source: 'push' }
  117. }
  118. /**
  119. * 把推送来的点位状态**就地**并进已加载的地图对象列表(验收第 4 条:不重建视图)。
  120. *
  121. * 只改状态命中的那些对象,且**返回新数组但复用未命中的元素引用**:
  122. * 消费方据此能只重画受影响的那几个标记,列表顺序、滚动位置与选中态都不动。
  123. * 不改坐标、不改名字(推送不带这些字段,凭空补就是编数据)。
  124. *
  125. * @param {Array} objects 已加载的地图对象
  126. * @param {object} pointStates `{ [pointCode]: { status } }`
  127. * @returns {{objects: Array, changed: Array<string>}} `changed` = 被改写状态的编码
  128. */
  129. export function applyPointStates(objects, pointStates) {
  130. const list = Array.isArray(objects) ? objects : []
  131. const table = pointStates && typeof pointStates === 'object' ? pointStates : {}
  132. const changed = []
  133. let touched = false
  134. const next = list.map((item) => {
  135. const code = (item && item.code) || ''
  136. const pushed = code ? table[code] : null
  137. if (!pushed || !pushed.status) return item
  138. // 状态没变就**不产生新对象**:避免同值推送导致无谓重渲染。
  139. if (item.status === pushed.status) return item
  140. touched = true
  141. changed.push(code)
  142. return { ...item, status: pushed.status, statusSource: 'push' }
  143. })
  144. return { objects: touched ? next : list, changed }
  145. }