/** * 状态推送的**增量合并**(票 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}} `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 } }