detailFlow.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. /**
  2. * 两级详情(票 10;CONTEXT.md「两级详情」):地图/台账 → 点位摘要 + 设备卡片 → 设备详情。
  3. *
  4. * 为什么单独成 domain 模块:这是一条**导航状态机**(先点位、再设备),与「取数成没成」
  5. * (`domain/sectionState.js`)和「地图上画什么」(`domain/pointStatus.js`)都不是一回事。
  6. * 把它放这里,store 只管取数、组件只管画,逻辑脚本能脱浏览器把每一级与每一次返回都走一遍。
  7. *
  8. * 「关联设备不作为独立地图标记重复渲染」在这里是**结构性**的:本模块只产出选择与卡片视图,
  9. * 没有任何一条路径会把设备写回地图标记计划 —— 地图对象永远只来自 `mapStore` 的地图摘要
  10. * (后端只给「监测点 + 未关联设备」,关联设备根本不在那份载荷里)。
  11. */
  12. import { legendIconFor, visualForStatus } from './pointStatus.js'
  13. // 票 44:设备状态必须走**独立**的设备词表,不得借用点位表(决策票 07/10 的边界)。
  14. import { deviceVisualForStatus } from './deviceStatus.js'
  15. /** 详情层级(也是 `data-detail-level` 的取值)。 */
  16. export const DETAIL_LEVEL = {
  17. NONE: 'none',
  18. POINT: 'point',
  19. EQUIPMENT: 'equipment',
  20. }
  21. /** 缺失值的占位符:界面显示它,而不是把「没给」画成 0 或空白(票 16 的立场)。 */
  22. export const DETAIL_PLACEHOLDER = '—'
  23. /** 地图对象的两种来源(与 `domain/mapSummary.js` 的 `MAP_OBJECT_KIND` 同名,见那里的定义)。 */
  24. const KIND_MONITORING_POINT = 'monitoring-point'
  25. const KIND_UNASSOCIATED_EQUIPMENT = 'unassociated-equipment'
  26. /** 空选择:什么都没打开(界面据此不渲染详情浮层)。 */
  27. export function emptyDetailSelection() {
  28. return { level: DETAIL_LEVEL.NONE, pointCode: '', deviceCode: '' }
  29. }
  30. /**
  31. * 地图对象 → 详情选择。
  32. *
  33. * 监测点进**第一级**(点位摘要 + 设备卡片);未关联设备没有点位可摘要,
  34. * 直接进**第二级**(设备详情)——「优先以监测点作为可点击地图对象」(CONTEXT.md)
  35. * 不等于「别的对象点了没反应」。认不出的对象返回 `null`:不猜、也不打开一张空卡片。
  36. * @param {object} object 地图对象(`domain/mapSummary.js` 的形状)
  37. */
  38. export function selectionFromMapObject(object) {
  39. const item = object || {}
  40. const code = item.code || ''
  41. if (!code) return null
  42. if (item.kind === KIND_MONITORING_POINT) {
  43. return { level: DETAIL_LEVEL.POINT, pointCode: code, deviceCode: '' }
  44. }
  45. if (item.kind === KIND_UNASSOCIATED_EQUIPMENT) {
  46. return { level: DETAIL_LEVEL.EQUIPMENT, pointCode: '', deviceCode: code }
  47. }
  48. return null
  49. }
  50. /** 打开某个监测点:回到第一级,并把设备选择清掉(换点位不该留着上一台的选中态)。 */
  51. export function openPoint(selection, pointCode) {
  52. if (!pointCode) return selection || emptyDetailSelection()
  53. return { level: DETAIL_LEVEL.POINT, pointCode, deviceCode: '' }
  54. }
  55. /**
  56. * 选择设备卡片:进第二级。点位编码**保留**(返回第一级要靠它),
  57. * 从地图上的未关联设备进来时 `pointCode` 本来就是空的。
  58. */
  59. export function openDevice(selection, deviceCode) {
  60. const current = selection || emptyDetailSelection()
  61. if (!deviceCode) return current
  62. return { level: DETAIL_LEVEL.EQUIPMENT, pointCode: current.pointCode || '', deviceCode }
  63. }
  64. /**
  65. * 返回第一级。没有点位可回(未关联设备直接进的第二级)时收成空选择 ——
  66. * 「返回点位摘要」不该把用户送到一个并不存在的点位。
  67. */
  68. export function backToPoint(selection) {
  69. const current = selection || emptyDetailSelection()
  70. if (!current.pointCode) return emptyDetailSelection()
  71. return { level: DETAIL_LEVEL.POINT, pointCode: current.pointCode, deviceCode: '' }
  72. }
  73. /**
  74. * 关闭详情:回到空选择(界面据此收掉浮层)。
  75. */
  76. export function closeDetail() {
  77. return emptyDetailSelection()
  78. }
  79. /**
  80. * 同一个点位目标是否已经打开(含已经选到设备的那一级)。
  81. * store 用它决定「点同一个点位不必再取一次数」,纯函数便于断言。
  82. */
  83. export function isSamePointTarget(selection, pointCode) {
  84. const current = selection || emptyDetailSelection()
  85. if (!pointCode) return false
  86. return current.pointCode === pointCode && current.level !== DETAIL_LEVEL.NONE
  87. }
  88. /**
  89. * 后端状态 + 后端标签 → 标签视图(**监测点**用;标签优先用后端给的词)。
  90. *
  91. * 注意与 `deviceStatusView()` 的分工:本函数读的是**点位**状态词表,
  92. * 给设备配色必须走 `deviceStatusView()` —— 拿点位表给设备配色是决策票 07/10 点名的越界。
  93. *
  94. * `icon`(票 46 返工第 2 条):走 `legendIconFor` **单源接缝** —— 详情里的状态标签与
  95. * 一张图顶部状态栏共用同一份图标登记与同一张词表(色键/形状/文字同源)。
  96. */
  97. export function statusView(status, labelOverride = '') {
  98. const visual = visualForStatus(status)
  99. return {
  100. key: visual.key,
  101. tone: visual.tone,
  102. shape: visual.shape,
  103. label: labelOverride || visual.label,
  104. icon: legendIconFor(visual.key),
  105. }
  106. }
  107. /**
  108. * 后端设备状态 + 标签 → 标签视图(票 44 修正越界)。
  109. *
  110. * 为什么必须另立一个函数:早先设备卡片也走 `statusView()`,于是**设备状态被拿点位表解释** ——
  111. * 而两张表的取值集不同(点位有 `EMPTY`「无设备」,设备五态**没有**),
  112. * 一旦后端给设备回一个点位专有的状态,界面会画出一个设备根本不可能有的态。
  113. * 现在设备的色/形/文字一律来自 `domain/deviceStatus.js`(独立平行表)。
  114. *
  115. * `icon`(票 46 返工第 2 条):与顶部状态栏同源(`legendIconFor`,设备与点位词表的键同名,
  116. * 五态 + 未发布都有登记);**取值仍走设备表**,只借图标登记,不借状态语义。
  117. */
  118. export function deviceStatusView(status, labelOverride = '') {
  119. const visual = deviceVisualForStatus(status)
  120. return {
  121. key: visual.key,
  122. tone: visual.tone,
  123. shape: visual.shape,
  124. label: labelOverride || visual.label,
  125. icon: legendIconFor(visual.key),
  126. }
  127. }
  128. /**
  129. * 状态原因 → 一句话。
  130. *
  131. * 两种形状都吃:点位详情是 `statusReasons[{status,deviceCode,reason}]`,
  132. * 设备卡片是 `statusReasons: string[]`。去重保序(后端顺序即优先级顺序,前端不重排)。
  133. * 一个原因都没有时回空串 —— 界面显示占位符,**不**写成「正常」。
  134. */
  135. export function reasonText(reasons) {
  136. const list = Array.isArray(reasons) ? reasons : []
  137. const texts = list
  138. .map((item) => (typeof item === 'string' ? item : (item && item.reason) || ''))
  139. .map((text) => String(text).trim())
  140. .filter(Boolean)
  141. return [...new Set(texts)].join(';')
  142. }
  143. /**
  144. * 关联设备数量。
  145. *
  146. * 后端 `deviceStatistics.totalDeviceCount` 是权威值(含「确实 0 台」);
  147. * 整段统计缺失(`statistics.missing` 记着,见 `domain/pointDetail.js`)时退回
  148. * 「已返回的卡片数」,而不是把「没给统计」显示成 0 台。
  149. * @param {object} detail `toMonitoringPointDetail()` 的领域对象
  150. */
  151. export function associatedDeviceCount(detail) {
  152. const current = detail || {}
  153. const statistics = current.statistics || null
  154. const statisticsGiven = statistics && Array.isArray(statistics.missing) && statistics.missing.length === 0
  155. if (statisticsGiven && Number.isFinite(statistics.total)) return statistics.total
  156. return Array.isArray(current.devices) ? current.devices.length : 0
  157. }
  158. /**
  159. * 设备卡片 → 卡片视图。顺序由调用方保持(`deviceCards()` 不排序)。
  160. * @param {object} card `toDeviceCard()` 的领域对象
  161. */
  162. export function deviceCardView(card) {
  163. const current = card || {}
  164. return {
  165. code: current.code || DETAIL_PLACEHOLDER,
  166. name: current.name || DETAIL_PLACEHOLDER,
  167. typeName: current.typeName || '',
  168. // 票 44 修正越界:**设备**状态走设备词表(原先是 `statusView`,即拿点位表解释设备)。
  169. status: deviceStatusView(current.status, current.statusLabel),
  170. reason: reasonText(current.statusReasons),
  171. statusTime: current.statusTime || '',
  172. }
  173. }
  174. /**
  175. * 设备卡片列表 —— **原样保持后端返回顺序**(票 10 验收第 3 条)。
  176. * 这里刻意不排序、不按状态优先级重排:顺序是后端的业务语义(谁重要由后端说)。
  177. * @param {object} detail `toMonitoringPointDetail()` 的领域对象
  178. */
  179. export function deviceCards(detail) {
  180. const devices = (detail && detail.devices) || []
  181. return devices.map(deviceCardView)
  182. }
  183. /**
  184. * 点位摘要(第一级的五行):名称 / 编码 / 状态 / 原因 / 关联设备数量。
  185. * @param {object} detail `toMonitoringPointDetail()` 的领域对象
  186. */
  187. export function pointSummary(detail) {
  188. const current = detail || {}
  189. return {
  190. name: current.name || DETAIL_PLACEHOLDER,
  191. code: current.code || DETAIL_PLACEHOLDER,
  192. status: statusView(current.status, current.statusLabel),
  193. reason: reasonText(current.statusReasons),
  194. deviceCount: associatedDeviceCount(current),
  195. }
  196. }
  197. /**
  198. * 点位设备计数 KPI 行(票 46 范围第 1 条:能用 KPI/状态卡表达的不再是大段纯文字)。
  199. *
  200. * 数据来源是后端 `deviceStatistics`(票 05 已适配成 `statistics`),前端**不重算**:
  201. * · 统计整段缺失(`statistics.missing` 非空)→ 回 `null`,界面**不画 KPI 行**,
  202. * 而不是画一排 0 ——「没给统计」与「各态都是 0 台」是两件事(票 16 的虚假零值立场);
  203. * · 计数 0 是真实值(「确实没有报警设备」),照原样画。
  204. * 色键/文字走**设备词表**(`deviceStatusView`),不借点位表 —— 这里数的是设备。
  205. * @param {object} detail `toMonitoringPointDetail()` 的领域对象
  206. */
  207. export function pointDeviceCountsView(detail) {
  208. const statistics = (detail && detail.statistics) || null
  209. if (!statistics || !Array.isArray(statistics.missing) || statistics.missing.length > 0) return null
  210. const rows = [
  211. { key: 'ALARM', label: '报警', count: statistics.alarm },
  212. { key: 'WARNING', label: '预警', count: statistics.warning },
  213. { key: 'WORK_ORDER', label: '工单处置', count: statistics.workOrder },
  214. { key: 'NORMAL', label: '正常', count: statistics.normal },
  215. { key: 'OFFLINE', label: '离线', count: statistics.offline },
  216. ]
  217. return rows.map((row) => ({
  218. ...row,
  219. // 色键走**设备词表**(数的是设备,不借点位表 —— 决策票 07/10 的边界,票 44 已收口)。
  220. tone: deviceStatusView(row.key).tone,
  221. }))
  222. }
  223. /** 从已加载的点位详情里取某台设备的卡片视图(第二级的表头用它;找不到回 null)。 */
  224. export function deviceCardOf(detail, deviceCode) {
  225. if (!deviceCode) return null
  226. const cards = deviceCards(detail)
  227. return cards.find((card) => card.code === deviceCode) || null
  228. }