Kaynağa Gözat

refactor(cockpit): 合并总体概览同参在途取数并固定本轮请求快照

三项有序算法优化第三项(113):仅real同有效输入pending整轮复用,settled手刷必须重新取数;标准deferred先发布,异常/旧finally不污染新flight。真实跨调用者10GET到5、转换2到1;58项公开回归及独立mock隔离探针通过。保留mock动态原路与原async契约,不降低数据时效;测试证据只存本地。
ChenJZ 3 gün önce
ebeveyn
işleme
7161401ee0

+ 28 - 4
src/modules/cockpit/services/overviewApi.js

@@ -32,7 +32,8 @@ import { districtQuery, industryQuery } from '../domain/filterOptions.js'
 import { EVENT_FAMILY } from '../domain/events.js'
 import { sectionFromResult } from '../domain/sectionState.js'
 import { buildQuery, fetchSection, isEmptyBy } from './domainRequest.js'
-import { requestCockpitJson } from './cockpitApi.js'
+import { planCockpitRequest, requestCockpitJson } from './cockpitApi.js'
+import { getCockpitConfig } from './cockpitConfig.js'
 
 /**
  * 四个端点共用的全局筛选入参(票 89)。
@@ -52,6 +53,26 @@ export const OVERVIEW_PATHS = {
     alarmDisposal: '/overview/alarm-disposal',
 }
 
+/**
+ * 本轮有效输入与real请求快照(票113)。地址/fetch/筛选码只在service解析。
+ * mock保持原路(处理器可动态注册);只冻结小型副本,不冻结调用者对象。
+ */
+export function snapshotOverviewRequest(options = {}) {
+    const explicitConfig = options.config || null
+    const config = explicitConfig || getCockpitConfig()
+    const plan = planCockpitRequest(config, OVERVIEW_PATHS.perception)
+    const fetchImpl = plan.mode === 'real'
+        ? options.fetchImpl || (typeof fetch !== 'undefined' ? fetch : null)
+        : null
+    const filters = filterParams(options)
+    const inputs = [explicitConfig, explicitConfig && explicitConfig.api, plan.mode, plan.url, fetchImpl, filters.industry, filters.district]
+    const shareable = plan.mode === 'real' && !plan.error && typeof fetchImpl === 'function'
+    if (plan.mode !== 'real') return { options, inputs, shareable }
+    const capturedConfig = Object.freeze({ ...config, api: Object.freeze({ ...config.api }) })
+    const capturedOptions = Object.freeze({ ...options, config: capturedConfig, fetchImpl })
+    return { options: capturedOptions, inputs, shareable }
+}
+
 /** 后端信封上的 `freshness.status`(四块同款)。 */
 const freshnessOf = (payload) => (payload && payload.freshness && payload.freshness.status) || ''
 
