/** * 设备详情聚合领域对象(票 05;消费方是票 11「各分区可独立成功、失败、为空或过期」)。 * * 后端形状(`equipment-detail-aggregate-demo.html`、`CockpitEquipmentDetailAggregateResponse`): * `{ deviceCode, aggregateState, serverTime, * archive|metrics|warnings|alarms|alarmWarningRelations|workOrders|timelines|latestImage: * { state: SUCCESS|EMPTY|STALE|FAILED, reason, message, data } }` * * **这是「分区独立」在契约层的原生形态**:每个分区自带 `state` 与 `reason`, * 后端把「档案读取失败但指标仍可用」直接表达出来。所以本层的关键动作是 * 「把每个分区翻成一个 `section()`」,而**不是**让整个详情共用一个成败—— * 那正是票 05 验收第 6 条与票 11 验收第 3 条要的行为。 * * 注意 `STALE` 分区的 `data` 仍带值(后端保留旧值),落进 `section.retained`。 */ import { fieldReader } from './fields.js' import { SECTION_STATE, section, sectionStateFromBackend } from './sectionState.js' import { toFreshness } from './freshness.js' import { toAlarmEvent, toAlarmWarningRelation, toWarningEvent } from './events.js' import { toWorkOrder, toWorkOrderTimeline } from './workOrder.js' /** 聚合响应的分区名(顺序即界面顺序)。 */ export const EQUIPMENT_SECTIONS = [ 'archive', 'metrics', 'warnings', 'alarms', 'alarmWarningRelations', 'workOrders', 'timelines', 'latestImage', ] /** 设备档案 → 领域对象。 */ export function toEquipmentArchive(raw) { const reader = fieldReader(raw, 'archive') const image = reader.optionalObj('latestImage') return { // 编码与名称是档案的标识字段:契约里必有,缺了要记一笔(其余描述性字段允许为空)。 code: reader.str('deviceCode'), name: reader.str('deviceName'), model: reader.optionalStr('deviceModel'), spec: reader.optionalStr('deviceSpec'), manufacturer: reader.optionalStr('manufacturer'), type: reader.optionalStr('deviceType'), typeName: reader.optionalStr('deviceTypeName'), assetValue: reader.optionalNum('assetValue'), expectedServiceYears: reader.optionalNum('expectedServiceYears'), location: reader.optionalStr('equipmentLocation'), ownershipUnit: reader.optionalStr('ownershipUnit'), maintainer: reader.optionalStr('maintainer'), maintainerPhone: reader.optionalStr('maintainerPhone'), installTime: reader.optionalStr('installTime'), remark: reader.optionalStr('remark'), // 票 11:「最新图片只展示后端指定的一张」——后端档案里就是这个单值。 image: image ? toDeviceImage(image) : null, missing: reader.missing, } } /** 设备图片(票 11:只认后端给的那一张,前端不挑)。 */ export function toDeviceImage(raw) { const reader = fieldReader(raw, 'image') return { url: reader.str('url'), capturedAt: reader.optionalStr('capturedAt'), missing: reader.missing, } } /** 指标点位(趋势序列)。 */ export function toMetricPoint(raw) { const reader = fieldReader(raw, 'metricPoint') return { value: reader.optionalNum('value'), status: reader.optionalStr('status'), sampledAt: reader.optionalStr('sampledAt'), freshness: reader.optionalStr('freshness'), } } /** 单个指标。 */ export function toMetric(raw) { const reader = fieldReader(raw, 'metric') return { code: reader.str('metricCode'), name: reader.optionalStr('metricName'), // 缺失指标保持 null(票 16:不显示虚假零值);真实 0 会照原样保留。 value: reader.optionalNum('value'), unit: reader.optionalStr('unit'), status: reader.optionalStr('status'), sampledAt: reader.optionalStr('sampledAt'), freshness: reader.optionalStr('freshness'), points: reader.optionalList('points').map(toMetricPoint), missing: reader.missing, } } /** 指标分区载荷(票 11 的「最新指标」)。 */ export function toMetricsPayload(raw) { const reader = fieldReader(raw) return { deviceCode: reader.optionalStr('deviceCode'), state: reader.optionalStr('state'), stateReason: reader.optionalStr('stateReason'), metrics: reader.list('metrics').map(toMetric), missing: reader.missing, } } /** * 把一个后端分区翻成前端分区。 * `data` 的转换按分区名分派;`null` 数据(失败/空)不调用转换函数。 * @param {string} name EQUIPMENT_SECTIONS 之一 * @param {object} raw 后端 `CockpitSectionResult` */ export function toSection(name, raw) { const reader = fieldReader(raw, name) const state = sectionStateFromBackend(reader.optionalStr('state')) const reason = reader.optionalStr('reason') const message = reader.optionalStr('message') const payload = reader.raw.data // 失败/空分区可能仍带 data(后端保留旧值);只有真有值才转换。 const hasPayload = payload !== undefined && payload !== null let data = null if (hasPayload) { data = convertSectionData(name, payload) } return section(state, { reason: message || reason, data, freshness: readSectionFreshness(name, payload), // 过期分区保留的是旧值:票 16 要求界面带过期标识而不是当实时值。 retained: state === SECTION_STATE.STALE, }) } function readSectionFreshness(name, payload) { if (name === 'metrics' && payload && typeof payload === 'object') { return String(payload.state || '') } return '' } function convertSectionData(name, payload) { if (name === 'archive') return toEquipmentArchive(payload) if (name === 'metrics') return toMetricsPayload(payload) if (name === 'warnings') return toList(payload, toWarningEvent) if (name === 'alarms') return toList(payload, toAlarmEvent) if (name === 'alarmWarningRelations') return toList(payload, toAlarmWarningRelation) if (name === 'workOrders') return toList(payload, toWorkOrder) if (name === 'timelines') return toList(payload, toWorkOrderTimeline) if (name === 'latestImage') return toDeviceImage(payload) // 后端加了新分区而前端还不认识:原样带出,不静默丢弃。 return payload } function toList(payload, mapper) { if (!Array.isArray(payload)) return [] return payload.map(mapper) } /** * 设备详情聚合 → 领域对象。每个分区各自成 `section()`,互不牵连。 * @param {object} raw 后端 `CockpitEquipmentDetailAggregateResponse` */ export function toEquipmentDetailAggregate(raw) { const base = fieldReader(raw) const sections = {} for (const name of EQUIPMENT_SECTIONS) { sections[name] = toSection(name, base.raw[name]) } return { deviceCode: base.optionalStr('deviceCode'), // 后端给的整体态:只作为参考带出,界面分区状态以各分区自己的为准 // (这样即使整体态漏标,成功分区也不会被拖成不可用)。 aggregateState: base.optionalStr('aggregateState'), serverTime: base.optionalStr('serverTime'), sections, missing: base.missing, } } /** 设备新鲜度分区(后端 `freshness` 在独立端点上,不是聚合的一部分)。 */ export function toDeviceFreshnessSection(raw) { const freshness = toFreshness(raw) const state = !freshness.freshness ? SECTION_STATE.FAILED : freshness.freshness === 'EXPIRED' || freshness.freshness === 'STALE' ? SECTION_STATE.STALE : freshness.freshness === 'EMPTY' ? SECTION_STATE.EMPTY : SECTION_STATE.SUCCESS return section(state, { reason: freshness.staleReason, data: freshness, freshness: freshness.freshness, retained: freshness.retained, }) }