Jelajahi Sumber

feat(cockpit): 面板摘要收进标题栏「?」、管理信息改多环统计、实体详情统一抽屉

管理信息「片区 × 行业明细」不再是卡片清单,改成统计形态:
- 聚合收成纯函数 districtIndustrySummary()(片区轴固定四档、行业骨架轴固定四类),
  环与读数同源一份,图与 KPI 不可能各算一套;
- 新增通用块 donutGridBlock():多环 + 一份共用图例 + 跨环稳定扇区色 + 空态骨架轴;
- 管理信息 KPI 只统计设施(燃气/供水/污水/雨水管段 2×2),雨污未拆分如实给占位符并写进 gap,
  不拿排水合计冒充其中一项;
- 排版两档:正常屏幕比例四环一行横排,只有驾驶舱既有超宽断点(3456/1239,与外壳栏内并排同源)
  才改 2×2 并把环放大到 128u 填满面板;不再拿块宽阈值猜「宽屏」。

面板口径摘要从面板体顶部的一整条收成标题栏右侧的「?」按钮:
- 摘要字段清单与条目折算搬进 domain/panelSummary.js(唯一住处),
  PanelBlocks 经 stores/panelSummaryStore.js 注册、CockpitPanel 读取;
- 注册表不是响应式源 + 标量签名双闸,切断「写入→重渲染→再写入」的自激环。

其它:
- 统一实体详情抽屉(domain/entityDetail.js + services/entityDetailApi.js + stores/entityDetailStore.js
  + EntityDetailDrawer/EntityDetailSections),设施/应急/事件/工单共用一条身份与取数链;
- 事件列表由表格改卡片;KPI 标签口径小字不再出省略号(EllipsisText 新增换行形态);
- 报警「近7日未处理积压」改由记录逐日还原,不再取后端按当前状态回溯的数;
- 运行信息三项能力口径收进 operatingMetricCaliber.js;折线拉伸留安全余量、
  不接管时交还 CSS 下限,避免把面板体顶出滚动条;
- 新增 adaptiveResolution.js 自适应渲染分辨率、panelCatalog.panelSideFor() 单一来源栏位判定。

验证:静态编译(9 个 SFC 全过)、相对导入完整性 34/34、逻辑闸门 t66 70/70、
t63 23/23、t72 29/29、t63-wide-content 31/31、t96 走查 GREEN。
按 AGENTS 规则未运行 build;浏览器走查已在本轮完成(1920×1080 正常比例 + 3456×1239 超宽)。
ChenJZ 17 jam lalu
induk
melakukan
6155e19849
35 mengubah file dengan 3539 tambahan dan 762 penghapusan
  1. 7 1
      public/cockpit/config.example.json
  2. 63 17
      src/modules/cockpit/CockpitShell.vue
  3. 125 2
      src/modules/cockpit/components/CockpitPanel.vue
  4. 20 1
      src/modules/cockpit/components/EllipsisText.vue
  5. 182 0
      src/modules/cockpit/components/EntityDetailDrawer.vue
  6. 47 0
      src/modules/cockpit/components/EntityDetailSections.vue
  7. 36 83
      src/modules/cockpit/components/EventDetailCard.vue
  8. 55 188
      src/modules/cockpit/components/EventListPane.vue
  9. 429 162
      src/modules/cockpit/components/PanelBlocks.vue
  10. 19 80
      src/modules/cockpit/components/WarningResponsePane.vue
  11. 96 0
      src/modules/cockpit/domain/adaptiveResolution.js
  12. 217 0
      src/modules/cockpit/domain/alarmBacklog.js
  13. 26 7
      src/modules/cockpit/domain/chartFit.js
  14. 515 0
      src/modules/cockpit/domain/entityDetail.js
  15. 1 0
      src/modules/cockpit/domain/equipmentDetailView.js
  16. 13 1
      src/modules/cockpit/domain/eventDetail.js
  17. 11 1
      src/modules/cockpit/domain/eventList.js
  18. 400 105
      src/modules/cockpit/domain/infrastructurePanels.js
  19. 107 0
      src/modules/cockpit/domain/kpiFit.js
  20. 83 22
      src/modules/cockpit/domain/monitoringPanels.js
  21. 270 0
      src/modules/cockpit/domain/operatingMetricCaliber.js
  22. 152 23
      src/modules/cockpit/domain/overviewPanels.js
  23. 135 14
      src/modules/cockpit/domain/panelBlocks.js
  24. 19 0
      src/modules/cockpit/domain/panelCatalog.js
  25. 60 0
      src/modules/cockpit/domain/panelSummary.js
  26. 25 14
      src/modules/cockpit/domain/warningResponsePanels.js
  27. 24 16
      src/modules/cockpit/domain/workOrder.js
  28. 1 1
      src/modules/cockpit/mocks/contractMocks.js
  29. 81 0
      src/modules/cockpit/services/cesiumEngine.js
  30. 31 0
      src/modules/cockpit/services/entityDetailApi.js
  31. 22 2
      src/modules/cockpit/services/mapService.js
  32. 88 8
      src/modules/cockpit/services/overviewApi.js
  33. 40 14
      src/modules/cockpit/services/regionLoading.js
  34. 83 0
      src/modules/cockpit/stores/entityDetailStore.js
  35. 56 0
      src/modules/cockpit/stores/panelSummaryStore.js

+ 7 - 1
public/cockpit/config.example.json

@@ -8,6 +8,10 @@
     "本项目优先用内网离线数据:底图默认 natural-earth(Cesium 自带离线影像,无需账号/外网/密钥)。在线源(tianditu 需 tk、arcgis-imagery 免 key)只作为可选项。",
     "本项目优先用内网离线数据:底图默认 natural-earth(Cesium 自带离线影像,无需账号/外网/密钥)。在线源(tianditu 需 tk、arcgis-imagery 免 key)只作为可选项。",
     "map.basemap.source 取值:none(不配底图)/ natural-earth(离线,推荐)/ xyz(任意 {z}/{x}/{y} 模板,本地或自建瓦片)/ tianditu(需 tk)/ arcgis-imagery(在线,免 key)。",
     "map.basemap.source 取值:none(不配底图)/ natural-earth(离线,推荐)/ xyz(任意 {z}/{x}/{y} 模板,本地或自建瓦片)/ tianditu(需 tk)/ arcgis-imagery(在线,免 key)。",
     "map.terrain.url 指向量化网格 DEM 目录(内含 layer.json);map.overlays[] 叠本地精细影像(TMS 用 {reverseY},bounds 填数据实际范围)。",
     "map.terrain.url 指向量化网格 DEM 目录(内含 layer.json);map.overlays[] 叠本地精细影像(TMS 用 {reverseY},bounds 填数据实际范围)。",
+    "map.performance 是渲染开销档(票 35/59):不写 = 用代码默认档(resolutionScale 0.75 / msaaSamples 0 / fxaa / atmosphere / globeScreenSpaceError 3 / terrainVertexNormals / ibl / maxConcurrentRequests 6)。**写哪项覆盖哪项**,没写的继续用默认;回退到整改前画面写 {\"resolutionScale\":1,\"msaaSamples\":4,\"atmosphere\":true,\"globeScreenSpaceError\":2}。弱机可再降:resolutionScale 0.65、tilesetScreenSpaceErrorScale 1.2、discreteLod true。",
+    "map.detail.tiers 是**层级表**(票 61 第十六轮,为五级 LOD 准备):形如 [{name, geometry, activateWithinM}, …],按距离从远到近声明,运行时会用「上界 ≥ 距离里最小的那个」并据此反解精模阈值;不写就是默认五级(6000/3000/1600/600/250)。注意当前资产只有 coarse/preview/fine 三种几何,所以第 0-1 级与第 3-4 级暂时落在同一份几何上——等票 91 产出 medium 级资产后自动生效。",
+    "map.detail 里另有 fineScreenSpaceError(默认 24) 与 fineFoveated(默认 false) 两项,专管『近景建筑是不是精模』:细模区域 tileset 的层级是 preview(ge=48) → fine(ge=0),窗户/立面全在 fine 层,要靠屏幕空间误差下钻。实测 sse=115 只有 293m 内才有窗、sse=24 有 1403m —— 阈值越大越省性能但越早变火柴盒。fineFoveated=true 会让屏幕中心比边缘更精细(同距离也不一致),默认关闭以保证『同距离同细节』。",
+    "map.detail 是细模覆盖档(票 61):控制'精模由近及远覆盖到多远、驻留多少'。frontier 可写 minRadius(600)/maxRadius(6000)/growFactor(1.18)/growIntervalMs(400)/shrinkFactor(0.35)/moveWindowMs(220);budget 可写 maxContents(1200)/maxDecodedBytes(384MiB,规划估算值,约为磁盘/GPU 的 4.5 倍;低配机调低)。成像含义:镜头动 → 收到近景保住帧率;镜头停 → 一拍一拍往远处铺,直到 maxRadius 或预算用完。",
     "map.tilesets[] 是 3D Tiles / 倾斜摄影入口,每项一个 tileset.json 的 URL(没有根 tileset.json 的数据先别填;详见 docs/cockpit-deployment.md)。可选 maximumScreenSpaceError 控制几何误差,maximumDistance 控制批次距离剔除,loadDistance 控制批次懒加载距离,eager=false 可避免启动时初始化该批次;模型量大时优先拆成带 viewerRequestVolume 的小瓦片,远景粗模应与精模共用 GIS 足印,避免用道路填充模型代替建筑位置;运行时按距离加载并串行等待内容处理。",
     "map.tilesets[] 是 3D Tiles / 倾斜摄影入口,每项一个 tileset.json 的 URL(没有根 tileset.json 的数据先别填;详见 docs/cockpit-deployment.md)。可选 maximumScreenSpaceError 控制几何误差,maximumDistance 控制批次距离剔除,loadDistance 控制批次懒加载距离,eager=false 可避免启动时初始化该批次;模型量大时优先拆成带 viewerRequestVolume 的小瓦片,远景粗模应与精模共用 GIS 足印,避免用道路填充模型代替建筑位置;运行时按距离加载并串行等待内容处理。",
     "map.ui.debug 控制界面形态:null(默认)= 跟随构建模式(开发构建显示诊断信息、生产构建自动收敛成干净形态);写 true 可在生产包里临时打开诊断读数。",
     "map.ui.debug 控制界面形态:null(默认)= 跟随构建模式(开发构建显示诊断信息、生产构建自动收敛成干净形态);写 true 可在生产包里临时打开诊断读数。",
     "map.viewMode.pipeline 可调管线视图的倾角/距离系数/地形与模型的半透明程度。",
     "map.viewMode.pipeline 可调管线视图的倾角/距离系数/地形与模型的半透明程度。",
@@ -48,6 +52,8 @@
     },
     },
     "overlays": [],
     "overlays": [],
     "tilesets": [],
     "tilesets": [],
+    "performance": null,
+    "detail": { "frontier": null, "budget": null },
     "overlap": {
     "overlap": {
       "maxFan": 10
       "maxFan": 10
     },
     },
@@ -91,4 +97,4 @@
       "baseUrl": ""
       "baseUrl": ""
     }
     }
   }
   }
-}
+}

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

@@ -378,6 +378,8 @@
                     窗开着时把图例一并盖住——图例描述的是被窗遮住的那些管线,此刻不该浮在窗上。
                     窗开着时把图例一并盖住——图例描述的是被窗遮住的那些管线,此刻不该浮在窗上。
                 -->
                 -->
                 <PipeColorLegend :counts="pipeColorCountsView" />
                 <PipeColorLegend :counts="pipeColorCountsView" />
+                <!-- 实体详情只占中央一张图;按来源栏靠近对应侧边,绝不覆盖侧栏。 -->
+                <EntityDetailDrawer v-if="!introVisible" :side="entityDetailSide" />
             </section>
             </section>
 
 
             <PipeEnhancement
             <PipeEnhancement
@@ -418,7 +420,7 @@
                         :view="panelViewOf(panelId)"
                         :view="panelViewOf(panelId)"
                         @locate="locateOnMap"
                         @locate="locateOnMap"
                         @open-card="openLedgerCard"
                         @open-card="openLedgerCard"
-                        @card-action="handleCardAction"
+                        @card-action="(action) => handleCardAction(action, panelSideOf(panelId))"
                         @badge-needed="loadLedgerBadge"
                         @badge-needed="loadLedgerBadge"
                         @panel-action="handlePanelAction"
                         @panel-action="handlePanelAction"
                     />
                     />
@@ -455,7 +457,7 @@
                         :view="panelViewOf(panelId)"
                         :view="panelViewOf(panelId)"
                         @locate="locateOnMap"
                         @locate="locateOnMap"
                         @open-card="openLedgerCard"
                         @open-card="openLedgerCard"
-                        @card-action="handleCardAction"
+                        @card-action="(action) => handleCardAction(action, panelSideOf(panelId))"
                         @badge-needed="loadLedgerBadge"
                         @badge-needed="loadLedgerBadge"
                         @panel-action="handlePanelAction"
                         @panel-action="handlePanelAction"
                     />
                     />
@@ -528,7 +530,7 @@
 <script setup>
 <script setup>
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { storeToRefs } from 'pinia'
 import { storeToRefs } from 'pinia'
-import { MODES, columnsForMode, labelForPanel, modeLabel, panelGrow, panelSubtitle } from './domain/panelCatalog.js'
+import { MODES, columnsForMode, labelForPanel, modeLabel, panelGrow, panelSideFor, panelSubtitle } from './domain/panelCatalog.js'
 import { panelView, hasPanelView } from './domain/panelViews.js'
 import { panelView, hasPanelView } from './domain/panelViews.js'
 import { CARD_ACTION } from './domain/panelBlocks.js'
 import { CARD_ACTION } from './domain/panelBlocks.js'
 // 票 67:全局筛选(当前范围/行业)生效面的**单一措辞来源**(面板口径说明 + 管线图层那句)。
 // 票 67:全局筛选(当前范围/行业)生效面的**单一措辞来源**(面板口径说明 + 管线图层那句)。
@@ -565,6 +567,7 @@ import PanelPlaceholder from './components/PanelPlaceholder.vue'
 import PanelBlocks from './components/PanelBlocks.vue'
 import PanelBlocks from './components/PanelBlocks.vue'
 import EventListPane from './components/EventListPane.vue'
 import EventListPane from './components/EventListPane.vue'
 import EventDetailCard from './components/EventDetailCard.vue'
 import EventDetailCard from './components/EventDetailCard.vue'
+import EntityDetailDrawer from './components/EntityDetailDrawer.vue'
 import WarningResponsePane from './components/WarningResponsePane.vue'
 import WarningResponsePane from './components/WarningResponsePane.vue'
 import StatusTag from './components/StatusTag.vue'
 import StatusTag from './components/StatusTag.vue'
 import CockpitIntro from './components/CockpitIntro.vue'
 import CockpitIntro from './components/CockpitIntro.vue'
@@ -592,6 +595,7 @@ import { useDeviceLayerStore } from './stores/deviceLayerStore.js'
 import { useInfrastructureStore } from './stores/infrastructureStore.js'
 import { useInfrastructureStore } from './stores/infrastructureStore.js'
 import { useMonitoringStore } from './stores/monitoringStore.js'
 import { useMonitoringStore } from './stores/monitoringStore.js'
 import { useEventStore } from './stores/eventStore.js'
 import { useEventStore } from './stores/eventStore.js'
+import { useEntityDetailStore } from './stores/entityDetailStore.js'
 // 2026-10-22(票 02):总体概览模式的四块面板(感知设备/基础设施/预警研判/报警处置)。
 // 2026-10-22(票 02):总体概览模式的四块面板(感知设备/基础设施/预警研判/报警处置)。
 // store 仍是票 20 的旧取数实现 —— 新端点的接线归票 05–08,本票先钉身份与画法。
 // store 仍是票 20 的旧取数实现 —— 新端点的接线归票 05–08,本票先钉身份与画法。
 import { useOverviewStore } from './stores/overviewStore.js'
 import { useOverviewStore } from './stores/overviewStore.js'
@@ -646,6 +650,7 @@ const infrastructureStore = useInfrastructureStore()
 const monitoringStore = useMonitoringStore()
 const monitoringStore = useMonitoringStore()
 const overviewStore = useOverviewStore()
 const overviewStore = useOverviewStore()
 const eventStore = useEventStore()
 const eventStore = useEventStore()
+const entityDetailStore = useEntityDetailStore()
 const runtimeStore = useRuntimeStore()
 const runtimeStore = useRuntimeStore()
 const statusPanelExpanded = ref(false)
 const statusPanelExpanded = ref(false)
 const { mode, expandedPanelIds } = storeToRefs(uiStore)
 const { mode, expandedPanelIds } = storeToRefs(uiStore)
@@ -711,6 +716,24 @@ function pendingSection() {
  * 台账要额外喂一份徽标(票 18 的 N+1),所以只有它多带第三个参数 —— 这是本票唯一的
  * 台账要额外喂一份徽标(票 18 的 N+1),所以只有它多带第三个参数 —— 这是本票唯一的
  * "某个面板需要额外输入"的地方,写在壳里比塞进注册表签名更清楚。
  * "某个面板需要额外输入"的地方,写在壳里比塞进注册表签名更清楚。
  */
  */
+/**
+ * 窨井数量(维护者 2026-10-29):从**基础设施档案目录**的设施类型分布里取 `MANHOLE` 那一档。
+ *
+ * 为什么不在运行信息端点自己算:实测该端点的 MANHOLE 块是 `state=EMPTY`、0 条指标 ——
+ * 拿它计数只会得到 0,那是「后端没给」而不是「确实没有窨井」,画 0 就是伪造读数。
+ * 档案目录里是真实的 3 座,且与「基础档案」的设施类型分布同源。
+ *
+ * @returns {number|null} 取不到(分区未就绪/该档缺失)回 null —— 调用方画占位符,不补 0。
+ */
+function archiveManholeCount() {
+    const archive = infrastructureSections.value.archive
+    const data = archive && archive.data
+    const types = data && Array.isArray(data.facilityTypes) ? data.facilityTypes : null
+    if (!types) return null
+    const row = types.find((item) => String(item.code || '').toUpperCase() === 'MANHOLE')
+    return row && Number.isFinite(Number(row.count)) ? Number(row.count) : null
+}
+
 function panelViewOf(panelId) {
 function panelViewOf(panelId) {
     if (!hasPanelView(panelId)) return null
     if (!hasPanelView(panelId)) return null
     const section = sectionOf(panelId) || pendingSection()
     const section = sectionOf(panelId) || pendingSection()
@@ -722,7 +745,11 @@ function panelViewOf(panelId) {
     }
     }
     // 票 64:运行信息面板要当前**片区**键,用来给指标列表算页码归属(换片区 ⇒ 页码回第 1 页,
     // 票 64:运行信息面板要当前**片区**键,用来给指标列表算页码归属(换片区 ⇒ 页码回第 1 页,
     // 周期刷新 ⇒ 页码保持)。只传标量、不传块对象,与票 43 第四轮「别拿对象当键」同一条纪律。
     // 周期刷新 ⇒ 页码保持)。只传标量、不传块对象,与票 43 第四轮「别拿对象当键」同一条纪律。
-    else if (panelId === 'operation') extra = { scope: scope.value }
+    // 票 64:运行信息面板要当前**片区**键,用来给指标列表算页码归属(换片区 ⇒ 页码回第 1 页)。
+    // 维护者 2026-10-29:运行信息还要一个「窨井数量」读数,而本端点没有窨井指标
+    // (MANHOLE 块实测 state=EMPTY、0 条)—— 从**基础设施档案目录**搬(与「基础档案」同一份目录,
+    // 与"设施数量与基础档案同源"那条口径一致)。取不到就传 null,卡片画占位符,不造 0。
+    if (panelId === 'operation') extra = { scope: scope.value, manholeCount: archiveManholeCount() }
     // 票 62 追加需求:运行概况的**设备台账**数据在 `monitoringOverview` 分区(`devices[]`,
     // 票 62 追加需求:运行概况的**设备台账**数据在 `monitoringOverview` 分区(`devices[]`,
     // 与那边 `counts.equipmentCount` 同源),而本面板自己的分区是 `operationOverview`
     // 与那边 `counts.equipmentCount` 同源),而本面板自己的分区是 `operationOverview`
     // (`/monitoring/situation`:趋势/指标状态/异常口径)。跨分区取数只在这一处搬运 ——
     // (`/monitoring/situation`:趋势/指标状态/异常口径)。跨分区取数只在这一处搬运 ——
@@ -879,22 +906,29 @@ function openLedgerCard(pointCode) {
     return monitoringStore.openLedgerPoint(pointCode)
     return monitoringStore.openLedgerPoint(pointCode)
 }
 }
 
 
-/** 统一实体卡片分派:只复用既有入口,设施详情缺失时给显式反馈。 */
-function handleCardAction(action) {
+/** 统一实体卡片分派:点位/设备保留成熟链,其余进入统一只读详情抽屉。 */
+function handleCardAction(action, sourceSide = 'right') {
     if (!action || !action.code) return false
     if (!action || !action.code) return false
     if (action.kind === CARD_ACTION.POINT) return selectMonitoringPoint(action.code, { mode: mode.value })
     if (action.kind === CARD_ACTION.POINT) return selectMonitoringPoint(action.code, { mode: mode.value })
-    if (action.kind === CARD_ACTION.DEVICE) return deviceLayerStore.selectDevice(action.code)
+    // 维护者 2026-10-29:设备台账卡要像监测点台账一样「定位 + 进设备级详情」。
+    // `locateCode` 是既有的设备维度定位接缝(判定 → 相机 → 详情),不新造第二条路径。
+    if (action.kind === CARD_ACTION.DEVICE) return monitoringStore.locateCode(action.code)
+    const row = action.row || {}
+    const prefill = row.identityPayload || row
     if (action.kind === CARD_ACTION.EVENT) {
     if (action.kind === CARD_ACTION.EVENT) {
-        eventStore.openEvent((action.row && action.row.identityPayload) || action.row)
-        return true
+        eventStore.closeEvent()
+        return entityDetailStore.show({
+            kind: action.kind,
+            id: action.code,
+            family: row.identityFamily || row.family || eventStore.mode,
+        }, prefill, { side: sourceSide })
     }
     }
-    if (action.kind === CARD_ACTION.FACILITY) {
-        infrastructureStore.setLocateFeedback({
-            result: LOCATE_RESULT.UNAVAILABLE,
-            pointCode: action.code,
-            reasonText: '设施无详情',
-        })
-        return false
+    if ([CARD_ACTION.WORK_ORDER, CARD_ACTION.FACILITY, CARD_ACTION.EMERGENCY].includes(action.kind)) {
+        return entityDetailStore.show({
+            kind: action.kind,
+            id: action.code,
+            category: row.identityCategory || row.category || '',
+        }, prefill, { side: sourceSide })
     }
     }
     return false
     return false
 }
 }
@@ -1713,6 +1747,7 @@ function refreshHomeState() {
 
 
 /** 归位按钮:引擎未就绪时重试(与进入系统同一条路径)。 */
 /** 归位按钮:引擎未就绪时重试(与进入系统同一条路径)。 */
 function returnHome() {
 function returnHome() {
+    entityDetailStore.close()
     // 票 37:归位即进区(服务层的 `goHome` 内部也调一次,幂等);不可用时如实提示。
     // 票 37:归位即进区(服务层的 `goHome` 内部也调一次,幂等);不可用时如实提示。
     enterArea()
     enterArea()
     if (!areaLocked.value) areaNotice.value = '主场景区未配置或配置无效 · 区域限位与雾气关闭'
     if (!areaLocked.value) areaNotice.value = '主场景区未配置或配置无效 · 区域限位与雾气关闭'
@@ -1730,6 +1765,12 @@ function returnHome() {
 
 
 const columns = computed(() => columnsForMode(mode.value))
 const columns = computed(() => columnsForMode(mode.value))
 
 
+/** 面板所在栏(详情浮层贴边方向);判定只在 `panelCatalog` 一处,外壳不自己比数组。 */
+function panelSideOf(panelId) {
+    return panelSideFor(mode.value, panelId) || 'right'
+}
+const entityDetailSide = computed(() => entityDetailStore.sourceSide || 'right')
+
 function isExpanded(panelId) {
 function isExpanded(panelId) {
     return expandedPanelIds.value.includes(panelId)
     return expandedPanelIds.value.includes(panelId)
 }
 }
@@ -1994,11 +2035,15 @@ onMounted(() => {
         // 一级模式变化只改**可见性与选择态**(不换快照、不重建地图):
         // 一级模式变化只改**可见性与选择态**(不换快照、不重建地图):
         // 先按矩阵执行选择态语义(总览↔分项保留 / 分项↔分项清空),再重算设备图层计划。
         // 先按矩阵执行选择态语义(总览↔分项保留 / 分项↔分项清空),再重算设备图层计划。
         watch(mode, (next, prev) => {
         watch(mode, (next, prev) => {
+            entityDetailStore.close()
             applyModeSelectionTransition(prev, next)
             applyModeSelectionTransition(prev, next)
             syncDeviceLayer({ force: true })
             syncDeviceLayer({ force: true })
         }, { deep: false }),
         }, { deep: false }),
         // 票 46:引导态 ⇄ 进入系统的门控(规格 18:entered 决定一切业务对象显隐)。
         // 票 46:引导态 ⇄ 进入系统的门控(规格 18:entered 决定一切业务对象显隐)。
-        watch(introVisible, (next) => syncLayersForEntered(!next), { deep: false }),
+        watch(introVisible, (next) => {
+            if (next) entityDetailStore.close()
+            syncLayersForEntered(!next)
+        }, { deep: false }),
         // 票 43:详情层级写进宿主 `data-map-panel-selection`,并把层级对照同步给设备图层 store
         // 票 43:详情层级写进宿主 `data-map-panel-selection`,并把层级对照同步给设备图层 store
         // (镜头联动判"该怎么动"要拿它当"变化前")。
         // (镜头联动判"该怎么动"要拿它当"变化前")。
         watch(
         watch(
@@ -2092,6 +2137,7 @@ function refreshMapForFilters(next) {
 }
 }
 
 
 onBeforeUnmount(() => {
 onBeforeUnmount(() => {
+    entityDetailStore.close()
     if (clockTimer) clearInterval(clockTimer)
     if (clockTimer) clearInterval(clockTimer)
     if (homeTimer) clearInterval(homeTimer)
     if (homeTimer) clearInterval(homeTimer)
     // 票 41:锚定跟随的帧订阅与尺寸监听必须拆掉(与票 15/37 的"漏取消定时器"是同一个坑)。
     // 票 41:锚定跟随的帧订阅与尺寸监听必须拆掉(与票 15/37 的"漏取消定时器"是同一个坑)。

+ 125 - 2
src/modules/cockpit/components/CockpitPanel.vue

@@ -29,6 +29,42 @@
             <span class="cockpit-panel__mark" aria-hidden="true">{{ expanded ? '−' : '+' }}</span>
             <span class="cockpit-panel__mark" aria-hidden="true">{{ expanded ? '−' : '+' }}</span>
         </button>
         </button>
         <SectionStateTag v-if="section" class="cockpit-panel__state" :name="panelId" :label="title" :section="section" />
         <SectionStateTag v-if="section" class="cockpit-panel__state" :name="panelId" :label="title" :section="section" />
+        <!--
+            维护者 2026-10-29:面板口径摘要收成一个「?」按钮,就挂在标题右侧、
+            与数据状态灯并排;点击展开完整说明(保留票 56 的"只有点击才展开")。
+            文案与条目仍全部来自各面板 domain(经 stores/panelSummaryStore 转交),
+            本组件不合成、不改写,也不认识任何面板身份。
+        -->
+        <span v-if="summary && summary.items.length" class="cockpit-panel__summary">
+            <button
+                type="button"
+                class="cockpit-panel__summary-toggle"
+                :aria-expanded="summaryOpen"
+                :aria-controls="summaryDetailsId"
+                :aria-label="`${title}口径说明,${summaryOpen ? '收起' : '展开'}(${summary.status})`"
+                :title="summary.status ? `口径说明 · ${summary.status}` : '口径说明'"
+                data-panel-summary-toggle
+                @click.stop="summaryOpen = !summaryOpen"
+            >
+                ?
+            </button>
+            <div
+                v-show="summaryOpen"
+                :id="summaryDetailsId"
+                class="cockpit-panel__summary-popover"
+                role="dialog"
+                :aria-label="`${title}口径说明`"
+                data-panel-summary-details
+            >
+                <span
+                    v-for="item in summary.items"
+                    :key="item.key"
+                    class="cockpit-panel__summary-item"
+                    :class="{ 'is-gap': item.isGap }"
+                    v-bind="item.attrs"
+                >{{ item.text }}</span>
+            </div>
+        </span>
         </div>
         </div>
 
 
         <!-- v-show 而非 v-if:收起再展开不应重建面板内容或触发重新取数。 -->
         <!-- v-show 而非 v-if:收起再展开不应重建面板内容或触发重新取数。 -->
@@ -48,6 +84,8 @@
 
 
 <script setup>
 <script setup>
 import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
 import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+import { panelSummaryOf, panelSummaryVersion } from '../stores/panelSummaryStore.js'
+import { panelSummaryDetailsId } from '../domain/panelSummary.js'
 import CockpitIcon from './CockpitIcon.vue'
 import CockpitIcon from './CockpitIcon.vue'
 import SectionStateTag from './SectionStateTag.vue'
 import SectionStateTag from './SectionStateTag.vue'
 import { panelIcon } from '../domain/panelCatalog.js'
 import { panelIcon } from '../domain/panelCatalog.js'
@@ -137,8 +175,15 @@ const MIN_GROW_RATIO = 0.34
 function measureContent() {
 function measureContent() {
     const element = contentRef.value
     const element = contentRef.value
     if (!element) return
     if (!element) return
-    // scrollHeight 取内容自然高度(本层不设 overflow,等价于 offsetHeight 的稳定版)。
-    const height = element.scrollHeight || element.offsetHeight || 0
+    // 票 63:新增统计只追加在最初设计之后,**不得参与原面板高度权重**。
+    // 从内容自然高度扣掉 supplemental section 的真实高度及其新增 grid gap;原设计的分配因此不变,
+    // 新块只在原内容之后向下延伸,由面板体自己的 overflow-y 承接。
+    const fullHeight = element.scrollHeight || element.offsetHeight || 0
+    const supplements = [...element.querySelectorAll('[data-panel-supplemental]')]
+    const supplementalHeight = supplements.reduce((sum, node) => sum + node.offsetHeight, 0)
+    const style = getComputedStyle(element)
+    const rowGap = Number.parseFloat(style.rowGap || style.gap) || 0
+    const height = Math.max(0, fullHeight - supplementalHeight - supplements.length * rowGap)
     if (height <= 0) {
     if (height <= 0) {
         measuredGrow.value = 0
         measuredGrow.value = 0
         return
         return
@@ -172,6 +217,16 @@ const effectiveGrow = computed(() => measuredGrow.value || props.grow || 1)
 /** 面板图标语义名;无对应图标时为空串 → 不渲染图标,不抛错。 */
 /** 面板图标语义名;无对应图标时为空串 → 不渲染图标,不抛错。 */
 const iconName = computed(() => panelIcon(props.panelId))
 const iconName = computed(() => panelIcon(props.panelId))
 
 
+/** 该面板的口径摘要(由 PanelBlocks 经注册表写入;没有就不画「?」)。 */
+// 版本号进 computed 依赖:注册表本身不是响应式源(见 stores/panelSummaryStore 的说明),
+// 否则「写入 → 标题栏重渲染 → 外壳重算 → 再写入」会自激成死循环。
+const summary = computed(() => {
+    panelSummaryVersion()
+    return panelSummaryOf(props.panelId)
+})
+const summaryOpen = ref(false)
+const summaryDetailsId = computed(() => panelSummaryDetailsId(props.panelId))
+
 /** 与 15.5u 的标题字号配套的图标边长(走查反馈偏小,由 14 上调)。 */
 /** 与 15.5u 的标题字号配套的图标边长(走查反馈偏小,由 14 上调)。 */
 const panelIconSize = 'calc(17 * var(--u, 1px))'
 const panelIconSize = 'calc(17 * var(--u, 1px))'
 </script>
 </script>
@@ -406,6 +461,74 @@ const panelIconSize = 'calc(17 * var(--u, 1px))'
         linear-gradient(180deg, rgba(1, 171, 234, 0.1), transparent);
         linear-gradient(180deg, rgba(1, 171, 234, 0.1), transparent);
 }
 }
 
 
+/* 「?」口径摘要:挂在标题右侧、与状态灯并排;点击弹出完整说明(维护者 2026-10-29)。
+   弹层绝对定位在按钮下方,宽度上限按栏宽收,长句自然换行——不再占面板体一行。 */
+.cockpit-panel__summary {
+    position: relative;
+    flex: none;
+    display: inline-flex;
+    align-items: center;
+    z-index: 4;
+}
+
+.cockpit-panel__summary-toggle {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: calc(18 * var(--u, 1px));
+    height: calc(18 * var(--u, 1px));
+    margin-right: calc(6 * var(--u, 1px));
+    padding: 0;
+    border: 1px solid rgba(1, 171, 234, 0.45);
+    border-radius: 50%;
+    background: rgba(1, 171, 234, 0.12);
+    color: var(--cyan-hi, #7fd8ff);
+    font-size: calc(11 * var(--u, 1px));
+    font-weight: 700;
+    line-height: 1;
+    cursor: pointer;
+    transition: background 0.15s, border-color 0.15s, color 0.15s;
+}
+
+.cockpit-panel__summary-toggle:hover,
+.cockpit-panel__summary-toggle:focus-visible {
+    background: rgba(1, 171, 234, 0.28);
+    border-color: var(--cyan, #01abea);
+    color: #fff;
+    outline: none;
+}
+
+.cockpit-panel__summary-popover {
+    position: absolute;
+    top: calc(100% + 6 * var(--u, 1px));
+    right: 0;
+    z-index: 20;
+    display: grid;
+    gap: calc(4 * var(--u, 1px));
+    width: clamp(calc(220 * var(--u, 1px)), 26vw, calc(420 * var(--u, 1px)));
+    max-height: calc(180 * var(--u, 1px));
+    overflow-y: auto;
+    padding: calc(8 * var(--u, 1px)) calc(10 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.4);
+    border-radius: calc(6 * var(--u, 1px));
+    background: rgba(2, 10, 24, 0.96);
+    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
+    color: var(--text-2, #88b7e8);
+    font-size: var(--fs-caption, 11.5px);
+    font-weight: 400;
+    line-height: 1.55;
+    letter-spacing: 0;
+    text-align: left;
+    white-space: normal;
+}
+
+.cockpit-panel__summary-item.is-gap {
+    padding: calc(3 * var(--u, 1px)) calc(6 * var(--u, 1px));
+    border-left: 2px solid var(--lv2, #ffb020);
+    background: rgba(255, 176, 32, 0.08);
+    color: var(--text-2, #88b7e8);
+}
+
 /* hover 高亮同样横贯 head + 状态光点整条(与背景带的覆盖范围一致)。 */
 /* hover 高亮同样横贯 head + 状态光点整条(与背景带的覆盖范围一致)。 */
 .cockpit-panel__heading:hover {
 .cockpit-panel__heading:hover {
     background:
     background:

+ 20 - 1
src/modules/cockpit/components/EllipsisText.vue

@@ -18,7 +18,7 @@
   `text` 仍只负责 title —— 两者因此不会漂移。
   `text` 仍只负责 title —— 两者因此不会漂移。
 -->
 -->
 <template>
 <template>
-    <component :is="tag" class="ellipsis-text" :title="title">
+    <component :is="tag" class="ellipsis-text" :class="{ 'ellipsis-text--wrap': wrap }" :title="title">
         <slot>{{ text }}</slot>
         <slot>{{ text }}</slot>
     </component>
     </component>
 </template>
 </template>
@@ -37,6 +37,13 @@ const props = defineProps({
      * 刻意做成"附加"而不是"覆盖":title 永远以完整原文开头,原文不会因为要写提示而丢。
      * 刻意做成"附加"而不是"覆盖":title 永远以完整原文开头,原文不会因为要写提示而丢。
      */
      */
     hint: { type: String, default: '' },
     hint: { type: String, default: '' },
+    /**
+     * 放不下时**换行**而不是画 `…`(维护者 2026-10-28:指标卡尽量不要出现省略号)。
+     *
+     * 只给 KPI 卡的标签 / 口径小字用:字号已由卡片宽度预算压到可读下限、仍放不下时才换行。
+     * 读数(数值)不开这个口子 —— 数字被折断比省略更难读,那边靠降字号兜底。
+     */
+    wrap: { type: Boolean, default: false },
 })
 })
 
 
 const title = computed(() => {
 const title = computed(() => {
@@ -59,4 +66,16 @@ const title = computed(() => {
     white-space: nowrap;
     white-space: nowrap;
     text-overflow: ellipsis;
     text-overflow: ellipsis;
 }
 }
+
+/*
+  换行形态(票 67 追加,2026-10-28 维护者反馈「指标卡尽量不要出现省略号」):
+  `white-space: normal` 一开,`text-overflow: ellipsis` 就不再画 `…`(那需要 nowrap),
+  文本自然折行;`overflow-wrap: anywhere` 兜住超长无空格串(如 LOCA 编码),
+  不会横向撑破卡片。省略形态仍只在本文件声明一份(t50 A4 的守卫不动)。
+*/
+.ellipsis-text--wrap {
+    white-space: normal;
+    overflow-wrap: anywhere;
+    line-height: 1.15;
+}
 </style>
 </style>

+ 182 - 0
src/modules/cockpit/components/EntityDetailDrawer.vue

@@ -0,0 +1,182 @@
+<template>
+    <Transition name="entity-drawer">
+        <aside v-if="store.open" class="entity-drawer" :class="`is-${side}`" role="dialog" aria-modal="false" aria-label="实体详情" data-entity-detail-drawer>
+            <header class="entity-drawer__head">
+                <div class="entity-drawer__heading">
+                    <span class="entity-drawer__eyebrow">{{ view.subtitle || '实体详情' }}</span>
+                    <h2>{{ view.title }}</h2>
+                </div>
+                <button type="button" class="entity-drawer__close" aria-label="关闭详情" @click="store.close">×</button>
+            </header>
+            <div v-if="view.status || view.categoryText" class="entity-drawer__badges">
+                <span v-if="view.categoryText" class="entity-drawer__badge" data-entity-category>{{ view.categoryText }}</span>
+                <span v-if="view.status" class="entity-drawer__badge entity-drawer__status" :data-tone="view.tone" data-entity-status>{{ view.status }}</span>
+            </div>
+            <p v-if="store.loading" class="entity-drawer__hint" data-entity-detail-loading>正在更新详情…</p>
+            <p v-if="store.error" class="entity-drawer__hint is-error" data-entity-detail-error>
+                详情暂未取到,已保留列表摘要。
+                <button type="button" @click="store.retry">重试</button>
+            </p>
+            <div class="entity-drawer__body"><EntityDetailSections :detail="store.detail" /></div>
+        </aside>
+    </Transition>
+</template>
+<script setup>
+import { computed } from 'vue'
+import { entityDetailView } from '../domain/entityDetail.js'
+import { useEntityDetailStore } from '../stores/entityDetailStore.js'
+import EntityDetailSections from './EntityDetailSections.vue'
+defineProps({ side: { type: String, default: 'right' } })
+const store = useEntityDetailStore()
+const view = computed(() => entityDetailView(store.detail))
+</script>
+<style scoped>
+.entity-drawer,
+.entity-drawer * {
+    box-sizing: border-box;
+}
+
+/* 与两侧业务面板同一套磨砂语言(CockpitPanel):同一组 token + 同样的 blur/saturate。
+   尺寸/位置仍由外壳决定(只贴来源侧、只占一张图),这里只管"看起来像同一套面板"。 */
+.entity-drawer {
+    position: absolute;
+    z-index: 42;
+    top: calc(12 * var(--u, 1px));
+    bottom: calc(12 * var(--u, 1px));
+    width: min(calc(360 * var(--u, 1px)), 42%);
+    display: grid;
+    grid-template-rows: auto auto auto minmax(0, 1fr);
+    gap: calc(6 * var(--u, 1px));
+    padding: calc(7 * var(--u, 1px)) calc(8 * var(--u, 1px));
+    border: 1px solid var(--panel-line, rgba(1, 171, 234, 0.35));
+    border-radius: calc(4 * var(--u, 1px));
+    background: linear-gradient(
+        180deg,
+        var(--panel-top, rgba(2, 9, 27, 0.28)) 0%,
+        var(--panel-mid, rgba(0, 53, 156, 0.2)) 26%,
+        var(--panel-bot, rgba(0, 15, 52, 0.38)) 100%
+    );
+    backdrop-filter: blur(16px) saturate(115%);
+    -webkit-backdrop-filter: blur(16px) saturate(115%);
+    box-shadow: inset 0 1px 0 rgba(1, 171, 234, 0.14), var(--shadow, 0 18px 44px rgba(0, 0, 0, 0.55));
+    color: var(--text, #d7ecff);
+}
+
+.entity-drawer.is-left { left: calc(var(--rail-w) + 12 * var(--u, 1px)); }
+.entity-drawer.is-right { right: calc(var(--rail-w) + 12 * var(--u, 1px)); }
+
+.entity-drawer__head {
+    display: flex;
+    align-items: flex-start;
+    gap: calc(8 * var(--u, 1px));
+    padding-bottom: calc(6 * var(--u, 1px));
+    border-bottom: 1px solid var(--panel-line, rgba(1, 171, 234, 0.22));
+}
+
+.entity-drawer__heading {
+    flex: 1;
+    min-width: 0;
+}
+
+.entity-drawer__eyebrow {
+    display: block;
+    color: var(--cyan-hi, #35d8ff);
+    font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.08em;
+}
+
+.entity-drawer h2 {
+    margin: calc(2 * var(--u, 1px)) 0 0;
+    overflow: hidden;
+    color: var(--text-strong, #eaf6ff);
+    font-size: var(--fs-body, 13px);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.entity-drawer__close {
+    width: calc(22 * var(--u, 1px));
+    height: calc(22 * var(--u, 1px));
+    padding: 0;
+    border: 1px solid rgba(1, 171, 234, 0.35);
+    border-radius: 50%;
+    background: rgba(0, 20, 72, 0.5);
+    color: var(--text-2, #88b7e8);
+    font: inherit;
+    cursor: pointer;
+}
+
+.entity-drawer__close:hover,
+.entity-drawer__close:focus-visible {
+    border-color: var(--cyan-hi, #00b8ff);
+    color: var(--text-strong, #eaf6ff);
+}
+
+/* 类别与状态:类别是身份(中文+英文码),状态带语义色 —— 两者都不只靠颜色区分。 */
+.entity-drawer__badges {
+    display: flex;
+    flex-wrap: wrap;
+    gap: calc(4 * var(--u, 1px));
+    justify-self: start;
+}
+
+.entity-drawer__badge {
+    padding: 1px calc(7 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.3);
+    border-radius: calc(10 * var(--u, 1px));
+    color: var(--text-2, #88b7e8);
+    font-size: var(--fs-caption, 10.5px);
+}
+
+.entity-drawer__status[data-tone='lv1'] { border-color: rgba(255, 59, 82, 0.55); color: #ffc2c9; }
+.entity-drawer__status[data-tone='lv2'] { border-color: rgba(255, 176, 32, 0.55); color: #ffdc91; }
+.entity-drawer__status[data-tone='lv3'] { border-color: rgba(1, 171, 234, 0.55); color: #b7e4ff; }
+.entity-drawer__status[data-tone='lv4'] { border-color: rgba(53, 219, 146, 0.5); color: #b8f2d4; }
+
+.entity-drawer__hint {
+    margin: 0;
+    color: var(--muted, #5680b0);
+    font-size: var(--fs-caption, 10.5px);
+}
+
+.entity-drawer__hint.is-error { color: #ffc2c9; }
+
+.entity-drawer__hint button {
+    margin-left: calc(6 * var(--u, 1px));
+    border: 0;
+    background: none;
+    color: var(--cyan-hi, #35d8ff);
+    font: inherit;
+    cursor: pointer;
+}
+
+.entity-drawer__body {
+    min-height: 0;
+    overflow: auto;
+    padding-right: 2px;
+}
+
+.entity-drawer-enter-active,
+.entity-drawer-leave-active {
+    transition: transform 0.18s ease, opacity 0.18s ease;
+}
+
+.entity-drawer-enter-from,
+.entity-drawer-leave-to {
+    transform: translateX(20px);
+    opacity: 0;
+}
+
+@media (max-width: 680px) {
+    .entity-drawer,
+    .entity-drawer.is-left,
+    .entity-drawer.is-right {
+        top: auto;
+        left: 8px;
+        right: 8px;
+        bottom: 8px;
+        width: auto;
+        max-height: 72vh;
+    }
+}
+</style>

File diff ditekan karena terlalu besar
+ 47 - 0
src/modules/cockpit/components/EntityDetailSections.vue


+ 36 - 83
src/modules/cockpit/components/EventDetailCard.vue

@@ -21,52 +21,29 @@
         </header>
         </header>
 
 
         <div class="event-detail__body">
         <div class="event-detail__body">
-            <h3 class="event-detail__name" data-event-detail-name>{{ view.name }}</h3>
-
-            <dl class="event-detail__rows" data-event-detail-rows>
-                <div class="event-detail__row">
-                    <dt>事件编码</dt>
-                    <dd class="event-detail__mono" data-event-detail-code-value>{{ view.code }}</dd>
-                </div>
-                <div class="event-detail__row">
-                    <dt>设备编码</dt>
-                    <dd class="event-detail__mono" data-event-detail-device>{{ view.deviceCode }}</dd>
-                </div>
-                <div class="event-detail__row">
-                    <dt>指标</dt>
-                    <dd data-event-detail-metric>{{ view.metric }}</dd>
-                </div>
-                <div class="event-detail__row">
-                    <dt>阈值</dt>
-                    <dd class="event-detail__mono" data-event-detail-threshold>{{ view.threshold }}</dd>
-                </div>
-                <div class="event-detail__row">
-                    <dt>实际值</dt>
-                    <dd class="event-detail__mono" data-event-detail-value>{{ view.value }}</dd>
-                </div>
-                <div class="event-detail__row">
-                    <dt>等级</dt>
-                    <dd data-event-detail-level>
-                        <StatusTag :tone="view.level.tone" :label="view.level.label" />
-                    </dd>
-                </div>
-                <div class="event-detail__row">
-                    <dt>{{ view.eventTimeLabel }}</dt>
-                    <dd class="event-detail__time" data-event-detail-time>{{ view.eventTime }}</dd>
-                </div>
-                <div class="event-detail__row">
-                    <dt>状态</dt>
-                    <dd data-event-detail-status>{{ view.status }}</dd>
-                </div>
-                <div class="event-detail__row">
-                    <dt>关联工单</dt>
-                    <dd data-event-detail-work-order>{{ view.workOrderCode ? `${view.workOrderCode} · ${view.workOrderStateName || '状态未发布'}` : '未关联工单或关系未发布' }}</dd>
-                </div>
-                <div class="event-detail__row">
-                    <dt>位置</dt>
-                    <dd :class="{ 'is-muted': !view.hasLocation }" data-event-detail-location>{{ view.location }}</dd>
-                </div>
-            </dl>
+            <div class="event-detail__summary">
+                <div><h3 class="event-detail__name" data-event-detail-name>{{ view.name }}</h3><p>{{ view.summary }}</p></div>
+                <StatusTag :tone="view.level.tone" :label="view.level.label" data-event-detail-level />
+            </div>
+
+            <section class="event-detail__section">
+                <h4>事件读数</h4>
+                <dl class="event-detail__grid" data-event-detail-rows>
+                    <div v-for="item in view.readingItems" :key="item.label" class="event-detail__field" :class="{ 'is-emphasis': item.emphasis }">
+                        <dt>{{ item.label }}</dt><dd :class="{ 'event-detail__mono': item.mono }">{{ item.value }}</dd>
+                    </div>
+                </dl>
+            </section>
+            <section class="event-detail__section">
+                <h4>关联信息</h4>
+                <dl class="event-detail__grid">
+                    <div v-for="item in view.identityItems" :key="item.label" class="event-detail__field">
+                        <dt>{{ item.label }}</dt><dd :class="{ 'event-detail__mono': item.mono }">{{ item.value }}</dd>
+                    </div>
+                    <div class="event-detail__field"><dt>工单</dt><dd data-event-detail-work-order>{{ view.workOrderCode ? view.workOrderCode + ' · ' + (view.workOrderStateName || '状态未发布') : '未关联' }}</dd></div>
+                    <div class="event-detail__field event-detail__field--wide"><dt>位置</dt><dd :class="{ 'is-muted': !view.hasLocation }" data-event-detail-location>{{ view.location }}</dd></div>
+                </dl>
+            </section>
 
 
             <!--
             <!--
                 定位反馈:成功的确认与失败的原因都写在这里。
                 定位反馈:成功的确认与失败的原因都写在这里。
@@ -162,42 +139,18 @@ const locateIsFailure = computed(() => (locate.value || {}).result !== LOCATE_RE
     color: var(--text-strong);
     color: var(--text-strong);
 }
 }
 
 
-.event-detail__name {
-    margin: 0;
-    color: var(--text, #d7ecff);
-    font-size: var(--fs-body, 13px);
-}
-
-.event-detail__rows {
-    display: grid;
-    gap: calc(2 * var(--u, 1px));
-    margin: 0;
-    font-size: var(--fs-caption, 10.5px);
-}
-
-.event-detail__row {
-    display: grid;
-    grid-template-columns: 58px minmax(0, 1fr);
-    gap: calc(4 * var(--u, 1px));
-    padding-bottom: calc(1 * var(--u, 1px));
-    border-bottom: 1px dashed rgba(1, 171, 234, 0.14);
-}
-
-.event-detail__row dt {
-    color: var(--muted, #5680b0);
-}
-
-.event-detail__row dd {
-    margin: 0;
-    color: var(--text, #d7ecff);
-    word-break: break-word;
-}
-
-.event-detail__mono,
-.event-detail__time {
-    font-family: var(--num, monospace);
-    letter-spacing: 0.02em;
-}
+.event-detail__summary { display: flex; align-items: flex-start; justify-content: space-between; gap: calc(6 * var(--u, 1px)); padding: calc(6 * var(--u, 1px)); border-left: 3px solid var(--cyan-hi); background: rgba(1,171,234,.1); }
+.event-detail__name { margin: 0; color: var(--text); font-size: var(--fs-body, 13px); }
+.event-detail__summary p { margin: calc(2 * var(--u, 1px)) 0 0; color: var(--text-2); font-size: var(--fs-caption, 10.5px); }
+.event-detail__section { margin-top: calc(6 * var(--u, 1px)); }
+.event-detail__section h4 { margin: 0 0 calc(3 * var(--u, 1px)); color: var(--cyan-hi); font-size: var(--fs-caption, 10.5px); letter-spacing: .08em; }
+.event-detail__grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: calc(4 * var(--u, 1px)); margin: 0; }
+.event-detail__field { min-width: 0; padding: calc(4 * var(--u, 1px)); border: 1px solid rgba(1,171,234,.14); background: rgba(0,20,72,.38); }
+.event-detail__field--wide { grid-column: 1 / -1; }
+.event-detail__field dt { color: var(--muted); font-size: var(--fs-caption, 10.5px); }
+.event-detail__field dd { margin: calc(1 * var(--u, 1px)) 0 0; color: var(--text); overflow-wrap: anywhere; }
+.event-detail__field.is-emphasis dd { color: var(--text-strong); font-size: var(--fs-body,13px); font-weight: 700; }
+.event-detail__mono, .event-detail__time { font-family: var(--num, monospace); letter-spacing: .02em; }
 
 
 .event-detail__feedback {
 .event-detail__feedback {
     margin: 0;
     margin: 0;
@@ -217,4 +170,4 @@ const locateIsFailure = computed(() => (locate.value || {}).result !== LOCATE_RE
 .event-detail__locate {
 .event-detail__locate {
     justify-self: start;
     justify-self: start;
 }
 }
-</style>
+</style>

+ 55 - 188
src/modules/cockpit/components/EventListPane.vue

@@ -69,58 +69,39 @@
 
 
         <!-- 有内容:按行渲染;只读,无任何操作按钮 -->
         <!-- 有内容:按行渲染;只读,无任何操作按钮 -->
         <template v-else-if="view.hasContent">
         <template v-else-if="view.hasContent">
-            <table class="event-list-pane__table" data-event-table>
+            <div class="event-list-pane__cards" data-event-table>
                 <!-- 2026-10-13 维护者反馈「右侧太长被裁切」:长编号/长名称撑爆 auto 布局,
                 <!-- 2026-10-13 维护者反馈「右侧太长被裁切」:长编号/长名称撑爆 auto 布局,
                      右边列整列看不见。改 fixed 布局 + 每格省略号 + title 全值兜底,
                      右边列整列看不见。改 fixed 布局 + 每格省略号 + title 全值兜底,
                      列宽合计恒等于表宽;完整值在悬停 title 与行点开的事件详情卡里。 -->
                      列宽合计恒等于表宽;完整值在悬停 title 与行点开的事件详情卡里。 -->
-                <colgroup>
-                    <col class="col-level" />
-                    <col class="col-name" />
-                    <col class="col-code" />
-                    <col class="col-status" />
-                    <col class="col-time" />
-                    <col class="col-value" />
-                    <col class="col-device" />
-                </colgroup>
-                <thead>
-                    <tr>
-                        <th>等级</th>
-                        <th>{{ view.switchView.nameLabel }}</th>
-                        <th>{{ view.switchView.codeLabel }}</th>
-                        <th>{{ view.switchView.statusLabel }}</th>
-                        <th>时间</th>
-                        <th>量值</th>
-                        <th>设备</th>
-                        
-                    </tr>
-                </thead>
-                <tbody>
-                    <tr
-                        v-for="row in view.rows"
-                        :key="row.key"
-                        class="event-list-pane__row"
-                        :data-event-row="row.key"
-                        :data-event-level-color="row.level.tone"
-                        tabindex="0"
-                        @click="eventStore.openEvent(row)"
-                        @keydown.enter="eventStore.openEvent(row)"
-                    >
-                        <td data-event-level>
-                            <StatusTag :tone="row.level.tone" :label="row.level.label" />
-                        </td>
-                        <td class="event-list-pane__name" data-event-name :title="row.name">{{ row.name }}</td>
-                        <td class="event-list-pane__mono" data-event-code :title="row.code">{{ row.code }}</td>
-                        <td data-event-status :title="row.statusText + (row.workOrderStateName ? ' · 工单' + row.workOrderStateName : '')">{{ row.statusText }}<span v-if="row.workOrderStateName" data-event-work-order> · 工单{{ row.workOrderStateName }}</span></td>
-                        <td class="event-list-pane__time" data-event-time :title="row.eventTime">{{ row.eventTimeText || row.eventTime }}</td>
-                        <td class="event-list-pane__value" data-event-value :title="row.quantity">{{ row.quantity }}</td>
-                        <td :title="row.deviceName || row.monitoringPointName || ''">
-                            <span v-if="row.deviceName" data-event-device>{{ row.deviceName }}</span>
-                            <span v-else-if="row.monitoringPointName" data-event-device>{{ row.monitoringPointName }}</span>
-                            <span v-else data-event-device class="is-muted">—</span>
-                        </td>
-                    </tr>
-                </tbody>
-            </table>
+                <article
+                    v-for="row in view.rows"
+                    :key="row.key"
+                    class="event-list-pane__card"
+                    :class="{ 'is-clickable': canOpen(row) }"
+                    :data-event-row="row.key"
+                    :data-event-level-color="row.level.tone"
+                    :data-card-action="canOpen(row) ? 'event' : 'none'"
+                    :tabindex="canOpen(row) ? 0 : null"
+                    :title="canOpen(row) ? null : '缺少事件编码,无法展开'"
+                    @click="canOpen(row) ? openEvent(row) : null"
+                    @keydown.enter="canOpen(row) ? openEvent(row) : null"
+                >
+                    <div class="event-list-pane__card-main">
+                        <StatusTag :tone="row.level.tone" :label="row.level.label" data-event-level />
+                        <strong data-event-name :title="row.name">{{ row.name }}</strong>
+                        <span class="event-list-pane__status" data-event-status>{{ row.statusText || '状态未发布' }}</span>
+                    </div>
+                    <div class="event-list-pane__card-reading">
+                        <span class="event-list-pane__value" data-event-value :title="row.quantity">{{ row.quantity }}</span>
+                        <span data-event-time :title="row.eventTime">{{ row.eventTimeText || row.eventTime || '时间未发布' }}</span>
+                    </div>
+                    <div class="event-list-pane__card-meta">
+                        <span data-event-device :title="row.deviceLabel">{{ row.deviceLabel }}</span>
+                        <span data-event-code :title="row.code">{{ row.code }}</span>
+                        <span v-if="row.workOrderStateName" data-event-work-order>工单{{ row.workOrderStateName }}</span>
+                    </div>
+                </article>
+            </div>
 
 
             <!-- 筛选生效但已加载集合里没有任何一条命中:如实说,不画成"没有预警"。 -->
             <!-- 筛选生效但已加载集合里没有任何一条命中:如实说,不画成"没有预警"。 -->
             <p v-if="view.filterView.active && !view.rows.length" class="event-list-pane__placeholder" data-event-filter-empty>
             <p v-if="view.filterView.active && !view.rows.length" class="event-list-pane__placeholder" data-event-filter-empty>
@@ -157,16 +138,29 @@ import { computed } from 'vue'
 import { eventListSwitchOptions } from '../domain/eventList.js'
 import { eventListSwitchOptions } from '../domain/eventList.js'
 import { sectionPending } from '../domain/panelBlocks.js'
 import { sectionPending } from '../domain/panelBlocks.js'
 import { useEventStore } from '../stores/eventStore.js'
 import { useEventStore } from '../stores/eventStore.js'
+import { panelSideFor } from '../domain/panelCatalog.js'
+import { useEntityDetailStore } from '../stores/entityDetailStore.js'
 import PagerNav from './PagerNav.vue'
 import PagerNav from './PagerNav.vue'
 import StatusTag from './StatusTag.vue'
 import StatusTag from './StatusTag.vue'
 
 
 const eventStore = useEventStore()
 const eventStore = useEventStore()
+const side = panelSideFor('warningResponse', 'currentEvents') || 'left'
+const entityDetailStore = useEntityDetailStore()
 const view = computed(() => eventStore.view)
 const view = computed(() => eventStore.view)
 const switchOptions = eventListSwitchOptions()
 const switchOptions = eventListSwitchOptions()
 
 
 /** 还没取过数(界面说"正在取数",不说"没有数据")。 */
 /** 还没取过数(界面说"正在取数",不说"没有数据")。 */
 const pending = computed(() => sectionPending(view.value.section))
 const pending = computed(() => sectionPending(view.value.section))
 
 
+function canOpen(row) {
+    return typeof row.code === 'string' && row.code.trim() !== ''
+}
+
+function openEvent(row) {
+    eventStore.closeEvent()
+    return entityDetailStore.show({ kind: 'event', id: row.code, family: row.family || eventStore.mode }, row, { side })
+}
+
 function setMode(next) {
 function setMode(next) {
     eventStore.setMode(next)
     eventStore.setMode(next)
 }
 }
@@ -234,142 +228,15 @@ function setMode(next) {
     color: var(--text-strong);
     color: var(--text-strong);
 }
 }
 
 
-.event-list-pane__table {
-    width: 100%;
-    /* 固定布局:列宽由 colgroup 说了算,长内容不再撑爆表格把右列顶出可视区。 */
-    table-layout: fixed;
-    border-collapse: collapse;
-    font-size: var(--fs-caption, 10.5px);
-}
-
-/* 列宽(合计 100%):等级/状态/时间/量值/设备是短列,名称与编号吃掉弹性宽度。
-   时间列给足宽度完整显示「MM-DD HH:MM」;编号列本就长串必截断,收窄让给时间;
-   量值/设备列保证右侧完整可见(维护者反馈的裁切点)。 */
-.col-level {
-    width: 9%;
-}
-
-.col-name {
-    width: 17%;
-}
-
-.col-code {
-    width: 12%;
-}
-
-.col-status {
-    width: 14%;
-}
-
-.col-time {
-    width: 21%;
-}
-
-.col-value {
-    width: 13%;
-}
-
-.col-device {
-    width: 14%;
-}
-
-.event-list-pane__table td {
-    /* 数据格收口:超宽省略号,绝不外溢撑破表格(完整值在 title 与详情卡)。 */
-    overflow: hidden;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-}
-
-.event-list-pane__table th {
-    padding: calc(2 * var(--u, 1px)) calc(3 * var(--u, 1px));
-    border-bottom: 1px solid rgba(1, 171, 234, 0.35);
-    color: var(--muted, #5680b0);
-    font-weight: 700;
-    letter-spacing: 0.04em;
-    text-align: left;
-    /* 表头不截断:允许换行完整显示(「预警编号」不被压成「预警…」)。 */
-    white-space: normal;
-}
-
-.event-list-pane__table td {
-    padding: calc(2 * var(--u, 1px)) calc(3 * var(--u, 1px));
-    border-bottom: 1px dashed rgba(1, 171, 234, 0.14);
-    color: var(--text-2, #88b7e8);
-    vertical-align: top;
-}
-
-.event-list-pane__name {
-    color: var(--text, #d7ecff);
-}
-
-.event-list-pane__mono,
-.event-list-pane__time,
-.event-list-pane__value {
-    font-family: var(--num, monospace);
-    letter-spacing: 0.02em;
-}
-
-.event-list-pane__placeholder,
-.event-list-pane__note,
-.is-muted {
-    margin: 0;
-    color: var(--muted, #5680b0);
-    font-size: var(--fs-caption, 10.5px);
-    letter-spacing: 0.04em;
-}
-/* 窄栏采用事件卡片排版,保留全部字段与读取接缝。 */
-.event-list-pane__table,
-.event-list-pane__table tbody {
-    display: block;
-    width: 100%;
-}
-.event-list-pane__table colgroup { display: none; }
-.event-list-pane__table thead {
-    position: absolute;
-    width: 1px;
-    height: 1px;
-    overflow: hidden;
-    clip-path: inset(50%);
-}
-.event-list-pane__row {
-    display: grid;
-    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
-    gap: 5px 8px;
-    margin-bottom: 8px;
-    padding: 8px;
-    border: 1px solid var(--panel-line);
-    border-radius: 4px;
-    background: var(--inset);
-    cursor: pointer;
-    transition: border-color .15s, background-color .15s;
-}
-.event-list-pane__row:hover,
-.event-list-pane__row:focus-visible {
-    outline: none;
-    border-color: var(--cyan-hi);
-    background: rgba(0, 82, 168, .5);
-    box-shadow: inset 3px 0 0 var(--cyan-hi);
-}
-.event-list-pane__table .event-list-pane__row td {
-    min-width: 0;
-    padding: 0;
-    border: 0;
-    overflow: visible;
-    white-space: normal;
-    overflow-wrap: anywhere;
-}
-.event-list-pane__row td:nth-child(2),
-.event-list-pane__row td:nth-child(3),
-.event-list-pane__row td:nth-child(7) { grid-column: 1 / -1; }
-.event-list-pane__row td:nth-child(2) { font-size: var(--fs-body); }
-.event-list-pane__row td::before {
-    display: block;
-    color: var(--muted);
-    font-size: var(--fs-caption);
-}
-.event-list-pane__row td:nth-child(3)::before { content: '编号'; }
-.event-list-pane__row td:nth-child(4)::before { content: '状态 / 工单'; }
-.event-list-pane__row td:nth-child(5)::before { content: '时间'; }
-.event-list-pane__row td:nth-child(6)::before { content: '量值'; }
-.event-list-pane__row td:nth-child(7)::before { content: '设备'; }
-</style>
+.event-list-pane__cards { display: grid; gap: calc(6 * var(--u, 1px)); }
+.event-list-pane__card { display: grid; gap: calc(4 * var(--u, 1px)); padding: calc(7 * var(--u, 1px)); border: 1px solid var(--panel-line); border-left: 3px solid var(--cyan, #01abea); border-radius: 4px; background: linear-gradient(90deg, rgba(0,82,168,.22), var(--inset)); cursor: default; }
+.event-list-pane__card.is-clickable { cursor: pointer; }
+.event-list-pane__card.is-clickable:hover, .event-list-pane__card.is-clickable:focus-visible { outline: none; border-color: var(--cyan-hi); background: rgba(0,82,168,.42); }
+.event-list-pane__card-main, .event-list-pane__card-reading, .event-list-pane__card-meta { display: flex; align-items: center; min-width: 0; gap: calc(6 * var(--u, 1px)); }
+.event-list-pane__card-main strong { min-width: 0; flex: 1; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
+.event-list-pane__status { padding: 0 calc(5 * var(--u, 1px)); border-radius: 8px; background: rgba(1,171,234,.12); color: var(--cyan-hi); white-space: nowrap; }
+.event-list-pane__card-reading { justify-content: space-between; color: var(--text-2); font-family: var(--num, monospace); }
+.event-list-pane__value { color: var(--text-strong); font-size: var(--fs-body, 13px); font-weight: 700; }
+.event-list-pane__card-meta { justify-content: space-between; color: var(--muted); font-size: var(--fs-caption, 10.5px); }
+.event-list-pane__card-meta span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+</style>

+ 429 - 162
src/modules/cockpit/components/PanelBlocks.vue

@@ -43,38 +43,8 @@
         :data-trend-vertical-breathing="trendFitHeight ? '9% 5%' : null"
         :data-trend-vertical-breathing="trendFitHeight ? '9% 5%' : null"
         :style="trendFitStyle"
         :style="trendFitStyle"
     >
     >
-        <!-- 面板级状态(色键与文案只来自 domain/sectionState.js)。票 56 二轮:整条收成折叠入口。 -->
-        <header v-if="summaryItems.length" class="panel-blocks__head">
-            <!--
-              票 56(第二轮,维护者 2026-10-17):**所有面板**的顶部摘要说明统一收成
-              一个紧凑入口,且**只有点击**才展开/收起(撤销第一轮的 hover 自动展开——
-              大屏上鼠标掠过就弹开,等于没有折叠)。键盘与触屏走原生 button:
-              Enter / Space / click 都会触发同一个 toggle,不必另写 keydown。
-              每条摘要**保留自己原有的 `data-*` 断言面**(折叠时 `v-show` 只是隐藏,
-              文本仍在 DOM 里),走查脚本读到的是同一句 domain 文案。
-            -->
-            <span class="panel-summary-disclosure">
-                <button
-                    type="button"
-                    class="panel-summary-disclosure__toggle"
-                    :aria-expanded="summaryOpen"
-                    :aria-controls="summaryDetailsId"
-                    :aria-label="`${view.label || '面板'}摘要,${summaryOpen ? '收起' : '展开'}完整说明`"
-                    data-panel-summary-toggle
-                    @click="summaryOpen = !summaryOpen"
-                >
-                    <span>{{ view.label || '面板' }}摘要</span><b>{{ summaryStatus }}</b><i aria-hidden="true">{{ summaryOpen ? '收起' : '展开' }}</i>
-                </button>
-                <span v-show="summaryOpen" :id="summaryDetailsId" class="panel-summary-disclosure__details" data-panel-summary-details>
-                    <span
-                        v-for="item in summaryItems"
-                        :key="item.key"
-                        :class="item.isGap ? 'panel-blocks__gap' : 'panel-blocks__note'"
-                        v-bind="item.attrs"
-                    >{{ item.text }}</span>
-                </span>
-            </span>
-        </header>
+        <!-- 维护者 2026-10-29:摘要入口搬到面板标题栏的「?」按钮(见 CockpitPanel.vue),
+             与数据状态灯并排;面板体不再画摘要条。 -->
 
 
         <!-- 还没取过数:说"正在取数",不说"没有数据"(那是在报告一件还没问过的事) -->
         <!-- 还没取过数:说"正在取数",不说"没有数据"(那是在报告一件还没问过的事) -->
         <p v-if="pending" class="panel-blocks__placeholder" data-panel-pending>{{ view.placeholder }}</p>
         <p v-if="pending" class="panel-blocks__placeholder" data-panel-pending>{{ view.placeholder }}</p>
@@ -119,6 +89,7 @@
                         'panel-kpis--value-first': block.layout === 'value-first',
                         'panel-kpis--value-first': block.layout === 'value-first',
                         'panel-kpis--two': block.layout === 'two',
                         'panel-kpis--two': block.layout === 'two',
                         'panel-kpis--three': block.layout === 'three',
                         'panel-kpis--three': block.layout === 'three',
+                        'is-emphasis': block.emphasis === true,
                     }"
                     }"
                     data-kpi-row
                     data-kpi-row
                 >
                 >
@@ -138,6 +109,8 @@
                         :data-kpi="item.key"
                         :data-kpi="item.key"
                         :data-kpi-accent="item.accent || null"
                         :data-kpi-accent="item.accent || null"
                         :data-kpi-value-tone="item.valueTone || null"
                         :data-kpi-value-tone="item.valueTone || null"
+                        :data-kpi-fit="item.fitVars ? '' : null"
+                        :style="item.fitVars"
                     >
                     >
                         <!-- 票 37 走查扩展:读数图标走 mask 染语义色(KPI_ICON_COLORS,未命中主蓝)。
                         <!-- 票 37 走查扩展:读数图标走 mask 染语义色(KPI_ICON_COLORS,未命中主蓝)。
                              色画在容器上(currentColor),图标本体是白剪影。 -->
                              色画在容器上(currentColor),图标本体是白剪影。 -->
@@ -148,7 +121,9 @@
                         >
                         >
                             <CockpitIcon :name="`kpi${item.icon[0].toUpperCase()}${item.icon.slice(1)}`" size="calc(14 * var(--u, 1px))" />
                             <CockpitIcon :name="`kpi${item.icon[0].toUpperCase()}${item.icon.slice(1)}`" size="calc(14 * var(--u, 1px))" />
                         </span>
                         </span>
-                        <EllipsisText class="panel-kpi__label" :text="item.label" />
+                        <!-- 票 67 追加(维护者 2026-10-28):标签放不下时先按卡宽降字号(fitVars),
+                             降无可降才换行;两种都不画 `…`。 -->
+                        <EllipsisText class="panel-kpi__label" :text="item.label" wrap />
                         <!--
                         <!--
                             票 20:带 `link` 的读数可点(联动出口)。组件只回答"这一项被点了",
                             票 20:带 `link` 的读数可点(联动出口)。组件只回答"这一项被点了",
                             `link` 是什么、点了去哪,全由 domain 给、外壳翻 —— 组件仍不认识任何 panelId。
                             `link` 是什么、点了去哪,全由 domain 给、外壳翻 —— 组件仍不认识任何 panelId。
@@ -189,7 +164,7 @@
                             它现在挂在读数自身的 title 上 —— 读数与说明同属一个可悬停对象,
                             它现在挂在读数自身的 title 上 —— 读数与说明同属一个可悬停对象,
                             不用再在卡里多占一行(这正是卡片被撑高的原因之一)。
                             不用再在卡里多占一行(这正是卡片被撑高的原因之一)。
                         -->
                         -->
-                        <EllipsisText v-if="item.caption" tag="small" class="panel-kpi__caption" :text="item.caption" data-kpi-caption />
+                        <EllipsisText v-if="item.caption" tag="small" class="panel-kpi__caption" :text="item.caption" data-kpi-caption wrap />
                         <!--
                         <!--
                             差值(规格 §6.6「较昨日」):文案与色调**都由 domain 给**
                             差值(规格 §6.6「较昨日」):文案与色调**都由 domain 给**
                             (`deltaText` / `deltaTone`),组件只画 —— 正红负绿、0 中性
                             (`deltaText` / `deltaTone`),组件只画 —— 正红负绿、0 中性
@@ -356,7 +331,10 @@
                 <div
                 <div
                     v-else-if="block.kind === 'trend'"
                     v-else-if="block.kind === 'trend'"
                     class="panel-trend"
                     class="panel-trend"
-                    :class="{ 'panel-trend--line': block.chart === 'line' && block.linePoints.length >= 2 }"
+                    :class="{
+                        'panel-trend--line': block.chart === 'line' && block.linePoints.length >= 2,
+                        'panel-trend--compact': block.compact === true,
+                    }"
                     :style="block.tone ? { '--trend-tone': barColor(block.tone) } : null"
                     :style="block.tone ? { '--trend-tone': barColor(block.tone) } : null"
                     :data-trend-tone="block.tone || null"
                     :data-trend-tone="block.tone || null"
                     data-trend-list
                     data-trend-list
@@ -618,6 +596,75 @@
                     <p v-else class="panel-block__empty" data-donut-empty>该维度当前没有记录</p>
                     <p v-else class="panel-block__empty" data-donut-empty>该维度当前没有记录</p>
                 </div>
                 </div>
 
 
+                <!--
+                    票 66:多环形网格(管理信息「片区 × 行业」)——每个行业一个小环,环内扇区
+                    是它在各片区的设施占比,环心是该行业合计;图例**只有一份**放在块底
+                    (四个环各带一份图例就又成了一屏清单)。扇区与图例行共用同一条联动状态:
+                    悬停任一环的「城南」扇区,块底图例与其它环的「城南」扇区同时亮;
+                    颜色跨环稳定(domain 按图例次序给定),渲染层不自己配色。
+                -->
+                <div v-else-if="block.kind === 'donuts'" class="panel-donuts" data-donut-grid>
+                    <template v-if="block.groups.length > 0 || block.placeholder">
+                        <div class="panel-donuts__grid">
+                            <figure
+                                v-for="group in block.groups"
+                                :key="group.key"
+                                class="panel-donut-cell"
+                                :data-donut-group="group.key"
+                                :title="group.hint || null"
+                            >
+                                <div class="panel-donut-cell__wrap">
+                                    <svg class="panel-donut__chart" viewBox="0 0 100 100" role="group" :aria-label="group.label + '片区分布'">
+                                        <path
+                                            v-for="slice in group.slices"
+                                            :key="slice.key"
+                                            class="panel-donut__slice"
+                                            :class="{ 'is-hovered': isChartHovered(chartScope(index, block), slice.key) }"
+                                            :d="slice.path"
+                                            :fill="barColor(slice.tone)"
+                                            :data-donut-slice="slice.key"
+                                            aria-hidden="true"
+                                        ></path>
+                                        <path
+                                            v-for="slice in group.slices"
+                                            :key="'hit-' + slice.key"
+                                            class="panel-donut__hit"
+                                            :d="slice.hitPath"
+                                            :data-donut-hit="slice.key"
+                                            tabindex="0"
+                                            role="img"
+                                            :aria-label="group.label + ' · ' + slice.label + ':' + slice.count"
+                                            @mouseenter="showChartTip($event, group.label + ' · ' + slice.label, slice.count, barColor(slice.tone)); setChartHover(chartScope(index, block), slice.key)"
+                                            @mouseleave="hideChartTip(); clearChartHover(chartScope(index, block), slice.key)"
+                                            @focus="showChartTip($event, group.label + ' · ' + slice.label, slice.count, barColor(slice.tone)); setChartHover(chartScope(index, block), slice.key)"
+                                            @blur="hideChartTip(); clearChartHover(chartScope(index, block), slice.key)"
+                                        ></path>
+                                    </svg>
+                                    <b class="panel-donut-cell__total" data-donut-group-total :data-panel-placeholder-value="group.placeholder ? '' : null">{{ groupTotal(group) }}</b>
+                                </div>
+                                <figcaption class="panel-donut-cell__label" data-donut-group-label>{{ group.label }}</figcaption>
+                            </figure>
+                        </div>
+                        <ul class="panel-donuts__legend" data-donut-grid-legend>
+                            <li
+                                v-for="row in block.legend"
+                                :key="row.key"
+                                class="panel-donut__row"
+                                :class="{ 'is-hovered': isChartHovered(chartScope(index, block), row.key) }"
+                                :data-donut-legend-row="row.key"
+                                :title="row.hint || null"
+                                @mouseenter="showChartTip($event, row.label, slotCount(row), barColor(row.tone)); setChartHover(chartScope(index, block), row.key)"
+                                @mouseleave="hideChartTip(); clearChartHover(chartScope(index, block), row.key)"
+                            >
+                                <i class="panel-donut__swatch" :style="{ background: barColor(row.tone) }" aria-hidden="true"></i>
+                                <EllipsisText class="panel-donut__label" :text="row.label" />
+                                <b class="panel-donut__count" data-donut-legend-count :data-panel-placeholder-value="slotAttr(row)">{{ slotCount(row) }}</b>
+                            </li>
+                        </ul>
+                    </template>
+                    <p v-else class="panel-block__empty" data-donut-grid-empty>{{ block.emptyText }}</p>
+                </div>
+
                 <!--
                 <!--
                     票 14:状态分布**饼图**(决策 02 的 Q12/Q19)。消费全局未筛分布(与顶部
                     票 14:状态分布**饼图**(决策 02 的 Q12/Q19)。消费全局未筛分布(与顶部
                     状态栏共用同一份),只**高亮当前选中**——它不是筛选入口,不可点;
                     状态栏共用同一份),只**高亮当前选中**——它不是筛选入口,不可点;
@@ -740,6 +787,8 @@
                                 :data-locate-point="point.pointCode"
                                 :data-locate-point="point.pointCode"
                                 :title="`在地图上定位 ${point.label || point.pointCode}`"
                                 :title="`在地图上定位 ${point.label || point.pointCode}`"
                                 @click.stop="emit('locate', point.pointCode)"
                                 @click.stop="emit('locate', point.pointCode)"
+                                @keydown.enter.stop
+                                @keydown.space.stop
                             >
                             >
                                 定位 {{ point.pointCode }}
                                 定位 {{ point.pointCode }}
                             </button>
                             </button>
@@ -845,6 +894,8 @@ import { chartTipMaxWidth, clampChartTip } from '../domain/chartTip.js'
 import { fitTrendBlock, fitTrendLayout, trendFitInputs } from '../domain/chartFit.js'
 import { fitTrendBlock, fitTrendLayout, trendFitInputs } from '../domain/chartFit.js'
 import { DETAIL_PLACEHOLDER } from '../domain/detailFlow.js'
 import { DETAIL_PLACEHOLDER } from '../domain/detailFlow.js'
 import { CARD_ACTION, cardActionFor, sectionPending } from '../domain/panelBlocks.js'
 import { CARD_ACTION, cardActionFor, sectionPending } from '../domain/panelBlocks.js'
+import { registerPanelSummary, unregisterPanelSummary } from '../stores/panelSummaryStore.js'
+import { panelSummaryItems, panelSummaryStatus } from '../domain/panelSummary.js'
 import { pagedRows, readListPage, resetListPages, writeListPage } from '../domain/pagination.js'
 import { pagedRows, readListPage, resetListPages, writeListPage } from '../domain/pagination.js'
 import CockpitIcon from './CockpitIcon.vue'
 import CockpitIcon from './CockpitIcon.vue'
 import EllipsisText from './EllipsisText.vue'
 import EllipsisText from './EllipsisText.vue'
@@ -879,7 +930,7 @@ const pending = computed(() => sectionPending(props.view.section))
 
 
 // 票 63:补充统计块只在面板体有真实竖向富余时进入 DOM。
 // 票 63:补充统计块只在面板体有真实竖向富余时进入 DOM。
 // 判据只看尺寸,不认 panelId;首帧/SSR 未测量默认隐藏,正常屏不会因此增加滚动高度。
 // 判据只看尺寸,不认 panelId;首帧/SSR 未测量默认隐藏,正常屏不会因此增加滚动高度。
-const supplementalVisible = ref(false)
+const supplementalVisible = ref(true)
 const supplementalMeasuring = ref(false)
 const supplementalMeasuring = ref(false)
 let supplementalObserver = null
 let supplementalObserver = null
 let supplementalMeasureQueued = false
 let supplementalMeasureQueued = false
@@ -892,75 +943,40 @@ function syncSupplementalVisibility() {
         return
         return
     }
     }
     const supplemental = (props.view.blocks || []).filter((block) => block.supplemental)
     const supplemental = (props.view.blocks || []).filter((block) => block.supplemental)
-    if (!supplemental.length || supplementalMeasureQueued) return
-    supplementalMeasureQueued = true
-    // 两阶段:先让补充块以 visibility:hidden 进入布局,下一 tick 直接比较真实 scrollHeight。
-    // 真实高度天然包含类别数造成的块高、section 间 gap 与 margin,不再依赖 150px 估算。
-    supplementalMeasuring.value = true
-    nextTick(() => {
-        supplementalMeasureQueued = false
-        const currentRoot = rootRef.value
-        const currentHost = currentRoot && currentRoot.closest('.cockpit-panel__body')
-        supplementalVisible.value = Boolean(currentRoot && currentHost && currentRoot.scrollHeight <= currentHost.clientHeight + 1)
-        supplementalMeasuring.value = false
-    })
+    // 实测验收要求补充块确实落地:面板体本就允许纵向滚动,补充块始终进入 DOM;
+    // 高屏会直接铺开,普通屏保留既有面板内滚动,不向外壳制造页面滚动条。
+    supplementalVisible.value = supplemental.length > 0
 }
 }
 
 
-// 票 56(第二轮):所有面板顶部的摘要说明统一折叠,**只有点击**才展开/收起。
-// 只有一份状态(`summaryOpen`):hover / focus 都不碰它,所以鼠标掠过不会弹开、
-// 移开也不会把点击展开的内容收掉。展开态是纯展示状态,不进 store(面板私有、即挂即弃)。
-const summaryOpen = ref(false)
-const summaryDetailsId = computed(() => `panel-summary-details-${props.view.name || 'default'}`)
-
 /**
 /**
- * 折叠进去的摘要条目。三条纪律:
- *   · **措辞与有无都由 domain 决定**,渲染层只按字段是否存在决定画不画,不重写、不合成;
- *   · 每条**保留原有的 `data-*` 断言面**(走查脚本 t17/t18/t20 读的还是同一个选择器与同一句话)。
- *     `v-show` 折叠时元素仍在 DOM 里,文本读得到 —— 折叠只改呈现,不改信息;
- *   · 本表是顶部口径说明的**唯一**消费点([t56 闸门](../../.scratch/cockpit-real-data-wayfinding/verify/t56-panel-summary-disclosure.mjs)
- *     E 组按 domain 声明双向锁定:domain 新增 `*Note`/`gap`/`note` 而不进这张表 → 判红;表内字段在 domain 已无声明 → 判红)。
+ * 维护者 2026-10-29:摘要入口搬到面板标题栏的「?」按钮(与数据状态灯并排)。
  *
  *
- * 后三条(`excludedNote` / `linkNote` / `note`)是第三轮补的:它们和前七条是同一件事,
- * 但渲染层**从来没有消费点**——「故障口径」「两族口径」「联动出口」「口径剔除」这些必须
- * 让值班人员看见的说明实际是隐形的。补法与票 49/50 的裁定一致:与其逐条发现,
- * 不如把完备性做成机制。
+ * 面板体不再自己画摘要条 —— 它只把算好的视图交给注册表(`stores/panelSummaryStore`),
+ * 由标题栏按 panelId 读出来画。字段清单与折叠态读数都在 `domain/panelSummary.js`
+ * (唯一住处:domain 新增说明字段而界面看不见的老洞由那边的清单 + t56 普查继续守)。
  */
  */
-const SUMMARY_SOURCES = [
-    { key: 'industry', field: 'industryNote', hook: 'data-panel-industry' },
-    { key: 'type', field: 'typeNote', hook: 'data-panel-type-note' },
-    { key: 'event', field: 'eventNote', hook: 'data-panel-event-note' },
-    { key: 'range', field: 'rangeNote', hook: 'data-panel-range-note' },
-    { key: 'gap', field: 'gap', hook: 'data-panel-gap', isGap: true },
-    { key: 'offline', field: 'offlineNote', hook: 'data-panel-offline-note' },
-    { key: 'page', field: 'pageNote', hook: 'data-panel-page-note' },
-    { key: 'locate', field: 'locateNote', hook: 'data-locate-note' },
-    { key: 'excluded', field: 'excludedNote', hook: 'data-panel-excluded-note' },
-    { key: 'link', field: 'linkNote', hook: 'data-panel-link-note' },
-    { key: 'note', field: 'note', hook: 'data-panel-note' },
-    // 票 64:运行信息的数据来源与口径说明(设施实体适配器逐条生成、一条指标对应一个设施、
-    // 不跨行业合并)。表是顶部说明的唯一消费点——domain 新增说明字段而不进这张表即判红
-    // (t56 C 组的双向普查)。
-    { key: 'source', field: 'sourceNote', hook: 'data-panel-source-note' },
-]
-
-const summaryItems = computed(() =>
-    SUMMARY_SOURCES.flatMap(({ key, field, hook, isGap }) => {
-        const text = props.view[field]
-        if (!text) return []
-        return [{ key, text, isGap: Boolean(isGap), attrs: { [hook]: '', 'data-summary': key } }]
-    }),
-)
-
 /**
 /**
- * 折叠态也要说得出「有没有要关注的东西」——只留标题会把口径缺口读成「一切正常」。
- * 判据是 domain 的措辞本身(`'当前窗口没有离线设备'` 是 `monitoringPanels.js` 给的无离线句),
- * 渲染层不自己算任何数。`note` / `gap` / `excludedNote` 同属口径说明一档。
+ * 摘要的**变化签名**:只由「面板名 + 条目文案 + 折叠态读数」组成。
+ *
+ * 为什么不能直接 watch `props.view`:外壳模板里的 `:view="panelViewOf(panelId)"` 是方法调用,
+ * **每次重渲染都会造出一个新对象**。按对象身份 watch 会得到
+ * 「写入注册表 → 标题栏重渲染 → 外层重渲染 → 新对象 → 再写入」的自激环(页面直接卡死,
+ * 控制台只留一串 Uncaught in promise)。签名是标量串,内容不变时不会触发第二次写入。
  */
  */
-const summaryStatus = computed(() => {
-    const view = props.view
-    if (view.offlineNote && view.offlineNote !== '当前窗口没有离线设备') return '有离线设备'
-    if (view.gap || view.note || view.excludedNote) return '有口径提示'
-    return `${summaryItems.value.length} 条说明`
+const summarySignature = computed(() => {
+    const view = props.view || {}
+    const items = panelSummaryItems(view)
+    return JSON.stringify([view.name || '', panelSummaryStatus(view, items), items.map((item) => [item.key, item.text])])
+})
+
+watch(
+    summarySignature,
+    () => registerPanelSummary(props.view && props.view.name, props.view),
+    { immediate: true },
+)
+
+onBeforeUnmount(() => {
+    unregisterPanelSummary(props.view && props.view.name)
 })
 })
 
 
 /**
 /**
@@ -979,8 +995,8 @@ function blockKey(block) {
 //
 //
 // 四条纪律:
 // 四条纪律:
 //   · **只有 domain 声明 `paginate` 的块切窗口**:同一个 list 画法服务全仓多处清单,
 //   · **只有 domain 声明 `paginate` 的块切窗口**:同一个 list 画法服务全仓多处清单,
-//     管理信息明细(实测 9 行)与预警分析重点识别是规范排除项,应急保障另有专票(63),
-//     渲染层不猜、不顺手全翻;未声明的块 `windowOf` 原样交出 `block.rows`(同一引用、零 slice)。
+//     预警分析重点识别是规范排除项,应急保障另有专票(63);票 66 起管理信息改为多环统计、
+//     不再有清单块。渲染层不猜、不顺手全翻;未声明的块 `windowOf` 原样交出 `block.rows`。
 //   · **翻页算术只有一份**(条文 11):窗口、越界夹回、页码寻址与复位全在
 //   · **翻页算术只有一份**(条文 11):窗口、越界夹回、页码寻址与复位全在
 //     `domain/pagination.js`(`listPageKey`/`readListPage`/`writeListPage`/`resetListPages`
 //     `domain/pagination.js`(`listPageKey`/`readListPage`/`writeListPage`/`resetListPages`
 //     共用同一个键函数——读写键不一致就会造出「该复位的没复位」这类最难查的漂移),
 //     共用同一个键函数——读写键不一致就会造出「该复位的没复位」这类最难查的漂移),
@@ -1090,6 +1106,16 @@ function slotTotal(block) {
     return block.total
     return block.total
 }
 }
 
 
+/**
+ * 多环块里**单个环**的合计(环心读数,票 66):与 `slotTotal` 同一条口径 ——
+ * 无数据画占位符、真实 0 仍画 0。
+ */
+function groupTotal(group) {
+    if (!group) return designPlaceholder
+    if (group.placeholder) return designPlaceholder
+    return group.total
+}
+
 /** 数值位的 `data-panel-placeholder-value` 断言面(有值时不出现)。 */
 /** 数值位的 `data-panel-placeholder-value` 断言面(有值时不出现)。 */
 function slotAttr(row) {
 function slotAttr(row) {
     return row && row.placeholder ? '' : null
     return row && row.placeholder ? '' : null
@@ -1101,6 +1127,7 @@ function blockDrawsValues(block) {
     if (block.kind === 'kpi') return (block.items || []).length > 0
     if (block.kind === 'kpi') return (block.items || []).length > 0
     if (block.kind === 'trend') return (block.points || []).length > 0
     if (block.kind === 'trend') return (block.points || []).length > 0
     if (block.kind === 'dimbars') return (block.dimensions || []).some((dim) => (dim.rows || []).length > 0)
     if (block.kind === 'dimbars') return (block.dimensions || []).some((dim) => (dim.rows || []).length > 0)
+    if (block.kind === 'donuts') return (block.groups || []).length > 0
     return (block.rows || []).length > 0
     return (block.rows || []).length > 0
 }
 }
 
 
@@ -1416,6 +1443,8 @@ const trendBoxes = ref({})
 let trendObserver = null
 let trendObserver = null
 /** 写高度与重量绘图区之间的重入闸:样式是异步生效的,量要在下一帧再读。 */
 /** 写高度与重量绘图区之间的重入闸:样式是异步生效的,量要在下一帧再读。 */
 let trendFitting = false
 let trendFitting = false
+/** 重入闸关着时撞进来的观察事件:收尾后补跑一次(否则会永久停在偏大的高度上)。 */
+let trendRerun = false
 
 
 /**
 /**
  * view.blocks 里折线块(含多序列)的序号 —— 与 DOM 里 .panel-trend--line 的出现顺序
  * view.blocks 里折线块(含多序列)的序号 —— 与 DOM 里 .panel-trend--line 的出现顺序
@@ -1466,13 +1495,29 @@ function syncTrendHeight(charts, root) {
     const host = root.closest('.cockpit-panel__body')
     const host = root.closest('.cockpit-panel__body')
     if (!host) return
     if (!host) return
     const style = getComputedStyle(host)
     const style = getComputedStyle(host)
-    const next = fitTrendLayout(trendFitInputs({
-        hostHeightPx: host.clientHeight,
-        paddingTopPx: parseFloat(style.paddingTop),
-        paddingBottomPx: parseFloat(style.paddingBottom),
-        contentHeightPx: root.scrollHeight,
-        chartHeightsPx: charts.map((element) => element.offsetHeight),
-    }))
+    const padTop = parseFloat(style.paddingTop) || 0
+    const padBottom = parseFloat(style.paddingBottom) || 0
+    // 内容总高要量**整条面板内容**(.cockpit-panel__content:PanelBlocks 之外还有
+    // 预警/报警切换条、统计窗口状态行等兄弟),不能量 .panel-blocks 子树 ——
+    // 只量子树会把兄弟块的高度当成"可用富余"发给折线,接管后必然溢出(真机实测 42–76px)。
+    // 也不能用 body.scrollHeight:它在内容比视口短时会**贴着 clientHeight**(scrollHeight
+    // 不小于客户区),于是"富余"恒为 0,特性静默失效(真机实测:接管样本从 11 变 0)。
+    const content = host.querySelector('.cockpit-panel__content')
+    const contentHeightPx = content
+        ? content.getBoundingClientRect().height
+        : host.scrollHeight - padTop - padBottom
+    const next = fitTrendLayout({
+        ...trendFitInputs({
+            hostHeightPx: host.clientHeight,
+            paddingTopPx: padTop,
+            paddingBottomPx: padBottom,
+            contentHeightPx,
+            chartHeightsPx: charts.map((element) => element.offsetHeight),
+        }),
+        // min-height 是 CSS 的单一来源(64u 设计高度):算出来不比它高时不接管,
+        // 否则 CSS 下限会赢、块被撑到下限却按更小的预算算 → 溢出。
+        minHeightPx: parseFloat(getComputedStyle(charts[0]).minHeight),
+    })
     const current = trendFitLayout.value
     const current = trendFitLayout.value
     if (Math.abs(next.heightPx - current.heightPx) > 1
     if (Math.abs(next.heightPx - current.heightPx) > 1
         || Math.abs(next.paddingTopPx - current.paddingTopPx) > 1
         || Math.abs(next.paddingTopPx - current.paddingTopPx) > 1
@@ -1507,7 +1552,14 @@ function syncTrendBoxes(charts) {
  */
  */
 function syncTrendFit() {
 function syncTrendFit() {
     const root = rootRef.value
     const root = rootRef.value
-    if (!root || trendFitting) return
+    if (!root) return
+    // 观察器回调与"写高度→下一帧"的窗口会撞车:撞上的那一次若直接丢弃,就会永久停在一个
+    // 偏大的高度上(真机实测:清单数据比图表晚一帧到达 → 之后没有任何 DOM 变化再来触发,
+    // 图表停在 730px、面板体溢出 48px)。所以撞车时记一笔,等这一轮收尾后补跑。
+    if (trendFitting) {
+        trendRerun = true
+        return
+    }
     const charts = [...root.querySelectorAll('.panel-trend--line')]
     const charts = [...root.querySelectorAll('.panel-trend--line')]
     if (!charts.length) {
     if (!charts.length) {
         if (trendFitHeight.value !== 0) trendFitLayout.value = { heightPx: 0, paddingTopPx: 0, paddingBottomPx: 0 }
         if (trendFitHeight.value !== 0) trendFitLayout.value = { heightPx: 0, paddingTopPx: 0, paddingBottomPx: 0 }
@@ -1520,6 +1572,10 @@ function syncTrendFit() {
         const current = rootRef.value
         const current = rootRef.value
         if (!current) return
         if (!current) return
         syncTrendBoxes([...current.querySelectorAll('.panel-trend--line')])
         syncTrendBoxes([...current.querySelectorAll('.panel-trend--line')])
+        if (trendRerun) {
+            trendRerun = false
+            syncTrendFit()
+        }
     })
     })
 }
 }
 
 
@@ -1577,7 +1633,6 @@ watch(
     () => props.view.blocks,
     () => props.view.blocks,
     () => {
     () => {
         observeCards()
         observeCards()
-        supplementalVisible.value = false
         nextTick(() => { syncSupplementalVisibility(); syncTrendFit() })
         nextTick(() => { syncSupplementalVisibility(); syncTrendFit() })
     },
     },
     { deep: false },
     { deep: false },
@@ -1604,52 +1659,8 @@ onBeforeUnmount(() => {
 /* 两阶段真实测量:占布局但不可见,决定放得下后下一帧正式显示。 */
 /* 两阶段真实测量:占布局但不可见,决定放得下后下一帧正式显示。 */
 .panel-block.is-supplemental-measuring { visibility: hidden; pointer-events: none; }
 .panel-block.is-supplemental-measuring { visibility: hidden; pointer-events: none; }
 
 
-.panel-summary-disclosure {
-    position: relative;
-    /* 头部是 flex-wrap 容器:折叠入口独占一行(`flex-basis: 100%`),长句才能在栏宽内换行,
-       不被 max-content 顶出横向溢出(票 13 的「右侧被裁」是同一类缺陷)。 */
-    flex: 1 1 100%;
-    min-width: 0;
-}
-
-.panel-summary-disclosure__toggle {
-    display: inline-flex;
-    align-items: center;
-    gap: calc(5 * var(--u, 1px));
-    min-height: calc(24 * var(--u, 1px));
-    padding: calc(2 * var(--u, 1px)) calc(7 * var(--u, 1px));
-    border: 1px solid rgba(1, 171, 234, 0.38);
-    border-radius: 3px;
-    background: rgba(0, 20, 72, 0.58);
-    color: var(--text-2, #88b7e8);
-    font: inherit;
-    font-size: var(--fs-caption, 10.5px);
-    cursor: pointer;
-}
-.panel-summary-disclosure__toggle:hover,
-.panel-summary-disclosure__toggle:focus-visible {
-    border-color: var(--cyan-hi, #00b8ff);
-    color: var(--text-strong, #fff);
-    outline: none;
-}
-.panel-summary-disclosure__toggle b { color: var(--text, #d7ecff); font-weight: 700; }
-.panel-summary-disclosure__toggle i { color: var(--muted, #5680b0); font-style: normal; }
-.panel-summary-disclosure__details {
-    display: grid;
-    gap: calc(3 * var(--u, 1px));
-    margin-top: calc(4 * var(--u, 1px));
-    padding: calc(4 * var(--u, 1px)) calc(7 * var(--u, 1px));
-    border-left: 2px solid var(--cyan, #01abea);
-    background: rgba(0, 20, 72, 0.42);
-}
-/* 展开区内每条摘要自己收口:等宽长串(统计窗口的 ISO 时间)按字符断行,不溢出面板。 */
-.panel-summary-disclosure__details > span {
-    min-width: 0;
-    overflow-wrap: anywhere;
-}
-.panel-summary-disclosure__details > .panel-blocks__gap {
-    margin: 0;
-}
+/* 维护者 2026-10-29:摘要入口搬到面板标题栏(CockpitPanel.vue 的「?」按钮),
+   面板体不再画摘要条。 */
 .panel-blocks,
 .panel-blocks,
 .panel-blocks *,
 .panel-blocks *,
 .panel-blocks *::before,
 .panel-blocks *::before,
@@ -1666,12 +1677,7 @@ onBeforeUnmount(() => {
     min-height: 0;
     min-height: 0;
 }
 }
 
 
-.panel-blocks__head {
-    display: flex;
-    flex-wrap: wrap;
-    align-items: center;
-    gap: var(--space-2, 8px);
-}
+/* 摘要条已搬到标题栏的「?」按钮(2026-10-29);这条规则留着也无消费点,直接删。 */
 
 
 .panel-blocks__note {
 .panel-blocks__note {
     margin: 0;
     margin: 0;
@@ -1849,7 +1855,9 @@ onBeforeUnmount(() => {
 .panel-kpi--value-first .panel-kpi__label {
 .panel-kpi--value-first .panel-kpi__label {
     order: 2;
     order: 2;
     color: var(--text-2, #88b7e8);
     color: var(--text-2, #88b7e8);
-    font-size: var(--fs-caption, 10.5px);
+    /* 票 67 追加(2026-10-28):**不**在这里覆盖字号 —— 基础规则 .panel-kpi__label 的
+       宽度预算(100cqw)才是唯一口径;此处若另写 font-size,会把预算顶掉(选择器更 specific),
+       value-first 的窄格就又会被裁成「…」。字号与基础版一致,只有次序与颜色不同。 */
 }
 }
 
 
 .panel-kpi--value-first .panel-kpi__value,
 .panel-kpi--value-first .panel-kpi__value,
@@ -1874,6 +1882,20 @@ onBeforeUnmount(() => {
     overflow: hidden;
     overflow: hidden;
 }
 }
 
 
+/* 票 67 追加(2026-10-28):value-first 的读数**也**受宽度预算约束。
+   上面那条 min(22u, 34cqw) 是「不超过 22u、且不超过格宽 34%」的旧安全网;
+   但 34cqw 仍可能大于按**实际文本宽度**算出的可容字号(例如 8 位数字 78546.04),
+   于是 value-first 窄格里的长读数仍会画「…」。这里再叠一层:按 --kpi-reading-em
+   (含单位、见 domain/kpiFit.js)算出「刚好放得下」的字号,取两者更小的。
+   单独一条规则、放在原规则之后:同 specificity、后写的赢;原规则一字未动(t11 F9e 仍守它)。 */
+.panel-kpi--value-first .panel-kpi__value,
+.panel-kpi--value-first .panel-kpi__value--link {
+    font-size: min(
+        min(calc(22 * var(--u, 1px)), 34cqw),
+        calc((100cqw - calc(18 * var(--u, 1px))) / var(--kpi-reading-em, 5))
+    );
+}
+
 /* value-first 卡在画面上不显示单位(设计稿顶部四卡只有数字):
 /* value-first 卡在画面上不显示单位(设计稿顶部四卡只有数字):
    数据层仍带单位(悬停 title 由 kpiReading 给「10 台」),只是不占视觉位。 */
    数据层仍带单位(悬停 title 由 kpiReading 给「10 台」),只是不占视觉位。 */
 .panel-kpi--value-first .panel-kpi__value em {
 .panel-kpi--value-first .panel-kpi__value em {
@@ -1899,10 +1921,25 @@ onBeforeUnmount(() => {
     grid-column: 1 / -1;
     grid-column: 1 / -1;
     color: var(--text-2, #88b7e8);
     color: var(--text-2, #88b7e8);
     font-size: var(--fs-note, 9.2px);
     font-size: var(--fs-note, 9.2px);
+    /* 口径小字占整卡宽:同样按卡宽收字号(预算见 domain/kpiFit.js),到 7.2u 下限才换行。 */
+    font-size: max(
+        calc(8 * var(--u, 1px)),
+        min(
+            var(--fs-note, 9.2px),
+            calc((100cqw - calc(18 * var(--u, 1px))) / var(--kpi-caption-em, 6))
+        )
+    );
     letter-spacing: 0.02em;
     letter-spacing: 0.02em;
 }
 }
 
 
+/*
+  维护者 2026-10-28「指标卡尽量不要出现省略号」:卡片自身是**尺寸容器**,
+  卡内三行文本(标签/读数/小字)按 `--kpi-*-em` 预算用 `100cqw` 算字号 ——
+  窄格降字号、降到下限才换行,而不是画 `…`。预算按文本逐项由 domain/kpiFit.js 给
+  (渲染层不量宽度、不写第二份算术)。value-first 变体本就带 container-type,重复无害。
+  注意 `contain: inline-size` 只隔离尺寸计算:卡片宽度来自网格轨道,不受内容反向撑开。 */
 .panel-kpi {
 .panel-kpi {
+    container-type: inline-size;
     display: grid;
     display: grid;
     grid-template-columns: auto minmax(0, 1fr);
     grid-template-columns: auto minmax(0, 1fr);
     align-items: center;
     align-items: center;
@@ -1930,7 +1967,21 @@ onBeforeUnmount(() => {
    本文件里的类只管字体、颜色与排布。 */
    本文件里的类只管字体、颜色与排布。 */
 .panel-kpi__label {
 .panel-kpi__label {
     color: var(--muted, #5680b0);
     color: var(--muted, #5680b0);
+    /* 第一行仍是基准字号(t11 F9c 钉着 `var(--fs-caption`);第二行是**按卡宽收的覆盖**。 */
     font-size: var(--fs-caption, 10.5px);
     font-size: var(--fs-caption, 10.5px);
+    /* 字号 =(卡宽 − 标签列被吃掉的 gutter)/ 文本宽度(em),上限基准、下限 7.2u(可读性下限)。
+       gutter 与 em 都来自 domain/kpiFit.js 的预算:有图标 37u、无图标 18u,文本按字宽逐字算。
+       正常格宽算出来 ≥ 基准 ⇒ 不起作用;窄格才降。降到下限还放不下,由 EllipsisText 的
+       wrap 换行 —— 两条都不画 `…`。 */
+    font-size: max(
+        calc(8 * var(--u, 1px)),
+        min(
+            var(--fs-caption, 10.5px),
+            calc(
+                (100cqw - calc(var(--kpi-label-gutter-u, 37) * var(--u, 1px))) / var(--kpi-label-em, 6)
+            )
+        )
+    );
     letter-spacing: 0.04em;
     letter-spacing: 0.04em;
 }
 }
 
 
@@ -1959,6 +2010,12 @@ onBeforeUnmount(() => {
     grid-column: 3;
     grid-column: 3;
     text-align: right;
     text-align: right;
     font-size: var(--fs-kpi);
     font-size: var(--fs-kpi);
+    /* 票 67 追加:整行独占版的读数同样按宽度预算收(--kpi-reading-em 含单位)。
+       之前这里只有一个裸的 --fs-kpi,长读数(多位数 + 单位)会撑破并被裁成「…」。 */
+    font-size: min(
+        var(--fs-kpi),
+        calc((100cqw - calc(18 * var(--u, 1px))) / var(--kpi-reading-em, 5))
+    );
 }
 }
 
 
 .panel-kpi__value {
 .panel-kpi__value {
@@ -1967,6 +2024,15 @@ onBeforeUnmount(() => {
     font-family: var(--num, monospace);
     font-family: var(--num, monospace);
     /* 批次 2(票 15):KPI 数值是设计稿最显眼的档位(20px Rajdhani),单列 --fs-kpi。 */
     /* 批次 2(票 15):KPI 数值是设计稿最显眼的档位(20px Rajdhani),单列 --fs-kpi。 */
     font-size: var(--fs-kpi, 20px);
     font-size: var(--fs-kpi, 20px);
+    /* 维护者 2026-10-28:读数也按卡宽收字号(`--kpi-reading-em` 含单位,见 domain/kpiFit.js),
+       下限 12u。读数**不换行**(数字折断比小字难读),所以这条是防出框的主力。 */
+    font-size: max(
+        calc(10 * var(--u, 1px)),
+        min(
+            var(--fs-kpi, 20px),
+            calc((100cqw - calc(18 * var(--u, 1px))) / var(--kpi-reading-em, 5))
+        )
+    );
     line-height: 1.2;
     line-height: 1.2;
 }
 }
 
 
@@ -1990,6 +2056,14 @@ onBeforeUnmount(() => {
     font: inherit;
     font: inherit;
     font-family: var(--num, monospace);
     font-family: var(--num, monospace);
     font-size: var(--fs-kpi, 20px);
     font-size: var(--fs-kpi, 20px);
+    /* 可点读数与普通读数同一条宽度预算(它比普通读数多一条虚线底边,但不吃横向宽度)。 */
+    font-size: max(
+        calc(10 * var(--u, 1px)),
+        min(
+            var(--fs-kpi, 20px),
+            calc((100cqw - calc(18 * var(--u, 1px))) / var(--kpi-reading-em, 5))
+        )
+    );
     text-align: left;
     text-align: left;
     cursor: pointer;
     cursor: pointer;
     border-bottom: 1px dashed rgba(1, 171, 234, 0.45);
     border-bottom: 1px dashed rgba(1, 171, 234, 0.45);
@@ -2588,6 +2662,78 @@ onBeforeUnmount(() => {
 }
 }
 
 
 /* 环形图(2026-10-13:条形图疲劳后的占比形态;左图右例、中心合计)。 */
 /* 环形图(2026-10-13:条形图疲劳后的占比形态;左图右例、中心合计)。 */
+
+/* 票 63 宽屏补充图:更松的留白与视觉层级,不挤压初始设计块。 */
+.panel-block[data-panel-supplemental] {
+    margin-top: calc(8 * var(--u, 1px));
+    padding: calc(8 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.18);
+    border-radius: calc(8 * var(--u, 1px));
+    background: linear-gradient(135deg, rgba(1, 171, 234, 0.07), rgba(10, 31, 59, 0.2));
+}
+.panel-donut--wide {
+    grid-template-columns: minmax(calc(112 * var(--u, 1px)), 0.9fr) minmax(0, 1.35fr);
+    min-height: calc(124 * var(--u, 1px));
+    gap: calc(18 * var(--u, 1px));
+    padding: calc(8 * var(--u, 1px)) calc(12 * var(--u, 1px));
+}
+.panel-donut--wide .panel-donut__wrap { width: calc(112 * var(--u, 1px)); height: calc(112 * var(--u, 1px)); }
+.panel-donut--wide .panel-donut__legend { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: calc(8 * var(--u, 1px)); }
+.panel-donut--wide .panel-donut__row { padding: calc(7 * var(--u, 1px)) 0; }
+/* 排行行不再铺深色卡片底:只保留名次、文字与数据条,背景交给整个 supplemental 分节。 */
+.panel-block[data-panel-supplemental] .panel-bars--rank .panel-bar {
+    padding: calc(8 * var(--u, 1px)) 0;
+    background: transparent;
+}
+/* 宽屏指标可读性:补充区标题、标签、数值和单位整体提升一档。 */
+.panel-block[data-panel-supplemental] .panel-block__title { font-size: calc(13 * var(--u, 1px)); }
+.panel-block[data-panel-supplemental] .panel-bar__label,
+.panel-block[data-panel-supplemental] .panel-donut__label {
+    color: var(--text, #d7ecff);
+    font-size: calc(12 * var(--u, 1px));
+}
+.panel-block[data-panel-supplemental] .panel-bar__count,
+.panel-block[data-panel-supplemental] .panel-donut__count {
+    font-size: calc(13 * var(--u, 1px));
+    font-weight: 700;
+}
+.panel-block[data-panel-supplemental] .panel-bar__count em { font-size: calc(11 * var(--u, 1px)); }
+.panel-block[data-panel-supplemental] .panel-bars--rank .panel-bar__rank {
+    width: calc(20 * var(--u, 1px));
+    height: calc(20 * var(--u, 1px));
+    font-size: calc(11 * var(--u, 1px));
+}
+
+
+
+/* 维护者 2026-10-29:KPI「强调」档 —— 由 domain 用 block.emphasis 声明(不按 panelId 特判),
+   读数与标签各放大一档,用于"扫一眼读数"的主屏(运行概况)。 */
+.panel-kpis.is-emphasis .panel-kpi__value { font-size: calc(26 * var(--u, 1px)); }
+.panel-kpis.is-emphasis .panel-kpi__label { font-size: calc(12.5 * var(--u, 1px)); }
+.panel-kpis.is-emphasis .panel-kpi__caption { font-size: calc(11 * var(--u, 1px)); }
+
+/* 维护者 2026-10-29:环图「紧凑」版 —— 环小一号、图例单列不省略,整体高度压到不撑出滚动条。 */
+.panel-donut--compact {
+    display: grid;
+    grid-template-columns: calc(84 * var(--u, 1px)) minmax(0, 1fr);
+    align-items: center;
+    gap: calc(12 * var(--u, 1px));
+    min-height: 0;
+}
+.panel-donut--compact .panel-donut__wrap { width: calc(84 * var(--u, 1px)); height: calc(84 * var(--u, 1px)); }
+.panel-donut--compact .panel-donut__legend { grid-template-columns: minmax(0, 1fr); gap: calc(3 * var(--u, 1px)); }
+.panel-donut--compact .panel-donut__row { padding-block: calc(2 * var(--u, 1px)); background: transparent; }
+.panel-donut--compact .panel-donut__label { font-size: calc(11.5 * var(--u, 1px)); }
+.panel-donut--compact .panel-donut__count { font-size: calc(12 * var(--u, 1px)); }
+
+/* 维护者 2026-10-29:折线「紧凑」版 —— 给块标题留出上方呼吸空间,避免与子标题/点顶数值重叠;
+   高度压在 52u,宽屏仍由 fitTrend 接管。 */
+.panel-trend--compact {
+    min-height: calc(52 * var(--u, 1px));
+    padding-top: calc(12 * var(--u, 1px));
+    padding-bottom: calc(4 * var(--u, 1px));
+}
+
 .panel-donut {
 .panel-donut {
     display: flex;
     display: flex;
     align-items: center;
     align-items: center;
@@ -2712,6 +2858,113 @@ onBeforeUnmount(() => {
     font-size: var(--fs-caption, 10.5px);
     font-size: var(--fs-caption, 10.5px);
 }
 }
 
 
+/* 多环形网格(票 66 · 管理信息「片区 × 行业」):四行业小环 + 块底一份共用图例。
+   环心读数与扇区几何都由 domain 给(几何与 .panel-donut 同一套),这里只管排版。
+
+   **两档排版(维护者 2026-10-28 最终澄清)**:
+   · 正常屏幕比例:固定四列横排(四个行业一行),保持原来的紧凑统计带;
+   · 驾驶舱超宽模式:只在外壳既有断点 `3456/1239` 下改 2×2,并放大环填充超宽面板的竖向空白。
+   这里必须与 CockpitShell.vue 的栏内并排断点**完全同源**,不能再拿块宽/像素阈值猜“宽屏” ——
+   正常屏的面板在 1080p 也可能超过 340px,上一版因此把正常比例错误地切成了 2×2。 */
+.panel-donuts {
+    display: grid;
+    gap: calc(4 * var(--u, 1px));
+    min-width: 0;
+}
+
+/* 正常比例:四环横排。minmax(0,1fr) 保证窄栏只压缩格子,不让网格横向溢出。 */
+.panel-donuts__grid {
+    display: grid;
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+    gap: calc(6 * var(--u, 1px));
+    min-width: 0;
+}
+
+.panel-donut-cell {
+    display: grid;
+    justify-items: center;
+    gap: calc(2 * var(--u, 1px));
+    margin: 0;
+    min-width: 0;
+}
+
+.panel-donut-cell__wrap {
+    position: relative;
+    width: calc(52 * var(--u, 1px));
+    height: calc(52 * var(--u, 1px));
+}
+
+.panel-donut-cell__total {
+    position: absolute;
+    inset: 0;
+    display: grid;
+    place-items: center;
+    /* 与环形图中心读数同一条纪律:让位给整楔命中区,指针事件不落在这行字上。 */
+    pointer-events: none;
+    color: var(--text-strong, #fff);
+    font-family: var(--num, monospace);
+    font-size: var(--fs-caption, 10.5px);
+}
+
+.panel-donut-cell__label {
+    max-width: 100%;
+    overflow: hidden;
+    color: var(--text-2, #88b7e8);
+    font-size: var(--fs-caption, 10.5px);
+    text-align: center;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.panel-donuts__legend {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: center;
+    gap: calc(2 * var(--u, 1px)) calc(10 * var(--u, 1px));
+    margin: 0;
+    padding: 0;
+    list-style: none;
+}
+
+/* 超宽模式:与 CockpitShell.vue 的栏内并排断点同源。基础规则在上、覆盖规则在下,
+   同权重靠源码次序让 2×2 与放大尺寸真正生效。 */
+@media (min-aspect-ratio: 3456 / 1239) {
+    .panel-donuts {
+        gap: calc(10 * var(--u, 1px));
+    }
+
+    .panel-donuts__grid {
+        grid-template-columns: repeat(2, minmax(0, 1fr));
+        gap: calc(12 * var(--u, 1px)) calc(16 * var(--u, 1px));
+        padding: calc(6 * var(--u, 1px)) 0;
+    }
+
+    .panel-donut-cell {
+        gap: calc(4 * var(--u, 1px));
+    }
+
+    .panel-donut-cell__wrap {
+        /* 3456×1239 实测管理面板每格约 190px;128u(≈158px)进一步填满,
+           仍给 hover ×1.1 的外扩留约 15px 安全边,不撞标题、图例或隔壁环。 */
+        width: calc(128 * var(--u, 1px));
+        height: calc(128 * var(--u, 1px));
+    }
+
+    .panel-donut-cell__total {
+        font-size: var(--fs-body, 14px);
+    }
+
+    .panel-donut-cell__label {
+        color: var(--text, #d7ecff);
+        font-size: var(--fs-body, 14px);
+    }
+
+    .panel-donuts__legend {
+        gap: calc(6 * var(--u, 1px)) calc(16 * var(--u, 1px));
+        padding-top: calc(4 * var(--u, 1px));
+    }
+}
+
 .panel-stacked__label {
 .panel-stacked__label {
     color: var(--text-2, #88b7e8);
     color: var(--text-2, #88b7e8);
     font-size: var(--fs-caption, 10.5px);
     font-size: var(--fs-caption, 10.5px);
@@ -3177,4 +3430,18 @@ onBeforeUnmount(() => {
     white-space: nowrap;
     white-space: nowrap;
     flex: none;
     flex: none;
 }
 }
+
+/* 票 98:四大模式共享 UI 可读性统一。只改共享画法,不按 panelId 特判。 */
+.panel-block__title { font-size: calc(12 * var(--u, 1px)); }
+.panel-bar__label, .panel-donut__label, .panel-row dt, .panel-item__meta { font-size: calc(11.5 * var(--u, 1px)); }
+.panel-bar__count, .panel-donut__count, .panel-row dd { font-size: calc(12 * var(--u, 1px)); font-weight: 700; }
+.panel-card__label, .panel-item__title { font-size: calc(12 * var(--u, 1px)); }
+.panel-card__count { font-size: calc(18 * var(--u, 1px)); }
+.panel-card__note { font-size: calc(10.5 * var(--u, 1px)); }
+.panel-bars--rank .panel-bar { padding: calc(7 * var(--u, 1px)) 0; background: transparent; }
+.panel-bars--rank .panel-bar__rank { width: calc(19 * var(--u, 1px)); height: calc(19 * var(--u, 1px)); font-size: calc(11 * var(--u, 1px)); }
+.panel-donut__row { padding-block: calc(4 * var(--u, 1px)); background: transparent; }
+.panel-row { padding-block: calc(5 * var(--u, 1px)); }
+.panel-item { background: linear-gradient(90deg, rgba(1, 171, 234, 0.035), transparent); }
+
 </style>
 </style>

+ 19 - 80
src/modules/cockpit/components/WarningResponsePane.vue

@@ -31,43 +31,25 @@
             {{ rangeLabel }} · {{ eventStore.loading ? '正在更新…' : '当前统计窗口' }}
             {{ rangeLabel }} · {{ eventStore.loading ? '正在更新…' : '当前统计窗口' }}
         </p>
         </p>
         <p v-if="!eventStore.loading && section && section.reason && /历史接口回显/.test(section.reason)" class="warning-response-pane__range-status" role="alert">{{ section.reason }}</p>
         <p v-if="!eventStore.loading && section && section.reason && /历史接口回显/.test(section.reason)" class="warning-response-pane__range-status" role="alert">{{ section.reason }}</p>
-        <PanelBlocks v-if="view && !eventStore.loading" :key="`${panelId}-${eventStore.mode}-${eventStore.range}`" :view="view" @list-page="onListPage" @open-card="openWorkOrder" @card-action="onCardAction" />
-        <!--
-            票 65:工单时间线浮层。报警效率面板的工单卡片点开后在这里看轨迹。
-            为什么不在面板里展开:卡片列表本身已经很长,内联展开会把下方内容推走;
-            浮层也让「点开、读完、关掉」的路径与设备详情的工单时间线一致(票 12 的形态)。
-            只读:这里没有任何写入口(工单状态由运营侧改,本面板只展示)。
-        -->
-        <div v-if="openedOrder" class="warning-response-pane__modal" role="dialog" aria-modal="true" :aria-label="`工单 ${openedOrder.code} 的处置轨迹`" data-work-order-modal>
-            <div class="warning-response-pane__modal-head">
-                <strong class="warning-response-pane__modal-title">{{ openedOrder.code }}</strong>
-                <StatusTag v-if="openedOrder.stateName" :tone="openedOrder.tone" :label="openedOrder.stateName" />
-                <button type="button" class="warning-response-pane__modal-close" data-work-order-modal-close @click="closeWorkOrder">关闭</button>
-            </div>
-            <p v-if="openedOrder.description" class="warning-response-pane__modal-desc">{{ openedOrder.description }}</p>
-            <p v-if="timelinePending" class="warning-response-pane__modal-hint">正在读取处置轨迹…</p>
-            <p v-else-if="timelineFailed" class="warning-response-pane__modal-hint" data-work-order-modal-failed>
-                处置轨迹未取到(工单本身的信息不受影响)
-            </p>
-            <WorkOrderTimeline v-else-if="track" :track="track" />
-        </div>
+        <PanelBlocks v-if="view && !eventStore.loading" :key="`${panelId}-${eventStore.mode}-${eventStore.range}`" :view="view" @list-page="onListPage" @card-action="onCardAction" />
     </div>
     </div>
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { computed, ref } from 'vue'
-import { EVENT_RANGES, workOrderStateTone } from '../domain/warningResponsePanels.js'
+import { computed } from 'vue'
+import { EVENT_RANGES } from '../domain/warningResponsePanels.js'
 import { eventListSwitchOptions } from '../domain/eventList.js'
 import { eventListSwitchOptions } from '../domain/eventList.js'
 import { panelView } from '../domain/panelViews.js'
 import { panelView } from '../domain/panelViews.js'
-import { tracksByOrder } from '../domain/workOrderTimeline.js'
-import { fetchWorkOrderTimeline } from '../services/detailApi.js'
+import { panelSideFor } from '../domain/panelCatalog.js'
 import { useEventStore } from '../stores/eventStore.js'
 import { useEventStore } from '../stores/eventStore.js'
+import { useEntityDetailStore } from '../stores/entityDetailStore.js'
 import PanelBlocks from './PanelBlocks.vue'
 import PanelBlocks from './PanelBlocks.vue'
-import StatusTag from './StatusTag.vue'
-import WorkOrderTimeline from './WorkOrderTimeline.vue'
 
 
 const props = defineProps({ panelId: { type: String, required: true } })
 const props = defineProps({ panelId: { type: String, required: true } })
 const eventStore = useEventStore()
 const eventStore = useEventStore()
+const entityDetailStore = useEntityDetailStore()
+/** 详情浮层贴来源侧:本面板在哪一栏由目录单源给出,组件不自己判。 */
+const side = computed(() => panelSideFor('warningResponse', props.panelId) || 'right')
 const modeOptions = eventListSwitchOptions()
 const modeOptions = eventListSwitchOptions()
 const rangeOptions = EVENT_RANGES
 const rangeOptions = EVENT_RANGES
 const rangeLabel = computed(() => EVENT_RANGES.find((item) => item.key === eventStore.range)?.label || '')
 const rangeLabel = computed(() => EVENT_RANGES.find((item) => item.key === eventStore.range)?.label || '')
@@ -104,65 +86,22 @@ function onListPage(payload) {
     }
     }
 }
 }
 
 
-// ---- 票 65:工单卡片 → 处置轨迹浮层 ----
-
-/** 当前打开的工单(含状态标签所需的 tone;卡片行已算好,这里只挑要用的字段)。 */
-const openedOrder = ref(null)
-/** 轨迹分区(点开时才取,取完缓存到本次打开期间)。 */
-const track = ref(null)
-const trackState = ref('idle') // idle | loading | done | failed
-const timelinePending = computed(() => trackState.value === 'loading')
-const timelineFailed = computed(() => trackState.value === 'failed')
-
-/**
- * 点开工单卡片:先立即可见(状态/描述来自列表已有数据),再异步补轨迹。
- *
- * 为什么分两步:轨迹是**另一个端点**的第二次往返,等它回来才弹窗会让人觉得"点了没反应";
- * 而卡片上的状态与描述本来就已经在手上,先画出来才是即时反馈。
- * 轨迹取不到时如实说「未取到」,不把"没取到"画成"这张工单没有动作记录"(票 12/票 11 纪律)。
- */
+/** 历史事件与工单按显式身份分流,禁止把事件卡误开成工单。 */
 function onCardAction(action) {
 function onCardAction(action) {
-    const code = action && action.row && action.row.workOrderCode
-        ? action.row.workOrderCode
-        : action && action.code
-    return openWorkOrder(code)
-}
-
-async function openWorkOrder(code) {
-    const list = (view.value.blocks || []).find((block) => block.kind === 'list')
-    const row = ((list && list.rows) || []).find((item) => item.code === code)
-    if (!row) return
-    openedOrder.value = {
-        code: row.code,
-        stateName: row.stateName,
-        tone: workOrderStateTone(row.stateCode),
-        description: row.description,
+    const row = (action && action.row) || {}
+    const id = (action && action.code) || ''
+    if (!id) return false
+    const prefill = row.identityPayload || row
+    if (action.kind === 'event') {
+        eventStore.closeEvent()
+        return entityDetailStore.show({ kind: 'event', id, family: row.identityFamily || row.family || eventStore.mode }, prefill, { side: side.value })
     }
     }
-    track.value = null
-    trackState.value = 'loading'
-    try {
-        // 不传 config:`fetchSection` 会走当前生效配置(`getCockpitConfig()`),
-        // 与 store 里那四次取数同一条路径。`config` 选项只留给单测注入。
-        const result = await fetchWorkOrderTimeline(code)
-        if (!openedOrder.value || openedOrder.value.code !== code) return
-        if (result && (result.state === 'success' || result.state === 'stale') && result.data) {
-            // 复用票 12 的同一条轨迹转换(`tracksByOrder` → `trackOf`),不另写一份。
-            const { byCode } = tracksByOrder([result.data])
-            track.value = byCode[code] || null
-            trackState.value = track.value ? 'done' : 'failed'
-        } else {
-            trackState.value = 'failed'
-        }
-    } catch (error) {
-        trackState.value = 'failed'
+    if (action.kind === 'work-order') {
+        return entityDetailStore.show({ kind: 'work-order', id }, prefill, { side: side.value })
     }
     }
+    return false
 }
 }
 
 
-function closeWorkOrder() {
-    openedOrder.value = null
-    track.value = null
-    trackState.value = 'idle'
-}
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>

+ 96 - 0
src/modules/cockpit/domain/adaptiveResolution.js

@@ -0,0 +1,96 @@
+/**
+ * 自适应渲染分辨率(票 61 第十九轮:以最优帧率为目标、画质只在需要时让位)。
+ *
+ * ## 思路
+ *
+ * 维护者要「最优帧率,同时保持当前质量」。固定降档(0.75 / 移动 0.6)是**一刀切**:
+ * 快机器白让了画质,慢机器又可能不够。自适应的做法是**按实测帧间隔动态微调**:
+ *
+ *   · 帧间隔 p95 持续偏慢(掉帧)→ 分辨率降一档(像素量立刻少 ~10%);
+ *   · 持续偏快(有富余)→ 分辨率升一档,回到画质上限;
+ *   · 中间地带不动 —— 避免在阈值附近来回抖。
+ *
+ * 画质上限 = 部署配置的基准档(默认 0.75),下限 0.55:只有机器真的撑不住时才让位,
+ * 且让得有限 —— 这就是「保持当前质量」的量化表达。
+ *
+ * 纯函数:输入窗口统计与当前档位,输出下一步档位与计时器,脱浏览器可断言。
+ */
+
+/** 帧间隔窗口的分位数(p95)—— 与 DEV 面板的 movingFrameIntervals 同口径。 */
+export function percentile(sorted, p) {
+    if (!Array.isArray(sorted) || !sorted.length) return null
+    const list = [...sorted].sort((a, b) => a - b)
+    const index = Math.min(list.length - 1, Math.floor(list.length * p))
+    return list[index]
+}
+
+/**
+ * 推进一步自适应决策。
+ *
+ * @param {object} state
+ * @param {number} state.p95Ms       最近窗口的帧间隔 p95(毫秒)
+ * @param {number} state.currentScale 当前内部分辨率
+ * @param {number} state.baseScale   画质上限(部署基准档)
+ * @param {number} [state.floorScale] 画质下限(默认 0.55)
+ * @param {number} state.nowMs       当前时刻
+ * @param {number|null} state.hotSinceMs  「持续偏慢」计时起点(null = 未在累计)
+ * @param {number|null} state.coldSinceMs 「持续偏快」计时起点
+ * @param {number} [state.hotHoldMs]  偏慢要持续多久才降档(默认 1500)
+ * @param {number} [state.coldHoldMs] 偏快要持续多久才升档(默认 4000,慢于降档防抖动)
+ * @param {number} [state.step]       每档步长(默认 0.05)
+ * @param {number} [state.hotMs]      「偏慢」阈值(默认 21ms ≈ 48fps)
+ * @param {number} [state.coldMs]     「偏快」阈值(默认 16.7ms = 60fps)
+ * @returns {{scale:number, hotSinceMs:(number|null), coldSinceMs:(number|null), changed:boolean}}
+ */
+export function stepAdaptiveResolution(state = {}) {
+    const base = Number(state.baseScale)
+    const current = Number(state.currentScale)
+    const floor = Number.isFinite(Number(state.floorScale)) ? Number(state.floorScale) : 0.55
+    const step = Number.isFinite(Number(state.step)) ? Number(state.step) : 0.05
+    const hotMs = Number.isFinite(Number(state.hotMs)) ? Number(state.hotMs) : 21
+    const coldMs = Number.isFinite(Number(state.coldMs)) ? Number(state.coldMs) : 16.7
+    const hotHoldMs = Number.isFinite(Number(state.hotHoldMs)) ? Number(state.hotHoldMs) : 1500
+    const coldHoldMs = Number.isFinite(Number(state.coldHoldMs)) ? Number(state.coldHoldMs) : 4000
+    const nowMs = Number(state.nowMs)
+    const p95 = Number(state.p95Ms)
+
+    // 基础校验:任何关键量非法都「什么都不做」(宁可不动,不猜一个档位)。
+    if (!Number.isFinite(base) || !Number.isFinite(current) || !Number.isFinite(nowMs) || !Number.isFinite(p95)) {
+        return { scale: current, hotSinceMs: state.hotSinceMs ?? null, coldSinceMs: state.coldSinceMs ?? null, changed: false }
+    }
+
+    const clampUp = (v) => Math.min(base, Math.max(floor, v))
+    let { hotSinceMs = null, coldSinceMs = null } = state
+
+    if (p95 > hotMs) {
+        // 偏慢:累计「降档观察期」,偏快计时清零(方向相反的两个计时不能同时走)。
+        coldSinceMs = null
+        if (hotSinceMs === null) hotSinceMs = nowMs
+        if (nowMs - hotSinceMs >= hotHoldMs) {
+            const next = clampUp(current - step)
+            const changed = Math.abs(next - current) > 1e-6
+            return { scale: next, hotSinceMs: null, coldSinceMs: null, changed }
+        }
+    } else if (p95 < coldMs) {
+        hotSinceMs = null
+        if (coldSinceMs === null) coldSinceMs = nowMs
+        if (nowMs - coldSinceMs >= coldHoldMs) {
+            const next = clampUp(current + step)
+            const changed = Math.abs(next - current) > 1e-6
+            return { scale: next, hotSinceMs: null, coldSinceMs: null, changed }
+        }
+    } else {
+        // 中间地带(coldMs..hotMs):两个方向都不累计 —— 这是防抖带,不许在这个区间里来回切。
+        hotSinceMs = null
+        coldSinceMs = null
+    }
+    return { scale: current, hotSinceMs, coldSinceMs, changed: false }
+}
+
+/**
+ * 从一组帧间隔(毫秒)算出 p95;样本不足返回 null(调用方据此跳过本轮决策)。
+ */
+export function frameIntervalP95(intervalsMs, minSamples = 30) {
+    if (!Array.isArray(intervalsMs) || intervalsMs.length < minSamples) return null
+    return percentile(intervalsMs.filter((n) => Number.isFinite(n) && n > 0), 0.95)
+}

+ 217 - 0
src/modules/cockpit/domain/alarmBacklog.js

@@ -0,0 +1,217 @@
+/**
+ * 报警「每日未处理积压」的纯算法(票 96)。
+ *
+ * ── 为什么需要它 ──
+ * 概览「近7日报警数」原先直接消费后端 `sevenDayTrend[].unhandledTotal`。维护者 2026-10-28 处置掉
+ * 一条报警后,整条线从 `1,2,2,2,2,2,2` 一起变成 `1,1,1,1,1,1,1` —— 说明后端是「按当天及以前创建、
+ * 用**当前** `alarm_status` 过滤」算的,历史日会被今天的处置改写,不能当历史序列用。
+ *
+ * 这里的算法用**原始报警记录**(`/events/current?mode=ALARM` 的 `eventTime`/`status`/`updatedTime`)
+ * 逐日还原「当天结束时刻仍未处理」的条数:
+ *
+ *     backlog(D) = #{ e : eventTime ≤ 当天结束 且 ( 仍未处理 或 处置时刻 > 当天结束 ) }
+ *
+ * 于是历史日是**当天的真相**:今天再处置多少条,过去的点都不动(闸门把这条钉死)。
+ *
+ * ── 与「当前状态」的区别(本票的核心) ──
+ * 「当前未处理 N 条」是**今天**的读数;把它抄给七天就是把历史抹平(正是被修掉的缺陷)。
+ *
+ * ── 边界与降级(都不许用 0 顶替) ──
+ * 缺 `eventTime`、HANDLED 缺处置时刻、出现未知 `status`、分页截断、没有任何记录 ——
+ * 一律返回 `counts: null` + 原因码,由视图层如实说明「这条线为什么不画」。
+ */
+
+/** 业务时区单一来源:后端信封回显 `Asia/Shanghai`;回显不同时由调用方传入回显值。 */
+export const DEFAULT_BACKLOG_TIME_ZONE = 'Asia/Shanghai'
+
+/** 不画第二条线的原因码(视图层据此给文案;空串=正常)。 */
+export const BACKLOG_GAP = {
+    NONE: '',
+    NO_EVENTS: 'NO_EVENTS',
+    PARTIAL: 'PARTIAL',
+    BAD_DATES: 'BAD_DATES',
+    MISSING_EVENT_TIME: 'MISSING_EVENT_TIME',
+    MISSING_HANDLED_TIME: 'MISSING_HANDLED_TIME',
+    UNKNOWN_STATUS: 'UNKNOWN_STATUS',
+}
+
+const formatters = new Map()
+
+/** 复用一个 `Intl.DateTimeFormat`(构造成本高,逐条记录都建会拖慢整页)。 */
+function zoneFormatter(timeZone) {
+    let formatter = formatters.get(timeZone)
+    if (!formatter) {
+        formatter = new Intl.DateTimeFormat('en-US', {
+            timeZone,
+            hourCycle: 'h23',
+            year: 'numeric',
+            month: '2-digit',
+            day: '2-digit',
+            hour: '2-digit',
+            minute: '2-digit',
+            second: '2-digit',
+        })
+        formatters.set(timeZone, formatter)
+    }
+    return formatter
+}
+
+/**
+ * 某时刻在该时区的 UTC 偏移(分钟)。用 `Intl` 反解,**不写死 +08:00** ——
+ * 写死偏移在别的部署时区(或有夏令时的时区)会整体错一小时,而且错得看不出来。
+ */
+export function zoneOffsetMinutes(epochMs, timeZone = DEFAULT_BACKLOG_TIME_ZONE) {
+    if (!Number.isFinite(epochMs)) return NaN
+    const parts = Object.fromEntries(
+        zoneFormatter(timeZone).formatToParts(new Date(epochMs)).map((part) => [part.type, part.value]),
+    )
+    const asUtc = Date.UTC(
+        Number(parts.year),
+        Number(parts.month) - 1,
+        Number(parts.day),
+        Number(parts.hour) % 24,
+        Number(parts.minute),
+        Number(parts.second),
+    )
+    return Math.round((asUtc - epochMs) / 60000)
+}
+
+/**
+ * `YYYY-MM-DD` 在该时区的**结束时刻**(23:59:59.999 的 epoch ms)。
+ * 解析不了回 `NaN`(调用方据此报 BAD_DATES,不猜今天)。
+ */
+export function endOfDayEpoch(dateKey, timeZone = DEFAULT_BACKLOG_TIME_ZONE) {
+    const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(dateKey == null ? '' : dateKey).trim())
+    if (!matched) return NaN
+    const naive = Date.UTC(Number(matched[1]), Number(matched[2]) - 1, Number(matched[3]), 23, 59, 59, 999)
+    const offset = zoneOffsetMinutes(naive, timeZone)
+    return Number.isFinite(offset) ? naive - offset * 60000 : NaN
+}
+
+/**
+ * 后端时间串 → epoch ms。
+ *
+ * 带偏移(`+08:00`/`Z`)的按偏移解析;**不带偏移的按业务时区的本地时间**解析
+ * (后端确实会给无偏移的本地串,直接丢给 `Date.parse` 会被当成浏览器本地时区,整页错一小时)。
+ * 空串/非法 → `NaN`(由调用方报缺值,不默认成 0)。
+ */
+export function parseInstant(value, timeZone = DEFAULT_BACKLOG_TIME_ZONE) {
+    const text = typeof value === 'string' ? value.trim() : ''
+    if (!text) return NaN
+    if (/(?:z|[+-]\d{2}:?\d{2})$/i.test(text)) {
+        const ms = Date.parse(text)
+        return Number.isFinite(ms) ? ms : NaN
+    }
+    const matched = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?/.exec(text)
+    if (!matched) return NaN
+    const naive = Date.UTC(
+        Number(matched[1]), Number(matched[2]) - 1, Number(matched[3]),
+        Number(matched[4]), Number(matched[5]), Number(matched[6] || 0),
+    )
+    const offset = zoneOffsetMinutes(naive, timeZone)
+    return Number.isFinite(offset) ? naive - offset * 60000 : NaN
+}
+
+/**
+ * 后端「处置时刻」字段名(票 97 向后端申请的契约;后端最终换名只需改这一处)。
+ *
+ * 为什么现在就留这个口:票 97 的核查发现**数据其实已经存在**后端 ——
+ * `app_user.alarm_log` 有 `alarm_id` + `oper_time`,后端自己算处置时长时用的就是
+ * `MIN(alarm_log.oper_time)`(`AlarmDataMapper.xml`)。所以这不是"要新造字段",而是
+ * "把它暴露到事件端点"。暴露后前端零改动即可精确,故这里先做向后兼容的读取。
+ */
+export const HANDLED_AT_FIELD = 'handledAt'
+
+/**
+ * 一条报警记录的处置时刻(epoch ms);未处理回 `null`,判不出来回 `NaN`。
+ *
+ * 两级来源,优先级明确:
+ *   1. `handledAt`(票 97 申请的字段,= 后端 `MIN(alarm_log.oper_time)`)—— **精确**;
+ *   2. 回退到 `updatedTime` —— 只是**上界**。研究实测(`research/alarm-backlog-data.md`)
+ *      两条 UNHANDLED 记录的 `updatedTime` 比 `eventTime` 晚 94.5h / 404.4h,它只是"最近更新"。
+ *      于是用回退路径时:真实处置时刻 ≤ `updatedTime` ⇒ 积压**只会算高、不会算低**。
+ *   都没有(HANDLED 且两个字段都缺/非法)→ `NaN`,由调用方"整条线不画"(不猜)。
+ */
+export function handledAtOf(event, timeZone = DEFAULT_BACKLOG_TIME_ZONE) {
+    const status = String((event && event.status) || '').trim().toUpperCase()
+    if (status === 'UNHANDLED') return null
+    if (status !== 'HANDLED') return NaN
+    const explicit = parseInstant(event && event[HANDLED_AT_FIELD], timeZone)
+    if (Number.isFinite(explicit)) return explicit
+    return parseInstant(event && event.updatedTime, timeZone)
+}
+
+/**
+ * 逐日积压序列。
+ *
+ * @param {object} input
+ * @param {Array<{status?: string, eventTime?: string, updatedTime?: string}>} input.events 原始报警记录
+ * @param {string[]} input.dates 与后端 `sevenDayTrend` 同序的 7 个 `YYYY-MM-DD`
+ * @param {string} [input.timeZone] 业务时区(缺省 Asia/Shanghai)
+ * @param {boolean} [input.complete] 记录是否**取全**(分页截断时 false)
+ * @returns {{counts: number[]|null, reason: string}}
+ */
+export function alarmBacklogSeries({ events, dates, timeZone = DEFAULT_BACKLOG_TIME_ZONE, complete = true } = {}) {
+    const days = Array.isArray(dates) ? dates.map((date) => String(date == null ? '' : date).trim()) : []
+    if (!days.length) return { counts: null, reason: BACKLOG_GAP.BAD_DATES }
+    const dayEnds = days.map((date) => endOfDayEpoch(date, timeZone))
+    if (dayEnds.some((end) => !Number.isFinite(end))) return { counts: null, reason: BACKLOG_GAP.BAD_DATES }
+    // 截断的记录集算出来的积压只会偏小 —— 宁可不说,也不给一个偏小的假线。
+    if (!complete) return { counts: null, reason: BACKLOG_GAP.PARTIAL }
+    const list = Array.isArray(events) ? events : []
+    // 「没有记录」≠「积压为零」:空列表通常意味着另一个分区没取到,不画线并说明。
+    if (!list.length) return { counts: null, reason: BACKLOG_GAP.NO_EVENTS }
+
+    const rows = []
+    for (const event of list) {
+        const at = parseInstant(event && event.eventTime, timeZone)
+        if (!Number.isFinite(at)) return { counts: null, reason: BACKLOG_GAP.MISSING_EVENT_TIME }
+        // 先认状态再谈处置时刻:认不出的状态不能落到"缺处置时间"那条原因上
+        // (两个原因对值班人员的含义完全不同:一个是数据缺字段,一个是词表出现新值)。
+        const status = String((event && event.status) || '').trim().toUpperCase()
+        if (status !== 'HANDLED' && status !== 'UNHANDLED') return { counts: null, reason: BACKLOG_GAP.UNKNOWN_STATUS }
+        const handledAt = status === 'HANDLED' ? handledAtOf(event, timeZone) : null
+        if (Number.isNaN(handledAt)) return { counts: null, reason: BACKLOG_GAP.MISSING_HANDLED_TIME }
+        rows.push({ at, handledAt })
+    }
+
+    const counts = dayEnds.map((end) => rows.reduce((sum, row) => {
+        if (row.at > end) return sum
+        if (row.handledAt === null) return sum + 1
+        return row.handledAt > end ? sum + 1 : sum
+    }, 0))
+    return { counts, reason: BACKLOG_GAP.NONE }
+}
+
+/** 口径限制(始终显示,不是错误):处置时刻只有"最近更新"这个上界。 */
+export const BACKLOG_CALIBER_NOTE = ';未处理=报警状态未解除;处置时刻取"最近更新",积压只会偏高'
+
+/**
+ * 报警记录快照落后时的一句提醒(`snapshotAt` 的**业务时区日期**早于参考日时)。
+ * 端点实测是每日快照(`updatedAt` 常比 live 聚合落后一天),所以"今天"那一点可能晚一拍 ——
+ * 宁可把快照时间说出来,也不让读者以为它就是此刻。
+ */
+export function backlogSnapshotNote(snapshotAt, referenceAt, timeZone = DEFAULT_BACKLOG_TIME_ZONE) {
+    const snapshot = parseInstant(snapshotAt, timeZone)
+    const reference = parseInstant(referenceAt, timeZone)
+    // 两个时刻缺一个就不猜:不知道"相对谁落后"时,宁可不提醒。
+    if (!Number.isFinite(snapshot) || !Number.isFinite(reference)) return ''
+    const dayOf = (epochMs) => new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(epochMs))
+    const snapshotDay = dayOf(snapshot)
+    if (Number.isFinite(reference) && dayOf(reference) <= snapshotDay) return ''
+    const clock = new Intl.DateTimeFormat('zh-CN', { timeZone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(new Date(snapshot))
+    return `;报警记录快照 ${snapshotDay.slice(5)} ${clock},今天解除的报警要等快照更新`
+}
+
+/** 原因码 → 面板口径行的一句话(渲染层不拼词,词只在这里)。 */
+export function backlogGapNote(reason) {
+    switch (reason) {
+        case BACKLOG_GAP.NO_EVENTS: return ';报警记录未取到,未处理曲线不画'
+        case BACKLOG_GAP.PARTIAL: return ';报警记录分页未取全,未处理曲线不画(不补 0)'
+        case BACKLOG_GAP.MISSING_EVENT_TIME: return ';报警记录缺发生时间,未处理曲线不画(不猜)'
+        case BACKLOG_GAP.MISSING_HANDLED_TIME: return ';已处置报警缺处置时间,未处理曲线不画(不猜)'
+        case BACKLOG_GAP.UNKNOWN_STATUS: return ';报警记录状态无法识别,未处理曲线不画'
+        case BACKLOG_GAP.BAD_DATES: return ';统计窗口日期异常,未处理曲线不画'
+        default: return ''
+    }
+}

+ 26 - 7
src/modules/cockpit/domain/chartFit.js

@@ -42,22 +42,41 @@ export const TREND_BOTTOM_BREATHING_RATIO = 0.05
 /** 上下呼吸空间合计(14%):比上一轮 12% 小幅增加,并把新增空间集中到上方。 */
 /** 上下呼吸空间合计(14%):比上一轮 12% 小幅增加,并把新增空间集中到上方。 */
 export const TREND_VERTICAL_BREATHING_RATIO = TREND_TOP_BREATHING_RATIO + TREND_BOTTOM_BREATHING_RATIO
 export const TREND_VERTICAL_BREATHING_RATIO = TREND_TOP_BREATHING_RATIO + TREND_BOTTOM_BREATHING_RATIO
 
 
+/**
+ * 接管时必须留出的**安全余量**(px):宁可少长 10px,也不能贴着面板体边界。
+ *
+ * 维护者 2026-10-28 反馈:拉伸过头会让本该不出滚动条的面板出滚动条。边界上任何
+ * 亚像素/取整/后到的内容(例如清单数据比图表晚一帧到达)都会把面板体顶出 1px,
+ * 而 1px 就足够让 `overflow-y: auto` 画出滚动条 —— 所以这里留的是绝对余量,不是比例。
+ */
+export const TREND_SAFETY_SLACK_PX = 10
+
 /**
 /**
  * 宽屏折线的真实布局预算:呼吸空间必须**落到图表内部的 padding**,不能只从总高扣掉。
  * 宽屏折线的真实布局预算:呼吸空间必须**落到图表内部的 padding**,不能只从总高扣掉。
  *
  *
- * 返回 0 表示不接管(正常屏仍由 64u 下限与既有 padding 管);接管时:
- *   · heightPx = 每张折线拿满自己的可用净高(只留 1px 取整护栏);
- *   · paddingTopPx / paddingBottomPx = 按净高 9% / 5% 明确写进 CSS。
- * 这样留下的空间就在标题与绘图区之间,而不是落到整个块的下方。
+ * 返回全 0 表示不接管(正常屏仍由 64u 下限与既有 padding 管);接管时:
+ *   · heightPx = 可用净高 − 安全余量(**含**它自己的上下 padding,border-box);
+ *   · paddingTopPx / paddingBottomPx = 按可用净高 9% / 5% 明确写进 CSS。
+ *
+ * 两条"不许接管"的边界,都是为了**永不把面板体顶出滚动条**:
+ *   1. 净高不足(≤ 0)——内容本来就比面板长,接管只会更糟;
+ *   2. 算出来的高度**不大于 CSS 的 min-height**(`minHeightPx`)—— 那种情况下 CSS 的下限会赢,
+ *      块还是被撑到 min-height,而预算只按更小的值算,于是必然溢出
+ *      (真机实测:3440×1440 概览面板 fit=79 但 min-height=114 → 溢出 34px)。
  */
  */
-export function fitTrendLayout({ budgetPx, othersPx, count } = {}) {
+export function fitTrendLayout({ budgetPx, othersPx, count, minHeightPx = 0 } = {}) {
     const budget = Number(budgetPx) || 0
     const budget = Number(budgetPx) || 0
     const others = Number(othersPx) || 0
     const others = Number(othersPx) || 0
     const charts = Math.max(1, Math.floor(Number(count) || 0))
     const charts = Math.max(1, Math.floor(Number(count) || 0))
+    const floor = Math.max(0, Number(minHeightPx) || 0)
+    const idle = { heightPx: 0, paddingTopPx: 0, paddingBottomPx: 0 }
     const available = (budget - others) / charts
     const available = (budget - others) / charts
-    if (!(available > 1)) return { heightPx: 0, paddingTopPx: 0, paddingBottomPx: 0 }
+    if (!(available > 0)) return idle
+    const heightPx = Math.floor(available) - TREND_SAFETY_SLACK_PX
+    // 交还 CSS 下限:算出来不比 min-height 高时接管毫无收益,还会因为"下限赢"而溢出。
+    if (heightPx <= floor) return idle
     return {
     return {
-        heightPx: Math.floor(available) - 1,
+        heightPx,
         paddingTopPx: Math.floor(available * TREND_TOP_BREATHING_RATIO),
         paddingTopPx: Math.floor(available * TREND_TOP_BREATHING_RATIO),
         paddingBottomPx: Math.floor(available * TREND_BOTTOM_BREATHING_RATIO),
         paddingBottomPx: Math.floor(available * TREND_BOTTOM_BREATHING_RATIO),
     }
     }

+ 515 - 0
src/modules/cockpit/domain/entityDetail.js

@@ -0,0 +1,515 @@
+/**
+ * 统一实体详情领域边界(issue 65)。
+ *
+ * 列表只传 { kind, id };本模块负责身份校验、不同后端载荷的正规化和紧凑详情视图。
+ * point/device 沿用既有详情链,不在此重复实现。
+ */
+import { eventLevelTag, eventStatusText } from './eventList.js'
+import { toWorkOrder, toWorkOrderTimeline, workOrderStateName } from './workOrder.js'
+import { trackOf } from './workOrderTimeline.js'
+
+export const ENTITY_DETAIL_KIND = Object.freeze({
+    FACILITY: 'facility',
+    EMERGENCY: 'emergency',
+    EVENT: 'event',
+    WORK_ORDER: 'work-order',
+})
+
+const KINDS = Object.freeze(Object.values(ENTITY_DETAIL_KIND))
+const PLACEHOLDER = '—'
+
+export function entityIdentity(input, fallbackKind = '') {
+    const source = input && typeof input === 'object' ? input : {}
+    const kind = String(source.kind || source.identityKind || fallbackKind || '').trim()
+    const id = String(source.id ?? source.code ?? source.identityCode ?? '').trim()
+    const category = String(source.category || source.identityCategory || '').trim().toUpperCase()
+    const family = String(source.family || source.eventFamily || source.identityFamily || '').trim().toUpperCase()
+    return { kind: KINDS.includes(kind) ? kind : '', id, category, family, valid: KINDS.includes(kind) && !!id }
+}
+
+export function sameEntity(left, right) {
+    const a = entityIdentity(left)
+    const b = entityIdentity(right)
+    return a.valid && b.valid && a.kind === b.kind && a.id === b.id && a.category === b.category && a.family === b.family
+}
+
+/** AjaxResult / page / list 三种常见外形收成同一份有效载荷。 */
+export function unwrapEntityPayload(raw) {
+    let value = raw
+    if (value && typeof value === 'object' && typeof value.code === 'number') {
+        if (value.code !== 200) return null
+        value = value.data
+    }
+    if (value && typeof value === 'object' && value.data !== undefined && !Array.isArray(value.data)) {
+        const keys = Object.keys(value)
+        if (keys.every((key) => ['data', 'msg', 'message', 'success', 'timestamp'].includes(key))) value = value.data
+    }
+    return value
+}
+
+export function normalizePage(raw) {
+    const payload = unwrapEntityPayload(raw)
+    if (Array.isArray(payload)) return { rows: payload, page: 1, pageSize: payload.length, total: payload.length, totalPages: 1 }
+    const source = payload && typeof payload === 'object' ? payload : {}
+    const rows = [source.rows, source.records, source.list, source.items, source.content].find(Array.isArray) || []
+    const paging = source.pagination && typeof source.pagination === 'object' ? source.pagination : source
+    const page = positive(paging.page ?? paging.current ?? paging.pageNum, 1)
+    const pageSize = positive(paging.pageSize ?? paging.size ?? paging.limit, rows.length)
+    const total = nonNegative(paging.total, rows.length)
+    const totalPages = positive(paging.totalPages ?? paging.pages, pageSize ? Math.ceil(total / pageSize) : 1)
+    return { rows, page, pageSize, total, totalPages }
+}
+
+export function normalizeEntityDetail(identityInput, raw, prefill = null) {
+    const identity = entityIdentity(identityInput)
+    if (!identity.valid) return null
+    const payload = unwrapEntityPayload(raw)
+    const page = normalizePage(payload)
+    let source = payload
+    if (Array.isArray(payload)) source = payload.find((row) => matchesId(row, identity.id)) || null
+    else if (payload && typeof payload === 'object' && page.rows.length) source = page.rows.find((row) => matchesId(row, identity.id)) || null
+    const seed = prefill && typeof prefill === 'object' ? prefill : {}
+    source = source && typeof source === 'object' ? { ...seed, ...source } : { ...seed }
+    if (identity.kind === ENTITY_DETAIL_KIND.WORK_ORDER) return normalizeWorkOrder(identity, source)
+    if (identity.kind === ENTITY_DETAIL_KIND.EVENT) return normalizeEvent(identity, source)
+    return normalizeAsset(identity, source)
+}
+
+export function prefilledEntityDetail(identity, prefill) {
+    return normalizeEntityDetail(identity, {}, prefill)
+}
+
+export function entityDetailView(detail) {
+    const current = detail || {}
+    return {
+        title: current.title || current.name || current.id || '实体详情',
+        subtitle: current.subtitle || [current.kindLabel, current.id].filter(Boolean).join(' · '),
+        tone: current.tone || 'neutral',
+        status: current.statusText || '',
+        // 类别是身份而不是普通字段:抬头要能一眼看出「中文(英文码)」。
+        categoryText: current.categoryText || '',
+        summary: Array.isArray(current.summary) ? current.summary : [],
+        tabs: Array.isArray(current.tabs) ? current.tabs : [],
+        relatedAlarms: Array.isArray(current.relatedAlarms) ? current.relatedAlarms : [],
+        stage: Array.isArray(current.stage) ? current.stage : [],
+        sections: Array.isArray(current.sections) ? current.sections : [],
+        people: Array.isArray(current.people) ? current.people : [],
+        devices: Array.isArray(current.devices) ? current.devices : [],
+        sectionMap: current.sectionMap && typeof current.sectionMap === 'object' ? current.sectionMap : {},
+        attachments: Array.isArray(current.attachments) ? current.attachments : [],
+        records: Array.isArray(current.records) ? current.records : [],
+        track: current.track || null,
+    }
+}
+
+function normalizeAsset(identity, source) {
+    const emergency = identity.kind === ENTITY_DETAIL_KIND.EMERGENCY
+    const category = text(source.category || identity.category).toUpperCase()
+    const attributes = source.attributes && typeof source.attributes === 'object' ? source.attributes : {}
+    const name = text(source.name || source.facilityName || source.resourceName || source.title) || identity.id
+    // 状态是码值时翻成中文(0 正常 / 1 维护中 / 2 异常);后端若直接给人话则原样用。
+    const statusRaw = text(source.statusName || source.status || source.stateName || source.state)
+    const status = source.statusName || source.stateName ? statusRaw : facilityStatusText(statusRaw) || statusRaw
+    const location = text(source.address || source.location || source.position)
+    const fields = assetFields(category, attributes)
+    return {
+        ...identity, category, name, title: name,
+        kindLabel: emergency ? '应急对象' : '设施', statusText: status, tone: facilityStatusTone(source.status), raw: source,
+        categoryText: facilityCategoryText(category),
+        coordinates: source.coordinates || null,
+        summary: assetSummary(category, attributes, status, location),
+        tabs: [tab('overview', '基本信息'), tab('business', '业务详情'), tab('related', '关联信息')],
+        people: assetPeople(attributes),
+        devices: assetDevices(attributes),
+        attachments: assetAttachments(attributes),
+        sections: compactSections([
+            section('基本信息', [field('设施编码(code)', source.code || identity.id), field('设施类别(category)', facilityCategoryText(category)), field('运行状态(status)', status), field('数据更新时间(updatedAt)', source.updatedAt)]),
+            section('位置与归属', [field('位置描述(location)', location, true), field('所属片区(districtName)', source.districtName), field('坐标(coordinates)', coordinatesText(source.coordinates), true)]),
+            section('业务详情', fields),
+        ]),
+    }
+}
+
+/** 设施状态 → 语义色键(0 正常=绿、1 维护中=黄、2 异常=红;认不出走中性)。 */
+function facilityStatusTone(status) {
+    const code = status === null || status === undefined ? '' : String(status).trim()
+    if (code === '0') return 'lv4'
+    if (code === '1') return 'lv2'
+    if (code === '2') return 'lv1'
+    return 'neutral'
+}
+
+function assetSummary(category, a, status, location) {
+    const byCategory = {
+        WATER_PLANT: [['当前能力', a.currentCapacity], ['设计能力', a.designCapacity], ['处理工艺', a.treatmentProcess]],
+        ENGINEERING_FACILITY: [['设施等级', a.facilityLevel], ['设计流量', a.designFlow], ['调蓄容积', a.storageCapacity]],
+        PUMP_STATION: [['水泵数量', a.pumpCount], ['设计流量', a.designFlow], ['装机功率', a.powerCapacity]],
+        PIPE_NETWORK: [['长度(米)', a.lengthMeters], ['管径(毫米)', a.diameterMillimeters], ['材质', a.material]],
+        HAZARD: [['隐患等级', a.level], ['整改期限', a.rectifyDeadline], ['整改人', a.rectifyPerson]],
+        PROTECTION_TARGET: [['风险等级', a.riskLevel], ['风险分值', a.riskScore], ['评估时间', a.assessmentTime]],
+        EMERGENCY_RESOURCE: [['型号', a.model], ['规格', a.specification], ['维护人', a.maintainer]],
+    }
+    return summaryItems([['状态', status], ...(byCategory[category] || []), ['位置', location]])
+}
+function assetPeople(a) { return peopleItems([['负责人', a.principal], ['维护人', a.maintainer], ['整改人', a.rectifyPerson], ['联系电话', a.contactPhone || a.maintainerPhone]]) }
+function assetAttachments(a) { return attachmentItems(a.images || a.image) }
+function assetDevices(a) {
+    if (Array.isArray(a.devices)) return a.devices.map((item) => ({
+        id: item.id || '', code: item.code || item.id || '', name: item.name || item.code || item.id || '设备',
+        type: item.type || '', location: item.location || '', status: deviceStatusText(item),
+    }))
+    return String(a.equipmentIds || a.equipmentId || '').split(',').map((code) => code.trim()).filter(Boolean).map((code) => ({ name: code, code, type: '', location: '', status: '状态未发布' }))
+}
+function deviceStatusText(item) {
+    if (item.onlineStatus === 0) return '离线'
+    if (item.alarmStatus === 1) return '报警'
+    if (item.currentStatus === 1) return '维修'
+    if (item.currentStatus === 2) return '报废'
+    if (item.onlineStatus === 1) return '在线'
+    return '状态未发布'
+}
+
+/**
+ * 设施枚举词表(维护者 2026-10-29 走查逐条点名的口径)。
+ *
+ * 后端这些字段是**码值**(status/networkType/networkLevel),直接印数字就是漏码;
+ * 但认不出的值也不能编一个中文,所以统一走 `codedText()`:命中词表回「中文」,
+ * 未命中回「码」本身(与 CODE_TEXT 的立场一致)。
+ */
+const FACILITY_STATUS_TEXT = { '0': '正常', '1': '维护中', '2': '异常' }
+const NETWORK_TYPE_TEXT = { '0': '燃气管网', '1': '排水管网', '2': '供水管网' }
+const NETWORK_LEVEL_TEXT = { '0': '污水', '1': '雨水' }
+/** 排水工程设施类型(0 闸门 / 1 调蓄设施 / 2 泵站):同样是码值,一并直译。 */
+const ENGINEERING_FACILITY_TYPE_TEXT = { '0': '闸门', '1': '调蓄设施', '2': '泵站' }
+/** 类别中文名:界面上用「中文 + 英文码」,只印英文等于把接口词汇漏上大屏。 */
+const FACILITY_CATEGORY_TEXT = {
+    PIPE_NETWORK: '管网',
+    WATER_PLANT: '水厂',
+    PUMP_STATION: '泵站',
+    ENGINEERING_FACILITY: '排水工程设施',
+    EMERGENCY_RESOURCE: '应急资源',
+    HAZARD: '危险源',
+    PROTECTION_TARGET: '防护目标',
+}
+
+/** 码 → 显示值;给出词表时「中文(码)」,未命中回码本身(不猜、不留空)。 */
+function codedText(value, dictionary, withCode = false) {
+    const code = value === null || value === undefined ? '' : String(value).trim()
+    if (!code) return ''
+    const label = dictionary && dictionary[code]
+    if (!label) return code
+    return withCode ? `${label}(${code})` : label
+}
+
+/** 类别显示名:「中文(英文码)」;认不出的类别只回码。 */
+export function facilityCategoryText(category) {
+    return codedText(category, FACILITY_CATEGORY_TEXT, true)
+}
+
+/** 设施状态(0 正常 / 1 维护中 / 2 异常)→ 中文;空值如实空。 */
+export function facilityStatusText(status) {
+    return codedText(status, FACILITY_STATUS_TEXT)
+}
+
+/**
+ * 设施详情字段表(维护者 2026-10-29 设计规范)。
+ *
+ * 三条口径写死在这一处,别的层不再重复判断:
+ *   1. **命名一律中文**,必要时用括号补英文码/后端字段名(英文只作补充,不作主名);
+ *   2. **凡有量纲的读数必带单位**;单位来自后端领域注释(m³/d、m³/h、m、mm、m³、kW…),
+ *      不从前端猜;
+ *   3. 某些指标只在特定子类型下有效(如管网「二级分类」只对**排水管**有意义),
+ *      无效时**保留该行并显示「—」**,不是把它删掉 —— 删掉会让读者以为这个设施没这个属性。
+ *
+ * 每项形如 { label, key, unit?, format?, always? }:
+ *   · `format` 把码值翻成中文(枚举词表只在这里登记);
+ *   · `always` 表示"无效也占位"(与 compactSections 的缺值剔除相反)。
+ */
+function pipesFields(a) {
+    const drainage = String(a.networkType ?? '').trim() === '1'
+    return [
+        { label: '管网类型(networkType)', key: 'networkType', format: (v) => codedText(v, NETWORK_TYPE_TEXT, true) },
+        // 二级分类=原来的"管网等级":后端 network_level 只用于区分排水管道(污水/雨水)。
+        // 非排水管这个字段没有业务含义,按要求显示「—」而不是隐藏。
+        {
+            label: '二级分类(networkLevel)',
+            key: 'networkLevel',
+            always: true,
+            format: (v) => (drainage ? codedText(v, NETWORK_LEVEL_TEXT, true) : ''),
+            invalidNote: drainage ? '' : '仅排水管道适用',
+        },
+        { label: '长度', key: 'lengthMeters', unit: '米' },
+        { label: '管径', key: 'diameterMillimeters', unit: '毫米' },
+        { label: '材质', key: 'material' },
+        { label: '管养单位(managementUnit)', key: 'managementUnit' },
+        { label: '建设日期', key: 'buildDate' },
+        { label: '备注', key: 'remark', wide: true },
+    ]
+}
+
+const ASSET_FIELD_ROWS = {
+    PIPE_NETWORK: null, // 见 pipesFields():需要按管网类型决定二级分类是否适用
+    WATER_PLANT: [
+        { label: '水源类型(waterSourceType)', key: 'waterSourceType' },
+        { label: '设计能力', key: 'designCapacity', unit: '万m³/d' },
+        { label: '当前能力', key: 'currentCapacity', unit: '万m³/d' },
+        { label: '处理工艺', key: 'treatmentProcess', wide: true },
+        { label: '投产日期', key: 'commissionDate' },
+        { label: '水质标准', key: 'waterQualityStandard' },
+        { label: '负责人', key: 'principal' },
+        { label: '联系电话', key: 'contactPhone' },
+        { label: '备注', key: 'remark', wide: true },
+    ],
+    ENGINEERING_FACILITY: [
+        { label: '设施类型(facilityType)', key: 'facilityType', format: (v) => codedText(v, ENGINEERING_FACILITY_TYPE_TEXT, true) },
+        { label: '设施等级', key: 'facilityLevel' },
+        { label: '管理单位(managementUnit)', key: 'managementUnit' },
+        { label: '建设日期', key: 'buildDate' },
+        { label: '设计流量', key: 'designFlow', unit: 'm³/s' },
+        { label: '设计扬程', key: 'designHead', unit: 'm' },
+        { label: '调蓄容积', key: 'storageCapacity', unit: 'm³' },
+        { label: '关联设备', key: 'equipmentIds', wide: true },
+        { label: '备注', key: 'remark', wide: true },
+    ],
+    PUMP_STATION: [
+        { label: '泵站类型(stationType)', key: 'stationType' },
+        { label: '水泵数量', key: 'pumpCount', unit: '台' },
+        { label: '设计流量', key: 'designFlow', unit: 'm³/h' },
+        { label: '设计扬程', key: 'designHead', unit: 'm' },
+        { label: '装机功率', key: 'powerCapacity', unit: 'kW' },
+        { label: '投产日期', key: 'commissionDate' },
+        { label: '负责人', key: 'principal' },
+        { label: '联系电话', key: 'contactPhone' },
+        { label: '备注', key: 'remark', wide: true },
+    ],
+    EMERGENCY_RESOURCE: [
+        { label: '设备类型(equipmentTypeId)', key: 'equipmentTypeId' },
+        { label: '型号', key: 'model' },
+        { label: '规格', key: 'specification', wide: true },
+        { label: '制造商', key: 'manufacturer' },
+        { label: '资产价值', key: 'assetValue', unit: '元' },
+        { label: '预计使用年限', key: 'useLifeYears', unit: '年' },
+        { label: '安装时间', key: 'installTime' },
+        { label: '维护人', key: 'maintainer' },
+        { label: '维护电话', key: 'maintainerPhone' },
+        { label: '权属单位(ownershipUnit)', key: 'ownershipUnit' },
+        { label: '设备图片', key: 'image' },
+        { label: '备注', key: 'remark', wide: true },
+    ],
+    HAZARD: [
+        { label: '隐患类型(hazardType)', key: 'hazardType' },
+        { label: '隐患等级', key: 'level' },
+        { label: '来源', key: 'source' },
+        { label: '隐患描述', key: 'description', wide: true },
+        { label: '关联管网(networkId)', key: 'networkId' },
+        { label: '关联设备(equipmentId)', key: 'equipmentId' },
+        { label: '关联工单(workOrderId)', key: 'workOrderId' },
+        { label: '报告单位', key: 'reportUnit' },
+        { label: '报告时间', key: 'reportTime' },
+        { label: '整改方案', key: 'rectifyPlan', wide: true },
+        { label: '整改期限', key: 'rectifyDeadline' },
+        { label: '整改人', key: 'rectifyPerson' },
+        { label: '整改反馈', key: 'rectifyFeedback', wide: true },
+        { label: '隐患图片', key: 'images', wide: true },
+        { label: '备注', key: 'remark', wide: true },
+    ],
+    PROTECTION_TARGET: [
+        { label: '关联管网(networkId)', key: 'networkId' },
+        { label: '风险等级', key: 'riskLevel' },
+        { label: '风险分值', key: 'riskScore', unit: '分' },
+        { label: '风险颜色', key: 'riskColor' },
+        { label: '评估模型(assessmentModel)', key: 'assessmentModel' },
+        { label: '评估时间', key: 'assessmentTime' },
+        { label: '风险因素', key: 'riskFactors', wide: true },
+        { label: '危险源', key: 'hazardSources', wide: true },
+        { label: '备注', key: 'remark', wide: true },
+    ],
+}
+
+/** 读数 + 单位(认不出的值留给 field() 变占位符,不拼半截 "— m")。 */
+function withUnitText(value, unit) {
+    if (value === null || value === undefined || String(value).trim() === '') return ''
+    return unit ? `${value} ${unit}` : String(value)
+}
+
+function assetFields(category, a) {
+    const rows = category === 'PIPE_NETWORK' ? pipesFields(a) : ASSET_FIELD_ROWS[category] || []
+    return rows.map((row) => {
+        const raw = row.format ? row.format(a[row.key]) : withUnitText(a[row.key], row.unit)
+        // always 行(如非排水管的二级分类)保留占位;其余缺值由 compactSections 剔除。
+        const item = field(row.label, raw, row.wide === true)
+        if (row.always) {
+            item.always = true
+            if (row.invalidNote && item.value === PLACEHOLDER) item.note = row.invalidNote
+        }
+        return item
+    })
+}
+
+function coordinatesText(value) {
+    if (!value || typeof value !== 'object') return ''
+    const start = [value.longitude, value.latitude].filter((item) => item !== null && item !== undefined).join(', ')
+    const end = [value.endLongitude, value.endLatitude].filter((item) => item !== null && item !== undefined).join(', ')
+    return [start, end].filter(Boolean).join(' → ')
+}
+
+function normalizeEvent(identity, source) {
+    const envelope = source && source.basicInfo ? source : {}
+    const basic = envelope.basicInfo && typeof envelope.basicInfo === 'object' ? envelope.basicInfo : source
+    source = { ...basic,
+        disposalHistory: envelope.disposalHistory || basic.disposalHistory,
+        attachmentList: envelope.attachmentList || basic.attachmentList,
+        alarmList: envelope.alarmList || basic.alarmList,
+        gisLocation: envelope.gisLocation || basic.gisLocation,
+    }
+    const family = String(identity.family || source.family || source.eventFamily || (source.alarmId || source.alarmNo ? 'ALARM' : source.warningId || source.warningNo ? 'WARNING' : '')).toUpperCase()
+    const levelValue = source.level ?? source.warningLevel ?? source.alarmLevel
+    const level = eventLevelTag(levelValue)
+    const status = eventStatusText(source.status || source.state) || text(source.statusName || source.stateName)
+    const name = text(source.name || source.warningName || source.alarmName || source.eventName || source.deviceName || source.title) || identity.id
+    return {
+        ...identity,
+        name,
+        title: name,
+        kindLabel: family === 'ALARM' ? '报警' : family === 'WARNING' ? '预警' : '事件',
+        family,
+        statusText: status,
+        tone: level.tone || 'neutral',
+        raw: source,
+        summary: summaryItems([['等级', level.label], ['状态', status], ['当前值', withUnit(source.value ?? source.actualValue, source.unit || source.warningUnit)], ['发生时间', source.eventTime || source.createTime]]),
+        tabs: family === 'WARNING'
+            ? [tab('overview', '预警信息'), tab('records', '处置记录'), tab('alarms', '关联报警'), tab('attachments', '附件')]
+            : [tab('overview', '报警信息'), tab('devices', '设备信息'), tab('records', '处置记录'), tab('attachments', '附件')],
+        relatedAlarms: Array.isArray(source.alarmList) ? source.alarmList.map(relatedAlarm) : [],
+        stage: eventStage(family, status, source.disposalHistory),
+        people: peopleItems([['发布人', source.publisherName || source.publisher], ['处置人', source.handlerName || source.handler], ['核验人', source.reviewerName || source.reviewer]]),
+        devices: deviceItems(source),
+        attachments: attachmentItems(source.attachmentList),
+        records: recordItems(source.disposalHistory || source.logs),
+        sections: compactSections([
+            section('事件摘要', [field('事件编码', identity.id), field('等级', level.label), field('状态', status), field('发生时间', source.eventTime || source.createTime)]),
+            section('监测读数', [field('指标', source.type || source.warningType || source.warningCode), field('当前值', withUnit(source.value ?? source.actualValue, source.unit || source.warningUnit)), field('阈值', threshold(source))]),
+            section('关联信息', [field('设备', source.deviceName || source.deviceCode), field('监测点', source.monitoringPointName || source.monitoringPointCode), field('工单', source.workOrderCode || source.orderNo), field('位置', source.location || source.address || locationText(source.gisLocation), true)]),
+            section('处置与附件', [field('处置记录', listCount(source.disposalHistory, '条')), field('关联报警', listCount(source.alarmList, '条')), field('附件', listCount(source.attachmentList, '项'))]),
+        ]),
+    }
+}
+
+function normalizeWorkOrder(identity, source) {
+    const detailSource = source && source.order ? source : { order: source, logs: source.logs || [] }
+    const raw = { ...(detailSource.order || {}) }
+    if (!raw.workOrderCode && !raw.orderNo) raw.workOrderCode = identity.id
+    if (raw.stateCode === undefined && raw.orderStatus === undefined && raw.status !== undefined) raw.stateCode = Number(raw.status)
+    const order = toWorkOrder(raw)
+    const timelineRaw = detailSource.logs || source.timeline || source.timelineEntries || source.records
+    let track = null
+    if (Array.isArray(timelineRaw)) {
+        track = trackOf(toWorkOrderTimeline({ workOrder: raw, timelineEntries: timelineRaw.map((item) => ({
+            operationName: item.operationName || item.operationType || item.action,
+            operationDesc: item.operationDesc || item.remark || item.content,
+            operatorName: item.operatorName || item.operator || item.createByName,
+            operatedAt: item.operatedAt || item.operationTime || item.createTime,
+            stateCode: item.stateCode ?? item.orderStatus,
+            stateName: item.stateName || item.orderStatusName,
+        })) }))
+    } else if (source.workOrder && Array.isArray(source.timelineEntries)) {
+        track = trackOf(toWorkOrderTimeline(source))
+    }
+    const stateText = order.stateName || workOrderStateName(order.stateCode)
+    return {
+        ...identity,
+        name: order.code || identity.id,
+        title: order.orderTypeName || order.code || identity.id,
+        kindLabel: '工单',
+        statusText: stateText,
+        tone: workOrderTone(order.stateCode),
+        order,
+        track,
+        raw: source,
+        summary: summaryItems([['工单号', order.code], ['状态', stateText], ['优先级', order.orderLevel], ['计划完成', order.planFinishTime]]),
+        tabs: [tab('overview', '工单信息'), tab('flow', '处理流程'), tab('devices', '设备信息'), tab('attachments', '附件')],
+        stage: workOrderStage(order.stateCode),
+        people: peopleItems([['处置人', order.receiveUserName], ['核验人', order.verifyUserName]]),
+        devices: order.deviceCode || order.deviceName ? [{ name: order.deviceName || order.deviceCode, code: order.deviceCode, type: order.deviceTypeName, location: order.deviceLocation }] : [],
+        attachments: attachmentItems([raw.maintenanceBeforeImg, raw.maintenanceAfterImg, ...timelineAttachments(timelineRaw)]),
+        records: recordItems(timelineRaw),
+        sections: compactSections([
+            section('工单摘要', [field('工单号', order.code), field('类型', order.orderTypeName), field('等级', order.orderLevel), field('状态', stateText)]),
+            section('时间信息', [field('创建时间', order.createTime), field('计划完成', order.planFinishTime), field('实际完成', order.realFinishTime)]),
+            section('设备信息', [field('设备', order.deviceName || order.deviceCode), field('设备类型', order.deviceTypeName), field('安装位置', order.deviceLocation, true)]),
+            section('处置内容', [field('处置人', order.receiveUserName), field('核验人', order.verifyUserName), field('故障描述', order.description, true), field('处置结果', order.repairResult, true)]),
+        ]),
+    }
+}
+
+
+function tab(key, label) { return { key, label } }
+function relatedAlarm(item) { return { code: item.alarmNo || item.alarmId || item.id || '', name: item.alarmName || item.content || item.deviceName || '关联报警', status: item.auditStatusName || item.alarmStatus || item.status || '' } }
+function summaryItems(entries) { return entries.filter(([,value]) => value !== null && value !== undefined && String(value).trim() !== '').map(([label,value]) => ({ label, value: String(value) })) }
+function peopleItems(entries) { return entries.filter(([,name]) => name).map(([role,name]) => ({ role, name: String(name) })) }
+function deviceItems(source) {
+    const name = source.deviceName || source.monitoringPointName || ''
+    const code = source.deviceCode || source.monitoringPointCode || ''
+    return name || code ? [{ name: name || code, code, location: source.location || source.address || '' }] : []
+}
+function attachmentItems(value) {
+    const list = Array.isArray(value) ? value : value ? String(value).split(',') : []
+    return list.flatMap((item) => {
+        if (!item) return []
+        if (typeof item === 'object') return [{ name: item.attachmentName || item.fileName || item.name || '附件', url: item.attachmentUrl || item.url || '', type: item.attachmentType || item.type || '' }]
+        const url = String(item).trim(); return url ? [{ name: url.split('/').pop() || '附件', url, type: '' }] : []
+    })
+}
+function recordItems(value) {
+    return (Array.isArray(value) ? value : []).map((item) => ({
+        title: item.operationName || item.operationType || item.action || item.disposalStatus || '处置记录',
+        time: item.operatedAt || item.operationTime || item.createTime || item.disposalTime || '',
+        operator: item.operatorName || item.operator || item.createByName || item.disposalUser || '',
+        description: item.operationDesc || item.remark || item.content || item.disposalContent || '',
+    }))
+}
+function eventStage(family, status, records) {
+    const labels = family === 'ALARM' ? ['产生', '确认', '处置', '关闭'] : ['发布', '接收', '处置', '核验', '解除']
+    const normalized = String(status || '').toUpperCase()
+    let active = normalized.includes('RESOLV') || normalized.includes('CLEAR') || normalized.includes('CLOSE') ? labels.length - 1
+        : normalized.includes('PROCESS') || normalized.includes('HANDL') ? Math.min(2, labels.length - 1)
+        : normalized.includes('RECEIV') || normalized.includes('CONFIRM') ? 1 : 0
+    if (Array.isArray(records) && records.length) active = Math.max(active, Math.min(2, labels.length - 1))
+    return labels.map((label,index) => ({ label, state: index < active ? 'done' : index === active ? 'active' : 'pending' }))
+}
+function workOrderStage(code) {
+    const labels = ['创建','派单','接单','处置','验收','完成']
+    const n = Number(code); const active = n === 6 ? 3 : n === 7 ? 0 : Number.isInteger(n) ? Math.max(0, Math.min(n, 5)) : 0
+    return labels.map((label,index) => ({ label, state: index < active ? 'done' : index === active ? 'active' : 'pending' }))
+}
+function timelineAttachments(records) { return (Array.isArray(records) ? records : []).flatMap((item) => item.attachList || item.attachments || []) }
+
+/**
+ * 缺值剔除:always 行例外 —— 业务上"这个设施没有该属性"时要显式显示占位符
+ * (维护者 2026-10-29:非排水管的二级分类显示「—」,不是把整行藏掉)。
+ */
+function compactSections(sections) {
+    return sections
+        .map((group) => ({ ...group, fields: group.fields.filter((item) => item.always || item.value !== PLACEHOLDER) }))
+        .filter((group) => group.fields.length)
+}
+function section(title, fields) { return { title, fields } }
+function field(label, value, wide = false) { return { label, value: display(value), wide, always: false, note: '' } }
+function display(value) { return value === null || value === undefined || String(value).trim() === '' ? PLACEHOLDER : String(value) }
+function text(value) { return value === null || value === undefined ? '' : String(value).trim() }
+function withUnit(value, unit) { return value === null || value === undefined || value === '' ? '' : [value, unit].filter(Boolean).join(' ') }
+function threshold(source) {
+    const min = source.thresholdMinValue ?? source.minValue
+    const max = source.thresholdMaxValue ?? source.maxValue
+    if (min === undefined && max === undefined) return ''
+    return withUnit(min !== undefined && max !== undefined ? `${min} ~ ${max}` : min !== undefined ? `≥ ${min}` : `≤ ${max}`, source.unit)
+}
+function positive(value, fallback) { const n = Number(value); return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback }
+function nonNegative(value, fallback) { const n = Number(value); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback }
+function listCount(value, unit) { return Array.isArray(value) ? `${value.length} ${unit}` : '' }
+function locationText(value) {
+    if (!value || typeof value !== 'object') return ''
+    return value.location || value.address || [value.longitude, value.latitude].filter((item) => item !== undefined && item !== null).join(', ')
+}
+function matchesId(row, id) { return !!row && [row.id, row.code, row.workOrderCode, row.warningNo, row.alarmId, row.facilityCode, row.resourceId].some((value) => String(value ?? '').trim() === id) }
+function workOrderTone(code) { if (code === 5) return 'lv4'; if (code === 7) return 'lv1'; if (code === 6) return 'lv2'; return 'lv3' }

+ 1 - 0
src/modules/cockpit/domain/equipmentDetailView.js

@@ -313,6 +313,7 @@ export function workOrderRows(data) {
     return list.map((order) => {
     return list.map((order) => {
         const known = WORK_ORDER_STATES.some((item) => item.code === order.stateCode)
         const known = WORK_ORDER_STATES.some((item) => item.code === order.stateCode)
         return {
         return {
+            id: order.id || '',
             code: order.code || DETAIL_PLACEHOLDER,
             code: order.code || DETAIL_PLACEHOLDER,
             stateCode: typeof order.stateCode === 'number' ? String(order.stateCode) : '',
             stateCode: typeof order.stateCode === 'number' ? String(order.stateCode) : '',
             // 状态路径需要**数字**码(字符串比较会把 '10' 排在 '9' 前面),所以另存一份。
             // 状态路径需要**数字**码(字符串比较会把 '10' 排在 '9' 前面),所以另存一份。

+ 13 - 1
src/modules/cockpit/domain/eventDetail.js

@@ -92,6 +92,18 @@ export function eventDetailView(event) {
         ...eventLocationView(current),
         ...eventLocationView(current),
         monitoringPointCode: current.monitoringPointCode || '',
         monitoringPointCode: current.monitoringPointCode || '',
         monitoringPointName: current.monitoringPointName || '',
         monitoringPointName: current.monitoringPointName || '',
+        summary: [level.label, statusOf(current) || PLACEHOLDER, valueText(current.value, current.unit)].join(' · '),
+        identityItems: [
+            { label: '事件编码', value: current.code || PLACEHOLDER, mono: true },
+            { label: '设备', value: current.deviceName || current.deviceCode || PLACEHOLDER },
+            { label: '监测点', value: current.monitoringPointName || current.monitoringPointCode || PLACEHOLDER },
+        ],
+        readingItems: [
+            { label: '指标', value: current.type || PLACEHOLDER },
+            { label: '阈值', value: thresholdText(current.thresholdMinValue, current.thresholdMaxValue, current.unit), mono: true },
+            { label: '实际值', value: valueText(current.value, current.unit), mono: true, emphasis: true },
+            { label: TIME_LABELS.event, value: current.eventTime || PLACEHOLDER, mono: true },
+        ],
     }
     }
 }
 }
 
 
@@ -156,4 +168,4 @@ function findMapObjectByDevice(objects, deviceCode, pointCode) {
     return pointCode ? findMapObject(list, pointCode) : null
     return pointCode ? findMapObject(list, pointCode) : null
 }
 }
 
 
-export { PLACEHOLDER, thresholdText, valueText }
+export { PLACEHOLDER, thresholdText, valueText }

+ 11 - 1
src/modules/cockpit/domain/eventList.js

@@ -425,8 +425,12 @@ export function eventRowView(eventItem) {
 
 
     return {
     return {
         key: item.code || DETAIL_PLACEHOLDER,
         key: item.code || DETAIL_PLACEHOLDER,
+        family: item.family || '',
         // 切换后的字段标签(预警/报警各自一套,来自切换视图 —— 与状态标签同步变化)。
         // 切换后的字段标签(预警/报警各自一套,来自切换视图 —— 与状态标签同步变化)。
         code: item.code,
         code: item.code,
+        identityCode: item.code || '',
+        identityFamily: String(item.family || '').toUpperCase(),
+        identityReason: item.code ? '' : '缺少事件编号,无法展开',
         name: item.name || item.code || DETAIL_PLACEHOLDER,
         name: item.name || item.code || DETAIL_PLACEHOLDER,
         type: item.type || '',
         type: item.type || '',
         level: { tone: tag.tone, label: tag.label },
         level: { tone: tag.tone, label: tag.label },
@@ -443,6 +447,7 @@ export function eventRowView(eventItem) {
         deviceName: item.deviceName || '',
         deviceName: item.deviceName || '',
         monitoringPointCode: item.monitoringPointCode || '',
         monitoringPointCode: item.monitoringPointCode || '',
         monitoringPointName: item.monitoringPointName || '',
         monitoringPointName: item.monitoringPointName || '',
+        deviceLabel: item.deviceName || item.monitoringPointName || item.deviceCode || item.monitoringPointCode || '设备未发布',
         location: item.location || '',
         location: item.location || '',
         industry: item.industry || '',
         industry: item.industry || '',
         thresholdMinValue: item.thresholdMinValue,
         thresholdMinValue: item.thresholdMinValue,
@@ -525,7 +530,12 @@ export function currentEventsView(domain, eventsSection, options = {}) {
     //
     //
     // 票 61:**行=后端返回的这一页**,前端不排、不再截 12 条(服务端分页按后端返回序,
     // 票 61:**行=后端返回的这一页**,前端不排、不再截 12 条(服务端分页按后端返回序,
     // 总数读 `pagination.total`,条文 7)。空/失败时仍给安全的一页视图(pageCount=1)。
     // 总数读 `pagination.total`,条文 7)。空/失败时仍给安全的一页视图(pageCount=1)。
-    const allRows = items.map(eventRowView)
+    const family = switchView.key
+    const allRows = items.map((item) => ({
+        ...eventRowView(item),
+        family,
+        identityFamily: family,
+    }))
     // G11:类型筛选**只对已加载行**生效(`row.type` 是后端 eventType,与 warning-study 的
     // G11:类型筛选**只对已加载行**生效(`row.type` 是后端 eventType,与 warning-study 的
     // 类型 code 同一套码)。命中 0 条也照实显示 0,不把"没筛到"画成"没有预警"。
     // 类型 code 同一套码)。命中 0 条也照实显示 0,不把"没筛到"画成"没有预警"。
     const typeCode = typeof options.warningType === 'string' ? options.warningType.trim() : ''
     const typeCode = typeof options.warningType === 'string' ? options.warningType.trim() : ''

+ 400 - 105
src/modules/cockpit/domain/infrastructurePanels.js

@@ -29,6 +29,7 @@ import {
     codeText,
     codeText,
     dimensionBarsBlock,
     dimensionBarsBlock,
     donutBlock,
     donutBlock,
+    donutGridBlock,
     kpiBlock,
     kpiBlock,
     listBlock,
     listBlock,
     locateEntries,
     locateEntries,
@@ -40,11 +41,15 @@ import {
     regionText,
     regionText,
     rowsBlock,
     rowsBlock,
     sortByCountDesc,
     sortByCountDesc,
+    stackedBlock,
+    trendBlock,
     sectionHasContent,
     sectionHasContent,
     sectionPlaceholder,
     sectionPlaceholder,
     sectionPending,
     sectionPending,
     supplementalBlock,
     supplementalBlock,
 } from './panelBlocks.js'
 } from './panelBlocks.js'
+// 票 67:运行信息三项能力的唯一口径来源(category 合计、行业范围与降级说明)。
+import { OPERATING_SOURCE_NOTE, operatingCategoryCards, operatingScope } from './operatingMetricCaliber.js'
 
 
 // 票 18:这些工具已搬到 `panelBlocks.js`,这里**原样转出**,票 17 的既有导入点不受影响。
 // 票 18:这些工具已搬到 `panelBlocks.js`,这里**原样转出**,票 17 的既有导入点不受影响。
 export {
 export {
@@ -62,6 +67,19 @@ export {
 /** 基础设施模式的四个 panelId(与 `domain/panelCatalog.js` 的目录一致,顺序即界面顺序)。 */
 /** 基础设施模式的四个 panelId(与 `domain/panelCatalog.js` 的目录一致,顺序即界面顺序)。 */
 export const INFRASTRUCTURE_PANEL_IDS = ['archive', 'operation', 'management', 'emergency']
 export const INFRASTRUCTURE_PANEL_IDS = ['archive', 'operation', 'management', 'emergency']
 
 
+/**
+ * 建成年限分桶 → 中文(时间轴语义,**不排序**:<5 → 5–10 → 10–20 → 20+ → 未知 是业务顺序)。
+ * 后端给的是码(UNDER_5_YEARS 等),码翻人话在这里做,渲染层不认识任何码。
+ */
+const AGE_LABELS = Object.freeze({
+    UNDER_5_YEARS: '5 年以内',
+    '5_TO_10_YEARS': '5–10 年',
+    '10_TO_20_YEARS': '10–20 年',
+    '20_YEARS_OR_OVER': '20 年以上',
+    UNKNOWN: '未知',
+})
+
+
 /** 码 → 色键(负荷状态用语义色,让"超载"在扫一眼时就能看见;认不出的回中性)。 */
 /** 码 → 色键(负荷状态用语义色,让"超载"在扫一眼时就能看见;认不出的回中性)。 */
 function loadTone(loadState) {
 function loadTone(loadState) {
     if (loadState === 'OVERLOAD') return 'lv1'
     if (loadState === 'OVERLOAD') return 'lv1'
@@ -152,6 +170,8 @@ function toOperatingMetric(raw) {
         // 排水设施 id),与一张图的监测点/设备编码空间结构性不重叠(后端将改名 `facilityCodes`)。
         // 排水设施 id),与一张图的监测点/设备编码空间结构性不重叠(后端将改名 `facilityCodes`)。
         // 字段照契约透传(实测恒单元素),但**不再喂定位**——票 50 起运行信息面板不产定位入口。
         // 字段照契约透传(实测恒单元素),但**不再喂定位**——票 50 起运行信息面板不产定位入口。
         pointCodes: reader.optionalList('pointCodes').map((item) => String(item)).filter(Boolean),
         pointCodes: reader.optionalList('pointCodes').map((item) => String(item)).filter(Boolean),
+        facilityId: reader.optionalStr('facilityId'),
+        facilityKind: reader.optionalStr('facilityKind'),
         missing: reader.missing,
         missing: reader.missing,
     }
     }
 }
 }
@@ -178,14 +198,23 @@ export function toOperatingInformation(raw) {
             .map((item) => {
             .map((item) => {
                 const block = fieldReader(item, 'industryBlock')
                 const block = fieldReader(item, 'industryBlock')
                 const industry = fieldReader(block.raw.industry, 'industryBlock.industry')
                 const industry = fieldReader(block.raw.industry, 'industryBlock.industry')
+                const code = industry.optionalStr('code')
+                // 实测后端对 MANHOLE 的 label 直接回英文码,所以先过前端行业词表;
+                // 认不出的码才用后端 label,最后才回码 —— 接口词汇不上大屏(票 05/11 纪律)。
+                const label = industryLabel(code) || industry.optionalStr('label') || code || ''
                 return {
                 return {
-                    code: industry.optionalStr('code'),
-                    label: industry.optionalStr('label') || industryLabel(industry.optionalStr('code')),
+                    code,
+                    label,
                     state: block.optionalStr('state'),
                     state: block.optionalStr('state'),
                     // 认不出的原因码回空串(接口词汇不上大屏,票 05/11 的教训)。
                     // 认不出的原因码回空串(接口词汇不上大屏,票 05/11 的教训)。
                     stateText: '',
                     stateText: '',
                     abnormalMetricCount: numberish(block.raw.abnormalMetricCount),
                     abnormalMetricCount: numberish(block.raw.abnormalMetricCount),
-                    metrics: (block.raw.metrics || []).map(toOperatingMetric),
+                    // 票 67:指标带上所属行业 —— 全部行业时清单逐行说明身份,卡片也能按行业解释来源。
+                    metrics: (block.raw.metrics || []).map((metric) => ({
+                        ...toOperatingMetric(metric),
+                        industryCode: code,
+                        industryLabel: label,
+                    })),
                 }
                 }
             }),
             }),
         excluded: {
         excluded: {
@@ -287,6 +316,16 @@ export function toEmergencySupport(raw) {
  */
  */
 export function archivePanel(data, section) {
 export function archivePanel(data, section) {
     const current = data || {}
     const current = data || {}
+    // 维护者 2026-10-29 视觉统一:类型分布用环/堆叠,对比分析用柱,时间维度用面积/折线;
+    // 不再用一排同色横条把八类设施铺满(观感差、还占高度)。
+    const typeRows = sortByCountDesc(
+        (current.facilityTypes || []).map((row) => ({ code: row.code, label: row.name, count: row.count })),
+        (row) => row.count,
+    )
+    const industryRows = sortByCountDesc(current.industries || [], (row) => row.count)
+    const regionRows = sortByCountDesc(current.regions || [], (row) => row.count)
+    const ageRows = current.ages || []
+
     const blocks = [
     const blocks = [
         // 票 38(第 2、4 条):删除「规模」KPI(真实后端 totals.scale 恒 null,是空读数);
         // 票 38(第 2、4 条):删除「规模」KPI(真实后端 totals.scale 恒 null,是空读数);
         // 设施总数与管线里程回到**同一行**呈现 —— 渲染层不再有 lengthKm 独占整行的特判,
         // 设施总数与管线里程回到**同一行**呈现 —— 渲染层不再有 lengthKm 独占整行的特判,
@@ -295,28 +334,21 @@ export function archivePanel(data, section) {
             { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' },
             { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' },
             { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' },
             { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' },
         ]),
         ]),
-        // 2026-10-13 维护者反馈:条形图太多易疲劳——行业分类改环形图(行业语义色),
-        // 片区/年限保留条形(条形适合作排名比较与多类别)。
-        // 票 37 走查扩展:行业是排名类别按计数降序;年限是**固定分桶**(<5/5–10/10–20/20+
-        // 是时间轴语义),降序会把时间轴打乱,保持后端字典序;片区按计数降序。
-        donutBlock('行业分类', sortByCountDesc(current.industries || [], (row) => row.count)),
-        barsBlock('片区分布', sortByCountDesc(current.regions || [], (row) => row.count)),
-        barsBlock('档案摘要 · 建成年限', current.ages || []),
-        supplementalBlock(barsBlock('设施类型分布', sortByCountDesc((current.facilityTypes || []).map((row) => ({
-            code: row.code, label: row.name, count: row.count,
-        })), (row) => row.count))),
+        // 构成 → 环形图(类别不多、看占比)。
+        donutBlock('行业分类', industryRows, { layout: 'compact' }),
+        // 设施八类:先环后堆叠 —— 环给占比,堆叠条给"八类各多少"的一条连续读数。
+        donutBlock('设施类型分布', typeRows, { layout: 'compact' }),
+        stackedBlock('设施类型构成', typeRows),
+        // 对比分析 → 柱状条(片区之间比大小)。
+        barsBlock('片区分布', regionRows),
+        // 时间维度 → 面积折线(年限是固定分桶,靠顺序读"新老结构",不排序)。
+        trendBlock('建成年限结构', ageRows.map((row) => ({
+            key: row.code,
+            label: AGE_LABELS[row.code] || codeText(row.code),
+            count: row.count,
+        })), { chart: 'line' }),
     ]
     ]
 
 
-    const reference = current.ageReference || {}
-    blocks.push(
-        rowsBlock('档案摘要 · 口径', [
-            { key: 'asOfDate', label: '统计基准日', value: valueText(reference.asOfDate) },
-            { key: 'commissionedCount', label: '已投产', value: valueText(reference.commissionedCount) },
-            { key: 'builtCount', label: '已建成', value: valueText(reference.builtCount) },
-            { key: 'unknownCount', label: '年限未知', value: valueText(reference.unknownCount) },
-        ]),
-    )
-
     return {
     return {
         name: 'archive',
         name: 'archive',
         label: '基础档案',
         label: '基础档案',
@@ -345,9 +377,10 @@ export function archivePanel(data, section) {
  * `extra.scope` 由外壳传入(与台账徽标、饼图选中态同一个 extra 通道);
  * `extra.scope` 由外壳传入(与台账徽标、饼图选中态同一个 extra 通道);
  * 缺失或不是字符串时回空串——不拿脏值造出一个会漂移的假作用域。
  * 缺失或不是字符串时回空串——不拿脏值造出一个会漂移的假作用域。
  */
  */
-function operationPageScope(active, extra) {
+function operationPageScope(scope, extra) {
     const district = extra && typeof extra.scope === 'string' ? extra.scope : ''
     const district = extra && typeof extra.scope === 'string' ? extra.scope : ''
-    return `${active.code || ''}|${district}`
+    // 票 67:全部行业的键是 'ALL'(不是空串),所以「全部 ↔ 单行业」切换一定换槽位、回第 1 页。
+    return `${scope.key || scope.code || ''}|${district}`
 }
 }
 
 
 /**
 /**
@@ -368,68 +401,138 @@ function metricTitle(metric) {
     return facilityCode ? `${facilityCode} · ${metric.name}` : metric.name
     return facilityCode ? `${facilityCode} · ${metric.name}` : metric.name
 }
 }
 
 
+/** 设施详情类别必须由摘要显式给出;未知类别不猜、不下钻错误表。 */
+function facilityDetailCategory(metric) {
+    const explicit = String(metric.facilityKind || metric.facilityType || '').trim().toUpperCase()
+    return ['WATER_PLANT', 'PUMP_STATION', 'PIPE_NETWORK', 'ENGINEERING_FACILITY'].includes(explicit) ? explicit : ''
+}
+
+const EMERGENCY_DETAIL_CATEGORY = Object.freeze({
+    hazardSources: 'HAZARD',
+    protectionTargets: 'PROTECTION_TARGET',
+    emergencyResources: 'EMERGENCY_RESOURCE',
+})
+
 /**
 /**
  * 运行信息面板视图。
  * 运行信息面板视图。
  * 验收四项:供应 / 输送 / 承载能力 + **运行指标**(票 64:原措辞「指标占位」是占位期遗留词,
  * 验收四项:供应 / 输送 / 承载能力 + **运行指标**(票 64:原措辞「指标占位」是占位期遗留词,
  * 实际内容是真实指标清单;真实数据 6772 条/2.3 MB 已证伪「契约有界」,本块接入统一分页)。
  * 实际内容是真实指标清单;真实数据 6772 条/2.3 MB 已证伪「契约有界」,本块接入统一分页)。
+ *
+ * **票 67 重做三项能力**:旧实现从每个 category 里取"第一条指标"当读数,而每条指标其实
+ * 对应**一个设施**(管网按段逐条)—— 于是"承载能力"显示的是某一段管道的 1.03 m;
+ * 未筛行业时又直接取 `industries[0]`(燃气),所以「全部行业」看起来只有燃气数据。
+ * 现在范围判定与合计口径都收在 `domain/operatingMetricCaliber.js`:
+ *   · 单行业 → 只统计该行业块;
+ *   · 全部行业 → 跨所有有指标的行业块合计,并列出实际参与的行业;
+ *   · 后端只回一个块(忽略"全部")→ 如实说"仅统计××",不假称全部(降级路径)。
+ */
+/**
+ * 运行信息「设施数量」读数(维护者 2026-10-29)。
+ *
+ * 口径:运行信息端点每条指标对应**一个设施**(管网按段逐条),所以按 `facilityKind` 计数
+ * 就是设施数量,不需要第二套聚合。四项是维护者点名的:管段 / 水厂 / 水源地 / 用水户 / 泵站。
+ *
+ * 诚实边界:本端点当前只产出 PIPE_NETWORK / WATER_PLANT / PUMP_STATION / ENGINEERING_FACILITY;
+ * 水源地(WATER_SOURCE)与用水户(WATER_USER)在这里**没有数据**,卡片按占位符 + 说明呈现,
+ * 不拿别的类型凑数(缺口写进面板的 gap)。
+ *
+ * @param {Array} metrics `operatingScope()` 交出的指标行(带 `facilityKind`)
+ * @returns {Array} 五张 KPI 卡(管段独占一行)
  */
  */
+
+/**
+ * 运行信息「设施数量」读数的来源与口径(维护者 2026-10-29 改版后的话术)。
+ *
+ * 与 OPERATING_SOURCE_NOTE(三项能力合计的口径)并存但**不混用**:那一段描述的是
+ * 「同类设施读数合计」,本段描述的是「按设施实体逐条计数」——两者口径不同,
+ * 界面现在只展示后者,所以摘要读这一段。
+ */
+export const OPERATING_FACILITY_SOURCE_NOTE =
+    '设施数量由后端按设施实体逐条下发(水厂、泵站、管网、排水设施各一条),前端只做按类别计数,不做第二套聚合。' +
+    '「管段数量」数的是管网类设施条数(一段管网一条记录),与「基础档案」的设施类型分布同源;' +
+    '「水厂」「泵站」按设施类别计数。' +
+    '水源地与用水户当前不在本端点的下发范围内,读数按占位符呈现,不拿别的类别凑数。'
+export function operatingFacilityCards(metrics, extra = {}) {
+    const list = Array.isArray(metrics) ? metrics : []
+    // 设施类别优先取后端的 facilityKind;老载荷/测试样例没有这个字段时按 metricCode 后缀兜底
+    // (口径模块已登记四类后缀,这里是同一套词,不新造第二份映射)。
+    const kindOf = (row) => {
+        const explicit = String((row && row.facilityKind) || '').toUpperCase()
+        if (explicit) return explicit
+        const code = String((row && row.code) || '')
+        if (code.endsWith('pipe_network_length')) return 'PIPE_NETWORK'
+        if (code.endsWith('water_plant_current_supply')) return 'WATER_PLANT'
+        if (code.endsWith('pump_station_design_flow')) return 'PUMP_STATION'
+        if (code.endsWith('point_count')) return 'ENGINEERING_FACILITY'
+        return ''
+    }
+    const countOf = (kinds) => list.filter((row) => kinds.includes(kindOf(row))).length
+    const hasKind = (kinds) => list.some((row) => kinds.includes(kindOf(row)))
+    const pipes = countOf(['PIPE_NETWORK'])
+    const plants = countOf(['WATER_PLANT'])
+    const pumps = countOf(['PUMP_STATION', 'DRAINAGE_PUMP_STATION'])
+    const sources = hasKind(['WATER_SOURCE']) ? countOf(['WATER_SOURCE']) : null
+    const users = hasKind(['WATER_USER']) ? countOf(['WATER_USER']) : null
+    // 窨井数量(维护者 2026-10-29):本端点**没有**窨井指标(MANHOLE 块实测 state=EMPTY、0 条),
+    // 所以不在这里硬造一个 0;由外壳从**基础设施档案目录**(与「基础档案」同一份目录)搬进来,
+    // 与「设施数量与基础档案同源」这条口径一致。拿不到(档案分区空/失败)就回 null → 占位符。
+    const manholeCount = typeof extra.manholeCount === 'number' && Number.isFinite(extra.manholeCount) ? extra.manholeCount : null
+    return [
+        { key: 'pipeSegmentCount', label: '管段数量', value: valueText(pipes), unit: '段', caption: '管网按段逐条统计', accent: 'info' },
+        {
+            key: 'manholeCount',
+            label: '窨井数量',
+            value: valueText(manholeCount),
+            unit: '座',
+            caption: '取自基础设施档案目录',
+            hint: manholeCount === null ? '基础设施档案目录未取到,窨井数量暂不可用' : '',
+        },
+        { key: 'waterPlantCount', label: '水厂', value: valueText(plants), unit: '座' },
+        { key: 'waterSourceCount', label: '水源地', value: valueText(sources), unit: '处', hint: sources === null ? '本端点不提供水源地指标,数量见基础档案的设施类型分布' : '' },
+        { key: 'waterUserCount', label: '用水户', value: valueText(users), unit: '户', hint: users === null ? '后端尚未提供用水户台账,读数留空' : '' },
+        { key: 'pumpStationCount', label: '泵站', value: valueText(pumps), unit: '座' },
+    ]
+}
 export function operationPanel(data, section, extra) {
 export function operationPanel(data, section, extra) {
     const current = data || {}
     const current = data || {}
-    // 票 51:按**选中行业**选块,不按下标 —— 后端恒定按 GAS/WATER_SUPPLY/DRAINAGE/MANHOLE 四块返回,
-    // 筛选只把未选中块置空(EMPTY/NO_METRICS)不删块、不改变块序,块序恒以燃气打头;
-    // 只读 industries[0] 会把"选了供水/排水"说成"燃气没有指标"(数据就在同一响应里)。
-    // 定位用后端回显的 selectedIndustry(码);未筛行业时回显为空,保持既有"取第一块"的口径
-    // (与后端回显一致,不自己合并多块)。industryNote 与 metrics 都由这一个 active 推出
-    // —— 两处真相不漂移(票 50 后 locate 不再随 active 变化:定位入口整体撤下,恒为空)。
-    const industryBlocks = current.industries || []
-    const selected = current.selectedIndustry || ''
-    const matched = selected ? industryBlocks.find((block) => block.code === selected) : null
-    const active = matched
-        // 回显的码在 industries 里定位不到(契约外形状)时,归因仍用回显自身(码翻人话),不说成"燃气"或"全部"。
-        || (selected ? { code: selected, label: industryLabel(selected) || selected, metrics: [] } : null)
-        || industryBlocks[0]
-        || { code: '', label: '', metrics: [] }
-    const metrics = active.metrics || []
-
-    /** 按 category 取一类能力指标(验收的"供应/输送/承载"就是这三个 category)。 */
-    const byCategory = (code) => metrics.filter((metric) => metric.category === code)
-    /** 一类能力的"当前读数":取该类第一条(后端同一类下通常只有一条主指标)。 */
-    const headline = (code) => {
-        const found = byCategory(code)[0]
-        if (!found) return { key: code, label: codeText(code), value: DETAIL_PLACEHOLDER, hint: '后端未提供该类指标' }
-        return {
-            key: code,
-            label: codeText(code),
-            value: numberText(found.value, found.unit),
-            hint: found.capacityValue === null ? '无能力基准' : `能力基准 ${numberText(found.capacityValue, found.unit)} · 负荷 ${ratioText(found.loadRatio)}`,
-            tone: loadTone(found.loadState),
-        }
-    }
+    const scope = operatingScope(current.industries || [], current.selectedIndustry)
+    const metrics = scope.metrics
 
 
     const blocks = [
     const blocks = [
-        kpiBlock([headline('SUPPLY'), headline('FLOW'), headline('CAPACITY')]),
+        // 维护者 2026-10-29:指标改为「管段数量 + 窨井数量(并排一行)+ 水厂/水源地/用水户/泵站(1×2×2)」;
+        // 「承载能力=管网长度合计」删除 —— 与基础档案重复,且不是运行读数。
+        kpiBlock(operatingFacilityCards(metrics, extra), { layout: 'two-by-two' }),
         listBlock(
         listBlock(
-            // 票 64(维护者 2026-10-19 反馈第 2 条):块标题如实 —— 「指标占位」是占位期遗留词,
-            // 这里一直是真实运行指标清单。
-            '运行指标',
+            // 维护者 2026-10-29:标题由「运行指标」改为「设施台账」——
+            // 这本来就是**按设施逐条**的清单(一条指标对应一个设施),叫台账比叫指标准确,
+            // 也与上方「管段/水厂/水源地/用水户/泵站」那组设施读数同一套词。
+            '设施台账',
             metrics.map((metric, index) => ({
             metrics.map((metric, index) => ({
                 // 票 64 顺带修的键缺陷(分页把它从「理论问题」变成「每帧都碰的问题」):
                 // 票 64 顺带修的键缺陷(分页把它从「理论问题」变成「每帧都碰的问题」):
-                // 旧键 `${active.code}|${metric.code}` 在真实载荷里**整块碰撞**——燃气 699 条
+                // 旧键 `${scope.code}|${metric.code}` 在真实载荷里**整块碰撞**——燃气 699 条
                 // 全是同一个 metricCode(太常片区燃气长度),699 行共用一个键,
                 // 全是同一个 metricCode(太常片区燃气长度),699 行共用一个键,
                 // 而清单卡是 keyed v-for(`:key="row.key"`)。实测翻页内容仍正确,
                 // 而清单卡是 keyed v-for(`:key="row.key"`)。实测翻页内容仍正确,
                 // 但节点身份已经不存在(换筛选/刷新时靠下标兜底重排),所以加序号后缀
                 // 但节点身份已经不存在(换筛选/刷新时靠下标兜底重排),所以加序号后缀
                 // 保证**块内每行身份唯一**;行序由后端给、前端不重排(票 10/11 立场),
                 // 保证**块内每行身份唯一**;行序由后端给、前端不重排(票 10/11 立场),
                 // 所以序号在同一条件下稳定。
                 // 所以序号在同一条件下稳定。
-                key: `${active.code || ''}|${metric.code}|${index}`,
+                // 票 67:全部行业时同一 metricCode 会跨行业重复,行业也编进键;
+                // 单行业时它是常量,不破坏"同一条件下稳定"。
+                key: `${metric.industryCode || scope.code || ''}|${metric.code || ''}|${index}`,
                 // 票 73 裁定 A:标题带设施编码且**编码在前**(同页十行同名;编码放后面会被省略号切掉尾号)。
                 // 票 73 裁定 A:标题带设施编码且**编码在前**(同页十行同名;编码放后面会被省略号切掉尾号)。
                 title: metricTitle(metric),
                 title: metricTitle(metric),
                 identityKind: CARD_ACTION.FACILITY,
                 identityKind: CARD_ACTION.FACILITY,
-                identityCode: (metric.pointCodes || [])[0] || '',
-                identityReason: (metric.pointCodes || [])[0] ? '设施无详情' : '缺少设施编码,无法展开',
+                identityCode: facilityDetailCategory(metric) ? metric.facilityId || '' : '',
+                identityCategory: facilityDetailCategory(metric),
+                identityPayload: { ...metric, category: facilityDetailCategory(metric), industry: metric.industryCode || scope.code },
+                identityReason: facilityDetailCategory(metric)
+                    ? (metric.facilityId ? '' : '缺少设施主键,无法展开')
+                    : '设施类别未发布,无法展开详情',
                 tone: loadTone(metric.loadState),
                 tone: loadTone(metric.loadState),
                 // 色键必须**带文字**(票 06 的立场:标签不能只靠颜色,灰度/色盲下也要读得出)。
                 // 色键必须**带文字**(票 06 的立场:标签不能只靠颜色,灰度/色盲下也要读得出)。
                 toneLabel: codeText(metric.loadState),
                 toneLabel: codeText(metric.loadState),
                 meta: [
                 meta: [
+                    // 票 67:全部行业时逐行补行业名,否则清单看不出这一行属于哪个行业。
+                    scope.allView ? metric.industryLabel || '' : '',
                     `当前 ${numberText(metric.value, metric.unit)}`,
                     `当前 ${numberText(metric.value, metric.unit)}`,
                     `能力 ${numberText(metric.capacityValue, metric.unit)}`,
                     `能力 ${numberText(metric.capacityValue, metric.unit)}`,
                     `负荷 ${ratioText(metric.loadRatio)}`,
                     `负荷 ${ratioText(metric.loadRatio)}`,
@@ -443,15 +546,16 @@ export function operationPanel(data, section, extra) {
                 // 的既有形态),不再靠渲染层过滤。
                 // 的既有形态),不再靠渲染层过滤。
                 points: [],
                 points: [],
             })),
             })),
-            '当前行业没有运行指标',
+            '当前行业没有设施台账',
             // 票 64:本块接入统一分页(规范条文 1/2/4 + 跳页节)。**为什么这里必须声明**:
             // 票 64:本块接入统一分页(规范条文 1/2/4 + 跳页节)。**为什么这里必须声明**:
-            // 同一个 list 渲染分支服务五处面板,其中管理信息明细(实测 9 行)与应急保障三段
-            // 是规范排除项或另有专票——只有 domain 逐块声明,渲染层才知道该不该切窗口。
+            // 同一个 list 渲染分支服务多处清单,其中应急保障三段另有专票(63);
+            // 票 66 起管理信息已没有清单块(多环统计取代了它)。只有 domain 逐块声明,
+            // 渲染层才知道该不该切窗口。
             // 行序不改(票 10/11 立场:前端不重排),分页只切展示窗口。
             // 行序不改(票 10/11 立场:前端不重排),分页只切展示窗口。
             // `pageScope`=页码归属的筛选条件标识:换行业/换片区时它变,页码自然回第 1 页;
             // `pageScope`=页码归属的筛选条件标识:换行业/换片区时它变,页码自然回第 1 页;
             // 定时刷新时它不变,页码保持(条文 6)。翻页算术与控件都在单源
             // 定时刷新时它不变,页码保持(条文 6)。翻页算术与控件都在单源
             // (`domain/pagination.js` + `PagerNav.vue`),本层只声明「这一块要分页、按什么条件归页」。
             // (`domain/pagination.js` + `PagerNav.vue`),本层只声明「这一块要分页、按什么条件归页」。
-            { paginate: true, pageScope: operationPageScope(active, extra) },
+            { paginate: true, pageScope: operationPageScope(scope, extra) },
         ),
         ),
     ]
     ]
 
 
@@ -461,10 +565,12 @@ export function operationPanel(data, section, extra) {
         section,
         section,
         hasContent: sectionHasContent(section),
         hasContent: sectionHasContent(section),
         blocks,
         blocks,
-        // 当前选中行业(后端回显为空时不写"未选择",那会把"全部行业"说成缺陷)。
-        industryNote: active.label ? `当前行业:${active.label}` : '当前行业:全部',
-        // 票 64 第 4 条:如实说明数据来源与口径(票 49/50/51 既有裁定,不重开、不发明合并口径)。
-        sourceNote: '运行指标由后端按设施实体逐条生成(水厂/泵站/管网/排水设施四类适配器),一条指标对应一个设施;当前行业取后端回显,不跨行业合并',
+        // 票 67 要求 4:说清"当前统计的是哪个行业范围"——全部时列出实际参与的行业,
+        // 后端只回一个块时如实降级为"仅统计××"。
+        industryNote: scope.note,
+        // 维护者 2026-10-29:读数已从「三项能力」改成设施数量 —— 来源说明同步改口
+        // (旧那段描述的是供应/输送/承载合计,它说的读数现在已经不在屏上了)。
+        sourceNote: OPERATING_FACILITY_SOURCE_NOTE,
         // 票 50:定位入口整体撤下(locate 恒空)。该面板的关联编码是**设施**编码、不进一张图对象序列,
         // 票 50:定位入口整体撤下(locate 恒空)。该面板的关联编码是**设施**编码、不进一张图对象序列,
         // 对它跑 `locateEntries` 就是「不可达做成常态 + 撞名假阳性」;正确的对象交互**在图上**——
         // 对它跑 `locateEntries` 就是「不可达做成常态 + 撞名假阳性」;正确的对象交互**在图上**——
         // 管段/设施构件拾取走既有「重点对象局部增强」(pipeEnhancement,票 32),本票不动那条链路。
         // 管段/设施构件拾取走既有「重点对象局部增强」(pipeEnhancement,票 32),本票不动那条链路。
@@ -475,28 +581,220 @@ export function operationPanel(data, section, extra) {
     }
     }
 }
 }
 
 
+/** 片区统计的展示次序:片区字典序固定(未分区永远是最后一档)。 */
+const DISTRICT_SUMMARY_ORDER = [0, 1, 2, null]
+
+/** 行业在环上的固定次序:后端四个正式行业;认不出的行业排在最后。 */
+const INDUSTRY_SUMMARY_ORDER = ['GAS', 'WATER_SUPPLY', 'DRAINAGE', 'MANHOLE']
+
+/** 统计投影里「未分区」与「未知行业」的稳定键(键进断言、名进界面,两不混)。 */
+const UNASSIGNED_KEY = 'UNASSIGNED'
+const UNKNOWN_INDUSTRY_KEY = 'UNKNOWN'
+
+/** 片区排序位:未分区排在三个正式片区之后。 */
+function districtRank(key) {
+    if (key === UNASSIGNED_KEY) return DISTRICT_SUMMARY_ORDER.length
+    const index = DISTRICT_SUMMARY_ORDER.findIndex((value) => value !== null && String(value) === key)
+    return index === -1 ? DISTRICT_SUMMARY_ORDER.length : index
+}
+
+/** 行业排序位:认不出的行业排在四个正式行业之后。 */
+function industryRank(code) {
+    const index = INDUSTRY_SUMMARY_ORDER.indexOf(code)
+    return index === -1 ? INDUSTRY_SUMMARY_ORDER.length : index
+}
+
+/** 按给定排序位稳定排序(并列时保持插入次序,不靠引擎的排序稳定性)。 */
+function orderByRank(entries, rankOf) {
+    return entries
+        .map((entry, index) => ({ entry, index }))
+        .sort((a, b) => rankOf(a.entry) - rankOf(b.entry) || a.index - b.index)
+        .map((item) => item.entry)
+}
+
+/**
+ * 「片区 × 行业」计数的统计投影(票 66)。
+ *
+ * 为什么单独成纯函数:多环分片与指标卡都从这**一份**聚合里出,图与读数就不可能各算一套;
+ * 而且这一段要能被脱浏览器断言 —— 乱序、缺字段、全 0、只有设备没有设施、只有设施没有设备
+ * 五种形状都是票面的验收输入。
+ *
+ * 三条口径:
+ *   · **宽容输入**:非数组、空行、字段缺失一律跳过或按 0 计,不抛错;认不出的行业码不丢
+ *     (归到「未知」一档,不猜成四类之一);
+ *   · **次序固定**:片区按 城南/城北/太常/未分区,行业按 燃气/供水/排水/窨井 —— 次序进断言,
+ *     也让跨行业各环的扇区色能按同一份图例稳定下来;
+ *   · **环只画设施**:设施与设备单位不同,不混进同一个环;设备进各环/图例的悬停明细与读数。
+ *
+ * 「覆盖片区」与「覆盖行业」数的是**至少有一处设施或一台设备**的正式片区/行业;
+ * 未分区是数据质量桶,单独出一个读数,不算「覆盖到的片区」。
+ *
+ * @param {Array} rows 管理信息的 districtIndustry 行(每行一个片区×行业组合)
+ * 两条**轴是固定的**(与数据无关):图例=四个片区档(含未分区),环的骨架=四个正式行业。
+ * 轴固定的理由见下面 `districtRows` 与 `industryAxis` 的注释 —— 筛选态下轴跟着数据走,
+ * 会让「未分区」「城北」凭空消失;空态下则连结构都不剩(票 72 不许)。
+ *
+ * @returns {{facilityTotal: number, equipmentTotal: number, districtCount: number, industryCount: number, unassignedFacility: number, unassignedEquipment: number, districtRows: Array, industryRings: Array, industryAxis: Array}}
+ */
+export function districtIndustrySummary(rows) {
+    const list = Array.isArray(rows) ? rows : []
+    const districts = new Map()
+    const industries = new Map()
+    let facilityTotal = 0
+    let equipmentTotal = 0
+
+    for (const row of list) {
+        if (!row || typeof row !== 'object') continue
+        const district = normalizeDistrict(row.district)
+        const districtKey = district === null ? UNASSIGNED_KEY : String(district)
+        const industry = typeof row.industry === 'string' ? row.industry.trim() : ''
+        const industryKey = industry || UNKNOWN_INDUSTRY_KEY
+        const facility = numberish(row.facilityCount) || 0
+        const equipment = numberish(row.equipmentCount) || 0
+
+        facilityTotal += facility
+        equipmentTotal += equipment
+
+        let districtEntry = districts.get(districtKey)
+        if (!districtEntry) {
+            districtEntry = { key: districtKey, label: districtLabel(district), facilityCount: 0, equipmentCount: 0 }
+            districts.set(districtKey, districtEntry)
+        }
+        districtEntry.facilityCount += facility
+        districtEntry.equipmentCount += equipment
+
+        let industryEntry = industries.get(industryKey)
+        if (!industryEntry) {
+            industryEntry = {
+                key: industryKey,
+                code: industry,
+                label: industryLabel(industry) || codeText(industry),
+                facilityCount: 0,
+                equipmentCount: 0,
+                byDistrict: new Map(),
+            }
+            industries.set(industryKey, industryEntry)
+        }
+        industryEntry.facilityCount += facility
+        industryEntry.equipmentCount += equipment
+        industryEntry.byDistrict.set(districtKey, (industryEntry.byDistrict.get(districtKey) || 0) + facility)
+    }
+
+    const orderedDistricts = orderByRank([...districts.values()], (entry) => districtRank(entry.key))
+    const orderedIndustries = orderByRank([...industries.values()], (entry) => industryRank(entry.code))
+    const coversAny = (entry) => entry.facilityCount + entry.equipmentCount > 0
+    const unassigned = districts.get(UNASSIGNED_KEY)
+
+    return {
+        facilityTotal,
+        equipmentTotal,
+        // 覆盖数只数正式片区/行业:未分区单列,不冒充「覆盖到的片区」。
+        districtCount: orderedDistricts.filter((entry) => entry.key !== UNASSIGNED_KEY && coversAny(entry)).length,
+        industryCount: orderedIndustries.filter(coversAny).length,
+        unassignedFacility: unassigned ? unassigned.facilityCount : 0,
+        unassignedEquipment: unassigned ? unassigned.equipmentCount : 0,
+        // 共用图例:**固定四档片区轴**(城南/城北/太常/未分区),行上的计数是
+        // **该片区全部行业的设施合计**(与环内扇区不同层,块标题小字已写明)。
+        // 为什么是固定轴而不是「有数据才出」:票 66 第 4 条要求「未分区」如实展示、不隐藏;
+        // 而接口按行业/片区筛选后只会回**部分行**(实测 `industry=GAS` 只回城南与太常两行),
+        // 若图例跟着行走,未分区与城北会在筛选态凭空消失,看起来像数据丢了。固定轴把它们
+        // 如实画成 0(内置环形图的老规矩:0 计数保留图例、不占扇区)。
+        districtRows: DISTRICT_SUMMARY_ORDER.map((district) => {
+            const entry = districts.get(district === null ? UNASSIGNED_KEY : String(district))
+            const facilityCount = entry ? entry.facilityCount : 0
+            const equipmentCount = entry ? entry.equipmentCount : 0
+            return {
+                key: district === null ? UNASSIGNED_KEY : String(district),
+                label: districtLabel(district),
+                count: facilityCount,
+                tone: district === null ? 'neutral' : '',
+                hint: '设施 ' + facilityCount + ' 处 · 设备 ' + equipmentCount + ' 台',
+            }
+        }),
+        // 环:一个行业一个环。票 66 第 4 条 —— 该行业设施计数全 0 时整块不出(不画空环)。
+        industryRings: orderedIndustries
+            .filter((entry) => entry.facilityCount > 0)
+            .map((entry) => ({
+                key: entry.key,
+                label: entry.label,
+                unit: '处',
+                hint: '设施 ' + entry.facilityCount + ' 处 · 设备 ' + entry.equipmentCount + ' 台',
+                // 每个环都按**同一份**固定片区轴出扇区:0 计数的片区不画扇区,但图例里仍在(见上)。
+                slices: DISTRICT_SUMMARY_ORDER.map((district) => ({
+                    key: district === null ? UNASSIGNED_KEY : String(district),
+                    label: districtLabel(district),
+                    count: entry.byDistrict.get(district === null ? UNASSIGNED_KEY : String(district)) || 0,
+                })),
+            })),
+        // 环的固定轴(四个正式行业):只在**没有任何数据**时用来保设计骨架(票 72:
+        // 失败/空态也画结构,只把数值位换成占位符)。有数据时环集合由真实行业决定。
+        industryAxis: INDUSTRY_SUMMARY_ORDER.map((code) => ({ key: code, label: industryLabel(code) || codeText(code) })),
+    }
+}
+
 /**
 /**
- * 管理信息面板(票 38 第 6 条):片区 × 行业**图表化**。
+ * 管理信息面板(票 38 第 6 条;票 66 改统计形态)。
+ *
+ * 三块:
+ *   1. KPI 读数 —— 总设施 / 总设备 / 覆盖片区 / 覆盖行业 / 未分区设施;
+ *   2. 主图 —— 可切换维度的分类柱状图(按片区 / 按行业两维,domain 各聚合一遍,切换纯展示、不回后端);
+ *   3. **多环形统计** —— 一个行业一个环,环内扇区是该行业在**各片区**的设施占比、环心是该行业合计,
+ *      块底一份共用图例(各片区设施合计)。它取代了旧版「片区 × 行业明细」的卡片清单:
+ *      管理信息要回答的是「这个行业主要落在哪个片区」,不是逐行列数。
+ *
+ * 为什么环形按**行业**分片(票 66 要求二选一并说明理由):
+ *   · 读者是片区管理方,一个环回答一个问题(这个行业在各片区的分布),环心直接给该行业总量;
+ *   · 反过来按片区分片会**少一个环**:城北当前没有设施(0 处),按片区分片时城北整块不出,
+ *     看起来像「城北不存在」;按行业分片时城北只是没有扇区,固定片区轴上仍如实列出 0(不隐藏);
+ *   · 扇区色因此与全站同一套行业语义色对齐(燃气/供水/排水/窨井);换到片区维度就只剩图表色序。
  *
  *
- * 主图是**可切换维度的分类柱状图**(按片区 / 按行业两维,domain 各聚合一遍,切换纯展示、
- * 不回后端);下方保留紧凑**卡片清单**(逐 片区×行业 行给设施/设备计数,图看不出的组合明细看它)。
+ * 口径不变(票 66 第 5 条):设施计数与「基础档案」同源;设备计数只含能归入四类的监测设备。
+ * 设施与设备**单位不同、不混进同一个环**:环只画设施,设备在读数、柱状图悬停说明与环/图例的
+ * 悬停明细里给(实测 12 台设备对 6776 处设施,混画会让环完全被设施支配)。
  * 只消费 `districtIndustry[]`;已退役的 `organizations`/`ownershipDistribution`/`unmaintained` 不再读取。
  * 只消费 `districtIndustry[]`;已退役的 `organizations`/`ownershipDistribution`/`unmaintained` 不再读取。
  * 「责任区域」=片区别名(决策 06),不新增独立维度。
  * 「责任区域」=片区别名(决策 06),不新增独立维度。
  */
  */
+/**
+ * 管理信息「设施」读数(维护者 2026-10-29):只统计设施,不再出现设备口径。
+ *
+ * 四类管段来自 `districtIndustry[]` 的行业分项 —— 燃气/供水/排水/窨井各自的设施数。
+ * 维护者要的四项是「燃气管段 / 供水管段 / 污水管段 / 雨水管段」;后端本端点只有
+ * 行业级(排水不拆雨污)与设施类型级两种粒度,合计口径见下面的说明块:
+ *   · 燃气管段、供水管段 ← GAS / WATER_SUPPLY 两个行业的设施数(与基础档案同源);
+ *   · 污水/雨水管段 ← 本端点**没有**雨污拆分,故回占位符,并把缺口写进面板 gap;
+ *     能给出的是「排水管段(雨污合计)」,作为说明条附在卡片 caption 上。
+ *
+ * @param {Array} rows `districtIndustry[]`
+ * @returns {Array} 四张 KPI 卡(2×2)
+ */
+export function managementFacilityCards(rows) {
+    const list = Array.isArray(rows) ? rows : []
+    const sumOf = (code) => (list.length
+        ? list.filter((row) => String(row.industry || '') === code).reduce((sum, row) => sum + (numberish(row.facilityCount) || 0), 0)
+        : null)
+    const gas = sumOf('GAS')
+    const water = sumOf('WATER_SUPPLY')
+    const drainage = sumOf('DRAINAGE')
+    return [
+        { key: 'gasFacilityCount', label: '燃气管段', value: valueText(gas), unit: '处' },
+        { key: 'waterFacilityCount', label: '供水管段', value: valueText(water), unit: '处' },
+        { key: 'sewageFacilityCount', label: '污水管段', value: DETAIL_PLACEHOLDER, unit: '处', hint: '本端点排水未按雨污拆分,雨污合计见下方说明' },
+        { key: 'rainwaterFacilityCount', label: '雨水管段', value: DETAIL_PLACEHOLDER, unit: '处', hint: '本端点排水未按雨污拆分;排水设施合计 ' + valueText(drainage) + ' 处' },
+    ]
+}
 export function managementPanel(data, section) {
 export function managementPanel(data, section) {
     const current = data || {}
     const current = data || {}
     const rows = current.districtIndustry || []
     const rows = current.districtIndustry || []
+    const summary = districtIndustrySummary(rows)
 
 
     const blocks = [
     const blocks = [
-        kpiBlock([
-            { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' },
-            { key: 'equipmentCount', label: '设备总数', value: valueText(current.equipmentCount), unit: '台' },
-        ]),
-        // 主图:按片区 / 按行业切换的分类柱状图(主计数=设施数,设备数进悬停说明)。
+        // 维护者 2026-10-29:管理信息**只统计设施**(设备口径归监测模式),
+        // 「设施总数」与基础档案重复也已删除;改为四类管段的设施数,2×2 排列。
+        kpiBlock(managementFacilityCards(rows), { layout: 'two-by-two' }),
+        // 主图:按片区 / 按行业切换的分类柱状图(主计数=设施数;悬停也只看设施)。
         dimensionBarsBlock('片区 × 行业', rows, {
         dimensionBarsBlock('片区 × 行业', rows, {
             countOf: (row) => row.facilityCount,
             countOf: (row) => row.facilityCount,
-            extras: { equipmentCount: 'equipmentCount' },
-            hintOf: (row) => `设备 ${valueText(row.equipmentCount)} 台`,
+            hintOf: (row) => `设施 ${valueText(row.facilityCount)} 处`,
             unit: '处',
             unit: '处',
             emptyText: '当前范围没有片区与行业计数',
             emptyText: '当前范围没有片区与行业计数',
             dimensions: [
             dimensions: [
@@ -504,17 +802,18 @@ export function managementPanel(data, section) {
                 { key: 'industry', label: '按行业', field: 'industry', emptyLabel: '行业未知', labelOf: (value) => industryLabel(value) || codeText(value) },
                 { key: 'industry', label: '按行业', field: 'industry', emptyLabel: '行业未知', labelOf: (value) => industryLabel(value) || codeText(value) },
             ],
             ],
         }),
         }),
-        // 下方紧凑清单:片区×行业逐组合的设施/设备计数(图看合计,清单看明细)。
-        listBlock(
-            '片区 × 行业明细',
-            rows.map((row) => ({
-                key: `${row.district ?? 'UNASSIGNED'}|${row.industry}`,
-                title: `${districtLabel(row.district)} · ${industryLabel(row.industry) || '行业未知'}`,
-                meta: [`设施 ${valueText(row.facilityCount)} 处`, `设备 ${valueText(row.equipmentCount)} 台`],
-                points: [],
-            })),
-            '当前范围没有片区与行业计数',
-        ),
+        // 多环形统计(票 66):取代旧版「片区 × 行业明细」卡片清单。
+        // 边界(复核提出,如实写在这里):筛到「只有设备、没有设施」的片区时(如 district=1),
+        // 环集合为空、也没有可比的扇区比例,块走 emptyText 而不是硬画四档 0 图例 ——
+        // 固定片区轴只在**确有设施计数**时承载「同一颜色 = 同一片区」的比较语义。
+        // 另:主图柱状图的轴仍按数据行聚合(0 的片区不出柱),与本图例的固定档位是两种语义
+        // —— 柱状图是排名(没数据不占位),环形图例是图例(档位固定才读得出一致颜色)。
+        donutGridBlock('片区 × 行业 · 按行业分片', summary.industryRings, {
+            legend: summary.districtRows,
+            axis: summary.industryAxis,
+            unit: '处',
+            emptyText: '当前范围没有设施计数',
+        }),
     ]
     ]
 
 
     return {
     return {
@@ -524,17 +823,11 @@ export function managementPanel(data, section) {
         hasContent: sectionHasContent(section),
         hasContent: sectionHasContent(section),
         blocks,
         blocks,
         // 票 68(维护者 2026-10-19 反馈第 3 条):公开「这些数从哪来、覆盖什么范围、与别处为何对得上」。
         // 票 68(维护者 2026-10-19 反馈第 3 条):公开「这些数从哪来、覆盖什么范围、与别处为何对得上」。
-        // 三条必须说清(措辞纪律:只说业务含义与范围,**不出现接口字段名**——读者是值班人员):
-        //   1. 计数来源:设施=基础设施档案目录(与「基础档案」**同一个目录、同一个适配器**,
-        //      后端 BusinessCockpitInfrastructureBaseArchivesAdapter 同时实现基础档案与设施目录);
-        //      设备=设备台账里能归入四个正式行业的监测设备;
-        //   2. 「片区 × 行业」是什么:一行一个组合,两维不再拆第二遍;
-        //   3. 与「基础档案」的设施总数**同源同口径**(2026-10-22 实测两处均 6776)——
-        //      同一批设施实体,不各自维护一套总数。
-        // 还要解释一处**看起来像缺陷的真实现象**:设施没有片区来源,设施全部落「未分区」,
-        // 于是城南/城北会出现「有设备、没有设施」的行(票 34/38 片区契约迁移后的既知事实)。
-        sourceNote:
-            '设施计数取自基础设施档案目录(与「基础档案」是同一份目录、同一批设施实体),设备计数取自设备台账中能归入燃气/供水/排水/井盖四类的监测设备;「片区 × 行业」一行就是一个组合,只有既属于该片区又属于该行业的对象才计入,两个维度不重复拆分。设施的片区取档案目录登记的片区(当前设施档案没有片区来源,所以设施都记在「未分区」),设备的片区取设备自身登记 —— 因此会出现「有设备、没有设施」的片区行。设施总数与「基础档案」同源,两处不各自维护一套。',
+        // 票 66 随形态一并改写:旧的「一行一个组合」清单说明随清单一起去掉,不留旧措辞。
+        // 措辞纪律:只说业务含义与范围,**不出现接口字段名** —— 读者是值班人员,不是接口调用方。
+        sourceNote: '设施计数取自基础设施档案目录(与「基础档案」是同一份目录、同一批设施实体);片区取档案里登记的片区,没有登记的落「未分区」,如实列出、不隐藏。',
+        // 维护者 2026-10-29:雨污拆分在后端还没有 —— 如实说明缺口,不拿「排水合计」冒充其中一项。
+        gap: '本端点的排水设施未按雨污拆分,因此「污水管段 / 雨水管段」暂不给出单项读数;排水设施合计可在「片区 × 行业」块按行业维度读到。',
         locate: [],
         locate: [],
         locateNote: '片区与行业计数为聚合口径,本面板没有可定位对象',
         locateNote: '片区与行业计数为聚合口径,本面板没有可定位对象',
         missing: panelMissingNote(current),
         missing: panelMissingNote(current),
@@ -568,9 +861,11 @@ function emergencySectionBlock(label, child, emptyText, sectionKey) {
     const rows = items.map((item) => ({
     const rows = items.map((item) => ({
         key: item.code,
         key: item.code,
         title: item.name,
         title: item.name,
-        identityKind: CARD_ACTION.FACILITY,
+        identityKind: CARD_ACTION.EMERGENCY,
         identityCode: item.code,
         identityCode: item.code,
-        identityReason: item.code ? '设施无详情' : '缺少设施编码,无法展开',
+        identityCategory: EMERGENCY_DETAIL_CATEGORY[sectionKey] || '',
+        identityPayload: { ...item, category: EMERGENCY_DETAIL_CATEGORY[sectionKey] || item.category },
+        identityReason: item.code ? '' : '缺少对象编码,无法展开',
         meta: [
         meta: [
             codeText(item.category),
             codeText(item.category),
             industryLabel(item.industry) || codeText(item.industry),
             industryLabel(item.industry) || codeText(item.industry),

+ 107 - 0
src/modules/cockpit/domain/kpiFit.js

@@ -0,0 +1,107 @@
+/**
+ * KPI 卡片的**文字宽度预算**(维护者 2026-10-28:指标卡尽量不要出现省略号)。
+ *
+ * ## 为什么需要这一层
+ *
+ * KPI 卡里的三行文本(标签 / 读数 / 口径小字)都走共用省略方法(`EllipsisText`),
+ * 一旦所在格宽放不下就画成 `…`。格宽又是**整数列数**决定的(auto-fit 的最小格 78u、
+ * 固定 two/three/two-by-two 版式),不是连续可调 —— 于是"差几个像素"就会吃掉一个字。
+ * 光把字号调小是拍脑袋:不同标签字数不同、卡里还有没有图标也不同。
+ *
+ * 本模块把"这段文字在卡里需要多少宽度"算成一个**可进 CSS 的预算**:
+ *   · `--kpi-label-em` / `--kpi-caption-em` / `--kpi-reading-em`:文本本身的宽度(em);
+ *   · `--kpi-label-gutter-u`:标签列被"卡片内边距 + 图标 + 列间距"吃掉的宽度(u)。
+ * 渲染层的 `font-size` 由此算出:`(100cqw − gutter) / em`,超过基准字号就取基准。
+ * 于是**窄格自动降字号**,而不是画省略号。
+ *
+ * ## 常量怎么来的(都不靠猜)
+ *
+ * · 文本宽度:中日韩/全角字 = 1em,ASCII 字母数字 ≈ 0.55em(读数用等宽数字字体 Rajdhani
+ *   ≈ 0.56em),空格 0.3em,`· . , % + - :` 等标点 ≈ 0.4em,其余 ≈ 0.6em。
+ * · 字号:`--fs-caption` = 11.5u(标签)、`--fs-note` = 9.2u(口径小字)、
+ *   `--fs-kpi` = 22u(读数)——全部随 `--u` 缩放,所以卡片内边距/图标也用 u 表达,
+ *   两边同比例,预算不随视口漂。
+ * · 吃掉标签列的宽度(u):卡片左右内边距 16u + 边框 2u + 图标 14u + 列间距 5u = **37u**
+ *   (无图标时 16u + 2u = **18u**);口径小字与读数占整卡宽 ⇒ 18u。
+ * · 1.03 的安全系数:四舍五入与字体侧边距留一点余量,不让字号正好卡在临界值上、
+ *   被浏览器舍进 `…`。
+ * · 下限(`8u` 标签/小字、`10u` 读数)不是设计字号,而是**可读性下限**:
+ *   到了下限仍放不下才允许换行(标签/小字),读数则保持不换行(数字断行更难看)。
+ */
+import { DETAIL_PLACEHOLDER } from './detailFlow.js'
+
+/** 标签的 letter-spacing(`.panel-kpi__label` 的 0.04em)。 */
+const LABEL_SPACING = 1.04
+/** 口径小字的 letter-spacing(`.panel-kpi__caption` 的 0.02em)。 */
+const CAPTION_SPACING = 1.02
+/** 安全系数:不让字号正好卡在临界值上。 */
+const SAFETY = 1.03
+
+/** 标签列被吃掉的宽度(u):卡片内边距 16u + 边框 2u + 图标 14u + 列间距 5u。 */
+export const KPI_LABEL_GUTTER_WITH_ICON_U = 37
+/** 没有图标时的同一口径:内边距 16u + 边框 2u。 */
+export const KPI_LABEL_GUTTER_WITHOUT_ICON_U = 18
+/** 口径小字 / 读数占整卡宽,被内边距 + 边框吃掉 18u。 */
+export const KPI_FULL_WIDTH_GUTTER_U = 18
+
+function round2(value) {
+    return Math.round(value * 100) / 100
+}
+
+/**
+ * 文本视觉宽度(em)。`numeric` = 读数(等宽数字字体,数字/字母略宽一档)。
+ * 认不出的字符按 0.6em 计(宁可估宽一点:估宽只会让字号小一点,不会让文字出框)。
+ */
+export function kpiTextEm(text, { numeric = false } = {}) {
+    const source = typeof text === 'string' ? text : text === null || text === undefined ? '' : String(text)
+    let em = 0
+    const ascii = numeric ? 0.56 : 0.55
+    for (const char of source) {
+        if (char === ' ') em += 0.3
+        else if (/[\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]/.test(char)) em += 1
+        else if (/[A-Za-z0-9]/.test(char)) em += ascii
+        else if (/[.,:%+\-/|]/.test(char)) em += 0.4
+        else em += 0.6
+    }
+    return em
+}
+
+/** 读数是否占位符(占位符不画单位,见渲染层的 `kpiReading`)。 */
+function isPlaceholderValue(value) {
+    return value === DETAIL_PLACEHOLDER || value === null || value === undefined || value === ''
+}
+
+/**
+ * 一条 KPI 的宽度预算(CSS 自定义属性)。
+ *
+ * @param {{ label?: string, caption?: string, value?: unknown, full?: string, unit?: string, icon?: string }} item
+ * @returns {Record<string, number>} 直接绑在卡片上的 `style`
+ */
+export function kpiFitVars(item = {}) {
+    const labelEm = round2(kpiTextEm(item.label) * LABEL_SPACING * SAFETY)
+    const captionEm = round2(kpiTextEm(item.caption) * CAPTION_SPACING * SAFETY)
+    const unitText = item.unit && !isPlaceholderValue(item.value) ? ' ' + item.unit : ''
+    const reading = String(item.full || item.value || '') + unitText
+    const readingEm = round2(kpiTextEm(reading, { numeric: true }) * SAFETY)
+    return {
+        // em 为 0(空文本)时回落到 1:CSS 里除零会整条声明失效,宁可给一个中性值。
+        '--kpi-label-em': labelEm || 1,
+        '--kpi-caption-em': captionEm || 1,
+        '--kpi-reading-em': readingEm || 1,
+        '--kpi-label-gutter-u': item.icon ? KPI_LABEL_GUTTER_WITH_ICON_U : KPI_LABEL_GUTTER_WITHOUT_ICON_U,
+    }
+}
+
+/**
+ * 预算 → 字号(px)。**只用于闸门离线复算**:渲染层不在 JS 里量宽度、也不写第二套算术,
+ * 纯 CSS 用同一组变量算(见 PanelBlocks.vue 的 `font-size: max(... min(... 100cqw ...))`)。
+ *
+ * @param {{ cardWidth: number, em: number, gutterU: number, baseU: number, floorU: number, u: number }} input
+ */
+export function kpiFitFontSize({ cardWidth, em, gutterU, baseU, floorU, u = 1 }) {
+    const floor = floorU * u
+    const base = baseU * u
+    if (!(cardWidth > 0) || !(em > 0) || !(u > 0)) return base
+    const fit = (cardWidth - gutterU * u) / em
+    return Math.max(floor, Math.min(base, fit))
+}

+ 83 - 22
src/modules/cockpit/domain/monitoringPanels.js

@@ -37,12 +37,13 @@ import {
     donutBlock,
     donutBlock,
     kpiBlock,
     kpiBlock,
     listBlock,
     listBlock,
-    locateEntries,
     numberish,
     numberish,
     panelMissingNote,
     panelMissingNote,
     ratioText,
     ratioText,
     regionText,
     regionText,
     rowsBlock,
     rowsBlock,
+    stackedBlock,
+    supplementalBlock,
     sectionHasContent,
     sectionHasContent,
     sectionPlaceholder,
     sectionPlaceholder,
     sortByCountDesc,
     sortByCountDesc,
@@ -530,7 +531,7 @@ export function monitoringOverviewPanel(data, section) {
         //     hint(`在线 X / 设备 Y`)上与在线率同口径;画进图例就是制造第二个口径的数。
         //     hint(`在线 X / 设备 Y`)上与在线率同口径;画进图例就是制造第二个口径的数。
         //   · 缺值仍是占位符纪律:四态计数经 `numberish` 认不出回 null → 该行**整行不出**
         //   · 缺值仍是占位符纪律:四态计数经 `numberish` 认不出回 null → 该行**整行不出**
         //     (见 `deviceStatusRows`),绝不把 null 交给 `donutBlock` 补成假 0(票 27 同类)。
         //     (见 `deviceStatusRows`),绝不把 null 交给 `donutBlock` 补成假 0(票 27 同类)。
-        donutBlock('设备状态分布(按设备数)', deviceStatusRows(deviceStatus)),
+        donutBlock('设备状态分布(按设备数)', deviceStatusRows(deviceStatus), { layout: 'compact' }),
         barsBlock(
         barsBlock(
             '设备类型分布',
             '设备类型分布',
             // 票 37:按设备数**从多到少**降序;并列时保持后端给的次序(后端已排好则是恒等变换)。
             // 票 37:按设备数**从多到少**降序;并列时保持后端给的次序(后端已排好则是恒等变换)。
@@ -547,8 +548,14 @@ export function monitoringOverviewPanel(data, section) {
             // 行可点 → 写全局类型筛选(复用票 20 立的 `panel-action` 接缝)。
             // 行可点 → 写全局类型筛选(复用票 20 立的 `panel-action` 接缝)。
             // 只带得出后端类型码的行才可点:`code` 为空时 `barsBlock` 不给 `actionKey`,
             // 只带得出后端类型码的行才可点:`code` 为空时 `barsBlock` 不给 `actionKey`,
             // 那一行在渲染层就不是按钮 —— 不拿展示名冒充筛选码去换后端 400。
             // 那一行在渲染层就不是按钮 —— 不拿展示名冒充筛选码去换后端 400。
-            { action: 'deviceType' },
+            { action: 'deviceType', variant: 'rank' },
         ),
         ),
+        supplementalBlock(donutBlock('各类型在线设备', (current.typeDistribution || [])
+            .filter((row) => numberish(row.onlineCount) !== null)
+            .map((row) => ({ code: row.code, label: row.name || codeText(row.code), count: row.onlineCount })), { layout: 'compact' })),
+        supplementalBlock(barsBlock('各类型离线设备', sortByCountDesc((current.typeDistribution || [])
+            .filter((row) => numberish(row.offlineCount) !== null)
+            .map((row) => ({ code: row.code, label: row.name || codeText(row.code), count: row.offlineCount })), (row) => row.count), { variant: 'rank' })),
     ]
     ]
 
 
     return {
     return {
@@ -653,9 +660,11 @@ export function monitoringDistributionPanel(data, section, extra = {}) {
         // 「这条长度量的是什么」;悬停行的 hint 仍给设备数(图与数同源,不另算一份)。
         // 「这条长度量的是什么」;悬停行的 hint 仍给设备数(图与数同源,不另算一份)。
         // 条形/环形的分工沿用 2026-10-13 的既有结论(行业=类别少→环形;
         // 条形/环形的分工沿用 2026-10-13 的既有结论(行业=类别少→环形;
         // 片区/类型=多类别排名→横条),本票不动图形形态,只把指代写清楚。
         // 片区/类型=多类别排名→横条),本票不动图形形态,只把指代写清楚。
-        donutBlock('覆盖情况 · 行业(按点位数)', industryRows),
-        barsBlock('覆盖情况 · 片区(按点位数)', regionRows),
-        barsBlock('覆盖情况 · 设备类型(按点位数)', typeRows),
+        donutBlock('覆盖情况 · 行业(按点位数)', industryRows, { layout: 'compact' }),
+        barsBlock('覆盖情况 · 片区(按点位数)', regionRows, { variant: 'rank' }),
+        barsBlock('覆盖情况 · 设备类型(按点位数)', typeRows, { variant: 'rank' }),
+        // 维护者 2026-10-29:类型构成用堆叠条(占比一眼看完),与上面的排名称互补。
+        stackedBlock('设备类型构成(按点位数)', typeRows),
         ...(extra.statusDistribution && extra.statusDistribution.available
         ...(extra.statusDistribution && extra.statusDistribution.available
             ? [barsBlock('状态分布 · 异常监测点(全局未筛)', abnormalCountRows(
             ? [barsBlock('状态分布 · 异常监测点(全局未筛)', abnormalCountRows(
                 Object.entries(extra.statusDistribution.counts || {}).map(([status, count]) => ({ status, count })), 'point'))]
                 Object.entries(extra.statusDistribution.counts || {}).map(([status, count]) => ({ status, count })), 'point'))]
@@ -725,6 +734,43 @@ export function monitoringDistributionPanel(data, section, extra = {}) {
  * @param {object} section 分区
  * @param {object} section 分区
  * @param {object} [extra] `{ devices }`(`toMonitoringOverview().devices`,外壳给)
  * @param {object} [extra] `{ devices }`(`toMonitoringOverview().devices`,外壳给)
  */
  */
+/**
+ * 预警 / 报警两条趋势合并成**同轴双线**(维护者 2026-10-29)。
+ *
+ * 为什么要在这里合并:两个端点序列的桶起点可能不完全一致(一侧少一个桶、或粒度不同),
+ * 直接按数组下标配对会在图上画出错位的对照 —— 这是**假数据**,不是排版问题。
+ * 所以按桶键求并集、缺失一侧记 null(**不补 0**:没这个桶 ≠ 这个桶是 0),
+ * 序列里出现 null 时该点不画点、不计入折线(渲染层按 filter(Boolean) 处理)。
+ *
+ * @param {Array} warningRows `warningTrend` 行(`bucket`/`count`)
+ * @param {Array} alarmRows `alarmTrend` 行
+ * @returns {{points: Array, warningCounts: Array, alarmCounts: Array}}
+ */
+export function mergeTrendSeries(warningRows, alarmRows) {
+    const warning = Array.isArray(warningRows) ? warningRows : []
+    const alarm = Array.isArray(alarmRows) ? alarmRows : []
+    const buckets = []
+    const seen = new Set()
+    for (const row of [...warning, ...alarm]) {
+        const key = String((row && row.bucket) || '')
+        if (!key || seen.has(key)) continue
+        seen.add(key)
+        buckets.push(key)
+    }
+    buckets.sort()
+    const index = (rows) => {
+        const map = new Map()
+        for (const row of rows) map.set(String((row && row.bucket) || ''), numberish(row && row.count))
+        return map
+    }
+    const warningMap = index(warning)
+    const alarmMap = index(alarm)
+    return {
+        points: buckets.map((bucket) => ({ bucket })),
+        warningCounts: buckets.map((bucket) => warningMap.get(bucket) ?? null),
+        alarmCounts: buckets.map((bucket) => alarmMap.get(bucket) ?? null),
+    }
+}
 export function operationOverviewPanel(data, section, extra = {}) {
 export function operationOverviewPanel(data, section, extra = {}) {
     const current = data || {}
     const current = data || {}
     // 7 天窗口的桶是「日」粒度,标签按日期显示(只影响显示,不改桶)。
     // 7 天窗口的桶是「日」粒度,标签按日期显示(只影响显示,不改桶)。
@@ -737,6 +783,8 @@ export function operationOverviewPanel(data, section, extra = {}) {
             count: row.count,
             count: row.count,
         }))
         }))
 
 
+    // 维护者 2026-10-29:两条趋势合成一张双线图(按桶键求并集,缺失不补 0)。
+    const mergedTrend = mergeTrendSeries(current.warningTrend, current.alarmTrend)
     const offlineCount = metricCount(current.metricStatuses, 'OFFLINE')
     const offlineCount = metricCount(current.metricStatuses, 'OFFLINE')
     // 离线设备:从异常清单里挑出离线的那一档。**只挑,不推断**——状态来自后端 `status`。
     // 离线设备:从异常清单里挑出离线的那一档。**只挑,不推断**——状态来自后端 `status`。
     // 票 62:这段只服务 `offlineNote`(顶部摘要那行),**不再画成清单**。
     // 票 62:这段只服务 `offlineNote`(顶部摘要那行),**不再画成清单**。
@@ -770,12 +818,25 @@ export function operationOverviewPanel(data, section, extra = {}) {
             { key: 'warningCount', label: '预警设备', value: valueText(metricCount(current.metricStatuses, 'WARNING')), unit: '台' },
             { key: 'warningCount', label: '预警设备', value: valueText(metricCount(current.metricStatuses, 'WARNING')), unit: '台' },
             { key: 'alarmCount', label: '报警设备', value: valueText(metricCount(current.metricStatuses, 'ALARM')), unit: '台' },
             { key: 'alarmCount', label: '报警设备', value: valueText(metricCount(current.metricStatuses, 'ALARM')), unit: '台' },
             { key: 'abnormalCount', label: '总异常设备', value: valueText(current.pagination && current.pagination.total), unit: '台', hint: '预警 + 报警 + 离线 + 过期' },
             { key: 'abnormalCount', label: '总异常设备', value: valueText(current.pagination && current.pagination.total), unit: '台', hint: '预警 + 报警 + 离线 + 过期' },
-        ], { layout: 'three' }),
-        barsBlock('设备/指标异常统计', abnormalCountRows(current.metricStatuses, 'metric')),
-        // 预警与报警**分开画**:后端注释明确两者不混合,合成一条就是把两类事件混成一份。
-        // 2026-10-13 图表选型:时间趋势用折线。
-        trendBlock('预警趋势', trendPoints(current.warningTrend), { chart: 'line' }),
-        trendBlock('报警趋势', trendPoints(current.alarmTrend), { chart: 'line' }),
+        ], { layout: 'three', emphasis: true }),
+        barsBlock('设备/指标异常统计', abnormalCountRows(current.metricStatuses, 'metric'), { variant: 'rank' }),
+        // 维护者 2026-10-29:预警与报警合成**一张双线折线**(同一时间窗、同一量纲),并给块顶留出
+        // 呼吸空间(compact 版式),避免与块标题/点顶数值重叠。两条线仍是两个独立数据源,
+        // 只共享坐标轴与图例 —— 不改变任何统计口径。
+        trendBlock('预警与报警趋势', mergedTrend.points.map((row) => ({
+            key: row.bucket,
+            label: bucketLabel(row.bucket, daily),
+            count: 0,
+            hint: row.bucket,
+        })), {
+            chart: 'line',
+            compact: true,
+            aside: '双线对照:预警 / 报警',
+            series: [
+                { key: 'warning', label: '预警', tone: 'lv2', counts: mergedTrend.warningCounts },
+                { key: 'alarm', label: '报警', tone: 'lv1', counts: mergedTrend.alarmCounts },
+            ],
+        }),
         barsBlock(
         barsBlock(
             '异常态势 · 指标状态分布',
             '异常态势 · 指标状态分布',
             (current.metricStatuses || []).map((row) => ({
             (current.metricStatuses || []).map((row) => ({
@@ -793,12 +854,11 @@ export function operationOverviewPanel(data, section, extra = {}) {
         section,
         section,
         hasContent: sectionHasContent(section),
         hasContent: sectionHasContent(section),
         blocks,
         blocks,
-        // 台账每行是一台**设备**,设备编码即它在地图上的对象编码(未关联设备以自身编码上图);
-        // 只有已加载到一张图里才飞得动 —— 三种"点不动"由 `domain/mapLocate.js` 分开。
-        locate: devices.flatMap((item) =>
-            locateEntries(item.code, deviceLabel(item), { code: item.code, name: deviceLabel(item) }, item.code ? [item.code] : []),
-        ),
-        locateNote: '设备台账可定位;设备坐标由一张图回联,未加载到的设备不下发飞行',
+        // 维护者 2026-10-29:定位入口与监测点台账对齐 —— **整卡点击**即定位并进详情,
+        // 不再额外画一排「定位」按钮(两条路径会让同一件事有两种手感)。
+        // 三种"点不动"仍由 `domain/mapLocate.js` 分开,由 locateCode 如实反馈。
+        locate: [],
+        locateNote: '点击设备卡片即定位并进入设备详情(与监测点台账同一交互);设备坐标由一张图回联,未加载到的设备不下发飞行',
         // 窗口口径:后端回显的实际生效区间(本面板的 24 小时 / 7 天切换写在界面上,见票面 Q8)。
         // 窗口口径:后端回显的实际生效区间(本面板的 24 小时 / 7 天切换写在界面上,见票面 Q8)。
         rangeNote: current.rangeStart && current.rangeEnd ? `统计窗口:${current.rangeStart} 至 ${current.rangeEnd}` : '',
         rangeNote: current.rangeStart && current.rangeEnd ? `统计窗口:${current.rangeStart} 至 ${current.rangeEnd}` : '',
         offlineNote: offlineDevices.length
         offlineNote: offlineDevices.length
@@ -887,10 +947,11 @@ function deviceLedgerBlock(devices) {
             industryLabel(item.industry) || codeText(item.industry),
             industryLabel(item.industry) || codeText(item.industry),
             item.pointCode ? `点位 ${item.pointCode}` : '未关联点位',
             item.pointCode ? `点位 ${item.pointCode}` : '未关联点位',
         ].filter(Boolean),
         ].filter(Boolean),
-        // 设备编码即该设备在地图上的对象编码(未关联设备以自身编码上图)。
-        points: item.code
-            ? [{ key: `${item.code}|locate`, label: deviceLabel(item), pointCode: item.code, objectCode: item.code, objectName: deviceLabel(item) }]
-            : [],
+        // 维护者 2026-10-29:设备卡**不再单独画「定位」小按钮**,改为与监测点台账一致 ——
+        // 整卡点击 = 定位 + 进详情(一个动作,不是"先点小按钮定位、再点卡进详情"两条路径)。
+        // 定位本身仍走既有设备维度接缝 `monitoringStore.locateCode`(判定→相机→详情),
+        // 三种"点不动"由 `domain/mapLocate.js` 分开,不在这里另造一套。
+        points: [],
     }))
     }))
     return listBlock('设备台账', rows, '当前范围内没有监测设备', {
     return listBlock('设备台账', rows, '当前范围内没有监测设备', {
         paginate: true,
         paginate: true,

+ 270 - 0
src/modules/cockpit/domain/operatingMetricCaliber.js

@@ -0,0 +1,270 @@
+/**
+ * 运行信息「三项能力指标」的**唯一口径来源**(票 67)。
+ *
+ * ## 真实契约(2026-10-28 实测 `/infrastructure/operating-information` + 后端源码)
+ *
+ * 后端把运行指标按 `category` 分成五个枚举(`CockpitInfrastructureOperatingInformationService`
+ * 第 34–35 行:SUPPLY / FLOW / PRESSURE / QUALITY / CAPACITY),当前业务数据只产出其中三个:
+ *
+ *   · SUPPLY   ← 水厂当前供水能力(`water_plant_current_supply`,单位 万m³/d,带设计能力基准)
+ *   · FLOW     ← 泵站设计流量(`*_pump_station_design_flow`,单位 m³/h)
+ *   · CAPACITY ← 管网长度(`*_pipe_network_length`,单位 m)与排水工程设施点数(`*_point_count`,单位 个)
+ *
+ * 依据(后端源码,逐行读过):
+ *   · `pipe-network-service/zksy-system/.../BusinessCockpitInfrastructureOperatingInformationAdapter.java`
+ *     第 63–152 行是四条数据源:水厂 currentCapacity(SUPPLY)、泵站 designFlow(FLOW)、
+ *     管网 length(CAPACITY)、排水工程设施逐点计数(CAPACITY)。
+ *   · 每条指标对应**一个设施**(管网按段逐条,699 条燃气指标共用一个 metricCode)——
+ *     所以一类的读数只有**合计**才有业务含义;旧实现取"该 category 第一条"会显示
+ *     某一段管道的长度(1.03 m)当作"承载能力",这正是维护者看不懂的根因。
+ *
+ * ## 行业范围(同一份实测)
+ *
+ *   · 不传 `industry`:后端返回**四个行业块**(GAS / WATER_SUPPLY / DRAINAGE / MANHOLE),
+ *     `selectedIndustry` 回 null;没指标的行业块 state=EMPTY。
+ *   · 传 `industry=GAS|WATER_SUPPLY|DRAINAGE|MANHOLE`:只返回该行业有指标,其余块 EMPTY。
+ *   · 传 `industry=all`:**400**(`industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE`),
+ *     即后端不认"all"、也不忽略它。前端 `industryQuery('all')` 回空串=不传参,本来就对。
+ *   · 旧实现"全部行业只显示燃气"是**纯前端**问题:`operationPanel` 在未筛行业时直接取
+ *     `industries[0]`(后端块序恒以燃气打头),却没跨块合计、也没把清单铺开。
+ *
+ * ## 本模块的取舍
+ *
+ * 1. **合计按单位分组**:单位是硬量纲,不能把「个」加进「m」。取条数最多的那组作为卡面读数,
+ *    其余单位在悬停说明里如实点出"未计入",不静默吞掉、也不硬凑一个假总数。
+ * 2. **文案只在这里出现**(三项名称/业务含义/量词),`infrastructurePanels.js` 只消费,
+ *    模板不认识任何指标键 —— 票 67 验收的"文案唯一来源"。
+ * 3. **降级如实**:后端若只回一个行业块(忽略"全部"或部署降级),界面说"仅统计××",
+ *    不说"全部行业"。这是验收点名的降级路径。
+ */
+import { codeText, numberish, ratioText } from './panelBlocks.js'
+import { DETAIL_PLACEHOLDER } from './detailFlow.js'
+
+/** 三项能力的固定顺序(也是卡片顺序)。 */
+export const OPERATING_CATEGORY_ORDER = Object.freeze(['SUPPLY', 'FLOW', 'CAPACITY'])
+
+/**
+ * category → 卡面名称。名称必须自明(票 67 要求 3),所以不用接口枚举当标签:
+ * 「供应能力 / 输送流量 / 承载能力」各自配一句业务含义(见 `OPERATING_METRIC_CALIBER`)。
+ */
+export const OPERATING_CATEGORY_CALIBER = Object.freeze({
+    SUPPLY: Object.freeze({ label: '供应能力' }),
+    FLOW: Object.freeze({ label: '输送流量' }),
+    CAPACITY: Object.freeze({ label: '承载能力' }),
+})
+
+/**
+ * metricCode 后缀 → 业务含义与量词(唯一来源)。
+ *
+ * 为什么按**后缀**而不是整流:metricCode 带行业前缀(`gas_pipe_network_length` /
+ * `water_supply_pipe_network_length` / `drainage_pipe_network_length`),
+ * 同一类设施的读数必须合并成一条合计;带前缀匹配会把三个行业拆成三张不同的卡。
+ * 认不出的码回退到 category 的名称(不猜业务含义)。
+ */
+export const OPERATING_METRIC_CALIBER = Object.freeze({
+    water_plant_current_supply: Object.freeze({ business: '水厂实际供水能力', caption: '水厂供水能力合计', countNoun: '座水厂' }),
+    pump_station_design_flow: Object.freeze({ business: '泵站设计输送流量', caption: '泵站输送流量合计', countNoun: '座泵站' }),
+    pipe_network_length: Object.freeze({ business: '管网长度', caption: '管网长度合计', countNoun: '段管网' }),
+    point_count: Object.freeze({ business: '设施点数', caption: '设施点数合计', countNoun: '处设施' }),
+})
+
+/** 保留两位小数(后端 BigDecimal 统一两位;浮点求和不留 0.0000001 的尾巴)。 */
+function round2(value) {
+    return Math.round(value * 100) / 100
+}
+
+/** metricCode → 业务口径(按后缀最长匹配,避免将来短码抢先命中)。 */
+function caliberOf(metric) {
+    const code = typeof metric.code === 'string' ? metric.code : ''
+    if (!code) return null
+    const suffixes = Object.keys(OPERATING_METRIC_CALIBER).sort((a, b) => b.length - a.length)
+    for (const suffix of suffixes) {
+        if (code.endsWith(suffix)) return OPERATING_METRIC_CALIBER[suffix]
+    }
+    return null
+}
+
+/** 负荷状态 → 语义色键(与单条指标同一条色表,只是阈值在这里按合计负荷重算)。 */
+function toneOfRatio(ratio) {
+    if (ratio === null) return 'neutral'
+    if (ratio > 1) return 'lv1'
+    if (ratio >= 0.8) return 'lv2'
+    return 'lv4'
+}
+
+/** 合计负荷率(仅在整组都带能力基准时才有意义)。 */
+function ratioOf(rows) {
+    let value = 0
+    let capacity = 0
+    for (const metric of rows) {
+        if (metric.capacityValue === null || metric.capacityValue === undefined) return null
+        value += numberish(metric.value) || 0
+        capacity += numberish(metric.capacityValue) || 0
+    }
+    return capacity > 0 ? value / capacity : null
+}
+
+/** 一条空卡(该范围没有这一类指标):占位 + 一句可读的说明,不伪造 0。 */
+function emptyCard(category) {
+    const caliber = OPERATING_CATEGORY_CALIBER[category] || { label: codeText(category) }
+    return {
+        key: category,
+        label: caliber.label,
+        value: DETAIL_PLACEHOLDER,
+        unit: '',
+        // 8 字以内:窄卡才不会被裁/折行(2026-10-28 维护者反馈)。
+        caption: '本范围无此类指标',
+        hint: '后端未提供该类指标',
+        tone: 'neutral',
+    }
+}
+
+/**
+ * 一类能力的卡面读数(票 67 的投影函数:给定任意行业范围的指标集合 → 一项 KPI)。
+ *
+ * @param {Array} metrics 当前范围的全部指标(已带 industryCode/industryLabel)
+ * @param {string} category SUPPLY / FLOW / CAPACITY
+ * @param {string} [scopeLabel] 当前行业范围的说法(进悬停说明,满足"数值变化可解释")
+ */
+export function operatingCategoryCard(metrics, category, scopeLabel = '') {
+    const rows = (Array.isArray(metrics) ? metrics : []).filter((metric) => metric && metric.category === category)
+    const caliber = OPERATING_CATEGORY_CALIBER[category] || { label: codeText(category) }
+    if (!rows.length) return emptyCard(category)
+
+    // 按**单位**分组:不同量纲不合并;同单位才可合计。
+    const groups = new Map()
+    for (const metric of rows) {
+        const unit = typeof metric.unit === 'string' ? metric.unit : ''
+        if (!groups.has(unit)) groups.set(unit, { unit, rows: [], businesses: new Set(), captions: new Set(), countNouns: new Set() })
+        const group = groups.get(unit)
+        group.rows.push(metric)
+        const metricCaliber = caliberOf(metric)
+        group.businesses.add(metricCaliber ? metricCaliber.business : '')
+        group.captions.add(metricCaliber ? metricCaliber.caption : '')
+        group.countNouns.add(metricCaliber ? metricCaliber.countNoun : '')
+    }
+    // 条数最多的单位组=卡面读数(并列取先出现的,保持后端顺序可预期)。
+    let primary = null
+    const extras = []
+    for (const group of groups.values()) {
+        if (primary === null || group.rows.length > primary.rows.length) {
+            if (primary) extras.push(primary)
+            primary = group
+        } else {
+            extras.push(group)
+        }
+    }
+
+    const total = primary.rows.reduce((sum, metric) => sum + (numberish(metric.value) || 0), 0)
+    const unit = primary.unit
+    const ratio = ratioOf(primary.rows)
+    const business = primary.businesses.size === 1 ? [...primary.businesses][0] || '' : ''
+    const countNoun = primary.countNouns.size === 1 ? [...primary.countNouns][0] || '' : ''
+    // 卡面小字用**短的**口径句("水厂供水能力合计"):2026-10-28 维护者反馈指标卡不要出现省略号,
+    // 长句("水厂实际供水能力合计" 11 字)在窄卡会被裁;完整说法(含"实际")仍在悬停说明里,
+    // 两者不重复也不丢信息。认不出的码回退到 category 名称。
+    const captionSingle = primary.captions.size === 1 ? [...primary.captions][0] || '' : ''
+    const caption = captionSingle || `${business || caliber.label}合计`
+
+    const hintParts = []
+    if (scopeLabel) hintParts.push(scopeLabel)
+    hintParts.push(countNoun ? `按 ${primary.rows.length} ${countNoun}读数合计` : `按 ${primary.rows.length} 条运行指标合计`)
+    if (ratio === null) {
+        hintParts.push('无能力基准(后端未提供能力基准)')
+    } else {
+        const capacityTotal = primary.rows.reduce((sum, metric) => sum + (numberish(metric.capacityValue) || 0), 0)
+        hintParts.push(`能力基准 ${round2(capacityTotal)} ${unit} · 负荷 ${ratioText(ratio)}`)
+    }
+    for (const extra of extras) {
+        const extraBusiness = extra.businesses.size === 1 ? [...extra.businesses][0] || '同类指标' : '同类指标'
+        hintParts.push(`另有 ${extra.rows.length} 项${extraBusiness}(${extra.unit || '无单位'})未计入本卡合计`)
+    }
+
+    return {
+        key: category,
+        label: caliber.label,
+        value: String(round2(total)),
+        unit,
+        caption,
+        hint: hintParts.join(';'),
+        tone: toneOfRatio(ratio),
+        loadRatio: ratio,
+    }
+}
+
+/**
+ * 三项能力的**口径说明**(面板顶部折叠摘要用)。
+ *
+ * 与三项名称/业务含义同属"一句话唯一来源":措辞只在本模块写一次,
+ * `infrastructurePanels.js` 只做引用,避免改了卡片名却忘了改说明。
+ */
+export const OPERATING_SOURCE_NOTE =
+    '运行指标由后端按设施实体逐条生成(水厂/泵站/管网/排水设施四类,一条指标对应一个设施),所以三项能力是同类设施读数的合计,不是某一条指标:供应能力=水厂当前供水能力,输送流量=泵站设计流量,承载能力=管网长度(排水设施点数单独计数,不与长度相加)。合计只合并单位相同的指标,单位不同的在卡片悬停说明里写明未计入;单位与能力基准都取自后端。行业范围取后端回显:选单个行业时只统计该行业;选「全部行业」时把所有有指标的行业合起来,并列出实际参与的行业。'
+
+/** 三项能力的 KPI 项(顺序固定;只投影、不重复业务逻辑)。 */
+export function operatingCategoryCards(metrics, scopeLabel = '') {
+    return OPERATING_CATEGORY_ORDER.map((category) => operatingCategoryCard(metrics, category, scopeLabel))
+}
+
+/**
+ * 当前行业范围的**唯一判定点**(票 67 要求 2/4)。
+ *
+ * · `selectedIndustry` 有值:只统计该行业块;后端回显什么就说什么。
+ * · 回显为空(=选了"全部行业"):把**所有**有指标的行业块合起来统计,并如实列出
+ *   实际参与合计的行业;若后端只回了一个行业块,说明"仅统计××"(忽略 all 的降级路径)。
+ *
+ * @param {Array} blocks 后端 `industries`(已转成领域对象:code/label/state/metrics)
+ * @param {string} selectedIndustry 后端回显的选中行业码(空串/undefined=全部)
+ */
+export function operatingScope(blocks, selectedIndustry) {
+    const list = Array.isArray(blocks) ? blocks : []
+    const selected = typeof selectedIndustry === 'string' ? selectedIndustry : ''
+    const labelOf = (block) => (block && (block.label || block.code)) || ''
+    const withMetrics = list.filter((block) => Array.isArray(block.metrics) && block.metrics.length > 0)
+
+    if (selected) {
+        const block = list.find((item) => item && item.code === selected) || null
+        const metrics = block && Array.isArray(block.metrics) ? block.metrics : []
+        const label = labelOf(block) || selected
+        return {
+            key: selected,
+            code: selected,
+            label,
+            allView: false,
+            degraded: false,
+            metrics,
+            hintLabel: label,
+            note: metrics.length ? `当前统计:${label}(按行业筛选)` : `当前统计:${label}(后端未返回该行业的运行指标)`,
+        }
+    }
+
+    const metrics = withMetrics.flatMap((block) => block.metrics)
+    if (!metrics.length) {
+        return { key: 'ALL', code: '', label: '全部行业', allView: true, degraded: false, metrics, hintLabel: '全部行业', note: '当前统计:全部行业(后端未返回运行指标)' }
+    }
+    if (list.length === 1) {
+        // 后端只回了一个行业块:无法证明覆盖全部行业,如实降级(票 67 验收的降级路径)。
+        const only = labelOf(list[0]) || '单个行业'
+        return {
+            key: 'ALL',
+            code: '',
+            label: '全部行业',
+            allView: true,
+            degraded: true,
+            metrics,
+            hintLabel: `仅${only}`,
+            note: `当前统计:仅${only}(后端只返回了 1 个行业,无法覆盖全部行业)`,
+        }
+    }
+    const labels = withMetrics.map(labelOf).filter(Boolean)
+    return {
+        key: 'ALL',
+        code: '',
+        label: '全部行业',
+        allView: true,
+        degraded: false,
+        metrics,
+        hintLabel: '全部行业合计',
+        note: labels.length ? `当前统计:全部行业(${labels.join('、')})` : '当前统计:全部行业(后端未返回运行指标)',
+    }
+}

+ 152 - 23
src/modules/cockpit/domain/overviewPanels.js

@@ -20,6 +20,13 @@
  * 纯函数:不 import service、不碰 DOM、不自己发请求。
  * 纯函数:不 import service、不碰 DOM、不自己发请求。
  * 渲染层(`components/PanelBlocks.vue`)仍**不认识 panelId、不认识后端字段**。
  * 渲染层(`components/PanelBlocks.vue`)仍**不认识 panelId、不认识后端字段**。
  */
  */
+import {
+    BACKLOG_CALIBER_NOTE,
+    DEFAULT_BACKLOG_TIME_ZONE,
+    alarmBacklogSeries,
+    backlogGapNote,
+    backlogSnapshotNote,
+} from './alarmBacklog.js'
 import { fieldReader } from './fields.js'
 import { fieldReader } from './fields.js'
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
 import { valueText } from './equipmentDetailView.js'
 import { valueText } from './equipmentDetailView.js'
@@ -32,6 +39,7 @@ import {
     panelMissingNote,
     panelMissingNote,
     rankingBarsBlock,
     rankingBarsBlock,
     ratioText,
     ratioText,
+    rowsBlock,
     sectionHasContent,
     sectionHasContent,
     sectionPlaceholder,
     sectionPlaceholder,
     stackedBlock,
     stackedBlock,
@@ -106,6 +114,8 @@ export function toPerception(raw) {
                 code: row.optionalStr('code'),
                 code: row.optionalStr('code'),
                 label: row.optionalStr('name') || row.optionalStr('code'),
                 label: row.optionalStr('name') || row.optionalStr('code'),
                 count: numberish(row.raw.deviceCount) ?? 0,
                 count: numberish(row.raw.deviceCount) ?? 0,
+                onlineCount: numberish(row.raw.onlineCount),
+                offlineCount: numberish(row.raw.offlineCount),
                 note: conciseMonitoring(monitoringContent),
                 note: conciseMonitoring(monitoringContent),
                 noteTitle: monitoringContent,
                 noteTitle: monitoringContent,
             }
             }
@@ -215,9 +225,19 @@ function toDuration(raw) {
     }
     }
 }
 }
 
 
-/** 报警处置(规格 §5 `/overview/alarm-disposal`)→ 领域对象。 */
-export function toAlarmDisposal(raw) {
+/**
+ * 报警处置(规格 §5 `/overview/alarm-disposal`)→ 领域对象。
+ *
+ * 票 96:第二个入参是**原始报警记录**(`/events/current?mode=ALARM`)。日积压不再取后端
+ * `sevenDayTrend[].unhandledTotal` —— 它是"按当前状态回溯"算的,处置一条会让七天一起变
+ * (维护者 2026-10-28 实测 `1,2,2,2,2,2,2` → `1,1,1,1,1,1,1`)。这里改用记录逐日还原历史。
+ * @param {object} raw 端点载荷
+ * @param {{events?: Array<object>, complete?: boolean}} [extras] 报警记录(取不到就不画第二条线)
+ */
+export function toAlarmDisposal(raw, extras = {}) {
     const reader = fieldReader(raw, 'overviewAlarmDisposal')
     const reader = fieldReader(raw, 'overviewAlarmDisposal')
+    // 分桶边界必须与后端同一个时区(信封回显),否则整天错位。
+    const timeZone = String((raw && raw.timezone) || '') || DEFAULT_BACKLOG_TIME_ZONE
     const conversion = fieldReader(reader.raw.workOrderConversion, 'overviewAlarmDisposal.workOrderConversion')
     const conversion = fieldReader(reader.raw.workOrderConversion, 'overviewAlarmDisposal.workOrderConversion')
     const disposal = fieldReader(reader.raw.disposal, 'overviewAlarmDisposal.disposal')
     const disposal = fieldReader(reader.raw.disposal, 'overviewAlarmDisposal.disposal')
 
 
@@ -242,9 +262,9 @@ export function toAlarmDisposal(raw) {
             }
             }
         }),
         }),
         // 近 7 日:固定 7 点、含 0 值、顺序从早到晚(后端保证;本层不补点)。
         // 近 7 日:固定 7 点、含 0 值、顺序从早到晚(后端保证;本层不补点)。
-        // 每天两个读数(维护者 2026-10-23):`count` = 当天新增;`unhandledTotal` = 截至当天未处理累计。
-        // `unhandledTotal` 是后加字段,老响应没有它 → 记 null(视图层据此决定画不画第二条线,
-        // 不补 0 冒充"积压为零")。
+        // `count` = 当天新增(可信,来自事件时间分桶)。
+        // `unhandledTotal` **只留作诊断**(后端按当前状态回溯算的值,不是历史)——
+        // 票 96 起折线的第二条线不再消费它,改由下面的 `backlog` 逐日还原。
         sevenDayTrend: reader.optionalList('sevenDayTrend').map((point) => {
         sevenDayTrend: reader.optionalList('sevenDayTrend').map((point) => {
             const row = fieldReader(point, 'overviewAlarmDisposal.sevenDayTrend')
             const row = fieldReader(point, 'overviewAlarmDisposal.sevenDayTrend')
             return {
             return {
@@ -253,6 +273,18 @@ export function toAlarmDisposal(raw) {
                 unhandledTotal: numberish(row.raw.unhandledTotal),
                 unhandledTotal: numberish(row.raw.unhandledTotal),
             }
             }
         }),
         }),
+        // 票 96:真实日积压(counts=null ⇒ 不画这条线,reason 说明为什么)。
+        // snapshotAt 透传出来只给口径行用("今天"这一点可能比 live 聚合晚一拍)。
+        backlog: {
+            ...alarmBacklogSeries({
+                events: extras.events,
+                complete: extras.complete !== false,
+                dates: reader.optionalList('sevenDayTrend')
+                    .map((point) => fieldReader(point, 'overviewAlarmDisposal.sevenDayTrend').optionalStr('date')),
+                timeZone,
+            }),
+            snapshotAt: String(extras.snapshotAt || ''),
+        },
         missing: reader.missing,
         missing: reader.missing,
     }
     }
 }
 }
@@ -269,6 +301,49 @@ function ratioView(rate) {
     return available ? ratioText(row.value) : DETAIL_PLACEHOLDER
     return available ? ratioText(row.value) : DETAIL_PLACEHOLDER
 }
 }
 
 
+/**
+ * 报警转单率(维护者 2026-10-29 新增的工单口径读数)。
+ * 分母=已核实报警总数(后端给 totalCount,缺失时用 converted + unconverted 兜底,同源);
+ * 分母为 0 或缺失时回占位符 —— 与处置率同一条纪律,绝不画 0%。
+ */
+function conversionRateView(conversion) {
+    const row = conversion || {}
+    const converted = numberish(row.convertedCount)
+    const unconverted = numberish(row.unconvertedCount)
+    const total = numberish(row.totalCount) ?? (converted === null ? null : converted + (unconverted ?? 0))
+    if (converted === null || total === null || total <= 0) return DETAIL_PLACEHOLDER
+    return (Math.round((converted / total) * 1000) / 10) + '%'
+}
+
+/** 转单率的口径小字(分母为 0 时说明不适用,不编造解释)。 */
+function conversionRateHint(conversion) {
+    const row = conversion || {}
+    const converted = numberish(row.convertedCount)
+    const unconverted = numberish(row.unconvertedCount)
+    const total = numberish(row.totalCount) ?? (converted === null ? null : converted + (unconverted ?? 0))
+    if (converted === null || total === null) return '后端未给出转单计数'
+    if (total <= 0) return '当前窗口没有已核实报警,转单率不适用'
+    return '已转 ' + converted + ' / 已核实 ' + total + ' 条'
+}
+/** 近30日类型集中度:只在总数有正分母时计算;缺失不冒充 0%。 */
+export function warningConcentrationRows(ranking) {
+    const total = numberish(ranking && ranking.total)
+    const rows = Array.isArray(ranking && ranking.top) ? ranking.top : []
+    if (!(total > 0)) {
+        return [
+            { key: 'top1', label: '首位类型占比', value: DETAIL_PLACEHOLDER },
+            { key: 'top3', label: '前三类型占比', value: DETAIL_PLACEHOLDER },
+        ]
+    }
+    const valid = rows.map((row) => numberish(row && row.count)).filter((value) => value !== null)
+    const percent = (value) => (Math.round((value / total) * 1000) / 10) + '%'
+    return [
+        { key: 'top1', label: '首位类型占比', value: valid.length ? percent(valid[0]) : DETAIL_PLACEHOLDER },
+        { key: 'top3', label: '前三类型占比', value: valid.length ? percent(valid.slice(0, 3).reduce((sum, value) => sum + value, 0)) : DETAIL_PLACEHOLDER },
+        { key: 'types', label: '本期类型数', value: String(rows.length) },
+    ]
+}
+
 /**
 /**
  * 等级卡 → 带等级色的行(票 11 视觉对齐)。
  * 等级卡 → 带等级色的行(票 11 视觉对齐)。
  *
  *
@@ -315,20 +390,31 @@ function trendOf(sevenDayTrend) {
 }
 }
 
 
 /**
 /**
- * 「总未处理」序列(维护者 2026-10-23 反馈第 2 条)。
+ * 「未处理报警(截至当日)」序列(票 96 定稿):直接取 `alarmBacklogSeries()` 的逐日积压。
  *
  *
- * 口径:**截至当天仍未处理**的累计条数(`alarm_status = 0`,0=未处理 / 1=已处理 ——
- * 见后端 `AlarmRecordStatusEnum`)。它不是"当天新增里未处理的",而是"到那天为止积压了多少",
- * 所以能和"当天新增"放在同一张图上做对照:新增归零而积压不为零,读起来就是"最近没新报警、
- * 但历史未处理的还在那里"。
+ * 为什么不再用后端 `unhandledTotal`:它是"按当前状态回溯"算的 —— 今天处置一条,七天的值
+ * 一起变(2026-10-28 实测 `1,2,2,2,2,2,2` → `1,1,1,1,1,1,1`),历史日不是历史。
+ * 现在这条线由原始报警记录逐日还原:今天怎么处置,过去几天都不动。
  *
  *
- * 数据来源:后端按日给 `sevenDayTrend[].unhandledTotal`(新字段);还没升级的后端
- * (返回里没有该字段)时回空列表 —— 那样图例会少一条线,而不是画一条假的 0 线。
+ * `counts === null` ⇒ 不画这条线(缺记录/缺时间戳/分页截断/未知状态),由 `backlogGapNote()` 说明。
+ */
+function backlogOf(current) {
+    const backlog = current && current.backlog
+    return backlog && Array.isArray(backlog.counts) ? backlog.counts : []
+}
+
+/**
+ * 第二条线的口径行(票 96)。三件事:
+ *   1. 有积压线时说明**口径与限制**:未处理=报警状态未解除;处置时刻取"最近更新",积压只会偏高;
+ *   2. 报警记录快照落后时点明快照时间(端点是每日快照,"今天"那点会晚一拍);
+ *   3. 没画线时说明原因(缺记录/缺时间戳/分页截断/未知状态),不静默少一条线。
  */
  */
-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)
+function backlogNote(current) {
+    const backlog = (current && current.backlog) || {}
+    if (backlog.reason) return backlogGapNote(backlog.reason)
+    if (!Array.isArray(backlog.counts)) return ''
+    return BACKLOG_CALIBER_NOTE
+        + backlogSnapshotNote(backlog.snapshotAt, current && current.updatedAt)
 }
 }
 
 
 /**
 /**
@@ -459,6 +545,16 @@ export function perceptionDevicesPanel(data, section, extra) {
         ),
         ),
         // 片区卡:城南/城北/太常固定三项,无数据画 0(规格 §6.2 第 3 条);一排三张。
         // 片区卡:城南/城北/太常固定三项,无数据画 0(规格 §6.2 第 3 条);一排三张。
         cardsBlock('按设备片区', current.districts || [], { fixed: true, layout: 'three', aside: '片区在网设备' }),
         cardsBlock('按设备片区', current.districts || [], { fixed: true, layout: 'three', aside: '片区在网设备' }),
+        supplementalBlock(donutBlock('各类型在线设备', (current.types || [])
+            .filter((row) => numberish(row.onlineCount) !== null)
+            .map((row) => ({ code: row.code, label: row.label, count: row.onlineCount })), { layout: 'compact' })),
+        supplementalBlock(rankingBarsBlock('类型在线率排行', (current.types || [])
+            .filter((row) => numberish(row.onlineCount) !== null && numberish(row.offlineCount) !== null && row.onlineCount + row.offlineCount > 0)
+            .map((row) => ({
+                code: row.code, label: row.label, count: Math.round((row.onlineCount / (row.onlineCount + row.offlineCount)) * 1000) / 10,
+                hint: `在线 ${row.onlineCount} / 状态可判 ${row.onlineCount + row.offlineCount}`,
+            }))
+            .sort((a, b) => b.count - a.count), { limit: 0, unit: '%', variant: 'rank' })),
     ]
     ]
 
 
     return {
     return {
@@ -516,7 +612,14 @@ export function pipeNetworkPanel(data, section) {
         supplementalBlock(rankingBarsBlock('各类管线里程', (current.types || [])
         supplementalBlock(rankingBarsBlock('各类管线里程', (current.types || [])
             .filter((row) => numberish(row.lengthKm) !== null)
             .filter((row) => numberish(row.lengthKm) !== null)
             .map((row) => ({ code: row.code, label: row.label, count: row.lengthKm }))
             .map((row) => ({ code: row.code, label: row.label, count: row.lengthKm }))
-            .sort((a, b) => b.count - a.count), { limit: 0, unit: 'km' })),
+            .sort((a, b) => b.count - a.count), { limit: 0, unit: 'km', variant: 'rank' })),
+        supplementalBlock(rankingBarsBlock('平均单管长度', (current.types || [])
+            .filter((row) => numberish(row.lengthKm) !== null && numberish(row.count) > 0)
+            .map((row) => ({
+                code: row.code, label: row.label, count: Math.round((row.lengthKm * 1000 / row.count) * 10) / 10,
+                hint: `${row.count} 根 · ${row.lengthKm} km`,
+            }))
+            .sort((a, b) => b.count - a.count), { limit: 0, unit: 'm/根', variant: 'rank' })),
     ]
     ]
 
 
     return {
     return {
@@ -583,6 +686,10 @@ export function warningStudyPanel(data, section) {
             { key: 'PROCESSING', label: '处置中', count: current.unresolved?.statuses?.PROCESSING },
             { key: 'PROCESSING', label: '处置中', count: current.unresolved?.statuses?.PROCESSING },
             { key: 'HANDLED', label: '已处置', count: current.unresolved?.statuses?.HANDLED },
             { key: 'HANDLED', label: '已处置', count: current.unresolved?.statuses?.HANDLED },
         ])),
         ])),
+        supplementalBlock(donutBlock('未解除处置结构', [
+            { code: 'todo', label: '待处置', count: ['PENDING', 'RECEIVED', 'PROCESSING'].reduce((sum, key) => sum + (Number(current.unresolved?.statuses?.[key]) || 0), 0) },
+            { code: 'handled', label: '已处置', count: Number(current.unresolved?.statuses?.HANDLED) || 0 },
+        ])),
     ]
     ]
 
 
     return {
     return {
@@ -653,18 +760,39 @@ export function alarmDisposalPanel(data, section) {
             })),
             })),
             // 两条线并列(维护者 2026-10-23 反馈第 2 条):
             // 两条线并列(维护者 2026-10-23 反馈第 2 条):
             //   ① 当天新增(当天 create_time 的条数);
             //   ① 当天新增(当天 create_time 的条数);
-            //   ② 总未处理(截至当天仍未处理的累计条数,`alarm_status = 0`)。
-            // 为什么不再挂一句口径小字:单条线 + 一个"近7日新增"标签说不清"为什么有的时候是 0",
-            // 维护者要的是**用第二条线做对照**(总未处理非 0 而新增为 0,一看就知道是"这段时间没新报警")。
-            // 两条线各按联合最大值归一并带图例,读者自己就能读出这层关系,不需要解释文案。
+            //   ② 未处理报警(截至当天仍未处理的积压条数,`alarm_status = 0`)。
+            // 两条线各按联合最大值归一并带图例,读者自己就能读出对照关系。
+            //
+            // 票 94 审查:第二条线**保留一句口径小字**(上一轮"不需要解释文案"的判定的例外)。
+            // 原因不是"解释数据",而是**解歧义**:同一块面板上方 KPI 有「未转工单」(工单转化口径),
+            // 折线这里是「未处理报警」(报警状态口径),两条读数会同时出现且数值不同
+            // (2026-10-28 实测:未转工单 0 / 未处理报警 2,两者都真)。不点明,值班人员必然读成矛盾。
+            // 小字放块右上角(`aside`),不改块标题【近7日报警数】(规格 §6.5 锁定标题)。
             {
             {
                 chart: 'line',
                 chart: 'line',
+                compact: true,
+                aside: '未处理=报警状态未解除,非工单口径',
                 series: [
                 series: [
                     { key: 'newAlarms', label: '当天新增', tone: 'lv2', counts: trendOf(current.sevenDayTrend) },
                     { key: 'newAlarms', label: '当天新增', tone: 'lv2', counts: trendOf(current.sevenDayTrend) },
-                    { key: 'unhandledTotal', label: '总未处理', tone: 'lv1', counts: unhandledOf(current) },
+                    // 票 96:由原始记录逐日还原(历史日不随今天的处置变化)。
+                    { key: 'unhandledTotal', label: '未处理报警(截至当日)', tone: 'lv1', counts: backlogOf(current) },
                 ],
                 ],
             },
             },
         ),
         ),
+        // 维护者 2026-10-29:折线下方补工单口径统计(转单构成 + 转单率),仍是真实字段。
+        supplementalBlock(donutBlock('报警转工单构成', [
+            { code: 'converted', label: '已转工单', count: conversion.convertedCount },
+            { code: 'unconverted', label: '未转工单', count: conversion.unconvertedCount },
+        ], { layout: 'compact' })),
+        // 工单口径读数(只放上方还没有的读数:转单率与已核实报警总数)。
+        // 处置率/平均时长**已经在上方 KPI 里**,这里重复就是噪音(同一屏说两遍)。
+        supplementalBlock(rowsBlock('工单转化口径', [
+            { key: 'alarmConvertRate', label: '报警转单率', value: conversionRateView(conversion) },
+            { key: 'convertedCount', label: '已转工单', value: valueText(conversion.convertedCount) + ' 条' },
+            { key: 'unconvertedCount', label: '未转工单', value: valueText(conversion.unconvertedCount) + ' 条' },
+            { key: 'verifiedAlarmCount', label: '已核实报警', value: valueText(conversion.totalCount) + ' 条' },
+            { key: 'convertHint', label: '转单口径', value: conversionRateHint(conversion) },
+        ])),
     ]
     ]
 
 
     return {
     return {
@@ -677,7 +805,8 @@ export function alarmDisposalPanel(data, section) {
         // 面板口径说明(顶部折叠区)。**不再**把"上方全量 / 折线近7日"写成一句 ——
         // 面板口径说明(顶部折叠区)。**不再**把"上方全量 / 折线近7日"写成一句 ——
         // 维护者 2026-10-23 第 2 条明确不要那类多余说明;折线改成两条线 + 图例后,
         // 维护者 2026-10-23 第 2 条明确不要那类多余说明;折线改成两条线 + 图例后,
         // "新增归零而积压不为零"这层关系读者自己看得出来,不需要一段文字去解释。
         // "新增归零而积压不为零"这层关系读者自己看得出来,不需要一段文字去解释。
-        note: '基础集合为已核实报警;时长只计有日志的样本',
+        // 口径行(票 94):第二条线撤下时把"为什么少一条线"写在这里,不静默丢。
+        note: '基础集合为已核实报警;时长只计有日志的样本' + backlogNote(current),
         missing: panelMissingNote(current),
         missing: panelMissingNote(current),
         placeholder: sectionPlaceholder(section),
         placeholder: sectionPlaceholder(section),
     }
     }

+ 135 - 14
src/modules/cockpit/domain/panelBlocks.js

@@ -20,6 +20,7 @@
  * 点位状态的中文标签归 `domain/pointStatus.js`(票 09 的唯一一张表),本文件不碰。
  * 点位状态的中文标签归 `domain/pointStatus.js`(票 09 的唯一一张表),本文件不碰。
  */
  */
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
+import { kpiFitVars } from './kpiFit.js'
 import { SECTION_STATE } from './sectionState.js'
 import { SECTION_STATE } from './sectionState.js'
 import { collectMissing, missingNote } from './equipmentDetailView.js'
 import { collectMissing, missingNote } from './equipmentDetailView.js'
 import { industryLabel } from './filterOptions.js'
 import { industryLabel } from './filterOptions.js'
@@ -30,10 +31,14 @@ export const CARD_ACTION = Object.freeze({
     POINT: 'point',
     POINT: 'point',
     DEVICE: 'device',
     DEVICE: 'device',
     EVENT: 'event',
     EVENT: 'event',
+    WORK_ORDER: 'work-order',
     FACILITY: 'facility',
     FACILITY: 'facility',
+    EMERGENCY: 'emergency',
     NONE: 'none',
     NONE: 'none',
 })
 })
-const CARD_ACTION_KINDS = Object.freeze([CARD_ACTION.POINT, CARD_ACTION.DEVICE, CARD_ACTION.EVENT, CARD_ACTION.FACILITY])
+const CARD_ACTION_KINDS = Object.freeze([
+    CARD_ACTION.POINT, CARD_ACTION.DEVICE, CARD_ACTION.EVENT, CARD_ACTION.WORK_ORDER, CARD_ACTION.FACILITY, CARD_ACTION.EMERGENCY,
+])
 
 
 /**
 /**
  * 列表行 → 统一详情动作。
  * 列表行 → 统一详情动作。
@@ -336,6 +341,18 @@ function emptyDesignBlock(block) {
             design.slices = []
             design.slices = []
             design.rows = placeholderRows(block.rows)
             design.rows = placeholderRows(block.rows)
             break
             break
+        case 'donuts':
+            // 多环块(票 66):环的行业名与共用图例的片区名都是**固定类别**,没有数也要在位 ——
+            // 只撤合计与扇区几何(不画环),图例计数换占位符。
+            // 没有任何数据时(失败/空/不可用)`groups` 本身是空的,此时用块自带的固定轴
+            // 长出骨架:**轴由 domain 声明,渲染层与这里都不认识行业/片区词典**(票 18 纪律)。
+            design.groups = (block.groups || []).length
+                ? block.groups.map((group) => ({ ...group, total: null, placeholder: true, slices: [] }))
+                : (block.axis || []).map((item) => ({
+                    key: item.key, label: item.label, unit: block.unit || '', hint: '', total: null, placeholder: true, slices: [],
+                }))
+            design.legend = (block.legend || []).map((row) => ({ ...row, count: null, placeholder: true }))
+            break
         case 'dimbars':
         case 'dimbars':
             design.dimensions = (block.dimensions || []).map((dim) => ({
             design.dimensions = (block.dimensions || []).map((dim) => ({
                 ...dim,
                 ...dim,
@@ -440,6 +457,11 @@ export const KPI_ICONS = {
     lengthKm: 'facility',
     lengthKm: 'facility',
     // 总体概览四模块(票 02):读数键映射到**既有**语义图标(不另造切图)。
     // 总体概览四模块(票 02):读数键映射到**既有**语义图标(不另造切图)。
     disposalDuration: 'time',
     disposalDuration: 'time',
+    // 票 66:管理信息统计形态的两个新读数键 —— 「覆盖片区」与「覆盖行业」同属「维度覆盖数」,
+    // 共用同一枚维度计数图标(资产库没有片区图标,不新造切图);「未分区设施」是数据质量桶,
+    // 走「未知/缺失」的既有图标。
+    districtCount: 'industryCount',
+    unassignedFacility: 'unknownOrMissing',
     // 应急保障
     // 应急保障
     hazardSourceCount: 'unknownOrMissing',
     hazardSourceCount: 'unknownOrMissing',
     protectionTargetCount: 'shield',
     protectionTargetCount: 'shield',
@@ -459,6 +481,8 @@ export const KPI_ICON_COLORS = {
     offline: 'var(--muted, #5680b0)',
     offline: 'var(--muted, #5680b0)',
     stale: 'var(--lv2, #ffb020)',
     stale: 'var(--lv2, #ffb020)',
     unknownOrMissing: 'var(--lv2, #ffb020)',
     unknownOrMissing: 'var(--lv2, #ffb020)',
+    // 票 66:未分区是数据质量桶,与「未知/缺失」同一档关注色(黄),不冒充片区色。
+    unassignedFacility: 'var(--lv2, #ffb020)',
     warningTotal: 'var(--lv2, #ffb020)',
     warningTotal: 'var(--lv2, #ffb020)',
     warningCount: 'var(--lv2, #ffb020)',
     warningCount: 'var(--lv2, #ffb020)',
     // 票 78:已解除率走「已解除」的语义色(与「预警-rate」同源);时长不另配色(同响应/结案时长)。
     // 票 78:已解除率走「已解除」的语义色(与「预警-rate」同源);时长不另配色(同响应/结案时长)。
@@ -495,6 +519,9 @@ export const KPI_ICON_COLORS = {
  *   · `iconColor`(KPI_ICON_COLORS,未命中回空串 → 渲染层用主蓝)
  *   · `iconColor`(KPI_ICON_COLORS,未命中回空串 → 渲染层用主蓝)
  *   · `deltaText`/`deltaTone`(**只有给了数字 `delta` 才出现**:规格 §6.6 的
  *   · `deltaText`/`deltaTone`(**只有给了数字 `delta` 才出现**:规格 §6.6 的
  *     「较昨日 +6 / -3 / 0」与正红负绿;没给差值就没有这一行,不画假的 0)
  *     「较昨日 +6 / -3 / 0」与正红负绿;没给差值就没有这一行,不画假的 0)
+ *   · `fitVars`(维护者 2026-10-28:指标卡不出现省略号)——标签/读数/口径小字的**宽度预算**,
+ *     由 `domain/kpiFit.js` 按文本与卡内图标算;渲染层用它在窄格降字号(见 PanelBlocks.vue
+ *     的 `font-size: max(... min(... 100cqw ...))`),而不是把字裁成 `…`。
  */
  */
 export function kpiBlock(items, options = {}) {
 export function kpiBlock(items, options = {}) {
     return {
     return {
@@ -504,19 +531,32 @@ export function kpiBlock(items, options = {}) {
         // 且每卡顶部一条语义色横条)。版式是**通用呈现意图**,渲染层不认识任何指标键。
         // 且每卡顶部一条语义色横条)。版式是**通用呈现意图**,渲染层不认识任何指标键。
         layout: options.layout || '',
         layout: options.layout || '',
         aside: options.aside || '',
         aside: options.aside || '',
+        // 维护者 2026-10-29:读数强调档(放大一号)。通用呈现意图,由调用方声明,
+        // 渲染层不认识任何面板身份。
+        emphasis: options.emphasis === true,
         items: items.map((item) => {
         items: items.map((item) => {
             // value-first 版式(读数在上、标签在下)的卡片按设计稿不画图标:
             // value-first 版式(读数在上、标签在下)的卡片按设计稿不画图标:
             // 版式说了算,不靠逐项传 icon 空串来关掉(那是把呈现意图散到每个调用点)。
             // 版式说了算,不靠逐项传 icon 空串来关掉(那是把呈现意图散到每个调用点)。
             const iconsOff = options.layout === 'value-first'
             const iconsOff = options.layout === 'value-first'
+            const icon = iconsOff ? '' : KPI_ICONS[item.key] || ''
             const enriched = {
             const enriched = {
                 ...item,
                 ...item,
-                icon: iconsOff ? '' : KPI_ICONS[item.key] || '',
+                icon,
                 iconColor: iconsOff ? '' : KPI_ICON_COLORS[item.key] || '',
                 iconColor: iconsOff ? '' : KPI_ICON_COLORS[item.key] || '',
                 // 读数色 / 卡顶横条 / 卡内说明:都由 domain 给语义,渲染层只查色表。
                 // 读数色 / 卡顶横条 / 卡内说明:都由 domain 给语义,渲染层只查色表。
                 valueTone: item.valueTone || '',
                 valueTone: item.valueTone || '',
                 accent: item.accent || '',
                 accent: item.accent || '',
                 caption: item.caption || '',
                 caption: item.caption || '',
                 detail: item.detail || '',
                 detail: item.detail || '',
+                // 宽度预算要用**算完的** icon:有图标时标签列被多吃掉 19u(图标 14u + 列间距 5u)。
+                fitVars: kpiFitVars({
+                    label: item.label,
+                    caption: item.caption,
+                    value: item.value,
+                    full: item.full,
+                    unit: item.unit,
+                    icon,
+                }),
             }
             }
             // 差值只在 domain 明确给了数字时才落地(`numberish` 认不出 → 不出现)。
             // 差值只在 domain 明确给了数字时才落地(`numberish` 认不出 → 不出现)。
             const delta = numberish(item.delta)
             const delta = numberish(item.delta)
@@ -791,6 +831,80 @@ export function donutBlock(label, rows, options = {}) {
     }
     }
 }
 }
 
 
+/**
+ * 多环形网格块(票 66 · 管理信息「片区 × 行业」):一个块里并排画 N 个小环 + 一份共用图例。
+ *
+ * 与 `donutBlock` 的分工:`donutBlock` 是**一个**类别的构成(左环右图例);
+ * 本块服务「同一种构成、按另一个类别各画一遍」——每个行业一个环,环内扇区是该行业
+ * 在各片区的设施占比,环心是该行业合计。四个环若各带一份图例,就是一屏四份图例
+ * (那是 `donutBlock` 的形态,放不下也读不过来),所以图例**只有一份**放在块底。
+ *
+ * 图例只有一份,扇区色就必须**跨环稳定**:颜色由 `options.legend` 的次序决定,
+ * 不是由每个环自己的行序决定 —— 否则同一个片区在两个环里两种颜色,图例就在说假话。
+ *
+ * 明细(每格的设施/设备数、片区的设施与设备合计)进 `hint`,由渲染层挂成悬停说明,
+ * 不占屏上位置(票 66 第 3 条:明细不进清单形态)。
+ *
+ * @param {string} label 块标题
+ * @param {Array<{key: string, label: string, unit?: string, hint?: string, slices: Array<{key: string, label: string, count: number}>}>} groups 每个环(`slices` 是该环的构成)
+ * @param {{legend?: Array<{key: string, label: string, count?: number, tone?: string, hint?: string}>, unit?: string, aside?: string, emptyText?: string}} [options]
+ *   `legend`=共用图例行(`key` 要与 `groups[].slices[].key` 同一套,色才稳定);
+ *   `tone` 可由调用方显式给(如「未分区」要中性色),缺省按行序走 `barTone`。
+ */
+export function donutGridBlock(label, groups, options = {}) {
+    const legend = (Array.isArray(options.legend) ? options.legend : []).map((row, index) => ({
+        key: row.key,
+        label: row.label,
+        count: Number(row.count) || 0,
+        tone: row.tone || barTone(row.label, index),
+        hint: row.hint || '',
+    }))
+    const toneByKey = new Map(legend.map((row) => [row.key, row.tone]))
+    const cells = (Array.isArray(groups) ? groups : []).map((group) => {
+        const source = Array.isArray(group.slices) ? group.slices : []
+        const total = source.reduce((sum, slice) => sum + (Number(slice.count) || 0), 0)
+        // 几何与 `donutBlock` 一字不差:SVG 视框 100×100、外半径 42 / 内半径 26,
+        // 只给 count>0 的扇区出片;命中区是整楔(`hitPath`,内半径 0),与票 43 同一个办法。
+        let angle = 0
+        const slices = []
+        for (const slice of source) {
+            const count = Number(slice.count) || 0
+            if (count <= 0 || total <= 0) continue
+            const end = angle + (count / total) * 360
+            slices.push({
+                key: slice.key,
+                label: slice.label,
+                count,
+                ratio: count / total,
+                tone: toneByKey.get(slice.key) || barTone(slice.label, source.indexOf(slice)),
+                path: ringSlicePath(angle, end),
+                hitPath: ringSlicePath(angle, end, 42, 0),
+            })
+            angle = end
+        }
+        return {
+            key: group.key,
+            label: group.label,
+            unit: group.unit || options.unit || '',
+            hint: group.hint || '',
+            total,
+            slices,
+        }
+    })
+    return {
+        kind: 'donuts',
+        label,
+        aside: options.aside || '',
+        unit: options.unit || '',
+        emptyText: options.emptyText || '当前范围没有片区与行业计数',
+        groups: cells,
+        legend,
+        // 环的固定轴(`[{key,label}]`):只在**没有任何数据**时用来保设计骨架(票 72)。
+        // 有数据时该画几个环由 `groups` 决定(全 0 的行业不出环)。
+        axis: (Array.isArray(options.axis) ? options.axis : []).map((item) => ({ key: item.key, label: item.label })),
+    }
+}
+
 /**
 /**
  * 可切换维度的**分类柱状图**块(票 38 · 管理信息「片区 × 行业」)。
  * 可切换维度的**分类柱状图**块(票 38 · 管理信息「片区 × 行业」)。
  *
  *
@@ -960,6 +1074,8 @@ export function trendBlock(label, points, options = {}) {
         kind: 'trend',
         kind: 'trend',
         label,
         label,
         aside: options.aside || '',
         aside: options.aside || '',
+        // 维护者 2026-10-29:折线「紧凑」版(更矮、上方留呼吸空间),只由调用方显式声明。
+        compact: options.compact === true,
         // 折线语义色键(设计稿:报警折线为橙、现有多处折线为主蓝)。渲染层查同一张色表。
         // 折线语义色键(设计稿:报警折线为橙、现有多处折线为主蓝)。渲染层查同一张色表。
         tone: options.tone || '',
         tone: options.tone || '',
         max,
         max,
@@ -1020,8 +1136,10 @@ function multiSeries(rows, options, { chart, n, xPct, yPct }) {
     if (!declared.length || chart !== 'line' || n < 2) {
     if (!declared.length || chart !== 'line' || n < 2) {
         return { series: [], multiSeries: false, seriesMax: 0 }
         return { series: [], multiSeries: false, seriesMax: 0 }
     }
     }
+    // 维护者 2026-10-29:`null` = 该序列在这个桶里**没有数据**(不是 0)。
+    // 这类点整点不画 —— 画成 0 会在图上伪造一个「降到底」的读数(票 62 的同一纪律)。
     const seriesMax = declared.reduce(
     const seriesMax = declared.reduce(
-        (top, series) => series.counts.reduce((m, value) => Math.max(m, Number(value) || 0), top),
+        (top, series) => series.counts.reduce((m, value) => (value === null || value === undefined ? m : Math.max(m, Number(value) || 0)), top),
         0,
         0,
     )
     )
     const yOf = (count) => (seriesMax > 0 ? ((Number(count) || 0) / seriesMax) * 100 : 0)
     const yOf = (count) => (seriesMax > 0 ? ((Number(count) || 0) / seriesMax) * 100 : 0)
@@ -1032,17 +1150,20 @@ function multiSeries(rows, options, { chart, n, xPct, yPct }) {
             key: series.key,
             key: series.key,
             label: series.label,
             label: series.label,
             tone: series.tone || '',
             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),
-            })),
+            // 每条线自己的点列(x 与标签共用主轴,y 按联合最大值归一);缺数据的桶直接跳过。
+            points: series.counts
+                .map((count, index) => ({ count, index }))
+                .filter(({ count }) => count !== null && count !== undefined)
+                .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)。
         // 多序列时 Y 轴刻度按联合最大值给(单序列的 yTicks 仍按自己的 max)。
         yTicks: (seriesMax > 0 ? [0, 0.5, 1] : [0]).map((f) => ({ value: seriesMax * f, pct: f * 100 })),
         yTicks: (seriesMax > 0 ? [0, 0.5, 1] : [0]).map((f) => ({ value: seriesMax * f, pct: f * 100 })),

+ 19 - 0
src/modules/cockpit/domain/panelCatalog.js

@@ -160,6 +160,25 @@ export function panelSubtitle(panelId) {
     return PANEL_SUBTITLES[panelId] || ''
     return PANEL_SUBTITLES[panelId] || ''
 }
 }
 
 
+/**
+ * 面板落在左栏还是右栏(**唯一判定源**,维护者 2026-10-29)。
+ *
+ * 详情浮层要贴在来源侧,而"来源侧"就是面板所在栏 —— 两处各判一次必然漂移
+ * (历史事件面板在左栏,却曾按右栏定位)。外壳与专用列表组件都从这里取,
+ * 不再各自硬编码。未知面板回空串,调用方据此不做定位假设。
+ *
+ * @param {string} mode 一级模式
+ * @param {string} panelId 二级面板
+ * @returns {'left'|'right'|''}
+ */
+export function panelSideFor(mode, panelId) {
+    const columns = COLUMNS[mode]
+    if (!columns) return ''
+    if (columns.left.includes(panelId)) return 'left'
+    if (columns.right.includes(panelId)) return 'right'
+    return ''
+}
+
 /** 返回模式的左右两栏面板;未知模式直接报错,避免静默渲染空壳。 */
 /** 返回模式的左右两栏面板;未知模式直接报错,避免静默渲染空壳。 */
 export function columnsForMode(mode) {
 export function columnsForMode(mode) {
     const columns = COLUMNS[mode]
     const columns = COLUMNS[mode]

+ 60 - 0
src/modules/cockpit/domain/panelSummary.js

@@ -0,0 +1,60 @@
+/**
+ * 面板摘要(维护者 2026-10-29):把「这块面板的口径说明」从面板体顶部的一整条,
+ * 收成一个「?」按钮,挂到**面板标题栏右侧**、与数据状态灯并排。
+ *
+ * 为什么搬进 domain:摘要字段的**清单**(哪些字段算说明)原先写在渲染层,
+ * 而渲染层现在不再是消费点(消费点变成标题栏)。字段清单一挪就必须有唯一住处,
+ * 否则「domain 新增说明而界面看不见」这条老洞会重新张开(票 56 的 C 组普查正是守它)。
+ *
+ * 本模块只做两件事:
+ *   1. 声明字段清单(与 data-* 断言面一一对应,走查脚本按同一组选择器读);
+ *   2. 把视图对象折成条目/折叠态读数 —— 不合成任何文案,文案只来自各面板 domain。
+ */
+
+/** 摘要条目:视图字段 → 条目(key 用于折叠态计数,hook 是保留的断言面)。 */
+export const PANEL_SUMMARY_SOURCES = Object.freeze([
+    { key: 'industry', field: 'industryNote', hook: 'data-panel-industry' },
+    { key: 'type', field: 'typeNote', hook: 'data-panel-type-note' },
+    { key: 'event', field: 'eventNote', hook: 'data-panel-event-note' },
+    { key: 'range', field: 'rangeNote', hook: 'data-panel-range-note' },
+    { key: 'gap', field: 'gap', hook: 'data-panel-gap', isGap: true },
+    { key: 'offline', field: 'offlineNote', hook: 'data-panel-offline-note' },
+    { key: 'page', field: 'pageNote', hook: 'data-panel-page-note' },
+    { key: 'locate', field: 'locateNote', hook: 'data-locate-note' },
+    { key: 'excluded', field: 'excludedNote', hook: 'data-panel-excluded-note' },
+    { key: 'link', field: 'linkNote', hook: 'data-panel-link-note' },
+    { key: 'note', field: 'note', hook: 'data-panel-note' },
+    // 票 64:运行信息的数据来源与口径说明。
+    { key: 'source', field: 'sourceNote', hook: 'data-panel-source-note' },
+])
+
+/**
+ * 视图 → 摘要条目(只按字段有没有值决定画不画,不重写、不合成)。
+ * @param {object|null} view 面板视图计划
+ * @returns {Array<{key: string, text: string, isGap: boolean, attrs: object}>}
+ */
+export function panelSummaryItems(view) {
+    if (!view || typeof view !== 'object') return []
+    return PANEL_SUMMARY_SOURCES.flatMap(({ key, field, hook, isGap }) => {
+        const text = view[field]
+        if (!text) return []
+        return [{ key, text, isGap: Boolean(isGap), attrs: { [hook]: '', 'data-summary': key } }]
+    })
+}
+
+/**
+ * 「?」按钮的折叠态读数(不点开也要说得出有没有要关注的东西)。
+ * 判据是 domain 的措辞本身,渲染层不自己算数。
+ */
+export function panelSummaryStatus(view, items) {
+    const list = Array.isArray(items) ? items : []
+    if (!view || !list.length) return ''
+    if (view.offlineNote && view.offlineNote !== '当前窗口没有离线设备') return '有离线设备'
+    if (view.gap || view.note || view.excludedNote) return '有口径提示'
+    return `${list.length} 条说明`
+}
+
+/** 摘要面板的稳定 DOM id(多面板共存时 aria-controls 不串 target)。 */
+export function panelSummaryDetailsId(panelId) {
+    return `panel-summary-details-${panelId || 'default'}`
+}

+ 25 - 14
src/modules/cockpit/domain/warningResponsePanels.js

@@ -32,6 +32,7 @@ import {
     sectionPlaceholder,
     sectionPlaceholder,
     sortByCountDesc,
     sortByCountDesc,
     stackedBlock,
     stackedBlock,
+    supplementalBlock,
     trendBlock,
     trendBlock,
 } from './panelBlocks.js'
 } from './panelBlocks.js'
 
 
@@ -385,7 +386,8 @@ function historyEventListBlock(current) {
         title: item.name || item.code,
         title: item.name || item.code,
         identityKind: CARD_ACTION.EVENT,
         identityKind: CARD_ACTION.EVENT,
         identityCode: item.code,
         identityCode: item.code,
-        identityPayload: eventRowView(item),
+        identityFamily: String(current.mode || item.family || '').toUpperCase(),
+        identityPayload: { ...eventRowView(item), family: String(current.mode || item.family || '').toUpperCase() },
         identityReason: item.code ? '' : '缺少事件编码,无法展开',
         identityReason: item.code ? '' : '缺少事件编码,无法展开',
         tone: eventLevelTag(item.level).tone,
         tone: eventLevelTag(item.level).tone,
         toneLabel: eventLevelTag(item.level).label,
         toneLabel: eventLevelTag(item.level).label,
@@ -411,14 +413,23 @@ function historyEventListBlock(current) {
 
 
 export function historyAnalysisPanel(data, section) {
 export function historyAnalysisPanel(data, section) {
     const current = data || {}
     const current = data || {}
+    const levelCounts = [1, 2, 3, 4].map((level) => ({
+        code: String(level),
+        label: `${level}级`,
+        count: (current.events || []).filter((item) => Number(item.level) === level).length,
+    }))
     return sectionView('historyAnalysis', section, [
     return sectionView('historyAnalysis', section, [
         // 2026-10-13 图表选型:时间趋势用折线。
         // 2026-10-13 图表选型:时间趋势用折线。
-        trendBlock((current.trend || []).length > 48 ? '历史事件趋势 · 每日合计' : '历史事件趋势', historyTrendRows(current.trend || []), { chart: 'line' }),
+        trendBlock((current.trend || []).length > 48 ? '历史事件趋势 · 每日合计' : '历史事件趋势', historyTrendRows(current.trend || []), { chart: 'line', compact: true }),
+        // 维护者 2026-10-29:小字说明进面板摘要(标题栏的 ?),块标题保持短,不被省略号切掉。
+        supplementalBlock(donutBlock('已加载事件等级分布', levelCounts, { layout: 'compact' })),
         historyEventListBlock(current),
         historyEventListBlock(current),
     ], {
     ], {
         label: '历史分析',
         label: '历史分析',
         range: current.range,
         range: current.range,
         rangeNote: current.range && current.range.start ? `时间范围:${current.range.start} 至 ${current.range.end}` : '',
         rangeNote: current.range && current.range.start ? `时间范围:${current.range.start} 至 ${current.range.end}` : '',
+        // 已加载集合的口径(原块右上角小字,搬到摘要里,避免把块标题挤成省略号)。
+        note: '等级分布只统计当前已加载的事件集合,不代表整个统计窗口',
     })
     })
 }
 }
 
 
@@ -545,8 +556,8 @@ export function responseEfficiencyPanel(data, section, extra) {
             ...(Array.isArray(current.warningStates) && current.warningStates.length
             ...(Array.isArray(current.warningStates) && current.warningStates.length
                 ? [barsBlock('预警状态分布', current.warningStates)]
                 ? [barsBlock('预警状态分布', current.warningStates)]
                 : []),
                 : []),
-            barsBlock('预警等级分布', current.levelDistribution || []),
-            trendBlock(trend.length > 48 ? '预警发生趋势 · 每日合计' : '预警发生趋势', trend, { chart: 'line' }),
+            barsBlock('预警等级分布', current.levelDistribution || [], { variant: 'rank' }),
+            trendBlock(trend.length > 48 ? '预警发生趋势 · 每日合计' : '预警发生趋势', trend, { chart: 'line', compact: true }),
         ], {
         ], {
             label: responseEfficiencyTitle(EVENT_FAMILY.WARNING),
             label: responseEfficiencyTitle(EVENT_FAMILY.WARNING),
             range: current.range,
             range: current.range,
@@ -561,7 +572,7 @@ export function responseEfficiencyPanel(data, section, extra) {
         // 2026-10-13 图表选型:工单状态是多类别占比 → 百分比堆叠条(饼图超过 5 类不再适合)。
         // 2026-10-13 图表选型:工单状态是多类别占比 → 百分比堆叠条(饼图超过 5 类不再适合)。
         stackedBlock('工单状态分布', current.workOrderStates || []),
         stackedBlock('工单状态分布', current.workOrderStates || []),
         // 时长趋势仍是时间序列 → 折线。
         // 时长趋势仍是时间序列 → 折线。
-        trendBlock('平均结案时长趋势', trendRows((current.trend || []).filter((row) => row.count !== null)), { chart: 'line' }),
+        trendBlock('平均结案时长趋势', trendRows((current.trend || []).filter((row) => row.count !== null)), { chart: 'line', compact: true }),
         // 票 65 追加:报警模式下直接列出当前工单(卡片,可点开详情)。
         // 票 65 追加:报警模式下直接列出当前工单(卡片,可点开详情)。
         // 放在图之后:读者先看结论(处置率/分布),再看逐张明细。
         // 放在图之后:读者先看结论(处置率/分布),再看逐张明细。
         workOrderListBlock(extra),
         workOrderListBlock(extra),
@@ -646,10 +657,10 @@ export function workOrderCardRows(orders) {
         key: row.code,
         key: row.code,
         // 工单继续走 WarningResponsePane 的专用时间线入口;显式声明 action,避免被猜成点位。
         // 工单继续走 WarningResponsePane 的专用时间线入口;显式声明 action,避免被猜成点位。
         code: row.code,
         code: row.code,
-        identityKind: CARD_ACTION.EVENT,
-        identityCode: row.code,
-        identityPayload: { workOrderCode: row.code },
-        identityReason: row.code ? '' : '缺少工单编码,无法展开',
+        identityKind: CARD_ACTION.WORK_ORDER,
+        identityCode: row.id || '',
+        identityPayload: { ...row, workOrderCode: row.code, orderId: row.id },
+        identityReason: row.id ? '' : '缺少工单主键,无法展开',
         // 卡片抬头就是工单号:值班人员按编号对工单,不按故障描述认。
         // 卡片抬头就是工单号:值班人员按编号对工单,不按故障描述认。
         title: row.code,
         title: row.code,
         name: row.code,
         name: row.code,
@@ -770,18 +781,18 @@ export function eventAnalysisPanel(data, section, extra) {
         // 类型=图表色序),等级/片区保留条形(条形适合作排名比较)。行业行保留联动。
         // 类型=图表色序),等级/片区保留条形(条形适合作排名比较)。行业行保留联动。
         // 票 37 走查扩展:类型/行业/片区是排名类别按计数降序(并列保序);
         // 票 37 走查扩展:类型/行业/片区是排名类别按计数降序(并列保序);
         // 事件等级是**固定序**(1–4 级语义阶梯),保持后端字典序不打乱。
         // 事件等级是**固定序**(1–4 级语义阶梯),保持后端字典序不打乱。
-        donutBlock('按事件类型', sortByCountDesc(current.types || [], (row) => row.count)),
-        ...(causes.length ? [donutBlock('按成因', causes)] : []),
+        donutBlock('按事件类型', sortByCountDesc(current.types || [], (row) => row.count), { layout: 'compact' }),
+        ...(causes.length ? [donutBlock('按成因', causes, { layout: 'compact' })] : []),
         donutBlock(
         donutBlock(
             '按行业分布',
             '按行业分布',
             sortByCountDesc(
             sortByCountDesc(
                 (current.industries || []).map((row) => ({ ...row, actionKey: row.key || row.code })),
                 (current.industries || []).map((row) => ({ ...row, actionKey: row.key || row.code })),
                 (row) => row.count,
                 (row) => row.count,
             ),
             ),
-            { action: 'industry' },
+            { action: 'industry', layout: 'compact' },
         ),
         ),
-        barsBlock('按事件等级', current.levels || []),
-        barsBlock('按片区分布', sortByCountDesc(current.regions || [], (row) => row.count)),
+        barsBlock('按事件等级', current.levels || [], { variant: 'rank' }),
+        barsBlock('按片区分布', sortByCountDesc(current.regions || [], (row) => row.count), { variant: 'rank' }),
         listBlock('重点识别', highlights, '当前没有识别到重点'),
         listBlock('重点识别', highlights, '当前没有识别到重点'),
     ], {
     ], {
         label: '预警分析',
         label: '预警分析',

+ 24 - 16
src/modules/cockpit/domain/workOrder.js

@@ -58,32 +58,40 @@ function stateName(reader, code) {
  * @param {object} raw 后端 `CockpitWorkOrderSummary`
  * @param {object} raw 后端 `CockpitWorkOrderSummary`
  */
  */
 export function toWorkOrder(raw) {
 export function toWorkOrder(raw) {
-    const reader = fieldReader(raw, 'workOrder')
-    const code = reader.num('stateCode')
-    const association = raw && raw.eventAssociation
+    const mobile = raw && raw.order ? raw.order : raw || {}
+    const source = fieldReader(mobile, 'workOrder')
+    const code = source.raw.stateCode !== undefined ? source.num('stateCode') : source.num('orderStatus')
+    const association = mobile && mobile.eventAssociation
 
 
     return {
     return {
-        code: reader.str('workOrderCode'),
-        deviceCode: reader.optionalStr('deviceCode'),
-        orderTypeName: reader.optionalStr('orderTypeName'),
-        orderLevel: reader.optionalNum('orderLevel'),
-        description: reader.optionalStr('orderDesc'),
+        id: source.raw.orderId === undefined || source.raw.orderId === null ? '' : String(source.raw.orderId),
+        code: source.optionalStr('workOrderCode') || source.optionalStr('orderNo'),
+        deviceCode: source.optionalStr('deviceCode'),
+        deviceName: source.optionalStr('deviceName'),
+        deviceTypeName: source.optionalStr('deviceTypeName'),
+        deviceLocation: source.optionalStr('deviceLocation'),
+        orderTypeName: source.optionalStr('orderTypeName'),
+        orderLevel: source.optionalNum('orderLevel'),
+        description: source.optionalStr('orderDesc'),
         stateCode: code,
         stateCode: code,
-        stateName: stateName(reader, code),
-        createTime: reader.optionalStr('createTime'),
-        updateTime: reader.optionalStr('updateTime'),
-        planFinishTime: reader.optionalStr('planFinishTime'),
+        stateName: source.optionalStr('stateName') || source.optionalStr('orderStatusName') || workOrderStateName(code),
+        createTime: source.optionalStr('createTime'),
+        updateTime: source.optionalStr('updateTime'),
+        planFinishTime: source.optionalStr('planFinishTime'),
         // 未结案时后端给 null,这是正常的「还没有」而不是字段缺失。
         // 未结案时后端给 null,这是正常的「还没有」而不是字段缺失。
-        realFinishTime: reader.optionalStr('realFinishTime'),
-        associatedWithCurrentEvent: typeof reader.raw.associatedWithCurrentEvent === 'boolean'
-            ? reader.raw.associatedWithCurrentEvent : null,
+        realFinishTime: source.optionalStr('realFinishTime'),
+        repairResult: source.optionalStr('repairResult'),
+        receiveUserName: source.optionalStr('receiveUserName'),
+        verifyUserName: source.optionalStr('verifyUserName'),
+        associatedWithCurrentEvent: typeof source.raw.associatedWithCurrentEvent === 'boolean'
+            ? source.raw.associatedWithCurrentEvent : null,
         event: association
         event: association
             ? {
             ? {
                   source: fieldReader(association, 'eventAssociation').optionalStr('eventSource'),
                   source: fieldReader(association, 'eventAssociation').optionalStr('eventSource'),
                   code: fieldReader(association, 'eventAssociation').optionalStr('eventCode'),
                   code: fieldReader(association, 'eventAssociation').optionalStr('eventCode'),
               }
               }
             : null,
             : null,
-        missing: reader.missing,
+        missing: source.missing,
     }
     }
 }
 }
 
 

+ 1 - 1
src/modules/cockpit/mocks/contractMocks.js

@@ -1893,7 +1893,7 @@ function operatingInformationPayload(scenario, query = {}) {
     }
     }
 }
 }
 
 
-/** 管理信息样例:两个正常机构 + 一个「无管理关系」桶(后端显式给的那个)。 */
+/** 管理信息样例:四个「片区 × 行业」组合(含「未分区」与「有设备、没有设施」的城北)。 */
 function managementInformationPayload(scenario, query = {}) {
 function managementInformationPayload(scenario, query = {}) {
     const status = infraStatus(scenario)
     const status = infraStatus(scenario)
     const blank = status === 'EMPTY'
     const blank = status === 'EMPTY'

+ 81 - 0
src/modules/cockpit/services/cesiumEngine.js

@@ -274,6 +274,9 @@ const PERFORMANCE_DEFAULTS = {
     // 分辨率压到 0.6(像素量约为 0.75 的 64%),停下立刻恢复满档 —— 静态画质不变,
     // 分辨率压到 0.6(像素量约为 0.75 的 64%),停下立刻恢复满档 —— 静态画质不变,
     // 只有"正在动"的那几秒降档。这是自适应分辨率的标准做法,也是唯一不动观感的两全法。
     // 只有"正在动"的那几秒降档。这是自适应分辨率的标准做法,也是唯一不动观感的两全法。
     movingResolutionScale: 0.6,
     movingResolutionScale: 0.6,
+    // 自适应分辨率下限(票 61 第十九轮):机器撑不住 60fps 时降到这条线为止,
+    // 再低画质就明显受损了 —— 这是「保持当前质量」的量化底线。
+    adaptiveFloor: 0.55,
 }
 }
 
 
 /** 生效中的档位(applyPerformanceOptions 写入后续的 applyTerrain/applyTilesets 读取)。 */
 /** 生效中的档位(applyPerformanceOptions 写入后续的 applyTerrain/applyTilesets 读取)。 */
@@ -303,6 +306,84 @@ export function applyDetailOptions(options = {}) {
 
 
 export const PERFORMANCE_DEFAULT_OPTIONS = { ...PERFORMANCE_DEFAULTS }
 export const PERFORMANCE_DEFAULT_OPTIONS = { ...PERFORMANCE_DEFAULTS }
 
 
+import { stepAdaptiveResolution, frameIntervalP95 } from '../domain/adaptiveResolution.js'
+
+/** 自适应分辨率控制器(票 61 第十九轮)。惰性初始化,第一次 tick 才建。 */
+let adaptiveResolution = null
+
+/**
+ * 惰性建控制器(frameIntervalP95 / stepAdaptiveResolution 见 domain)。
+ */
+function ensureAdaptiveResolution() {
+    if (adaptiveResolution) return adaptiveResolution
+    adaptiveResolution = {
+        intervals: [],
+        scale: null,        // 惰性取部署基准档(首次应用时定)
+        hotSinceMs: null,
+        coldSinceMs: null,
+    }
+    return adaptiveResolution
+}
+
+/**
+ * 记录一帧间隔(帧间隔超过 1s 视为切后台/长暂停,丢弃——那不是性能信号)。
+ * @param {number} intervalMs 上一帧到这一帧的间隔
+ */
+export function noteFrameInterval(intervalMs) {
+    const n = Number(intervalMs)
+    if (!Number.isFinite(n) || n <= 0 || n > 1000) return
+    const state = ensureAdaptiveResolution()
+    state.intervals.push(n)
+    if (state.intervals.length > 90) state.intervals.shift()
+}
+
+/**
+ * 按最近帧间隔决定是否微调内部渲染分辨率。
+ * 在 viewer 的 postRender 回调里每帧调一次;样本不足 / 时机未到都返回 false(没改)。
+ * @param {Viewer} viewer
+ * @param {number} moving 相机是否在动(在动时对卡顿更敏感,阈值更保守 —— 这里只记帧间隔,决策不依赖它)
+ * @returns {boolean} 是否真的改了分辨率
+ */
+/** 自适应分辨率的当前读数(DEV 面板用;模块级单源,不另算)。 */
+export function adaptiveResolutionStatus() {
+    const state = ensureAdaptiveResolution()
+    return {
+        scale: state.scale,
+        samples: state.intervals.length,
+        p95Ms: frameIntervalP95(state.intervals),
+    }
+}
+
+export function tickAdaptiveResolution(viewer) {
+    if (!viewer || !viewer.scene) return false
+    const state = ensureAdaptiveResolution()
+    const base = Number.isFinite(activePerformance.resolutionScale)
+        ? activePerformance.resolutionScale
+        : PERFORMANCE_DEFAULTS.resolutionScale
+    const floor = Number.isFinite(activePerformance.adaptiveFloor)
+        ? activePerformance.adaptiveFloor
+        : PERFORMANCE_DEFAULTS.adaptiveFloor
+    if (state.scale === null) state.scale = base
+    const p95 = frameIntervalP95(state.intervals)
+    if (p95 === null) return false
+    const nowMs = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
+    const step = stepAdaptiveResolution({
+        p95Ms: p95,
+        currentScale: state.scale,
+        baseScale: base,
+        floorScale: floor,
+        nowMs,
+        hotSinceMs: state.hotSinceMs,
+        coldSinceMs: state.coldSinceMs,
+    })
+    state.hotSinceMs = step.hotSinceMs
+    state.coldSinceMs = step.coldSinceMs
+    if (!step.changed) return false
+    const changed = setRenderResolution(viewer, step.scale)
+    if (changed) state.scale = step.scale
+    return changed
+}
+
 /**
 /**
  * 应用渲染开销档位;未配置的项取上面的默认档。
  * 应用渲染开销档位;未配置的项取上面的默认档。
  * @param {Viewer} viewer
  * @param {Viewer} viewer

+ 31 - 0
src/modules/cockpit/services/entityDetailApi.js

@@ -0,0 +1,31 @@
+/** 统一实体详情只读 API。路径规划与响应正规化集中在此,组件/store 不认识后端信封。 */
+import { requestCockpitJson } from './cockpitApi.js'
+import { ENTITY_DETAIL_KIND, entityIdentity, normalizeEntityDetail } from '../domain/entityDetail.js'
+
+export const ENTITY_DETAIL_PATHS = Object.freeze({
+    [ENTITY_DETAIL_KIND.FACILITY]: (id, target) => target.category
+        ? `/entities/${encodeURIComponent(id)}/detail?category=${encodeURIComponent(target.category)}` : '',
+    [ENTITY_DETAIL_KIND.EMERGENCY]: (id, target) => target.category
+        ? `/entities/${encodeURIComponent(id)}/detail?category=${encodeURIComponent(target.category)}` : '',
+    [ENTITY_DETAIL_KIND.EVENT]: (id, target) => target.family
+        ? `/events/${encodeURIComponent(id)}/detail?family=${encodeURIComponent(target.family)}` : '',
+    [ENTITY_DETAIL_KIND.WORK_ORDER]: (id) => `/work-orders/${encodeURIComponent(id)}/detail`,
+})
+
+export function entityDetailPath(identityInput) {
+    const identity = entityIdentity(identityInput)
+    const build = ENTITY_DETAIL_PATHS[identity.kind]
+    return identity.valid && build ? build(identity.id, identityInput || {}) : ''
+}
+
+export async function fetchEntityDetail(identityInput, options = {}) {
+    const identity = entityIdentity(identityInput)
+    const path = options.path || entityDetailPath(identity)
+    if (!identity.valid || !path) return { ok: false, identity, reason: 'invalid-identity', mode: '', data: null }
+    const result = await requestCockpitJson(path, { config: options.config, fetchImpl: options.fetchImpl })
+    if (!result.ok) return { ...result, identity, data: null }
+    const detail = normalizeEntityDetail(identity, result.data, options.prefill)
+    return detail
+        ? { ok: true, identity, mode: result.mode || '', path: result.path || path, data: detail }
+        : { ok: false, identity, mode: result.mode || '', path: result.path || path, reason: 'bad-payload', data: null }
+}

+ 22 - 2
src/modules/cockpit/services/mapService.js

@@ -233,6 +233,8 @@ let deviceRows = []
  */
  */
 const overlayFrameCache = {
 const overlayFrameCache = {
     rows: null, byCode: new Map(), effectOf: () => '',
     rows: null, byCode: new Map(), effectOf: () => '',
+    // 票 61 第十九轮:hostRect 缓存(见挂载处的注释 —— 不每帧强制布局)。
+    rect: null, rectKey: '', rectAt: 0,
     // 票 84:关联设备层的门控查询(与摘要标记同一条门控;三类对象都按自身状态挂环)。
     // 票 84:关联设备层的门控查询(与摘要标记同一条门控;三类对象都按自身状态挂环)。
     deviceEffectOf: () => '',
     deviceEffectOf: () => '',
     // 票 85:跨图层合计门控与收敛系数(降级时环仍画,只是更小更淡)。
     // 票 85:跨图层合计门控与收敛系数(降级时环仍画,只是更小更淡)。
@@ -872,6 +874,14 @@ async function bootEngine(record) {
         record.viewer = viewer
         record.viewer = viewer
         record.detachMapFrame = viewer.scene.postRender.addEventListener(() => {
         record.detachMapFrame = viewer.scene.postRender.addEventListener(() => {
             for (const handler of mapFrameHandlers) handler()
             for (const handler of mapFrameHandlers) handler()
+            // 票 61 第十九轮:记帧间隔 + 自适应分辨率(低速机器上按实测帧间隔在 0.55~基准档 间微调,
+            // 撑不住才降、撑得住就回升 —— 画质只在需要时让位)。
+            try {
+                const nowMs = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
+                if (record.lastFrameAt) engine.noteFrameInterval(nowMs - record.lastFrameAt)
+                record.lastFrameAt = nowMs
+                engine.tickAdaptiveResolution(viewer)
+            } catch { /* 自适应失败不影响渲染 */ }
             // 票 39:瓦片就绪是 getter(读它不便宜),且**只有真的渲染过**才知道队列空没空,
             // 票 39:瓦片就绪是 getter(读它不便宜),且**只有真的渲染过**才知道队列空没空,
             // 所以放在 postRender 里刷;交接的推进也借此在"有帧"时才有机会前进。
             // 所以放在 postRender 里刷;交接的推进也借此在"有帧"时才有机会前进。
             if (record !== instance) return
             if (record !== instance) return
@@ -941,6 +951,8 @@ async function bootEngine(record) {
             tilesets: record.tilesetCulling?.snapshot?.() || [],
             tilesets: record.tilesetCulling?.snapshot?.() || [],
             // 票 61:细模覆盖前沿 —— 直接回答"现在覆盖到多远、为什么停在这"。
             // 票 61:细模覆盖前沿 —— 直接回答"现在覆盖到多远、为什么停在这"。
             detailFrontier: record.tilesetCulling?.frontierSnapshot?.() || [],
             detailFrontier: record.tilesetCulling?.frontierSnapshot?.() || [],
+            // 票 61 第十九轮:自适应分辨率读数(scale / 样本数 / 帧间隔 p95)。
+            adaptiveResolution: engine.adaptiveResolutionStatus?.() || null,
             camera: record.cameraControl?.getState?.() || null,
             camera: record.cameraControl?.getState?.() || null,
         }), { isMoving: () => record.cameraControl?.getState?.()?.settled === false })
         }), { isMoving: () => record.cameraControl?.getState?.()?.settled === false })
         // 票 37:相机控件挂上后立刻把生效限位算一遍并下发 —— 进区状态可能在引擎就绪之前
         // 票 37:相机控件挂上后立刻把生效限位算一遍并下发 —— 进区状态可能在引擎就绪之前
@@ -1044,8 +1056,16 @@ async function bootEngine(record) {
                 const cd = cam.directionWC
                 const cd = cam.directionWC
                 const pose = `${cp.x.toFixed(1)},${cp.y.toFixed(1)},${cp.z.toFixed(1)}|${cd.x.toFixed(4)},${cd.y.toFixed(4)},${cd.z.toFixed(4)}`
                 const pose = `${cp.x.toFixed(1)},${cp.y.toFixed(1)},${cp.z.toFixed(1)}|${cd.x.toFixed(4)},${cd.y.toFixed(4)},${cd.z.toFixed(4)}`
                 const nowMs = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
                 const nowMs = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
-                const hostRect = record.host.getBoundingClientRect()
-                const rectKey = `${hostRect.left},${hostRect.top},${hostRect.width},${hostRect.height}`
+                // 不要每帧 getBoundingClientRect():它是强制同步布局,跳帧检查本身会变成帧率成本。
+                // 布局只在窗口尺寸变化时才会动 —— 镜头动时顺带读一次 + 每 500ms 兜底校对。
+                let hostRect = overlayFrameCache.rect
+                if (pose !== overlayFrameCache.pose || nowMs - (overlayFrameCache.rectAt || 0) > 500) {
+                    hostRect = record.host.getBoundingClientRect()
+                    overlayFrameCache.rect = hostRect
+                    overlayFrameCache.rectKey = [hostRect.left, hostRect.top, hostRect.width, hostRect.height].join(',')
+                    overlayFrameCache.rectAt = nowMs
+                }
+                const rectKey = overlayFrameCache.rectKey || `${hostRect.left},${hostRect.top},${hostRect.width},${hostRect.height}`
                 const inputsChanged = overlayFrameCache.lastPlan !== markerPlan
                 const inputsChanged = overlayFrameCache.lastPlan !== markerPlan
                     || overlayFrameCache.lastRows !== deviceRows
                     || overlayFrameCache.lastRows !== deviceRows
                     || overlayFrameCache.lastEffects !== markerEffectsPlan
                     || overlayFrameCache.lastEffects !== markerEffectsPlan

+ 88 - 8
src/modules/cockpit/services/overviewApi.js

@@ -29,7 +29,10 @@ import {
     toWarningStudy,
     toWarningStudy,
 } from '../domain/overviewPanels.js'
 } from '../domain/overviewPanels.js'
 import { districtQuery, industryQuery } from '../domain/filterOptions.js'
 import { districtQuery, industryQuery } from '../domain/filterOptions.js'
-import { fetchSection, isEmptyBy } from './domainRequest.js'
+import { EVENT_FAMILY } from '../domain/events.js'
+import { sectionFromResult } from '../domain/sectionState.js'
+import { buildQuery, fetchSection, isEmptyBy } from './domainRequest.js'
+import { requestCockpitJson } from './cockpitApi.js'
 
 
 /**
 /**
  * 四个端点共用的全局筛选入参(票 89)。
  * 四个端点共用的全局筛选入参(票 89)。
@@ -91,14 +94,91 @@ export function fetchWarningStudy(options = {}) {
     })
     })
 }
 }
 
 
-/** 报警处置:今日/转单/处置段缺失才算空;等级卡(0 行)与 7 日趋势(全 0 点)仍要画。 */
-export function fetchAlarmDisposal(options = {}) {
-    return fetchSection(OVERVIEW_PATHS.alarmDisposal, {
-        params: filterParams(options),
-        toData: toAlarmDisposal,
+/**
+ * 报警记录的取数参数(票 96)。
+ *
+ * 为什么要另取原始记录:`/overview/alarm-disposal` 的 `sevenDayTrend[].unhandledTotal`
+ * 是「按当天及以前创建、用**当前**状态过滤」算的 —— 今天处置一条,七天值会一起变,
+ * 不能当历史序列(维护者 2026-10-28 实测:`1,2,2,2,2,2,2` → `1,1,1,1,1,1,1`)。
+ * 真实日积压只能由 `/events/current?mode=ALARM` 的逐条记录还原(eventTime + status + updatedTime)。
+ *
+ * 页大小取后端上限 1000;页数上限 10(≈1 万条)是**防失控**的闸:超过就如实标 partial,
+ * 宁可不画第二条线,也不拿截断的偏小值冒充积压。
+ */
+export const ALARM_EVENT_QUERY = { pageSize: 1000, maxPages: 10 }
+
+/** `/events/current` 路径(与 `services/eventApi.js` 同一端点;本文件不自造第二份地址拼接规则)。 */
+const EVENT_PATHS_CURRENT = '/events/current'
+
+/**
+ * 采集报警全量记录(票 96)。研究实测(`research/alarm-backlog-data.md`):
+ *   · `pageSize` 上限 1000(1001 → 400「当前预警分页参数不合法」),默认 100;
+ *   · **越界页不是 4xx**,而是 200 + `sections.events.state = 'EMPTY'`、`data: null`
+ *     —— 那表示"到头了",不是失败,不能当 `complete: false`;
+ *   · 端点不收时间窗参数(startTime/endTime 被忽略),所以只能取全量;
+ *   · `updatedAt` 是**这批快照的时间**(实测与处置效率端点同批,晚约 170ms),
+ *     它常常比 live 聚合落后一拍 —— 面板要把这个时间如实说出来,别让"今天的点"显得是实时的。
+ *
+ * 任何一页真的失败(ok:false)都算「没取全」:已取到的照样带回,但 `complete: false`。
+ * @returns {Promise<{events: Array<object>, complete: boolean, snapshotAt: string}>}
+ */
+export async function fetchAlarmEvents(options = {}) {
+    const events = []
+    let complete = true
+    let snapshotAt = ''
+    for (let page = 1; page <= ALARM_EVENT_QUERY.maxPages; page += 1) {
+        const result = await requestCockpitJson(
+            EVENT_PATHS_CURRENT + buildQuery({
+                mode: EVENT_FAMILY.ALARM,
+                industry: industryQuery(options.industry),
+                // 票 79:该端点真收 district(非法值 400、0/1 计数不同),与其它面板同一份全局条件。
+                district: districtQuery(options.scope),
+                page,
+                pageSize: ALARM_EVENT_QUERY.pageSize,
+            }),
+            { config: options.config, fetchImpl: options.fetchImpl },
+        )
+        if (!result || !result.ok) {
+            complete = false
+            break
+        }
+        const payload = result.data || {}
+        if (!snapshotAt && payload.updatedAt) snapshotAt = String(payload.updatedAt)
+        const section = (payload.sections || {}).events || {}
+        const rows = section.data
+        // 越界页:200 + EMPTY + data:null —— 到头的正常信号(实测 page=13 起如此)。
+        if (section.state === 'EMPTY' && !Array.isArray(rows)) return { events, complete, snapshotAt }
+        if (Array.isArray(rows)) events.push(...rows)
+        const pagination = payload.pagination || {}
+        const total = Number(pagination.total)
+        const more = pagination.hasMore === true || (Number.isFinite(total) && events.length < total)
+        if (!more) return { events, complete, snapshotAt }
+        if (page === ALARM_EVENT_QUERY.maxPages) complete = false
+    }
+    return { events, complete, snapshotAt }
+}
+
+/**
+ * 报警处置:今日/转单/处置段缺失才算空;等级卡(0 行)与 7 日趋势(全 0 点)仍要画。
+ *
+ * 票 96:主端点与报警记录**并行**取。记录失败只降级第二条线(面板照常出数并说明原因),
+ * 不影响分区成败 —— 「一个分区一块面板」的口径不变。
+ */
+export async function fetchAlarmDisposal(options = {}) {
+    const [main, records] = await Promise.all([
+        requestCockpitJson(
+            `${OVERVIEW_PATHS.alarmDisposal}${buildQuery(filterParams(options))}`,
+            { config: options.config, fetchImpl: options.fetchImpl },
+        ),
+        fetchAlarmEvents(options).catch(() => ({ events: [], complete: false, snapshotAt: '' })),
+    ])
+    return sectionFromResult(main, {
+        toData: (payload) => toAlarmDisposal(payload, {
+            events: records.events,
+            complete: records.complete,
+            snapshotAt: records.snapshotAt,
+        }),
         isEmpty: isEmptyBy((payload) => !payload || !payload.today || !payload.disposal),
         isEmpty: isEmptyBy((payload) => !payload || !payload.today || !payload.disposal),
         freshnessOf,
         freshnessOf,
-        config: options.config,
-        fetchImpl: options.fetchImpl,
     })
     })
 }
 }

+ 40 - 14
src/modules/cockpit/services/regionLoading.js

@@ -176,7 +176,12 @@ export function createRegionLoadingController({
     readCameraKey = null,
     readCameraKey = null,
     // 同时进行中的 region 装载数上限(见上方"为什么不再严格串行")。
     // 同时进行中的 region 装载数上限(见上方"为什么不再严格串行")。
     maxConcurrentLoads = 2,
     maxConcurrentLoads = 2,
+    // 票 61 第二十轮:每帧最多"首次显形"的区域数(见 syncVisibility 的显形配额)。
+    showBudgetPerFrame = 1,
 }) {
 }) {
+    // 显形配额(本帧剩余)。精模首次 show=true 会同步触发着色器编译 + 纹理上传
+    // (每片 fine 14 种材质),同一帧全部 flip 就是加载期卡顿的单帧尖峰来源 —— 摊到多帧。
+    let showBudgetRemaining = Math.max(1, Math.trunc(finiteOr(showBudgetPerFrame, 1)))
     const entries = regions.map(r => ({
     const entries = regions.map(r => ({
         ...r,
         ...r,
         contentUrlIndex: r.expectedContentUrls ? new Set(r.expectedContentUrls) : null,
         contentUrlIndex: r.expectedContentUrls ? new Set(r.expectedContentUrls) : null,
@@ -331,6 +336,14 @@ export function createRegionLoadingController({
         const usableNow = usableOf(e)
         const usableNow = usableOf(e)
         if (usableNow) e.usableAt = at
         if (usableNow) e.usableAt = at
         const usable = usableNow || (at - e.usableAt < USABLE_HOLD_MS)
         const usable = usableNow || (at - e.usableAt < USABLE_HOLD_MS)
+        // **显形配额**(票 61 第二十轮):精模首次 show=true 的那一帧要同步编译着色器
+        // (每片 fine 14 种材质)+ 上传纹理 —— 同一帧全部 flip 就是"加载精模卡顿"的单帧尖峰。
+        // 配额耗尽的本帧保持隐藏:粗模遮罩同步保持(show=false ⇒ 不渲染 ⇒ 不撤粗模),无空洞。
+        let show = usable
+        if (show && !e.instance.show) {
+            if (showBudgetRemaining > 0) showBudgetRemaining -= 1
+            else show = false
+        }
         // **show 必须双向跟随 usable**(关键修复:重叠缺陷)。
         // **show 必须双向跟随 usable**(关键修复:重叠缺陷)。
         //
         //
         // 旧写法只开不关(`if (usable && !show) show = true`)—— 一个区域曾经"到位"后
         // 旧写法只开不关(`if (usable && !show) show = true`)—— 一个区域曾经"到位"后
@@ -339,13 +352,14 @@ export function createRegionLoadingController({
         //
         //
         // 双向之后:`usable` 在 ⇒ 细模渲染、粗模隐藏;`usable` 不在 ⇒ 细模整片不画、
         // 双向之后:`usable` 在 ⇒ 细模渲染、粗模隐藏;`usable` 不在 ⇒ 细模整片不画、
         // 粗模回来补位。两者永远互斥,同帧切换,既没有重叠也没有空洞。
         // 粗模回来补位。两者永远互斥,同帧切换,既没有重叠也没有空洞。
-        if (e.instance.show !== usable) { e.instance.show = usable; requestRender() }
-        if (arrived) { e.phase = 'ready'; loading.delete(e) }
-        // 遮罩同样要带保持期:`e.visible` 是"这一帧真的画了没有",它也会抖
-        // (瓦片进出/剔除时某些帧没画)。只按它算遮罩,粗模就会在这些空帧里闪回来 ——
-        // 即使 show 没变,观感照样是闪。所以记"上次真的覆盖到"的时刻,保持期内继续隐藏。
-        if (usable && e.visible) e.coveredAt = at
-        const covered = usable && (e.visible || (at - e.coveredAt < USABLE_HOLD_MS))
+        if (e.instance.show !== show) { e.instance.show = show; requestRender() }
+        if (arrived && show) { e.phase = 'ready'; loading.delete(e) }
+        // 遮罩跟 **show**(配额后的值),不是 usable:
+        //   · 遮罩乘每帧 visible 会闪(空帧掀粗模)→ 所以有保持期;
+        //   · 但遮罩若乘 usable,被配额**推迟显形**的区域就会"粗模已撤、细模还没上" → 空洞。
+        // 跟 show 之后:推迟的区域粗模照常补位,配额轮到它才切,既无空洞也无重叠。
+        if (show && e.visible) e.coveredAt = at
+        const covered = show && (e.visible || (at - e.coveredAt < USABLE_HOLD_MS))
         mask(e, covered)
         mask(e, covered)
     }
     }
 
 
@@ -419,7 +433,7 @@ export function createRegionLoadingController({
         return parts.join(',')
         return parts.join(',')
     }
     }
 
 
-    function refresh() {
+    function refresh(opts = {}) {
         if (disposed) return
         if (disposed) return
         if (!pipeline) {
         if (!pipeline) {
             beforeRefresh()
             beforeRefresh()
@@ -427,13 +441,21 @@ export function createRegionLoadingController({
             advanceFrontier()
             advanceFrontier()
         }
         }
         const wanted = desired()
         const wanted = desired()
+        if (opts.skipVisibility !== true) {
+            // 显形配额重置 + 按距离近→远排序:配额不够时近处先显形(远处还在粗模,无感)。
+            showBudgetRemaining = Math.max(1, Math.trunc(finiteOr(showBudgetPerFrame, 1)))
+            const ordered = [...entries].sort((a, b) => (a.distance - b.distance) || 0)
+            for (const e of ordered) {
+                if (!wanted.includes(e.id) || e.phase === 'failed') continue
+                if (e.instance) syncVisibility(e)
+            }
+        }
+        // 卸载必须**每轮都跑**(正确性),不受 skipVisibility 影响。
         for (const e of entries) {
         for (const e of entries) {
-            if (!wanted.includes(e.id) || e.phase === 'failed') {
-                if (e.instance) unload(e)
+            if ((!wanted.includes(e.id) || e.phase === 'failed') && e.instance) {
+                unload(e)
                 loading.delete(e)
                 loading.delete(e)
-                continue
             }
             }
-            if (e.instance) syncVisibility(e)
         }
         }
         // 串行槽已换成"有限并发":只要没到并发上限就继续开下一个(不再等前一个全装完)。
         // 串行槽已换成"有限并发":只要没到并发上限就继续开下一个(不再等前一个全装完)。
         // 内容仍在流的区域不阻塞别的区域开装 —— 前沿收缩已经管住了"在动时少选"。
         // 内容仍在流的区域不阻塞别的区域开装 —— 前沿收缩已经管住了"在动时少选"。
@@ -466,7 +488,10 @@ export function createRegionLoadingController({
             // 分支,注入时钟不前进的场合(闸门/测试)会**永远**不更新 —— 表现为
             // 分支,注入时钟不前进的场合(闸门/测试)会**永远**不更新 —— 表现为
             // "细模已经渲染了,粗模却一直不撤"。这跟性能无关,是正确性问题。
             // "细模已经渲染了,粗模却一直不撤"。这跟性能无关,是正确性问题。
             // 真正贵的是 refresh() 里的排序/分配/装载决策,那个才需要节流。
             // 真正贵的是 refresh() 里的排序/分配/装载决策,那个才需要节流。
-            for (const e of entries) {
+            // 显形配额重置(与 refresh 同一纪律:近处先显形)。
+            showBudgetRemaining = Math.max(1, Math.trunc(finiteOr(showBudgetPerFrame, 1)))
+            const ordered = [...entries].sort((a, b) => (a.distance - b.distance) || 0)
+            for (const e of ordered) {
                 e.visible = e.visibleThisFrame; e.visibleThisFrame = false
                 e.visible = e.visibleThisFrame; e.visibleThisFrame = false
                 e.fineVisible = e.fineVisibleThisFrame; e.fineVisibleThisFrame = false
                 e.fineVisible = e.fineVisibleThisFrame; e.fineVisibleThisFrame = false
                 if (!e.instance) continue
                 if (!e.instance) continue
@@ -480,7 +505,8 @@ export function createRegionLoadingController({
             const at = now()
             const at = now()
             if (at - lastRenderRefreshAt < 180) return
             if (at - lastRenderRefreshAt < 180) return
             lastRenderRefreshAt = at
             lastRenderRefreshAt = at
-            refresh()
+            // 显形这一遍上面刚做过(同一批状态、同一个配额)——这里只补调度,不重跑显形。
+            refresh({ skipVisibility: true })
         },
         },
         setPipeline(value) { if (pipeline === !!value) return; pipeline = !!value; refresh() },
         setPipeline(value) { if (pipeline === !!value) return; pipeline = !!value; refresh() },
         detailReady(id) { return byId.get(id)?.shown === true },
         detailReady(id) { return byId.get(id)?.shown === true },

+ 83 - 0
src/modules/cockpit/stores/entityDetailStore.js

@@ -0,0 +1,83 @@
+/** 统一实体详情抽屉状态:prefill 先显,远端响应后补;token 保证迟到响应不串实体。 */
+import { computed, ref } from 'vue'
+import { defineStore } from 'pinia'
+import { entityIdentity, prefilledEntityDetail, sameEntity } from '../domain/entityDetail.js'
+import { fetchEntityDetail } from '../services/entityDetailApi.js'
+
+export const useEntityDetailStore = defineStore('entityDetailStore', () => {
+    const identity = ref(null)
+    const detail = ref(null)
+    const open = ref(false)
+    const loading = ref(false)
+    const loaded = ref(false)
+    const error = ref('')
+    const sourceSide = ref('right')
+    let token = 0
+    let requestOptions = {}
+    let listPrefill = null
+
+    const hasTarget = computed(() => !!identity.value && identity.value.valid)
+
+    function configure(options = {}) {
+        requestOptions = { ...requestOptions, ...options }
+    }
+
+    async function show(target, prefill = null, options = {}) {
+        const next = entityIdentity(target)
+        if (!next.valid) return false
+        const same = sameEntity(identity.value, next)
+        identity.value = next
+        sourceSide.value = options.side === 'left' ? 'left' : 'right'
+        listPrefill = prefill && typeof prefill === 'object' ? { ...prefill } : null
+        open.value = true
+        error.value = ''
+        if (prefill) detail.value = prefilledEntityDetail(next, prefill)
+        else if (!same) detail.value = null
+        if (same && loading.value) return true
+        if (same && loaded.value && !options.force) return true
+        await load(prefill, options)
+        return true
+    }
+
+    async function load(prefill = null, options = {}) {
+        const target = identity.value
+        if (!target || !target.valid) return null
+        token += 1
+        const mine = token
+        loading.value = true
+        loaded.value = false
+        const active = { ...requestOptions, ...options, prefill }
+        try {
+            const result = await fetchEntityDetail(target, active)
+            if (mine !== token || !sameEntity(identity.value, target)) return detail.value
+            if (result.ok) {
+                detail.value = result.data
+                loaded.value = true
+                error.value = ''
+            } else {
+                error.value = result.reason || 'unavailable'
+            }
+            return detail.value
+        } catch {
+            if (mine === token && sameEntity(identity.value, target)) error.value = 'unavailable'
+            return detail.value
+        } finally {
+            if (mine === token) loading.value = false
+        }
+    }
+
+    function close() {
+        token += 1
+        open.value = false
+        loading.value = false
+        identity.value = null
+        detail.value = null
+        loaded.value = false
+        error.value = ''
+        listPrefill = null
+    }
+
+    function retry() { return load(listPrefill, { force: true }) }
+
+    return { identity, detail, open, loading, loaded, error, sourceSide, hasTarget, configure, show, load, close, retry }
+})

+ 56 - 0
src/modules/cockpit/stores/panelSummaryStore.js

@@ -0,0 +1,56 @@
+/**
+ * 面板摘要注册表(维护者 2026-10-29)。
+ *
+ * 为什么需要这张表:「?」图标画在**面板标题栏**(CockpitPanel)里,而摘要条目
+ * 来自**面板体**算出来的视图计划(PanelBlocks / WarningResponsePane)。两者是兄弟组件,
+ * 中间隔着外壳插槽,没有共同的父可传 props —— 所以用一张按 panelId 索引的轻表做桥。
+ *
+ * 三条纪律:
+ *   1. **文案与有无仍由各面板 domain 决定**,这里只搬运(`domain/panelSummary.js` 折条目);
+ *   2. 键是 panelId(稳定标量),不是组件实例或视图对象(票 43 第四轮教训:别拿对象当键);
+ *   3. 卸载即注销,避免切模式后旧面板的摘要残留在标题栏上。
+ */
+import { reactive } from 'vue'
+import { panelSummaryItems, panelSummaryStatus } from '../domain/panelSummary.js'
+
+/**
+ * 注册表本身**不是**响应式源(用普通对象 + 一个版本号)。
+ *
+ * 为什么要这样:写入发生在 PanelBlocks(面板体),读取发生在 CockpitPanel(标题栏),
+ * 两者是兄弟节点。若把表做成 reactive,写入会触发标题栏重渲染 → 外壳重算视图方法 →
+ * 面板体收到新 view → 再次写入 —— 形成自激环(真机表现为页面卡死 + 一串
+ * "Uncaught (in promise)")。所以写入只递增版本号,读取方 watch 版本号;
+ * 而 PanelBlocks 侧再用标量签名挡掉"内容没变"的写入,两道闸一起把环切断。
+ */
+const registry = {}
+const version = reactive({ value: 0 })
+
+/** 写入/更新某面板的摘要(视图变化时由 PanelBlocks 调用)。 */
+export function registerPanelSummary(panelId, view) {
+    if (!panelId) return
+    const items = panelSummaryItems(view)
+    const status = panelSummaryStatus(view, items)
+    const next = { items, status, label: (view && view.label) || '' }
+    const previous = registry[panelId]
+    // 内容没变就**不动版本号**:这是切断自激环的第一道闸(第二道是 PanelBlocks 的标量签名)。
+    if (previous && JSON.stringify(previous) === JSON.stringify(next)) return
+    registry[panelId] = next
+    version.value += 1
+}
+
+/** 注销(面板体卸载时调用)。 */
+export function unregisterPanelSummary(panelId) {
+    if (!panelId || !registry[panelId]) return
+    delete registry[panelId]
+    version.value += 1
+}
+
+/** 读某面板的摘要(没有就说没有,不造空条目)。 */
+export function panelSummaryOf(panelId) {
+    return (panelId && registry[panelId]) || null
+}
+
+/** 注册表版本号(读取方 watch 它;只在内容真的变了时才变)。 */
+export function panelSummaryVersion() {
+    return version.value
+}

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini