Prechádzať zdrojové kódy

refactor(cockpit): 复用运行监测成功首批并校验台账输入归属

三项有序算法优化第一项(111):成功完整首批只请求和转换一次,保留异常首批独立取数机会;输入漂移后的重试从新第一页开始,禁止复活旧loader状态。42项公开入口回归与旧台账组16项通过。测试和证据按仓库规则仅存本地。
ChenJZ 3 dní pred
rodič
commit
f197b893fa

+ 7 - 4
src/modules/cockpit/domain/ledgerLoader.js

@@ -194,7 +194,7 @@ export function createLedgerLoader(options = {}) {
     }
 
     /** 翻下一页。settled 后是空操作;失败后可以再次调用(重试**同一页**,不静默丢数据)。 */
-    async function loadNext() {
+    async function loadNext(firstPage) {
         if (!fetchPage) return readState()
         if (ledgerAutoStops(state)) return readState()
 
@@ -213,7 +213,9 @@ export function createLedgerLoader(options = {}) {
 
         let result = null
         try {
-            result = await fetchPage(query)
+            result = wanted === 1 && firstPage && firstPage.ok === true && firstPage.data && firstPage.data.page === 1
+                ? firstPage
+                : await fetchPage(query)
         } catch (error) {
             result = { ok: false, reason: 'unreachable', error: String((error && error.message) || error) }
         }
@@ -237,10 +239,11 @@ export function createLedgerLoader(options = {}) {
     }
 
     /** 换新快照:丢掉已装载集合,从第 1 页重来(下一次请求不带 `snapshotAt`)。 */
-    async function refresh() {
+    async function refresh(firstPage) {
         state = emptyLedgerState()
         token += 1
-        return loadNext()
+        // 可选成功首批只用于这次refresh;后续页照原token/失败纪律取数,不存跨轮缓存。
+        return loadNext(firstPage)
     }
 
     return { loadNext, refresh, getState: readState }

+ 67 - 14
src/modules/cockpit/services/monitoringApi.js

@@ -34,7 +34,9 @@ import {
 } from '../domain/monitoringPanels.js'
 import { industryQuery, districtQuery } from '../domain/filterOptions.js'
 import { buildQuery, fetchSection, isEmptyBy } from './domainRequest.js'
-import { requestCockpitJson } from './cockpitApi.js'
+import { requestCockpitJson, findMockTransport } from './cockpitApi.js'
+import { getCockpitConfig } from './cockpitConfig.js'
+import { SECTION_STATE } from '../domain/sectionState.js'
 
 /** 三个面板端点的路径(组件与 store 都不自己拼路径)。 */
 export const MONITORING_PATHS = {
@@ -122,9 +124,8 @@ export function fetchMonitoringOverview(options = {}) {
  * 「空」= 后端说这一屏既没有监测点、也没有未关联设备,且总数 0。
  * 分页窗口横跨两类对象(后端是一条总序列),所以判据要两类都看。
  */
-export function fetchMonitoringDistribution(options = {}) {
-    const path = MONITORING_PATHS.monitoringDistribution
-    const query = {
+function distributionQuery(options) {
+    return {
         industry: industryParam(options.industry),
         deviceType: options.deviceType,
         pointStatus: statusParam(options.pointStatus),
@@ -135,10 +136,19 @@ export function fetchMonitoringDistribution(options = {}) {
         // 票 62:第 2 页起必须带第 1 页锁定的快照(后端 `validatePagination` 硬要求)。
         snapshotAt: options.snapshotAt,
     }
+}
+
+export function fetchMonitoringDistribution(options = {}) {
+    const path = MONITORING_PATHS.monitoringDistribution
+    const query = distributionQuery(options)
     if (options.raw === true) return fetchDistributionRaw(path, query, options)
+    return fetchDistributionSection(path, query, options)
+}
+
+function fetchDistributionSection(path, query, options, toData = toMonitoringDistribution) {
     return fetchSection(path, {
         params: query,
-        toData: toMonitoringDistribution,
+        toData,
         isEmpty: isEmptyBy((payload) => {
             const points = Array.isArray(payload && payload.monitoringPoints) ? payload.monitoringPoints.length : 0
             const equipment = Array.isArray(payload && payload.unassociatedEquipment) ? payload.unassociatedEquipment.length : 0
@@ -151,6 +161,57 @@ export function fetchMonitoringDistribution(options = {}) {
     })
 }
 
+/** 同轮首批的实际请求输入快照;默认第一页与显式 page=1 等价,adapter 身份仍单独比较。 */
+export function monitoringDistributionInputs(options = {}) {
+    const config = options.config || getCockpitConfig()
+    const query = distributionQuery(options)
+    const path = MONITORING_PATHS.monitoringDistribution
+    const adapter = config.dataSource === 'real'
+        ? options.fetchImpl || (typeof fetch !== 'undefined' ? fetch : null)
+        : findMockTransport(path + buildQuery(query))
+    const page = query.page === undefined || query.page === null || query.page === '' ? 1 : query.page
+    return [options.config || null, options.config && options.config.api, config.dataSource, config.api && config.api.baseUrl, adapter, buildQuery({ ...query, page })]
+}
+
+/** 分布只投影一次;仅正常成功且回显第1页时供台账种子,其余保留原独立取数机会。 */
+export async function fetchMonitoringDistributionBatch(options = {}) {
+    let explicitPaging = false
+    const section = await fetchDistributionSection(MONITORING_PATHS.monitoringDistribution, distributionQuery(options), options, (payload) => {
+        // hasMore在领域默认值中会把缺失折成false;先保留是否明确提供,不能据此种入截断首批。
+        explicitPaging = !!(payload && payload.pagination && typeof payload.pagination.hasMore === 'boolean')
+        return toMonitoringDistribution(payload)
+    })
+    const data = section.data
+    const paging = data && data.pagination
+    const loaded = data ? data.points.length + data.equipment.length : 0
+    const validFirstPage = explicitPaging && paging && paging.page === 1 &&
+        Number.isInteger(paging.pageSize) && paging.pageSize > 0 &&
+        Number.isInteger(paging.total) && paging.total >= 0 &&
+        loaded === Math.min(paging.pageSize, paging.total) && !!data.snapshotAt &&
+        (paging.hasMore ? loaded < paging.total : loaded === paging.total)
+    const firstPage = section.state === SECTION_STATE.SUCCESS && validFirstPage
+        ? { ok: true, data: { ...ledgerPageData(data), points: copyLedgerRows(data.points), equipment: copyLedgerRows(data.equipment) } }
+        : undefined
+    return { section, firstPage }
+}
+
+// 两个公开分区仍各持可变DTO;复用转换结果而非重新解析,但不共享可写行/数组。
+function copyLedgerRows(rows) {
+    return rows.map((row) => ({ ...row, statusReasons: [...row.statusReasons], missing: [...row.missing] }))
+}
+
+function ledgerPageData(data) {
+    return {
+        snapshotAt: data.snapshotAt,
+        page: data.pagination.page,
+        pageSize: data.pagination.pageSize,
+        total: data.pagination.total,
+        hasMore: data.pagination.hasMore === true,
+        points: data.points,
+        equipment: data.equipment,
+    }
+}
+
 /**
  * 台账装载链的取一页(票 62):走同一个统一出口,但**不经过分区收口** ——
  * 装载链自己要判「成功/快照失效/失败」,把它收成 `{ ok, data, reason }` 交回去。
@@ -167,15 +228,7 @@ async function fetchDistributionRaw(path, params, options) {
     const data = toMonitoringDistribution(result.data)
     return {
         ok: true,
-        data: {
-            snapshotAt: data.snapshotAt,
-            page: data.pagination.page,
-            pageSize: data.pagination.pageSize,
-            total: data.pagination.total,
-            hasMore: data.pagination.hasMore === true,
-            points: data.points,
-            equipment: data.equipment,
-        },
+        data: ledgerPageData(data),
     }
 }
 

+ 42 - 12
src/modules/cockpit/stores/monitoringStore.js

@@ -33,6 +33,8 @@ import { LOCATE_RESULT, locateFailureText, planLocate } from '../domain/mapLocat
 import { TERRAIN_LOCATE_REASON } from '../domain/pointLocate.js'
 import {
     fetchMonitoringDistribution,
+    fetchMonitoringDistributionBatch,
+    monitoringDistributionInputs,
     fetchMonitoringOverview,
     fetchMonitoringSituation,
 } from '../services/monitoringApi.js'
@@ -40,6 +42,7 @@ 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'
 
@@ -51,7 +54,7 @@ function pending() {
 /** 该把哪个 panelId 喂给哪个取数函数 —— 台账**不在**这里(它与分布共用一次取数)。 */
 const FETCHERS = {
     monitoringOverview: fetchMonitoringOverview,
-    monitoringDistribution: fetchMonitoringDistribution,
+    monitoringDistribution: fetchMonitoringDistributionBatch,
     operationOverview: fetchMonitoringSituation,
 }
 
@@ -123,6 +126,8 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
 
     /** 台账装载器(幂等;`setup` 时按当前条件建一次)。 */
     let ledgerLoader = null
+    /** loader内部集合所属的有效请求输入;新轮/漂移时清空,reuse不能复活旧页。 */
+    let ledgerInputs = null
 
     /** 自动链两批之间的间隔(票 62:给渲染与交互留一口气,不一次打满请求)。 */
     const LEDGER_AUTO_BATCH_GAP_MS = 16
@@ -180,6 +185,11 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
         }
 
         const active = activeOptions()
+        const firstPageInputs = monitoringDistributionInputs(active)
+        // 新一轮开始就作废旧台账写入/下一页;不能等三块面板返回后才换纪元。
+        const initialLedgerEpoch = ++ledgerEpoch
+        ledgerInputs = null
+        let firstPage
         const window = rangeWindow(trendRange.value)
 
         token += 1
@@ -189,7 +199,12 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
         const results = await Promise.all(
             INDEPENDENT_PANELS.map(async (panelId) => {
                 try {
-                    return [panelId, await FETCHERS[panelId]({ ...active, ...(panelId === 'operationOverview' ? window : {}) })]
+                    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 同款兜底:适配层不抛,但转换层万一抛了要收成一条失败分区,
                     // 不能把面板卡在「加载中」(用户还得能看见其它块)。
@@ -211,9 +226,10 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
         sections.value = next
         loading.value = false
 
-        // 票 62:台账不再读分布的这一次响应(分布只回第 1 页切片、也不保证装载完整),
-        // 改由**自己的自动装载链**取全(下方 `loadLedgerChain`)。两者并发:台账慢不拖住其它三块。
-        const chain = loadLedgerChain('refresh')
+        // 台账仍有独立自动链;同轮成功首批可复用,后续页仍按其快照token装载到完整。
+        const sameRequest = initialLedgerEpoch === ledgerEpoch &&
+            sameInputs(firstPageInputs, monitoringDistributionInputs({ ...activeOptions(), page: 1 }))
+        const chain = loadLedgerChain('refresh', sameRequest ? firstPage : undefined)
         await chain
         return sections.value
     }
@@ -275,19 +291,29 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
      * @param {'refresh'|'reuse'} kind `refresh`=换新快照并自动连翻到 settled;
      *        `reuse`=还在同一快照有效期内,**只补没取完的部分**(定时刷新走这条,不重放全链)
      */
-    async function loadLedgerChain(kind = 'refresh') {
+    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
-        const started = kind === 'refresh' ? await loader.refresh() : loader.getState()
-        if (mine !== ledgerEpoch) return sections.value.monitoringPointLedger
+        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 (mine === ledgerEpoch && !ledgerSettledForAuto(state)) {
+        while (stillCurrent() && !ledgerSettledForAuto(state)) {
             await new Promise((resolve) => setTimeout(resolve, LEDGER_AUTO_BATCH_GAP_MS))
-            if (mine !== ledgerEpoch) break
+            if (!stillCurrent()) break
             state = await loader.loadNext()
-            if (mine !== ledgerEpoch) break
+            if (!stillCurrent()) break
             syncLedgerSection(state)
             // 失败/过期不自动重试(决策 04):停链,等新鲜度周期或手动入口。
             if (state.phase === 'failed' || state.phase === 'expired') break
@@ -308,6 +334,7 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
      * 已 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
@@ -321,7 +348,9 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
      */
     async function loadMoreLedger() {
         const state = ledger.state
-        if (!state || ledgerSettledForAuto(state)) return sections.value.monitoringPointLedger
+        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')
     }
 
@@ -426,6 +455,7 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
         token += 1
         ledgerEpoch += 1
         ledgerLoader = null
+        ledgerInputs = null
         ledger.state = null
         sections.value = Object.fromEntries(MONITORING_PANEL_IDS.map((panelId) => [panelId, pending()]))
         badges.value = {}