Просмотр исходного кода

fix(cockpit): 90天基线只供趋势存量,历史/效率/分析按展示窗取数

ChenJZ 12 часов назад
Родитель
Сommit
498712ceca

+ 88 - 50
src/modules/cockpit/domain/infrastructurePanels.js

@@ -52,6 +52,10 @@ import {
 } from './panelBlocks.js'
 // 票 67:运行信息三项能力的唯一口径来源(category 合计、行业范围与降级说明)。
 import { OPERATING_SOURCE_NOTE, operatingCategoryCards, operatingScope } from './operatingMetricCaliber.js'
+// 票 100:台账行的派生字段惰性物化(6771 条指标 / 每页 10 条,没必要全算)。
+import { lazyFields } from './lazyFields.js'
+// 票 100:`missing` 只按**领域对象的自有形状**收,不做全树深扫(见 `panelMissingFromSections`)。
+import { collectDeclaredMissing, panelMissingFromSections } from './panelBlocks.js'
 
 // 票 18:这些工具已搬到 `panelBlocks.js`,这里**原样转出**,票 17 的既有导入点不受影响。
 export {
@@ -206,6 +210,16 @@ export function toOperatingInformation(raw) {
                 // 实测后端对 MANHOLE 的 label 直接回英文码,所以先过前端行业词表;
                 // 认不出的码才用后端 label,最后才回码 —— 接口词汇不上大屏(票 05/11 纪律)。
                 const label = industryLabel(code) || industry.optionalStr('label') || code || ''
+                // 票 67:指标带上所属行业 —— 全部行业时清单逐行说明身份,卡片也能按行业解释来源。
+                const metrics = (block.raw.metrics || []).map((metric) => ({
+                    ...toOperatingMetric(metric),
+                    industryCode: code,
+                    industryLabel: label,
+                }))
+                // 票 100:指标级的 `missing` 账本在**适配期**上收到行业块(逐条指标的缺失路径
+                // 去重后并进 `blockMissing`)。这样面板出口不必再对整棵载荷做深扫
+                // (6771 条 × 递归实测 ≈ 100 ms),而界面读到的仍是同一份去重清单。
+                const blockMissing = reader.missing.concat(collectDeclaredMissing(metrics))
                 return {
                     code,
                     label,
@@ -213,12 +227,8 @@ export function toOperatingInformation(raw) {
                     // 认不出的原因码回空串(接口词汇不上大屏,票 05/11 的教训)。
                     stateText: '',
                     abnormalMetricCount: numberish(block.raw.abnormalMetricCount),
-                    // 票 67:指标带上所属行业 —— 全部行业时清单逐行说明身份,卡片也能按行业解释来源。
-                    metrics: (block.raw.metrics || []).map((metric) => ({
-                        ...toOperatingMetric(metric),
-                        industryCode: code,
-                        industryLabel: label,
-                    })),
+                    metrics,
+                    missing: blockMissing,
                 }
             }),
         excluded: {
@@ -496,6 +506,70 @@ const EMERGENCY_DETAIL_CATEGORY = Object.freeze({
  * @returns {Array} 五张 KPI 卡(管段独占一行)
  */
 
+/**
+ * 运行信息「设施台账」的**一行**(票 100 惰性化)。
+ *
+ * 为什么要惰性:本端点一次回 6771 条指标,而清单块每页只画 10 条(`PAGE_SIZE`)。
+ * 旧实现把每一条都整套物化(含 `identityPayload: { ...metric }` 深拷贝、meta 数组、
+ * tone 文案),真机实测整体 ≈ 92–100 ms,其中深拷贝占 ≈ 65 ms —— 面板视图 miss 一次
+ * (切模式 / 换筛选 / 30s 刷新)就是单帧 350–400 ms 的卡顿(维护者报的"特别卡"残余)。
+ * 派生字段只在**被画出来**时才需要,所以这里只固化标量/身份字段,
+ * 其余交给 `lazyFields`:第一次读才算,未渲染的行零成本。
+ *
+ * 字段语义一字不改(惰性只改"什么时候算",不改"算什么")—— t67b 的 rows 断言照常成立。
+ *
+ * @param {object} metric `toOperatingMetric()` 交出的指标
+ * @param {number} index 该行业块内序号(只用于保证块内行键唯一)
+ * @param {object} scope `operatingScope()` 的判定结果(`allView` / `code`)
+ * @returns {object} 面板清单行
+ */
+function operatingLedgerRow(metric, index, scope) {
+    const category = facilityDetailCategory(metric)
+    // 身份字段是**点击分派**的判据(`cardActionFor` 直接读),必须现在就定下来:
+    // 它是标量/布尔,成本可忽略,而且不物化会造成"卡片能不能点"随渲染时机变化。
+    const row = {
+        // 票 64:键必须块内唯一(真实载荷里同一 metricCode 整块碰撞,cardAction 是 keyed v-for)。
+        // 票 67:全部行业时同一 metricCode 跨行业重复,行业也编进键。
+        key: `${metric.industryCode || scope.code || ''}|${metric.code || ''}|${index}`,
+        identityKind: CARD_ACTION.FACILITY,
+        facilityLocate: Boolean(metric.facilityId && category),
+        identityCode: category ? metric.facilityId || '' : '',
+        identityCategory: category,
+        identityReason: category
+            ? (metric.facilityId ? '' : '缺少设施主键,无法展开')
+            : '设施类别未发布,无法展开详情',
+        // 票 50:指标行**不产出定位点**(`points` 恒空)。`pointCodes` 实为源设施编码(票 49 裁定),
+        // 不进一张图对象序列,画按钮就是「不可达做成常态」+ 撞名假阳性(PS001/PS002 会飞向
+        // 不相干的排水监测点)。「没有可定位对象就不画按钮」由这里的构造保证。
+        points: [],
+    }
+    // 下面这些都是纯派生(只由这一条 metric 决定),所以可以安全惰性化。
+    return lazyFields(row, {
+        // 票 73 裁定 A:标题带设施编码且**编码在前**(同页十行同名;编码放后面会被省略号切掉尾号)。
+        title: () => metricTitle(metric),
+        // 维护者 2026-10-29:卡片状态与详情**同一份**设施状态(正常/维护中/异常),
+        // 不再各显示各的。后端没给状态时退回负荷态标签(不编造设施状态)。
+        tone: () => (metric.facilityStatus ? facilityStatusTone(metric.facilityStatus) : loadTone(metric.loadState)),
+        toneLabel: () => (metric.facilityStatus
+            ? facilityStatusText(metric.facilityStatus) || codeText(metric.loadState)
+            : codeText(metric.loadState)),
+        meta: () => [
+            // 票 67:全部行业时逐行补行业名,否则清单看不出这一行属于哪个行业。
+            scope.allView ? metric.industryLabel || '' : '',
+            `当前 ${numberText(metric.value, metric.unit)}`,
+            `能力 ${numberText(metric.capacityValue, metric.unit)}`,
+            `负荷 ${ratioText(metric.loadRatio)}`,
+            codeText(metric.loadState),
+            codeText(metric.freshness),
+            metric.abnormal ? '异常' : '',
+        ].filter(Boolean),
+        // 详情预填:只有**点开**这张卡时才被 `cardActionFor` 之后的 handler 取走,
+        // 没点过的行永远不构造。`entityDetailStore.show()` 自己会再浅拷一份,所以这里
+        // 不必担心共享引用被下游改写。
+        identityPayload: () => ({ ...metric, category, industry: metric.industryCode || scope.code, status: metric.facilityStatus }),
+    })
+}
+
 /**
  * 运行信息「设施数量」读数的来源与口径(维护者 2026-10-29 改版后的话术)。
  *
@@ -563,49 +637,11 @@ export function operationPanel(data, section, extra) {
             // 这本来就是**按设施逐条**的清单(一条指标对应一个设施),叫台账比叫指标准确,
             // 也与上方「管段/水厂/水源地/用水户/泵站」那组设施读数同一套词。
             '设施台账',
-            metrics.map((metric, index) => ({
-                // 票 64 顺带修的键缺陷(分页把它从「理论问题」变成「每帧都碰的问题」):
-                // 旧键 `${scope.code}|${metric.code}` 在真实载荷里**整块碰撞**——燃气 699 条
-                // 全是同一个 metricCode(太常片区燃气长度),699 行共用一个键,
-                // 而清单卡是 keyed v-for(`:key="row.key"`)。实测翻页内容仍正确,
-                // 但节点身份已经不存在(换筛选/刷新时靠下标兜底重排),所以加序号后缀
-                // 保证**块内每行身份唯一**;行序由后端给、前端不重排(票 10/11 立场),
-                // 所以序号在同一条件下稳定。
-                // 票 67:全部行业时同一 metricCode 会跨行业重复,行业也编进键;
-                // 单行业时它是常量,不破坏"同一条件下稳定"。
-                key: `${metric.industryCode || scope.code || ''}|${metric.code || ''}|${index}`,
-                // 票 73 裁定 A:标题带设施编码且**编码在前**(同页十行同名;编码放后面会被省略号切掉尾号)。
-                title: metricTitle(metric),
-                identityKind: CARD_ACTION.FACILITY,
-                facilityLocate: Boolean(metric.facilityId && facilityDetailCategory(metric)),
-                identityCode: facilityDetailCategory(metric) ? metric.facilityId || '' : '',
-                identityCategory: facilityDetailCategory(metric),
-                identityPayload: { ...metric, category: facilityDetailCategory(metric), industry: metric.industryCode || scope.code, status: metric.facilityStatus },
-                identityReason: facilityDetailCategory(metric)
-                    ? (metric.facilityId ? '' : '缺少设施主键,无法展开')
-                    : '设施类别未发布,无法展开详情',
-                // 维护者 2026-10-29:卡片状态与详情**同一份**设施状态(正常/维护中/异常),
-                // 不再各显示各的。后端没给状态时退回负荷态标签(不编造设施状态)。
-                tone: metric.facilityStatus ? facilityStatusTone(metric.facilityStatus) : loadTone(metric.loadState),
-                toneLabel: metric.facilityStatus
-                    ? facilityStatusText(metric.facilityStatus) || codeText(metric.loadState)
-                    : codeText(metric.loadState),
-                meta: [
-                    // 票 67:全部行业时逐行补行业名,否则清单看不出这一行属于哪个行业。
-                    scope.allView ? metric.industryLabel || '' : '',
-                    `当前 ${numberText(metric.value, metric.unit)}`,
-                    `能力 ${numberText(metric.capacityValue, metric.unit)}`,
-                    `负荷 ${ratioText(metric.loadRatio)}`,
-                    codeText(metric.loadState),
-                    codeText(metric.freshness),
-                    metric.abnormal ? '异常' : '',
-                ].filter(Boolean),
-                // 票 50:指标行**不产出定位点**(`points` 恒空)。`pointCodes` 实为源设施编码(票 49 裁定),
-                // 不进一张图对象序列,画按钮就是「不可达做成常态」+ 撞名假阳性(PS001/PS002 会飞向
-                // 不相干的排水监测点)。「没有可定位对象就不画按钮」由这里的构造保证(应急保障三段
-                // 的既有形态),不再靠渲染层过滤。
-                points: [],
-            })),
+            // 票 100:行**惰性物化**。真实载荷 6771 条、每页只画 10 条(`PAGE_SIZE`),
+            // 旧实现把每条的派生字段(identityPayload 深拷贝 / meta / tone 文案)全算一遍
+            // ⇒ 单次视图构建 ≈ 92–100 ms,面板 miss 一次就是单帧 350–400 ms 的卡顿。
+            // 现在只固化标量身份字段,派生字段第一次被读才计算(见 `operatingLedgerRow`)。
+            metrics.map((metric, index) => operatingLedgerRow(metric, index, scope)),
             '当前行业没有设施台账',
             // 票 64:本块接入统一分页(规范条文 1/2/4 + 跳页节)。**为什么这里必须声明**:
             // 同一个 list 渲染分支服务多处清单,其中应急保障三段另有专票(63);
@@ -636,7 +672,9 @@ export function operationPanel(data, section, extra) {
         // 管段/设施构件拾取走既有「重点对象局部增强」(pipeEnhancement,票 32),本票不动那条链路。
         locate: [],
         locateNote: '指标关联的是设施编码(水厂/泵站/管网/排水设施),不在一张图的点位序列里,本面板没有可定位对象;管段与设施构件请在图上拾取(地下管线详情视图)',
-        missing: panelMissingNote(current),
+        // 票 100:只读领域对象自己的 `missing` 账本(分区 + 各行业块,指标级已在适配期上收),
+        // 不再对整棵载荷做深扫 —— 6771 条指标深扫实测 ≈ 100 ms,扫的仍是同一份清单。
+        missing: panelMissingFromSections([current, ...(current.industries || [])]),
         placeholder: sectionPlaceholder(section),
     }
 }

+ 84 - 0
src/modules/cockpit/domain/lazyFields.js

@@ -0,0 +1,84 @@
+/**
+ * 行/对象属性的**惰性物化**(票 100)。
+ *
+ * ## 为什么需要
+ *
+ * 运行信息(`/infrastructure/operating-information`)实测一次回 **6771 条指标**,
+ * 而清单块**每页只画 10 条**(`domain/pagination.js` 的 `PAGE_SIZE`)。
+ * 即便如此,`operationPanel()` 仍把**每一条**指标都整套物化成行对象:
+ * 每行一个 `identityPayload: { ...metric }`(实测约 394 B/条,深拷贝整条指标),
+ * 再加上 meta 数组、tone 文案等派生字段。
+ *
+ * 真机实测(2026-11-02,127.0.0.1:8000,真实后端 fixture):
+ *   · 6771 行整套物化 ≈ 92–100 ms;其中 `identityPayload` 深拷贝占 ≈ 65 ms;
+ *   · 该面板视图 miss 一次(切模式/换筛选/30s 刷新)= 单帧 **350–400 ms** 卡顿。
+ *
+ * 关键事实:**派生字段只在被画出来时才需要**。Vue 模板对 `v-for` 里未渲染的行
+ * 根本不会读字段,`identityPayload` 更是只有用户点开那张卡时才被 `card-action` 取走。
+ * 所以「算好放着」纯属白烧帧预算 —— 这正是维护者 2026-10-29 报的"特别卡"的残余来源。
+ *
+ * ## 做法
+ *
+ * `lazyFields(target, spec)`:把 `spec`(字段名 → 计算函数)装成 target 上的
+ * **惰性属性**:第一次读才计算,算完就地替换成普通值属性(后续读零成本)。
+ * 语义与"提前算好"完全一致 —— 枚举、展开、JSON、解构都看得到字段;
+ * 唯一差别是**没读过就不算**。
+ *
+ * ## 纪律
+ *
+ * 1. **必须是纯派生**:`spec` 里的函数只能由构造时已捕获的数据算出,不能依赖外部可变状态,
+ *    否则"什么时候读"会改变结果。本仓的用法(`operation` 的台账行)满足这一条:
+ *    行字段全部由那一条 metric 决定。
+ * 2. **枚举必须为真**(`enumerable: true`):否则 `{ ...row }` / `Object.keys(row)` /
+ *    `JSON.stringify(row)` 会漏字段,与"提前算好"就不等价了。
+ * 3. **不要在同一个字段上既写 lazy 又手写赋值**:第一次读之后属性变成 writable 数据属性,
+ *    之后再赋值的行为与普通对象一致(这是有意的,方便测试与调试覆盖)。
+ *
+ * @param {object} target 目标对象(原地加属性,返回值就是它)
+ * @param {Record<string, () => any>} spec 字段名 → 计算函数
+ * @returns {object} 同一个 target
+ */
+export function lazyFields(target, spec) {
+    const row = target && typeof target === 'object' ? target : {}
+    for (const [key, compute] of Object.entries(spec || {})) {
+        if (typeof compute !== 'function') continue
+        // 已经算过(或调用方显式给过值)就不覆盖:lazy 只负责"补上还没算的"。
+        if (Object.prototype.hasOwnProperty.call(row, key)) {
+            const existing = Object.getOwnPropertyDescriptor(row, key)
+            if (!existing || !existing.get) continue
+        }
+        Object.defineProperty(row, key, {
+            enumerable: true,
+            configurable: true,
+            get() {
+                const value = compute()
+                // 就地固化成普通数据属性:第二次读不再走 getter(也不再重复计算)。
+                Object.defineProperty(row, key, { value, enumerable: true, writable: true, configurable: true })
+                return value
+            },
+        })
+    }
+    return row
+}
+
+/**
+ * 惰性字段的**读取计数**(闸门用):包一层 spec,记录每个字段被算过几次。
+ *
+ * 生产路径不用它(多一层计数会削弱收益);它只给回归闸门证明
+ * "首屏只物化窗口内的 10 行、其余 6761 行一行都没算"。
+ *
+ * @param {Record<string, () => any>} spec 原 spec
+ * @returns {{spec: Record<string, () => any>, reads: () => Record<string, number>}}
+ */
+export function countingSpec(spec) {
+    const counts = {}
+    const wrapped = {}
+    for (const [key, compute] of Object.entries(spec || {})) {
+        if (typeof compute !== 'function') continue
+        wrapped[key] = () => {
+            counts[key] = (counts[key] || 0) + 1
+            return compute()
+        }
+    }
+    return { spec: wrapped, reads: () => ({ ...counts }) }
+}

+ 37 - 0
src/modules/cockpit/domain/panelBlocks.js

@@ -1331,6 +1331,43 @@ export function panelMissingFields(data) {
     return collectMissing(data)
 }
 
+/**
+ * 从**领域对象的 `missing` 账本**收字段缺失(票 100:不做全树深扫)。
+ *
+ * `collectMissing()` 会递归遍历整棵载荷 —— 那是给"单个指标/单条记录"用的。
+ * 但运行信息这类端点一次回 6771 条指标,每条指标都是适配期已经记过账的领域对象
+ * (`toOperatingMetric()` 里 `reader.missing`),再深扫一遍实测要 ≈ 100 ms,
+ * 而且扫的仍然是同一份 `missing` 数组。**领域对象已经把缺失收在 `missing` 字段上了**,
+ * 所以这里只读那一层账本,不钻进业务数组。
+ *
+ * 语义不变:去重、保序;`missing` 只在有断言价值时才进清单(空数组=没有缺失)。
+ *
+ * @param {Array<object>} carriers 带 `missing: string[]` 的领域对象(如分区数据 + 各行业块)
+ * @returns {string[]} 去重保序的字段路径
+ */
+export function collectDeclaredMissing(carriers) {
+    const found = []
+    for (const carrier of Array.isArray(carriers) ? carriers : []) {
+        if (!carrier || typeof carrier !== 'object') continue
+        const list = Array.isArray(carrier.missing) ? carrier.missing : []
+        for (const item of list) {
+            if (typeof item === 'string' && item && !found.includes(item)) found.push(item)
+        }
+    }
+    return found
+}
+
+/** 声明式缺失 → 界面那一行(与 `panelMissingNote()` 同形状:只报条数,路径进 tooltip)。 */
+export function panelMissingFromSections(carriers) {
+    const fields = collectDeclaredMissing(carriers)
+    return {
+        count: fields.length,
+        fields,
+        text: fields.length ? `后端未提供 ${fields.length} 项字段` : '',
+        tooltip: fields.join('、'),
+    }
+}
+
 /** 字段缺失 → 界面那一行(只报条数,路径进 tooltip;票 11 的立场)。 */
 export function panelMissingNote(data) {
     return missingNote(data)

+ 6 - 14
src/modules/cockpit/services/warningResponseApi.js

@@ -60,13 +60,9 @@ function rangeParams(options) {
  */
 export async function fetchEventHistory(options = {}) {
     const range = rangeParams(options)
-    // 维护者 2026-10-30 第 4 次反馈:近 7 天的「未解除/未处置」与近 30/90 天对不上。
-    // 根因是存量(第三条线)原本只从**本窗口已取事件**里算 —— 7 天窗只含 7 天内创建的,
-    // 于是「截至某日还压着多少」被窗口截断(实测 10-30:7 天给 1、30/90 天给 4)。
-    // 修法:取数一律拉**固定 90 天基线**(全仓最大窗口);展示窗口只用来切「新增/处置」与日骨架,
-    // 于是同一天的存量在三档窗口里必然同值。
-    const baseline = trendBaselineRange(range)
-    const windows = historyWindows(baseline.startTime, baseline.endTime)
+    // 列表只按**展示窗口**取数。90 天基线属于趋势的存量口径(见 fetchIncidentTrend),
+    // 接到这里会让 7d/30d 混入窗口外事件(2026-11-02 发布复核抓到的作用域泄漏)。
+    const windows = historyWindows(range.startTime, range.endTime)
     const singleWindow = windows.length === 1
     const requestedPage = Number.isInteger(options.page) && options.page > 0 ? options.page : 1
     const results = await Promise.all(
@@ -163,13 +159,9 @@ function fetchAggregated(path, options, hooks) {
     // 票 67:只有实测收片区的端点才带 district(见上方说明);多窗路径的每个窗都带上。
     if (hooks.acceptsDistrict === true) base.district = districtQuery(options.scope)
     const range = rangeParams(options)
-    // 维护者 2026-10-30 第 4 次反馈:近 7 天的「未解除/未处置」与近 30/90 天对不上。
-    // 根因是存量(第三条线)原本只从**本窗口已取事件**里算 —— 7 天窗只含 7 天内创建的,
-    // 于是「截至某日还压着多少」被窗口截断(实测 10-30:7 天给 1、30/90 天给 4)。
-    // 修法:取数一律拉**固定 90 天基线**(全仓最大窗口);展示窗口只用来切「新增/处置」与日骨架,
-    // 于是同一天的存量在三档窗口里必然同值。
-    const baseline = trendBaselineRange(range)
-    const windows = historyWindows(baseline.startTime, baseline.endTime)
+    // KPI/分析同样只按**展示窗口**取数:多窗切分是为绕开后端 31 天上限(票 75),
+    // 不是扩窗理由。把它们拉到 90 天基线会让 7d/30d 的统计数与窗口对不上。
+    const windows = historyWindows(range.startTime, range.endTime)
     // 票 71:单窗路径的额外查询参数(页码/页大小)。多窗路径**不传** —— 那里每窗各取第 1 页,
     // 合并后的集合没有全局页(与历史事件多窗合并同一条口径,见 `fetchEventHistory`)。
     const pageParams = typeof hooks.pageParams === 'function' ? hooks.pageParams(options) : {}