| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380 |
- /**
- * 地图摘要状态(ADR 0004 的 `mapStore`):分页快照、已加载点位、未关联设备、渐进加载状态。
- *
- * 职责边界(票 08):
- * · 分页与快照语义**不在本文件**——它们在 `domain/mapSummary.js` 的装载器里(纯函数 + 注入取数),
- * 所以脱浏览器的逻辑脚本能完整演示六态(见 verify/t08-map-summary.mjs);
- * · 本文件只做两件事:把「取一页」接到 `services/mapApi.js`,把装载器状态转成 Vue 响应式,
- * 供外壳与地图浮标渲染;
- * · 组件与页面**不出现** `page` / `pageSize` / `snapshotAt`(票 08 验收第 2 条:这些参数由适配层管理)。
- *
- * 「地图先显示框架,再按分页批次增加摘要点位」的落点是:装载器初始态是 `idle`(= 框架已就绪、
- * 等待分页数据),宿主先渲染,`advance()` 每成功一页才把点位并进 `objects`(票 08 验收第 1 条)。
- *
- * ── 票 14:自动分批加载(决策 02 的 Q24/Q25)──
- *
- * 「进入系统/筛选变化后自动连续分批加载到完成(批次间留帧间隔)」:
- * · `refresh()` 现在是一条**装载链**:换新快照取第 1 页 → 自动连续 `loadNext` 直到
- * settled(完成/空)或失败/失效——失败**不做无限自动重试**(决策 04:恢复靠周期刷新与手动入口);
- * · 链有**纪元号**(`loadEpoch`):新的 reload(换筛选/再刷新)作废旧链,旧链的迟到写入
- * 按纪元号整批丢弃(与装载器自身的 token 纪律同构,两层各管各的);
- * · `advance()` 仍是**单步**翻页(DEV 把手/走查用):调用它会作废自动链;
- * · 走查兼容:`window.__COCKPIT_MAP_MANUAL_PAGES__ = true` 关闭自动链(老走查逐页驱动)。
- */
- import { computed, ref, shallowRef } from 'vue'
- import { defineStore } from 'pinia'
- import {
- MAP_SUMMARY_PHASE,
- MAP_SUMMARY_SETTLED_PHASES,
- MAP_OBJECT_KIND,
- createMapSummaryLoader,
- emptyMapSummary,
- summaryView,
- } from '../domain/mapSummary.js'
- import { locationVerdict, coordinateOverview, facilityCoordinateExtra, mergeCoordinateExtras } from '../domain/coordinate.js'
- import { facilityPointCoordinateExtra } from '../domain/facilityPoints.js'
- import { usePipeNetworkStore } from './pipeNetworkStore.js'
- import { useFacilityPointStore } from './facilityPointStore.js'
- import { applyPointStates as applyStatusToObjects } from '../domain/statusPush.js'
- import { fetchMapSnapshot } from '../services/mapApi.js'
- /** 演示与走查用的页大小:后端默认 500,这里用 2 才能在几页之内演示渐进加载。 */
- export const MAP_SUMMARY_PAGE_SIZE = 2
- /** 自动分批的批间间隔(毫秒)。「批次间留帧间隔防卡顿」(决策 02 的 Q24)。 */
- export const AUTO_BATCH_GAP_MS = 120
- /** 走查兼容开关:置 true 后 refresh 不自动连翻(老走查用 DEV 把手逐页驱动)。 */
- export const MANUAL_PAGES_KEY = '__COCKPIT_MAP_MANUAL_PAGES__'
- /** 批间等待(可注入:逻辑闸门传即刻 resolve 的假等待)。 */
- let batchWait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
- /** 仅测试用:替换批间等待(走假时钟/零延迟的确定性断言)。 */
- export function setBatchWaitForTest(fn) {
- batchWait = typeof fn === 'function' ? fn : (ms) => new Promise((resolve) => setTimeout(resolve, ms))
- }
- function manualPagesMode() {
- return typeof globalThis !== 'undefined' && globalThis[MANUAL_PAGES_KEY] === true
- }
- /** 该阶段是否要继续自动装载(完成/空/失败/失效都停)。 */
- function autoStops(phase) {
- return MAP_SUMMARY_SETTLED_PHASES.includes(phase) || phase === MAP_SUMMARY_PHASE.FAILED || phase === MAP_SUMMARY_PHASE.EXPIRED
- }
- /**
- * 把装载器的查询参数接到领域接口,并把**分区块状结果**翻成装载器要的 `{ ok, data, reason }`。
- *
- * 为什么要有这一步翻译(票 08 踩到的第一坑):`fetchMapSnapshot` 回的是票 05 的 `section()`
- * (`{ state, reason, mode, data }`,**没有 `ok` 字段**),而装载器吃的是「一次取数的成败」。
- * 直接把它塞进装载器会恒判失败。两者都是对的,只是词汇不同,所以翻译**只在这一处**:
- * · `success` → 成功,带上领域批次;
- * · `empty` → 也算成功:后端确实说没数据,装载器据此收 `empty` 态;
- * · 其余(失败/不可用/过期)→ 失败,原因原样带给装载器(`snapshot-expired` 会被它认出来)。
- *
- * 票 18:**全局筛选(行业/范围)在这里贴上每一次请求**。它必须进查询串而不是只记在 store,
- * 否则后端不会过滤、地图也不会回 `mapFilter` 回显(票 25 第 3 节冻结的路径)。
- * 票 14:**多值 `pointStatus` 同样贴上每一次请求**(决策 02 冻结的契约:状态筛选作用到一张图;
- * 空集合 → `statusFilterQuery` 回空串=不传参)。
- */
- function fetchMapSummaryPage(query, options) {
- return fetchMapSnapshot({
- page: query.page,
- pageSize: query.pageSize,
- snapshotAt: query.snapshotAt,
- industry: options.industry,
- scope: options.scope,
- deviceType: options.deviceType,
- pointStatus: options.pointStatus,
- config: options.config,
- fetchImpl: options.fetchImpl,
- }).then((result) => {
- if (result.state === 'success' || result.state === 'empty') {
- return { ok: true, data: result.data || { objects: [], complete: true, total: 0 }, mode: result.mode }
- }
- return { ok: false, reason: result.reason || 'unavailable', mode: result.mode }
- })
- }
- export const useMapStore = defineStore('mapStore', () => {
- /** 装载器状态(整体替换,不做深响应:点位数可能上千,深响应会白烧性能)。 */
- const summary = shallowRef(emptyMapSummary({ pageSize: MAP_SUMMARY_PAGE_SIZE }))
- /** 装载链进行中(界面据此置灰 🔄 手动刷新,避免连点重复发请求)。 */
- const busy = ref(false)
- /**
- * 票 15:已收到的推送点位状态(`{ [pointCode]: {...} }`)。
- * 翻页时用它给**新到的对象**补状态 —— 推送常早于快照到达,只在"当时已加载"的对象上写
- * 会让后翻到的点位显示快照里的旧状态(真机上抓到的 bug)。
- */
- let pushedPointStates = {}
- let loader = null
- let loadOptions = {}
- /**
- * 票 42:是否正有一条约「换新快照」的刷新链在跑(从发起到 settled)。
- * 外壳据此在**发起瞬间**撤下旧关联设备图层(规格 14:先撤下,不等首页返回)。
- */
- const refreshing = ref(false)
- /**
- * 装载纪元(票 14 的自动链纪律):每次新的 reload/单步取数都作废旧链,
- * 旧链的迟到写入按纪元号整批丢弃。装载器自身还有一层 token(同批加载的并发
- * 保护),两层各管各的:纪元管"哪条链有资格写 store",token 管"哪次请求有资格合并"。
- */
- let loadEpoch = 0
- /**
- * 装好装载器(幂等)。取数一律走 `services/mapApi.js`;单测/走查可注入 `fetchImpl`。
- *
- * 票 18:注入项**合并**而不是整体替换。原因:本函数有多个调用点
- * (`MapCanvas` 挂载、`detailStore.loadSharedSections`、本 store 的 `applyFilters`),
- * 整体替换会让后到的调用把先到的筛选条件悄悄抹掉 —— 那正是"筛了但没生效"的经典来源。
- * @param {object} [options] `{ config, fetchImpl, industry, scope, deviceType }`
- */
- function setup(options = {}) {
- loadOptions = { ...loadOptions, ...options }
- if (!loader) {
- loader = createMapSummaryLoader({
- pageSize: MAP_SUMMARY_PAGE_SIZE,
- fetchPage: (query) => fetchMapSummaryPage(query, loadOptions),
- })
- summary.value = loader.getState()
- }
- return loader
- }
- /**
- * 装载链(票 14):一次取数 + (refresh 链)自动连续分批到 settled。
- *
- * 纪元纪律:进入即 `++loadEpoch` 作废其它链;每一步写 store 前都核对纪元,
- * 被换筛选/再刷新打断的旧链**整批丢弃**(迟到响应不得落进新范围)。
- * 失败/快照失效即停——失败不做无限自动重试(决策 04),恢复靠新鲜度周期与手动刷新。
- * @param {'refresh'|'next'} kind refresh = 换新快照第 1 页并自动连翻;next = 单步翻页
- */
- async function runLoadChain(kind) {
- const mine = ++loadEpoch
- busy.value = true
- // 票 42:刷新链从发起那一刻起就是「换新快照」——外壳立即撤旧层,不等首页返回。
- if (kind === 'refresh') refreshing.value = true
- try {
- let state = kind === 'refresh' ? await loader.refresh() : await loader.loadNext()
- if (mine !== loadEpoch) return summary.value
- summary.value = withPushedStatus(state)
- // 自动分批:只挂在 refresh 链上(进入系统/筛选变化/手动刷新都要"加载到完成")。
- while (kind === 'refresh' && mine === loadEpoch && !autoStops(summary.value.phase)) {
- if (manualPagesMode()) break
- await batchWait(AUTO_BATCH_GAP_MS)
- if (mine !== loadEpoch) break
- state = await loader.loadNext()
- if (mine !== loadEpoch) break
- summary.value = withPushedStatus(state)
- }
- } finally {
- if (mine === loadEpoch) {
- busy.value = false
- // 票 42:当前链收口即熄灭刷新信号(被更新链接管时由其自身收口)。
- refreshing.value = false
- }
- }
- return summary.value
- }
- /**
- * 票 18:全局筛选(行业/范围/状态)变化 → 记下新条件并**从第 1 页换新快照重载**。
- *
- * 为什么是重载而不是"把新结果追加进去"(Q9 裁定):同一张地图上混着两个筛选范围的对象,
- * 比"丢掉了已翻的页"更坏 —— 界面会说"当前行业:燃气"而图上还留着供水与排水的点,
- * 那是一条查不出来的假象。快照一致性本来就要求一次加载只对应一份数据(票 08),
- * 筛选取值变了就是另一份数据。
- *
- * @param {object} filters `{ industry, scope, deviceType, pointStatus }`
- * @returns {Promise<object>} 重载后的装载器状态
- */
- async function applyFilters(filters = {}) {
- setup(filters)
- return refresh()
- }
- /**
- * 取下一页(票 08 验收第 1 条:一批一批地增)。完成/空之后是空操作。
- * **单步**:调用它会作废正在跑的自动链(票 14;DEV 把手/走查逐页驱动用)。
- *
- * 票 15:新到的对象要**补上已经收到的推送状态**。真实时序里推送常常早于(或夹在)
- * 分页快照之间 —— 只把推送写在"当时已加载"的对象上,随后翻到的那个点位就会显示
- * 快照里的旧状态(本票在真机上抓到的 bug:MP-001 已推成 OFFLINE,翻页后却仍是 NORMAL)。
- * @returns {Promise<object>} 取完后的装载器状态
- */
- async function advance() {
- setup(loadOptions)
- return runLoadChain('next')
- }
- /**
- * 刷新:换到后端的新快照,从第 1 页重新加载(票 08 验收第 3 条的另一半),
- * 并**自动连续分批加载到完成**(票 14;决策 02 的 Q24——进入系统/筛选变化/
- * 手动刷新都走这条链)。快照已失效时也走这条路径——后端会开一份新快照。
- *
- * 票 15:刷新是**显式重新取数**,所以丢弃缓存的推送状态,让这一份快照说了算
- * (否则一次较旧的推送会盖掉刚取回的新状态 —— 那是"用旧值覆盖新值",正好违反本票纪律)。
- * @returns {Promise<object>} 取完后的装载器状态
- */
- async function refresh() {
- setup(loadOptions)
- pushedPointStates = {}
- return runLoadChain('refresh')
- }
- /** 界面用的文案与色键(`domain/mapSummary.js` 的 `summaryView`)。 */
- const view = computed(() => summaryView(summary.value))
- /**
- * 票 14:八桶全局计数(`mapSummary` 的 `statusDistribution`)。
- * 顶部状态栏与监测分布饼图**共用这一份**(决策 02:全局未筛全集,筛选时不变);
- * `available:false` = 后端还没发布这一段,界面如实降级、不补 0。
- */
- const statusDistribution = computed(() => summary.value.statusDistribution || { available: false, counts: {}, businessCounts: {}, attachmentCounts: {} })
- /**
- * 两条统计轴:业务状态只数监测点;附加状态单独数未关联设备。
- * 不从 `counts` 里二次猜测或把两类对象相加——对象来源已由后端发布,
- * 这里仅按 kind 分桶,保证顶部图例能同时显示「报警 1」与「预警 3 / 工单 1 / 离线 3」的并行事实。
- */
- const statusAxes = computed(() => {
- const distribution = statusDistribution.value
- const objects = summary.value.objects || []
- const businessCounts = {}
- const attachmentCounts = { UNASSOCIATED: 0 }
- for (const item of objects) {
- if (!item || !item.kind) continue
- if (item.kind === MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT) {
- // 未关联设备同时属于附加轴和业务轴:deviceStatus 仍必须计入业务状态。
- attachmentCounts.UNASSOCIATED += 1
- const status = item.status || 'UNKNOWN'
- businessCounts[status] = (businessCounts[status] || 0) + 1
- } else if (item.kind === MAP_OBJECT_KIND.MONITORING_POINT) {
- const status = item.status || 'UNKNOWN'
- businessCounts[status] = (businessCounts[status] || 0) + 1
- }
- }
- return {
- available: distribution.available,
- businessCounts: distribution.available && Object.keys(businessCounts).length === 0
- ? distribution.businessCounts || {}
- : businessCounts,
- attachmentCounts: distribution.available && attachmentCounts.UNASSOCIATED === 0
- ? distribution.attachmentCounts || {}
- : attachmentCounts,
- }
- })
- /**
- * 票 15:新到的一批对象 → 补上已缓存的推送状态。
- *
- * 只在**确实有缓存**时做一遍替换(没有缓存就直接返回原状态对象,
- * 避免每次翻页都白造一个新对象引发无谓重渲染)。
- * @param {object} state 装载器刚合并出的状态
- */
- function withPushedStatus(state) {
- if (!Object.keys(pushedPointStates).length) return state
- const result = applyStatusToObjects(state.objects || [], pushedPointStates)
- if (!result.changed.length) return state
- return { ...state, objects: result.objects }
- }
- /**
- * 票 07:坐标准入总览(后端全量口径 + 前端已加载口径 + 不上图清单)。
- * 组件只读这一个对象,不自己算坐标 —— 判定归 `domain/coordinate.js`。
- *
- * 票 24/68:设施与管线也并进同一份总览。它们不是监测点、**不进**本 store 的对象序列
- * (两套统计口径不得混算),但「哪些东西没上图」是跨类的,所以这里**只读**两个图层
- * 折算出的准入结果并进总览;`blocked` 与 `blockedEntries` 仍由同一个对象产出,
- * 徽标与清单因此保持自洽。
- *
- * 设施点(票 68)与管线/构件是两份附加项(各自有独立的筛选条件),合并只做加法:
- * 管线一份照旧、设施点一份新增,两边都按各自当前筛选裁剪。
- */
- const coordinate = computed(() => coordinateOverview(
- summary.value.objects,
- summary.value.coordinateSummary,
- mergeCoordinateExtras(
- facilityCoordinateExtra(usePipeNetworkStore().filtered),
- facilityPointCoordinateExtra(useFacilityPointStore().filtered),
- ),
- ))
- /**
- * 票 41:某个编码的坐标准入结论。**判定在 `domain/coordinate.js` 的 `locationVerdict()`**
- * (纯函数、可脱浏览器断言),store 只把已加载对象喂给它。
- * @param {string} code 对象编码
- */
- function locationVerdictOf(code) {
- return locationVerdict(summary.value.objects, code)
- }
- /** 已上图对象数;走查与浮标都读它。 */
- const loaded = computed(() => summary.value.loaded)
- /** 是否已经取不动了(完成或空):界面据此把「加载下一页」置灰。 */
- const settled = computed(() => MAP_SUMMARY_SETTLED_PHASES.includes(summary.value.phase))
- /** 票 18:当前贴在这次地图加载上的筛选条件(回显与排查读它)。 */
- const filters = computed(() => ({
- industry: loadOptions.industry || '',
- scope: loadOptions.scope || '',
- deviceType: loadOptions.deviceType || '',
- // 票 14:多值状态集合(数组;空 = 未筛)。
- pointStatus: Array.isArray(loadOptions.pointStatus) ? loadOptions.pointStatus.slice() : [],
- }))
- /**
- * 票 15:**就地**把推送来的点位状态并进已加载对象(验收第 4 条「不重建视图」)。
- *
- * 为什么在 store 而不是组件:地图对象是这一层的状态,改它就该在这儿改;组件只消费结果。
- * 只替换状态真的变了的那几条(未命中的元素**保持同一引用**),所以:
- * · 面板/列表的滚动位置、展开状态、选中对象都不受影响(它们不依赖这个数组的身份);
- * · 地图只需按 `changed` 重画那几个标记,不整批重建。
- * 坐标、名字、行业一概不动 —— 推送不带这些字段,凭空补就是编数据。
- *
- * **缓存住**(`pushedPointStates`):推送常早于分页快照到达,翻页时要用它给新对象补状态
- * (见 `withPushedStatus`)。缓存按"最新一条推送"覆盖,不做历史。
- * @param {object} pointStates `{ [pointCode]: { status } }`
- * @returns {Array<string>} 被改写状态的编码(空数组 = 没有任何变化)
- */
- function applyPointStates(pointStates) {
- const table = pointStates && typeof pointStates === 'object' ? pointStates : {}
- // 先记下来:即使当前没有对应对象(还没翻到那一页)也要记住,供后续翻页补写。
- pushedPointStates = { ...pushedPointStates, ...table }
- const current = summary.value
- const result = applyStatusToObjects(current.objects, table)
- if (!result.changed.length) return []
- summary.value = { ...current, objects: result.objects }
- return result.changed
- }
- /** 仅测试用:丢掉装载器与状态。 */
- function reset() {
- // 先作废在跑的装载链(票 14 的纪元纪律),再清状态。
- loadEpoch += 1
- loader = null
- summary.value = emptyMapSummary({ pageSize: MAP_SUMMARY_PAGE_SIZE })
- busy.value = false
- loadOptions = {}
- pushedPointStates = {}
- }
- /**
- * 票 42:本快照**已取过的页**(全量,不受合并态护栏截断影响)里是否出现过该对象。
- * 只回答「取到过没有」;「可不可上图」仍归 `locationVerdict()`。
- */
- function hasSnapshotObject(kind, code) {
- return loader ? loader.hasObject(kind, code) : false
- }
- return { summary, view, statusDistribution, statusAxes, coordinate, locationVerdict: locationVerdictOf, loaded, busy, settled, refreshing, filters, hasSnapshotObject, setup, advance, refresh, applyFilters, applyPointStates, reset }
- })
|