monitoringPanels.js 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866
  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(记录创建时间),不使用 updatedAt 或 sampledAt 兜底。
  15. * 点位状态中文来自 pointStatus;片区只消费正式 district 编码,空值显未分区。
  16. * 监测概览/运行概况不接收片区筛选,监测分布接收 district。
  17. *
  18. * 后端形状依据(`E:\pipe-ner-server`,逐类读过响应类、控制器与服务):
  19. * `CockpitMonitoringOverviewResponse` / `...DistributionResponse` / `...SituationResponse`、
  20. * `CockpitMonitoringOverviewController` / `...DistributionController` / `...SituationController`、
  21. * 服务 `CockpitMonitoring{Overview,Distribution,Situation}Service`,
  22. * 演示载荷 `docs/cockpit/monitoring-{overview,distribution,operating-situation}-demo.html`。
  23. * BigDecimal 在线上是字符串(`"0.32"`),所以走 `panelBlocks.js` 的 `numberish`。
  24. */
  25. import { fieldReader } from './fields.js'
  26. import { DETAIL_PLACEHOLDER } from './detailFlow.js'
  27. import { valueText } from './equipmentDetailView.js'
  28. import { shortEventTime } from './eventList.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. codeText,
  36. donutBlock,
  37. kpiBlock,
  38. listBlock,
  39. locateEntries,
  40. numberish,
  41. panelMissingNote,
  42. ratioText,
  43. regionText,
  44. rowsBlock,
  45. sectionHasContent,
  46. sectionPlaceholder,
  47. sortByCountDesc,
  48. trendBlock,
  49. } from './panelBlocks.js'
  50. /** 运行监测模式的四个 panelId(与 `domain/panelCatalog.js` 的目录一致,顺序即界面顺序)。 */
  51. export const MONITORING_PANEL_IDS = [
  52. 'monitoringOverview',
  53. 'monitoringDistribution',
  54. 'operationOverview',
  55. 'monitoringPointLedger',
  56. ]
  57. /**
  58. * 指标状态码 → 人话(契约手册第 2 节冻结的六态:NORMAL/WARNING/ALARM/EXPIRED/OFFLINE/UNKNOWN)。
  59. *
  60. * 为什么自带一张而不是并进 `CODE_TEXT`:这六个词是**运行监测专用**的指标状态字典
  61. * (手册原句「运行监测专用,`EXPIRED` 不与 `OFFLINE` 合并」),而 `CODE_TEXT` 里
  62. * `EXPIRED` 属于新鲜度字典。两张字典在契约里就是两张,合成一张会让「指标过期」与
  63. * 「数据过期」共用措辞而看不出区别。六个取值**全部**登记,认不出的回原码。
  64. */
  65. export const METRIC_STATUS_TEXT = {
  66. NORMAL: '正常',
  67. WARNING: '预警',
  68. ALARM: '报警',
  69. EXPIRED: '过期',
  70. OFFLINE: '离线',
  71. UNKNOWN: '未发布',
  72. }
  73. /** 后端 `abnormalType` 与 `status` 在响应里取的是同一个值(服务逐行读过),所以只翻一份。 */
  74. export function metricStatusText(code) {
  75. const value = typeof code === 'string' ? code.trim() : ''
  76. if (!value) return '未知'
  77. return METRIC_STATUS_TEXT[value] || value
  78. }
  79. /** 数据时间和设备采样时间的语义说明。 */
  80. export const METRIC_FRESHNESS_GAP =
  81. '数据时间=记录创建时间,不是设备采样时刻;缺失时显示未知,不使用接口响应或快照时间代替'
  82. /**
  83. * 全局状态分布**未取到**时的如实说明(票 14;决策 02:状态栏与饼图共用同一份
  84. * 全局未筛分布,数据源是 `map-snapshot` 的八桶全局计数)。缺失时不伪造全零饼图。
  85. */
  86. export const STATUS_DISTRIBUTION_UNPUBLISHED =
  87. '全局状态分布未取到(后端尚未发布八桶全局计数);顶部状态栏与本饼图共用同一份数据,发布前两者都不显示计数'
  88. /** 八桶 → 人话(来自票 09 的唯一一张表;含 UNKNOWN「状态未发布」与 UNASSOCIATED「无关联设备」)。 */
  89. const POINT_STATUS_FILTER_LABELS = Object.fromEntries(legendVisuals().map((visual) => [visual.key, visual.label]))
  90. /** 范围(区域)筛选的落点说明:三个端点里只有监测分布收 `region`。 */
  91. export const REGION_SCOPE_NOTE = '片区筛选按后端片区编码作用于监测分布'
  92. // 井盖(`MANHOLE`)**已可筛选**(票 18 实施时的缺口已由后端关闭):
  93. // 它现已是第四个正式行业,且全部 11 个 cockpit 服务都收(含 `map-snapshot`)。
  94. // 映射已接上(`domain/filterOptions.js` 的 `INDUSTRY_CODES.manhole`),
  95. // 因此本模块**不再有**「井盖无契约位」这条缺口常量 —— 曾用名 `MANHOLE_INDUSTRY_GAP` 已删除。
  96. // ================================================================ 契约适配
  97. /**
  98. * 监测概览(`/monitoring/overview`)→ 领域对象。
  99. * 票 18 验收第 1 条:监测点/设备规模、在线率、类型分布和最近采集时间。
  100. *
  101. * 端点入参只有 `industry` 与 `deviceType`(控制器逐行读过)——
  102. * **不收 `region`,也不收 `pointStatus`**,所以本面板的筛选面就是行业 + 类型。
  103. */
  104. export function toMonitoringOverview(raw) {
  105. const reader = fieldReader(raw, 'monitoringOverview')
  106. const counts = fieldReader(reader.raw.counts, 'monitoringOverview.counts')
  107. const status = fieldReader(reader.raw.status, 'monitoringOverview.status')
  108. const onlineRate = fieldReader(reader.raw.onlineRate, 'monitoringOverview.onlineRate')
  109. const freshness = fieldReader(reader.raw.freshness, 'monitoringOverview.freshness')
  110. const mapFilter = reader.optionalObj('mapFilter') || {}
  111. return {
  112. createdAt: reader.optionalStr('createdAt'),
  113. updatedAt: reader.optionalStr('updatedAt'),
  114. rangeType: (reader.optionalObj('range') || {}).type || '',
  115. // 规模
  116. monitoringPointCount: numberish(counts.raw.monitoringPointCount),
  117. equipmentCount: numberish(counts.raw.equipmentCount),
  118. facilityCount: numberish(counts.raw.facilityCount),
  119. infrastructureFacilityCount: numberish(counts.raw.infrastructureFacilityCount),
  120. // 在线率:`value` 是 0–1 的小数(后端 BigDecimal),`state` 为 EMPTY 时表示分母为 0。
  121. onlineRate: numberish(onlineRate.raw.value),
  122. onlineCount: numberish(onlineRate.raw.numerator),
  123. onlineDenominator: numberish(onlineRate.raw.denominator),
  124. onlineState: onlineRate.optionalStr('state'),
  125. // 设备在线/离线/滞后/未知四态 + 口径分母(后端 `Status`)。
  126. deviceStatus: {
  127. online: numberish(status.raw.onlineCount),
  128. offline: numberish(status.raw.offlineCount),
  129. stale: numberish(status.raw.staleCount),
  130. unknownOrMissing: numberish(status.raw.unknownOrMissingCount),
  131. denominator: numberish(status.raw.denominator),
  132. },
  133. // 类型分布:后端已按 deviceCount 降序排好(服务里排序),本层不重排。
  134. typeDistribution: reader.optionalList('typeDistribution').map((item) => {
  135. const row = fieldReader(item, 'typeDistribution')
  136. const type = fieldReader(row.raw.deviceType, 'typeDistribution.deviceType')
  137. return {
  138. code: type.optionalStr('code'),
  139. name: type.optionalStr('name') || type.optionalStr('code'),
  140. industry: row.optionalStr('industry'),
  141. deviceCount: numberish(row.raw.deviceCount),
  142. onlineCount: numberish(row.raw.onlineCount),
  143. offlineCount: numberish(row.raw.offlineCount),
  144. staleCount: numberish(row.raw.staleCount),
  145. }
  146. }),
  147. // 新鲜度:本端点只有信封级(见文件头第 ① 条)。
  148. freshness: freshness.optionalStr('status'),
  149. freshUpdatedAt: freshness.optionalStr('updatedAt'),
  150. serverUpdatedAt: freshness.optionalStr('serverUpdatedAt'),
  151. selectedIndustry: mapFilter.industry || '',
  152. selectedDeviceType: mapFilter.deviceType || '',
  153. // 口径剔除(后端解释"为什么这些设备没算进来",界面照实说明)。
  154. excluded: {
  155. unknownTypeCount: numberish((reader.raw.excluded || {}).unknownTypeCount),
  156. typeKindMismatchCount: numberish((reader.raw.excluded || {}).typeKindMismatchCount),
  157. nonFormalIndustryCount: numberish((reader.raw.excluded || {}).nonFormalIndustryCount),
  158. },
  159. missing: reader.missing,
  160. }
  161. }
  162. /** 分布行(区域/行业/设备类型三族共用同一个 `StatusCounts` 结构)→ 领域行。 */
  163. function toStatusCounts(raw) {
  164. const reader = fieldReader(raw, 'statusCounts')
  165. return {
  166. alarm: numberish(reader.raw.alarm),
  167. warning: numberish(reader.raw.warning),
  168. workOrder: numberish(reader.raw.workOrder),
  169. normal: numberish(reader.raw.normal),
  170. offline: numberish(reader.raw.offline),
  171. empty: numberish(reader.raw.empty),
  172. }
  173. }
  174. /**
  175. * 监测分布的一个地图标记 → 领域行。
  176. *
  177. * **注意本端点不给设备编码列表**(那是 `/map-snapshot` 的 `CockpitMonitoringPointSummary.deviceCodes`),
  178. * 所以台账卡片的设备徽标只能逐点位拉详情(票 18 的 Q10 裁定),不由本层伪造。
  179. */
  180. function toDistributionMarker(raw, kind) {
  181. const reader = fieldReader(raw, kind)
  182. const type = fieldReader(reader.raw.deviceType, `${kind}.deviceType`)
  183. return {
  184. kind,
  185. code: reader.optionalStr('pointCode') || reader.optionalStr('deviceCode'),
  186. name: reader.optionalStr('pointName') || reader.optionalStr('deviceName') || reader.optionalStr('deviceCode'),
  187. district: normalizeDistrict(reader.raw.district),
  188. createdAt: reader.optionalStr('createdAt'),
  189. industry: reader.optionalStr('industry'),
  190. typeCode: type.optionalStr('code'),
  191. typeName: type.optionalStr('name'),
  192. status: reader.optionalStr('pointStatus'),
  193. statusReasons: reader.optionalList('statusReasons').map((item) => String(item)),
  194. longitude: numberish(reader.raw.longitude),
  195. latitude: numberish(reader.raw.latitude),
  196. missing: reader.missing,
  197. }
  198. }
  199. /**
  200. * 监测分布(`/monitoring/distribution`)→ 领域对象。
  201. * 票 18 验收第 2 条:覆盖情况、状态分布和地图筛选。
  202. * 验收第 4 条的台账**共用这一次取数**(Q4 裁定)——它读同一个响应的 `monitoringPoints` 与 `pagination`。
  203. *
  204. * 三处后端语义(逐行读服务确认,不是猜的):
  205. * · 三份分布与状态计数是**全量口径**(服务先把全部 marker 聚合完再切片分页),
  206. * 所以它们不随翻页跳动 —— 面板报的是全城分布,不是"这一页的分布";
  207. * · 分页窗口横跨「监测点 + 未关联设备」一条总序列(与 `map-snapshot` 同一套语义);
  208. * · `region` 空值在响应里归一为字面量 `未分区`(是真实取值,不是空值)。
  209. */
  210. export function toMonitoringDistribution(raw) {
  211. const reader = fieldReader(raw, 'monitoringDistribution')
  212. const pagination = fieldReader(reader.raw.pagination, 'monitoringDistribution.pagination')
  213. const freshness = fieldReader(reader.raw.freshness, 'monitoringDistribution.freshness')
  214. const mapFilter = reader.optionalObj('mapFilter') || {}
  215. const regionRows = reader.optionalList('districtDistribution').map((item) => {
  216. const row = fieldReader(item, 'districtDistribution')
  217. return {
  218. code: normalizeDistrict(row.raw.district),
  219. pointCount: numberish(row.raw.pointCount) || 0,
  220. deviceCount: numberish(row.raw.deviceCount) || 0,
  221. statusCounts: toStatusCounts(row.raw.statusCounts),
  222. }
  223. })
  224. const industryRows = reader.optionalList('industryDistribution').map((item) => {
  225. const row = fieldReader(item, 'industryDistribution')
  226. return {
  227. code: row.optionalStr('industry'),
  228. pointCount: numberish(row.raw.pointCount) || 0,
  229. deviceCount: numberish(row.raw.deviceCount) || 0,
  230. statusCounts: toStatusCounts(row.raw.statusCounts),
  231. }
  232. })
  233. const typeRows = reader.optionalList('deviceTypeDistribution').map((item) => {
  234. const row = fieldReader(item, 'deviceTypeDistribution')
  235. const type = fieldReader(row.raw.deviceType, 'deviceTypeDistribution.deviceType')
  236. return {
  237. code: type.optionalStr('code'),
  238. name: type.optionalStr('name') || type.optionalStr('code'),
  239. industry: row.optionalStr('industry'),
  240. pointCount: numberish(row.raw.pointCount) || 0,
  241. deviceCount: numberish(row.raw.deviceCount) || 0,
  242. statusCounts: toStatusCounts(row.raw.statusCounts),
  243. }
  244. })
  245. return {
  246. createdAt: reader.optionalStr('createdAt'),
  247. updatedAt: reader.optionalStr('updatedAt'),
  248. freshUpdatedAt: freshness.optionalStr('updatedAt'),
  249. freshness: freshness.optionalStr('status'),
  250. regions: regionRows,
  251. industries: industryRows,
  252. deviceTypes: typeRows,
  253. // 状态分布:后端按字典顺序给全部六个状态(含计数为 0 的,界面据此不丢行)。
  254. pointStatuses: reader.optionalList('pointStatusDistribution').map((item) => {
  255. const row = fieldReader(item, 'pointStatusDistribution')
  256. return { status: row.optionalStr('status'), count: numberish(row.raw.count) || 0 }
  257. }),
  258. // 台账(Q4:与分布共用这次取数)
  259. points: reader.optionalList('monitoringPoints').map((item) => toDistributionMarker(item, 'monitoringPoint')),
  260. equipment: reader.optionalList('unassociatedEquipment').map((item) => toDistributionMarker(item, 'unassociatedEquipment')),
  261. pagination: {
  262. page: numberish(pagination.raw.page),
  263. pageSize: numberish(pagination.raw.pageSize),
  264. total: numberish(pagination.raw.total),
  265. totalPages: numberish(pagination.raw.totalPages),
  266. hasMore: pagination.bool('hasMore', false),
  267. },
  268. // 生效范围回显(Q3:筛选入口只有全局筛选条,面板靠这段回显,不自己存一份筛选状态)。
  269. selectedIndustry: mapFilter.industry || '',
  270. selectedDeviceType: mapFilter.deviceType || '',
  271. selectedPointStatus: mapFilter.pointStatus || '',
  272. selectedDistrict: normalizeDistrict(mapFilter.district),
  273. districtFilter: mapFilter.districtFilter || '',
  274. excluded: {
  275. unknownTypeCount: numberish((reader.raw.excluded || {}).unknownTypeCount),
  276. typeKindMismatchCount: numberish((reader.raw.excluded || {}).typeKindMismatchCount),
  277. nonFormalIndustryCount: numberish((reader.raw.excluded || {}).nonFormalIndustryCount),
  278. invalidCoordinateCount: numberish((reader.raw.excluded || {}).invalidCoordinateCount),
  279. },
  280. missing: reader.missing,
  281. }
  282. }
  283. /** 一个异常设备 → 领域行(`abnormalType` 与 `status` 后端取同值,只留一个)。 */
  284. function toAbnormalEquipment(raw) {
  285. const reader = fieldReader(raw, 'abnormalEquipment')
  286. const type = fieldReader(reader.raw.deviceType, 'abnormalEquipment.deviceType')
  287. return {
  288. // 设备编码(不是点位编码):本端点逐行给的是设备维度。
  289. code: reader.optionalStr('deviceCode'),
  290. name: reader.optionalStr('deviceName') || reader.optionalStr('deviceCode'),
  291. typeCode: type.optionalStr('code'),
  292. typeName: type.optionalStr('name'),
  293. industry: reader.optionalStr('industry'),
  294. district: normalizeDistrict(reader.raw.district),
  295. createdAt: reader.optionalStr('createdAt'),
  296. status: reader.optionalStr('status'),
  297. statusReasons: reader.optionalList('statusReasons').map((item) => String(item)),
  298. longitude: numberish(reader.raw.longitude),
  299. latitude: numberish(reader.raw.latitude),
  300. missing: reader.missing,
  301. }
  302. }
  303. /** 趋势点 → 领域行;桶标签由 `bucketLabel()` 给人话,原始值留在 `key` 里供断言。 */
  304. function toTrendPoint(raw) {
  305. const reader = fieldReader(raw, 'trendPoint')
  306. return { bucket: reader.optionalStr('bucketStart'), count: numberish(reader.raw.count) || 0 }
  307. }
  308. /**
  309. * ISO 时间戳 → 趋势桶的短标签(给人读;解析不了就回原串,不丢桶)。
  310. *
  311. * 后端桶是业务时区(Asia/Shanghai)的整点/整日 ISO 串,含偏移量。
  312. * 本函数**只做截断显示**,不改桶的粒度、不重采样、不补点 —— 那是后端语义。
  313. * @param {string} bucketStart ISO-8601 时间戳
  314. * @param {boolean} daily 桶是否为「日」粒度(7 天窗口用)
  315. */
  316. export function bucketLabel(bucketStart, daily = false) {
  317. const value = typeof bucketStart === 'string' ? bucketStart : ''
  318. const matched = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value)
  319. if (!matched) return value
  320. const [, , month, day, hour, minute] = matched
  321. if (daily) return `${Number(month)}/${Number(day)}`
  322. return `${hour}:${minute}`
  323. }
  324. /**
  325. * 运行概况(`/monitoring/situation`)→ 领域对象。
  326. * 票 18 验收第 3 条:事件趋势、离线设备和异常态势。
  327. *
  328. * 端点语义(逐行读服务确认):
  329. * · 不传 `startTime`/`endTime` 时后端默认**最近 24 小时**、按小时分桶,上限 31 天;
  330. * · **预警与报警两条趋势永不混合**(服务注释原句:warnings feed warningTrend only,
  331. * alarms feed alarmTrend only),所以本层给两块,不自己加总成一条;
  332. * · `abnormalEquipment` 是**异常设备清单**(服务定义:WARNING/ALARM/EXPIRED/OFFLINE 四态),
  333. * 离线设备是它的一个子集 —— 界面据此分开说"离线多少台"与"异常清单里有什么"。
  334. */
  335. export function toMonitoringSituation(raw) {
  336. const reader = fieldReader(raw, 'monitoringSituation')
  337. const pagination = fieldReader(reader.raw.pagination, 'monitoringSituation.pagination')
  338. const freshness = fieldReader(reader.raw.freshness, 'monitoringSituation.freshness')
  339. const range = reader.optionalObj('range') || {}
  340. // 指标状态分布:后端按固定字典顺序给六态(含 0),本层不重排、不丢行。
  341. const metricStatusMap = new Map(reader.optionalList('metricStatusDistribution').map((item) => {
  342. const row = fieldReader(item, 'metricStatusDistribution')
  343. return [row.optionalStr('status'), numberish(row.raw.count) || 0]
  344. }))
  345. const metricStatuses = ['NORMAL', 'WARNING', 'ALARM', 'EXPIRED', 'OFFLINE', 'UNKNOWN']
  346. .map((status) => ({ status, count: metricStatusMap.get(status) || 0 }))
  347. const coverage = reader.optionalList('industryCoverage').map((item) => {
  348. const row = fieldReader(item, 'industryCoverage')
  349. return {
  350. code: row.optionalStr('industry'),
  351. deviceCount: numberish(row.raw.deviceCount) || 0,
  352. abnormalCount: numberish(row.raw.abnormalCount) || 0,
  353. statusCounts: toStatusCounts(row.raw.statusCounts),
  354. }
  355. })
  356. return {
  357. createdAt: reader.optionalStr('createdAt'),
  358. updatedAt: reader.optionalStr('updatedAt'),
  359. freshUpdatedAt: freshness.optionalStr('updatedAt'),
  360. freshness: freshness.optionalStr('status'),
  361. // 窗口:后端回显实际生效区间(不传时是最近 24 小时,`type` 为 `TREND_WINDOW`)。
  362. rangeType: range.type || '',
  363. rangeStart: range.startTime || '',
  364. rangeEnd: range.endTime || '',
  365. metricStatuses,
  366. warningTrend: reader.optionalList('warningTrend').map(toTrendPoint),
  367. alarmTrend: reader.optionalList('alarmTrend').map(toTrendPoint),
  368. coverage,
  369. abnormalEquipment: reader.optionalList('abnormalEquipment').map(toAbnormalEquipment),
  370. pagination: {
  371. page: numberish(pagination.raw.page),
  372. pageSize: numberish(pagination.raw.pageSize),
  373. total: numberish(pagination.raw.total),
  374. totalPages: numberish(pagination.raw.totalPages),
  375. hasMore: pagination.bool('hasMore', false),
  376. },
  377. selectedIndustry: (reader.optionalObj('mapFilter') || {}).industry || '',
  378. selectedDeviceType: (reader.optionalObj('mapFilter') || {}).deviceType || '',
  379. excluded: {
  380. unknownTypeCount: numberish((reader.raw.excluded || {}).unknownTypeCount),
  381. typeKindMismatchCount: numberish((reader.raw.excluded || {}).typeKindMismatchCount),
  382. nonFormalIndustryCount: numberish((reader.raw.excluded || {}).nonFormalIndustryCount),
  383. invalidCoordinateCount: numberish((reader.raw.excluded || {}).invalidCoordinateCount),
  384. },
  385. missing: reader.missing,
  386. }
  387. }
  388. // ================================================================ 视图计划
  389. /** 票 54:数据时间屏上短读,完整 ISO 进入 title;缺值先占位。 */
  390. function createdAtKpi(value) {
  391. const full = valueText(value)
  392. if (full === DETAIL_PLACEHOLDER) return { key: 'createdAt', label: '数据时间', value: full, full }
  393. return { key: 'createdAt', label: '数据时间', value: shortEventTime(value) || full, full }
  394. }
  395. /** 该状态在指标状态分布里的计数。 */
  396. function metricCount(statuses, code) {
  397. const found = (statuses || []).find((row) => row.status === code)
  398. return found ? found.count : null
  399. }
  400. /**
  401. * 监测概览面板视图。
  402. * 验收四项:监测点/设备规模、在线率、类型分布、最近采集时间。
  403. */
  404. export function monitoringOverviewPanel(data, section) {
  405. const current = data || {}
  406. const deviceStatus = current.deviceStatus || {}
  407. // 在线率:分母为 0 时后端回 `state: EMPTY` + `value: 0`,那个 0 是**没有分母时的占位**,
  408. // 不是"在线率确实是 0"——照它画成 0.0% 就是票 16 明令禁止的虚假零值。所以先按 state 收口。
  409. const rateAvailable = current.onlineState !== 'EMPTY' && current.onlineDenominator !== null && current.onlineDenominator > 0
  410. const rate = rateAvailable ? current.onlineRate : null
  411. const blocks = [
  412. kpiBlock([
  413. { key: 'monitoringPointCount', label: '监测点', value: valueText(current.monitoringPointCount), unit: '个' },
  414. { key: 'equipmentCount', label: '监测设备', value: valueText(current.equipmentCount), unit: '台' },
  415. {
  416. key: 'onlineRate',
  417. label: '在线率',
  418. value: ratioText(rate),
  419. // 在线率的分母是后端给的「已解析设备数」,把分子分母摊开说,免得读者自己猜口径;
  420. // 后端明说没有分母(`EMPTY`)时如实说"没有可算的设备",不算出一个 0。
  421. hint: rateAvailable
  422. ? `在线 ${valueText(current.onlineCount)} / 设备 ${valueText(current.onlineDenominator)}`
  423. : '当前范围内没有可参与计算的设备,在线率不适用',
  424. },
  425. // 采集时间:本端点只有信封级时间(见文件头第 ① 条),措辞与"采集"区分开。
  426. createdAtKpi(current.createdAt),
  427. ], { layout: 'two-by-two' }),
  428. rowsBlock('设备状态', [
  429. { key: 'online', label: '在线', value: valueText(deviceStatus.online) },
  430. { key: 'offline', label: '离线', value: valueText(deviceStatus.offline) },
  431. { key: 'stale', label: '数据滞后', value: valueText(deviceStatus.stale) },
  432. { key: 'unknownOrMissing', label: '未知/缺失', value: valueText(deviceStatus.unknownOrMissing) },
  433. { key: 'denominator', label: '在线率分母', value: valueText(deviceStatus.denominator) },
  434. ]),
  435. barsBlock(
  436. '设备类型分布',
  437. // 票 37:按设备数**从多到少**降序;并列时保持后端给的次序(后端已排好则是恒等变换)。
  438. sortByCountDesc(
  439. (current.typeDistribution || []).map((row) => ({
  440. code: row.code,
  441. label: row.name || codeText(row.code),
  442. count: row.deviceCount || 0,
  443. // 当前生效的类型行:渲染层画选中态,用户据此知道"再点一下就取消"。
  444. selected: Boolean(row.code) && row.code === current.selectedDeviceType,
  445. })),
  446. (row) => row.count,
  447. ),
  448. // 行可点 → 写全局类型筛选(复用票 20 立的 `panel-action` 接缝)。
  449. // 只带得出后端类型码的行才可点:`code` 为空时 `barsBlock` 不给 `actionKey`,
  450. // 那一行在渲染层就不是按钮 —— 不拿展示名冒充筛选码去换后端 400。
  451. { action: 'deviceType' },
  452. ),
  453. ]
  454. return {
  455. name: 'monitoringOverview',
  456. label: '监测概览',
  457. section,
  458. hasContent: sectionHasContent(section),
  459. blocks,
  460. // 概览是聚合口径(后端只给分类型计数,不给逐点位坐标),没有可定位对象。
  461. locate: [],
  462. locateNote: '监测概览是分类型聚合口径(后端不返回逐点位坐标),本面板没有可定位对象',
  463. // 采集时间与数据时间的区别如实说明(文件头第 ① 条)。
  464. gap: METRIC_FRESHNESS_GAP,
  465. industryNote: current.selectedIndustry ? `当前行业:${industryLabel(current.selectedIndustry) || current.selectedIndustry}` : '当前行业:全部',
  466. // 票 37:生效类型必须看得见 —— 否则读者只会看到"类型分布少了几行",
  467. // 不知道该条件从哪来、也不知道怎么撤(同一条行再点一次)。
  468. typeNote: current.selectedDeviceType
  469. ? `当前类型:${deviceTypeLabel(current.typeDistribution, current.selectedDeviceType)}(再点同一行取消)`
  470. : '当前类型:全部',
  471. missing: panelMissingNote(current),
  472. placeholder: sectionPlaceholder(section),
  473. }
  474. }
  475. /**
  476. * 类型码 → 展示名(票 37)。名字优先取自**本端点返回的类型分布**(同一份数据里带着 name,
  477. * 不另建一份类型词典);分布里没有该码时用 `codeText` 的既有口径——**数据值照原样带出**,
  478. * 因为空白会被读成"没有数据",而"没见过的类型码"是真实数据。
  479. * @param {Array} rows 类型分布行(含 `code`/`name`)
  480. * @param {string} code 生效的类型码
  481. */
  482. function deviceTypeLabel(rows, code) {
  483. if (!code) return ''
  484. const found = (rows || []).find((row) => row.code === code)
  485. return (found && found.name) || codeText(code)
  486. }
  487. /**
  488. * 监测分布面板视图。
  489. * 验收三项:覆盖情况、状态分布、地图筛选(本票的筛选入口是全局筛选条,面板只回显生效范围)。
  490. *
  491. * **票 14 改写**:状态统计从「可点状态条」改为**饼图**(决策 02 的 Q12/Q19)——
  492. * · 数据源是**全局未筛分布**(`extra.statusDistribution`,与地图顶部状态栏共用同一份,
  493. * 来自 `map-snapshot` 的八桶全局计数;不是本端点随筛选变的那份);
  494. * · 饼图只**高亮当前选中**,不再是筛选入口——全局条件只有一个可操作面(顶部状态栏),
  495. * 面板自带筛选正是票 07 点名的「入口分散且重复」缺陷;
  496. * · 全局分布未取到时如实说明(不伪造全零饼图)。
  497. *
  498. * @param {object} data `toMonitoringDistribution()` 的结果
  499. * @param {object} section 分区
  500. * @param {object} [extra] `{ statusDistribution, selectedStatuses }`(外壳给的共用数据与当前选中)
  501. */
  502. export function monitoringDistributionPanel(data, section, extra = {}) {
  503. const current = data || {}
  504. // 覆盖情况(Q11 裁定):用**本端点自己的**区域/行业/设备类型分布 ——
  505. // 票 24 指向的 `overview/monitoring` 的 `formalScope` 不在本端点上,拿不到也不去拉。
  506. // 票 37 走查扩展(维护者 2026-10-14):三块都是排名类别,统一按计数降序(并列保序);
  507. // 环形图(行业)与条形(片区/类型)同口径。
  508. const regionRows = sortByCountDesc(
  509. (current.regions || []).map((row) => ({
  510. code: row.code,
  511. label: districtLabel(row.code),
  512. count: row.pointCount || 0,
  513. // 每一行带上设备数与状态明细,但主条长按**点位数**归一(图与数同源:口径写在标题里)。
  514. hint: `设备 ${valueText(row.deviceCount)}`,
  515. })),
  516. (row) => row.count,
  517. )
  518. const industryRows = sortByCountDesc(
  519. (current.industries || []).map((row) => ({
  520. code: row.code,
  521. label: industryLabel(row.code) || regionText(row.code),
  522. count: row.pointCount || 0,
  523. hint: `设备 ${valueText(row.deviceCount)}`,
  524. })),
  525. (row) => row.count,
  526. )
  527. const typeRows = sortByCountDesc(
  528. (current.deviceTypes || []).map((row) => ({
  529. code: row.code || row.name,
  530. label: row.name || codeText(row.code),
  531. count: row.pointCount || 0,
  532. hint: `设备 ${valueText(row.deviceCount)}`,
  533. })),
  534. (row) => row.count,
  535. )
  536. // 票 14:饼图切片(几何与高亮判定都在 `domain/statusPie.js`,本层只搬运)。
  537. const pie = planStatusPie({
  538. distribution: extra.statusDistribution || null,
  539. rows: legendVisuals(),
  540. selected: Array.isArray(extra.selectedStatuses) ? extra.selectedStatuses : [],
  541. })
  542. const blocks = [
  543. // 2026-10-13 维护者反馈:条形图太多易疲劳——行业行改环形图(行业语义色),
  544. // 片区/设备类型保留条形(多类别排名比较)。
  545. donutBlock('覆盖情况 · 行业', industryRows),
  546. barsBlock('覆盖情况 · 片区', regionRows),
  547. barsBlock('覆盖情况 · 设备类型', typeRows),
  548. ...(extra.statusDistribution && extra.statusDistribution.available
  549. ? [barsBlock('监测点异常统计(全局未筛)', abnormalCountRows(
  550. Object.entries(extra.statusDistribution.counts || {}).map(([status, count]) => ({ status, count })), 'point'))]
  551. : []),
  552. // 状态分布饼图:消费全局未筛分布并高亮当前选中;**不可点**(筛选入口只在顶部状态栏)。
  553. {
  554. kind: 'pie',
  555. label: '点位状态分布(全局未筛)',
  556. ready: pie.ready,
  557. hasSelection: pie.hasSelection,
  558. total: pie.total,
  559. slices: pie.slices,
  560. rows: pie.rows,
  561. note: pie.ready ? '' : STATUS_DISTRIBUTION_UNPUBLISHED,
  562. },
  563. rowsBlock('口径', [
  564. { key: 'pointTotal', label: '监测点总数', value: valueText(current.pagination && current.pagination.total) },
  565. { key: 'industry', label: '生效行业', value: current.selectedIndustry ? industryLabel(current.selectedIndustry) || current.selectedIndustry : '全部' },
  566. { key: 'district', label: '生效片区', value: districtFilterLabel({ district: current.selectedDistrict, districtFilter: current.districtFilter }) },
  567. // 票 37:类型是全局筛选条件之一,生效范围必须与本模式其它条件一样看得见
  568. // (否则读者只看到"类型分布少了几行",不知道条件从哪来、怎么撤)。
  569. { key: 'deviceType', label: '生效类型', value: current.selectedDeviceType ? deviceTypeLabel(current.deviceTypes, current.selectedDeviceType) : '全部' },
  570. { key: 'createdAt', label: '数据时间', value: valueText(current.createdAt) },
  571. // 票 14:状态多值化——回显是逗号串,逐桶翻人话;空 = 全部。
  572. {
  573. key: 'pointStatus',
  574. label: '生效状态',
  575. value: current.selectedPointStatus
  576. ? String(current.selectedPointStatus)
  577. .split(',')
  578. .map((code) => POINT_STATUS_FILTER_LABELS[code] || code)
  579. .filter(Boolean)
  580. .join('、')
  581. : '全部',
  582. },
  583. // 坐标准入被拦下的对象数:分布口径只统计可上图对象,这个数解释差值(票 07 的口径)。
  584. { key: 'invalidCoordinate', label: '坐标未通过校验', value: valueText(current.excluded && current.excluded.invalidCoordinateCount) },
  585. ]),
  586. ]
  587. return {
  588. name: 'monitoringDistribution',
  589. label: '监测分布',
  590. section,
  591. hasContent: sectionHasContent(section),
  592. blocks,
  593. locate: [],
  594. locateNote: '监测分布是聚合分布口径(后端不返回逐点位坐标),本面板没有可定位对象',
  595. // 范围筛选落点说明保留;「状态筛选不作用到一张图」的缺口说明已随票 14 的契约修订删除。
  596. gap: REGION_SCOPE_NOTE,
  597. missing: panelMissingNote(current),
  598. placeholder: sectionPlaceholder(section),
  599. }
  600. }
  601. /**
  602. * 运行概况面板视图。
  603. * 验收三项:事件趋势、离线设备、异常态势。
  604. */
  605. export function operationOverviewPanel(data, section) {
  606. const current = data || {}
  607. // 7 天窗口的桶是「日」粒度,标签按日期显示(只影响显示,不改桶)。
  608. const daily = isDailyWindow(current)
  609. const trendPoints = (list) =>
  610. (list || []).map((row) => ({
  611. key: row.bucket,
  612. label: bucketLabel(row.bucket, daily),
  613. count: row.count,
  614. }))
  615. const offlineCount = metricCount(current.metricStatuses, 'OFFLINE')
  616. // 离线设备:从异常清单里挑出离线的那一档。**只挑,不推断**——状态来自后端 `status`。
  617. const offlineDevices = (current.abnormalEquipment || []).filter((item) => item.status === 'OFFLINE')
  618. const blocks = [
  619. kpiBlock([
  620. createdAtKpi(current.createdAt),
  621. { key: 'offlineCount', label: '离线设备', value: valueText(offlineCount), unit: '台' },
  622. { key: 'alarmCount', label: '报警中', value: valueText(metricCount(current.metricStatuses, 'ALARM')), unit: '台' },
  623. { key: 'warningCount', label: '预警中', value: valueText(metricCount(current.metricStatuses, 'WARNING')), unit: '台' },
  624. { key: 'abnormalCount', label: '异常设备', value: valueText(current.pagination && current.pagination.total), unit: '台' },
  625. ]),
  626. barsBlock('设备/指标异常统计', abnormalCountRows(current.metricStatuses, 'metric')),
  627. // 预警与报警**分开画**:后端注释明确两者不混合,合成一条就是把两类事件混成一份。
  628. // 2026-10-13 图表选型:时间趋势用折线。
  629. trendBlock('预警趋势', trendPoints(current.warningTrend), { chart: 'line' }),
  630. trendBlock('报警趋势', trendPoints(current.alarmTrend), { chart: 'line' }),
  631. barsBlock(
  632. '异常态势 · 指标状态分布',
  633. (current.metricStatuses || []).map((row) => ({
  634. code: row.status,
  635. label: metricStatusText(row.status),
  636. count: row.count || 0,
  637. })),
  638. ),
  639. listBlock(
  640. '异常设备清单',
  641. (current.abnormalEquipment || []).map((item) => ({
  642. key: item.code,
  643. title: item.name,
  644. dataTime: valueText(item.createdAt),
  645. tone: abnormalTone(item.status),
  646. // 色键必带文字(票 06):状态词来自指标状态字典,退化成"未知"也不留空标签。
  647. toneLabel: metricStatusText(item.status),
  648. meta: [
  649. item.typeName,
  650. industryLabel(item.industry) || codeText(item.industry),
  651. districtLabel(item.district),
  652. ...item.statusReasons,
  653. ].filter(Boolean),
  654. // 设备编码即该设备在地图上的对象编码(未关联设备以自身编码上图),
  655. // 但**只有已加载到一张图里**才飞得动 —— 三种"点不动"由 `domain/mapLocate.js` 分开。
  656. points: item.code
  657. ? [{ key: `${item.code}|locate`, label: item.name, pointCode: item.code, objectCode: item.code, objectName: item.name }]
  658. : [],
  659. })),
  660. '当前窗口没有异常设备',
  661. ),
  662. ]
  663. return {
  664. name: 'operationOverview',
  665. label: '运行概况',
  666. section,
  667. hasContent: sectionHasContent(section),
  668. blocks,
  669. locate: (current.abnormalEquipment || []).flatMap((item) =>
  670. locateEntries(item.code, item.name, { code: item.code, name: item.name }, item.code ? [item.code] : []),
  671. ),
  672. locateNote: '异常设备可定位;设备坐标由一张图回联,未加载到的设备不下发飞行',
  673. // 窗口口径:后端回显的实际生效区间(本面板的 24 小时 / 7 天切换写在界面上,见票面 Q8)。
  674. rangeNote: current.rangeStart && current.rangeEnd ? `统计窗口:${current.rangeStart} 至 ${current.rangeEnd}` : '',
  675. offlineNote: offlineDevices.length
  676. ? `离线 ${offlineDevices.length} 台:${offlineDevices.map((item) => item.name).join('、')}`
  677. : '当前窗口没有离线设备',
  678. missing: panelMissingNote(current),
  679. placeholder: sectionPlaceholder(section),
  680. }
  681. }
  682. /** 7 天窗口的桶按日显示(判据是后端回显的区间长度,不是前端自己记的开关)。 */
  683. function isDailyWindow(current) {
  684. const start = Date.parse((current && current.rangeStart) || '')
  685. const end = Date.parse((current && current.rangeEnd) || '')
  686. if (!Number.isFinite(start) || !Number.isFinite(end)) return false
  687. return end - start > 36 * 3600 * 1000
  688. }
  689. /** 异常状态 → 色键(报警最重、其次预警、离线与过期走中性)。 */
  690. function abnormalTone(status) {
  691. if (status === 'ALARM') return 'lv1'
  692. if (status === 'WARNING') return 'lv2'
  693. if (status === 'OFFLINE' || status === 'EXPIRED') return 'neutral'
  694. return 'neutral'
  695. }
  696. /**
  697. * 监测点台账面板视图。
  698. * 验收第 4 条:以滚动卡片列出当前行业筛选内的监测点;点击卡片走与地图相同的两级详情。
  699. *
  700. * 台账与监测分布**共用一次取数**(Q4 裁定),所以卡片就是分布响应里的 `monitoringPoints`;
  701. * 分页由端点自己的 `pagination` 决定,本层只把"还有没有下一页"如实带出。
  702. *
  703. * **设备徽标不在这里**(Q10 裁定走 N+1):本端点不给设备编码,
  704. * 徽标由 store 逐点位取 `/monitoring-points/{code}` 的 `deviceStatistics` 后补进卡片视图。
  705. * @param {object} data `toMonitoringDistribution()` 的结果
  706. * @param {object} section 分区
  707. * @param {object} [badges] `{ [pointCode]: { state, total, alarm, warning, offline } }`(store 给)
  708. */
  709. export function monitoringPointLedgerPanel(data, section, badges = {}) {
  710. const current = data || {}
  711. const pagination = current.pagination || {}
  712. // 分母认不出就**整句不出**(作者本意),且绝不用 `undefined` 冒充读数:
  713. // `data` 为 null(真实空态/失败态,见 `sectionFromResult()` 的 `isEmpty` 分支)时
  714. // `pagination` 是 `{}`,`total` 是 `undefined` 而不是 `null` —— 只判 `=== null` 会把
  715. // 它漏进模板串。`numberish()` 是仓库里「数字/数字串→数字,认不出回 null」的唯一口径。
  716. const total = numberish(pagination.total)
  717. const cards = (current.points || []).map((point) => {
  718. const visual = visualForStatus(point.status)
  719. const badge = badges[point.code] || null
  720. return {
  721. key: point.code,
  722. code: point.code,
  723. name: point.name,
  724. dataTime: valueText(point.createdAt),
  725. // 状态词与色键来自票 09 的唯一一张表(认不出的状态落「状态未发布」,不猜成正常)。
  726. status: visual.key,
  727. tone: visual.tone,
  728. shape: visual.shape,
  729. statusLabel: visual.label,
  730. meta: [
  731. industryLabel(point.industry) || codeText(point.industry),
  732. districtLabel(point.district),
  733. point.typeName,
  734. ].filter(Boolean),
  735. reason: (point.statusReasons || []).join(';'),
  736. // 徽标:还没取到就**不给 0**(0 是"确实没有设备",与"还没问过"是两件事)。
  737. badge: badge && badge.state === 'success'
  738. ? {
  739. total: valueText(badge.total),
  740. alarm: valueText(badge.alarm),
  741. warning: valueText(badge.warning),
  742. offline: valueText(badge.offline),
  743. }
  744. : null,
  745. badgePending: !badge || badge.state === 'pending',
  746. }
  747. })
  748. const blocks = [
  749. listBlock('监测点', cards, '当前筛选范围内没有监测点'),
  750. ]
  751. return {
  752. name: 'monitoringPointLedger',
  753. label: '监测点台账',
  754. section,
  755. hasContent: sectionHasContent(section),
  756. blocks,
  757. // 卡片本身就是可定位/可详情的对象,所以这里不再另给一批按钮:
  758. // 点击卡片走**与地图相同**的两级详情入口(`detailStore.openPoint`),不隐式移动相机
  759. // —— 与"点地图标记"的行为一致(票 10 的既有语义)。
  760. locate: [],
  761. locateNote: '点击卡片进入与地图一致的两级详情(点位摘要 → 设备卡片);本面板不移动相机',
  762. // 分页口径:本端点分页窗口横跨「监测点 + 未关联设备」,所以 total 含设备,
  763. // 卡片数会小于 total —— 这个差值说明白,免得读成"还有一些点没列出来"。
  764. // 分母缺失(`total === null`)时整句不出:宁可不说,也不报一个假读数。
  765. pageNote:
  766. total === null
  767. ? ''
  768. : `已列出 ${cards.length} 个点位 · 本端点序列共 ${total} 条(含未关联设备)${pagination.hasMore ? ' · 还有下一页' : ''}`,
  769. hasMore: pagination.hasMore === true,
  770. page: pagination.page,
  771. missing: panelMissingNote(current),
  772. placeholder: sectionPlaceholder(section),
  773. }
  774. }
  775. /** panelId → 视图构造函数(本模式自己的四块)。 */
  776. export const MONITORING_PANEL_BUILDERS = {
  777. monitoringOverview: monitoringOverviewPanel,
  778. monitoringDistribution: monitoringDistributionPanel,
  779. operationOverview: operationOverviewPanel,
  780. // 台账签名多一个 `badges`(N+1 徽标);注册表调用时给 undefined 即为「还没取到」。
  781. monitoringPointLedger: (data, section, badges) => monitoringPointLedgerPanel(data, section, badges),
  782. }
  783. /**
  784. * 一个面板的视图。`data` 为 null(失败/空)时仍返回结构完整的视图(块为空),
  785. * 组件据此画占位说明 —— 「失败」不该让整块面板消失,那会连面板标题都没了。
  786. */
  787. export function monitoringPanelView(panelId, data, section, badges) {
  788. const build = MONITORING_PANEL_BUILDERS[panelId]
  789. if (!build) return null
  790. return build(data, section, badges)
  791. }
  792. /** 台账当前该请求的下一页(`null` = 不用再翻)。纯函数,store 与断言共用。 */
  793. export function nextLedgerPage(data) {
  794. const pagination = (data && data.pagination) || null
  795. if (!pagination || pagination.page === null) return 1
  796. if (pagination.hasMore !== true) return null
  797. return pagination.page + 1
  798. }
  799. /** 该点位是否需要补拉徽标(已被要求过就不重复发请求)。纯函数,便于断言。 */
  800. export function shouldLoadBadge(badges, pointCode) {
  801. if (!pointCode) return false
  802. return !(badges && badges[pointCode])
  803. }
  804. /** 台账卡片的状态分布(走查与人工排查读它,不读中文)。 */
  805. export function ledgerStatusCounts(data) {
  806. const counts = {}
  807. for (const point of (data && data.points) || []) {
  808. const key = visualForStatus(point.status).key
  809. counts[key] = (counts[key] || 0) + 1
  810. }
  811. return counts
  812. }
  813. /** 缺失值占位符(组件与本模块共用同一个词,免得两处各写一个字面量)。 */
  814. export const LEDGER_PLACEHOLDER = DETAIL_PLACEHOLDER