eventList.js 19 KB

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