infrastructurePanels.js 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708
  1. /**
  2. * 基础设施模式四个二级面板的**领域与视图层**(票 17)。
  3. *
  4. * 四块面板:基础档案 `archive` / 运行信息 `operation` / 管理信息 `management` / 应急保障 `emergency`。
  5. * 本模块做两件事,两件都是纯函数(不 import service、不碰 DOM、不自己发请求):
  6. * 1. **契约适配**:后端四个面板级聚合读模型的载荷 → 领域对象(字段改名、缺失记账、码翻人话);
  7. * 2. **视图计划**:领域对象 → 组件可以直接画的块(KPI / 分布 / 键值行 / 条目列表 / 可定位对象)。
  8. *
  9. * 票 18 起,**「块长什么样」与「码怎么翻」搬到 `domain/panelBlocks.js`**(票 17 判定四块共用一层
  10. * 视图计划,票 18/19/20 沿用同一形状 —— 于是"构造一个块"只有一份实现,各票只写"放哪些块")。
  11. * 本文件保留票 17 的全部对外导出名(含从 `panelBlocks.js` 转出的那几个),
  12. * 所以票 17 的 `verify/t17-infrastructure-panels.mjs` 一行都不用改。
  13. *
  14. * 后端形状依据(`E:\pipe-ner-server`,逐类读过响应类与演示载荷):
  15. * `CockpitInfrastructureBaseArchivesResponse` / `...OperatingInformationResponse` /
  16. * `...ManagementInformationResponse` / `...EmergencySupportResponse`,
  17. * 演示载荷 `docs/cockpit/infrastructure-*-demo.html`。
  18. * 注意四个切片的 BigDecimal 在演示载荷里是**字符串**(`"0.32"`),所以本层用 `numberish`
  19. * 宽进严出:数字与数字串都认,真 0 是有效值,认不出的回 null(占位符)。
  20. */
  21. import { fieldReader } from './fields.js'
  22. import { DETAIL_PLACEHOLDER } from './detailFlow.js'
  23. import { collectMissing, valueText } from './equipmentDetailView.js'
  24. import { industryLabel, normalizeDistrict, districtLabel } from './filterOptions.js'
  25. import {
  26. barsBlock,
  27. CARD_ACTION,
  28. childHasContent,
  29. codeText,
  30. dimensionBarsBlock,
  31. donutBlock,
  32. kpiBlock,
  33. listBlock,
  34. locateEntries,
  35. numberish,
  36. numberText,
  37. panelLocateEntries,
  38. panelMissingNote,
  39. ratioText,
  40. regionText,
  41. rowsBlock,
  42. sortByCountDesc,
  43. sectionHasContent,
  44. sectionPlaceholder,
  45. sectionPending,
  46. supplementalBlock,
  47. } from './panelBlocks.js'
  48. // 票 18:这些工具已搬到 `panelBlocks.js`,这里**原样转出**,票 17 的既有导入点不受影响。
  49. export {
  50. codeText,
  51. numberish,
  52. numberText,
  53. panelLocateEntries,
  54. ratioText,
  55. regionText,
  56. sectionHasContent,
  57. sectionPlaceholder,
  58. sectionPending,
  59. }
  60. /** 基础设施模式的四个 panelId(与 `domain/panelCatalog.js` 的目录一致,顺序即界面顺序)。 */
  61. export const INFRASTRUCTURE_PANEL_IDS = ['archive', 'operation', 'management', 'emergency']
  62. /** 码 → 色键(负荷状态用语义色,让"超载"在扫一眼时就能看见;认不出的回中性)。 */
  63. function loadTone(loadState) {
  64. if (loadState === 'OVERLOAD') return 'lv1'
  65. if (loadState === 'WATCH') return 'lv2'
  66. if (loadState === 'NORMAL') return 'lv4'
  67. return 'neutral'
  68. }
  69. // ================================================================ 契约适配
  70. /**
  71. * 分布行 → 视图行。
  72. *
  73. * 注意 `Distribution` 是**三类维度共用**的一个结构(`category` / `industry` / `area` 三选一有值),
  74. * 所以取键的字段名必须按维度给,不能一律读 `code` —— 后端 `industryDistribution` 的行里
  75. * `code` 是 null、值在 `industry` 上(见 `CockpitInfrastructureEmergencySupportService`
  76. * 的三个构造函数)。
  77. * @param {Array} list 后端分布数组
  78. * @param {string} keyField 该维度取值的字段名(`code` / `industry` / `area`)
  79. * @param {(value: string) => string} [toLabel] 取值 → 展示名
  80. */
  81. function distributionRows(list, keyField, toLabel) {
  82. return (Array.isArray(list) ? list : []).map((item) => {
  83. const reader = fieldReader(item, 'distribution')
  84. const value = reader.optionalStr(keyField) || reader.optionalStr('code')
  85. return { code: value, label: toLabel ? toLabel(value) : codeText(value), count: numberish(reader.raw.count) || 0 }
  86. })
  87. }
  88. /**
  89. * 基础档案(`/infrastructure/base-archives`)→ 领域对象。
  90. * 票 17 的四项:设施规模、行业分类、区域分布、档案摘要。
  91. */
  92. export function toBaseArchives(raw) {
  93. const reader = fieldReader(raw, 'baseArchives')
  94. const totals = fieldReader(reader.raw.totals, 'baseArchives.totals')
  95. const ageReference = fieldReader(reader.raw.ageReference, 'baseArchives.ageReference')
  96. return {
  97. updatedAt: reader.optionalStr('updatedAt'),
  98. rangeType: (reader.optionalObj('range') || {}).type || '',
  99. // 设施底数与里程(票 38:`totals.scale` 不再解析——真实后端恒 null,「规模」读数已删除)
  100. facilityCount: numberish(totals.raw.facilityCount),
  101. lengthKm: numberish(totals.raw.lengthKm),
  102. // 行业分类 / 区域分布 / 档案摘要(这三个分布的行都取值在 `code` 上)
  103. industries: distributionRows(reader.raw.industryDistribution, 'code', industryLabel),
  104. regions: reader.optionalList('districtDistribution').map((row) => ({ code: normalizeDistrict(row.district), label: districtLabel(row.district), count: numberish(row.count) })),
  105. ages: distributionRows(reader.raw.ageDistribution, 'code'),
  106. facilityTypes: (reader.raw.facilityTypeDistribution || []).map((item) => {
  107. const type = fieldReader(item, 'facilityType')
  108. return { code: type.optionalStr('code'), name: type.optionalStr('name') || type.optionalStr('code'), count: numberish(type.raw.count) || 0 }
  109. }),
  110. ageReference: {
  111. asOfDate: ageReference.optionalStr('asOfDate'),
  112. commissionedCount: numberish(ageReference.raw.commissionedCount),
  113. builtCount: numberish(ageReference.raw.builtCount),
  114. unknownCount: numberish(ageReference.raw.unknownCount),
  115. },
  116. // 口径剔除:后端把"为什么没算进来"如实给出,界面照实说明(票 17 的"档案摘要"要能解释口径)。
  117. excluded: {
  118. unknownTypeCount: numberish((reader.raw.excluded || {}).unknownTypeCount),
  119. typeKindMismatchCount: numberish((reader.raw.excluded || {}).typeKindMismatchCount),
  120. },
  121. selectedIndustry: reader.optionalObj('selectedFilters') ? reader.raw.selectedFilters.industry || '' : '',
  122. missing: reader.missing,
  123. }
  124. }
  125. /** 单个运行指标 → 领域对象。 */
  126. function toOperatingMetric(raw) {
  127. const reader = fieldReader(raw, 'operatingMetric')
  128. const freshness = fieldReader(reader.raw.freshness, 'operatingMetric.freshness')
  129. return {
  130. code: reader.optionalStr('metricCode'),
  131. name: reader.optionalStr('metricName') || reader.optionalStr('metricCode'),
  132. category: reader.optionalStr('category'),
  133. unit: reader.optionalStr('unit'),
  134. value: numberish(reader.raw.value),
  135. capacityValue: numberish(reader.raw.capacityValue),
  136. loadRatio: numberish(reader.raw.loadRatio),
  137. loadState: reader.optionalStr('loadState'),
  138. sampledAt: reader.optionalStr('sampledAt'),
  139. freshness: freshness.optionalStr('status'),
  140. ageSeconds: numberish(freshness.raw.ageSeconds),
  141. abnormal: reader.bool('abnormal', false),
  142. // 票 49 裁定:这里装的是**源设施编码**(水厂 plantCode / 泵站 stationCode / 管网 networkCode /
  143. // 排水设施 id),与一张图的监测点/设备编码空间结构性不重叠(后端将改名 `facilityCodes`)。
  144. // 字段照契约透传(实测恒单元素),但**不再喂定位**——票 50 起运行信息面板不产定位入口。
  145. pointCodes: reader.optionalList('pointCodes').map((item) => String(item)).filter(Boolean),
  146. missing: reader.missing,
  147. }
  148. }
  149. /**
  150. * 运行信息(`/infrastructure/operating-information`)→ 领域对象。
  151. * 票 17 要求「供应、输送、承载能力和运行指标」—— 三类能力对应后端的
  152. * `SUPPLY` / `FLOW` / `CAPACITY` 三个 `category`。(票 64:原措辞「指标占位」已按真实内容纠正。)
  153. */
  154. export function toOperatingInformation(raw) {
  155. const reader = fieldReader(raw, 'operatingInformation')
  156. return {
  157. updatedAt: reader.optionalStr('updatedAt'),
  158. rangeType: (reader.optionalObj('range') || {}).type || '',
  159. rangeStart: (reader.optionalObj('range') || {}).start || '',
  160. rangeEnd: (reader.optionalObj('range') || {}).end || '',
  161. // 票 51:回显只留**码**(`{code,key,label}` 里 code 是稳定锚)—— 块选择用它定位对应块;
  162. // 未筛行业时后端回 null,这里如实回空串。
  163. selectedIndustry: (reader.optionalObj('selectedIndustry') || {}).code || '',
  164. // 按行业分组:后端本来就是按行业聚合的(一个行业一块指标),这里保持后端顺序,不重排。
  165. industries: reader
  166. .optionalList('industries')
  167. .map((item) => {
  168. const block = fieldReader(item, 'industryBlock')
  169. const industry = fieldReader(block.raw.industry, 'industryBlock.industry')
  170. return {
  171. code: industry.optionalStr('code'),
  172. label: industry.optionalStr('label') || industryLabel(industry.optionalStr('code')),
  173. state: block.optionalStr('state'),
  174. // 认不出的原因码回空串(接口词汇不上大屏,票 05/11 的教训)。
  175. stateText: '',
  176. abnormalMetricCount: numberish(block.raw.abnormalMetricCount),
  177. metrics: (block.raw.metrics || []).map(toOperatingMetric),
  178. }
  179. }),
  180. excluded: {
  181. unknownIndustryCount: numberish((reader.raw.excluded || {}).unknownIndustryCount),
  182. nonFormalIndustryCount: numberish((reader.raw.excluded || {}).nonFormalIndustryCount),
  183. },
  184. missing: reader.missing,
  185. }
  186. }
  187. /** 管理信息读模型:片区编码与行业计数,不消费已退役的机构/权属维度。 */
  188. export function toManagementInformation(raw) {
  189. const reader = fieldReader(raw, 'managementInformation')
  190. const totals = fieldReader(reader.raw.totals, 'managementInformation.totals')
  191. return {
  192. updatedAt: reader.optionalStr('updatedAt'),
  193. facilityCount: numberish(totals.raw.facilityCount),
  194. equipmentCount: numberish(totals.raw.equipmentCount),
  195. districtIndustry: reader.optionalList('districtIndustry').map((item) => ({
  196. district: normalizeDistrict(item.district),
  197. industry: typeof item.industry === 'string' ? item.industry : '',
  198. facilityCount: numberish(item.facilityCount),
  199. equipmentCount: numberish(item.equipmentCount),
  200. })),
  201. missing: reader.missing,
  202. }
  203. }
  204. /** 应急保障的一个条目 → 领域对象。 */
  205. function toEmergencyItem(raw) {
  206. const reader = fieldReader(raw, 'emergencyItem')
  207. const associations = fieldReader(reader.raw.eventAssociations, 'emergencyItem.eventAssociations')
  208. return {
  209. code: reader.optionalStr('objectCode'),
  210. name: reader.optionalStr('objectName') || reader.optionalStr('objectCode'),
  211. category: reader.optionalStr('category'),
  212. industry: reader.optionalStr('industry'),
  213. area: reader.optionalStr('area'),
  214. pointCodes: reader.optionalList('pointCodes').map((item) => String(item)).filter(Boolean),
  215. warningCount: numberish(associations.raw.warningCount),
  216. alarmCount: numberish(associations.raw.alarmCount),
  217. workOrderCount: numberish(associations.raw.workOrderCount),
  218. missing: reader.missing,
  219. }
  220. }
  221. /** 应急保障的一个子分区(危险源 / 防护目标 / 应急资源各一块)。 */
  222. function toEmergencySection(raw) {
  223. const reader = fieldReader(raw, 'emergencySection')
  224. return {
  225. state: reader.optionalStr('state'),
  226. stateReason: reader.optionalStr('stateReason'),
  227. items: (reader.raw.items || []).map(toEmergencyItem),
  228. }
  229. }
  230. /**
  231. * 应急保障(`/infrastructure/emergency-support`)→ 领域对象。
  232. * 票 17 要求「危险源、防护目标和应急资源」—— 正好是后端的三个 section。
  233. * **过期与不可用分得开**:后端 `UNAVAILABLE`(该段没取到)与 `EMPTY`(确实没有)是两回事。
  234. */
  235. export function toEmergencySupport(raw) {
  236. const reader = fieldReader(raw, 'emergencySupport')
  237. const totals = fieldReader(reader.raw.totals, 'emergencySupport.totals')
  238. const sections = fieldReader(reader.raw.sections, 'emergencySupport.sections')
  239. return {
  240. updatedAt: reader.optionalStr('updatedAt'),
  241. totals: {
  242. emergencyResourceCount: numberish(totals.raw.emergencyResourceCount),
  243. hazardSourceCount: numberish(totals.raw.hazardSourceCount),
  244. protectionTargetCount: numberish(totals.raw.protectionTargetCount),
  245. totalObjectCount: numberish(totals.raw.totalObjectCount),
  246. },
  247. sections: {
  248. hazardSources: toEmergencySection(sections.raw.hazardSources),
  249. protectionTargets: toEmergencySection(sections.raw.protectionTargets),
  250. emergencyResources: toEmergencySection(sections.raw.emergencyResources),
  251. },
  252. eventSummary: {
  253. warningCount: numberish((reader.raw.eventSummary || {}).warningCount),
  254. alarmCount: numberish((reader.raw.eventSummary || {}).alarmCount),
  255. workOrderCount: numberish((reader.raw.eventSummary || {}).workOrderCount),
  256. },
  257. // 分布行按维度取键:行业族值在 `industry` 上,区域族值在 `area` 上(`code` 为 null)。
  258. industries: distributionRows(reader.raw.industryDistribution, 'industry', industryLabel),
  259. areas: distributionRows(reader.raw.areaDistribution, 'area', regionText),
  260. selectedCategory: (reader.raw.selectedFilters || {}).category || '',
  261. missing: reader.missing,
  262. }
  263. }
  264. // ================================================================ 视图计划
  265. /**
  266. * 基础档案面板视图。
  267. * 验收四项:设施规模 / 行业分类 / 区域分布 / 档案摘要。
  268. */
  269. export function archivePanel(data, section) {
  270. const current = data || {}
  271. const blocks = [
  272. // 票 38(第 2、4 条):删除「规模」KPI(真实后端 totals.scale 恒 null,是空读数);
  273. // 设施总数与管线里程回到**同一行**呈现 —— 渲染层不再有 lengthKm 独占整行的特判,
  274. // KPI 网格 auto-fit 让两项并排,不改变其它读数。
  275. kpiBlock([
  276. { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' },
  277. { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' },
  278. ]),
  279. // 2026-10-13 维护者反馈:条形图太多易疲劳——行业分类改环形图(行业语义色),
  280. // 片区/年限保留条形(条形适合作排名比较与多类别)。
  281. // 票 37 走查扩展:行业是排名类别按计数降序;年限是**固定分桶**(<5/5–10/10–20/20+
  282. // 是时间轴语义),降序会把时间轴打乱,保持后端字典序;片区按计数降序。
  283. donutBlock('行业分类', sortByCountDesc(current.industries || [], (row) => row.count)),
  284. barsBlock('片区分布', sortByCountDesc(current.regions || [], (row) => row.count)),
  285. barsBlock('档案摘要 · 建成年限', current.ages || []),
  286. supplementalBlock(barsBlock('设施类型分布', sortByCountDesc((current.facilityTypes || []).map((row) => ({
  287. code: row.code, label: row.name, count: row.count,
  288. })), (row) => row.count))),
  289. ]
  290. const reference = current.ageReference || {}
  291. blocks.push(
  292. rowsBlock('档案摘要 · 口径', [
  293. { key: 'asOfDate', label: '统计基准日', value: valueText(reference.asOfDate) },
  294. { key: 'commissionedCount', label: '已投产', value: valueText(reference.commissionedCount) },
  295. { key: 'builtCount', label: '已建成', value: valueText(reference.builtCount) },
  296. { key: 'unknownCount', label: '年限未知', value: valueText(reference.unknownCount) },
  297. ]),
  298. )
  299. return {
  300. name: 'archive',
  301. label: '基础档案',
  302. section,
  303. hasContent: sectionHasContent(section),
  304. blocks,
  305. // 基础档案端点**不给逐设施的编码与坐标**(只有聚合分布),所以本面板没有可定位对象。
  306. locate: [],
  307. locateNote: '基础档案是聚合分布口径(后端不返回逐设施坐标),本面板没有可定位对象',
  308. missing: panelMissingNote(current),
  309. placeholder: sectionPlaceholder(section),
  310. }
  311. }
  312. /**
  313. * 票 64:运行指标列表**页码归属的筛选条件标识**(翻页复位判据的输入,不是页码本身)。
  314. *
  315. * 为什么要有这个串:规范条文 5/6 要求「条件变化 → 页码回第 1 页;定时刷新不复位」,
  316. * 而视图计划在这两种情况下**都会**重算出新块对象——按对象身份判条件变没变,
  317. * 会把每次刷新都读成换筛选(票 43 第四轮记过的同一条教训:别拿对象当键)。
  318. * 所以由 domain 给一个跨刷新稳定的标量:当前行业块 + 全局片区键。
  319. * 换行业/换片区 ⇒ 串变 ⇒ 渲染层清掉该块页码(回第 1 页);周期刷新(同一筛选)⇒ 串不变 ⇒ 页码保持。
  320. * **切走再切回也算一次条件变化**(同样回第 1 页)——所以这个串只当**判据**用,
  321. * 不编进页码归属键:编进键会让切回时读到上次留下的旧槽位(条文 6 不许)。
  322. *
  323. * `extra.scope` 由外壳传入(与台账徽标、饼图选中态同一个 extra 通道);
  324. * 缺失或不是字符串时回空串——不拿脏值造出一个会漂移的假作用域。
  325. */
  326. function operationPageScope(active, extra) {
  327. const district = extra && typeof extra.scope === 'string' ? extra.scope : ''
  328. return `${active.code || ''}|${district}`
  329. }
  330. /**
  331. * 票 73(维护者 2026-10-23 裁定 A):指标行标题带设施编码。
  332. *
  333. * 为什么必须带:同页 10 行 `metricName` 全同(燃气首屏第 2–10 行连读数都是 1.00 m、
  334. * `loadState`/`freshness` 也全同),行上唯一能分开它们的身份就是设施编码
  335. * (票 49 裁定的 `pointCodes`,实测 6362 条恒单元素)。标题仍是**单源**:只在本函数拼,
  336. * 渲染层不拼第二份。缺编码时退回指标名——不留「 · 」悬空分隔符。
  337. *
  338. * **编码在前、指标名在后**(维护者 2026-10-23 追加裁定):`.panel-item__title` 是单行
  339. * nowrap + 共用省略,省略号切的是**文本尾部**——把 16 字符编码放在指标名后面,
  340. * 恰好把唯一可辨识的尾号切掉(票 73 首次实施实测反馈「最后几位隐藏,显示不直观」)。
  341. * 编码放最前则无论栏多窄都先保住身份,指标名放后面(同页同名时它本就重复,被省略不损失信息)。
  342. */
  343. function metricTitle(metric) {
  344. const facilityCode = (metric.pointCodes || [])[0] || ''
  345. return facilityCode ? `${facilityCode} · ${metric.name}` : metric.name
  346. }
  347. /**
  348. * 运行信息面板视图。
  349. * 验收四项:供应 / 输送 / 承载能力 + **运行指标**(票 64:原措辞「指标占位」是占位期遗留词,
  350. * 实际内容是真实指标清单;真实数据 6772 条/2.3 MB 已证伪「契约有界」,本块接入统一分页)。
  351. */
  352. export function operationPanel(data, section, extra) {
  353. const current = data || {}
  354. // 票 51:按**选中行业**选块,不按下标 —— 后端恒定按 GAS/WATER_SUPPLY/DRAINAGE/MANHOLE 四块返回,
  355. // 筛选只把未选中块置空(EMPTY/NO_METRICS)不删块、不改变块序,块序恒以燃气打头;
  356. // 只读 industries[0] 会把"选了供水/排水"说成"燃气没有指标"(数据就在同一响应里)。
  357. // 定位用后端回显的 selectedIndustry(码);未筛行业时回显为空,保持既有"取第一块"的口径
  358. // (与后端回显一致,不自己合并多块)。industryNote 与 metrics 都由这一个 active 推出
  359. // —— 两处真相不漂移(票 50 后 locate 不再随 active 变化:定位入口整体撤下,恒为空)。
  360. const industryBlocks = current.industries || []
  361. const selected = current.selectedIndustry || ''
  362. const matched = selected ? industryBlocks.find((block) => block.code === selected) : null
  363. const active = matched
  364. // 回显的码在 industries 里定位不到(契约外形状)时,归因仍用回显自身(码翻人话),不说成"燃气"或"全部"。
  365. || (selected ? { code: selected, label: industryLabel(selected) || selected, metrics: [] } : null)
  366. || industryBlocks[0]
  367. || { code: '', label: '', metrics: [] }
  368. const metrics = active.metrics || []
  369. /** 按 category 取一类能力指标(验收的"供应/输送/承载"就是这三个 category)。 */
  370. const byCategory = (code) => metrics.filter((metric) => metric.category === code)
  371. /** 一类能力的"当前读数":取该类第一条(后端同一类下通常只有一条主指标)。 */
  372. const headline = (code) => {
  373. const found = byCategory(code)[0]
  374. if (!found) return { key: code, label: codeText(code), value: DETAIL_PLACEHOLDER, hint: '后端未提供该类指标' }
  375. return {
  376. key: code,
  377. label: codeText(code),
  378. value: numberText(found.value, found.unit),
  379. hint: found.capacityValue === null ? '无能力基准' : `能力基准 ${numberText(found.capacityValue, found.unit)} · 负荷 ${ratioText(found.loadRatio)}`,
  380. tone: loadTone(found.loadState),
  381. }
  382. }
  383. const blocks = [
  384. kpiBlock([headline('SUPPLY'), headline('FLOW'), headline('CAPACITY')]),
  385. listBlock(
  386. // 票 64(维护者 2026-10-19 反馈第 2 条):块标题如实 —— 「指标占位」是占位期遗留词,
  387. // 这里一直是真实运行指标清单。
  388. '运行指标',
  389. metrics.map((metric, index) => ({
  390. // 票 64 顺带修的键缺陷(分页把它从「理论问题」变成「每帧都碰的问题」):
  391. // 旧键 `${active.code}|${metric.code}` 在真实载荷里**整块碰撞**——燃气 699 条
  392. // 全是同一个 metricCode(太常片区燃气长度),699 行共用一个键,
  393. // 而清单卡是 keyed v-for(`:key="row.key"`)。实测翻页内容仍正确,
  394. // 但节点身份已经不存在(换筛选/刷新时靠下标兜底重排),所以加序号后缀
  395. // 保证**块内每行身份唯一**;行序由后端给、前端不重排(票 10/11 立场),
  396. // 所以序号在同一条件下稳定。
  397. key: `${active.code || ''}|${metric.code}|${index}`,
  398. // 票 73 裁定 A:标题带设施编码且**编码在前**(同页十行同名;编码放后面会被省略号切掉尾号)。
  399. title: metricTitle(metric),
  400. identityKind: CARD_ACTION.FACILITY,
  401. identityCode: (metric.pointCodes || [])[0] || '',
  402. identityReason: (metric.pointCodes || [])[0] ? '设施无详情' : '缺少设施编码,无法展开',
  403. tone: loadTone(metric.loadState),
  404. // 色键必须**带文字**(票 06 的立场:标签不能只靠颜色,灰度/色盲下也要读得出)。
  405. toneLabel: codeText(metric.loadState),
  406. meta: [
  407. `当前 ${numberText(metric.value, metric.unit)}`,
  408. `能力 ${numberText(metric.capacityValue, metric.unit)}`,
  409. `负荷 ${ratioText(metric.loadRatio)}`,
  410. codeText(metric.loadState),
  411. codeText(metric.freshness),
  412. metric.abnormal ? '异常' : '',
  413. ].filter(Boolean),
  414. // 票 50:指标行**不产出定位点**(`points` 恒空)。`pointCodes` 实为源设施编码(票 49 裁定),
  415. // 不进一张图对象序列,画按钮就是「不可达做成常态」+ 撞名假阳性(PS001/PS002 会飞向
  416. // 不相干的排水监测点)。「没有可定位对象就不画按钮」由这里的构造保证(应急保障三段
  417. // 的既有形态),不再靠渲染层过滤。
  418. points: [],
  419. })),
  420. '当前行业没有运行指标',
  421. // 票 64:本块接入统一分页(规范条文 1/2/4 + 跳页节)。**为什么这里必须声明**:
  422. // 同一个 list 渲染分支服务五处面板,其中管理信息明细(实测 9 行)与应急保障三段
  423. // 是规范排除项或另有专票——只有 domain 逐块声明,渲染层才知道该不该切窗口。
  424. // 行序不改(票 10/11 立场:前端不重排),分页只切展示窗口。
  425. // `pageScope`=页码归属的筛选条件标识:换行业/换片区时它变,页码自然回第 1 页;
  426. // 定时刷新时它不变,页码保持(条文 6)。翻页算术与控件都在单源
  427. // (`domain/pagination.js` + `PagerNav.vue`),本层只声明「这一块要分页、按什么条件归页」。
  428. { paginate: true, pageScope: operationPageScope(active, extra) },
  429. ),
  430. ]
  431. return {
  432. name: 'operation',
  433. label: '运行信息',
  434. section,
  435. hasContent: sectionHasContent(section),
  436. blocks,
  437. // 当前选中行业(后端回显为空时不写"未选择",那会把"全部行业"说成缺陷)。
  438. industryNote: active.label ? `当前行业:${active.label}` : '当前行业:全部',
  439. // 票 64 第 4 条:如实说明数据来源与口径(票 49/50/51 既有裁定,不重开、不发明合并口径)。
  440. sourceNote: '运行指标由后端按设施实体逐条生成(水厂/泵站/管网/排水设施四类适配器),一条指标对应一个设施;当前行业取后端回显,不跨行业合并',
  441. // 票 50:定位入口整体撤下(locate 恒空)。该面板的关联编码是**设施**编码、不进一张图对象序列,
  442. // 对它跑 `locateEntries` 就是「不可达做成常态 + 撞名假阳性」;正确的对象交互**在图上**——
  443. // 管段/设施构件拾取走既有「重点对象局部增强」(pipeEnhancement,票 32),本票不动那条链路。
  444. locate: [],
  445. locateNote: '指标关联的是设施编码(水厂/泵站/管网/排水设施),不在一张图的点位序列里,本面板没有可定位对象;管段与设施构件请在图上拾取(地下管线详情视图)',
  446. missing: panelMissingNote(current),
  447. placeholder: sectionPlaceholder(section),
  448. }
  449. }
  450. /**
  451. * 管理信息面板(票 38 第 6 条):片区 × 行业**图表化**。
  452. *
  453. * 主图是**可切换维度的分类柱状图**(按片区 / 按行业两维,domain 各聚合一遍,切换纯展示、
  454. * 不回后端);下方保留紧凑**卡片清单**(逐 片区×行业 行给设施/设备计数,图看不出的组合明细看它)。
  455. * 只消费 `districtIndustry[]`;已退役的 `organizations`/`ownershipDistribution`/`unmaintained` 不再读取。
  456. * 「责任区域」=片区别名(决策 06),不新增独立维度。
  457. */
  458. export function managementPanel(data, section) {
  459. const current = data || {}
  460. const rows = current.districtIndustry || []
  461. const blocks = [
  462. kpiBlock([
  463. { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' },
  464. { key: 'equipmentCount', label: '设备总数', value: valueText(current.equipmentCount), unit: '台' },
  465. ]),
  466. // 主图:按片区 / 按行业切换的分类柱状图(主计数=设施数,设备数进悬停说明)。
  467. dimensionBarsBlock('片区 × 行业', rows, {
  468. countOf: (row) => row.facilityCount,
  469. extras: { equipmentCount: 'equipmentCount' },
  470. hintOf: (row) => `设备 ${valueText(row.equipmentCount)} 台`,
  471. unit: '处',
  472. emptyText: '当前范围没有片区与行业计数',
  473. dimensions: [
  474. { key: 'district', label: '按片区', field: 'district', emptyLabel: '未分区', labelOf: (value) => districtLabel(value) },
  475. { key: 'industry', label: '按行业', field: 'industry', emptyLabel: '行业未知', labelOf: (value) => industryLabel(value) || codeText(value) },
  476. ],
  477. }),
  478. // 下方紧凑清单:片区×行业逐组合的设施/设备计数(图看合计,清单看明细)。
  479. listBlock(
  480. '片区 × 行业明细',
  481. rows.map((row) => ({
  482. key: `${row.district ?? 'UNASSIGNED'}|${row.industry}`,
  483. title: `${districtLabel(row.district)} · ${industryLabel(row.industry) || '行业未知'}`,
  484. meta: [`设施 ${valueText(row.facilityCount)} 处`, `设备 ${valueText(row.equipmentCount)} 台`],
  485. points: [],
  486. })),
  487. '当前范围没有片区与行业计数',
  488. ),
  489. ]
  490. return {
  491. name: 'management',
  492. label: '管理信息',
  493. section,
  494. hasContent: sectionHasContent(section),
  495. blocks,
  496. // 票 68(维护者 2026-10-19 反馈第 3 条):公开「这些数从哪来、覆盖什么范围、与别处为何对得上」。
  497. // 三条必须说清(措辞纪律:只说业务含义与范围,**不出现接口字段名**——读者是值班人员):
  498. // 1. 计数来源:设施=基础设施档案目录(与「基础档案」**同一个目录、同一个适配器**,
  499. // 后端 BusinessCockpitInfrastructureBaseArchivesAdapter 同时实现基础档案与设施目录);
  500. // 设备=设备台账里能归入四个正式行业的监测设备;
  501. // 2. 「片区 × 行业」是什么:一行一个组合,两维不再拆第二遍;
  502. // 3. 与「基础档案」的设施总数**同源同口径**(2026-10-22 实测两处均 6776)——
  503. // 同一批设施实体,不各自维护一套总数。
  504. // 还要解释一处**看起来像缺陷的真实现象**:设施没有片区来源,设施全部落「未分区」,
  505. // 于是城南/城北会出现「有设备、没有设施」的行(票 34/38 片区契约迁移后的既知事实)。
  506. sourceNote:
  507. '设施计数取自基础设施档案目录(与「基础档案」是同一份目录、同一批设施实体),设备计数取自设备台账中能归入燃气/供水/排水/井盖四类的监测设备;「片区 × 行业」一行就是一个组合,只有既属于该片区又属于该行业的对象才计入,两个维度不重复拆分。设施的片区取档案目录登记的片区(当前设施档案没有片区来源,所以设施都记在「未分区」),设备的片区取设备自身登记 —— 因此会出现「有设备、没有设施」的片区行。设施总数与「基础档案」同源,两处不各自维护一套。',
  508. locate: [],
  509. locateNote: '片区与行业计数为聚合口径,本面板没有可定位对象',
  510. missing: panelMissingNote(current),
  511. placeholder: sectionPlaceholder(section),
  512. }
  513. }
  514. /**
  515. * 应急保障三段 → 条目列表块(每段的 state 决定它是内容、空还是不可用)。
  516. *
  517. * **票 63(批次五)**:三段接入统一分页(规范条文 1/2/4 的前端集合形态)。
  518. * 真实数据已证明这不是空转——实测 `protectionTargets` **20 条**(两页)、
  519. * `emergencyResources` 9 条、`hazardSources` 0 条;20 条那一段此前是一屏 20 张卡。
  520. *
  521. * 三条纪律落在这里:
  522. * 1. **三段各自独立页码**:`pageScope` 带上段标识(`hazardSources`/`protectionTargets`/
  523. * `emergencyResources`),翻危险源的页不会动到防护目标 —— 渲染层的页码槽按
  524. * 「面板名 + 块标题」寻址,标题本来就不同,但把段码写进 `pageScope` 才是
  525. * 真正按段复位的依据(换筛选时三段一起回第 1 页)。
  526. * 2. **段内行次序不动**:`pagedRows` 只切窗口,不排序(票 10/11 立场)。
  527. * 3. **定位按钮不受影响**:`points` 挂在被窗口化之后的行上,翻到第 2 页看到的是
  528. * 第 11–20 条的定位按钮,行为与翻页前完全一致。
  529. *
  530. * `stateText` 语义不变(`UNAVAILABLE`/`EMPTY` 分得开,且**优先于**空列表提示渲染)。
  531. * @param {string} label 段标题(也是渲染层页码槽的键之一)
  532. * @param {object} child 该段的后端分区
  533. * @param {string} emptyText 空态文案
  534. * @param {string} sectionKey 段码(`sections` 上的键;用于 `pageScope` 与断言面)
  535. */
  536. function emergencySectionBlock(label, child, emptyText, sectionKey) {
  537. const items = (child && child.items) || []
  538. const rows = items.map((item) => ({
  539. key: item.code,
  540. title: item.name,
  541. identityKind: CARD_ACTION.FACILITY,
  542. identityCode: item.code,
  543. identityReason: item.code ? '设施无详情' : '缺少设施编码,无法展开',
  544. meta: [
  545. codeText(item.category),
  546. industryLabel(item.industry) || codeText(item.industry),
  547. regionText(item.area),
  548. item.alarmCount ? `报警 ${item.alarmCount}` : '',
  549. item.warningCount ? `预警 ${item.warningCount}` : '',
  550. item.workOrderCount ? `工单 ${item.workOrderCount}` : '',
  551. ].filter(Boolean),
  552. points: item.pointCodes.map((pointCode) => ({
  553. key: `${item.code}|${pointCode}`,
  554. label: item.name,
  555. pointCode,
  556. objectCode: item.code,
  557. objectName: item.name,
  558. })),
  559. }))
  560. return {
  561. ...listBlock(label, childHasContent(child && child.state) ? rows : [], emptyText, {
  562. // 前端集合分页:数据已一次装齐,pager 只切窗口、零请求。
  563. paginate: true,
  564. // 页码归属键=段码 + 全局条件:换行业/片区/类别时三段一起回第 1 页,
  565. // 定时刷新(条件不变)页码保持(条文 5/6)。标量键,不接受对象身份(票 43 教训)。
  566. pageScope: emergencySectionScope(sectionKey, child),
  567. }),
  568. // 该段自己的成败(后端 `UNAVAILABLE` 与 `EMPTY` 分得开,界面也说得出区别)。
  569. state: (child && child.state) || '',
  570. stateText: child && child.state === 'UNAVAILABLE' ? '该段数据未取到' : child && child.state === 'EMPTY' ? '该段当前没有对象' : '',
  571. // 断层面:段码 + 该段真实条数(走查与闸门按段取数,不靠标题字符串猜)。
  572. sectionKey,
  573. total: rows.length,
  574. }
  575. }
  576. /**
  577. * 应急保障某一段的**页码归属键**(票 63)。
  578. *
  579. * 只用**标量**:段码 + 该段收到的条数 + 筛选回显(类别/行业/片区)。
  580. * 不把行内容/对象身份编进来——视图计划每次重算都是新对象,按对象记会让每次刷新
  581. * 都读成"换了筛选"(票 43 第四轮记过的教训,票 64 又踩过一次)。
  582. * 条数也进键:同一段的数据**变短**时(例如换类别后从 20 条降到 3 条),
  583. * 页码槽跟着换,越界自然回到第 1 页,不用另外写一条夹回逻辑。
  584. * @param {string} sectionKey 段码
  585. * @param {object} child 该段分区(提供条数与 state)
  586. */
  587. function emergencySectionScope(sectionKey, child) {
  588. const count = ((child && child.items) || []).length
  589. return `${sectionKey || ''}|${count}|${(child && child.state) || ''}`
  590. }
  591. /**
  592. * 应急保障面板视图。
  593. * 验收三项:危险源 / 防护目标 / 应急资源 —— 后端的三个 section 正好对应。
  594. */
  595. export function emergencyPanel(data, section) {
  596. const current = data || {}
  597. const sections = current.sections || {}
  598. const blocks = [
  599. kpiBlock([
  600. { key: 'hazardSourceCount', label: '危险源', value: valueText(current.totals && current.totals.hazardSourceCount), unit: '处' },
  601. { key: 'protectionTargetCount', label: '防护目标', value: valueText(current.totals && current.totals.protectionTargetCount), unit: '处' },
  602. { key: 'emergencyResourceCount', label: '应急资源', value: valueText(current.totals && current.totals.emergencyResourceCount), unit: '项' },
  603. ]),
  604. emergencySectionBlock('危险源', sections.hazardSources, '没有危险源对象', 'hazardSources'),
  605. emergencySectionBlock('防护目标', sections.protectionTargets, '没有防护目标', 'protectionTargets'),
  606. emergencySectionBlock('应急资源', sections.emergencyResources, '没有应急资源', 'emergencyResources'),
  607. barsBlock('区域分布', sortByCountDesc(current.areas || [], (row) => row.count)),
  608. ]
  609. const allItems = ['hazardSources', 'protectionTargets', 'emergencyResources'].flatMap((key) =>
  610. ((sections[key] || {}).items || []).map((item) => ({ item, key })),
  611. )
  612. // 票 68(维护者 2026-10-19 反馈第 3 条):应急保障也要说清「这些对象从哪来」。
  613. // 关联编码的说明是**条件句**(不只写当前实测):真实接口的三类对象目前一律不带编码
  614. // (后端适配器对三类对象都下发空列表,票 49 已记),所以界面此刻没有可定位对象——
  615. // 但这句话不能写成恒真的「没有编码」,否则后端日后真给出编码时文案就开始撒谎。
  616. const anyPointCode = allItems.some(({ item }) => (item.pointCodes || []).length > 0)
  617. const pointCodeNote = anyPointCode
  618. ? '对象带的关联编码是设施编码(不是一张图的监测点编码),可据此在图上查找对应设施。'
  619. : '对象带的关联编码是设施编码(不是一张图的监测点编码);当前三类对象都没有带编码,所以本面板没有可定位对象。'
  620. return {
  621. name: 'emergency',
  622. label: '应急保障',
  623. section,
  624. hasContent: sectionHasContent(section),
  625. blocks,
  626. locate: allItems.flatMap(({ item }) =>
  627. locateEntries(item.code, item.name, { code: item.code, name: item.name }, item.pointCodes),
  628. ),
  629. // 票 68 更正:原文案「关联点位可定位」在两处站不住——① 真实数据没有可定位对象(点了没东西);
  630. // ② 这些编码是**设施**编码(票 49 裁定),叫「点位」会把设施说成监测点。改为按实测条件如实说。
  631. locateNote: anyPointCode
  632. ? '三类对象带的关联编码是设施编码(不是一张图的监测点编码),定位按该编码在图上查找对应设施'
  633. : '三类对象当前都没有关联设施编码,本面板没有可定位对象',
  634. // 票 68:三段各自的业务来源 + 关联编码语义 + 关联事件口径(三段各一句,逐句可核)。
  635. // 措辞纪律:只说业务含义与范围,**不出现接口字段名**(读者是值班人员,不是接口调用方)。
  636. sourceNote: [
  637. '三类对象来自各自的业务台账、不合并:应急资源=设备台账里的监测设备;防护目标=燃气风险评估里登记的防护目标;危险源=隐患台账里能关联到设备的隐患(关联不到设备的不计入,所以这一段为空不等于隐患库为空)。',
  638. pointCodeNote,
  639. '关联事件=对象所在设备上尚未解除的预警、尚未关闭的报警与未结束的工单,按对象逐条加总(同一台设备的事件可能同时计在设备对象与它关联的危险源上),不是全城去重后的事件总数;只有能回联到设备的对象才有这类计数,防护目标不挂设备,它的关联事件恒为 0。',
  640. ].join(''),
  641. // 关联事件总量是后端给的口径,界面照实带出(不自己加总)。
  642. eventNote: current.eventSummary
  643. ? `关联事件:预警 ${valueText(current.eventSummary.warningCount)} · 报警 ${valueText(current.eventSummary.alarmCount)} · 工单 ${valueText(current.eventSummary.workOrderCount)}`
  644. : '',
  645. missing: panelMissingNote(current),
  646. placeholder: sectionPlaceholder(section),
  647. }
  648. }
  649. /** panelId → 视图构造函数(本模式自己的四块)。 */
  650. export const INFRASTRUCTURE_PANEL_BUILDERS = {
  651. archive: archivePanel,
  652. operation: operationPanel,
  653. management: managementPanel,
  654. emergency: emergencyPanel,
  655. }
  656. /**
  657. * 一个面板的视图。`data` 为 null(失败/空)时仍返回结构完整的视图(块为空),
  658. * 组件据此画占位说明 —— 「失败」不该让整块面板消失,那会连面板标题都没了。
  659. */
  660. export function infrastructurePanelView(panelId, data, section) {
  661. const build = INFRASTRUCTURE_PANEL_BUILDERS[panelId]
  662. if (!build) return null
  663. return build(data, section)
  664. }
  665. /** 收集一个面板视图里真实的字段缺失(含嵌套层,票 05 的 `missing` 清单口径)。 */
  666. export function panelMissingFields(data) {
  667. return collectMissing(data)
  668. }