@@ -123,6 +144,8 @@ const EVENT_PATHS_CURRENT = '/events/current'
  * @returns {Promise<{events: Array<object>, complete: boolean, snapshotAt: string}>}
  */
 export async function fetchAlarmEvents(options = {}) {
+    options = snapshotOverviewRequest(options).options
+    const filters = filterParams(options)
     const events = []
     let complete = true
     let snapshotAt = ''
@@ -130,9 +153,9 @@ export async function fetchAlarmEvents(options = {}) {
         const result = await requestCockpitJson(
             EVENT_PATHS_CURRENT + buildQuery({
                 mode: EVENT_FAMILY.ALARM,
-                industry: industryQuery(options.industry),
-                // 票 79:该端点真收 district(非法值 400、0/1 计数不同),与其它面板同一份全局条件。
-                district: districtQuery(options.scope),
+                industry: filters.industry,
+                // 票 79:该端点真收district;入口映射一次,全部后续页沿用(0不丢)。
+                district: filters.district,
                 page,
                 pageSize: ALARM_EVENT_QUERY.pageSize,
             }),
@@ -165,6 +188,7 @@ export async function fetchAlarmEvents(options = {}) {
  * 不影响分区成败 —— 「一个分区一块面板」的口径不变。
  */
 export async function fetchAlarmDisposal(options = {}) {
+    options = snapshotOverviewRequest(options).options
     const [main, records] = await Promise.all([
         requestCockpitJson(
             `${OVERVIEW_PATHS.alarmDisposal}${buildQuery(filterParams(options))}`,

+ 89 - 35
src/modules/cockpit/stores/overviewStore.js

@@ -25,16 +25,18 @@
  * 唯一保留的联动是预警类型排行 → 切「预警处置」模式(落点在 `CockpitShell` 的
  * `handlePanelAction`,筛选状态在 `eventStore`)。
  */
-import { ref } from 'vue'
+import { ref, watch } from 'vue'
 import { defineStore } from 'pinia'
 import { SECTION_STATE, section } from '../domain/sectionState.js'
 import { REQUEST_REASON } from '../domain/requestReason.js'
 import { OVERVIEW_PANEL_IDS } from '../domain/overviewPanels.js'
+import { sameInputs } from '../domain/panelViewCache.js'
 import {
     fetchAlarmDisposal,
     fetchPerception,
     fetchPipeNetwork,
     fetchWarningStudy,
+    snapshotOverviewRequest,
 } from '../services/overviewApi.js'
 
 /** 未取数时的分区(与其它 store 同形,`loaded:false` 表示"还没请求过")。 */
@@ -76,9 +78,23 @@ export const useOverviewStore = defineStore('overviewStore', () => {
 
     /** 请求序号:重取时旧的在途响应作废(迟到的四块响应整批丢弃,不半新半旧)。 */
     let token = 0
+    let inFlight = null
+    let applyingInputs = false
+
+    function invalidateIfChanged() {
+        if (!inFlight || sameInputs(inFlight.inputs, snapshotOverviewRequest(activeOptions()).inputs)) return
+        token += 1
+        inFlight = null
+        loading.value = false
+    }
+
+    watch([industry, scope], () => {
+        if (!applyingInputs) invalidateIfChanged()
+    }, { flush: 'sync' })
 
     function configure(options = {}) {
         requestOptions = { ...requestOptions, ...options }
+        if (!applyingInputs) invalidateIfChanged()
     }
 
     /** 当前请求条件(全局筛选 + 真实配置)。 */
@@ -101,47 +117,85 @@ export const useOverviewStore = defineStore('overviewStore', () => {
      * @returns {Promise<object>} 取完后的全部分区
      */
     async function loadPanels(options = {}) {
-        if (options.industry !== undefined) industry.value = options.industry || ''
-        if (options.scope !== undefined) scope.value = options.scope || ''
-        const injections = Object.fromEntries(
-            Object.entries(options).filter(([key]) => key !== 'industry' && key !== 'scope'),
-        )
-        if (Object.keys(injections).length) configure(injections)
+        // 一次load批量应用选项,sync watcher不对中间组合发失效通知。
+        const wasApplying = applyingInputs
+        applyingInputs = true
+        try {
+            if (options.industry !== undefined) industry.value = options.industry || ''
+            if (options.scope !== undefined) scope.value = options.scope || ''
+            const injections = Object.fromEntries(
+                Object.entries(options).filter(([key]) => key !== 'industry' && key !== 'scope'),
+            )
+            if (Object.keys(injections).length) configure(injections)
+        } finally {
+            applyingInputs = wasApplying
+        }
+
+        const snapshot = snapshotOverviewRequest(activeOptions())
+        if (snapshot.shareable && inFlight && inFlight.shareable && sameInputs(inFlight.inputs, snapshot.inputs)) return inFlight.promise
+
+        let resolve, reject
+        const promise = new Promise((yes, no) => { resolve = yes; reject = no })
+        const flight = { inputs: snapshot.inputs, token: ++token, promise, shareable: snapshot.shareable }
+        // 先发布deferred,loading watcher与同步fake fetch重入都能join;dispatch不延后。
+        inFlight = flight
+        try {
+            loading.value = true
+            runPanels(flight, snapshot.options).then(resolve, reject)
+        } catch (error) {
+            // sync watcher可在runner启动前抛错;只清自己的slot,deferred必须结清。
+            if (flight.token === token && inFlight === flight) {
+                inFlight = null
+                try { loading.value = false } catch (cleanupError) { error = cleanupError }
+            }
+            reject(error)
+        }
+        return promise
+    }
 
-        token += 1
-        const mine = token
-        loading.value = true
-
-        const results = await Promise.all(
-            OVERVIEW_PANEL_IDS.map(async (panelId) => {
-                try {
-                    return [panelId, await FETCHERS[panelId](activeOptions())]
-                } catch (error) {
-                    // 与票 11/17/18/20 同款兜底:适配层不抛,但转换层万一抛了要收成一条失败分区,
-                    // 不能把面板卡在「加载中」(用户还得能看见其它三块)。
-                    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 }
-        sections.value = next
-        loading.value = false
-        return sections.value
+    async function runPanels(flight, options) {
+        try {
+            if (flight.token !== token || inFlight !== flight) return sections.value
+            const results = await Promise.all(
+                OVERVIEW_PANEL_IDS.map(async (panelId) => {
+                    try {
+                        return [panelId, await FETCHERS[panelId](options)]
+                    } catch (error) {
+                        // 每块独立兜底:转换层意外抛错也不打掉其它三块。
+                        return [panelId, section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })]
+                    }
+                }),
+            )
+            // 迟到整批丢弃;原地改plain config而没有第二次load也不能写回旧值。
+            if (flight.token !== token || inFlight !== flight || !sameInputs(flight.inputs, snapshotOverviewRequest(activeOptions()).inputs)) return sections.value
+            const next = { ...sections.value }
+            for (const [panelId, value] of results) next[panelId] = { ...value, loaded: true }
+            sections.value = next
+            return sections.value
+        } finally {
+            if (flight.token === token && inFlight === flight) {
+                inFlight = null
+                loading.value = false
+            }
+        }
     }
 
     /** 仅测试用:回到未加载状态。 */
     function reset() {
         token += 1
+        inFlight = null
+        const wasApplying = applyingInputs
+        applyingInputs = true
+        try {
+            requestOptions = {}
+            industry.value = ''
+            scope.value = ''
+        } finally {
+            applyingInputs = wasApplying
+        }
+        // inputs先复位,再通知状态;sections watcher若开启新flight,不清它的loading。
         sections.value = Object.fromEntries(OVERVIEW_PANEL_IDS.map((panelId) => [panelId, pending()]))
-        loading.value = false
-        requestOptions = {}
-        industry.value = ''
-        scope.value = ''
+        if (!inFlight) loading.value = false
     }
 
     return {