/** * 地图摘要状态(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} 重载后的装载器状态 */ async function applyFilters(filters = {}) { setup(filters) return refresh() } /** * 取下一页(票 08 验收第 1 条:一批一批地增)。完成/空之后是空操作。 * **单步**:调用它会作废正在跑的自动链(票 14;DEV 把手/走查逐页驱动用)。 * * 票 15:新到的对象要**补上已经收到的推送状态**。真实时序里推送常常早于(或夹在) * 分页快照之间 —— 只把推送写在"当时已加载"的对象上,随后翻到的那个点位就会显示 * 快照里的旧状态(本票在真机上抓到的 bug:MP-001 已推成 OFFLINE,翻页后却仍是 NORMAL)。 * @returns {Promise} 取完后的装载器状态 */ async function advance() { setup(loadOptions) return runLoadChain('next') } /** * 刷新:换到后端的新快照,从第 1 页重新加载(票 08 验收第 3 条的另一半), * 并**自动连续分批加载到完成**(票 14;决策 02 的 Q24——进入系统/筛选变化/ * 手动刷新都走这条链)。快照已失效时也走这条路径——后端会开一份新快照。 * * 票 15:刷新是**显式重新取数**,所以丢弃缓存的推送状态,让这一份快照说了算 * (否则一次较旧的推送会盖掉刚取回的新状态 —— 那是"用旧值覆盖新值",正好违反本票纪律)。 * @returns {Promise} 取完后的装载器状态 */ 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} 被改写状态的编码(空数组 = 没有任何变化) */ 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 } })