Jelajahi Sumber

feat(cockpit): wire global district/industry filters end-to-end (t80/t81/t89)

- 9 previously-unfiltered endpoints now carry district/industry:
  monitoring/overview, monitoring/situation, infrastructure/operating-information,
  infrastructure/emergency-support, work-orders, and overview/{perception,
  pipe-network,warning-study,alarm-disposal}; overviewStore gains filter
  conditions + activeOptions(), shell passes them at its 4 call sites
- event family + work-order list district/industry wiring rides along
  (eventApi, warningResponseApi, detailApi, eventList, EventListPane, contractMocks)
- filterCaliber: DISTRICT_GAPS / INDUSTRY_GAPS emptied — every panel is now
  really filtered (deployment-verified: invalid value 400, valid values differ,
  unfiltered echoes null); the gap mechanism itself is kept
- warningResponsePanels: work-order list pageNote is now a single canonical note

Gates: t67 60/60, t67-mutations 25/25, t79 17/17, t62 51/51.
ChenJZ 6 jam lalu
induk
melakukan
20866bdfc8

+ 17 - 13
src/modules/cockpit/CockpitShell.vue

@@ -1521,8 +1521,9 @@ async function refreshModePanels() {
         // 票 67:片区也要跟着下(预警分析是事件族里唯一收片区入参的端点)。
         await eventStore.loadPanels({ mode: eventStore.mode, industry: industry.value, scope: scope.value })
     } else {
-        // 四模块端点都不收入参(规格 §1):周期刷新只重取,不带任何筛选条件。
-        await overviewStore.loadPanels()
+        // 票 89:四模块端点已由后端补齐片区/行业入参 —— 周期刷新也按当前筛选取数,
+        // 否则刷新一次就把筛选悄悄丢回全域。
+        await overviewStore.loadPanels({ industry: industry.value, scope: scope.value })
     }
 }
 
