equipmentDetailView.js 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. /**
  2. * 设备详情分区的**界面视图**(票 11):把 `domain/equipmentDetail.js` 的领域对象翻成
  3. * 组件可以直接画的行/图,不把「怎么显示」散进模板里。
  4. *
  5. * 为什么单独成一个 domain 模块(而不是写在组件里):
  6. * 1. 票 11 的核心不是「画六个框」,而是**分区独立**——某分区失败/为空/过期时,
  7. * 其余分区照常画。这条判据必须能脱浏览器断言,所以判定与占位措辞都是纯函数;
  8. * 2. 组件若要判断「值缺没缺、空没空、过没过期」,就必然去读后端裸字段
  9. * (票 10 的静态守卫已经禁止过一次),那一层判断就会在模板里长出第二份。
  10. *
  11. * 两条与票 16 一致的硬约束(在这里收口,组件不再自己判):
  12. * · **缺值显示占位符,不显示虚假零值**:`valueText(0)` 是 `'0'`,`valueText(null)` 是 `'—'`;
  13. * · **过期与空分开**:`STALE` 是有值但过期(内容照画,另带过期标识),
  14. * `EMPTY` 是确实没有值(画占位符)。
  15. *
  16. * 两处刻意的范围收边(都是「别抢下游票的活」):
  17. * · **只渲染六块面板**(票 11 正文点名的档案 / 指标 / 预警 / 报警 / 工单 / 图片)。
  18. * 聚合契约里的 `alarmWarningRelations` 属票 13(预警-报警关联)——它仍在 store 的八个分区里,
  19. * 只是不在本票的渲染计划内;
  20. * · 事件只如实列出等级号/状态/时间/量值,**不在这里定事件的四级配色与标签词**:
  21. * 那是票 13/19 的视觉语言(`domain/statusTones.js` 的事件四级),本票不预支。
  22. *
  23. * 票 12 接手的**唯一一处**:`timelines` 分区(票 11 当时写明「归票 12」)。
  24. * 它不新增第七块面板 —— 状态轨迹是**工单状态面板里每张工单自己的**一条线
  25. * (票 12 验收第 2 条「每张工单有独立时间线」),所以它挂在 `kind: 'orders'` 那一块上,
  26. * 由 `timelineSource()` 按工单编码配好再交给组件。
  27. *
  28. * 票 46 追加**分类查询的视图函数**(本文件末尾):预警/报警按事件等级、工单按状态字典
  29. * 归组出分类 chips 与过滤结果。判据全是纯函数(可脱浏览器断言),组件只画结论。
  30. * 等级词表复用票 13 的 `eventList.js`(`eventLevelTag`),工单词表复用 `workOrder.js`
  31. * —— 都是既有单源,本票不发明第二张表。
  32. */
  33. import { DETAIL_PLACEHOLDER } from './detailFlow.js'
  34. import { SECTION_STATE, section } from './sectionState.js'
  35. import { WORK_ORDER_STATES, workOrderStateName } from './workOrder.js'
  36. import { tracksByOrder } from './workOrderTimeline.js'
  37. import { eventLevelTag, shortEventTime } from './eventList.js'
  38. /**
  39. * 六块面板的渲染计划,顺序即界面顺序(与票 11 正文的点名顺序一致)。
  40. * `kind` 决定用哪一组视图构造函数 —— 组件据此分派,不在模板里认后端字段。
  41. *
  42. * `kind` 刻意用自有词(`orders` / `events` / `image`)而不是照抄后端分区名
  43. * (`workOrders` / `warnings` / `latestImage`):分区名是**契约**、只该在 `name` 里出现一次,
  44. * 组件里若再散落后端键名,票 05 的静态守卫(页面/store 零后端裸字段名)就挡不住真泄漏了。
  45. *
  46. * 票 46 返工(维护者 2026-10-15 第 1 条):`latestImage` 标 `mergedInto: 'archive'` ——
  47. * **渲染计划仍是六块**(t11 D1/D9 钉住六块独立成败,分区数据与状态一个都不合并),
  48. * 变的只是**呈现归属**:图片不再单独占一个标签页,并进「设备档案」页的键值行下方。
  49. */
  50. export const EQUIPMENT_SECTION_PANELS = [
  51. { name: 'archive', label: '设备档案', kind: 'archive' },
  52. { name: 'metrics', label: '最新指标', kind: 'metrics' },
  53. { name: 'warnings', label: '预警信息', kind: 'events' },
  54. { name: 'alarms', label: '报警信息', kind: 'events' },
  55. { name: 'workOrders', label: '工单状态', kind: 'orders' },
  56. { name: 'latestImage', label: '最新图片', kind: 'image', mergedInto: 'archive' },
  57. ]
  58. /**
  59. * 设备详情的**标签页**计划(票 46 返工第 1 条)。
  60. *
  61. * 维护者原话:弃用「浏览器式的文档切换」(六块竖排、靠一条长滚动条找),改为切换显示
  62. * 设备档案(默认,图片归此页)|最新指标|预警信息|报警信息|工单状态。
  63. * 这里给的是**页签清单**(顺序即界面顺序):`name` 是页签自己的稳定键(events 页签跨
  64. * 预警/报警两个分区,所以不复用分区名),`sections` 是它背后要渲染的分区名。
  65. * 「最新图片」不在页签清单里 —— 它作为 `archive` 页签的一部分渲染(`mergedInto`)。
  66. */
  67. export const EQUIPMENT_TABS = [
  68. { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },
  69. { name: 'metrics', label: '最新指标', sections: ['metrics'] },
  70. { name: 'warnings', label: '预警信息', sections: ['warnings'] },
  71. { name: 'alarms', label: '报警信息', sections: ['alarms'] },
  72. { name: 'workOrders', label: '工单状态', sections: ['workOrders'] },
  73. ]
  74. /** 默认页签(维护者点名「设备档案(默认)」)。 */
  75. export const DEFAULT_EQUIPMENT_TAB = 'archive'
  76. /**
  77. * 页签 → 该页要渲染的面板(从 `equipmentPanels()` 的结果里挑,**保留渲染计划自己的次序**)。
  78. * 挑不出来(聚合没数据)时回空数组,由调用方按"没内容"处理,不猜。
  79. */
  80. export function panelsForTab(panels, tabName) {
  81. const tab = EQUIPMENT_TABS.find((item) => item.name === tabName)
  82. if (!tab) return []
  83. const list = Array.isArray(panels) ? panels : []
  84. return tab.sections.map((name) => list.find((panel) => panel.name === name)).filter(Boolean)
  85. }
  86. /**
  87. * 页签计数(维护者要「避免长滚动列表查找困难」——页签上先给出条数,不用点进去才知道有多少)。
  88. * 只数**列表型**分区(预警/报警/工单);档案/指标/图片不是条目集合,回空串不画数字。
  89. */
  90. export function tabCount(panels, tabName) {
  91. const tab = EQUIPMENT_TABS.find((item) => item.name === tabName)
  92. if (!tab) return ''
  93. const list = Array.isArray(panels) ? panels : []
  94. const counts = tab.sections
  95. .map((name) => list.find((panel) => panel.name === name))
  96. .filter((panel) => panel && (panel.kind === 'events' || panel.kind === 'orders'))
  97. .map((panel) => (Array.isArray(panel.section.data) ? panel.section.data.length : 0))
  98. if (!counts.length) return ''
  99. return String(counts.reduce((sum, value) => sum + value, 0))
  100. }
  101. /**
  102. * 分页视图 —— **已升格到单源 `domain/pagination.js`(票 59,条文 11)**。
  103. *
  104. * 这里按原名保留导出兼容(`HISTORY_PAGE_SIZE`/`pagedRows`),实现转调单源:
  105. * 翻页算术只允许有一份,组件与旧调用方都不再各写一份(旧 t46 闸门与调用点不受影响)。
  106. */
  107. export { PAGE_SIZE as HISTORY_PAGE_SIZE, pagedRows } from './pagination.js'
  108. /** 本票的渲染计划**不含**这一个聚合分区(票 13 的域)。 */
  109. export const DEFERRED_SECTIONS = ['alarmWarningRelations']
  110. /** 有内容可画的分区状态(`STALE` 是"有值但过期",内容照画)。 */
  111. const CONTENT_STATES = [SECTION_STATE.SUCCESS, SECTION_STATE.STALE]
  112. /** 该分区有没有内容可画。 */
  113. export function sectionHasContent(value) {
  114. return CONTENT_STATES.includes((value && value.state) || '')
  115. }
  116. /**
  117. * 没有内容时那一行占位说明。
  118. * `SUCCESS` 不该走到这里(它一定有内容或有自己的空列表提示),所以回空串。
  119. */
  120. export function sectionPlaceholder(value) {
  121. const state = (value && value.state) || SECTION_STATE.FAILED
  122. if (state === SECTION_STATE.EMPTY) return '该分区当前没有数据'
  123. if (state === SECTION_STATE.FAILED) return '该分区加载失败(其它分区不受影响)'
  124. if (state === SECTION_STATE.UNAVAILABLE) return '该分区数据不可用(其它分区不受影响)'
  125. return ''
  126. }
  127. /**
  128. * 值 → 文本。缺失一律占位符,**绝不**用 0 冒充(票 16 的验收原句);
  129. * 反过来后端真给的 0 是有效读数,照原样显示。
  130. */
  131. export function valueText(value) {
  132. if (typeof value === 'number' && Number.isFinite(value)) return String(value)
  133. if (typeof value === 'string' && value.trim()) return value.trim()
  134. return DETAIL_PLACEHOLDER
  135. }
  136. /** 数值 + 单位(缺值只显示占位符,不显示「— mg/L」这种半截读数)。 */
  137. export function quantityText(value, unit) {
  138. const text = valueText(value)
  139. if (text === DETAIL_PLACEHOLDER) return text
  140. return unit ? `${text} ${unit}` : text
  141. }
  142. /**
  143. * 收集一段载荷里记下的字段缺失(`domain/fields.js` 的 `missing` 清单)。
  144. *
  145. * 为什么要递归找:缺失清单挂在**记它的那一层**上——档案挂在 `archive.data.missing`,
  146. * 单个指标挂在 `metrics.data.metrics[i].missing`。只读顶层就会漏掉大部分。
  147. * 去重保序、带环保护(领域对象是纯数据,但契约缺口可能造成自引用形状)。
  148. */
  149. export function collectMissing(value) {
  150. const found = []
  151. const seen = new Set()
  152. const walk = (node) => {
  153. if (!node || typeof node !== 'object' || seen.has(node)) return
  154. seen.add(node)
  155. if (Array.isArray(node)) {
  156. for (const item of node) walk(item)
  157. return
  158. }
  159. if (Array.isArray(node.missing)) {
  160. for (const item of node.missing) {
  161. if (typeof item === 'string' && item && !found.includes(item)) found.push(item)
  162. }
  163. }
  164. for (const key of Object.keys(node)) {
  165. if (key === 'missing') continue
  166. walk(node[key])
  167. }
  168. }
  169. walk(value)
  170. return found
  171. }
  172. /** 档案 → 行视图(键值对)。图片不在这里:它有自己的分区,见 `imageView()`。 */
  173. export function archiveRows(data) {
  174. const archive = data || {}
  175. return [
  176. { key: 'name', label: '名称', value: valueText(archive.name) },
  177. { key: 'code', label: '编码', value: valueText(archive.code), mono: true },
  178. { key: 'typeName', label: '类型', value: valueText(archive.typeName) },
  179. { key: 'model', label: '型号', value: valueText(archive.model) },
  180. { key: 'spec', label: '规格', value: valueText(archive.spec) },
  181. { key: 'manufacturer', label: '厂商', value: valueText(archive.manufacturer) },
  182. { key: 'assetValue', label: '资产原值', value: valueText(archive.assetValue) },
  183. { key: 'expectedServiceYears', label: '预计年限', value: valueText(archive.expectedServiceYears) },
  184. { key: 'location', label: '安装位置', value: valueText(archive.location) },
  185. { key: 'ownershipUnit', label: '产权单位', value: valueText(archive.ownershipUnit) },
  186. { key: 'maintainer', label: '维护单位', value: valueText(archive.maintainer) },
  187. { key: 'maintainerPhone', label: '维护电话', value: valueText(archive.maintainerPhone) },
  188. { key: 'installTime', label: '安装时间', value: valueText(archive.installTime) },
  189. { key: 'remark', label: '备注', value: valueText(archive.remark) },
  190. ]
  191. }
  192. /** 指标列表 → 行视图。缺 `value` 的行显示占位符,真 0 照原样。 */
  193. export function metricRows(data) {
  194. const list = (data && data.metrics) || []
  195. return list.map((metric) => ({
  196. code: metric.code || '',
  197. name: metric.name || metric.code || DETAIL_PLACEHOLDER,
  198. value: valueText(metric.value),
  199. unit: metric.unit || '',
  200. status: metric.status || '',
  201. sampledAt: metric.sampledAt || '',
  202. missing: metric.missing || [],
  203. }))
  204. }
  205. /**
  206. * 后端 `stateReason` → 给人读的说明。
  207. *
  208. * **绝不把代号甩给值班人员**(票 05 的真机走查逮到过一次「界面漏出内部原因码」,
  209. * 那是本仓已经付过学费的教训):认得出就翻成人话,认不出**回空串**——
  210. * 宁可什么都不说,也不把 `SAMPLE_OUTDATED` 这类接口词汇画在大屏上。
  211. * 「过期」这件事本身已由分区状态标签说明,这里只是补充"为什么"。
  212. */
  213. export function metricsSourceText(data) {
  214. const dictionary = {
  215. READY: '',
  216. SAMPLE_OUTDATED: '采样已超过配置周期',
  217. }
  218. const reason = (data && data.stateReason) || ''
  219. return dictionary[reason] || ''
  220. }
  221. /**
  222. * 记录一次「后端没给这些字段」的缺失。
  223. *
  224. * 界面**只报条数**,字段路径(`archive.deviceName` 这类)进 `fields` 供排查与 tooltip:
  225. * 路径是接口契约词汇,直接铺在大屏上是给值班人员看噪音,与上面"不漏原因码"同一条立场。
  226. * 排查时仍拿得到完整清单(`title` 属性 / DEV 把手),所以这不是把信息丢掉。
  227. */
  228. export function missingNote(value) {
  229. const fields = collectMissing(value)
  230. return {
  231. count: fields.length,
  232. fields,
  233. text: fields.length ? `后端未提供 ${fields.length} 项字段` : '',
  234. tooltip: fields.join('、'),
  235. }
  236. }
  237. /**
  238. * 事件(预警/报警共用)→ 行视图。
  239. * 等级只出「N 级」文本与后端色名(挂在 `data-event-level-color` 上供票 13 接管),
  240. * 本票不把事件四级配色画出来(那是票 13/19 的域)。
  241. *
  242. * 票 46 追加**分类与展开详情**所需的视图字段(都是既有领域字段的改名/复用,不读新后端字段):
  243. * · `levelTag` / `categoryKey` / `categoryLabel`:等级分类(词表复用票 13 的 `eventLevelTag`,
  244. * 认不出的等级归「等级未知」,不猜成某个真实等级);
  245. * · `timeText`:折叠行的短读法(复用 `shortEventTime`,完整 `eventTime` 保留给展开详情);
  246. * · `type` / `deviceCode` / `deviceName`:展开详情要保留「来源与关联对象」(票 46 范围第 3 条)。
  247. */
  248. export function eventRows(data) {
  249. const list = Array.isArray(data) ? data : []
  250. return list.map((event) => {
  251. const levelNumber = typeof event.level === 'number' && Number.isInteger(event.level) ? event.level : null
  252. const levelTag = eventLevelTag(levelNumber)
  253. return {
  254. code: event.code || DETAIL_PLACEHOLDER,
  255. name: event.name || DETAIL_PLACEHOLDER,
  256. levelText: typeof event.level === 'number' && Number.isFinite(event.level) ? `${event.level} 级` : DETAIL_PLACEHOLDER,
  257. levelColor: event.levelColor || '',
  258. status: event.status || '',
  259. eventTime: event.eventTime || '',
  260. quantity: quantityText(event.value, event.unit),
  261. location: event.location || '',
  262. levelTag: { tone: levelTag.tone, label: levelTag.label },
  263. categoryKey: levelNumber === null ? 'level:unknown' : `level:${levelNumber}`,
  264. categoryLabel: levelNumber === null ? '等级未知' : levelTag.label,
  265. timeText: shortEventTime(event.eventTime),
  266. type: event.type || '',
  267. deviceCode: event.deviceCode || '',
  268. deviceName: event.deviceName || '',
  269. }
  270. })
  271. }
  272. /** 工单列表 → 行视图。状态名缺失时按状态字典(后端 0–7)补(不伪造:字典也认不出就回占位符)。
  273. *
  274. * 票 46 追加分类与展开字段:
  275. * · `categoryKey` / `categoryLabel`:工单**状态**分类(字典 0–7,单源 `workOrder.js`;
  276. * 认不出的码归 `state:unknown`「状态未知」,不并进任何一个真实态);
  277. * · `timeText`:折叠行的短读法(创建时间,完整值保留给展开详情);
  278. * * `source`:来源(后端 `orderTypeName` 的展示结论 —— 展开详情要能回答"这条从哪来")。
  279. */
  280. export function workOrderRows(data) {
  281. const list = Array.isArray(data) ? data : []
  282. return list.map((order) => {
  283. const known = WORK_ORDER_STATES.some((item) => item.code === order.stateCode)
  284. return {
  285. code: order.code || DETAIL_PLACEHOLDER,
  286. stateCode: typeof order.stateCode === 'number' ? String(order.stateCode) : '',
  287. // 状态路径需要**数字**码(字符串比较会把 '10' 排在 '9' 前面),所以另存一份。
  288. stateCodeNumber: typeof order.stateCode === 'number' ? order.stateCode : null,
  289. stateName: order.stateName || workOrderStateName(order.stateCode) || DETAIL_PLACEHOLDER,
  290. typeName: order.orderTypeName || '',
  291. description: order.description || '',
  292. associationText: order.associatedWithCurrentEvent === true ? '已关联当前事件'
  293. : order.associatedWithCurrentEvent === false ? '未关联当前事件' : '当前事件关联关系未发布',
  294. createTime: order.createTime || '',
  295. planFinishTime: order.planFinishTime || '',
  296. // 未结案时后端给 null("还没有"),与"字段缺失"一样显示占位符。
  297. realFinishTime: order.realFinishTime || DETAIL_PLACEHOLDER,
  298. categoryKey: known ? `state:${order.stateCode}` : 'state:unknown',
  299. categoryLabel: known ? workOrderStateName(order.stateCode) : '状态未知',
  300. timeText: shortEventTime(order.createTime),
  301. source: order.orderTypeName || '',
  302. }
  303. })
  304. }
  305. /**
  306. * 图片 → **单张**视图(票 11 验收第 5 条「最新图片只展示后端指定的一张」)。
  307. *
  308. * 这里刻意只认一个 `url` 单值:前端不索引数组、不 `find`、不挑"第一张"——
  309. * 挑图就是前端替后端做业务判断,而且"挑错了"没人看得出来。后端给几张是后端的事,
  310. * 界面只画后端点名的那一张。
  311. */
  312. export function imageView(data) {
  313. const image = data || null
  314. const url = image && typeof image.url === 'string' ? image.url.trim() : ''
  315. return {
  316. hasImage: !!url,
  317. url,
  318. capturedAt: (image && image.capturedAt) || DETAIL_PLACEHOLDER,
  319. }
  320. }
  321. /**
  322. * 工单面板的**时间线来源**(票 12):把 `timelines` 子分区按工单编码配成每张一条。
  323. *
  324. * 三个刻意的返回约定,都是为了让组件不做判断:
  325. * · `tracks` 一定是对象(哪怕没有时间线分区),组件不必写 `?.` 兜底;
  326. * · `state` 带上 `timelines` 自己的分区状态 —— 它**独立于工单列表**成败:
  327. * 后端 `loadTimelines()` 会在时间线取失败时给 FAILED(`SOURCE_SECTION_FAILED`),
  328. * 而工单列表可能照常 SUCCESS。界面必须能说出"工单在、但它的轨迹没取到",
  329. * 不能因为工单列表成功就假装轨迹也在(那是把缺数据画成没有动作)。
  330. * · `placeholder` 与其它分区的措辞同源(`sectionPlaceholder`),不另写一套。
  331. *
  332. * 注意**不是**「分区没数据就整块不画」:状态路径所需的当前状态码在**工单列表**分区里,
  333. * 与轨迹成败无关,所以调用方在 `hasContent === false` 时改走 `pathOnlyTrack()`
  334. * 仍把状态路径铺出来(别因为轨迹没拿到就丢掉"这张工单现在在哪一态")。
  335. * @param {object} aggregate `stores/detailStore.js` 的 `sections.equipmentAggregate`
  336. */
  337. export function timelineSource(aggregate) {
  338. const data = (aggregate && aggregate.data) || null
  339. const sections = (data && data.sections) || {}
  340. const value = panelSection(sections, 'timelines')
  341. const { byCode, duplicates, orphans } = tracksByOrder(value.data)
  342. const hasContent = sectionHasContent(value)
  343. // 措辞从 `sectionPlaceholder()` 来(不另写一套),但带上「工单动作轨迹」这个抬头 ——
  344. // 这句话会画在**工单面板内部**,光说"该分区"读者不知道指哪一块。
  345. const detail = hasContent ? '' : sectionPlaceholder(value)
  346. return {
  347. section: value,
  348. state: value.state,
  349. retained: value.retained === true,
  350. hasContent,
  351. // 分区没内容时一律给空表:界面据此说占位说明,而不是把上一次的轨迹留在屏幕上。
  352. tracks: hasContent ? byCode : {},
  353. duplicates,
  354. orphans,
  355. placeholder: detail ? `工单动作轨迹:${detail}` : '',
  356. }
  357. }
  358. /** 分区的空列表提示(后端说 SUCCESS 但列表是空的,仍是"明确 UI"而不是一片空白)。 */
  359. export function emptyListText(label) {
  360. return `${label}当前没有记录`
  361. }
  362. // ================================================================ 票 46:分类查询与历史折叠
  363. /**
  364. * 分类 chips 的**固定次序**(不按计数排,也不按行出现次序):
  365. * 次序来自既有单源词表 —— 事件等级 1..4(票 13 `statusTones.js`)、工单状态 0..7(`workOrder.js`)。
  366. * 「未知」永远在最后:它是兜底桶,不该插进真实语义序列里。
  367. */
  368. export const EVENT_CATEGORY_ORDER = ['level:1', 'level:2', 'level:3', 'level:4', 'level:unknown']
  369. export const WORK_ORDER_CATEGORY_ORDER = [...WORK_ORDER_STATES.map((item) => `state:${item.code}`), 'state:unknown']
  370. /** 分类 chips 里「全部」那一项的键(空串=不设分类条件)。 */
  371. export const CATEGORY_ALL = ''
  372. /**
  373. * 行视图 → 分类 chips(`[{ key, label, count }]`,首项恒为「全部」)。
  374. *
  375. * 三条判据都收在这里,组件不再自己归组:
  376. * · **计数是真实值**:0 计数的词表项**不出现**(chip 条不是字典,铺一排 0 只会挤掉有用的入口);
  377. * · 词表外的键(后端加了新等级/新状态码)照样出现并排在已知项之后 —— 不静默丢数据;
  378. * · 不排序、不改行次序(与票 10/11「前端不重排」同一条立场)。
  379. * @param {Array<{categoryKey: string, categoryLabel: string}>} rows 已带分类键的行视图
  380. * @param {string[]} order 已知分类的展示次序
  381. */
  382. export function categoryChips(rows, order = []) {
  383. const list = Array.isArray(rows) ? rows : []
  384. const counts = new Map()
  385. const labels = new Map()
  386. for (const row of list) {
  387. const key = row.categoryKey || CATEGORY_ALL
  388. counts.set(key, (counts.get(key) || 0) + 1)
  389. if (!labels.has(key)) labels.set(key, row.categoryLabel || key)
  390. }
  391. const known = order.filter((key) => counts.has(key))
  392. const extra = [...counts.keys()].filter((key) => !order.includes(key))
  393. return [
  394. { key: CATEGORY_ALL, label: '全部', count: list.length },
  395. ...[...known, ...extra].map((key) => ({ key, label: labels.get(key) || key, count: counts.get(key) })),
  396. ]
  397. }
  398. /**
  399. * 按分类过滤行视图。空键=「全部」,原样返回(同一批对象引用,不复制、不改序)。
  400. * 认不出的键回**空数组**而不是全量:分类条件生效却返回未筛数据,是"筛了个寂寞"的假绿。
  401. */
  402. export function filterByCategory(rows, categoryKey) {
  403. const list = Array.isArray(rows) ? rows : []
  404. const key = typeof categoryKey === 'string' ? categoryKey.trim() : ''
  405. if (!key) return list
  406. return list.filter((row) => row.categoryKey === key)
  407. }
  408. /** 分类生效时那句「当前分类 · 再点取消」的可见回显(与票 37 的 typeNote 同一立场)。 */
  409. export function categoryNote(chips, categoryKey) {
  410. const key = typeof categoryKey === 'string' ? categoryKey.trim() : ''
  411. if (!key) return ''
  412. const found = (chips || []).find((chip) => chip.key === key)
  413. return `当前分类:${(found && found.label) || key}(再点同一项取消)`
  414. }
  415. /**
  416. * 展开详情的一行(值缺失一律占位符 —— 票 16「不把没给画成空白/0」)。
  417. * `keepEmpty` 的项是票 46 范围第 3 条点名要**始终保留**的四件事:
  418. * 数据时间 / 状态 / 来源 / 关联对象;其余项有值才画(不铺一排 `—` 噪音)。
  419. */
  420. function detailRow(label, value, keepEmpty = false) {
  421. const text = valueText(value)
  422. if (!keepEmpty && text === DETAIL_PLACEHOLDER) return null
  423. return { label, value: text }
  424. }
  425. /** 一条预警/报警 → 展开后的详情行(时间、状态、来源、关联设备都在,缺值给占位符)。 */
  426. export function eventDetailRows(row) {
  427. const current = row || {}
  428. return detailRowList([
  429. detailRow('数据时间', current.eventTime, true),
  430. detailRow('状态', current.status, true),
  431. detailRow('来源', current.type, true),
  432. detailRow('关联设备', current.deviceName || current.deviceCode, true),
  433. detailRow('编码', current.code),
  434. detailRow('等级', current.levelText),
  435. detailRow('量值', current.quantity),
  436. detailRow('位置', current.location),
  437. ])
  438. }
  439. /** 一条工单 → 展开后的详情行(同上四件事 + 计划/实际结案时间)。
  440. * `实际结案` 必留:未结案时后端给 null、视图是占位符,折叠行本来就写着「结案 —」,
  441. * 展开后若这一行消失就成了"折叠说没有、展开什么都不说"(前后矛盾)。
  442. */
  443. export function workOrderDetailRows(row) {
  444. const current = row || {}
  445. return detailRowList([
  446. detailRow('数据时间', current.createTime, true),
  447. detailRow('状态', current.stateName, true),
  448. detailRow('来源', current.source || current.typeName, true),
  449. detailRow('关联对象', current.associationText, true),
  450. detailRow('编码', current.code),
  451. detailRow('计划结案', current.planFinishTime),
  452. detailRow('实际结案', current.realFinishTime, true),
  453. detailRow('说明', current.description),
  454. ])
  455. }
  456. /** 详情行清单:剔除「可选项没值」的 null(必选项即使占位符也保留)。 */
  457. function detailRowList(rows) {
  458. return rows.filter(Boolean)
  459. }
  460. /** 聚合分区 → 设备编码(判定"已加载的这份是不是当前这台设备")。 */
  461. export function aggregateDeviceCode(aggregate) {
  462. const data = (aggregate && aggregate.data) || null
  463. return (data && data.deviceCode) || ''
  464. }
  465. /**
  466. * 已加载的聚合是不是**当前这台设备**的。
  467. *
  468. * 用它挡住两类错画:请求还没回来时把上一台设备的数据挂在新的表头下;
  469. * 以及失败分区(`data` 为 null)被当成"已经取过"而不再重试。
  470. */
  471. export function equipmentDetailMatches(aggregate, deviceCode) {
  472. if (!deviceCode) return false
  473. if (!aggregate || aggregate.loaded !== true) return false
  474. return aggregateDeviceCode(aggregate) === deviceCode
  475. }
  476. /**
  477. * 整个聚合请求本身就没成功(`FAILED` / `UNAVAILABLE`,`data` 为 null)。
  478. *
  479. * 为什么需要单独一个判据:这种情况下六个分区**全都**没有数据,
  480. * 若照常渲染六块面板,每块都会说「该分区加载失败(其它分区不受影响)」——
  481. * 那句话在只有一块坏时是真的,在这里是**假的**(六块一起坏了,没有"其它分区"可依赖)。
  482. * 谎报"别的还活着"会让值班人员以为还有可用数据,比直接说"这台设备的详情没取到"更坏。
  483. *
  484. * 注意与「后端把每个分区各自标 FAILED」的区别:后者 `data` 仍在、
  485. * `aggregateState: PARTIAL` 且各分区自带 state —— 那才是真正的"部分失败",
  486. * 必须照常渲染六块(那是本票验收第 3 条要的形态)。
  487. *
  488. * **刻意不比对设备编码**:失败分区 `data` 为 null、带不出是哪台设备(见 `aggregateDeviceCode`)。
  489. * 而它一定属于**当前**选中那台——store 的 token 纪律保证迟到的响应(含失败)会被丢弃,
  490. * 且换设备时 `deviceLoading` 为真、界面先走"加载中"分支,落不到这里。
  491. * 所以「失败态 = 当前这台没取到」是构造性的,不需要(也无法)再从载荷里认。
  492. * @param {object} aggregate `stores/detailStore.js` 的 `sections.equipmentAggregate`
  493. */
  494. export function aggregateUnavailable(aggregate) {
  495. if (!aggregate || aggregate.loaded !== true) return false
  496. return [SECTION_STATE.FAILED, SECTION_STATE.UNAVAILABLE].includes(aggregate.state)
  497. }
  498. /** 取一个聚合子分区;后端整段缺失时给一条失败分区,**不静默当成功**。 */
  499. function panelSection(sections, name) {
  500. const found = sections && sections[name]
  501. return found || section(SECTION_STATE.FAILED)
  502. }
  503. /**
  504. * 聚合分区 → 六块面板的渲染计划(组件只需遍历它)。
  505. *
  506. * 注意**不看** `aggregateState`:后端整体态漏标或标错时,成功分区不能被它拖成不可用
  507. * (票 05 的 B13 已定下这条立场,这里是它的界面侧延续)。
  508. * @param {object} aggregate `stores/detailStore.js` 的 `sections.equipmentAggregate`
  509. */
  510. export function equipmentPanels(aggregate) {
  511. const data = (aggregate && aggregate.data) || null
  512. const sections = (data && data.sections) || {}
  513. return EQUIPMENT_SECTION_PANELS.map((panel) => {
  514. const value = panelSection(sections, panel.name)
  515. return {
  516. name: panel.name,
  517. label: panel.label,
  518. kind: panel.kind,
  519. section: value,
  520. hasContent: sectionHasContent(value),
  521. missing: missingNote(value.data),
  522. placeholder: sectionPlaceholder(value),
  523. }
  524. })
  525. }