warningResponsePanels.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  1. /**
  2. * 预警处置模式的三块聚合面板(票 19)。
  3. *
  4. * 所有转换都是纯函数:组件只消费 view,store 只负责请求与共享 mode/range。
  5. * 预警/报警沿用票 13 的 mode;历史、效率、分析三块不各自维护一套切换状态。
  6. */
  7. import { fieldReader } from './fields.js'
  8. import { eventLevelTag, eventListSwitchView, toEventItem } from './eventList.js'
  9. import { valueText, workOrderRows } from './equipmentDetailView.js'
  10. import { EVENT_FAMILY } from './events.js'
  11. import { WORK_ORDER_STATES } from './workOrder.js'
  12. // 票 67:全局行业筛选**作用不到**全量工单端点(实测不收该入参),措辞取单一来源。
  13. import { industryGap } from './filterCaliber.js'
  14. import {
  15. barsBlock,
  16. donutBlock,
  17. kpiBlock,
  18. listBlock,
  19. numberish,
  20. rowsBlock,
  21. sectionHasContent,
  22. sectionPlaceholder,
  23. sortByCountDesc,
  24. stackedBlock,
  25. trendBlock,
  26. } from './panelBlocks.js'
  27. export const WARNING_RESPONSE_PANEL_IDS = ['historyAnalysis', 'responseEfficiency', 'eventAnalysis']
  28. export const EVENT_RANGES = [
  29. { key: '7d', label: '近 7 天', type: 'LAST_7_DAYS' },
  30. { key: '30d', label: '近 30 天', type: 'LAST_30_DAYS' },
  31. { key: '90d', label: '近 90 天', type: 'LAST_90_DAYS' },
  32. ]
  33. const RANGE_TYPES = Object.fromEntries(EVENT_RANGES.map((item) => [item.type, item.key]))
  34. function list(raw, key) {
  35. const value = raw && raw[key]
  36. return Array.isArray(value) ? value : []
  37. }
  38. function countRows(rows, key = 'count') {
  39. return list(rows, key).map((item) => {
  40. const reader = fieldReader(item, 'eventAnalysisRow')
  41. return {
  42. code: reader.optionalStr('code') || reader.optionalStr('name') || reader.optionalStr('label'),
  43. label: reader.optionalStr('name') || reader.optionalStr('label') || reader.optionalStr('code'),
  44. count: numberish(reader.raw.count) || 0,
  45. }
  46. })
  47. }
  48. function levelRows(rows) {
  49. return (Array.isArray(rows) ? rows : []).map((item) => {
  50. const level = Number(item && (item.level === undefined ? item.code : item.level))
  51. const tag = eventLevelTag(level)
  52. return { code: String(level), label: tag.label, count: numberish(item && item.count) || 0 }
  53. })
  54. }
  55. function formatDuration(seconds, unit) {
  56. const value = numberish(seconds)
  57. if (value === null) return '—'
  58. if (unit) return `${value} ${{ HOURS: '小时', MINUTES: '分钟', SECONDS: '秒' }[unit] || unit}`
  59. if (value >= 3600) return `${(value / 3600).toFixed(1)} 小时`
  60. if (value >= 60) return `${(value / 60).toFixed(1)} 分钟`
  61. return `${value} 秒`
  62. }
  63. function durationValue(raw, names) {
  64. for (const name of names) {
  65. const candidate = raw && raw[name]
  66. if (candidate && typeof candidate === 'object') return candidate
  67. if (candidate !== undefined && candidate !== null) return { value: candidate }
  68. }
  69. return null
  70. }
  71. function durationView(raw, names) {
  72. const value = durationValue(raw, names)
  73. return value ? formatDuration(value.value, value.unit) : '—'
  74. }
  75. /**
  76. * 处置率 → 界面读数。
  77. *
  78. * **分母为 0 不是 0%**(票 18 的 M3/M14 变异测试立的纪律,票 20 沿用到这里):
  79. * 后端在没有可算事件时回 `state=EMPTY` 且 `value="0.0000"` —— 那个 0 是「没有分母」的占位,
  80. * 照 `value` 直画就是「处置率 0.0%」,而真相是「当前范围内没有可参与计算的事件」。
  81. * 所以先按 `state`/分母收口,不适用时显示占位符。
  82. *
  83. * **票 20 导出**:态势总览也要画处置率,两处必须用同一条判据 ——
  84. * 各写一份就是"同一件纪律两处各自小心",早晚分叉。
  85. * @param {object} current `toHandlingEfficiency()` 的领域对象(或同形对象)
  86. */
  87. export function handlingRateView(current) {
  88. const row = current || {}
  89. const unavailable = row.handlingState === 'EMPTY' || (typeof row.denominator === 'number' && row.denominator === 0)
  90. if (unavailable) return '不适用'
  91. if (row.handlingRate === null || row.handlingRate === undefined) return '—'
  92. return `${(row.handlingRate * 100).toFixed(1)}%`
  93. }
  94. /** 处置率不适用时的说明(界面挂在 KPI 的 hint 上,不伪装成 0%)。 */
  95. export function handlingRateHint(current) {
  96. const row = current || {}
  97. if (row.handlingState === 'EMPTY' || (typeof row.denominator === 'number' && row.denominator === 0)) {
  98. return '当前范围没有可计入工单,处置率不适用'
  99. }
  100. return ''
  101. }
  102. function rangeView(raw) {
  103. const range = (raw && raw.range) || {}
  104. const key = RANGE_TYPES[range.type] || ''
  105. return {
  106. key,
  107. type: range.type || '',
  108. start: range.startTime || range.start || '',
  109. end: range.endTime || range.end || '',
  110. }
  111. }
  112. function sectionView(name, section, blocks, extra = {}) {
  113. return {
  114. name,
  115. label: extra.label || name,
  116. section,
  117. hasContent: sectionHasContent(section),
  118. blocks,
  119. range: extra.range || null,
  120. rangeNote: extra.rangeNote || '',
  121. missing: extra.missing || { count: 0, text: '', tooltip: '' },
  122. placeholder: sectionPlaceholder(section),
  123. ...extra,
  124. }
  125. }
  126. /** 历史分析载荷 → 领域对象。历史行沿用当前事件的同一事件行形状。 */
  127. export function toEventHistory(raw) {
  128. const reader = fieldReader(raw, 'eventHistory')
  129. const source = reader.raw.sections && reader.raw.sections.events ? reader.raw.sections.events.data : reader.raw.data
  130. // 票 61:后端新增逐事件行 `events[]`(形状与 events/current 同构);
  131. // 旧的 `sections.events.data` / `data` 形状保留兼容(mock 与历史载荷都吃得下)。
  132. const rawEvents = Array.isArray(reader.raw.events) ? reader.raw.events : source
  133. return {
  134. mode: reader.optionalStr('mode') || reader.optionalStr('eventFamily'),
  135. range: rangeView(raw),
  136. events: Array.isArray(rawEvents) ? rawEvents.map(toEventItem) : [],
  137. trend: Array.isArray(reader.raw.rows)
  138. ? reader.raw.rows.map((row) => ({ bucket: row.timeBucket || '', count: numberish(row.eventCount) || 0 }))
  139. : list(raw, 'trend').map((item) => ({ bucket: String(item && (item.bucket || item.bucketStart || '')), count: numberish(item && item.count) || 0 })),
  140. bucketed: Array.isArray(reader.raw.rows),
  141. pagination: reader.raw.pagination || {},
  142. // 票 61 分页口径(单窗服务端 / 多窗已加载合并集合),由 fetchEventHistory 组装。
  143. paging: reader.raw.paging || null,
  144. missing: reader.missing,
  145. }
  146. }
  147. /** 效率载荷 → 领域对象。兼容秒数字段和 averageDuration 聚合对象。 */
  148. export function toHandlingEfficiency(raw) {
  149. const reader = fieldReader(raw, 'handlingEfficiency')
  150. const familyRaw = reader.optionalStr('eventFamily') || reader.optionalStr('mode')
  151. const familyCode = familyRaw === EVENT_FAMILY.ALARM || familyRaw === EVENT_FAMILY.WARNING ? familyRaw : ''
  152. const family = reader.raw.familyMetrics && typeof reader.raw.familyMetrics === 'object' ? reader.raw.familyMetrics : {}
  153. const summary = reader.raw.summary || {}
  154. const handling = summary.handlingEfficiency || family.handlingEfficiency || reader.raw.handlingEfficiency
  155. const handlingValue = handling && typeof handling === 'object' ? handling : {}
  156. const states = Array.isArray(reader.raw.workOrderStateCounts)
  157. ? Object.fromEntries(reader.raw.workOrderStateCounts.map((row) => [row.stateCode, row.count]))
  158. : family.workOrderStates || reader.raw.workOrderStates || reader.raw.workOrderStateDistribution || {}
  159. const averageDuration = summary.averageDuration || reader.raw.averageDuration || {}
  160. const distribution = WORK_ORDER_STATES.map((item) => ({
  161. code: String(item.code),
  162. label: item.name,
  163. count: numberish(states[item.code] === undefined ? states[String(item.code)] : states[item.code]) || 0,
  164. }))
  165. // 票 65:本端点按**事件族**分流。预警族没有工单口径可画(预警不挂工单),
  166. // 但有真实的预警口径读数:条数、逐事件等级、逐桶发生量、工单关联实况;
  167. // 处置率/工单状态/结案时长属**报警的工单处置口径**,预警模式下不得上屏。
  168. const trendRowsRaw = list(raw, 'durationTrend')
  169. const events = list(raw, 'events')
  170. const totalEvents = numberish(summary.totalEvents === undefined ? reader.raw.totalEvents : summary.totalEvents)
  171. // 等级分布只统计**已返回的**事件行(本端点分页,未取全时如实标记,不拿半份冒充全量)。
  172. const levelDistribution = [1, 2, 3, 4].map((level) => ({
  173. code: String(level),
  174. label: eventLevelTag(level).label,
  175. count: events.filter((item) => Number(item && item.level) === level).length,
  176. }))
  177. return {
  178. family: familyCode,
  179. handlingRate: numberish(handlingValue.value === undefined ? (handlingValue.rate === undefined ? reader.raw.handlingRate : handlingValue.rate) : handlingValue.value),
  180. // 票 20:把 `state` 一并带出 —— 「分母为 0」与「处置率真的是 0%」必须分得开
  181. // (后端在无分母时回 value="0.0000" + state="EMPTY")。
  182. handlingState: handlingValue.state || reader.raw.handlingState || '',
  183. handlingReason: handlingValue.reason || reader.raw.handlingReason || '',
  184. basis: handlingValue.basis || reader.raw.basis || '',
  185. numerator: numberish(handlingValue.numerator === undefined ? reader.raw.numerator : handlingValue.numerator),
  186. denominator: numberish(handlingValue.denominator === undefined ? reader.raw.denominator : handlingValue.denominator),
  187. totalEvents,
  188. handledEvents: numberish(reader.raw.handledEvents),
  189. // 票 65:事件回联工单的真实计数(预警族实测 linked=0 / missing=全部)。
  190. linkedWorkOrderCount: numberish(summary.linkedWorkOrderCount === undefined ? reader.raw.linkedWorkOrderCount : summary.linkedWorkOrderCount),
  191. missingWorkOrderCount: numberish(summary.missingWorkOrderCount === undefined ? reader.raw.missingWorkOrderCount : summary.missingWorkOrderCount),
  192. levelDistribution,
  193. // 等级分布的分母是否等于该窗口的事件总数(分页未取全时界面如实说明)。
  194. levelDistributionComplete: totalEvents === null || events.length >= totalEvents,
  195. responseDuration: durationValue(raw, ['averageResponseDuration', 'averageResponseSeconds']) || durationValue(averageDuration, ['responseDuration', 'responseSeconds', 'response']),
  196. closeDuration: summary.averageDuration
  197. ? summary.averageDuration.state === 'EMPTY' ? null : summary.averageDuration
  198. : durationValue(raw, ['averageCloseDuration', 'averageCloseSeconds']) || durationValue(averageDuration, ['closeDuration', 'closeSeconds', 'close']),
  199. workOrderStates: distribution,
  200. // 逐桶**事件发生量**(与下面的结案时长趋势是两个口径,预警族只用这一份)。
  201. eventTrend: trendRowsRaw.map((item) => ({
  202. bucket: String(item && (item.timeBucket || item.bucket || item.bucketStart || '')),
  203. count: numberish(item && item.eventCount) || 0,
  204. })),
  205. trend: trendRowsRaw.map((item) => ({
  206. bucket: String(item && (item.timeBucket || item.bucket || item.bucketStart || '')),
  207. count: item && item.averageDuration
  208. ? item.averageDuration.state === 'EMPTY' ? null : numberish(item.averageDuration.value)
  209. : numberish(item && (item.handlingEfficiency === undefined ? item.count : item.handlingEfficiency)),
  210. unit: item && item.averageDuration ? item.averageDuration.unit || '' : '',
  211. })),
  212. range: rangeView(raw),
  213. missing: reader.missing,
  214. }
  215. }
  216. /** 多维分析载荷 → 领域对象。 */
  217. export function toEventAnalysis(raw) {
  218. const reader = fieldReader(raw, 'eventAnalysis')
  219. const dimensions = reader.raw.dimensions
  220. const source = Array.isArray(dimensions)
  221. ? Object.fromEntries(dimensions.map((dimension) => [dimension.dimensionCode, dimension.buckets || []]))
  222. : dimensions || reader.raw
  223. const dimensionRows = (key) => (source[key] || []).map((bucket) => ({ code: bucket.bucketCode, label: bucket.bucketLabel || bucket.bucketCode, count: numberish(bucket.count) || 0 }))
  224. const highlights = list(raw, 'highlights').map((item) => {
  225. const row = fieldReader(item, 'highlight')
  226. return { title: row.optionalStr('title') || row.optionalStr('name'), detail: row.optionalStr('detail') || row.optionalStr('description'), count: numberish(row.raw.count) }
  227. })
  228. return {
  229. types: Array.isArray(dimensions) ? dimensionRows('EVENT_TYPE') : countRows(source, 'types').concat(countRows(source, 'byType')),
  230. levels: Array.isArray(dimensions) ? levelRows(dimensionRows('LEVEL')) : levelRows(source.levels || source.byLevel),
  231. industries: Array.isArray(dimensions) ? dimensionRows('INDUSTRY') : countRows(source, 'industries').concat(countRows(source, 'byIndustry')),
  232. regions: Array.isArray(dimensions) ? dimensionRows('DISTRICT') : countRows(source, 'regions').concat(countRows(source, 'byRegion')),
  233. highlights,
  234. range: rangeView(raw),
  235. missing: reader.missing,
  236. }
  237. }
  238. function trendRows(rows) {
  239. const list = rows || []
  240. const dates = new Set(list.map((row) => String(row.bucket || '').slice(0, 10)))
  241. return list.map((row) => {
  242. const bucket = String(row.bucket || '')
  243. const matched = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/.exec(bucket)
  244. const label = matched
  245. ? dates.size === 1 && matched[4] ? `${matched[4]}:${matched[5]}` : `${matched[2]}/${matched[3]}`
  246. : bucket
  247. return { key: row.bucket, label, count: row.count, displayText: row.unit ? formatDuration(row.count, row.unit) : '' }
  248. })
  249. }
  250. function historyTrendRows(rows = []) {
  251. if (rows.length <= 48 || !rows.every((row) => /^\d{4}-\d{2}-\d{2}/.test(String(row.bucket)))) return trendRows(rows)
  252. const days = new Map()
  253. for (const row of rows) {
  254. const day = String(row.bucket).slice(0, 10)
  255. days.set(day, (days.get(day) || 0) + (Number(row.count) || 0))
  256. }
  257. return trendRows([...days].map(([bucket, count]) => ({ bucket, count })))
  258. }
  259. /**
  260. * 历史事件清单块(票 61)。
  261. *
  262. * 两种分页形态由 `data.paging` 决定,**不在这里猜**:
  263. * · `mode:'server'`(单窗,近 7 天):`events` 就是后端返回的当前页,
  264. * 块只带 `serverPage` 元数据,翻页由 store 重新请求(PanelBlocks 只画控件);
  265. * · `mode:'merged'`(多窗,近 30/90 天):`events` 是已加载合并集合,
  266. * 块声明 `paginate` 由渲染层切窗口;页脚如实说明合并了几个窗口/加载了多少条
  267. * (多窗不存在全局页,不伪造页码)。
  268. * 没有 `paging` 元数据(旧 mock/旧载荷)时退化为普通清单块,不画翻页控件。
  269. */
  270. function historyEventListBlock(current) {
  271. const events = current.events || []
  272. const rows = events.map((item) => ({
  273. key: item.code,
  274. title: item.name || item.code,
  275. tone: eventLevelTag(item.level).tone,
  276. toneLabel: eventLevelTag(item.level).label,
  277. meta: [item.code, item.eventTime, item.deviceName, item.status].filter(Boolean),
  278. points: [],
  279. }))
  280. const emptyText = '当前时间范围没有历史事件'
  281. const paging = current.paging && typeof current.paging === 'object' ? current.paging : null
  282. if (!paging) return listBlock('历史事件', rows, emptyText)
  283. if (paging.mode === 'server') {
  284. return listBlock('历史事件', rows, emptyText, {
  285. serverPage: { page: paging.page, pageCount: paging.pageCount, total: paging.total },
  286. })
  287. }
  288. return listBlock('历史事件', rows, emptyText, {
  289. paginate: true,
  290. // 页码归属键:范围/族一变就复位(条文 5/6;标量键,不接受对象身份)。
  291. // 范围键由请求侧带出(后端回显的 range.type 是 TREND_WINDOW,不能当预设范围用)。
  292. pageScope: `${current.mode || ''}|${paging.rangeKey || ''}`,
  293. pageNote: `已合并 ${paging.windowCount || 0} 个时间窗 · 已加载 ${paging.loaded || 0} 条`,
  294. })
  295. }
  296. export function historyAnalysisPanel(data, section) {
  297. const current = data || {}
  298. return sectionView('historyAnalysis', section, [
  299. // 2026-10-13 图表选型:时间趋势用折线。
  300. trendBlock((current.trend || []).length > 48 ? '历史事件趋势 · 每日合计' : '历史事件趋势', historyTrendRows(current.trend || []), { chart: 'line' }),
  301. historyEventListBlock(current),
  302. ], {
  303. label: '历史分析',
  304. range: current.range,
  305. rangeNote: current.range && current.range.start ? `时间范围:${current.range.start} 至 ${current.range.end}` : '',
  306. })
  307. }
  308. /**
  309. * 预警效率面板的**标题**(票 65):随预警/报警切换一起换,词表复用
  310. * `eventListSwitchView` 的单一词条(「预警」/「报警」),不新增第二张表。
  311. * 外壳(面板外框标题)与本面板的内部标题都读它,两处由构造一致。
  312. */
  313. export function responseEfficiencyTitle(mode) {
  314. return `${eventListSwitchView(mode).label}效率`
  315. }
  316. /**
  317. * 预警效率面板的**口径说明**(票 65,预警族)。
  318. *
  319. * 背景(2026-10-21 复核取证,维护者要求「确认预警数据是否正确」):
  320. * 后端 `CockpitHandlingEfficiencyService` 的 `stateCounts` 来自 `scopeWorkOrders(...)`,
  321. * 其源码注释原句是「范围内**全部工单**(不要求关联报警/事件)」——即处置率/状态分布/
  322. * 结案时长都是**工单**口径,与事件族无关。实测两族同样结构:
  323. * 预警族 `linkedWorkOrderCount=0`、八态合计 0。因此**预警族确实没有预警自身的处置流程统计**。
  324. *
  325. * 这段文字是要给值班人员看的,所以修掉上一版的两处毛病:
  326. * ① 「本端点」是接口词汇(项目纪律:接口词汇不上大屏)→ 改成说「后端/当前数据」;
  327. * ② 前半句先讲「我们没有字段」,读者要读两遍才知道面板上的数是什么
  328. * → 改成先讲**面板上这些数是什么、能信什么**,再讲为什么没有工单口径。
  329. */
  330. export const WARNING_EFFICIENCY_GAP =
  331. '预警不挂工单,所以这里不画工单口径的处置率、工单状态分布与结案时长;' +
  332. '下方的条数、等级分布与发生趋势都是本窗口内预警自身的真实读数。' +
  333. '预警的处置流程统计(如「预警→处置动作」流转)后端尚未提供,已在需求池登记,上线后此处会自动换成预警自身的口径'
  334. /**
  335. * 预警族 KPI:预警自身可证实的读数(票 65)。
  336. * 「已关联/未关联工单」是**真实计数**(预警族实测 0 / 全部),不是 0% 假读数。
  337. * 读数缺失走 `valueText` 的占位符(票 22/42 纪律:不把「没有」画成 0)。
  338. */
  339. function warningEfficiencyKpis(current) {
  340. return kpiBlock([
  341. { key: 'warningTotal', label: '预警条数', value: valueText(current.totalEvents), unit: '条' },
  342. { key: 'warningLinked', label: '已关联工单', value: valueText(current.linkedWorkOrderCount), unit: '条', hint: '预警不挂工单:该读数是事件回联到工单的实况' },
  343. { key: 'warningMissing', label: '未关联工单', value: valueText(current.missingWorkOrderCount), unit: '条', hint: '预警不挂工单:没有回联工单的预警条数' },
  344. ])
  345. }
  346. /** 报警族 KPI:工单口径(工单是报警的处置手段 —— 票 41/42/52 既有裁定,本票不动)。 */
  347. function alarmEfficiencyKpis(current) {
  348. return kpiBlock([
  349. { key: 'handlingRate', label: '处置率', value: handlingRateView(current), hint: handlingRateHint(current) },
  350. { key: 'responseDuration', label: '平均响应时长', value: durationView(current, ['responseDuration']) },
  351. { key: 'closeDuration', label: '平均关闭时长', value: durationView(current, ['closeDuration']) },
  352. ])
  353. }
  354. /**
  355. * 响应效率(「预警效率」)面板(票 19;**票 65 两族语义分流**)。
  356. *
  357. * 预警族与报警族的处置对象根本不同:**工单是报警的处置手段**(票 41/42/52),
  358. * 而预警不挂工单。所以同一个端点返回的工单口径字段只在报警模式下有意义;
  359. * 预警模式下照画就是「用一个不存在的对象统计预警」(2026-10-19 维护者反馈第 6 条)。
  360. *
  361. * `extra.mode` 是当前切换族(外壳从 `eventStore.mode` 传入);`data.family` 是后端回显,
  362. * 两者都有时以**回显**为准(那份数据确实是哪一族就以哪一族画)。都没有时按报警口径
  363. * (工单口径是既有形态,不因本票对旧调用点改造)。
  364. *
  365. * @param {object|null} data `toHandlingEfficiency()` 的领域对象
  366. * @param {object} section 分区
  367. * @param {{mode?: string}} [extra] 当前切换族
  368. */
  369. export function responseEfficiencyPanel(data, section, extra) {
  370. const current = data || {}
  371. const family = current.family || (extra && extra.mode) || EVENT_FAMILY.ALARM
  372. if (family === EVENT_FAMILY.WARNING) {
  373. // 发生趋势与历史分析同一条「多桶→按日合并」口径(报复用,不另写一份采样)。
  374. const trend = historyTrendRows(current.eventTrend || [])
  375. return sectionView('responseEfficiency', section, [
  376. warningEfficiencyKpis(current),
  377. barsBlock('预警等级分布', current.levelDistribution || []),
  378. trendBlock(trend.length > 48 ? '预警发生趋势 · 每日合计' : '预警发生趋势', trend, { chart: 'line' }),
  379. ], {
  380. label: responseEfficiencyTitle(EVENT_FAMILY.WARNING),
  381. range: current.range,
  382. // 口径说明(走票 56 的折叠摘要,`data-panel-note`):说清「预警不挂工单」与契约缺口。
  383. note: WARNING_EFFICIENCY_GAP,
  384. rangeNote: warningRangeNote(current),
  385. })
  386. }
  387. return sectionView('responseEfficiency', section, [
  388. alarmEfficiencyKpis(current),
  389. // 2026-10-13 图表选型:工单状态是多类别占比 → 百分比堆叠条(饼图超过 5 类不再适合)。
  390. stackedBlock('工单状态分布', current.workOrderStates || []),
  391. // 时长趋势仍是时间序列 → 折线。
  392. trendBlock('平均结案时长趋势', trendRows((current.trend || []).filter((row) => row.count !== null)), { chart: 'line' }),
  393. // 票 65 追加:报警模式下直接列出当前工单(卡片,可点开详情)。
  394. // 放在图之后:读者先看结论(处置率/分布),再看逐张明细。
  395. workOrderListBlock(extra),
  396. ], {
  397. label: responseEfficiencyTitle(EVENT_FAMILY.ALARM),
  398. range: current.range,
  399. // 报警族口径说明:工单是报警的处置手段(与预警族那句并列,切换时一起换)。
  400. note: ALARM_EFFICIENCY_BASIS,
  401. rangeNote: current.rangeNote === undefined
  402. ? (current.denominator === null || current.denominator === undefined
  403. ? ''
  404. : `可计入工单 ${current.denominator} 张 · 已结案 ${current.numerator === null ? '—' : current.numerator} 张`)
  405. : current.rangeNote,
  406. })
  407. }
  408. /**
  409. * 报警族的「当前工单列表」块(票 65)。
  410. *
  411. * **数据来自另一个端点**(`GET /api/cockpit/v1/work-orders`),不是 `handling-efficiency`:
  412. * 后者只给工单**状态计数**、不返回工单实体(实测报警族八态合计 0,但设备维度能查到 7 张工单)。
  413. * 所以这个块的行来自 `extra.workOrderList`(store 单独取一次)。
  414. *
  415. * **两处口径必须是同一批工单**(2026-10-22 维护者反馈「列表没按时间筛」后对齐):
  416. * 上方「工单状态分布」的分母来自 `CockpitHandlingEfficiencyService.scopeWorkOrders(...)`,
  417. * 那里按 `create_time` 落在窗口内;因此本列表也按 `create_time` + 同一时间窗过滤
  418. * (window 由 store 从 `activeConditions()` 传下去)。这样两处统计的是同一批工单,
  419. * 不会再出现「分布说 0 张、列表列出 7 张」的自相矛盾。
  420. *
  421. * 三条如实处理:
  422. * · 还没取到 → 不给假空块,`emptyText` 说「正在取数」;
  423. * · 取到但是 0 张 → 说「当前没有工单」(这是真实业务结论),不是「加载失败」;
  424. * · 取失败 → 说「工单列表未取到」,与「确实没有」是两句不同的话(票 11/18 分区纪律)。
  425. *
  426. * @param {{workOrderList?: object}} [extra] store 传下来的工单列表分区(`sections.workOrderList`)
  427. */
  428. function workOrderListBlock(extra) {
  429. const section = (extra && extra.workOrderList) || null
  430. const loaded = !!section && section.loaded === true
  431. const data = (section && section.data) || null
  432. const orders = (data && data.orders) || []
  433. const paging = (data && data.pagination) || null
  434. // 票 67:工单列表走全量工单端点,它**不收行业入参**(实测:非法行业也 200、响应逐字节相同),
  435. // 所以切行业时上方「工单状态分布」会变、本列表不变。不说明就是一条看不出来的假象
  436. // (同一屏两个数各说各话,正是维护者反馈第 8 条抱怨的形态)。措辞归 domain/filterCaliber.js。
  437. const industryNote = industryGap('workOrderList')
  438. const pageNote = (text) => [text, industryNote].filter(Boolean).join(' · ')
  439. if (!loaded) {
  440. return listBlock('当前工单列表', [], '正在取数…', { pageNote: pageNote('按创建时间落在此窗口,与上方「工单状态分布」是同一批工单') })
  441. }
  442. if (section.state !== 'success' && section.state !== 'stale' && orders.length === 0) {
  443. // 失败/不可用:明确说没取到,不画成「0 张工单」。
  444. return listBlock('当前工单列表', [], '工单列表未取到(其余读数不受影响)', {
  445. pageNote: pageNote('按创建时间落在此窗口,与上方「工单状态分布」是同一批工单'),
  446. })
  447. }
  448. return listBlock('当前工单列表', workOrderCardRows(orders), '当前没有工单', {
  449. serverPage: paging ? { page: paging.page, pageCount: paging.totalPages, total: paging.total } : undefined,
  450. pageNote: pageNote(paging
  451. ? `本窗口内创建的工单共 ${paging.total} 张 · 与上方「工单状态分布」是同一批工单(均按创建时间落在所选范围)`
  452. : '按创建时间落在此窗口,与上方「工单状态分布」是同一批工单'),
  453. })
  454. }
  455. /**
  456. * 工单 → 列表卡片行(票 65 精简形态)。
  457. *
  458. * 字段选择只取「一眼判断该不该点开」的信息:工单号、状态、设备、描述、创建时间。
  459. * 行字段名**贴着共享渲染层已认的形状**(`code`/`name`/`tone`/`toneLabel`/`meta`):
  460. * `PanelBlocks` 的 list 块见到 `row.code` 就把整张卡做成可点(点击 emit `open-card`),
  461. * 见到 `tone`+`toneLabel` 就画状态标签 —— 所以这里不再发明第二套卡片协议。
  462. *
  463. * 文本一律复用 `equipmentDetailView.workOrderRows`(单一词表:状态字典 0–7、
  464. * 关联文本、时间短读法都在那儿定过一次),本函数只做**卡片需要的再包装**。
  465. *
  466. * @param {Array} orders `toWorkOrder()` 的领域对象数组
  467. */
  468. export function workOrderCardRows(orders) {
  469. const rows = workOrderRows(orders)
  470. return rows.map((row) => ({
  471. key: row.code,
  472. // `code` 是渲染层的可点契约(点开走票 12 的工单时间线)。
  473. code: row.code,
  474. // 卡片抬头就是工单号:值班人员按编号对工单,不按故障描述认。
  475. title: row.code,
  476. name: row.code,
  477. // 色键必带文字(票 06:标签不能只靠颜色)——文案取自 workOrder.js 的词表。
  478. tone: workOrderStateTone(row.stateCodeNumber),
  479. toneLabel: row.stateName,
  480. stateCode: row.stateCodeNumber,
  481. stateName: row.stateName,
  482. // 副信息行:来源 / 创建时间 / 关联实况(都有才画,缺的自动不占位)。
  483. meta: [
  484. row.typeName,
  485. row.createTime ? `创建 ${row.createTime}` : '',
  486. row.associationText,
  487. ].filter(Boolean),
  488. // 描述可能很长(实测有「监测报警,当前值:14.60(阈值:-~5),请现场核实处理」):
  489. // 列表里由 EllipsisText 省略,完整值在时间线弹窗里看 —— 卡片不为一段长文本撑高整块。
  490. description: row.description,
  491. reason: row.description,
  492. }))
  493. }
  494. /**
  495. * 工单状态 → `StatusTag` 的色键(票 65 卡片用)。
  496. *
  497. * **不登记第二张状态词表**:词(待派单…)一律走 `workOrder.js` 的 `WORK_ORDER_STATES`,
  498. * 这里只回答「这一态借哪一枚既有色键上色」。
  499. *
  500. * 为什么映射到既有键而不是新增键:`StatusTag` 的 tone 是**封闭词表**
  501. * (`domain/statusTones.js` 的 `TONES`,未知 tone 会退回中性灰),
  502. * 新增 `state-closed` 之类必须同时改那张表与样式,而那属于视觉批次;
  503. * 本票只需「一眼看出这张工单是等着办、在办还是已了」,借用四档语义色即可:
  504. * · 待派单/待接单 → `lv1`(最需立刻响应,借一级告警色);
  505. * · 已接单/处理中/待验收 → `lv3`(在办,借三级信息色);
  506. * · 延期中 → `lv2`(旁路,需要盯);
  507. * · 已结案 → `lv4`(了结,借正常色);
  508. * · 已取消/认不出 → `neutral`(中性,不暗示任何态度)。
  509. */
  510. export function workOrderStateTone(stateCode) {
  511. if (stateCode === null || stateCode === undefined) return 'neutral'
  512. const state = WORK_ORDER_STATES.find((item) => item.code === stateCode)
  513. if (!state) return 'neutral'
  514. if (state.code === 0 || state.code === 1) return 'lv1'
  515. if (state.code === 6) return 'lv2'
  516. if (state.terminal && state.code === 5) return 'lv4'
  517. if (state.terminal) return 'neutral'
  518. return 'lv3'
  519. }
  520. /** 报警族的口径说明(与预警族那句成对:切换时口径说明一起换)。 */
  521. export const ALARM_EFFICIENCY_BASIS =
  522. '报警以工单为处置手段:处置率=已结案工单 ÷ 可计入工单(状态 0–6,排除已取消),单位为工单张数'
  523. /**
  524. * 预警族的窗口读数(票 65):条数与工单关联实况都取**本窗口**的计数。
  525. * 后端未给任何一项时回空串(不编造「0 条」——那是在说一件没被告知的事)。
  526. * 等级分布来自**已返回的事件行**,分页未取全时如实说明分母不全(不拿半份冒充全量)。
  527. */
  528. function warningRangeNote(current) {
  529. const parts = []
  530. if (numberish(current.totalEvents) !== null) parts.push(`本窗口预警 ${current.totalEvents} 条`)
  531. if (numberish(current.linkedWorkOrderCount) !== null) parts.push(`已关联工单 ${current.linkedWorkOrderCount} 条`)
  532. if (numberish(current.missingWorkOrderCount) !== null) parts.push(`未关联工单 ${current.missingWorkOrderCount} 条`)
  533. if (current.levelDistributionComplete === false) parts.push('等级分布仅统计已返回的事件页,非全量')
  534. return parts.join(' · ')
  535. }
  536. export function eventAnalysisPanel(data, section) {
  537. const current = data || {}
  538. const highlights = (current.highlights || []).map((item, index) => ({
  539. key: `${item.title || '重点'}-${index}`,
  540. title: item.title || '重点识别',
  541. meta: [item.detail, item.count === null ? '' : `事件 ${item.count} 条`].filter(Boolean),
  542. points: [],
  543. }))
  544. return sectionView('eventAnalysis', section, [
  545. // 2026-10-13 维护者反馈:条形图四连排视觉疲劳——类别少的构成改环形图(行业=行业色、
  546. // 类型=图表色序),等级/片区保留条形(条形适合作排名比较)。行业行保留联动。
  547. // 票 37 走查扩展:类型/行业/片区是排名类别按计数降序(并列保序);
  548. // 事件等级是**固定序**(1–4 级语义阶梯),保持后端字典序不打乱。
  549. donutBlock('按事件类型', sortByCountDesc(current.types || [], (row) => row.count)),
  550. donutBlock(
  551. '按行业分布',
  552. sortByCountDesc(
  553. (current.industries || []).map((row) => ({ ...row, actionKey: row.key || row.code })),
  554. (row) => row.count,
  555. ),
  556. { action: 'industry' },
  557. ),
  558. barsBlock('按事件等级', current.levels || []),
  559. barsBlock('按片区分布', sortByCountDesc(current.regions || [], (row) => row.count)),
  560. listBlock('重点识别', highlights, '当前没有识别到重点'),
  561. ], {
  562. label: '预警分析',
  563. range: current.range,
  564. })
  565. }
  566. export const WARNING_RESPONSE_PANEL_BUILDERS = {
  567. historyAnalysis: historyAnalysisPanel,
  568. responseEfficiency: responseEfficiencyPanel,
  569. eventAnalysis: eventAnalysisPanel,
  570. }