@@ -1877,7 +1878,8 @@ onMounted(() => {
             }
             if (action === STARTUP_ACTION.LOAD_OVERVIEW_PANELS) {
                 overviewStore.configure({ config: result.config })
-                overviewStore.loadPanels()
+                // 票 89:启动取数同样带上全局筛选(与基础设施/运行监测量块同一条纪律)。
+                overviewStore.loadPanels({ industry: industry.value, scope: scope.value })
                 continue
             }
             // 票 15:状态推送。外壳只说"用配置启动"——地址、凭据与 mock/real 的连接方式
@@ -2001,8 +2003,7 @@ watch(industry, (next) => {
     monitoringStore.loadPanels({ industry: next })
     // 票 13:当前预警的事件列表跟随全局行业(`/events/current` 收 industry 入参)。
     eventStore.loadPanels({ mode: eventStore.mode, industry: next })
-    // 票 05–08:概览四模块端点都不收行业入参,故这里**不重取**(切筛选空发四个请求没有意义);
-    // 口径在面板说明里如实交代(domain/filterCaliber.js 的既有纪律)。
+    overviewStore.loadPanels({ industry: next })
     // 票 24:行业筛选同样作用到管线与构件图层。几何是**全量取回、本地裁剪**,
     // 所以切行业只改本地筛选条件、不重新发请求(切换必须即时,不等一个往返)。
     pipeNetworkStore.setIndustry(next)
@@ -2010,20 +2011,23 @@ watch(industry, (next) => {
 })
 
 /**
- * 片区筛选变化:地图、监测分布、基础档案、管理计数、概览片区分布与**预警分析**同步刷新。
+ * 片区筛选变化:地图、监测分布、基础档案、管理计数、概览片区分布与**预警处置四块**同步刷新。
  *
- * 票 67 补的不是"所有面板都跟着变",而是**"该跟着变的都跟着变、不该变的都说明白"**:
- *  - 真收片区入参的端点:`map-snapshot` / 基础档案 / 管理信息 / 监测分布 /
- *    预警分析(`/events/multidimensional-analysis`)→ 在这里与下方 store 里接线;
- *  - 不收片区入参的端点(当前事件/历史事件/处置效率/监测概览/运行概况/运行信息/
- *    应急保障/概览端点/几何读模型)→ **不下发参数**,由 `domain/filterCaliber.js`
- *    在对应面板的口径说明里如实交代(旧版这里是静默忽略:切了片区什么都没变、也没说为什么)。
+ * 票 67 补的不是"所有面板都跟着变",而是**"该跟着变的都跟着变、不该变的都说明白"**;
+ * 票 79/80 按部署后的实测把事件族补齐;票 89 把后端已补齐入参的 9 个端点一次接线:
+ *  - 真收片区入参的端点:`map-snapshot` / 基础档案 / 管理信息 / 监测分布 / **事件族四块**
+ *    (`/events/*`)/ **监测概览、运行概况、运行信息、应急保障、概览四块、全量工单列表**
+ *    → 在这里与 store/service 里接线,切片区就重取;
+ *  - 界面上原先「本面板不随当前范围变化」的缺口句已随 2026-10-27 部署实测撤除
+ *    (9 个端点非法值 400、合法值读数不同,两轴都真的生效);口径单源在
+ *    `domain/filterCaliber.js`,那张表现在是空表。
  *  `eventStore` 原本整条旁路:切片区后预警处置四块面板一个请求都不发(票 67 的缺口 2)。
  */
 watch(scope, (next) => {
     if (!lifecycleReady.value) return
     infrastructureStore.loadPanels({ scope: next })
-    // 票 05–08:概览四模块不收片区入参,不重取(同行业筛选的立场)。
+    // 票 89:概览四块已接手片区入参,跟随重取(与行业筛选同一条纪律)。
+    overviewStore.loadPanels({ scope: next })
     monitoringStore.loadPanels({ scope: next })
     // 换片区 = 换一批数据 ⇒ 页码回第 1 页由 eventStore 的条件键(含 `scope`)负责。
     eventStore.loadPanels({ mode: eventStore.mode, scope: next })

+ 46 - 5
src/modules/cockpit/components/EventListPane.vue

@@ -37,12 +37,30 @@
         </div>
 
         <!--
-            票 67:全局筛选「当前范围」在本面板的落点 —— 当前事件端点实测不收片区入参,
-            所以整张表不随片区变化。措辞来自 domain/filterCaliber.js 的单一来源,
-            与 PanelBlocks 的面板口径说明共用一个断言钩子 `data-panel-gap`。
-            放在状态文案之前:读者先知道"这个条件在这里不起作用",再看数。
+            票 79:全局筛选「当前范围」在本面板的落点 —— 自票 77 起当前事件端点真收片区入参
+            (部署后实测非法值 400、0/1 读数不同),本表随当前范围收窄,不再挂「不随片区变化」
+            的缺口说明(view.gap 已置空,`data-panel-gap` 断言钩子保留)。
+            生效状态由**响应回显**判定:双字段口径单源在 domain/filterCaliber.js 的
+            `districtEchoView`,筛选生效时以 `data-scope-echo` 画一行「后端回显」,
+            未筛(note 为空)不占行 —— 放在状态文案之前,读者先知道这批数是哪个范围的。
         -->
         <p v-if="view.gap" class="event-list-pane__note" data-panel-gap>{{ view.gap }}</p>
+        <p v-if="view.scopeEcho && view.scopeEcho.note" class="event-list-pane__note" data-scope-echo>{{ view.scopeEcho.note }}</p>
+
+        <!--
+            G11(票 07):从「预警研判 · 类型排行」点进来的**本地类型筛选**回显。
+            一句话说清三件事:筛了哪个类型、命中/已加载各几条、这张表只覆盖可解析设备的
+            当前预警(不冒充全量)。清除入口与筛选同一行,键盘可达。
+        -->
+        <p v-if="view.filterView.active" class="event-list-pane__note" data-event-type-filter>
+            {{ view.filterView.note }}
+            <button
+                type="button"
+                class="event-list-pane__clear"
+                data-event-type-clear
+                @click="eventStore.clearWarningTypeFilter()"
+            >清除筛选</button>
+        </p>
 
         <!-- 分区自己的状态(pending / empty / failed / unavailable 各说各的话) -->
 
@@ -104,6 +122,11 @@
                 </tbody>
             </table>
 
+            <!-- 筛选生效但已加载集合里没有任何一条命中:如实说,不画成"没有预警"。 -->
+            <p v-if="view.filterView.active && !view.rows.length" class="event-list-pane__placeholder" data-event-filter-empty>
+                当前已加载列表中没有该类型的预警;这不是"全量无预警",覆盖范围见上方说明。
+            </p>
+
             <!--
                 票 61:服务端页码分页(共享 PagerNav,规范条文 2/3/4/9 + 跳页节)。
                 翻页带 page/pageSize 发请求(页码状态在 eventStore,组件只转发目标页);
@@ -120,7 +143,7 @@
                 />
             </div>
             <p v-if="view.updatedAt" class="event-list-pane__note" data-event-updated>
-                数据时间 {{ view.updatedAt }}
+                {{ view.snapshotLabel }} {{ view.updatedAt }}
             </p>
         </template>
 
@@ -164,6 +187,24 @@ function setMode(next) {
 }
 
 /* 分段切换:两个等宽的胶囊按钮,选中项高亮(与全局筛选条同一套视觉语言) */
+.event-list-pane__clear {
+    margin-left: calc(6 * var(--u, 1px));
+    padding: 0 calc(6 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.45);
+    border-radius: calc(8 * var(--u, 1px));
+    background: transparent;
+    color: var(--cyan, #01abea);
+    font: inherit;
+    font-size: var(--fs-caption, 10.5px);
+    cursor: pointer;
+}
+
+.event-list-pane__clear:hover,
+.event-list-pane__clear:focus-visible {
+    border-color: var(--cyan-hi, #00b8ff);
+    color: var(--text);
+}
+
 .event-list-pane__switch {
     display: inline-flex;
     gap: calc(3 * var(--u, 1px));

+ 204 - 9
src/modules/cockpit/domain/eventList.js

@@ -31,15 +31,15 @@
  *
  * 本文件是纯函数层:不 import service、不碰 DOM、不自己发请求(t05 的 D3/D4 守卫盯着)。
  */
-import { fieldReader, toLocation } from './fields.js'
+import { fieldReader, toLocation, TIME_LABELS } from './fields.js'
 import { SECTION_STATE, section, sectionStateFromBackend } from './sectionState.js'
-import { EVENT_FAMILY } from './events.js'
+import { EVENT_FAMILY, LEVEL_COLORS } from './events.js'
 import { LEVEL_TAGS, DEFAULT_TONE } from './statusTones.js'
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
 import { workOrderStateName } from './workOrder.js'
 import { serverPageView } from './pagination.js'
 // 票 67:本面板"不随当前范围变化"的措辞(单一来源,面板不各写一份)。
-import { districtGap } from './filterCaliber.js'
+import { districtEchoView } from './filterCaliber.js'
 
 /** 事件 `status` 的直译表(已知取值,来源见文件头;认不出的回原码)。 */
 export const EVENT_STATUS_TEXT = {
@@ -49,6 +49,166 @@ export const EVENT_STATUS_TEXT = {
     HANDLING: '处置中',
 }
 
+/**
+ * 事件**类型码 → 中文**(票 66)。
+ *
+ * 为什么需要:多维分析端点(`/events/multidimensional-analysis`)的 `EVENT_TYPE` 桶
+ * **`bucketLabel` 与 `bucketCode` 同值**,下发的就是采集协议码(实测 2026-10-22:
+ * `WARN-PRESSURE` / `WARN-CONDUCTIVITY` / `WARN-WATER-LEVEL`),后端不翻译。
+ * 照 `bucketLabel` 直画就是把接口代号印上大屏(票 05/17 立的纪律,票 22/27 同类先例)。
+ *
+ * 取值来源**逐条有据**(不是猜的,也不许猜):
+ *   · `WarningCodeEnum`(管线后端 `com.zksy.base.alarm.domain.Enum`):悬浮物/COD/氨氮/电导率/PH;
+ *   · `AlarmMetricSource`(同仓 `base.alarm.enums`):瞬时流量/流速/水位/压力(真实分析端点下发这一套);
+ *   · `GasWarningCodeEnum`、`FirefightingWarningCodeEnum`、窨井 `WARN_CODE_*`、漏失 `LEAK_*` 常量。
+ *
+ * **两种拼法都要登记**:同一批编码在库里既有连字符(`WARN-WATER-LEVEL`)也有下划线
+ * (`AlarmMetricSource` 与实测 `/events/current` 里的 `WARN_WATER_LEVEL`)。
+ * 只登一种,同一个「水位预警」就会一条链路有中文、另一条漏英文码。
+ *
+ * **一处如实登记的冲突**:`WARN-PRESSURE` 在消防压力服务里叫「消防压力过高预警」,
+ * 在 `AlarmMetricSource` 里是遥测压力(供水/排水)。分析端点只给码、分不出是哪一种,
+ * 实测该码挂在「测试压力计」(供水) 与「公司正门」(排水) 上 ⇒ 取**指标语义**的「压力预警」,
+ * 不冒充消防口径(票 66 正文记了这条冲突)。
+ */
+export const EVENT_TYPE_TEXT = {
+    // 压力(AlarmMetricSource 主码 + 消防压力服务的五个上下限/变幅码)
+    'WARN-PRESSURE': '压力预警',
+    'WARN-PRESSURE-HIGH': '压力上限预警',
+    'WARN-PRESSURE-HIGH-HIGH': '压力上上限预警',
+    'WARN-PRESSURE-LOW': '压力下限预警',
+    'WARN-PRESSURE-LOW-LOW': '压力下下限预警',
+    'WARN-PRESSURE-VARY': '压力变幅预警',
+    // 流量/流速/水位
+    'WARN-INSTANT-FLOW': '瞬时流量预警',
+    'WARN-INSTANT-FLOW-SUPPLY': '供水瞬时流量预警',
+    'WARN-FLOW-SPEED': '流速预警',
+    'WARN-FLOW-SPEED-SUPPLY': '供水流速预警',
+    'WARN-WATER-LEVEL': '水位预警',
+    // 污水水质(WarningCodeEnum)
+    'WARN-SUSPENDED-SOLIDS': '悬浮物预警',
+    'WARN-COD': 'COD预警',
+    'WARN-AMMONIA-NITROGEN': '氨氮预警',
+    'WARN-CONDUCTIVITY': '电导率预警',
+    'WARN-PH': 'PH预警',
+    // 可燃气体
+    'WARN-GAS-CONCENTRATION': '可燃气体浓度预警',
+    // 后端枚举自己拼错了(`TEMPERATRUE`),键照抄它的拼法,不然真实数据认不出
+    'WARN-GAS-TEMPERATRUE': '可燃气体温度预警',
+    'WARN-GAS-HUMIDITY': '可燃气体湿度预警',
+    // 窨井 / 漏失
+    'WARN-TILT': '窨井倾斜预警',
+    'WARN-WATER-INFILTRATION': '窨井渗水预警',
+    'WARN-WATER-MANHOLE-LEVEL': '窨井水位预警',
+    'WARN-LEAK': '漏失预警',
+    'WARN-LEAK-FREQ': '漏失特征频段预警',
+    // 下划线拼法(`AlarmMetricSource` 的水质码;实测 `/events/current` 下发 `WARN_WATER_LEVEL`)
+    'WARN_PRESSURE': '压力预警',
+    'WARN_INSTANT_FLOW': '瞬时流量预警',
+    'WARN_FLOW_SPEED': '流速预警',
+    'WARN_WATER_LEVEL': '水位预警',
+    'WARN_SUSPENDED_SOLIDS': '悬浮物预警',
+    'WARN_COD': 'COD预警',
+    'WARN_AMMONIA_NITROGEN': '氨氮预警',
+    'WARN_CONDUCTIVITY': '电导率预警',
+    'WARN_PH': 'PH预警',
+    // 后端给「事件类型缺失」的显式兜底桶
+    UNKNOWN_EVENT_TYPE: '未知事件类型',
+}
+
+/**
+ * **契约样例专用**的类型码(票 66)。
+ *
+ * `contractMocks` 的样例事件用的是 `GAS_CONCENTRATION` / `SEWAGE_PH` 这类虚构码
+ * (真实后端不下发它们,见 `mocks/contractMocks.js` 的 `CURRENT_WARNINGS`)。
+ * 单独成表而不是混进 `EVENT_TYPE_TEXT`:这一张是**样例词汇**,真实词汇与它分得开,
+ * 排查「这个码哪来的」时不会把样例码当成真实编码。
+ *
+ * 中文名取自样例行自己的 `eventName`(如 `GAS_CONCENTRATION` 的样例名是「燃气浓度预警」),
+ * 不另外臆造。登记它的唯一目的:mock 模式下也不把码画上屏(否则维护者切 mock 又看到一遍英文)。
+ */
+export const SAMPLE_EVENT_TYPE_TEXT = {
+    GAS_CONCENTRATION: '燃气浓度预警',
+    GAS_PRESSURE: '燃气压力预警',
+    WATER_PRESSURE: '供水压力预警',
+    WATER_FLOW: '供水流量预警',
+    WATER_CHLORINE: '供水余氯预警',
+    WATER_TURBIDITY: '供水浊度预警',
+    SEWAGE_CONCENTRATION: '污水 COD 预警',
+    SEWAGE_PH: '污水 PH 预警',
+    SEWAGE_NH3: '污水氨氮预警',
+    SEWAGE_TEMP: '污水温度预警',
+    DRAINAGE_LEVEL: '排水水位预警',
+    DRAINAGE_FLOW: '排水流量预警',
+}
+
+/** 成因码 → 中文(`CAUSE` 维度;后端兜底桶 `UNKNOWN_CAUSE` 的 label 本身就是这几个中文字)。 */
+export const CAUSE_TEXT = {
+    UNKNOWN_CAUSE: '未知/缺失成因',
+}
+
+const CJK = /[\u3400-\u4dbf\u4e00-\u9fff]/
+
+/** 后端给的 label 是不是「人话」(含中日韩文字)。不含就是码/空白,不能当展示文本。 */
+function humanLabel(label) {
+    const text = typeof label === 'string' ? label.trim() : ''
+    return text && CJK.test(text) ? text : ''
+}
+
+/**
+ * 码 → 中文的统一收口(票 66 的翻译层,所有维度共用这一条判定顺序)。
+ *
+ * 判定顺序(顺序本身就是口径,改动前先读票 66):
+ *   1. **登记表命中** → 中文。单一词表优先,不受后端 label 干扰(后端 label 与 code 常常同值);
+ *   2. 后端 label 已经是人话(含中文)→ 用它。不越俎代庖:真实载荷里 `eventType` 确实
+ *      存在「电导率预警」这种已中文化的值,改写反而丢信息;
+ *   3. 认不出 → **回原码**。`eventType`/`cause` 是**数据值**(实测同一字段里同时出现
+ *      `WARN-PRESSURE`、`1`、`2`、「电导率预警」——不是封闭枚举),照原样显示才是诚实的,
+ *      与 `panelBlocks.CODE_TEXT` 的立场一致。空码则回空串(不把「没有」画成某个值)。
+ *
+ * 「认不出的码回原码」的另一半是**闸门**:t66 用真实载荷做普查,登记表漏了哪个码当场判红,
+ * 所以本次上屏的码不会是漏网之鱼。
+ */
+export function localizeCodeText(code, label, ...dictionaries) {
+    const rawCode = typeof code === 'string' ? code.trim() : ''
+    for (const dictionary of dictionaries) {
+        const hit = rawCode && dictionary && dictionary[rawCode]
+        if (hit) return hit
+    }
+    const human = humanLabel(label)
+    if (human) return human
+    return rawCode || (typeof label === 'string' ? label.trim() : '')
+}
+
+/** 事件类型码 → 中文(真实码优先,其次契约样例码;都认不出见 `localizeCodeText` 第 3 条)。 */
+export function eventTypeText(code, label) {
+    return localizeCodeText(code, label, EVENT_TYPE_TEXT, SAMPLE_EVENT_TYPE_TEXT)
+}
+
+/** 成因码 → 中文。 */
+export function causeText(code, label) {
+    return localizeCodeText(code, label, CAUSE_TEXT)
+}
+
+/**
+ * 等级桶(`LEVEL` 维度的 `bucketCode`/颜色名)→ 等级号 1–4;认不出回 null。
+ *
+ * 为什么要认两种:`bucketCode` 是等级号('1'…'4'、'UNKNOWN'),`bucketLabel` 是颜色名
+ * (RED/YELLOW/BLUE/GREEN/UNKNOWN)。只认号,遇到只有颜色名的载荷就整块丢成「等级未知」。
+ */
+export function eventLevelFromBucket(code, label) {
+    const numeric = Number(code)
+    if (Number.isInteger(numeric) && numeric >= 1 && numeric <= 4) return numeric
+    const text = String(label || code || '').trim().toUpperCase()
+    const index = LEVEL_COLORS.indexOf(text)
+    return index >= 0 ? index + 1 : null
+}
+
+/** 等级桶 → `LEVEL_TAGS` 的色键 + 文字(认不出走 `eventLevelTag(null)` = 中性灰 + 「等级未知」)。 */
+export function eventLevelTagFromBucket(code, label) {
+    return eventLevelTag(eventLevelFromBucket(code, label))
+}
+
 /** 预警/报警的切换视图(**唯一**一份:字段标签与状态标签从同一对象取,切换必然同步)。 */
 export function eventListSwitchView(mode) {
     if (mode === EVENT_FAMILY.ALARM) {
@@ -218,6 +378,11 @@ export function toCurrentEvents(raw) {
             level: typeof filters.level === 'number' ? filters.level : null,
             industry: filters.industry || '',
             status: filters.status || '',
+            // 票 79:片区回显**双字段**原样带出(null 保真,票 42/20 的纪律)——
+            // 「未筛」与「只看未分区」的差别就在这两个字段的组合里,丢掉任何一个,
+            // 状态条就只能靠猜(判定单源在 filterCaliber.districtEchoView)。
+            district: typeof filters.district === 'number' ? filters.district : null,
+            districtName: typeof filters.districtName === 'string' ? filters.districtName : null,
         },
         pagination: {
             page: pagination.page,
@@ -340,8 +505,12 @@ export function sortEventRows(items) {
  * null,但分区状态仍在 —— 单独传分区才能说对"为什么没有数据"。
  * @param {object|null} domain `toCurrentEvents()` 的结果(成功时有值)
  * @param {object} [eventsSection] 事件分区(`section()`;缺省从 domain 里取)
+ * @param {{warningType?: string, warningTypeLabel?: string}} [options]
+ *        「预警类型」**本地筛选**(G11):只在已加载行上过滤,不回后端。
+ *        后端 `/events/current` 不收 type 参数,且当前列表本身只含"可解析设备"的子集 ——
+ *        所以这里必须给出**覆盖范围**(`filterView`),不能声称筛选完整。
  */
-export function currentEventsView(domain, eventsSection) {
+export function currentEventsView(domain, eventsSection, options = {}) {
     const current = domain || null
     const sectionValue = eventsSection || (current && current.sections && current.sections.events) || null
     const switchView = eventListSwitchView((current && current.mode) || EVENT_FAMILY.WARNING)
@@ -356,7 +525,25 @@ export function currentEventsView(domain, eventsSection) {
     //
     // 票 61:**行=后端返回的这一页**,前端不排、不再截 12 条(服务端分页按后端返回序,
     // 总数读 `pagination.total`,条文 7)。空/失败时仍给安全的一页视图(pageCount=1)。
-    const rows = items.map(eventRowView)
+    const allRows = items.map(eventRowView)
+    // G11:类型筛选**只对已加载行**生效(`row.type` 是后端 eventType,与 warning-study 的
+    // 类型 code 同一套码)。命中 0 条也照实显示 0,不把"没筛到"画成"没有预警"。
+    const typeCode = typeof options.warningType === 'string' ? options.warningType.trim() : ''
+    const typeLabel = typeof options.warningTypeLabel === 'string' && options.warningTypeLabel ? options.warningTypeLabel : typeCode
+    const rows = typeCode ? allRows.filter((row) => row.type === typeCode) : allRows
+    const filterView = typeCode
+        ? {
+              active: true,
+              code: typeCode,
+              label: typeLabel,
+              loaded: allRows.length,
+              matched: rows.length,
+              // 覆盖范围**必须**随筛选一起出现:这张表只含可解析设备的当前预警(后端 G11 缺口),
+              // 本地筛选只是在这一份已加载集合里做减法,不是全量。
+              // 口径三件套:当前页(服务端分页,筛选不跨页)、只含可解析设备的当前预警、不是全量。
+              note: `已按预警类型「${typeLabel}」本地筛选:命中 ${rows.length} / 当前页已加载 ${allRows.length} 条;本列表只含可解析设备的当前预警、且筛选不跨页,不代表全部未解除预警(缺口见票 07/G11)`,
+          }
+        : { active: false, code: '', label: '', loaded: allRows.length, matched: allRows.length, note: '' }
     const pagination = serverPageView(current && current.pagination, items.length)
     const placeholder = hasContent
         ? ''
@@ -379,9 +566,17 @@ export function currentEventsView(domain, eventsSection) {
         placeholder,
         pagination: hasContent ? pagination : null,
         updatedAt: (current && current.updatedAt) || '',
-        // 票 67:本表**不随「当前范围」变化** —— 当前事件端点实测不收片区入参(见
-        // services/eventApi.js 文件头)。措辞取 domain/filterCaliber.js 的单一来源;
-        // 这条说明由 EventListPane 以 `data-panel-gap` 渲染(与 PanelBlocks 同一个断言钩子)。
-        gap: districtGap('currentEvents'),
+        // 页脚那行是**信封快照时间**(后端 snapshot.getUpdatedAt()),不是某条业务数据的时刻——
+        // 旧词「数据时间」把它说成后者,是读不懂的根因(票 62)。标签取自 `TIME_LABELS` 单点词表。
+        snapshotLabel: TIME_LABELS.snapshot,
+        // 票 79:片区生效状态由**后端回显**判定(双字段口径,措辞单源 filterCaliber):
+        // 筛选生效时给一句说明(组件按 data-scope-echo 渲染),未筛(note 为空)不占行。
+        scopeEcho: districtEchoView(current && current.filters),
+        // 票 79:片区缺口已关闭 —— `/events/current` 自票 77 起真收 district(部署后实测
+        // 非法值 400、0/1 读数不同),所以本表随「当前范围」收窄,不再挂缺口说明。
+        // 保留字段是为了渲染层与既有断言面稳定(空串=不画)。
+        gap: '',
+        // G11:类型本地筛选的回显与覆盖范围(组件画选中态 + 「清除筛选」入口)。
+        filterView,
     }
 }

+ 55 - 60
src/modules/cockpit/domain/filterCaliber.js

@@ -1,83 +1,47 @@
 /**
- * 全局筛选(**当前范围** = 片区、**行业**)的生效面单一口径(票 67)。
+ * 全局筛选(**当前范围** = 片区、**行业**)的生效面单一口径(票 67 立,票 79/89 收口)。
  *
  * 为什么要有这个文件:维护者 2026-10-19 反馈第 8 条「上方菜单栏的当前范围没有影响到
- * 场景及物体的筛选及部分 UI 中的信息筛选」。审计(票 67,2026-10-22)的结论是
- * **后端各端点支持程度不一样**:有的真收片区并校验,有的完全不认这个参数。
- * 本仓纪律(票 05/18):前端**不得**给不支持的端点伪造筛选,**也不得**静默忽略 ——
- * 做不到的面板必须当场说清楚。这些"说清楚"的措辞收在这一处,面板与渲染层都不另写一份。
+ * 场景及物体的筛选及部分 UI 中的信息筛选」。本仓纪律(票 05/18):前端**不得**给不支持的
+ * 端点伪造筛选,**也不得**静默忽略 —— 做不到的面板必须当场说清楚。这些「说清楚」的措辞
+ * 收在这一处,面板与渲染层都不另写一份。
  *
- * ── 后端逐端点实测(2026-10-22,只读探针:`verify/artifacts/t67/`)──
- * 判据两条:① 非法值 `district=99` 是否 400(说明后端真读并校验该参数);
- * ② `district=0/1` 响应是否真的不同(说明后端按片区过滤,而不是原样返回)。
- *
- *   收片区且真的过滤:`/map-snapshot`、`/infrastructure/base-archives`、
- *     `/infrastructure/management-information`、`/monitoring/distribution`、
- *     **`/events/multidimensional-analysis`**(预警分析)
- *   完全不认片区(非法值也 200,响应逐字节相同):`/events/current`、
- *     `/events/history-analysis`、`/events/handling-efficiency`、`/monitoring/overview`、
- *     `/monitoring/situation`、`/infrastructure/operating-information`、
- *     `/infrastructure/emergency-support`、`/overview/**`、`/work-orders`
- *
- * 行业面基本齐全(不认行业的只有 `/overview/infrastructure`(无任何入参)、
- * `/overview/warning-settings`、`/work-orders`;`/infrastructure/geometry` 不收行业,
- * 但几何是**全量取回、本地裁剪**,所以行业在图层上仍然生效)。
- *
- * 管线/构件几何:实测 6766 个 feature 的 properties 里**没有任何片区字段**
- * (只有 source/sourceId/code/name/industry/typeCode/networkType/networkLevel/pointType/
- * networkId/diameter/remark/sourceCoordinates/coordinateValidation),
- * 所以片区既不能由后端筛、也不能由前端本地筛 —— 只能如实说明。
+ * ── 结论(2026-10-27 17:0x 部署态复测,服务已跑新构建)──
+ * 驾驶舱读模型端点**全部同时收** `district` 与 `industry`,**已无筛选缺口**:
+ *   · 非法值一律 400:`district=99` / `industry=WHATEVER` 在本轮新补的 9 个端点
+ *     (监测概览/运行概况、运行信息/应急保障、全量工单、概览四块)全部被拒;
+ *   · 合法值读数真的不同:`district=0` vs `district=1`、`GAS` vs `WATER_SUPPLY` 响应 DIFF;
+ *   · 全域=不传,回显为空(没有静默退化成某个片区/行业)。
+ * 早前「端点不收入参 / 忽略片区」的结论只对应旧构建,已随部署作废 —— 所以
+ * `DISTRICT_GAPS` / `INDUSTRY_GAPS` 两张缺口表**已清空**,面板不再有缺口句。
+ * (`/infrastructure/geometry` 依旧不收入参,但几何是**全量取回、本地裁剪**,
+ *  片区/行业在管线图层上仍然生效,本来就不算缺口,见 `PIPE_DISTRICT_NOTE`。)
  *
  * 纯数据 + 纯函数:不依赖 Vue,便于直接断言(与 `filterOptions.js` 同一定位)。
  */
+import { districtLabel } from './filterOptions.js'
 
 /** 界面上这两个全局条件的**原话**(措辞跟筛选条一致,说明里引用它就无需另造一套叫法)。 */
 export const SCOPE_FILTER_LABEL = '当前范围'
 export const INDUSTRY_FILTER_LABEL = '行业'
 
-/** 后端不认片区时的一句通用原因,逐面板只换"哪个端点"。 */
-const NO_DISTRICT_PARAM = '后端该端点未提供片区入参'
-
 /**
  * panelId → 片区不生效的如实说明。
  *
- * 只登记**实测确认不认片区**的面板;生效的面板(基础档案、管理信息、监测分布、
- * 预警分析、一张图的监测点)**不登记** —— 没有缺口就不该画一条缺口提示。
+ * **2026-10-27 部署实测后为空**:所有驾驶舱读模型端点都收片区、读数随片区变化,
+ * 没有「不生效」可说。表与 `districtGap()` 接缝保留 —— 渲染层与 `withFilterCaliber()`
+ * 仍按它取值,撤掉的是**内容**而不是**机制**(将来真有不支持片区的端点仍从这里登记)。
  */
-export const DISTRICT_GAPS = {
-    currentEvents: `「${SCOPE_FILTER_LABEL}」不作用于本面板:${NO_DISTRICT_PARAM}(当前事件端点实测下发该参数也不生效),本表始终列出全部片区`,
-    historyAnalysis: `「${SCOPE_FILTER_LABEL}」不作用于本面板:${NO_DISTRICT_PARAM}(历史事件端点实测下发该参数也不生效),本表始终统计全部片区`,
-    responseEfficiency: `「${SCOPE_FILTER_LABEL}」不作用于本面板:${NO_DISTRICT_PARAM}(处置效率端点实测下发该参数也不生效),本面板始终统计全部片区`,
-    monitoringOverview: `「${SCOPE_FILTER_LABEL}」不作用于本面板:${NO_DISTRICT_PARAM}(监测概览),点位与设备规模、类型分布始终为全部片区`,
-    operationOverview: `「${SCOPE_FILTER_LABEL}」不作用于本面板:${NO_DISTRICT_PARAM}(运行概况),趋势与异常口径始终为全部片区`,
-    operation: `「${SCOPE_FILTER_LABEL}」不作用于本面板:${NO_DISTRICT_PARAM}(运行信息),指标清单始终为全部片区`,
-    emergency: `「${SCOPE_FILTER_LABEL}」不作用于本面板:${NO_DISTRICT_PARAM}(应急保障),三类对象计数始终为全部片区`,
-    // 概览四块:票 05–08 把数据源换成四个新端点,**规格 §1 明确四个端点都不收入参**
-    // (本机后端此刻尚未部署这四端点,实测 404 ⇒ 这条依据是前端实现与规格,不是"实测后端忽略")。
-    perceptionDevices: `「${SCOPE_FILTER_LABEL}」不作用于本面板:概览四块的数据端点按规格不收入参(不收片区),本面板读数始终为全域`,
-    pipeNetwork: `「${SCOPE_FILTER_LABEL}」不作用于本面板:概览四块的数据端点按规格不收入参(不收片区),本面板读数始终为全域`,
-    warningStudy: `「${SCOPE_FILTER_LABEL}」不作用于本面板:概览四块的数据端点按规格不收入参(不收片区),本面板读数始终为全域`,
-    alarmDisposal: `「${SCOPE_FILTER_LABEL}」不作用于本面板:概览四块的数据端点按规格不收入参(不收片区),本面板读数始终为全域`,
-}
+export const DISTRICT_GAPS = {}
 
 /**
  * panelId → 行业不生效的如实说明。
  *
- * 两处,成因不同、都不许前端伪造:
- *   1. 报警效率面板里的「当前工单列表」走全量工单端点(票 65 新增),它**实测不认**行业
- *      (非法值也 200、响应不变),所以切行业时上方分布会变、下方列表不变 ——
- *      不说明就是一条看不出来的假象。
- *   2. 概览四块:票 05–08 的四个新端点按规格 §1 **完全不吃入参**,行业无处落脚。
- *      (这四端点在本机后端尚未部署,实测 404;依据是前端实现与规格,不是"实测后端忽略"。)
+ * **2026-10-27 部署实测后为空**(原因同片区表):所有端点都收行业。
+ * `/infrastructure/geometry` 依旧不收入参,但几何是**全量取回、本地裁剪**,
+ * 行业在图层上仍然生效,本来就不算缺口。
  */
-export const INDUSTRY_GAPS = {
-    workOrderList: `「${INDUSTRY_FILTER_LABEL}」不作用于本工单列表:后端工单端点未提供行业入参,列表始终列出全部行业的工单(上方分布仍按行业统计)`,
-    perceptionDevices: `「${INDUSTRY_FILTER_LABEL}」不作用于本面板:概览四块的数据端点按规格不收入参,本面板读数始终为全部行业`,
-    pipeNetwork: `「${INDUSTRY_FILTER_LABEL}」不作用于本面板:概览四块的数据端点按规格不收入参,本面板读数始终为全部行业`,
-    warningStudy: `「${INDUSTRY_FILTER_LABEL}」不作用于本面板:概览四块的数据端点按规格不收入参,本面板读数始终为全部行业`,
-    alarmDisposal: `「${INDUSTRY_FILTER_LABEL}」不作用于本面板:概览四块的数据端点按规格不收入参,本面板读数始终为全部行业`,
-}
-
+export const INDUSTRY_GAPS = {}
 /** 取某面板的片区缺口说明;没有缺口回空串(渲染层按空串不画)。 */
 export function districtGap(panelId) {
     return Object.hasOwn(DISTRICT_GAPS, panelId) ? DISTRICT_GAPS[panelId] : ''
@@ -99,6 +63,37 @@ export function industryGap(panelId) {
  */
 export const PIPE_DISTRICT_NOTE = `「${SCOPE_FILTER_LABEL}」按设施编码规则(片区 + 行业 + 时间编码)作用到管线与构件图层:几何数据本身没有片区字段,片区是从编码前缀推出来的;编码推不出片区的对象(阀门、窨井等少量构件)在按片区查看时不显示,选「未分区」才看得到它们`
 
+/**
+ * 事件族响应里 `filters` 回显的**双字段片区口径**(票 79)。
+ *
+ * 后端把「实际生效的片区」回显成两个字段:`district`(0/1/2 或 null)与
+ * `districtName`(中文名或 null)。只看其中一个必然看错:
+ *   · 「未分区」是后端归一后的**真实取值**(票 67 的教训:不能拿 null 编码反推「没筛」)——
+ *     `district=null` 且名字是「未分区」是「只看未分区」,不是未筛;
+ *   · 名字缺失同样推不出「没筛」以外的任何结论 —— 编码在场就是筛了,名字只是展示词。
+ *
+ * 判定(**唯一一份**:状态条与说明文案都从这里取,面板不各写一份):
+ *   · 两者皆 null/缺 ⇒ 未筛(全域,说明留空 —— 全域是常态,不占一行);
+ *   · `district=null` 且名字「未分区」 ⇒ 只看未分区;
+ *   · 其余 ⇒ 按该片区(名字优先用回显词,缺了按编码补)。
+ *
+ * @param {object|undefined} filters 响应回显的 filters(字段可能缺)
+ * @returns {{state: 'NONE'|'UNASSIGNED'|'DISTRICT', label: string, note: string}}
+ */
+export function districtEchoView(filters) {
+    const echo = filters && typeof filters === 'object' ? filters : {}
+    const hasCode = echo.district === 0 || echo.district === 1 || echo.district === 2
+    const name = typeof echo.districtName === 'string' ? echo.districtName.trim() : ''
+    if (!hasCode && !name) return { state: 'NONE', label: '全域', note: '' }
+    if (hasCode) {
+        const label = name || districtLabel(echo.district)
+        return { state: 'DISTRICT', label, note: `后端回显:本面板按片区「${label}」统计` }
+    }
+    if (name === '未分区') return { state: 'UNASSIGNED', label: '未分区', note: '后端回显:本面板只统计未分区的对象' }
+    const label = name || '未分区'
+    return { state: 'DISTRICT', label, note: `后端回显:本面板按片区「${label}」统计` }
+}
+
 /**
  * 把筛选口径说明并进面板视图的 `gap`(口径缺口)字段。
  *
@@ -118,4 +113,4 @@ export function withFilterCaliber(view, panelId) {
     const added = [districtGap(panelId), industryGap(panelId)].filter(Boolean)
     if (!added.length) return view
     return { ...view, gap: [view.gap, ...added].filter(Boolean).join(';') }
-}
+}

+ 228 - 44
src/modules/cockpit/domain/warningResponsePanels.js

@@ -5,13 +5,21 @@
  * 预警/报警沿用票 13 的 mode;历史、效率、分析三块不各自维护一套切换状态。
  */
 import { fieldReader } from './fields.js'
-import { eventLevelTag, eventListSwitchView, toEventItem } from './eventList.js'
+import {
+    causeText,
+    eventLevelTag,
+    eventLevelTagFromBucket,
+    eventListSwitchView,
+    eventTypeText,
+    localizeCodeText,
+    toEventItem,
+} from './eventList.js'
 import { valueText, workOrderRows } from './equipmentDetailView.js'
 import { EVENT_FAMILY } from './events.js'
 import { WORK_ORDER_STATES } from './workOrder.js'
-// 票 67:全局行业筛选**作用不到**全量工单端点(实测不收该入参),措辞取单一来源。
-import { industryGap } from './filterCaliber.js'
+import { industryLabel } from './filterOptions.js'
 import {
+    CODE_TEXT,
     barsBlock,
     donutBlock,
     kpiBlock,
@@ -39,22 +47,48 @@ function list(raw, key) {
     return Array.isArray(value) ? value : []
 }
 
-function countRows(rows, key = 'count') {
+/**
+ * 旧形状(`dimensions` 不是数组时的 `types`/`industries` 等)→ 行。
+ *
+ * 票 66:加 `translate` 参数 —— 同一个转换要能带自己的词表(类型/成因/行业各一张),
+ * 但**行形状与降序口径只有这一份**,不因为加翻译就复制一份 `countRows`。
+ */
+function countRows(rows, key = 'count', translate = null) {
     return list(rows, key).map((item) => {
         const reader = fieldReader(item, 'eventAnalysisRow')
+        const code = reader.optionalStr('code') || reader.optionalStr('name') || reader.optionalStr('label')
+        const label = reader.optionalStr('name') || reader.optionalStr('label') || reader.optionalStr('code')
         return {
-            code: reader.optionalStr('code') || reader.optionalStr('name') || reader.optionalStr('label'),
-            label: reader.optionalStr('name') || reader.optionalStr('label') || reader.optionalStr('code'),
+            code,
+            label: translate ? translate(code, label) : label,
             count: numberish(reader.raw.count) || 0,
         }
     })
 }
 
+/**
+ * 行业桶 → 中文(票 66):先走全局筛选的**同一张**行业词表(`filterOptions.industryLabel`
+ * —— 它把后端码 GAS/WATER_SUPPLY/DRAINAGE/MANHOLE 翻成燃气/供水/排水/窨井),
+ * 认不出的回 `CODE_TEXT` 的既有口径(行业码是**数据值**,照原样带出,见 `panelBlocks.CODE_TEXT` 注释)。
+ * 不新造第二份行业词表 —— 筛选条、地图图例、本面板必须说同一个词。
+ */
+function industryText(code, label) {
+    return industryLabel(code) || localizeCodeText(code, label, CODE_TEXT)
+}
+
+/**
+ * 等级桶 → 行(票 66)。
+ *
+ * 后端 `LEVEL` 桶的形状是 `{bucketCode: '1'..'4'|'UNKNOWN', bucketLabel: 'RED'..'GREEN'|'UNKNOWN'}`
+ * —— **码是等级号、label 是颜色名**,两族都一样。旧实现只读码:码在就侥幸对、只有颜色名时
+ * 整块落「等级未知」。现在两种都认(`eventLevelTagFromBucket`),中文名仍只来自 `LEVEL_TAGS`。
+ */
 function levelRows(rows) {
-    return (Array.isArray(rows) ? rows : []).map((item) => {
-        const level = Number(item && (item.level === undefined ? item.code : item.level))
-        const tag = eventLevelTag(level)
-        return { code: String(level), label: tag.label, count: numberish(item && item.count) || 0 }
+    return (Array.isArray(rows) ? rows : []).map((raw) => {
+        const source = raw || {}
+        const code = source.code !== undefined ? String(source.code) : source.level === undefined ? '' : String(source.level)
+        const tag = eventLevelTagFromBucket(source.code !== undefined ? source.code : source.level, source.label)
+        return { code, label: tag.label, count: numberish(source.count) || 0 }
     })
 }
 
@@ -171,6 +205,14 @@ export function toHandlingEfficiency(raw) {
         ? Object.fromEntries(reader.raw.workOrderStateCounts.map((row) => [row.stateCode, row.count]))
         : family.workOrderStates || reader.raw.workOrderStates || reader.raw.workOrderStateDistribution || {}
     const averageDuration = summary.averageDuration || reader.raw.averageDuration || {}
+    // 票 78:预警**自身**的处置流程聚合(票 74 契约,后端已交付)。两字段为预警族专属,
+    // 报警族与旧载荷为 null —— null 保真(票 42/20 同一纪律):缺失不数成 0,界面据此占位。
+    const warningEfficiencyRaw = summary.warningHandlingEfficiency && typeof summary.warningHandlingEfficiency === 'object'
+        ? summary.warningHandlingEfficiency
+        : null
+    const warningDurationRaw = summary.warningDuration && typeof summary.warningDuration === 'object'
+        ? summary.warningDuration
+        : null
     const distribution = WORK_ORDER_STATES.map((item) => ({
         code: String(item.code),
         label: item.name,
@@ -188,6 +230,16 @@ export function toHandlingEfficiency(raw) {
         label: eventLevelTag(level).label,
         count: events.filter((item) => Number(item && item.level) === level).length,
     }))
+    // 票 78:六态分布行(DRAFT…CLOSED,脏状态由后端归入「未知」桶一并下发)。
+    // 行的码/中文名/计数**全部以后端回显为准** —— 词表取 WarningWorkflow 中文名,
+    // 前端不另造一套状态词;字段缺失(旧载荷/报警族)回 null,界面不画假块。
+    const warningStates = Array.isArray(reader.raw.warningStateCounts)
+        ? reader.raw.warningStateCounts.map((row) => ({
+              code: row && row.stateCode !== undefined && row.stateCode !== null ? String(row.stateCode) : '',
+              label: (row && row.stateName) || (row && row.stateCode) || '',
+              count: numberish(row && row.count) || 0,
+          }))
+        : null
     return {
         family: familyCode,
         handlingRate: numberish(handlingValue.value === undefined ? (handlingValue.rate === undefined ? reader.raw.handlingRate : handlingValue.rate) : handlingValue.value),
@@ -211,6 +263,24 @@ export function toHandlingEfficiency(raw) {
             ? summary.averageDuration.state === 'EMPTY' ? null : summary.averageDuration
             : durationValue(raw, ['averageCloseDuration', 'averageCloseSeconds']) || durationValue(averageDuration, ['closeDuration', 'closeSeconds', 'close']),
         workOrderStates: distribution,
+        // 票 78:预警自身处置流程读数(已解除率/平均处置时长聚合 + 六态分布行)。
+        // 三者都是**预警族专属**:报警族与旧载荷为 null,消费点据此占位/不画假块。
+        warningStates,
+        warningEfficiency: warningEfficiencyRaw ? {
+            value: numberish(warningEfficiencyRaw.value),
+            numerator: numberish(warningEfficiencyRaw.numerator),
+            denominator: numberish(warningEfficiencyRaw.denominator),
+            state: warningEfficiencyRaw.state || '',
+            reason: warningEfficiencyRaw.reason || '',
+            basis: warningEfficiencyRaw.basis || '',
+        } : null,
+        warningDuration: warningDurationRaw ? {
+            value: numberish(warningDurationRaw.value),
+            unit: warningDurationRaw.unit || '',
+            sampleCount: numberish(warningDurationRaw.sampleCount),
+            state: warningDurationRaw.state || '',
+            reason: warningDurationRaw.reason || '',
+        } : null,
         // 逐桶**事件发生量**(与下面的结案时长趋势是两个口径,预警族只用这一份)。
         eventTrend: trendRowsRaw.map((item) => ({
             bucket: String(item && (item.timeBucket || item.bucket || item.bucketStart || '')),
@@ -228,22 +298,43 @@ export function toHandlingEfficiency(raw) {
     }
 }
 
-/** 多维分析载荷 → 领域对象。 */
+/**
+ * 多维分析载荷 → 领域对象(票 19;**票 66 加码→中文翻译层**)。
+ *
+ * 票 66 的要点:后端 `buckets[].bucketLabel` 对 `EVENT_TYPE`/`INDUSTRY`/`LEVEL` 下发的
+ * 就是**英文码**(实测 2026-10-22 两族都如此),照 `bucketLabel` 直画等于把接口代号印上大屏。
+ * 所以这里按维度各挂一张词表(类型/成因各一张,行业/等级复用既有单源),
+ * 领域对象交出去的 `label` 一律是**可上屏的中文**;认不出的码回原码(数据值口径,见
+ * `eventList.localizeCodeText`),行与计数**不丢**(丢一行 = 分布合计对不上总数,那是谎话)。
+ *
+ * `causes`(票 66 新增):`CAUSE` 维度此前没有任何消费点。后端两个族都**恒定**回这一维
+ * (缺成因也走 `UNKNOWN_CAUSE` 兜底桶,源码注释原句 "never hides missing causes"),
+ * 面板据此决定要不要画「按成因」块 —— 没返回这一维就**不画假块**。
+ */
 export function toEventAnalysis(raw) {
     const reader = fieldReader(raw, 'eventAnalysis')
     const dimensions = reader.raw.dimensions
     const source = Array.isArray(dimensions)
         ? Object.fromEntries(dimensions.map((dimension) => [dimension.dimensionCode, dimension.buckets || []]))
         : dimensions || reader.raw
-    const dimensionRows = (key) => (source[key] || []).map((bucket) => ({ code: bucket.bucketCode, label: bucket.bucketLabel || bucket.bucketCode, count: numberish(bucket.count) || 0 }))
+    const dimensionRows = (key, translate) => (source[key] || []).map((bucket) => ({
+        code: bucket.bucketCode,
+        label: translate ? translate(bucket.bucketCode, bucket.bucketLabel) : bucket.bucketLabel || bucket.bucketCode,
+        count: numberish(bucket.count) || 0,
+    }))
     const highlights = list(raw, 'highlights').map((item) => {
         const row = fieldReader(item, 'highlight')
         return { title: row.optionalStr('title') || row.optionalStr('name'), detail: row.optionalStr('detail') || row.optionalStr('description'), count: numberish(row.raw.count) }
     })
+    const familyRaw = reader.optionalStr('eventFamily') || reader.optionalStr('mode')
     return {
-        types: Array.isArray(dimensions) ? dimensionRows('EVENT_TYPE') : countRows(source, 'types').concat(countRows(source, 'byType')),
+        // 当前族(后端回显):面板的「事件」口径说明据此换词(预警/报警两族各说各的)。
+        family: familyRaw === EVENT_FAMILY.ALARM || familyRaw === EVENT_FAMILY.WARNING ? familyRaw : '',
+        types: Array.isArray(dimensions) ? dimensionRows('EVENT_TYPE', eventTypeText) : countRows(source, 'types', eventTypeText).concat(countRows(source, 'byType', eventTypeText)),
         levels: Array.isArray(dimensions) ? levelRows(dimensionRows('LEVEL')) : levelRows(source.levels || source.byLevel),
-        industries: Array.isArray(dimensions) ? dimensionRows('INDUSTRY') : countRows(source, 'industries').concat(countRows(source, 'byIndustry')),
+        industries: Array.isArray(dimensions) ? dimensionRows('INDUSTRY', industryText) : countRows(source, 'industries', industryText).concat(countRows(source, 'byIndustry', industryText)),
+        causes: Array.isArray(dimensions) ? dimensionRows('CAUSE', causeText) : countRows(source, 'causes', causeText).concat(countRows(source, 'byCause', causeText)),
+        // 片区不上翻译层:后端给的就是中文名(城南/城北/太常/未分区),码是 '0'/'1'/'2'。
         regions: Array.isArray(dimensions) ? dimensionRows('DISTRICT') : countRows(source, 'regions').concat(countRows(source, 'byRegion')),
         highlights,
         range: rangeView(raw),
@@ -335,34 +426,78 @@ export function responseEfficiencyTitle(mode) {
 }
 
 /**
- * 预警效率面板的**口径说明**(票 65,预警族)。
+ * 预警效率面板的**口径说明**(票 65 立项;**票 78 改写**)。
  *
- * 背景(2026-10-21 复核取证,维护者要求「确认预警数据是否正确」):
- * 后端 `CockpitHandlingEfficiencyService` 的 `stateCounts` 来自 `scopeWorkOrders(...)`,
- * 其源码注释原句是「范围内**全部工单**(不要求关联报警/事件)」——即处置率/状态分布/
- * 结案时长都是**工单**口径,与事件族无关。实测两族同样结构:
- * 预警族 `linkedWorkOrderCount=0`、八态合计 0。因此**预警族确实没有预警自身的处置流程统计**。
+ * 背景:票 65 当时实测预警族没有预警自身的处置流程统计(工单口径两族混同),
+ * 文案如实写下「后端尚未提供、已登记需求池」。票 74 已把契约补上(后端 `983fc27a`):
+ * 预警族现在回 `warningStateCounts`(WarningWorkflow 六态)、`warningHandlingEfficiency`
+ * (已解除 ÷ 窗口内预警数)、`warningDuration`(发布→已解除平均时长)——
+ * 旧文案从今天起就是谎话,必须改写。
  *
- * 这段文字是要给值班人员看的,所以修掉上一版的两处毛病:
- *   ① 「本端点」是接口词汇(项目纪律:接口词汇不上大屏)→ 改成说「后端/当前数据」;
- *   ② 前半句先讲「我们没有字段」,读者要读两遍才知道面板上的数是什么
- *      → 改成先讲**面板上这些数是什么、能信什么**,再讲为什么没有工单口径。
+ * 新版只说三件事(措辞纪律沿用票 65 复核结论:不写接口词汇,先讲读数再讲口径):
+ *   ① 这些数是**预警自身**的处置流程统计,工单口径只在报警效率出现(两族不混);
+ *   ② 已解除率的分母是本窗口预警条数(第一张卡),分子是已解除(CLOSED)条数;
+ *   ③ 平均处置时长从预警发布计到已解除。
  */
 export const WARNING_EFFICIENCY_GAP =
-    '预警不挂工单,所以这里不画工单口径的处置率、工单状态分布与结案时长;' +
-    '下方的条数、等级分布与发生趋势都是本窗口内预警自身的真实读数。' +
-    '预警的处置流程统计(如「预警→处置动作」流转)后端尚未提供,已在需求池登记,上线后此处会自动换成预警自身的口径'
+    '预警不挂工单,工单口径的处置率与时长只在报警效率出现;' +
+    '这里的条数、已解除率、平均处置时长与预警状态分布都是本窗口内预警自身的处置流程统计。' +
+    '已解除率=已解除的预警 ÷ 本窗口预警条数(分母即第一张卡的条数),平均处置时长从预警发布计到已解除'
+
+/**
+ * 已解除率 → 界面读数(票 78,预警族)。
+ *
+ * 与 `handlingRateView` 同一条判据的预警族实例(两处判据一致,只是读的字段不同):
+ *   · 聚合缺失(旧载荷/报警族)→ `—`:我们**不知道**,不冒充任何读数;
+ *   · `state=EMPTY` 或分母 0(后端 reason=NO_WARNINGS)→ `不适用`:
+ *     那个 "0.0000" 是「没有分母」的占位,照画就是「已解除率 0.0%」的假读数;
+ *   · 其余 → 百分比(后端给 0–1 四位小数,与报警族处置率同一种读法)。
+ */
+export function warningResolveRateView(current) {
+    const row = (current && current.warningEfficiency) || null
+    if (!row) return '—'
+    if (row.state === 'EMPTY' || (typeof row.denominator === 'number' && row.denominator === 0)) return '不适用'
+    if (row.value === null || row.value === undefined) return '—'
+    return `${(row.value * 100).toFixed(1)}%`
+}
+
+/** 已解除率不适用时的说明(挂在 KPI 的 hint 上;缺失数据不给编造的解释)。 */
+export function warningResolveRateHint(current) {
+    const row = (current && current.warningEfficiency) || null
+    if (row && (row.state === 'EMPTY' || (typeof row.denominator === 'number' && row.denominator === 0))) {
+        return '本窗口没有预警,已解除率不适用'
+    }
+    return ''
+}
 
 /**
- * 预警族 KPI:预警自身可证实的读数(票 65)。
- * 「已关联/未关联工单」是**真实计数**(预警族实测 0 / 全部),不是 0% 假读数。
+ * 平均处置时长 → 界面读数(票 78,预警族)。
+ * `state=EMPTY`(reason=NO_CLOSED_WARNINGS,窗口内没有已解除的预警)与缺失都画占位符
+ * —— 与报警族「平均关闭时长」的 EMPTY 处理(`closeDuration`)同一条纪律:不画 0 小时冒充。
+ */
+export function warningDurationView(current) {
+    const row = (current && current.warningDuration) || null
+    if (!row || row.state === 'EMPTY') return '—'
+    return formatDuration(row.value, row.unit)
+}
+
+/** 平均处置时长无样本时的说明(占位符旁边说清「为什么没有数」)。 */
+export function warningDurationHint(current) {
+    const row = (current && current.warningDuration) || null
+    return row && row.state === 'EMPTY' ? '窗口内还没有已解除的预警,暂无时长样本' : ''
+}
+
+/**
+ * 预警族 KPI:预警自身的处置流程读数(票 78;票 65 立的三卡已随票 74 契约升级)。
+ * 三张卡=条数 / 已解除率 / 平均处置时长,读 `summary.warning*` 新字段;
+ * 「已关联/未关联工单」退到窗口读数(真实计数仍在,只是不占 KPI 位 —— 见 `warningRangeNote`)。
  * 读数缺失走 `valueText` 的占位符(票 22/42 纪律:不把「没有」画成 0)。
  */
 function warningEfficiencyKpis(current) {
     return kpiBlock([
         { key: 'warningTotal', label: '预警条数', value: valueText(current.totalEvents), unit: '条' },
-        { key: 'warningLinked', label: '已关联工单', value: valueText(current.linkedWorkOrderCount), unit: '条', hint: '预警不挂工单:该读数是事件回联到工单的实况' },
-        { key: 'warningMissing', label: '未关联工单', value: valueText(current.missingWorkOrderCount), unit: '条', hint: '预警不挂工单:没有回联工单的预警条数' },
+        { key: 'warningResolvedRate', label: '已解除率', value: warningResolveRateView(current), hint: warningResolveRateHint(current) },
+        { key: 'warningDuration', label: '平均处置时长', value: warningDurationView(current), hint: warningDurationHint(current) },
     ])
 }
 
@@ -398,12 +533,19 @@ export function responseEfficiencyPanel(data, section, extra) {
         const trend = historyTrendRows(current.eventTrend || [])
         return sectionView('responseEfficiency', section, [
             warningEfficiencyKpis(current),
+            // 票 78:预警状态分布(WarningWorkflow 六态 + 脏状态的「未知」桶)。
+            // 行的中文名由后端 `stateName` 下发,前端不另造状态词;字段缺失(旧载荷/
+            // 报警族)不画假块 —— 缺失不画假 0 的同一纪律。全 0 也是真实分布(缺桶计 0)。
+            ...(Array.isArray(current.warningStates) && current.warningStates.length
+                ? [barsBlock('预警状态分布', current.warningStates)]
+                : []),
             barsBlock('预警等级分布', current.levelDistribution || []),
             trendBlock(trend.length > 48 ? '预警发生趋势 · 每日合计' : '预警发生趋势', trend, { chart: 'line' }),
         ], {
             label: responseEfficiencyTitle(EVENT_FAMILY.WARNING),
             range: current.range,
-            // 口径说明(走票 56 的折叠摘要,`data-panel-note`):说清「预警不挂工单」与契约缺口。
+            // 口径说明(走票 56 的折叠摘要,`data-panel-note`):说清「预警不挂工单」
+            // 与这些数是预警自身的处置流程统计、已解除率的分母(票 78 改写)。
             note: WARNING_EFFICIENCY_GAP,
             rangeNote: warningRangeNote(current),
         })
@@ -457,26 +599,25 @@ function workOrderListBlock(extra) {
     const orders = (data && data.orders) || []
     const paging = (data && data.pagination) || null
 
-    // 票 67:工单列表走全量工单端点,它**不收行业入参**(实测:非法行业也 200、响应逐字节相同),
-    // 所以切行业时上方「工单状态分布」会变、本列表不变。不说明就是一条看不出来的假象
-    // (同一屏两个数各说各话,正是维护者反馈第 8 条抱怨的形态)。措辞归 domain/filterCaliber.js。
-    const industryNote = industryGap('workOrderList')
-    const pageNote = (text) => [text, industryNote].filter(Boolean).join(' · ')
+    // 票 89:片区与行业两轴缺口均已关闭 —— `/work-orders` 自票 77 收 `industry`、
+    // 部署实测后确认也收 `district`(非法值 400、district=0/1 读数不同),本列表与上方
+    // 「工单状态分布」现在按同一批工单收窄,不再需要块级缺口说明。
+    const WORK_ORDER_BASIS = '按创建时间落在此窗口,与上方「工单状态分布」是同一批工单'
 
     if (!loaded) {
-        return listBlock('当前工单列表', [], '正在取数…', { pageNote: pageNote('按创建时间落在此窗口,与上方「工单状态分布」是同一批工单') })
+        return listBlock('当前工单列表', [], '正在取数…', { pageNote: WORK_ORDER_BASIS })
     }
     if (section.state !== 'success' && section.state !== 'stale' && orders.length === 0) {
         // 失败/不可用:明确说没取到,不画成「0 张工单」。
         return listBlock('当前工单列表', [], '工单列表未取到(其余读数不受影响)', {
-            pageNote: pageNote('按创建时间落在此窗口,与上方「工单状态分布」是同一批工单'),
+            pageNote: WORK_ORDER_BASIS,
         })
     }
     return listBlock('当前工单列表', workOrderCardRows(orders), '当前没有工单', {
         serverPage: paging ? { page: paging.page, pageCount: paging.totalPages, total: paging.total } : undefined,
-        pageNote: pageNote(paging
+        pageNote: paging
             ? `本窗口内创建的工单共 ${paging.total} 张 · 与上方「工单状态分布」是同一批工单(均按创建时间落在所选范围)`
-            : '按创建时间落在此窗口,与上方「工单状态分布」是同一批工单'),
+            : WORK_ORDER_BASIS,
     })
 }
 
@@ -553,33 +694,74 @@ export const ALARM_EFFICIENCY_BASIS =
     '报警以工单为处置手段:处置率=已结案工单 ÷ 可计入工单(状态 0–6,排除已取消),单位为工单张数'
 
 /**
- * 预警族的窗口读数(票 65):条数与工单关联实况都取**本窗口**的计数。
- * 后端未给任何一项时回空串(不编造「0 条」——那是在说一件没被告知的事)。
+ * 预警族的窗口读数(票 65 立法;票 78 扩到处置流程读数):
+ * 条数(即已解除率的分母)、已解除条数(分子)、时长样本数与工单关联实况都取**本窗口**计数。
+ * 后端未给任何一项时回空串(不编造「0 条」——那是在说一件没被告知的事);
+ * EMPTY 的聚合不给读数(「本窗口没有预警」已经由 KPI 的 hint 说清,不在这里重复半句)。
  * 等级分布来自**已返回的事件行**,分页未取全时如实说明分母不全(不拿半份冒充全量)。
  */
 function warningRangeNote(current) {
     const parts = []
     if (numberish(current.totalEvents) !== null) parts.push(`本窗口预警 ${current.totalEvents} 条`)
+    const efficiency = current.warningEfficiency
+    if (efficiency && efficiency.state !== 'EMPTY' && numberish(efficiency.numerator) !== null) {
+        parts.push(`已解除 ${efficiency.numerator} 条`)
+    }
+    const duration = current.warningDuration
+    if (duration && duration.state !== 'EMPTY' && numberish(duration.sampleCount) !== null && duration.sampleCount > 0) {
+        parts.push(`时长样本 ${duration.sampleCount} 条`)
+    }
     if (numberish(current.linkedWorkOrderCount) !== null) parts.push(`已关联工单 ${current.linkedWorkOrderCount} 条`)
     if (numberish(current.missingWorkOrderCount) !== null) parts.push(`未关联工单 ${current.missingWorkOrderCount} 条`)
     if (current.levelDistributionComplete === false) parts.push('等级分布仅统计已返回的事件页,非全量')
     return parts.join(' · ')
 }
 
-export function eventAnalysisPanel(data, section) {
+/**
+ * 预警分析面板的**「事件」口径说明**(票 66)。
+ *
+ * 维护者 2026-10-19 反馈第 7 条的第二个问题:面板上同时出现「事件类型」「事件等级」「历史事件」
+ * 「重点识别」,但界面上从没说过「事件」在预警/报警两族下分别指什么、与「预警」「报警」
+ * 是什么关系,值班人员只能猜。这里给一句话口径。
+ *
+ * 词一律走 `eventListSwitchView`(预警/报警的**唯一**词表,票 13 立的),本函数不写字面量:
+ * 切族时说明文字必然跟着换,不会出现「说明说预警、数据是报警」。
+ */
+export function eventAnalysisBasis(family) {
+    const view = eventListSwitchView(family)
+    const other = eventListSwitchView(view.key === EVENT_FAMILY.ALARM ? EVENT_FAMILY.WARNING : EVENT_FAMILY.ALARM)
+    return `「事件」是${view.label}与${other.label}两类告警的合称:本面板只统计当前选中的「${view.label}」,` +
+        `上面的「事件类型」「事件等级」也都只指这些${view.label};` +
+        '报警以工单为处置手段、预警不挂工单,工单是处置记录,本身不算事件'
+}
+
+/**
+ * 预警分析面板(票 19;票 66 加翻译层与「事件」口径说明)。
+ *
+ * @param {object|null} data `toEventAnalysis()` 的领域对象(label 已是中文)
+ * @param {object} section 分区
+ * @param {{mode?: string}} [extra] 当前切换族(与效率面板同一接缝)
+ */
+export function eventAnalysisPanel(data, section, extra) {
     const current = data || {}
+    const family = current.family || (extra && extra.mode) || EVENT_FAMILY.WARNING
     const highlights = (current.highlights || []).map((item, index) => ({
         key: `${item.title || '重点'}-${index}`,
         title: item.title || '重点识别',
         meta: [item.detail, item.count === null ? '' : `事件 ${item.count} 条`].filter(Boolean),
         points: [],
     }))
+    // 成因块(票 66):**后端回出这一维才画**。真实两族都恒定回(缺成因走 UNKNOWN_CAUSE 兜底桶),
+    // 但旧载荷/mock 可能没有这一维 —— 没有就不画假块(票 05 的「缺值不画假 0」同一立场),
+    // 顺便避免空环形图落进渲染层那句「当前范围内没有监测点与未关联设备」(那是监测概览的措辞)。
+    const causes = sortByCountDesc(current.causes || [], (row) => row.count)
     return sectionView('eventAnalysis', section, [
         // 2026-10-13 维护者反馈:条形图四连排视觉疲劳——类别少的构成改环形图(行业=行业色、
         // 类型=图表色序),等级/片区保留条形(条形适合作排名比较)。行业行保留联动。
         // 票 37 走查扩展:类型/行业/片区是排名类别按计数降序(并列保序);
         // 事件等级是**固定序**(1–4 级语义阶梯),保持后端字典序不打乱。
         donutBlock('按事件类型', sortByCountDesc(current.types || [], (row) => row.count)),
+        ...(causes.length ? [donutBlock('按成因', causes)] : []),
         donutBlock(
             '按行业分布',
             sortByCountDesc(
@@ -594,6 +776,8 @@ export function eventAnalysisPanel(data, section) {
     ], {
         label: '预警分析',
         range: current.range,
+        // 票 66:一句话说清「事件」指什么(走票 56 的折叠摘要,data-panel-note)。
+        note: eventAnalysisBasis(family),
     })
 }
 

+ 75 - 25
src/modules/cockpit/mocks/contractMocks.js

@@ -1553,17 +1553,22 @@ function historyAnalysisPayload(scenario, query) {
 }
 
 /**
- * `/events/handling-efficiency`(票 19;票 65 两族语义分流)。
+ * `/events/handling-efficiency`(票 19;票 65 两族语义分流;**票 78 随票 74 契约两族分叉**)。
  *
  * 形状照抄后端 `CockpitHandlingEfficiencyResponse`:`eventFamily` 回显 + `summary`
- * (条数/工单关联实况/工单口径处置率与时长)+ `workOrderStateCounts`(八态全铺)+
- * `durationTrend`(逐桶 `eventCount` 与结案时长)+ 逐事件行 `events[]`。
+ * (条数/工单关联实况 + 两族各自的处置口径聚合)+ 状态计数 + `durationTrend`
+ * (逐桶 `eventCount` 与结案时长)+ 逐事件行 `events[]`。
  *
- * **两族同形但语义不同**(票 65 的票面事实):后端对两族回同一套字段,可预警不挂工单。
- * mock 因此照着真实数据给:预警族 `linkedWorkOrderCount=0`、`missingWorkOrderCount=条数`
- * (逐事件行 `hasWorkOrder=false`),工单口径字段照旧存在但**只在报警模式下被消费**;
- * 报警族按等级 1 的事件给承接工单(样例里 `linked=4`),让工单口径在报警模式有真读数。
- * 不在这里「帮」前端把预警族的工单字段置空 —— 那会把契约缺口藏起来,与票面纪律相反。
+ * **两族契约分叉**(票 74 已交付,后端实测见 `verify/artifacts/t74/`):
+ *   · 预警族:工单口径六字段(`handledCount/inProgressCount/cancelledCount/`
+ *     `handlingEfficiency/averageDuration/workOrderStateCounts`)显式 `null`;
+ *     预警自身流程给 `warningStateCounts`(WarningWorkflow 六态全铺、缺桶计 0,
+ *     脏状态入「未知」桶)+ `summary.warningHandlingEfficiency`(已解除 ÷ 窗口内预警数,
+ *     分母 0 → EMPTY/NO_WARNINGS)+ `summary.warningDuration`(无样本 → EMPTY/NO_CLOSED_WARNINGS)。
+ *   · 报警族:工单口径原样,三个 `warning*` 字段为 `null`。
+ * 六态计数从样例事件的 `status` 推导(认得出的入对应桶,认不出的一律入 UNKNOWN ——
+ * 与后端 `normalizeWarningStatus` 的归一规则同构),六态合计恒等于条数;
+ * 不为演示手调分布数字(map Notes 的静态样例裁定)。
  */
 function handlingEfficiencyPayload(scenario, query) {
     const source = responseEventSource(query)
@@ -1572,8 +1577,9 @@ function handlingEfficiencyPayload(scenario, query) {
     const denominator = blank ? 0 : 3
     const numerator = blank ? 0 : 2
     const items = blank ? [] : source.items
+    const isAlarm = source.mode === 'ALARM'
     // 预警不挂工单(实测),报警按等级 1 的样本给承接工单 —— 两族的关联实况刻意不同。
-    const linkedOf = (item) => source.mode === 'ALARM' && Number(item.level) === 1
+    const linkedOf = (item) => isAlarm && Number(item.level) === 1
     const linkedCount = items.filter(linkedOf).length
     const events = items.map((item) => {
         const linked = linkedOf(item)
@@ -1591,33 +1597,75 @@ function handlingEfficiencyPayload(scenario, query) {
             hasWorkOrder: linked,
         }
     })
+    // 票 78:WarningWorkflow 六态全铺(缺桶计 0),认不出的状态入 UNKNOWN 桶(后端同构)。
+    const warningStatuses = ['DRAFT', 'PENDING', 'RECEIVED', 'PROCESSING', 'HANDLED', 'CLOSED']
+    const warningStateCounts = isAlarm
+        ? null
+        : warningStatuses
+            .map((stateCode) => ({
+                stateCode,
+                stateName: { DRAFT: '草稿', PENDING: '待接收', RECEIVED: '已接收', PROCESSING: '处置中', HANDLED: '已处置', CLOSED: '已解除' }[stateCode],
+                count: items.filter((item) => item && item.status === stateCode).length,
+            }))
+            .concat((() => {
+                const unknown = items.filter((item) => item && !warningStatuses.includes(item.status)).length
+                return unknown > 0 ? [{ stateCode: 'UNKNOWN', stateName: '未知', count: unknown }] : []
+            })())
+    // 预警族自身的已解除率/时长:与后端同一算法(CLOSED ÷ 条数;无 CLOSED 样本即 EMPTY)。
+    const closedWarnings = isAlarm ? 0 : (warningStateCounts.find((row) => row.stateCode === 'CLOSED') || { count: 0 }).count
+    const warningTotal = isAlarm ? 0 : items.length
+    const summary = isAlarm ? {
+        totalEvents: items.length,
+        linkedWorkOrderCount: linkedCount,
+        missingWorkOrderCount: items.length - linkedCount,
+        handledCount: blank ? 0 : 2,
+        inProgressCount: blank ? 0 : 1,
+        cancelledCount: blank ? 0 : 1,
+        handlingEfficiency: { value: blank ? 0 : numerator / denominator, numerator, denominator, state: denominator ? 'AVAILABLE' : 'EMPTY', reason: denominator ? 'READY' : 'NO_ELIGIBLE_WORK_ORDERS', basis: 'CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS' },
+        averageDuration: { value: denominator ? 2.4 : 0, unit: 'HOURS', state: denominator ? 'AVAILABLE' : 'EMPTY', sampleCount: denominator ? 2 : 0, reason: denominator ? null : 'NO_CLOSED_WORK_ORDERS' },
+        // 票 78:报警族不回预警自身字段(后端显式 null,不缺键)。
+        warningHandlingEfficiency: null,
+        warningDuration: null,
+    } : {
+        totalEvents: items.length,
+        linkedWorkOrderCount: linkedCount,
+        missingWorkOrderCount: items.length - linkedCount,
+        // 票 78:工单口径在预警族显式 null(不把「不适用」数成 0)。
+        handledCount: null,
+        inProgressCount: null,
+        cancelledCount: null,
+        handlingEfficiency: null,
+        averageDuration: null,
+        warningHandlingEfficiency: warningTotal > 0
+            ? { value: (closedWarnings / warningTotal).toFixed(4), numerator: closedWarnings, denominator: warningTotal, state: 'AVAILABLE', reason: null, basis: 'CLOSED_WARNINGS_OVER_WINDOW_WARNINGS' }
+            : { value: '0.0000', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_WARNINGS', basis: 'CLOSED_WARNINGS_OVER_WINDOW_WARNINGS' },
+        warningDuration: closedWarnings > 0
+            ? { value: '1.25', unit: 'HOURS', sampleCount: closedWarnings, state: 'AVAILABLE', reason: null }
+            : { value: '0.00', unit: 'HOURS', sampleCount: 0, state: 'EMPTY', reason: 'NO_CLOSED_WARNINGS' },
+    }
     return {
         requestId: 'mock-handling-efficiency', apiVersion: 'v1', readOnly: true,
-        displayName: source.mode === 'ALARM' ? '报警效率' : '预警效率',
+        displayName: isAlarm ? '报警效率' : '预警效率',
         eventFamily: source.mode,
         range, updatedAt: SNAPSHOT_AT,
         pagination: { page: 1, pageSize: 100, total: items.length, totalPages: items.length ? 1 : 0, hasMore: false },
-        summary: {
-            totalEvents: items.length,
-            linkedWorkOrderCount: linkedCount,
-            missingWorkOrderCount: items.length - linkedCount,
-            handledCount: blank ? 0 : 2,
-            inProgressCount: blank ? 0 : 1,
-            cancelledCount: blank ? 0 : 1,
-            handlingEfficiency: { value: blank ? 0 : numerator / denominator, numerator, denominator, state: denominator ? 'AVAILABLE' : 'EMPTY', reason: denominator ? 'READY' : 'NO_ELIGIBLE_WORK_ORDERS', basis: 'CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS' },
-            averageDuration: { value: denominator ? 2.4 : 0, unit: 'HOURS', state: denominator ? 'AVAILABLE' : 'EMPTY', sampleCount: denominator ? 2 : 0, reason: denominator ? null : 'NO_CLOSED_WORK_ORDERS' },
-        },
+        summary,
         // 正式端点仅提供结案平均时长,不编造平均响应时长。
         // 票 20:键为后端 0–7 状态码(待派单…已取消)。旧的 1–7 键已随字典对齐改掉。
-        workOrderStateCounts: WORK_ORDER_STATES.map((state) => ({ stateCode: state.code, stateName: state.name, count: denominator ? state.code === 5 ? 2 : state.code === 6 ? 1 : state.code === 7 ? 1 : 0 : 0 })),
-        // 票 65:逐桶带 `eventCount`(事件发生量;预警族只消费这一份,结案时长属工单口径)。
+        // 票 78:工单状态计数只属报警族;预警族显式 null。
+        workOrderStateCounts: isAlarm
+            ? WORK_ORDER_STATES.map((state) => ({ stateCode: state.code, stateName: state.name, count: denominator ? state.code === 5 ? 2 : state.code === 6 ? 1 : state.code === 7 ? 1 : 0 : 0 }))
+            : null,
+        warningStateCounts,
+        // 票 65:逐桶带 `eventCount`(事件发生量)。票 74 契约:预警族逐桶的
+        // `handledCount`/`averageDuration` 为 null(结案时长属工单口径),报警族保留。
         durationTrend: blank
             ? []
             : [1.4, 2.1, 2.4, 1.8, 2.6, 2.2, 2.4].map((value, index) => ({
                   timeBucket: `2026-09-${String(index + 1).padStart(2, '0')}`,
                   eventCount: 2,
-                  handledCount: 0,
-                  averageDuration: { value, unit: 'HOURS', state: 'AVAILABLE' },
+                  handledCount: isAlarm ? 0 : null,
+                  averageDuration: isAlarm ? { value, unit: 'HOURS', state: 'AVAILABLE' } : null,
               })),
         events,
     }
@@ -3052,7 +3100,9 @@ const PAYLOADS = {
     '/monitoring/distribution': (s, _requested, context) =>
         monitoringDistributionPayload(s, context.query, context.monitoring),
     '/monitoring/situation': (s, _requested, context) => monitoringSituationPayload(s, context.query),
-    // 票 05–08:总体概览四模块端点(四块各一个,互不牵连)。都是**无入参**端点。
+    // 票 05–08:总体概览四模块端点(四块各一个,互不牵连)。票 89 起前端会下发
+    // district/industry(后端已补齐入参);mock 样例仍是**固定读数、不按条件裁剪** ——
+    // 参数是否真的进了 URL 由 t67 闸门注入 fetch 捕获判定,不靠 mock 假装筛过。
     '/overview/perception': (s) => overviewPerceptionPayload(s),
     '/overview/pipe-network': (s) => overviewPipeNetworkPayload(s),
     '/overview/warning-study': (s) => overviewWarningStudyPayload(s),

+ 9 - 0
src/modules/cockpit/services/detailApi.js

@@ -12,6 +12,7 @@ import { toWorkOrder, toWorkOrderTimeline } from '../domain/workOrder.js'
 import { isEmptyBy, fetchSection } from './domainRequest.js'
 import { deviceMetricKind, deviceMetricPath, toTypedMetricsPayload } from '../domain/deviceMetricSources.js'
 import { SECTION_STATE } from '../domain/sectionState.js'
+import { districtQuery, industryQuery } from '../domain/filterOptions.js'
 
 function missingCode(kind, code) {
     return Promise.resolve({
@@ -150,6 +151,14 @@ export function fetchWorkOrderList(query = {}, options = {}) {
     // 时间窗原样透传(ISO-8601 带偏移):掐头去尾的格式化会让「同一窗口」与其它面板不一致。
     if (typeof query.startTime === 'string' && query.startTime) params.set('startTime', query.startTime)
     if (typeof query.endTime === 'string' && query.endTime) params.set('endTime', query.endTime)
+    // 票 79:/work-orders 自票 77 起真收 industry(部署后实测非法值 400、GAS/WATER 响应不同);
+    // 口径与上方「工单状态分布」同一套(工单设备 → 设备类型树 → 行业)。
+    const industry = industryQuery(options.industry)
+    if (industry) params.set('industry', industry)
+    // 票 89:后端已把片区入参补到本端点(响应另带 `filters` 回显,票 81)。0 是合法编码
+    // (城南),**不能用真值判断**跳过 —— 只挡掉空串(全域=不传)。
+    const district = districtQuery(options.scope)
+    if (district !== '') params.set('district', String(district))
     const queryString = params.toString()
 
     return fetchSection(`/work-orders${queryString ? `?${queryString}` : ''}`, {

+ 10 - 6
src/modules/cockpit/services/eventApi.js

@@ -8,11 +8,12 @@
  * 分工与票 05/17/18 的其它领域模块完全一致:本文件只做「拼查询参数 → 走 04 的统一出口 →
  * 用 `sectionFromResult` 收成一个分区」。**不在这里发起 fetch、不出现凭据**。
  *
- * **片区入参(票 67,2026-10-22 实测)**:本端点**不认 `district`** —— 非法值
- * (`district=99`)照 200、`district=0/1` 的响应逐字节相同,响应里的 `filters` 也没有片区字段。
- * 所以这里**不下发**片区:给不认的参数塞值只会让人以为筛过了。同一族里只有
- * `/events/multidimensional-analysis` 真收片区(见 `warningResponseApi.js`),
- * 本面板"不随当前范围变化"由 `domain/filterCaliber.js` 在界面上如实说明。
+ * **片区入参(票 79/80 更正,2026-10-27 部署态实测)**:本端点自票 77 起**真收 `district`**
+ * —— 非法值(`district=99`)400 `district must be 0, 1, 2 or UNASSIGNED`、
+ * `district=0/1` 的读数真的不同,响应 `filters` 双字段回显 `district`/`districtName`
+ * (判定单源在 `domain/filterCaliber.js` 的 `districtEchoView`)。所以这里与预警分析
+ * 同一份 `scope` 下发(见 `warningResponseApi.js`);不传=全域,不得静默当成某个片区。
+ * 票 67 时代「本端点不认片区」的旧结论已过期,缺口说明已随票 79 撤除。
  *
  * 与设备维度 `/equipments/{code}/events/current` 的区别:那是单台设备的当前事件
  * (`CockpitEventReadResponse`,`domain/events.js` 的 `toEventRead`),
@@ -26,7 +27,7 @@
  */
 import { toCurrentEvents } from '../domain/eventList.js'
 import { EVENT_FAMILY } from '../domain/events.js'
-import { industryQuery } from '../domain/filterOptions.js'
+import { districtQuery, industryQuery } from '../domain/filterOptions.js'
 import { fetchSection } from './domainRequest.js'
 
 /** 跨设备事件端点(组件与 store 都不自己拼路径)。 */
@@ -76,6 +77,9 @@ export function fetchCurrentEvents(options = {}) {
             mode: modeParam(options.mode),
             level: levelParam(options.level),
             industry: industryQuery(options.industry),
+            // 票 79:/events/current 自票 77 起真收 district(部署后实测:非法值 400、0/1 计数不同),
+            // 与多维分析同一份 scope —— 「当前范围」不再只作用于预警分析一块。
+            district: districtQuery(options.scope),
             status: eventStatusParam(options.status),
             page: options.page,
             pageSize: options.pageSize,

+ 8 - 2
src/modules/cockpit/services/infrastructureApi.js

@@ -14,6 +14,10 @@
  *
  * 全局行业筛选(CONTEXT.md:作用于整个驾驶舱的全局条件)在这里落成 `industry` 入参:
  * 前端键 → 后端码的映射归 `domain/filterOptions.js` 的 `industryQuery()`。
+ *
+ * 全局片区筛选(票 89):四块里基础档案与管理信息早已下发,运行信息与应急保障在后端补齐
+ * 入参前刻意没发(票 67 的缺口句如实交代)—— 后端提交补齐并部署实测后(2026-10-27),
+ * 这里照同一份 `scope` 下发(`districtQuery()`,0 必须保留、全域不传),缺口句已撤除。
  * **票 18 更正(2026-09-28)**:「井盖」现已可筛 —— `MANHOLE` 是后端第四个正式行业,
  * 且这些基础设施服务都收(原「无契约位故不传参」的说法已被后端推翻);
  * 认不出的键仍回空串=不传参(不拿脏值去换 400)。
@@ -70,7 +74,8 @@ export function fetchBaseArchives(options = {}) {
  */
 export function fetchOperatingInformation(options = {}) {
     return fetchSection(INFRASTRUCTURE_PATHS.operation, {
-        params: { industry: industryParam(options.industry), range: options.range },
+        // 票 89:该端点已由后端补齐片区入参(与档案/管理同一份 scope)。
+        params: { industry: industryParam(options.industry), district: districtQuery(options.scope), range: options.range },
         toData: toOperatingInformation,
         isEmpty: isEmptyBy((payload) => {
             const blocks = Array.isArray(payload && payload.industries) ? payload.industries : []
@@ -110,7 +115,8 @@ export function fetchManagementInformation(options = {}) {
  */
 export function fetchEmergencySupport(options = {}) {
     return fetchSection(INFRASTRUCTURE_PATHS.emergency, {
-        params: { industry: industryParam(options.industry), category: options.category, area: options.area },
+        // 票 89:该端点已由后端补齐片区入参(与档案/管理同一份 scope)。
+        params: { industry: industryParam(options.industry), district: districtQuery(options.scope), category: options.category, area: options.area },
         toData: toEmergencySupport,
         isEmpty: isEmptyBy((payload) => {
             const totals = (payload && payload.totals) || {}

+ 61 - 16
src/modules/cockpit/services/monitoringApi.js

@@ -3,10 +3,10 @@
  *
  * 三块面板各有一个**面板级聚合读模型**端点(票 24 调研的核心纠正:后端早已为这些画面
  * 备好了读模型,不必从单设备端点上拼):
- *   monitoringOverview      → `/monitoring/overview`(`industry` / `deviceType`)
+ *   monitoringOverview      → `/monitoring/overview`(`industry` / `district` / `deviceType`)
  *   monitoringDistribution  → `/monitoring/distribution`(`industry` / `deviceType` /
  *                             `pointStatus` / `district` / `page` / `pageSize` / `snapshotAt`)
- *   operationOverview       → `/monitoring/situation`(`industry` / `deviceType` /
+ *   operationOverview       → `/monitoring/situation`(`industry` / `district` / `deviceType` /
  *                             `startTime` / `endTime` / `page` / `pageSize`)
  *
  * 分工与票 05/17 的其它领域模块完全一致:本文件只做「拼查询参数 → 走 04 的统一出口 →
@@ -20,7 +20,9 @@
  * 入参映射的边界(三处后端契约差异,如实收在这里,不往上层漏):
  *   · `industry`:前端键 → 后端码归 `domain/filterOptions.js` 的 `industryQuery()`;
  *     四个正式行业(含窨井 `MANHOLE`)都下发,认不出的键回空串=不传参;
- *   · district:监测分布按正式编码筛选,0/1/2/UNASSIGNED;全域不传。
+ *   · district:三块(票 89 起)都按正式编码筛选,0/1/2/UNASSIGNED;全域不传。
+ *     监测概览与运行概况在后端补齐片区入参前刻意没下发(票 67 的缺口句如实交代);
+ *     后端补齐并部署实测后(2026-10-27)照同一份 `scope` 下发,缺口句已撤除。
  *   · `pointStatus`:**两端点都收**(票 14;决策 02/规格 07 冻结的多值契约——此前只有
  *     监测分布收,`map-snapshot` 不收的缺口已由后端契约修订关闭)。多值逗号分隔,
  *     八桶取值(六态 + UNKNOWN + UNASSOCIATED);认不出的值后端 400,前端白名单先挡。
@@ -31,7 +33,8 @@ import {
     toMonitoringSituation,
 } from '../domain/monitoringPanels.js'
 import { industryQuery, districtQuery } from '../domain/filterOptions.js'
-import { fetchSection, isEmptyBy } from './domainRequest.js'
+import { buildQuery, fetchSection, isEmptyBy } from './domainRequest.js'
+import { requestCockpitJson } from './cockpitApi.js'
 
 /** 三个面板端点的路径(组件与 store 都不自己拼路径)。 */
 export const MONITORING_PATHS = {
@@ -89,7 +92,12 @@ export function statusParam(pointStatus) {
  */
 export function fetchMonitoringOverview(options = {}) {
     return fetchSection(MONITORING_PATHS.monitoringOverview, {
-        params: { industry: industryParam(options.industry), deviceType: options.deviceType },
+        params: {
+            industry: industryParam(options.industry),
+            // 票 89:该端点已由后端补齐片区入参(0 必须保留,全域回空串=不传)。
+            district: districtQuery(options.scope),
+            deviceType: options.deviceType,
+        },
         toData: toMonitoringOverview,
         isEmpty: isEmptyBy((payload) => {
             const counts = (payload && payload.counts) || {}
@@ -108,21 +116,28 @@ export function fetchMonitoringOverview(options = {}) {
  * 监测分布(票 18 验收第 2 条:覆盖情况、状态分布和地图筛选);
  * 台账与它**共用本函数的返回值**(验收第 4 条)。
  *
+ * 票 62:新增 `options.raw` —— 台账装载链要拿到**未收成分区**的原始结果(它自己判
+ * 成败与快照 token,不信分区状态)。默认行为一字不变,既有调用零影响。
+ *
  * 「空」= 后端说这一屏既没有监测点、也没有未关联设备,且总数 0。
  * 分页窗口横跨两类对象(后端是一条总序列),所以判据要两类都看。
  */
 export function fetchMonitoringDistribution(options = {}) {
-    return fetchSection(MONITORING_PATHS.monitoringDistribution, {
-        params: {
-            industry: industryParam(options.industry),
-            deviceType: options.deviceType,
-            pointStatus: statusParam(options.pointStatus),
-            // 片区直接按正式编码下发,包含未分区哨兵。
-            district: districtQuery(options.scope),
-            page: options.page,
-            pageSize: options.pageSize,
-            snapshotAt: options.snapshotAt,
-        },
+    const path = MONITORING_PATHS.monitoringDistribution
+    const query = {
+        industry: industryParam(options.industry),
+        deviceType: options.deviceType,
+        pointStatus: statusParam(options.pointStatus),
+        // 片区直接按正式编码下发,包含未分区哨兵。
+        district: districtQuery(options.scope),
+        page: options.page,
+        pageSize: options.pageSize,
+        // 票 62:第 2 页起必须带第 1 页锁定的快照(后端 `validatePagination` 硬要求)。
+        snapshotAt: options.snapshotAt,
+    }
+    if (options.raw === true) return fetchDistributionRaw(path, query, options)
+    return fetchSection(path, {
+        params: query,
         toData: toMonitoringDistribution,
         isEmpty: isEmptyBy((payload) => {
             const points = Array.isArray(payload && payload.monitoringPoints) ? payload.monitoringPoints.length : 0
@@ -136,6 +151,34 @@ export function fetchMonitoringDistribution(options = {}) {
     })
 }
 
+/**
+ * 台账装载链的取一页(票 62):走同一个统一出口,但**不经过分区收口** ——
+ * 装载链自己要判「成功/快照失效/失败」,把它收成 `{ ok, data, reason }` 交回去。
+ * 领域转换仍复用 `toMonitoringDistribution`(前端不认后端裸字段)。
+ */
+async function fetchDistributionRaw(path, params, options) {
+    const result = await requestCockpitJson(`${path}${buildQuery(params)}`, {
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+    if (!result || result.ok !== true) {
+        return { ok: false, reason: (result && result.reason) || 'unavailable' }
+    }
+    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,
+        },
+    }
+}
+
 /**
  * 运行概况(票 18 验收第 3 条:事件趋势、离线设备和异常态势)。
  *
@@ -147,6 +190,8 @@ export function fetchMonitoringSituation(options = {}) {
     return fetchSection(MONITORING_PATHS.operationOverview, {
         params: {
             industry: industryParam(options.industry),
+            // 票 89:该端点已由后端补齐片区入参(与概览、分布同一份 scope)。
+            district: districtQuery(options.scope),
             deviceType: options.deviceType,
             startTime: options.startTime,
             endTime: options.endTime,

+ 62 - 109
src/modules/cockpit/services/overviewApi.js

@@ -1,98 +1,77 @@
 /**
- * 总体概览模式的领域接口(票 20)。
+ * 总体概览四模块的领域接口(2026-10-22 四模块重做 · 票 05–08)。
  *
- * 三个端点(票 24 调研的核心纠正:后端早已为这些画面备好了面板级聚合读模型):
- *   `/overview/infrastructure`   —— **不收任何入参**(控制器逐行读过)
- *   `/overview/monitoring`       —— 收 `industry`
- *   `/overview/warning-settings` —— 收 `mode` / `range` / `startTime` / `endTime`
+ * 四个端点,四块面板各自独立成败(一块 500 不打掉其它三块):
+ *   `/overview/perception`      —— 感知设备(规格 §2)
+ *   `/overview/pipe-network`    —— 基础设施(规格 §3)
+ *   `/overview/warning-study`   —— 运行监测-预警研判(规格 §4)
+ *   `/overview/alarm-disposal`  —— 运行监测-报警处置(规格 §5)
  *
- * 另加一个**借用**的端点(见 `domain/overviewPanels.js` 文件头第 ② 条):
- *   `/infrastructure/base-archives` —— 票 17 的端点,只为拿 `districtDistribution`
- *   (验收第 2 条点名「区域分布」,而概览端点自己不含区域字段)。
+ * **票 89 更正**:后端已把全局 `district` 与 `industry` 入参补齐到这四个端点
+ * (规格 §1「不收入参」的旧口径作废)。所以四块照同一份全局条件下发参数
+ * (`districtQuery()` / `industryQuery()`,全域回空串=不传),外壳也随之在筛选变化时重取。
+ * 2026-10-27 部署实测确认四块两轴都生效(非法值 400、合法值读数不同),
+ * 界面上原先的缺口句已撤除。
  *
- * 分工与票 05/17/18/19 完全一致:本文件只做「拼查询参数 → 走 04 的统一出口 →
- * 用 `sectionFromResult` 收成一个分区」。**每块各自成败**(一块端点坏了不牵连其它块)。
+ * 空判据是这四块最容易画错的地方(规格 §6.7 与盘点 §7.2):
+ *   · 感知/预警研判/报警处置:「无数据」时后端回 `freshness.status = EMPTY` 但**仍给固定行**
+ *     (4 类型 / 3 片区 / 4 等级 / 7 个趋势点)。这些固定项必须画 0,所以 `isEmpty` 只认
+ *     「关键段缺失」,不按计数为 0 判空;视图层再把 `EMPTY + data` 视为可画(见各视图 `hasContent`)。
+ *   · 基础设施:没有管道就**不画假环**,`pipeCount = 0` 判空(EMPTY 无 data → 面板占位)。
  *
- * 三处入参口径,如实收在这里、不往上层漏:
- *   · `industry`:**只有 `/overview/monitoring` 收**(前端键 → 后端码归 `industryQuery()`)。
- *     另两个概览端点不收筛选,所以全局行业筛选在概览里只作用于一块 —— 界面如实说明;
- *   · `range`:本票固定声明 `LAST_7_DAYS`(验收第 1 条的「近 7 日趋势」)。
- *     后端只认 `LAST_24_HOURS`/`LAST_7_DAYS`/`LAST_30_DAYS` 与成对的显式区间,认不出会 400;
- *   · `mode`:**不传**。后端一次就返回预警与报警两族,做切换等于把已有数据藏起来
- *     (票 20 的 Q1 裁定)。
+ * 分工与其它领域服务一致:本文件只做「路径 + 转换函数 + 空判据」,地址/凭据/mock-real
+ * 切换全在 `cockpitApi.js`(本文件不出现 `Authorization`)。
  */
 import {
-    toOverviewInfrastructure,
-    toOverviewMonitoring,
-    toOverviewRegions,
-    toOverviewWarningSettings,
+    toAlarmDisposal,
+    toPerception,
+    toPipeNetwork,
+    toWarningStudy,
 } from '../domain/overviewPanels.js'
-import { industryQuery, districtQuery } from '../domain/filterOptions.js'
+import { districtQuery, industryQuery } from '../domain/filterOptions.js'
 import { fetchSection, isEmptyBy } from './domainRequest.js'
 
-/** 三个概览端点的路径(组件与 store 都不自己拼路径)。 */
-export const OVERVIEW_PATHS = {
-    infrastructure: '/overview/infrastructure',
-    monitoring: '/overview/monitoring',
-    warningSettings: '/overview/warning-settings',
-    /** 借用来取区域分布的票 17 端点。 */
-    regions: '/infrastructure/base-archives',
-}
-
-/** 后端信封上的 `freshness.status`(三块同款)。 */
-const freshnessOf = (payload) => (payload && payload.freshness && payload.freshness.status) || ''
-
 /**
- * 本票声明的趋势窗口。
+ * 四个端点共用的全局筛选入参(票 89)。
  *
- * 「近 7 日趋势」是验收第 1 条点名的读数,而后端趋势桶**跟随声明的窗口过滤**
- * (`CockpitOverviewWarningSettingsService.resolveWindow` 的 `LAST_7_DAYS`)。
- * 不声明时后端维持 `CURRENT_BASELINE`(无界),那时"近 7 日"就只是界面上的一句话 —— 所以必须声明。
+ * 全域/未传 → 空串,`buildQuery` 跳过(全域不等于某个片区/行业);**0 必须原样下发**
+ * (城南是合法片区编码,不能被真值判断吃掉)。
  */
-export const OVERVIEW_RANGE = 'LAST_7_DAYS'
+function filterParams(options) {
+    return { district: districtQuery(options.scope), industry: industryQuery(options.industry) }
+}
 
-/** 行业入参:只有认得出的正式行业才传(`all` 与认不出的键都回空串,`buildQuery` 会跳过空值)。 */
-function industryParam(industry) {
-    return industryQuery(industry)
+/** 四个概览端点的路径(组件与 store 都不自己拼路径)。 */
+export const OVERVIEW_PATHS = {
+    perception: '/overview/perception',
+    pipeNetwork: '/overview/pipe-network',
+    warningStudy: '/overview/warning-study',
+    alarmDisposal: '/overview/alarm-disposal',
 }
 
-/**
- * 概览基础设施(验收第 2 条:设施底数、行业构成)。
- *
- * 「空」只认后端明说没有:设施数为 0 **且** 一个行业都没有才算空 ——
- * 某一维恰好为 0 不等于整块是空的。
- */
-export function fetchOverviewInfrastructure(options = {}) {
-    return fetchSection(OVERVIEW_PATHS.infrastructure, {
-        // 该端点不收任何入参(控制器无 @RequestParam)。
-        toData: toOverviewInfrastructure,
-        isEmpty: isEmptyBy((payload) => {
-            const totals = (payload && payload.totals) || {}
-            const count = Number(totals.facilityCount)
-            const industries = Array.isArray(payload && payload.industries) ? payload.industries : []
-            return (!Number.isFinite(count) || count === 0) && industries.length === 0
-        }),
+/** 后端信封上的 `freshness.status`(四块同款)。 */
+const freshnessOf = (payload) => (payload && payload.freshness && payload.freshness.status) || ''
+
+/** 感知设备:只判「设备规模段与类型段整个缺失」为不适用;计数为 0 是真实数据(空态由 freshness 说)。 */
+export function fetchPerception(options = {}) {
+    return fetchSection(OVERVIEW_PATHS.perception, {
+        params: filterParams(options),
+        toData: toPerception,
+        isEmpty: isEmptyBy((payload) => !payload || !payload.totals || !Array.isArray(payload.byType)),
         freshnessOf,
         config: options.config,
         fetchImpl: options.fetchImpl,
     })
 }
 
-/**
- * 区域分布(票 17 的 `/infrastructure/base-archives`,验收第 2 条的第三个读数)。
- *
- * 为什么单独一个取数而不是并进上面那个:它是**另一个端点**的成败,
- * 由它自己说(区域取不到不该把设施底数与行业构成一起打没)。
- */
-export function fetchOverviewRegions(options = {}) {
-    return fetchSection(OVERVIEW_PATHS.regions, {
-        params: { industry: industryParam(options.industry), district: districtQuery(options.scope) },
-        toData: toOverviewRegions,
+/** 基础设施:没有管道就不画假环(`pipeCount = 0` → EMPTY 无 data → 面板占位)。 */
+export function fetchPipeNetwork(options = {}) {
+    return fetchSection(OVERVIEW_PATHS.pipeNetwork, {
+        params: filterParams(options),
+        toData: toPipeNetwork,
         isEmpty: isEmptyBy((payload) => {
-            const regions = Array.isArray(payload && payload.districtDistribution) ? payload.districtDistribution : []
             const totals = (payload && payload.totals) || {}
-            const count = Number(totals.facilityCount)
-            return regions.length === 0 && (!Number.isFinite(count) || count === 0)
+            return !payload || !payload.totals || Number(totals.pipeCount) === 0
         }),
         freshnessOf,
         config: options.config,
@@ -100,50 +79,24 @@ export function fetchOverviewRegions(options = {}) {
     })
 }
 
-/**
- * 概览运行监测(验收第 3 条:在线率、离线统计、监测覆盖)。
- *
- * **只有本端点收 `industry`** —— 所以全局行业筛选在概览里就落在这里。
- * 「空」= 设备与监测点都数到 0 且类型分布为空。
- */
-export function fetchOverviewMonitoring(options = {}) {
-    return fetchSection(OVERVIEW_PATHS.monitoring, {
-        params: { industry: industryParam(options.industry) },
-        toData: toOverviewMonitoring,
-        isEmpty: isEmptyBy((payload) => {
-            const counts = (payload && payload.counts) || {}
-            const devices = Number(counts.deviceCount)
-            const points = Number(counts.monitoringPointCount)
-            const types = Array.isArray(payload && payload.typeDistribution) ? payload.typeDistribution.length : 0
-            return (!Number.isFinite(devices) || devices === 0) && (!Number.isFinite(points) || points === 0) && types === 0
-        }),
+/** 预警研判:今日/未解除段缺失才算空;等级卡与排行在「0 条」时仍要画 0 / 空榜。 */
+export function fetchWarningStudy(options = {}) {
+    return fetchSection(OVERVIEW_PATHS.warningStudy, {
+        params: filterParams(options),
+        toData: toWarningStudy,
+        isEmpty: isEmptyBy((payload) => !payload || !payload.today || !payload.unresolved),
         freshnessOf,
         config: options.config,
         fetchImpl: options.fetchImpl,
     })
 }
 
-/**
- * 概览预警设置(验收第 1 条的当前预警/报警 + 处置率 + 近 7 日趋势;验收第 4 条的统计)。
- *
- * **一次给两族**(`warning` + `alarm`),所以态势总览与预警设置共用这一次取数(Q1 裁定)。
- * `range` 固定声明 `LAST_7_DAYS`(见 `OVERVIEW_RANGE`)。
- *
- * 「空」= 两族都数到 0 且两族趋势都为空。
- */
-export function fetchOverviewWarningSettings(options = {}) {
-    return fetchSection(OVERVIEW_PATHS.warningSettings, {
-        params: { range: OVERVIEW_RANGE },
-        toData: toOverviewWarningSettings,
-        isEmpty: isEmptyBy((payload) => {
-            const total = (family) => Number(((payload && payload[family]) || {}).total)
-            const trend = (family) => {
-                const list = ((payload && payload[family]) || {}).trend
-                return Array.isArray(list) ? list.length : 0
-            }
-            const sum = (Number.isFinite(total('warning')) ? total('warning') : 0) + (Number.isFinite(total('alarm')) ? total('alarm') : 0)
-            return sum === 0 && trend('warning') === 0 && trend('alarm') === 0
-        }),
+/** 报警处置:今日/转单/处置段缺失才算空;等级卡(0 行)与 7 日趋势(全 0 点)仍要画。 */
+export function fetchAlarmDisposal(options = {}) {
+    return fetchSection(OVERVIEW_PATHS.alarmDisposal, {
+        params: filterParams(options),
+        toData: toAlarmDisposal,
+        isEmpty: isEmptyBy((payload) => !payload || !payload.today || !payload.disposal),
         freshnessOf,
         config: options.config,
         fetchImpl: options.fetchImpl,

+ 83 - 17
src/modules/cockpit/services/warningResponseApi.js

@@ -29,15 +29,13 @@ const HISTORY_WINDOW_MAX_PAGE_SIZE = 1000
 const RANGE_KEY_BY_TYPE = { LAST_7_DAYS: '7d', LAST_30_DAYS: '30d', LAST_90_DAYS: '90d' }
 
 /**
- * 片区入参的落点(票 67,2026-10-22 逐端点实测)。
+ * 片区入参的落点(票 79 更正;部署后实测 `verify/artifacts/t80/`)。
  *
- * 事件族四个端点里**只有** `/events/multidimensional-analysis`(预警分析)真收 `district`:
- * 非法值 400、`district=0/1` 的桶计数不同。另外三个(当前事件 / 历史事件 / 处置效率)
- * 实测**完全不认**该参数(非法值也 200、响应逐字节相同),所以本文件只给分析端点下发,
- * 其余一律不下发 —— 给不认的端点塞参数是「伪装的筛选」,比不筛更坏(票 67 的验收纪律)。
- *
- * 同一族里「一块随片区变、三块不变」这件事,界面上由 `domain/filterCaliber.js` 的
- * 面板口径说明如实交代(概览模式的行业筛选早就是同一形态的先例)。
+ * **票 67 的旧结论已过期**:当时事件族四个端点里只有 `/events/multidimensional-analysis` 真收
+ * `district`,另外三个完全不认。票 77 把 `/events/current`、`/events/history-analysis`、
+ * `/events/handling-efficiency` 的 `district` 补齐并已部署 —— 2026-10-27 实测四个端点对非法值
+ * 一律 400、`district=0/1` 的读数真的不同,所以**四块面板都下发同一份 `scope`**。
+ * 界面上那句「只有预警分析随片区变」的口径说明随之撤除(`domain/filterCaliber.js` 同步)。
  */
 function rangeParams(options) {
     const window = options.startTime && options.endTime ? options : eventTimeWindow(options.rangeType)
@@ -113,6 +111,9 @@ function fetchHistoryWindow(options = {}) {
         params: {
             eventFamily: familyParam(options.mode),
             industry: industryQuery(options.industry),
+            // 票 79:自票 77 起历史事件与处置效率都真收 district(部署后实测非法值 400、0/1 响应不同),
+            // 与预警分析共用同一份 scope —— 「预警处置」四块不再各说各话。
+            district: districtQuery(options.scope),
             ...rangeParams(options),
             page: options.page,
             pageSize: options.pageSize,
@@ -207,7 +208,12 @@ function countOf(value) {
  *   · `summary` 各计数:求和;`handlingEfficiency` 用**合并后状态计数重算**
  *     (分子=已结案(5),分母=可计入(0–6)——与后端 `stateCounts` 同一算法,不抄单窗百分比);
  *   · `averageDuration`:按 `sampleCount` 加权平均(无样本如实 EMPTY,不画 0 冒充);
- *   · `workOrderStateCounts`:按 `stateCode` 求和(字典名取首见窗)。
+ *   · `workOrderStateCounts`:按 `stateCode` 求和(字典名取首见窗);
+ *   · 票 78(预警族,票 74 契约):`warningStateCounts` 按 `stateCode` 求和(次序取首见窗),
+ *     `warningHandlingEfficiency` 用**合并后六态重算**(分子=CLOSED,分母=六态合计),
+ *     `warningDuration` 按 `sampleCount` 加权平均(无样本 EMPTY + NO_CLOSED_WARNINGS);
+ *     有窗给出这些字段才出现在合并产物里(旧载荷/报警族保持缺失,前端不画假块);
+ *     工单三计数(handled/inProgress/cancelled)**null 保真**:全窗为 null 合并结果也是 null。
  *
  * 导出说明:闸门(t75)直接消费它断言合并口径 —— 合并规则只有这一份实现,
  * 闸门不复刻第二份(复刻会漂移:服务改了、闸门还在验旧规则)。
@@ -226,15 +232,27 @@ export function mergeEfficiencyPayloads(payloads, fullRange) {
     let cancelled = 0
     let weightedDuration = 0
     let durationSamples = 0
+    let handledSeen = false
+    let inProgressSeen = false
+    let cancelledSeen = false
     const byState = new Map()
+    // 票 78:预警族自身字段(票 74 契约)—— 各窗独立可验,合并不发明新数。
+    let sawWarningStates = false
+    let warningWeighted = 0
+    let warningSamples = 0
+    let warningBasis = ''
+    let warningUnit = ''
+    const byWarningState = new Map()
     for (const payload of list) {
         const summary = payload.summary || {}
         totalEvents += countOf(summary.totalEvents)
         linked += countOf(summary.linkedWorkOrderCount)
         missing += countOf(summary.missingWorkOrderCount)
-        handled += countOf(summary.handledCount)
-        inProgress += countOf(summary.inProgressCount)
-        cancelled += countOf(summary.cancelledCount)
+        // 票 78:工单三计数 **null 保真** —— 预警族(票 74 契约)全窗为 null 时,
+        // 合并结果也保持 null(数成 0 等于谎称「工单口径为 0」);有窗给出才求和。
+        if (summary.handledCount !== null && summary.handledCount !== undefined) { handled += countOf(summary.handledCount); handledSeen = true }
+        if (summary.inProgressCount !== null && summary.inProgressCount !== undefined) { inProgress += countOf(summary.inProgressCount); inProgressSeen = true }
+        if (summary.cancelledCount !== null && summary.cancelledCount !== undefined) { cancelled += countOf(summary.cancelledCount); cancelledSeen = true }
         const duration = summary.averageDuration || {}
         const samples = countOf(duration.sampleCount)
         if (samples > 0 && Number.isFinite(Number(duration.value))) {
@@ -249,6 +267,28 @@ export function mergeEfficiencyPayloads(payloads, fullRange) {
                 count: ((previous && previous.count) || 0) + countOf(row.count),
             })
         }
+        // 票 78:预警六态按 `stateCode` 求和(次序取首见窗 = 后端展示序);有窗给出才合并,
+        // 旧载荷/报警族保持缺失(不画假块)。basis/unit 取首见窗,时长按样本数加权。
+        if (Array.isArray(payload.warningStateCounts)) {
+            sawWarningStates = true
+            for (const row of payload.warningStateCounts) {
+                const previous = byWarningState.get(row.stateCode)
+                byWarningState.set(row.stateCode, {
+                    stateCode: row.stateCode,
+                    stateName: (previous && previous.stateName) || row.stateName,
+                    count: ((previous && previous.count) || 0) + countOf(row.count),
+                })
+            }
+            const warningEfficiency = summary.warningHandlingEfficiency || {}
+            if (warningEfficiency.basis && !warningBasis) warningBasis = warningEfficiency.basis
+            const warningDuration = summary.warningDuration || {}
+            if (!warningUnit && warningDuration.unit) warningUnit = warningDuration.unit
+            const warningSamplesOfWindow = countOf(warningDuration.sampleCount)
+            if (warningSamplesOfWindow > 0 && Number.isFinite(Number(warningDuration.value))) {
+                warningWeighted += Number(warningDuration.value) * warningSamplesOfWindow
+                warningSamples += warningSamplesOfWindow
+            }
+        }
         for (const row of Array.isArray(payload.durationTrend) ? payload.durationTrend : []) {
             if (seenBuckets.has(row.timeBucket)) continue
             seenBuckets.add(row.timeBucket)
@@ -269,22 +309,40 @@ export function mergeEfficiencyPayloads(payloads, fullRange) {
         ? { value: (weightedDuration / durationSamples).toFixed(2), unit: 'HOURS', sampleCount: durationSamples, state: 'AVAILABLE', reason: 'READY' }
         : { value: '0.00', unit: 'HOURS', sampleCount: 0, state: 'EMPTY', reason: 'NO_CLOSED_WORK_ORDERS' }
 
+    // 票 78:预警族处置率用合并后六态**重算**(分子=CLOSED,分母=六态合计=窗口内预警数;
+    // 与后端 warningEfficiency 同一算法,不抄单窗百分比)。无样本时长如实 EMPTY,不画 0 冒充。
+    const warningRows = [...byWarningState.values()]
+    const warningClosed = warningRows.filter((row) => row.stateCode === 'CLOSED').reduce((sum, row) => sum + row.count, 0)
+    const warningTotal = warningRows.reduce((sum, row) => sum + row.count, 0)
+    const warningBasisFinal = warningBasis || 'CLOSED_WARNINGS_OVER_WINDOW_WARNINGS'
+    const warningEfficiencyMerged = warningTotal > 0
+        ? { value: (warningClosed / warningTotal).toFixed(4), numerator: warningClosed, denominator: warningTotal, state: 'AVAILABLE', reason: null, basis: warningBasisFinal }
+        : { value: '0.0000', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_WARNINGS', basis: warningBasisFinal }
+    const warningDurationMerged = warningSamples > 0
+        ? { value: (warningWeighted / warningSamples).toFixed(2), unit: warningUnit || 'HOURS', sampleCount: warningSamples, state: 'AVAILABLE', reason: null }
+        : { value: '0.00', unit: warningUnit || 'HOURS', sampleCount: 0, state: 'EMPTY', reason: 'NO_CLOSED_WARNINGS' }
+
     return {
         ...first,
         range: { ...(first.range || {}), type: 'TREND_WINDOW', startTime: fullRange.startTime, endTime: fullRange.endTime },
         durationTrend,
         events,
         workOrderStateCounts: stateCounts,
+        // 票 78:预警族字段只在确有窗给出时进入合并产物(旧载荷/报警族保持缺失,前端不画假块)。
+        ...(sawWarningStates ? { warningStateCounts: warningRows } : {}),
         summary: {
             ...(first.summary || {}),
             totalEvents,
             linkedWorkOrderCount: linked,
             missingWorkOrderCount: missing,
-            handledCount: handled,
-            inProgressCount: inProgress,
-            cancelledCount: cancelled,
+            handledCount: handledSeen ? handled : null,
+            inProgressCount: inProgressSeen ? inProgress : null,
+            cancelledCount: cancelledSeen ? cancelled : null,
             handlingEfficiency,
             averageDuration,
+            ...(sawWarningStates
+                ? { warningHandlingEfficiency: warningEfficiencyMerged, warningDuration: warningDurationMerged }
+                : {}),
         },
     }
 }
@@ -345,13 +403,20 @@ export function mergeAnalysisPayloads(payloads, fullRange) {
 
 const EFFICIENCY_EMPTY = (payload) => {
     const family = payload && payload.familyMetrics
-    return !payload || (!(payload.summary && payload.summary.handlingEfficiency) && !payload.handlingEfficiency && !(family && family.handlingEfficiency) && payload.averageResponseSeconds === undefined && payload.averageResponseDuration === undefined && !(payload.averageDuration && typeof payload.averageDuration === 'object'))
+    const summary = (payload && payload.summary) || {}
+    // 票 78:预警族(票 74 契约)的效率读数在 `warningHandlingEfficiency`/`warningDuration`
+    // 与 `warningStateCounts` —— 只认工单口径的旧判据会把新预警族载荷误判成空分区
+    // (7 天单窗就会整块挂「无数据」),所以这三个字段在场即视为「有数据」。
+    return !payload || (!summary.handlingEfficiency && !payload.handlingEfficiency && !(family && family.handlingEfficiency) && payload.averageResponseSeconds === undefined && payload.averageResponseDuration === undefined && !(summary.averageDuration && typeof summary.averageDuration === 'object') && !summary.warningHandlingEfficiency && !summary.warningDuration && !Array.isArray(payload.warningStateCounts))
 }
 
 export function fetchHandlingEfficiency(options = {}) {
     return fetchAggregated(WARNING_RESPONSE_PATHS.efficiency, options, {
         toDomain: toHandlingEfficiency,
         merge: mergeEfficiencyPayloads,
+        // 票 79:票 77 补齐并部署后实测该端点真收 district(非法值 400、工单口径随片区收敛),
+        // 多窗(30/90 天)的每一窗都要带上,否则 90 天口径会退回全域。
+        acceptsDistrict: true,
         isEmptyRaw: EFFICIENCY_EMPTY,
     })
 }
@@ -360,7 +425,8 @@ export function fetchEventAnalysis(options = {}) {
     return fetchAggregated(WARNING_RESPONSE_PATHS.analysis, options, {
         toDomain: toEventAnalysis,
         merge: mergeAnalysisPayloads,
-        // 票 67:事件族里**唯一**收片区入参的端点(实测见 `rangeParams` 上方)。
+        // 票 79:票 67「事件族里唯一收片区」的旧结论已过期 —— 四个端点自票 77 起都真收
+        // district(部署态实测见 `rangeParams` 上方与 `verify/artifacts/t80/`),四块都下发。
         acceptsDistrict: true,
         isEmptyRaw: (payload) => {
             const dimensions = (payload && payload.dimensions) || payload || {}

+ 67 - 150
src/modules/cockpit/stores/overviewStore.js

@@ -1,21 +1,29 @@
 /**
- * 总体概览模式的面板状态(票 20)。
+ * 总体概览四模块的面板状态(2026-10-22 四模块重做 · 票 05–08)。
  *
- * 职责边界,沿用票 05/11/17/18/19 已定的分工:
+ * 职责边界沿用票 05/11/17/18/19/20 已定的分工:
  *   · 本 store 只做三件事:调 `services/overviewApi.js` 取数、把**每次取数各自收成一个分区**、
- *     把「点指标 → 切模式」与「点行业行 → 写全局筛选」两条联动接上;
- *   · 面板怎么画归 `domain/overviewPanels.js`(纯函数,本文件不做任何字段改名);
+ *     维护请求注入与并发/竞态(token);
+ *   · 面板怎么画归 `domain/overviewPanels.js`(纯函数,本文件不做字段改名);
  *   · 路径拼接、凭据、mock/real 切换归 `services/`(本文件不出现 fetch、不出现后端裸字段名)。
  *
- * **四块面板、四个取数**(一块端点坏了不牵连其它块):
- *   infrastructureSummary → `/overview/infrastructure` + `/infrastructure/base-archives`(区域分布)
- *   monitoringSummary     → `/overview/monitoring`
- *   alertSettings         → `/overview/warning-settings`(**一次给预警+报警两族**)
- *   situationSummary      → **跨上面三份**(它是"整体态势摘要",本来就横跨三块)
+ * 四块面板 ↔ 四个端点,一一对应(不再有「态势总览」这种跨端点的合成分区):
+ *   perceptionDevices → `/overview/perception`
+ *   pipeNetwork       → `/overview/pipe-network`
+ *   warningStudy      → `/overview/warning-study`
+ *   alarmDisposal     → `/overview/alarm-disposal`
  *
- * 态势总览因此**不自己取数**:它读的是另外三块已经取到的分区数据(Q1 裁定)。
- * 这样「预警总数」在态势总览与预警设置里**按构造是同一个数** ——
- * 不是"两处各自取一次、期望它们一致"(那正是票 18 的台账/分布共用一次取数的同一立场)。
+ * **四块互不牵连**:并发发四个请求、每块各自 try/catch 收成失败分区 ——
+ * 一块端点 500 只让那一块显示「加载失败」,其它三块照常出数(票 05 的验收原句)。
+ *
+ * **票 89**:四个端点已由后端补齐 `district`/`industry` 入参,所以本 store 像
+ * monitoring/infrastructure/event store 一样持有全局筛选并透传给 services
+ * (此前刻意不带筛选:当时端点不收入参,带了就是伪造)。
+ *
+ * 联动也随旧视图一起退场:新四块不产生 `link` / `industry` 面板动作
+ * (规格 §6 没有这些交互),行业筛选改由外壳直接写 `filterStore`;
+ * 唯一保留的联动是预警类型排行 → 切「预警处置」模式(落点在 `CockpitShell` 的
+ * `handlePanelAction`,筛选状态在 `eventStore`)。
  */
 import { ref } from 'vue'
 import { defineStore } from 'pinia'
@@ -23,218 +31,127 @@ import { SECTION_STATE, section } from '../domain/sectionState.js'
 import { REQUEST_REASON } from '../domain/requestReason.js'
 import { OVERVIEW_PANEL_IDS } from '../domain/overviewPanels.js'
 import {
-    fetchOverviewInfrastructure,
-    fetchOverviewMonitoring,
-    fetchOverviewRegions,
-    fetchOverviewWarningSettings,
+    fetchAlarmDisposal,
+    fetchPerception,
+    fetchPipeNetwork,
+    fetchWarningStudy,
 } from '../services/overviewApi.js'
-import { useFilterStore } from './filterStore.js'
-import { useUiStore } from './uiStore.js'
 
-/** 未取数时的分区(与 `infrastructureStore.pending()` 同形,`loaded:false` 表示"还没请求过")。 */
+/** 未取数时的分区(与其它 store 同形,`loaded:false` 表示"还没请求过")。 */
 function pending() {
     return { state: SECTION_STATE.EMPTY, reason: '', mode: '', data: null, missing: [], freshness: '', retained: false, loaded: false }
 }
 
 /**
- * 真正发请求的三个 panelId。
+ * panelId → 取数函数(四块各一个端点)。
  *
- * `situationSummary` **不在**这里:它是跨三块的汇总视图,数据由另外三块喂
- * (见文件头;也是 Q1 裁定"态势总览与预警设置共用一次取数"的落点)。
+ * 键与 `OVERVIEW_PANEL_IDS` 必须逐项一致 —— 写错键不会报错,只会让某块永远停在
+ * 「正在取数」,所以逻辑闸门把它钉住(t86 的 A/B 组)。
  */
 const FETCHERS = {
-    infrastructureSummary: fetchOverviewInfrastructure,
-    monitoringSummary: fetchOverviewMonitoring,
-    alertSettings: fetchOverviewWarningSettings,
+    perceptionDevices: fetchPerception,
+    pipeNetwork: fetchPipeNetwork,
+    warningStudy: fetchWarningStudy,
+    alarmDisposal: fetchAlarmDisposal,
 }
 
-/** 需要独立请求的面板。 */
-const INDEPENDENT_PANELS = ['infrastructureSummary', 'monitoringSummary', 'alertSettings']
-
 export const useOverviewStore = defineStore('overviewStore', () => {
-    /** 四块面板各自的分区状态(态势总览的那份由另外三块合成,见 `situationSection`)。 */
+    /** 四块面板各自的分区状态(键=目录里的 panelId)。 */
     const sections = ref(Object.fromEntries(OVERVIEW_PANEL_IDS.map((panelId) => [panelId, pending()])))
 
     /** 正在取数(界面只做轻提示;分区成败仍由各分区自己说)。 */
     const loading = ref(false)
 
-    /** 当前生效的行业筛选(**前端键**;到后端码的映射归 services)。 */
-    const industry = ref('')
-
-    /** 请求注入(config / fetchImpl):取一次让几块共用,单测与走查都靠它。 */
+    /** 请求注入(config / fetchImpl):取一次让四块共用,单测与走查都靠它。 */
     let requestOptions = {}
 
-    /** 请求序号:换筛选/重取时旧的在途响应作废。 */
+    /**
+     * 当前生效的全局筛选(**前端键**;到后端码的映射归 services)。
+     *
+     * 票 89:四块端点已由后端补齐片区/行业入参,所以这两条与行业/片区同层、跨重取保留
+     * (定时刷新不带条件时也照旧下发,不会悄悄退回全域)。空串=全域/全部。
+     */
+    const industry = ref('')
+    const scope = ref('')
+
+    /** 请求序号:重取时旧的在途响应作废(迟到的四块响应整批丢弃,不半新半旧)。 */
     let token = 0
 
     function configure(options = {}) {
         requestOptions = { ...requestOptions, ...options }
     }
 
+    /** 当前请求条件(全局筛选 + 真实配置)。 */
+    function activeOptions() {
+        return { ...requestOptions, industry: industry.value, scope: scope.value }
+    }
+
     /** 直接写入一个分区(走查与单测注入用)。 */
     function setSection(panelId, value) {
         sections.value = { ...sections.value, [panelId]: { ...value, loaded: true } }
     }
 
     /**
-     * 取概览的三块(+ 区域分布)。
-     *
-     * **并发取、各自成败**:一块失败不回滚其它已成功分区。
-     * 区域分布是**第四次取数**,它只喂基础设施面板(见 `services/overviewApi.js` 的说明);
-     * 它失败时基础设施面板仍画出设施底数与行业构成,只是没有区域那一条。
+     * 取四块(四个端点并发、各自成败)。
      *
-     * 行业筛选只有 `/overview/monitoring` 收(契约现状),所以它只重取那一块 ——
-     * 另外两块照旧(它们本来就不收该入参,重取只会空转一次请求)。
-     * @param {object} [options] `{ industry, config, fetchImpl }`
+     * 全局行业/片区(票 89):`industry`/`scope` 是**条件键**,undefined=不动、空=清空;
+     * 其余键(config/fetchImpl)照旧并进 `configure`。条件跨重取保留 —— 定时刷新不带条件时
+     * 仍按当前筛选取数,不会悄悄退回全域。
+     * @param {object} [options] `{ industry, scope, config, fetchImpl }`
      * @returns {Promise<object>} 取完后的全部分区
      */
     async function loadPanels(options = {}) {
         if (options.industry !== undefined) industry.value = options.industry || ''
-        if (Object.keys(options).some((key) => key !== 'industry')) configure(options)
-
-        const active = { ...requestOptions, industry: industry.value }
+        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)
 
         token += 1
         const mine = token
         loading.value = true
 
         const results = await Promise.all(
-            INDEPENDENT_PANELS.map(async (panelId) => {
+            OVERVIEW_PANEL_IDS.map(async (panelId) => {
                 try {
-                    return [panelId, await FETCHERS[panelId](active)]
+                    return [panelId, await FETCHERS[panelId](activeOptions())]
                 } catch (error) {
-                    // 与票 11/17/18 同款兜底:适配层不抛,但转换层万一抛了要收成一条失败分区,
-                    // 不能把面板卡在「加载中」(用户还得能看见其它块)。
+                    // 与票 11/17/18/20 同款兜底:适配层不抛,但转换层万一抛了要收成一条失败分区,
+                    // 不能把面板卡在「加载中」(用户还得能看见其它三块)。
                     return [panelId, section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })]
                 }
             }),
         )
 
-        // 区域分布:只喂基础设施面板,所以它的成败单独收着,不占一个 panelId。
-        let regions = null
-        try {
-            regions = await fetchOverviewRegions(active)
-        } catch (error) {
-            regions = 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 }
-
-        // 区域分布并进基础设施分区(它是同一块面板的第四个读数,不是独立分区)。
-        // 票 38 第二轮(维护者复验反馈第 1 条):这里曾**硬写 `regions: null`**,
-        // 于是概览「管线里程」永远取不到(显示占位符 `—`),而「基础设施-基础档案」
-        // 读的是**同一个** `/infrastructure/base-archives` 端点的 `lengthKm` → 两处对不上。
-        // 里程的唯一来源就是这份档案(概览自己的端点不给 lengthKm),所以把它真的并进来,
-        // 两处数字由**同一个数据源**构造相等,而不是"各自取一次期望一致"。
-        // 区域取不到**不算整块失败**:设施底数与行业构成照画(分区内的一段缺失)。
-        const infraSection = next.infrastructureSummary
-        const regionsLoaded = regions && (regions.state === SECTION_STATE.SUCCESS || regions.state === SECTION_STATE.STALE)
-        next.infrastructureSummary = {
-            ...infraSection,
-            regions: regionsLoaded ? regions.data : null,
-            regionsState: regions ? regions.state : SECTION_STATE.FAILED,
-            regionsReason: regions ? regions.reason : '',
-        }
-
         sections.value = next
         loading.value = false
         return sections.value
     }
 
-    /**
-     * 态势总览的分区:**由另外三块合成**(它不自己取数,见文件头)。
-     *
-     * 成败取"最好的那一份"而不是"最差的那一份":三块里有一块成功就该把成功的读数画出来
-     * (部分成功在票 16 的词汇里是 `partial`,界面另有整体汇总,不必在这里重复表达)。
-     * 三块全没取到才算"还没取数"。
-     */
-    function situationSection() {
-        const current = sections.value
-        const parts = ['infrastructureSummary', 'monitoringSummary', 'alertSettings'].map((panelId) => current[panelId]).filter(Boolean)
-        const loaded = parts.filter((part) => part.loaded === true)
-        if (!loaded.length) return pending()
-        const good = loaded.filter((part) => part.state === SECTION_STATE.SUCCESS || part.state === SECTION_STATE.STALE)
-        const base = good[0] || loaded[0]
-        return { ...base, loaded: true }
-    }
-
-    /** 态势总览的合成数据(三份领域对象;缺的那份为 null,面板据此少画几项读数)。 */
-    function situationData() {
-        const current = sections.value
-        const infra = current.infrastructureSummary || {}
-        const monitoring = current.monitoringSummary || {}
-        const warnings = current.alertSettings || {}
-        return {
-            infrastructure: infra.data || null,
-            // 区域分布并进基础设施数据(面板按同一个领域对象读)。
-            regions: infra.regions || null,
-            monitoring: monitoring.data || null,
-            // 票 38 第二轮(t20 的 D2b 红复验):**原样引用**,不再 `{ ...spread }` 复制一份 ——
-            // 态势总览与预警设置必须读**同一份**载荷(引用相同),两处数字才由构造不可能对不上。
-            warningSettings: warnings.data || null,
-        }
-    }
-
-    /** 基础设施面板的数据(设施底数 + 行业构成 + 区域分布三段合成一份)。 */
-    function infrastructureData() {
-        const current = sections.value.infrastructureSummary || {}
-        return { infrastructure: current.data || null, regions: current.regions || null }
-    }
-
-    /**
-     * 点态势总览/预警设置的指标 → 切到「预警处置」模式(见 `domain/overviewPanels.js` 文件头第 ① 条)。
-     *
-     * 为什么是切模式而不是地图定位:本模式三个端点**都不含点位编码或坐标**,
-     * 没有可回联的对象。预警处置模式的事件列表带 `deviceCode`,由票 13/14 的既有链路
-     * 落到点位 —— 这是**真实存在**的那条联动路径,不伪造一次定位。
-     * @param {string} mode 目标一级模式(目前只有 `warningResponse`)
-     * @returns {boolean} 是否真的切了
-     */
-    function linkToMode(mode) {
-        if (!mode) return false
-        const uiStore = useUiStore()
-        uiStore.setMode(mode)
-        return true
-    }
-
-    /**
-     * 点基础设施面板的行业行 → 写全局行业筛选(真的驱动一张图与所有面板)。
-     *
-     * 票 38 第二轮(t20 的 E2 红复验):这里曾拿到 `filterStore` 却**没有调用**它,
-     * 于是「点行业行」静默无事发生 —— 与维护者反馈第 5 条「点了没反应」同一类缺陷。
-     * @param {string} industryKey 前端行业键(`gas`/`water`/`drainage`/`manhole`)
-     */
-    function linkToIndustry(industryKey) {
-        if (!industryKey) return false
-        const filterStore = useFilterStore()
-        filterStore.setIndustry(industryKey)
-        return filterStore.industry === industryKey
-    }
-
     /** 仅测试用:回到未加载状态。 */
     function reset() {
         token += 1
         sections.value = Object.fromEntries(OVERVIEW_PANEL_IDS.map((panelId) => [panelId, pending()]))
         loading.value = false
+        requestOptions = {}
         industry.value = ''
+        scope.value = ''
     }
 
     return {
         sections,
         loading,
         industry,
+        scope,
         configure,
         setSection,
         loadPanels,
-        situationSection,
-        situationData,
-        infrastructureData,
-        linkToMode,
-        linkToIndustry,
         reset,
     }
 })