entityDetail.js 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  1. /**
  2. * 统一实体详情领域边界(issue 65)。
  3. *
  4. * 列表只传 { kind, id };本模块负责身份校验、不同后端载荷的正规化和紧凑详情视图。
  5. * point/device 沿用既有详情链,不在此重复实现。
  6. */
  7. import { eventLevelFromBucket, eventLevelTag, eventStatusText } from './eventList.js'
  8. import { DEFAULT_TONE } from './statusTones.js'
  9. import { toWorkOrder, toWorkOrderTimeline, workOrderStateName } from './workOrder.js'
  10. import { trackOf } from './workOrderTimeline.js'
  11. export const ENTITY_DETAIL_KIND = Object.freeze({
  12. FACILITY: 'facility',
  13. EMERGENCY: 'emergency',
  14. EVENT: 'event',
  15. WORK_ORDER: 'work-order',
  16. })
  17. const KINDS = Object.freeze(Object.values(ENTITY_DETAIL_KIND))
  18. const PLACEHOLDER = '—'
  19. export function entityIdentity(input, fallbackKind = '') {
  20. const source = input && typeof input === 'object' ? input : {}
  21. const kind = String(source.kind || source.identityKind || fallbackKind || '').trim()
  22. const id = String(source.id ?? source.code ?? source.identityCode ?? '').trim()
  23. const category = String(source.category || source.identityCategory || '').trim().toUpperCase()
  24. const family = String(source.family || source.eventFamily || source.identityFamily || '').trim().toUpperCase()
  25. return { kind: KINDS.includes(kind) ? kind : '', id, category, family, valid: KINDS.includes(kind) && !!id }
  26. }
  27. export function sameEntity(left, right) {
  28. const a = entityIdentity(left)
  29. const b = entityIdentity(right)
  30. return a.valid && b.valid && a.kind === b.kind && a.id === b.id && a.category === b.category && a.family === b.family
  31. }
  32. /** AjaxResult / page / list 三种常见外形收成同一份有效载荷。 */
  33. export function unwrapEntityPayload(raw) {
  34. let value = raw
  35. if (value && typeof value === 'object' && typeof value.code === 'number') {
  36. if (value.code !== 200) return null
  37. value = value.data
  38. }
  39. if (value && typeof value === 'object' && value.data !== undefined && !Array.isArray(value.data)) {
  40. const keys = Object.keys(value)
  41. if (keys.every((key) => ['data', 'msg', 'message', 'success', 'timestamp'].includes(key))) value = value.data
  42. }
  43. return value
  44. }
  45. export function normalizePage(raw) {
  46. const payload = unwrapEntityPayload(raw)
  47. if (Array.isArray(payload)) return { rows: payload, page: 1, pageSize: payload.length, total: payload.length, totalPages: 1 }
  48. const source = payload && typeof payload === 'object' ? payload : {}
  49. const rows = [source.rows, source.records, source.list, source.items, source.content].find(Array.isArray) || []
  50. const paging = source.pagination && typeof source.pagination === 'object' ? source.pagination : source
  51. const page = positive(paging.page ?? paging.current ?? paging.pageNum, 1)
  52. const pageSize = positive(paging.pageSize ?? paging.size ?? paging.limit, rows.length)
  53. const total = nonNegative(paging.total, rows.length)
  54. const totalPages = positive(paging.totalPages ?? paging.pages, pageSize ? Math.ceil(total / pageSize) : 1)
  55. return { rows, page, pageSize, total, totalPages }
  56. }
  57. export function normalizeEntityDetail(identityInput, raw, prefill = null) {
  58. const identity = entityIdentity(identityInput)
  59. if (!identity.valid) return null
  60. const payload = unwrapEntityPayload(raw)
  61. const page = normalizePage(payload)
  62. let source = payload
  63. if (Array.isArray(payload)) source = payload.find((row) => matchesId(row, identity.id)) || null
  64. else if (payload && typeof payload === 'object' && page.rows.length) source = page.rows.find((row) => matchesId(row, identity.id)) || null
  65. const seed = prefill && typeof prefill === 'object' ? prefill : {}
  66. source = source && typeof source === 'object' ? { ...seed, ...source } : { ...seed }
  67. if (identity.kind === ENTITY_DETAIL_KIND.WORK_ORDER) return normalizeWorkOrder(identity, source)
  68. if (identity.kind === ENTITY_DETAIL_KIND.EVENT) return normalizeEvent(identity, source)
  69. return normalizeAsset(identity, source)
  70. }
  71. export function prefilledEntityDetail(identity, prefill) {
  72. return normalizeEntityDetail(identity, {}, prefill)
  73. }
  74. export function entityDetailView(detail) {
  75. const current = detail || {}
  76. return {
  77. title: current.title || current.name || current.id || '实体详情',
  78. // 副标题用业务编号(不是主键):主键对使用者没有意义。
  79. subtitle: current.subtitle || [current.kindLabel, current.displayCode || current.id].filter(Boolean).join(' · '),
  80. tone: current.tone || 'neutral',
  81. status: current.statusText || '',
  82. // 等级与状态各有自己的语义色:等级沿用卡片那张四级词表,不与状态色混用。
  83. levelLabel: current.levelLabel || '',
  84. levelTone: current.levelTone || '',
  85. // 类别是身份而不是普通字段:抬头要能一眼看出是什么对象。
  86. categoryText: current.categoryText || '',
  87. summary: Array.isArray(current.summary) ? current.summary : [],
  88. tabs: Array.isArray(current.tabs) ? current.tabs : [],
  89. relatedAlarms: Array.isArray(current.relatedAlarms) ? current.relatedAlarms : [],
  90. stage: Array.isArray(current.stage) ? current.stage : [],
  91. sections: Array.isArray(current.sections) ? current.sections : [],
  92. people: Array.isArray(current.people) ? current.people : [],
  93. devices: Array.isArray(current.devices) ? current.devices : [],
  94. sectionMap: current.sectionMap && typeof current.sectionMap === 'object' ? current.sectionMap : {},
  95. attachments: Array.isArray(current.attachments) ? current.attachments : [],
  96. records: Array.isArray(current.records) ? current.records : [],
  97. track: current.track || null,
  98. }
  99. }
  100. function normalizeAsset(identity, source) {
  101. const emergency = identity.kind === ENTITY_DETAIL_KIND.EMERGENCY
  102. const category = text(source.category || identity.category).toUpperCase()
  103. const attributes = source.attributes && typeof source.attributes === 'object' ? source.attributes : {}
  104. const name = text(source.name || source.facilityName || source.resourceName || source.title) || identity.id
  105. // 状态是码值时翻成中文(0 正常 / 1 维护中 / 2 异常);后端若直接给人话则原样用。
  106. const statusRaw = text(source.statusName || source.status || source.stateName || source.state)
  107. const status = source.statusName || source.stateName ? statusRaw : facilityStatusText(statusRaw) || statusRaw
  108. const location = text(source.address || source.location || source.position)
  109. const fields = assetFields(category, attributes)
  110. return {
  111. ...identity, category, name, title: name,
  112. kindLabel: emergency ? '应急对象' : '设施', statusText: status, tone: facilityStatusTone(source.status), raw: source,
  113. categoryText: facilityCategoryText(category),
  114. coordinates: source.coordinates || null,
  115. summary: assetSummary(category, attributes, status, location),
  116. tabs: [tab('overview', '基本信息'), tab('business', '业务详情'), tab('related', '关联信息')],
  117. people: assetPeople(attributes),
  118. devices: assetDevices(attributes),
  119. attachments: assetAttachments(attributes),
  120. sections: compactSections([
  121. section('基本信息', [field('设施编码', source.code || identity.id), field('设施类别', facilityCategoryText(category)), field('运行状态', status), field('数据更新时间', source.updatedAt)]),
  122. section('位置与归属', [field('位置描述', location, true), field('所属片区', source.districtName), field('坐标', coordinatesText(source.coordinates), true)]),
  123. section('业务详情', fields),
  124. ]),
  125. }
  126. }
  127. /** 设施状态 → 语义色键(0 正常=绿、1 维护中=黄、2 异常=红;认不出走中性)。
  128. * 维护者第 15 轮:运行信息的指标行也要按设施状态着色,所以本函数对外导出(原先是模块私有)。 */
  129. export function facilityStatusTone(status) {
  130. const code = status === null || status === undefined ? '' : String(status).trim()
  131. if (code === '0') return 'lv4'
  132. if (code === '1') return 'lv2'
  133. if (code === '2') return 'lv1'
  134. return 'neutral'
  135. }
  136. /**
  137. * 数值型指标的**单位登记**(维护者 2026-10-29「所有数值都要带单位」)。
  138. *
  139. * 只在这里写一次,摘要网格与业务明细都从这里取 —— 两处各写一份单位必然漂移
  140. * (已经踩过:摘要写「长度(米)」、明细写「长度」+ 单位,同一份数据两种长相)。
  141. * 单位来自后端领域注释,不由前端猜。
  142. */
  143. const COMMON_UNITS = {
  144. lengthMeters: '米',
  145. diameterMillimeters: '毫米',
  146. designCapacity: '万m³/d',
  147. currentCapacity: '万m³/d',
  148. designHead: 'm',
  149. storageCapacity: 'm³',
  150. pumpCount: '台',
  151. powerCapacity: 'kW',
  152. assetValue: '元',
  153. useLifeYears: '年',
  154. riskScore: '分',
  155. }
  156. /**
  157. * 同一字段名在不同设施里量纲不同:泵站设计流量是 m³/h,排水工程设施设计流量是 m³/s。
  158. * 所以单位必须**按类别**登记,"一个键一个单位"会张冠李戴。
  159. */
  160. const UNIT_BY_CATEGORY = {
  161. PIPE_NETWORK: {},
  162. WATER_PLANT: {},
  163. PUMP_STATION: { designFlow: 'm³/h' },
  164. ENGINEERING_FACILITY: { designFlow: 'm³/s' },
  165. EMERGENCY_RESOURCE: {},
  166. HAZARD: {},
  167. PROTECTION_TARGET: {},
  168. }
  169. /** 字段在该设施类别下的单位;没登记返回空串(纯文本字段)。 */
  170. function unitOf(category, key) {
  171. const scoped = UNIT_BY_CATEGORY[category] || {}
  172. return scoped[key] !== undefined ? scoped[key] : COMMON_UNITS[key] || ''
  173. }
  174. /** 摘要与明细共用的中文字段名(维护者 2026-10-29:一律中文、不带括注)。 */
  175. const SUMMARY_LABELS = {
  176. lengthMeters: '长度',
  177. diameterMillimeters: '管径',
  178. material: '材质',
  179. designCapacity: '设计能力',
  180. currentCapacity: '当前能力',
  181. treatmentProcess: '处理工艺',
  182. designFlow: '设计流量',
  183. designHead: '设计扬程',
  184. storageCapacity: '调蓄容积',
  185. pumpCount: '水泵数量',
  186. powerCapacity: '装机功率',
  187. assetValue: '资产价值',
  188. useLifeYears: '预计使用年限',
  189. riskScore: '风险分值',
  190. riskLevel: '风险等级',
  191. fmodel: '型号',
  192. model: '型号',
  193. specification: '规格',
  194. maintainer: '维护人',
  195. level: '等级',
  196. rectifyDeadline: '整改期限',
  197. rectifyPerson: '整改人',
  198. assessmentTime: '评估时间',
  199. facilityLevel: '设施等级',
  200. }
  201. /** 同类字段在不同设施里的业务名不同(隐患等级 vs 设施等级),差异只在这里登记。 */
  202. const SUMMARY_LABELS_BY_CATEGORY = {
  203. HAZARD: { level: '隐患等级' },
  204. ENGINEERING_FACILITY: { facilityLevel: '设施等级' },
  205. }
  206. function assetSummary(category, a, status, location) {
  207. // 摘要只挑"一眼要看的读数",键与明细同源;单位统一由 METRIC_UNITS 提供。
  208. const byCategory = {
  209. WATER_PLANT: ['currentCapacity', 'designCapacity', 'treatmentProcess'],
  210. ENGINEERING_FACILITY: ['facilityLevel', 'designFlow', 'storageCapacity'],
  211. PUMP_STATION: ['pumpCount', 'designFlow', 'powerCapacity'],
  212. PIPE_NETWORK: ['lengthMeters', 'diameterMillimeters', 'material'],
  213. HAZARD: ['level', 'rectifyDeadline', 'rectifyPerson'],
  214. PROTECTION_TARGET: ['riskLevel', 'riskScore', 'assessmentTime'],
  215. EMERGENCY_RESOURCE: ['model', 'specification', 'maintainer'],
  216. }
  217. const labels = { ...SUMMARY_LABELS, ...SUMMARY_LABELS_BY_CATEGORY[category] }
  218. const rows = (byCategory[category] || []).map((key) => {
  219. const unit = unitOf(category, key)
  220. const raw = a[key]
  221. // 只有真有单位时才拼单位;纯文本字段(材质/负责人…)原样。
  222. const value = unit ? withUnitText(raw, unit) : raw
  223. return [labels[key] || SUMMARY_LABELS[key] || key, value]
  224. })
  225. return summaryItems([['状态', status], ...rows, ['位置', location]])
  226. }
  227. function assetPeople(a) { return peopleItems([['负责人', a.principal], ['维护人', a.maintainer], ['整改人', a.rectifyPerson], ['联系电话', a.contactPhone || a.maintainerPhone]]) }
  228. function assetAttachments(a) { return attachmentItems(a.images || a.image) }
  229. function assetDevices(a) {
  230. if (Array.isArray(a.devices)) return a.devices.map((item) => ({
  231. id: item.id || '', code: item.code || item.id || '', name: item.name || item.code || item.id || '设备',
  232. type: item.type || '', location: item.location || '', status: deviceStatusText(item),
  233. }))
  234. return String(a.equipmentIds || a.equipmentId || '').split(',').map((code) => code.trim()).filter(Boolean).map((code) => ({ name: code, code, type: '', location: '', status: '状态未发布' }))
  235. }
  236. function deviceStatusText(item) {
  237. if (item.onlineStatus === 0) return '离线'
  238. if (item.alarmStatus === 1) return '报警'
  239. if (item.currentStatus === 1) return '维修'
  240. if (item.currentStatus === 2) return '报废'
  241. if (item.onlineStatus === 1) return '在线'
  242. return '状态未发布'
  243. }
  244. /**
  245. * 设施枚举词表(维护者 2026-10-29 走查逐条点名的口径)。
  246. *
  247. * 后端这些字段是**码值**(status/networkType/networkLevel),直接印数字就是漏码;
  248. * 但认不出的值也不能编一个中文,所以统一走 `codedText()`:命中词表回「中文」,
  249. * 未命中回「码」本身(与 CODE_TEXT 的立场一致)。
  250. */
  251. const FACILITY_STATUS_TEXT = { '0': '正常', '1': '维护中', '2': '异常' }
  252. const NETWORK_TYPE_TEXT = { '0': '燃气管网', '1': '排水管网', '2': '供水管网' }
  253. const NETWORK_LEVEL_TEXT = { '0': '污水', '1': '雨水' }
  254. /** 排水工程设施类型(0 闸门 / 1 调蓄设施 / 2 泵站):同样是码值,一并直译。 */
  255. const ENGINEERING_FACILITY_TYPE_TEXT = { '0': '闸门', '1': '调蓄设施', '2': '泵站' }
  256. /** 类别中文名:界面上用「中文 + 英文码」,只印英文等于把接口词汇漏上大屏。 */
  257. const FACILITY_CATEGORY_TEXT = {
  258. PIPE_NETWORK: '管网',
  259. WATER_PLANT: '水厂',
  260. PUMP_STATION: '泵站',
  261. ENGINEERING_FACILITY: '排水工程设施',
  262. EMERGENCY_RESOURCE: '应急资源',
  263. HAZARD: '危险源',
  264. PROTECTION_TARGET: '防护目标',
  265. }
  266. /** 码 → 显示值;给出词表时「中文(码)」,未命中回码本身(不猜、不留空)。 */
  267. function codedText(value, dictionary, withCode = false) {
  268. const code = value === null || value === undefined ? '' : String(value).trim()
  269. if (!code) return ''
  270. const label = dictionary && dictionary[code]
  271. if (!label) return code
  272. return withCode ? `${label}(${code})` : label
  273. }
  274. /** 类别显示名:一律中文(维护者 2026-10-29:取消英文括注)。 */
  275. export function facilityCategoryText(category) {
  276. return codedText(category, FACILITY_CATEGORY_TEXT)
  277. }
  278. /** 设施状态(0 正常 / 1 维护中 / 2 异常)→ 中文;空值如实空。 */
  279. export function facilityStatusText(status) {
  280. return codedText(status, FACILITY_STATUS_TEXT)
  281. }
  282. /**
  283. * 设施详情字段表(维护者 2026-10-29 设计规范)。
  284. *
  285. * 三条口径写死在这一处,别的层不再重复判断:
  286. * 1. **命名一律中文**,必要时用括号补英文码/后端字段名(英文只作补充,不作主名);
  287. * 2. **凡有量纲的读数必带单位**;单位来自后端领域注释(m³/d、m³/h、m、mm、m³、kW…),
  288. * 不从前端猜;
  289. * 3. 某些指标只在特定子类型下有效(如管网「二级分类」只对**排水管**有意义),
  290. * 无效时**保留该行并显示「—」**,不是把它删掉 —— 删掉会让读者以为这个设施没这个属性。
  291. *
  292. * 每项形如 { label, key, unit?, format?, always? }:
  293. * · `format` 把码值翻成中文(枚举词表只在这里登记);
  294. * · `always` 表示"无效也占位"(与 compactSections 的缺值剔除相反)。
  295. */
  296. function pipesFields(a) {
  297. const drainage = String(a.networkType ?? '').trim() === '1'
  298. return [
  299. { label: '管网类型', key: 'networkType', format: (v) => codedText(v, NETWORK_TYPE_TEXT) },
  300. // 二级分类=原来的"管网等级":后端 network_level 只用于区分排水管道(污水/雨水)。
  301. // 非排水管这个字段没有业务含义,按要求显示「—」而不是隐藏。
  302. {
  303. label: '二级分类',
  304. key: 'networkLevel',
  305. always: true,
  306. format: (v) => (drainage ? codedText(v, NETWORK_LEVEL_TEXT) : ''),
  307. invalidNote: drainage ? '' : '仅排水管道适用',
  308. },
  309. { label: '长度', key: 'lengthMeters' },
  310. { label: '管径', key: 'diameterMillimeters' },
  311. { label: '材质', key: 'material' },
  312. { label: '管养单位', key: 'managementUnit' },
  313. { label: '建设日期', key: 'buildDate' },
  314. { label: '备注', key: 'remark', wide: true },
  315. ]
  316. }
  317. const ASSET_FIELD_ROWS = {
  318. PIPE_NETWORK: null, // 见 pipesFields():需要按管网类型决定二级分类是否适用
  319. WATER_PLANT: [
  320. { label: '水源类型', key: 'waterSourceType' },
  321. { label: '设计能力', key: 'designCapacity' },
  322. { label: '当前能力', key: 'currentCapacity' },
  323. { label: '处理工艺', key: 'treatmentProcess', wide: true },
  324. { label: '投产日期', key: 'commissionDate' },
  325. { label: '水质标准', key: 'waterQualityStandard' },
  326. { label: '负责人', key: 'principal' },
  327. { label: '联系电话', key: 'contactPhone' },
  328. { label: '备注', key: 'remark', wide: true },
  329. ],
  330. ENGINEERING_FACILITY: [
  331. { label: '设施类型', key: 'facilityType', format: (v) => codedText(v, ENGINEERING_FACILITY_TYPE_TEXT) },
  332. { label: '设施等级', key: 'facilityLevel' },
  333. { label: '管理单位', key: 'managementUnit' },
  334. { label: '建设日期', key: 'buildDate' },
  335. { label: '设计流量', key: 'designFlow' },
  336. { label: '设计扬程', key: 'designHead' },
  337. { label: '调蓄容积', key: 'storageCapacity' },
  338. { label: '关联设备', key: 'equipmentIds', wide: true },
  339. { label: '备注', key: 'remark', wide: true },
  340. ],
  341. PUMP_STATION: [
  342. { label: '泵站类型', key: 'stationType' },
  343. { label: '水泵数量', key: 'pumpCount' },
  344. { label: '设计流量', key: 'designFlow' },
  345. { label: '设计扬程', key: 'designHead' },
  346. { label: '装机功率', key: 'powerCapacity' },
  347. { label: '投产日期', key: 'commissionDate' },
  348. { label: '负责人', key: 'principal' },
  349. { label: '联系电话', key: 'contactPhone' },
  350. { label: '备注', key: 'remark', wide: true },
  351. ],
  352. EMERGENCY_RESOURCE: [
  353. { label: '设备类型', key: 'equipmentTypeId' },
  354. { label: '型号', key: 'model' },
  355. { label: '规格', key: 'specification', wide: true },
  356. { label: '制造商', key: 'manufacturer' },
  357. { label: '资产价值', key: 'assetValue' },
  358. { label: '预计使用年限', key: 'useLifeYears' },
  359. { label: '安装时间', key: 'installTime' },
  360. { label: '维护人', key: 'maintainer' },
  361. { label: '维护电话', key: 'maintainerPhone' },
  362. { label: '权属单位', key: 'ownershipUnit' },
  363. { label: '设备图片', key: 'image' },
  364. { label: '备注', key: 'remark', wide: true },
  365. ],
  366. HAZARD: [
  367. { label: '隐患类型', key: 'hazardType' },
  368. { label: '隐患等级', key: 'level' },
  369. { label: '来源', key: 'source' },
  370. { label: '隐患描述', key: 'description', wide: true },
  371. { label: '关联管网', key: 'networkId' },
  372. { label: '关联设备', key: 'equipmentId' },
  373. { label: '关联工单', key: 'workOrderId' },
  374. { label: '报告单位', key: 'reportUnit' },
  375. { label: '报告时间', key: 'reportTime' },
  376. { label: '整改方案', key: 'rectifyPlan', wide: true },
  377. { label: '整改期限', key: 'rectifyDeadline' },
  378. { label: '整改人', key: 'rectifyPerson' },
  379. { label: '整改反馈', key: 'rectifyFeedback', wide: true },
  380. { label: '隐患图片', key: 'images', wide: true },
  381. { label: '备注', key: 'remark', wide: true },
  382. ],
  383. PROTECTION_TARGET: [
  384. { label: '关联管网', key: 'networkId' },
  385. { label: '风险等级', key: 'riskLevel' },
  386. { label: '风险分值', key: 'riskScore' },
  387. { label: '风险颜色', key: 'riskColor' },
  388. { label: '评估模型', key: 'assessmentModel' },
  389. { label: '评估时间', key: 'assessmentTime' },
  390. { label: '风险因素', key: 'riskFactors', wide: true },
  391. { label: '危险源', key: 'hazardSources', wide: true },
  392. { label: '备注', key: 'remark', wide: true },
  393. ],
  394. }
  395. /** 读数 + 单位(认不出的值留给 field() 变占位符,不拼半截 "— m")。 */
  396. function withUnitText(value, unit) {
  397. if (value === null || value === undefined || String(value).trim() === '') return ''
  398. return unit ? `${value} ${unit}` : String(value)
  399. }
  400. function assetFields(category, a) {
  401. const rows = category === 'PIPE_NETWORK' ? pipesFields(a) : ASSET_FIELD_ROWS[category] || []
  402. return rows.map((row) => {
  403. // 单位一律由 `unitOf(category, key)` 给:同一字段名在不同设施里量纲可能不同。
  404. const raw = row.format ? row.format(a[row.key]) : withUnitText(a[row.key], unitOf(category, row.key))
  405. // always 行(如非排水管的二级分类)保留占位;其余缺值由 compactSections 剔除。
  406. const item = field(row.label, raw, row.wide === true)
  407. if (row.always) {
  408. item.always = true
  409. if (row.invalidNote && item.value === PLACEHOLDER) item.note = row.invalidNote
  410. }
  411. return item
  412. })
  413. }
  414. function coordinatesText(value) {
  415. if (!value || typeof value !== 'object') return ''
  416. const start = [value.longitude, value.latitude].filter((item) => item !== null && item !== undefined).join(', ')
  417. const end = [value.endLongitude, value.endLatitude].filter((item) => item !== null && item !== undefined).join(', ')
  418. return [start, end].filter(Boolean).join(' → ')
  419. }
  420. /**
  421. * 事件等级 → 色键 + 文字。
  422. *
  423. * **三种输入形状都要认**(维护者 2026-10-29 走查:「等级明明能拿到却标成未知」):
  424. * · 后端原始事件行:`level: 1`(数字);
  425. * · 列表行视图 `eventRowView`:`level: { tone, label }`(已翻译过的对象);
  426. * · 移动端预警/报警详情:`warningLevel / alarmLevel`(数字或等级名)。
  427. * 详情面板首屏先用列表行 prefill、随后被远端覆盖,两条路都要能读出同一个等级 ——
  428. * 早先只认数字,prefill 的等级对象就被判成"等级未知"。
  429. */
  430. /** 「二级预警」「3级」「四级」→ 1..4;认不出回 null(不猜等级)。 */
  431. function numericLevelOf(value) {
  432. const text = value === null || value === undefined ? '' : String(value).trim()
  433. if (!text) return null
  434. const arabic = /([1-4])\s*级/.exec(text)
  435. if (arabic) return Number(arabic[1])
  436. const cn = /([一二三四])\s*级/.exec(text)
  437. if (cn) return { 一: 1, 二: 2, 三: 3, 四: 4 }[cn[1]]
  438. return null
  439. }
  440. function eventLevelFrom(source) {
  441. const raw = source || {}
  442. // 已经翻译过的对象:原样沿用(这正是列表与详情一致的结构性保证)。
  443. const tagged = raw.level
  444. if (tagged && typeof tagged === 'object' && tagged.label) {
  445. return { tone: tagged.tone || DEFAULT_TONE, label: tagged.label }
  446. }
  447. // 移动端字段名与等级名(如 "二级预警")走桶解析;认不出回「等级未知」。
  448. const direct = raw.level ?? raw.warningLevel ?? raw.alarmLevel
  449. if (typeof direct === 'number') return eventLevelTag(direct)
  450. // 中文等级名(后端会下发「二级预警」「三级报警」这类人话)→ 阿拉伯数字。
  451. const numeric = numericLevelOf(direct)
  452. if (numeric !== null) return eventLevelTag(numeric)
  453. const fromBucket = eventLevelFromBucket(raw.levelCode ?? raw.levelColor ?? direct, direct)
  454. return eventLevelTag(fromBucket)
  455. }
  456. function normalizeEvent(identity, source) {
  457. const envelope = source && source.basicInfo ? source : {}
  458. const basic = envelope.basicInfo && typeof envelope.basicInfo === 'object' ? envelope.basicInfo : source
  459. source = { ...basic,
  460. disposalHistory: envelope.disposalHistory || basic.disposalHistory,
  461. attachmentList: envelope.attachmentList || basic.attachmentList,
  462. alarmList: envelope.alarmList || basic.alarmList,
  463. gisLocation: envelope.gisLocation || basic.gisLocation,
  464. }
  465. const family = String(identity.family || source.family || source.eventFamily || (source.alarmId || source.alarmNo ? 'ALARM' : source.warningId || source.warningNo ? 'WARNING' : '')).toUpperCase()
  466. const level = eventLevelFrom(source)
  467. const status = eventStatusText(source.status || source.state) || text(source.statusName || source.stateName)
  468. const name = text(source.name || source.warningName || source.alarmName || source.eventName || source.deviceName || source.title) || identity.id
  469. // 展示用的事件编码一律取**业务编号**(warningNo / alarmNo),主键只用于取数。
  470. const displayCode = text(source.codeText || source.code || source.warningNo || source.alarmNo || source.eventCode) || identity.id
  471. return {
  472. ...identity,
  473. name,
  474. displayCode,
  475. title: name,
  476. kindLabel: family === 'ALARM' ? '报警' : family === 'WARNING' ? '预警' : '事件',
  477. family,
  478. statusText: status,
  479. tone: level.tone || 'neutral',
  480. // 抬头直接显示等级(与卡片同一张词表、同一套色),不再只写在字段里。
  481. levelLabel: level.label,
  482. levelTone: level.tone,
  483. raw: source,
  484. // 等级带语义色(与卡片同一张词表),是摘要里唯一的等级呈现位置。
  485. summary: summaryItems([['等级', level.label, level.tone], ['状态', status], ['当前值', withUnit(source.value ?? source.actualValue, source.unit || source.warningUnit)], ['发生时间', source.eventTime || source.createTime]]),
  486. tabs: family === 'WARNING'
  487. ? [tab('overview', '预警信息'), tab('records', '处置记录'), tab('alarms', '关联报警'), tab('attachments', '附件')]
  488. : [tab('overview', '报警信息'), tab('devices', '设备信息'), tab('records', '处置记录'), tab('attachments', '附件')],
  489. relatedAlarms: Array.isArray(source.alarmList) ? source.alarmList.map(relatedAlarm) : [],
  490. stage: eventStage(family, status, source.disposalHistory),
  491. people: peopleItems([['发布人', source.publisherName || source.publisher], ['处置人', source.handlerName || source.handler], ['核验人', source.reviewerName || source.reviewer]]),
  492. devices: deviceItems(source),
  493. attachments: attachmentItems(source.attachmentList),
  494. records: recordItems(source.disposalHistory || source.logs),
  495. sections: compactSections([
  496. // 等级只出现在摘要卡片那一处(带色),这里不再重复一遍纯文字等级。
  497. section('事件摘要', [field('事件编码', displayCode), field('状态', status), field('发生时间', source.eventTime || source.createTime)]),
  498. section('监测读数', [
  499. field('指标', source.type || source.warningType || source.warningCode),
  500. // 读数与阈值都带单位(维护者 2026-10-29:数值型指标必须有单位提示)。
  501. field('实际值', withUnit(source.value ?? source.actualValue, source.unit || source.warningUnit)),
  502. field('阈值', threshold(source)),
  503. field('超出幅度', withUnit(source.deviationRatio, '%')),
  504. ]),
  505. section('关联信息', [field('设备', source.deviceName || source.deviceCode), field('监测点', source.monitoringPointName || source.monitoringPointCode), field('工单', source.workOrderCode || source.orderNo), field('位置', source.location || source.address || locationText(source.gisLocation), true)]),
  506. section('处置与附件', [
  507. field('处置记录', listCount(source.disposalHistory, '条')),
  508. field('关联报警', listCount(source.alarmList, '条')),
  509. field('附件', listCount(source.attachmentList, '项')),
  510. field('督办记录', listCount(source.supervisionList, '条')),
  511. ]),
  512. ]),
  513. }
  514. }
  515. function normalizeWorkOrder(identity, source) {
  516. const detailSource = source && source.order ? source : { order: source, logs: source.logs || [] }
  517. const raw = { ...(detailSource.order || {}) }
  518. if (!raw.workOrderCode && !raw.orderNo) raw.workOrderCode = identity.id
  519. if (raw.stateCode === undefined && raw.orderStatus === undefined && raw.status !== undefined) raw.stateCode = Number(raw.status)
  520. const order = toWorkOrder(raw)
  521. const timelineRaw = detailSource.logs || source.timeline || source.timelineEntries || source.records
  522. let track = null
  523. if (Array.isArray(timelineRaw)) {
  524. track = trackOf(toWorkOrderTimeline({ workOrder: raw, timelineEntries: timelineRaw.map((item) => ({
  525. operationName: item.operationName || item.operationType || item.action,
  526. operationDesc: item.operationDesc || item.remark || item.content,
  527. operatorName: item.operatorName || item.operator || item.createByName,
  528. operatedAt: item.operatedAt || item.operationTime || item.createTime,
  529. stateCode: item.stateCode ?? item.orderStatus,
  530. stateName: item.stateName || item.orderStatusName,
  531. })) }))
  532. } else if (source.workOrder && Array.isArray(source.timelineEntries)) {
  533. track = trackOf(toWorkOrderTimeline(source))
  534. }
  535. const stateText = order.stateName || workOrderStateName(order.stateCode)
  536. return {
  537. ...identity,
  538. name: order.code || identity.id,
  539. title: order.orderTypeName || order.code || identity.id,
  540. kindLabel: '工单',
  541. statusText: stateText,
  542. tone: workOrderTone(order.stateCode),
  543. order,
  544. track,
  545. raw: source,
  546. summary: summaryItems([['工单号', order.code], ['状态', stateText], ['优先级', orderLevelText(order.orderLevel)], ['计划完成', order.planFinishTime]]),
  547. tabs: [tab('overview', '工单信息'), tab('flow', '处理流程'), tab('devices', '设备信息'), tab('attachments', '附件')],
  548. stage: workOrderStage(order.stateCode),
  549. people: peopleItems([['处置人', order.receiveUserName], ['核验人', order.verifyUserName]]),
  550. devices: order.deviceCode || order.deviceName ? [{ name: order.deviceName || order.deviceCode, code: order.deviceCode, type: order.deviceTypeName, location: order.deviceLocation }] : [],
  551. attachments: attachmentItems([raw.maintenanceBeforeImg, raw.maintenanceAfterImg, ...timelineAttachments(timelineRaw)]),
  552. records: recordItems(timelineRaw),
  553. sections: compactSections([
  554. section('工单摘要', [field('工单号', order.code), field('类型', order.orderTypeName), field('优先级', orderLevelText(order.orderLevel)), field('状态', stateText)]),
  555. section('时间信息', [field('创建时间', order.createTime), field('计划完成', order.planFinishTime), field('实际完成', order.realFinishTime)]),
  556. section('设备信息', [field('设备', order.deviceName || order.deviceCode), field('设备类型', order.deviceTypeName), field('安装位置', order.deviceLocation, true)]),
  557. section('处置内容', [field('处置人', order.receiveUserName), field('核验人', order.verifyUserName), field('故障描述', order.description, true), field('处置结果', order.repairResult, true)]),
  558. ]),
  559. }
  560. }
  561. /** 工单优先级码值(0 紧急 / 1 一般 / 2 低)→ 中文;认不出回码。 */
  562. function orderLevelText(value) {
  563. const code = value === null || value === undefined ? '' : String(value).trim()
  564. if (!code) return ''
  565. return { '0': '紧急', '1': '一般', '2': '低' }[code] || code
  566. }
  567. function tab(key, label) { return { key, label } }
  568. function relatedAlarm(item) { return { code: item.alarmNo || item.alarmId || item.id || '', name: item.alarmName || item.content || item.deviceName || '关联报警', status: item.auditStatusName || item.alarmStatus || item.status || '' } }
  569. function summaryItems(entries) { return entries.filter(([,value]) => value !== null && value !== undefined && String(value).trim() !== '').map(([label,value,tone]) => ({ label, value: String(value), tone: tone || '' })) }
  570. function peopleItems(entries) { return entries.filter(([,name]) => name).map(([role,name]) => ({ role, name: String(name) })) }
  571. function deviceItems(source) {
  572. const name = source.deviceName || source.monitoringPointName || ''
  573. const code = source.deviceCode || source.monitoringPointCode || ''
  574. return name || code ? [{ name: name || code, code, location: source.location || source.address || '' }] : []
  575. }
  576. function attachmentItems(value) {
  577. const list = Array.isArray(value) ? value : value ? String(value).split(',') : []
  578. return list.flatMap((item) => {
  579. if (!item) return []
  580. if (typeof item === 'object') return [{ name: item.attachmentName || item.fileName || item.name || '附件', url: item.attachmentUrl || item.url || '', type: item.attachmentType || item.type || '' }]
  581. const url = String(item).trim(); return url ? [{ name: url.split('/').pop() || '附件', url, type: '' }] : []
  582. })
  583. }
  584. function recordItems(value) {
  585. return (Array.isArray(value) ? value : []).map((item) => ({
  586. title: item.operationName || item.operationType || item.action || item.disposalStatus || '处置记录',
  587. time: item.operatedAt || item.operationTime || item.createTime || item.disposalTime || '',
  588. operator: item.operatorName || item.operator || item.createByName || item.disposalUser || '',
  589. description: item.operationDesc || item.remark || item.content || item.disposalContent || '',
  590. }))
  591. }
  592. function eventStage(family, status, records) {
  593. const labels = family === 'ALARM' ? ['产生', '确认', '处置', '关闭'] : ['发布', '接收', '处置', '核验', '解除']
  594. const normalized = String(status || '').toUpperCase()
  595. let active = normalized.includes('RESOLV') || normalized.includes('CLEAR') || normalized.includes('CLOSE') ? labels.length - 1
  596. : normalized.includes('PROCESS') || normalized.includes('HANDL') ? Math.min(2, labels.length - 1)
  597. : normalized.includes('RECEIV') || normalized.includes('CONFIRM') ? 1 : 0
  598. if (Array.isArray(records) && records.length) active = Math.max(active, Math.min(2, labels.length - 1))
  599. return labels.map((label,index) => ({ label, state: index < active ? 'done' : index === active ? 'active' : 'pending' }))
  600. }
  601. function workOrderStage(code) {
  602. const labels = ['创建','派单','接单','处置','验收','完成']
  603. const n = Number(code); const active = n === 6 ? 3 : n === 7 ? 0 : Number.isInteger(n) ? Math.max(0, Math.min(n, 5)) : 0
  604. return labels.map((label,index) => ({ label, state: index < active ? 'done' : index === active ? 'active' : 'pending' }))
  605. }
  606. function timelineAttachments(records) { return (Array.isArray(records) ? records : []).flatMap((item) => item.attachList || item.attachments || []) }
  607. /**
  608. * 缺值剔除:always 行例外 —— 业务上"这个设施没有该属性"时要显式显示占位符
  609. * (维护者 2026-10-29:非排水管的二级分类显示「—」,不是把整行藏掉)。
  610. */
  611. function compactSections(sections) {
  612. return sections
  613. .map((group) => ({ ...group, fields: group.fields.filter((item) => item.always || item.value !== PLACEHOLDER) }))
  614. .filter((group) => group.fields.length)
  615. }
  616. function section(title, fields) { return { title, fields } }
  617. function field(label, value, wide = false) { return { label, value: display(value), wide, always: false, note: '' } }
  618. function display(value) { return value === null || value === undefined || String(value).trim() === '' ? PLACEHOLDER : String(value) }
  619. function text(value) { return value === null || value === undefined ? '' : String(value).trim() }
  620. /**
  621. * 读数 + 单位。三条纪律(维护者 2026-10-29):
  622. * · 没值就整句不出(不拼半截 "— μS/cm");
  623. * · **有值但后端没给单位**时如实标注「单位未发布」,不假装无单位是个读数;
  624. * · 有单位则一律追加 —— 数值型指标不允许裸数字上屏。
  625. */
  626. function withUnit(value, unit) {
  627. const hasValue = value !== null && value !== undefined && String(value).trim() !== ''
  628. if (!hasValue) return ''
  629. return unit ? `${value} ${unit}` : `${value}(单位未发布)`
  630. }
  631. function threshold(source) {
  632. const min = source.thresholdMinValue ?? source.minValue
  633. const max = source.thresholdMaxValue ?? source.maxValue
  634. if (min === undefined && max === undefined) return ''
  635. return withUnit(min !== undefined && max !== undefined ? `${min} ~ ${max}` : min !== undefined ? `≥ ${min}` : `≤ ${max}`, source.unit)
  636. }
  637. function positive(value, fallback) { const n = Number(value); return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback }
  638. function nonNegative(value, fallback) { const n = Number(value); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback }
  639. function listCount(value, unit) { return Array.isArray(value) ? `${value.length} ${unit}` : '' }
  640. function locationText(value) {
  641. if (!value || typeof value !== 'object') return ''
  642. return value.location || value.address || [value.longitude, value.latitude].filter((item) => item !== undefined && item !== null).join(', ')
  643. }
  644. function matchesId(row, id) { return !!row && [row.id, row.code, row.workOrderCode, row.warningNo, row.alarmId, row.facilityCode, row.resourceId].some((value) => String(value ?? '').trim() === id) }
  645. function workOrderTone(code) { if (code === 5) return 'lv4'; if (code === 7) return 'lv1'; if (code === 6) return 'lv2'; return 'lv3' }