| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497 |
- /**
- * 运行监测模式的面板状态(票 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<object>} 取完后的全部分区
- */
- 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<object>} 台账分区
- */
- 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<object>} 该点位的徽标状态
- */
- 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,
- }
- })
|