ソースを参照

fix(cockpit): 状态图例图标切 mask 渲染,修复生产 CSS 相对路径 404

浏览器走查发现 /css/cockpit/... 404 根因:icon.status.* 的 mask 消费走 CSS 相对 url()(按消费样式表 css/ 目录解析为 css/cockpit/...),而 image 模式 <img> 路径由 JS 按 BASE_URL 拼接(/cockpit/...)。将 manifest 中 icon.status.* 的 renderMode 改为 mask 后统一走 JS 解析路径。走查四模式+管线视图无新增错误,GIS 瓦片 404 为本机瓦片缺失(配置边界内)。
ChenJZ 1 日 前
親
コミット
b82ed6ace0
35 ファイル変更、1706 行追加、325 行削除
  1. 27 1
      CONTEXT.md
  2. 14 0
      docs/agents/label-colors.yaml
  3. 242 44
      public/cockpit/assets/manifest.json
  4. 46 5
      src/modules/cockpit/CockpitShell.vue
  5. 440 43
      src/modules/cockpit/components/EquipmentDetailSections.vue
  6. 1 1
      src/modules/cockpit/components/FacilityDetailCard.vue
  7. 61 24
      src/modules/cockpit/components/MapCanvas.vue
  8. 31 1
      src/modules/cockpit/components/PipeEnhancement.vue
  9. 44 23
      src/modules/cockpit/components/PointDetailCard.vue
  10. 3 3
      src/modules/cockpit/domain/detailFlow.js
  11. 63 28
      src/modules/cockpit/domain/entityDetail.js
  12. 10 0
      src/modules/cockpit/domain/equipmentDetail.js
  13. 162 55
      src/modules/cockpit/domain/equipmentDetailView.js
  14. 5 5
      src/modules/cockpit/domain/eventDetail.js
  15. 54 22
      src/modules/cockpit/domain/eventList.js
  16. 5 4
      src/modules/cockpit/domain/eventStatusVocab.js
  17. 2 0
      src/modules/cockpit/domain/events.js
  18. 4 4
      src/modules/cockpit/domain/facilityPoints.js
  19. 2 2
      src/modules/cockpit/domain/facilitySelection.js
  20. 20 4
      src/modules/cockpit/domain/modeLayerPlan.js
  21. 70 32
      src/modules/cockpit/domain/monitoringPanels.js
  22. 3 2
      src/modules/cockpit/domain/panelBlocks.js
  23. 28 1
      src/modules/cockpit/domain/pointStatus.js
  24. 1 1
      src/modules/cockpit/domain/warningResponsePanels.js
  25. 3 3
      src/modules/cockpit/domain/workOrder.js
  26. 5 5
      src/modules/cockpit/domain/workOrderTimeline.js
  27. 55 0
      src/modules/cockpit/mocks/contractMocks.js
  28. 22 0
      src/modules/cockpit/services/cockpitConfig.js
  29. 43 1
      src/modules/cockpit/services/detailApi.js
  30. 24 6
      src/modules/cockpit/services/deviceImage.js
  31. 5 2
      src/modules/cockpit/services/mapService.js
  32. 3 1
      src/modules/cockpit/stores/detailStore.js
  33. 204 0
      src/modules/cockpit/stores/deviceStatusStore.js
  34. 3 1
      src/modules/cockpit/stores/mapStore.js
  35. 1 1
      src/modules/cockpit/stores/monitoringStore.js

+ 27 - 1
CONTEXT.md

@@ -49,9 +49,21 @@ _Avoid_: 井下实测结构(无资料时)、从附近管道推断接管关
 _Avoid_: 把所有节点的因子统称为湿度、把不同节点当作不同设备
 
 **最新指标**:
-所选监测设备各节点、各监测因子分别对应的最新记录值;各指标的记录时间可以不同。旧记录仍为最后已知值,应保留其时间及过期标识;缺失值与真实零值不同,不补造读数。
+所选监测设备各节点、各监测因子分别对应的最新记录值;各指标的记录时间可以不同。旧记录仍为最后已知值,不能因时间较早就当成无数据;缺失值与真实零值不同,不补造读数。
 _Avoid_: 用整台设备最后一条记录代表所有节点、将过期记录当作无数据、把入库时间称为物理采样时间
 
+**采集记录**:
+监测设备形成的逐条数据记录,包含该条记录的入库时刻和实际监测因子读数;按原始记录顺序呈现,同一入库时刻可以对应多个因子。采集记录与各因子的最新指标集合不同,不用最新值冒充历史。
+_Avoid_: 将入库时间当采样时间、用一个较新时刻覆盖其它记录、以新鲜度标签替代真实读数
+
+**业务编码**:
+业务系统给设备、事件、设施或工单公开使用的编码,用于用户识别业务对象;未提供时不能用数据库主键冒充。
+_Avoid_: 用内部id填补缺失code、把主键当业务编号
+
+**主键**:
+存储、查询和内部关联所使用的身份键;它不是用户识别对象的业务编码,不作为表中缺失业务编码的显示兜底。
+_Avoid_: 展示主键冒充编号、为了隐藏显示值而破坏内部关联
+
 **设备类型指标源**:
 设备详情的最新指标不能把所有设备强行读同一张表;先按档案 `deviceType/deviceTypeName`,缺失时仅按明确设备名称类型词选择专用源(如流量计→供水流量趋势、压力计→压力趋势、漏失→漏失趋势)。专用源未授权或未发布时显示不可用/未提供,不伪造零值;档案、预警等其它分区独立保留。
 _Avoid_: 用通用EMPTY断言设备无指标、用行业猜测设备类型、把401转成正常或零值
@@ -203,3 +215,17 @@ _Avoid_: 定位、回到上一个点位
 **面板锚点**:
 当前详情面板在一张图中指向的业务对象视觉标记中心:点位摘要指向监测点,设备详情指向所选监测设备;返回摘要后恢复监测点锚点。标记暂时不可见时,详情可以暂隐,设备与监测点的业务归属仍保持。
 _Avoid_: 相机旋转中心、设备详情仍指向父监测点、将面板锚点变化当作设备归属变化
+
+**设备预警/报警统计**:
+以监测设备为计数对象的状态池计数:只要设备存在关联的报警、预警或未结束工单,就分别加入对应状态池,同一设备可同时在多个池中;只有「在线」与「离线」互斥。各池之和可以大于设备台数。顶部状态栏、监测分布、运行概况三处共用同一份全局池计数,且不依赖当前一级模式。监测点自身的聚合状态仍可用于点位表达,但不作为额外设备计入。**池里只装监测概览台账里存在的设备**:事件表里存有非设备编码(历史脏值与类型码,如 15/16/20),直接把事件行的设备编码入池会虚增台数。
+_Avoid_: 把点位聚合数量当设备数量、同时计入监测点和其设备、因是否关联而重复或漏计设备、把事件表里出现的编码一律当设备
+
+**设备状态图例**:
+一张图顶部状态栏各行的**计数口径**:报警/预警/工单处置/独立设备是设备侧可叠加标签的台数,在线/离线是台账互斥台数,无设备监测点是点位侧特殊状态。每行另给出「上图 N」——当前模式下真正画到地图上的对象数,与前面的设备台数不是一个口径(设备无坐标、或挂载点位按聚合状态显示成别的颜色,都会让 N 更小),两个数并排显示,不留读者猜哪个错了。
+_Avoid_: 把图例计数当作地图坐标点数、把「上图 N」也按设备口径读、把两数不等当缺陷
+
+**点位特效显示规则**:
+厂家 2026-11-06 重述的两套**显示优先级**,只管点位/设备点画什么颜色与特效,不管统计与筛选(统计按「设备预警/报警统计」的池口径,筛选按标签命中)。
+**设备点**:无任何标签时只分在线/离线;有标签时按 报警 > 工单处置 > 预警 > 独立设备 展示;全部报警都已由未结束工单承接时显示工单处置(蓝),只要还剩一条报警未关联工单就显示报警(红);工单全部进入终态后若仍有未解除的预警/报警,照常显示黄/红;无任何业务标签且在线时,用黄绿区分「正常关联监测点的设备」与「未关联监测点的独立设备」。
+**监测点**:按内部关联设备的**显示状态**聚合,报警 > 预警 > 工单处置;关联设备全是离线则离线;没有关联设备则「无设备监测点」。监测点自身不进入顶部图例的设备计数池(唯一例外是无设备监测点),只参与设备筛选。
+_Avoid_: 用同一张优先级表同时服务设备点与监测点(两张序在预警/工单上刻意相反,合并必有一方违规)、把监测点的设备聚合数计进设备图例

+ 14 - 0
docs/agents/label-colors.yaml

@@ -0,0 +1,14 @@
+# 本文件的注释由插件生成;你自己加的注释会在下次写入时被替换。
+# 这一份数据的版本号(核不认识的新版本只读不写)
+version: 1
+bug: d73a4a
+needs-triage: fbca04
+needs-info: "5319e7"
+ready-for-agent: "0e8a16"
+ready-for-human: b60205
+wontfix: ffffff
+"wayfinder:map": "8b5cf6"
+"wayfinder:research": "0ea5e9"
+"wayfinder:prototype": f59e0b
+"wayfinder:grilling": "9d7cd8"
+"wayfinder:task": "10b981"

+ 242 - 44
public/cockpit/assets/manifest.json

@@ -984,7 +984,7 @@
       },
       "pixelRatio": 1,
       "transparent": true,
-      "renderMode": "image",
+      "renderMode": "mask",
       "version": "2026.10-lanhu-final-icons",
       "hash": "sha256:8a0fcc748ca5c103a0760b5b998f21c18c97bf98711404e38d6cb89a04887d24",
       "status": "formal",
@@ -1016,7 +1016,7 @@
       },
       "pixelRatio": 1,
       "transparent": true,
-      "renderMode": "image",
+      "renderMode": "mask",
       "version": "2026.10-lanhu-final-icons",
       "hash": "sha256:a7a2495d03561bd8988eeb8496a735f06db4501f227ae7f66e9f43210f95d4cc",
       "status": "formal",
@@ -1048,7 +1048,7 @@
       },
       "pixelRatio": 1,
       "transparent": true,
-      "renderMode": "image",
+      "renderMode": "mask",
       "version": "2026.10-lanhu-final-icons",
       "hash": "sha256:c57e49712ab3b6214f735af5111d90c07f6064f9a932d8b435da4d426739d045",
       "status": "formal",
@@ -1080,7 +1080,7 @@
       },
       "pixelRatio": 1,
       "transparent": true,
-      "renderMode": "image",
+      "renderMode": "mask",
       "version": "2026.10-lanhu-final-icons",
       "hash": "sha256:9c804dee1c27e9dac8d3154989713bbb40b2e527b17fa64621130bf9e3be7592",
       "status": "formal",
@@ -1112,7 +1112,7 @@
       },
       "pixelRatio": 1,
       "transparent": true,
-      "renderMode": "image",
+      "renderMode": "mask",
       "version": "2026.10-lanhu-final-icons",
       "hash": "sha256:5282364c5a206c185258d944161539f9e5b35ecf6a65fc5ea1ea186fdc7a227d",
       "status": "formal",
@@ -1144,7 +1144,7 @@
       },
       "pixelRatio": 1,
       "transparent": true,
-      "renderMode": "image",
+      "renderMode": "mask",
       "version": "2026.10-lanhu-final-icons",
       "hash": "sha256:590d2177e727f4dc7e0d18449b385126c7db1d18e873b02112b6d31079f39719",
       "status": "formal",
@@ -1176,7 +1176,7 @@
       },
       "pixelRatio": 1,
       "transparent": true,
-      "renderMode": "image",
+      "renderMode": "mask",
       "version": "2026.10-lanhu-final-icons",
       "hash": "sha256:ecf9d3a096889ac2501c27197e7f3df645dd816a918ffc680de511a1d6a0b9e0",
       "status": "formal",
@@ -1208,7 +1208,7 @@
       },
       "pixelRatio": 1,
       "transparent": true,
-      "renderMode": "image",
+      "renderMode": "mask",
       "version": "2026.10-lanhu-final-icons",
       "hash": "sha256:08957837521c1676b59100bf6f0e08589cc4bf7c16b949bb9890531d6b6a9e01",
       "status": "formal",
@@ -1426,14 +1426,25 @@
         "meaning": "KPI·设施底数/设施总数(计数堆叠;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 42, "height": 42 },
+      "dimensions": {
+        "width": 42,
+        "height": 42
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:1e946da063053f0c7e7f13578d0752ad3893251023aebbe869fb9d381d80f764",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·设施底数/设施总数" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·设施底数/设施总数"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1447,14 +1458,25 @@
         "meaning": "KPI·监测规模(设备齿轮;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:8c274b14ec835a355778a9b736fa64187103f7a83081b8d2a1519526033af778",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·监测规模" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·监测规模"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1468,14 +1490,25 @@
         "meaning": "KPI·智能设备/监测设备(设备机柜;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:be6b39b260551654146b3a3ba8d1e1cb6d62e9ae32b8d0f952ad638c7f8a9b04",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·智能设备/监测设备" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·智能设备/监测设备"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1489,14 +1522,25 @@
         "meaning": "KPI·监测点(定位;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:422dd436f1342a7245a3b2cbb9d880d260f35c3211690d42bed3f9ac025aa807",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·监测点" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·监测点"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1510,14 +1554,25 @@
         "meaning": "KPI·监测设施/基础设施/管线里程(管网;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:28e51257c9ad5fc149d08aeae45fcd5ea3fd502e8b9cc6a718315cb930e38c2d",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·监测设施/基础设施/管线里程" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·监测设施/基础设施/管线里程"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1531,14 +1586,25 @@
         "meaning": "KPI·行业数/未关联工单(资源整合;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:439c7b595675ef85b702a4d0b7b8c11e0c8f355298743fde2449337d61e6e5ee",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·行业数/未关联工单" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·行业数/未关联工单"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1552,14 +1618,25 @@
         "meaning": "KPI·在线率/在线(信号;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:0d781b8bdb55aafb882de34755a120161e49d81cb403557844c94ccf96c972cc",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·在线率/在线" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·在线率/在线"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1573,14 +1650,25 @@
         "meaning": "KPI·离线/离线设备(断信号;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:b6bc6c6c7921c5f4b5ea006943d1c93f2a2f8ae79bf2503aa68fe2118a382002",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·离线/离线设备" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·离线/离线设备"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1594,14 +1682,25 @@
         "meaning": "KPI·数据滞后(信号叉;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 68, "height": 68 },
+      "dimensions": {
+        "width": 68,
+        "height": 68
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:7e93c4d03b3af0da0312e07ecd2cb4f0e74affd8952312808ca1453e9b323e17",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·数据滞后" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·数据滞后"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1615,14 +1714,25 @@
         "meaning": "KPI·未知/缺失/已取消/危险源(警示三角;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:23f6256b3741d2f8c049b4ee4f83f66270fb5593a2e78fc8342ee06252b95aa5",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·未知/缺失/已取消/危险源" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·未知/缺失/已取消/危险源"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1636,14 +1746,25 @@
         "meaning": "KPI·当前预警/预警中/预警总数/待处理(警铃;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:6bde0c549e991cff135e30e2dc04d233415df55edc1ecb8f170b83e415fd2d45",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·预警族读数" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·预警族读数"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1657,14 +1778,25 @@
         "meaning": "KPI·当前报警/报警中/报警总数(警灯;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:43b6f739207c930038cdc2e53835498188200952e5befd9dc7f84c5eb4c14ebc",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·报警族读数" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·报警族读数"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1678,14 +1810,25 @@
         "meaning": "KPI·异常设备(异常折线;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:1fea1770ef76b05d4c2582faa865a0d656f5afd0b0f4334953313c6c1fc7be24",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·异常设备" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·异常设备"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1699,14 +1842,25 @@
         "meaning": "KPI·已处理/已解除率(解除铃;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:8bf9eebac5f89d88c0a3f56163844fda2b6a7ce4cd96f8b75b994cbcb5082bd5",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·已处理/已解除率" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·已处理/已解除率"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1720,14 +1874,25 @@
         "meaning": "KPI·处置率(协议实施;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:2d0eadc22d39aa2729604778c3827b82bad5defcf289ee6814b52e936ca752ee",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·处置率" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·处置率"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1741,14 +1906,25 @@
         "meaning": "KPI·数据时间/响应时长/办结时长(时钟;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:5d068d02dcefacb58bb8c6ae73c20e61ee6f031c841f8d3df47e51e234ef2b62",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·数据时间/时长" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·数据时间/时长"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1762,14 +1938,25 @@
         "meaning": "KPI·规模/应急资源(规模种类;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 256, "height": 256 },
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:24b6130bb0e8c38493fb1f2a082b0aac5a12ebc51c15d0df9f25053dbcab34df",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·规模/应急资源" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·规模/应急资源"
+      },
       "fallbackKey": null,
       "core": false
     },
@@ -1783,14 +1970,25 @@
         "meaning": "KPI·防护目标(盾;去色染白供 mask 染语义色)"
       },
       "format": "png",
-      "dimensions": { "width": 72, "height": 72 },
+      "dimensions": {
+        "width": 72,
+        "height": 72
+      },
       "pixelRatio": 1,
       "transparent": true,
       "renderMode": "mask",
       "version": "2026.10-t37-kpi-icons",
       "hash": "sha256:5187ae99b8870b0f893689a957524b9c1dfa79da4d4e76424d14c0c7c5c14080",
       "status": "formal",
-      "appliesTo": { "areas": ["icon.kpi"], "states": ["default"], "meaning": "KPI·防护目标" },
+      "appliesTo": {
+        "areas": [
+          "icon.kpi"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "KPI·防护目标"
+      },
       "fallbackKey": null,
       "core": false
     },

+ 46 - 5
src/modules/cockpit/CockpitShell.vue

@@ -593,6 +593,7 @@ import { useDetailStore } from './stores/detailStore.js'
 import { useDeviceLayerStore } from './stores/deviceLayerStore.js'
 import { useInfrastructureStore } from './stores/infrastructureStore.js'
 import { useMonitoringStore } from './stores/monitoringStore.js'
+import { useDeviceStatusStore } from './stores/deviceStatusStore.js'
 import { useEventStore } from './stores/eventStore.js'
 import { useEntityDetailStore } from './stores/entityDetailStore.js'
 // 2026-10-22(票 02):总体概览模式的四块面板(感知设备/基础设施/预警研判/报警处置)。
@@ -650,6 +651,9 @@ const detailStore = useDetailStore()
 const mapStore = useMapStore()
 const infrastructureStore = useInfrastructureStore()
 const monitoringStore = useMonitoringStore()
+// 维护者 2026-11-04:设备状态池是**全站口径**(顶部状态栏四个模式都常驻),
+// 所以它有独立的全局数据源,不挂在「运行监测」的面板分区上。
+const deviceStatusStore = useDeviceStatusStore()
 const overviewStore = useOverviewStore()
 const eventStore = useEventStore()
 const entityDetailStore = useEntityDetailStore()
