eventList.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. /**
  2. * 事件列表与预警/报警切换(票 13)的领域层。
  3. *
  4. * 数据源是**跨设备** `/events/current`(后端 `CockpitCurrentEventsResponse`,
  5. * 见 `E:\pipe-ner-server\docs\cockpit\README.md` 与 `CockpitCurrentEventsService`):
  6. * `mode`(WARNING|ALARM)必填并校验;`sections.events` 是
  7. * `CockpitSectionResult<List<EventItem>>`(**预警与报警永不混合**,mode 决定取哪一族);
  8. * 行内另带 `sections.workOrders`(工单摘要分区,本票不画、留给票 19)。
  9. *
  10. * 与设备维度 `/equipments/{code}/events/current`(`CockpitEventReadResponse`)不是
  11. * 同一个契约:设备维度一次带回 warnings+alarms 两族,本层只认跨设备的这一份。
  12. * `domain/events.js` 的 `toEventRead` 留在设备维度用,不在这里重复实现。
  13. *
  14. * 四条硬纪律(都是本票验收原句,收口在这里、组件不再自己判):
  15. * 1. **预警与报警可切换,列表字段和状态标签同步变化**(验收第 1 条):
  16. * `eventListSwitchView(mode)` 是**唯一**的切换视图 —— 字段标签(预警编号/报警编号)
  17. * 与状态标签名(预警状态/报警状态)从同一个对象取,切换时必然一起变,
  18. * 不会出现"字段是报警的、标签还是预警的"(结构上不可能)。
  19. * 2. **事件等级使用 1-4 级颜色并有文字辅助**(验收第 3 条):
  20. * `eventLevelTag(level)` 走 `statusTones.js` 的 `LEVEL_TAGS`(lv1..lv4 + '1级'..'4级'),
  21. * 与点位状态色(`pointStatus.js`)不是同一张表;认不出的等级落中性灰 + 文字,不猜。
  22. * 3. **事件不创建独立地图标记**(验收第 4 条):本层不产生任何标记计划、不 import
  23. * 引擎/地图服务;事件只带 `deviceCode` / `monitoringPointCode` 供票 14 按设备回联位置。
  24. * 4. **不判断预警真伪或误报**(验收第 5 条):后端契约明确「系统不判断预警真伪,
  25. * 没有任何准确率、误报率字段」(README 第 2 节),本层不发明任何判定,状态照原样带出。
  26. *
  27. * 事件 `status` 是后端**未冻结**的自由字符串(现网取值含 PENDING/PROCESSING/HANDLING 等,
  28. * 见 `BusinessCockpitDeviceFactResolver.OPEN_WARNING_STATUS`;契约测试用 ACTIVE/PENDING/
  29. * PROCESSING)。与票 17 的 `scale` 同一处境:直译已知取值,**认不出的回原码**(它是数据值
  30. * 不是原因码,照原样显示才是诚实的,CODE_TEXT 的立场),并由逻辑闸门守「登记码不得等于原码」。
  31. *
  32. * 本文件是纯函数层:不 import service、不碰 DOM、不自己发请求(t05 的 D3/D4 守卫盯着)。
  33. */
  34. import { fieldReader, toLocation } from './fields.js'
  35. import { SECTION_STATE, section, sectionStateFromBackend } from './sectionState.js'
  36. import { EVENT_FAMILY } from './events.js'
  37. import { LEVEL_TAGS, DEFAULT_TONE } from './statusTones.js'
  38. import { DETAIL_PLACEHOLDER } from './detailFlow.js'
  39. import { workOrderStateName } from './workOrder.js'
  40. /** 事件列表默认展示的最新条数(验收第 2 条「默认展示最新 10-15 条」的中值)。 */
  41. export const DEFAULT_EVENT_LIMIT = 12
  42. /** 事件 `status` 的直译表(已知取值,来源见文件头;认不出的回原码)。 */
  43. export const EVENT_STATUS_TEXT = {
  44. ACTIVE: '进行中',
  45. PENDING: '待处理',
  46. PROCESSING: '处理中',
  47. HANDLING: '处置中',
  48. }
  49. /** 预警/报警的切换视图(**唯一**一份:字段标签与状态标签从同一对象取,切换必然同步)。 */
  50. export function eventListSwitchView(mode) {
  51. if (mode === EVENT_FAMILY.ALARM) {
  52. return {
  53. key: EVENT_FAMILY.ALARM,
  54. label: '报警',
  55. title: '当前报警',
  56. codeLabel: '报警编号',
  57. nameLabel: '报警名称',
  58. statusLabel: '报警状态',
  59. }
  60. }
  61. return {
  62. key: EVENT_FAMILY.WARNING,
  63. label: '预警',
  64. title: '当前预警',
  65. codeLabel: '预警编号',
  66. nameLabel: '预警名称',
  67. statusLabel: '预警状态',
  68. }
  69. }
  70. /** 两个切换选项(组件遍历它画分段按钮;顺序固定:预警在前、报警在后)。 */
  71. export function eventListSwitchOptions() {
  72. return [eventListSwitchView(EVENT_FAMILY.WARNING), eventListSwitchView(EVENT_FAMILY.ALARM)]
  73. }
  74. /** 当前切换键是否合法(不合法就不该拿去换后端 400)。 */
  75. export function isKnownEventMode(mode) {
  76. return mode === EVENT_FAMILY.WARNING || mode === EVENT_FAMILY.ALARM
  77. }
  78. /**
  79. * 事件等级 → 色键 + 文字(验收第 3 条:颜色 + 文字辅助)。
  80. *
  81. * 事件等级 1-4 对应 `LEVEL_TAGS`(lv1..lv4,标签 '1级'..'4级')—— 与点位状态色
  82. * 分离是结构性的:本函数只从 `statusTones.js` 的等级表取,不碰 `pointStatus.js`。
  83. * 认不出的等级落 `DEFAULT_TONE`(中性灰)+ 文字「等级未知」,不猜成某个真实等级。
  84. * @param {number|null|undefined} level 后端 `EventItem.level`(1-4)
  85. */
  86. export function eventLevelTag(level) {
  87. const tag = Number.isInteger(level) && level >= 1 && level <= 4 ? LEVEL_TAGS[level - 1] : null
  88. return tag ? { tone: tag.tone, label: tag.label } : { tone: DEFAULT_TONE, label: '等级未知' }
  89. }
  90. /**
  91. * 事件 `status` → 展示文本。已知取值直译;**认不出的回原码**(数据值照原样显示才是
  92. * 诚实的,与 `CODE_TEXT` 同一立场);空串回空串(不把"没有状态"画成某个状态)。
  93. */
  94. export function eventStatusText(status) {
  95. const value = typeof status === 'string' ? status.trim() : ''
  96. if (!value) return ''
  97. return EVENT_STATUS_TEXT[value] || value
  98. }
  99. /**
  100. * ISO 时间戳 → 列表列的短读法(2026-10-13 维护者反馈:全串把右侧列挤出可视区被裁)。
  101. * `2026-09-02T10:00:00+08:00` → `09-02 10:00`;解析不了回原串(不丢信息、不编造)。
  102. * 只影响显示;排序仍走 `eventTime` 原值(字典序即时间序,ISO 偏移一致时成立)。
  103. */
  104. export function shortEventTime(value) {
  105. const text = typeof value === 'string' ? value.trim() : ''
  106. if (!text) return ''
  107. const matched = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(text)
  108. if (!matched) return text
  109. const [, , month, day, hour, minute] = matched
  110. return `${month}-${day} ${hour}:${minute}`
  111. }
  112. /** 有内容可画的分区状态(过期是有值,内容照画)。 */
  113. const CONTENT_STATES = [SECTION_STATE.SUCCESS, SECTION_STATE.STALE]
  114. /** 该事件分区有没有内容可画。 */
  115. export function eventSectionHasContent(section) {
  116. return CONTENT_STATES.includes((section && section.state) || '')
  117. }
  118. /**
  119. * 一条 `EventItem` → 领域行(票 13 的列表行;票 14 的事件详情在其上扩展)。
  120. *
  121. * 位置只以 `deviceCode` / `monitoringPointCode` 带出,**不在这里做任何地图标记**。
  122. * 经纬度与 `coordinate` 仍保留给票 14(按坐标回联/详情),但本票不消费 renderable。
  123. * @param {object} raw 后端 `CockpitCurrentEventsResponse.EventItem`
  124. */
  125. export function toEventItem(raw) {
  126. const reader = fieldReader(raw, 'eventItem')
  127. return {
  128. // 统一键:预警与报警都用 eventNo(契约里两族同一字段名,不存在同名冲突)。
  129. code: reader.str('eventNo'),
  130. name: reader.optionalStr('eventName'),
  131. type: reader.optionalStr('eventType'),
  132. level: reader.optionalNum('level'),
  133. levelColor: reader.optionalStr('levelColor'),
  134. status: reader.optionalStr('status'),
  135. eventTime: reader.optionalStr('eventTime'),
  136. updatedTime: reader.optionalStr('updatedTime'),
  137. value: reader.optionalNum('value'),
  138. unit: reader.optionalStr('unit'),
  139. // 设备编码是回联位置的键(票 14);缺失时留空,不伪造。
  140. deviceCode: reader.optionalStr('deviceCode'),
  141. deviceName: reader.optionalStr('deviceName'),
  142. monitoringPointCode: reader.optionalStr('monitoringPointCode'),
  143. monitoringPointName: reader.optionalStr('monitoringPointName'),
  144. location: reader.optionalStr('location'),
  145. industry: reader.optionalStr('industry'),
  146. thresholdMinValue: reader.optionalNum('thresholdMinValue'),
  147. thresholdMaxValue: reader.optionalNum('thresholdMaxValue'),
  148. // 坐标校验(BT-4 后事件行携带;本票不画地图,只留作票 14 的输入)。
  149. coordinate: toLocation(raw && raw.coordinateValidation),
  150. // 工单摘要(`CockpitCurrentEventsResponse.WorkOrderSummary`;列表行只带编码与状态名,
  151. // 其余留给票 14/19 的详情)。
  152. workOrder: toWorkOrderBrief(raw && raw.workOrderSummary),
  153. missing: reader.missing,
  154. }
  155. }
  156. /** 工单摘要 → 领域短行(缺整段就 null,不画空对象冒充)。 */
  157. export function toWorkOrderBrief(raw) {
  158. if (!raw || typeof raw !== 'object') return null
  159. const reader = fieldReader(raw, 'workOrderSummary')
  160. return {
  161. code: reader.optionalStr('workOrderCode'),
  162. stateCode: reader.optionalNum('stateCode'),
  163. stateName: reader.optionalStr('stateName') || workOrderStateName(reader.optionalNum('stateCode')),
  164. }
  165. }
  166. /**
  167. * 后端 `CockpitSectionResult<List<EventItem>>` → 前端 `section()`(分区状态 1:1)。
  168. * 失败/空分区 `data` 为 null;成功分区 `data` 是领域行数组。
  169. */
  170. export function toEventSection(raw) {
  171. const reader = fieldReader(raw, 'eventsSection')
  172. const state = sectionStateFromBackend(reader.optionalStr('state'))
  173. const reason = reader.optionalStr('reason')
  174. const message = reader.optionalStr('message')
  175. const payload = reader.raw.data
  176. const hasPayload = Array.isArray(payload)
  177. return section(state, {
  178. reason: message || reason,
  179. data: hasPayload ? payload.map(toEventItem) : null,
  180. })
  181. }
  182. /**
  183. * `/events/current` 响应 → 领域对象(票 13 唯一入口)。
  184. *
  185. * `sections` 刻意不含后端裸键名:`workOrders` 分区在领域里叫 `orders`
  186. * (票 05 的静态守卫只认 `workOrders` 为后端字段名,组件/页面里出现即视为泄漏)。
  187. *
  188. * @param {object} raw 后端 `CockpitCurrentEventsResponse`
  189. */
  190. export function toCurrentEvents(raw) {
  191. const reader = fieldReader(raw, 'currentEvents')
  192. const sections = reader.obj('sections')
  193. const filters = reader.optionalObj('filters') || {}
  194. const pagination = reader.optionalObj('pagination') || {}
  195. const mapFilter = reader.optionalObj('mapFilter') || {}
  196. return {
  197. mode: reader.optionalStr('mode') || EVENT_FAMILY.WARNING,
  198. displayName: reader.optionalStr('displayName'),
  199. updatedAt: reader.optionalStr('updatedAt'),
  200. filters: {
  201. mode: filters.mode || '',
  202. level: typeof filters.level === 'number' ? filters.level : null,
  203. industry: filters.industry || '',
  204. status: filters.status || '',
  205. },
  206. pagination: {
  207. page: pagination.page,
  208. pageSize: pagination.pageSize,
  209. total: pagination.total,
  210. totalPages: pagination.totalPages,
  211. hasMore: pagination.hasMore === true,
  212. },
  213. mapFilter: {
  214. mode: mapFilter.mode || '',
  215. level: typeof mapFilter.level === 'number' ? mapFilter.level : null,
  216. industry: mapFilter.industry || '',
  217. status: mapFilter.status || '',
  218. deviceCodes: Array.isArray(mapFilter.deviceCodes) ? mapFilter.deviceCodes : [],
  219. },
  220. sections: {
  221. // 事件分区(本票渲染它;`orders` 留给票 19 的「当前预警」工单块)。
  222. events: toEventSection(sections.events),
  223. orders: toEventSection(sections && sections.workOrders),
  224. },
  225. missing: reader.missing,
  226. }
  227. }
  228. /**
  229. * 事件列表行视图(组件只遍历它,不认后端字段)。
  230. *
  231. * 等级标签与状态文本都从领域行现算 —— 组件里不再出现第二份映射。
  232. * @param {object} eventItem `toEventItem()` 的结果
  233. */
  234. export function eventRowView(eventItem) {
  235. const item = eventItem || {}
  236. const tag = eventLevelTag(item.level)
  237. const quantity =
  238. typeof item.value === 'number' && Number.isFinite(item.value)
  239. ? item.unit
  240. ? `${item.value} ${item.unit}`
  241. : String(item.value)
  242. : DETAIL_PLACEHOLDER
  243. return {
  244. key: item.code || DETAIL_PLACEHOLDER,
  245. // 切换后的字段标签(预警/报警各自一套,来自切换视图 —— 与状态标签同步变化)。
  246. code: item.code,
  247. name: item.name || item.code || DETAIL_PLACEHOLDER,
  248. type: item.type || '',
  249. level: { tone: tag.tone, label: tag.label },
  250. statusText: eventStatusText(item.status),
  251. eventTime: item.eventTime || '',
  252. // 列表列的短读法(2026-10-13 维护者反馈:ISO 全串把右侧列挤出可视区被裁)。
  253. // 只截断显示、不改排序键(排序仍用 eventTime 原值);解析不了回原串。
  254. eventTimeText: shortEventTime(item.eventTime),
  255. updatedTime: item.updatedTime || '',
  256. quantity,
  257. value: item.value,
  258. unit: item.unit || '',
  259. deviceCode: item.deviceCode || '',
  260. deviceName: item.deviceName || '',
  261. monitoringPointCode: item.monitoringPointCode || '',
  262. monitoringPointName: item.monitoringPointName || '',
  263. location: item.location || '',
  264. industry: item.industry || '',
  265. thresholdMinValue: item.thresholdMinValue,
  266. thresholdMaxValue: item.thresholdMaxValue,
  267. coordinate: item.coordinate || null,
  268. workOrderCode: (item.workOrder && item.workOrder.code) || '',
  269. workOrderStateName: (item.workOrder && item.workOrder.stateName) || '',
  270. }
  271. }
  272. /** 刷新未返回所选事件时保留身份,但明确标注旧数据,不据缺页推断已处置。 */
  273. export function refreshedEventSelection(selected, domain) {
  274. if (!selected) return null
  275. const events = domain && domain.sections && domain.sections.events
  276. const found = events && Array.isArray(events.data) && events.data.find((row) => row.code === selected.code)
  277. if (found && eventSectionHasContent(events)) return { ...eventRowView(found), refreshNotice: '' }
  278. return { ...selected, refreshNotice: '当前列表未返回该事件,详情为上次读取的数据' }
  279. }
  280. /**
  281. * 事件分区 → 列表行(最新默认条数在前端收口:`latestEventRows` 取排序后的前 N 条)。
  282. * @param {object} eventsSection `toCurrentEvents().sections.events`
  283. */
  284. export function eventListRows(eventsSection) {
  285. const list = (eventsSection && eventsSection.data) || []
  286. return list.map(eventRowView)
  287. }
  288. /**
  289. * 取最新 N 条(验收第 2 条)。排序口径与后端 `CockpitCurrentEventsService.eventOrdering()`
  290. * 一致:eventTime 倒序(缺时间的排最后),同时间按 eventNo 升序 —— 前端不发明第二套排序。
  291. * @param {Array} items 领域事件数组(未排序)
  292. * @param {number} [limit] 条数上限(默认 `DEFAULT_EVENT_LIMIT`)
  293. */
  294. export function latestEventRows(items, limit = DEFAULT_EVENT_LIMIT) {
  295. const sorted = [...(items || [])].sort((left, right) => {
  296. const l = (left && left.eventTime) || ''
  297. const r = (right && right.eventTime) || ''
  298. if (l && r && l !== r) return l > r ? -1 : 1
  299. if (l && !r) return -1
  300. if (!l && r) return 1
  301. const lc = (left && left.code) || ''
  302. const rc = (right && right.code) || ''
  303. if (lc && rc && lc !== rc) return lc < rc ? -1 : 1
  304. return 0
  305. })
  306. const cap = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_EVENT_LIMIT
  307. return sorted.slice(0, cap)
  308. }
  309. /**
  310. * 当前预警面板的视图计划(票 13 在 `currentEvents` 面板的挂载内容)。
  311. *
  312. * `eventsSection` 是**事件分区**(不是整个响应分区):状态栏/占位措辞说的都是"这批事件"
  313. * 有没有数据,工单分区成败不影响它(票 11/18 的分区独立纪律)。
  314. *
  315. * 两个入参的分工:`domain` 携带已转换的事件数组(成功时),`eventsSection` 携带
  316. * 分区自己的状态(pending / empty / failed / unavailable)。失败/空时 `domain` 为
  317. * null,但分区状态仍在 —— 单独传分区才能说对"为什么没有数据"。
  318. * @param {object|null} domain `toCurrentEvents()` 的结果(成功时有值)
  319. * @param {object} [eventsSection] 事件分区(`section()`;缺省从 domain 里取)
  320. */
  321. export function currentEventsView(domain, eventsSection) {
  322. const current = domain || null
  323. const sectionValue = eventsSection || (current && current.sections && current.sections.events) || null
  324. const switchView = eventListSwitchView((current && current.mode) || EVENT_FAMILY.WARNING)
  325. const hasContent = eventSectionHasContent(sectionValue)
  326. // 还没取过数(分区未 loaded)时说「正在取数」,不说「没有数据」——
  327. // 那是在报告一件还没问过的事(与 panelBlocks 的 sectionPending 同一立场)。
  328. const pending = !!sectionValue && sectionValue.loaded === false
  329. const items = (sectionValue && sectionValue.data) || []
  330. // 行必须转成**视图行**(`eventRowView`:等级 tag、状态文本、量值、设备名),
  331. // 组件只认视图行 —— 直接把领域行交给组件会让 level.tone/statusText/quantity 全是
  332. // undefined(逻辑闸门只数行数抓不到,真机走查才会红;这里收口)。
  333. const rows = latestEventRows(items).map(eventRowView)
  334. const placeholder = hasContent
  335. ? ''
  336. : pending
  337. ? '正在取数…'
  338. : !sectionValue
  339. ? '该分区加载失败(其它分区不受影响)'
  340. : sectionValue.state === SECTION_STATE.EMPTY
  341. ? '当前没有事件'
  342. : sectionValue.state === SECTION_STATE.UNAVAILABLE
  343. ? '该分区数据不可用(其它分区不受影响)'
  344. : '该分区加载失败(其它分区不受影响)'
  345. return {
  346. switchView,
  347. section: sectionValue,
  348. hasContent,
  349. rows,
  350. total: (sectionValue && sectionValue.data ? sectionValue.data.length : 0) || (current && current.pagination && current.pagination.total) || 0,
  351. limited: !!sectionValue && hasContent && items.length > rows.length,
  352. placeholder,
  353. pagination: (current && current.pagination) || null,
  354. updatedAt: (current && current.updatedAt) || '',
  355. }
  356. }