Selaa lähdekoodia

feat(cockpit): 总体概览四模块大屏(感知设备/基础设施/预警研判/报警处置)

把 overview 一级模式的四块面板换成需求指定的四模块,并接入新增只读端点:

- 感知设备:设备总数/在线/离线/在线率(value-first 版式)+ 类型卡 2×2(可点套行业筛选)
  + 片区卡 3 列;类型卡监测内容做展示层精简(去行业前缀与「监测」后缀、留两项加「等」),
  完整原文放 noteTitle 供悬停
- 基础设施:管道数/管线里程 + 三类环形图(供水/排水/燃气),空态不画假环
- 运行监测-预警研判:今日新增带较昨日红绿、未解除、分级四卡、类型排行(limit 0 = 全部类型)
- 运行监测-报警处置:两行三卡、分级四卡、近 7 日双序列折线(当天新增 / 总未处理)+ 图例

布局与观感:
- 面板高度按内容实测自适应(ResizeObserver → flex 权重),表格类面板打折 + 同栏份额下限
- 超宽屏改用 `@media (min-aspect-ratio: 3456 / 1239)`;每块面板 `flex: 0 0 var(--column-w)`,
  并排后宽度与竖排完全一致。**不再用 `@container`** —— 查询容器不能被自身命中,
  写在其上的 `--rail-w` 从未生效,才导致前几轮「窄到变形 / 右侧出框」
- 设计像素 `--u` 改按视口高度缩放(`clamp(1px, 0.1235vh, 2px)`):只加宽不加高不再整体放大
- 字号层级:数值 22u(value-first 同档,`min(…, 34cqw)` 兜底不出框)> 块标题 18u > 名称 11.5u > 说明 9.2u

验证:t02 34/34、t86 51/51、t11 渲染层全通过、变异 32/32。
ChenJZ 12 tuntia sitten
vanhempi
commit
6e6e8abf8f

+ 227 - 39
src/modules/cockpit/CockpitShell.vue

@@ -382,6 +382,11 @@
                 @close="closePipeEnhancement"
             />
 
+            <!--
+                超宽屏(2026-10-23 维护者):长宽比 ≥ 3:1 时,两侧栏内部由「上下堆叠」改「并排」,
+                并且两栏各自退到画面上下、把中间让给一张图。判据用**容器** `aspect-ratio`
+                而不是视口 media query —— 驾驶舱跑在微前端宿主里,视口不是本组件的已知量。
+            -->
             <aside class="cockpit-shell__column is-left" aria-label="左栏">
                 <CockpitPanel
                     v-for="panelId in columns.left"
@@ -389,8 +394,12 @@
                     :panel-id="panelId"
                     :title="panelTitle(panelId)"
                     :meta="panelMeta(panelId)"
+                    :meta-align="panelSubtitle(panelId) ? 'end' : ''"
                     :section="sectionOf(panelId)"
                     :expanded="isExpanded(panelId)"
+                    :grow="panelGrow(panelId)"
+                    :table-heavy="isTableHeavyPanel(panelId)"
+                    :basis="columnBasis(columns.left)[panelId]"
                     @toggle="uiStore.togglePanel(panelId)"
                 >
                     <PanelBlocks
@@ -421,8 +430,12 @@
                     :panel-id="panelId"
                     :title="panelTitle(panelId)"
                     :meta="panelMeta(panelId)"
+                    :meta-align="panelSubtitle(panelId) ? 'end' : ''"
                     :section="sectionOf(panelId)"
                     :expanded="isExpanded(panelId)"
+                    :grow="panelGrow(panelId)"
+                    :table-heavy="isTableHeavyPanel(panelId)"
+                    :basis="columnBasis(columns.right)[panelId]"
                     @toggle="uiStore.togglePanel(panelId)"
                 >
                     <PanelBlocks
@@ -502,14 +515,15 @@
 <script setup>
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { storeToRefs } from 'pinia'
-import { MODES, columnsForMode, labelForPanel, modeLabel } from './domain/panelCatalog.js'
+import { MODES, columnsForMode, labelForPanel, modeLabel, panelGrow, panelSubtitle } from './domain/panelCatalog.js'
 import { panelView, hasPanelView } from './domain/panelViews.js'
 // 票 67:全局筛选(当前范围/行业)生效面的**单一措辞来源**(面板口径说明 + 管线图层那句)。
 import { PIPE_DISTRICT_NOTE } from './domain/filterCaliber.js'
 // 票 65:「预警效率」面板标题随预警/报警切换,词表复用 domain 单一词条。
 import { responseEfficiencyTitle } from './domain/warningResponsePanels.js'
-// 票 20:概览的联动映射(`link` 键 → 目标模式);外壳不自己发明这张表。
-import { OVERVIEW_LINK_MODES } from './domain/overviewPanels.js'
+// 2026-10-22(票 05–08):overview 新四块不再产生 `link` 面板动作,旧映射表随旧视图退役;
+// 唯一的联动是预警类型排行 → 切「预警处置」并做本地类型筛选(见 handlePanelAction)。
+import { EVENT_FAMILY } from './domain/events.js'
 import { LOCATE_RESULT } from './domain/mapLocate.js'
 import { resolveDetailAnchor, detailAnchorLocation } from './domain/detailAnchor.js'
 import { DEVICE_LAYER_KIND } from './domain/deviceLayer.js'
@@ -564,12 +578,13 @@ import { useDeviceLayerStore } from './stores/deviceLayerStore.js'
 import { useInfrastructureStore } from './stores/infrastructureStore.js'
 import { useMonitoringStore } from './stores/monitoringStore.js'
 import { useEventStore } from './stores/eventStore.js'
-// 票 20:总体概览模式的四块面板(态势总览由另外三块合成,见其 store 的说明)。
+// 2026-10-22(票 02):总体概览模式的四块面板(感知设备/基础设施/预警研判/报警处置)。
+// store 仍是票 20 的旧取数实现 —— 新端点的接线归票 05–08,本票先钉身份与画法。
 import { useOverviewStore } from './stores/overviewStore.js'
 import { useRuntimeStore } from './stores/runtimeStore.js'
 import { useMapStore } from './stores/mapStore.js'
 import { MAP_OBJECT_KIND, MAP_SUMMARY_PHASE } from './domain/mapSummary.js'
