/** * 运行监测模式的面板状态(票 18)。 * * 职责边界,沿用票 05/11/17 已定的分工: * · 本 store 只做四件事:调 `services/monitoringApi.js` 取三块面板、把**每次取数各自收成 * 一个分区**、按端点的 `pagination` 渐进加载台账、按需补拉台账卡片的设备徽标; * · 面板怎么画归 `domain/monitoringPanels.js`(纯函数,本文件不做任何字段改名); * · 路径拼接、凭据、mock/real 切换归 `services/`(本文件不出现 fetch、不出现后端裸字段名)。 * * **三块各自成败**(一块端点坏了不牵连其它两块):状态是 `{ [panelId]: section() }`, * 与 `infrastructureStore` 同款。 * * ── 四处本票特有的收口,都写在对应函数上 ── * ① 台账与监测分布**共用一次取数**(Q4 裁定):`loadPanels` 里 `/monitoring/distribution` * 只请求一次,结果同时喂给两个分区 —— 两块各请求一次会让它们各持一份快照、分页进度还会打架。 * ② 台账**渐进加载**:`loadMoreLedger()` 带第 1 页锁定的 `snapshotAt` 请求下一页 * (后端 `page > 1` 必须带快照,否则 400),并把新一页的点位**追加**进台账分区。 * ③ 卡片**徽标 N+1**(Q10 裁定):`loadLedgerBadge(pointCode)` 逐点位取详情里的设备计数; * 卡片进视口才调(Q14 裁定),**一台失败不影响其它卡片**。 * ④ **异常设备定位**:复用票 17 立的那条共享接缝(`domain/mapLocate.js`), * 不新写一套判定。 */ import { computed, reactive, ref } from 'vue' import { defineStore } from 'pinia' import { SECTION_STATE, section } from '../domain/sectionState.js' import { REQUEST_REASON } from '../domain/requestReason.js' import { MONITORING_PANEL_IDS, shouldLoadBadge } from '../domain/monitoringPanels.js' // 票 62:台账自动渐进装载链(与 mapStore 的自动链同款纪律,实现独立)。 import { createLedgerLoader, ledgerLoading, ledgerView } from '../domain/ledgerLoader.js' // 票 37:类型码的归一收口(数据值无静态白名单,认不出回空串=不筛)。 import { normalizeDeviceType } from '../domain/filterOptions.js' import { LOCATE_RESULT, locateFailureText, planLocate } from '../domain/mapLocate.js' import { TERRAIN_LOCATE_REASON } from '../domain/pointLocate.js' import { fetchMonitoringDistribution, fetchMonitoringDistributionBatch, monitoringDistributionInputs, fetchMonitoringOverview, fetchMonitoringSituation, } from '../services/monitoringApi.js' import { fetchMonitoringPointDetail } from '../services/detailApi.js' import { getCameraState, locateMonitoringPoint } from '../services/mapService.js' import { useDetailStore } from './detailStore.js' import { useMapStore } from './mapStore.js' import { sameInputs } from '../domain/panelViewCache.js' // 票 41:监测点选择的统一入口(与地图点选共用同一个函数)。 import { selectMonitoringPoint } from '../pointSelection.js' /** 未取数时的分区(与 `infrastructureStore.pending()` 同形,`loaded:false` 表示"还没请求过")。 */ function pending() { return { state: SECTION_STATE.EMPTY, reason: '', mode: '', data: null, missing: [], freshness: '', retained: false, loaded: false } } /** 该把哪个 panelId 喂给哪个取数函数 —— 台账**不在**这里(它与分布共用一次取数)。 */ const FETCHERS = { monitoringOverview: fetchMonitoringOverview, monitoringDistribution: fetchMonitoringDistributionBatch, operationOverview: fetchMonitoringSituation, } /** 需要独立请求的面板(台账走分布的响应,不单独请求)。 */ const INDEPENDENT_PANELS = ['monitoringOverview', 'monitoringDistribution', 'operationOverview'] /** 运行概况的时间窗口(Q8 裁定:面板内给 24 小时 / 7 天;显式区间必须成对传)。 */ export const TREND_RANGES = [ { key: '24h', label: '24 小时', hours: 24 }, { key: '7d', label: '7 天', hours: 24 * 7 }, ] /** 一个窗口键 → `{ startTime, endTime }`(ISO-8601 带偏移;不成对会被后端 400 拒掉)。 */ export function rangeWindow(key, now = new Date()) { const found = TREND_RANGES.find((item) => item.key === key) if (!found) return {} const end = now const start = new Date(end.getTime() - found.hours * 3600 * 1000) // 后端要带偏移量的 ISO 串;`toISOString()` 给的是 UTC(Z),语义等价、可被 `OffsetDateTime.parse` 吃下。 return { startTime: start.toISOString(), endTime: end.toISOString() } } export const useMonitoringStore = defineStore('monitoringStore', () => { /** 四块面板各自的分区状态(台账的分区由分布的响应喂)。 */ const sections = ref(Object.fromEntries(MONITORING_PANEL_IDS.map((panelId) => [panelId, pending()]))) /** 正在取数(界面只做轻提示;分区成败仍由各分区自己说)。 */ const loading = ref(false) /** 台账的卡片徽标(Q10/Q14:逐点位按需取,各自成败)。 */ const badges = ref({}) /** 台账正在补拉徽标的点位集合(避免同一张卡滚动时反复发请求)。 */ const badgeInFlight = ref({}) /** 台账正在加载下一页(保留导出:界面与闸门读「是否还有一批在途」)。 */ const ledgerBusy = ref(false) /** * 台账装载链的合并态(票 62):`{ phase, snapshot, page, nextPage, total, loaded, points, equipment, reason }`。 * 台账分区(`sections.monitoringPointLedger`)由它派生 —— 分区仍是界面读的那一份, * 装载链是它的数据来源,两者不再各持一套进度。 */ const ledger = reactive({ state: null }) /** 当前生效的行业筛选(**前端键**;到后端码的映射归 services)。 */ const industry = ref('') /** 当前生效的设备类型码(票 37;空串=全部类型)。与行业同层的全局条件回显。 */ const deviceType = ref('') /** 当前生效的点位状态筛选(**八桶集合**,票 14 多值;空数组=全部)。 */ const pointStatus = ref([]) /** 当前生效的范围筛选(**前端键**;到后端 district 的映射归领域词典)。 */ const scope = ref('') /** 运行概况的窗口(主键,见 `TREND_RANGES`)。 */ const trendRange = ref('24h') /** 最近一次定位的结果(界面据此给一行反馈,也是走查的断言面)。 */ const locate = ref({ result: '', pointCode: '', reasonText: '' }) /** 请求序号:换筛选/重取时旧的在途响应作废。 */ let token = 0 /** 台账装载纪元(票 62):每次新链(首载/换筛选/手动刷新)作废旧链,迟到写入整批丢弃。 */ let ledgerEpoch = 0 /** 台账装载器(幂等;`setup` 时按当前条件建一次)。 */ let ledgerLoader = null /** loader内部集合所属的有效请求输入;新轮/漂移时清空,reuse不能复活旧页。 */ let ledgerInputs = null /** 自动链两批之间的间隔(票 62:给渲染与交互留一口气,不一次打满请求)。 */ const LEDGER_AUTO_BATCH_GAP_MS = 16 /** 请求注入(config / fetchImpl):取一次让三块共用,单测与走查都靠它。 */ let requestOptions = {} function configure(options = {}) { requestOptions = { ...requestOptions, ...options } } /** 直接写入一个分区(走查与单测注入用)。 */ function setSection(panelId, value) { sections.value = { ...sections.value, [panelId]: { ...value, loaded: true } } } /** 当前请求条件(筛选 + 真实配置)。 */ function activeOptions() { return { ...requestOptions, industry: industry.value, deviceType: deviceType.value, pointStatus: pointStatus.value, scope: scope.value, } } /** * 取三块面板。**并发取、各自成败**:一块失败不回滚其它已成功分区。 * * 台账与监测分布**共用** `/monitoring/distribution` 的这一次响应(Q4 裁定): * 分布分区拿聚合与分页,台账分区拿**同一份点位序列** —— 两块的分页进度因此 * 天然一致,不会出现"分布说还有下一页、台账却说到底了"。 * * 行业/范围/状态/类型(票 37)都是全局条件,空值=不传参(全部)。 * `deviceType` 归一收在 `domain/filterOptions.js`(类型码是数据值,无静态白名单)。 * @param {object} [options] `{ industry, deviceType, pointStatus, scope, trendRange, config, fetchImpl }` * @returns {Promise} 取完后的全部分区 */ async function loadPanels(options = {}) { if (options.industry !== undefined) industry.value = options.industry || '' // 票 37:设备类型与行业/范围同层 —— undefined = 不动;null/空 = 清空; // 非法形状归一成空串(脏值不进入全局条件,也不换后端 400)。 if (options.deviceType !== undefined) deviceType.value = normalizeDeviceType(options.deviceType) // 票 14:pointStatus 多值集合;undefined = 不动,null/空 = 清空。 if (options.pointStatus !== undefined) { pointStatus.value = Array.isArray(options.pointStatus) ? options.pointStatus.slice() : [] } if (options.scope !== undefined) scope.value = options.scope || '' if (options.trendRange !== undefined && TREND_RANGES.some((item) => item.key === options.trendRange)) { trendRange.value = options.trendRange } if (Object.keys(options).some((key) => !['industry', 'deviceType', 'pointStatus', 'scope', 'trendRange'].includes(key))) { configure(options) } const active = activeOptions() const firstPageInputs = monitoringDistributionInputs(active) // 新一轮开始就作废旧台账写入/下一页;不能等三块面板返回后才换纪元。 const initialLedgerEpoch = ++ledgerEpoch ledgerInputs = null let firstPage const window = rangeWindow(trendRange.value) token += 1 const mine = token loading.value = true const results = await Promise.all( INDEPENDENT_PANELS.map(async (panelId) => { try { const result = await FETCHERS[panelId]({ ...active, ...(panelId === 'operationOverview' ? window : {}) }) if (panelId === 'monitoringDistribution') { firstPage = result.firstPage return [panelId, result.section] } return [panelId, result] } catch (error) { // 与票 11/17 同款兜底:适配层不抛,但转换层万一抛了要收成一条失败分区, // 不能把面板卡在「加载中」(用户还得能看见其它块)。 return [panelId, section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })] } }), ) // 迟到的响应整批丢弃:换筛选后旧数据不得落进新范围。 if (mine !== token) return sections.value const next = { ...sections.value } for (const [panelId, value] of results) next[panelId] = { ...value, loaded: true } // 换筛选就丢掉上一批徽标:留着会把 A 行业的设备数挂在 B 行业的点位上(同一条 token 纪律)。 badges.value = {} badgeInFlight.value = {} sections.value = next loading.value = false // 台账仍有独立自动链;同轮成功首批可复用,后续页仍按其快照token装载到完整。 const sameRequest = initialLedgerEpoch === ledgerEpoch && sameInputs(firstPageInputs, monitoringDistributionInputs({ ...activeOptions(), page: 1 })) const chain = loadLedgerChain('refresh', sameRequest ? firstPage : undefined) await chain return sections.value } // ============================================================ 票 62:台账自动渐进装载链 // // 四条纪律(票面 Acceptance 逐条落在这里): // ① **纪元**:每次新链 `++ledgerEpoch`,旧链的迟到写入整批丢弃(与 mapStore 同款); // ② 条件变化 → 新快照从第 1 页重来;定时刷新走 `reuseLedgerSnapshot`,**不重放全链**(票 33 教训); // ③ 第 2 页起带第 1 页锁定的 `snapshotAt`(后端硬要求,token 只认响应回显); // ④ 失败不回滚已装载内容(`domain/ledgerLoader.js` 保证),也不无限自动重试(决策 04)。 /** 建/复用装载器:`fetchPage` 每次现取条件,筛选变了也不必重建。 */ function ensureLedgerLoader() { if (ledgerLoader) return ledgerLoader ledgerLoader = createLedgerLoader({ // 页大小交给后端默认值(不在前端硬编码第二份口径)。 fetchPage: (query) => fetchMonitoringDistribution({ ...activeOptions(), ...query, raw: true }), }) return ledgerLoader } /** 合并态 → 台账分区(界面读的还是同一个 `sections.monitoringPointLedger`)。 */ function syncLedgerSection(state) { ledger.state = state const view = ledgerView(state) const data = { // 兼容既有面板视图的字段名(`points`/`equipment`/`pagination` 是它读的那几个)。 points: state.points, equipment: state.equipment, snapshotAt: state.snapshot, // 页码分页要的三个数:`total` 连同分页视图一起由 `domain/ledgerLoader.js` 给 // (`totalPages` 也由它算——store 不另写一份 ceil 算术,条文 11 的单源纪律)。 pagination: { page: state.page, pageSize: state.pageSize, total: view.total, totalPages: view.pageCount, hasMore: false, }, loaded: view.loaded, ledgerPhase: state.phase, ledgerNote: view.pageNote, } const state_ = state.phase === 'failed' || state.phase === 'expired' ? SECTION_STATE.FAILED : view.loaded === 0 && state.phase === 'empty' ? SECTION_STATE.EMPTY : SECTION_STATE.SUCCESS sections.value = { ...sections.value, monitoringPointLedger: { ...section(state_, { data, reason: state.reason }), loaded: true, retained: view.loaded > 0 }, } ledgerBusy.value = ledgerLoading(state) } /** * 跑一条台账链。 * @param {'refresh'|'reuse'} kind `refresh`=换新快照并自动连翻到 settled; * `reuse`=还在同一快照有效期内,**只补没取完的部分**(定时刷新走这条,不重放全链) */ async function loadLedgerChain(kind = 'refresh', firstPage) { const loader = ensureLedgerLoader() const inputs = monitoringDistributionInputs({ ...activeOptions(), page: 1 }) const reuse = kind === 'reuse' && sameInputs(ledgerInputs, inputs) const mine = ++ledgerEpoch if (!reuse) ledgerInputs = null const stillCurrent = () => { if (mine !== ledgerEpoch) return false if (sameInputs(inputs, monitoringDistributionInputs({ ...activeOptions(), page: 1 }))) return true ledgerInputs = null return false } const started = reuse ? loader.getState() : await loader.refresh(firstPage) if (!stillCurrent()) return sections.value.monitoringPointLedger ledgerInputs = inputs syncLedgerSection(started) let state = started while (stillCurrent() && !ledgerSettledForAuto(state)) { await new Promise((resolve) => setTimeout(resolve, LEDGER_AUTO_BATCH_GAP_MS)) if (!stillCurrent()) break state = await loader.loadNext() if (!stillCurrent()) break syncLedgerSection(state) // 失败/过期不自动重试(决策 04):停链,等新鲜度周期或手动入口。 if (state.phase === 'failed' || state.phase === 'expired') break } return sections.value.monitoringPointLedger } /** 自动链的循环条件(settled 或失败都停)。 */ function ledgerSettledForAuto(state) { return ['complete', 'empty', 'failed', 'expired'].includes(state && state.phase) } /** * 票 62:定时刷新走这条 —— **不换快照、不重放全链**。 * * 票 33 的教训:30s tick 若每次重放全量自动链,会让整块面板周期性抖动(mapStore 那边 * 的直接后果是画面抽搐)。这里只做一件事:链路还没 settled 且没失败时,把剩下的批次补完; * 已 settled 的台账**一个请求都不发**。 */ async function reuseLedgerSnapshot() { if (!sameInputs(ledgerInputs, monitoringDistributionInputs({ ...activeOptions(), page: 1 }))) return loadLedgerChain('refresh') const state = ledger.state if (!state) return loadLedgerChain('refresh') if (ledgerSettledForAuto(state)) return sections.value.monitoringPointLedger return loadLedgerChain('reuse') } /** * 票 62:「加载下一页监测点」按钮已退役 —— 保留同名入口只为兼容既有调用点与走查脚本, * 行为改为「再推一批」(只在链路未 settled 时有效,settled 后是空操作)。 * @returns {Promise} 台账分区 */ async function loadMoreLedger() { const state = ledger.state if (!state) return sections.value.monitoringPointLedger if (!sameInputs(ledgerInputs, monitoringDistributionInputs({ ...activeOptions(), page: 1 }))) return loadLedgerChain('refresh') if (ledgerSettledForAuto(state)) return sections.value.monitoringPointLedger return loadLedgerChain('reuse') } /** * 补拉一个点位的设备徽标(Q10:走 N+1,Q14:卡片进视口才拉)。 * * 为什么不能从已有数据算:`/monitoring/distribution` 的 marker **不给设备编码列表** * (那是 `/map-snapshot` 的 `deviceCodes`),所以徽标只能问详情端点要设备计数 * (`domain/pointDetail.js` 已把它翻成 `statistics`,本文件只见这个领域字段)。 * **一台失败不影响其它卡片**:失败记成 `failed` 状态, * 界面显示占位符而不是 0(0 是"确实没有设备",与"没取到"是两件事)。 * @param {string} pointCode 点位编码 * @returns {Promise} 该点位的徽标状态 */ async function loadLedgerBadge(pointCode) { if (!shouldLoadBadge(badges.value, pointCode)) return badges.value[pointCode] if (badgeInFlight.value[pointCode]) return badges.value[pointCode] badgeInFlight.value = { ...badgeInFlight.value, [pointCode]: true } const active = activeOptions() let result = null try { result = await fetchMonitoringPointDetail(pointCode, active) } catch (error) { result = section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null }) } const statistics = (result.data && result.data.statistics) || null // 只有真拿到统计才算成功:`statistics.missing` 非空表示后端没给这一段, // 那时**不给 0**(见上)。 const given = result.state === SECTION_STATE.SUCCESS && statistics && (!statistics.missing || !statistics.missing.length) const entry = given ? { state: 'success', total: statistics.total, alarm: statistics.alarm, warning: statistics.warning, offline: statistics.offline } : { state: result.state === SECTION_STATE.SUCCESS ? 'missing' : 'failed', total: null, alarm: null, warning: null, offline: null } const nextInFlight = { ...badgeInFlight.value } delete nextInFlight[pointCode] badgeInFlight.value = nextInFlight badges.value = { ...badges.value, [pointCode]: entry } return entry } /** * 点台账卡片 / 异常设备 → 打开与地图**相同**的两级详情,并触发点位级定位。 * * **票 41 改写了本函数的语义**(原为「不移动相机」,见票 18 的 Q4):验收第 1、2 条要求 * 「地图点击和监测点台账选择使用同一监测点选择入口」且「有效点位坐标触发点位级完整视角定位」。 * 台账因此不再是不动相机的旁路 —— 它调 `pointSelection.selectMonitoringPoint`, * 与地图点选走**同一个**函数(不是"两处各自小心保持一致")。 * @param {string} pointCode 目标点位编码 */ async function openLedgerPoint(pointCode) { return selectMonitoringPoint(pointCode) } /** * 点运行概况里的异常设备 → 联动地图(复用票 17 立的那条共享接缝 ④)。 * * 三步,顺序有意为之:先判定能否定位(三种"点不动"分得开)→ 判定通过再动相机 * → 相机下发成功再打开两级详情。票 53:相机走**地形感知接缝** `locateMonitoringPoint` * (中心高程取采样地表,取不到就失败,不以椭球 0 冒充成功),按失败原因如实给说法 * (地图未就绪 / 坐标不可用 / 地形待确认),**不假装定位成功**。 * @param {string} code 目标编码(异常设备是设备维度,但未关联设备也以自身编码上图) * @param {object} [deps] 注入点(单测用):`{ objects, mapReady, flyPoint, openPoint }` */ async function locateCode(code, deps = {}) { const mapStore = useMapStore() const detailStore = useDetailStore() const objects = deps.objects || mapStore.summary.objects const mapReady = deps.mapReady === undefined ? !!getCameraState() : !!deps.mapReady const flyPoint = deps.flyPoint || locateMonitoringPoint const openPoint = deps.openPoint || ((value) => detailStore.openPoint(value)) const plan = planLocate({ objects, pointCode: code, mapReady }) locate.value = { result: plan.result, pointCode: plan.pointCode, reasonText: plan.reasonText } if (plan.result !== LOCATE_RESULT.OK) return locate.value // 默认按「地图未就绪」口径:接缝没给原因时不替用户猜成地形问题(票 53 验收第 3 条)。 let failureReason = TERRAIN_LOCATE_REASON.MAP const applied = await flyPoint(plan.object, { onFailure: (reason) => { failureReason = reason }, }) if (applied !== true) { locate.value = { result: LOCATE_RESULT.UNAVAILABLE, pointCode: plan.pointCode, reasonText: locateFailureText(failureReason), } return locate.value } // 未关联设备没有点位可摘要,直接进设备级(与票 10 从地图点设备同一条路)。 if (plan.object.kind === 'monitoring-point') await openPoint(plan.pointCode) else await detailStore.openDevice(plan.pointCode) return locate.value } /** 仅测试用:回到未加载状态。 */ function reset() { token += 1 ledgerEpoch += 1 ledgerLoader = null ledgerInputs = null ledger.state = null sections.value = Object.fromEntries(MONITORING_PANEL_IDS.map((panelId) => [panelId, pending()])) badges.value = {} badgeInFlight.value = {} loading.value = false ledgerBusy.value = false industry.value = '' deviceType.value = '' pointStatus.value = [] scope.value = '' trendRange.value = '24h' locate.value = { result: '', pointCode: '', reasonText: '' } } return { sections, loading, badges, badgeInFlight, ledgerBusy, industry, deviceType, pointStatus, scope, trendRange, locate, configure, setSection, loadPanels, loadMoreLedger, reuseLedgerSnapshot, loadLedgerChain, ledger, loadLedgerBadge, openLedgerPoint, locateCode, reset, } })