@@ -765,6 +769,9 @@ function panelViewOf(panelId) {
         // 只有对应面板消费的那一项才进快照(其余位置填 null,保持槽位对齐)。
         panelId === 'monitoringPointLedger' ? monitoringBadges.value : null,
         panelId === 'monitoringDistribution' ? mapStore.statusDistribution : null,
+        // 维护者 2026-11-04:状态分布统一到**设备状态池**(全站同源),缓存键必须带上它。
+        panelId === 'monitoringDistribution' ? deviceStatusStore.pools : null,
+        panelId === 'operationOverview' ? deviceStatusStore.pools : null,
         panelId === 'monitoringDistribution' ? pointStatus.value : null,
         panelId === 'operation' ? scope.value : null,
         panelId === 'operation' ? archiveManholeCount() : null,
@@ -775,7 +782,12 @@ function panelViewOf(panelId) {
         let extra
         if (panelId === 'monitoringPointLedger') extra = monitoringBadges.value
         else if (panelId === 'monitoringDistribution') {
-            extra = { statusDistribution: mapStore.statusDistribution, selectedStatuses: pointStatus.value }
+            // 维护者 2026-11-04:四卡与状态分布改吃**设备状态池**(全局同源),不再用点位八桶。
+            extra = {
+                statusDistribution: mapStore.statusDistribution,
+                deviceStatusPools: deviceStatusStore.pools,
+                selectedStatuses: pointStatus.value,
+            }
         }
         // 票 64:运行信息要当前**片区**键(换片区 ⇒ 页码回第 1 页,周期刷新 ⇒ 页码保持)。
         // 维护者 2026-10-29:本端点没有窨井指标(MANHOLE 块实测 state=EMPTY、0 条),
@@ -783,7 +795,11 @@ function panelViewOf(panelId) {
         else if (panelId === 'operation') extra = { scope: scope.value, manholeCount: archiveManholeCount() }
         // 票 62:运行概况的设备台账在 `monitoringOverview` 分区(跨分区取数只在这一处搬运)。
         else if (panelId === 'operationOverview') {
-            extra = { devices: (overview && overview.data && overview.data.devices) || [] }
+            // 维护者 2026-11-04:运行概况的预警/报警设备也取同一份状态池,三处同数。
+            extra = {
+                devices: (overview && overview.data && overview.data.devices) || [],
+                deviceStatusPools: deviceStatusStore.pools,
+            }
         }
         // 票 11:感知设备的类型卡可点,要当前生效行业键画选中态(只传标量)。
         else if (panelId === 'perceptionDevices') extra = { industry: filterStore.industry }
@@ -1159,10 +1175,15 @@ const introVisible = ref(intro.value.enabled)
 const pipeEnhancementStore = usePipeEnhancementStore()
 const enhancementDevicesStore = useEnhancementDevicesStore()
 const { selected: pipeEnhancementSelection, radiusM: pipeEnhancementRadius } = storeToRefs(pipeEnhancementStore)
+/** 选中的图例行 → 命中的设备编码集(与图例同一份设备台账;空集=不筛)。 */
+const deviceFilter = computed(() => deviceStatusStore.filterDeviceCodes(pointStatus.value))
+
 const modeLayerPlan = computed(() => planModeLayers({
     mode: mode.value,
     objects: mapStore.summary.objects,
     abnormalDevices: modeAbnormalDevices.value,
+    // 维护者 2026-11-05:状态筛选按**设备标签**过滤地图对象,与图例计数同口径。
+    deviceFilter: deviceFilter.value,
     // 票 24:设施点位与管线接真实图层。取不到时由 store 收成失败分区,
     // 计划如实降级为「未就绪」——不再传 `null` 假装"还没接线"。
     // 过滤只在这一层做(`pipeNetworkStore.filtered`),引擎与组件都不做第二次过滤。
@@ -1599,7 +1620,8 @@ refreshStore.configure({
     // 面板:取完当前模式的全部分区,回一个「panelId → 分区」查表函数供判据用
     // (判据在 domain/refreshRun.js,外壳不自己数哪块坏)。
     refreshPanels: async () => {
-        await refreshModePanels()
+        // 设备状态池跟着同一轮刷新走:顶部状态栏在任何模式下都必须与面板同步。
+        await Promise.all([refreshModePanels(), refreshDevicePools()])
         return (panelId) => sectionOf(panelId)
     },
     // 票 61 验收第 4 条:本轮成败记进 freshnessService 那一本账(不另造第二份"最近刷新时间")。
@@ -2076,6 +2098,8 @@ onMounted(() => {
         lifecycleReady.value = true
         // 首屏只取当前模式;其它模式在第一次切过去时再取(筛选变化会清缓存)。
         ensureModePanelsLoaded(mode.value)
+        // 状态池与一级模式无关,首屏即取一次(顶部状态栏在四个模式都常驻)。
+        refreshDevicePools()
     })
 
     // 引导期间显示自转;进入后停(引擎就绪前调用会被忽略,等引擎 ready 再兜一次)。
@@ -2231,8 +2255,24 @@ watch(deviceType, (next) => {
     refreshMapForFilters({ deviceType: next })
 })
 
+/**
+ * 取一次全局设备状态池(首屏 / 筛选变化 / 统一刷新共用)。
+ *
+ * 它是**全站口径**:顶部状态栏在四个一级模式里都常驻,所以不能等「运行监测」的分区
+ * 来喂,否则不进那个模式就不更新(维护者 2026-11-04 指出的缺陷)。
+ */
+function refreshDevicePools() {
+    return deviceStatusStore.refresh({
+        industry: industry.value,
+        scope: scope.value,
+        deviceType: deviceType.value,
+    })
+}
+
 /** 把筛选条件下给地图并**换新快照重载**(票 08 的快照一致性:一次加载只对应一份数据)。 */
 function refreshMapForFilters(next) {
+    // 行业/片区/类型/状态任何一个变化都同时重取状态池(它按行业+片区+类型取数)。
+    refreshDevicePools()
     mapStore
         .applyFilters(next)
         .then(() => detailStore.syncMapSnapshotSection())
@@ -3235,7 +3275,8 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
 
 /* 票 10:两级详情浮层。贴在两栏之间的空档里、右栏左侧,
    宽度与栏同量级;高度由内容决定、超长时卡片自己滚动。
-   2026-10-13 维护者反馈设备详情面板太窄:340→380u(上限 420→460px)。 */
+   2026-10-13 维护者反馈设备详情面板太窄:340→380u(上限 420→460px)。
+   2026-11-05 维护者要求采集记录表不横向滚动:480→520u(上限 560→620px),所有详情面板同宽。 */
 .cockpit-shell__detail {
     position: absolute;
     z-index: var(--z-columns);
@@ -3243,7 +3284,7 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
     /* 让位宽度用 --rail-w:宽屏时右栏是两条栏宽,详情卡要避开整条栏。 */
     right: calc(var(--rail-w) + var(--space-2));
     /* 宽度上限同时受 rail-w 约束:宽屏时两条栏宽之外才是留给详情的空间。 */
-    width: min(calc(100% - 2 * var(--rail-w) - 24px), clamp(420px, calc(480 * var(--u)), 560px));
+    width: min(calc(100% - 2 * var(--rail-w) - 24px), clamp(460px, calc(520 * var(--u)), 620px));
     max-height: 100%;
     min-height: 0;
     display: flex;

+ 440 - 43
src/modules/cockpit/components/EquipmentDetailSections.vue

@@ -1,5 +1,5 @@
 <!--
-  设备详情的分区(票 11):档案 / 指标 / 预警 / 报警 / 工单状态 / 最新图片。
+  设备详情的分区(票 11):档案 / 指标 / 预警 / 报警 / 工单状态 / 设备图片。
 
   票 46 返工(维护者 2026-10-15 第 1 条):**弃用六块竖排的长滚动**,改为标签页切换
   设备档案(默认,图片归此页下方)|最新指标|预警信息|报警信息|工单状态;
@@ -10,8 +10,8 @@
   本组件不再自画 pager、不再有第二份翻页算术;前后页/页码/跳页共用唯一出口
   `gotoPage(panel, target)`,越界夹回由 `pagedRows` 做。
   **渲染计划仍是六块独立分区**(t11 D1/D9):标签页只改呈现归属,
-  每块仍各带自己的状态标签与成败 —— 档案读失败但指标可用是后端契约的原生态,
-  界面共用一个成败等于把后端已说清的信息揉回含糊的「加载失败」。
+  每块仍独立成败;指标页改读独立采集记录分区,按当前需求不画状态点/过期说明。
+  档案读失败但指标可用仍各自呈现,不能共用一个成败。
 
   三种「没内容」在界面上的区别(票 16 的词汇,措辞由 domain/equipmentDetailView.js 给):
     空数据 EMPTY  → 「该分区当前没有数据」
@@ -19,8 +19,7 @@
     不可用 UNAVAILABLE → 「该分区数据不可用(其它分区不受影响)」
   过期 STALE 是**有值**:内容照画,只是标签说「数据已过期,显示最后一次成功数据」。
 
-  刻意不做的事:**不画事件的四级配色**(票 13/19 的视觉语言)。等级只以文本 +
-  `data-event-level-color` 如实带出后端给的值。
+  事件高亮采用既有四级颜色词表;数值预警黄/报警红与等级提示点分别呈现。
 
   票 12 在**工单状态这块里**接上了状态时间线(不新增第七块面板):
   每张工单下面挂它自己的那条轨迹(`timelineSource()` 按工单编码配好)。
@@ -51,7 +50,7 @@
         </div>
 
         <!--
-            当前页签的面板(档案页含两块:档案 + 最新图片,所以 tabpanel 挂在**包装层**
+            当前页签的面板(档案页含两块:档案 + 设备图片,所以 tabpanel 挂在**包装层**
             而不是每块 section 上 —— 一个页签对应一个 tabpanel 才是正确的可访问语义)。
         -->
         <div class="equipment-tab__body" role="tabpanel" data-equipment-tab-body>
@@ -65,11 +64,11 @@
             >
             <header class="equipment-section__head">
                 <span class="equipment-section__title">{{ panel.label }}</span>
-                <SectionStateTag :name="panel.name" :section="panel.section" />
+                <SectionStateTag v-if="panel.kind !== 'metrics'" :name="panel.name" :section="panel.section" />
             </header>
 
             <!-- 过期分区在内容之上再点一次题(颜色之外的第二种提示,灰度下也读得出) -->
-            <p v-if="panel.section.retained" class="equipment-section__stale" data-section-stale-note>
+            <p v-if="panel.kind !== 'metrics' && panel.section.retained" class="equipment-section__stale" data-section-stale-note>
                 {{ staleNote }}
             </p>
 
@@ -106,28 +105,77 @@
                     </div>
                 </dl>
 
-                <!-- 指标:名称 + 读数 + 单位 + 采样时间 -->
-                <div v-else-if="panel.kind === 'metrics'" class="equipment-section__list" data-metric-list>
-                    <p v-if="metricsSource(panel)" class="equipment-section__note" data-metric-source>{{ metricsSource(panel) }}</p>
-                    <article
-                        v-for="(metric, index) in rowsOf(panel)"
-                        :key="metric.code || index"
-                        class="equipment-section__item"
-                        :data-metric="metric.code || index"
-                    >
-                        <span class="equipment-section__item-name">{{ metric.name }}</span>
-                        <b class="equipment-section__value" :class="{ 'is-muted': isPlaceholder(metric.value) }" data-metric-value>
-                            {{ metric.value }}<em v-if="metric.unit && !isPlaceholder(metric.value)"> {{ metric.unit }}</em>
-                        </b>
-                        <span class="equipment-section__meta">
-                            <span v-if="metric.status">{{ metric.status }}</span>
-                            <span v-if="metric.freshnessLabel" data-metric-stale>{{ metric.freshnessLabel }}</span>
-                            <span data-sampled-at>{{ metric.sampledAtLabel }} {{ metric.sampledAt || '—' }}</span>
-                        </span>
-                    </article>
-                    <p v-if="!rowsOf(panel).length" class="equipment-section__hint" data-section-empty-list>
-                        {{ emptyList('指标') }}
-                    </p>
+                <!-- 采集记录:最近50行;分页后只合并当前页相同采集时间。 -->
+                <div v-else-if="panel.kind === 'metrics'" class="equipment-section__list" data-collection-records>
+                    <p class="equipment-section__note">采集记录(最近50条)</p>
+                    <div class="collection-table-wrap">
+                        <table v-if="recordTableOf(panel).total" class="collection-table" data-collection-table aria-label="设备最近采集记录">
+                            <thead>
+                                <tr>
+                                    <th v-for="column in recordTableOf(panel).columns" :key="column.key" :class="{ 'collection-table__time': column.isTime, 'collection-table__audio-col': column.isAudio }">
+                                        <span class="collection-table__heading" :title="column.label">{{ column.label }}</span>
+                                        <span v-if="column.unit" class="collection-table__unit">({{ column.unit }})</span>
+                                    </th>
+                                </tr>
+                            </thead>
+                            <tbody v-for="group in recordTableOf(panel).groups" :key="group.key"
+                                :class="{ 'collection-table__group--latest': group.isLatest }"
+                                :style="{ '--collection-accent': group.color }"
+                                :data-collection-group="group.key" :data-collection-group-state="group.state">
+                                <tr v-for="(row, rowIndex) in group.rows" :key="rowIndex" data-collection-row :class="{ 'collection-table__row--latest': group.isLatest }" :data-collection-latest="group.isLatest ? 'true' : undefined">
+                                    <template v-for="(cell, cellIndex) in row.cells" :key="cell.key">
+                                        <td
+                                            v-if="!recordTableOf(panel).columns[cellIndex].isTime || row.timeRowspan > 0"
+                                            data-collection-cell
+                                            :data-reading-state="cell.alert?.state"
+                                            :style="cell.alert ? { '--reading-color': cell.alert.color } : undefined"
+                                            :rowspan="recordTableOf(panel).columns[cellIndex].isTime ? row.timeRowspan : undefined"
+                                            :class="{ 'collection-table__time': recordTableOf(panel).columns[cellIndex].isTime, 'collection-table__audio-col': recordTableOf(panel).columns[cellIndex].isAudio }"
+                                        >
+                                            <template v-if="recordTableOf(panel).columns[cellIndex].isTime">
+                                                <span v-if="group.isLatest" class="collection-table__latest-badge" data-collection-latest-badge>最新</span>
+                                                <span class="collection-table__time-date">{{ row.createDate }}</span>
+                                                <span v-if="row.createClock" class="collection-table__time-clock">{{ row.createClock }}</span>
+                                            </template>
+                                            <button v-else-if="cell.audioPath" type="button" class="collection-table__audio-button" data-collection-audio-open :aria-label="'播放' + (cell.audioLabel || '音频记录')" :title="cell.audioLabel || '播放音频'" @click="openRecordAudio(cell, $event)">
+                                                <span aria-hidden="true">▶</span><span>播放</span>
+                                            </button>
+                                            <!--
+                                                long 布局的格内是「指标名:读数」整条:名称走正文样式,
+                                                读数段与其它设备的指标值同一套等宽数字字体。
+                                            -->
+                                            <span
+                                                v-else-if="cell.reading && cell.reading.label"
+                                                class="collection-table__reading"
+                                            >
+                                                <span class="collection-table__reading-label">{{ cell.reading.label }}</span>
+                                                <span class="collection-table__reading-result">
+                                                    <span class="collection-table__reading-value" :class="{ 'is-muted': isPlaceholder(cell.reading.value) }">{{ cell.reading.value }}</span>
+                                                    <span v-if="cell.alert" class="collection-table__alert-dot" tabindex="0" role="img"
+                                                        :style="{ color: cell.alert.dotColor }" :title="cell.alert.label" :aria-label="cell.alert.label" />
+                                                </span>
+                                            </span>
+                                            <span v-else class="collection-table__reading-result">
+                                                <span :class="{ 'is-muted': isPlaceholder(cell.text), 'collection-table__value': cell.isReading }">{{ cell.text }}</span>
+                                                <span v-if="cell.alert" class="collection-table__alert-dot" tabindex="0" role="img"
+                                                    :style="{ color: cell.alert.dotColor }" :title="cell.alert.label" :aria-label="cell.alert.label" />
+                                            </span>
+                                        </td>
+                                    </template>
+                                </tr>
+                            </tbody>
+                        </table>
+                    </div>
+                    <p v-if="!recordTableOf(panel).total" class="equipment-section__hint" data-section-empty-list>暂无采集记录</p>
+                    <p v-else class="equipment-section__hint" data-collection-assessment-note>{{ recordTableOf(panel).assessmentNote }}</p>
+                    <PagerNav
+                        v-if="recordTableOf(panel).total"
+                        :page="recordTableOf(panel).page"
+                        :page-count="recordTableOf(panel).pageCount"
+                        :total="recordTableOf(panel).total"
+                        list-name="采集记录"
+                        @page="(target) => gotoPage(panel, target)"
+                    />
                 </div>
 
                 <!--
@@ -141,8 +189,13 @@
                         v-for="(event, index) in pagedOf(panel).rows"
                         :key="event.code || index"
                         class="equipment-section__item equipment-section__item--collapsible"
+                        :class="{ 'equipment-section__item--active': event.active }"
+                        :style="{ '--detail-accent': event.activeColor }"
+                        :open="isDetailOpen(panel, event, index)"
+                        @toggle="rememberDetailToggle(panel, event, index, $event)"
                         :data-event="event.code || index"
                         :data-event-level-color="event.levelColor"
+                        :data-item-active="event.active ? 'true' : 'false'"
                         data-history-item
                     >
                         <summary class="equipment-section__item-head">
@@ -150,10 +203,11 @@
                             <span class="equipment-section__meta">
                                 <span data-event-level>{{ event.levelText }}</span>
                                 <span v-if="event.status">{{ event.status }}</span>
+                                <span v-if="event.active" class="equipment-section__active-badge" data-active-badge>{{ event.activeLabel }}</span>
                             </span>
                             <span class="equipment-section__meta">
                                 <span v-if="event.timeText">{{ event.timeText }}</span>
-                                <span class="equipment-section__mono">{{ event.code }}</span>
+                                <span class="equipment-section__mono">{{ event.codeText }}</span>
                             </span>
                             <span class="equipment-section__value" :class="{ 'is-muted': isPlaceholder(event.quantity) }" data-event-value>
                                 {{ event.quantity }}
@@ -191,8 +245,13 @@
                         v-for="(order, index) in pagedOf(panel).rows"
                         :key="order.code || index"
                         class="equipment-section__item equipment-section__item--collapsible"
+                        :class="{ 'equipment-section__item--active': order.active }"
+                        :style="{ '--detail-accent': order.activeColor }"
+                        :open="isDetailOpen(panel, order, index)"
+                        @toggle="rememberDetailToggle(panel, order, index, $event)"
                         :data-work-order="order.code || index"
                         :data-work-order-state="order.stateCode"
+                        :data-item-active="order.active ? 'true' : 'false'"
                         data-history-item
                     >
                         <summary class="equipment-section__item-head">
@@ -203,6 +262,7 @@
                             <span class="equipment-section__meta">
                                 <span class="equipment-section__mono">{{ order.code }}</span>
                                 <span data-work-order-association>{{ order.associationText }}</span>
+                                <span v-if="order.active" class="equipment-section__active-badge" data-active-badge>{{ order.activeLabel }}</span>
                             </span>
                             <span class="equipment-section__meta">
                                 <span v-if="order.timeText">创建 {{ order.timeText }}</span>
@@ -251,11 +311,11 @@
                     />
                 </div>
 
-                <!-- 最新图片:只画后端指定的那一张(前端不索引、不挑选) -->
+                <!-- 设备图片:只画后端指定的那一张(前端不索引、不挑选) -->
                 <figure v-else-if="panel.kind === 'image'" class="equipment-section__figure" data-latest-image>
                     <template v-if="image(panel).hasImage">
                         <button v-if="!imageFailed" type="button" class="equipment-section__image-button" aria-label="放大查看设备图片" @click="imageDialog.showModal()">
-                            <img class="equipment-section__img" :src="image(panel).url" :alt="`${deviceCode} 最新图片`" @load="imageLoading = false" @error="imageFailed = true; imageLoading = false" />
+                            <img class="equipment-section__img" :src="image(panel).url" :alt="`${deviceCode} 设备图片`" @load="imageLoading = false" @error="imageFailed = true; imageLoading = false" />
                             <span>{{ imageLoading ? '图片加载中…' : '点击放大' }}</span>
                         </button>
                         <p v-else class="equipment-section__hint" role="status">图片加载失败,请检查图片服务地址或文件是否可访问。<button type="button" @click="imageFailed = false; imageLoading = true">重试</button></p>
@@ -282,13 +342,33 @@
         </div>
         <dialog ref="imageDialog" class="equipment-image-viewer" aria-label="设备图片放大查看" @click="closeImageBackdrop">
             <button type="button" autofocus class="equipment-image-viewer__close" @click="imageDialog.close()">关闭</button>
-            <img :src="currentImageUrl" :alt="`${deviceCode} 最新图片`" />
+            <img :src="currentImageUrl" :alt="`${deviceCode} 设备图片`" />
         </dialog>
+        <!--
+            音频播放条:贴在播放键上方渐入的小浮层(Teleport 到 body,避免被滚动容器裁剪)。
+            不再用居中大弹窗/背景虚化;点击外部、滚动或 Esc 都会收起。
+        -->
+        <Teleport to="body">
+            <div
+                v-if="activeAudioPath"
+                ref="audioPlayer"
+                class="collection-audio-player"
+                :class="{ 'is-below': audioPlacement.below }"
+                :style="audioPlayerStyle"
+                data-collection-audio-player
+            >
+                <div class="collection-audio-player__head">
+                    <span class="collection-audio-player__label">{{ activeAudioLabel }}</span>
+                    <button type="button" class="collection-audio-player__close" aria-label="关闭播放条" @click="closeAudioPlayer">×</button>
+                </div>
+                <audio v-if="activeAudioUrl" class="collection-audio-player__audio" controls preload="metadata" :src="activeAudioUrl" />
+            </div>
+        </Teleport>
     </div>
 </template>
 
 <script setup>
-import { computed, ref, watch } from 'vue'
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
 import { resolveDeviceImageUrl } from '../services/deviceImage.js'
 import { DETAIL_PLACEHOLDER } from '../domain/detailFlow.js'
 import {
@@ -305,10 +385,10 @@ import {
     eventRows,
     filterByCategory,
     imageView,
-    metricRows,
-    metricsSourceText,
+    collectionRecordTable,
     pagedRows,
     panelsForTab,
+    prioritizeActiveRows,
     tabCount,
     timelineSource,
     workOrderDetailRows,
@@ -360,12 +440,68 @@ const categories = ref({})
 
 /** 各分区当前页码(1 起;预警/报警/工单三块都接入分页)。 */
 const pages = ref({})
+const recordTables = computed(() => Object.fromEntries(
+    panels.value.filter((panel) => panel.kind === 'metrics').map((panel) => [panel.name, collectionRecordTable(panel.section.data, pages.value[panel.name] || 1)]),
+))
+function recordTableOf(panel) {
+    return recordTables.value[panel.name]
+}
+const audioPlayer = ref(null)
+const activeAudioPath = ref('')
+const activeAudioLabel = ref('音频记录')
+const activeAudioUrl = computed(() => activeAudioPath.value ? resolveDeviceImageUrl(activeAudioPath.value) : '')
+const audioPlacement = ref({ left: 0, top: 0, below: false })
+const audioPlayerStyle = computed(() => ({
+    left: `${audioPlacement.value.left}px`,
+    top: `${audioPlacement.value.top}px`,
+}))
+let audioTriggerEl = null
+
+/** 打开播放条:以触发按钮为锚,默认在按钮上方渐入;顶部空间不足时翻到按钮下方。 */
+function openRecordAudio(cell, event) {
+    if (!cell?.audioPath) return
+    const rect = event?.currentTarget?.getBoundingClientRect?.()
+    if (rect) {
+        const margin = 132
+        const left = Math.min(Math.max(rect.left + rect.width / 2, margin), Math.max(margin, window.innerWidth - margin))
+        const below = rect.top < 96
+        audioPlacement.value = { left: Math.round(left), top: Math.round(below ? rect.bottom : rect.top), below }
+    }
+    audioTriggerEl = event?.currentTarget || null
+    activeAudioPath.value = cell.audioPath
+    activeAudioLabel.value = cell.audioLabel || '音频记录'
+}
+function closeAudioPlayer() {
+    if (!activeAudioPath.value) return
+    activeAudioPath.value = ''
+    audioTriggerEl = null
+}
+function audioOutsideHandler(event) {
+    if (audioPlayer.value?.contains(event.target) || audioTriggerEl?.contains?.(event.target)) return
+    closeAudioPlayer()
+}
+function audioEscapeHandler(event) {
+    if (event.key === 'Escape') closeAudioPlayer()
+}
+function attachAudioGuards() {
+    document.addEventListener('pointerdown', audioOutsideHandler, true)
+    // 滚动时按钮会离开锚点,直接收起(capture 才能收到各滚动容器不冒泡的 scroll)。
+    window.addEventListener('scroll', closeAudioPlayer, true)
+    window.addEventListener('keydown', audioEscapeHandler, true)
+}
+function detachAudioGuards() {
+    if (typeof document === 'undefined' || typeof window === 'undefined') return
+    document.removeEventListener('pointerdown', audioOutsideHandler, true)
+    window.removeEventListener('scroll', closeAudioPlayer, true)
+    window.removeEventListener('keydown', audioEscapeHandler, true)
+}
+watch(activeAudioPath, (open) => { if (open) attachAudioGuards(); else detachAudioGuards() })
+onBeforeUnmount(detachAudioGuards)
 
 /** 某分区的行视图(未过滤)。 */
 function rowsOf(panel) {
     const data = panel.section.data
     if (panel.kind === 'archive') return archiveRows(data)
-    if (panel.kind === 'metrics') return metricRows(data)
     if (panel.kind === 'orders') return workOrderRows(data)
     return eventRows(data)
 }
@@ -395,7 +531,9 @@ function toggleCategory(panelName, key) {
 
 /** 过滤后的行(chip 只筛视图,不动数据、不发请求)。 */
 function visibleRows(panel) {
-    return filterByCategory(rowsOf(panel), activeCategory(panel.name))
+    const rows = filterByCategory(rowsOf(panel), activeCategory(panel.name))
+    if (panel.kind !== 'events' && panel.kind !== 'orders') return rows
+    return prioritizeActiveRows(rows)
 }
 
 /** 过滤 + 分页后的窗口(domain 单源 `pagedRows` 收边界;票 59 三清单同一条路径,组件不再备第二份兜底形状)。 */
@@ -415,12 +553,35 @@ function categoryNoteText(panel) {
     return categoryNote(chipsOf(panel), activeCategory(panel.name))
 }
 
+/**
+ * 票 115 优化:未解除/未结案条目的展开状态。
+ *
+ * 切换页签时清空手动状态 ⇒ 进入对应页签时,active 条目默认展开;
+ * 用户手动收起/展开后以手动状态为准(manualOpen[key]),不会每帧被默认值顶回去。
+ */
+const manualOpen = ref({})
+function detailKey(panel, item, index) {
+    return panel.name + '|' + (item.code || index)
+}
+function isDetailOpen(panel, item, index) {
+    const key = detailKey(panel, item, index)
+    return Object.hasOwn(manualOpen.value, key) ? manualOpen.value[key] : item.active === true
+}
+function rememberDetailToggle(panel, item, index, event) {
+    const key = detailKey(panel, item, index)
+    manualOpen.value = { ...manualOpen.value, [key]: event.target.open }
+}
+watch(activeTab, () => {
+    manualOpen.value = {}
+})
+
 // 换设备:页签、分类条件与页码全部复位(不同设备的分布互不相干,留着就是拿 A 的状态看 B)。
 watch(
     () => props.deviceCode,
     () => {
         categories.value = {}
         pages.value = {}
+        manualOpen.value = {}
         activeTab.value = DEFAULT_EQUIPMENT_TAB
     },
 )
@@ -438,10 +599,6 @@ function trackOfOrder(order) {
     return full || pathOnlyTrack(order)
 }
 
-function metricsSource(panel) {
-    return metricsSourceText(panel.section.data)
-}
-
 function image(panel) {
     const view = imageView(panel.section.data)
     return { ...view, url: resolveDeviceImageUrl(view.url) }
@@ -458,6 +615,9 @@ watch([() => props.deviceCode, currentImageUrl], () => {
     imageFailed.value = false
     imageLoading.value = true
 })
+watch([() => props.deviceCode, activeTab], () => {
+    closeAudioPlayer()
+})
 function closeImageBackdrop(event) {
     if (event.target !== imageDialog.value) return
     const rect = imageDialog.value.getBoundingClientRect()
@@ -474,6 +634,159 @@ function isPlaceholder(text) {
 </script>
 
 <style scoped>
+.collection-table-wrap { min-width: 0; overflow-x: hidden; }
+.collection-table {
+    width: 100%;
+    table-layout: fixed;
+    border-collapse: collapse;
+    color: var(--text-primary, #e5f4ff);
+    font-size: calc(11 * var(--u, 1px));
+}
+.collection-table th,
+.collection-table td {
+    min-width: 0;
+    padding: calc(4 * var(--u, 1px)) calc(3 * var(--u, 1px));
+    border: 1px solid rgba(69, 161, 226, .16);
+    text-align: left;
+    vertical-align: middle;
+}
+.collection-table th {
+    color: #a6d8ff;
+    background: rgba(29, 83, 124, .25);
+    font-size: calc(10 * var(--u, 1px));
+    letter-spacing: .02em;
+    line-height: 1.2;
+}
+.collection-table__heading {
+    display: inline-block;
+    max-width: 100%;
+    overflow: hidden;
+    color: inherit;
+    font-weight: 600;
+    text-overflow: ellipsis;
+    vertical-align: bottom;
+    white-space: nowrap;
+    word-break: keep-all;
+}
+.collection-table__unit {
+    white-space: normal;
+    word-break: break-word;
+    overflow-wrap: anywhere;
+    font-size: .92em;
+}
+.collection-table td { background: rgba(7, 27, 48, .35); font-variant-numeric: tabular-nums; line-height: 1.35; overflow-wrap: anywhere; }
+.collection-table tbody:not(.collection-table__group--latest):nth-of-type(even) td { background: rgba(7, 27, 48, .22); }
+.collection-table tbody:not(.collection-table__group--latest) tr:hover td { background: rgba(1, 171, 234, .12); }
+/* 渐变只画在整次采集的行组上,td 透明;长表 rowspan 的时间格也共用同一张背景。 */
+.collection-table__group--latest {
+    --reading-color: var(--lv4, #27d17a);
+    background: linear-gradient(90deg, color-mix(in srgb, var(--collection-accent) 24%, transparent), rgba(7, 27, 48, .28) 100%);
+}
+.collection-table .collection-table__group--latest td {
+    background: transparent;
+    border-color: color-mix(in srgb, var(--collection-accent) 30%, transparent);
+}
+.collection-table__group--latest tr:first-child td:first-child { box-shadow: inset 3px 0 0 var(--collection-accent); }
+.collection-table__group--latest:hover {
+    background: linear-gradient(90deg, color-mix(in srgb, var(--collection-accent) 30%, transparent), rgba(7, 27, 48, .34) 100%);
+}
+.collection-table__latest-badge {
+    display: inline-flex;
+    align-items: center;
+    margin-bottom: calc(2 * var(--u, 1px));
+    padding: 0 calc(5 * var(--u, 1px));
+    border: 1px solid color-mix(in srgb, var(--collection-accent) 65%, transparent);
+    border-radius: 999px;
+    background: color-mix(in srgb, var(--collection-accent) 16%, transparent);
+    color: var(--collection-accent);
+    font-size: var(--fs-caption, 10.5px);
+    line-height: calc(16 * var(--u, 1px));
+    letter-spacing: .04em;
+    white-space: nowrap;
+}
+/* 采集时间已拆两行(日期 10 字符是最宽一行),定宽按内容收紧,
+   余量让给右侧指标列(维护者 2026-11-06 反馈:125u 约剩一半空白)。 */
+.collection-table__time { width: calc(82 * var(--u, 1px)); }
+.collection-table__time-date,
+.collection-table__time-clock { display: block; white-space: nowrap; }
+.collection-table__audio-col { width: calc(72 * var(--u, 1px)); }
+.collection-table__audio-button {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    gap: calc(3 * var(--u, 1px));
+    width: 100%;
+    min-width: 0;
+    padding: calc(3 * var(--u, 1px)) calc(4 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, .5);
+    border-radius: 999px;
+    background: rgba(1, 91, 150, .26);
+    color: #b8e8ff;
+    font: inherit;
+    white-space: nowrap;
+    cursor: pointer;
+}
+.collection-table__audio-button:hover,
+.collection-table__audio-button:focus-visible { border-color: #ffb020; color: #ffd08a; outline: none; }
+/* 指标读数排版(wide / long 两种布局共用这一套,不因列名不同而脱节):
+   字体与左右侧面板 KPI 数值(.panel-kpi__value)同档 --num(Rajdhani / Share Tech Mono…,
+   单源 tokens.css);字号按表格密度自 22u KPI 档收到 13.5u;满不透明度的 --text-strong
+   高亮白并加粗,读数在大屏上一眼可读;读数相对单元格左边界略微缩进。
+   long 布局的「指标名:读数」整条,名称段用正文样式、读数段复用本套字体(见下)。
+   报警行以更高优先级抢回红色,占位灰由后置 is-muted 覆盖。 */
+.collection-table__value,
+.collection-table__reading-value {
+    color: var(--reading-color, var(--text-strong, #fff));
+    opacity: 1;
+    font-family: var(--num, monospace);
+    font-size: calc(13.5 * var(--u, 1px));
+    font-weight: 700;
+    letter-spacing: .02em;
+    text-shadow: 0 0 8px color-mix(in srgb, currentColor 24%, transparent);
+}
+.collection-table__value {
+    padding-left: calc(4 * var(--u, 1px));
+}
+.collection-table__reading-label {
+    color: var(--muted, #5680b0);
+    font-size: calc(11 * var(--u, 1px));
+    font-weight: 400;
+}
+.collection-table__reading {
+    display: grid;
+    grid-template-columns: minmax(0, 48%) minmax(0, 1fr);
+    column-gap: calc(14 * var(--u, 1px));
+    align-items: baseline;
+    padding-left: calc(4 * var(--u, 1px));
+    text-align: left;
+}
+.collection-table__reading-result {
+    display: inline-flex;
+    min-width: 0;
+    align-items: baseline;
+    justify-content: flex-start;
+    gap: calc(6 * var(--u, 1px));
+}
+.collection-table__reading-result > span:first-child { min-width: 0; }
+.collection-table__value.is-muted,
+.collection-table__reading-value.is-muted { color: var(--muted, #5680b0); font-weight: 400; text-shadow: none; }
+.collection-table__alert-dot {
+    display: inline-block;
+    flex: none;
+    width: calc(7 * var(--u, 1px));
+    height: calc(7 * var(--u, 1px));
+    border-radius: 50%;
+    background: currentColor;
+    box-shadow: 0 0 6px currentColor;
+    cursor: help;
+}
+.collection-table__alert-dot:focus-visible { outline: 1px solid currentColor; outline-offset: 3px; }
+.collection-table__label {
+    color: var(--muted, #5680b0);
+    font-size: calc(10.5 * var(--u, 1px));
+}
+
+
 .equipment-sections,
 .equipment-sections *,
 .equipment-sections *::before,
@@ -680,6 +993,37 @@ function isPlaceholder(text) {
     padding-bottom: calc(5 * var(--u, 1px));
 }
 
+/* 事件按等级色强调;未分级预警黄、报警红;非终态工单天蓝。 */
+.equipment-section__item--active {
+    padding-left: calc(6 * var(--u, 1px));
+    border-bottom-color: color-mix(in srgb, var(--detail-accent) 65%, transparent);
+    border-radius: calc(6 * var(--u, 1px));
+    background: linear-gradient(90deg, color-mix(in srgb, var(--detail-accent) 20%, transparent), rgba(7, 27, 48, .12) 78%);
+    box-shadow: inset 3px 0 0 var(--detail-accent), inset 0 0 0 1px color-mix(in srgb, var(--detail-accent) 30%, transparent), 0 0 12px color-mix(in srgb, var(--detail-accent) 12%, transparent);
+}
+.equipment-section__item--active[open] {
+    border-bottom-color: color-mix(in srgb, var(--detail-accent) 78%, transparent);
+    background: linear-gradient(90deg, color-mix(in srgb, var(--detail-accent) 26%, transparent), rgba(7, 27, 48, .16) 82%);
+}
+.equipment-section__item--active .equipment-section__item-head,
+.equipment-section__item--collapsible.equipment-section__item--active[open] .equipment-section__item-head {
+    border-color: color-mix(in srgb, var(--detail-accent) 26%, transparent);
+    background: color-mix(in srgb, var(--detail-accent) 6%, transparent);
+}
+.equipment-section__active-badge {
+    display: inline-flex;
+    align-items: center;
+    padding: 0 calc(6 * var(--u, 1px));
+    border: 1px solid var(--detail-accent);
+    border-radius: 999px;
+    background: color-mix(in srgb, var(--detail-accent) 24%, transparent);
+    color: color-mix(in srgb, var(--detail-accent) 55%, white);
+    font-size: var(--fs-caption, 10.5px);
+    line-height: calc(16 * var(--u, 1px));
+    letter-spacing: .04em;
+    white-space: nowrap;
+}
+
 .equipment-section__item-head {
     display: grid;
     gap: 1px;
@@ -822,4 +1166,57 @@ function isPlaceholder(text) {
 .equipment-image-viewer::backdrop { background: rgba(1, 5, 20, .8); backdrop-filter: blur(6px); }
 .equipment-image-viewer img { display: block; width: 100%; height: auto; max-height: 78vh; object-fit: contain; }
 .equipment-image-viewer__close { display: block; margin-left: auto; margin-bottom: 8px; cursor: pointer; }
+/* 音频播放条:贴播放键上方渐入的小浮层,不遮罩、不虚化整屏 UI。 */
+.collection-audio-player {
+    position: fixed;
+    z-index: calc(var(--z-columns, 30) + 5);
+    width: min(264px, 88vw);
+    padding: calc(6 * var(--u, 1px)) calc(8 * var(--u, 1px)) calc(8 * var(--u, 1px));
+    border: 1px solid var(--cyan, #01abea);
+    border-radius: calc(8 * var(--u, 1px));
+    background: rgba(4, 20, 38, .97);
+    box-shadow: 0 8px 22px rgba(0, 0, 0, .45);
+    color: var(--text, #e5f4ff);
+    transform: translate(-50%, calc(-100% - 8px));
+    animation: collection-audio-pop-above .18s ease-out;
+}
+.collection-audio-player.is-below {
+    transform: translate(-50%, 8px);
+    animation-name: collection-audio-pop-below;
+}
+@keyframes collection-audio-pop-above {
+    from { opacity: 0; transform: translate(-50%, calc(-100% - 2px)); }
+}
+@keyframes collection-audio-pop-below {
+    from { opacity: 0; transform: translate(-50%, 2px); }
+}
+.collection-audio-player__head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: calc(6 * var(--u, 1px));
+    margin-bottom: calc(4 * var(--u, 1px));
+}
+.collection-audio-player__label {
+    min-width: 0;
+    overflow: hidden;
+    color: #a6d8ff;
+    font-size: var(--fs-caption, 10.5px);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.collection-audio-player__close {
+    flex: none;
+    width: calc(20 * var(--u, 1px));
+    height: calc(20 * var(--u, 1px));
+    padding: 0;
+    border: 1px solid rgba(1, 171, 234, .5);
+    border-radius: 999px;
+    background: rgba(1, 91, 150, .26);
+    color: inherit;
+    font-size: calc(12 * var(--u, 1px));
+    line-height: 1;
+    cursor: pointer;
+}
+.collection-audio-player__audio { display: block; width: 100%; max-width: 100%; height: calc(32 * var(--u, 1px)); }
 </style>

+ 1 - 1
src/modules/cockpit/components/FacilityDetailCard.vue

@@ -33,7 +33,7 @@ defineProps({ locationReason: { type: String, default: '' }, locationHint: { typ
 const emit = defineEmits(['locate-device'])
 const store = useEntityDetailStore()
 const view = computed(() => entityDetailView(store.detail))
-const code = computed(() => store.detail?.raw?.code || store.identity?.id || '—')
+const code = computed(() => store.detail?.displayCode || '未提供')
 </script>
 <style scoped>
 /* Same frame language as PointDetailCard: content-height flex body, one token gradient/blur, vertical-only scroll. */

+ 61 - 24
src/modules/cockpit/components/MapCanvas.vue

@@ -44,6 +44,15 @@
                     <CockpitIcon v-if="row.icon" monochrome :name="row.icon" size="calc(14 * var(--u, 1px))" class="map-holo__legend-icon" />
                     <StatusTag :tone="row.tone" :shape="row.shape" :label="row.label" />
                     <span class="map-holo__legend-count" data-status-count>:{{ row.count === null ? '—' : row.count }}</span>
+                    <!--
+                        维护者 2026-11-05:「图例的预警 7 与图上坐标点 2 对不上」。
+                        这不是缺陷,但**界面必须自己说清楚**——两行数字的口径本来就不同:
+                          · 前面的计数=**设备**台账里有这种标签的台数(可按状态筛选的池子大小);
+                          · 这里的「上图 N」=当前模式**真正画上地图的对象数**(点位聚合 + 独立设备)。
+                        设备没有坐标(MISSING_LONGITUDE)、或它挂在的点位已按聚合状态显示成别的颜色,
+                        都会让 N < 计数。把两个数同时摆出来,读者不必去猜哪个是错的。
+                    -->
+                    <span v-if="row.onMap !== null" class="map-holo__legend-onmap" data-status-onmap-text>· 上图 {{ row.onMap }}</span>
                 </button>
             </div>
 
@@ -140,6 +149,7 @@ import { useEntityDetailStore } from '../stores/entityDetailStore.js'
 import { useFacilityPointStore } from '../stores/facilityPointStore.js'
 import { useFilterStore } from '../stores/filterStore.js'
 import { useMapStore } from '../stores/mapStore.js'
+import { useDeviceStatusStore } from '../stores/deviceStatusStore.js'
 import { useDetailStore } from '../stores/detailStore.js'
 import StatusTag from './StatusTag.vue'
 import CockpitIcon from './CockpitIcon.vue'
@@ -196,6 +206,11 @@ const markerStatusText = computed(() => {
 
 // ---- 票 14:状态栏 = 全局组合筛选入口(八桶多选 OR、全局未筛计数、选中高亮、行常驻可点)----
 const filterStore = useFilterStore()
+// 维护者 2026-11-04:设备状态行统一取**全局设备状态池**(与运行概况、监测分布同一份),
+// 它不依赖一级模式,所以切到任何模式顶部数字都一致。
+const deviceStatusStore = useDeviceStatusStore()
+/** 状态池里有对应桶的图例行;其余行(无设备监测点、未关联设备)保持点位/身份口径。 */
+const DEVICE_POOL_ROW_KEYS = ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE']
 const { pointStatus: selectedStatuses, facilityTypes: selectedFacilityTypes } = storeToRefs(filterStore)
 // 票 68:设施点的图例口径来自设施点图层 store(档案计数 + 可上图数),只读不写。
 const facilityPointStore = useFacilityPointStore()
@@ -211,33 +226,55 @@ const AXIS_NOTES = {
     UNKNOWN: ';状态未发布保留本行,不猜测业务状态',
 }
 
+const LEGEND_ORDER = ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY', 'UNASSOCIATED']
 const statusRows = computed(() => {
     const distribution = mapStore.statusDistribution
     const axes = mapStore.statusAxes
-    return legendVisuals().map((visual) => {
-        const selected = selectedStatuses.value.includes(visual.key)
-        // 业务轴统计所有对象的业务状态(含未关联设备自身 deviceStatus);
-        // UNASSOCIATED 另计附加轴,故一台设备可同时贡献「预警」与「无关联设备」两行。
-        const axisCounts = visual.key === 'UNASSOCIATED' ? axes.attachmentCounts : axes.businessCounts
-        const count = distribution.available ? axisCounts[visual.key] || 0 : null
-        const note = AXIS_NOTES[visual.key] || ''
-        return {
-            key: visual.key,
-            tone: visual.tone,
-            shape: visual.shape,
-            label: visual.label,
-            // 批次 1 第三轮:状态行区分图标(legendVisuals 补的纯视觉字段)。
-            icon: visual.icon || '',
-            count,
-            selected,
-            title:
-                count === null
-                    ? `按「${visual.label}」筛选(可多选);全局状态分布未取到,计数暂不可见${note}`
-                    : selected
-                      ? `取消「${visual.label}」筛选(计数 ${count} 为全局未筛口径,不随筛选变化)${note}`
-                      : `按「${visual.label}」筛选(可多选;业务轴与附加轴分开统计)${note}`,
-        }
-    })
+    const device = deviceStatusStore.pools
+    // 「上图 N」的唯一来源:计划的 `byStatus`(与地图标记同一份,本组件不另算一遍)。
+    const markers = modePlan.value.byStatus || {}
+    const onMapCount = (key) => Number(markers[key] || 0)
+    // 维护者 2026-11-05:图例只统计**设备**,且报警/预警/工单/独立设备是可叠加标签、
+    // 正常与离线互斥;「无设备监测点」只针对没有关联设备的监测点,故仍取点位口径。
+    return legendVisuals()
+        .filter((visual) => LEGEND_ORDER.includes(visual.key))
+        .sort((left, right) => LEGEND_ORDER.indexOf(left.key) - LEGEND_ORDER.indexOf(right.key))
+        .map((visual) => {
+            const selected = selectedStatuses.value.includes(visual.key)
+            // 设备项一律取设备池(含 NORMAL/OFFLINE);只有「无设备监测点」仍走点位口径。
+            const fromDevice = device.available && visual.key !== 'EMPTY'
+            const count = visual.key === 'EMPTY'
+                ? (distribution.available ? (axes.businessCounts.EMPTY || 0) : null)
+                : fromDevice ? (device.counts[visual.key] || 0) : null
+            const note = visual.key === 'EMPTY'
+                ? ';按监测点统计:没有任何关联设备的点位'
+                // 维护者 2026-11-05:图例已把 NORMAL 改称「在线」,说明里同步改叫法。
+                : ';按设备统计,可与其它标签同时存在(在线/离线互斥)'
+            // 「上图 N」与前面的计数**口径不同**(前面数设备、这里数画到地图上的对象)。
+            // 两个数都摆出来,读者一眼能看出差额来自「没坐标 / 点位聚合状态覆盖」这类数据事实,
+            // 而不是去猜哪个数字错了。
+            // 「独立设备」是**类别轴**:它那 17 台设备在地图上是按各自业务状态画的
+            // (黄绿十字只在没有业务异常时出现),并没有一个叫「独立设备」的标记可言,
+            // 所以这一行不给「上图 N」——给了只会是 0,读起来像缺陷。其余行都是标记语言,
+            // 逐行给真实上图数。
+            const onMap = visual.key === 'UNASSOCIATED' ? null : onMapCount(visual.key)
+            return {
+                key: visual.key,
+                tone: visual.tone,
+                shape: visual.shape,
+                label: visual.label,
+                icon: visual.icon || '',
+                count,
+                onMap,
+                selected,
+                title:
+                    count === null
+                        ? `按「${visual.label}」筛选(可多选);计数暂不可用${note}`
+                        : selected
+                          ? `取消「${visual.label}」筛选(计数 ${count})${note}`
+                          : `按「${visual.label}」筛选(可多选)${note}`,
+            }
+        })
 })
 
 /** 点状态桶 → 写全局筛选(地图与面板的跟随由外壳的 watcher 收口,本组件不自己发请求)。 */

+ 31 - 1
src/modules/cockpit/components/PipeEnhancement.vue

@@ -27,6 +27,18 @@
                     <strong>{{ deviceState.selected.name || deviceState.selected.code }}</strong>
                     <button type="button" data-enhancement-device-close @click="$emit('device-close')">关闭详情</button>
                 </header>
+                <div class="pipe-enhancement__device-status-card" data-enhancement-device-status-card>
+                    <StatusTag
+                        v-if="selectedDeviceStatus"
+                        :tone="selectedDeviceStatus.tone"
+                        :shape="selectedDeviceStatus.shape"
+                        :label="selectedDeviceStatus.label"
+                        :icon="selectedDeviceStatus.icon"
+                        data-enhancement-device-status
+                    />
+                    <span v-else class="pipe-enhancement__device-status-empty">—</span>
+                    <span class="pipe-enhancement__device-status-label">设备状态</span>
+                </div>
                 <p>{{ deviceState.selected.placementText }}</p>
                 <p v-if="deviceState.selected.pointCode">所属监测点:{{ deviceState.selected.pointCode }}</p>
                 <p v-if="deviceState.loadingDetail">设备详情加载中</p>
@@ -81,7 +93,9 @@
 <script setup>
 import { computed, nextTick, onBeforeUnmount, watch, ref } from 'vue'
 import { mountPipeEnhancement } from '../services/threePipeEnhancement.js'
+import { deviceStatusView as deviceVisualOf } from '../domain/detailFlow.js'
 import EquipmentDetailSections from './EquipmentDetailSections.vue'
+import StatusTag from './StatusTag.vue'
 
 const props = defineProps({
     plan: { type: Object, required: true },
@@ -89,6 +103,10 @@ const props = defineProps({
 })
 const emit = defineEmits(['close', 'radius-change', 'device-pick', 'device-close', 'device-retry', 'devices-reload', 'object-pick'])
 const viewport = ref(null)
+const selectedDeviceStatus = computed(() => {
+    const device = props.deviceState?.selected
+    return device?.status ? deviceVisualOf(device.status, device.statusLabel || '') : null
+})
 const localPipeStates = computed(() => [...new Map((props.plan.objects || [])
     .filter((object) => object.sourceKind === 'pipe').map((object) => [object.code, object])).values()])
 /**
@@ -215,7 +233,7 @@ onBeforeUnmount(() => {
     z-index: 6;
     left: 12px;
     top: 12px;
-    width: min(340px, calc(100% - 24px));
+    width: min(clamp(460px, calc(520 * var(--u, 1px)), 620px), calc(100% - 24px));
     max-height: calc(100% - 24px);
     overflow: auto;
     box-sizing: border-box;
@@ -227,6 +245,18 @@ onBeforeUnmount(() => {
     font-size: 12px;
 }
 .pipe-enhancement__device-detail header { display: flex; justify-content: space-between; gap: 8px; align-items: center; margin-bottom: 8px; }
+.pipe-enhancement__device-status-card {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    margin: 6px 0 10px;
+    padding: 7px 10px;
+    border: 1px solid rgba(1, 171, 234, .36);
+    border-radius: 8px;
+    background: linear-gradient(135deg, rgba(4, 42, 76, .72), rgba(5, 23, 47, .88));
+}
+.pipe-enhancement__device-status-label { color: #7ba4c3; }
+.pipe-enhancement__device-status-empty { color: #a7c4d9; }
 .pipe-enhancement__device-detail button { color: #e4f4ff; background: #08213a; border: 1px solid #21618a; padding: 5px 8px; cursor: pointer;
     border-radius: calc(8 * var(--u, 1px));}
 .pipe-enhancement__device-detail :deep(.equipment-sections) { max-height: 60vh; overflow: auto; }

+ 44 - 23
src/modules/cockpit/components/PointDetailCard.vue

@@ -236,35 +236,28 @@
             <dl class="point-detail__rows">
                 <div class="point-detail__row">
                     <dt>设备名称</dt>
-                    <dd data-device-name>{{ deviceCard ? deviceCard.name : '—' }}</dd>
+                    <dd data-device-name>{{ headerDeviceName }}</dd>
                 </div>
                 <div class="point-detail__row">
                     <dt>设备编码</dt>
                     <dd class="point-detail__mono" data-device-code-value>{{ selection.deviceCode || '—' }}</dd>
                 </div>
-                <div class="point-detail__row">
-                    <dt>状态</dt>
-                    <dd>
-                        <StatusTag
-                            v-if="deviceCard"
-                            data-device-status
-                            :tone="deviceCard.status.tone"
-                            :shape="deviceCard.status.shape"
-                            :label="deviceCard.status.label"
-                            :icon="deviceCard.status.icon"
-                        />
-                        <span v-else class="is-muted">—</span>
-                    </dd>
-                </div>
-                <div class="point-detail__row">
-                    <dt>原因</dt>
-                    <dd :class="{ 'is-muted': !deviceReason }" data-device-reason>{{ deviceReason || '暂无原因' }}</dd>
-                </div>
             </dl>
 
-            <p v-if="!selection.pointCode" class="point-detail__hint" data-device-unassociated>
-                该设备没有关联监测点(地图上的未关联设备)
-            </p>
+            <!-- 票 115 优化:设备状态沿用点位摘要的状态卡样式,单独成行突出显示。 -->
+            <div v-if="headerStatus" class="point-detail__status-card point-detail__status-card--device" data-device-status-card>
+                <StatusTag
+                    data-device-status
+                    :tone="headerStatus.tone"
+                    :shape="headerStatus.shape"
+                    :label="headerStatus.label"
+                    :icon="headerStatus.icon"
+                />
+                <span class="point-detail__status-reason is-muted" data-device-status-label>设备状态</span>
+            </div>
+            <div v-else class="point-detail__status-card point-detail__status-card--device" data-device-status-card>
+                <span class="is-muted">—</span>
+            </div>
 
             <!--
                 票 11:六个分区(档案 / 指标 / 预警 / 报警 / 工单 / 图片)。每块各自成败、各自渲染,
@@ -310,6 +303,7 @@ import { computed, ref, watch } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useDetailStore } from '../stores/detailStore.js'
 import { useDeviceLayerStore } from '../stores/deviceLayerStore.js'
+import { useMapStore } from '../stores/mapStore.js'
 import { DETAIL_LEVEL, deviceCards, deviceCardOf, pointDeviceCountsView, pointSummary } from '../domain/detailFlow.js'
 // 票 60(统一分页批次二):翻页窗口计算归 `domain/pagination.js` 单源(条文 11),
 // 控件用共享 `PagerNav.vue`;本组件只存「哪一屏看第几页」这份视图状态,不自画 pager。
@@ -337,6 +331,7 @@ const props = defineProps({
     locationReason: { type: String, default: '' },
 })
 const detailStore = useDetailStore()
+const mapStore = useMapStore()
 // 票 42:关联设备清单与它的独立图例(总数来自当前快照,不由本组件重算)。
 const deviceLayerStore = useDeviceLayerStore()
 const { rows: deviceRows, counts: deviceCounts, total: deviceLayerTotal, legend: deviceLegend, overlap: deviceOverlap, effectsPlan: deviceEffects, notice: deviceNotice } =
@@ -355,7 +350,27 @@ const summary = computed(() => (pointData.value ? pointSummary(pointData.value)
 const deviceCountsView = computed(() => (pointData.value ? pointDeviceCountsView(pointData.value) : null))
 const cards = computed(() => (pointData.value ? deviceCards(pointData.value) : []))
 const deviceCard = computed(() => deviceCardOf(pointData.value, selection.value.deviceCode))
-const deviceReason = computed(() => (deviceCard.value ? deviceCard.value.reason : ''))
+
+/**
+ * 票 115:设备级头部的身份/状态**不依赖点位详情**。
+ * 未关联设备没有点位卡片(pointData 为空),旧实现因此把名称/状态都画成「—」;
+ * 这里改为:名称优先取设备档案,状态优先取点位设备卡,未关联设备退到快照设备对象。
+ */
+const snapshotEquipment = computed(() => {
+    const objects = (mapStore.summary && mapStore.summary.objects) || []
+    return objects.find((item) => item && item.kind === 'unassociated-equipment' && item.code === selection.value.deviceCode) || null
+})
+const aggregateArchive = computed(() => {
+    const aggregate = sections.value.equipmentAggregate
+    if (!equipmentDetailMatches(aggregate, selection.value.deviceCode)) return null
+    return (aggregate.data && aggregate.data.sections && aggregate.data.sections.archive && aggregate.data.sections.archive.data) || null
+})
+const headerDeviceName = computed(() => aggregateArchive.value?.name || deviceCard.value?.name || snapshotEquipment.value?.name || '—')
+const headerStatus = computed(() => {
+    if (deviceCard.value) return deviceCard.value.status
+    const status = snapshotEquipment.value?.status
+    return status ? deviceVisualOf(status) : null
+})
 const title = computed(() => (level.value === LEVEL.EQUIPMENT ? '设备详情' : '点位摘要'))
 
 /**
@@ -601,6 +616,12 @@ const aggregateDown = computed(() => aggregateUnavailable(sections.value.equipme
     font-size: var(--fs-caption, 10.5px);
 }
 
+/* 票 115 优化:设备详情的状态卡与点位摘要同款,单独成行。 */
+.point-detail__status-card--device {
+    margin-top: calc(3 * var(--u, 1px));
+    padding: calc(5 * var(--u, 1px)) var(--space-2, 8px);
+}
+
 .point-detail__kpis {
     display: grid;
     grid-template-columns: repeat(auto-fit, minmax(calc(56 * var(--u, 1px)), 1fr));

+ 3 - 3
src/modules/cockpit/domain/detailFlow.js

@@ -98,7 +98,7 @@ export function isSamePointTarget(selection, pointCode) {
 }
 
 /**
- * 后端状态 + 后端标签 → 标签视图(**监测点**用;标签优先用后端给的词)。
+ * 后端点位状态 → 正式中文标签(labelOverride 仅兼容保留,不覆盖词表)。
  *
  * 注意与 `deviceStatusView()` 的分工:本函数读的是**点位**状态词表,
  * 给设备配色必须走 `deviceStatusView()` —— 拿点位表给设备配色是决策票 07/10 点名的越界。
@@ -112,7 +112,7 @@ export function statusView(status, labelOverride = '') {
         key: visual.key,
         tone: visual.tone,
         shape: visual.shape,
-        label: labelOverride || visual.label,
+        label: visual.label,
         icon: legendIconFor(visual.key),
     }
 }
@@ -134,7 +134,7 @@ export function deviceStatusView(status, labelOverride = '') {
         key: visual.key,
         tone: visual.tone,
         shape: visual.shape,
-        label: labelOverride || visual.label,
+        label: visual.label,
         icon: legendIconFor(visual.key),
     }
 }

+ 63 - 28
src/modules/cockpit/domain/entityDetail.js

@@ -4,7 +4,7 @@
  * 列表只传 { kind, id };本模块负责身份校验、不同后端载荷的正规化和紧凑详情视图。
  * point/device 沿用既有详情链,不在此重复实现。
  */
-import { eventLevelFromBucket, eventLevelTag, eventStatusText } from './eventList.js'
+import { eventLevelFromBucket, eventLevelTag, eventStatusText, eventTypeText, eventNameText } from './eventList.js'
 import { DEFAULT_TONE } from './statusTones.js'
 import { toWorkOrder, toWorkOrderTimeline, workOrderStateName } from './workOrder.js'
 import { trackOf } from './workOrderTimeline.js'
@@ -72,7 +72,11 @@ export function normalizeEntityDetail(identityInput, raw, prefill = null) {
     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 : {}
+    const publishedCodeKey = identity.kind === ENTITY_DETAIL_KIND.EVENT && source && source.codeText === undefined
+        ? ['code', 'eventCode', 'warningNo', 'alarmNo'].find((key) => Object.hasOwn(source, key)) : ''
+    const publishedCode = publishedCodeKey ? text(source[publishedCodeKey]) : undefined
     source = source && typeof source === 'object' ? { ...seed, ...source } : { ...seed }
+    if (publishedCode !== undefined) source.codeText = publishedCode
     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)
@@ -85,9 +89,9 @@ export function prefilledEntityDetail(identity, prefill) {
 export function entityDetailView(detail) {
     const current = detail || {}
     return {
-        title: current.title || current.name || current.id || '实体详情',
-        // 副标题用业务编号(不是主键):主键对使用者没有意义。
-        subtitle: current.subtitle || [current.kindLabel, current.displayCode || current.id].filter(Boolean).join(' · '),
+        title: current.title || current.name || '实体详情',
+        // 副标题仅用业务编号;缺码不回退主键。
+        subtitle: current.subtitle || [current.kindLabel, current.displayCode || '编码未提供'].filter(Boolean).join(' · '),
         tone: current.tone || 'neutral',
         status: current.statusText || '',
         // 等级与状态各有自己的语义色:等级沿用卡片那张四级词表,不与状态色混用。
@@ -113,15 +117,16 @@ 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
+    const displayCode = text(source.code)
+    const name = text(source.name || source.facilityName || source.resourceName || source.title) || (emergency ? '应急对象名称未提供' : '设施名称未提供')
     // 状态是码值时翻成中文(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 statusRaw = source.status ?? source.state ?? source.statusName ?? source.stateName
+    const status = (category === 'PROTECTION_TARGET' ? riskLevelText(statusRaw) : emergency ? undefinedEnumText(statusRaw) : facilityStatusText(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,
+        ...identity, category, name, title: name, displayCode,
+        kindLabel: emergency ? '应急对象' : '设施', statusText: status, tone: emergency ? 'neutral' : facilityStatusTone(source.status), raw: source,
         categoryText: facilityCategoryText(category),
         coordinates: source.coordinates || null,
         summary: assetSummary(category, attributes, status, location),
@@ -130,7 +135,7 @@ function normalizeAsset(identity, source) {
         devices: assetDevices(attributes),
         attachments: assetAttachments(attributes),
         sections: compactSections([
-            section('基本信息', [field('设施编码', source.code || identity.id), field('设施类别', facilityCategoryText(category)), field('运行状态', status), field('数据更新时间', source.updatedAt)]),
+            section('基本信息', [field('设施编码', displayCode || '未提供'), field('设施类别', facilityCategoryText(category)), field('运行状态', status), field('数据更新时间', source.updatedAt)]),
             section('位置与归属', [field('位置描述', location, true), field('所属片区', source.districtName), field('坐标', coordinatesText(source.coordinates), true)]),
             section('业务详情', fields),
         ]),
@@ -225,7 +230,7 @@ function assetSummary(category, a, status, location) {
     const labels = { ...SUMMARY_LABELS, ...SUMMARY_LABELS_BY_CATEGORY[category] }
     const rows = (byCategory[category] || []).map((key) => {
         const unit = unitOf(category, key)
-        const raw = a[key]
+        const raw = assetFieldValue(key, a)
         // 只有真有单位时才拼单位;纯文本字段(材质/负责人…)原样。
         const value = unit ? withUnitText(raw, unit) : raw
         return [labels[key] || SUMMARY_LABELS[key] || key, value]
@@ -236,10 +241,10 @@ function assetPeople(a) { return peopleItems([['负责人', a.principal], ['维
 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 || '设备',
+        id: item.id || '', code: text(item.code), name: item.name || item.code || '设备名称未提供',
         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: '状态未发布' }))
+    return String(a.equipmentIds || a.equipmentId || '').split(',').map((id) => id.trim()).filter(Boolean).map((id) => ({ id, name: '设备名称未提供', code: '', type: '', location: '', status: '状态未发布' }))
 }
 function deviceStatusText(item) {
     if (item.onlineStatus === 0) return '离线'
@@ -275,12 +280,12 @@ const FACILITY_CATEGORY_TEXT = {
     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
+    const label = dictionary && Object.hasOwn(dictionary, code) ? dictionary[code] : ''
+    if (!label) return /\p{Script=Han}/u.test(code) && !/\p{Script=Latin}/u.test(code) ? code : '未知'
     return withCode ? `${label}(${code})` : label
 }
 
@@ -415,11 +420,35 @@ function withUnitText(value, unit) {
     return unit ? `${value} ${unit}` : String(value)
 }
 
+function undefinedEnumText(raw) {
+    const value = text(raw)
+    if (!value || /\p{Script=Han}/u.test(value) && !/\p{Script=Latin}/u.test(value)) return value
+    return /^[+-]?\d+(?:\.\d+)?$/.test(value) ? `未定义(原值:${value})` : '未知'
+}
+
+/** RiskAssessmentServiceImpl 发布 high/medium/low;历史数字码无定义,不套易积水点字典。 */
+function riskLevelText(raw) {
+    const value = text(raw)
+    const names = { high: '高风险', medium: '中风险', low: '低风险' }
+    return Object.hasOwn(names, value) ? names[value] : undefinedEnumText(raw)
+}
+
+function assetFieldValue(key, a) {
+    if (key === 'equipmentIds' || key === 'equipmentId') {
+        const codes = Array.isArray(a.devices) ? a.devices.map((item) => text(item.code)).filter(Boolean) : []
+        return codes.join('、') || '未提供'
+    }
+    if (['networkId', 'workOrderId', 'equipmentTypeId'].includes(key)) return '未提供'
+    if (key === 'riskLevel') return riskLevelText(a[key])
+    if (key === 'riskColor') return undefinedEnumText(a[key])
+    return a[key]
+}
+
 function assetFields(category, a) {
     const rows = category === 'PIPE_NETWORK' ? pipesFields(a) : ASSET_FIELD_ROWS[category] || []
     return rows.map((row) => {
         // 单位一律由 `unitOf(category, key)` 给:同一字段名在不同设施里量纲可能不同。
-        const raw = row.format ? row.format(a[row.key]) : withUnitText(a[row.key], unitOf(category, row.key))
+        const raw = row.format ? row.format(a[row.key]) : withUnitText(assetFieldValue(row.key, a), unitOf(category, row.key))
         // always 行(如非排水管的二级分类)保留占位;其余缺值由 compactSections 剔除。
         const item = field(row.label, raw, row.wide === true)
         if (row.always) {
@@ -475,6 +504,11 @@ function eventLevelFrom(source) {
     return eventLevelTag(fromBucket)
 }
 
+/** BusinessCockpitEventAdapter.alarmStatus:0→UNHANDLED,1→HANDLED;核验态不参与。 */
+function mobileAlarmStatus(value) {
+    return typeof value === 'number' ? value === 0 ? 'UNHANDLED' : value === 1 ? 'HANDLED' : 'UNKNOWN' : value
+}
+
 function normalizeEvent(identity, source) {
     const envelope = source && source.basicInfo ? source : {}
     const basic = envelope.basicInfo && typeof envelope.basicInfo === 'object' ? envelope.basicInfo : source
@@ -486,10 +520,11 @@ function normalizeEvent(identity, source) {
     }
     const family = String(identity.family || source.family || source.eventFamily || (source.alarmId || source.alarmNo ? 'ALARM' : source.warningId || source.warningNo ? 'WARNING' : '')).toUpperCase()
     const level = eventLevelFrom(source)
-    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
-    // 展示用的事件编码一律取**业务编号**(warningNo / alarmNo),主键只用于取数。
-    const displayCode = text(source.codeText || source.code || source.warningNo || source.alarmNo || source.eventCode) || identity.id
+    const status = eventStatusText(source.status || source.state || mobileAlarmStatus(source.alarmStatus) || source.statusName || source.stateName || source.statusText, family)
+    const name = eventNameText(source.name || source.warningName || source.alarmName || source.eventName || source.deviceName || source.title) || '事件名称未提供'
+    // 列表 prefill 的 code 是旧内部请求键,只有 codeText 才是业务码;缺码不回退身份。
+    const listCode = source.codeText === '编码未提供' ? '' : source.codeText
+    const displayCode = text(source.codeText !== undefined ? listCode : source.code || source.eventCode || source.warningNo || source.alarmNo)
     return {
         ...identity,
         name,
@@ -516,9 +551,9 @@ function normalizeEvent(identity, source) {
         records: recordItems(source.disposalHistory || source.logs),
         sections: compactSections([
             // 等级只出现在摘要卡片那一处(带色),这里不再重复一遍纯文字等级。
-            section('事件摘要', [field('事件编码', displayCode), field('状态', status), field('发生时间', source.eventTime || source.createTime)]),
+            section('事件摘要', [field('事件编码', displayCode || '未提供'), field('状态', status), field('发生时间', source.eventTime || source.createTime)]),
             section('监测读数', [
-                field('指标', source.type || source.warningType || source.warningCode),
+                field('指标', eventTypeText(source.type || source.warningType || source.warningCode, source.typeName || source.warningTypeName)),
                 // 读数与阈值都带单位(维护者 2026-10-29:数值型指标必须有单位提示)。
                 field('实际值', withUnit(source.value ?? source.actualValue, source.unit || source.warningUnit)),
                 field('阈值', threshold(source)),
@@ -538,7 +573,6 @@ function normalizeEvent(identity, source) {
 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
@@ -558,8 +592,9 @@ function normalizeWorkOrder(identity, source) {
     const stateText = order.stateName || workOrderStateName(order.stateCode)
     return {
         ...identity,
-        name: order.code || identity.id,
-        title: order.orderTypeName || order.code || identity.id,
+        name: order.code || '工单名称未提供',
+        title: order.orderTypeName || order.code || '工单名称未提供',
+        displayCode: order.code,
         kindLabel: '工单',
         statusText: stateText,
         tone: workOrderTone(order.stateCode),
@@ -574,7 +609,7 @@ function normalizeWorkOrder(identity, source) {
         attachments: attachmentItems([raw.maintenanceBeforeImg, raw.maintenanceAfterImg, ...timelineAttachments(timelineRaw)]),
         records: recordItems(timelineRaw),
         sections: compactSections([
-            section('工单摘要', [field('工单号', order.code), field('类型', order.orderTypeName), field('优先级', orderLevelText(order.orderLevel)), field('状态', stateText)]),
+            section('工单摘要', [field('工单号', order.code || '未提供'), field('类型', order.orderTypeName), field('优先级', orderLevelText(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)]),
@@ -591,7 +626,7 @@ function orderLevelText(value) {
 }
 
 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 relatedAlarm(item) { return { id: item.alarmId || item.id || '', code: text(item.code || item.alarmNo || item.alarmCode), name: item.alarmName || item.content || item.deviceName || '关联报警', status: eventStatusText(mobileAlarmStatus(item.alarmStatus) || item.status || item.auditStatusName, 'ALARM') } }
 function summaryItems(entries) { return entries.filter(([,value]) => value !== null && value !== undefined && String(value).trim() !== '').map(([label,value,tone]) => ({ label, value: String(value), tone: tone || '' })) }
 function peopleItems(entries) { return entries.filter(([,name]) => name).map(([role,name]) => ({ role, name: String(name) })) }
 function deviceItems(source) {

+ 10 - 0
src/modules/cockpit/domain/equipmentDetail.js

@@ -110,6 +110,16 @@ export function toMetricsPayload(raw) {
     }
 }
 
+/** 最近采集记录:只消费后端recordView,不从旧指标或趋势补造历史。 */
+export function toCollectionRecordsPayload(raw) {
+    return {
+        deviceCode: typeof raw?.deviceCode === 'string' ? raw.deviceCode : '',
+        state: typeof raw?.state === 'string' ? raw.state : '',
+        stateReason: typeof raw?.stateReason === 'string' ? raw.stateReason : '',
+        recordView: raw?.recordView || null,
+    }
+}
+
 /**
  * 把一个后端分区翻成前端分区。
  * `data` 的转换按分区名分派;`null` 数据(失败/空)不调用转换函数。

+ 162 - 55
src/modules/cockpit/domain/equipmentDetailView.js

@@ -17,8 +17,7 @@
  *   · **只渲染六块面板**(票 11 正文点名的档案 / 指标 / 预警 / 报警 / 工单 / 图片)。
  *     聚合契约里的 `alarmWarningRelations` 属票 13(预警-报警关联)——它仍在 store 的八个分区里,
  *     只是不在本票的渲染计划内;
- *   · 事件只如实列出等级号/状态/时间/量值,**不在这里定事件的四级配色与标签词**:
- *     那是票 13/19 的视觉语言(`domain/statusTones.js` 的事件四级),本票不预支。
+ *   · 事件的等级色复用既有四级词表;与数值的预警黄/报警红状态色分别表达。
  *
  * 票 12 接手的**唯一一处**:`timelines` 分区(票 11 当时写明「归票 12」)。
  * 它不新增第七块面板 —— 状态轨迹是**工单状态面板里每张工单自己的**一条线
@@ -35,7 +34,124 @@ import { TIME_LABELS } from './fields.js'
 import { SECTION_STATE, section } from './sectionState.js'
 import { WORK_ORDER_STATES, workOrderStateName } from './workOrder.js'
 import { tracksByOrder } from './workOrderTimeline.js'
-import { eventLevelTag, shortEventTime } from './eventList.js'
+import { eventLevelTag, shortEventTime, eventStatusText, eventTypeText, eventNameText } from './eventList.js'
+import { statusVocabulary } from './eventStatusVocab.js'
+import { pagedRows } from './pagination.js'
+import { detailEventLevel, collectionCellAlert, collectionGroupAccent } from './equipmentDetailColors.js'
+
+const RECORD_TEXT = {
+    COD: '化学需氧量(COD)', PH: '酸碱度(pH)', pH: '酸碱度(pH)',
+    create_time: '采集时间', indicator: '监测因子', item: '监测因子', value: '监测值', filepath: '音频记录',
+}
+function recordText(value, fallback = '监测值') {
+    const text = valueText(value)
+    if (text === DETAIL_PLACEHOLDER) return text
+    return Object.hasOwn(RECORD_TEXT, text) ? RECORD_TEXT[text] : /[\u3400-\u9fff]/u.test(text) ? text : fallback
+}
+function recordIndicatorText(value) {
+    return valueText(value).replace(/^(COD|PH|pH)(?=[::]|$)/u, (name) => RECORD_TEXT[name])
+}
+
+/**
+ * 该列装的是**读数**还是**标签**。
+ *
+ * wide 布局:一列一个指标(列名即指标名,格内就是读数)。
+ * long 布局:只有一列 indicator,格内是「指标名:读数」的整条 —— 它仍是读数,
+ * 不是说明文字;此前按列名一律当标签,long 设备的指标字色/字号就与其它设备脱节。
+ */
+function isReadingColumn(column, layout) {
+    if (column.isTime || column.isAudio) return false
+    if (!['indicator', 'item'].includes(column.key)) return true
+    return layout === 'long'
+}
+
+/**
+ * 「指标名:读数」→ 名称与读数两段(只拆第一处冒号)。
+ * 读数段供渲染层套等宽数字字体,名称段保留正文样式。
+ */
+function splitIndicatorReading(text) {
+    const source = String(text ?? '')
+    const match = source.match(/^(.+?)([::])(.+)$/)
+    if (!match) return { label: '', value: source }
+    return { label: match[1].trim(), value: match[3].trim() }
+}
+
+/** 采集记录时间 → `yyyy-mm-dd hh:mm:ss`(不展示时区/毫秒;后端可能给 ISO 或空格分隔)。 */
+export function formatRecordTime(value) {
+    const raw = typeof value === 'string' ? value.trim() : ''
+    if (!raw) return DETAIL_PLACEHOLDER
+    const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/)
+    if (match) return `${match[1]}-${match[2]}-${match[3]} ${match[4]}:${match[5]}:${match[6]}`
+    const date = new Date(raw)
+    if (Number.isNaN(date.getTime())) return DETAIL_PLACEHOLDER
+    const pad = (number) => String(number).padStart(2, '0')
+    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
+}
+
+/** 后端recordView的列/行,先每页10行再重算相邻相同create_time的td跨度。 */
+export function collectionRecordTable(data, page = 1) {
+    const view = data?.recordView || {}
+    const rawColumns = (Array.isArray(view.columns) ? view.columns : []).map((column) => ({
+        key: column.key,
+        label: column.key === 'create_time' ? '采集时间' : recordText(column.label, RECORD_TEXT[column.key] || '监测值'),
+        unit: column.unit || '',
+        isTime: column.key === 'create_time',
+        isAudio: column.key === 'filepath',
+    }))
+    const columns = [...rawColumns.filter((column) => column.isTime), ...rawColumns.filter((column) => !column.isTime)]
+    // 先映射全部50行,才能在同次采集跨页时沿用整组最高严重性。
+    const allRows = (Array.isArray(view.rows) ? view.rows : []).slice(0, 50).map((row, index) => {
+        const createTime = formatRecordTime(row.createTime)
+        return {
+            createTime,
+            createDate: createTime === DETAIL_PLACEHOLDER ? DETAIL_PLACEHOLDER : createTime.slice(0, 10),
+            createClock: createTime === DETAIL_PLACEHOLDER ? '' : createTime.slice(11, 19),
+            isLatest: index === 0,
+            // 分组保留原始精度:显示到秒不代表毫秒不同的两次采集可以合并。
+            timeKey: createTime === DETAIL_PLACEHOLDER ? '' : row.createTime.trim(),
+            timeRowspan: 1,
+            cells: columns.map((column) => {
+                const cell = (Array.isArray(row.cells) ? row.cells : []).find((item) => item.key === column.key)
+                const value = view.layout === 'long' ? cell?.display ?? cell?.value
+                    : cell?.value !== null && cell?.value !== undefined && cell?.value !== '' ? cell.value : cell?.display
+                const indicator = ['indicator', 'item'].includes(column.key)
+                const text = column.isTime ? createTime : indicator ? recordIndicatorText(value) : valueText(value)
+                const reading = view.layout === 'long' && indicator ? splitIndicatorReading(text) : null
+                // 分离列布局中的纯名称不是数值,只有「名称:读数」组合才需要分栏。
+                const isReading = isReadingColumn(column, view.layout) && (!indicator || Boolean(reading?.label))
+                return {
+                    key: column.key, text, isReading, reading,
+                    alarm: cell?.alarm === true,
+                    alert: isReading ? collectionCellAlert(cell) : null,
+                    audioPath: column.isAudio && text !== DETAIL_PLACEHOLDER ? text : '',
+                    audioLabel: column.isAudio ? column.label : '',
+                }
+            }),
+        }
+    })
+    // 相邻同时间组成同次采集,不重排、不合并缺失时间;每组只绘制一次横向渐变。
+    for (let start = 0; start < allRows.length;) {
+        let end = start + 1
+        while (allRows[start].timeKey && end < allRows.length && allRows[end].timeKey === allRows[start].timeKey) end += 1
+        const accent = collectionGroupAccent(allRows.slice(start, end))
+        for (let i = start; i < end; i += 1) Object.assign(allRows[i], { groupKey: start, latestGroup: start === 0, accent })
+        start = end
+    }
+    const window = pagedRows(allRows, page)
+    const rows = window.rows
+    const groups = []
+    for (let start = 0; start < rows.length;) {
+        let end = start + 1
+        while (end < rows.length && rows[end].groupKey === rows[start].groupKey) end += 1
+        rows[start].timeRowspan = end - start
+        for (let i = start + 1; i < end; i += 1) rows[i].timeRowspan = 0
+        groups.push({ key: rows[start].groupKey, rows: rows.slice(start, end), isLatest: rows[start].latestGroup, ...rows[start].accent })
+        start = end
+    }
+    return { ...window, rows, columns, groups,
+        assessmentNote: '采集接口暂未提供阈值、预警判定与等级;当前仅按已返回的报警标记着色,绿色为最新记录默认强调。',
+    }
+}
 
 /**
  * 六块面板的渲染计划,顺序即界面顺序(与票 11 正文的点名顺序一致)。
@@ -55,7 +171,7 @@ export const EQUIPMENT_SECTION_PANELS = [
     { name: 'warnings', label: '预警信息', kind: 'events' },
     { name: 'alarms', label: '报警信息', kind: 'events' },
     { name: 'workOrders', label: '工单状态', kind: 'orders' },
-    { name: 'latestImage', label: '最新图片', kind: 'image', mergedInto: 'archive' },
+    { name: 'latestImage', label: '设备图片', kind: 'image', mergedInto: 'archive' },
 ]
 
 /**
@@ -65,7 +181,7 @@ export const EQUIPMENT_SECTION_PANELS = [
  * 设备档案(默认,图片归此页)|最新指标|预警信息|报警信息|工单状态。
  * 这里给的是**页签清单**(顺序即界面顺序):`name` 是页签自己的稳定键(events 页签跨
  * 预警/报警两个分区,所以不复用分区名),`sections` 是它背后要渲染的分区名。
- * 「最新图片」不在页签清单里 —— 它作为 `archive` 页签的一部分渲染(`mergedInto`)。
+ * 「设备图片」不在页签清单里 —— 它作为 `archive` 页签的一部分渲染(`mergedInto`)。
  */
 export const EQUIPMENT_TABS = [
     { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },
@@ -207,48 +323,9 @@ export function archiveRows(data) {
         { key: 'maintainer', label: '维护单位', value: valueText(archive.maintainer) },
         { key: 'maintainerPhone', label: '维护电话', value: valueText(archive.maintainerPhone) },
         { key: 'installTime', label: '安装时间', value: valueText(archive.installTime) },
-        { key: 'remark', label: '备注', value: valueText(archive.remark) },
     ]
 }
 
-/** 指标列表 → 行视图。缺 `value` 的行显示占位符,真 0 照原样。 */
-export function metricRows(data) {
-    const list = (data && data.metrics) || []
-
-    return list.map((metric) => ({
-        code: metric.code || '',
-        name: metric.name || metric.code || DETAIL_PLACEHOLDER,
-        value: valueText(metric.value),
-        unit: metric.unit || '',
-        status: metric.status || '',
-        freshnessLabel: metric.freshness === 'STALE' ? '已过期' : '',
-        sampledAt: metric.sampledAt || '',
-        // 兼容旧契约;源记录 create_time 不冒充设备采样时刻。
-        sampledAtLabel: metric.timeSource === 'CREATED_AT' ? TIME_LABELS.ingested : TIME_LABELS.sampled,
-        missing: metric.missing || [],
-    }))
-}
-
-/**
- * 后端 `stateReason` → 给人读的说明。
- *
- * **绝不把代号甩给值班人员**(票 05 的真机走查逮到过一次「界面漏出内部原因码」,
- * 那是本仓已经付过学费的教训):认得出就翻成人话,认不出**回空串**——
- * 宁可什么都不说,也不把 `SAMPLE_OUTDATED` 这类接口词汇画在大屏上。
- * 「过期」这件事本身已由分区状态标签说明,这里只是补充"为什么"。
- */
-export function metricsSourceText(data) {
-    const dictionary = {
-        READY: '',
-        SAMPLE_OUTDATED: '采样已超过配置周期',
-    }
-    const reason = (data && data.stateReason) || ''
-    if (reason === 'SAMPLE_OUTDATED' && (data.metrics || []).some((metric) => metric.timeSource === 'CREATED_AT')) {
-        return '指标记录已超过配置周期'
-    }
-    return dictionary[reason] || ''
-}
-
 /**
  * 记录一次「后端没给这些字段」的缺失。
  *
@@ -281,14 +358,20 @@ export function eventRows(data) {
     const list = Array.isArray(data) ? data : []
 
     return list.map((event) => {
-        const levelNumber = typeof event.level === 'number' && Number.isInteger(event.level) ? event.level : null
+        const level = detailEventLevel(event)
+        const levelNumber = level.level
         const levelTag = eventLevelTag(levelNumber)
+        const eventStatus = String(event.status || '').trim()
+        const statusEntry = Object.entries(statusVocabulary(event.family)).find(([code, meta]) =>
+            code === eventStatus.toUpperCase() || meta.label === eventStatus)
         return {
             code: event.code || DETAIL_PLACEHOLDER,
-            name: event.name || DETAIL_PLACEHOLDER,
-            levelText: typeof event.level === 'number' && Number.isFinite(event.level) ? `${event.level} 级` : DETAIL_PLACEHOLDER,
+            codeText: event.codeText || '编码未提供',
+            name: eventNameText(event.name) || DETAIL_PLACEHOLDER,
+            levelText: levelNumber ? `${levelNumber} 级` : '未分级',
+            activeColor: level.color,
             levelColor: event.levelColor || '',
-            status: event.status || '',
+            status: eventStatusText(event.status, event.family),
             eventTime: event.eventTime || '',
             quantity: quantityText(event.value, event.unit),
             location: event.location || '',
@@ -296,9 +379,12 @@ export function eventRows(data) {
             categoryKey: levelNumber === null ? 'level:unknown' : `level:${levelNumber}`,
             categoryLabel: levelNumber === null ? '等级未知' : levelTag.label,
             timeText: shortEventTime(event.eventTime),
-            type: event.type || '',
+            type: eventTypeText(event.type),
             deviceCode: event.deviceCode || '',
             deviceName: event.deviceName || '',
+            // 票 115 优化:未到终态的事件(预警未解除 / 报警未处理)在列表里突出并默认展开。
+            active: Boolean(statusEntry) && !statusEntry[1].terminal,
+            activeLabel: String(event.family || '').toUpperCase() === 'ALARM' ? '未处理' : '未解除',
         }
     })
 }
@@ -315,14 +401,15 @@ export function workOrderRows(data) {
     const list = Array.isArray(data) ? data : []
 
     return list.map((order) => {
-        const known = WORK_ORDER_STATES.some((item) => item.code === order.stateCode)
+        const state = WORK_ORDER_STATES.find((item) => item.code === order.stateCode)
+        const known = Boolean(state)
         return {
             id: order.id || '',
             code: order.code || DETAIL_PLACEHOLDER,
             stateCode: typeof order.stateCode === 'number' ? String(order.stateCode) : '',
             // 状态路径需要**数字**码(字符串比较会把 '10' 排在 '9' 前面),所以另存一份。
             stateCodeNumber: typeof order.stateCode === 'number' ? order.stateCode : null,
-            stateName: order.stateName || workOrderStateName(order.stateCode) || DETAIL_PLACEHOLDER,
+            stateName: workOrderStateName(order.stateCode) || (/[\u3400-\u9fff]/u.test(order.stateName || '') ? order.stateName : '状态未知'),
             typeName: order.orderTypeName || '',
             description: order.description || '',
             associationText: order.associatedWithCurrentEvent === true ? '已关联当前事件'
@@ -335,12 +422,16 @@ export function workOrderRows(data) {
             categoryLabel: known ? workOrderStateName(order.stateCode) : '状态未知',
             timeText: shortEventTime(order.createTime),
             source: order.orderTypeName || '',
+            // 票 115 优化:未进入终态(已结案/已取消之外)的工单突出并默认展开。
+            active: known && !state.terminal,
+            activeLabel: '未结案',
+            activeColor: 'var(--cyan-hi, #00b8ff)',
         }
     })
 }
 
 /**
- * 图片 → **单张**视图(票 11 验收第 5 条「最新图片只展示后端指定的一张」)。
+ * 图片 → **单张**视图(票 11 验收第 5 条「后端指定的一张」;分区标题在票 115 改为「设备图片」)。
  *
  * 这里刻意只认一个 `url` 单值:前端不索引数组、不 `find`、不挑"第一张"——
  * 挑图就是前端替后端做业务判断,而且"挑错了"没人看得出来。后端给几张是后端的事,
@@ -396,6 +487,16 @@ export function timelineSource(aggregate) {
     }
 }
 
+/** 清单视图把未解除/未结案项置顶;组内顺序保持调用方原样。 */
+export function prioritizeActiveRows(rows) {
+    const active = []
+    const other = []
+    for (const row of Array.isArray(rows) ? rows : []) {
+        (row?.active === true ? active : other).push(row)
+    }
+    return [...active, ...other]
+}
+
 /** 分区的空列表提示(后端说 SUCCESS 但列表是空的,仍是"明确 UI"而不是一片空白)。 */
 export function emptyListText(label) {
     return `${label}当前没有记录`
@@ -479,7 +580,7 @@ export function eventDetailRows(row) {
         detailRow('状态', current.status, true),
         detailRow('来源', current.type, true),
         detailRow('关联设备', current.deviceName || current.deviceCode, true),
-        detailRow('编码', current.code),
+        detailRow('编码', current.codeText || '编码未提供'),
         detailRow('等级', current.levelText),
         detailRow('量值', current.quantity),
         detailRow('位置', current.location),
@@ -556,6 +657,11 @@ function panelSection(sections, name) {
     return found || section(SECTION_STATE.FAILED)
 }
 
+/** 指标页的单一分区入口:新记录状态独立于仍保留给旧API的metrics。 */
+export function collectionRecordsSection(metrics) {
+    return metrics?.collectionRecords || section(SECTION_STATE.UNAVAILABLE, { reason: '未提供采集记录数据源' })
+}
+
 /**
  * 聚合分区 → 六块面板的渲染计划(组件只需遍历它)。
  *
@@ -568,7 +674,8 @@ export function equipmentPanels(aggregate) {
     const sections = (data && data.sections) || {}
 
     return EQUIPMENT_SECTION_PANELS.map((panel) => {
-        const value = panelSection(sections, panel.name)
+        const original = panelSection(sections, panel.name)
+        const value = panel.name === 'metrics' ? collectionRecordsSection(original) : original
         return {
             name: panel.name,
             label: panel.label,
@@ -576,7 +683,7 @@ export function equipmentPanels(aggregate) {
             section: value,
             hasContent: sectionHasContent(value),
             missing: missingNote(value.data),
-            placeholder: sectionPlaceholder(value),
+            placeholder: panel.name === 'metrics' && value.state === SECTION_STATE.EMPTY ? '暂无采集记录' : sectionPlaceholder(value),
         }
     })
 }

+ 5 - 5
src/modules/cockpit/domain/eventDetail.js

@@ -2,7 +2,7 @@
  * 事件详情与定位计划(票 14)。纯函数:不发请求、不碰 DOM 或地图服务。
  * 事件详情保留列表行的领域字段;定位只认后端 coordinateValidation.renderable。
  */
-import { eventLevelTag, eventStatusText } from './eventList.js'
+import { eventLevelTag, eventStatusText, eventTypeText } from './eventList.js'
 import { TIME_LABELS } from './fields.js'
 import { LOCATE_RESULT, LOCATE_TEXT, findMapObject, locateCameraTarget } from './mapLocate.js'
 import { blockReason } from './coordinate.js'
@@ -74,11 +74,11 @@ export function eventDetailView(event) {
     const current = event || {}
     const level = levelOf(current)
     return {
-        code: current.code || PLACEHOLDER,
+        code: current.codeText || '编码未提供',
         name: current.name || PLACEHOLDER,
         deviceCode: current.deviceCode || PLACEHOLDER,
         deviceName: current.deviceName || PLACEHOLDER,
-        metric: current.type || PLACEHOLDER,
+        metric: eventTypeText(current.type, current.typeName) || '未提供',
         threshold: thresholdText(current.thresholdMinValue, current.thresholdMaxValue, current.unit),
         value: valueText(current.value, current.unit),
         level,
@@ -94,12 +94,12 @@ export function eventDetailView(event) {
         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.codeText || '编码未提供', mono: true },
             { label: '设备', value: current.deviceName || current.deviceCode || PLACEHOLDER },
             { label: '监测点', value: current.monitoringPointName || current.monitoringPointCode || PLACEHOLDER },
         ],
         readingItems: [
-            { label: '指标', value: current.type || PLACEHOLDER },
+            { label: '指标', value: eventTypeText(current.type, current.typeName) || '未提供' },
             { 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 },

+ 54 - 22
src/modules/cockpit/domain/eventList.js

@@ -34,6 +34,7 @@
 import { fieldReader, toLocation, TIME_LABELS } from './fields.js'
 import { SECTION_STATE, section, sectionStateFromBackend } from './sectionState.js'
 import { EVENT_FAMILY, LEVEL_COLORS } from './events.js'
+import { statusText as familyStatusText } from './eventStatusVocab.js'
 import { LEVEL_TAGS, DEFAULT_TONE } from './statusTones.js'
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
 import { workOrderStateName } from './workOrder.js'
@@ -98,10 +99,10 @@ export const EVENT_TYPE_TEXT = {
     'WARN-WATER-LEVEL': '水位预警',
     // 污水水质(WarningCodeEnum)
     'WARN-SUSPENDED-SOLIDS': '悬浮物预警',
-    'WARN-COD': 'COD预警',
+    'WARN-COD': '化学需氧量(COD)预警',
     'WARN-AMMONIA-NITROGEN': '氨氮预警',
     'WARN-CONDUCTIVITY': '电导率预警',
-    'WARN-PH': 'PH预警',
+    'WARN-PH': '酸碱度(pH)预警',
     // 可燃气体
     'WARN-GAS-CONCENTRATION': '可燃气体浓度预警',
     // 后端枚举自己拼错了(`TEMPERATRUE`),键照抄它的拼法,不然真实数据认不出
@@ -119,10 +120,10 @@ export const EVENT_TYPE_TEXT = {
     'WARN_FLOW_SPEED': '流速预警',
     'WARN_WATER_LEVEL': '水位预警',
     'WARN_SUSPENDED_SOLIDS': '悬浮物预警',
-    'WARN_COD': 'COD预警',
+    'WARN_COD': '化学需氧量(COD)预警',
     'WARN_AMMONIA_NITROGEN': '氨氮预警',
     'WARN_CONDUCTIVITY': '电导率预警',
-    'WARN_PH': 'PH预警',
+    'WARN_PH': '酸碱度(pH)预警',
     // 后端给「事件类型缺失」的显式兜底桶
     UNKNOWN_EVENT_TYPE: '未知事件类型',
 }
@@ -145,8 +146,8 @@ export const SAMPLE_EVENT_TYPE_TEXT = {
     WATER_FLOW: '供水流量预警',
     WATER_CHLORINE: '供水余氯预警',
     WATER_TURBIDITY: '供水浊度预警',
-    SEWAGE_CONCENTRATION: '污水 COD 预警',
-    SEWAGE_PH: '污水 PH 预警',
+    SEWAGE_CONCENTRATION: '污水化学需氧量(COD)预警',
+    SEWAGE_PH: '污水酸碱度(pH)预警',
     SEWAGE_NH3: '污水氨氮预警',
     SEWAGE_TEMP: '污水温度预警',
     DRAINAGE_LEVEL: '排水水位预警',
@@ -163,7 +164,7 @@ const CJK = /[\u3400-\u4dbf\u4e00-\u9fff]/
 /** 后端给的 label 是不是「人话」(含中日韩文字)。不含就是码/空白,不能当展示文本。 */
 function humanLabel(label) {
     const text = typeof label === 'string' ? label.trim() : ''
-    return text && CJK.test(text) ? text : ''
+    return text && CJK.test(text) && !/\p{Script=Latin}/u.test(text) ? text : ''
 }
 
 /**
@@ -186,9 +187,9 @@ export function localizeCodeText(code, label, ...dictionaries) {
         const hit = rawCode && dictionary && dictionary[rawCode]
         if (hit) return hit
     }
-    const human = humanLabel(label)
+    const human = humanLabel(label) || humanLabel(rawCode)
     if (human) return human
-    return rawCode || (typeof label === 'string' ? label.trim() : '')
+    return rawCode || label ? '未知' : ''
 }
 
 /** 事件类型码 → 中文(真实码优先,其次契约样例码;都认不出见 `localizeCodeText` 第 3 条)。 */
@@ -196,6 +197,17 @@ export function eventTypeText(code, label) {
     return localizeCodeText(code, label, EVENT_TYPE_TEXT, SAMPLE_EVENT_TYPE_TEXT)
 }
 
+/** GET 真实名称的精确词表;未登记的业务名称/编号原样保留。 */
+export function eventNameText(value) {
+    const name = typeof value === 'string' ? value.trim() : ''
+    const names = {
+        'COD监测': '化学需氧量(COD)监测', 'PH监测': '酸碱度(pH)监测', 'pH监测': '酸碱度(pH)监测', OFFLINE: '离线报警',
+        '污水 COD 预警': '污水化学需氧量(COD)预警', '污水 PH 预警': '污水酸碱度(pH)预警',
+        '污水 COD 报警': '污水化学需氧量(COD)报警', '污水 PH 报警': '污水酸碱度(pH)报警',
+    }
+    return Object.hasOwn(names, name) ? names[name] : name
+}
+
 /** 成因码 → 中文。 */
 export function causeText(code, label) {
     return localizeCodeText(code, label, CAUSE_TEXT)
@@ -282,10 +294,28 @@ export function isTerminalEventStatus(status) {
     return TERMINAL_STATUS.has(value)
 }
 
-export function eventStatusText(status) {
+/**
+ * 「当前」列表按族排除的状态(维护者 2026-11-05)。
+ *
+ * 预警状态为 DRAFT/PENDING/RECEIVED/PROCESSING/HANDLED/CLOSED 六态,**除草稿与已解除外
+ * 都算预警状态**——已处置(HANDLED)仍需核查,必须出现在当前列表里(否则预警数量对不上,
+ * 维护者实测应为 8)。报警维持原口径:已处理即离开「当前」。
+ * 认不出/缺失的状态按「不能证明已排除」保留。
+ */
+const WARNING_EXCLUDED_FROM_CURRENT = new Set(['DRAFT', 'CLOSED'])
+export function isHiddenFromCurrentList(status, family) {
+    const value = typeof status === 'string' ? status.trim().toUpperCase() : ''
+    if (!value) return false
+    if (String(family || '').trim().toUpperCase() === 'WARNING') return WARNING_EXCLUDED_FROM_CURRENT.has(value)
+    return TERMINAL_STATUS.has(value)
+}
+
+/** 已知family用正式词表;无family兼容旧调用;未知机器码只显示中文占位。 */
+export function eventStatusText(status, family = '') {
+    if (isKnownEventMode(String(family).toUpperCase())) return familyStatusText(family, status)
     const value = typeof status === 'string' ? status.trim() : ''
     if (!value) return ''
-    return EVENT_STATUS_TEXT[value] || value
+    return Object.hasOwn(EVENT_STATUS_TEXT, value.toUpperCase()) ? EVENT_STATUS_TEXT[value.toUpperCase()] : humanLabel(value) || '状态未知'
 }
 
 /**
@@ -324,7 +354,7 @@ export function toEventItem(raw) {
         // 注意报警的 eventNo 是**主键 UUID**,上屏必须用 eventCode(业务编号 alarmNo)
         // —— 维护者 2026-10-29:「报警详情的事件编码应该用 code 而非 id」。
         code: reader.str('eventNo'),
-        codeText: reader.optionalStr('eventCode') || reader.str('eventNo'),
+        codeText: reader.optionalStr('code') || reader.optionalStr('eventCode'),
         name: reader.optionalStr('eventName'),
         type: reader.optionalStr('eventType'),
         level: reader.optionalNum('level'),
@@ -497,14 +527,14 @@ export function eventRowView(eventItem) {
         // 切换后的字段标签(预警/报警各自一套,来自切换视图 —— 与状态标签同步变化)。
         // `code` 是内部键(取数用);`codeText` 是上屏的业务编号(报警为 alarmNo)。
         code: item.code,
-        codeText: item.codeText || item.code || '',
+        codeText: item.codeText || '编码未提供',
         identityCode: item.code || '',
         identityFamily: String(item.family || '').toUpperCase(),
         identityReason: item.code ? '' : '缺少事件编号,无法展开',
-        name: item.name || item.code || DETAIL_PLACEHOLDER,
+        name: eventNameText(item.name) || item.codeText || '事件名称未提供',
         type: item.type || '',
         level: { tone: tag.tone, label: tag.label },
-        statusText: eventStatusText(item.status),
+        statusText: eventStatusText(item.status, item.family),
         // 处理状态也要能按颜色区分(维护者 2026-10-29):色键与文字同源,
         // 认不出的状态落中性灰 + 原码 —— 不猜成"处理中"。
         statusTone: eventStatusTone(item.status),
@@ -583,8 +613,10 @@ export function prepareCurrentEventItems(domain, eventsSection, warningType = ''
     const current = domain || null
     const sectionValue = eventsSection || (current && current.sections && current.sections.events) || null
     const items = (sectionValue && sectionValue.data) || []
-    // 未知状态保留,已了结状态隐藏;类型筛选只对这一批已加载领域行生效。
-    const visibleItems = items.filter((item) => !isTerminalEventStatus(item && item.status))
+    // 未知状态保留;按族排除「已离开当前」的状态(预警:草稿/已解除;报警:原终态口径)。
+    // 类型筛选只对这一批已加载领域行生效。
+    const family = (current && current.mode) || ''
+    const visibleItems = items.filter((item) => !isHiddenFromCurrentList(item && item.status, family))
     const typeCode = typeof warningType === 'string' ? warningType.trim() : ''
     const rows = typeCode ? visibleItems.filter((item) => item && item.type === typeCode) : visibleItems
     return {
@@ -632,10 +664,10 @@ export function currentEventsView(domain, eventsSection, options = {}, prepared)
               label: typeLabel,
               loaded,
               matched: rows.length,
-              // 覆盖范围**必须**随筛选一起出现:这张表只含可解析设备的当前预警(后端 G11 缺口),
-              // 本地筛选只是在这一份已加载集合里做减法,不是全量。
-              // 口径三件套:当前页(服务端分页,筛选不跨页)、只含可解析设备的当前预警、不是全量。
-              note: `已按预警类型「${typeLabel}」本地筛选:命中 ${rows.length} / 当前页已加载 ${loaded} 条;本列表只含可解析设备的当前预警、且筛选不跨页,不代表全部未解除预警(缺口见票 07/G11)`,
+              // 覆盖范围**必须**随筛选一起出现(票 07/G11 的类型缺口):后端 2026-11-05 起
+              // 返回全部当前预警(含未解析设备与已处置),本地类型筛选在这一份集合里做减法,
+              // 分页也在同一份集合上做——命中数与总数自此对得上。
+              note: `已按预警类型「${typeLabel}」本地筛选:命中 ${rows.length} / 当前预警共 ${loaded} 条`,
           }
         : { active: false, code: '', label: '', loaded, matched: loaded, note: '' }
     // 维护者 2026-10-29:**分页必须按「过滤后的可见行」算**。
@@ -672,7 +704,7 @@ export function currentEventsView(domain, eventsSection, options = {}, prepared)
         // 行=**当前页**的可见行(过滤后前端分页);`total` 是可见行总数。
         // 先在领域行上筛选/分页,再格式化本页;翻页不再投影整批事件。
         rows: pageView.rows.map((item) => ({
-            ...eventRowView(item),
+            ...eventRowView({ ...item, family }),
             family,
             identityFamily: family,
         })),

+ 5 - 4
src/modules/cockpit/domain/eventStatusVocab.js

@@ -22,7 +22,7 @@
  * ── 共用的是**形状**,不是词条 ──
  * 两个族的词表都走同一个 `statusVocabulary()` 构造函数(同一套字段:label/tone/terminal/
  * unhandled),所以「怎么画一个状态标签」只有一份实现;而「某个码是什么意思」各族自述。
- * 认不出的码一律**回原码**(数据值照原样显示,不猜 —— 与 `eventList.localizeCodeText` 同一立场)。
+ * 认不出的机器状态显示「状态未知」;协议码仍保留在领域数据中,不编造正常状态。
  */
 
 /** 状态标签的语义色键(沿用 `statusTones.js` 的四档,不新造色)。 */
@@ -86,7 +86,7 @@ export function terminalStatuses(family) {
  *
  * 三条判定顺序(与 `localizeCodeText` 同一条思路):
  *   1. 本族词表命中 → 中文;
- *   2. 认不出 → **回原码**(数据值照原样;空码回空串,不把「没有」画成某个状态)。
+ *   2. 已发布中文保留;未登记机器码显示「状态未知」;空字段留空,不造状态。
  *
  * @param {string} family `WARNING`/`ALARM`
  * @param {string} status 后端状态码
@@ -94,8 +94,9 @@ export function terminalStatuses(family) {
 export function statusText(family, status) {
     const code = typeof status === 'string' ? status.trim() : ''
     if (!code) return ''
-    const meta = statusVocabulary(family)[code]
-    return meta ? meta.label : code
+    const vocabulary = statusVocabulary(family)
+    const meta = Object.hasOwn(vocabulary, code.toUpperCase()) ? vocabulary[code.toUpperCase()] : null
+    return meta ? meta.label : /\p{Script=Han}/u.test(code) && !/\p{Script=Latin}/u.test(code) ? code : '状态未知'
 }
 
 /**

+ 2 - 0
src/modules/cockpit/domain/events.js

@@ -41,6 +41,7 @@ export function toWarningEvent(raw) {
         family: EVENT_FAMILY.WARNING,
         // 统一键(票 13 列表用),保留后端原名以便回溯。
         code: reader.str('warningNo'),
+        codeText: reader.optionalStr('code') || reader.optionalStr('warningNo'),
         sourceCode: 'warningNo',
         name: reader.optionalStr('warningName'),
         type: reader.optionalStr('warningType'),
@@ -72,6 +73,7 @@ export function toAlarmEvent(raw) {
     return {
         family: EVENT_FAMILY.ALARM,
         code: reader.str('alarmId'),
+        codeText: reader.optionalStr('code') || reader.optionalStr('alarmNo') || reader.optionalStr('alarmCode'),
         sourceCode: 'alarmId',
         name: reader.optionalStr('warningName'),
         // 报警的 `warningCode` 是告警规则编码,不是预警事件编码:另存以免和 `code` 混。

+ 4 - 4
src/modules/cockpit/domain/facilityPoints.js

@@ -240,8 +240,8 @@ export function facilityPointMarkers(points) {
                 // 覆盖层节点池的键:用实体主键(现网两座水厂共用编码 WP001,编码做键会互相顶掉)。
                 key: row.key,
                 kind: 'facility-point',
-                code: row.code || row.id,
-                name: row.name || row.code || row.id,
+                code: row.code || '',
+                name: row.name || row.code || '设施名称未提供',
                 type: row.type,
                 symbol: meta.symbol || (FACILITY_DETAIL_CATEGORIES.includes(row.type) ? 'pipeSpecial' : ''),
                 status: row.status,
@@ -417,8 +417,8 @@ export function facilityPointCoordinateExtra(points) {
             // key 用实体主键而不是编码:现网两座水厂共用编码 WP001,编码做键会互相顶掉。
             key: row.key || ('facility-point|' + row.type + '|' + row.id),
             kind: 'facility-point',
-            code: row.code || row.id,
-            name: row.name || row.code || row.id,
+            code: row.code || '',
+            name: row.name || row.code || '设施名称未提供',
             reasonCode: row.reasonCode || '',
             reasonText: row.reasonText || NO_MAP_LOCATION_TEXT,
         })),

+ 2 - 2
src/modules/cockpit/domain/facilitySelection.js

@@ -93,8 +93,8 @@ export function resolveFacilityPoint({ identity: input, points, detail, network
     return {
         key: 'facility-point|' + identity.category + '|' + identity.id,
         id: identity.id, type: identity.category, detailCategory: identity.category,
-        code: String(raw.code || source.code || identity.id),
-        name: current?.name || source.name || String(raw.name || identity.id),
+        code: String(raw.code || source.code || ''),
+        name: current?.name || source.name || raw.name || '设施名称未提供',
         status: String(status), tone: facilityStatusTone(status),
         industry: source.industry || raw.industry || '',
         district: source.district ?? raw.district ?? null,

+ 20 - 4
src/modules/cockpit/domain/modeLayerPlan.js

@@ -107,20 +107,34 @@ function isAttentionEquipment(object, abnormalCodes) {
     return isAttentionPointStatus(object.status) || abnormalCodes.has(object.code)
 }
 
-function visibleMapObjects(mode, objects, abnormalDevices) {
+function visibleMapObjects(mode, objects, abnormalDevices, deviceFilter) {
     const abnormalCodes = abnormalCodeSet(abnormalDevices)
     const source = Array.isArray(objects) ? objects : []
+    // 设备标签筛选:点位只要有任一关联设备命中即显示(监测点是设备聚合点);
+    // 独立设备按自身编码命中。
+    const tagFiltered = deviceFilter == null ? source : source.filter((object) => {
+        if (!object) return false
+        const codes = deviceFilter.codes || new Set()
+        if (object.kind === MAP_OBJECT_KIND.MONITORING_POINT) {
+            const deviceCodes = Array.isArray(object.deviceCodes) ? object.deviceCodes : []
+            // 空白点位(无关联设备)只在勾选「无设备监测点」时显示;其它标签按关联设备命中。
+            if (!deviceCodes.length) return deviceFilter.emptyPoints === true
+            return deviceCodes.some((code) => codes.has(code))
+        }
+        if (object.kind === MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT) return codes.has(object.code)
+        return false
+    })
 
     if (mode === MODE_LAYER.INFRASTRUCTURE) return []
     if (mode === MODE_LAYER.WARNING_RESPONSE) {
-        return source.filter((object) => {
+        return tagFiltered.filter((object) => {
             if (!object) return false
             if (object.kind === MAP_OBJECT_KIND.MONITORING_POINT) return isAttentionPoint(object, abnormalCodes)
             if (object.kind === MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT) return isAttentionEquipment(object, abnormalCodes)
             return false
         })
     }
-    return source.filter((object) => object && (
+    return tagFiltered.filter((object) => object && (
         object.kind === MAP_OBJECT_KIND.MONITORING_POINT ||
         object.kind === MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT
     ))
@@ -201,6 +215,8 @@ export function planModeLayers({
     mode = MODE_LAYER.OVERVIEW,
     objects = [],
     abnormalDevices,
+    /** 命中所选设备标签的设备编码集(null=不筛)。筛选与图例同一口径(维护者 2026-11-05)。 */
+    deviceFilter = null,
     facilities,
     pipelines,
     facilityPoints,
@@ -229,7 +245,7 @@ export function planModeLayers({
         }
     }
 
-    const source = visibleMapObjects(normalizedMode, objects, abnormalDevices)
+    const source = visibleMapObjects(normalizedMode, objects, abnormalDevices, deviceFilter)
     const pointPlan = planPointMarkers(source)
     const abnormalCodes = abnormalCodeSet(abnormalDevices)
     const abnormalDataReady = hasArray(abnormalDevices)

+ 70 - 32
src/modules/cockpit/domain/monitoringPanels.js

@@ -27,8 +27,7 @@ import { fieldReader, TIME_LABELS } from './fields.js'
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
 import { valueText } from './equipmentDetailView.js'
 import { industryLabel, normalizeDistrict, districtLabel, districtFilterLabel } from './filterOptions.js'
-import { legendVisuals, visualForStatus } from './pointStatus.js'
-import { planStatusPie } from './statusPie.js'
+import { legendLabelFor, legendVisuals, visualForStatus } from './pointStatus.js'
 import { abnormalCountRows } from './abnormalCounts.js'
 import {
     barsBlock,
@@ -212,7 +211,8 @@ function toDistributionMarker(raw, kind) {
         industry: reader.optionalStr('industry'),
         typeCode: type.optionalStr('code'),
         typeName: type.optionalStr('name'),
-        status: reader.optionalStr('pointStatus'),
+        // 点位给 `pointStatus`,未关联设备给 `deviceStatus`;两者都是该行的业务状态。
+        status: reader.optionalStr('pointStatus') || reader.optionalStr('deviceStatus'),
         statusReasons: reader.optionalList('statusReasons').map((item) => String(item)),
         longitude: numberish(reader.raw.longitude),
         latitude: numberish(reader.raw.latitude),
@@ -627,15 +627,51 @@ function deviceTypeLabel(rows, code) {
  * @param {object} [extra] `{ statusDistribution, selectedStatuses }`(外壳给的共用数据与当前选中)
  */
 /**
- * 异常监测点 → **指标卡 2×2**(维护者第 15 轮)。
+ * 设备状态池 → **全站共用**的计数(维护者 2026-11-04 补充裁定)。
  *
- * 为什么不再用横条:这组读数是「当前有多少个异常点位」的**结论型数字**,值班人员要的是
- * 一眼读出四类各多少,而不是比条长;而且它原本挂在面板中段,被上面的覆盖分布压住了。
- * 卡片顺序固定为 报警 / 预警 / 工单处置 / 离线(与地图顶部状态栏同一套语义顺序)。
+ * 工单、报警、预警**不是互斥状态**:只要该设备存在关联的报警、预警或未结束工单,就分别
+ * 加入对应状态池;只有「正常」与「离线」是冲突态(一台设备只能是其中之一)。
+ * 所以各池之和**可以大于设备总数**——这是规则本身,不是重复计数缺陷。
  *
- * 缺值纪律不变:`extra.statusDistribution` 不可用时整块不出(调用点已判 available),
- * 单个桶缺失按「未知」画占位符 —— 不把「没给」画成 0 个异常点。
+ * 输入是同一批设备的**证据集合**,每条 `{ deviceCode, statuses, monitoringState }`:
+ * statuses 来自各读模型已发布的设备状态(地图业务、监测分布业务、指标异常行),
+ * monitoringState 来自监测概览台账(ONLINE/OFFLINE/...)。任一来源声明某状态即入该池,
+ * 这正是「存在关联记录即可入池」的落地;没有任何来源时不臆造。
+ *
+ * 一份设备证据都没有时回 `available:false`,界面如实降级,**不补 0**。
+ * @param {Array<{deviceCode:string, statuses?:string[], monitoringState?:string}>} records
  */
+export const DEVICE_POOL_KEYS = ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE']
+
+export function deviceStatusPools(records) {
+    const list = Array.isArray(records) ? records : []
+    const byCode = new Map()
+    for (const record of list) {
+        if (!record || !record.deviceCode) continue
+        const entry = byCode.get(record.deviceCode) || { statuses: new Set(), monitoringState: '' }
+        for (const status of record.statuses || []) {
+            if (status) entry.statuses.add(String(status).toUpperCase())
+        }
+        if (record.monitoringState) entry.monitoringState = String(record.monitoringState).toUpperCase()
+        byCode.set(record.deviceCode, entry)
+    }
+    if (!byCode.size) return { available: false, counts: {}, total: null }
+    const counts = Object.fromEntries(DEVICE_POOL_KEYS.map((key) => [key, 0]))
+    for (const entry of byCode.values()) {
+        const statuses = entry.statuses
+        // 冲突态只有「正常 / 离线」:先判离线,非离线且没有任何异常状态的设备才算正常。
+        const offline = entry.monitoringState === 'OFFLINE' || statuses.has('OFFLINE')
+        if (statuses.has('ALARM')) counts.ALARM += 1
+        if (statuses.has('WARNING')) counts.WARNING += 1
+        if (statuses.has('WORK_ORDER')) counts.WORK_ORDER += 1
+        if (offline) counts.OFFLINE += 1
+        else if (!statuses.has('ALARM') && !statuses.has('WARNING') && !statuses.has('WORK_ORDER') && !statuses.has('EXPIRED')) {
+            counts.NORMAL += 1
+        }
+    }
+    return { available: true, counts, total: byCode.size }
+}
+
 export function abnormalPointCards(counts) {
     const source = counts && typeof counts === 'object' ? counts : {}
     const rows = [
@@ -654,10 +690,10 @@ export function abnormalPointCards(counts) {
         key: row.key,
         label: row.label,
         value: valueText(countOf(row.key)),
-        unit: '个',
+        unit: '台',
         valueTone: row.tone,
         accent: row.tone,
-        caption: '全局未筛',
+        caption: '设备状态池',
     }))
 }
 /**
@@ -729,12 +765,17 @@ export function monitoringDistributionPanel(data, section, extra = {}) {
         (row) => row.count,
     )
 
-    // 票 14:饼图切片(几何与高亮判定都在 `domain/statusPie.js`,本层只搬运)。
-    const pie = planStatusPie({
-        distribution: extra.statusDistribution || null,
-        rows: legendVisuals(),
-        selected: Array.isArray(extra.selectedStatuses) ? extra.selectedStatuses : [],
-    })
+    // 维护者 2026-11-04:状态分布取**设备状态池**(全站同源),不再用点位八桶、也不再假定互斥。
+    const device = extra.deviceStatusPools && extra.deviceStatusPools.available
+        ? extra.deviceStatusPools
+        : { available: false, counts: {}, total: null }
+    const deviceRows = DEVICE_POOL_KEYS.map((key) => ({
+        code: key,
+        // 用**图例同一份用词**(维护者 2026-11-05:图例已把 NORMAL 改称「在线」);
+        // 环图若仍走词条原 label「正常」,同一个面板里就会两种叫法。
+        label: legendLabelFor(key),
+        count: device.counts[key] || 0,
+    }))
 
     const blocks = [
         // 票 39(第 8 条):每块标题 = **「分节 · 维度(条长口径)」**。
@@ -748,23 +789,18 @@ export function monitoringDistributionPanel(data, section, extra = {}) {
         // 维护者第 15 轮:① 覆盖情况 · 片区与覆盖情况 · 行业统一成饼图(同族对照,形态一致才好比);
         // ② 覆盖情况 · 设备类型与下面的「设备类型构成(按点位数)」内容重叠、意义也不清楚 —— 整块删除;
         // ③「异常监测点」从横条改**指标卡 2×2**,并提到面板最顶部(它是值班人员最先要看的一组读数)。
-        ...(extra.statusDistribution && extra.statusDistribution.available
-            ? [kpiBlock(abnormalPointCards(extra.statusDistribution.counts || {}), { layout: 'two-by-two' })]
+        ...(device.available
+            ? [kpiBlock(abnormalPointCards(device.counts), { layout: 'two-by-two' })]
             : []),
         donutBlock('覆盖情况 · 行业(按点位数)', industryRows, { layout: 'compact' }),
         donutBlock('覆盖情况 · 片区(按点位数)', regionRows, { layout: 'compact' }),
         stackedBlock('设备类型构成(按点位数)', typeRows),
-        // 状态分布饼图:消费全局未筛分布并高亮当前选中;**不可点**(筛选入口只在顶部状态栏)。
-        {
-            kind: 'pie',
-            label: '状态分布 · 点位状态(全局未筛)',
-            ready: pie.ready,
-            hasSelection: pie.hasSelection,
-            total: pie.total,
-            slices: pie.slices,
-            rows: pie.rows,
-            note: pie.ready ? '' : STATUS_DISTRIBUTION_UNPUBLISHED,
-        },
+        // 状态分布:设备状态池环图(报警/预警/工单处置/在线/离线 —— 行文案与顶部图例同一份),
+        // 与顶部、运行概况同源同数。
+        // 分布没取到就整块不出(不伪造全零环图,与四卡同一条缺值纪律)。
+        ...(device.available
+            ? [donutBlock('状态分布 · 设备状态池', deviceRows, { layout: 'compact' })]
+            : []),
     ]
 
     return {
@@ -866,6 +902,8 @@ export function operationOverviewPanel(data, section, extra = {}) {
     //   · 总异常设备 = 异常设备清单总数(pagination.total = 预警+报警+离线+过期)。
     // 旧「数据入库时间」卡退役(不在 6 张清单里)。
     // 分布没取到时(六档 count 全 null)→ 总数/在线/离线/预警/报警全走占位符,绝不画 0(票 16)。
+    // 维护者 2026-11-04:预警/报警设备改取**设备状态池**,与顶部状态栏、监测分布同数。
+    const pools = extra.deviceStatusPools && extra.deviceStatusPools.available ? extra.deviceStatusPools : null
     const distributionProvided = (current.metricStatuses || []).some((row) => row.count !== null)
     const deviceTotal = distributionProvided
         ? current.metricStatuses.reduce((sum, row) => sum + (row.count || 0), 0)
@@ -881,8 +919,8 @@ export function operationOverviewPanel(data, section, extra = {}) {
             { key: 'deviceCount', label: '设备总数', value: valueText(deviceTotal), unit: '台', hint: '按指标状态统计(六档之和)' },
             { key: 'online', label: '在线设备', value: valueText(onlineCount), unit: '台', hint: '正常 + 预警 + 报警的设备(正在上报)' },
             { key: 'offlineCount', label: '离线设备', value: valueText(offlineCount), unit: '台' },
-            { key: 'warningCount', label: '预警设备', value: valueText(metricCount(current.metricStatuses, 'WARNING')), unit: '台' },
-            { key: 'alarmCount', label: '报警设备', value: valueText(metricCount(current.metricStatuses, 'ALARM')), unit: '台' },
+            { key: 'warningCount', label: '预警设备', value: valueText(pools ? pools.counts.WARNING : metricCount(current.metricStatuses, 'WARNING')), unit: '台', hint: pools ? '设备状态池(可与其他状态重叠)' : '' },
+            { key: 'alarmCount', label: '报警设备', value: valueText(pools ? pools.counts.ALARM : metricCount(current.metricStatuses, 'ALARM')), unit: '台', hint: pools ? '设备状态池(可与其他状态重叠)' : '' },
             { key: 'abnormalCount', label: '总异常设备', value: valueText(current.pagination && current.pagination.total), unit: '台', hint: '预警 + 报警 + 离线 + 过期' },
         ], { layout: 'three', emphasis: true }),
         // 维护者第 15 轮:「设备/指标异常统计」与「异常态势 · 指标状态分布」是同一份六态数据的

+ 3 - 2
src/modules/cockpit/domain/panelBlocks.js

@@ -115,7 +115,7 @@ export const REGION_EMPTY = '未分区'
 export const CODE_EMPTY = '未知'
 
 /**
- * 用一张给定的词表把码翻成人话;认不出的**回原码**(见 `CODE_TEXT` 的说明)。
+ * 用给定词表显示中文;未登记机器码显示未知,已发布中文保留;业务标识不走此函数。
  * @param {object} dictionary 码 → 中文
  * @param {string} code 后端码/值
  * @param {object} [options] `{ empty }` 空值时的说法
@@ -123,7 +123,8 @@ export const CODE_EMPTY = '未知'
 export function textFrom(dictionary, code, { empty = CODE_EMPTY } = {}) {
     const value = typeof code === 'string' ? code.trim() : ''
     if (!value) return empty
-    return (dictionary && dictionary[value]) || value
+    if (dictionary && Object.hasOwn(dictionary, value)) return dictionary[value]
+    return /\p{Script=Han}/u.test(value) && !/\p{Script=Latin}/u.test(value) ? value : CODE_EMPTY
 }
 
 /** 码 → 人话(共享词表)。 */

+ 28 - 1
src/modules/cockpit/domain/pointStatus.js

@@ -180,7 +180,9 @@ export function planPointMarkers(objects) {
             // 批次 3:内层行业图标选型依据(合成计划层在服务层消费)。
             industry: (item && item.industry) || '',
             // 文字线索:形状之外还带一句人话,弹层/图例计数都读它。
-            label: visual.label,
+            // 走 legendLabelFor 而非 visual.label:图例把 NORMAL 改称「在线」后,标记的状态文案
+            // 必须跟着改 —— 「图例与标记同一张表」是既有不变量(t09 的 C5 就是它的判据)。
+            label: legendLabelFor(visual.key),
             longitude: (item && item.longitude),
             latitude: (item && item.latitude),
         }
@@ -226,12 +228,37 @@ export function legendIconFor(key) {
  * 图例文案(2026-10-22 维护者裁定):`EMPTY` / `UNASSOCIATED` 两个词在「一张图」图例上语义不明确——
  * 「无设备」读不出说的是**监测点**还是设备,「无关联设备」也读不出它指的是**一台没挂点位的独立设备**。
  * 只改**图例**用词,不动词条本体的 `label`(面板/弹层仍读原文案,避免连锁改动)。
+ *
+ * 2026-11-05 维护者追加:`NORMAL` 在**图例**上改称「在线」。图例这一行数的是**设备台账**的
+ * 在线台数(与「离线」互斥),叫「正常」会和业务处置状态(工单/预警/报警)混成同一层级,
+ * 读不出「这台设备有没有在采集」。点位/设备卡片仍读后端 `statusLabel`「正常」,不受影响。
  */
 const LEGEND_LABEL_BY_KEY = {
+    NORMAL: '在线',
     EMPTY: '无设备监测点',
     UNASSOCIATED: '独立设备(未关联监测点)',
 }
 
+/** 六态 + 两条附加词条的**原始 label**(下标访问要防原型链,故用 Map 而非普通对象)。 */
+const VISUAL_LABEL_BY_KEY = new Map(
+    [...Object.keys(POINT_STATUS_VISUALS).map((key) => [key, POINT_STATUS_VISUALS[key].label]),
+        [UNKNOWN_VISUAL.key, UNKNOWN_VISUAL.label],
+        [UNASSOCIATED_VISUAL.key, UNASSOCIATED_VISUAL.label]],
+)
+
+/**
+ * 图例用词(键 → 展示文案)。给**不是图例行、但和图例数同一批设备**的视图用:
+ * 监测分布的「状态分布 · 设备状态池」环图、运行概况的 KPI 卡都读它,避免图例说「在线」
+ * 而旁边环图说「正常」——同一台设备在同一个屏上出现两种叫法。
+ * 未登记的键回**词条原 label**(面板/弹层仍读后端 statusLabel,不改源头词表)。
+ * @param {string} key 状态键
+ */
+export function legendLabelFor(key) {
+    // 只认自有键:普通对象下标会把 toString 这类原型方法也命中并当文案带出去。
+    const legend = Object.prototype.hasOwnProperty.call(LEGEND_LABEL_BY_KEY, key) ? LEGEND_LABEL_BY_KEY[key] : ''
+    return legend || VISUAL_LABEL_BY_KEY.get(key) || key
+}
+
 /** 图例的全部行(含未关联设备与「状态未发布」),按后端优先级排序。
  *  `icon`:批次 1 第三轮新增——状态标识行的区分图标(语义名取自上面的单源表)。 */
 export function legendVisuals() {

+ 1 - 1
src/modules/cockpit/domain/warningResponsePanels.js

@@ -451,7 +451,7 @@ export function eventCardRows(events, family) {
             // 副信息:设备 / 编号 / 时间 / 工单
             meta: [
                 view.deviceLabel ? `设备 ${view.deviceLabel}` : '',
-                view.code,
+                view.codeText,
                 view.eventTimeText || view.eventTime,
                 view.workOrderStateName ? `工单${view.workOrderStateName}` : '',
             ].filter(Boolean),

+ 3 - 3
src/modules/cockpit/domain/workOrder.js

@@ -65,7 +65,7 @@ export function toWorkOrder(raw) {
 
     return {
         id: source.raw.orderId === undefined || source.raw.orderId === null ? '' : String(source.raw.orderId),
-        code: source.optionalStr('workOrderCode') || source.optionalStr('orderNo'),
+        code: source.optionalStr('code') || source.optionalStr('workOrderCode') || source.optionalStr('orderNo'),
         deviceCode: source.optionalStr('deviceCode'),
         deviceName: source.optionalStr('deviceName'),
         deviceTypeName: source.optionalStr('deviceTypeName'),
@@ -74,7 +74,7 @@ export function toWorkOrder(raw) {
         orderLevel: source.optionalNum('orderLevel'),
         description: source.optionalStr('orderDesc'),
         stateCode: code,
-        stateName: source.optionalStr('stateName') || source.optionalStr('orderStatusName') || workOrderStateName(code),
+        stateName: workOrderStateName(code) || '状态未知',
         createTime: source.optionalStr('createTime'),
         updateTime: source.optionalStr('updateTime'),
         planFinishTime: source.optionalStr('planFinishTime'),
@@ -114,7 +114,7 @@ export function toWorkOrderTimeline(raw) {
                 operatorName: entry.optionalStr('operatorName'),
                 operatedAt: entry.optionalStr('operatedAt'),
                 stateCode: code,
-                stateName: entry.optionalStr('stateName') || workOrderStateName(code),
+                stateName: workOrderStateName(code) || '状态未知',
                 missing: entry.missing,
             }
         })

+ 5 - 5
src/modules/cockpit/domain/workOrderTimeline.js

@@ -121,7 +121,7 @@ export function trackOf(timeline) {
 
         if (known) {
             previousCode = rawCode
-            previousName = entry.stateName || workOrderStateName(rawCode)
+            previousName = workOrderStateName(rawCode)
             if (isOnWorkOrderPath(rawCode) && (reachedCode === null || rawCode > reachedCode)) reachedCode = rawCode
         }
 
@@ -129,12 +129,12 @@ export function trackOf(timeline) {
             index: index + 1,
             // 同一条轨迹里可能有同一时刻的两条记录,序号一起进 key,避免渲染时撞 key。
             key: `${entry.operatedAt || ''}#${index}`,
-            operationName: entry.operationName || '',
+            operationName: entry.operationName ? /\p{Script=Han}/u.test(entry.operationName) ? entry.operationName : '操作未知' : '',
             operationDesc: entry.operationDesc || '',
             operatorName: entry.operatorName || '',
             operatedAt: entry.operatedAt || '',
             stateCode: known ? rawCode : null,
-            stateName: known ? entry.stateName || workOrderStateName(rawCode) : '',
+            stateName: known ? workOrderStateName(rawCode) : '状态未知',
             fromStateName,
             action,
             actionLabel: ACTION_LABELS[action],
@@ -147,7 +147,7 @@ export function trackOf(timeline) {
     return {
         code: (order && order.code) || '',
         stateCode: isWorkOrderStateCode(stateCode) ? stateCode : null,
-        stateName: (order && order.stateName) || (isWorkOrderStateCode(stateCode) ? workOrderStateName(stateCode) : ''),
+        stateName: workOrderStateName(stateCode) || '状态未知',
         reachedCode,
         steps,
         entryCount: steps.length,
@@ -176,7 +176,7 @@ export function pathOnlyTrack(order) {
     return {
         code: row.code || '',
         stateCode: known ? code : null,
-        stateName: row.stateName || (known ? workOrderStateName(code) : ''),
+        stateName: known ? workOrderStateName(code) : '状态未知',
         // 没有轨迹就无从知道"走到过的最远处",如实留 null(不拿当前态冒充走过最远)。
         reachedCode: null,
         steps: [],

+ 55 - 0
src/modules/cockpit/mocks/contractMocks.js

@@ -1205,6 +1205,60 @@ function equipmentAggregate(scenario, deviceCode = '') {
     }
 }
 
+/**
+ * 采集记录样例(票 115 / #114 契约):用 `recordView.columns/rows` 驱动表格。
+ * create_time 故意给 ISO 串,界面应统一格式成 `yyyy-mm-dd hh:mm:ss`;
+ * 相邻两行同 createTime 用来验证 td 合并。
+ */
+function collectionRecordsPayload(scenario, deviceCode = '') {
+    const code = deviceCode || ARCHIVE.deviceCode
+    if (scenario === 'empty') {
+        return {
+            requestId: 'mock-collection-records',
+            apiVersion: 'v1',
+            readOnly: true,
+            deviceCode: code,
+            state: 'EMPTY',
+            stateReason: 'NO_RECORDS',
+            recordView: { layout: 'wide', columns: [], rows: [] },
+        }
+    }
+    const columns = [
+        { key: 'create_time', label: '数据时间', unit: null },
+        { key: 'COD', label: '化学需氧量(COD)', unit: 'mg/L' },
+        { key: 'PH', label: '酸碱度(pH)', unit: null },
+        { key: 'TEMPERATURE', label: '温度', unit: '℃' },
+        { key: 'filepath', label: '音频记录', unit: null },
+    ]
+    const row = (createTime, cod, ph, temperature) => ({
+        createTime,
+        cells: [
+            { key: 'create_time', value: createTime },
+            { key: 'COD', value: cod },
+            { key: 'PH', value: ph },
+            { key: 'TEMPERATURE', value: temperature },
+            { key: 'filepath', value: null },
+        ],
+    })
+    return {
+        requestId: 'mock-collection-records',
+        apiVersion: 'v1',
+        readOnly: true,
+        deviceCode: code,
+        state: 'READY',
+        stateReason: 'READY',
+        recordView: {
+            layout: 'wide',
+            columns,
+            rows: [
+                row('2026-09-02T09:59:30+08:00', 0, 7.2, 22.2),
+                row('2026-09-02T09:59:30+08:00', 1.5, 7.1, 21.8),
+                row('2026-09-02T09:58:30+08:00', 2.5, 7.0, 21.5),
+            ],
+        },
+    }
+}
+
 // ---------------------------------------------------------------- 事件与工单
 
 function eventRead(scenario, deviceCode = '') {
@@ -3097,6 +3151,7 @@ const PAYLOADS = {
     '/map-snapshot': (s, _requested, context) => mapSnapshotPayload(s, context.path, context.mapSnapshot),
     '/monitoring-points/*': (s, requested) => pointDetailPayload(s, requested),
     '/equipments/*/detail-aggregate': (s, requested) => equipmentAggregate(s, requested),
+    '/equipments/*/collection-records': (s, requested) => collectionRecordsPayload(s, requested),
     '/equipments/*/events/current': (s, requested) => eventRead(s, requested),
     '/equipments/*/events/history': (s, requested) => eventRead(s, requested),
     // 票 13:跨设备当前事件(预警/报警切换 + 当前事件列表;`mode` 必填并校验)。

+ 22 - 0
src/modules/cockpit/services/cockpitConfig.js

@@ -46,6 +46,7 @@ export const ENV_KEYS = {
     basemapSource: 'VITE_COCKPIT_BASEMAP_SOURCE',
     basemapUrl: 'VITE_COCKPIT_BASEMAP_URL',
     configUrl: 'VITE_COCKPIT_CONFIG_URL',
+    imageBaseUrl: 'VITE_APP_MINIO_BASE_URL',
 }
 
 function isPlainObject(value) {
@@ -59,6 +60,7 @@ export function builtinConfig() {
         dataSource: DATA_SOURCE.MOCK,
         api: { baseUrl: '', token: '' },
         ws: { url: '', token: '' },
+        images: { baseUrl: '' },
         // 票 16:刷新周期(秒)。`refreshSeconds` = 一个「周期」的长度;轮询与新鲜度判定都读它。
         // 默认 30 秒(决策:前端轮询 + WS 断线也算失败周期)。
         runtime: { refreshSeconds: 30, polling: true },
@@ -235,6 +237,23 @@ export function withUrlDataSourceOverride(config, location = null, isDev = false
  * @param {object} raw 合并后的原始配置
  * @returns {{config: object, issues: string[]}}
  */
+function readImageBaseUrl(value, issues) {
+    if (value === undefined || value === null || value === '') return ''
+    if (typeof value === 'string') {
+        const path = value.trim()
+        if (!path) return ''
+        try {
+            const parsed = new URL(path, 'http://image-config.invalid/')
+            const publicPath = /^https?:\/\//i.test(path) || (path.startsWith('/') && !path.startsWith('//'))
+            if (publicPath && ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password && !parsed.search && !parsed.hash) return path
+        } catch {
+            // 不记录原值:公开地址配置里不能携带凭据或签名。
+        }
+    }
+    issues.push('图片访问基址必须是公开HTTP(S)地址或同源路径,不允许凭据、签名参数或其它协议')
+    return ''
+}
+
 export function normalizeConfig(raw) {
     const config = builtinConfig()
     const issues = []
@@ -253,6 +272,7 @@ export function normalizeConfig(raw) {
 
     const api = isPlainObject(raw.api) ? raw.api : {}
     const ws = isPlainObject(raw.ws) ? raw.ws : {}
+    const images = isPlainObject(raw.images) ? raw.images : {}
     const map = isPlainObject(raw.map) ? raw.map : {}
     const runtime = isPlainObject(raw.runtime) ? raw.runtime : {}
     const basemap = isPlainObject(map.basemap) ? map.basemap : {}
@@ -314,6 +334,7 @@ export function normalizeConfig(raw) {
     config.api.token = readString(api.token, config.api.token, issues, 'api.token')
     config.ws.url = readString(ws.url, config.ws.url, issues, 'ws.url')
     config.ws.token = readString(ws.token, config.ws.token, issues, 'ws.token')
+    config.images.baseUrl = readImageBaseUrl(images.baseUrl, issues)
 
     // 票 16:刷新周期(秒)。必须是正数 —— 0 或负数会让「连续两个周期」永远不成立。
     const refreshSeconds = readNumber(runtime.refreshSeconds, issues, 'runtime.refreshSeconds')
@@ -600,6 +621,7 @@ export function readEnvConfig(env) {
     if (env[ENV_KEYS.wsUrl]) ws.url = env[ENV_KEYS.wsUrl]
     if (env[ENV_KEYS.wsToken]) ws.token = env[ENV_KEYS.wsToken]
     if (Object.keys(ws).length) out.ws = ws
+    if (env[ENV_KEYS.imageBaseUrl]) out.images = { baseUrl: env[ENV_KEYS.imageBaseUrl] }
 
     const map = {}
     if (env[ENV_KEYS.tiandituToken]) map.tianditu = { token: env[ENV_KEYS.tiandituToken] }

+ 43 - 1
src/modules/cockpit/services/detailApi.js

@@ -6,7 +6,7 @@
  * 所以调用方拿到的是若干并列的 `section()`,而不是一个共用成败的详情对象。
  */
 import { toMonitoringPointDetail } from '../domain/pointDetail.js'
-import { toEquipmentArchive, toEquipmentDetailAggregate, toDeviceFreshnessSection, toMetricsPayload } from '../domain/equipmentDetail.js'
+import { toEquipmentArchive, toEquipmentDetailAggregate, toDeviceFreshnessSection, toMetricsPayload, toCollectionRecordsPayload } from '../domain/equipmentDetail.js'
 import { toEventRead } from '../domain/events.js'
 import { toWorkOrder, toWorkOrderTimeline } from '../domain/workOrder.js'
 import { isEmptyBy, fetchSection } from './domainRequest.js'
@@ -71,6 +71,48 @@ export function fetchEquipmentMetrics(deviceCode, options = {}) {
  * 每个分区独立成败。
  */
 export async function fetchEquipmentDetailAggregate(deviceCode, options = {}) {
+    if (!deviceCode) return missingCode('设备', deviceCode)
+    let [aggregate, collectionRecords] = await Promise.all([
+        fetchLegacyEquipmentDetailAggregate(deviceCode, options),
+        fetchEquipmentCollectionRecords(deviceCode, options),
+    ])
+    if (!aggregate.data) {
+        if (![SECTION_STATE.SUCCESS, SECTION_STATE.EMPTY].includes(collectionRecords.state)) return aggregate
+        aggregate = {
+            ...aggregate,
+            state: SECTION_STATE.SUCCESS,
+            reason: '',
+            data: toEquipmentDetailAggregate({ deviceCode, aggregateState: 'PARTIAL' }),
+        }
+    }
+    const sections = aggregate.data.sections
+    return {
+        ...aggregate,
+        data: { ...aggregate.data, sections: { ...sections, metrics: { ...sections.metrics, collectionRecords } } },
+    }
+}
+
+/** 独立采集记录分区;接口未发布如实失败,不fallback旧metrics。 */
+export async function fetchEquipmentCollectionRecords(deviceCode, options = {}) {
+    if (!deviceCode) return missingCode('设备', deviceCode)
+    const result = await fetchSection(`/equipments/${encodeURIComponent(deviceCode)}/collection-records`, {
+        toData: toCollectionRecordsPayload,
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+    if (result.state !== SECTION_STATE.SUCCESS) return result
+    const payload = result.data
+    if (payload.state === 'FAILED') return { ...result, state: SECTION_STATE.FAILED, reason: payload.stateReason, data: null }
+    if (payload.state === 'UNAVAILABLE') return { ...result, state: SECTION_STATE.UNAVAILABLE, reason: payload.stateReason, data: null }
+    if (payload.state === 'EMPTY') return { ...result, state: SECTION_STATE.EMPTY }
+    if (!['READY', 'SUCCESS'].includes(payload.state) || !Array.isArray(payload.recordView?.columns) || !Array.isArray(payload.recordView?.rows)) {
+        return { ...result, state: SECTION_STATE.FAILED, reason: '采集记录载荷不完整', data: null }
+    }
+    if (!payload.recordView.rows.length) return { ...result, state: SECTION_STATE.EMPTY }
+    return result
+}
+
+async function fetchLegacyEquipmentDetailAggregate(deviceCode, options = {}) {
     if (!deviceCode) return missingCode('设备', deviceCode)
     const aggregate = await fetchSection(`/equipments/${encodeURIComponent(deviceCode)}/detail-aggregate`, {
         toData: toEquipmentDetailAggregate,

+ 24 - 6
src/modules/cockpit/services/deviceImage.js

@@ -1,14 +1,32 @@
 import { getCockpitConfig } from './cockpitConfig.js'
 
-/** Backend-upload paths belong to the API server's origin, not the cockpit route. */
+/** 设备图片与采集附件共用对象基址;绝对签名地址保持原样,不迁移凭据。 */
 export function resolveDeviceImageUrl(value, config = getCockpitConfig()) {
-    const path = typeof value === 'string' ? value.trim().replace(/\\/g, '/') : ''
-    if (!path) return ''
-    if (/^(https?:|blob:|data:image\/)/i.test(path)) return path
-    if (/^[a-z][a-z0-9+.-]*:/i.test(path)) return ''
+    const raw = typeof value === 'string' ? value.trim() : ''
+    if (!raw) return ''
+    if (/^(blob:|data:image\/)/i.test(raw)) return raw
     const documentBase = typeof document !== 'undefined' ? document.baseURI : 'http://localhost/'
+    const real = config?.dataSource === 'real'
+    const imageBase = real ? config.images?.baseUrl : ''
     try {
-        const apiBase = config?.dataSource === 'real' ? config.api?.baseUrl : ''
+        let path = raw
+        if (/^https?:\/\//i.test(raw)) {
+            const absolute = new URL(raw)
+            if (absolute.username || absolute.password) return ''
+            const prefix = ['/pipe/', '/minio/pipe/'].find((item) => absolute.pathname.startsWith(item))
+            // 查询串可能是签名的一部分;不能重写其主机、路径或编码。
+            if (!imageBase || absolute.search || !prefix) return raw
+            path = absolute.pathname.slice(prefix.length)
+        } else if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) {
+            return ''
+        }
+        path = path.replace(/\\/g, '/')
+        if (imageBase) {
+            const base = new URL(imageBase.replace(/\/+$/, '') + '/', documentBase)
+            if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash) return ''
+            return new URL(path.replace(/^\/+/, ''), base).href
+        }
+        const apiBase = real ? config.api?.baseUrl : ''
         const base = apiBase ? new URL(apiBase, documentBase).origin + '/' : documentBase
         return new URL(path, base).href
     } catch {

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

@@ -359,7 +359,7 @@ export function hasMapMarker(kind, code, pointCode = '', key = '') {
 }
 
 export function getMapMarkerAnchor(kind, code, pointCode = '', key = '') {
-    if (!instance || !instance.engine || !instance.viewer || !code) return null
+    if (!instance || !instance.engine || !instance.viewer || (!code && !(kind === 'facility-point' && key))) return null
     if (kind === 'facility-point') {
         const marker = facilityMarkerFor(code, key)
         const at = marker && instance.projectMapPoint?.(marker.longitude, marker.latitude)
@@ -3373,7 +3373,10 @@ export function setFacilitySelection(identity) {
 }
 
 function facilityMarkerFor(code, key = '') {
-    const matches = facilityMarkerRows.filter(item => item.code === code && (!key || item.key === key))
+    if (!code && !key) return null
+    const matches = facilityMarkerRows.filter(item => key
+        ? item.key === key && (!code || item.code === code)
+        : item.code === code)
     return matches.length === 1 ? matches[0] : null
 }
 

+ 3 - 1
src/modules/cockpit/stores/detailStore.js

@@ -32,7 +32,7 @@ import { fetchEquipmentDetailAggregate, fetchEquipmentFreshness, fetchMonitoring
 import { SECTION_STATE, overallState, section, sectionStateFromReason } from '../domain/sectionState.js'
 import { REQUEST_REASON } from '../domain/requestReason.js'
 import { MAP_SUMMARY_PHASE } from '../domain/mapSummary.js'
-import { equipmentDetailMatches } from '../domain/equipmentDetailView.js'
+import { equipmentDetailMatches, collectionRecordsSection } from '../domain/equipmentDetailView.js'
 import {
     DETAIL_LEVEL,
     backToPoint as backToPointSelection,
@@ -270,9 +270,11 @@ export const useDetailStore = defineStore('detailStore', () => {
         if (!deviceCode) return selection.value
 
         const aggregate = sections.value.equipmentAggregate
+        const collection = collectionRecordsSection(aggregate.data?.sections?.metrics)
         const answered =
             equipmentDetailMatches(aggregate, deviceCode) &&
             aggregate.loaded === true &&
+            ![SECTION_STATE.FAILED, SECTION_STATE.UNAVAILABLE].includes(collection.state) &&
             [SECTION_STATE.SUCCESS, SECTION_STATE.EMPTY, SECTION_STATE.STALE].includes(aggregate.state)
         // 同一台设备正在取数时再点一下不该叠第二个请求。
         const inFlight = deviceLoading.value && (previous.deviceCode || '') === deviceCode

+ 204 - 0
src/modules/cockpit/stores/deviceStatusStore.js

@@ -0,0 +1,204 @@
+/**
+ * 设备状态池的**全局**数据源(维护者 2026-11-04)。
+ *
+ * 为什么单独成 store:状态池是全站口径——顶部状态栏在四个一级模式里都常驻,不能挂在
+ * 「运行监测」的面板分区上,否则不进那个模式就不刷新(这正是本次要修的缺陷)。
+ * 因此本 store 自己持有两个全局读模型:监测概览台账(在线/离线)与运行概况异常清单
+ * (逐设备异常状态),在任何一级模式下都能取到同一份证据。
+ *
+ * 语义(见 domain 的 deviceStatusPools):工单/报警/预警可同时存在,只有正常与离线互斥。
+ * 数据没到时 `pools.available:false`,界面如实降级,不补 0。
+ */
+import { computed, ref } from 'vue'
+import { defineStore } from 'pinia'
+import { fetchMonitoringOverview, fetchMonitoringSituation } from '../services/monitoringApi.js'
+import { fetchCurrentEvents } from '../services/eventApi.js'
+import { EVENT_FAMILY } from '../domain/events.js'
+import { deviceStatusPools } from '../domain/monitoringPanels.js'
+import { useMapStore } from './mapStore.js'
+
+export const useDeviceStatusStore = defineStore('deviceStatusStore', () => {
+    /** 监测概览分区(逐设备 monitoringState) */
+    const overview = ref(null)
+    /** 运行概况分区(逐设备异常状态) */
+    const situation = ref(null)
+    /** 当前预警/报警事件(用于「设备打了什么标签」:预警=有预警事件的设备,报警=有未处理报警的设备) */
+    const warnings = ref(null)
+    const alarms = ref(null)
+    const loading = ref(false)
+    const loaded = ref(false)
+
+    /** 请求序号:换筛选时旧的在途响应作废。 */
+    let token = 0
+
+    /**
+     * 三条读模型 → 设备证据。
+     * 地图那份由 mapStore 提供(它本来就在全模式加载),所以未关联设备的业务状态不必重取。
+     */
+    /** 从事件分区取出「涉及的设备编码」(去重)。事件行缺设备编码时不计数,不臆造。 */
+    function eventDeviceCodes(section) {
+        const rows = section && section.data && section.data.sections && section.data.sections.events
+            ? section.data.sections.events.data : null
+        const codes = new Set()
+        for (const row of rows || []) {
+            const code = row && (row.deviceCode || '')
+            if (code) codes.add(String(code).trim())
+        }
+        return codes
+    }
+
+    /**
+     * 设备标签口径(维护者 2026-11-05):
+     *   · 基础属性只有「正常 / 离线」二选一(按台账 monitoringState);
+     *   · 报警、预警、工单、独立设备是**可叠加标签**,一台设备可同时带上多个;
+     *   · 图例只数设备,不数监测点。
+     * 因此四类标签各自独立去重,互不排斥,也不与正常/离线互斥。
+     */
+    function records() {
+        const mapStore = useMapStore()
+        const list = []
+        const ledger = overview.value && overview.value.data && overview.value.data.devices
+        for (const device of ledger || []) {
+            list.push({ deviceCode: device && device.code, monitoringState: device && device.status })
+        }
+        for (const object of mapStore.summary.objects || []) {
+            if (object && object.kind === 'unassociated-equipment') {
+                list.push({ deviceCode: object.code, labels: ['UNASSOCIATED'] })
+            }
+        }
+        return list
+    }
+
+    const pools = computed(() => {
+        const ledger = overview.value && overview.value.data && overview.value.data.devices
+        const devices = Array.isArray(ledger) ? ledger : []
+        if (!devices.length) return { available: false, counts: {}, total: null }
+        const mapStore = useMapStore()
+        const unassociated = new Set(
+            (mapStore.summary.objects || [])
+                .filter((object) => object && object.kind === 'unassociated-equipment' && object.code)
+                .map((object) => String(object.code).trim()),
+        )
+        const rowsOf = (section) => {
+            const events = section && section.data && section.data.sections && section.data.sections.events
+            return (events && events.data) || []
+        }
+        const workOrders = new Set()
+        for (const section of [warnings.value, alarms.value]) {
+            for (const row of rowsOf(section)) {
+                const brief = row && row.workOrder
+                const state = brief && typeof brief.stateCode === 'number' ? brief.stateCode : null
+                const open = state !== null && ((state >= 0 && state <= 4) || state === 6)
+                if (open && row.deviceCode) workOrders.add(String(row.deviceCode).trim())
+            }
+        }
+        // 台账里的设备编码集合:**状态池只数设备**(维护者 2026-11-04/05 口径)。
+        // 为什么要拿它做交集:现网事件表里存着**不是设备**的编码(实测 15/16/20、
+        // TEST-CBYL5432168 —— 设备档案接口逐个 404,它们是历史脏值或类型码),
+        // 若直接按事件行的 deviceCode 入池,预警池会多报 4 台「不存在的设备」(图例 7 台,
+        // 而地图最多只有 2 个坐标点)。设备池里只该有设备台账里存在的设备。
+        const ledgerCodes = new Set(devices.map((device) => String((device && device.code) || '').trim()).filter(Boolean))
+        const onlyDevices = (codes) => new Set([...codes].filter((code) => ledgerCodes.has(code)))
+        // 设备明细:基础属性(NORMAL/OFFLINE,互斥)+ 可叠加标签(ALARM/WARNING/WORK_ORDER/UNASSOCIATED)
+        const tagSets = {
+            ALARM: onlyDevices(eventDeviceCodes(alarms.value)),
+            WARNING: onlyDevices(eventDeviceCodes(warnings.value)),
+            WORK_ORDER: onlyDevices(workOrders),
+            UNASSOCIATED: onlyDevices(unassociated),
+        }
+        const byCode = new Map()
+        for (const device of devices) {
+            // 台账行是**领域字段**:code/name/status(适配层已从 deviceCode/monitoringState 改名)。
+            const code = String((device && device.code) || '').trim()
+            if (!code) continue
+            const state = String((device && device.status) || '').toUpperCase()
+            byCode.set(code, {
+                deviceCode: code,
+                deviceName: (device && device.name) || code,
+                // 只有正常与离线互斥;其余按标签判断
+                base: state === 'OFFLINE' ? 'OFFLINE' : state === 'ONLINE' ? 'NORMAL' : '',
+                tags: Object.keys(tagSets).filter((key) => tagSets[key].has(code)),
+            })
+        }
+        const offline = devices.filter((device) => String((device && device.status) || '').toUpperCase() === 'OFFLINE').length
+        return {
+            available: true,
+            total: devices.length,
+            devices: [...byCode.values()],
+            counts: {
+                // 六个池与 devices[] 的标签**同源同数**(都读 tagSets),不重复计算第二遍。
+                ALARM: tagSets.ALARM.size,
+                WARNING: tagSets.WARNING.size,
+                WORK_ORDER: tagSets.WORK_ORDER.size,
+                NORMAL: devices.length - offline,
+                OFFLINE: offline,
+                UNASSOCIATED: tagSets.UNASSOCIATED.size,
+            },
+        }
+    })
+
+    async function refresh(options = {}) {
+        const mine = ++token
+        loading.value = true
+        try {
+            const query = {
+                industry: options.industry,
+                scope: options.scope,
+                deviceType: options.deviceType,
+                config: options.config,
+                fetchImpl: options.fetchImpl,
+            }
+            const [overviewSection, situationSection, warningSection, alarmSection] = await Promise.all([
+                fetchMonitoringOverview(query),
+                fetchMonitoringSituation(query),
+                fetchCurrentEvents({ mode: EVENT_FAMILY.WARNING, page: 1, pageSize: 1000, config: options.config, fetchImpl: options.fetchImpl }),
+                fetchCurrentEvents({ mode: EVENT_FAMILY.ALARM, page: 1, pageSize: 1000, config: options.config, fetchImpl: options.fetchImpl }),
+            ])
+            if (mine !== token) return pools.value
+            overview.value = overviewSection
+            situation.value = situationSection
+            warnings.value = warningSection
+            alarms.value = alarmSection
+            loaded.value = true
+        } finally {
+            if (mine === token) loading.value = false
+        }
+        return pools.value
+    }
+
+    /** 仅测试/重置用。 */
+    function reset() {
+        token += 1
+        overview.value = null
+        situation.value = null
+        loading.value = false
+        loaded.value = false
+    }
+
+    /**
+     * 按选中的状态行筛选设备(与图例同一份台账/标签)。
+     * 空集合=不筛;「无设备监测点」不参与设备筛选(它是点位特殊状态)。
+     * @param {string[]} keys 选中的图例键
+     * @returns {Set<string>|null} 命中的设备编码集;null=不筛
+     */
+    function filterDeviceCodes(keys) {
+        const all = Array.isArray(keys) ? keys.filter(Boolean) : []
+        if (!all.length) return null
+        // 「无设备监测点」是点位特殊状态:只筛没有关联设备的空白点位,不参与设备标签匹配。
+        const emptyPoints = all.includes('EMPTY')
+        const selected = all.filter((key) => key !== 'EMPTY')
+        if (!selected.length) return { codes: new Set(), emptyPoints }
+        const current = pools.value
+        if (!current.available) return new Set()
+        const codes = new Set()
+        for (const device of current.devices || []) {
+            const hit = selected.some((key) => (key === 'NORMAL' || key === 'OFFLINE')
+                ? device.base === key
+                : device.tags.includes(key))
+            if (hit) codes.add(device.deviceCode)
+        }
+        return { codes, emptyPoints }
+    }
+
+    return { overview, situation, pools, filterDeviceCodes, loading, loaded, refresh, reset }
+})

+ 3 - 1
src/modules/cockpit/stores/mapStore.js

@@ -90,7 +90,9 @@ function fetchMapSummaryPage(query, options) {
         industry: options.industry,
         scope: options.scope,
         deviceType: options.deviceType,
-        pointStatus: options.pointStatus,
+        // 维护者 2026-11-05:状态筛选改按**设备标签**在前端本地做(与图例同一份设备台账),
+        // 不再把 pointStatus 下发给 map-snapshot —— 后端点位口径与设备标签口径必然对不上。
+        pointStatus: undefined,
         config: options.config,
         fetchImpl: options.fetchImpl,
     }).then((result) => {

+ 1 - 1
src/modules/cockpit/stores/monitoringStore.js

@@ -20,7 +20,7 @@
  * ④ **异常设备定位**:复用票 17 立的那条共享接缝(`domain/mapLocate.js`),
  *    不新写一套判定。
  */
-import { reactive, ref } from 'vue'
+import { computed, reactive, ref } from 'vue'
 import { defineStore } from 'pinia'
 import { SECTION_STATE, section } from '../domain/sectionState.js'
 import { REQUEST_REASON } from '../domain/requestReason.js'