mapStore.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. /**
  2. * 地图摘要状态(ADR 0004 的 `mapStore`):分页快照、已加载点位、未关联设备、渐进加载状态。
  3. *
  4. * 职责边界(票 08):
  5. * · 分页与快照语义**不在本文件**——它们在 `domain/mapSummary.js` 的装载器里(纯函数 + 注入取数),
  6. * 所以脱浏览器的逻辑脚本能完整演示六态(见 verify/t08-map-summary.mjs);
  7. * · 本文件只做两件事:把「取一页」接到 `services/mapApi.js`,把装载器状态转成 Vue 响应式,
  8. * 供外壳与地图浮标渲染;
  9. * · 组件与页面**不出现** `page` / `pageSize` / `snapshotAt`(票 08 验收第 2 条:这些参数由适配层管理)。
  10. *
  11. * 「地图先显示框架,再按分页批次增加摘要点位」的落点是:装载器初始态是 `idle`(= 框架已就绪、
  12. * 等待分页数据),宿主先渲染,`advance()` 每成功一页才把点位并进 `objects`(票 08 验收第 1 条)。
  13. *
  14. * ── 票 14:自动分批加载(决策 02 的 Q24/Q25)──
  15. *
  16. * 「进入系统/筛选变化后自动连续分批加载到完成(批次间留帧间隔)」:
  17. * · `refresh()` 现在是一条**装载链**:换新快照取第 1 页 → 自动连续 `loadNext` 直到
  18. * settled(完成/空)或失败/失效——失败**不做无限自动重试**(决策 04:恢复靠周期刷新与手动入口);
  19. * · 链有**纪元号**(`loadEpoch`):新的 reload(换筛选/再刷新)作废旧链,旧链的迟到写入
  20. * 按纪元号整批丢弃(与装载器自身的 token 纪律同构,两层各管各的);
  21. * · `advance()` 仍是**单步**翻页(DEV 把手/走查用):调用它会作废自动链;
  22. * · 走查兼容:`window.__COCKPIT_MAP_MANUAL_PAGES__ = true` 关闭自动链(老走查逐页驱动)。
  23. */
  24. import { computed, ref, shallowRef } from 'vue'
  25. import { defineStore } from 'pinia'
  26. import {
  27. MAP_SUMMARY_PHASE,
  28. MAP_SUMMARY_SETTLED_PHASES,
  29. MAP_OBJECT_KIND,
  30. createMapSummaryLoader,
  31. emptyMapSummary,
  32. summaryView,
  33. } from '../domain/mapSummary.js'
  34. import { locationVerdict, coordinateOverview, facilityCoordinateExtra, mergeCoordinateExtras } from '../domain/coordinate.js'
  35. import { facilityPointCoordinateExtra } from '../domain/facilityPoints.js'
  36. import { usePipeNetworkStore } from './pipeNetworkStore.js'
  37. import { useFacilityPointStore } from './facilityPointStore.js'
  38. import { applyPointStates as applyStatusToObjects } from '../domain/statusPush.js'
  39. import { fetchMapSnapshot } from '../services/mapApi.js'
  40. /** 演示与走查用的页大小:后端默认 500,这里用 2 才能在几页之内演示渐进加载。 */
  41. export const MAP_SUMMARY_PAGE_SIZE = 2
  42. /** 自动分批的批间间隔(毫秒)。「批次间留帧间隔防卡顿」(决策 02 的 Q24)。 */
  43. export const AUTO_BATCH_GAP_MS = 120
  44. /** 走查兼容开关:置 true 后 refresh 不自动连翻(老走查用 DEV 把手逐页驱动)。 */
  45. export const MANUAL_PAGES_KEY = '__COCKPIT_MAP_MANUAL_PAGES__'
  46. /** 批间等待(可注入:逻辑闸门传即刻 resolve 的假等待)。 */
  47. let batchWait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
  48. /** 仅测试用:替换批间等待(走假时钟/零延迟的确定性断言)。 */
  49. export function setBatchWaitForTest(fn) {
  50. batchWait = typeof fn === 'function' ? fn : (ms) => new Promise((resolve) => setTimeout(resolve, ms))
  51. }
  52. function manualPagesMode() {
  53. return typeof globalThis !== 'undefined' && globalThis[MANUAL_PAGES_KEY] === true
  54. }
  55. /** 该阶段是否要继续自动装载(完成/空/失败/失效都停)。 */
  56. function autoStops(phase) {
  57. return MAP_SUMMARY_SETTLED_PHASES.includes(phase) || phase === MAP_SUMMARY_PHASE.FAILED || phase === MAP_SUMMARY_PHASE.EXPIRED
  58. }
  59. /**
  60. * 把装载器的查询参数接到领域接口,并把**分区块状结果**翻成装载器要的 `{ ok, data, reason }`。
  61. *
  62. * 为什么要有这一步翻译(票 08 踩到的第一坑):`fetchMapSnapshot` 回的是票 05 的 `section()`
  63. * (`{ state, reason, mode, data }`,**没有 `ok` 字段**),而装载器吃的是「一次取数的成败」。
  64. * 直接把它塞进装载器会恒判失败。两者都是对的,只是词汇不同,所以翻译**只在这一处**:
  65. * · `success` → 成功,带上领域批次;
  66. * · `empty` → 也算成功:后端确实说没数据,装载器据此收 `empty` 态;
  67. * · 其余(失败/不可用/过期)→ 失败,原因原样带给装载器(`snapshot-expired` 会被它认出来)。
  68. *
  69. * 票 18:**全局筛选(行业/范围)在这里贴上每一次请求**。它必须进查询串而不是只记在 store,
  70. * 否则后端不会过滤、地图也不会回 `mapFilter` 回显(票 25 第 3 节冻结的路径)。
  71. * 票 14:**多值 `pointStatus` 同样贴上每一次请求**(决策 02 冻结的契约:状态筛选作用到一张图;
  72. * 空集合 → `statusFilterQuery` 回空串=不传参)。
  73. */
  74. function fetchMapSummaryPage(query, options) {
  75. return fetchMapSnapshot({
  76. page: query.page,
  77. pageSize: query.pageSize,
  78. snapshotAt: query.snapshotAt,
  79. industry: options.industry,
  80. scope: options.scope,
  81. deviceType: options.deviceType,
  82. pointStatus: options.pointStatus,
  83. config: options.config,
  84. fetchImpl: options.fetchImpl,
  85. }).then((result) => {
  86. if (result.state === 'success' || result.state === 'empty') {
  87. return { ok: true, data: result.data || { objects: [], complete: true, total: 0 }, mode: result.mode }
  88. }
  89. return { ok: false, reason: result.reason || 'unavailable', mode: result.mode }
  90. })
  91. }
  92. export const useMapStore = defineStore('mapStore', () => {
  93. /** 装载器状态(整体替换,不做深响应:点位数可能上千,深响应会白烧性能)。 */
  94. const summary = shallowRef(emptyMapSummary({ pageSize: MAP_SUMMARY_PAGE_SIZE }))
  95. /** 装载链进行中(界面据此置灰 🔄 手动刷新,避免连点重复发请求)。 */
  96. const busy = ref(false)
  97. /**
  98. * 票 15:已收到的推送点位状态(`{ [pointCode]: {...} }`)。
  99. * 翻页时用它给**新到的对象**补状态 —— 推送常早于快照到达,只在"当时已加载"的对象上写
  100. * 会让后翻到的点位显示快照里的旧状态(真机上抓到的 bug)。
  101. */
  102. let pushedPointStates = {}
  103. let loader = null
  104. let loadOptions = {}
  105. /**
  106. * 票 42:是否正有一条约「换新快照」的刷新链在跑(从发起到 settled)。
  107. * 外壳据此在**发起瞬间**撤下旧关联设备图层(规格 14:先撤下,不等首页返回)。
  108. */
  109. const refreshing = ref(false)
  110. /**
  111. * 装载纪元(票 14 的自动链纪律):每次新的 reload/单步取数都作废旧链,
  112. * 旧链的迟到写入按纪元号整批丢弃。装载器自身还有一层 token(同批加载的并发
  113. * 保护),两层各管各的:纪元管"哪条链有资格写 store",token 管"哪次请求有资格合并"。
  114. */
  115. let loadEpoch = 0
  116. /**
  117. * 装好装载器(幂等)。取数一律走 `services/mapApi.js`;单测/走查可注入 `fetchImpl`。
  118. *
  119. * 票 18:注入项**合并**而不是整体替换。原因:本函数有多个调用点
  120. * (`MapCanvas` 挂载、`detailStore.loadSharedSections`、本 store 的 `applyFilters`),
  121. * 整体替换会让后到的调用把先到的筛选条件悄悄抹掉 —— 那正是"筛了但没生效"的经典来源。
  122. * @param {object} [options] `{ config, fetchImpl, industry, scope, deviceType }`
  123. */
  124. function setup(options = {}) {
  125. loadOptions = { ...loadOptions, ...options }
  126. if (!loader) {
  127. loader = createMapSummaryLoader({
  128. pageSize: MAP_SUMMARY_PAGE_SIZE,
  129. fetchPage: (query) => fetchMapSummaryPage(query, loadOptions),
  130. })
  131. summary.value = loader.getState()
  132. }
  133. return loader
  134. }
  135. /**
  136. * 装载链(票 14):一次取数 + (refresh 链)自动连续分批到 settled。
  137. *
  138. * 纪元纪律:进入即 `++loadEpoch` 作废其它链;每一步写 store 前都核对纪元,
  139. * 被换筛选/再刷新打断的旧链**整批丢弃**(迟到响应不得落进新范围)。
  140. * 失败/快照失效即停——失败不做无限自动重试(决策 04),恢复靠新鲜度周期与手动刷新。
  141. * @param {'refresh'|'next'} kind refresh = 换新快照第 1 页并自动连翻;next = 单步翻页
  142. */
  143. async function runLoadChain(kind) {
  144. const mine = ++loadEpoch
  145. busy.value = true
  146. // 票 42:刷新链从发起那一刻起就是「换新快照」——外壳立即撤旧层,不等首页返回。
  147. if (kind === 'refresh') refreshing.value = true
  148. try {
  149. let state = kind === 'refresh' ? await loader.refresh() : await loader.loadNext()
  150. if (mine !== loadEpoch) return summary.value
  151. summary.value = withPushedStatus(state)
  152. // 自动分批:只挂在 refresh 链上(进入系统/筛选变化/手动刷新都要"加载到完成")。
  153. while (kind === 'refresh' && mine === loadEpoch && !autoStops(summary.value.phase)) {
  154. if (manualPagesMode()) break
  155. await batchWait(AUTO_BATCH_GAP_MS)
  156. if (mine !== loadEpoch) break
  157. state = await loader.loadNext()
  158. if (mine !== loadEpoch) break
  159. summary.value = withPushedStatus(state)
  160. }
  161. } finally {
  162. if (mine === loadEpoch) {
  163. busy.value = false
  164. // 票 42:当前链收口即熄灭刷新信号(被更新链接管时由其自身收口)。
  165. refreshing.value = false
  166. }
  167. }
  168. return summary.value
  169. }
  170. /**
  171. * 票 18:全局筛选(行业/范围/状态)变化 → 记下新条件并**从第 1 页换新快照重载**。
  172. *
  173. * 为什么是重载而不是"把新结果追加进去"(Q9 裁定):同一张地图上混着两个筛选范围的对象,
  174. * 比"丢掉了已翻的页"更坏 —— 界面会说"当前行业:燃气"而图上还留着供水与排水的点,
  175. * 那是一条查不出来的假象。快照一致性本来就要求一次加载只对应一份数据(票 08),
  176. * 筛选取值变了就是另一份数据。
  177. *
  178. * @param {object} filters `{ industry, scope, deviceType, pointStatus }`
  179. * @returns {Promise<object>} 重载后的装载器状态
  180. */
  181. async function applyFilters(filters = {}) {
  182. setup(filters)
  183. return refresh()
  184. }
  185. /**
  186. * 取下一页(票 08 验收第 1 条:一批一批地增)。完成/空之后是空操作。
  187. * **单步**:调用它会作废正在跑的自动链(票 14;DEV 把手/走查逐页驱动用)。
  188. *
  189. * 票 15:新到的对象要**补上已经收到的推送状态**。真实时序里推送常常早于(或夹在)
  190. * 分页快照之间 —— 只把推送写在"当时已加载"的对象上,随后翻到的那个点位就会显示
  191. * 快照里的旧状态(本票在真机上抓到的 bug:MP-001 已推成 OFFLINE,翻页后却仍是 NORMAL)。
  192. * @returns {Promise<object>} 取完后的装载器状态
  193. */
  194. async function advance() {
  195. setup(loadOptions)
  196. return runLoadChain('next')
  197. }
  198. /**
  199. * 刷新:换到后端的新快照,从第 1 页重新加载(票 08 验收第 3 条的另一半),
  200. * 并**自动连续分批加载到完成**(票 14;决策 02 的 Q24——进入系统/筛选变化/
  201. * 手动刷新都走这条链)。快照已失效时也走这条路径——后端会开一份新快照。
  202. *
  203. * 票 15:刷新是**显式重新取数**,所以丢弃缓存的推送状态,让这一份快照说了算
  204. * (否则一次较旧的推送会盖掉刚取回的新状态 —— 那是"用旧值覆盖新值",正好违反本票纪律)。
  205. * @returns {Promise<object>} 取完后的装载器状态
  206. */
  207. async function refresh() {
  208. setup(loadOptions)
  209. pushedPointStates = {}
  210. return runLoadChain('refresh')
  211. }
  212. /** 界面用的文案与色键(`domain/mapSummary.js` 的 `summaryView`)。 */
  213. const view = computed(() => summaryView(summary.value))
  214. /**
  215. * 票 14:八桶全局计数(`mapSummary` 的 `statusDistribution`)。
  216. * 顶部状态栏与监测分布饼图**共用这一份**(决策 02:全局未筛全集,筛选时不变);
  217. * `available:false` = 后端还没发布这一段,界面如实降级、不补 0。
  218. */
  219. const statusDistribution = computed(() => summary.value.statusDistribution || { available: false, counts: {}, businessCounts: {}, attachmentCounts: {} })
  220. /**
  221. * 两条统计轴:业务状态只数监测点;附加状态单独数未关联设备。
  222. * 不从 `counts` 里二次猜测或把两类对象相加——对象来源已由后端发布,
  223. * 这里仅按 kind 分桶,保证顶部图例能同时显示「报警 1」与「预警 3 / 工单 1 / 离线 3」的并行事实。
  224. */
  225. const statusAxes = computed(() => {
  226. const distribution = statusDistribution.value
  227. const objects = summary.value.objects || []
  228. const businessCounts = {}
  229. const attachmentCounts = { UNASSOCIATED: 0 }
  230. for (const item of objects) {
  231. if (!item || !item.kind) continue
  232. if (item.kind === MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT) {
  233. // 未关联设备同时属于附加轴和业务轴:deviceStatus 仍必须计入业务状态。
  234. attachmentCounts.UNASSOCIATED += 1
  235. const status = item.status || 'UNKNOWN'
  236. businessCounts[status] = (businessCounts[status] || 0) + 1
  237. } else if (item.kind === MAP_OBJECT_KIND.MONITORING_POINT) {
  238. const status = item.status || 'UNKNOWN'
  239. businessCounts[status] = (businessCounts[status] || 0) + 1
  240. }
  241. }
  242. return {
  243. available: distribution.available,
  244. businessCounts: distribution.available && Object.keys(businessCounts).length === 0
  245. ? distribution.businessCounts || {}
  246. : businessCounts,
  247. attachmentCounts: distribution.available && attachmentCounts.UNASSOCIATED === 0
  248. ? distribution.attachmentCounts || {}
  249. : attachmentCounts,
  250. }
  251. })
  252. /**
  253. * 票 15:新到的一批对象 → 补上已缓存的推送状态。
  254. *
  255. * 只在**确实有缓存**时做一遍替换(没有缓存就直接返回原状态对象,
  256. * 避免每次翻页都白造一个新对象引发无谓重渲染)。
  257. * @param {object} state 装载器刚合并出的状态
  258. */
  259. function withPushedStatus(state) {
  260. if (!Object.keys(pushedPointStates).length) return state
  261. const result = applyStatusToObjects(state.objects || [], pushedPointStates)
  262. if (!result.changed.length) return state
  263. return { ...state, objects: result.objects }
  264. }
  265. /**
  266. * 票 07:坐标准入总览(后端全量口径 + 前端已加载口径 + 不上图清单)。
  267. * 组件只读这一个对象,不自己算坐标 —— 判定归 `domain/coordinate.js`。
  268. *
  269. * 票 24/68:设施与管线也并进同一份总览。它们不是监测点、**不进**本 store 的对象序列
  270. * (两套统计口径不得混算),但「哪些东西没上图」是跨类的,所以这里**只读**两个图层
  271. * 折算出的准入结果并进总览;`blocked` 与 `blockedEntries` 仍由同一个对象产出,
  272. * 徽标与清单因此保持自洽。
  273. *
  274. * 设施点(票 68)与管线/构件是两份附加项(各自有独立的筛选条件),合并只做加法:
  275. * 管线一份照旧、设施点一份新增,两边都按各自当前筛选裁剪。
  276. */
  277. const coordinate = computed(() => coordinateOverview(
  278. summary.value.objects,
  279. summary.value.coordinateSummary,
  280. mergeCoordinateExtras(
  281. facilityCoordinateExtra(usePipeNetworkStore().filtered),
  282. facilityPointCoordinateExtra(useFacilityPointStore().filtered),
  283. ),
  284. ))
  285. /**
  286. * 票 41:某个编码的坐标准入结论。**判定在 `domain/coordinate.js` 的 `locationVerdict()`**
  287. * (纯函数、可脱浏览器断言),store 只把已加载对象喂给它。
  288. * @param {string} code 对象编码
  289. */
  290. function locationVerdictOf(code) {
  291. return locationVerdict(summary.value.objects, code)
  292. }
  293. /** 已上图对象数;走查与浮标都读它。 */
  294. const loaded = computed(() => summary.value.loaded)
  295. /** 是否已经取不动了(完成或空):界面据此把「加载下一页」置灰。 */
  296. const settled = computed(() => MAP_SUMMARY_SETTLED_PHASES.includes(summary.value.phase))
  297. /** 票 18:当前贴在这次地图加载上的筛选条件(回显与排查读它)。 */
  298. const filters = computed(() => ({
  299. industry: loadOptions.industry || '',
  300. scope: loadOptions.scope || '',
  301. deviceType: loadOptions.deviceType || '',
  302. // 票 14:多值状态集合(数组;空 = 未筛)。
  303. pointStatus: Array.isArray(loadOptions.pointStatus) ? loadOptions.pointStatus.slice() : [],
  304. }))
  305. /**
  306. * 票 15:**就地**把推送来的点位状态并进已加载对象(验收第 4 条「不重建视图」)。
  307. *
  308. * 为什么在 store 而不是组件:地图对象是这一层的状态,改它就该在这儿改;组件只消费结果。
  309. * 只替换状态真的变了的那几条(未命中的元素**保持同一引用**),所以:
  310. * · 面板/列表的滚动位置、展开状态、选中对象都不受影响(它们不依赖这个数组的身份);
  311. * · 地图只需按 `changed` 重画那几个标记,不整批重建。
  312. * 坐标、名字、行业一概不动 —— 推送不带这些字段,凭空补就是编数据。
  313. *
  314. * **缓存住**(`pushedPointStates`):推送常早于分页快照到达,翻页时要用它给新对象补状态
  315. * (见 `withPushedStatus`)。缓存按"最新一条推送"覆盖,不做历史。
  316. * @param {object} pointStates `{ [pointCode]: { status } }`
  317. * @returns {Array<string>} 被改写状态的编码(空数组 = 没有任何变化)
  318. */
  319. function applyPointStates(pointStates) {
  320. const table = pointStates && typeof pointStates === 'object' ? pointStates : {}
  321. // 先记下来:即使当前没有对应对象(还没翻到那一页)也要记住,供后续翻页补写。
  322. pushedPointStates = { ...pushedPointStates, ...table }
  323. const current = summary.value
  324. const result = applyStatusToObjects(current.objects, table)
  325. if (!result.changed.length) return []
  326. summary.value = { ...current, objects: result.objects }
  327. return result.changed
  328. }
  329. /** 仅测试用:丢掉装载器与状态。 */
  330. function reset() {
  331. // 先作废在跑的装载链(票 14 的纪元纪律),再清状态。
  332. loadEpoch += 1
  333. loader = null
  334. summary.value = emptyMapSummary({ pageSize: MAP_SUMMARY_PAGE_SIZE })
  335. busy.value = false
  336. loadOptions = {}
  337. pushedPointStates = {}
  338. }
  339. /**
  340. * 票 42:本快照**已取过的页**(全量,不受合并态护栏截断影响)里是否出现过该对象。
  341. * 只回答「取到过没有」;「可不可上图」仍归 `locationVerdict()`。
  342. */
  343. function hasSnapshotObject(kind, code) {
  344. return loader ? loader.hasObject(kind, code) : false
  345. }
  346. return { summary, view, statusDistribution, statusAxes, coordinate, locationVerdict: locationVerdictOf, loaded, busy, settled, refreshing, filters, hasSnapshotObject, setup, advance, refresh, applyFilters, applyPointStates, reset }
  347. })