-import { getCockpitConfigState, loadCockpitConfig } from './services/cockpitConfig.js'
+import { getCockpitConfigState, loadCockpitConfig, withUrlDataSourceOverride } from './services/cockpitConfig.js'
 import {
     atHome,
     getAreaLimits,
@@ -633,7 +648,7 @@ const industryIconSize = 'calc(14 * var(--u, 1px))'
 // 16 块面板现已全部接上真实视图;`PanelPlaceholder` 仍是未注册 panelId 的兜底。
 const { sections: infrastructureSections } = storeToRefs(infrastructureStore)
 const { sections: monitoringSections, badges: monitoringBadges } = storeToRefs(monitoringStore)
-// 票 20:概览四块的分区(态势总览那块由 `overviewStore.situationSection()` 合成,不在这个对象里)。
+// 概览四块的分区(键与 `panelCatalog.js` 的新 panelId 一致;旧合成区随票 02 一并移除)。
 const { sections: overviewSections } = storeToRefs(overviewStore)
 /**
  * 启动已就绪(配置落地且按启动计划执行完首轮取数)——筛选跟随只在这之后生效。
@@ -654,8 +669,8 @@ function panelTitle(panelId) {
 
 /** 该 panelId 的分区(各 store 各查各的;都没接就回 null)。 */
 function sectionOf(panelId) {
-    // 票 20:态势总览**不自己取数**,它的分区由另外三块合成(见 overviewStore 的说明)。
-    if (panelId === 'situationSummary') return overviewStore.situationSection()
+    // 2026-10-22(票 02):overview 旧四块(含「态势总览」这个合成区)已从目录移除,
+    // 这里不再有合成特例 —— 新四块各查各的分区,接线归票 05–08。
     if (Object.prototype.hasOwnProperty.call(overviewSections.value, panelId)) return overviewSections.value[panelId]
     if (Object.prototype.hasOwnProperty.call(infrastructureSections.value, panelId)) return infrastructureSections.value[panelId]
     if (Object.prototype.hasOwnProperty.call(monitoringSections.value, panelId)) return monitoringSections.value[panelId]
@@ -696,24 +711,91 @@ function panelViewOf(panelId) {
     // 票 62 追加需求:运行概况的**设备台账**数据在 `monitoringOverview` 分区(`devices[]`,
     // 与那边 `counts.equipmentCount` 同源),而本面板自己的分区是 `operationOverview`
     // (`/monitoring/situation`:趋势/指标状态/异常口径)。跨分区取数只在这一处搬运 ——
-    // 与上面「态势总览横跨三块」同一立场,注册表签名保持 `(panelId, data, section, extra)`。
+    // 与「一块面板的数据来自哪几处」同一立场,注册表签名保持 `(panelId, data, section, extra)`。
     else if (panelId === 'operationOverview') {
         const overview = monitoringSections.value.monitoringOverview
         extra = { devices: (overview && overview.data && overview.data.devices) || [] }
     }
-    // 票 20:态势总览要**三份领域对象**(它横跨基础设施/运行监测/预警设置三块),
-    // 与基础设施面板要「设施底数 + 行业构成 + 区域分布」一样,都是"这一块的数据来自哪几处"
-    // 的搬运 —— 写在壳里,注册表签名保持 `(panelId, data, section, extra)` 不变。
-    if (panelId === 'situationSummary') return panelView(panelId, overviewStore.situationData(), section, extra)
-    if (panelId === 'infrastructureSummary') return panelView(panelId, overviewStore.infrastructureData(), section, extra)
+    // 2026-10-22(票 02):overview 新四块都是**单源面板**(各自一个端点、一份领域对象),
+    // 不再有「态势总览横跨三块」那种合成喂养 —— 一律直读自己的分区数据。
+    // 票 11:感知设备的类型卡可点(按行业筛地图),所以要把**当前生效的行业键**喂给它画选中态。
+    // 只传标量:卡片行自己不知道 store 是什么(渲染层也仍不认识筛选语义)。
+    else if (panelId === 'perceptionDevices') extra = { industry: filterStore.industry }
     return panelView(panelId, section.data, section, extra)
 }
 
+/**
+ * 由**专用列表组件**渲染的面板(它们不走 PanelBlocks 的块系统,所以查不到 list 块)。
+ *
+ * 这是一张**很短的、以渲染方式为判据**的表,不是"哪些面板内容多"的业务分类:
+ * 它回答的是"这块是不是由列表组件画的",与 `panelViewOf` 的分派分支一一对应,
+ * 面板增删时跟着那边一起改(两处都在本文件里,改动可见)。
+ */
+const LIST_COMPONENT_PANELS = ['currentEvents', 'historyAnalysis']
+
+/**
+ * 同栏每块面板的**高度份额**(`--panel-basis`),维护者 2026-10-23 反馈第 3 条。
+ *
+ * 为什么必须在外壳算:份额是"同栏内相对分配",是同栏几块之间的关系 ——
+ * 单块面板自己看不到兄弟,纯 CSS 也算不出权重和。所以外壳按栏算一次,逐块下发。
+ *
+ * 三步(顺序有意义):
+ *   1. **归一**:以该栏最大权重为基准(权重本身是内容自然高度,可能上万,不能直接当 basis);
+ *   2. **下限**:任何一块不得低于最大权重块的 `MIN_SHARE_RATIO`(约 0.36)——
+ *      表格面板打折后不会把另一块压成一条线;
+ *   3. **失衡上限**:任何一块不得高于最小块的 `MAX_BALANCE_RATIO` 倍 ——
+ *      表格再长也只占这一比例,同栏另一块始终留得住可观面积。
+ *
+ * 返回 0–100 的百分比(CSS flex-basis 直接吃)。
+ */
+const MIN_SHARE_RATIO = 0.36
+const MAX_BALANCE_RATIO = 1.75
+
+/** 该栏每块的 basis 份额(panelId → 百分比字符串)。 */
+function columnBasis(panelIds) {
+    if (!Array.isArray(panelIds) || panelIds.length === 0) return {}
+    const weights = panelIds.map((panelId) => Math.max(1, panelGrow(panelId) || 1))
+    const top = Math.max(...weights)
+    const floor = top * MIN_SHARE_RATIO
+    const balanced = weights.map((weight) => Math.max(weight, floor))
+    // 失衡上限:最重的那块最多是最轻的 MAX_BALANCE_RATIO 倍。
+    const lightest = Math.min(...balanced)
+    const ceiling = lightest * MAX_BALANCE_RATIO
+    const capped = balanced.map((weight) => Math.min(weight, ceiling))
+    const sum = capped.reduce((total, weight) => total + weight, 0)
+    return Object.fromEntries(panelIds.map((panelId, index) => [panelId, `${((capped[index] / sum) * 100).toFixed(2)}%`]))
+}
+
+/**
+ * 面板是否**以表格/清单为主体**(维护者 2026-10-23 反馈第 3 条)。
+ *
+ * 判据看**视图里的块**而不是面板身份:有 `list` 块(长清单/台账)就算表格主体。
+ * 用视图判而不是维护一张"哪些面板是表格"的表 —— 后者会随面板增删漂移,
+ * 而"这块的内容是不是长清单"本来就能从视图里读出来。
+ */
+function isTableHeavyPanel(panelId) {
+    // 「当前预警」走 EventListPane 这个专用组件(不是 PanelBlocks 块),视图为 null,
+    // 只查 blocks 会把它整个漏掉 —— 但它恰恰是这一栏里最"表格"的一块。
+    // 所以先认这两类**专用列表组件**的面板,再按块判其余。
+    if (LIST_COMPONENT_PANELS.includes(panelId)) return true
+    const view = panelViewOf(panelId)
+    if (!view || !Array.isArray(view.blocks)) return false
+    return view.blocks.some((block) => block.kind === 'list' || block.kind === 'rows')
+}
+
+
+
 /**
  * 面板标题右侧的提要:有数据的写一句口径摘要;**已接数据但还没取数**写「正在取数」;
  * 没实现的写「面板占位」—— 后两者必须分开,否则"正在加载"会被读成"这块还没做"。
+ *
+ * 2026-10-22(票 02 · 规格 §6.1):overview 四块的小字是**面板身份**(如「研判分析」),
+ * 严格照抄、不增删;因此它们优先于数据提要 —— 分区成败仍由标题栏的
+ * `SectionStateTag` 说(就绪/无数据/过期/失败),两件事各画各的位置。
  */
 function panelMeta(panelId) {
+    const subtitle = panelSubtitle(panelId)
+    if (subtitle) return subtitle
     // 票 13:当前预警面板的提要由「事件 + 切换」视图给(条数/状态来自事件分区)。
     if (panelId === 'currentEvents') {
         const view = eventStore.view
@@ -798,31 +880,56 @@ function loadLedgerBadge(pointCode) {
 }
 
 /**
- * 票 20 立的联动接缝(票 37 复用):渲染层只回答「哪一项被点了」(`{ kind, key }`),
+ * 面板联动接缝(票 20 立,票 37/05–08 复用):渲染层只回答「哪一项被点了」(`{ kind, key }`),
  * **它不认识 panelId、也不认识业务语义**;把 `kind` 翻成动作收在这一处,
  * 与 `panelViews.js` 把 panelId 翻成视图计划是同一个立场。
  *
- * 票 37 增 `deviceType` 一种:点监测概览的类型分布行 → 写全局设备类型筛选
- * (toggle 语义在 filterStore:再点同一行取消)。**没有"地图定位"这一种**:
- * 概览/概览的端点都不含点位编码或坐标(见 `domain/overviewPanels.js` 文件头第 ① 条),
- * `link` 落到预警处置模式后,那条链路(事件带设备编码 → 票 13/14 跳点位)才是真实存在的。
+ * 四种动作:
+ *   · `industry`   —— 写全局行业筛选(基础设施面板的行业行;这里直接写 filterStore,
+ *     不再借道 overviewStore 的旧方法 —— 概览四块已不产生这个动作);
+ *   · `deviceType` —— 写全局设备类型筛选(toggle 语义在 filterStore);
+ *   · `warningType` —— 预警研判的类型排行 → 切「预警处置」+ 事件列表**本地**按类型筛选(G11);
+ *   · 其余一律拒绝(不静默吞掉一个认不出的动作)。
+ * **没有"地图定位"这一种**:概览四个端点不含点位编码或坐标,落到事件列表才是真实链路。
  * @param {{kind: string, key: string}} action
  */
 function handlePanelAction(action) {
     if (!action) return false
-    if (action.kind === 'link') return overviewStore.linkToMode(overviewLinkMode(action.key))
-    if (action.kind === 'industry') return overviewStore.linkToIndustry(action.key)
+    if (action.kind === 'industry') {
+        filterStore.setIndustry(action.key)
+        return true
+    }
     if (action.kind === 'deviceType') {
         filterStore.toggleDeviceType(action.key)
         return true
     }
+    if (action.kind === 'warningType') return focusWarningType(action.key)
     return false
 }
 
-/** `link` 键 → 目标一级模式(映射表在 domain,外壳不发明语义)。 */
-function overviewLinkMode(linkKey) {
-    const found = (OVERVIEW_LINK_MODES || {})[linkKey]
-    return found || ''
+/**
+ * 预警类型排行 → 切「预警处置」模式的当前预警列表,并按该类型**本地**筛选(票 07 · G11)。
+ *
+ * 三步都必须真实发生:切模式(否则用户看不到列表)、切到预警族(排行是预警类型)、
+ * 写本地筛选状态(不伪造服务端参数)。列表覆盖范围由 `eventStore.view.filterView` 如实回显。
+ * @param {string} code warning_type 码(排行行的 `actionKey`)
+ * @returns {boolean} 是否真的切了
+ */
+function focusWarningType(code) {
+    const key = typeof code === 'string' ? code.trim() : ''
+    if (!key) return false
+    uiStore.setMode('warningResponse')
+    if (eventStore.mode !== EVENT_FAMILY.WARNING) eventStore.setMode(EVENT_FAMILY.WARNING)
+    eventStore.setWarningTypeFilter(key, warningTypeLabel(key))
+    return true
+}
+
+/** 类型码 → 中文名(从已加载的研判分区里取,取不到就显示码本身 —— 不编一个假名)。 */
+function warningTypeLabel(code) {
+    const section = overviewSections.value.warningStudy
+    const ranking = (section && section.data && section.data.typeRanking) || {}
+    const row = (ranking.top || []).find((item) => item.code === code)
+    return (row && row.label) || code
 }
 
 /** 票 21:视图条件的两个选项(决策地图票 05:全局视图条件,与范围/行业同级)。 */
@@ -1196,7 +1303,7 @@ function measureAnchor() {
     // 用它做判据不会出现「设了高度→量到高度→又设回 auto」的来回抖动。
     const natural = detailRef.value ? detailRef.value.scrollHeight : 0
     const height = Number.isFinite(natural) && natural > 0 ? natural : 0
-    return { canvas, layout: solvePointAnchor({ anchor, canvas, height, iconSize: anchor.iconSize || 18, gap: anchor.panelGap || 12 }) }
+    return { canvas, layout: solvePointAnchor({ anchor, canvas, height, iconSize: anchor.iconSize || 18, gap: anchor.panelGap || 12, avoidKey: `${anchor.panelGap || 0}/${anchor.iconSize || 0}` }) }
 }
 
 /**
@@ -1213,6 +1320,8 @@ function measureAnchor() {
 function refreshAnchor(options = {}) {
     if (!detailOpen.value || introVisible.value || typeof document === 'undefined') return
     if (document.hidden && options.force !== true) return
+    // 维护者反馈:限速 10Hz 后"点位摘要/设备详情面板不跟手"。锚定必须**逐帧跟随**,
+    // 与 DOM 图标同一条纪律;重复写 DOM 由下面的 `lastAnchorKey` 判定挡住,镜头不动时不写。
 
     const main = detailRef.value ? detailRef.value.closest('.cockpit-shell__main') : null
     if (main) {
@@ -1221,8 +1330,12 @@ function refreshAnchor(options = {}) {
     }
 
     const { layout } = measureAnchor()
+    // 票 76(2026-10-26 面板让位不稳定根因):缓存键**必须包含让位量**。
+    // 原来键里只有 side/rect/line —— 进入聚合选择态(②④)时标记位置没变,但 `panelGap` /
+    // `iconSize` 变了(84/40 vs 12/18),落位结果随之变化,然而键相同 → 被当成「没变化」跳过,
+    // 于是面板**停在旧位置**。走查表现为「hover 时偶尔让位、真正进入选择态反而不让位」。
     const key = layout.visible
-        ? `${layout.side}|${Math.round(layout.rect.left)},${Math.round(layout.rect.top)},${Math.round(layout.rect.width)},${Math.round(layout.rect.height)}|${JSON.stringify(layout.line)}`
+        ? `${layout.side}|${Math.round(layout.rect.left)},${Math.round(layout.rect.top)},${Math.round(layout.rect.width)},${Math.round(layout.rect.height)}|${JSON.stringify(layout.line)}|${layout.avoidKey || ''}`
         : layout.reason
     // 与上一帧相同就**不再写 DOM**(规格:镜头稳定且布局未变时停止重算)。
     if (key === lastAnchorKey) return
@@ -1374,7 +1487,8 @@ async function refreshCurrentModePanels() {
         // 票 67:片区也要跟着下(预警分析是事件族里唯一收片区入参的端点)。
         await eventStore.loadPanels({ mode: eventStore.mode, industry: industry.value, scope: scope.value })
     } else {
-        await overviewStore.loadPanels({ industry: industry.value, scope: scope.value })
+        // 四模块端点都不收入参(规格 §1):周期刷新只重取,不带任何筛选条件。
+        await overviewStore.loadPanels()
     }
 }
 
@@ -1670,7 +1784,11 @@ onMounted(() => {
         // `register-mocks`。此前的 `if (dataSource === MOCK)` 门让 real 成为空壳——
         // 规格明令移除;失败语义(分区 UNAVAILABLE / NO_BASE_URL)由 requestCockpitJson
         // 与 sectionState 对两种数据源一视同仁,**没有**任何「real 失败回退 mock」的路径。
-        const plan = planCockpitStartup(result.config)
+        // 票 76:DEV 走查桥 —— `?dataSource=mock|real` 覆盖配置(配合 `?scenario=` 出 K>1 聚合场景)。
+        // 生产构建里 `import.meta.env.DEV` 为假,helper 原样返回,不改正式路径。
+        const plan = planCockpitStartup(
+            withUrlDataSourceOverride(result.config, null, !!(import.meta.env && import.meta.env.DEV)),
+        )
         for (const action of plan.actions) {
             if (action === STARTUP_ACTION.REGISTER_MOCKS) {
                 ensureCockpitMocks()
@@ -1725,7 +1843,7 @@ onMounted(() => {
             }
             if (action === STARTUP_ACTION.LOAD_OVERVIEW_PANELS) {
                 overviewStore.configure({ config: result.config })
-                overviewStore.loadPanels({ industry: industry.value, scope: scope.value })
+                overviewStore.loadPanels()
                 continue
             }
             // 票 15:状态推送。外壳只说"用配置启动"——地址、凭据与 mock/real 的连接方式
@@ -1777,7 +1895,7 @@ onMounted(() => {
     // (规格:恢复或尺寸变化后立即重算,不等下一帧)。
     stopMapFrame = onMapFrame(() => refreshAnchor())
     // 相机状态变化也重算(隐藏标签页里渲染帧不触发,这条才是可靠的跟随路径;见 mapService 的注释)。
-    // 它由输入/飞行驱动、次数有限,所以同样走 `force`(不受"页面不可见"早退影响)。
+    // 恢复为逐帧 force:维护者反馈"点位摘要/设备详情面板不跟手",锚定必须与镜头同帧对齐。
     stopCameraState = onCameraStateChange(() => refreshAnchor({ force: true }))
     anchorResizeHandler = () => refreshAnchor({ force: true })
     window.addEventListener('resize', anchorResizeHandler)
@@ -1853,8 +1971,8 @@ watch(industry, (next) => {
     monitoringStore.loadPanels({ industry: next })
     // 票 13:当前预警的事件列表跟随全局行业(`/events/current` 收 industry 入参)。
     eventStore.loadPanels({ mode: eventStore.mode, industry: next })
-    // 票 20:概览只有「运行监测」那块收行业入参(另两块不收,store 内部只重取能筛的那块)。
-    overviewStore.loadPanels({ industry: next })
+    // 票 05–08:概览四模块端点都不收行业入参,故这里**不重取**(切筛选空发四个请求没有意义);
+    // 口径在面板说明里如实交代(domain/filterCaliber.js 的既有纪律)。
     // 票 24:行业筛选同样作用到管线与构件图层。几何是**全量取回、本地裁剪**,
     // 所以切行业只改本地筛选条件、不重新发请求(切换必须即时,不等一个往返)。
     pipeNetworkStore.setIndustry(next)
@@ -1875,7 +1993,7 @@ watch(industry, (next) => {
 watch(scope, (next) => {
     if (!lifecycleReady.value) return
     infrastructureStore.loadPanels({ scope: next })
-    overviewStore.loadPanels({ scope: next })
+    // 票 05–08:概览四模块不收片区入参,不重取(同行业筛选的立场)。
     monitoringStore.loadPanels({ scope: next })
     // 换片区 = 换一批数据 ⇒ 页码回第 1 页由 eventStore 的条件键(含 `scope`)负责。
     eventStore.loadPanels({ mode: eventStore.mode, scope: next })
@@ -2599,7 +2717,8 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
     display: flex;
     align-items: center;
     gap: calc(6 * var(--u));
-    max-width: min(72%, calc(100% - 2 * var(--column-w)));
+    /* 让位宽度用 --rail-w(宽屏时是两条栏宽),不用 --column-w —— 否则宽屏下浮层压面板。 */
+    max-width: min(72%, calc(100% - 2 * var(--rail-w)));
     pointer-events: none;
 }
 
@@ -2735,6 +2854,73 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
 .cockpit-shell__column.is-left { left: 0; }
 
 .cockpit-shell__column.is-right { right: 0; }
+/* ============================================================
+   超宽屏(长宽比 >= 3:1):**只改栏内的排列方向,不动两栏的位置**
+   ============================================================
+   维护者 2026-10-23(第二次澄清):面板宽度还是固定的,且依然将面板进行左右分区、
+   留出中间的一张图部分;只是在屏幕比例由 16:9 变为超宽屏(3:1)时,两侧面板由
+   分别的上下排列变为左右排列,其依然分布在屏幕两侧。
+
+   这里**不**把两栏挪去上下半屏(那是更早一版的理解错误),只做两件事:
+     1. 两栏仍在左右两侧、中央留出一张图(位置规则不变);
+     2. 栏**内**两块面板由竖排改并排 —— 否则两块会在一条窄栏里各自挤成细条。
+
+   **判据改用视口媒体查询**(比例取自维护者给的实例 3456×1239,约 2.79)。
+   不再用 @container 的两个理由:
+     · 换算要扣顶栏/筛选条/内边距,扣多少还随高度变,门槛值会漂移;
+     · @container 的**查询容器不能被自己的查询命中** —— 早先把 --rail-w 写在
+       `.cockpit-shell__main` 上,那条规则从未生效,栏宽一直是 --column-w,
+       于是栏内两块面板要么被压窄、要么溢出(前几轮全部翻车于此)。
+   代价:微前端宿主里量的是视口而非外壳;本驾驶舱是整页应用,可接受。
+
+   **并排的唯一规则:每块面板宽度 = 竖排时的一条栏宽(--column-w),一分不差。**
+   (维护者 2026-10-23 澄清两次后的定稿:宽度观感必须与并排前一致,
+    相当于「收起一块、另一块独占一整条」。)
+
+   这条规则把前几轮的错误都排除掉:
+     · 不给面板百分比 basis(那会随栏宽变化而收窄);
+     · 不设栏宽上限(百分比/像素上限一旦生效,就只剩「把面板压窄」一条路);
+     · 不用宽度门槛去拒绝并排(观感一致性不该取决于屏幕多宽)。
+   栏宽自然变成「两块原宽 + 间隙」,中央一张图让出这一段,仍夹在两栏之间。
+
+   注意 --u 已改为**按视口高度**缩放(tokens.css):只加宽不加高的屏上元素不再放大,
+   所以「两块原宽」在超宽屏上是 2×440px 这类绝对宽度,并排后不会挤占中央。 */
+/* 3456×1239 约等于 2.79:维护者给出的实例宽度比 —— 到此比例开始并排。 */
+@media (min-aspect-ratio: 3456 / 1239) {
+    /*
+        栏内改并排。**每块面板的宽度与竖排时完全相同**(都是一条 --column-w)——
+        维护者 2026-10-23 澄清两次后定稿的语义:
+        「变为并排后,面板的宽度观感应与并排前一致,不应变为收窄的观感
+          (类似于将同侧一块面板收起而独占一整条的观感)」。
+        栏的位置(left/right)仍不动,中央一张图照旧夹在中间。
+    */
+    /* 让位宽度随之翻倍(中央浮层/事件详情/详情卡都吃这一条)。
+       正常比例回落到 tokens 的 `--rail-w: var(--column-w)`。
+       现在写在外壳自己身上是**安全的**:媒体查询不要求「查询容器不能被自身命中」,
+       而早先用 @container 时这条规则对容器自身不生效 —— 那正是「面板被压窄 / 出框」的根因。 */
+    .cockpit-shell__main {
+        --rail-w: calc(2 * var(--column-w) + calc(8 * var(--u, 1px)));
+    }
+
+    .cockpit-shell__column {
+        flex-direction: row;
+        /* 栏宽 = 两块面板原宽 + 间隙(此时每块正好回到 --column-w 的设计观感)。 */
+        width: var(--rail-w);
+    }
+
+    .cockpit-shell__column > .cockpit-panel {
+        min-width: 0;
+        /* 并排后每块的宽度**就等于一条栏宽 --column-w** —— 维护者 2026-10-23 原话:
+           「变为并排后,面板的宽度观感应与并排前一致,不应变为收窄的观感
+           (类似于将同侧一块面板收起而独占一整条的观感)」。
+           所以写死 0 0 var(--column-w):不放大、不收缩、不吃百分比。
+           栏宽随之变成「两块原宽 + 间隙」,两边正好铺满,中央留一整条给一张图。
+           grow 与 shrink 都是 0:宁可让栏超出容器被裁,也不把面板压窄 ——
+           压窄的同时 --u 的字号不变,观感就是「变窄、变形」。 */
+        flex: 0 0 var(--column-w);
+    }
+}
+
 
 /* 票 10:两级详情浮层。贴在两栏之间的空档里、右栏左侧,
    宽度与栏同量级;高度由内容决定、超长时卡片自己滚动。
@@ -2743,8 +2929,10 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
     position: absolute;
     z-index: var(--z-columns);
     top: 0;
-    right: calc(var(--column-w) + var(--space-2));
-    width: min(calc(100% - 24px), clamp(420px, calc(480 * var(--u)), 560px));
+    /* 让位宽度用 --rail-w:宽屏时右栏是两条栏宽,详情卡要避开整条栏。 */
+    right: calc(var(--rail-w) + var(--space-2));
+    /* 宽度上限同时受 rail-w 约束:宽屏时两条栏宽之外才是留给详情的空间。 */
+    width: min(calc(100% - 2 * var(--rail-w) - 24px), clamp(420px, calc(480 * var(--u)), 560px));
     max-height: 100%;
     min-height: 0;
     display: flex;
@@ -2809,8 +2997,8 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
     position: absolute;
     z-index: 5;
     top: 24px;
-    left: calc(var(--column-w) + 24 * var(--u));
-    width: min(480px, calc(100% - var(--column-w) - 48 * var(--u)));
+    left: calc(var(--rail-w) + 24 * var(--u));
+    width: min(480px, calc(100% - var(--rail-w) - 48 * var(--u)));
     max-height: calc(100% - 48px);
     overflow-y: auto;
     overflow-x: hidden;

+ 217 - 34
src/modules/cockpit/components/CockpitPanel.vue

@@ -5,7 +5,14 @@
   标题栏是 button,其文本即按钮的可访问名(标记符号 aria-hidden,不参与命名)。
 -->
 <template>
-    <section class="cockpit-panel" :class="{ 'is-collapsed': !expanded }" :data-panel-id="panelId">
+    <section
+        class="cockpit-panel"
+        :class="{ 'is-collapsed': !expanded }"
+        :style="{ '--panel-grow': effectiveGrow, '--panel-basis': basis || '0%' }"
+        :data-panel-id="panelId"
+        :data-panel-grow="Math.round(effectiveGrow * 10) / 10"
+        :data-panel-measured="measuredGrow ? 'yes' : 'no'"
+    >
         <div class="cockpit-panel__heading">
         <button
             type="button"
@@ -18,7 +25,7 @@
                 <CockpitIcon v-if="iconName" :name="iconName" :size="panelIconSize" class="cockpit-panel__icon" />
                 {{ title }}
             </span>
-            <small class="cockpit-panel__meta">{{ meta }}</small>
+            <small class="cockpit-panel__meta" :class="{ 'is-end': metaAlign === 'end' }">{{ meta }}</small>
             <span class="cockpit-panel__mark" aria-hidden="true">{{ expanded ? '−' : '+' }}</span>
         </button>
         <SectionStateTag v-if="section" class="cockpit-panel__state" :name="panelId" :label="title" :section="section" />
@@ -26,13 +33,21 @@
 
         <!-- v-show 而非 v-if:收起再展开不应重建面板内容或触发重新取数。 -->
         <div v-show="expanded" class="cockpit-panel__body">
-            <slot />
+            <!--
+                内容包裹层(2026-10-23 维护者第 1 条:面板高度自适应作为**基本规则**):
+                这一层的高度=内容自然高度(不被拉伸),量它就知道"这块面板要多少空间",
+                再按它给 flex 权重。量 body 本身不行 —— body 会被 flex 拉伸,
+                scrollHeight 会跟着分配高度变,权重会自我强化。
+            -->
+            <div ref="contentRef" class="cockpit-panel__content">
+                <slot />
+            </div>
         </div>
     </section>
 </template>
 
 <script setup>
-import { computed } from 'vue'
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
 import CockpitIcon from './CockpitIcon.vue'
 import SectionStateTag from './SectionStateTag.vue'
 import { panelIcon } from '../domain/panelCatalog.js'
@@ -40,8 +55,39 @@ import { panelIcon } from '../domain/panelCatalog.js'
 const props = defineProps({
     title: { type: String, required: true },
     meta: { type: String, default: '' },
+    /**
+     * 标题右侧小字的对齐:`''`(缺省,左对齐,跟随标题)/ `'end'`(贴右)。
+     *
+     * 为什么是**局部 prop** 而不是改全局:overview 四块的右上角小字是规格 §6.1 的
+     * 「右上角」要求(设备规模·类型·片区分布 等),而其它模式的面板 meta 一直是
+     * 「标题右侧的数据提要」,两条语义不同 —— 全局改会把 12 块面板一起挪位。
+     */
+    metaAlign: { type: String, default: '' },
     section: { type: Object, default: null },
     expanded: { type: Boolean, default: true },
+    /**
+     * 该面板在所在栏里的**高度权重**(2026-10-23 维护者:面板高度按内容自适应)。
+     *
+     * 取值由外壳按「这块面板大概有多少内容」给:默认 1(两块等分),内容密的给 >1、
+     * 内容简的给 <1。它是相对权重不是一个尺寸 —— flex-grow 本来就是「剩下多少按权重分」,
+     * 所以换屏幕高度不用重算。缺省 1 表示"不关心,按等分"。
+     */
+    grow: { type: Number, default: 0 },
+    /**
+     * 这块面板是否**以表格/清单为主体**(外壳按「有没有 list 块」算好传进来)。
+     *
+     * 为什么要传:自适应权重按内容自然高度量,表格行数上百时高度虚高,
+     * 会把同列另一块挤成一条缝。渲染层仍不认识面板身份 —— 它只认这个布尔标志。
+     */
+    tableHeavy: { type: Boolean, default: false },
+    /**
+     * 这块面板在**所在栏**里的高度份额(如 `"38.50%"`),由外壳按同栏各块权重归一算出。
+     *
+     * 为什么由外壳给:份额是同栏几块之间的相对分配,单块看不到兄弟、纯 CSS 也算不出权重和。
+     * 它落到 `flex-basis` 上 —— 下限用 basis 表达而不是 `min-height`,
+     * 因为百分比 `min-height` 会把整栏先顶满、让权重分配几乎失效(见样式里的说明)。
+     */
+    basis: { type: String, default: '' },
     /**
      * 面板的稳定英文键(`domain/panelCatalog.js` 的 panelId)。
      * 走查与断言读它来认面板 —— 不能从面板体内的文案反推:
@@ -52,6 +98,77 @@ const props = defineProps({
 
 const emit = defineEmits(['toggle'])
 
+/**
+ * 面板高度自适应(维护者 2026-10-23 第 1 条:**基本规则**,按内容设权重)。
+ *
+ * 做法:量内容包裹层的自然高度,把它作为 flex-grow 的权重 —— flex 分配剩余空间时
+ * 本来就是「按权重分」,于是"内容多的面板拿到更多高度"是**测量结果**而不是手填常量。
+ *
+ * 为什么不量 body:body 被 flex 拉伸,scrollHeight 会跟着分配高度走,
+ * 权重自我强化(分得多 → 量得大 → 分得更多)。内容层是 auto 高度,量它才稳。
+ *
+ * 兜底:还没量到(首帧/SSR)时用外壳给的 grow;两者都没有就回 1(等分)。
+ * 量到 0(空面板)时也用 1,避免除零式塌陷。
+ */
+const contentRef = ref(null)
+const measuredGrow = ref(0)
+let resizeObserver = null
+
+/**
+ * 表格类面板的权重打折(维护者 2026-10-23 反馈第 3 条)。
+ *
+ * 为什么需要:自适应权重按内容自然高度量,而**表格/清单**(预警处置的「当前预警」「历史分析」)
+ * 每行高度固定、行数却可能上百 —— 量出来的高度能是旁边面板的十几倍,于是同列的另一块
+ * 被挤到只剩标题栏。维护者原话:「带有表格的面板权重会变得非常高…需要保留一个下限比例,
+ * 至少能够显示面板的大部分区域」。
+ *
+ * 两件事同时做:
+ *   1. **打折**:表格面板的权重乘 `TABLE_GROW_DISCOUNT`(内容多不等于该占那么多版面);
+ *   2. **下限**:同一栏里,权重不得低于该栏最大权重的 `MIN_GROW_RATIO` ——
+ *      保证被挤压的那块至少还剩这一比例的高度,看得见大部分内容。
+ *
+ * 判据是**有没有表格块**(`block.kind === 'list'`),不是面板 id —
+ * 渲染层不认识面板身份这条纪律不变;面板身份由外壳算好传进来(`tableHeavy`)。
+ */
+const TABLE_GROW_DISCOUNT = 0.35
+/** 同栏内任一面板的权重不得低于最大权重的这个比例(即至少分到约 1/3 的高度)。 */
+const MIN_GROW_RATIO = 0.34
+
+function measureContent() {
+    const element = contentRef.value
+    if (!element) return
+    // scrollHeight 取内容自然高度(本层不设 overflow,等价于 offsetHeight 的稳定版)。
+    const height = element.scrollHeight || element.offsetHeight || 0
+    if (height <= 0) {
+        measuredGrow.value = 0
+        return
+    }
+    // 表格面板打折后再交给外壳做同栏归一(下限在那里统一施加,见 `normalizeGrow`)。
+    measuredGrow.value = props.tableHeavy ? height * TABLE_GROW_DISCOUNT : height
+}
+
+onMounted(() => {
+    measureContent()
+    // 内容变化(取数到达、筛选变化、宽窄重排、展开/收起)都要重新量 ——
+    // 监听**内容层自己**的尺寸:收起时 v-show 置 display:none,量到 0 就回落
+    // `props.grow`(此时权重本就不参与分配,因为 is-collapsed 已 flex:0 0 auto);
+    // 再展开时 display 变化会触发 ResizeObserver,重新拿到真实高度。
+    if (typeof ResizeObserver === 'function' && contentRef.value) {
+        resizeObserver = new ResizeObserver(measureContent)
+        resizeObserver.observe(contentRef.value)
+    }
+})
+
+onBeforeUnmount(() => {
+    if (resizeObserver) resizeObserver.disconnect()
+    resizeObserver = null
+})
+
+/** 实际生效的权重:测量值优先,其次外壳声明,最后 1。 */
+const effectiveGrow = computed(() => measuredGrow.value || props.grow || 1)
+
+
+
 /** 面板图标语义名;无对应图标时为空串 → 不渲染图标,不抛错。 */
 const iconName = computed(() => panelIcon(props.panelId))
 
@@ -80,8 +197,21 @@ const panelIconSize = 'calc(17 * var(--u, 1px))'
 
     position: relative;
     display: flex;
-    /* 展开时参与分配所在栏的高度:两块等分,收起的那块缩回标题栏高度、其余吃掉全部空间。 */
-    flex: 1 1 0;
+    /* 展开时参与分配所在栏的高度。
+       2026-10-23(维护者):**不再硬等分**,改为按内容高度加权 —— `--panel-grow` 由外壳
+       按该面板内容的估算高度给出默认比例(内容多的拿更多),没给时退回 1:1。
+       至少保留 `min-height`,否则内容少的那块会被压到只剩标题栏。 */
+    /* 高度分配(维护者 2026-10-23 反馈第 3 条:表格面板不能把同栏挤没)。
+       **下限必须用 flex-basis 表达,不能用 min-height**:
+       百分比 `min-height` 在 flex 列里相对父容器高度解析,两块各占 34%/42% 的 min
+       会先把整栏顶满(合计 76%),剩下 24% 才按 grow 分 —— 于是"打折"几乎失效,
+       而内容少的那块被顶到 34% 后又被撑出大片空白。
+       改成 basis = 权重份额:`calc(权重 ÷ 权重和 × 100%)` 在 flex 里就是"先按份额预分",
+       剩余空间再按 grow 微调,打折与下限同时成立。
+       纯 CSS 算不出同栏权重和,所以外壳把份额算好写成 `--panel-basis`(见 columnBasis)。 */
+    flex: var(--panel-grow, 1) 1 var(--panel-basis, 0%);
+    /* 硬护栏:任何情况下至少露出标题栏 + 一段内容(防止极端内容把另一块压成一条线)。 */
+    min-height: calc(72 * var(--u, 1px));
     flex-direction: column;
     min-width: 0;
     min-height: 0;
@@ -100,6 +230,15 @@ const panelIconSize = 'calc(17 * var(--u, 1px))'
 
 .cockpit-panel.is-collapsed {
     flex: 0 0 auto;
+    /* 收起态只占标题栏高度,下限不适用。 */
+    min-height: 0;
+    flex-basis: auto;
+}
+
+/* 卡片密度(2026-10-23):面板内边距与块间距压矮一档,好让四块内容在一屏内看完、
+   不依赖滚动条。压缩的是**呼吸感**,不是字号 —— 读数与标签一档不降。 */
+.cockpit-panel__body {
+    --panel-density-gap: calc(2 * var(--u, 1px));
 }
 
 .cockpit-panel::before {
@@ -135,41 +274,25 @@ const panelIconSize = 'calc(17 * var(--u, 1px))'
     min-height: calc(40 * var(--u, 1px));
     padding: 0 calc(12 * var(--u, 1px)) 0 calc(8 * var(--u, 1px));
     border: 0;
-    border-bottom: 1px solid rgba(1, 171, 234, 0.22);
-    background:
-        linear-gradient(90deg, var(--panel-mid, rgba(0, 53, 156, 0.2)) 0%, var(--panel-bot, rgba(0, 15, 52, 0.38)) 42%, transparent 78%),
-        linear-gradient(180deg, rgba(1, 171, 234, 0.1), transparent);
+    /* 标题栏**自己不画背景、也不画线**(票 11 二轮真正修好这条的改动)。
+       根因不是"两条线",而是**背景带在 button 右端就结束了**:状态光点是 button 的
+       兄弟节点,head 的渐变背景(… 42%, transparent 78%)到 78% 就透明,
+       于是从状态光点往左出现一条明显的"带子断口" —— 肉眼看就是标题与状态光点被分割。
+       所以背景与分隔线都搬到 .cockpit-panel__heading(横贯 head + state 整条)。 */
+    background: transparent;
     font: inherit;
     text-align: left;
     cursor: pointer;
 }
-
-.cockpit-panel__head::after {
-    content: '';
-    position: absolute;
-    left: 12px;
-    right: 12px;
-    bottom: -1px;
-    height: 1px;
-    background: linear-gradient(90deg, rgba(1, 171, 234, 0.85), rgba(1, 171, 234, 0.1) 46%, transparent);
-}
-
-.cockpit-panel__head:hover {
-    background:
-        linear-gradient(90deg, rgba(1, 171, 234, 0.6) 0%, rgba(0, 15, 52, 0.34) 42%, transparent 78%),
-        linear-gradient(180deg, rgba(1, 171, 234, 0.14), transparent);
-}
+/* hover 反馈画在 heading 上(不是 head):head 只覆盖按钮那一段,
+   在 head 上画高亮会在状态光点左侧又切一刀 —— 与要修的那个 bug 是同一个形状。 */
 
 .cockpit-panel__head:focus-visible {
     outline: 1px solid var(--cyan, #01abea);
     outline-offset: -2px;
 }
 
-.cockpit-panel.is-collapsed .cockpit-panel__head {
-    border-bottom-color: transparent;
-}
-
-.cockpit-panel.is-collapsed .cockpit-panel__head::after {
+.cockpit-panel.is-collapsed .cockpit-panel__heading::after {
     display: none;
 }
 
@@ -222,6 +345,12 @@ const panelIconSize = 'calc(17 * var(--u, 1px))'
     text-overflow: ellipsis;
 }
 
+.cockpit-panel__meta.is-end {
+    /* 规格 §6.1:overview 四块的小字是"右上角",贴右显示;仍与折叠符号留出间距。 */
+    padding-right: calc(6 * var(--u, 1px));
+    text-align: right;
+}
+
 .cockpit-panel__meta::before {
     content: '▸ ';
     color: rgba(1, 171, 234, 0.7);
@@ -242,19 +371,66 @@ const panelIconSize = 'calc(17 * var(--u, 1px))'
     z-index: 1;
     flex: 1 1 auto;
     min-height: 0;
-    padding: calc(10 * var(--u, 1px)) calc(11 * var(--u, 1px));
+    /* 2026-10-23 压矮:10u→7u 上下、11u→10u 左右(四块要在一屏内看完)。 */
+    padding: calc(7 * var(--u, 1px)) calc(10 * var(--u, 1px));
     /* 2026-10-13 维护者要求:严格只竖向滚动,横向滚动条不出现。
        横向裁切风险由内容侧防:面板内所有网格列都有 minmax(0,·),长词/编码走
        overflow-wrap(见 .panel-row dd 与 .panel-item 系列的 min-width:0)。 */
     overflow-y: auto;
     overflow-x: hidden;
 }
+
+/* 内容包裹层:高度=内容自然高度(不被 flex 拉伸),供 ResizeObserver 测量。
+   量它就是量"这块面板要多少空间",权重由测量值给(见 script 里的 measureContent)。 */
+.cockpit-panel__content {
+    display: block;
+    min-width: 0;
+}
+/* 标题行容器:**唯一**画背景带与分隔线的地方(head 自身透明)。
+   背景带必须覆盖 head + 状态光点**整条**,否则在状态光点左侧会出现断口 ——
+   那正是维护者两次指出的"标题与右侧状态光点之间存在明显分割"。 */
 .cockpit-panel__heading {
+    position: relative;
     display: flex;
     align-items: center;
     flex: none;
     min-width: 0;
-    border-bottom: 1px solid var(--panel-line);
+    border-bottom: 1px solid transparent;
+    background:
+        linear-gradient(
+            90deg,
+            var(--panel-mid, rgba(0, 53, 156, 0.2)) 0%,
+            var(--panel-bot, rgba(0, 15, 52, 0.38)) 46%,
+            rgba(0, 15, 52, 0.28) 100%
+        ),
+        linear-gradient(180deg, rgba(1, 171, 234, 0.1), transparent);
+}
+
+/* hover 高亮同样横贯 head + 状态光点整条(与背景带的覆盖范围一致)。 */
+.cockpit-panel__heading:hover {
+    background:
+        linear-gradient(90deg, rgba(1, 171, 234, 0.32) 0%, rgba(0, 15, 52, 0.36) 46%, rgba(0, 15, 52, 0.3) 100%),
+        linear-gradient(180deg, rgba(1, 171, 234, 0.14), transparent);
+}
+
+.cockpit-panel__heading::after {
+    content: '';
+    position: absolute;
+    left: 0;
+    right: 0;
+    bottom: -1px;
+    height: 1px;
+    /* 一条完整的分割线:从标题左侧竖条起(6%)一直拉到面板右缘,
+       **不在状态光点之前收淡** —— 提前收淡正是"看起来被切开"的另一半原因。 */
+    background: linear-gradient(
+        90deg,
+        transparent 0%,
+        var(--panel-line-hi, #01abea) 6%,
+        rgba(1, 171, 234, 0.85) 60%,
+        rgba(1, 171, 234, 0.62) 100%
+    );
+    opacity: 0.65;
+    pointer-events: none;
 }
 .cockpit-panel__heading .cockpit-panel__head {
     flex: 1;
@@ -262,5 +438,12 @@ const panelIconSize = 'calc(17 * var(--u, 1px))'
     width: auto;
     border-bottom: 0;
 }
-.cockpit-panel__state { margin-right: var(--space-2); }
+/* 状态光点:与标题栏共用同一条背景带(见 .cockpit-panel__heading)。
+   右侧内边距与 head 的右内边距一致,让背景带两侧留白对称 ——
+   之前它靠 margin-right 顶开,导致背景带在它左侧断开。 */
+.cockpit-panel__state {
+    margin-right: calc(12 * var(--u, 1px));
+    position: relative;
+    z-index: 2;
+}
 </style>

+ 684 - 31
src/modules/cockpit/components/PanelBlocks.vue

@@ -78,7 +78,19 @@
                 class="panel-block"
                 :data-panel-block="block.kind"
             >
-                <EllipsisText v-if="block.label" tag="h4" class="panel-block__title" :text="block.label" />
+                <!--
+                    块标题行(票 11 视觉对齐):左侧竖条 + 标题,右侧可选小字(`block.aside`)。
+                    小字是**块级口径提要**(如「点击卡片可按行业筛选地图」「片区在网设备」),
+                    与面板右上角小字(PANEL_SUBTITLES,面板身份)分开:一个说块、一个说面板。
+                    文案全部由 domain 给,渲染层不合成、不改写。
+                -->
+                <div v-if="block.label || block.aside" class="panel-block__head">
+                    <h4 v-if="block.label" class="panel-block__title" :data-block-title="block.label">
+                        <i class="panel-block__title-bar" aria-hidden="true"></i>
+                        <EllipsisText tag="span" :text="block.label" />
+                    </h4>
+                    <EllipsisText v-if="block.aside" tag="small" class="panel-block__aside" :text="block.aside" data-block-aside />
+                </div>
 
                 <!-- KPI:等宽数字 + 单位,缺值降亮度显示占位符。
                      2026-10-13 维护者要求:每项读数左侧带语义图标(domain 的 KPI_ICONS
@@ -86,7 +98,12 @@
                 <div
                     v-if="block.kind === 'kpi'"
                     class="panel-kpis"
-                    :class="{ 'panel-kpis--two-by-two': block.layout === 'two-by-two' }"
+                    :class="{
+                        'panel-kpis--two-by-two': block.layout === 'two-by-two',
+                        'panel-kpis--value-first': block.layout === 'value-first',
+                        'panel-kpis--two': block.layout === 'two',
+                        'panel-kpis--three': block.layout === 'three',
+                    }"
                     data-kpi-row
                 >
                     <!-- 票 38(第 4 条):基础档案的「设施总数 + 管线里程」两项落回同一张
@@ -97,8 +114,14 @@
                         v-for="item in block.items"
                         :key="item.key"
                         class="panel-kpi"
-                        :class="{ 'panel-kpi--wide': item.wide === true }"
+                        :class="[
+                            { 'panel-kpi--wide': item.wide === true },
+                            block.layout === 'value-first' ? 'panel-kpi--value-first' : '',
+                            item.accent ? `panel-kpi--accent-${item.accent}` : '',
+                        ]"
                         :data-kpi="item.key"
+                        :data-kpi-accent="item.accent || null"
+                        :data-kpi-value-tone="item.valueTone || null"
                     >
                         <!-- 票 37 走查扩展:读数图标走 mask 染语义色(KPI_ICON_COLORS,未命中主蓝)。
                              色画在容器上(currentColor),图标本体是白剪影。 -->
@@ -133,15 +156,93 @@
                             v-else
                             tag="b"
                             class="panel-kpi__value"
-                            :class="{ 'is-muted': isPlaceholder(item.value) }"
+                            :class="[{ 'is-muted': isPlaceholder(item.value) }, item.valueTone ? `is-${item.valueTone}` : '']"
                             :text="kpiReading(item)"
+                            :hint="item.hint"
                         >
                             {{ item.value }}<em v-if="item.unit && !isPlaceholder(item.value)"> {{ item.unit }}</em>
                         </EllipsisText>
-                        <EllipsisText v-if="item.hint" tag="small" class="panel-kpi__hint" :text="item.hint" />
+                        <!--
+                            卡内小字(`caption`,设计稿的第三行,如「已派发处置」「全域管网总长」):
+                            **屏上常显**,超框时按共用省略方法收成「…」,完整原文进 title。
+
+                            `hint`(口径细节,如在线率的「分子 3 / 分母 10」)**不再占屏上位置**:
+                            维护者 2026-10-23 明确「不需要在外展示分子分母,鼠标 hover 展示即可」。
+                            它现在挂在读数自身的 title 上 —— 读数与说明同属一个可悬停对象,
+                            不用再在卡里多占一行(这正是卡片被撑高的原因之一)。
+                        -->
+                        <EllipsisText v-if="item.caption" tag="small" class="panel-kpi__caption" :text="item.caption" data-kpi-caption />
+                        <!--
+                            差值(规格 §6.6「较昨日」):文案与色调**都由 domain 给**
+                            (`deltaText` / `deltaTone`),组件只画 —— 正红负绿、0 中性
+                            这条规则不落在渲染层(否则第二个面板就会各写一套)。
+                        -->
+                        <small
+                            v-if="item.deltaText"
+                            class="panel-kpi__delta"
+                            :class="`is-${item.deltaTone}`"
+                            :data-kpi-delta="item.deltaTone"
+                        >{{ item.deltaText }}</small>
                     </div>
                 </div>
 
+                <!--
+                    卡片组(票 02):一排「标签 + 计数」的小卡 —— 设备类型卡(卡内小字=监测内容)、
+                    片区卡、固定等级卡共用这一块画法。`fixed` 声明的是语义(固定项、无数据 0),
+                    不是另画一种卡;`note` 是卡内小字,没有就不画那一行。
+                -->
+                <!--
+                    卡片组(票 02 建立,票 11 视觉对齐):三种卡共用这一块画法 ——
+                    类型卡(徽标 + 计数 + 名称 + 监测内容小字)、片区卡(计数 + 名称)、
+                    固定等级卡(按等级着色的计数 + 名称)。
+
+                    版式由 domain 的 `layout` 声明(`two-by-two` / `three` / `four`),
+                    语义色由行的 `tone`(边框+徽标)与 `valueTone`(只染数字)给;
+                    可点行(`action` 且行有 `actionKey`)渲染成 `<button>`,与条形图同一条接缝。
+                -->
+                <div
+                    v-else-if="block.kind === 'cards'"
+                    class="panel-cards"
+                    :class="block.layout ? `panel-cards--${block.layout}` : ''"
+                    :data-cards-fixed="block.fixed ? 'yes' : 'no'"
+                    :data-cards-layout="block.layout || null"
+                    :data-cards-action="block.action || null"
+                    data-card-list
+                >
+                    <component
+                        :is="block.action && row.actionKey ? 'button' : 'div'"
+                        v-for="row in block.rows"
+                        :key="row.key"
+                        class="panel-card"
+                        :class="[
+                            block.action && row.actionKey ? 'panel-card--button' : '',
+                            row.icon ? 'panel-card--with-icon' : '',
+                            row.tone ? `panel-card--tone-${row.tone.replace(':', '-')}` : '',
+                            row.valueTone ? `panel-card--value-${row.valueTone}` : '',
+                            row.selected ? 'is-active' : '',
+                        ]"
+                        :type="block.action && row.actionKey ? 'button' : null"
+                        :data-card="row.key"
+                        :data-card-tone="row.tone || null"
+                        :data-card-value-tone="row.valueTone || null"
+                        :data-card-action="block.action || null"
+                        :aria-pressed="block.action && row.actionKey ? (row.selected ? 'true' : 'false') : null"
+                        :title="block.action && row.actionKey ? `按「${row.label}」筛选地图` : null"
+                        @click="block.action && row.actionKey ? emit('panel-action', { kind: block.action, key: row.actionKey }) : null"
+                    >
+                        <!-- 徽标:卡内小图标(行业图标走既有切图,语义色染在徽章容器上)。 -->
+                        <span v-if="row.icon" class="panel-card__badge" aria-hidden="true">
+                            <CockpitIcon :name="row.icon" size="calc(15 * var(--u, 1px))" />
+                        </span>
+                        <b class="panel-card__count" data-card-count>{{ row.count }}<em v-if="block.unit"> {{ block.unit }}</em></b>
+                        <EllipsisText class="panel-card__label" :text="row.label" />
+                        <!-- 小字要能吃两行,所以不套共用省略组件(它固定 nowrap);
+                             完整原文仍走 title,与共用省略方法是同一条纪律的另一形态。 -->
+                        <small v-if="row.note" class="panel-card__note" :title="row.noteTitle || row.note">{{ row.note }}</small>
+                    </component>
+                    <p v-if="!block.rows.length" class="panel-block__empty" data-card-empty>该维度当前没有记录</p>
+                </div>
+
                 <!--
                     分布:条长按最大项归一(相对长度,不冒充占比)。
                     票 20:带 `action` 的块整行可点(如行业构成 → 写全局行业筛选);
@@ -151,10 +252,16 @@
                     2026-10-13 维护者要求:条形按行语义着色(行业行=行业色、状态行=语义色、
                     其余=图表色序),一眼对应类别;色值仍只在 tokens.css 声明一份。
                 -->
-                <div v-else-if="block.kind === 'bars'" class="panel-bars" :class="{ 'panel-bars--clickable': block.action }" data-bar-list>
+                <div
+                    v-else-if="block.kind === 'bars'"
+                    class="panel-bars"
+                    :class="{ 'panel-bars--clickable': block.action, 'panel-bars--rank': block.variant === 'rank' }"
+                    :data-bar-variant="block.variant || null"
+                    data-bar-list
+                >
                     <component
                         :is="block.action && row.actionKey ? 'button' : 'div'"
-                        v-for="row in block.rows"
+                        v-for="(row, rowIndex) in block.rows"
                         :key="row.key"
                         class="panel-bar"
                         :class="{ 'panel-bar--button': block.action && row.actionKey, 'is-active': row.selected }"
@@ -168,9 +275,11 @@
                         @mouseenter="showChartTip($event, row.label, row.hint || row.count, barColor(row.tone))"
                         @mouseleave="hideChartTip"
                     >
+                        <!-- rank 版式:名次徽章(序号由渲染层按行序给,1 起) -->
+                        <i v-if="block.variant === 'rank'" class="panel-bar__rank" aria-hidden="true">{{ rowIndex + 1 }}</i>
                         <EllipsisText class="panel-bar__label" :text="row.label" />
                         <i class="panel-bar__track"><i class="panel-bar__fill" :style="{ width: `${Math.round(row.ratio * 100)}%`, background: barColor(row.tone) }"></i></i>
-                        <span class="panel-bar__count" data-bar-count>{{ row.count }}</span>
+                        <span class="panel-bar__count" data-bar-count>{{ row.count }}<em v-if="block.unit">{{ block.unit }}</em></span>
                     </component>
                     <p v-if="!block.rows.length" class="panel-block__empty" data-bar-empty>该维度当前没有记录</p>
                 </div>
@@ -226,8 +335,86 @@
                       · 每点顶标注数值(≤8 点全标;多时标峰值),悬停 title 兜底;
                       · X 轴保留桶标签行。读数接缝(data-trend-*)不变。
                 -->
-                <div v-else-if="block.kind === 'trend'" class="panel-trend" :class="{ 'panel-trend--line': block.chart === 'line' && block.linePoints.length >= 2 }" data-trend-list>
-                    <template v-if="block.chart === 'line' && block.linePoints.length >= 2">
+                <div
+                    v-else-if="block.kind === 'trend'"
+                    class="panel-trend"
+                    :class="{ 'panel-trend--line': block.chart === 'line' && block.linePoints.length >= 2 }"
+                    :style="block.tone ? { '--trend-tone': barColor(block.tone) } : null"
+                    :data-trend-tone="block.tone || null"
+                    data-trend-list
+                >
+                    <!--
+                        多序列折线(维护者 2026-10-23 反馈第 2 条):一条图里对比多条线 + 图例。
+                        与单线版式的差别:**不画面积**(多条面积会糊成一片)、**不标点顶数值**
+                        (会互相压),改在图例里给每条线的名字与色块,悬停仍读具体日期与数值。
+                        Y 轴按所有线的联合最大值归一(domain 已算好),所以两条线的高低可比。
+                    -->
+                    <template v-if="block.multiSeries">
+                        <div class="panel-trend__chart" data-trend-chart data-trend-multi="yes">
+                            <div
+                                v-for="tick in block.yTicks"
+                                :key="`mgrid-${tick.pct}`"
+                                class="panel-trend__grid"
+                                :class="{ 'is-top': tick.pct === 100 }"
+                                :style="{ bottom: `${tick.pct}%` }"
+                            >
+                                <b class="panel-trend__ytick" data-trend-ytick>{{ tick.value }}</b>
+                            </div>
+                            <div class="panel-trend__plot">
+                                <svg class="panel-trend__line" viewBox="0 0 100 100" preserveAspectRatio="none" role="img" :aria-label="block.label">
+                                    <polyline
+                                        v-for="series in block.series"
+                                        :key="series.key"
+                                        class="panel-trend__line-path"
+                                        :points="series.points.map((p) => `${p.x},${p.y}`).join(' ')"
+                                        :style="{ stroke: barColor(series.tone) }"
+                                        fill="none"
+                                        :data-trend-series="series.key"
+                                    />
+                                </svg>
+                                <template v-for="series in block.series" :key="`dots-${series.key}`">
+                                    <div
+                                        v-for="p in series.points"
+                                        :key="`${series.key}-${p.key}`"
+                                        class="panel-trend__node"
+                                        :style="{ left: `${p.xPct}%`, bottom: `${p.yPct}%` }"
+                                        :data-trend="p.key"
+                                        :data-trend-series="series.key"
+                                        :aria-label="`${series.label} ${p.hint || p.label}:${p.count}`"
+                                        @mouseenter="showChartTip($event, `${series.label} · ${p.hint || p.label}`, p.count, barColor(series.tone))"
+                                        @mouseleave="hideChartTip"
+                                    >
+                                        <i class="panel-trend__dot" :style="{ background: barColor(series.tone) }" aria-hidden="true"></i>
+                                    </div>
+                                </template>
+                            </div>
+                        </div>
+                        <div class="panel-trend__axis" data-trend-axis>
+                            <EllipsisText
+                                v-for="(point, i) in block.points"
+                                :key="point.key"
+                                tag="span"
+                                class="panel-trend__label"
+                                :style="{ left: `${series_xPct(block, i)}%` }"
+                                :text="block.axisIndices.includes(i) ? point.label : ''"
+                            />
+                        </div>
+                        <!-- 图例:每条线一个色块 + 名称(维护者要求「不同颜色区分并增加图例」)。 -->
+                        <ul class="panel-trend__legend" data-trend-legend>
+                            <li
+                                v-for="series in block.series"
+                                :key="`lg-${series.key}`"
+                                class="panel-trend__legend-item"
+                                :data-trend-legend-item="series.key"
+                                @mouseenter="showChartTip($event, series.label, series.points.reduce((s, p) => s + p.count, 0), barColor(series.tone))"
+                                @mouseleave="hideChartTip"
+                            >
+                                <i class="panel-trend__legend-swatch" :style="{ background: barColor(series.tone) }" aria-hidden="true"></i>
+                                <span>{{ series.label }}</span>
+                            </li>
+                        </ul>
+                    </template>
+                    <template v-else-if="block.chart === 'line' && block.linePoints.length >= 2">
                         <div class="panel-trend__chart" data-trend-chart>
                             <!-- Y 轴刻度 + 网格线(刻度标签落在左侧留出的槽里) -->
                             <div
@@ -257,7 +444,7 @@
                                     :style="{ left: `${p.xPct}%`, bottom: `${p.yPct}%` }"
                                     :data-trend="p.key"
                                     :aria-label="`${p.label}:${p.displayText || p.count}`"
-                                    @mouseenter="showChartTip($event, p.label, p.displayText || p.count, 'var(--cyan-hi, #00b8ff)')"
+                                    @mouseenter="showChartTip($event, p.hint || p.label, p.displayText || p.count, 'var(--cyan-hi, #00b8ff)')"
                                     @mouseleave="hideChartTip"
                                 >
                                     <b v-if="p.annotate" class="panel-trend__vlabel" data-trend-count>{{ p.displayText || p.count }}</b>
@@ -282,7 +469,7 @@
                             :key="point.key"
                             class="panel-trend__point"
                             :data-trend="point.key"
-                            @mouseenter="showChartTip($event, point.label, point.displayText || point.count, 'var(--cyan, #01abea)')"
+                            @mouseenter="showChartTip($event, point.hint || point.label, point.displayText || point.count, 'var(--cyan, #01abea)')"
                             @mouseleave="hideChartTip"
                         >
                             <i class="panel-trend__bar" :style="{ height: `${Math.max(2, Math.round(point.ratio * 100))}%` }"></i>
@@ -352,7 +539,13 @@
                     手感对齐已验收横条统计(整行即热区)。键盘焦点在命中楔形上,
                     可访问名称=「类别:数量」;中心合计 `pointer-events:none` 让位给命中区。
                 -->
-                <div v-else-if="block.kind === 'donut'" class="panel-donut" data-donut-list>
+                <div
+                    v-else-if="block.kind === 'donut'"
+                    class="panel-donut"
+                    :class="block.layout ? `panel-donut--${block.layout}` : ''"
+                    :data-donut-layout="block.layout || null"
+                    data-donut-list
+                >
                     <template v-if="block.total > 0">
                         <div class="panel-donut__wrap">
                             <svg class="panel-donut__chart" viewBox="0 0 100 100" role="group" :aria-label="block.label">
@@ -954,6 +1147,18 @@ const BAR_TONE_COLORS = {
     'chart:8': 'var(--chart-c8, #5680b0)',
 }
 
+/**
+ * 多序列折线里 X 轴标签的横向位置(%)。
+ *
+ * 单序列时几何在 `block.linePoints[i].xPct`;多序列时每条线各有一份点列,
+ * 但它们共用同一组日期 —— 取第一条序列的第 i 个点即可(不重复计算、也不假设两条线等长)。
+ */
+function series_xPct(block, index) {
+    const series = (block.series || [])[0]
+    if (!series || !series.points || !series.points[index]) return 0
+    return series.points[index].xPct
+}
+
 function barColor(tone) {
     return BAR_TONE_COLORS[tone] || 'var(--cyan, #01abea)'
 }
@@ -1121,7 +1326,7 @@ onBeforeUnmount(() => {
 .panel-blocks__note {
     margin: 0;
     color: var(--muted, #5680b0);
-    font-size: var(--fs-caption, 10.5px);
+    font-size: var(--fs-note, 9.2px);
     letter-spacing: 0.04em;
 }
 
@@ -1136,23 +1341,85 @@ onBeforeUnmount(() => {
     line-height: 1.5;
 }
 
+/* 块间距(2026-10-23 压矮):3u→2u,配合 body 内边距收紧,
+   目标是四块面板内容在一屏内看完、不靠滚动。 */
 .panel-block {
     display: grid;
-    gap: calc(3 * var(--u, 1px));
+    gap: calc(2 * var(--u, 1px));
+}
+
+/* 块标题行(票 11 视觉对齐):左侧竖条 + 标题,右侧块级小字。
+   竖条与标题共用一行,行内两端对齐;窄列时小字收缩省略(走共用省略方法)。 */
+.panel-block__head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: var(--space-2, 8px);
+    min-width: 0;
+    /* 2026-10-23:块头不额外吃间距(间距由 .panel-block 的 gap 统一给)。 */
+    line-height: 1.2;
 }
 
 .panel-block__title {
+    display: flex;
+    align-items: center;
+    gap: calc(6 * var(--u, 1px));
+    min-width: 0;
     margin: 0;
-    color: var(--text-2, #88b7e8);
-    font-size: var(--fs-caption, 10.5px);
+    color: var(--text, #d7ecff);
+    font-size: var(--fs-body, 14px);
     font-weight: 700;
-    letter-spacing: 0.06em;
+    letter-spacing: 0.04em;
+}
+
+/* 标题左侧竖条:块的身份标记(设计稿每条分区标题都带)。 */
+.panel-block__title-bar {
+    flex: none;
+    width: calc(3 * var(--u, 1px));
+    height: calc(13 * var(--u, 1px));
+    background: linear-gradient(180deg, var(--cyan-hi, #00b8ff), var(--cyan, #01abea));
+    box-shadow: 0 0 6px rgba(1, 171, 234, 0.65);
+}
+
+.panel-block__aside {
+    flex: 0 1 auto;
+    min-width: 0;
+    color: var(--muted, #5680b0);
+    font-size: var(--fs-note, 9.2px);
+    letter-spacing: 0.02em;
 }
 
 .panel-kpis {
     display: grid;
     grid-template-columns: repeat(auto-fit, minmax(calc(78 * var(--u, 1px)), 1fr));
-    gap: var(--space-2, 8px);
+    gap: calc(5 * var(--u, 1px));
+}
+
+/* value-first 版式固定一排四张(感知设备顶部四卡,设计稿的形态)。
+   栏宽收起时(容器 < 300u)退 2×2 —— 判的是**容器**不是视口,理由同下方 two-by-two:
+   大屏驾驶舱里视口 media query 永远命中不到。
+   `overflow: visible` 是必需的:accent 横条贴在卡顶,裁掉就看不见了。 */
+.panel-kpis--value-first {
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+    gap: calc(6 * var(--u, 1px));
+    container-type: inline-size;
+    container-name: panel-kpi-value-first;
+}
+
+/* 固定列数版式(票 11):设计稿里「两张一行」「三张一行」是明确版式,
+   不靠 auto-fit 按容器宽度去撞 —— 撞出来的列数会随栏宽变,走查与设计稿对不上。 */
+.panel-kpis--two {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.panel-kpis--three {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+@container panel-kpi-value-first (max-width: 300px) {
+    .panel-kpis--value-first {
+        grid-template-columns: repeat(2, minmax(0, 1fr));
+    }
 }
 
 /* 票 54:监测概览四卡在常规宽度固定 2×2,避免 auto-fit 展开成一行。
@@ -1186,19 +1453,118 @@ onBeforeUnmount(() => {
     }
 }
 
+/* ---- value-first 版式(票 11 视觉对齐:读数在上、标签在下)----
+   设计稿顶部四卡:卡顶一条语义色横条、大号读数居中、标签在下。
+   与缺省版的差别只在**顺序与对齐**;图标在 value-first 里不画(设计稿这类卡只用数字说话),
+   所以网格退成单列、行内容居中,其余(占位符/单位/差值)一字不改。 */
+.panel-kpis--value-first .panel-kpi,
+.panel-kpi--value-first {
+    /* 让格内数值能用 cqw 按格宽自适应(见 .panel-kpi--value-first .panel-kpi__value)。
+       inline-size 隔离只影响尺寸计算、不动外观。 */
+    container-type: inline-size;
+    grid-template-columns: minmax(0, 1fr);
+    justify-items: center;
+    row-gap: 1px;
+    /* 2026-10-23 第二轮压矮:上下 7/6u → 5/4u。 */
+    padding: calc(5 * var(--u, 1px)) var(--space-2, 8px) calc(4 * var(--u, 1px));
+    overflow: hidden;
+}
+
+/* 卡顶语义色横条:一条 2u 亮线贴在卡顶(accent 由 domain 给色键)。 */
+.panel-kpi--value-first::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    height: calc(2 * var(--u, 1px));
+    background: var(--kpi-accent, var(--cyan, #01abea));
+    box-shadow: 0 0 8px var(--kpi-accent, var(--cyan, #01abea));
+}
+
+.panel-kpi--value-first {
+    position: relative;
+}
+
+/* accent 色键 → token(色值仍只在 tokens.css 声明一份)。 */
+.panel-kpi--accent-lv1 { --kpi-accent: var(--lv1, #ff3b52); }
+.panel-kpi--accent-lv2 { --kpi-accent: var(--lv2, #ffb020); }
+.panel-kpi--accent-lv3 { --kpi-accent: var(--lv3, #01abea); }
+.panel-kpi--accent-lv4 { --kpi-accent: var(--lv4, #27d17a); }
+.panel-kpi--accent-info { --kpi-accent: var(--cyan-hi, #00b8ff); }
+
+/* value-first 的排布:读数在上、标签在下。
+   DOM 顺序仍是「读数与标签成对出现」,视觉顺序靠 `order` —— 两者分离后,
+   屏幕阅读器读到的语义顺序不变,而画面上符合设计稿的「10 / 设备总数」。 */
+.panel-kpi--value-first .panel-kpi__label {
+    order: 2;
+    color: var(--text-2, #88b7e8);
+    font-size: var(--fs-caption, 10.5px);
+}
+
+.panel-kpi--value-first .panel-kpi__value,
+.panel-kpi--value-first .panel-kpi__value--link {
+    order: 1;
+    /* 20u → 22u(维护者 2026-10-26 第三次反馈:「还是小了,参考运行监测-报警处置卡片里的字号」)。
+       报警处置的同级读数走 --fs-kpi(22u),这里**统一成同一个值** —— 四块面板的指标数值
+       从此一样大,不再各自一档。装不下怎么办?由下面的 cqw 上限兜底,而不是靠把字号调小。 */
+    /* 兜底约束:字号再受**所在格子宽度**限制(34cqw)。
+       34cqw 在正常格宽下**不生效**(实测格宽约 83px → 28px > 22u≈25px),
+       只在格子被压得很窄时才降字号,保证任何长度都画不到格子外。
+       数字字体是 Rajdhani/Bahnschrift 这类窄体(约 0.5em),「27.3%」在 22u 下约 63px,
+       83px 的格子放得下 —— 所以这条是安全网,不是日常生效的规则。
+       (早先写 27cqw 会真的压住 22u:格宽 83px 时只给到 22.4px,等于白改。)
+       cqw 需要格子上有 container-type,见 .panel-kpi--value-first 那条;
+       规则作用在**子元素**上,不是容器自身,不踩「容器不能被自身查询命中」那个坑。 */
+    font-size: min(calc(22 * var(--u, 1px)), 34cqw);
+    line-height: 1.1;
+    text-align: center;
+    font-weight: 700;
+    max-width: 100%;
+    overflow: hidden;
+}
+
+/* value-first 卡在画面上不显示单位(设计稿顶部四卡只有数字):
+   数据层仍带单位(悬停 title 由 kpiReading 给「10 台」),只是不占视觉位。 */
+.panel-kpi--value-first .panel-kpi__value em {
+    display: none;
+}
+
+/* 差值 / 口径小字在 value-first 里落在标签之下(同列居中)。 */
+.panel-kpi--value-first .panel-kpi__delta,
+.panel-kpi--value-first .panel-kpi__caption {
+    order: 3;
+    text-align: center;
+}
+
+/* 读数语义色(`valueTone`:在线绿、离线红、等级色…)。只染数字,不动卡片其余部分。 */
+.panel-kpi__value.is-lv1 { color: var(--lv1, #ff3b52); }
+.panel-kpi__value.is-lv2 { color: var(--lv2, #ffb020); }
+.panel-kpi__value.is-lv3 { color: var(--lv3, #01abea); }
+.panel-kpi__value.is-lv4 { color: var(--lv4, #27d17a); }
+.panel-kpi__value.is-neutral { color: var(--text-strong, #fff); }
+
+/* 卡内常显口径小字(`caption`):比 hint 更亮一档 —— 它是屏上要读的字,不是悬停补充。 */
+.panel-kpi__caption {
+    grid-column: 1 / -1;
+    color: var(--text-2, #88b7e8);
+    font-size: var(--fs-note, 9.2px);
+    letter-spacing: 0.02em;
+}
+
 .panel-kpi {
     display: grid;
     grid-template-columns: auto minmax(0, 1fr);
     align-items: center;
     column-gap: calc(5 * var(--u, 1px));
-    row-gap: 1px;
-    padding: calc(4 * var(--u, 1px)) var(--space-2, 8px);
-    border: 1px solid rgba(1, 171, 234, 0.24);
+    row-gap: 0;
+    padding: calc(3 * var(--u, 1px)) var(--space-2, 6px);
     border: 1px solid rgba(1, 171, 234, 0.35);
     /* 2026-10-23 维护者要求:面板背景取消颜色渐变后,颜色渐变转移到数据统计指标卡片 ——
        .panel-kpi 用 tokens 的 --stat-card-bg(亮科技蓝 → 深蓝,色相可感知)。 */
-    border-radius: calc(8 * var(--u, 1px));
     background: var(--stat-card-bg, linear-gradient(180deg, rgba(0, 82, 155, 0.45), rgba(0, 28, 70, 0.6)));
+    /* 2026-10-23 维护者要求:指标卡加圆角(8u,与 tab/notice 的既有圆角档一致)。 */
+    border-radius: calc(8 * var(--u, 1px));
 }
 
 /* KPI 图标:与标签同一行,弱发光(同面板标题图标的弱辉光档)。
@@ -1224,7 +1590,7 @@ onBeforeUnmount(() => {
    (真机走查 2026-10-14:在线率 / 预警已解除率两张卡的标题整行不见,根因即此)。 */
 .panel-kpi__value,
 .panel-kpi__value--link,
-.panel-kpi__hint {
+.panel-kpi__caption {
     grid-column: 1 / -1;
 }
 
@@ -1296,11 +1662,176 @@ onBeforeUnmount(() => {
     color: var(--text);
 }
 
-.panel-kpi__hint {
+/* 票 11 二轮(维护者 2026-10-23):KPI 的口径细节(如在线率的「分子 3 / 分母 10」)
+   **不再上屏占位**,改由读数自身的 title 承载;这里不再有 .panel-kpi__hint 的样式
+   (无消费点不保留 —— 留着它会诱导出第二个"常显说明")。 */
+
+/* 差值(规格 §6.6):正红、负绿、0 中性。色键来自 domain 的 deltaTone(lv1/lv4/neutral),
+   与其它语义读数共用 tokens.css 的同一批变量,不在这里写第二份色值。 */
+.panel-kpi__delta {
+    grid-column: 1 / -1;
+    font-family: var(--num, monospace);
+    font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.02em;
+}
+
+.panel-kpi__delta.is-lv1 {
+    color: var(--lv1, #ff3b52);
+}
+
+.panel-kpi__delta.is-lv4 {
+    color: var(--lv4, #27d17a);
+}
+
+.panel-kpi__delta.is-neutral {
     color: var(--muted, #5680b0);
+}
+
+/* 卡片组(票 02 建立,票 11 视觉对齐):一排「计数 + 名称(+ 小字)」的卡。
+   版式由 domain 的 `layout` 选列数(设计稿:类型卡 2×2、片区卡一排三张、等级卡一排四张);
+   缺省仍是 auto-fit 自适应。卡内自上而下:徽标 → 计数 → 名称 → 小字。 */
+.panel-cards {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(calc(72 * var(--u, 1px)), 1fr));
+    gap: calc(4 * var(--u, 1px));
+}
+
+.panel-cards--two-by-two {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.panel-cards--three {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.panel-cards--four {
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.panel-card {
+    display: grid;
+    justify-items: center;
+    gap: 0;
+    min-width: 0;
+    /* 2026-10-23 第二轮压矮:上下 5u→2u、左右 8u→5u,行距归零。
+       卡片是纵向堆叠里最占高度的一块,压它是"一屏看完"的主要杠杆。 */
+    padding: calc(2 * var(--u, 1px)) calc(5 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.24);
+    border-radius: calc(8 * var(--u, 1px));
+    /* 同上:计数卡也是数据统计指标卡片,共用 --stat-card-bg 颜色渐变。 */
+    background: var(--stat-card-bg, linear-gradient(180deg, rgba(0, 82, 155, 0.45), rgba(0, 28, 70, 0.6)));
+    text-align: center;
+}
+
+/* 可点卡片(类型卡 → 行业筛选):抹按钮默认外观,保留 hover/focus(与条形图同一立场)。 */
+.panel-card--button {
+    width: 100%;
+    border: 1px solid rgba(1, 171, 234, 0.24);
+    border-radius: calc(8 * var(--u, 1px));
+    font: inherit;
+    color: inherit;
+    cursor: pointer;
+}
+
+.panel-card--button:hover,
+.panel-card--button:focus-visible {
+    border-color: rgba(1, 171, 234, 0.7);
+    background: rgba(1, 171, 234, 0.12);
+}
+
+.panel-card--button:focus-visible {
+    outline: 1px solid var(--cyan, #01abea);
+    outline-offset: 1px;
+}
+
+/* 选中态(当前生效的行业筛选):与条形图选中行同一视觉语言。 */
+.panel-card--button.is-active {
+    border-color: var(--cyan, #01abea);
+    background: rgba(1, 171, 234, 0.16);
+    box-shadow: inset 0 0 0 1px var(--cyan, #01abea);
+}
+
+/* 卡内徽标:圆形淡底 + 语义色图标(类型卡的行业图标)。 */
+/* 卡内徽标:2026-10-23 由 26u 收至 22u(压矮卡片,图标仍看得清)。 */
+.panel-card__badge {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: calc(19 * var(--u, 1px));
+    height: calc(19 * var(--u, 1px));
+    border-radius: 50%;
+    background: rgba(1, 171, 234, 0.12);
+    color: var(--cyan, #01abea);
+}
+
+/* 行语义色(`tone`):染边框与徽标底(类型卡 = 行业色)。值仍只在 tokens.css 一份。 */
+.panel-card--tone-industry-gas { border-color: rgba(254, 59, 83, 0.45); }
+.panel-card--tone-industry-water { border-color: rgba(1, 171, 234, 0.45); }
+.panel-card--tone-industry-drainage { border-color: rgba(39, 209, 122, 0.45); }
+.panel-card--tone-industry-manhole { border-color: rgba(169, 112, 255, 0.45); }
+
+.panel-card--tone-industry-gas .panel-card__badge { background: rgba(254, 59, 83, 0.16); color: var(--industry-gas, #fe3b53); }
+.panel-card--tone-industry-water .panel-card__badge { background: rgba(1, 171, 234, 0.16); color: var(--industry-water, #01abea); }
+.panel-card--tone-industry-drainage .panel-card__badge { background: rgba(39, 209, 122, 0.16); color: var(--industry-drainage, #27d17a); }
+.panel-card--tone-industry-manhole .panel-card__badge { background: rgba(169, 112, 255, 0.16); color: var(--industry-manhole, #a970ff); }
+
+/* 卡名:**单行省略**(超框收成「…」,完整原文在 title)。
+   不能用多行:卡名长短不一时多行会把同排卡片撑成不同高度,一排卡参差不齐。 */
+.panel-card__label {
+    max-width: 100%;
+    color: var(--text-2, #88b7e8);
     font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.04em;
+}
+
+/* 卡内小字(监测内容):多段短词,**最多两行**,超出按行数截断并显示省略标记。
+   为什么要限行而不是自由换行:自由换行时「排水设备」那张卡会因为 8 项监测内容
+   把整排拉高,其余三张卡被同高拉伸成大片空白(维护者 2026-10-23:卡片高度要压矮、
+   不要靠滚动看全)。两行是够读的量,细节仍在 title。
+   兼容性:-webkit-line-clamp 是这套写法里被广泛实现的那个;不支持时退回单行省略,
+   信息量降级但不破版。 */
+.panel-card__note {
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: 2;
+    line-clamp: 2;
+    max-width: 100%;
+    overflow: hidden;
+    color: var(--muted, #5680b0);
+    font-size: var(--fs-note, 9.2px);
+    /* 行高 1.35→1.2:两行小字本来就密,压行高比压字号更安全(字号可读性结论不动)。 */
+    line-height: 1.2;
+    letter-spacing: 0.02em;
+    overflow-wrap: anywhere;
+    text-overflow: ellipsis;
 }
 
+.panel-card__count {
+    min-width: 0;
+    color: var(--text-strong, #fff);
+    font-family: var(--num, monospace);
+    /* 与 KPI 读数**完全同档**(22u,维护者 2026-10-26 第 1 条:以报警处置卡片字号为准)。
+       卡片是 2/3 列布局、格宽 100~160px,22u 的数字放得下;4 分格的等级卡也够宽。 */
+    font-size: calc(22 * var(--u, 1px));
+    line-height: 1;
+}
+
+.panel-card__count em {
+    color: var(--muted, #5680b0);
+    font-style: normal;
+    font-size: calc(10 * var(--u, 1px));
+}
+
+/* 读数语义色(等级卡):数字与边框同色(设计稿的一级红/二级黄/三级蓝/四级蓝)。 */
+.panel-card--value-lv1 { border-color: rgba(255, 59, 82, 0.55); }
+.panel-card--value-lv1 .panel-card__count { color: var(--lv1, #ff3b52); }
+.panel-card--value-lv2 { border-color: rgba(255, 176, 32, 0.55); }
+.panel-card--value-lv2 .panel-card__count { color: var(--lv2, #ffb020); }
+.panel-card--value-lv3 { border-color: rgba(1, 171, 234, 0.55); }
+.panel-card--value-lv3 .panel-card__count { color: var(--lv3, #01abea); }
+.panel-card--value-lv4 { border-color: rgba(39, 209, 122, 0.55); }
+.panel-card--value-lv4 .panel-card__count { color: var(--lv4, #27d17a); }
+
 .panel-bars {
     display: grid;
     gap: calc(2 * var(--u, 1px));
@@ -1376,6 +1907,64 @@ onBeforeUnmount(() => {
     gap: calc(4 * var(--u, 1px));
 }
 
+/* rank 版式(票 11 · 预警类型排行):名次徽章 + 名称 + 计数一行,条形在**下一行通栏**。
+   与缺省三列格子不同,这里换成两行网格:第一行三段(徽章/名称/计数),第二行条轨道跨两列。 */
+.panel-bars--rank .panel-bar {
+    grid-template-columns: calc(18 * var(--u, 1px)) minmax(0, 1fr) auto;
+    row-gap: calc(4 * var(--u, 1px));
+    align-items: center;
+    padding: calc(3 * var(--u, 1px)) 0;
+}
+
+.panel-bars--rank .panel-bar__rank {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: calc(16 * var(--u, 1px));
+    height: calc(16 * var(--u, 1px));
+    border-radius: 50%;
+    background: var(--chart-c1, #1780ff);
+    color: var(--text-strong, #fff);
+    font-family: var(--num, monospace);
+    font-size: var(--fs-caption, 10.5px);
+    font-style: normal;
+    line-height: 1;
+}
+
+/* 前三名徽章按名次换色(第 1 名橙、第 2 名黄、第 3 名蓝;其余沿用主蓝)。 */
+.panel-bars--rank .panel-bar:nth-child(1) .panel-bar__rank {
+    background: var(--lv2, #ffb020);
+    box-shadow: 0 0 6px rgba(255, 176, 32, 0.6);
+}
+
+.panel-bars--rank .panel-bar:nth-child(2) .panel-bar__rank {
+    background: var(--chart-c3, #00b8ff);
+}
+
+.panel-bars--rank .panel-bar:nth-child(3) .panel-bar__rank {
+    background: var(--chart-c1, #1780ff);
+}
+
+/* 第一行=徽章 / 名称 / 计数;条轨道独占第二行、跨满三列。
+   计数必须**显式**定位回第 1 行第 3 列:轨道带 `grid-column: 1 / -1` 会占掉下一整行,
+   排在它后面的计数若走自动放置就会掉到第 3 行(名次、名称、条、计数各占一行)。 */
+.panel-bars--rank .panel-bar__count {
+    grid-column: 3;
+    grid-row: 1;
+}
+
+.panel-bars--rank .panel-bar__track {
+    grid-column: 1 / -1;
+    grid-row: 2;
+    height: calc(5 * var(--u, 1px));
+}
+
+.panel-bar__count em {
+    color: var(--muted, #5680b0);
+    font-style: normal;
+    font-size: var(--fs-caption, 10.5px);
+}
+
 .panel-bar__label {
     color: var(--text-2, #88b7e8);
     font-size: var(--fs-caption, 10.5px);
@@ -1431,8 +2020,9 @@ onBeforeUnmount(() => {
     display: grid;
     grid-template-rows: minmax(0, 1fr) auto;
     gap: calc(2 * var(--u, 1px));
-    height: calc(78 * var(--u, 1px));
-    padding: calc(8 * var(--u, 1px)) 0 calc(1 * var(--u, 1px));
+    /* 2026-10-23 压矮:78u→64u(报警处置面板底部折线;点数与数值标注不变)。 */
+    height: calc(64 * var(--u, 1px));
+    padding: calc(6 * var(--u, 1px)) 0 calc(1 * var(--u, 1px));
 }
 
 .panel-trend__chart {
@@ -1483,11 +2073,14 @@ onBeforeUnmount(() => {
 }
 
 .panel-trend__area {
-    fill: rgba(1, 171, 234, 0.12);
+    fill: var(--trend-tone, var(--cyan, #01abea));
+    fill-opacity: 0.14;
 }
 
 .panel-trend__line-path {
-    stroke: var(--cyan, #01abea);
+    /* 折线色(票 11):由 domain 的 `tone` 注入 --trend-tone(如报警折线=lv2 橙),
+       缺省仍是主蓝 —— 色值只经 barColor 查 tokens.css 的同一张表,组件不写死色。 */
+    stroke: var(--trend-tone, var(--cyan, #01abea));
     stroke-width: 1.6;
     stroke-linejoin: round;
     stroke-linecap: round;
@@ -1508,8 +2101,8 @@ onBeforeUnmount(() => {
     width: 100%;
     height: 100%;
     border-radius: 50%;
-    background: var(--cyan-hi, #00b8ff);
-    box-shadow: 0 0 4px rgba(1, 171, 234, 0.8);
+    background: var(--trend-tone, var(--cyan-hi, #00b8ff));
+    box-shadow: 0 0 4px var(--trend-tone, rgba(1, 171, 234, 0.8));
 }
 
 .panel-trend__node.is-peak .panel-trend__dot {
@@ -1560,6 +2153,41 @@ onBeforeUnmount(() => {
     transform: translateX(-100%);
 }
 
+/* 多序列折线的图例(维护者 2026-10-23 反馈第 2 条):横排、色块 + 名称,压在 X 轴下方一行。
+   不放右上角:那是面板级小字的位置,两者挤一起会互相顶。 */
+.panel-trend__legend {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: center;
+    gap: calc(4 * var(--u, 1px)) calc(12 * var(--u, 1px));
+    margin: calc(2 * var(--u, 1px)) 0 0;
+    padding: 0;
+    list-style: none;
+}
+
+.panel-trend__legend-item {
+    display: inline-flex;
+    align-items: center;
+    gap: calc(4 * var(--u, 1px));
+    color: var(--text-2, #88b7e8);
+    font-size: var(--fs-caption, 10.5px);
+    line-height: 1.2;
+    cursor: default;
+}
+
+.panel-trend__legend-swatch {
+    flex: none;
+    width: calc(10 * var(--u, 1px));
+    height: calc(3 * var(--u, 1px));
+    border-radius: 1px;
+    box-shadow: 0 0 4px currentcolor;
+}
+
+/* 多序列的线路径色由行内 style 给(每条线自己的 tone);这里只保证描边属性不被覆盖成默认色。 */
+.panel-trend__line-path[data-trend-series] {
+    stroke-width: 1.8;
+}
+
 /* 百分比堆叠条(2026-10-13 图表选型:多类别占比)。 */
 .panel-stacked {
     display: grid;
@@ -1613,6 +2241,31 @@ onBeforeUnmount(() => {
     min-width: 0;
 }
 
+/* stack-below 版式(票 11 · 管道分类占比):环形居中、图例在下横排 —— 设计稿的形态。 */
+.panel-donut--stack-below {
+    flex-direction: column;
+    align-items: center;
+    gap: calc(6 * var(--u, 1px));
+}
+
+.panel-donut--stack-below .panel-donut__wrap {
+    width: calc(96 * var(--u, 1px));
+    height: calc(96 * var(--u, 1px));
+}
+
+.panel-donut--stack-below .panel-donut__legend {
+    display: flex;
+    flex: none;
+    flex-wrap: wrap;
+    justify-content: center;
+    gap: var(--space-3, 12px);
+    width: 100%;
+}
+
+.panel-donut--stack-below .panel-donut__row {
+    flex: none;
+}
+
 .panel-donut__wrap {
     position: relative;
     flex: none;

+ 539 - 524
src/modules/cockpit/domain/overviewPanels.js

@@ -1,671 +1,686 @@
 /**
- * 总体概览模式四个二级面板的**领域与视图层**(票 20)。
+ * 总体概览模式四块面板的**领域与视图层**(2026-10-22 四模块重做 · 票 02)。
  *
- * 四块面板:态势总览 `situationSummary` / 基础设施 `infrastructureSummary` /
- * 运行监测 `monitoringSummary` / 预警设置 `alertSettings`。
+ * 四块面板(身份与左右栏顺序的唯一来源是 `domain/panelCatalog.js`,本文件只决定"每块画什么"):
+ *   左栏 感知设备 `perceptionDevices` / 基础设施 `pipeNetwork`
+ *   右栏 运行监测-预警研判 `warningStudy` / 运行监测-报警处置 `alarmDisposal`
  *
- * 分工与票 17/18/19 完全一致(本票沿用同一形状,不造第二套):
- *   1. **契约适配**:后端面板级聚合读模型的载荷 → 领域对象(字段改名、缺失记账、码翻人话);
- *   2. **视图计划**:领域对象 → 组件可以直接画的块。
- *   块的构造与码表在 `domain/panelBlocks.js`;本文件只写"放哪些块"。
+ * 三层分工沿用票 17–20 已定的成例,一字不改:
+ *   1. **契约适配**:端点载荷 → 领域对象(字段改名、缺失记账、码翻人话);
+ *   2. **视图计划**:领域对象 → 组件可以直接画的块(块画法在 `domain/panelBlocks.js`);
+ *   3. **取数**:`services/` + `stores/`(**本票不接线**,见下)。
  *
- * 纯函数:不 import service、不碰 DOM、不自己发请求。
- *
- * ── 三处后端契约现状,如实记在这里(不猜、也不拿别的字段冒充)──
- *
- * ① **本模式的三个端点都不给点位编码或坐标**(逐类读过三个响应类与三个 Service)。
- *    所以「点面板指标联动一张图」在**概览自己的数据上做不到** ——
- *    票 17 那条 `mapLocate` 接缝需要"点位编码 → 回联已加载对象",
- *    而这里连编码都没有,没有可绑的东西。本票的联动因此走**两条真实存在的路**:
- *      · 指标/事件族 → 切到「预警处置」模式落到真实事件列表(那里的事件带 `deviceCode`,
- *        再由票 13/14 的既有链路跳到点位);
- *      · 行业构成行 → 写全局行业筛选(`filterStore.setIndustry`,**真的**驱动地图与所有面板)。
- *    **不伪造**一次地图定位,也不在概览里造"设备→点位"跳转。
+ * 本票的交付边界(票 02 原文「可先按契约数据结构画,数据源/后端接线归后续票 05–08」):
+ * 这四块目前**没有 store 喂数据**,分区停在「正在取数」;05–08 把规格 §1 的四个新端点
+ * 接上后本文件的块形状不再改动 —— 这正是本票的目的:先把身份、顺序、画法钉死。
  *
- * ② **`/overview/infrastructure` 没有区域分布**(`Totals` + `Industries` + `Excluded`)。
- *    验收第 2 条点名的「区域分布」数据真实存在于票 17 的 `/infrastructure/base-archives`
- *    (`districtDistribution`),且「基础档案」面板已经在画它。本层**复用票 17 的纯适配函数**
- *    `toBaseArchives()` 取那一段 —— 只引适配层,**不引块构造器**(那才是票 18 警告的耦合)。
+ * 口径唯一来源:`.scratch/cockpit-overview-redesign/specs/2026-10-22-overview-four-modules-contract.md`
+ * §2 感知设备 / §3 基础设施 / §4 预警研判 / §5 报警处置 / §6 UI 契约。
  *
- * ③ **`/overview/monitoring` 的状态桶没有"故障"**(`online/offline/stale/unknownOrMissing`)。
- *    验收第 3 条点名的「故障统计」在后端**不存在**独立桶,且「是否等同故障」是票 25
- *    至今未答的业务问题(`research/panel-field-coverage.md` §5)。维护者 2026-09-29 裁定:
- *    **不发明"故障"数**,四个真实桶按本义呈现,缺口写进 `MONITORING_FAULT_GAP`。
- *
- * 后端形状依据(`E:\pipe-ner-server`,逐类读过响应类与 Service):
- *   `CockpitOverviewInfrastructureResponse` / `CockpitOverviewMonitoringResponse` /
- *   `CockpitOverviewWarningSettingsResponse`、
- *   `CockpitOverview{Infrastructure,Monitoring,WarningSettings}Service`。
- *   BigDecimal 在线上是字符串(`getValue()` 带 `@JsonFormat(shape=STRING)`),故走 `numberish`。
+ * 纯函数:不 import service、不碰 DOM、不自己发请求。
+ * 渲染层(`components/PanelBlocks.vue`)仍**不认识 panelId、不认识后端字段**。
  */
 import { fieldReader } from './fields.js'
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
 import { valueText } from './equipmentDetailView.js'
-import { industryLabel } from './filterOptions.js'
+import { SECTION_STATE } from './sectionState.js'
 import {
-    barsBlock,
-    codeText,
+    cardsBlock,
     donutBlock,
     kpiBlock,
     numberish,
     panelMissingNote,
+    rankingBarsBlock,
     ratioText,
-    rowsBlock,
     sectionHasContent,
     sectionPlaceholder,
-    sortByCountDesc,
-    stackedBlock,
     trendBlock,
 } from './panelBlocks.js'
-// 票 17 的纯适配层:只为拿 `districtDistribution`(见文件头第 ② 条)。
-// 引的是 `toBaseArchives` 这一个纯函数,不是它那四块面板的构造器。
-import { toBaseArchives } from './infrastructurePanels.js'
-// 票 19 的处置率读数与映射:态势总览与预警效率必须用**同一条**判据
-// (否则"分母为 0"在一个面板是 `—`、在另一个是 `0.0%`)。
-import { handlingRateView, handlingRateHint } from './warningResponsePanels.js'
-// 后端 0–7 状态码的唯一前端字典(票 20 收口)。
-import { WORK_ORDER_STATES } from './workOrder.js'
-
-/** 总体概览模式的四个 panelId(与 `domain/panelCatalog.js` 的目录一致,顺序即界面顺序)。 */
-export const OVERVIEW_PANEL_IDS = [
-    'situationSummary',
-    'infrastructureSummary',
-    'monitoringSummary',
-    'alertSettings',
-]
+
+/** 总体概览模式的四个 panelId(顺序即界面顺序:左栏两块在前,右栏两块在后)。 */
+export const OVERVIEW_PANEL_IDS = ['perceptionDevices', 'pipeNetwork', 'warningStudy', 'alarmDisposal']
 
 /**
- * 「故障统计」在后端契约里没有独立桶的如实说明(验收第 3 条的缺口,见文件头第 ③ 条)。
+ * 设备大类(后端根类型码 1/2/3/4)→ 行业色键 / 行业图标 / 全局行业筛选键。
  *
- * 维护者 2026-09-29 裁定:故障语义复杂("数据过期"未必是故障,可能只是网络波动),
- * 因此**不合成**一个"故障"数,四个真实状态桶按本义呈现。
+ * 三张表都按**后端根类型码**索引(不是中文名):码是稳定键,中文只用于展示(ADR 0003 同一条立场)。
+ * 筛选键取 `domain/filterOptions.js` 的既有行业键,所以点类型卡走的是与顶部行业 chip **同一条**筛选,
+ * 不是第二个筛选入口。
  */
-export const MONITORING_FAULT_GAP =
-    '后端监测概览的状态桶是「在线 / 离线 / 滞后 / 状态未知」四态,没有独立的「故障」桶;' +
-    '本面板照实呈现这四态,不把「滞后」或「状态未知」折算成故障(故障口径待业务确认)'
-
-/** 概览的联动落点:指标点击切到预警处置模式(见文件头第 ① 条)。 */
-export const OVERVIEW_LINK_MODES = {
-    WARNING: 'warningResponse',
-    ALARM: 'warningResponse',
-    EFFICIENCY: 'warningResponse',
-}
+const TYPE_TONES = { 1: 'industry:water', 2: 'industry:drainage', 3: 'industry:gas', 4: 'industry:manhole' }
+const TYPE_ICONS = { 1: 'water', 2: 'drainage', 3: 'gas', 4: 'manhole' }
+const TYPE_FILTER_KEYS = { 1: 'water', 2: 'drainage', 3: 'gas', 4: 'manhole' }
+
 
 // ================================================================ 契约适配
 
+/** 比例读数(`{value, numerator, denominator, state, reason}`)→ 领域对象。 */
+function toRatio(raw) {
+    const rate = fieldReader(raw, 'ratio')
+    return {
+        value: numberish(rate.raw.value),
+        numerator: numberish(rate.raw.numerator),
+        denominator: numberish(rate.raw.denominator),
+        state: rate.optionalStr('state'),
+        reason: rate.optionalStr('reason'),
+    }
+}
+
+/** 今日/昨日/差值(规格 §0:「较昨日」= 今日新增 − 昨日新增,可为负)。 */
+function toToday(raw) {
+    const today = fieldReader(raw, 'today')
+    return {
+        newCount: numberish(today.raw.newCount),
+        yesterdayCount: numberish(today.raw.yesterdayCount),
+        delta: numberish(today.raw.delta),
+    }
+}
+
 /**
- * 概览基础设施(`/overview/infrastructure`)→ 领域对象。
- * 验收第 2 条:设施底数、行业构成、区域分布。
+ * 感知设备(规格 §2 `/overview/perception`)→ 领域对象。
  *
- * `districtDistribution` **不在本端点上**(见文件头第 ② 条),由 `toBaseArchives()` 的那一份补,
- * 本函数只做本端点自己的字段。
+ * 交接说明(票 05):`types` 已按后端固定顺序(供水/排水/燃气/窨井)原样带出,
+ * **本层不重排、不补项**;`note` 就是后端给的监测内容小字(规格 §2 的固定常量映射)。
  */
-export function toOverviewInfrastructure(raw) {
-    const reader = fieldReader(raw, 'overviewInfrastructure')
-    const totals = fieldReader(reader.raw.totals, 'overviewInfrastructure.totals')
+export function toPerception(raw) {
+    const reader = fieldReader(raw, 'overviewPerception')
+    const totals = fieldReader(reader.raw.totals, 'overviewPerception.totals')
+    const excluded = reader.raw.excluded || {}
 
     return {
         updatedAt: reader.optionalStr('updatedAt'),
-        facilityCount: numberish(totals.raw.facilityCount),
-        smartDeviceCount: numberish(totals.raw.smartDeviceCount),
-        // 行业构成:后端已排好序,本层不重排;`label` 缺失时按码翻(不显示裸码)。
-        industries: reader.optionalList('industries').map((item) => {
-            const row = fieldReader(item, 'overviewIndustry')
+        deviceCount: numberish(totals.raw.deviceCount),
+        onlineCount: numberish(totals.raw.onlineCount),
+        offlineCount: numberish(totals.raw.offlineCount),
+        onlineRate: toRatio(totals.raw.onlineRate),
+        // 类型卡:四项固定顺序由后端给;卡内小字 = `monitoringContent`。
+        // 维护者 2026-10-23:供水/排水这两张卡下的小字「尽可能精简」——
+        // 真实内容很长(供水 4 项、排水 8 项,如「排水瞬时流量监测 / 排水流速监测 / …」),
+        // 所以展示层压缩成「最多两项 + 等」,**完整原文进 title 悬停可读**,不丢信息。
+        types: reader.optionalList('byType').map((item) => {
+            const row = fieldReader(item, 'overviewPerception.byType')
+            const monitoringContent = row.optionalStr('monitoringContent')
             return {
                 code: row.optionalStr('code'),
-                label: row.optionalStr('label') || industryLabel(row.optionalStr('code')) || row.optionalStr('code'),
-                key: row.optionalStr('key'),
-                facilityCount: numberish(row.raw.facilityCount),
-                smartDeviceCount: numberish(row.raw.smartDeviceCount),
-                facilityTypes: (row.raw.facilityTypes || []).map((type) => {
-                    const row2 = fieldReader(type, 'facilityType')
-                    // 票 38(第 2 条):`scale` 不再透传——真实值是 "km"(长度单位)不是规模语义,
-                    // 长度已由 lengthKm 承担,界面没有任何「规模」展示位(保留它只会诱导出第二次误用)。
-                    return { code: row2.optionalStr('code'), name: row2.optionalStr('name') || row2.optionalStr('code'), count: numberish(row2.raw.count), lengthKm: numberish(row2.raw.lengthKm) }
-                }),
+                label: row.optionalStr('name') || row.optionalStr('code'),
+                count: numberish(row.raw.deviceCount) ?? 0,
+                note: conciseMonitoring(monitoringContent),
+                noteTitle: monitoringContent,
+            }
+        }),
+        // 片区卡:三项固定(城南/城北/太常),无数据是 0。
+        districts: reader.optionalList('byDistrict').map((item) => {
+            const row = fieldReader(item, 'overviewPerception.byDistrict')
+            const district = row.raw.district
+            return {
+                code: district === null || district === undefined ? 'UNASSIGNED' : String(district),
+                label: row.optionalStr('name') || row.optionalStr('district'),
+                count: numberish(row.raw.deviceCount) ?? 0,
             }
         }),
         excluded: {
-            unknownTypeCount: numberish((reader.raw.excluded || {}).unknownTypeCount),
-            typeKindMismatchCount: numberish((reader.raw.excluded || {}).typeKindMismatchCount),
+            unknownTypeCount: numberish(excluded.unknownTypeCount),
+            unassignedDistrictCount: numberish(excluded.unassignedDistrictCount),
         },
         missing: reader.missing,
     }
 }
 
-/**
- * 概览运行监测(`/overview/monitoring`)→ 领域对象。
- * 验收第 3 条:在线率、离线/故障统计、监测覆盖。
- *
- * 端点入参只有 `industry`(控制器逐行读过)——不收区域/类型/状态。
- */
-export function toOverviewMonitoring(raw) {
-    const reader = fieldReader(raw, 'overviewMonitoring')
-    const counts = fieldReader(reader.raw.counts, 'overviewMonitoring.counts')
-    const status = fieldReader(counts.raw.status, 'overviewMonitoring.status')
-    const onlineRate = fieldReader(counts.raw.onlineRate, 'overviewMonitoring.onlineRate')
-    const metricFreshness = fieldReader(reader.raw.metricFreshness, 'overviewMonitoring.metricFreshness')
-    const scope = reader.optionalObj('formalScope') || {}
+/** 基础设施(规格 §3 `/overview/pipe-network`)→ 领域对象。 */
+export function toPipeNetwork(raw) {
+    const reader = fieldReader(raw, 'overviewPipeNetwork')
+    const totals = fieldReader(reader.raw.totals, 'overviewPipeNetwork.totals')
+    const excluded = reader.raw.excluded || {}
 
     return {
         updatedAt: reader.optionalStr('updatedAt'),
-        // 规模与覆盖
-        deviceCount: numberish(counts.raw.deviceCount),
-        monitoringPointCount: numberish(counts.raw.monitoringPointCount),
-        monitoringFacilityCount: numberish(counts.raw.monitoringFacilityCount),
-        infrastructureFacilityCount: numberish(counts.raw.infrastructureFacilityCount),
-        formalIndustries: Array.isArray(scope.industries) ? scope.industries.filter(Boolean) : [],
-        // 在线率:`state` 为 EMPTY 表示分母为 0(那时 `value` 的 0 是占位,不是 0%)。
-        onlineRate: numberish(onlineRate.raw.value),
-        onlineCount: numberish(onlineRate.raw.onlineCount),
-        onlineDenominator: numberish(onlineRate.raw.denominator),
-        onlineState: onlineRate.optionalStr('state'),
-        // 四个真实状态桶(**没有"故障"**,见 MONITORING_FAULT_GAP)。
-        deviceStatus: {
-            online: numberish(status.raw.online),
-            offline: numberish(status.raw.offline),
-            stale: numberish(status.raw.stale),
-            unknownOrMissing: numberish(status.raw.unknownOrMissing),
-            denominator: numberish(status.raw.denominator),
-        },
-        // **本端点独有的**逐设备指标最近采集时间(票 18 的缺口在这里有值,见其 I2/BT-8)。
-        metricFreshness: {
-            status: metricFreshness.optionalStr('status'),
-            updatedAt: metricFreshness.optionalStr('updatedAt'),
-            serverUpdatedAt: metricFreshness.optionalStr('serverUpdatedAt'),
-        },
-        // 类型分布:后端按 industry×deviceType 给,本层不重排。
-        typeDistribution: reader.optionalList('typeDistribution').map((item) => {
-            const row = fieldReader(item, 'overviewTypeDistribution')
-            const type = fieldReader(row.raw.deviceType, 'overviewTypeDistribution.deviceType')
-            const industry = fieldReader(row.raw.industry, 'overviewTypeDistribution.industry')
+        pipeCount: numberish(totals.raw.pipeCount),
+        lengthKm: numberish(totals.raw.lengthKm),
+        // 三类固定顺序:供水/排水/燃气(后端已排好,本层不重排)。
+        types: reader.optionalList('byPipeType').map((item) => {
+            const row = fieldReader(item, 'overviewPipeNetwork.byPipeType')
             return {
-                industryCode: industry.optionalStr('code'),
-                industryLabel: industry.optionalStr('label') || industryLabel(industry.optionalStr('code')),
-                typeCode: type.optionalStr('code'),
-                typeName: type.optionalStr('name') || type.optionalStr('code'),
-                deviceCount: numberish(row.raw.deviceCount),
+                code: row.optionalStr('code'),
+                label: row.optionalStr('name') || row.optionalStr('code'),
+                count: numberish(row.raw.count) ?? 0,
+                lengthKm: numberish(row.raw.lengthKm),
+                ratio: numberish(row.raw.ratio),
             }
         }),
-        selectedIndustry: (reader.optionalObj('selectedIndustry') || {}).label || '',
-        excluded: {
-            unknownTypeCount: numberish((reader.raw.excluded || {}).unknownTypeCount),
-            typeKindMismatchCount: numberish((reader.raw.excluded || {}).typeKindMismatchCount),
-        },
+        excluded: { unclassifiedCount: numberish(excluded.unclassifiedCount) },
         missing: reader.missing,
     }
 }
 
-/**
- * 概览预警设置(`/overview/warning-settings`)→ 领域对象。
- * 验收第 4 条:保持合同名称,只读展示预警/报警统计。
- *
- * **一次响应同时给预警与报警两族**(`warning` + `alarm`),所以态势总览与预警设置
- * **共用这一次取数**(Q1 裁定),不做 WARNING/ALARM 切换。
- *
- * **票 42 起两族口径分叉**(后端 `b047a60d`,维护者 2026-10-14 裁定):
- * 预警族只给**预警自身生命周期**(状态/等级/行业/片区分布 + 已解除率),
- * 工单口径六字段(`handled/pending/cancelledCount/unlinkedEventCount/invalidWorkOrderStateCount/workOrderStates`)
- * 恒为 `null`;报警族**保留工单口径**(报警靠工单闭环)并**并存** `alarm_status` 生命周期分布。
- * `null` 在本层原样透传 —— 「不适用」与「真的是 0」必须在视图层分得开(票 22 纪律)。
- */
-export function toOverviewWarningSettings(raw) {
-    const reader = fieldReader(raw, 'overviewWarningSettings')
-    const freshness = fieldReader(reader.raw.freshness, 'overviewWarningSettings.freshness')
-
-    /** 一族(预警或报警)→ 领域对象。 */
-    const family = (key) => {
-        const row = fieldReader(reader.raw[key], `overviewWarningSettings.${key}`)
-        // 工单状态分布:**缺失(null)=该族不走工单口径**(预警族恒如此),
-        // 绝不给它补 8 根假零条 —— 那是把"不适用"画成"全是 0"(票 42 验收第 4 条)。
-        const states = row.optionalObj('workOrderStates')
-        const efficiency = row.raw.handlingEfficiency || {}
-        // 分布桶直读:后端已按族全铺且 `sum(桶)==total`,前端**不补 0、不重排、不翻码**
-        //(`label` 就是后端给的人话)。后端没给某组时记 `null`,视图整块跳过(不画空块)。
-        const buckets = (name) => {
-            const items = row.raw[name]
-            if (!Array.isArray(items)) return null
-            return items.map((item) => {
-                const cell = fieldReader(item, `overviewWarningSettings.${key}.${name}`)
-                return { code: cell.optionalStr('code'), label: cell.optionalStr('label'), count: numberish(cell.raw.count) || 0 }
-            })
-        }
-        return {
-            family: row.optionalStr('eventFamily') || key.toUpperCase(),
-            state: row.optionalStr('state'),
-            stateReason: row.optionalStr('stateReason'),
-            total: numberish(row.raw.total),
-            // 工单口径五读数:预警族恒 `null`(视图显示「不适用」),报警族保留工单语义。
-            handled: numberish(row.raw.handled),
-            pending: numberish(row.raw.pending),
-            cancelledCount: numberish(row.raw.cancelledCount),
-            unlinkedEventCount: numberish(row.raw.unlinkedEventCount),
-            invalidWorkOrderStateCount: numberish(row.raw.invalidWorkOrderStateCount),
-            // 速率连同 `state/reason` 一起带("分母为 0/没有预警"与"真的是 0%"必须分得开);
-            // 语义按族不同:预警族=已解除÷总数,报警族=工单口径处置率(决策票 06)。
-            efficiency: {
-                handlingRate: numberish(efficiency.value),
-                handlingState: efficiency.state || '',
-                handlingReason: efficiency.reason || null,
-                denominator: numberish(efficiency.denominator),
-                numerator: numberish(efficiency.numerator),
+/** 预警研判(规格 §4 `/overview/warning-study`)→ 领域对象。 */
+export function toWarningStudy(raw) {
+    const reader = fieldReader(raw, 'overviewWarningStudy')
+    const unresolved = fieldReader(reader.raw.unresolved, 'overviewWarningStudy.unresolved')
+    const statuses = fieldReader(unresolved.raw.statuses, 'overviewWarningStudy.unresolved.statuses')
+    const ranking = fieldReader(reader.raw.typeRanking, 'overviewWarningStudy.typeRanking')
+
+    return {
+        updatedAt: reader.optionalStr('updatedAt'),
+        today: toToday(reader.raw.today),
+        unresolved: {
+            count: numberish(unresolved.raw.count),
+            // 固定四键(规格 §4);缺的给 0(后端契约固定铺满)。
+            statuses: {
+                PENDING: numberish(statuses.raw.PENDING) ?? 0,
+                RECEIVED: numberish(statuses.raw.RECEIVED) ?? 0,
+                PROCESSING: numberish(statuses.raw.PROCESSING) ?? 0,
+                HANDLED: numberish(statuses.raw.HANDLED) ?? 0,
             },
-            workOrderStates: states === null ? null : WORK_ORDER_STATES.map((state) => {
-                const value = states[state.code] === undefined ? states[String(state.code)] : states[state.code]
-                return { code: String(state.code), label: state.name, count: numberish(value) || 0 }
-            }),
-            // 票 42:自身生命周期分布(两族都有;`statusDistribution` 的码表两族不同 ——
-            // 预警是 `WarningWorkflow` 六态、报警是 `alarm_status` 两态,label 由后端给)。
-            statusDistribution: buckets('statusDistribution'),
-            levelDistribution: buckets('levelDistribution'),
-            industryDistribution: buckets('industryDistribution'),
-            districtDistribution: buckets('districtDistribution'),
-            // 趋势:桶是后端给的(`range` 声明窗口后由服务端过滤),本层不重采样。
-            // `handled` 的含义按族不同:预警=桶内已解除,报警=桶内关联工单已结案。
-            trend: row.optionalList('trend').map((point) => {
-                const cell = fieldReader(point, 'warningTrendPoint')
-                return { key: cell.optionalStr('bucket'), label: cell.optionalStr('bucket'), count: numberish(cell.raw.total) || 0 }
+        },
+        // 等级卡:一级→四级固定顺序,无数据 0。
+        levels: reader.optionalList('byLevel').map((item) => {
+            const row = fieldReader(item, 'overviewWarningStudy.byLevel')
+            return {
+                code: String(row.raw.level ?? ''),
+                label: row.optionalStr('name') || String(row.raw.level ?? ''),
+                count: numberish(row.raw.count) ?? 0,
+            }
+        }),
+        typeRanking: {
+            rangeDays: numberish(ranking.raw.rangeDays),
+            total: numberish(ranking.raw.total),
+            // 后端已按计数降序且并列按码升序(规格 §4),本层不重排。
+            top: ranking.optionalList('top').map((item) => {
+                const row = fieldReader(item, 'overviewWarningStudy.typeRanking.top')
+                return {
+                    code: row.optionalStr('code'),
+                    label: row.optionalStr('name') || row.optionalStr('code'),
+                    count: numberish(row.raw.count) ?? 0,
+                }
             }),
-        }
+        },
+        missing: reader.missing,
     }
+}
+
+/** 时长读数(规格 §5:`{value, unit, sampleCount, invalidSampleCount, state, reason}`)→ 领域对象。 */
+function toDuration(raw) {
+    const row = fieldReader(raw, 'duration')
+    return {
+        value: numberish(row.raw.value),
+        unit: row.optionalStr('unit') || 'HOURS',
+        sampleCount: numberish(row.raw.sampleCount),
+        // 负时长被后端剔除的样本数(规格 §5 的 `invalidSampleCount`):只说"剔除了几个",
+        // 不拿无效样本凑平均,也不静默丢。
+        invalidSampleCount: numberish(row.raw.invalidSampleCount),
+        state: row.optionalStr('state'),
+        reason: row.optionalStr('reason'),
+    }
+}
+
+/** 报警处置(规格 §5 `/overview/alarm-disposal`)→ 领域对象。 */
+export function toAlarmDisposal(raw) {
+    const reader = fieldReader(raw, 'overviewAlarmDisposal')
+    const conversion = fieldReader(reader.raw.workOrderConversion, 'overviewAlarmDisposal.workOrderConversion')
+    const disposal = fieldReader(reader.raw.disposal, 'overviewAlarmDisposal.disposal')
 
     return {
         updatedAt: reader.optionalStr('updatedAt'),
-        displayName: reader.optionalStr('displayName'),
-        // 窗口回显:`range.type` 就是本票请求时声明的 `LAST_7_DAYS`(验收第 1 条的"近 7 日")。
-        rangeType: ((reader.raw.range || {}).type) || '',
-        rangeStart: ((reader.raw.range || {}).start) || '',
-        rangeEnd: ((reader.raw.range || {}).end) || '',
-        freshness: freshness.optionalStr('status'),
-        warning: family('warning'),
-        alarm: family('alarm'),
-        selectedMode: reader.optionalStr('selectedMode'),
+        today: toToday(reader.raw.today),
+        conversion: {
+            convertedCount: numberish(conversion.raw.convertedCount),
+            unconvertedCount: numberish(conversion.raw.unconvertedCount),
+            totalCount: numberish(conversion.raw.totalCount),
+        },
+        // 处置率与两个时长都**带着 state**:分母/样本为 0 时不显示 0%,显示占位符(规格 §5/§6.7)。
+        rate: toRatio(disposal.raw.rate),
+        responseDuration: toDuration(disposal.raw.avgResponseDuration),
+        disposalDuration: toDuration(disposal.raw.avgDisposalDuration),
+        levels: reader.optionalList('byLevel').map((item) => {
+            const row = fieldReader(item, 'overviewAlarmDisposal.byLevel')
+            return {
+                code: String(row.raw.level ?? ''),
+                label: row.optionalStr('name') || String(row.raw.level ?? ''),
+                count: numberish(row.raw.count) ?? 0,
+            }
+        }),
+        // 近 7 日:固定 7 点、含 0 值、顺序从早到晚(后端保证;本层不补点)。
+        // 每天两个读数(维护者 2026-10-23):`count` = 当天新增;`unhandledTotal` = 截至当天未处理累计。
+        // `unhandledTotal` 是后加字段,老响应没有它 → 记 null(视图层据此决定画不画第二条线,
+        // 不补 0 冒充"积压为零")。
+        sevenDayTrend: reader.optionalList('sevenDayTrend').map((point) => {
+            const row = fieldReader(point, 'overviewAlarmDisposal.sevenDayTrend')
+            return {
+                date: row.optionalStr('date'),
+                count: numberish(row.raw.count) ?? 0,
+                unhandledTotal: numberish(row.raw.unhandledTotal),
+            }
+        }),
         missing: reader.missing,
     }
 }
 
+// ================================================================ 视图计划
+
+/**
+ * 比例读数 → 界面文本:`state: EMPTY`(分母 0)显示占位符,**不显示 0%**(规格 §5 末句)。
+ * 与票 18/20 的在线率判据同一条,不另写第二套。
+ */
+function ratioView(rate) {
+    const row = rate || {}
+    const available = row.state !== 'EMPTY' && typeof row.denominator === 'number' && row.denominator > 0
+    return available ? ratioText(row.value) : DETAIL_PLACEHOLDER
+}
+
 /**
- * 区域分布(票 17 的 `/infrastructure/base-archives`)→ 供基础设施面板用的领域行。
+ * 等级卡 → 带等级色的行(票 11 视觉对齐)。
  *
- * 单独一个薄函数而不是在视图里直接调 `toBaseArchives`:这样"本面板的区域分布来自哪个端点"
- * 只有一处可查,且视图层不必认识票 17 的载荷形状。
- * 票 38:`scale` 不再带出(真实后端恒 null,「规模」读数已从界面删除);`lengthKm` 保留。
+ * 等级色键 lv1..lv4 由**等级码 1..4** 直接给出(ADR 0006/0008 的既有调色板),
+ * 不因为「一级」在预警里是最高优先、在报警里也是最高优先而另写一份映射。
+ * 徽标(icon)不画:设计稿的等级卡只有数字 + 名称。
  */
-export function toOverviewRegions(raw) {
-    const archives = toBaseArchives(raw)
-    return { regions: archives.regions || [], lengthKm: archives.lengthKm, missing: archives.missing }
+function withLevelTones(levels) {
+    return levels.map((row) => ({ ...row, valueTone: LEVEL_TONES[row.code] || '' }))
 }
 
-// ================================================================ 视图计划
+/** 等级码 → 色键(两块的等级卡共用这一张表)。 */
+const LEVEL_TONES = { 1: 'lv1', 2: 'lv2', 3: 'lv3', 4: 'lv4' }
 
-/** 状态桶 → KPI 项(四态照实呈现,不合成"故障")。 */
-function statusKpis(status) {
-    const row = status || {}
-    return [
-        { key: 'online', label: '在线', value: valueText(row.online), unit: '台' },
-        { key: 'offline', label: '离线', value: valueText(row.offline), unit: '台' },
-        // `STALE` 在契约手册里是"滞后",**不是**故障(见 MONITORING_FAULT_GAP)。
-        { key: 'stale', label: '滞后', value: valueText(row.stale), unit: '台' },
-        { key: 'unknownOrMissing', label: '状态未知', value: valueText(row.unknownOrMissing), unit: '台' },
-    ]
+/**
+ * 把「监测内容」压成卡内一行小字(维护者 2026-10-23:供水/排水卡下的小字尽可能精简)。
+ *
+ * 原始内容来自库(`equipment_warning_type` 派生,实测供水 4 项、排水 8 项,最长约 90 字),
+ * 直接上屏会撑满两行还被裁切。压缩规则都是**去冗余、不改变含义**:
+ *   1. 按 `/` 切成条目;
+ *   2. 去掉条目里的行业前缀(卡名已经是「供水设备」「排水设备」,前缀重复);
+ *   3. 去掉每个条目结尾的「监测」("供水压力监测" → "压力");
+ *   4. 最多留 **两项**,超出加「等」。
+ * 例:供水 → `压力 / 瞬时流量 等`;排水 → `水位 / 瞬时流量 等`。
+ *
+ * 完整原文不进这里,由调用方放进 `noteTitle`(渲染层用作 title 悬停),所以没有信息丢失。
+ */
+function conciseMonitoring(text) {
+    const raw = String(text == null ? '' : text).trim()
+    if (!raw) return ''
+    const items = raw
+        .split('/')
+        .map((part) => part.trim())
+        .map((part) => part.replace(/^(供水|排水|燃气|窨井)/, '').replace(/监测$/, '').trim())
+        .filter(Boolean)
+    if (!items.length) return ''
+    const kept = items.slice(0, 2)
+    return items.length > kept.length ? `${kept.join(' / ')} 等` : kept.join(' / ')
 }
 
-// 曾有一个 `actionableBarsBlock()` 局部函数(票 20 立,给每行补 `actionKey`):
-// 它**从未被调用过**(票 37 复核:全仓 grep 唯一命中是它自己的定义),
-// 且与本票新增的 `barsBlock(label, rows, { action })` 是同一件事的两种写法 ——
-// 「同一件事两处实现」正是维护者 2026-10-13 点名的「早晚不一致」来源,故随本票删除。
-// 可点分布块现在只有一条路:`barsBlock(..., { action })`(见 `domain/panelBlocks.js`)。
-
-/** 在线率读数:分母为 0 显示占位符,不画 0.0%(票 18 的纪律,此处复用同一判据)。 */
-function onlineRateView(data) {
-    const row = data || {}
-    const available = row.onlineState !== 'EMPTY' && typeof row.onlineDenominator === 'number' && row.onlineDenominator > 0
-    return available ? ratioText(row.onlineRate) : DETAIL_PLACEHOLDER
+/** 折线点列 → 纯数值列表(多序列 API 只要值,日期标签共用主轴的 points)。 */
+function trendOf(sevenDayTrend) {
+    return (sevenDayTrend || []).map((point) => numberish(point.count) || 0)
 }
 
-function onlineRateHint(data) {
-    const row = data || {}
-    const available = row.onlineState !== 'EMPTY' && typeof row.onlineDenominator === 'number' && row.onlineDenominator > 0
-    if (available) return `在线 ${valueText(row.onlineCount)} / 分母 ${valueText(row.onlineDenominator)}`
-    return '当前范围内没有可参与计算的设备,在线率不适用'
+/**
+ * 「总未处理」序列(维护者 2026-10-23 反馈第 2 条)。
+ *
+ * 口径:**截至当天仍未处理**的累计条数(`alarm_status = 0`,0=未处理 / 1=已处理 ——
+ * 见后端 `AlarmRecordStatusEnum`)。它不是"当天新增里未处理的",而是"到那天为止积压了多少",
+ * 所以能和"当天新增"放在同一张图上做对照:新增归零而积压不为零,读起来就是"最近没新报警、
+ * 但历史未处理的还在那里"。
+ *
+ * 数据来源:后端按日给 `sevenDayTrend[].unhandledTotal`(新字段);还没升级的后端
+ * (返回里没有该字段)时回空列表 —— 那样图例会少一条线,而不是画一条假的 0 线。
+ */
+function unhandledOf(current) {
+    const rows = (current && current.sevenDayTrend) || []
+    if (!rows.some((point) => point.unhandledTotal !== undefined && point.unhandledTotal !== null)) return []
+    return rows.map((point) => numberish(point.unhandledTotal) || 0)
 }
 
 /**
- * 态势总览面板视图(验收第 1 条)。
+ * 未解除卡片的第三行小字:「处置中预警」(设计稿原句)。
  *
- * 跨行业 KPI:设施底数、监测规模、在线率、当前预警/报警、预警已解除率 + 近 7 日趋势。
+ * 只有**真的**取到处置中条数(>0)才报这个数;否则回落到「待处置预警总量」的口径说明——
+ * 不编一个读数,也不把 0 写成「处置中 0 条」这种看起来像正常业务的假读数。
+ */
+function unresolvedCaption(unresolved) {
+    const processing = numberish((unresolved || {}).statuses && (unresolved || {}).statuses.PROCESSING)
+    if (processing !== null && processing > 0) return `处置中预警 ${processing} 条`
+    return '含待办 / 已接收 / 处置中 / 已处置'
+}
+
+/** 后端 `reason` 码 → 给人读的半句(认不出的回空串,不把接口词汇甩到大屏上) */
+const EMPTY_REASON_TEXT = {
+    NO_DEVICES: '当前没有设备',
+    NO_ALARMS: '当前没有已核实报警',
+    NO_RESPONSE_LOGS: '没有接单日志',
+    NO_DISPOSAL_LOGS: '没有验收通过日志',
+}
+
+function emptyReasonText(reason) {
+    return Object.prototype.hasOwnProperty.call(EMPTY_REASON_TEXT, reason) ? EMPTY_REASON_TEXT[reason] : ''
+}
+
+/** 比例读数的口径说明(悬停读全:分子/分母,或"分母为 0 不适用")。 */
+function ratioHint(rate) {
+    const row = rate || {}
+    const available = row.state !== 'EMPTY' && typeof row.denominator === 'number' && row.denominator > 0
+    if (!available) {
+        const why = emptyReasonText(row.reason)
+        return why ? `${why},该比例不适用(不显示 0%)` : '分母为 0,该比例不适用(不显示 0%)'
+    }
+    return `分子 ${valueText(row.numerator)} / 分母 ${valueText(row.denominator)}`
+}
+
+/** 单位码 → 人话(接口词汇不上大屏)。 */
+function durationUnitText(unit) {
+    const dictionary = { HOURS: '小时', MINUTES: '分钟' }
+    return dictionary[unit] || ''
+}
+
+/** 时长读数 → 界面文本(无有效样本 → 占位符,不显示 0 小时)。 */
+function durationView(duration) {
+    const row = duration || {}
+    if (row.state === 'EMPTY' || numberish(row.value) === null) return DETAIL_PLACEHOLDER
+    return valueText(row.value)
+}
+
+/** 时长的口径说明:单位 + 样本数 + 被剔除的无效样本(规格 §5 的 `sampleCount/invalidSampleCount`)。 */
+function durationHint(duration) {
+    const row = duration || {}
+    if (row.state === 'EMPTY' || numberish(row.value) === null) {
+        const why = emptyReasonText(row.reason)
+        return why ? `${why},该时长不适用` : '无有效样本,该时长不适用'
+    }
+    const unit = durationUnitText(row.unit)
+    const sample = typeof row.sampleCount === 'number' ? `,样本 ${row.sampleCount}` : ''
+    const invalid = typeof row.invalidSampleCount === 'number' && row.invalidSampleCount > 0
+        ? `(已剔除 ${row.invalidSampleCount} 个无效样本)`
+        : ''
+    return `单位 ${unit || row.unit}${sample}${invalid}`
+}
+
+/**
+ * 本面板「有内容可画」的判据(规格 §6.7 的例外 + 盘点 §7.2)。
  *
- * 数据来自**三份取数**(基础设施 / 运行监测 / 预警设置)——它们是各自独立成败的分区,
- * 所以这里把三份都作为输入,缺哪份就少哪几项读数,而**不**让整块面板消失。
- * @param {object} data `{ infrastructure, monitoring, warningSettings }` 三份领域对象
- * @param {object} section 分区(取预警设置那份的成败作本面板的代表)
+ * 常规分区只有 SUCCESS/STALE 有内容可画;但**固定项面板**(类型卡/片区卡/等级卡)在
+ * 「后端说没有数据、却仍给固定行」时必须照画 0 —— 否则「无数据 0」会被占位吞掉。
+ * 触发形状固定为 `state = EMPTY` 且 `data != null`(fetcher 的空判据只认关键段缺失,
+ * 不按计数为 0 判空,所以 EMPTY 也会带 data —— 两处判据是配套的)。
+ *
+ * 基础设施**不适用**这条例外:没有管道就不画假环(ticket 06 的验收原句)。
  */
-export function situationSummaryPanel(data, section) {
-    const current = data || {}
-    const infra = current.infrastructure || null
-    const monitoring = current.monitoring || null
-    const warnings = current.warningSettings || null
-    const warning = (warnings && warnings.warning) || null
-    const alarm = (warnings && warnings.alarm) || null
+function fixedCardsSection(section) {
+    if (sectionHasContent(section)) return true
+    return !!section && section.state === SECTION_STATE.EMPTY && section.data !== null && section.data !== undefined
+}
+
+/** 排除项说明(规格 §2/§3 的 `excluded`:不静默丢,见 G9)。 */
+function excludedText(parts) {
+    const text = parts.filter(Boolean).join(' · ')
+    return text ? `口径剔除:${text}` : ''
+}
 
+/**
+ * 感知设备面板(规格 §6.2)。
+ *
+ * 顶部 4 指标卡(总数/在线/离线/在线率)→ 4 张设备类型卡(含监测内容小字)→ 3 张片区卡。
+ * 在线率分母为 0 时显示占位符;无状态记录的设备计离线(G7 裁定)。
+ */
+export function perceptionDevicesPanel(data, section, extra) {
+    const current = data || {}
+    // 当前生效的全局行业筛选键(外壳喂的标量):只用来画类型卡的选中态。
+    const activeIndustry = (extra && extra.industry) || ''
     const blocks = [
-        kpiBlock([
-            { key: 'facilityCount', label: '设施底数', value: valueText(infra && infra.facilityCount), unit: '处' },
-            { key: 'smartDeviceCount', label: '监测规模', value: valueText(infra && infra.smartDeviceCount), unit: '台' },
-            { key: 'deviceCount', label: '智能设备', value: valueText(monitoring && monitoring.deviceCount), unit: '台' },
-            { key: 'monitoringPointCount', label: '监测点', value: valueText(monitoring && monitoring.monitoringPointCount), unit: '个' },
-            { key: 'onlineRate', label: '在线率', value: onlineRateView(monitoring), hint: onlineRateHint(monitoring) },
-            { key: 'warningTotal', label: '当前预警', value: valueText(warning && warning.total), unit: '条', link: 'WARNING', linkHint: '跳到预警处置模式的当前预警列表' },
-            { key: 'alarmTotal', label: '当前报警', value: valueText(alarm && alarm.total), unit: '条', link: 'ALARM', linkHint: '跳到预警处置模式的当前报警列表' },
-            {
-                key: 'handlingRate',
-                label: '预警已解除率',
-                value: warning ? handlingRateView(warning.efficiency) : DETAIL_PLACEHOLDER,
-                hint: warning ? warningRateHint(warning.efficiency) : '',
-                link: 'EFFICIENCY',
-                linkHint: '跳到预警处置模式的预警效率明细',
-            },
-        ]),
-        // 近 7 日趋势:预警与报警**两条序列**(后端一次给两族,故不需要切换)。
-        // 2026-10-13 图表选型:时间趋势用折线(维护者表格「有历史信息,带入时间趋势」行)。
-        trendBlock('近 7 日预警趋势', (warning && warning.trend) || [], { chart: 'line' }),
-        trendBlock('近 7 日报警趋势', (alarm && alarm.trend) || [], { chart: 'line' }),
+        // 顶部四卡(设计稿 2026-10-23 视觉对齐):读数在上、标签在下,每卡顶部一条语义色横条,
+        // 读数按语义着色(在线绿 / 离线红 / 其余白)—— 色与横条都是 domain 给的 valueTone/accent。
+        kpiBlock(
+            [
+                { key: 'deviceCount', label: '设备总数', value: valueText(current.deviceCount), unit: '台', accent: 'info' },
+                { key: 'online', label: '在线', value: valueText(current.onlineCount), unit: '台', valueTone: 'lv4', accent: 'lv4' },
+                { key: 'offline', label: '离线', value: valueText(current.offlineCount), unit: '台', valueTone: 'lv1', accent: 'lv1' },
+                {
+                    key: 'onlineRate',
+                    label: '在线率',
+                    value: ratioView(current.onlineRate),
+                    hint: ratioHint(current.onlineRate),
+                    accent: 'info',
+                },
+            ],
+            { layout: 'value-first' },
+        ),
+        // 类型卡 2x2(设计稿):卡内小字=后端的监测内容;行业图标 + 行业色边框;
+        // 可点 → 写全局行业筛选(与条形图同一条 panel-action: industry 接缝,不新造交互)。
+        cardsBlock(
+            '按设备类型',
+            (current.types || []).map((row) => ({
+                ...row,
+                tone: TYPE_TONES[row.code] || '',
+                icon: TYPE_ICONS[row.code] || '',
+                actionKey: TYPE_FILTER_KEYS[row.code] || '',
+                // 选中态:当前行业筛选正好是这张卡代表的行业(再点一次即取消,走外壳既有 toggle 语义)。
+                selected: Boolean(activeIndustry) && TYPE_FILTER_KEYS[row.code] === activeIndustry,
+            })),
+            { fixed: true, layout: 'two-by-two', action: 'industry', aside: '点击按行业筛选' },
+        ),
+        // 片区卡:城南/城北/太常固定三项,无数据画 0(规格 §6.2 第 3 条);一排三张。
+        cardsBlock('按设备片区', current.districts || [], { fixed: true, layout: 'three', aside: '片区在网设备' }),
     ]
 
     return {
-        name: 'situationSummary',
-        label: '态势总览',
+        name: 'perceptionDevices',
+        label: '感知设备',
         section,
-        hasContent: sectionHasContent(section),
+        // 空态仍要画 4 类型卡 / 3 片区卡的 0(规格 §6.7 的例外)。
+        hasContent: fixedCardsSection(section),
         blocks,
-        // 联动出口:指标上的 `link` 由外壳翻成"切到预警处置模式"(见文件头第 ① 条)。
-        links: [
-            { key: 'WARNING', label: '查看当前预警列表', mode: OVERVIEW_LINK_MODES.WARNING },
-            { key: 'ALARM', label: '查看当前报警列表', mode: OVERVIEW_LINK_MODES.ALARM },
-            { key: 'EFFICIENCY', label: '查看处置效率明细', mode: OVERVIEW_LINK_MODES.EFFICIENCY },
-        ],
-        linkNote: '点预警/报警/已解除率读数可跳到「预警处置」模式的事件列表(本模式端点不含点位编码,不在地图上定位)',
-        rangeNote: warnings && warnings.rangeType === 'LAST_7_DAYS' ? '趋势窗口:近 7 日(后端按声明窗口过滤)' : '',
+        excludedNote: excludedText([
+            current.excluded && current.excluded.unknownTypeCount ? `类型未知 ${valueText(current.excluded.unknownTypeCount)} 台` : '',
+            current.excluded && current.excluded.unassignedDistrictCount
+                ? `未分片区 ${valueText(current.excluded.unassignedDistrictCount)} 台(不进片区卡)`
+                : '',
+        ]),
+        note: '设备数取台账全量;无状态记录计离线',
         missing: panelMissingNote(current),
         placeholder: sectionPlaceholder(section),
     }
 }
 
 /**
- * 基础设施面板视图(验收第 2 条)。
- * 设施底数 + 行业构成 + 区域分布(区域来自票 17 的 `base-archives`,见文件头第 ② 条)。
+ * 基础设施面板(规格 §6.3)。
+ *
+ * 顶部 2 指标卡(管道数/管线里程 km)→ 环形饼图(三类管道条数与占比,图例逐项带计数)。
  */
-export function infrastructureSummaryPanel(data, section) {
+export function pipeNetworkPanel(data, section) {
     const current = data || {}
-    const infra = current.infrastructure || {}
-    // `regions` 是 `toOverviewRegions()` 的领域对象(`{regions, lengthKm, scale}`),
-    // 不是行数组本身 —— 它连同里程/规模一起带出来,界面才有得画。
-    const regionData = current.regions || null
-    const regions = (regionData && regionData.regions) || []
-
     const blocks = [
+        // 两卡按设计稿:图标+标签 → 大号数值+单位 → 口径小字(`caption`)。
         kpiBlock([
-            { key: 'facilityCount', label: '设施总数', value: valueText(infra.facilityCount), unit: '处' },
-            { key: 'smartDeviceCount', label: '智能设备', value: valueText(infra.smartDeviceCount), unit: '台' },
-            { key: 'industryCount', label: '行业数', value: String((infra.industries || []).length), unit: '个' },
-            // 里程来自票 17 那份档案(本端点不给),取不到时显示占位符。
-            // 票 38(第 2 条):「规模」读数已删除——真实后端 totals.scale 恒 null,是空读数。
-            // 票 38 第二轮(维护者复验反馈第 1 条):概览这块 `wide` 让管线里程独占整行——
-            // 四个读数里它数值最长(真实 62.60552),并排会挤压;`wide` 由 domain 声明,
-            // 渲染层只认这个通用标志、不再按 key 特判(与「谁独占整行归 domain」同一条纪律)。
-            { key: 'lengthKm', label: '管线里程', value: valueText(regionData && regionData.lengthKm), unit: 'km', wide: true },
-        ]),
-        // 2026-10-13 维护者反馈:条形图太多易疲劳——行业构成是「类别少、占比」场景,改环形图;
-        // 行业行走行业语义色(与行业切图同源);图例行保留联动(点它写全局行业筛选,接缝不变)。
-        // 票 37 走查扩展:行业是排名类别,两块都按计数降序(并列保序)。
-        donutBlock(
-            '行业构成 · 设施',
-            sortByCountDesc(
-                (infra.industries || []).map((row) => ({ code: row.code, label: row.label, count: row.facilityCount || 0, actionKey: row.key })),
-                (row) => row.count,
-            ),
-            { action: 'industry' },
-        ),
+            {
+                key: 'pipeCount',
+                label: '管道数',
+                value: valueText(current.pipeCount),
+                unit: '根',
+                caption: '供水 · 排水 · 燃气',
+            },
+            { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km', caption: '管网总长' },
+        ], { layout: 'two' }),
+        // 环形居中、图例在下横排。
+        // 2026-10-23 维护者第 3 条:标题**不再重复管道数量**(上面两张指标卡已经给了),
+        // 就叫「管道分类占比」—— 同一屏里同一个数说两遍只是噪音。
         donutBlock(
-            '行业构成 · 智能设备',
-            sortByCountDesc(
-                (infra.industries || []).map((row) => ({ code: row.code, label: row.label, count: row.smartDeviceCount || 0, actionKey: row.key })),
-                (row) => row.count,
-            ),
-            { action: 'industry' },
+            '管道分类占比',
+            (current.types || []).map((row) => ({
+                code: row.code,
+                label: row.label,
+                count: row.count,
+                // 环形几何按计数画;里程进悬停说明(图例仍逐项带条数)。
+                hint: numberish(row.lengthKm) === null ? '' : `${row.lengthKm} km`,
+            })),
+            { layout: 'stack-below' },
         ),
-        barsBlock('片区分布', sortByCountDesc(regions, (row) => row.count)),
     ]
 
     return {
-        name: 'infrastructureSummary',
+        name: 'pipeNetwork',
         label: '基础设施',
         section,
         hasContent: sectionHasContent(section),
         blocks,
-        // 联动出口:行业构成行可写全局行业筛选(真的驱动地图与所有面板)。
-        industryLinks: (infra.industries || [])
-            .filter((row) => row.key)
-            .map((row) => ({ code: row.code, key: row.key, label: row.label })),
-        linkNote: '点行业行可把该行业设为全局筛选条件(同时作用于一张图与各面板)',
-        // 区域分布来自哪个端点,界面如实说明(本端点自己没有这一段)。
-        gap: regions.length ? '' : '片区分布取自 `/infrastructure/base-archives`(本概览端点不含片区字段),当前未取到',
-        excludedNote:
-            infra.excluded && (infra.excluded.unknownTypeCount || infra.excluded.typeKindMismatchCount)
-                ? `口径剔除:类型未知 ${valueText(infra.excluded.unknownTypeCount)} · 类型不匹配 ${valueText(infra.excluded.typeKindMismatchCount)}`
+        excludedNote: excludedText([
+            current.excluded && current.excluded.unclassifiedCount
+                ? `未分类管线 ${valueText(current.excluded.unclassifiedCount)} 条(计入总数、不进饼图)`
                 : '',
+        ]),
+        note: '里程按管长合计折算;占比按条数',
         missing: panelMissingNote(current),
         placeholder: sectionPlaceholder(section),
     }
 }
 
 /**
- * 运行监测面板视图(验收第 3 条)。
- * 在线率 + 四态统计 + 监测覆盖 + 类型分布。
+ * 运行监测-预警研判面板(规格 §6.4)。
  *
- * `data` 就是**本块自己的领域对象**(`section.data`)—— 单源面板直读,不套一层壳。
- * ⚠️ 票 20 真机走查逮到的真缺陷:本函数原先读 `current.monitoring`(以为拿到的是
- * `{ monitoring }` 包装),而外壳传进来的是裸领域对象,于是**整块读数全是占位符 `—`**;
- * 而逻辑闸门当时全绿,因为**测试喂的正是那个错误的包装形状**(测试与实现犯了同一个错)。
+ * 顶部 2 指标卡(今日新增带差值、未解除)→ 4 张固定等级卡 → 类型排行 TOP3(可点)。
+ * 排行标题严格为 `近30日 · 共XX起 · 点击筛选`(XX = 后端给的近 30 日总数)。
  */
-export function monitoringSummaryPanel(data, section) {
-    const monitoring = data || {}
-
+export function warningStudyPanel(data, section) {
+    const current = data || {}
+    const today = current.today || {}
+    const ranking = current.typeRanking || {}
+    // 排行标题按设计稿拆成两段:块标题「预警类型排行」+ 右上角小字「近30日 · 共XX起 · 点击筛选」。
+    // 小字整句仍是需求原文的锁定文案(拆位置不拆字),闸门按整句断言。
+    const rankingAside = `近30日 · 共${valueText(ranking.total)}起 · 点击筛选`
     const blocks = [
         kpiBlock([
-            { key: 'onlineRate', label: '在线率', value: onlineRateView(monitoring), hint: onlineRateHint(monitoring) },
-            ...statusKpis(monitoring.deviceStatus),
-        ]),
-        rowsBlock('监测覆盖', [
-            { key: 'monitoringPointCount', label: '监测点', value: valueText(monitoring.monitoringPointCount) },
-            { key: 'deviceCount', label: '智能设备', value: valueText(monitoring.deviceCount) },
-            { key: 'monitoringFacilityCount', label: '监测设施', value: valueText(monitoring.monitoringFacilityCount) },
-            { key: 'infrastructureFacilityCount', label: '基础设施', value: valueText(monitoring.infrastructureFacilityCount) },
+            // 差值:正红负绿(规格 §6.6),块构造器按 `delta` 自动补文案与色调。
+            { key: 'warningToday', label: '今日新增', value: valueText(today.newCount), unit: '条', delta: today.delta },
+            // 未解除的第三行小字是「处置中预警」——处置中条数是本契约里可证实的读数(不再另编口径)。
             {
-                key: 'formalIndustries',
-                label: '正式行业范围',
-                value: (monitoring.formalIndustries || []).map((code) => industryLabel(code) || codeText(code)).join(' · ') || DETAIL_PLACEHOLDER,
+                key: 'warningUnresolved',
+                label: '未解除',
+                value: valueText((current.unresolved || {}).count),
+                unit: '条',
+                caption: unresolvedCaption(current.unresolved),
             },
-        ]),
-        // 票 37 走查扩展(维护者 2026-10-14):类型分布是「多类别排名」场景,
-        // 与监测概览同口径——按设备数从多到少(并列保持后端次序)。
-        barsBlock(
-            '类型分布 · 智能设备',
-            sortByCountDesc(
-                (monitoring.typeDistribution || []).map((row) => ({
-                    code: `${row.industryCode || ''}|${row.typeCode || ''}`,
-                    label: [row.industryLabel, row.typeName].filter(Boolean).join(' · ') || row.typeCode,
-                    count: row.deviceCount || 0,
-                })),
-                (row) => row.count,
-            ),
+        ], { layout: 'two' }),
+        // 等级卡:一级→四级固定四项,无数据 0(规格 §6.4 第 2 条);数字与边框同等级色。
+        cardsBlock(
+            '未解除预警 · 分级',
+            withLevelTones(current.levels || []),
+            { fixed: true, layout: 'four' },
         ),
+        // 全类型可点:动作键 `warningType`(落点:切「预警处置」模式并按类型过滤本地列表)。
+        // 维护者 2026-10-23 第 4 条:不再只列前三,所有类型都排出来(limit: 0 = 全部)。
+        rankingBarsBlock('预警类型排行', ranking.top || [], {
+            limit: 0,
+            action: 'warningType',
+            variant: 'rank',
+            unit: '起',
+            aside: rankingAside,
+        }),
     ]
 
-    // 逐设备最近采集时间:**本端点独有**(票 18 的缺口 I2/BT-8 在这里有值)。
-    const freshness = monitoring.metricFreshness || {}
-    if (freshness.updatedAt || freshness.status) {
-        blocks.push(
-            rowsBlock('最近采集时间', [
-                { key: 'metricStatus', label: '采集状态', value: codeText(freshness.status) },
-                { key: 'metricUpdatedAt', label: '最近采集', value: valueText(freshness.updatedAt) },
-                { key: 'metricServerUpdatedAt', label: '服务端时间', value: valueText(freshness.serverUpdatedAt) },
-            ]),
-        )
-    }
-
     return {
-        name: 'monitoringSummary',
-        label: '运行监测',
+        name: 'warningStudy',
+        label: '运行监测-预警研判',
         section,
-        hasContent: sectionHasContent(section),
+        // 无预警时等级卡要画 4 个 0(规格 §6.4 第 2 条 + §6.7 例外)。
+        hasContent: fixedCardsSection(section),
         blocks,
-        // 验收第 3 条的「故障统计」缺口:如实说明,不合成一个"故障"数(见文件头第 ③ 条)。
-        gap: MONITORING_FAULT_GAP,
-        missing: panelMissingNote(monitoring),
+        linkNote: '点类型排行会切到「预警处置」模式并按该预警类型过滤当前列表(规格 §6.4 第 4 条;类型筛选支持归票 07)',
+        note: '未解除含待办/处置中/已处置',
+        missing: panelMissingNote(current),
         placeholder: sectionPlaceholder(section),
     }
 }
 
 /**
- * 票 42:预警族速率的说明。预警族的速率是**已解除率**(已解除 ÷ 预警总数),
- * 与报警族/预警处置模式的**工单口径处置率**不是一个东西 —— hint 用词必须分开,
- * 不让「已解除 19 / 预警 31」被读成工单处置率。判据复用 `handlingRateView`(票 20 单源)。
- */
-function warningRateHint(efficiency) {
-    const row = efficiency || {}
-    if (handlingRateView(row) === '不适用') return '当前范围没有预警,已解除率不适用'
-    if (typeof row.numerator === 'number' && typeof row.denominator === 'number') {
-        return `已解除 ${row.numerator} / 预警总数 ${row.denominator}`
-    }
-    return ''
-}
-
-/**
- * 票 42:自身生命周期分布块(状态/等级/行业/片区)。后端已按族全铺且计数自洽,
- * 前端不补 0、不重排、不翻码;某组后端没给时**整块跳过** —— 「不适用」显式化,不画空块。
- * `title` 是界面用语(「预警」/「报警」),与 KPI 的机器键(`WARNING-*`)分工。
- */
-function lifecycleBars(title, row) {
-    const groups = [
-        // 生命周期状态/等级是**固定语义序**(后端字典),不重排;
-        // 行业/片区是排名类别——票 37 走查扩展:按计数降序(并列保序)。
-        ['statusDistribution', '生命周期状态', false],
-        ['levelDistribution', '等级分布', false],
-        ['industryDistribution', '行业分布', true],
-        ['districtDistribution', '片区分布', true],
-    ]
-    return groups
-        .filter(([name]) => Array.isArray(row[name]))
-        .map(([name, label, sortDesc]) =>
-            barsBlock(`${title} · ${label}`, sortDesc ? sortByCountDesc(row[name], (item) => item.count) : row[name]),
-        )
-}
-
-/**
- * 预警族的块(票 42:只给**预警自身生命周期**,工单口径整体不出现 ——
- * 「预警不走工单流程」,画工单字段等于把不适用画成读数)。
+ * 运行监测-报警处置面板(规格 §6.5)。
+ *
+ * 第一行 3 指标卡(今日新增带差值/已转工单/未转工单)→ 第二行 3 指标卡
+ * (处置率/平均响应时长/平均处置时长)→ 4 张固定等级卡 → 近 7 日折线(标题【近7日报警数】)。
+ * 比例与时长在分母/样本为 0 时显示占位符(规格 §5 末句 + §6.7)。
  */
-function warningFamilyBlocks(family) {
-    const row = family || {}
-    const familyKey = row.family || 'WARNING'
-    return [
+export function alarmDisposalPanel(data, section) {
+    const current = data || {}
+    const today = current.today || {}
+    const conversion = current.conversion || {}
+    const rate = current.rate || {}
+    const response = current.responseDuration || {}
+    const disposal = current.disposalDuration || {}
+    const blocks = [
+        // 第一行(设计稿):今日新增(带较昨日差值)、已转工单、未转工单;每卡第三行是口径小字。
         kpiBlock([
-            { key: `${familyKey}-total`, label: '预警总数', value: valueText(row.total), unit: '条' },
+            { key: 'alarmToday', label: '今日新增', value: valueText(today.newCount), unit: '条', delta: today.delta },
+            { key: 'alarmConverted', label: '已转工单', value: valueText(conversion.convertedCount), unit: '条', caption: '已派发处置' },
+            { key: 'alarmUnconverted', label: '未转工单', value: valueText(conversion.unconvertedCount), unit: '条', caption: '待核实转派' },
+        ], { layout: 'three' }),
+        kpiBlock([
+            { key: 'alarmRate', label: '报警处置率', value: ratioView(rate), hint: ratioHint(rate), caption: '已解除占比' },
             {
-                key: `${familyKey}-rate`,
-                label: '已解除率',
-                value: handlingRateView(row.efficiency),
-                hint: warningRateHint(row.efficiency),
-                link: 'EFFICIENCY',
-                linkHint: '跳到预警处置模式的预警效率明细',
+                key: 'responseDuration',
+                label: '平均响应时长',
+                value: durationView(response),
+                unit: durationUnitText(response.unit),
+                hint: durationHint(response),
+                caption: '处置响应',
             },
-        ]),
-        ...lifecycleBars('预警', row),
-    ]
-}
-
-/**
- * 报警族的块(票 42:**保留工单口径** —— 维护者 2026-10-14 裁定「报警靠工单闭环」,
- * 同时并存 `alarm_status` 自身生命周期分布,两者不同源、不互相冒充)。
- */
-function alarmFamilyBlocks(family) {
-    const row = family || {}
-    const familyKey = row.family || 'ALARM'
-    const states = row.workOrderStates || []
-    return [
-        kpiBlock([
-            { key: `${familyKey}-total`, label: '报警总数', value: valueText(row.total), unit: '条' },
-            { key: `${familyKey}-handled`, label: '已处理', value: valueText(row.handled), unit: '条' },
-            { key: `${familyKey}-pending`, label: '待处理', value: valueText(row.pending), unit: '条' },
-            { key: `${familyKey}-cancelled`, label: '已取消', value: valueText(row.cancelledCount), unit: '条' },
-            { key: `${familyKey}-unlinked`, label: '未关联工单', value: valueText(row.unlinkedEventCount), unit: '条' },
             {
-                key: `${familyKey}-rate`,
-                label: '处置率',
-                value: handlingRateView(row.efficiency),
-                hint: handlingRateHint(row.efficiency),
-                link: 'EFFICIENCY',
+                key: 'disposalDuration',
+                label: '平均处置时长',
+                value: durationView(disposal),
+                unit: durationUnitText(disposal.unit),
+                hint: durationHint(disposal),
+                caption: '处置完成',
             },
-        ]),
-        // 工单状态分布:0–7 全铺(缺的态是真实计数 0,不是"没有数据")。
-        // 2026-10-13 图表选型:状态是多类别占比 → 百分比堆叠条。
-        stackedBlock('报警 · 工单状态分布', states),
-        // 票 42:报警自身生命周期(UNHANDLED/HANDLED 两桶 + 等级/行业/片区)并存。
-        ...lifecycleBars('报警', row),
+        ], { layout: 'three' }),
+        // 等级卡:一级→四级固定四项,无数据 0(规格 §6.5 第 3 条);数字与边框同等级色。
+        cardsBlock('已核实报警 · 分级', withLevelTones(current.levels || []), { fixed: true, layout: 'four' }),
+        // 折线:标题严格为「【近7日报警数】」,X 轴 MM-DD、悬停读全日期(hint),含 0 点;
+        // 线色按设计稿取 lv2(橙),tone 由 domain 给、色值仍只在 tokens.css 声明一份。
+        trendBlock(
+            '【近7日报警数】',
+            (current.sevenDayTrend || []).map((point) => ({
+                key: point.date,
+                label: shortDate(point.date),
+                count: point.count,
+                hint: point.date,
+            })),
+            // 两条线并列(维护者 2026-10-23 反馈第 2 条):
+            //   ① 当天新增(当天 create_time 的条数);
+            //   ② 总未处理(截至当天仍未处理的累计条数,`alarm_status = 0`)。
+            // 为什么不再挂一句口径小字:单条线 + 一个"近7日新增"标签说不清"为什么有的时候是 0",
+            // 维护者要的是**用第二条线做对照**(总未处理非 0 而新增为 0,一看就知道是"这段时间没新报警")。
+            // 两条线各按联合最大值归一并带图例,读者自己就能读出这层关系,不需要解释文案。
+            {
+                chart: 'line',
+                series: [
+                    { key: 'newAlarms', label: '当天新增', tone: 'lv2', counts: trendOf(current.sevenDayTrend) },
+                    { key: 'unhandledTotal', label: '总未处理', tone: 'lv1', counts: unhandledOf(current) },
+                ],
+            },
+        ),
     ]
-}
-
-/**
- * 预警设置面板视图(验收第 4 条:保持合同名称,只读展示预警/报警统计)。
- *
- * **两族都画**(Q1 裁定:后端一次给两族,做切换等于把已有数据藏起来)。
- *
- * `data` 是**本块自己的领域对象**(`section.data`,即 `toOverviewWarningSettings()` 的结果)。
- * ⚠️ 与 `monitoringSummaryPanel` 同一个坑(真机走查逮到):原先读 `current.warningSettings`
- * 那层不存在的包装,于是整块 KPI 全是 `—`。单源面板一律直读 `data`。
- */
-export function alertSettingsPanel(data, section) {
-    const warnings = data || {}
-    const warning = warnings.warning || null
-    const alarm = warnings.alarm || null
-
-    // 票 42:两族口径不同,块结构也不同 —— 预警族纯自身生命周期,报警族工单口径 + 自身生命周期。
-    const blocks = [...warningFamilyBlocks(warning), ...alarmFamilyBlocks(alarm)]
 
     return {
-        name: 'alertSettings',
-        label: '预警设置',
+        name: 'alarmDisposal',
+        label: '运行监测-报警处置',
         section,
-        hasContent: sectionHasContent(section),
+        // 无报警时等级卡要画 4 个 0、7 日折线画 7 个 0 点(规格 §6.5 + §6.7 例外)。
+        hasContent: fixedCardsSection(section),
         blocks,
-        links: [
-            { key: 'WARNING', label: '查看当前预警列表', mode: OVERVIEW_LINK_MODES.WARNING },
-            { key: 'ALARM', label: '查看当前报警列表', mode: OVERVIEW_LINK_MODES.ALARM },
-            { key: 'EFFICIENCY', label: '查看处置效率明细', mode: OVERVIEW_LINK_MODES.EFFICIENCY },
-        ],
-        // 面板名保持合同名「预警设置」(验收第 4 条原句),但两族口径不同 —— 说明这一点。
-        note: '本面板按合同名称保留为「预警设置」:预警族为预警自身生命周期统计(不走工单),报警族为工单口径 + 报警自身生命周期并存;本模式端点不含点位编码,不联动地图',
-        rangeNote: warnings.rangeType === 'LAST_7_DAYS' ? '统计窗口:近 7 日' : '',
-        missing: panelMissingNote(warnings),
+        // 面板口径说明(顶部折叠区)。**不再**把"上方全量 / 折线近7日"写成一句 ——
+        // 维护者 2026-10-23 第 2 条明确不要那类多余说明;折线改成两条线 + 图例后,
+        // "新增归零而积压不为零"这层关系读者自己看得出来,不需要一段文字去解释。
+        note: '基础集合为已核实报警;时长只计有日志的样本',
+        missing: panelMissingNote(current),
         placeholder: sectionPlaceholder(section),
     }
 }
 
-/** panelId → 视图构造函数(本模式自己的四块)。 */
-export const OVERVIEW_PANEL_BUILDERS = {
-    situationSummary: situationSummaryPanel,
-    infrastructureSummary: infrastructureSummaryPanel,
-    monitoringSummary: monitoringSummaryPanel,
-    alertSettings: alertSettingsPanel,
+/** `YYYY-MM-DD` → 轴标签 `MM-DD`(悬停仍给完整日期,见上面折线的 `hint`)。 */
+function shortDate(date) {
+    const text = typeof date === 'string' ? date.trim() : ''
+    return text.length >= 10 && text[4] === '-' && text[7] === '-' ? text.slice(5) : text
 }
 
-/**
- * 一个面板的视图。`data` 为 null(失败/空)时仍返回结构完整的视图(块为空),
- * 组件据此画占位说明 —— 「失败」不该让整块面板消失,那会连面板标题都没了。
- */
-export function overviewPanelView(panelId, data, section) {
-    const build = OVERVIEW_PANEL_BUILDERS[panelId]
-    if (!build) return null
-    return build(data, section)
+/** panelId → 视图构造函数(本模式自己的四块)。 */
+export const OVERVIEW_PANEL_BUILDERS = {
+    perceptionDevices: perceptionDevicesPanel,
+    pipeNetwork: pipeNetworkPanel,
+    warningStudy: warningStudyPanel,
+    alarmDisposal: alarmDisposalPanel,
 }

+ 234 - 7
src/modules/cockpit/domain/panelBlocks.js

@@ -132,6 +132,33 @@ export function ratioText(value) {
     return `${(parsed * 100).toFixed(parsed * 100 >= 100 ? 0 : 1)}%`
 }
 
+// ================================================================ 差值(较昨日)
+
+/**
+ * 差值色调(规格 §6.6):**正=红、负=绿、0=中性**。
+ *
+ * 注意这与「好坏」无关 —— 报警多了是红、少了是绿,是值班习惯(红=需要关注),
+ * 而**不是**在暗示"增加就是坏事"。所以色调只由**符号**决定,不看绝对值。
+ * 复用项目既有语义色键(lv1 红 / lv4 绿 / neutral 中性),不另造一套颜色词。
+ */
+export function deltaTone(delta) {
+    const value = numberish(delta)
+    if (value === null || value === 0) return 'neutral'
+    return value > 0 ? 'lv1' : 'lv4'
+}
+
+/**
+ * 差值文案(规格 §6.6 原句格式):`较昨日 +6` / `较昨日 -3` / `较昨日 0`。
+ * 认不出的差值回空串 —— 不画一个假的「较昨日 0」。
+ */
+export function deltaText(delta) {
+    const value = numberish(delta)
+    if (value === null) return ''
+    if (value > 0) return `较昨日 +${value}`
+    if (value < 0) return `较昨日 -${Math.abs(value)}`
+    return '较昨日 0'
+}
+
 // ================================================================ 分区状态(视图层口语)
 
 /** 有内容可画的两种状态(过期是有值)。 */
@@ -222,8 +249,18 @@ export const KPI_ICONS = {
     handlingRateView: 'efficiency',
     responseDuration: 'time',
     closeDuration: 'time',
+    // 票 02 新增的四模块读数键:
+    pipeCount: 'facility',
+    warningToday: 'warning',
+    warningUnresolved: 'warning',
+    alarmToday: 'alarm',
+    alarmConverted: 'resolved',
+    alarmUnconverted: 'unknownOrMissing',
+    alarmRate: 'efficiency',
     // 基础档案(票 38:`scale` 规模读数已删除——真实后端 totals.scale 恒 null)
     lengthKm: 'facility',
+    // 总体概览四模块(票 02):读数键映射到**既有**语义图标(不另造切图)。
+    disposalDuration: 'time',
     // 应急保障
     hazardSourceCount: 'unknownOrMissing',
     protectionTargetCount: 'shield',
@@ -262,19 +299,112 @@ export const KPI_ICON_COLORS = {
     handlingRate: 'var(--lv4, #27d17a)',
     handlingRateView: 'var(--lv4, #27d17a)',
     createdAt: 'var(--muted, #5680b0)',
+    // 总体概览四模块(票 02)
+    warningToday: 'var(--lv2, #ffb020)',
+    warningUnresolved: 'var(--lv2, #ffb020)',
+    alarmToday: 'var(--lv1, #ff3b52)',
+    alarmConverted: 'var(--lv4, #27d17a)',
+    alarmUnconverted: 'var(--lv2, #ffb020)',
+    alarmRate: 'var(--lv4, #27d17a)',
 }
 
-/** KPI 块。`value` 为 null 时界面画占位符(`valueText` 负责)。
- *  每项自动带 `icon`(KPI_ICONS 映射,未命中为空串 → 组件不画图标)
- *  与 `iconColor`(KPI_ICON_COLORS,未命中回空串 → 渲染层用主蓝)。 */
+/**
+ * KPI 块。`value` 为 null 时界面画占位符(`valueText` 负责)。
+ *
+ * 每项自动带:
+ *   · `icon`(KPI_ICONS 映射,未命中为空串 → 组件不画图标)
+ *   · `iconColor`(KPI_ICON_COLORS,未命中回空串 → 渲染层用主蓝)
+ *   · `deltaText`/`deltaTone`(**只有给了数字 `delta` 才出现**:规格 §6.6 的
+ *     「较昨日 +6 / -3 / 0」与正红负绿;没给差值就没有这一行,不画假的 0)
+ */
 export function kpiBlock(items, options = {}) {
     return {
         kind: 'kpi',
+        // 版式(2026-10-23 视觉对齐):缺省=「图标+标签 → 读数 → 说明」;
+        // `value-first` =「读数 → 标签」(感知设备顶部四卡,设计稿里读数在上、标签在下,
+        // 且每卡顶部一条语义色横条)。版式是**通用呈现意图**,渲染层不认识任何指标键。
         layout: options.layout || '',
-        items: items.map((item) => ({
-            ...item,
-            icon: KPI_ICONS[item.key] || '',
-            iconColor: KPI_ICON_COLORS[item.key] || '',
+        aside: options.aside || '',
+        items: items.map((item) => {
+            // value-first 版式(读数在上、标签在下)的卡片按设计稿不画图标:
+            // 版式说了算,不靠逐项传 icon 空串来关掉(那是把呈现意图散到每个调用点)。
+            const iconsOff = options.layout === 'value-first'
+            const enriched = {
+                ...item,
+                icon: iconsOff ? '' : KPI_ICONS[item.key] || '',
+                iconColor: iconsOff ? '' : KPI_ICON_COLORS[item.key] || '',
+                // 读数色 / 卡顶横条 / 卡内说明:都由 domain 给语义,渲染层只查色表。
+                valueTone: item.valueTone || '',
+                accent: item.accent || '',
+                caption: item.caption || '',
+                detail: item.detail || '',
+            }
+            // 差值只在 domain 明确给了数字时才落地(`numberish` 认不出 → 不出现)。
+            const delta = numberish(item.delta)
+            if (delta !== null) {
+                enriched.delta = delta
+                enriched.deltaText = item.deltaText || deltaText(delta)
+                enriched.deltaTone = item.deltaTone || deltaTone(delta)
+            }
+            return enriched
+        }),
+    }
+}
+
+/**
+ * 卡片块:**一组并列的计数卡**(票 02 的三种卡共用这一个构造器)。
+ *
+ * 三种卡(规格 §6.2/§6.4/§6.5)画的其实是同一件事 —— 一排"标签 + 计数"的小卡,
+ * 差异只在语义:类型卡带一句小字(`note`,如「压力 / 流量 / 漏失」)、
+ * 片区卡与等级卡是**固定项**(后端不给也是 0 —— 0 是真实计数,不是缺失)。
+ * 所以只有这一个构造器(不按用途各写一份),由调用方声明 `fixed` 与 `note`:
+ *   · `fixed: true` —— 固定项集合,无数据画 0(等级卡/片区卡/类型卡都是);
+ *   · `note` —— 卡内小字(类型卡的监测内容;其它卡留空)。
+ *
+ * `count` 一律是**数字**:卡片位展示的就是计数,认不出的按 0 落(与「固定项无数据 0」
+ * 同一口径);需要"缺值画占位符"的读数请走 `kpiBlock`(那条纪律一字不改)。
+ * 卡片**不可点**(规格 §6 没有点卡动作),所以不带 `actionKey` —— 可点排行走 `barsBlock`。
+ *
+ * 版式(2026-10-23 视觉对齐):`layout` 是**列数意图**——`two-by-two`(类型卡 2×2)、
+ * `three`(片区卡一排三张)、`four`(等级卡一排四张);缺省按容器宽度自适应。
+ * `tone` 是行语义色(等级卡 lv1..lv4、类型卡行业色),`icon` 是卡内徽标(行业图标);
+ * `action`/行 `actionKey` 让卡片可点(类型卡 → 写全局行业筛选,与条形图同一条接缝)。
+ *
+ * @param {string} label 块标题
+ * @param {Array<{key?: string, label: string, count?: number, note?: string, tone?: string, icon?: string, actionKey?: string}>} rows
+ * @param {{fixed?: boolean, unit?: string, layout?: string, aside?: string, action?: string}} [options]
+ */
+export function cardsBlock(label, rows, options = {}) {
+    const list = Array.isArray(rows) ? rows : []
+    const action = options.action || ''
+    return {
+        kind: 'cards',
+        label,
+        aside: options.aside || '',
+        // 版式(通用呈现意图,渲染层不认任何指标键)。
+        layout: options.layout || '',
+        // 固定项声明:界面据此画满整排(无数据 0),不因某个类别缺席就少一张卡。
+        fixed: options.fixed === true,
+        unit: options.unit || '',
+        action,
+        rows: list.map((row) => ({
+            key: row.key ?? row.code ?? row.label,
+            label: row.label,
+            count: numberish(row.count) ?? 0,
+            // 卡内小字(类型卡的 monitoringContent);没有就不画那一行。
+            note: row.note || '',
+            // 小字可能被压缩过(如「压力 / 瞬时流量 等」),完整原文放这里给 title 悬停用。
+            noteTitle: row.noteTitle || '',
+            // 行语义色:染卡边框与徽标(类型卡的行业色)。
+            tone: row.tone || '',
+            // 读数色:只染数字(等级卡的 lv1..lv4)。与 tone 分开——类型卡是「边框有色、数字白」,
+            // 等级卡是「边框与数字同色」,一个字段表达不了两种。
+            valueTone: row.valueTone || '',
+            icon: row.icon || '',
+            // 可点行才给 actionKey(与 barsBlock 同一条纪律:不拿展示名兜底当筛选码)。
+            actionKey: action ? row.actionKey || '' : '',
+            // 当前生效的筛选行(画选中态,用户据此知道"再点一下就取消"):只有 domain 明说选中才亮。
+            selected: row.selected === true,
         })),
     }
 }
@@ -372,6 +502,11 @@ export function barsBlock(label, rows, options = {}) {
     return {
         kind: 'bars',
         label,
+        aside: options.aside || '',
+        // 版式:缺省横条一行一条;`rank` =名次徽章 + 名称 + 计数一行、条在下一行通栏
+        // (设计稿的「预警类型排行」形态)。渲染层只按 variant 选画法。
+        variant: options.variant || '',
+        unit: options.unit || '',
         action,
         rows: list.map((row, index) => ({
             key: row.code ?? row.label,
@@ -388,6 +523,34 @@ export function barsBlock(label, rows, options = {}) {
     }
 }
 
+/**
+ * 排行块(票 02 · 「近30日 · 共XX起 · 点击筛选」的类型榜)。
+ *
+ * 它**就是 `barsBlock` 的一个限额视图**,不重写画法:排序由后端给(规格 §4 已按计数降序),
+ * 本层只截前 N 项 —— 不重排、不补足(不足 3 项就画不足,不拿别的类型凑满)。
+ * 可点沿用同一条接缝(`action` + 行上的 `actionKey`),渲染层只回答"哪一行被点了"。
+ *
+ * @param {string} label 块标题(构造标题文案由视图层拼,块只拿最终标题)
+ * @param {Array<{code?: string, label: string, count: number}>} rows 后端已排序的行
+ * @param {{limit?: number, action?: string}} [options] 取前几项(缺省 3;0 = 全部)与整块动作
+ */
+export function rankingBarsBlock(label, rows, options = {}) {
+    // limit 语义:正数 = 只要前 N 名;**0 = 全部**(维护者 2026-10-23 第 4 条:
+    // 「预警类型排行」要列出所有类型,不再只给前三)。缺省仍为 3,其他调用点行为不变。
+    const all = Array.isArray(rows) ? rows : []
+    const limit = options.limit === 0 ? all.length : Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 3
+    const list = all.slice(0, limit)
+    return {
+        ...barsBlock(label, list, {
+            action: options.action || '',
+            aside: options.aside || '',
+            variant: options.variant || '',
+            unit: options.unit || '',
+        }),
+        limit,
+    }
+}
+
 /**
  * 环形图块(2026-10-13 维护者反馈:横向条形图太多、视觉疲劳且不直观——
  * 「类别占比、类别少」的场景改用环形图,中心读数给合计)。
@@ -432,6 +595,10 @@ export function donutBlock(label, rows, options = {}) {
     return {
         kind: 'donut',
         label,
+        aside: options.aside || '',
+        // 版式:缺省=环形在左、图例在右;`stack-below` =环形居中、图例在下横排
+        // (设计稿的「管道分类占比」形态,且分区标题带「· 共 N 根」)。
+        layout: options.layout || '',
         total,
         action: options.action || '',
         slices,
@@ -601,6 +768,8 @@ export function trendBlock(label, points, options = {}) {
                   label: row.label,
                   count: row.count || 0,
                   displayText: row.displayText || '',
+                  // 悬停读完整原文:X 轴要短(如 MM-DD)、tooltip 要全(如完整日期)。
+                  hint: row.hint || '',
                   annotate: n <= 8 || i === peakIndex,
                   x: xPct(i),
                   y: 100 - yPct(row.count),
@@ -611,6 +780,9 @@ export function trendBlock(label, points, options = {}) {
     return {
         kind: 'trend',
         label,
+        aside: options.aside || '',
+        // 折线语义色键(设计稿:报警折线为橙、现有多处折线为主蓝)。渲染层查同一张色表。
+        tone: options.tone || '',
         max,
         chart,
         points: rows.map((row) => ({
@@ -640,6 +812,61 @@ export function trendBlock(label, points, options = {}) {
         // X 轴标签抽样步长:最多显示 ~6 个(首尾必显),避免小时粒度挤成一团。
         axisIndices: Array.from(new Set(Array.from({ length: Math.min(4, n) }, (_, i) => Math.round(i * (n - 1) / Math.max(1, Math.min(4, n) - 1))))),
         axisEvery: chart === 'line' && n >= 2 ? Math.ceil(n / 4) : 1,
+        // 多序列(维护者 2026-10-23):同一张折线图上并列多条线(如"当天新增"与"总未处理"),
+        // 各自按**共同最大值**归一,否则两条线各按自己的尺度画、看不出谁高谁低。
+        // 每条序列自带 tone(色键)→ 渲染层查同一张色表并画图例。
+        ...multiSeries(rows, options, { chart, n, xPct, yPct }),
+    }
+}
+
+/**
+ * 多序列折线(维护者 2026-10-23 反馈第 2 条:不要单条线 + 口径小字,要多条线对比 + 图例)。
+ *
+ * 入参约定:调用方给 `points`(与单序列同形:key/label/count/hint),并给
+ * `options.series = [{ key, label, tone, counts: number[] }]` —— 每条序列只给"取值列表",
+ * x 轴标签仍共用 `points`。这样调用点不用把同一份日期抄三遍。
+ *
+ * 归一化用**所有序列的联合最大值**:各画各的尺度会让两条线看起来一样高,
+ * 那正是"对比"要避免的假象。
+ *
+ * 只画线 + 图例,不画面积与点顶数值 —— 多条线叠面积会糊成一片、点顶标注会互相压。
+ * 单序列(没给 series)时仍走原来的单线版式(面积 + 点顶数值),行为不变。
+ */
+function multiSeries(rows, options, { chart, n, xPct, yPct }) {
+    // 空数组也算"没有这条线":后端没给该序列时调用方回 `[]`,若放行就会画出一条全 0 的假线 ——
+    // 那是在用 0 冒充"积压为零",与本仓"不把缺失画成 0"的纪律相反。
+    const declared = Array.isArray(options.series)
+        ? options.series.filter((s) => s && s.key && Array.isArray(s.counts) && s.counts.length > 0)
+        : []
+    if (!declared.length || chart !== 'line' || n < 2) {
+        return { series: [], multiSeries: false, seriesMax: 0 }
+    }
+    const seriesMax = declared.reduce(
+        (top, series) => series.counts.reduce((m, value) => Math.max(m, Number(value) || 0), top),
+        0,
+    )
+    const yOf = (count) => (seriesMax > 0 ? ((Number(count) || 0) / seriesMax) * 100 : 0)
+    return {
+        multiSeries: true,
+        seriesMax,
+        series: declared.map((series) => ({
+            key: series.key,
+            label: series.label,
+            tone: series.tone || '',
+            // 每条线自己的点列(x 与标签共用主轴,y 按联合最大值归一)。
+            points: series.counts.map((count, index) => ({
+                key: rows[index] ? rows[index].key : String(index),
+                label: rows[index] ? rows[index].label : '',
+                hint: rows[index] ? rows[index].hint || '' : '',
+                count: Number(count) || 0,
+                x: xPct(index),
+                y: 100 - yOf(count),
+                xPct: xPct(index),
+                yPct: yOf(count),
+            })),
+        })),
+        // 多序列时 Y 轴刻度按联合最大值给(单序列的 yTicks 仍按自己的 max)。
+        yTicks: (seriesMax > 0 ? [0, 0.5, 1] : [0]).map((f) => ({ value: seriesMax * f, pct: f * 100 })),
     }
 }
 

+ 62 - 11
src/modules/cockpit/domain/panelCatalog.js

@@ -16,12 +16,18 @@ export const MODE_LABELS = {
     warningResponse: '预警处置',
 }
 
-/** 二级面板展示名,按 panelId 解析;业务逻辑不读中文。 */
+/**
+ * 二级面板展示名,按 panelId 解析;业务逻辑不读中文。
+ *
+ * 2026-10-22(overview 四模块重做,票 02):overview 的四块换成「感知设备 / 基础设施 /
+ * 运行监测-预警研判 / 运行监测-报警处置」,旧四键(态势总览/基础设施/运行监测/预警设置)
+ * 已从目录移除 —— 旧端点投影不再是本模式的界面口径(见 specs/2026-10-22-overview-four-modules-contract.md)。
+ */
 export const PANEL_LABELS = {
-    situationSummary: '态势总览',
-    infrastructureSummary: '基础设施',
-    monitoringSummary: '运行监测',
-    alertSettings: '预警设置',
+    perceptionDevices: '感知设备',
+    pipeNetwork: '基础设施',
+    warningStudy: '运行监测-预警研判',
+    alarmDisposal: '运行监测-报警处置',
     archive: '基础档案',
     operation: '运行信息',
     management: '管理信息',
@@ -36,14 +42,52 @@ export const PANEL_LABELS = {
     eventAnalysis: '预警分析',
 }
 
+/**
+ * 面板在所在栏里的**高度权重**(2026-10-23 维护者:面板高度按内容自适应)。
+ *
+ * 用途:栏内两块面板不再硬等分高度,而按「这块大概有多少内容」分配 —— 感知设备有三段
+ * (4 指标卡 + 4 类型卡 + 3 片区卡)明显比基础设施(2 指标卡 + 环形图)密,二者 1:1 等分时
+ * 前者要靠滚动、后者留大片空白。权重是**相对值**,不是尺寸:换屏幕高度不用重算。
+ *
+ * 为什么放目录而不是各面板自己声明:它是"这块面板在本模式里的版位",属于模式目录的职责
+ * (与左右栏顺序、标签同层);渲染层仍不认识它,外壳只查表透传。
+ */
+export const PANEL_GROW = {
+    // 总体概览:左栏(三段 : 两段)、右栏(两指标+四卡+三行排行 ≈ 两行三卡+四卡+折线)
+    perceptionDevices: 1.15,
+    pipeNetwork: 0.85,
+    warningStudy: 1,
+    alarmDisposal: 1.1,
+}
+
+/** 面板高度权重;未登记的面板回 1(等分),不抛错。 */
+export function panelGrow(panelId) {
+    return PANEL_GROW[panelId] || 1
+}
+
+/**
+ * 面板标题右侧的**固定小字**(规格 §6.1,严格照抄、不增删)。
+ *
+ * 与「分区状态」分工:小字是面板身份(这块面板讲什么),状态词由
+ * `SectionStateTag` 说(就绪/无数据/过期/失败)。两者不互相顶替,
+ * 所以这里只放身份文案 —— 不把「正在取数」「暂无数据」混进来。
+ */
+export const PANEL_SUBTITLES = {
+    perceptionDevices: '设备规模·类型·片区分布',
+    pipeNetwork: '管网底数·分类占比',
+    warningStudy: '研判分析',
+    alarmDisposal: '工单转化',
+}
+
 /**
  * 模式 → 左右两栏面板,顺序固定(ADR 0003)。
  * panelId 扁平且全局唯一,不带模式前缀。
  */
 const COLUMNS = {
+    // 2026-10-22(票 02):左=感知设备、基础设施;右=运行监测-预警研判、运行监测-报警处置。
     overview: {
-        left: ['situationSummary', 'infrastructureSummary'],
-        right: ['monitoringSummary', 'alertSettings'],
+        left: ['perceptionDevices', 'pipeNetwork'],
+        right: ['warningStudy', 'alarmDisposal'],
     },
     infrastructure: {
         left: ['archive', 'operation'],
@@ -70,10 +114,12 @@ const COLUMNS = {
  * 未列出的 panelId 返回空串 → 渲染层不画图标,不抛错(与 `iconFor` 的未知名行为一致)。
  */
 export const PANEL_ICONS = {
-    situationSummary: 'panelSituation',
-    infrastructureSummary: 'panelSituation',
-    monitoringSummary: 'panelMonitoring',
-    alertSettings: 'panelAlert',
+    // overview 四块(票 02):图标名仍是 `domain/icons.js` 的既有语义名,
+    // 不为新面板另造切图 —— 设备看监测、管网看态势、研判看分析、处置看报警。
+    perceptionDevices: 'panelMonitoring',
+    pipeNetwork: 'panelSituation',
+    warningStudy: 'panelAnalysis',
+    alarmDisposal: 'panelAlert',
     archive: 'panelArchive',
     operation: 'panelMonitoring',
     management: 'panelArchive',
@@ -109,6 +155,11 @@ export function panelIcon(panelId) {
     return PANEL_ICONS[panelId] || ''
 }
 
+/** 面板标题右侧小字;没有固定小字的面板返回空串(外壳回落到数据状态提要)。 */
+export function panelSubtitle(panelId) {
+    return PANEL_SUBTITLES[panelId] || ''
+}
+
 /** 返回模式的左右两栏面板;未知模式直接报错,避免静默渲染空壳。 */
 export function columnsForMode(mode) {
     const columns = COLUMNS[mode]

+ 305 - 15
src/modules/cockpit/mocks/contractMocks.js

@@ -38,12 +38,26 @@ const OVERLAP_DEVICE_LAYER_COORDS = [
 ]
 
 /**
- * 票 45:**K>10 降级样例** —— 11 台设备同坐标(超过 `map.overlap.maxFan` 默认 10),
- * 状态混合(3 ALARM / 3 WARNING / 5 NORMAL),验证「本屏显示 10 台 / 其余见清单」降级形态。
- * 地图快照 `deviceCodes` 与详情 `devices[]` 必须一致(否则会误判「与当前快照不一致」)。
+ * 票 76(2026-10-23 维护者裁定「删过期 mock」):旧的 `EQ-DG-*` K>10 样例已删除。
+ * 它经多轮修改后快照/详情/坐标三处各说各话(走查反复出现「太常跳到城南」「11 台不一致」),
+ * 聚合测试统一改用下面的 `aggregate-lab`(`EQ-AG-*`,三处同源)。
  */
-const OVERLAP_DEGRADED_CODES = Array.from({ length: 11 }, (_, index) => `EQ-DG-${String(index + 1).padStart(3, '0')}`)
-const OVERLAP_DEGRADED_COORDS = Array.from({ length: 11 }, () => ({ longitude: 110.3989, latitude: 28.4349 }))
+/**
+ * 票 76(mock 串点修正,2026-10-23 走查):MP-002「太常」的 K=11 样例改用**独立编码**。
+ * 此前它与专用场景点位(MP-001 城南)共用 `EQ-DG-*` —— 同一台设备同时挂在两个点位下,
+ * 点设备按编码回联时命中另一点位,走查表现为「太常的设备跳到城南聚合」。
+ */
+const OVERLAP_TAICHANG_CODES = Array.from({ length: 11 }, (_, index) => `EQ-TC-${String(index + 1).padStart(3, '0')}`)
+/**
+ * 票 76(聚合测试数据重建,维护者 2026-10-23 要求):**干净的聚合实验样例**。
+ *
+ * 旧样例(`EQ-DG-*`)经多轮修改已不可靠(快照/详情/坐标三处各说各话)。这份新样例只做一件事:
+ * **4 台设备同坐标**(K=4 > 1 且 ≤ maxFan=10,不进降级),且
+ *   快照 `deviceCodes`、详情 `devices[]`、设备坐标 **三处同源**。
+ * 走查入口:`?scenario=aggregate-lab` → 点开 **MP-001(城南测试监测点)**。
+ */
+const AGGREGATE_LAB_CODES = Array.from({ length: 4 }, (_, index) => `EQ-AG-${String(index + 1).padStart(3, '0')}`)
+const AGGREGATE_LAB_COORDS = Array.from({ length: 4 }, () => ({ longitude: 110.3989, latitude: 28.4349 }))
 
 /**
  * 票 47:**设备状态动效样例** —— 五态各一台(坐标各自错开,不触发重叠聚合),
@@ -100,7 +114,8 @@ const MAP_POINTS = [
         pointStatus: 'ALARM',
         statusReasons: ['EQ-ASSOC-001 报警'],
         // 维护者要求的主场景示例之一:**K=11 降级**(超过 `map.overlap.maxFan` 默认 10)。
-        deviceCodes: OVERLAP_DEGRADED_CODES.slice(),
+        // 票 76:用太常专属编码(不再与城南场景点共用 `EQ-DG-*`,避免同一设备挂两个点位)。
+        deviceCodes: OVERLAP_TAICHANG_CODES.slice(),
         industry: 'GAS',
         district: 2, districtName: '太常',
         longitude: 110.36299369455823,
@@ -554,12 +569,15 @@ function mapSnapshotPayload(scenario, path, overrides = {}) {
     const deviceLayerCodes = {
         'device-layer': DEVICE_LAYER_CODES,
         'device-layer-overlap': DEVICE_LAYER_CODES,
-        'device-layer-overlap-degraded': OVERLAP_DEGRADED_CODES,
+        // 票 76:全新的干净聚合样例(4 台同坐标,快照/详情/坐标同源)。
+        'aggregate-lab': AGGREGATE_LAB_CODES,
         // 票 47:动效样例与降级样例的设备编码(与详情一一对应)。
         'device-effects': EFFECTS_SAMPLE.map((item) => item.deviceCode),
         'device-effects-degraded': EFFECTS_DEGRADED_SAMPLE.map((item) => item.deviceCode),
     }[scenario]
     if (deviceLayerCodes) {
+        // 票 76(2026-10-23 修正):不再清空其它点位的编码 —— 详情已按 `requestedCode` 回各自原生载荷
+        // (`nativePointDetail`),清空反而让「快照 0 台 / 详情 11 台」再次不一致、设备点不上图。
         const points = MAP_POINTS.map((item) =>
             item.pointCode === DEVICE_LAYER_POINT
                 ? { ...item, deviceCodes: deviceLayerCodes.slice() }
@@ -618,11 +636,11 @@ function mapFilterEcho(filters, points, equipment) {
 /**
  * 票 45:**K>10 降级载荷**(11 台设备同坐标、状态混合)。
  *
- * 主场景(MP-002)与专用场景 `device-layer-overlap-degraded` **共用这一份** ——
+ * 主场景(MP-002 太常)与 `aggregate-lab`(MP-001 城南)共用这一份构造器 ——
  * 两处各写一份必然漂移(本仓反复栽在"同一份样例登记两处"上)。
  */
-function degradedOverlapPointDetail({ code, name, coords = OVERLAP_DEGRADED_COORDS, statuses = ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL'] }) {
-    const devices = OVERLAP_DEGRADED_CODES.map((code_, index) => {
+function degradedOverlapPointDetail({ code, name, codes = [], coords = [], statuses = ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL'] }) {
+    const devices = codes.map((code_, index) => {
         const deviceStatus = statuses[index]
         return {
             deviceCode: code_,
@@ -643,7 +661,7 @@ function degradedOverlapPointDetail({ code, name, coords = OVERLAP_DEGRADED_COOR
         monitoringPointName: name,
         pointStatus: 'ALARM',
         statusLabel: '报警',
-        statusReasons: [{ status: 'ALARM', deviceCode: OVERLAP_DEGRADED_CODES[0], reason: '压力超高' }],
+        statusReasons: [{ status: 'ALARM', deviceCode: codes[0], reason: '压力超高' }],
         deviceStatistics: {
             totalDeviceCount: devices.length,
             alarmDeviceCount: statuses.filter((status) => status === 'ALARM').length,
@@ -719,7 +737,36 @@ function effectsPointDetail({ code, name, samples, pointStatus = 'ALARM' }) {
     }
 }
 
+/**
+ * 票 76(mock 串点修正,2026-10-23 走查):任意点位的「原生详情」。
+ *
+ * 场景样例只覆盖 `DEVICE_LAYER_POINT`;请求其它点位时必须回**它自己的**载荷 ——
+ * `deviceLayerStore` 只接受 `data.code === 选中编码` 的卡片(`deviceLayerStore.js:184`),
+ * 回错点位的载荷会让该点位的设备全部「详情待确认 / 与快照不一致 → 不上图」,
+ * 走查表现为「点 MP-002 面板不出、关联设备不出现、要点几次或刷新地图才出来」。
+ */
+function nativePointDetail(requestedCode) {
+    if (requestedCode === 'MP-001') {
+        return overlapPointDetail({ code: 'MP-001', name: '城南测试监测点' })
+    }
+    if (requestedCode === 'MP-002') {
+        // 太常样例:K=11 同坐标,编码用自己的 `EQ-TC-*`(与场景点不再共用)。
+        return degradedOverlapPointDetail({
+            code: 'MP-002', name: '太常测试监测点',
+            codes: OVERLAP_TAICHANG_CODES,
+            coords: OVERLAP_TAICHANG_CODES.map(() => ({ longitude: 110.3632, latitude: 28.4524 })),
+            statuses: ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'WORK_ORDER', 'NORMAL', 'NORMAL', 'OFFLINE', 'NORMAL'],
+        })
+    }
+    return pointDetail()
+}
+
 function pointDetailPayload(scenario, requestedCode = '') {
+    // 票 76:下列场景样例都只覆盖 DEVICE_LAYER_POINT;请求别的点位 → 回它自己的原生详情。
+    const scenarioSamples = ['device-layer', 'device-layer-overlap', 'device-effects', 'device-effects-degraded', 'aggregate-lab']
+    if (scenarioSamples.includes(scenario) && requestedCode && requestedCode !== DEVICE_LAYER_POINT) {
+        return nativePointDetail(requestedCode)
+    }
     if (scenario === 'empty') {
         return {
             ...pointDetail({ code: 'MP-EMPTY', name: '预留监测点', status: 'EMPTY' }),
@@ -760,8 +807,19 @@ function pointDetailPayload(scenario, requestedCode = '') {
      * 设备编码与地图快照 `deviceCodes` 完全一致,全部坐标准入有效 ⇒ K=11 > maxFan(10) ⇒ 降级形态
      * 「K 台设备(本屏显示 10 台 / 其余见清单)」。聚合符号显示最高优先级状态(ALARM)。
      */
-    if (scenario === 'device-layer-overlap-degraded') {
-        return degradedOverlapPointDetail({ code: DEVICE_LAYER_POINT, name: '城南测试监测点' })
+    /**
+     * 票 76:**干净聚合实验样例**(维护者要求重建测试数据)——
+     * 4 台设备同坐标、编码与快照 `deviceCodes` 一致、坐标同源;不降级(K=4 ≤ maxFan)。
+     * 这是验证「聚合符号 / 展开 fan / 选中强调」的**唯一入口**(旧 `EQ-DG-*` 样例已按维护者裁定删除)。
+     */
+    if (scenario === 'aggregate-lab') {
+        return degradedOverlapPointDetail({
+            code: DEVICE_LAYER_POINT,
+            name: '城南测试监测点',
+            codes: AGGREGATE_LAB_CODES,
+            coords: AGGREGATE_LAB_COORDS,
+            statuses: ['ALARM', 'WARNING', 'NORMAL', 'NORMAL'],
+        })
     }
 
     /**
@@ -807,8 +865,10 @@ function pointDetailPayload(scenario, requestedCode = '') {
             // 主场景的 MP-002 是 ALARM 点位(示例用于展示降级聚合的最高优先级状态)。
             return degradedOverlapPointDetail({
                 code: 'MP-002', name: '太常测试监测点',
+                // 票 76(mock 串点修正):太常样例用**自己的编码**,不再与城南场景点共用 `EQ-DG-*`。
+                codes: OVERLAP_TAICHANG_CODES,
                 // 主场景仍是 K=11 同坐标样例,位置在太常点附近;五态沿用已验收的动效词表。
-                coords: OVERLAP_DEGRADED_CODES.map(() => ({ longitude: 110.3632, latitude: 28.4524 })),
+                coords: OVERLAP_TAICHANG_CODES.map(() => ({ longitude: 110.3632, latitude: 28.4524 })),
                 statuses: ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'WORK_ORDER', 'NORMAL', 'NORMAL', 'OFFLINE', 'NORMAL'],
             })
         }
@@ -1572,6 +1632,11 @@ function multidimensionalAnalysisPayload(scenario, query) {
         range: warningRange(query), updatedAt: SNAPSHOT_AT,
         dimensions: [
             { dimensionCode: 'EVENT_TYPE', buckets: count('eventType').map((row) => ({ bucketCode: row.code, bucketLabel: row.name, count: row.count })) },
+            // 票 66:真实后端**恒定**回 CAUSE 维(缺成因走 `UNKNOWN_CAUSE` 兜底桶,服务端注释原句
+            // "The unknown bucket is always present so the cockpit never hides missing causes")。
+            // 样例照抄这一维:不抄的话「按成因」块在 mock 下永远不出现,样例比契约少一维是**形状**问题,
+            // 不是为了「演示某项行为」补数据(见 map Notes 的静态样例裁定)。
+            { dimensionCode: 'CAUSE', buckets: [{ bucketCode: 'UNKNOWN_CAUSE', bucketLabel: '未知/缺失成因', count: source.items.length }] },
             { dimensionCode: 'LEVEL', buckets: levels.map((row) => ({ bucketCode: String(row.level), bucketLabel: `${row.level}级`, count: row.count })) },
             { dimensionCode: 'INDUSTRY', buckets: count('industry').map((row) => ({ bucketCode: row.code, bucketLabel: { GAS: '燃气', WATER_SUPPLY: '供水', DRAINAGE: '排水', MANHOLE: '窨井' }[row.code], count: row.count })) },
             { dimensionCode: 'DISTRICT', buckets: [{ bucketCode: 'UNASSIGNED', bucketLabel: '未分区', count: source.items.length }] },
@@ -2437,11 +2502,231 @@ function overviewEnvelope(updatedAt) {
     }
 }
 
+
+// ================================================================ 票 05–08:总体概览四模块端点
+
+/**
+ * 四模块信封(规格 §0:`requestId/apiVersion/readOnly/timezone/updatedAt/freshness`)。
+ *
+ * 与旧 `overviewEnvelope` 的区别:**空态要能带固定行**。旧 helper 用 `updatedAt` 有无
+ * 推 `EMPTY`,而四模块的空态恰恰是「freshness=EMPTY + 仍回 4 类型/3 片区/4 等级/7 天」——
+ * 前端靠它画「无数据 0」(规格 §6.7 的例外)。所以这里显式给 `status/reason`。
+ */
+function overviewModulesEnvelope(status, updatedAt = SNAPSHOT_AT, reason = null) {
+    return {
+        requestId: 'mock-overview-modules',
+        apiVersion: 'v1',
+        readOnly: true,
+        timezone: 'Asia/Shanghai',
+        updatedAt: updatedAt || null,
+        freshness: { status, reason, updatedAt: updatedAt || null, serverUpdatedAt: SNAPSHOT_AT },
+    }
+}
+
+/**
+ * `/overview/perception`(票 05;规格 §2)。
+ *
+ * success 取**后端实测真值**(10 台 / 在线 3 / 离线 7 / 在线率 0.3000;类型 3·5·0·1;
+ * 片区 6·3·1;excluded.unknownTypeCount = 1 是那台悬空类型设备,如实保留)。
+ * empty **仍回 4 类型 + 3 片区行(全 0)**:固定卡必须画 0,不是"无数据"。
+ */
+function overviewPerceptionPayload(scenario) {
+    const types = [
+        { code: '1', name: '供水设备', deviceCount: 3, onlineCount: 1, offlineCount: 2, monitoringContent: '压力 / 流量 / 漏失', monitoringContentSource: 'DERIVED' },
+        { code: '2', name: '排水设备', deviceCount: 5, onlineCount: 2, offlineCount: 3, monitoringContent: '液位 / 流量 / 内涝', monitoringContentSource: 'DERIVED' },
+        { code: '3', name: '燃气设备', deviceCount: 0, onlineCount: 0, offlineCount: 0, monitoringContent: '可燃气体 / 压力', monitoringContentSource: 'FALLBACK' },
+        { code: '4', name: '窨井设备', deviceCount: 1, onlineCount: 0, offlineCount: 1, monitoringContent: '井盖状态 / 液位', monitoringContentSource: 'FALLBACK' },
+    ]
+    const districts = [
+        { district: 0, name: '城南', deviceCount: 6 },
+        { district: 1, name: '城北', deviceCount: 3 },
+        { district: 2, name: '太常', deviceCount: 1 },
+    ]
+    if (scenario === 'empty') {
+        return {
+            ...overviewModulesEnvelope('EMPTY', null, 'NO_DEVICES'),
+            totals: { deviceCount: 0, onlineCount: 0, offlineCount: 0, noStatusCount: 0, onlineRate: { value: '0', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_DEVICES' } },
+            byType: types.map((row) => ({ ...row, deviceCount: 0, onlineCount: 0, offlineCount: 0 })),
+            byDistrict: districts.map((row) => ({ ...row, deviceCount: 0 })),
+            excluded: { unknownTypeCount: 0, unassignedDistrictCount: 0 },
+        }
+    }
+    if (scenario === 'degraded') {
+        // 缺监测内容小字与 excluded 段:卡片仍画(计数在),小字不画;界面不伪造小字。
+        return {
+            ...overviewModulesEnvelope('CURRENT'),
+            totals: { deviceCount: 10, onlineCount: 3, offlineCount: 7, noStatusCount: 0, onlineRate: { value: '0.3000', numerator: 3, denominator: 10, state: 'AVAILABLE', reason: null } },
+            byType: types.map(({ monitoringContent, monitoringContentSource, ...row }) => row),
+            byDistrict: districts,
+        }
+    }
+    return {
+        ...overviewModulesEnvelope('CURRENT'),
+        totals: { deviceCount: 10, onlineCount: 3, offlineCount: 7, noStatusCount: 0, onlineRate: { value: '0.3000', numerator: 3, denominator: 10, state: 'AVAILABLE', reason: null } },
+        byType: types,
+        byDistrict: districts,
+        excluded: { unknownTypeCount: 1, unassignedDistrictCount: 0 },
+    }
+}
+
+/**
+ * `/overview/pipe-network`(票 06;规格 §3)。success 用实测真值(6762 条 / 78.55 km)。
+ * empty 回 `pipeCount=0` 且固定 3 行 0 —— 面板按空态画占位,**不画假环**。
+ */
+function overviewPipeNetworkPayload(scenario) {
+    const types = [
+        { code: 'WATER_PIPE', name: '供水管道', count: 2489, lengthKm: '19.10', ratio: '0.3681' },
+        { code: 'DRAINAGE_PIPE', name: '排水管道', count: 3574, lengthKm: '52.26', ratio: '0.5285' },
+        { code: 'GAS_PIPE', name: '燃气管道', count: 699, lengthKm: '7.18', ratio: '0.1034' },
+    ]
+    if (scenario === 'empty') {
+        return {
+            ...overviewModulesEnvelope('EMPTY', null, 'NO_PIPES'),
+            totals: { pipeCount: 0, lengthKm: '0.00' },
+            byPipeType: types.map((row) => ({ ...row, count: 0, lengthKm: '0.00', ratio: '0' })),
+            excluded: { unclassifiedCount: 0 },
+            state: 'EMPTY',
+            reason: 'NO_PIPES',
+        }
+    }
+    return {
+        ...overviewModulesEnvelope('CURRENT'),
+        totals: { pipeCount: 6762, lengthKm: '78.55' },
+        byPipeType: types,
+        excluded: { unclassifiedCount: 0 },
+        state: 'AVAILABLE',
+        reason: null,
+    }
+}
+
+/**
+ * `/overview/warning-study`(票 07;规格 §4)。
+ *
+ * success:今日 5 / 未解除 18(PENDING 11 / PROCESSING 5 / HANDLED 2),
+ * 分级 9·6·2·1(**和 = 18** —— 规格示例曾把全量 42 的分级抄进来,是错的)。
+ * 类型排行的 `code` 取本地事件族同值,使「点击筛选」在 mock 上真的命中;
+ * 真实后端返回 `WARN-*` 码,前端按精确 code 匹配,不匹配时如实提示覆盖范围(G11)。
+ */
+function overviewWarningStudyPayload(scenario) {
+    const levels = [
+        { level: 1, name: '一级预警', count: 9 },
+        { level: 2, name: '二级预警', count: 6 },
+        { level: 3, name: '三级预警', count: 2 },
+        { level: 4, name: '四级预警', count: 1 },
+    ]
+    if (scenario === 'empty') {
+        return {
+            ...overviewModulesEnvelope('EMPTY', null, 'NO_WARNINGS'),
+            today: { newCount: 0, yesterdayCount: 0, delta: 0 },
+            unresolved: { count: 0, statuses: { PENDING: 0, RECEIVED: 0, PROCESSING: 0, HANDLED: 0 } },
+            byLevel: levels.map((row) => ({ ...row, count: 0 })),
+            typeRanking: { rangeDays: 30, total: 0, top: [] },
+        }
+    }
+    if (scenario === 'degraded') {
+        // 缺排行段:等级卡照画,排行整块不画(不伪造榜单)。
+        return {
+            ...overviewModulesEnvelope('CURRENT'),
+            today: { newCount: 5, yesterdayCount: 0, delta: 5 },
+            unresolved: { count: 18, statuses: { PENDING: 11, RECEIVED: 0, PROCESSING: 5, HANDLED: 2 } },
+            byLevel: levels,
+        }
+    }
+    return {
+        ...overviewModulesEnvelope('CURRENT'),
+        today: { newCount: 5, yesterdayCount: 0, delta: 5 },
+        unresolved: { count: 18, statuses: { PENDING: 11, RECEIVED: 0, PROCESSING: 5, HANDLED: 2 } },
+        byLevel: levels,
+        // 排行**列出全部类型**且与 total 对得上(维护者 2026-10-26:页面写「共27起」而三行只凑出 23 起)。
+        // 下面 count 之和刻意等于 total(42) —— mock 也要自洽,否则走查看不出「对不上」这类问题。
+        typeRanking: {
+            rangeDays: 30,
+            total: 42,
+            top: [
+                { code: 'GAS_CONCENTRATION', name: '燃气浓度预警', count: 14 },
+                { code: 'WATER_PRESSURE', name: '供水压力预警', count: 9 },
+                { code: 'SEWAGE_CONCENTRATION', name: '污水 COD 预警', count: 6 },
+                { code: 'WATER_FLOW', name: '供水流量预警', count: 5 },
+                { code: 'MANHOLE_COVER', name: '井盖状态预警', count: 4 },
+                { code: 'SEWAGE_LEVEL', name: '排水水位预警', count: 4 },
+            ],
+        },
+    }
+}
+
+/**
+ * `/overview/alarm-disposal`(票 08;规格 §5)。success 用**受控样本**(后端实测:
+ * 已转 2 / 未转 0、处置率 1/2、平均响应 30.5h、平均处置 219.2h、等级 4 级 2 条)。
+ *
+ * **7 日折线全 0 是真实形态,不是样例偷懒**(维护者 2026-10-23 反馈第 2 条复核):
+ * 库里那 2 条报警的 create_time 是 2026-10-12/13,而窗口是「今天及前 6 天」;
+ * 实测全库 90 条报警只分布在 10-10 / 10-12 / 10-16 三天,窗口内三种口径(audit_status、
+ * alarm_status、不过滤)都是 0 条。所以「已转 2 + 四级 2」与「折线 0」是**同时成立**的:
+ * 上方读数是全量、折线是窗口内新增。面板用 `trendCaliberNote()` 把这层口径写明。
+ *
+ * empty:固定 4 等级 0 行 + 7 天全 0 点,比例/时长 EMPTY(界面显示占位符,不是 0%)。
+ */
+function overviewAlarmDisposalPayload(scenario) {
+    const levels = [
+        { level: 1, name: '一级报警', count: 0 },
+        { level: 2, name: '二级报警', count: 0 },
+        { level: 3, name: '三级报警', count: 0 },
+        { level: 4, name: '四级报警', count: 2 },
+    ]
+    const dates = ['2026-10-16', '2026-10-17', '2026-10-18', '2026-10-19', '2026-10-20', '2026-10-21', '2026-10-22']
+    // 每天两个读数(后端新字段):count = 当天新增;unhandledTotal = 截至当天未处理的累计。
+    // 样例取真实库形态:窗口内当天新增全 0(库里最新一条报警在 10-16),总未处理恒为 2。
+    // 不补 non-zero 的假新增 —— mock 要与真实数据同形,否则走查看不出「为什么折线贴底」。
+    const trend = (last = 0) =>
+        dates.map((date, index) => ({
+            date,
+            count: index === dates.length - 1 ? last : 0,
+            unhandledTotal: 2,
+        }))
+    if (scenario === 'empty') {
+        return {
+            ...overviewModulesEnvelope('EMPTY', null, 'NO_ALARMS'),
+            today: { newCount: 0, yesterdayCount: 0, delta: 0 },
+            workOrderConversion: { convertedCount: 0, unconvertedCount: 0, totalCount: 0 },
+            disposal: {
+                rate: { numerator: 0, denominator: 0, value: '0', state: 'EMPTY', reason: 'NO_ALARMS', basis: 'CLOSED_WORK_ORDERS_OVER_ALL_ALARMS' },
+                avgResponseDuration: { value: null, unit: 'HOURS', sampleCount: 0, invalidSampleCount: 0, state: 'EMPTY', reason: 'NO_RESPONSE_LOGS' },
+                avgDisposalDuration: { value: null, unit: 'HOURS', sampleCount: 0, invalidSampleCount: 0, state: 'EMPTY', reason: 'NO_DISPOSAL_LOGS' },
+            },
+            byLevel: levels.map((row) => ({ ...row, count: 0 })),
+            sevenDayTrend: trend(0),
+        }
+    }
+    if (scenario === 'degraded') {
+        // 缺两个时长段:按"无有效样本"显示占位符,不显示 0 小时。
+        return {
+            ...overviewModulesEnvelope('CURRENT'),
+            today: { newCount: 0, yesterdayCount: 0, delta: 0 },
+            workOrderConversion: { convertedCount: 2, unconvertedCount: 0, totalCount: 2 },
+            disposal: { rate: { numerator: 1, denominator: 2, value: '0.5000', state: 'AVAILABLE', reason: null, basis: 'CLOSED_WORK_ORDERS_OVER_ALL_ALARMS' } },
+            byLevel: levels,
+            sevenDayTrend: trend(0),
+        }
+    }
+    return {
+        ...overviewModulesEnvelope('CURRENT'),
+        today: { newCount: 0, yesterdayCount: 0, delta: 0 },
+        workOrderConversion: { convertedCount: 2, unconvertedCount: 0, totalCount: 2 },
+        disposal: {
+            rate: { numerator: 1, denominator: 2, value: '0.5000', state: 'AVAILABLE', reason: null, basis: 'CLOSED_WORK_ORDERS_OVER_ALL_ALARMS' },
+            avgResponseDuration: { value: '30.5', unit: 'HOURS', sampleCount: 1, invalidSampleCount: 0, state: 'AVAILABLE', reason: null },
+            avgDisposalDuration: { value: '219.2', unit: 'HOURS', sampleCount: 1, invalidSampleCount: 0, state: 'AVAILABLE', reason: null },
+        },
+        byLevel: levels,
+        sevenDayTrend: trend(0),
+    }
+}
+
 /**
  * `/overview/infrastructure`(票 20 验收第 2 条)。
  *
  * 注意**没有 `districtDistribution`** —— 这正是本端点的契约现状(区域分布走票 17 的
- * `base-archives`,见 `domain/overviewPanels.js` 文件头第 ② 条)。样例必须照实,
+ * `base-archives`)。样例必须照实,
  * 否则"区域分布不在本端点上"这条判断就失去了证据。
  */
 function overviewInfrastructurePayload(scenario) {
@@ -2767,6 +3052,11 @@ const PAYLOADS = {
     '/monitoring/distribution': (s, _requested, context) =>
         monitoringDistributionPayload(s, context.query, context.monitoring),
     '/monitoring/situation': (s, _requested, context) => monitoringSituationPayload(s, context.query),
+    // 票 05–08:总体概览四模块端点(四块各一个,互不牵连)。都是**无入参**端点。
+    '/overview/perception': (s) => overviewPerceptionPayload(s),
+    '/overview/pipe-network': (s) => overviewPipeNetworkPayload(s),
+    '/overview/warning-study': (s) => overviewWarningStudyPayload(s),
+    '/overview/alarm-disposal': (s) => overviewAlarmDisposalPayload(s),
     // 票 20:总体概览模式三块面板的面板级聚合读模型(各块独立成败)。
     // 三个 builder 都吃**请求查询串**:`/overview/monitoring` 的行业筛选与
     // `/overview/warning-settings` 的窗口必须真的按请求回,否则

+ 42 - 11
src/modules/cockpit/styles/tokens.css

@@ -17,10 +17,15 @@
  */
 
 .cockpit-shell {
-    /* 设计像素:1440(蓝湖主画板宽)时为 1px,随视口线性放大封顶 2px(2880 时一倍),
-       更窄不再缩小(下限 1px 保住 1280px 最小宽度的可读性承诺——规格 03「1280 优先文字可读」)。
-       原为 1920 基准(0.0521vw);按规格 03「--u 以蓝湖主画板宽度为设计基准」改为 1440。 */
-    --u: clamp(1px, 0.0694vw, 2px);
+    /* 设计像素 --u:**按视口高度**缩放,不按宽度(维护者 2026-10-23)。
+       原先写 `0.0694vw`(1440 宽基准):屏幕一拉宽,字号、顶栏、间距全部同比放大 ——
+       超宽屏上"文字和顶部菜单缩放太多",而驾驶舱的**信息密度是由高度承载的**,
+       多出来的宽度应该用来**多放内容/留白**,不该把同一屏内容整体吹大。
+       改为 0.1235vh:1080 高时 1.333(与改动前 1920×1080 完全一致,16:9 屏观感不变),
+       1440 高时 1.778,封顶 2px(2160 高即 4K),下限 1px 保住 1280×720 的可读性承诺。
+       效果:3840×1080 / 3440×1440 这类"只加宽不加高"的屏上,元素**不再放大**;
+       元素大小由占比(高度份额)决定,而不是被宽度推着长。 */
+    --u: clamp(1px, 0.1235vh, 2px);
 
     /* ---- 底色与面板 ----
        设计稿(9/9 画板一致):页面底 #010514;面板 = #02091b@0.28 底 + 蓝色九停渐变
@@ -44,8 +49,6 @@
     --panel-top: rgba(0, 28, 70, 0.82);
     --panel-mid: rgba(0, 28, 70, 0.4);
     --panel-bot: rgba(0, 28, 70, 0.8);
-    /* 数据统计指标卡片(.panel-kpi 等)的颜色渐变:亮科技蓝 → 深蓝,
-       与面板底色同族但色相可感知;卡片更小,alpha 比面板淡(不喧宾夺主)。 */
     /* 数据统计指标卡片(.panel-kpi 等)的颜色渐变:深蓝(顶)→ 亮科技蓝(底),
        与面板底色同族但色相可感知;卡片更小,alpha 比面板淡(不喧宾夺主)。
        2026-10-23 维护者:反向试一试(原为亮蓝→深蓝)。 */
@@ -72,11 +75,13 @@
     --lv3: #01abea;
     --lv4: #27d17a;
     --neutral: #5680b0;
-    /* 点位状态统计:连接中断、无设备、未知状态、关联缺失各用独立颜色。 */
+    /* 点位状态统计:连接中断、无设备、未知状态、关联缺失各用独立颜色。
+       票 45(维护者裁定):「无关联设备」是**附加状态**,色键改**绿色系黄绿**(与正常在线同族、
+       与 lv4 正绿区分),且只在设备没有业务异常时替代正常态;业务异常按业务状态色显示。 */
     --point-offline: #94a3b8;
     --point-empty: #c4d5e8;
     --point-unknown: #a970ff;
-    --point-unassociated: #ff8a48;
+    --point-unassociated: #a3e635;
 
     /* ---- 行业语义色(2026-10-13 维护者要求:UI 与统计图表用行业色区分类别)----
        色值取自批次 1 入库的四枚行业切图主色(probe-industry-colors.mjs 实测):
@@ -130,11 +135,19 @@
        --fs-kpi 为本批新增:面板 KPI 数值(设计稿 20px Rajdhani;消费点 PanelBlocks
        .panel-kpi__value)。设计稿另有大号指标 25px(主画板顶部状态区),现有面板结构无对应
        消费点,按规格 03「无消费点不提交」暂不设槽位(票 15 已登记)。 */
+    /* 指标类型名称(KPI label / 卡片名)—— 维护者 2026-10-23「保持现状」,不要动这一档。 */
     --fs-caption: calc(11.5 * var(--u));
     --fs-body: calc(14 * var(--u));
     --fs-title: calc(15.5 * var(--u));
     --fs-heading: calc(18 * var(--u));
-    --fs-kpi: calc(20 * var(--u));
+    /* 指标数值是页面上**最大**的一类字(维护者 2026-10-23 第 1 条),但必须装得进格子 ——
+       2026-10-26 浏览器走查实测:在线率「27.3%」是 5 个字符,26u 时超出所在格子约 14px。
+       取 22u:仍明显大于块标题(18u)与名称(11.5u),且 5 字符在分格内放得下。 */
+    --fs-kpi: calc(22 * var(--u));
+    /* 补充说明(口径小字、块右上角小字、卡片备注、面板底部说明)——
+       维护者 2026-10-23 第 3 条「字体大小缩小」,独立一档,不再和类型名称同号。
+       9.2u ≈ 名称的 0.8 倍:还在可读范围内,但明显退到第三层级。 */
+    --fs-note: calc(9.2 * var(--u));
 
     /* ---- 间距 ----
        设计稿兄弟垂直间距众数 8u(×26),次众数 5/12/15;现有三档语义保留。 */
@@ -144,8 +157,26 @@
 
     /* ---- 布局尺寸 ---- */
     /* 左右两栏的宽度(票 10 起两级详情浮层要贴着它让位,所以从组件样式提到这里只留一份)。
-       设计稿左右栏宽 347;2026-10-13 维护者反馈两侧面板稍宽、挤压中央一张图,收窄 5%(347→330)。 */
-    --column-w: clamp(280px, calc(330 * var(--u)), 460px);
+       设计稿左右栏宽 347;2026-10-13 维护者反馈两侧面板稍宽、挤压中央一张图,收窄 5%(347→330)。
+
+       **2026-10-23 修:上限改成与 --u 同比**(维护者反馈「宽屏下面板太窄导致内容变形」)。
+       原先是 `clamp(280px, 330u, 460px)` —— 上限是**死数 460px**,而 --u 会随视口放大到 2,
+       于是大屏上"设计像素宽度"被压到 460/2 = 230u(设计稿是 330u),面板相对缩水 30%,
+       卡内文字却按 --u 放大 —— 一边放大一边挤,就是"内容变形"的观感。
+       现在上限取 `330u × 1.4 = 462u`(与下限同一坐标系、随 u 走),只是把原来的像素上限
+       换算成了设计像素上限,**比例在各分辨率下恒定**。
+       另加"不超过视口 22%"的护栏:极宽屏(32:9)上两栏各取 330u 会挤掉中央一张图。 */
+    --column-w: clamp(280px, calc(330 * var(--u)), min(calc(462 * var(--u)), 22vw));
+
+    /* 让位宽度(票 11 三轮):中央浮层/事件详情要避开"两侧面板占掉多少"。
+       正常(16:9)时就是一条栏宽;超宽屏(>= 3:1)时栏内两块并排,栏变成两条栏宽。
+       消费点用这一条而不是直接乘 --column-w —— 否则宽屏下浮层会压在面板上。 */
+    --rail-w: var(--column-w);
+
+    /* 票 48:一张图**底部读数带**的高度(管线颜色图例贴带底,与两侧栏面板底边并排对齐)。
+       这条带上的东西不止一个:图例占 `bottom: 0`,定位反馈浮层(票 38)必须起算在这条带**之上**,
+       否则图例贴底后就压住浮层。两边共吃这一个数——写两个魔法数是「两处真相」的老毛病。 */
+    --map-bottom-band: calc(26 * var(--u));
 
     /* ---- 层级 ---- */
     --z-map: 0;