monitoringPanels.js 65 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189
  1. /**
  2. * 运行监测模式四个二级面板的**领域与视图层**(票 18)。
  3. *
  4. * 四块面板:监测概览 `monitoringOverview` / 监测分布 `monitoringDistribution` /
  5. * 运行概况 `operationOverview` / 监测点台账 `monitoringPointLedger`。
  6. *
  7. * 分工与票 17 完全一致(本票沿用同一形状,不造第二套):
  8. * 1. **契约适配**:后端三个面板级聚合读模型的载荷 → 领域对象(字段改名、缺失记账、码翻人话);
  9. * 2. **视图计划**:领域对象 → 组件可以直接画的块。
  10. * 块的构造与码表在 `domain/panelBlocks.js`(票 18 从票 17 提出来共用),本文件只写"放哪些块"。
  11. *
  12. * 纯函数:不 import service、不碰 DOM、不自己发请求。
  13. *
  14. * 「入库时间」只认 createdAt(源记录 create_time),不使用 updatedAt 或 sampledAt 兜底。
  15. * 全部时间类措辞取自 `fields.js` 的 `TIME_LABELS`(票 62 的唯一词表),本文件不另写一版。
  16. * 点位状态中文来自 pointStatus;片区只消费正式 district 编码,空值显未分区。
  17. * 监测概览/运行概况不接收片区筛选,监测分布接收 district。
  18. *
  19. * 后端形状依据(`E:\pipe-ner-server`,逐类读过响应类、控制器与服务):
  20. * `CockpitMonitoringOverviewResponse` / `...DistributionResponse` / `...SituationResponse`、
  21. * `CockpitMonitoringOverviewController` / `...DistributionController` / `...SituationController`、
  22. * 服务 `CockpitMonitoring{Overview,Distribution,Situation}Service`,
  23. * 演示载荷 `docs/cockpit/monitoring-{overview,distribution,operating-situation}-demo.html`。
  24. * BigDecimal 在线上是字符串(`"0.32"`),所以走 `panelBlocks.js` 的 `numberish`。
  25. */
  26. import { fieldReader, TIME_LABELS } from './fields.js'
  27. import { DETAIL_PLACEHOLDER } from './detailFlow.js'
  28. import { valueText } from './equipmentDetailView.js'
  29. import { industryLabel, normalizeDistrict, districtLabel, districtFilterLabel } from './filterOptions.js'
  30. import { legendVisuals, visualForStatus } from './pointStatus.js'
  31. import { planStatusPie } from './statusPie.js'
  32. import { abnormalCountRows } from './abnormalCounts.js'
  33. import {
  34. barsBlock,
  35. CARD_ACTION,
  36. codeText,
  37. donutBlock,
  38. donutGridBlock,
  39. kpiBlock,
  40. listBlock,
  41. numberish,
  42. panelMissingNote,
  43. ratioText,
  44. regionText,
  45. rowsBlock,
  46. stackedBlock,
  47. supplementalBlock,
  48. sectionHasContent,
  49. sectionPlaceholder,
  50. sortByCountDesc,
  51. trendBlock,
  52. } from './panelBlocks.js'
  53. /** 运行监测模式的四个 panelId(与 `domain/panelCatalog.js` 的目录一致,顺序即界面顺序)。 */
  54. export const MONITORING_PANEL_IDS = [
  55. 'monitoringOverview',
  56. 'monitoringDistribution',
  57. 'operationOverview',
  58. 'monitoringPointLedger',
  59. ]
  60. /**
  61. * 指标状态码 → 人话(契约手册第 2 节冻结的六态:NORMAL/WARNING/ALARM/EXPIRED/OFFLINE/UNKNOWN)。
  62. *
  63. * 为什么自带一张而不是并进 `CODE_TEXT`:这六个词是**运行监测专用**的指标状态字典
  64. * (手册原句「运行监测专用,`EXPIRED` 不与 `OFFLINE` 合并」),而 `CODE_TEXT` 里
  65. * `EXPIRED` 属于新鲜度字典。两张字典在契约里就是两张,合成一张会让「指标过期」与
  66. * 「数据过期」共用措辞而看不出区别。六个取值**全部**登记,认不出的回原码。
  67. */
  68. export const METRIC_STATUS_TEXT = {
  69. NORMAL: '正常',
  70. WARNING: '预警',
  71. ALARM: '报警',
  72. EXPIRED: '过期',
  73. OFFLINE: '离线',
  74. UNKNOWN: '未发布',
  75. }
  76. /** 后端 `abnormalType` 与 `status` 在响应里取的是同一个值(服务逐行读过),所以只翻一份。 */
  77. export function metricStatusText(code) {
  78. const value = typeof code === 'string' ? code.trim() : ''
  79. if (!value) return '未知'
  80. return METRIC_STATUS_TEXT[value] || value
  81. }
  82. /** 入库时间与设备采样时间的语义说明(票 62:随唯一词表改名,措辞同步)。三种时间彼此不同:入库时间(createdAt,源记录写库时刻)/采样时间(sampledAt,设备采集时刻)/快照时间(信封 updatedAt,后端快照生成时刻)。 */
  83. export const METRIC_FRESHNESS_GAP =
  84. `${TIME_LABELS.latestIngested}=源记录写库时刻,不是设备采样时刻;缺失时显示占位符「—」,不使用接口响应或快照时间代替`
  85. /**
  86. * 全局状态分布**未取到**时的如实说明(票 14;决策 02:状态栏与饼图共用同一份
  87. * 全局未筛分布,数据源是 `map-snapshot` 的八桶全局计数)。缺失时不伪造全零饼图。
  88. */
  89. export const STATUS_DISTRIBUTION_UNPUBLISHED =
  90. '全局状态分布未取到(后端尚未发布八桶全局计数);顶部状态栏与本饼图共用同一份数据,发布前两者都不显示计数'
  91. /** 八桶 → 人话(来自票 09 的唯一一张表;含 UNKNOWN「状态未发布」与 UNASSOCIATED「无关联设备」)。 */
  92. const POINT_STATUS_FILTER_LABELS = Object.fromEntries(legendVisuals().map((visual) => [visual.key, visual.label]))
  93. /** 范围(区域)筛选的落点说明:三个端点里只有监测分布收 `region`。 */
  94. export const REGION_SCOPE_NOTE = '片区筛选按后端片区编码作用于监测分布'
  95. // 井盖(`MANHOLE`)**已可筛选**(票 18 实施时的缺口已由后端关闭):
  96. // 它现已是第四个正式行业,且全部 11 个 cockpit 服务都收(含 `map-snapshot`)。
  97. // 映射已接上(`domain/filterOptions.js` 的 `INDUSTRY_CODES.manhole`),
  98. // 因此本模块**不再有**「井盖无契约位」这条缺口常量 —— 曾用名 `MANHOLE_INDUSTRY_GAP` 已删除。
  99. // ================================================================ 契约适配
  100. /**
  101. * 监测概览(`/monitoring/overview`)→ 领域对象。
  102. * 票 18 验收第 1 条:监测点/设备规模、在线率、类型分布和最近采集时间。
  103. *
  104. * 端点入参只有 `industry` 与 `deviceType`(控制器逐行读过)——
  105. * **不收 `region`,也不收 `pointStatus`**,所以本面板的筛选面就是行业 + 类型。
  106. *
  107. * 票 62 追加需求:本端点还回出**逐设备台账** `devices[]`(与 `counts.equipmentCount`
  108. * 同源同口径,行数恒等于该计数)。运行概况面板的「设备台账」清单读的就是这一份 ——
  109. * 它替代了原先借用 `/monitoring/situation`「异常设备清单」的做法:那张清单只装异常四态
  110. * 设备(WARNING/ALARM/EXPIRED/OFFLINE),既不是台账、条数也与设备总数对不上。
  111. */
  112. export function toMonitoringOverview(raw) {
  113. const reader = fieldReader(raw, 'monitoringOverview')
  114. const counts = fieldReader(reader.raw.counts, 'monitoringOverview.counts')
  115. const status = fieldReader(reader.raw.status, 'monitoringOverview.status')
  116. const onlineRate = fieldReader(reader.raw.onlineRate, 'monitoringOverview.onlineRate')
  117. const freshness = fieldReader(reader.raw.freshness, 'monitoringOverview.freshness')
  118. const mapFilter = reader.optionalObj('mapFilter') || {}
  119. return {
  120. createdAt: reader.optionalStr('createdAt'),
  121. updatedAt: reader.optionalStr('updatedAt'),
  122. rangeType: (reader.optionalObj('range') || {}).type || '',
  123. // 规模
  124. monitoringPointCount: numberish(counts.raw.monitoringPointCount),
  125. equipmentCount: numberish(counts.raw.equipmentCount),
  126. facilityCount: numberish(counts.raw.facilityCount),
  127. infrastructureFacilityCount: numberish(counts.raw.infrastructureFacilityCount),
  128. // 在线率:`value` 是 0–1 的小数(后端 BigDecimal),`state` 为 EMPTY 时表示分母为 0。
  129. onlineRate: numberish(onlineRate.raw.value),
  130. onlineCount: numberish(onlineRate.raw.numerator),
  131. onlineDenominator: numberish(onlineRate.raw.denominator),
  132. onlineState: onlineRate.optionalStr('state'),
  133. // 设备在线/离线/滞后/未知四态 + 口径分母(后端 `Status`)。
  134. deviceStatus: {
  135. online: numberish(status.raw.onlineCount),
  136. offline: numberish(status.raw.offlineCount),
  137. stale: numberish(status.raw.staleCount),
  138. unknownOrMissing: numberish(status.raw.unknownOrMissingCount),
  139. denominator: numberish(status.raw.denominator),
  140. },
  141. // 类型分布:后端已按 deviceCount 降序排好(服务里排序),本层不重排。
  142. typeDistribution: reader.optionalList('typeDistribution').map((item) => {
  143. const row = fieldReader(item, 'typeDistribution')
  144. const type = fieldReader(row.raw.deviceType, 'typeDistribution.deviceType')
  145. return {
  146. code: type.optionalStr('code'),
  147. name: type.optionalStr('name') || type.optionalStr('code'),
  148. industry: row.optionalStr('industry'),
  149. deviceCount: numberish(row.raw.deviceCount),
  150. onlineCount: numberish(row.raw.onlineCount),
  151. offlineCount: numberish(row.raw.offlineCount),
  152. staleCount: numberish(row.raw.staleCount),
  153. }
  154. }),
  155. // 票 62 追加需求:逐设备台账(行数恒等于 `equipmentCount`,与上面那个计数同源)。
  156. devices: reader.optionalList('devices').map(toLedgerDevice),
  157. // 新鲜度:本端点只有信封级(见文件头第 ① 条)。
  158. freshness: freshness.optionalStr('status'),
  159. freshUpdatedAt: freshness.optionalStr('updatedAt'),
  160. serverUpdatedAt: freshness.optionalStr('serverUpdatedAt'),
  161. selectedIndustry: mapFilter.industry || '',
  162. selectedDeviceType: mapFilter.deviceType || '',
  163. // 口径剔除(后端解释"为什么这些设备没算进来",界面照实说明)。
  164. excluded: {
  165. unknownTypeCount: numberish((reader.raw.excluded || {}).unknownTypeCount),
  166. typeKindMismatchCount: numberish((reader.raw.excluded || {}).typeKindMismatchCount),
  167. nonFormalIndustryCount: numberish((reader.raw.excluded || {}).nonFormalIndustryCount),
  168. },
  169. missing: reader.missing,
  170. }
  171. }
  172. /** 分布行(区域/行业/设备类型三族共用同一个 `StatusCounts` 结构)→ 领域行。 */
  173. function toStatusCounts(raw) {
  174. const reader = fieldReader(raw, 'statusCounts')
  175. return {
  176. alarm: numberish(reader.raw.alarm),
  177. warning: numberish(reader.raw.warning),
  178. workOrder: numberish(reader.raw.workOrder),
  179. normal: numberish(reader.raw.normal),
  180. offline: numberish(reader.raw.offline),
  181. empty: numberish(reader.raw.empty),
  182. }
  183. }
  184. /**
  185. * 监测分布的一个地图标记 → 领域行。
  186. *
  187. * **注意本端点不给设备编码列表**(那是 `/map-snapshot` 的 `CockpitMonitoringPointSummary.deviceCodes`),
  188. * 所以台账卡片的设备徽标只能逐点位拉详情(票 18 的 Q10 裁定),不由本层伪造。
  189. */
  190. function toDistributionMarker(raw, kind) {
  191. const reader = fieldReader(raw, kind)
  192. const type = fieldReader(reader.raw.deviceType, `${kind}.deviceType`)
  193. return {
  194. kind,
  195. code: reader.optionalStr('pointCode') || reader.optionalStr('deviceCode'),
  196. name: reader.optionalStr('pointName') || reader.optionalStr('deviceName') || reader.optionalStr('deviceCode'),
  197. district: normalizeDistrict(reader.raw.district),
  198. createdAt: reader.optionalStr('createdAt'),
  199. industry: reader.optionalStr('industry'),
  200. typeCode: type.optionalStr('code'),
  201. typeName: type.optionalStr('name'),
  202. status: reader.optionalStr('pointStatus'),
  203. statusReasons: reader.optionalList('statusReasons').map((item) => String(item)),
  204. longitude: numberish(reader.raw.longitude),
  205. latitude: numberish(reader.raw.latitude),
  206. missing: reader.missing,
  207. }
  208. }
  209. /**
  210. * 监测分布(`/monitoring/distribution`)→ 领域对象。
  211. * 票 18 验收第 2 条:覆盖情况、状态分布和地图筛选。
  212. * 验收第 4 条的台账**共用这一次取数**(Q4 裁定)——它读同一个响应的 `monitoringPoints` 与 `pagination`。
  213. *
  214. * 三处后端语义(逐行读服务确认,不是猜的):
  215. * · 三份分布与状态计数是**全量口径**(服务先把全部 marker 聚合完再切片分页),
  216. * 所以它们不随翻页跳动 —— 面板报的是全城分布,不是"这一页的分布";
  217. * · 分页窗口横跨「监测点 + 未关联设备」一条总序列(与 `map-snapshot` 同一套语义);
  218. * · `region` 空值在响应里归一为字面量 `未分区`(是真实取值,不是空值)。
  219. */
  220. export function toMonitoringDistribution(raw) {
  221. const reader = fieldReader(raw, 'monitoringDistribution')
  222. const pagination = fieldReader(reader.raw.pagination, 'monitoringDistribution.pagination')
  223. const freshness = fieldReader(reader.raw.freshness, 'monitoringDistribution.freshness')
  224. const mapFilter = reader.optionalObj('mapFilter') || {}
  225. const regionRows = reader.optionalList('districtDistribution').map((item) => {
  226. const row = fieldReader(item, 'districtDistribution')
  227. return {
  228. code: normalizeDistrict(row.raw.district),
  229. pointCount: numberish(row.raw.pointCount) || 0,
  230. deviceCount: numberish(row.raw.deviceCount) || 0,
  231. statusCounts: toStatusCounts(row.raw.statusCounts),
  232. }
  233. })
  234. const industryRows = reader.optionalList('industryDistribution').map((item) => {
  235. const row = fieldReader(item, 'industryDistribution')
  236. return {
  237. code: row.optionalStr('industry'),
  238. pointCount: numberish(row.raw.pointCount) || 0,
  239. deviceCount: numberish(row.raw.deviceCount) || 0,
  240. statusCounts: toStatusCounts(row.raw.statusCounts),
  241. }
  242. })
  243. const typeRows = reader.optionalList('deviceTypeDistribution').map((item) => {
  244. const row = fieldReader(item, 'deviceTypeDistribution')
  245. const type = fieldReader(row.raw.deviceType, 'deviceTypeDistribution.deviceType')
  246. return {
  247. code: type.optionalStr('code'),
  248. name: type.optionalStr('name') || type.optionalStr('code'),
  249. industry: row.optionalStr('industry'),
  250. pointCount: numberish(row.raw.pointCount) || 0,
  251. deviceCount: numberish(row.raw.deviceCount) || 0,
  252. statusCounts: toStatusCounts(row.raw.statusCounts),
  253. }
  254. })
  255. return {
  256. createdAt: reader.optionalStr('createdAt'),
  257. updatedAt: reader.optionalStr('updatedAt'),
  258. // 票 62:快照标记(执行票同期由后端补齐回显)。台账自动链第 2 页起必须原样回带它,
  259. // 否则后端 400;token 只认后端回显,不由前端挑时间戳去猜。
  260. snapshotAt: reader.optionalStr('snapshotAt'),
  261. freshUpdatedAt: freshness.optionalStr('updatedAt'),
  262. freshness: freshness.optionalStr('status'),
  263. regions: regionRows,
  264. industries: industryRows,
  265. deviceTypes: typeRows,
  266. // 状态分布:后端按字典顺序给全部六个状态(含计数为 0 的,界面据此不丢行)。
  267. pointStatuses: reader.optionalList('pointStatusDistribution').map((item) => {
  268. const row = fieldReader(item, 'pointStatusDistribution')
  269. return { status: row.optionalStr('status'), count: numberish(row.raw.count) || 0 }
  270. }),
  271. // 台账(Q4:与分布共用这次取数)
  272. points: reader.optionalList('monitoringPoints').map((item) => toDistributionMarker(item, 'monitoringPoint')),
  273. equipment: reader.optionalList('unassociatedEquipment').map((item) => toDistributionMarker(item, 'unassociatedEquipment')),
  274. pagination: {
  275. page: numberish(pagination.raw.page),
  276. pageSize: numberish(pagination.raw.pageSize),
  277. total: numberish(pagination.raw.total),
  278. totalPages: numberish(pagination.raw.totalPages),
  279. hasMore: pagination.bool('hasMore', false),
  280. },
  281. // 生效范围回显(Q3:筛选入口只有全局筛选条,面板靠这段回显,不自己存一份筛选状态)。
  282. selectedIndustry: mapFilter.industry || '',
  283. selectedDeviceType: mapFilter.deviceType || '',
  284. selectedPointStatus: mapFilter.pointStatus || '',
  285. selectedDistrict: normalizeDistrict(mapFilter.district),
  286. districtFilter: mapFilter.districtFilter || '',
  287. excluded: {
  288. unknownTypeCount: numberish((reader.raw.excluded || {}).unknownTypeCount),
  289. typeKindMismatchCount: numberish((reader.raw.excluded || {}).typeKindMismatchCount),
  290. nonFormalIndustryCount: numberish((reader.raw.excluded || {}).nonFormalIndustryCount),
  291. invalidCoordinateCount: numberish((reader.raw.excluded || {}).invalidCoordinateCount),
  292. },
  293. missing: reader.missing,
  294. }
  295. }
  296. /** 一个异常设备 → 领域行(`abnormalType` 与 `status` 后端取同值,只留一个)。 */
  297. function toAbnormalEquipment(raw) {
  298. const reader = fieldReader(raw, 'abnormalEquipment')
  299. const type = fieldReader(reader.raw.deviceType, 'abnormalEquipment.deviceType')
  300. return {
  301. // 设备编码(不是点位编码):本端点逐行给的是设备维度。
  302. code: reader.optionalStr('deviceCode'),
  303. name: reader.optionalStr('deviceName') || reader.optionalStr('deviceCode'),
  304. typeCode: type.optionalStr('code'),
  305. typeName: type.optionalStr('name'),
  306. industry: reader.optionalStr('industry'),
  307. district: normalizeDistrict(reader.raw.district),
  308. createdAt: reader.optionalStr('createdAt'),
  309. status: reader.optionalStr('status'),
  310. statusReasons: reader.optionalList('statusReasons').map((item) => String(item)),
  311. longitude: numberish(reader.raw.longitude),
  312. latitude: numberish(reader.raw.latitude),
  313. missing: reader.missing,
  314. }
  315. }
  316. /** 趋势点 → 领域行;桶标签由 `bucketLabel()` 给人话,原始值留在 `key` 里供断言。 */
  317. function toTrendPoint(raw) {
  318. const reader = fieldReader(raw, 'trendPoint')
  319. return { bucket: reader.optionalStr('bucketStart'), count: numberish(reader.raw.count) || 0 }
  320. }
  321. /**
  322. * ISO 时间戳 → 趋势桶的短标签(给人读;解析不了就回原串,不丢桶)。
  323. *
  324. * 后端桶是业务时区(Asia/Shanghai)的整点/整日 ISO 串,含偏移量。
  325. * 本函数**只做截断显示**,不改桶的粒度、不重采样、不补点 —— 那是后端语义。
  326. * @param {string} bucketStart ISO-8601 时间戳
  327. * @param {boolean} daily 桶是否为「日」粒度(7 天窗口用)
  328. */
  329. export function bucketLabel(bucketStart, daily = false) {
  330. const value = typeof bucketStart === 'string' ? bucketStart : ''
  331. const matched = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value)
  332. if (!matched) return value
  333. const [, , month, day, hour, minute] = matched
  334. if (daily) return `${Number(month)}/${Number(day)}`
  335. return `${hour}:${minute}`
  336. }
  337. /**
  338. * 运行概况(`/monitoring/situation`)→ 领域对象。
  339. * 票 18 验收第 3 条:事件趋势、离线设备和异常态势。
  340. *
  341. * 端点语义(逐行读服务确认):
  342. * · 不传 `startTime`/`endTime` 时后端默认**最近 24 小时**、按小时分桶,上限 31 天;
  343. * · **预警与报警两条趋势永不混合**(服务注释原句:warnings feed warningTrend only,
  344. * alarms feed alarmTrend only),所以本层给两块,不自己加总成一条;
  345. * · `abnormalEquipment` 是**异常设备清单**(服务定义:WARNING/ALARM/EXPIRED/OFFLINE 四态),
  346. * 离线设备是它的一个子集 —— 界面据此分开说"离线多少台"与"异常清单里有什么"。
  347. */
  348. export function toMonitoringSituation(raw) {
  349. const reader = fieldReader(raw, 'monitoringSituation')
  350. const pagination = fieldReader(reader.raw.pagination, 'monitoringSituation.pagination')
  351. const freshness = fieldReader(reader.raw.freshness, 'monitoringSituation.freshness')
  352. const range = reader.optionalObj('range') || {}
  353. // 指标状态分布:后端按固定字典顺序给六态(含 0),本层不重排、不丢行。
  354. // 票 92:「整个分布没取到」≠「某一桶是 0」——前者六档全缺(count 为 null,界面画占位符),
  355. // 后者该档照实计 0(六态全铺是既有口径)。混在一起就是「没数据」被画成「0 台设备」。
  356. const distributionProvided = Array.isArray(reader.raw.metricStatusDistribution)
  357. const metricStatusMap = new Map(reader.optionalList('metricStatusDistribution').map((item) => {
  358. const row = fieldReader(item, 'metricStatusDistribution')
  359. return [row.optionalStr('status'), numberish(row.raw.count) ?? 0]
  360. }))
  361. const metricStatuses = ['NORMAL', 'WARNING', 'ALARM', 'EXPIRED', 'OFFLINE', 'UNKNOWN']
  362. .map((status) => ({ status, count: distributionProvided ? (metricStatusMap.get(status) ?? 0) : null }))
  363. const coverage = reader.optionalList('industryCoverage').map((item) => {
  364. const row = fieldReader(item, 'industryCoverage')
  365. return {
  366. code: row.optionalStr('industry'),
  367. deviceCount: numberish(row.raw.deviceCount) || 0,
  368. abnormalCount: numberish(row.raw.abnormalCount) || 0,
  369. statusCounts: toStatusCounts(row.raw.statusCounts),
  370. }
  371. })
  372. return {
  373. createdAt: reader.optionalStr('createdAt'),
  374. updatedAt: reader.optionalStr('updatedAt'),
  375. freshUpdatedAt: freshness.optionalStr('updatedAt'),
  376. freshness: freshness.optionalStr('status'),
  377. // 窗口:后端回显实际生效区间(不传时是最近 24 小时,`type` 为 `TREND_WINDOW`)。
  378. rangeType: range.type || '',
  379. rangeStart: range.startTime || '',
  380. rangeEnd: range.endTime || '',
  381. metricStatuses,
  382. warningTrend: reader.optionalList('warningTrend').map(toTrendPoint),
  383. alarmTrend: reader.optionalList('alarmTrend').map(toTrendPoint),
  384. coverage,
  385. abnormalEquipment: reader.optionalList('abnormalEquipment').map(toAbnormalEquipment),
  386. pagination: {
  387. page: numberish(pagination.raw.page),
  388. pageSize: numberish(pagination.raw.pageSize),
  389. total: numberish(pagination.raw.total),
  390. totalPages: numberish(pagination.raw.totalPages),
  391. hasMore: pagination.bool('hasMore', false),
  392. },
  393. selectedIndustry: (reader.optionalObj('mapFilter') || {}).industry || '',
  394. selectedDeviceType: (reader.optionalObj('mapFilter') || {}).deviceType || '',
  395. excluded: {
  396. unknownTypeCount: numberish((reader.raw.excluded || {}).unknownTypeCount),
  397. typeKindMismatchCount: numberish((reader.raw.excluded || {}).typeKindMismatchCount),
  398. nonFormalIndustryCount: numberish((reader.raw.excluded || {}).nonFormalIndustryCount),
  399. invalidCoordinateCount: numberish((reader.raw.excluded || {}).invalidCoordinateCount),
  400. },
  401. missing: reader.missing,
  402. }
  403. }
  404. // ================================================================ 视图计划
  405. /**
  406. * 逐设备台账行(`/monitoring/overview` 的 `devices[]`)→ 领域行(票 62 追加需求)。
  407. * 与 `toAbnormalEquipment` 刻意分开:那张是**异常口径**(四态 + 阈值事由),
  408. * 这张是**设备台账**(全部智能设备 + 监测状态 + 所属点位),两者不可互换。
  409. */
  410. function toLedgerDevice(raw) {
  411. const reader = fieldReader(raw, 'overviewDevice')
  412. const type = fieldReader(reader.raw.deviceType, 'overviewDevice.deviceType')
  413. return {
  414. // 设备编码(不是点位编码):本清单是**设备**台账。
  415. code: reader.optionalStr('deviceCode'),
  416. name: reader.optionalStr('deviceName'),
  417. typeCode: type.optionalStr('code'),
  418. typeName: type.optionalStr('name'),
  419. industry: reader.optionalStr('industry'),
  420. // 监测状态(后端五态之一);缺失留空,视图层按「未发布」如实说(不猜成正常)。
  421. status: reader.optionalStr('monitoringState'),
  422. pointCode: reader.optionalStr('monitoringPointCode'),
  423. sampledAt: reader.optionalStr('sampledAt'),
  424. createdAt: reader.optionalStr('createdAt'),
  425. missing: reader.missing,
  426. }
  427. }
  428. /** 该状态在指标状态分布里的计数。 */
  429. function metricCount(statuses, code) {
  430. const found = (statuses || []).find((row) => row.status === code)
  431. return found ? found.count : null
  432. }
  433. /**
  434. * 设备状态四态 → 环形图行(票 39 第 7 条)。
  435. *
  436. * **只保留读得出的计数**:`numberish` 认不出回 `null`(字段缺失/载荷形状不对),
  437. * 而 `donutBlock` 内部按 `Number(count) || 0` 建扇区与图例——把 `null` 交出去就是
  438. * 画一个「在线 0 台」的假读数(票 27 修的是同一类缺陷:`undefined` 冒充有效值)。
  439. * 真实的 `0` 是有效读数,照旧保留(图例行在、不产扇区)。
  440. * 四态一个都没读出来时 `rows` 为空、`total=0`,渲染层走既有空态(`data-donut-empty`),
  441. * **不会**画一个「合计 0」的空环——「后端没给状态」与「状态全是 0」在界面上分得开。
  442. * 顺序固定为在线→离线→滞后→未知,**不套 `sortByCountDesc`**(四态是语义字典序,
  443. * 非排名类别;同异常桶 t44 的 FIXED 立场)。
  444. */
  445. function deviceStatusRows(deviceStatus) {
  446. const source = deviceStatus || {}
  447. return [
  448. { code: 'online', label: '在线', count: numberish(source.online) },
  449. { code: 'offline', label: '离线', count: numberish(source.offline) },
  450. { code: 'stale', label: '数据滞后', count: numberish(source.stale) },
  451. { code: 'unknownOrMissing', label: '未知/缺失', count: numberish(source.unknownOrMissing) },
  452. ].filter((row) => row.count !== null)
  453. }
  454. /* 票 92:旧「数据入库时间」KPI(createdAtKpi)已随概览/运行概况的 KPI 改版退役 —— 两处消费点
  455. 都不再展示时间类读数(票面 6 张清单里没有时间卡),按「无消费点不提交」纪律删除。 */
  456. /**
  457. * 监测概览面板视图。
  458. * 验收四项:监测点/设备规模、在线率、类型分布、最近采集时间。
  459. */
  460. export function monitoringOverviewPanel(data, section) {
  461. const current = data || {}
  462. const deviceStatus = current.deviceStatus || {}
  463. // 在线率:分母为 0 时后端回 `state: EMPTY` + `value: 0`,那个 0 是**没有分母时的占位**,
  464. // 不是"在线率确实是 0"——照它画成 0.0% 就是票 16 明令禁止的虚假零值。所以先按 state 收口。
  465. const rateAvailable = current.onlineState !== 'EMPTY' && current.onlineDenominator !== null && current.onlineDenominator > 0
  466. const rate = rateAvailable ? current.onlineRate : null
  467. // 设备类型三块合成一块(维护者第 16 轮):类型总量 / 各类型在线 / 各类型离线
  468. // 共用一份图例 —— 三份图例是「重复图例」的根源(同一批类型名写三遍)。
  469. const typeRows = (current.typeDistribution || []).map((row) => ({
  470. code: row.code,
  471. label: row.name || codeText(row.code),
  472. count: row.deviceCount || 0,
  473. }))
  474. const onlineTypeRows = (current.typeDistribution || [])
  475. .filter((row) => numberish(row.onlineCount) !== null)
  476. .map((row) => ({ ...row, label: row.name || codeText(row.code) }))
  477. const offlineTypeRows = (current.typeDistribution || [])
  478. .filter((row) => numberish(row.offlineCount) !== null)
  479. .map((row) => ({ ...row, label: row.name || codeText(row.code) }))
  480. const blocks = [
  481. kpiBlock([
  482. { key: 'monitoringPointCount', label: '监测点', value: valueText(current.monitoringPointCount), unit: '个' },
  483. { key: 'equipmentCount', label: '监测设备', value: valueText(current.equipmentCount), unit: '台' },
  484. {
  485. key: 'onlineRate',
  486. label: '在线率',
  487. value: ratioText(rate),
  488. // 在线率的分母是后端给的「已解析设备数」,把分子分母摊开说,免得读者自己猜口径;
  489. // 后端明说没有分母(`EMPTY`)时如实说"没有可算的设备",不算出一个 0。
  490. hint: rateAvailable
  491. ? `在线 ${valueText(current.onlineCount)} / 设备 ${valueText(current.onlineDenominator)}`
  492. : '当前范围内没有可参与计算的设备,在线率不适用',
  493. },
  494. // 离线设备:取**本端点自己的设备状态**(status.offlineCount),与运行概况那张卡
  495. // (来自 /monitoring/situation 的**指标**状态 OFFLINE 档)不是同一口径——
  496. // 同名词不同义,所以各自卡上把口径说清(维护者 2026-10-27 裁定:第 4 张卡取离线设备)。
  497. {
  498. key: 'offline',
  499. label: '离线设备',
  500. value: valueText(deviceStatus.offline),
  501. unit: '台',
  502. hint: '按设备在线状态统计(本端点口径)',
  503. },
  504. ], { layout: 'two-by-two' }),
  505. // 票 39(第 7 条):设备状态由纯键值行改**环形图**(复用既有块,不新造渲染分支)。
  506. // · 选型按仓库自己记录的图表选型表(`donutBlock` 头注):「类别占比、类别少」
  507. // → 环形图;四态=4 个互斥类别,且本面板已有一条横条(类型分布)、维护者
  508. // 2026-10-13 明说「条形图太多易疲劳」——堆叠条仍是一条横条,形态不解疲劳。
  509. // · **四态照实**:在线/离线/数据滞后/未知缺失各一行图例带读数,不折算成
  510. // 「故障」(后端逐设备走 ONLINE/OFFLINE/STALE 判定链,其余落 unknownOrMissing,
  511. // 四桶互斥且穷尽——`CockpitMonitoringOverviewService` 第 165–175 行,服务逐行读过)。
  512. // · 0 计数态**保留图例行不画扇区**(`donutBlock` 既有语义):「确实没有离线设备」
  513. // 与「没这项数据」在图上分得开,读数为 0 是真实值。
  514. // · 「在线率分母」不再是独立一行:环形合计含「未知/缺失」,而后端在线率
  515. // 分母=在线+离线+滞后(**不含**未知/缺失,服务第 185 行)。分母摊在 KPI 的
  516. // hint(`在线 X / 设备 Y`)上与在线率同口径;画进图例就是制造第二个口径的数。
  517. // · 缺值仍是占位符纪律:四态计数经 `numberish` 认不出回 null → 该行**整行不出**
  518. // (见 `deviceStatusRows`),绝不把 null 交给 `donutBlock` 补成假 0(票 27 同类)。
  519. donutBlock('设备状态分布(按设备数)', deviceStatusRows(deviceStatus), { layout: 'compact' }),
  520. // 维护者第 16 轮:设备类型分布 + 各类型在线 + 各类型离线**合成一块**「设备类型状态分布」——
  521. // 上面两张环(在线 / 离线,环心分别是总在线、总离线),下面一条堆叠条(各类型设备总量构成),
  522. // **共用同一份图例**(类型即图例项)。原先三块各带一份图例,重复图例才是真正的观感问题。
  523. donutGridBlock(
  524. '设备类型状态分布',
  525. [
  526. {
  527. key: 'online',
  528. label: '在线',
  529. unit: '台',
  530. slices: onlineTypeRows.map((row) => ({ key: row.code, label: row.label, count: row.onlineCount })),
  531. },
  532. {
  533. key: 'offline',
  534. label: '离线',
  535. unit: '台',
  536. slices: offlineTypeRows.map((row) => ({ key: row.code, label: row.label, count: row.offlineCount })),
  537. },
  538. ],
  539. {
  540. unit: '台',
  541. legend: typeRows.map((row) => ({ key: row.code, label: row.label, count: row.count })),
  542. // 底部堆叠条与图例同源:各类型的**设备总量**构成。
  543. stack: { label: '设备类型构成', rows: typeRows.map((row) => ({ key: row.code, count: row.count })) },
  544. emptyText: '当前范围没有设备类型与状态计数',
  545. },
  546. ),
  547. ]
  548. return {
  549. name: 'monitoringOverview',
  550. label: '监测概览',
  551. section,
  552. hasContent: sectionHasContent(section),
  553. blocks,
  554. // 概览是聚合口径(后端只给分类型计数,不给逐点位坐标),没有可定位对象。
  555. locate: [],
  556. locateNote: '监测概览是分类型聚合口径(后端不返回逐点位坐标),本面板没有可定位对象',
  557. // 入库时间与采样时间的区别如实说明(文件头第 ① 条 + `TIME_LABELS`)。
  558. gap: METRIC_FRESHNESS_GAP,
  559. industryNote: current.selectedIndustry ? `当前行业:${industryLabel(current.selectedIndustry) || current.selectedIndustry}` : '当前行业:全部',
  560. // 票 37:生效类型必须看得见 —— 否则读者只会看到"类型分布少了几行",
  561. // 不知道该条件从哪来、也不知道怎么撤(同一条行再点一次)。
  562. typeNote: current.selectedDeviceType
  563. ? `当前类型:${deviceTypeLabel(current.typeDistribution, current.selectedDeviceType)}(再点同一行取消)`
  564. : '当前类型:全部',
  565. missing: panelMissingNote(current),
  566. placeholder: sectionPlaceholder(section),
  567. }
  568. }
  569. /**
  570. * 类型码 → 展示名(票 37)。名字优先取自**本端点返回的类型分布**(同一份数据里带着 name,
  571. * 不另建一份类型词典);分布里没有该码时用 `codeText` 的既有口径——**数据值照原样带出**,
  572. * 因为空白会被读成"没有数据",而"没见过的类型码"是真实数据。
  573. * @param {Array} rows 类型分布行(含 `code`/`name`)
  574. * @param {string} code 生效的类型码
  575. */
  576. function deviceTypeLabel(rows, code) {
  577. if (!code) return ''
  578. const found = (rows || []).find((row) => row.code === code)
  579. return (found && found.name) || codeText(code)
  580. }
  581. /**
  582. * 监测分布面板视图。
  583. * 验收三项:覆盖情况、状态分布、地图筛选(本票的筛选入口是全局筛选条,面板只回显生效范围)。
  584. *
  585. * **票 14 改写**:状态统计从「可点状态条」改为**饼图**(决策 02 的 Q12/Q19)——
  586. * · 数据源是**全局未筛分布**(`extra.statusDistribution`,与地图顶部状态栏共用同一份,
  587. * 来自 `map-snapshot` 的八桶全局计数;不是本端点随筛选变的那份);
  588. * · 饼图只**高亮当前选中**,不再是筛选入口——全局条件只有一个可操作面(顶部状态栏),
  589. * 面板自带筛选正是票 07 点名的「入口分散且重复」缺陷;
  590. * · 全局分布未取到时如实说明(不伪造全零饼图)。
  591. *
  592. * @param {object} data `toMonitoringDistribution()` 的结果
  593. * @param {object} section 分区
  594. * @param {object} [extra] `{ statusDistribution, selectedStatuses }`(外壳给的共用数据与当前选中)
  595. */
  596. /**
  597. * 异常监测点 → **指标卡 2×2**(维护者第 15 轮)。
  598. *
  599. * 为什么不再用横条:这组读数是「当前有多少个异常点位」的**结论型数字**,值班人员要的是
  600. * 一眼读出四类各多少,而不是比条长;而且它原本挂在面板中段,被上面的覆盖分布压住了。
  601. * 卡片顺序固定为 报警 / 预警 / 工单处置 / 离线(与地图顶部状态栏同一套语义顺序)。
  602. *
  603. * 缺值纪律不变:`extra.statusDistribution` 不可用时整块不出(调用点已判 available),
  604. * 单个桶缺失按「未知」画占位符 —— 不把「没给」画成 0 个异常点。
  605. */
  606. export function abnormalPointCards(counts) {
  607. const source = counts && typeof counts === 'object' ? counts : {}
  608. const rows = [
  609. { key: 'ALARM', label: '报警', tone: 'lv1' },
  610. { key: 'WARNING', label: '预警', tone: 'lv2' },
  611. { key: 'WORK_ORDER', label: '工单处置', tone: 'lv3' },
  612. { key: 'OFFLINE', label: '离线', tone: 'neutral' },
  613. ]
  614. const countOf = (key) => {
  615. const raw = source[key]
  616. if (raw === null || raw === undefined || raw === '') return null
  617. const value = numberish(raw)
  618. return value === null ? null : value
  619. }
  620. return rows.map((row) => ({
  621. key: row.key,
  622. label: row.label,
  623. value: valueText(countOf(row.key)),
  624. unit: '个',
  625. valueTone: row.tone,
  626. accent: row.tone,
  627. caption: '全局未筛',
  628. }))
  629. }
  630. /**
  631. * 「口径 · 生效范围与总数」→ 面板摘要文案(维护者第 15 轮:并入摘要)。
  632. *
  633. * 为什么搬到摘要:这六条是**抬头读数**(现在按什么条件在看),不是图表,占一整块把面板撑长;
  634. * 做过期/空态说明也已经在摘要里,同一族信息收在一处更好读。
  635. * 措辞纪律不变:只说范围与口径,不出现接口字段名;缺值照占位符(不编「全部」)。
  636. */
  637. function distributionScopeNote(current) {
  638. const total = valueText(current.pagination && current.pagination.total)
  639. const industry = current.selectedIndustry
  640. ? industryLabel(current.selectedIndustry) || current.selectedIndustry
  641. : '全部'
  642. const district = districtFilterLabel({ district: current.selectedDistrict, districtFilter: current.districtFilter })
  643. const deviceType = current.selectedDeviceType
  644. ? deviceTypeLabel(current.deviceTypes, current.selectedDeviceType)
  645. : '全部'
  646. const pointStatus = current.selectedPointStatus
  647. ? String(current.selectedPointStatus)
  648. .split(',')
  649. .map((code) => POINT_STATUS_FILTER_LABELS[code] || code)
  650. .filter(Boolean)
  651. .join('、')
  652. : '全部'
  653. const invalid = valueText(current.excluded && current.excluded.invalidCoordinateCount)
  654. return [
  655. '监测点总数 ' + total,
  656. '生效行业 ' + industry,
  657. '生效片区 ' + district,
  658. '生效类型 ' + deviceType,
  659. '生效状态 ' + pointStatus,
  660. '坐标未通过校验 ' + invalid,
  661. ].join(' · ')
  662. }
  663. export function monitoringDistributionPanel(data, section, extra = {}) {
  664. const current = data || {}
  665. // 覆盖情况(Q11 裁定):用**本端点自己的**区域/行业/设备类型分布 ——
  666. // 票 24 指向的 `overview/monitoring` 的 `formalScope` 不在本端点上,拿不到也不去拉。
  667. // 票 37 走查扩展(维护者 2026-10-14):三块都是排名类别,统一按计数降序(并列保序);
  668. // 环形图(行业)与条形(片区/类型)同口径。
  669. const regionRows = sortByCountDesc(
  670. (current.regions || []).map((row) => ({
  671. code: row.code,
  672. label: districtLabel(row.code),
  673. count: row.pointCount || 0,
  674. // 每一行带上设备数与状态明细,但主条长按**点位数**归一(图与数同源:口径写在标题里)。
  675. hint: `设备 ${valueText(row.deviceCount)}`,
  676. })),
  677. (row) => row.count,
  678. )
  679. const industryRows = sortByCountDesc(
  680. (current.industries || []).map((row) => ({
  681. code: row.code,
  682. label: industryLabel(row.code) || regionText(row.code),
  683. count: row.pointCount || 0,
  684. hint: `设备 ${valueText(row.deviceCount)}`,
  685. })),
  686. (row) => row.count,
  687. )
  688. const typeRows = sortByCountDesc(
  689. (current.deviceTypes || []).map((row) => ({
  690. code: row.code || row.name,
  691. label: row.name || codeText(row.code),
  692. count: row.pointCount || 0,
  693. hint: `设备 ${valueText(row.deviceCount)}`,
  694. })),
  695. (row) => row.count,
  696. )
  697. // 票 14:饼图切片(几何与高亮判定都在 `domain/statusPie.js`,本层只搬运)。
  698. const pie = planStatusPie({
  699. distribution: extra.statusDistribution || null,
  700. rows: legendVisuals(),
  701. selected: Array.isArray(extra.selectedStatuses) ? extra.selectedStatuses : [],
  702. })
  703. const blocks = [
  704. // 票 39(第 8 条):每块标题 = **「分节 · 维度(条长口径)」**。
  705. // 维护者反馈第 8 条的原话是「图与数据指代对象不明确」——同一个面板里既有按
  706. // **点位数**排的覆盖分布、又有**全局未筛**的状态分布,三种口径混在一屏而不写在
  707. // 标题上,读者无从判断每张图数的是什么、数的是点位还是设备。
  708. // 分节词(覆盖情况 / 状态分布 / 口径)让三族各自成节,括号里的口径说明
  709. // 「这条长度量的是什么」;悬停行的 hint 仍给设备数(图与数同源,不另算一份)。
  710. // 条形/环形的分工沿用 2026-10-13 的既有结论(行业=类别少→环形;
  711. // 片区/类型=多类别排名→横条),本票不动图形形态,只把指代写清楚。
  712. // 维护者第 15 轮:① 覆盖情况 · 片区与覆盖情况 · 行业统一成饼图(同族对照,形态一致才好比);
  713. // ② 覆盖情况 · 设备类型与下面的「设备类型构成(按点位数)」内容重叠、意义也不清楚 —— 整块删除;
  714. // ③「异常监测点」从横条改**指标卡 2×2**,并提到面板最顶部(它是值班人员最先要看的一组读数)。
  715. ...(extra.statusDistribution && extra.statusDistribution.available
  716. ? [kpiBlock(abnormalPointCards(extra.statusDistribution.counts || {}), { layout: 'two-by-two' })]
  717. : []),
  718. donutBlock('覆盖情况 · 行业(按点位数)', industryRows, { layout: 'compact' }),
  719. donutBlock('覆盖情况 · 片区(按点位数)', regionRows, { layout: 'compact' }),
  720. stackedBlock('设备类型构成(按点位数)', typeRows),
  721. // 状态分布饼图:消费全局未筛分布并高亮当前选中;**不可点**(筛选入口只在顶部状态栏)。
  722. {
  723. kind: 'pie',
  724. label: '状态分布 · 点位状态(全局未筛)',
  725. ready: pie.ready,
  726. hasSelection: pie.hasSelection,
  727. total: pie.total,
  728. slices: pie.slices,
  729. rows: pie.rows,
  730. note: pie.ready ? '' : STATUS_DISTRIBUTION_UNPUBLISHED,
  731. },
  732. ]
  733. return {
  734. name: 'monitoringDistribution',
  735. label: '监测分布',
  736. section,
  737. hasContent: sectionHasContent(section),
  738. blocks,
  739. locate: [],
  740. locateNote: '监测分布是聚合分布口径(后端不返回逐点位坐标),本面板没有可定位对象',
  741. // 范围筛选落点说明保留;「状态筛选不作用到一张图」的缺口说明已随票 14 的契约修订删除。
  742. gap: REGION_SCOPE_NOTE,
  743. // 维护者第 15 轮:原「口径 · 生效范围与总数」块并入摘要(数据时间仍在,见下)。
  744. note: distributionScopeNote(current) + (current.createdAt ? ' · ' + TIME_LABELS.latestIngested + ' ' + valueText(current.createdAt) : ''),
  745. // 数据时间是唯一带「测量含义」的读数,单独留在摘要尾巴上,不丢。
  746. rangeNote: '',
  747. missing: panelMissingNote(current),
  748. placeholder: sectionPlaceholder(section),
  749. }
  750. }
  751. /**
  752. * 运行概况面板视图。
  753. * 验收三项:事件趋势、离线设备、异常态势。
  754. *
  755. * **票 62 追加需求(2026-10-22 维护者)**:本面板不再展示「异常设备清单」,改展示
  756. * **设备台账**——按当前数据应列出全部 9 台监测设备,而不是只列异常四态的 8 台。
  757. * 台账数据来自 `/monitoring/overview` 的 `devices[]`(与那边的 `counts.equipmentCount`
  758. * 同源同口径),由外壳作为 `extra.devices` 搬进来;本面板自己的分区
  759. * (`/monitoring/situation`)继续供趋势、指标状态与异常统计——**异常口径不丢,只是
  760. * 不再用它当台账**。两者不是一回事:异常清单只装 WARNING/ALARM/EXPIRED/OFFLINE 四态设备。
  761. * @param {object} data `toMonitoringSituation()` 的结果
  762. * @param {object} section 分区
  763. * @param {object} [extra] `{ devices }`(`toMonitoringOverview().devices`,外壳给)
  764. */
  765. /**
  766. * 预警 / 报警两条趋势合并成**同轴双线**(维护者 2026-10-29)。
  767. *
  768. * 为什么要在这里合并:两个端点序列的桶起点可能不完全一致(一侧少一个桶、或粒度不同),
  769. * 直接按数组下标配对会在图上画出错位的对照 —— 这是**假数据**,不是排版问题。
  770. * 所以按桶键求并集、缺失一侧记 null(**不补 0**:没这个桶 ≠ 这个桶是 0),
  771. * 序列里出现 null 时该点不画点、不计入折线(渲染层按 filter(Boolean) 处理)。
  772. *
  773. * @param {Array} warningRows `warningTrend` 行(`bucket`/`count`)
  774. * @param {Array} alarmRows `alarmTrend` 行
  775. * @returns {{points: Array, warningCounts: Array, alarmCounts: Array}}
  776. */
  777. export function mergeTrendSeries(warningRows, alarmRows) {
  778. const warning = Array.isArray(warningRows) ? warningRows : []
  779. const alarm = Array.isArray(alarmRows) ? alarmRows : []
  780. const buckets = []
  781. const seen = new Set()
  782. for (const row of [...warning, ...alarm]) {
  783. const key = String((row && row.bucket) || '')
  784. if (!key || seen.has(key)) continue
  785. seen.add(key)
  786. buckets.push(key)
  787. }
  788. buckets.sort()
  789. const index = (rows) => {
  790. const map = new Map()
  791. for (const row of rows) map.set(String((row && row.bucket) || ''), numberish(row && row.count))
  792. return map
  793. }
  794. const warningMap = index(warning)
  795. const alarmMap = index(alarm)
  796. return {
  797. points: buckets.map((bucket) => ({ bucket })),
  798. warningCounts: buckets.map((bucket) => warningMap.get(bucket) ?? null),
  799. alarmCounts: buckets.map((bucket) => alarmMap.get(bucket) ?? null),
  800. }
  801. }
  802. export function operationOverviewPanel(data, section, extra = {}) {
  803. const current = data || {}
  804. // 7 天窗口的桶是「日」粒度,标签按日期显示(只影响显示,不改桶)。
  805. const daily = isDailyWindow(current)
  806. const trendPoints = (list) =>
  807. (list || []).map((row) => ({
  808. key: row.bucket,
  809. label: bucketLabel(row.bucket, daily),
  810. count: row.count,
  811. }))
  812. // 维护者 2026-10-29:两条趋势合成一张双线图(按桶键求并集,缺失不补 0)。
  813. const mergedTrend = mergeTrendSeries(current.warningTrend, current.alarmTrend)
  814. const offlineCount = metricCount(current.metricStatuses, 'OFFLINE')
  815. // 离线设备:从异常清单里挑出离线的那一档。**只挑,不推断**——状态来自后端 `status`。
  816. // 票 62:这段只服务 `offlineNote`(顶部摘要那行),**不再画成清单**。
  817. const offlineDevices = (current.abnormalEquipment || []).filter((item) => item.status === 'OFFLINE')
  818. // 票 62:设备台账数据源(与 `counts.equipmentCount` 同源,行数恒等于它)。
  819. const devices = Array.isArray(extra.devices) ? extra.devices : []
  820. // 票 92(维护者 2026-10-27):KPI 改为 6 张、3×2 排版。全部取自本面板自己的分区
  821. // `/monitoring/situation`(不跨面板取数),数值真实且分区独立:
  822. // · 设备总数 = 六档指标状态计数之和(后端按设备粒度给,总和 = 设备台数);
  823. // · 在线设备 = 正常 + 预警 + 报警(正在上报的设备;与监测概览设备轴「在线」口径一致,已实测比对);
  824. // · 离线设备 = 指标状态 OFFLINE 档(沿用既有取数);
  825. // · 预警/报警设备 = WARNING / ALARM 档(沿用既有取数,改名);
  826. // · 总异常设备 = 异常设备清单总数(pagination.total = 预警+报警+离线+过期)。
  827. // 旧「数据入库时间」卡退役(不在 6 张清单里)。
  828. // 分布没取到时(六档 count 全 null)→ 总数/在线/离线/预警/报警全走占位符,绝不画 0(票 16)。
  829. const distributionProvided = (current.metricStatuses || []).some((row) => row.count !== null)
  830. const deviceTotal = distributionProvided
  831. ? current.metricStatuses.reduce((sum, row) => sum + (row.count || 0), 0)
  832. : null
  833. const onlineCount = distributionProvided
  834. ? metricCount(current.metricStatuses, 'NORMAL')
  835. + metricCount(current.metricStatuses, 'WARNING')
  836. + metricCount(current.metricStatuses, 'ALARM')
  837. : null
  838. const blocks = [
  839. kpiBlock([
  840. { key: 'deviceCount', label: '设备总数', value: valueText(deviceTotal), unit: '台', hint: '按指标状态统计(六档之和)' },
  841. { key: 'online', label: '在线设备', value: valueText(onlineCount), unit: '台', hint: '正常 + 预警 + 报警的设备(正在上报)' },
  842. { key: 'offlineCount', label: '离线设备', value: valueText(offlineCount), unit: '台' },
  843. { key: 'warningCount', label: '预警设备', value: valueText(metricCount(current.metricStatuses, 'WARNING')), unit: '台' },
  844. { key: 'alarmCount', label: '报警设备', value: valueText(metricCount(current.metricStatuses, 'ALARM')), unit: '台' },
  845. { key: 'abnormalCount', label: '总异常设备', value: valueText(current.pagination && current.pagination.total), unit: '台', hint: '预警 + 报警 + 离线 + 过期' },
  846. ], { layout: 'three', emphasis: true }),
  847. // 维护者第 15 轮:「设备/指标异常统计」与「异常态势 · 指标状态分布」是同一份六态数据的
  848. // 两种画法(横条 + 横条)—— 合并成**一张饼图**,一次读出六态占比与读数。
  849. // 六态环单独一块(compact 版,环径已缩):右侧运行概况栏只有约 280px,
  850. // 与趋势并排会把两者都压得读不出来(实测标题都叠在一起),所以仍是上下排。
  851. donutBlock('指标状态分布(六态)', abnormalCountRows(current.metricStatuses, 'metric').map((row) => ({
  852. code: row.key,
  853. label: row.label,
  854. count: row.count,
  855. })), { layout: 'compact' }),
  856. // 维护者 2026-10-29:预警与报警合成**一张双线折线**(同一时间窗、同一量纲),并给块顶留出
  857. // 呼吸空间(compact 版式),避免与块标题/点顶数值重叠。两条线仍是两个独立数据源,
  858. // 只共享坐标轴与图例 —— 不改变任何统计口径。维护者第 15 轮:宽屏太挤 → 加 tall 档拉高。
  859. trendBlock('预警与报警趋势', mergedTrend.points.map((row) => ({
  860. key: row.bucket,
  861. label: bucketLabel(row.bucket, daily),
  862. count: 0,
  863. hint: row.bucket,
  864. })), {
  865. chart: 'line',
  866. compact: true,
  867. tall: true,
  868. aside: '双线对照:预警 / 报警',
  869. series: [
  870. { key: 'warning', label: '预警', tone: 'lv2', counts: mergedTrend.warningCounts },
  871. { key: 'alarm', label: '报警', tone: 'lv1', counts: mergedTrend.alarmCounts },
  872. ],
  873. }),
  874. // 维护者 2026-10-29 走查:设备台账清单此前被移除,导致台账卡片交互整体消失。
  875. // 恢复为**面板最后一块**(KPI / 六态环 / 趋势之后),整卡点击=定位 + 进详情,
  876. // 与监测点台账同一交互;它是本面板唯一逐台可下钻的入口,不能缺。
  877. deviceLedgerBlock(devices),
  878. ]
  879. return {
  880. name: 'operationOverview',
  881. label: '运行概况',
  882. section,
  883. hasContent: sectionHasContent(section),
  884. blocks,
  885. // 维护者 2026-10-29:定位入口与监测点台账对齐 —— **整卡点击**即定位并进详情,
  886. // 不再额外画一排「定位」按钮(两条路径会让同一件事有两种手感)。
  887. // 三种"点不动"仍由 `domain/mapLocate.js` 分开,由 locateCode 如实反馈。
  888. locate: [],
  889. locateNote: '点击设备卡片即定位并进入设备详情(与监测点台账同一交互);设备坐标由一张图回联,未加载到的设备不下发飞行',
  890. // 窗口口径:后端回显的实际生效区间(本面板的 24 小时 / 7 天切换写在界面上,见票面 Q8)。
  891. rangeNote: current.rangeStart && current.rangeEnd ? `统计窗口:${current.rangeStart} 至 ${current.rangeEnd}` : '',
  892. offlineNote: offlineDevices.length
  893. ? `离线 ${offlineDevices.length} 台:${offlineDevices.map((item) => item.name).join('、')}`
  894. : '当前窗口没有离线设备',
  895. missing: panelMissingNote(current),
  896. placeholder: sectionPlaceholder(section),
  897. }
  898. }
  899. /** 7 天窗口的桶按日显示(判据是后端回显的区间长度,不是前端自己记的开关)。 */
  900. function isDailyWindow(current) {
  901. const start = Date.parse((current && current.rangeStart) || '')
  902. const end = Date.parse((current && current.rangeEnd) || '')
  903. if (!Number.isFinite(start) || !Number.isFinite(end)) return false
  904. return end - start > 36 * 3600 * 1000
  905. }
  906. /**
  907. * 异常状态 → 色键(报警最重、其次预警、离线与过期走中性)。
  908. * 票 62:异常清单已从运行概况撤下(改展示设备台账),本函数仍服务于
  909. * `offlineNote` 的语义与 `toAbnormalEquipment` 的既有消费点,故保留。
  910. */
  911. function abnormalTone(status) {
  912. if (status === 'ALARM') return 'lv1'
  913. if (status === 'WARNING') return 'lv2'
  914. if (status === 'OFFLINE' || status === 'EXPIRED') return 'neutral'
  915. return 'neutral'
  916. }
  917. /** 设备台账行的展示名:有名称用名称,没名称用编码(**不编造**,也不留空行)。 */
  918. function deviceLabel(item) {
  919. return (item && (item.name || item.code)) || DETAIL_PLACEHOLDER
  920. }
  921. /**
  922. * 设备监测状态 → 色键(票 62)。按语义分档,与 `abnormalTone` 同一套色;
  923. * 在线/正常走成功色,滞后走关注色,离线/未发布走中性(**不把"没状态"画成正常**)。
  924. */
  925. function deviceStateTone(status) {
  926. if (status === 'ALARM') return 'lv1'
  927. if (status === 'WARNING' || status === 'STALE') return 'lv2'
  928. if (status === 'ONLINE' || status === 'NORMAL') return 'lv4'
  929. return 'neutral'
  930. }
  931. /**
  932. * 设备监测状态码 → 人话(票 62)。
  933. * `ONLINE` 是设备轴上的补充取值(指标状态字典里没有它);其余复用六态字典,
  934. * 认不出的回原码、空值如实说「状态未发布」——不猜成正常。
  935. */
  936. function deviceStateText(status) {
  937. const value = typeof status === 'string' ? status.trim() : ''
  938. if (!value) return '状态未发布'
  939. if (value === 'ONLINE') return '在线'
  940. return metricStatusText(value)
  941. }
  942. /**
  943. * 运行概况的**设备台账**清单块(票 62 追加需求,2026-10-22 维护者)。
  944. *
  945. * 与原先「异常设备清单」是**结构性差别**,不只是换个标题:
  946. * · **数据源**:`/monitoring/overview` 的 `devices[]`(全部智能设备,行数 ≡ `counts.equipmentCount`),
  947. * 不是 `/monitoring/situation` 的 `abnormalEquipment[]`(只装 WARNING/ALARM/EXPIRED/OFFLINE);
  948. * · **每行内容**:名称(主键展示)+ 设备编码、类型、行业、监测状态、所属点位、数据入库时间;
  949. * · **总数口径**:读设备台账行数本身(= `equipmentCount`),页脚明说「共 N 台监测设备」;
  950. * · 仍是前端集合分页 10/页(规范三分法),翻页**零请求**。
  951. * @param {Array} devices 领域设备行(`toMonitoringOverview().devices`)
  952. */
  953. function deviceLedgerBlock(devices) {
  954. const rows = (devices || []).map((item) => ({
  955. key: item.code || `${item.typeCode}|${item.pointCode}`,
  956. title: deviceLabel(item),
  957. identityKind: CARD_ACTION.DEVICE,
  958. identityCode: item.code,
  959. identityReason: item.code ? '' : '缺少设备编码,无法展开',
  960. dataTime: valueText(item.createdAt),
  961. // 标签由 domain 给(`TIME_LABELS` 单点词表):渲染层不认「时间」这个词,更不自己拼。
  962. dataTimeLabel: TIME_LABELS.ingested,
  963. tone: deviceStateTone(item.status),
  964. // 色键必带文字(票 06):状态词认不出也回原码,不留空标签、不猜成正常。
  965. toneLabel: deviceStateText(item.status),
  966. meta: [
  967. item.code,
  968. item.typeName,
  969. industryLabel(item.industry) || codeText(item.industry),
  970. item.pointCode ? `点位 ${item.pointCode}` : '未关联点位',
  971. ].filter(Boolean),
  972. // 维护者 2026-10-29:设备卡**不再单独画「定位」小按钮**,改为与监测点台账一致 ——
  973. // 整卡点击 = 定位 + 进详情(一个动作,不是"先点小按钮定位、再点卡进详情"两条路径)。
  974. // 定位本身仍走既有设备维度接缝 `monitoringStore.locateCode`(判定→相机→详情),
  975. // 三种"点不动"由 `domain/mapLocate.js` 分开,不在这里另造一套。
  976. points: [],
  977. }))
  978. return listBlock('设备台账', rows, '当前范围内没有监测设备', {
  979. paginate: true,
  980. // 页码归属键:只看设备全集的身份(行数)——换筛选导致集合变化时自然复位。
  981. // 标量键,不接受对象身份(票 43 教训:视图计划每次重算都是新对象)。
  982. pageScope: `devices|${rows.length}`,
  983. pageNote: rows.length ? `共 ${rows.length} 台监测设备(当前筛选范围)` : '',
  984. })
  985. }
  986. /**
  987. * 监测点台账面板视图。
  988. * 验收第 4 条:以滚动卡片列出当前行业筛选内的监测点;点击卡片走与地图相同的两级详情。
  989. *
  990. * **票 62 改写(批次四)**:数据不再取「分布这一次响应的第 1 页切片」,而是 store 的
  991. * **自动渐进装载链**(`domain/ledgerLoader.js`)已经装好的**完整序列**;
  992. * · 装载与一张图点位数据同款(条件变化 → 新快照 → 自动连翻到 settled);
  993. * · 界面只对**已装载集合**做统一页码分页(10/页、页码直达),翻页是**纯视图动作**;
  994. * · 「加载下一页监测点」按钮与 `ledger-more` 接缝**退役**(自动链自己会翻完);
  995. * · 页脚如实给「已装载 M · 共 N 条(含未关联设备)」+ 装载中/失败标注。
  996. *
  997. * **设备徽标仍走 N+1**(Q10/Q14 不变):本端点不给设备编码,徽标由 store 逐点位取。
  998. * 票 62 的改进是**只为当前页窗口内的卡片补拉**——窗口化之后一屏最多 10 张卡,
  999. * 不再"一页 500 张卡进视口就打出 500 个请求"。
  1000. * @param {object} data `toMonitoringDistribution()`/装载链条目(读 `points`/`equipment`/`pagination`)
  1001. * @param {object} section 分区
  1002. * @param {object} [badges] `{ [pointCode]: { state, total, alarm, warning, offline } }`(store 给)
  1003. */
  1004. export function monitoringPointLedgerPanel(data, section, badges = {}) {
  1005. const current = data || {}
  1006. const pagination = current.pagination || {}
  1007. // 分母认不出就**整句不出**(作者本意),且绝不用 `undefined` 冒充读数:
  1008. // `data` 为 null(真实空态/失败态,见 `sectionFromResult()` 的 `isEmpty` 分支)时
  1009. // `pagination` 是 `{}`,`total` 是 `undefined` 而不是 `null` —— 只判 `=== null` 会把
  1010. // 它漏进模板串。`numberish()` 是仓库里「数字/数字串→数字,认不出回 null」的唯一口径。
  1011. const total = numberish(pagination.total)
  1012. const cards = (current.points || []).map((point) => {
  1013. const visual = visualForStatus(point.status)
  1014. const badge = badges[point.code] || null
  1015. return {
  1016. key: point.code,
  1017. code: point.code,
  1018. name: point.name,
  1019. identityKind: CARD_ACTION.POINT,
  1020. identityCode: point.code,
  1021. identityReason: point.code ? '' : '缺少点位编码,无法展开',
  1022. dataTime: valueText(point.createdAt),
  1023. // 同上:标签由 domain 给,渲染层只拼值。
  1024. dataTimeLabel: TIME_LABELS.ingested,
  1025. // 状态词与色键来自票 09 的唯一一张表(认不出的状态落「状态未发布」,不猜成正常)。
  1026. status: visual.key,
  1027. tone: visual.tone,
  1028. shape: visual.shape,
  1029. statusLabel: visual.label,
  1030. meta: [
  1031. industryLabel(point.industry) || codeText(point.industry),
  1032. districtLabel(point.district),
  1033. point.typeName,
  1034. ].filter(Boolean),
  1035. reason: (point.statusReasons || []).join(';'),
  1036. // 徽标:还没取到就**不给 0**(0 是"确实没有设备",与"还没问过"是两件事)。
  1037. badge: badge && badge.state === 'success'
  1038. ? {
  1039. total: valueText(badge.total),
  1040. alarm: valueText(badge.alarm),
  1041. warning: valueText(badge.warning),
  1042. offline: valueText(badge.offline),
  1043. }
  1044. : null,
  1045. badgePending: !badge || badge.state === 'pending',
  1046. }
  1047. })
  1048. // 票 62:页脚口径由 store 的装载链给(它才知道「已装载多少 / 总数多少 / 还在不在装」);
  1049. // 装载链没给(走查注入的裸载荷)时退回既有措辞,保持 t27 的防回归判据不变。
  1050. const pageNote = typeof current.ledgerNote === 'string' && current.ledgerNote
  1051. ? current.ledgerNote
  1052. : total === null
  1053. ? ''
  1054. : `已列出 ${cards.length} 个点位 · 本端点序列共 ${total} 条(含未关联设备)`
  1055. const blocks = [
  1056. listBlock('监测点', cards, '当前筛选范围内没有监测点', {
  1057. // 票 62:已装载集合做前端集合分页(翻页零请求)。
  1058. paginate: true,
  1059. // 页码归属键只用条件(换筛选/换快照即复位);装载链继续往集合里追加时**不复位**
  1060. // ——用户停在第 3 页读台账,自动链补完后面的批次不该把人弹回第 1 页(条文 6)。
  1061. pageScope: `${current.snapshotAt || ''}|${pagination.pageSize || ''}`,
  1062. }),
  1063. ]
  1064. return {
  1065. name: 'monitoringPointLedger',
  1066. label: '监测点台账',
  1067. section,
  1068. hasContent: sectionHasContent(section),
  1069. blocks,
  1070. // 卡片本身就是可定位/可详情的对象,所以这里不再另给一批按钮:
  1071. // 点击卡片走**与地图相同**的两级详情入口(`detailStore.openPoint`),不隐式移动相机
  1072. // —— 与"点地图标记"的行为一致(票 10 的既有语义)。
  1073. locate: [],
  1074. locateNote: '点击卡片进入与地图一致的两级详情(点位摘要 → 设备卡片);本面板不移动相机',
  1075. // 分页口径:本端点分页窗口横跨「监测点 + 未关联设备」,所以 total 含设备,
  1076. // 卡片数会小于 total —— 这个差值说明白,免得读成"还有一些点没列出来"。
  1077. pageNote,
  1078. // 票 62:「加载下一页」按钮退役(自动链负责装载),`hasMore` 恒 false 让渲染层不画按钮。
  1079. hasMore: false,
  1080. page: pagination.page,
  1081. missing: panelMissingNote(current),
  1082. placeholder: sectionPlaceholder(section),
  1083. }
  1084. }
  1085. /** panelId → 视图构造函数(本模式自己的四块)。 */
  1086. export const MONITORING_PANEL_BUILDERS = {
  1087. monitoringOverview: monitoringOverviewPanel,
  1088. monitoringDistribution: monitoringDistributionPanel,
  1089. operationOverview: operationOverviewPanel,
  1090. // 台账签名多一个 `badges`(N+1 徽标);注册表调用时给 undefined 即为「还没取到」。
  1091. monitoringPointLedger: (data, section, badges) => monitoringPointLedgerPanel(data, section, badges),
  1092. }
  1093. /**
  1094. * 一个面板的视图。`data` 为 null(失败/空)时仍返回结构完整的视图(块为空),
  1095. * 组件据此画占位说明 —— 「失败」不该让整块面板消失,那会连面板标题都没了。
  1096. */
  1097. export function monitoringPanelView(panelId, data, section, badges) {
  1098. const build = MONITORING_PANEL_BUILDERS[panelId]
  1099. if (!build) return null
  1100. return build(data, section, badges)
  1101. }
  1102. /**
  1103. * 台账当前该请求的下一页(`null` = 不用再翻)。纯函数,断言与旧调用共用。
  1104. *
  1105. * 票 62:装载链的推进已收进 `domain/ledgerLoader.js`(它同时管快照锁定与失败纪律);
  1106. * 本函数保留为**载荷层面的只读判据**(「这份响应还有没有下一页」),不再被 store 用作
  1107. * 推进手段 —— 两处各推一次会把「谁决定下一页」变成两个答案。
  1108. */
  1109. export function nextLedgerPage(data) {
  1110. const pagination = (data && data.pagination) || null
  1111. if (!pagination || pagination.page === null) return 1
  1112. if (pagination.hasMore !== true) return null
  1113. return pagination.page + 1
  1114. }
  1115. /** 该点位是否需要补拉徽标(已被要求过就不重复发请求)。纯函数,便于断言。 */
  1116. export function shouldLoadBadge(badges, pointCode) {
  1117. if (!pointCode) return false
  1118. return !(badges && badges[pointCode])
  1119. }
  1120. /** 台账卡片的状态分布(走查与人工排查读它,不读中文)。 */
  1121. export function ledgerStatusCounts(data) {
  1122. const counts = {}
  1123. for (const point of (data && data.points) || []) {
  1124. const key = visualForStatus(point.status).key
  1125. counts[key] = (counts[key] || 0) + 1
  1126. }
  1127. return counts
  1128. }
  1129. /** 缺失值占位符(组件与本模块共用同一个词,免得两处各写一个字面量)。 */
  1130. export const LEDGER_PLACEHOLDER = DETAIL_PLACEHOLDER