| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159 |
- /**
- * 状态推送的**增量合并**(票 15)——纯函数层。
- *
- * 为什么单独成一层:合并规则(按时间取舍、点位状态改写、不重建视图)是这张票的核心判断,
- * 必须能脱浏览器断言;连接/重连那些副作用留在 `services/runtimeSocket.js`。
- *
- * 三条纪律(全部来自本票验收原句与 CONTEXT.md):
- * 1. **旧消息被忽略**(验收第 3 条):按 `serverUpdatedAt` 比大小,非更新的帧一律丢弃。
- * 时间认不出的帧**不采纳**(宁可漏一次状态,也不让乱序的旧值盖掉新值)。
- * 2. **点位状态只按后端指名的点位改写**(CONTEXT.md「前端只渲染,不重算」):
- * 帧里给了 `monitoringPointCode` 才动那个点位;没给就只更新设备维度。
- * 绝不由「设备 ALARM」推导「点位 ALARM」——那是重算业务状态。
- * 3. **就地更新,不重建**(验收第 4 条):返回的是"哪些对象被改了",消费方据此
- * 原地替换那一条,不整表重画(面板状态/滚动位置因此天然不变)。
- *
- * 关于 `serverUpdatedAt` 的比较口径:后端给的是带偏移的 ISO-8601(`+08:00`),
- * 但**偏移可能不同**(同一时刻可能是 `Z`)。所以先转成 epoch 毫秒再比,
- * 不拿字符串比大小 —— 字符串比在跨偏移时会给出错误结论。
- */
- /** 一条增量是否比已记录的更新(未知时间一律不采纳)。 */
- export function isNewerDelta(delta, seenAt) {
- const mine = parseStamp(delta && delta.serverUpdatedAt)
- if (mine === null) return false
- const previous = parseStamp(seenAt)
- if (previous === null) return true
- return mine > previous
- }
- /**
- * 解析时间戳 → epoch 毫秒;认不出的回 null(不猜、不当成 0)。
- * `Date.parse` 对带偏移的 ISO 串可靠;对空串/垃圾串回 NaN,这里统一收敛成 null。
- */
- export function parseStamp(value) {
- if (typeof value !== 'string' || !value.trim()) return null
- const ms = Date.parse(value)
- return Number.isFinite(ms) ? ms : null
- }
- /**
- * 把一条增量并进状态表。
- *
- * @param {object} params
- * @param {object} params.state 现态 `{ deviceStates: {code: {status, serverUpdatedAt, ...}}, seenAt }`
- * @param {object} params.delta `toDeviceStatusEvent()` 的结果
- * @returns {{state: object, accepted: boolean, reason: string, deviceCode: string, pointCode: string, status: string}}
- * `accepted:false` 时 `state` **原样返回同一个引用**(调用方据此跳过重渲染)
- */
- export function mergeDeviceStatus({ state, delta } = {}) {
- const current = normalizeState(state)
- const event = delta || {}
- const deviceCode = String(event.deviceCode || '').trim()
- const status = String(event.status || '').trim()
- const rejected = (reason) => ({
- state: current.source,
- accepted: false,
- reason,
- deviceCode,
- pointCode: '',
- status,
- })
- // 没有设备编码的帧无法回联任何对象(`toDeviceStatusEvent` 已挡一道,这里再挡)
- if (!deviceCode) return rejected('NO_DEVICE_CODE')
- // 状态认不出的帧不采纳:不把空状态写进表(那会把已知状态抹成"未发布")
- if (!status) return rejected('NO_STATUS')
- const previous = current.deviceStates[deviceCode]
- if (!isNewerDelta(event, previous && previous.serverUpdatedAt)) {
- return rejected(previous ? 'STALE' : 'NO_TIMESTAMP')
- }
- const nextDeviceStates = {
- ...current.deviceStates,
- [deviceCode]: {
- deviceCode,
- status,
- serverUpdatedAt: event.serverUpdatedAt || '',
- statusTime: event.statusTime || '',
- reason: event.reason || '',
- },
- }
- // 点位状态:**只在后端指名点位时**改写(纪律 2)。
- const pointCode = String(event.monitoringPointCode || '').trim()
- const nextPointStates = { ...current.pointStates }
- if (pointCode) {
- nextPointStates[pointCode] = {
- pointCode,
- status,
- serverUpdatedAt: event.serverUpdatedAt || '',
- deviceCode,
- // 来源如实标注:这是推送来的点位状态,不是 REST 快照里的那一份。
- source: 'push',
- }
- }
- return {
- state: {
- deviceStates: nextDeviceStates,
- pointStates: nextPointStates,
- seenAt: event.serverUpdatedAt || current.seenAt,
- source: current.source,
- },
- accepted: true,
- reason: '',
- deviceCode,
- pointCode,
- status,
- }
- }
- /** 现态归一(缺字段补空,避免调用方每次自己兜)。 */
- function normalizeState(state) {
- const raw = state && typeof state === 'object' ? state : {}
- return {
- source: raw,
- deviceStates: raw.deviceStates && typeof raw.deviceStates === 'object' ? raw.deviceStates : {},
- pointStates: raw.pointStates && typeof raw.pointStates === 'object' ? raw.pointStates : {},
- seenAt: raw.seenAt || '',
- }
- }
- /** 空态(运行时服务与单测共用同一形状)。 */
- export function emptyStatusState() {
- return { deviceStates: {}, pointStates: {}, seenAt: '', source: 'push' }
- }
- /**
- * 把推送来的点位状态**就地**并进已加载的地图对象列表(验收第 4 条:不重建视图)。
- *
- * 只改状态命中的那些对象,且**返回新数组但复用未命中的元素引用**:
- * 消费方据此能只重画受影响的那几个标记,列表顺序、滚动位置与选中态都不动。
- * 不改坐标、不改名字(推送不带这些字段,凭空补就是编数据)。
- *
- * @param {Array} objects 已加载的地图对象
- * @param {object} pointStates `{ [pointCode]: { status } }`
- * @returns {{objects: Array, changed: Array<string>}} `changed` = 被改写状态的编码
- */
- export function applyPointStates(objects, pointStates) {
- const list = Array.isArray(objects) ? objects : []
- const table = pointStates && typeof pointStates === 'object' ? pointStates : {}
- const changed = []
- let touched = false
- const next = list.map((item) => {
- const code = (item && item.code) || ''
- const pushed = code ? table[code] : null
- if (!pushed || !pushed.status) return item
- // 状态没变就**不产生新对象**:避免同值推送导致无谓重渲染。
- if (item.status === pushed.status) return item
- touched = true
- changed.push(code)
- return { ...item, status: pushed.status, statusSource: 'push' }
- })
- return { objects: touched ? next : list, changed }
- }
|