equipmentDetail.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  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. timeSource: reader.optionalStr('timeSource'),
  89. freshness: reader.optionalStr('freshness'),
  90. points: reader.optionalList('points').map(toMetricPoint),
  91. missing: reader.missing,
  92. }
  93. }
  94. /** 指标分区载荷(票 11 的「最新指标」)。 */
  95. export function toMetricsPayload(raw) {
  96. const reader = fieldReader(raw)
  97. return {
  98. deviceCode: reader.optionalStr('deviceCode'),
  99. state: reader.optionalStr('state'),
  100. stateReason: reader.optionalStr('stateReason'),
  101. metrics: reader.list('metrics').map(toMetric),
  102. missing: reader.missing,
  103. }
  104. }
  105. /** 最近采集记录:只消费后端recordView,不从旧指标或趋势补造历史。 */
  106. export function toCollectionRecordsPayload(raw) {
  107. return {
  108. deviceCode: typeof raw?.deviceCode === 'string' ? raw.deviceCode : '',
  109. state: typeof raw?.state === 'string' ? raw.state : '',
  110. stateReason: typeof raw?.stateReason === 'string' ? raw.stateReason : '',
  111. recordView: raw?.recordView || null,
  112. }
  113. }
  114. /**
  115. * 把一个后端分区翻成前端分区。
  116. * `data` 的转换按分区名分派;`null` 数据(失败/空)不调用转换函数。
  117. * @param {string} name EQUIPMENT_SECTIONS 之一
  118. * @param {object} raw 后端 `CockpitSectionResult`
  119. */
  120. export function toSection(name, raw) {
  121. const reader = fieldReader(raw, name)
  122. const state = sectionStateFromBackend(reader.optionalStr('state'))
  123. const reason = reader.optionalStr('reason')
  124. const message = reader.optionalStr('message')
  125. const payload = reader.raw.data
  126. // 失败/空分区可能仍带 data(后端保留旧值);只有真有值才转换。
  127. const hasPayload = payload !== undefined && payload !== null
  128. let data = null
  129. if (hasPayload) {
  130. data = convertSectionData(name, payload)
  131. }
  132. return section(state, {
  133. reason: message || reason,
  134. data,
  135. freshness: readSectionFreshness(name, payload),
  136. // 过期分区保留的是旧值:票 16 要求界面带过期标识而不是当实时值。
  137. retained: state === SECTION_STATE.STALE,
  138. })
  139. }
  140. function readSectionFreshness(name, payload) {
  141. if (name === 'metrics' && payload && typeof payload === 'object') {
  142. return String(payload.state || '')
  143. }
  144. return ''
  145. }
  146. function convertSectionData(name, payload) {
  147. if (name === 'archive') return toEquipmentArchive(payload)
  148. if (name === 'metrics') return toMetricsPayload(payload)
  149. if (name === 'warnings') return toList(payload, toWarningEvent)
  150. if (name === 'alarms') return toList(payload, toAlarmEvent)
  151. if (name === 'alarmWarningRelations') return toList(payload, toAlarmWarningRelation)
  152. if (name === 'workOrders') return toList(payload, toWorkOrder)
  153. if (name === 'timelines') return toList(payload, toWorkOrderTimeline)
  154. if (name === 'latestImage') return toDeviceImage(payload)
  155. // 后端加了新分区而前端还不认识:原样带出,不静默丢弃。
  156. return payload
  157. }
  158. function toList(payload, mapper) {
  159. if (!Array.isArray(payload)) return []
  160. return payload.map(mapper)
  161. }
  162. /**
  163. * 设备详情聚合 → 领域对象。每个分区各自成 `section()`,互不牵连。
  164. * @param {object} raw 后端 `CockpitEquipmentDetailAggregateResponse`
  165. */
  166. export function toEquipmentDetailAggregate(raw) {
  167. const base = fieldReader(raw)
  168. const sections = {}
  169. for (const name of EQUIPMENT_SECTIONS) {
  170. sections[name] = toSection(name, base.raw[name])
  171. }
  172. return {
  173. deviceCode: base.optionalStr('deviceCode'),
  174. // 后端给的整体态:只作为参考带出,界面分区状态以各分区自己的为准
  175. // (这样即使整体态漏标,成功分区也不会被拖成不可用)。
  176. aggregateState: base.optionalStr('aggregateState'),
  177. serverTime: base.optionalStr('serverTime'),
  178. sections,
  179. missing: base.missing,
  180. }
  181. }
  182. /** 设备新鲜度分区(后端 `freshness` 在独立端点上,不是聚合的一部分)。 */
  183. export function toDeviceFreshnessSection(raw) {
  184. const freshness = toFreshness(raw)
  185. const state = !freshness.freshness
  186. ? SECTION_STATE.FAILED
  187. : freshness.freshness === 'EXPIRED' || freshness.freshness === 'STALE'
  188. ? SECTION_STATE.STALE
  189. : freshness.freshness === 'EMPTY'
  190. ? SECTION_STATE.EMPTY
  191. : SECTION_STATE.SUCCESS
  192. return section(state, {
  193. reason: freshness.staleReason,
  194. data: freshness,
  195. freshness: freshness.freshness,
  196. retained: freshness.retained,
  197. })
  198. }