equipmentDetail.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. /**
  2. * 设备详情聚合领域对象(票 05;消费方是票 11「各分区可独立成功、失败、为空或过期」)。
  3. *
  4. * 后端形状(`equipment-detail-aggregate-demo.html`、`CockpitEquipmentDetailAggregateResponse`):
  5. * `{ deviceCode, aggregateState, serverTime,
  6. * archive|metrics|warnings|alarms|alarmWarningRelations|workOrders|timelines|latestImage:
  7. * { state: SUCCESS|EMPTY|STALE|FAILED, reason, message, data } }`
  8. *
  9. * **这是「分区独立」在契约层的原生形态**:每个分区自带 `state` 与 `reason`,
  10. * 后端把「档案读取失败但指标仍可用」直接表达出来。所以本层的关键动作是
  11. * 「把每个分区翻成一个 `section()`」,而**不是**让整个详情共用一个成败——
  12. * 那正是票 05 验收第 6 条与票 11 验收第 3 条要的行为。
  13. *
  14. * 注意 `STALE` 分区的 `data` 仍带值(后端保留旧值),落进 `section.retained`。
  15. */
  16. import { fieldReader } from './fields.js'
  17. import { SECTION_STATE, section, sectionStateFromBackend } from './sectionState.js'
  18. import { toFreshness } from './freshness.js'
  19. import { toAlarmEvent, toAlarmWarningRelation, toWarningEvent } from './events.js'
  20. import { toWorkOrder, toWorkOrderTimeline } from './workOrder.js'
  21. /** 聚合响应的分区名(顺序即界面顺序)。 */
  22. export const EQUIPMENT_SECTIONS = [
  23. 'archive',
  24. 'metrics',
  25. 'warnings',
  26. 'alarms',
  27. 'alarmWarningRelations',
  28. 'workOrders',
  29. 'timelines',
  30. 'latestImage',
  31. ]
  32. /** 设备档案 → 领域对象。 */
  33. export function toEquipmentArchive(raw) {
  34. const reader = fieldReader(raw, 'archive')
  35. const image = reader.optionalObj('latestImage')
  36. return {
  37. // 编码与名称是档案的标识字段:契约里必有,缺了要记一笔(其余描述性字段允许为空)。
  38. code: reader.str('deviceCode'),
  39. name: reader.str('deviceName'),
  40. model: reader.optionalStr('deviceModel'),
  41. spec: reader.optionalStr('deviceSpec'),
  42. manufacturer: reader.optionalStr('manufacturer'),
  43. type: reader.optionalStr('deviceType'),
  44. typeName: reader.optionalStr('deviceTypeName'),
  45. assetValue: reader.optionalNum('assetValue'),
  46. expectedServiceYears: reader.optionalNum('expectedServiceYears'),
  47. location: reader.optionalStr('equipmentLocation'),
  48. ownershipUnit: reader.optionalStr('ownershipUnit'),
  49. maintainer: reader.optionalStr('maintainer'),
  50. maintainerPhone: reader.optionalStr('maintainerPhone'),
  51. installTime: reader.optionalStr('installTime'),
  52. remark: reader.optionalStr('remark'),
  53. // 票 11:「最新图片只展示后端指定的一张」——后端档案里就是这个单值。
  54. image: image ? toDeviceImage(image) : null,
  55. missing: reader.missing,
  56. }
  57. }
  58. /** 设备图片(票 11:只认后端给的那一张,前端不挑)。 */
  59. export function toDeviceImage(raw) {
  60. const reader = fieldReader(raw, 'image')
  61. return {
  62. url: reader.str('url'),
  63. capturedAt: reader.optionalStr('capturedAt'),
  64. missing: reader.missing,
  65. }
  66. }
  67. /** 指标点位(趋势序列)。 */
  68. export function toMetricPoint(raw) {
  69. const reader = fieldReader(raw, 'metricPoint')
  70. return {
  71. value: reader.optionalNum('value'),
  72. status: reader.optionalStr('status'),
  73. sampledAt: reader.optionalStr('sampledAt'),
  74. freshness: reader.optionalStr('freshness'),
  75. }
  76. }
  77. /** 单个指标。 */
  78. export function toMetric(raw) {
  79. const reader = fieldReader(raw, 'metric')
  80. return {
  81. code: reader.str('metricCode'),
  82. name: reader.optionalStr('metricName'),
  83. // 缺失指标保持 null(票 16:不显示虚假零值);真实 0 会照原样保留。
  84. value: reader.optionalNum('value'),
  85. unit: reader.optionalStr('unit'),
  86. status: reader.optionalStr('status'),
  87. sampledAt: reader.optionalStr('sampledAt'),
  88. freshness: reader.optionalStr('freshness'),
  89. points: reader.optionalList('points').map(toMetricPoint),
  90. missing: reader.missing,
  91. }
  92. }
  93. /** 指标分区载荷(票 11 的「最新指标」)。 */
  94. export function toMetricsPayload(raw) {
  95. const reader = fieldReader(raw)
  96. return {
  97. deviceCode: reader.optionalStr('deviceCode'),
  98. state: reader.optionalStr('state'),
  99. stateReason: reader.optionalStr('stateReason'),
  100. metrics: reader.list('metrics').map(toMetric),
  101. missing: reader.missing,
  102. }
  103. }
  104. /**
  105. * 把一个后端分区翻成前端分区。
  106. * `data` 的转换按分区名分派;`null` 数据(失败/空)不调用转换函数。
  107. * @param {string} name EQUIPMENT_SECTIONS 之一
  108. * @param {object} raw 后端 `CockpitSectionResult`
  109. */
  110. export function toSection(name, raw) {
  111. const reader = fieldReader(raw, name)
  112. const state = sectionStateFromBackend(reader.optionalStr('state'))
  113. const reason = reader.optionalStr('reason')
  114. const message = reader.optionalStr('message')
  115. const payload = reader.raw.data
  116. // 失败/空分区可能仍带 data(后端保留旧值);只有真有值才转换。
  117. const hasPayload = payload !== undefined && payload !== null
  118. let data = null
  119. if (hasPayload) {
  120. data = convertSectionData(name, payload)
  121. }
  122. return section(state, {
  123. reason: message || reason,
  124. data,
  125. freshness: readSectionFreshness(name, payload),
  126. // 过期分区保留的是旧值:票 16 要求界面带过期标识而不是当实时值。
  127. retained: state === SECTION_STATE.STALE,
  128. })
  129. }
  130. function readSectionFreshness(name, payload) {
  131. if (name === 'metrics' && payload && typeof payload === 'object') {
  132. return String(payload.state || '')
  133. }
  134. return ''
  135. }
  136. function convertSectionData(name, payload) {
  137. if (name === 'archive') return toEquipmentArchive(payload)
  138. if (name === 'metrics') return toMetricsPayload(payload)
  139. if (name === 'warnings') return toList(payload, toWarningEvent)
  140. if (name === 'alarms') return toList(payload, toAlarmEvent)
  141. if (name === 'alarmWarningRelations') return toList(payload, toAlarmWarningRelation)
  142. if (name === 'workOrders') return toList(payload, toWorkOrder)
  143. if (name === 'timelines') return toList(payload, toWorkOrderTimeline)
  144. if (name === 'latestImage') return toDeviceImage(payload)
  145. // 后端加了新分区而前端还不认识:原样带出,不静默丢弃。
  146. return payload
  147. }
  148. function toList(payload, mapper) {
  149. if (!Array.isArray(payload)) return []
  150. return payload.map(mapper)
  151. }
  152. /**
  153. * 设备详情聚合 → 领域对象。每个分区各自成 `section()`,互不牵连。
  154. * @param {object} raw 后端 `CockpitEquipmentDetailAggregateResponse`
  155. */
  156. export function toEquipmentDetailAggregate(raw) {
  157. const base = fieldReader(raw)
  158. const sections = {}
  159. for (const name of EQUIPMENT_SECTIONS) {
  160. sections[name] = toSection(name, base.raw[name])
  161. }
  162. return {
  163. deviceCode: base.optionalStr('deviceCode'),
  164. // 后端给的整体态:只作为参考带出,界面分区状态以各分区自己的为准
  165. // (这样即使整体态漏标,成功分区也不会被拖成不可用)。
  166. aggregateState: base.optionalStr('aggregateState'),
  167. serverTime: base.optionalStr('serverTime'),
  168. sections,
  169. missing: base.missing,
  170. }
  171. }
  172. /** 设备新鲜度分区(后端 `freshness` 在独立端点上,不是聚合的一部分)。 */
  173. export function toDeviceFreshnessSection(raw) {
  174. const freshness = toFreshness(raw)
  175. const state = !freshness.freshness
  176. ? SECTION_STATE.FAILED
  177. : freshness.freshness === 'EXPIRED' || freshness.freshness === 'STALE'
  178. ? SECTION_STATE.STALE
  179. : freshness.freshness === 'EMPTY'
  180. ? SECTION_STATE.EMPTY
  181. : SECTION_STATE.SUCCESS
  182. return section(state, {
  183. reason: freshness.staleReason,
  184. data: freshness,
  185. freshness: freshness.freshness,
  186. retained: freshness.retained,
  187. })
  188. }