Parcourir la source

feat(cockpit): 地形/影像接入、视角预设、生产形态与浮层布局(T21/T29/T30/T32/T33/T34)

- T21 地形与 3D Tiles 基线:本地 DEM + 影像叠加 + 免费离线底图;地面/管线视图切换
  (决策地图票 05 语义:压低相机 + 放宽俯仰 + 地表/模型半透明,构造性保证不重置中心)
- T29 底图影像配置驱动;T30 进入引导(自转星球 + 点击进入)与视角预设/归位,
  并做掉地形感知拾取(修复拉近钻山体黑屏)
- T32 生产形态降噪:调试读数按 map.ui.debug(缺省跟随构建模式)收敛;
  T33 浮层布局:图例置顶、地图摘要收成小胶囊、「无地图位置」移右下角并收成一行胶囊
- T34 轻量体块模型:可重跑生成器与回验脚本(维护者目测否决,重做见 T35)
- 新增 docs/cockpit-deployment.md 生产部署手册;localGisStaticPlugin 挂载本机 GIS 目录
Kazerin il y a 1 mois
Parent
commit
2f07a3a8dc
99 fichiers modifiés avec 19228 ajouts et 243 suppressions
  1. 5 1
      .fes.js
  2. 3 0
      .gitignore
  3. 161 0
      .scratch/cockpit-shell-panel-layout/handoff/backend-contract-gaps.md
  4. 46 11
      .scratch/cockpit-shell-panel-layout/issues/04-config-and-credentials.md
  5. 62 11
      .scratch/cockpit-shell-panel-layout/issues/05-mock-domain-adapter.md
  6. 111 9
      .scratch/cockpit-shell-panel-layout/issues/07-coordinate-validation.md
  7. 57 10
      .scratch/cockpit-shell-panel-layout/issues/08-map-summary-pagination.md
  8. 71 9
      .scratch/cockpit-shell-panel-layout/issues/09-point-status-legend.md
  9. 70 10
      .scratch/cockpit-shell-panel-layout/issues/10-monitoring-point-detail.md
  10. 82 10
      .scratch/cockpit-shell-panel-layout/issues/11-equipment-detail-aggregate.md
  11. 103 11
      .scratch/cockpit-shell-panel-layout/issues/21-3d-tiles-terrain-baseline.md
  12. 152 70
      .scratch/cockpit-shell-panel-layout/issues/25-backend-contract-gaps.md
  13. 32 2
      .scratch/cockpit-shell-panel-layout/issues/27-cesium-viewer-integration.md
  14. 51 0
      .scratch/cockpit-shell-panel-layout/issues/29-basemap-imagery-config.md
  15. 68 0
      .scratch/cockpit-shell-panel-layout/issues/30-intro-gate-and-view-presets.md
  16. 71 0
      .scratch/cockpit-shell-panel-layout/issues/32-production-ui-cleanup.md
  17. 69 0
      .scratch/cockpit-shell-panel-layout/issues/33-overlay-layout.md
  18. 95 0
      .scratch/cockpit-shell-panel-layout/issues/34-lightweight-city-massing.md
  19. 48 0
      .scratch/cockpit-shell-panel-layout/issues/35-city-massing-redo.md
  20. 14 3
      .scratch/cockpit-shell-panel-layout/map.md
  21. 18 0
      .scratch/cockpit-shell-panel-layout/verify/fixtures/tiles/tileset.json
  22. 12 0
      .scratch/cockpit-shell-panel-layout/verify/t01-browser-walkthrough.mjs
  23. 202 0
      .scratch/cockpit-shell-panel-layout/verify/t04-config-walkthrough.mjs
  24. 339 0
      .scratch/cockpit-shell-panel-layout/verify/t04-config.mjs
  25. 219 0
      .scratch/cockpit-shell-panel-layout/verify/t05-adapter-walkthrough.mjs
  26. 540 0
      .scratch/cockpit-shell-panel-layout/verify/t05-adapter.mjs
  27. 5 1
      .scratch/cockpit-shell-panel-layout/verify/t06-assets.mjs
  28. 332 0
      .scratch/cockpit-shell-panel-layout/verify/t07-coordinate-walkthrough.mjs
  29. 492 0
      .scratch/cockpit-shell-panel-layout/verify/t07-coordinate.mjs
  30. 328 0
      .scratch/cockpit-shell-panel-layout/verify/t08-map-summary-walkthrough.mjs
  31. 570 0
      .scratch/cockpit-shell-panel-layout/verify/t08-map-summary.mjs
  32. 406 0
      .scratch/cockpit-shell-panel-layout/verify/t09-point-status-walkthrough.mjs
  33. 396 0
      .scratch/cockpit-shell-panel-layout/verify/t09-point-status.mjs
  34. 449 0
      .scratch/cockpit-shell-panel-layout/verify/t10-point-detail-walkthrough.mjs
  35. 647 0
      .scratch/cockpit-shell-panel-layout/verify/t10-point-detail.mjs
  36. 509 0
      .scratch/cockpit-shell-panel-layout/verify/t11-equipment-detail-walkthrough.mjs
  37. 752 0
      .scratch/cockpit-shell-panel-layout/verify/t11-equipment-detail.mjs
  38. 293 0
      .scratch/cockpit-shell-panel-layout/verify/t21-gis-walkthrough.mjs
  39. 110 0
      .scratch/cockpit-shell-panel-layout/verify/t21-terrain.mjs
  40. 257 0
      .scratch/cockpit-shell-panel-layout/verify/t21-view-modes-walkthrough.mjs
  41. 101 0
      .scratch/cockpit-shell-panel-layout/verify/t21-view-modes.mjs
  42. 183 0
      .scratch/cockpit-shell-panel-layout/verify/t29-basemap-walkthrough.mjs
  43. 120 0
      .scratch/cockpit-shell-panel-layout/verify/t29-basemap.mjs
  44. 200 0
      .scratch/cockpit-shell-panel-layout/verify/t30-intro-walkthrough.mjs
  45. 111 0
      .scratch/cockpit-shell-panel-layout/verify/t30-view-presets.mjs
  46. 184 0
      .scratch/cockpit-shell-panel-layout/verify/t32-ui-mode-walkthrough.mjs
  47. 207 0
      .scratch/cockpit-shell-panel-layout/verify/t33-overlay-layout-walkthrough.mjs
  48. 330 0
      docs/cockpit-deployment.md
  49. 109 0
      public/cockpit/README.md
  50. 54 0
      public/cockpit/config.example.json
  51. 97 0
      src/common/localGisStaticPlugin.js
  52. 62 0
      src/common/vendoredPluginLibAliases.js
  53. 381 5
      src/modules/cockpit/CockpitShell.vue
  54. 165 0
      src/modules/cockpit/components/CockpitIntro.vue
  55. 366 0
      src/modules/cockpit/components/EquipmentDetailSections.vue
  56. 258 22
      src/modules/cockpit/components/MapCanvas.vue
  57. 210 0
      src/modules/cockpit/components/MapCoordinateBlocked.vue
  58. 409 0
      src/modules/cockpit/components/PointDetailCard.vue
  59. 105 0
      src/modules/cockpit/components/SectionStateTag.vue
  60. 36 8
      src/modules/cockpit/components/StatusTag.vue
  61. 141 0
      src/modules/cockpit/domain/basemap.js
  62. 76 0
      src/modules/cockpit/domain/configStatus.js
  63. 205 0
      src/modules/cockpit/domain/coordinate.js
  64. 189 0
      src/modules/cockpit/domain/detailFlow.js
  65. 205 0
      src/modules/cockpit/domain/equipmentDetail.js
  66. 321 0
      src/modules/cockpit/domain/equipmentDetailView.js
  67. 151 0
      src/modules/cockpit/domain/events.js
  68. 121 0
      src/modules/cockpit/domain/fields.js
  69. 84 0
      src/modules/cockpit/domain/freshness.js
  70. 95 0
      src/modules/cockpit/domain/mapLayers.js
  71. 419 0
      src/modules/cockpit/domain/mapSummary.js
  72. 107 0
      src/modules/cockpit/domain/pointDetail.js
  73. 223 0
      src/modules/cockpit/domain/pointStatus.js
  74. 24 0
      src/modules/cockpit/domain/requestReason.js
  75. 200 0
      src/modules/cockpit/domain/sectionState.js
  76. 19 8
      src/modules/cockpit/domain/statusTones.js
  77. 110 0
      src/modules/cockpit/domain/viewModes.js
  78. 94 0
      src/modules/cockpit/domain/viewPresets.js
  79. 123 0
      src/modules/cockpit/domain/workOrder.js
  80. 707 0
      src/modules/cockpit/mocks/contractMocks.js
  81. 60 0
      src/modules/cockpit/mocks/index.js
  82. 27 0
      src/modules/cockpit/services/cameraControl.js
  83. 13 3
      src/modules/cockpit/services/cesiumAssets.js
  84. 404 19
      src/modules/cockpit/services/cesiumEngine.js
  85. 236 0
      src/modules/cockpit/services/cockpitApi.js
  86. 503 0
      src/modules/cockpit/services/cockpitConfig.js
  87. 160 0
      src/modules/cockpit/services/detailApi.js
  88. 66 0
      src/modules/cockpit/services/domainRequest.js
  89. 67 0
      src/modules/cockpit/services/mapApi.js
  90. 636 16
      src/modules/cockpit/services/mapService.js
  91. 68 0
      src/modules/cockpit/services/pointPick.js
  92. 91 0
      src/modules/cockpit/services/runtimeApi.js
  93. 395 0
      src/modules/cockpit/stores/detailStore.js
  94. 16 3
      src/modules/cockpit/stores/filterStore.js
  95. 128 0
      src/modules/cockpit/stores/mapStore.js
  96. 6 1
      src/modules/cockpit/styles/tokens.css
  97. 76 0
      tools/city-massing/README.md
  98. 536 0
      tools/city-massing/build-town.mjs
  99. 191 0
      tools/city-massing/verify-town.mjs

+ 5 - 1
.fes.js

@@ -17,6 +17,7 @@ import UnoCSS from 'unocss/vite'
 import glsl from 'vite-plugin-glsl'
 import federation from '@originjs/vite-plugin-federation'
 import cesiumStatic from './src/common/cesiumStaticPlugin.js'
+import localGisStatic from './src/common/localGisStaticPlugin.js'
 import { vendoredPluginLibAliases } from './src/common/vendoredPluginLibAliases.js'
 
 const __filename = fileURLToPath(import.meta.url);
@@ -84,7 +85,10 @@ export default defineBuildConfig({
                 // 票27:cesium 不进 shared —— 远程不需要它,且体积大、CESIUM_BASE_URL 是进程级单例。
             }),
             // 票27:把 cesium 预构建静态资源接到 <base>/cesium/(dev 中间件 + build 拷贝)。
-            cesiumStatic()
+            cesiumStatic(),
+            // 票21:把本机/甲方的 GIS 目录(DEM、影像切片)挂成静态路径,目录由 .env.local 的
+            // COCKPIT_GIS_MOUNTS 给;没配就什么也不挂,别的机器照常跑。
+            localGisStatic()
         ],
         build: {
             target: 'esnext', // 或者 'es2020' 以支持 BigInt

+ 3 - 0
.gitignore

@@ -26,3 +26,6 @@ pluginMaker/install
 
 # DSH 编辑审阅产物(每次编辑在项目根生成,属运行期临时文件)
 .dsh-*
+
+# 驾驶舱部署配置(票 04):各环境真实地址与只读令牌不入库,模板见 public/cockpit/config.example.json
+public/cockpit/config.json

+ 161 - 0
.scratch/cockpit-shell-panel-layout/handoff/backend-contract-gaps.md

@@ -0,0 +1,161 @@
+# 后端契约缺口交付包(供后端项目经理建票)
+
+Status: ready-for-human
+Labels: ready-for-human
+Category: handoff
+Blocked by: None
+
+本文件是 [25: 后端契约缺口待办(跨领域两项 + 补契约清单)](../issues/25-backend-contract-gaps.md) 的交付资产:把复核后**仍然缺失**的后端工作写成可直接建票的草案,交给后端项目经理排期,不需要 agent 再加工。
+
+**已建票(2026-09-18)**:五张草案已在 Gogs `shaoy/pipe-ner-server` 建成真实工单(标签 `needs-triage`,待后端 PM 排期与裁定):
+
+| 草案 | Gogs 票号 | 状态 |
+| --- | --- | --- |
+| BT-1 | [#59](http://192.168.110.30:3000/shaoy/pipe-ner-server/issues/59) | ✅ 已交付并关闭(2026-09-18) |
+| BT-2 | [#60](http://192.168.110.30:3000/shaoy/pipe-ner-server/issues/60) | ✅ 已交付并关闭(2026-09-18,走路径①完整) |
+| BT-3 | [#61](http://192.168.110.30:3000/shaoy/pipe-ner-server/issues/61) | ✅ 已交付并关闭(2026-09-18) |
+| BT-4 | [#62](http://192.168.110.30:3000/shaoy/pipe-ner-server/issues/62) | ✅ 已交付并关闭(2026-09-18) |
+| BT-5 | [#63](http://192.168.110.30:3000/shaoy/pipe-ner-server/issues/63) | 🔄 进行中(2026-09-18:`map-snapshot` 第 1/22 片已接真实库,票未关) |
+
+BT-6 为可选票,未建。若后端 PM 确认需要,再按下面草案建。
+
+复核基线(2026-09-17,2026-09-18 第三轮复验无变化):
+
+- 后端仓库 `E:\pipe-ner-server`,本地 HEAD `ed0ace5`(`test(cockpit): add contract integration environment and handoff (#43)`)。
+- 远端 `origin` HEAD 仍是 `e2b4e45`(#42)——**#43 尚未推送**,联调前请确认部署来源是其预期提交。
+- 复核方式:只读阅读控制器、响应类、适配器与 `docs/cockpit/README.md`;未改动后端任何文件,未跑构建或调试命令。
+
+## 一、完成情况总览
+
+票 25 列出的缺口,今天的状态如下(后端 Gogs #33–#43 已交付的切片):
+
+| 项 | 内容 | 状态 | 证据 |
+| --- | --- | --- | --- |
+| A1 | 跨设备当前事件流 | ✅ 已交付 | `GET /api/cockpit/v1/events/current`(`mode` 必填,`level`/`industry`/`status`/分页);`CockpitCurrentEventsResponse.EventItem` 含 `eventNo/eventType/level/levelColor/status/value/unit/deviceCode/location/industry/longitude/latitude/workOrderSummary` + `sections{events,workOrders}` + `mapFilter.deviceCodes` |
+| A2 | 跨设备历史事件流 + 时间范围 | ✅ 已交付 | `GET /api/cockpit/v1/events/history-analysis`(`eventFamily` 必填 + `startTime`/`endTime`);回显 `Range{type,startTime,endTime}` |
+| B | 趋势时间范围可声明 | ⚠️ 仅剩一处 | `monitoring/situation`、`infrastructure/operating-information`、`events/history-analysis`、`events/handling-efficiency`、`events/multidimensional-analysis` 均已支持范围参数;`overview/warning-settings` 仍只有 `mode`(→ 见 BT-3) |
+| C | 响应/关闭时长聚合 | ✅ 已交付 | `GET /api/cockpit/v1/events/handling-efficiency`:`averageDuration{value,unit,sampleCount,state,reason}` + `durationTrend[]` + 工单 `durationHours`;口径已冻结 `basis = CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS` |
+| D | 应急保障域三件套 | ✅ 已交付 | `GET /api/cockpit/v1/infrastructure/emergency-support` |
+| E | 管理信息域 | ✅ 已交付 | `GET /api/cockpit/v1/infrastructure/management-information` |
+| F | 设施/行业运行信息 | ✅ 已交付 | `GET /api/cockpit/v1/infrastructure/operating-information` |
+| G1 | 全局行业筛选落到地图点位 | ❌ 未交付(且路径待裁定) | `map-snapshot` 入参仍只有 `page/pageSize/snapshotAt`;但 `/monitoring/distribution`(#37)已带 `industry`/`deviceType`/`pointStatus`/`region` 与逐点 `industry`/`deviceType`(→ 见 BT-2) |
+| G2 | 点位摘要补 `industry`、类型、徽标 | ❌ 未交付 | `CockpitMonitoringPointSummary` 仍只有 `pointCode/pointName/pointStatus/statusReasons/longitude/latitude/coordinateValidation` |
+| G3 | 未关联设备补 `industry`、设备类型 | ✅ 已交付 | `CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker` |
+| G4 | 设备编码回联点位(WS 增量可用) | ❌ 未交付 | 四个增量 DTO 均只有 `deviceCode` + 状态,无 `monitoringPointCode`(→ 见 BT-1) |
+| H | 跨设备设备状态清单 | ✅ 已交付 | `GET /api/cockpit/v1/monitoring/situation`:`abnormalEquipment[]{deviceCode,deviceName,deviceType,industry,region,abnormalType,status,statusReasons,longitude,latitude,coordinateValidation}` + 分页 + `mapFilter.deviceCodes` |
+
+另有 #43 交付的**移交与联调手册**:`docs/cockpit/README.md`(契约总则、字典冻结、端点清单、分页/快照/错误口径、WebSocket 增量、启动与联调检查表、容器化评估、已知未验证项)。这份手册本身已解答了票 25 里「字段名、语义、字典」类的问题,不需要再建票。
+
+## 二、真实数据接线状态(程序级缺口)
+
+后端目前交付的是**契约**,不是**数据**:
+
+- 23 个 cockpit 适配器接口中,**22 个只装配了 `Empty*Adapter`**(`@Service`,直接返回 `...Snapshot.empty(...)`)。
+- 唯一接入真实数据的是工单读模型:`BusinessCockpitWorkOrderReadAdapter`(`@Primary`,覆盖 `EmptyCockpitWorkOrderReadAdapter`)。
+- 各切片在 `docs/cockpit/` 下的 `*-demo.html` 用的是内嵌 fixtures,不代表真实库数据。
+- 计数口径:若不计 `/foundation` 契约自检,则为 22 个读模型接口 / 21 个空实现。
+
+后果:前端把适配层切到真实接口后会拿到**全空快照**(200 + `freshness.state = EMPTY`),mock 阶段不受影响。这项工作需要一个程序级排期(→ 见 BT-5)。
+
+## 三、建议新建的后端工单
+
+以下每节即一张工单草案,标题与正文可直接复制到 Gogs(`shaoy/pipe-ner-server`)。顺序即建议优先级。
+
+### BT-1 Cockpit device-to-point linkage(REST + WebSocket)→ Gogs #59 ✅ 已交付
+
+**背景与证据**
+
+- `CockpitMapSnapshotService.monitoringPoint()` 只把 `CockpitMapSnapshot.SourceMonitoringPoint.devices` 用于解析点位状态,没有写进响应;`CockpitMonitoringPointSummary` 没有任何设备字段。
+- WS 增量只带 `deviceCode`:`CockpitMonitoringDistributionDeviceStatusDelta`、`CockpitMonitoringOverviewDeviceStatusDelta`、`CockpitMonitoringSituationDeviceStatusDelta`、`CockpitOverviewMonitoringDeviceStatusDelta` 四个 DTO 均无 `monitoringPointCode`;`DeviceStatusWebSocketHandler` 全文无点位字段。
+- 事件读模型的行也没有归属点位:`CockpitCurrentEventsResponse.EventItem` 与 `history-analysis` 的 row 都缺 `monitoringPointCode`。
+- 现有可用但代价高的替代路径:`GET /monitoring-points/{monitoringPointCode}` 的 `devices[].deviceCode`(逐点 N+1)。
+- 后端内部已持有该映射(`SourceMonitoringPoint.devices`;`applyDeviceStatusDelta` 能按 `deviceCode` 解析到点位),因此改动量集中在响应层。
+
+**要做什么**(建议 ①+②,③ 视事件面板需要)
+
+1. `CockpitMonitoringPointSummary` 增加 `deviceCodes`(关联设备编码列表),并顺带补 `industry` 与 `typeCode`。
+2. WS 增量帧增加 `monitoringPointCode`(未关联设备为 `null` / 不出现)。
+3. 事件读模型行增加 `monitoringPointCode` 与 `monitoringPointName`。
+
+**验收标准**
+
+- 点位摘要返回关联设备编码;同一 `snapshotAt` 下与 `/monitoring-points/{code}` 的 `devices[]` 一致,且不产生重复点位。
+- WS 增量给出 `monitoringPointCode`;未关联设备显式 `null`,不静默丢失。
+- 事件行返回 `monitoringPointCode`,无归属时为 `null`。
+- 契约测试覆盖 REST 与 WS 两侧;`docs/cockpit/README.md` 与字段字典同步更新。
+
+**解除阻塞的前端票**:15(WebSocket 状态联动)、08(地图摘要)、19(当前预警 / 历史分析上图联动)。
+
+### BT-2 全局行业筛选落图:路径裁定 + 落地 → Gogs #60 ✅ 已交付(走路径①完整)
+
+**背景与证据**
+
+- `CONTEXT.md` 定义行业筛选是作用于整个驾驶舱的全局条件。
+- `GET /map-snapshot` 没有 `industry` 入参;`CockpitMonitoringPointSummary` 也没有 `industry`/类型字段,服务端与客户端都无从过滤。
+- 但 `GET /monitoring/distribution`(#37)与 `GET /monitoring/overview` 已支持 `industry`/`deviceType`,distribution 还逐点返回 `industry`/`deviceType` 并给 `mapFilter{industry,deviceType,pointStatus,region,pointCodes,deviceCodes}`。
+
+**要做什么**(二选一,并在 `docs/cockpit/README.md` 第 3 节写明结论)
+
+1. 给 `map-snapshot` 增加 `industry`(以及 `deviceType`)入参,并给 `CockpitMonitoringPointSummary` 补 `industry`/`typeCode`;或
+2. 正式声明地图筛选以 `/monitoring/distribution` 为准,`map-snapshot` 保持纯分页快照,前端改取数路径。
+
+**验收标准**
+
+- 决策结论写入契约手册,前端不需要猜路径。
+- 选定路径端到端可用:服务端完成过滤,`mapFilter` 回显实际生效范围,分页与 `snapshotAt` 语义不变。
+
+**解除阻塞的前端票**:18(运行监测面板,含验收项「面板筛选可作用于地图点位」与监测点台账)、08(地图摘要)。
+
+### BT-3 warning-settings 趋势窗口可声明 → Gogs #61 ✅ 已交付
+
+**背景与证据**:其余切片都已接受时间范围并回显 `Range`,只有 `GET /overview/warning-settings` 仍只有 `mode`;`CockpitOverviewWarningSettingsResponse.Range{type,start,end}` 结构已存在,窗口目前固定。
+
+**要做什么**:给该端点增加 `range` / `startTime` / `endTime` 入参,填充 `Range` 与 `trend[].bucket`,沿用兄弟切片的范围语义。
+
+**验收标准**:非法范围返回 400 `INVALID_REQUEST`;`range` 回显实际生效区间;趋势桶跟随窗口变化;契约测试与手册同步。
+
+**解除阻塞的前端票**:20(态势总览近 7 日趋势)、总体概览的预警设置面板。
+
+### BT-4 事件行补 coordinateValidation 与报警阈值 → Gogs #62 ✅ 已交付
+
+**背景与证据**:字典已冻结「无效或未验证坐标 `renderable=false`,禁止渲染」,`CockpitMonitoringDistributionResponse`、`CockpitMonitoringSituationResponse` 等都已携带 `CockpitCoordinateValidation`;但 `CockpitCurrentEventsResponse.EventItem` 只有 `longitude`/`latitude`,没有 `coordinateValidation`,事件因此无法判定能否上图。A1 原建议中的 `thresholdMinValue`/`thresholdMaxValue`(仅报警族)同样缺失。
+
+**要做什么**:事件读模型(`events/current` 与 `events/history-analysis`)的行补 `coordinateValidation`;报警族在数据可得时补阈值字段。
+
+**验收标准**:每条事件携带 `coordinateValidation`(`state`/`reason`/`renderable`/`crs`);坐标缺失或未验证时 `renderable=false` 且有 reason;契约测试与手册同步。
+
+**解除阻塞的前端票**:19(当前预警上图、历史分析定位)。
+
+### BT-5 真实数据接线(程序级) → Gogs #63
+
+**背景与证据**:见上文「二、真实数据接线状态」——23 个适配器接口中 22 个仍为空实现(不计 `/foundation` 自检则 21/22)。
+
+**要做什么**:按切片补真实适配器(复用既有表与服务,遵循 `cockpit-backend-read-contracts` spec 的「先组合既有表与服务,测量后再加投影/索引」)。建议优先级:先地图与监测链路(`map-snapshot`、`monitoring/distribution`、`monitoring/overview`、`monitoring/situation`、`monitoring-points/{code}`),再基础设施三切片,最后预警处置四切片(其中工单链路已接)。
+
+**验收标准**:每个切片存在非空适配器并被 Spring 装配;用真实库跑通 `docs/cockpit/README.md` 第 6 节的联调检查表 1–7;`snapshotAt` 分页稳定、监测点不重复;空结果仍走 200 + `freshness.state=EMPTY`,不用 5xx 表达空。
+
+**解除阻塞**:所有前端票「接真实接口」的环节(mock 阶段不受阻)。
+
+### BT-6(可选,待确认)situation 支持 monitoringState 过滤
+
+**背景**:H 的原始需求是「跨设备设备状态清单(如离线设备清单)」。`/monitoring/situation` 已给出分页的 `abnormalEquipment[]`,但它按 `industry`/`deviceType`/时间过滤,没有 `monitoringState` 入参。
+
+**要做什么**:若 `operationOverview` 的离线设备清单需要按状态取数,给 `situation` 增加 `monitoringState`(六态字典)入参。
+
+**验收标准**:传 `OFFLINE` 只返回离线设备;`mapFilter` 回显;不改变默认行为。
+
+**备注**:若 `abnormalEquipment` 已满足面板需求,此票不必建。
+
+## 四、需要业务方口径(不是后端工单)
+
+1. **「故障」是什么?** 字典现状:点位六态 `ALARM/WARNING/WORK_ORDER/NORMAL/OFFLINE/EMPTY`;指标六态 `NORMAL/WARNING/ALARM/EXPIRED/OFFLINE/UNKNOWN`,且明确 `EXPIRED` 不与 `OFFLINE` 合并;`situation.abnormalEquipment.abnormalType` 是异常分类。需业务确认「故障」是否等于这些集合的子集,还是要一个独立桶。
+2. **「处置率」口径**:后端已冻结 `basis = CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS`(已结案工单数 ÷ 事件总数,见 `docs/cockpit/README.md` 第 2 节)。需业务确认这是否即所说的处置率(含已取消事件)。
+3. **「管理单位」与 `maintainer`**:`management-information` 以 `organizations` + `ownershipDistribution` + `unmaintained` 建模,与维护单位分开。需业务确认这一区分并回写术语表。
+
+## 五、给项目经理的决策点
+
+1. ~~G1 的落图路径二选一(BT-2 的两个方案)。~~ **已裁定(2026-09-18)**:走路径①——地图以 `map-snapshot` 为准,且补全 `industry`/`region`/`deviceType` 三入参与 `mapFilter` 回显;结论见 `docs/cockpit/README.md` 第 3 节。
+2. ~~G2/G4 的实现取一或全做(BT-1 的 ①②③)。~~ **已全部实现**:BT-1(#59)交付 G4 三件套,BT-2(#60)交付 G1+G2。
+3. 真实数据接线的排期与责任人(BT-5 #63)——这是当前**唯一**剩下的后端缺口(BT-1…BT-4 均已交付关闭)。
+4. 提醒:后端本地 `#43` 未推送,远端仍是 `#42`;联调前请确认部署来源提交。
+5. 提醒:各切片 `*-demo.html` 是 fixtures,不是真实数据;手册第 7/8 节的容器化与性能均为未验证项。

+ 46 - 11
.scratch/cockpit-shell-panel-layout/issues/04-config-and-credentials.md

@@ -1,20 +1,55 @@
-# 04: 配置与凭据注入
+# 04: 配置与凭据注入
 
 **What to build:** 通过部署环境或后端运行时配置注入后端地址、天地图、Cesium、WebSocket 等配置与凭据。前端不硬编码账号、密钥或环境地址。
 
 Blocked by: 01
 
-Status: ready-for-agent
+Status: resolved
 Labels: ready-for-agent
 
-- [ ] 配置统一由配置服务读取,页面不直接拼接环境地址。
-- [ ] 后端接口、天地图、Cesium、WebSocket 均可由配置覆盖。
-- [ ] 真实接口的 `Authorization: Bearer <JWT>` 由部署层注入(生产网关补头,开发本地反代或部署配置提供只读 token);适配层统一附加,页面与 store 不触碰凭据。
-- [ ] 凭据缺失时不进入登录页,而是显示数据不可用状态。
-- [ ] 数据源默认 mock;真实接口由部署配置显式开启,两者走同一适配层。
-- [ ] 配置加载成功、失败和缺失三种状态可演示。
-- [ ] 有配置加载纯逻辑单测。
+> 请勿据标签重复接单:本票已完成并有关闭记录,`Labels` 保留只是为了让面板不把它退化成 `needs-triage`。
 
-## 进度:0%
+- [x] 配置统一由配置服务读取,页面不直接拼接环境地址。
+- [x] 后端接口、天地图、Cesium、WebSocket 均可由配置覆盖。
+- [x] 真实接口的 `Authorization: Bearer <JWT>` 由部署层注入(生产网关补头,开发本地反代或部署配置提供只读 token);适配层统一附加,页面与 store 不触碰凭据。
+- [x] 凭据缺失时不进入登录页,而是显示数据不可用状态。
+- [x] 数据源默认 mock;真实接口由部署配置显式开启,两者走同一适配层。
+- [x] 配置加载成功、失败和缺失三种状态可演示。
+- [x] 有配置加载纯逻辑单测。
 
-未动工。等待按 Blocked by 依赖顺序实施。
+## 进度:100%
+
+**已完成并关闭(2026-09-17)。** 后端地址、天地图、Cesium、WebSocket 与凭据都收进配置服务,前端零硬编码;默认 mock,真接口要部署配置显式打开;凭据只在适配层出现一次。
+
+### 与维护者确认的两条前置决策
+
+- **注入方式:运行时分层**(后者覆盖前者,深合并):内置默认 → 构建期 `VITE_COCKPIT_*` → `<base>cockpit/config.json` → 宿主注入 `globalThis.__COCKPIT_CONFIG__`。
+- **联调凭据**:仓库只放 `public/cockpit/config.example.json`;真实 `config.json` 进 `.gitignore`;本机只读令牌写进它或 `.env.local`(也已 gitignore),生产一律由网关补头。
+
+### 改动
+
+- 新增 `domain/configStatus.js`:配置/数据源/凭据词汇 + 「状态 → 色键/文案」纯函数映射;另给票 05 留了 `isCredentialFailure()` 与统一文案 `DATA_UNAVAILABLE_LABEL`。
+- 新增 `services/cockpitConfig.js`:四层解析、宽松校验(错字段退回默认并记 issue,不整份作废)、三态 `ready/missing/failed`、同步读 `getCockpitConfig()`;**没有任何配置来源时一个请求都不发**(原因见下方「一个坑」)。
+- 新增 `services/cockpitApi.js`:适配层接缝。`planCockpitRequest()` 是全仓唯一拼地址、唯一附加 `Authorization` 的地方(且只在 real 分支加);`requestCockpitJson()` 是 mock/real 唯一出口,失败一律收敛成 `{ok:false, reason}`,不抛异常、不跳登录页;mock 处理器注册表留给票 05 挂领域样例。
+- 新增 `public/cockpit/config.example.json`(部署模板);`.gitignore` 忽略 `public/cockpit/config.json`;`public/cockpit/README.md` 补「部署配置」一节:四层、三态、凭据策略、**部署后怎么改无需重新构建**。
+- 外壳顶栏加配置标签(复用票 06 的 `StatusTag`):`data-config-state` / `data-data-source` / `data-source` 供走查断言;界面只显示状态,不显示任何令牌。
+- 配置真被消费,不是摆设:`map.cesium.enabled=false` → 地图服务不启动引擎并如实标注 `disabled`(中央区域留临时影像兜底);`map.cesium.baseUrl` → 覆盖 `CESIUM_BASE_URL`(`cesiumAssets.ensureCesiumBaseUrl(override)`)。
+- 未动 `MapCanvas`、面板与 store:它们连配置服务都不直接 import,页面只读外壳给的视图模型。
+
+### 验证证据
+
+- **纯逻辑**:`node .scratch/cockpit-shell-panel-layout/verify/t04-config.mjs` → 通过。覆盖三态、四层来源与覆盖顺序、宽松校验、载荷判定、请求计划、mock/real 同一出口、假 401 归类;含两条**静态守卫**:8 个页面/store 文件零 `Authorization`/凭据字样、15 个模块文件零登录跳转,且 `VITE_COCKPIT_*`/`__COCKPIT_CONFIG__` 只在配置服务里读。
+- **真机走查**:`node .scratch/cockpit-shell-panel-layout/verify/t04-config-walkthrough.mjs` → **9/9**:默认缺失态(内置 mock、无页面报错)、宿主注入成功态(real)、运行时文件成功态(`source=file`,真走 HTTP)、坏文件失败态(红色「数据不可用」、URL 不变、页面里没有登录入口)、`cesium.enabled=false` 生效;并断言**注入的令牌不出现在 DOM/顶栏**。
+- **回归**:`t01-browser-walkthrough` **20/20**(A11「无页面级报错」保持绿)、`t01-panel-catalog` / `t03-camera-contract` / `t06-assets` / `t28-fill-container` 全通过、`yarn build` **exit 0(55s)**。
+- **归因(对抗式)**:`t03-camera-walkthrough` 有 2 项红(A5/A8:中心点投影)。把本票三个改动文件 `git stash` 回 HEAD 复跑,**同样两条、同样数字**(投影 827,417 vs 画布中心 840,520)→ 属票 03 在途工作(claimed / 90%),与本票无关;随后已 `stash pop` 还原。
+
+### 一个坑(值得记住)
+
+dev 下 Vite 的 HTML 回退只对 `Accept: text/html` 生效;配置服务发的是 `Accept: application/json`,所以「没有配置文件」拿到的是**真 404**(Chrome 记一条 console error),不是 200 HTML。第一版按「默认总去探测 `cockpit/config.json`」实现,实测把 `t01` 的 A11 打红;改为**没有显式配置 URL 就不发请求**(此时 `missing` 表示「没有任何配置来源」),文件层由部署侧显式开启,三种等价写法:宿主页一行 `window.__COCKPIT_CONFIG_URL__ = 'auto'`、构建期 `VITE_COCKPIT_CONFIG_URL=auto`、或直接给绝对 URL 从后端拉(`auto` = 按应用基址取约定路径,qiankun / 子目录部署下也对)。README 已写清。
+
+### 边界与遗留
+
+- 天地图与 WebSocket 目前只有配置位与文档化的消费点(`map.tianditu.token` → 票 21;`ws.url`/`ws.token` → 票 15);本票保证它们**能被配置覆盖**,真正使用落在那两票。
+- 「401/凭据类失败只把**分区**标成数据不可用」的渲染归票 05(其验收原句);本票已提供 `REQUEST_REASON.UNAUTHORIZED`、`isCredentialFailure()`、统一文案,并用假 401 在逻辑脚本里断言过。
+- 为放部署模板,票 06 的 `t06-assets.mjs` E1「public/cockpit 下每个资源都有引用点」需把 `*.example.json` 与 README 一视同仁(模板由运维复制,代码永不引用):已按此最小修改,`aerial.webp` 的引用清单照旧。
+- 本票改动**未提交**(工作区里另有票 03 会话的提交 `71c55bc` 与在途改动)。

+ 62 - 11
.scratch/cockpit-shell-panel-layout/issues/05-mock-domain-adapter.md

@@ -1,20 +1,71 @@
-# 05: Mock 领域适配层
+# 05: Mock 领域适配层
 
 **What to build:** 建立驾驶舱领域适配层,让 mock 数据与真实接口使用同一契约。页面组件只消费领域对象,不直接依赖后端字段。
 
 Blocked by: 04
 
-Status: ready-for-agent
+Status: resolved
 Labels: ready-for-agent
 
-- [ ] 存在可切换的 mock 数据源;默认 mock,真实接口由部署配置显式开启。
-- [ ] 真实模式下适配层统一附加部署层注入的 `Authorization` 请求头。
-- [ ] 接口字段到领域对象的转换集中在适配层。
-- [ ] 页面和 store 不直接处理后端裸字段。
-- [ ] mock 数据覆盖成功、空数据、字段缺失、失败和过期样例。
-- [ ] 401 或凭据类失败只把受影响分区标为数据不可用,保留已成功分区与地图骨架。
-- [ ] 有适配器转换单测。
+- [x] 存在可切换的 mock 数据源;默认 mock,真实接口由部署配置显式开启。
+- [x] 真实模式下适配层统一附加部署层注入的 `Authorization` 请求头。
+- [x] 接口字段到领域对象的转换集中在适配层。
+- [x] 页面和 store 不直接处理后端裸字段。
+- [x] mock 数据覆盖成功、空数据、字段缺失、失败和过期样例。
+- [x] 401 或凭据类失败只把受影响分区标为数据不可用,保留已成功分区与地图骨架。
+- [x] 有适配器转换单测。
 
-## 进度:0%
+## 进度:100%
 
-未动工。等待按 Blocked by 依赖顺序实施。
+**已完成并关闭(2026-09-18)。** 逻辑闸门 **178/178**、真机走查 **10/10**,维护者已开启服务并要求跑测(当时撞上沙箱 `spawn EPERM`,经批准后放行执行)。
+
+### 交付边界(与维护者确认,2026-09-18)
+
+Q1 裁定 **(B) 接缝 + 共享详情域**:票 05 交出「领域词汇 + 转换机制 + common 详情域适配器」,16 个**面板级**聚合留给 17–20(它们各自的验收原句就写着「有面板数据适配和联动单测」)。
+其余裁定:Q2 分区状态词汇照搬后端四态 + 前端特有 `unavailable`;Q3 mock 照抄后端真实字段名;Q4 做「纯逻辑 + 最小可演示」;Q5 不新开 ADR。
+
+### 改动
+
+**领域词汇与工具**(`domain/`,纯函数、可脱浏览器断言,不 import service)
+
+- `sectionState.js`:分区状态词汇。后端 `CockpitSectionState` 四态(`SUCCESS`/`EMPTY`/`STALE`/`FAILED`)**1:1** 映射,另加前端特有 `UNAVAILABLE`(凭据/链路类失败,对应票 04 的 `isCredentialFailure()` 与统一文案)。`STALE`(有值但过期,`retained:true`)与 `EMPTY`(确实没有值)严格分开 —— 这是票 16 两条验收原句的落点。`sectionFromResult()` 是唯一收口,`sectionView()` 给色键与文案,`overallState()` 让 `partial` 可与全坏区分。
+- `requestReason.js`:`REQUEST_REASON` 从 `services/cockpitApi.js` 提到 domain。原因:`sectionState` 要按失败原因判「数据不可用」还是「加载失败」,而 domain 反向 import service 会层次倒挂。`cockpitApi.js` 仍转出该常量,**票 04 的脚本与调用点零改动**。
+- `fields.js`:字段读取器。核心是区分「本该有却没有」(记 `missing`,界面显示占位符)与「本来就允许为空」(静默 `null`);缺失一律回 `null`,**绝不用 0 冒充**,而后端真给的 0 是有效数据。
+- `mapSummary.js`(票 07/08 的输入)、`pointDetail.js`(票 09/10)、`equipmentDetail.js`(票 11)、`workOrder.js`(票 11/12)、`events.js`(票 13/14/19)、`freshness.js`(票 16):逐字段转换,字段名以 `E:\pipe-ner-server\docs\cockpit\*.html` 与 `research/panel-field-coverage.md` 为准。
+
+**服务层**(ADR 0005 的领域拆分)
+
+- `domainRequest.js`:三个领域模块共用的「拼查询串 → 走票 04 的 `requestCockpitJson` → 收成一个分区」。
+- `mapApi.js` / `detailApi.js` / `runtimeApi.js`:领域接口。**每个分区各自成败**,调用方拿到的是一组并列 `section()` 而不是共用成败的详情对象。
+- `cockpitApi.js`(改):新增 `findMockTransport()` 通配匹配,让带路径参数的接口(`/equipments/*/detail-aggregate`)也能 mock;mock 处理器可在异常时声明失败原因,但只认白名单取值。
+- `mocks/contractMocks.js` + `mocks/index.js`:契约化 mock。字段名与嵌套形状**照抄后端演示载荷**,值用边界值构造(坐标 VALID/UNVERIFIED/INVALID/ZERO、指标真 0、未结案 `null`)。场景开关走宿主全局键,**不改代码不重新构建**即可演示五类样例与 401。
+
+**最小可演示**(Q4=B)
+
+- `components/SectionStateTag.vue` + `stores/detailStore.js`(ADR 0004 的 `detailStore`)+ `CockpitShell.vue` 筛选条右侧的分区状态板:三条共享分区各带 `data-section-state` / `data-section-name` / `data-section-tone` / `data-section-retained`,走查可直接断言。**只有 mock 模式才自动取数**:real 模式后端未就位,自动取数只会刷 404(沿用票 04「没有显式配置就不发请求」的立场)。
+
+### 验证证据
+
+- **纯逻辑**:`node .scratch/cockpit-shell-panel-layout/verify/t05-adapter.mjs` → **178/178 通过**。四段:A 分区状态(四态映射、401 归类、过期 vs 空、部分可用、视图映射、**界面文案零原因码泄漏**);B 领域转换(用后端演示载荷的真实字段名喂入,含 0 值/缺失/null-time/超范围坐标/七态/回退动作/阈值只在报警/未知配色);C 契约化 mock(五类样例逐一取数、401 与前缀隔离、real 分支同一适配层且带凭据、假 401、坏载荷、查询串、缺编码不发请求、WS 增量帧两个字段名、注册幂等);D 静态守卫(10 个页面/store 文件零后端裸字段、零凭据、零登录跳转;8 个领域模块不自己发请求、不 import service)。
+- **真机走查**:`node .scratch/cockpit-shell-panel-layout/verify/t05-adapter-walkthrough.mjs` → **10/10 通过**(`yarn dev` @ localhost:8000,维护者开启并要求跑测)。逐条:默认三条分区 `success`+`lv4`、引擎 `ready`、宿主唯一、四面板齐全、零页面级报错;空数据样例显示「无数据」中性色且不误报失败;过期样例 `stale`+`lv2`+`retained=true` 且文案含「已过期」;失败样例三分区 `failed` 而外壳/四面板/地图骨架全保留;**401 只标受影响分区**(设备详情 `unavailable`+`lv1`+「数据不可用」+「凭据未通过校验(由部署层注入)」,地图摘要与新鲜度仍 `success`)、地图骨架未被牵连、不跳登录页、零页面级报错。截图归档在 `verify/walkthrough/t05-sections-{success,401}.png`(走查产物,gitignore)。
+- **回归**:`t01-panel-catalog` / `t03-camera-contract` / `t04-config` / `t06-assets` / `t21-terrain` / `t29-basemap` **全部通过(exit 0)**(票 04 的凭据守卫与既有断言未受影响)。
+- **布局**:分区板接入后实测筛选条仍为 34px 行高、`scrollWidth === clientWidth`(无横向溢出)、文档无横向滚动。
+- **编译**:`@vue/compiler-sfc` 对 `CockpitShell.vue` 与 `SectionStateTag.vue` 的 template 与 `<script setup>` 均解析通过,绑定无悬空。
+- **单测直接证到的关键行为**:档案分区失败时指标分区仍是成功(票 11 验收第 3 条);401 只打掉被选中的那一个分区,同一次加载里地图摘要与新鲜度照常成功、`overallState` 为 `partial`;过期分区 `retained:true` 且保留旧值;空分区 `data:null`;未注册接口回 `mock-handler-missing` 而不是静默成功。
+
+### 真机走查抓到的两个真 bug(第一轮 7/10 → 修复后 10/10)
+
+这正是「渲染侧不能只靠纯逻辑」的实证——两条逻辑闸门全绿时它们照样存在:
+
+1. **分区标签把状态词顶掉了(真 UI bug)**:`SectionStateTag` 原先用 `labelOverride` 让分区名**替换**状态文案,结果过期分区只显示「显示最后一次成功数据,不是实时值」、不可用分区只显示「凭据未通过校验」——**肉眼与断言都读不出「已过期 / 数据不可用」**,而成功态与失败态长得几乎一样。修法:分区名与状态词**两者都渲染**(`label` + `sectionView().label`),状态词永不被顶掉。修在组件里,所有分区同享。
+2. **界面漏出内部原因码**:失败分区的 `detail` 直接回传 `reason`,截图上出现「数据加载失败**unavailable**」。修法:`sectionState.js` 新增 `reasonDetail()` 字典,六种原因各有中文说明、**认不出的原因回空串**(宁可只说「数据加载失败」,也不把接口代号甩给值班人员),并补 `A9-no-reason-code-in-ui` 静态断言防回归。
+
+另有一条**属走查脚本自身的时序问题**(非产品缺陷):`A1-map-skeleton-alive` 在引擎仍 `booting` 时就读断言(`engine=booting` → 误判红)。修法与 `t04`/`t29` 一致——先等 `dataset.mapEngine` 落到终态再断言。
+
+### 边界与遗留
+
+- `runtimeApi.js` 只交 REST 侧与**增量帧→领域事件**的转换;连接、重连、按 `serverUpdatedAt` 丢弃旧消息归票 15(其票面原句),本票不持有连接。
+- 事件的地理位置仍是契约缺口(后端只有文本 `location`,无经纬度):转换层已留 `longitude`/`latitude` 位并如实回 `null`,补契约见票 25。
+- 面板级(16 个二级面板)领域适配按 Q1=B 留给 17–20;本票只交共享详情域。
+- `PanelPlaceholder` 仍是各面板体内占位 —— 面板内容归 17–20,本票只把**共享分区状态**接上界面。
+- 本票改动**未提交**(工作区另有票 03/30 会话的在途改动)。

+ 111 - 9
.scratch/cockpit-shell-panel-layout/issues/07-coordinate-validation.md

@@ -1,18 +1,120 @@
-# 07: 坐标校验与无地图位置
+# 07: 坐标校验与无地图位置
 
 **What to build:** 对后端发布坐标做防御性校验。非法、缺失、零值、超范围或未校验坐标的对象不上图,但保留在列表和详情中,并显示明确原因。
 
 Blocked by: 08
 
-Status: ready-for-agent
+Status: resolved
 Labels: ready-for-agent
 
-- [ ] 缺失、非法、零值、超范围和未校验坐标均不上图。
-- [ ] 对象仍出现在列表和详情中。
-- [ ] UI 显示“无地图位置”或具体坐标原因。
-- [ ] 前端不自动纠偏、不使用默认坐标。
-- [ ] 有坐标校验纯逻辑单测。
+> 认领:2026-09-18 会话接手。前置 08(地图摘要分页与快照)已 resolved,依赖已解除。
+>
+> 实施前按 grilling 澄清了 6 个悬而未决的取舍(票面语气是「不上图 / 保留在列表」,而地图上今天**一个点位都没画**、
+> 列表也还没建),维护者裁定:Q1=只交**数据面准入判定**(渲染归 09/22);Q2=(c) 但以数据面为主体,
+> 另加一个地图上的可见清单作为本票的唯一可见证据;Q3=按推荐(只认后端 `renderable`);Q4=B(用后端**全量**口径);
+> Q5=逻辑闸门 + 真机走查两件套;Q6=依赖已解除。
 
-## 进度:0%
+## 验收清单
 
-未动工。等待按 Blocked by 依赖顺序实施。
+- [x] 缺失、非法、零值、超范围和未校验坐标均不上图。
+- [x] 对象仍出现在列表和详情中。—— 交的是**数据面** + 本票可见清单;真列表(票 10 两级详情 / 票 18 台账)接手同一份数据。
+- [x] UI 显示“无地图位置”或具体坐标原因。—— 真机走查 B3 实测三条中文原因逐条渲染。
+- [x] 前端不自动纠偏、不使用默认坐标。
+- [x] 有坐标校验纯逻辑单测。—— `t07-coordinate.mjs` **39/39**;真机走查 **20/20**。
+
+## 进度:100%
+
+**已完成并关闭(2026-09-18)。** 逻辑闸门 **39/39**、真机走查 **20/20**;
+回归 t05(179/179 + 走查 10/10)、t08(44/44 + 走查 16/16)、t01 走查 20/20、t28 7/7、t29 6/6、t30 7/7 全绿。
+
+### 交付
+
+**领域层**(新增 `domain/coordinate.js`,纯函数、可脱浏览器断言)
+
+- 准入判据**唯一**:`isRenderable(item)` 只认后端 `coordinate.renderable === true`。
+  连 `state === 'VALID'` 都不作为备选判据——那是第二套判据,一旦与 `renderable` 分歧(后端将来加新 state),
+  两个答案必然有一个是错的,而错的会画到地图上。票 07 原句「前端不自动纠偏、不使用默认坐标」的代码化落点就在这里。
+- 原因文案三层:后端**整段没给**校验段(票 05 的 `validated:false`)→「坐标未校验」(这是「缺失」,不是「非法」);
+  reason 命中词表 → 具体原因;其余认不出 →「无地图位置」。词表**照抄后端源码**(本机 `E:\pipe-ner-server`
+  的 `CockpitCoordinateValidator`,六个取值:`UNVERIFIED_COORDINATE` / `MISSING_LONGITUDE` / `MISSING_LATITUDE` /
+  `LONGITUDE_OUT_OF_RANGE` / `LATITUDE_OUT_OF_RANGE` / `ZERO_COORDINATE`),保证文案与后端结论一一对应而非前端猜一套。
+- `splitByCoordinate()` 交**两堆**:不上图 ≠ 不存在。被拦下的对象带编码与名称进 `blockedEntries()`,
+  供列表渲染——静默丢掉正是 CONTEXT.md「无地图位置」词条点名禁止的(_Avoid_: 默认坐标点、静默隐藏)。
+- `coordinateOverview(objects, backendSummary)`:界面唯一读的那个对象,把**两个口径并排**给出(见下)。
+
+**两个口径,主语分开(维护者裁定的 Q4=B)**
+
+这是本票最容易骗人的地方,所以分开说、不混成一个数:
+
+- `backend` —— 后端 `coordinateValidation` 汇总,**全量**口径。读后端源码确认:`CockpitMapSnapshotService.load()`
+  用整份 `points`/`equipment` 算完汇总**再**切片分页,所以它不随翻页跳动。
+- `loaded` —— 前端按已加载对象算的自检口径。
+- 二者不相等是**正常**的。原来的漏读:`toMapBatch()` 根本没读这段(票 05 遗留),现如实带出并单测钉住。
+
+**修掉一处会骗人的措辞**:票 08 的摘要文案写「已上图 N 个」而 `N = loaded`(含不上图对象)。
+票 07 起有对象因坐标被拦下,这两个数不再相等——照旧写法会把 5 个说成上图的 5 个(实际只有 2 个画得出来)。
+现在按坐标准入分开报:「已上图 2 个 · 无地图位置 3 个」;调用方没给 `objects` 时(纯文案单测)保持原措辞。
+
+**界面**(`components/MapCoordinateBlocked.vue`,新增)
+
+- 地图上的「无地图位置」清单:计数 + 两个口径各一行 + 可展开的逐对象清单(对象名 + 编码 + **中文具体原因**)。
+- 定位是被左栏逼出来的:中央一张图是满幅背景,左右两栏 `z-index:2` 浮在其上(地图 `z-index:0` 且自成层叠上下文),
+  贴左下的浮块会被 300px 宽的左栏整块盖住 → 居中放在底部信息带之上(`bottom: 100u`,让开图例/标题/状态灯那一条)。
+- `data-map-blocked-*` 供走查断言;原因码只上 `data-*`,界面上只有中文。
+- `MapCanvas.vue` 追加宿主 dataset:`data-map-renderable` / `data-map-blocked` / `data-map-blocked-backend` / `data-map-coordinate-crs`。
+- `stores/mapStore.js` 供货 `coordinate` 计算属性——组件零坐标判据(静态守卫 I3 钉住)。
+
+**mock**(`mocks/contractMocks.js`)
+
+- 响应补 `coordinateValidation` 全量汇总,**照抄后端的三桶分法**(`renderable` 优先,其余里 `state === 'INVALID'` 记非法,
+  剩下的记未校验)——换一套算法就会让界面显示的数与后端对不上。
+- `empty` / `degraded` 两个样例同样带上,供现场演示。
+
+### 验证证据
+
+- **纯逻辑**:`node .scratch/cockpit-shell-panel-layout/verify/t07-coordinate.mjs` → **39/39**。八段:
+  A 唯一准入判据(含两条对抗:`state=VALID` 但 `renderable=false` **必须**不上图;`state=INVALID` 但 `renderable=true` 照画,
+  以及「181° 原值不被纠正」);B 六种原因逐一命中 + `validated:false` 归「缺失」+ 认不出的不泄露原因码;
+  C 切分不静默丢对象(两堆都交出去);D 两个口径分开 + 缺失不补 0;E 汇总进批次且**跨页不累加**;
+  F mock 三桶分法与后端一致;G store 供货;H 「已上图」措辞不再把被拦下的算进去;
+  I 静态守卫(领域层零范围重算、准入只有一处、界面零坐标判据、界面零原因码、后端裸字段不出领域层)。
+- **真机走查**:`node .scratch/cockpit-shell-panel-layout/verify/t07-coordinate-walkthrough.mjs` → **20/20**
+ (`yarn dev` @ localhost:8000,Chrome 152 headless;**先核对四个改动模块确实是最新的**再跑,避免拿旧模块假绿)。逐条:
+ 翻到底后**可上图 2 · 无地图位置 3 · 已加载 5**;清单计数 3 且**对象没被静默丢掉**;
+ 后端全量口径 `3`(非法 2 + 未校验 1)与已加载口径 `2/5` **是两个数、措辞分开**;
+ 摘要文案不再虚报「已上图 5 个」;展开后三条原因**逐条中文**(坐标未经校验 / 经度超出合法范围 / 零坐标(经纬度均为 0)),
+ 原因码只出现在 `data-*`;空样例 `未取到坐标校验汇总`(不编 0);字段缺失样例落「坐标未校验」且**仍被列出**;
+ 清单不出地图宿主、零横向溢出、仍只有一个地图宿主、全程零页面级报错。
+- **回归**:`t08-map-summary` **44/44**(逻辑)+ 走查 **16/16**、`t05-adapter` **179/179** + 走查 **10/10**、
+  `t01-browser-walkthrough` **20/20**、`t28-fill-container` **7/7**、`t29-basemap-walkthrough` **6/6**、
+  `t30-intro-walkthrough` **7/7**,另有 `t01-panel-catalog` / `t03-camera-contract` / `t04-config` / `t06-assets` /
+  `t21-terrain` / `t21-view-modes` / `t29-basemap` / `t30-view-presets` 逻辑脚本全通过。
+- **未运行**:`yarn build`(生产构建)与 git 提交——按 `AGENTS.md` 的验证规则,只在维护者明确要求时执行。
+
+### 对抗式自查抓到的问题(实证)
+
+1. **守卫被自己的注释判红**:静态守卫 I2 第一版查「是否出现 `state === 'VALID'`」,结果命中的是我解释
+   *为什么不用它* 的那句注释。改成先剥注释再断言——拿注释当证据等于没守。
+2. **空样例的汇总其实是空的,不是 0**:票 05 的 `sectionFromResult` 对 `EMPTY` 分区不带载荷,
+   全零汇总在适配层就被放下。我最初断言 `blockedBackend === '0'` 会假红,而措辞写成「后端未发布」
+   更是把责任推错地方(那个 0 后端确实发布过)→ 改为「未取到坐标校验汇总」,断言与文案同步修正。
+3. **走查脚本第一版会假绿**:外壳挂载后只自动取**第 1 批**(票 08 的渐进加载),而 mock 五条按页大小 2 分三批——
+   不翻页就只持有前 2 个对象、看不到后两个坏坐标对象,断言会以为「没有坏坐标」。
+   已补 `advanceUntilSettled()` 真翻页(票 08 踩过同型坑)。
+4. **浮块会被左栏盖住**:见上「定位是被左栏逼出来的」。改为居中放底部信息带之上后,走查 D1 实测
+   `panelInsideHost: true`、零横向溢出,猜测被证据替代。
+5. **走查第一版有一条假绿(跑起来才抓到)**:`data-map-blocked-backend` 这个名字**同时**挂在
+   地图宿主(dataset)与清单里的 `<p>` 上,而宿主在 DOM 里更靠前 —— `querySelector` 命中宿主,
+   拿回的是**整页文本**,于是 `includes('全量')` 恒真。改成面板内选择器 + 给 `<p>` 换
+   `data-map-coordinate-backend/-loaded`,复跑后 A5 的证据才变成真正那一行
+   (`全量可上图 2/5 · 坐标非法 2 · 未校验 1 | 已加载 5 个 · 可上图 2 · 无地图位置 3`)。
+   教训同票 08:**走查绿不够,得知道绿的是什么**。
+
+### 边界与遗留
+
+- **渲染归票 09/22**:本票只交准入判定 + 可见原因,地图标记/图例/管线都不画(维护者 Q1=A 裁定)。
+- **真列表归票 10/18**:本票的清单是「保留在列表里」的早期可见面,等真列表建起来后消费同一份
+  `blockedEntries`,本清单自然退为地图上的辅助提示。
+- **事件坐标**:事件读模型暂无 `coordinateValidation`(后端补契约见 `issues/25` 的 BT-4),票 14 的
+  「无有效坐标时显示原因」需等该契约;本票的判据已可复用(`blockReason()` 对缺校验段给「坐标未校验」)。
+- **未提交**:本票改动留在工作区(工作区另有票 21/27/29/30 会话的在途改动,见 `map.md`)。

+ 57 - 10
.scratch/cockpit-shell-panel-layout/issues/08-map-summary-pagination.md

@@ -1,19 +1,66 @@
-# 08: 地图摘要分页与快照
+# 08: 地图摘要分页与快照
 
 **What to build:** 接入地图摘要分页契约,渐进加载监测点和未关联设备。同一批加载使用相同快照标记,刷新时切换到新快照。
 
 Blocked by: 05
 
-Status: ready-for-agent
+Status: resolved
 Labels: ready-for-agent
 
-- [ ] 地图先显示框架,再按分页批次增加摘要点位。
-- [ ] 分页参数和快照参数由适配层管理。
-- [ ] 同一次加载保持快照一致。
-- [ ] 监测点与未关联设备都能进入地图摘要状态。
-- [ ] 加载中、完成、失败和快照过期状态可演示。
-- [ ] 有分页与快照合并单测。
+- [x] 地图先显示框架,再按分页批次增加摘要点位。
+- [x] 分页参数和快照参数由适配层管理。
+- [x] 同一次加载保持快照一致。
+- [x] 监测点与未关联设备都能进入地图摘要状态。
+- [x] 加载中、完成、失败和快照过期状态可演示。
+- [x] 有分页与快照合并单测。
 
-## 进度:0%
+## 进度:100%
 
-未动工。等待按 Blocked by 依赖顺序实施。
+**已完成并关闭(2026-09-18)。** 逻辑闸门 **44/44**、真机走查 **16/16**,回归 t01/t03/t04/t05(179/179)/t06/t21/t28(7/7)/t29/t30 全部通过、t05 走查 10/10。
+
+### 交付
+
+**领域层**(`domain/mapSummary.js` 追加,纯函数 + 注入取数,可脱浏览器断言)
+
+- `MAP_SUMMARY_PHASE` 六态:`idle / loading / complete / empty / failed / expired`——验收第 5 条的四态全部可演示,`EMPTY`(后端说总数 0)与 `COMPLETE`(确实加载完)严格分开。
+- `mergeMapPage(prev, page)`:跨页合并的核心。三条语义照抄后端实现(`CockpitMapSnapshotService`)而不是演示页的乐观假设:**分页窗口是「监测点 + 未关联设备」一条总序列**(先排满监测点再排设备,跨页横跨两类);按 `kind+code` 去重(重放/重叠切片不得在地图上画两遍,不同 kind 同名编码不合并、无编码脏对象不吞掉);`prev = null` 即换快照,旧对象不残留。
+- `createMapSummaryLoader({ fetchPage, pageSize })`:渐进加载状态机。**第 1 页不带 `snapshotAt`、第 2 页起带第 1 页锁定的标记**(后端 `validatePagination` 的硬要求);响应快照与锁定值不一致立即进 `expired`,绝不把两个快照的点拼在一张图上;失败保持 page 不动 = 重试同一页不跳页;完成态是终态;取数函数抛错收敛成 `failed` 不卡 loading。
+- `summaryView(state)`:六态 → 中文文案 + 色键,内部原因码不上界面(票 05 同一条纪律)。
+- `toMapBatch` 补 `objects` 字段:批次对外就是一条「监测点在前、设备在后」的合并序列,地图不必各写一套分支。
+
+**服务层**(`services/cockpitApi.js`、`services/mapApi.js` 不动语义)
+
+- `REQUEST_REASON.SNAPSHOT_EXPIRED`(`snapshot-expired`):后端把快照失效放在 **400 响应体 `AjaxResult.data.category`**(`CockpitErrorCategory` 大写枚举),不在 HTTP 状态码里——只看状态码会把它和「分页参数不合法」混成一个原因。`readBusinessReason()` 做枚举→词汇映射,只认白名单这一个取值,其它(含 `INVALID_REQUEST`)一律回笼统 `unavailable`。
+- 分区与装载器的词汇翻译只在 `stores/mapStore.js` 一处:`fetchMapSnapshot` 回的是票 05 的 `section()`(**没有 `ok` 字段**),装载器吃 `{ok, data, reason}`——直接塞会恒判失败(逻辑闸门第一轮抓到的接口形状坑),翻译时 `success/empty → ok:true`,其余失败原因原样上提。
+
+**store 与界面**
+
+- `stores/mapStore.js`(ADR 0004 的 `mapStore`):装载器唯一实例。对外只有 `advance()`(取下一批)/ `refresh()`(换新快照、从第 1 页重来)/ `view`(文案色键)/ `settled`(完成或空)——**组件与页面不出现 `page`/`pageSize`/`snapshotAt`**(静态断言守着)。演示页大小取 2(mock 三页翻完 5 个对象,渐进可感)。
+- `CockpitShell.vue`:筛选条右区新增「地图摘要」指示器——实时文案(加载中·第 N 页·已上图 M 个 / 加载完成·N/M / 快照已失效·请刷新…)+ 一个动作按钮(未完 = 「加载下一页」,完成 = 「刷新快照」),`data-map-summary-phase` 供走查断言。
+- `MapCanvas.vue`:装载状态写进宿主 `dataset`(`data-map-summary[-loaded|-total|-snapshot|-page]`),底部状态灯随态变色(加载中橙呼吸/完成绿/失败失效红);DEV 把手新增 `__cockpitMap.mapSummary.{advance,refresh,getState}` 供人工演示与走查驱动。
+- `detailStore.js`:地图摘要分区改由 `mapStore` 供货(同一端点只拉一份、只持一个快照)。**分区映射刻意不把「还没翻完」写成 `STALE`**——票 16 词汇里 STALE 是「有值但过期」,分页未完的数据是新鲜的,说成"已过期"是对值班人员撒谎;取到一部分就是 `SUCCESS`,「翻到第几页」由地图摘要指示器与地图点位数体现。真异常三种如实报:`FAILED` / `STALE+retained`(快照作废)/ `EMPTY`。
+
+**mock**(`mocks/contractMocks.js`、`mocks/index.js`)
+
+- `/map-snapshot` 从「只给一页假分页」改成**按后端真实语义分页**:窗口横跨两类对象、第 2 页起不带快照或快照不一致抛 `SNAPSHOT_EXPIRED`、`complete` 只在末尾为真。走查开关新增 `window.__COCKPIT_MOCK_MAP__ = { pageSize, snapshotAt, expireAfterPage }`——`expireAfterPage: 1` 即演示「取完第 1 页后后端换快照」。
+- t05 的 C1 断言随之更新(旧断言写的是「一次拿到全部」):第 1 页只是总序列的一段(2 个对象、`complete:false`),并补「第 3 页带着第 1 页快照请求、结果一致且 complete」的断言。
+
+### 验证证据
+
+- **纯逻辑**:`node .scratch/cockpit-shell-panel-layout/verify/t08-map-summary.mjs` → **44/44**。八段:A 批次合并(跨页拼接/去重/跨 kind 不合并/换快照清空/长跑护栏 maxBatches+maxObjects);B 演示态词(六态中文文案、零原因码泄漏);C 装载器状态机(同批同快照、渐进、完成即终态、刷新换快照、过期不拼接、失败重试同页、空快照、参数由调用方给);D 适配层(查询串带 `page/pageSize/snapshotAt`、400 里的 `SNAPSHOT_EXPIRED` 与 `INVALID_REQUEST` 分别归类);E 取数抛错收敛;F mock 真实分页(切片、跨两类、缺快照即失效);G 装载器→store→分区状态(部分翻完 = success 而非 stale、未取到不改写分区、刷新回第 1 页、过期保留已上图);H 静态守卫(8 个界面文件零分页/快照参数、参数只在 mapStore 适配处拼、外壳不直接翻页)。
+- **真机走查**:`node .scratch/cockpit-shell-panel-layout/verify/t08-map-summary-walkthrough.mjs` → **16/16**(`yarn dev` @ localhost:8000,服务由维护者开启)。逐条:首屏引擎 ready + 第 1 批已在图上且 `loaded < total`(框架先于全量);逐批推进 2→4→5、终态 complete、批次数 3;**整个加载过程快照标记唯一**;完成后按钮切「刷新快照」;刷新回第 1 页(loaded 5→2,旧快照的点不残留);失败场景分区板 `failed` 而地图宿主与引擎保留;快照过期场景显示「已失效·请刷新」且保留已上图 2 个;空快照显示「没有可上图对象」不误报失败;**1680/1280 两种宽度下筛选条零横向溢出**、零页面级报错。
+- **回归**:t01 / t03 / t04 / t05(179/179,含更新后的 C1 三断言)/ t06 / t21×2 / t29 / t30 逻辑脚本全通过;t28 走查 7/7(外壳填满、无滚动条);t05 走查 10/10(共享分区板、401 只标受影响分区、地图骨架不牵连)。
+
+### 走查抓到并修掉的问题(对抗式自查的实证)
+
+1. **走查脚本自身假绿(第一轮 11/11 是假的)**:`check()` 签名多了一个 `desc` 参数,调用点却是三参——证据字符串被当成"通过"吃掉,所有断言恒真。修签名后立刻暴露三个真失败(起点判定、按钮态时序、刷新等待),逐个修脚本并复核产品行为后才得真 16/16。教训:**走查脚本也要对得了账,绿不够,得知道绿的是什么。**
+2. **「没翻完」误标为「已过期」(设计修正)**:最初把部分翻完的地图摘要分区映射成 `STALE`,走查与复盘发现这既违反票 16 的词汇(STALE=有值但过期),又与地图上的真实进度自相矛盾。改为部分翻完 = `SUCCESS`,进度归地图摘要指示器。
+3. **1280 宽筛选条溢出 +202px(布局回归)**:新增控件后 flex 项不许收缩。给 `__scope-left/right` 补 `min-width:0`、摘要块与分区板显式 `flex-shrink:1`,走查守卫 F1 实测两种宽度 `scopeOverflow: 0`。
+4. **console.log 的 `%` 格式串吞证据**:模板串里的 `%` 走了 console 格式化分支,证据被吞——改用字符串拼接输出。
+
+### 边界与遗留
+
+- 本票交的是「摘要数据进图」的数据面:点位的**视觉渲染**(图标、点位聚合、选中)归票 22(Cesium 管线渲染),坐标校验与「无地图位置」归票 07(`coordinate` 字段已随对象带出,`summarizeCoordinateValidation` 即其计数依据)。
+- 增量更新(WebSocket 推送换快照)归票 15;「手动刷新」按钮的全局语义归票 16,本票的 `refresh()` 是它可复用的入口。
+- 演示页大小 2 是 mock/演示值;真实部署按后端默认 500、上限 1000(`mapStore.MAP_SUMMARY_PAGE_SIZE` 一处可调)。
+- 本票改动**未提交**(工作区另有票 04/05/21/27/29/30 会话的在途改动,见 map.md)。

+ 71 - 9
.scratch/cockpit-shell-panel-layout/issues/09-point-status-legend.md

@@ -1,18 +1,80 @@
-# 09: 点位状态图例与标记
+# 09: 点位状态图例与标记
 
 **What to build:** 将后端解析后的监测点状态渲染为地图标记、颜色和图例。前端不重算点位状态,不把事件等级色混用为点位状态色。
 
 Blocked by: 08
 
-Status: ready-for-agent
+Status: resolved
 Labels: ready-for-agent
 
-- [ ] 监测点标记按后端返回状态渲染。
-- [ ] 正常、预警、报警、处置中工单、离线/无关联设备状态均可表达。
-- [ ] 每种颜色都有文字或形状辅助,不单独依赖颜色。
-- [ ] 地图图例与标记状态一致。
-- [ ] 有状态到视觉表现的单测。
+- [x] 监测点标记按后端返回状态渲染。
+- [x] 正常、预警、报警、处置中工单、离线/无关联设备状态均可表达。
+- [x] 每种颜色都有文字或形状辅助,不单独依赖颜色。
+- [x] 地图图例与标记状态一致。
+- [x] 有状态到视觉表现的单测。
 
-## 进度:0%
+## 进度:100%
 
-未动工。等待按 Blocked by 依赖顺序实施。
+**已完成并关闭(2026-09-18)。** 逻辑闸门 **38/38**、真机走查 **28/28**;回归 16 个既有闸门全绿
+(t01 20/20、t04 9/9、t05 179/179 + 10/10、t06、t07 39/39 + 20/20、t08 44/44 + 16/16、
+t21 9/9 + 10/10、t28 7/7、t29 6/6、t30 7/7)。
+
+### 交付物
+
+- 新增 `src/modules/cockpit/domain/pointStatus.js`:**状态 → 视觉表现的唯一一张表**。
+  六个后端状态(`ALARM/WARNING/WORK_ORDER/NORMAL/OFFLINE/EMPTY`,标签与优先级逐字照抄后端
+  `CockpitMonitoringPointStatus`)各带 `色键 + 形状 + 文字`;另有 `UNKNOWN`(状态未发布)
+  与 `UNASSOCIATED`(无关联设备)两条。地图标记、地图图例、`markerSvg()` 图片都从这一张表取,
+  所以「图例与标记一致」是结构性的,不靠两边各自小心。
+- `services/cesiumEngine.js` 新增点位标记层:`CustomDataSource('cockpit-point-markers')` +
+  billboard(形状 SVG data URL、`CLAMP_TO_GROUND` 贴地形)。引擎层只认 `shape/tone`,不认识业务状态名。
+- `services/mapService.js` 新增 `syncPointMarkersForSummary()` / `getPointMarkerState()` /
+  `clearPointMarkers()`;语义色**从 `.cockpit-shell` 的计算样式读** `--lv1..--neutral`,
+  域层与引擎零色值(票 06 的单一口径)。
+- `components/MapCanvas.vue`:地图图例换成**点位状态图例**(8 行,每行形状 + 文字 + 计数),
+  标记随分页批次增量重画;`StatusTag` 增可选 `shape` 道具(不传仍是票 06 的圆点)。
+- `mocks/contractMocks.js`:新增 `point-statuses` 场景(六态 + 未关联设备 + 一个前端不认识的状态,
+  全部可上图)。**默认样例一个字节未动**,所以票 07/08 的走查数字(`2,4,5` / 可上图 2 · 不上图 3)原样成立。
+
+### 两个只有真机才暴露的 bug(都是视觉探针抓到的,走查当时反而绿)
+
+1. **Cesium 的 `DataSourceCollection.add()` 是异步的**(`Promise.resolve(ds).then()` 里才 push)。
+   同一 tick 内「先写后读」会建出**两个数据源**:写进未注册的 A,再从新建的空 B 读回 0 个。
+   症状极具迷惑性——模块计数与 dataset 都说 8,组件渲染却是「点位标记 0」,
+   而晚一点的 DEV 把手调用(A 已注册)又读回 8,同一页面两套答案。
+   修法:`WeakMap` 按 viewer 缓存数据源实例。
+2. **补画重试封顶 10 秒**:引擎冷启动(动态拉 Cesium 分包 + 地形/影像)比装载器慢,
+   超时后第一批点位再也没画上,只有用户点「加载下一页」才会重新触发。
+   修法:退出判据改成**引擎终态**(ready/failed/disabled/unavailable),不再是"试够次数就放弃"。
+
+第 2 条同时暴露了走查的盲区:它一路在点分页按钮,所以永远能画出标记。
+为此新增走查块 **H**:**一次都不点**,只等引擎 ready,要求标记自己出现。
+
+### 对抗式复验(每条断言都确认过能变红,不是恒真)
+
+- 把 `toneColors()` 改成恒返回一个颜色 → C2/C3 红(**第一版 C2 比的是"图片是否不同",而图片里含形状,
+  所以它当时假绿 25/25**;已改成把颜色从 SVG 里单独抠出来比,并要求逐个等于 token 实测值)。
+- 把图例截成 5 行(标记仍 8 个)→ D2/D3/G1/H2 红。
+- 把 `StatusTag` 的形状去掉 → D4/D6/D7 红。
+- 把所有状态折叠成 `NORMAL` → B1/B2/B3/B5/C4/C7/D3/D5 红。
+
+### 口径说明(供后续票对齐)
+
+- 票面写「处置中工单」,后端枚举的 label 是**「工单处置」**;本票用后端的词,避免前端另造一套业务词汇。
+- 「离线」与「无设备」同为中性灰,靠**形状**区分(空心圆 vs 空心方块),并各自带文字。
+- 地图标记设了 `disableDepthTestDistance: Infinity`:状态点不因地形/建筑遮挡而消失(大屏值守优先)。
+  若将来要"被楼挡住",改这一处即可。
+- 地图上原来的**事件四级图例已移除**(票 09 正文明确不许混用)。事件等级图例归票 13/19;
+  票 14「事件定位」要把事件画到地图上时,需自己带一份事件图例,**不要**直接复用点位状态那 8 行。
+
+### 遗留
+
+- 点位聚合(CONTEXT.md「点位聚合」/ ADR 0002)不在本票验收内:同一位置多点目前各画各的,
+  没有聚合与展开。本票只保证状态词表与图例一致。
+- 地图标记的点击选中归票 10(`entity.id` 已按 `point|<kind>|<code>` 建好,可直接拾取)。
+
+## Answer
+
+**点位状态 → 视觉表现收成一张表(`domain/pointStatus.js`),地图标记与地图图例同源于它;
+前端只渲染后端 `pointStatus`,认不出的状态如实落「状态未发布」而不猜成正常;
+每个状态同时有形状、色键与文字,颜色不是唯一线索;事件等级色不再出现在地图图例上。**

+ 70 - 10
.scratch/cockpit-shell-panel-layout/issues/10-monitoring-point-detail.md

@@ -1,19 +1,79 @@
-# 10: 监测点详情与设备卡片
+# 10: 监测点详情与设备卡片
 
 **What to build:** 点击监测点后加载点位摘要和关联设备卡片。用户选择设备卡片后,再进入设备详情;关联设备不重复绘制为独立地图点位。
 
 Blocked by: 09
 
-Status: ready-for-agent
+Status: resolved
 Labels: ready-for-agent
 
-- [ ] 点击监测点可打开点位摘要。
-- [ ] 点位摘要包含名称、编码、状态、原因和关联设备数量。
-- [ ] 设备卡片顺序由后端返回顺序决定。
-- [ ] 选择设备卡片后进入设备详情。
-- [ ] 关联设备不作为独立地图标记重复渲染。
-- [ ] 有两级详情状态单测。
+- [x] 点击监测点可打开点位摘要。
+- [x] 点位摘要包含名称、编码、状态、原因和关联设备数量。
+- [x] 设备卡片顺序由后端返回顺序决定。
+- [x] 选择设备卡片后进入设备详情。
+- [x] 关联设备不作为独立地图标记重复渲染。
+- [x] 有两级详情状态单测。
 
-## 进度:0%
+## 进度:100%
 
-未动工。等待按 Blocked by 依赖顺序实施。
+**已完成并关闭(2026-09-18)。** 逻辑闸门 **57/57**(`verify/t10-point-detail.mjs`)、
+真机走查 **28/28**(`verify/t10-point-detail-walkthrough.mjs`,**真实鼠标**点地图标记)。
+回归 12 个既有逻辑闸门 + 4 个既有走查全绿(t01 20/20、t05 10/10、t09 28/28、t28 7/7)。
+
+### 交付物
+
+- `domain/detailFlow.js`(新):两级详情的**导航状态机**(`none → point → equipment`,
+  换点位清掉已选设备、返回/关闭收口)+ 摘要与卡片视图。`deviceCards()` **不排序**
+  (验收第 3 条);状态→形状/色键/文字沿用票 09 的 `domain/pointStatus.js` 那一张表,
+  认不出的状态仍落「状态未发布」。
+- `stores/detailStore.js`:新增 `selection`(在点位级还是设备级)、`pointDetail` 分区与
+  `openSelection / openPoint / openDevice / selectDevice / backToPoint / closeDetail`。三处收口:
+  同一个点位重复点**不再取数**;A→B 竞态里**迟到的响应丢弃**(不会把 A 的数据挂在 B 的表头下);
+  **选设备卡片只换层级、不取数** —— 设备详情聚合的加载是票 11 的契约。
+- `domain/pointDetail.js` 补一处:整段 `deviceStatistics` 缺失时记一笔 `missing`,
+  消费方据此区分「后端确实说 0 台」与「后端没给统计」(否则界面会把契约缺口画成「0 台」,
+  违反票 16 的「不显示虚假零值」)。
+- 地图点选:`services/cesiumEngine.js` 的 `pickPointMarkerAt()`(只认带 `pointCode` 的实体,
+  回 `kind/code/status` 身份而不是 Cesium 实体)、`services/pointPick.js`(左键「点一下」与
+  「拖着走」的位移判据;只观察事件、不 `preventDefault`/`stopPropagation`,**票 03 的手感不变**)、
+  `services/mapService.js` 的 `onPointPick()` 订阅 + `pickPointAt()`(引擎未就绪、点到空白一律
+  `null`,不改已有选择),`components/MapCanvas.vue` 订阅后把选择交给 store。
+- `components/PointDetailCard.vue`(新):两级浮层。第一级是五行摘要 + 设备卡片(整块可点);
+  第二级带设备身份与「返回点位」。**第二级的分区内容(档案/指标/预警/报警/工单/图片)留给票 11**。
+  `CockpitShell.vue` 把它作为 `main` 的兄弟浮在两栏之间(地图节点结构不变),栏宽提到
+  `styles/tokens.css` 的 `--column-w` 供两处共用。
+- mock:新增样例 `point-detail-order`(设备卡片**逆状态优先级**排列,专门验「顺序由后端返回顺序
+  决定」);默认样例与既有场景零改动。
+
+### 真机走查断言的链路(28 项里的关键几条)
+
+- **真实鼠标**点地图标记 → 打开点位摘要,且卡片里的点位编码 = 宿主 `data-map-picked` 拾到的那一个;
+- 拖拽 140px **不打开**浮层(左键平移不被新功能打坏);
+- 摘要五要素齐全;状态标签的形状/色键/文字与地图上那个标记**逐个相等**;
+- 设备卡片顺序 = 后端顺序(正常 → 报警 → 预警,界面没有按状态优先级重排);
+- 打开详情前后地图标记数不变、设备编码从不出现在标记计划里;
+- 选卡片进第二级 → 返回第一级(卡片仍在)→ 关闭(标记数不变);
+- 未关联设备的标记点一下就进设备详情(没有点位可摘要,也就不显示「返回点位」)。
+
+### 两个只有真机才暴露的坑(都写进了走查脚本的注释)
+
+1. 相机落位是**阻尼飞行**(跨县要 1~2 秒),飞行途中视口中心还在半路 —— 第一版固定 sleep 后
+   直接点屏幕中心,必然点空(表现为「点不到标记」的假失败)。改成等 `shownPivot` 贴到目标再点。
+2. 贴地标记与「高度 0 的中心」之间有**地形高程差**(实测中心偏移 30px)。所以走查先用引擎拾取
+   在中心附近找到标记的**真实屏幕坐标**,再用真实鼠标点那个坐标 —— 点选链路本身仍是 DOM 事件,
+   不是绕过去调 API。
+
+### 遗留(不在本票验收内)
+
+- 设备详情分区内容归票 11;本票只保证「进入第二级并带出设备身份」。
+- 顺带发现(未改):配置层把未配置的 `map.home` 归一化成 `null`,而 `domain/viewPresets.js` 的
+  `readCoord(null, 默认值)` 会得到 `0`(`Number(null) === 0`)—— 没有配置文件时会「归位到 (0,0)」。
+  真实环境有 `public/cockpit/config.json` 兜着,故本票不动它,记在这里(属票 30 的域)。
+- 点位聚合仍不在任何票的范围内(票 09 的遗留照旧)。
+
+## Answer
+
+**两级详情落地:点位级(摘要 + 设备卡片)与设备级(身份 + 返回)是一条纯函数导航状态机,
+取数只发生在点位级且竞态有收口;地图标记的点选走「观察型」DOM 绑定(拖拽不误触发)与
+只认点位标记的引擎拾取;关联设备从不进入标记计划,所以「不重复上图」是结构性保证,
+不是靠两边各自小心。**

+ 82 - 10
.scratch/cockpit-shell-panel-layout/issues/11-equipment-detail-aggregate.md

@@ -1,19 +1,91 @@
-# 11: 设备详情聚合与分区状态
+# 11: 设备详情聚合与分区状态
 
 **What to build:** 通过设备详情聚合契约加载设备档案、最新指标、预警、报警、工单状态和最新图片。各分区可独立成功、失败、为空或过期。
 
 Blocked by: 10
 
-Status: ready-for-agent
+Status: claimed
 Labels: ready-for-agent
 
-- [ ] 设备详情按设备编码加载。
-- [ ] 档案、指标、预警、报警、工单和图片分区独立渲染。
-- [ ] 任一分区失败时不遮盖其他成功分区。
-- [ ] 空数据、字段缺失和过期状态均有明确 UI。
-- [ ] 最新图片只展示后端指定的一张。
-- [ ] 有分区状态映射单测。
+- [x] 设备详情按设备编码加载。
+- [x] 档案、指标、预警、报警、工单和图片分区独立渲染。
+- [x] 任一分区失败时不遮盖其他成功分区。
+- [x] 空数据、字段缺失和过期状态均有明确 UI。
+- [x] 最新图片只展示后端指定的一张。
+- [x] 有分区状态映射单测。
 
-## 进度:0%
+## 进度:95%
 
-未动工。等待按 Blocked by 依赖顺序实施。
+实现完成、逻辑闸门全绿;**待确认**:真机走查未跑(脚本已写好,见下)。
+
+### 交付物
+
+- `domain/equipmentDetailView.js`(新):六个分区的**界面视图**(纯函数)。
+  面板渲染计划 `EQUIPMENT_SECTION_PANELS`(档案 / 指标 / 预警 / 报警 / 工单 / 图片)、
+  占位符与空/失败/不可用措辞、档案行 / 指标行 / 事件行 / 工单行 / 图片视图,
+  外加三个判据:`sectionHasContent`(过期算有值)、`equipmentDetailMatches`(这份载荷是不是当前这台)、
+  `aggregateUnavailable`(整包失败,见下)。
+- `components/EquipmentDetailSections.vue`(新):六块分区各带自己的状态标签与内容,
+  一块坏了缩成一行提示、其余照常画。
+- `components/PointDetailCard.vue`:第二级由「待接入」占位换成真分区容器。
+- `stores/detailStore.js`:`loadEquipmentAggregate`(按编码取数)+ `deviceLoading`,
+  并把**进设备级**的两个入口(`selectDevice` / `openDevice`)改成自己负责取数。
+- `mocks/contractMocks.js`:新增 `equipment-partial` 场景(档案/工单/图片各自 FAILED,
+  指标/预警/报警 SUCCESS)。原先那段分区级失败样例挂在 `failed` 分支下**永远走不到**
+  (`failed` 在注册层就抛错),所以「任一分区失败不遮盖其它分区」在 mock 上一直无法演示。
+
+### 三条值得记下的判断
+
+**1. 票 10 的「进第二级不取数」是延期,不是契约。** 票 10 票面原文写明「设备详情聚合的加载是票 11 的契约」,
+所以本票把取数收进 `selectDevice` / `openDevice` 这两个**唯一**的进级入口:
+「进了设备级却没取数」在结构上不可能发生,调用方不必记得多调一次。
+竞态收口沿用票 10 的点位级同款(token 丢弃迟到响应),并追加两条:
+已答过的同一台不再重复取(返回卡片列表再点回来不重拉),
+失败/不可用**不算已答**(留重试路径,否则永远没有恢复路径)。
+
+**2. 「整包失败」与「分区级失败」必须分开,否则界面对值班人员撒谎。**
+聚合端点整体读不到时 `data` 为 null、六块一起没数据,若照常渲染六块,
+每块都会说「该分区加载失败(其它分区不受影响)」—— 那句话在只有一块坏时是真的,
+在六块一起坏时是**假的**。所以整包失败走一句实话 + 「重新加载」入口,
+分区级失败(`aggregateState: PARTIAL`、各分区自带 state、`data` 在)才逐块渲染。
+这是本票验收第 3 条的反面,专门写进了 `aggregateUnavailable` 的注释与单测。
+(附带发现:失败分区 `data` 为 null、带不出设备编码,所以该判据**刻意不比对编码**,
+靠 store 的 token 纪律保证「失败态必定属于当前那台」;D11 把这个前提钉住了。)
+
+**3. 不把接口词汇漏到大屏上。** 后端 `stateReason`(`SAMPLE_OUTDATED` 这类)
+与字段缺失清单(`archive.deviceName` 这类路径)都是接口契约词汇。
+票 05 的真机走查已经逮到过一次同类泄漏,所以:认得出的原因翻人话、
+认不出的**回空串**;缺失只报**条数**,字段路径进 `title`(排查拿得到,值班人员看不到)。
+两条都有单测 + 组件静态守卫(F4b/F4c),并且**都做了变异测试**确认守卫有牙
+(F4b 第一版只查引号字面量,会放过模板里的裸文本,已修)。
+
+### 验证
+
+逻辑闸门 **61/61**(`verify/t11-equipment-detail.mjs`,六段:分区映射、视图层、store 取数与竞态、
+分区独立、图片单值、静态守卫)。回归 12 个既有逻辑闸门全绿
+(t01 / t04 / t05 188 / t06 / t07 39 / t08 / t09 38 / t10 57 / t21 / t21-view / t29 / t30)。
+
+**t05 的静态守卫面已扩到本票新增的两个组件与一个 domain** ——
+不补进去,新代码就是检查不到的荒地。**t10 的 C4 按本票改写了**:它原本断言
+「进第二级不取数」,那正是票 10 延期给票 11 的接缝,本票接手后该断言不再成立(已改为断言按编码取数)。
+
+### 待确认(95% 的原因)
+
+`verify/t11-equipment-detail-walkthrough.mjs` 已写好但**未运行**:
+按 `AGENTS.md` 的项目规则,未获明确要求不得跑构建或调试命令(含 dev server 与浏览器走查)。
+本机 8000/8002 端口有维护者的 dev server 在跑,脚本可用:
+
+    node .scratch/cockpit-shell-panel-layout/verify/t11-equipment-detail-walkthrough.mjs
+
+它覆盖 5 组:成功(六块画出来 + 整卡可见文本零代号零字段路径)、
+`equipment-partial`(3 坏 3 好)、`empty`、`stale`、`failed`(一句实话 + 重试入口)。
+用 `__COCKPIT_MOCK_ONLY__` 把异常样例只施加在聚合端点,地图与点位摘要保持 success ——
+否则全局 `empty` 会把地图清空、没有可点的标记,走查会退化成假失败。
+**跑通后方可 close。**
+
+## Comments
+
+### 2026-09-18 认领与阶段判定
+
+已认领(`Status: claimed`)。本票无 `needs-triage` 标签、`Blocked by: 10` 且票 10 已 resolved,
+故不经过诊断阶段,直接进入实施。

+ 103 - 11
.scratch/cockpit-shell-panel-layout/issues/21-3d-tiles-terrain-baseline.md

@@ -1,20 +1,112 @@
-# 21: 3D Tiles 与地形基线
+# 21: 3D Tiles 与地形基线
 
 **What to build:** 接入本地或甲方提供的 3D Tiles、地形和倾斜摄影资源,实现地面视图与管线视图切换。首期不依赖 Cesium Ion。
 
 Blocked by: 03, 04
 
-Status: ready-for-agent
+Status: claimed
 Labels: ready-for-agent
 
-- [ ] 支持本地或甲方资源地址配置。
-- [ ] 地形、3D Tiles 和倾斜摄影资源可加载。
-- [ ] 资源加载中、成功、失败和空状态可区分。
-- [ ] 地面视图与管线视图可切换:管线视图压低相机并把地形与 3D Tiles 压成半透明,地面视图还原。
-- [ ] 切换视图不重置旋转中心,也不改变左键/右键/滚轮三键分工。
-- [ ] 不使用硬编码 Cesium Ion token。
-- [ ] 有资源加载状态单测或走查记录。
+> 认领:2026-09-17 会话接手。维护者已给出本机 GIS 数据位置(`D:\GIS\GIS\yuanlingDEM` 量化网格地形 + `D:\GIS\GIS\yuanlingPic3` 影像切片),本轮先做「地形 + 本地影像 + 免费全局底图」;地面/管线视图切换与 3D Tiles/倾斜摄影(数据未提供)留在本票后续。
 
-## 进度:0%
+## 依赖与事实
 
-未动工。等待按 Blocked by 依赖顺序实施。
+- 引擎已就绪:票 27 已把 Cesium Viewer 接进地图服务层,本票在其上接真资源即可。
+- **影像底图已有配置化通道**(票 29 已关闭):`map.basemap.source` 支持 `none` / `natural-earth`(Cesium 自带离线影像)/ `tianditu`(填 `map.tianditu.token`,开发走 `/tianditu.map` 代理)/ `xyz`(任意 `{z}/{x}/{y}` 模板)。甲方本地瓦片若已是 XYZ/WMTS 切片,先按 `xyz` 配即可;本票专注地形、3D Tiles、倾斜摄影与地面-管线视图切换,不要重做底图影像层。天地图的**真实取图**也还没用真 tk 联调过,可在本票顺手验收。
+- 维护者 2026-09-17 明确:**本机已有本展示区域所需的 GIS 数据**;具体地址、数据格式(瓦片/地形/模型)以及**正式部署后如何编辑更新**的方案,等维护者提供 —— 开始实施前先向维护者索取这两项。
+- 禁止项沿用决策地图调研:不使用 Ion token、`Terrain.fromWorldTerrain`、`createOsmBuildings`。
+
+- [x] 支持本地或甲方资源地址配置。
+- [ ] 地形、3D Tiles 和倾斜摄影资源可加载。—— **地形已可加载**(本机 DEM 实测取到 14 级瓦片、零 404);**3D Tiles 入口已实现并验证链路**(`map.tilesets[]` → `Cesium3DTileset`,合法 tileset 能挂上、坏的单独记失败);**甲方模型数据仍不可加载**:`D:\GIS\GIS\yuanlingSHP` 是 cesiumlab `model2tiles` 导出,缺根 `tileset.json` 且分块 `boundingVolume` 是局部坐标(无 `transform`),需重导或提供模型原点。← **本票唯一的硬阻塞,待维护者动作**
+- [x] 资源加载中、成功、失败和空状态可区分。—— 引擎 booting/ready/failed/disabled;地形 `ready`/`invalid`;3D Tiles `ready`/`invalid`(`data-map-tilesets` + `stats.tilesetErrors`)。
+      **说明**:「空状态」在本引擎里**没有稳定信号**——Cesium 按视锥懒加载,资源挂上了但当前视野无瓦片是正常态(此时 `numberOfTilesWithContentReady` 也为 0),据此报「空」会把好数据误判成空。故只保证三态如实,未伪造第四态;要不要在 UI 上区分「空」请维护者定夺。
+- [x] 地面视图与管线视图可切换:管线视图压低相机并把地形与 3D Tiles 压成半透明,地面视图还原。
+- [x] 切换视图不重置旋转中心,也不改变左键/右键/滚轮三键分工。
+- [x] 不使用硬编码 Cesium Ion token。
+- [x] 有资源加载状态单测或走查记录。
+
+> **地形感知的相机已由票 30 做掉**(原先记在本票「下一步」第 3 条):`pickGroundAtCenter()` 改为按地形
+> 拾取,并在地形就绪后采样「家」视角的实测高程 —— 拉到距离下限 120 m 时中心高程 111.6 m,近景不再黑屏。
+
+## 进度:90%
+
+**本轮(2026-09-17)做完「本地地形 + 本地影像 + 免费全局底图」这条主线**,维护者要的「基础 GIS 地球地图」已经能看到沅陵县城真数据。
+
+### 改动
+
+- 新增 `src/common/localGisStaticPlugin.js`:dev server 把 `COCKPIT_GIS_MOUNTS`(`.env.local` 里给的 `{prefix,dir}` 列表)挂成静态路径 —— DEM 与影像上百 MB,既不进仓库也不进产物;没配就什么也不挂,别的机器照常跑。
+- `domain/mapLayers.js`(新):`planTerrain()` / `planOverlays()` / `planTilesets()` 纯函数计划层;`services/cockpitConfig.js` 增加 `map.terrain{url,requestVertexNormals}`、`map.overlays[]{url,minimumLevel,maximumLevel,bounds,credit}`、`map.tilesets[]{url,maximumScreenSpaceError,credit}` 的归一化与校验。
+- `services/cesiumEngine.js`:`applyTerrain()` 走 `CesiumTerrainProvider.fromUrl`(量化网格,`scheme:"tms"` 由 Cesium 自己认,**服务端不用翻 y**);`applyOverlays()` 把本地精细切片叠在全局底图之上(TMS 用 `{reverseY}`,并按 `bounds` 设 `rectangle`,范围外不发请求);**`applyTilesets()` 逐项挂 `Cesium3DTileset`,单项失败不影响其它项**。
+- `domain/basemap.js` 新增免费全局层 preset `arcgis-imagery`(ArcGIS 官方世界影像,免费免 key,模板顺序 `{z}/{y}/{x}`);**离线优先**:默认与推荐都是 `natural-earth`。
+- `services/mapService.js`:引擎启动时按计划挂地形 / 叠加层 / 3D Tiles,宿主带 `data-map-terrain`、`data-map-tilesets`,`getLifecycleStats()` 增 `terrain` / `terrainError` / `overlays` / `tilesets` / `tilesetErrors`;浮标变成「底图:… · 地形:… · 叠加影像 N 层 · 3D Tiles N 个」。
+- 配置与数据对齐:`public/cockpit/config.json`(本机真实配置,gitignore)按 `yuanlingPic3/meta.json` 的 bounds 设 `bounds`、按 TMS 用 `{reverseY}`;`.env.local` 挂目录并打开配置文件层。
+- **新增 `docs/cockpit-deployment.md`(给运维的生产部署手册)**:一页速查表、配置四层与开启方式、逐字段改法(后端/底图/地形/影像/3D Tiles)、nginx `location` 片段与两条硬要求(别走 SPA 回退、要 CORS)、完整最小配置、8 条自检表、常见问题。`public/cockpit/README.md` 顶部指路过去(它偏开发期接数据)。
+- 新增走查夹具 `.scratch/cockpit-shell-panel-layout/verify/fixtures/tiles/tileset.json`(只有包围盒、无 content 的合法 tileset,用来验证入口链路,不伪造任何模型/影像)。
+
+### 验证证据(`yarn dev` @ 8001,Chrome 152 headless)
+
+- 纯逻辑 `t21-terrain.mjs`:通过。含**配置与磁盘数据对齐核对**(`layer.json` 的 quantized-mesh-1.0 / EPSG:4326 / `scheme:"tms"` / octvertexnormals,`meta.json` 的 tms / 3857 / bounds 与配置一致)。
+- 真机走查 `t21-gis-walkthrough.mjs` → **9/9**:三层都挂上;细层影像只请求声明范围内的瓦片;压到沅陵拉近后 **DEM 192 块(最高 14 级)+ 影像 199 块,全部 HTTP 200、零 404**(这条同时证明 TMS 的 y 约定、availability、`{reverseY}` 都对);镜头停在沅陵上空 4.6 km;DEM 地址配错时 `invalid` + 明确失败文案 + 回退椭球;**3D Tiles 入口:合法 tileset 挂上(`stats.tilesets==1`)而同批里坏的 URL 单独记失败(`tilesetErrors` 1 条)、引擎与其它图层不受影响**;全程无页面级报错。
+- 视觉:`yarn dev` 不注入任何东西直接打开 `/cockpit` —— 离线全局影像球 + 顶栏「配置就绪 · 数据源 mock」+ 浮标「底图:内置离线影像 Natural Earth II · 地形:dem · 叠加影像 1 层」;换中心 + 滚轮拉近 4.6 km 后是沅陵县城真影像(河道、桥、体育场、建筑)。
+- 回归:`t01` 走查 **20/20**、`t04` **9/9**、`t29` **6/6**、`t06`/`t28`/`t03-camera-contract`/`t01-panel-catalog`/`t21-terrain`/`t29-basemap` 逻辑脚本全通过、`yarn build` exit 0。
+- 环境事实:本机**外网瓦片不可达**(ArcGIS / OSM 从 node 与浏览器都被挡),所以全局层实测用离线 `natural-earth`;在线源只做模板与署名断言,**未真机验证**(维护者明确"能用内网离线就不用在线",故不作阻塞项)。天地图无 key,同样不做主路径。
+
+### 3D Tiles 数据的现状(需要维护者决定)
+
+`D:\GIS\GIS\yuanlingSHP` 确实是 3D Tiles:cesiumlab `model2tiles` 导出,5 个层级(16–20)、769 个分块 `*.json` + 772 个 `*.glb`,每个分块 json 是**自洽的 tileset 文档**。但**缺根 `tileset.json`**,且分块 `boundingVolume.box` 是局部坐标(例:中心 6265/2293;叶子 20 级为 `-3258, 9736, 105`),**没有任何 `transform`** —— 也就是没有把模型放到地球上的地理参考。
+
+**2026-09-18 结论:维护者目测判定「明显错位 + 只是白色立方体,效果非常差」→ 不采用。**
+合成的根 `tileset.json` 已删除,`/gis/models` 挂载已移除,`yuanlingSHP` 那份数据不再使用。
+替代方案见 **票 34**(参照 GIS 数据做轻量体块模型)。
+
+当时的做法与机器验证留档(供以后判断"错位"到底出在哪):
+
+1. 源数据是**地理坐标**:`h49c004002/*.prj` 为 `GEOGCS["CGCS_2000", ... UNIT["Degree"]]`,`output/*.json` 是 GeoJSON;
+2. 13 个 level-16 分块里,稠密簇(10 块)是县城核心,用目标坐标锚定它反推局部系原点,写成 ENU→ECEF `transform`;
+3. 回验:簇心经 transform 回算与目标差 ≈5 m、13 个分块中心全落在影像 bounds 内、真机加载零报错。
+
+**为什么仍然错位**:锚定假设("稠密簇中心 == 维护者给的目标坐标")没有依据 —— 原始导出本就没有原点,
+靠推测必然偏;且分块体块是**单色白模**,与影像里的城市形态对不上。这条路先放下。
+
+### 本轮追加(2026-09-17 晚):地面/管线视图切换
+
+决策地图票 05 已把语义锁死,本轮按它落地:
+
+- 新增 `domain/viewModes.js`(纯函数):`planViewMode()` 把「哪个视图」翻译成相机预设 + 图层透明度;
+  `viewHprFor()` 只产出 `{heading, pitch, range}` —— **签名里根本没有中心参数**,从构造上保证「切换视图不重置中心」。
+  管线视图:pitch 抬到 `-18°`(上限放宽到 `+5°` 允许平视/轻微仰视)、弹簧臂压到当前距离的 `35%`(夹在 400–6000 m)、
+  地形 α`0.45` / 3D Tiles α`0.4`;地面视图:还原为 α`1` 与常规俯视。
+- `stores/filterStore.js`:视图条件并入 `filterStore`(决策地图指定 —— 与行业筛选同属全局条件,**切一级模式不重置**)。
+- `services/cameraControl.js`:新增 `setPitchLimits()` 与 `setHpr()` —— 只改限制与 `pitch/range`,不碰输入绑定、不碰中心。
+- `services/cesiumEngine.js`:`applyTilesets()` 现在回传实例(供改样式);新增 `applyViewModeLayers()` ——
+  地形走 `globe.translucency`,3D Tiles 走 `Cesium3DTileStyle` 的 color alpha;地面视图**清掉样式**而不是设成 1。
+- `services/mapService.js`:`setViewMode()` / `getViewMode()`;进管线前记下地面基线(倾角+距离),回地面时按它还原;
+  宿主带 `data-map-view-mode`(启动即为 `ground`,不是切换过才有)。
+- `CockpitShell.vue`:筛选条加「视图:地面视图 / 管线视图」两个按钮(`data-view-mode` + `aria-pressed`),
+  与范围/行业同级、切一级模式不重置。
+- `services/cockpitConfig.js` + 部署手册 2.7:`map.viewMode.pipeline{ pitch, rangeFactor, terrainAlpha, tilesetAlpha }`
+  可覆盖默认值,脏值忽略退回默认。
+- 新增 `verify/t21-view-modes.mjs`(逻辑)与 `verify/t21-view-modes-walkthrough.mjs`(真机)。
+
+### 验证证据(本轮追加)
+
+- 纯逻辑 `t21-view-modes.mjs`:通过。含**构造性断言**:`viewHprFor()` 的返回值里没有 `pivot` 字段、
+  计划里也没有中心 —— 「不重置中心」不是靠人眼比对,而是类型层面就不可能;另有地面还原(按基线)、
+  clamp 边界(min>max 的坑已修)、脏配置兜底。
+- 真机走查 `t21-view-modes-walkthrough.mjs` → **10/10**:初始 `ground`;切管线后距离 `12000 → 4200 m`、
+  pitch `-55° → -18°`,而**中心经纬高完全一致**(`110.39028164000001, 28.4436787, 111.57951503970997` 前后逐位相同);
+  按钮 `aria-pressed` 与 `data-map-view-mode` 同步;**管线视图里左键仍只平移**(中心移动 −0.0036°、距离/倾角不变)、
+  **滚轮仍只改距离**(4200 → 6787 m、倾角不变);回地面按基线还原(-55° / 12000 m);
+  **切一级模式后仍是管线视图**(全局条件);全程无页面级报错。
+- 视觉:管线视图截图 —— 相机贴着地面侧看(河湾与县城呈侧视,不是俯视)。
+- 回归:7 个浏览器套件全绿(t01 20/20、t04 9/9、t21 9/9、t21 视图 10/10、t28 7/7、t29 6/6、t30 7/7)、
+  8 个逻辑脚本全通过、`yarn build` exit 0(57 s)。
+
+### 下一步(本票未完成的部分)
+
+1. **3D Tiles / 倾斜摄影数据源** —— 甲方那份 `yuanlingSHP` 经目测否决(错位 + 白模),
+   替代方案已由 **票 34**(轻量体块模型)接手,等维护者对其远景效果判定;
+   若判定采用,本票的验收第 2 条改为「**示意模型**可加载」,并在部署文档里显著标注非实测。
+2. 「空状态」是否要在 UI 上区分 —— 技术上没有稳定信号(见验收第 2 条的说明),需维护者定夺要不要做。
+3. 若后续拿到**带正确原点的**真实 3D Tiles / 倾斜摄影(cesiumlab 重导并勾选生成根 tileset),
+   直接填 `map.tilesets` 即可替换示意模型 —— 入口与三态可见性都已就绪。

+ 152 - 70
.scratch/cockpit-shell-panel-layout/issues/25-backend-contract-gaps.md

@@ -11,123 +11,205 @@ Blocked by: None
 
 ## Why ready-for-human
 
-结论来自 [24: 面板字段与后端契约覆盖调研](24-panel-field-coverage.md)(已 resolved)、2026-09-17 对后端仓库的复核,以及主会话的逐条核实。缺口分两类:
+结论来自 [24: 面板字段与后端契约覆盖调研](24-panel-field-coverage.md)(已 resolved)、2026-09-17 两轮对后端仓库的复核,以及主会话的逐条核实。缺口分两类:
 
-1. **后端补契约**——需要后端负责人实现。本仓库工作区写权限只覆盖 `E:\City-Lifeline-Cockpit-TvT`,且后端仓库当时有并行会话的未提交改动,不适合由本票直接改。
+1. **后端补契约**——需要后端负责人实现。本仓库工作区写权限只覆盖 `E:\City-Lifeline-Cockpit-TvT`,且后端仓库当时有并行会话的在途改动,不适合由本票直接改。
 2. **业务定义确认**——需要业务方回答,本票不发明结论。
 
-## 复核记录(2026-09-17)
+可直接交付给后端项目经理的建票草案已单独产出:[后端契约缺口交付包](../handoff/backend-contract-gaps.md)。
 
-复核方式:只读阅读后端仓库 `E:\pipe-ner-server` 的控制器与响应类(`com.zksy.web.controller.cockpit`、`com.zksy.cockpit.*`);未改动后端任何文件,未跑构建或调试命令。
+## 复核记录(2026-09-18 第八轮:BT-5 第 1 片落地,票未关)
 
-### 结论:8 组补契约中 4 组已交付,2 项跨领域缺口仍在,跨设备读模型仍未动
+**BT-5(Gogs #63)按人类裁定走「纵向样板」:完成 `map-snapshot` 一片的真实数据接线,余 21 片未动,票保持 open(约 5%)。**
 
-后端在 2026-09-16~09-17 落地了 5 个新读模型端点(对应后端 Gogs #33–#37):
+- **裁定**:本轮范围=`map-snapshot` 纵向样板(不追求 22 片全接);井盖口径「以数据库为准」。
+- **落地**:新增 `BusinessCockpitMapSnapshotAdapter`(`@Primary`),组合 `monitor_point` / `equipment_point_rel` / `equipment_status` / `early_warning` / `work_order` / `equipment_type` 六张既有表与服务,**不新增表、不加投影**,复用 BT-1…BT-4 已冻结的服务层(过滤/分页/`mapFilter`/坐标校验均不改)。
+- **真实库证据**(只读,KingBase `192.168.110.30:54321/pipi_ner`):接线后产出 **7 个真实点位**(2 个带设备)、**3 条有效设备关联**、**34 台未关联设备**、5 台报警、1 台离线 —— 非空且与库一致。
+- **测试**:新增 `BusinessCockpitMapSnapshotAdapterTest` 4 例;全量 `mvn -pl zksy-admin -am test` **465 测试 0 失败**。
+- **实测出的真实数据缺口(阻塞后续切片,已全部写进 #63)**:
+  1. 设备→点位关联极稀疏(仅 3 条有效,覆盖 2/7 点位)→ BT-1 的 `deviceCodes`/WS `monitoringPointCode` 在真实数据下近乎全空(**数据缺口,非代码缺陷**);
+  2. 现网**无坐标校核字段** → 按冻结字典一律 `renderable=false`(不上图),要上图需数据侧补标志或裁定「默认已校核」;
+  3. `monitor_point` 无行业列,点位类型只能在关联设备类型唯一时推导;
+  4. 点位状态/类型**中英文脏值**(`正常`/`预警` vs `ACTIVE`);
+  5. **工单状态值域跨端不一致**:现网 0-based、cockpit 冻结 1-based;
+  6. **窨井(井盖)缺契约位**:库里有独立 `manhole_base`(3 条)/`manhole_device_rel`(6 条)与 `manhole` 类型根,前端筛选也有「井盖」项,但后端正式行业枚举只有 3 个;
+  7. 窨井坐标脏(`1212,1212`)。
 
-| 端点 | Gogs | 提交状态 | 对应补契约组 |
-| --- | --- | --- | --- |
-| `GET /api/cockpit/v1/infrastructure/operating-information` | #33 | 已提交 | F 运行信息 |
-| `GET /api/cockpit/v1/infrastructure/management-information` | #34 | 已提交 | E 管理信息 |
-| `GET /api/cockpit/v1/infrastructure/emergency-support` | #35 | 已提交 | D 应急保障 |
-| `GET /api/cockpit/v1/monitoring/overview` | #36 | 已提交(当前 HEAD) | 监测概览 |
-| `GET /api/cockpit/v1/monitoring/distribution` | #37 | **工作区未提交** | G 行业/类型维度、G3 |
+> wayfinder 记法:同前几轮——本票属后端 Gogs tracker,不挂在任何 wayfinder map 之下,故 gist 记在本票(其父台账)。
+
+## 复核记录(2026-09-18 第七轮:BT-4 已交付)
+
+**BT-4(Gogs #62)事件行补坐标校验与报警阈值完成并关闭**:
+
+- **落地**:`events/current` 的 `EventItem` 增加 `coordinateValidation`(`state`/`reason`/`renderable`/`crs`)与 `thresholdMinValue`/`thresholdMaxValue`(报警族在数据可得时有值,预警族 `null`);快照源补 `coordinateVerified`(报警族另补阈值)。校验复用 `CockpitCoordinateValidator`,与地图/分布/态势**同一实现**。
+- **不臆造验证**:适配器未给 `coordinateVerified` 时按未验证处理(`UNVERIFIED` + `renderable=false`),符合字典「未验证禁止渲染」;接线后按真实标志放行。
+- **适用面**:`events/history-analysis` 的行是小时桶聚合、无逐事件行,**不适用**该字段(与 BT-1 的结论一致)。
+- **入册**:README 第 4 节三条口径 + `current-warnings-demo.html` 增「上图」列(标记只画 `renderable=true` 的事件)。
+- **证据**:`CockpitCurrentEventsControllerTest` 14 例(含 `INVALID`/`LONGITUDE_OUT_OF_RANGE`、`UNVERIFIED`/`UNVERIFIED_COORDINATE`、报警阈值断言);全量 `mvn -pl zksy-admin -am test` **453 测试 0 失败**。
+- **口径说明**:本票与 BT-5 不在「11 判定项 + 3 业务问题」的计分口径内,故进度仍记 79%;但它们同属交付包里的后端缺口,**BT-1…BT-4 现已全部交付**,只剩 BT-5(真实数据接线)与 3 条业务口径问题。
+
+> wayfinder 记法:同前几轮——本票属后端 Gogs tracker,不挂在任何 wayfinder map 之下,故 gist 记在本票(其父台账)。
+
+## 复核记录(2026-09-18 第六轮:BT-3 已交付,B 关闭——11 个契约判定项全部达成)
+
+**BT-3(Gogs #61)趋势窗口可声明完成并关闭**:
+
+- **落地**:`GET /overview/warning-settings` 增加 `range`(`LAST_24_HOURS`/`LAST_7_DAYS`/`LAST_30_DAYS`,同 `infrastructure/operating-information`)与 `startTime`/`endTime`(显式 ISO 区间,成对且 `startTime ≤ endTime`);`range{type,start,end}` 回显生效区间(显式区间 `type=CUSTOM`,不声明时维持 `CURRENT_BASELINE` + `null`);`trend[]` 按窗口过滤(桶为业务时区日期,含端点,顺序沿用适配器)。
+- **错误口径**:非法 `range`、单边区间、`startTime > endTime`、时间戳不可解析 → 400 `INVALID_REQUEST`,文案与兄弟切片一致。
+- **入册**:`docs/cockpit/README.md` 第 3 节端点参数 + 第 4 节新增「时间范围口径」。
+- **证据**:`CockpitOverviewWarningSettingsControllerTest` 9 例 + ServiceTest 3 例;全量 `mvn -pl zksy-admin -am test` **453 测试 0 失败**。
+- **本轮据此更新**:判定项 **B ⚠️→✅**。至此 11 个契约判定项(A、B、C、D、E、F、G1、G2、G3、G4、H)**全部交付**;剩余只有 BT-4(#62 事件坐标校验)、BT-5(#63 真实数据接线)与 3 条业务口径问题。
+
+> wayfinder 记法:同前两轮——本票属后端 Gogs tracker,不挂在任何 wayfinder map 之下,故 gist 记在本票(其父台账)。
+
+## 复核记录(2026-09-18 第五轮:BT-2 已裁定并交付,G1+G2 关闭)
+
+**BT-2(Gogs #60)路径裁定 + 落地完成并关闭**:
+
+- **裁定**:走**路径①完整**——地图取数以 `map-snapshot` 为准(不用 `monitoring/distribution` 供数),`map-snapshot` 承接全局筛选。理由:前端全局条件是「范围(scope) + 行业」两维,只加 industry 会让范围对地图无效;且票 08 的装载器已按 map-snapshot 的扁平分页形状验收关闭。
+- **落地**:`map-snapshot` 增加 `industry`/`region`/`deviceType` 三个入参;逐点补 `region`/`industry`/`typeCode`/`typeName`(未关联设备同样补);响应新增 `mapFilter{industry,region,deviceType,pointCodes,deviceCodes}` 回显生效范围;`coordinateValidation` 汇总改为「筛选范围内全量口径」;默认不筛时**不排除任何对象**(行业不可推导为 `null`)。
+- **入册**:结论写入 `docs/cockpit/README.md` 第 3 节「地图筛选路径(冻结结论)」(票面验收第 1 条)。
+- **证据**:`CockpitMapSnapshotControllerTest` 14 例(含筛选、回显、非法行业 400、带筛选翻页快照锁);全量 `mvn -pl zksy-admin -am test` **450 测试 0 失败**。
+- **本轮据此更新**:判定项 **G1 ❌→✅、G2 ❌→✅**;AC1 达成。至此契约判定项只剩 **B 一处**(`overview/warning-settings` 趋势窗口,Gogs #61),另有 BT-4(#62 事件坐标校验)与 BT-5(#63 真实数据接线)。
 
-这 5 个切片与既有切片同样采用「契约 + 空适配器 + demo」形态,真实数据源尚未接线。对「补契约」这一定位算缺口关闭,对「能取到真实数据」则仍待后端后续接线。
+> wayfinder 记法:同第四轮——本票属后端 Gogs tracker,不挂在任何 wayfinder map 之下,故 gist 记在本票(其父台账)。
 
-### 逐项判定
+## 复核记录(2026-09-18 第四轮:BT-1 已交付并关闭)
 
-| 组 | 内容 | 今日状态 | 证据 |
+**BT-1(Gogs #59)实施完成并关闭**——设备→点位回联三件套全部落地:
+
+- **① REST**:`CockpitMonitoringPointSummary` 新增 `deviceCodes`(从 `SourceMonitoringPoint.devices` 投影,保序去重),map-snapshot 点位摘要现在能回联设备。
+- **② WS**:四个增量 DTO 新增 `monitoringPointCode`;`DeviceStatusRedisListener` 按 `equipment_base.equipment_code → equipment_point_rel → monitor_point.point_code` 解析,未关联显式 `null`(帧序列化 `WriteMapNullValue`),查询异常降级不阻断推送。
+- **③ 事件行**:`EventItem` 新增 `monitoringPointCode`/`monitoringPointName`(快照源与 `EventRow` 同步补字段)。`history-analysis` 的 row 是小时桶聚合行,无逐事件行,不适用该字段。
+- **证据**:`mvn -pl zksy-admin -am test`(全量 zksy-admin 与 zksy-system)**445 个测试 0 失败**(含 REST 契约与 WS 增量两侧新增断言)。
+- **本轮据此更新**:判定项 **G4 由 ❌ 转为 ✅**;AC2 的候选 ①②③ 全部实现,结论达成。
+
+> wayfinder 记法:本票属后端 Gogs tracker,不挂在任何 wayfinder map 之下(后端仓库无 `map.md`),故 gist 记在本票(其父台账)而非某张 map 的「Decisions so far」。
+
+## 复核记录(2026-09-18 第三轮:建票完成)
+
+承接第二轮结论,重新只读复核后端(HEAD 仍 `ed0ace5`、远端仍 `e2b4e45`,G1/G2/G4/BT-3/BT-4 证据逐条复验成立),并把交付包草案在 Gogs 建成真实工单:
+
+| BT | Gogs 票号 | 标题 | 标签 |
 | --- | --- | --- | --- |
-| D | 应急保障域三件套 | ✅ 已交付 | `CockpitInfrastructureEmergencySupportResponse`:`Totals{emergencyResourceCount,hazardSourceCount,protectionTargetCount,totalObjectCount}` + `Sections{emergencyResources,hazardSources,protectionTargets}`(每项 `objectCode/objectName/category/industry/area/pointCodes/eventAssociations`)+ 三类分布 + `eventSummary` + `mapFilter` + `excluded`;入参 `category/industry/area` |
-| E | 管理信息域 | ✅ 已交付 | `CockpitInfrastructureManagementInformationResponse`:`organizations{organizationCode,organizationName,industry,facilityCount,equipmentCount,unmaintained,ownershipDistribution}` + `ownershipDistribution` + `totals.unmaintainedCount` + `mapFilter{industry,organizationCode}`;入参 `industry/organizationCode`。原建议的 `managementUnits/ownershipUnits/responsibilityZones` 三数组未采用,改为「机构 + 权属分布」形状 |
-| F | 设施/行业运行信息 | ✅ 已交付 | `CockpitInfrastructureOperatingInformationResponse`:`industries[].metrics[]{metricCode,value,capacityValue,loadRatio,loadState,sampledAt,freshness,trend,abnormal,pointCodes}` + `abnormalMetricCount` + `mapFilter{industry,metricCodes,pointCodes}`;入参 `industry/range/from/to` |
-| G3 | 未关联设备补 `industry` 与设备类型 | ✅ 已交付 | `CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker{deviceCode,deviceName,region,industry,deviceType,pointStatus,...}` |
-| G1 | `map-snapshot` 加 `industry` 入参 | ❌ 未交付 | `CockpitMapSnapshotController` 第 35–39 行入参仍只有 `page/pageSize/snapshotAt` |
-| G2 | 点位摘要加 `industry`、点位类型与徽标 | ❌ 未交付 | `CockpitMonitoringPointSummary` 仍只有 `pointCode/pointName/pointStatus/statusReasons/longitude/latitude/coordinateValidation` 7 个字段 |
-| G4 | 点位摘要返回关联设备编码 | ❌ 未交付 | 同上;`monitoring/distribution` 的 `mapFilter.deviceCodes` 是扁平列表,不构成 device→point 映射 |
-| A | 跨设备当前/历史事件流 | ❌ 未交付 | 事件端点仍只有 `equipments/{deviceCode}/events/current` 与 `events/history` |
-| B | 趋势时间范围可声明 | ⚠️ 部分 | `operating-information` 已有 `range/from/to`;`overview/warning-settings` 入参仍只有 `mode`(`Response.Range{type,start,end}` 结构已在,属加参数) |
-| C | 响应/关闭时长聚合 | ❌ 未交付 | cockpit 包内无任何 duration/Seconds 字段;#31 落地的是比率型 `handlingEfficiency{numerator,denominator,value,state,reason}` 与 `cancelledCount/unlinkedEventCount`,不是时长 |
-| H | 跨设备设备状态清单 | ❌ 未交付 | 只有 `Counts.Status{online,offline,stale,unknownOrMissing,denominator}` 分桶,无逐设备清单;无 `equipments/status` 端点 |
+| BT-1 | [#59](http://192.168.110.30:3000/shaoy/pipe-ner-server/issues/59) | BT-1 Cockpit device-to-point linkage(REST + WebSocket) | needs-triage |
+| BT-2 | [#60](http://192.168.110.30:3000/shaoy/pipe-ner-server/issues/60) | BT-2 全局行业筛选落图:路径裁定 + 落地 | needs-triage |
+| BT-3 | [#61](http://192.168.110.30:3000/shaoy/pipe-ner-server/issues/61) | BT-3 warning-settings 趋势窗口可声明 | needs-triage |
+| BT-4 | [#62](http://192.168.110.30:3000/shaoy/pipe-ner-server/issues/62) | BT-4 事件行补 coordinateValidation 与报警阈值 | needs-triage |
+| BT-5 | [#63](http://192.168.110.30:3000/shaoy/pipe-ner-server/issues/63) | BT-5 真实数据接线(程序级) | needs-triage |
+
+- 每张票正文 = 交付包对应小节 + 一行来源说明(前端 tracker 票 25 / `handoff/backend-contract-gaps.md`)。
+- 标签统一 `needs-triage`:这些票都含待后端负责人裁定的点(BT-2 路径二选一、BT-1 ①②③、BT-5 排期),应由后端 PM 评估后转 `ready-for-agent`。
+- BT-6(situation 的 `monitoringState` 过滤)按草案标注为可选,**未建**。
+- 本轮两处事实修正:①「06(全局筛选)」错引改为 18(票 18 验收项「面板筛选可作用于地图点位」,`filterStore` 已在票 01 落地);② 适配器计数改为精确口径(23 接口 / 22 空实现 / 1 真实,不计 `/foundation` 自检则 22/21)。
+- 本票与交付包均已同步上述修正。
 
-### 两个跨领域缺口的补充证据
+## 复核记录(2026-09-17 第二轮,替代同日第一轮)
+
+第一轮复核时后端 HEAD 为 `#36`、`#37` 未提交,当时判定「8 组补契约中 4 组已交付」。同日后端又落地 `#37`–`#43`,第二轮结论大幅前移:**11 个契约判定项中 7 项已交付,1 项只剩一处,3 项未交付**。
+
+复核方式:只读阅读后端仓库 `E:\pipe-ner-server` 的控制器、响应类、适配器与 `docs/cockpit/README.md`;未改动后端任何文件,未跑构建或调试命令。
+
+- 后端本地 HEAD:`ed0ace5`(`test(cockpit): add contract integration environment and handoff (#43)`)。
+- 远端 `origin` HEAD 仍是 `e2b4e45`(#42)——**#43 未推送**,联调前需确认部署来源提交。
+
+### 第二轮逐项判定
+
+| 项 | 内容 | 状态 | 证据 |
+| --- | --- | --- | --- |
+| A | 跨设备事件读模型(当前 + 历史) | ✅ 已交付 | `GET /events/current`(`mode` 必填并校验,`level`/`industry`/`status`/分页)与 `GET /events/history-analysis`(`eventFamily` 必填并校验 + `startTime`/`endTime`);`EventItem` 含 `eventNo/eventType/level/levelColor/status/eventTime/value/unit/deviceCode/deviceName/location/industry/longitude/latitude/workOrderSummary`;历史侧回显 `Range{type,startTime,endTime}`。残留 `monitoringPointCode` 与 `coordinateValidation`(分别见 G4、BT-4) |
+| B | 趋势时间范围可声明 | ✅ 已交付(2026-09-18,Gogs #61) | `overview/warning-settings` 增加 `range` 三档 + `startTime`/`endTime`,回显 `range{type,start,end}`,趋势桶按窗口过滤;其余切片早已支持范围参数 |
+| C | 响应/关闭时长聚合 | ✅ 已交付 | `GET /events/handling-efficiency`:`averageDuration{value,unit,sampleCount,state,reason}` + `durationTrend[]` + 工单 `durationHours`;口径冻结为 `basis = CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS` |
+| D | 应急保障域三件套 | ✅ 已交付 | `GET /infrastructure/emergency-support`:`Totals{emergencyResourceCount,hazardSourceCount,protectionTargetCount}` + 三 section + `eventSummary` |
+| E | 管理信息域 | ✅ 已交付 | `GET /infrastructure/management-information`:`organizations` + `ownershipDistribution` + `unmaintained` |
+| F | 设施/行业运行信息 | ✅ 已交付 | `GET /infrastructure/operating-information`:`metrics` + `capacityValue`/`loadRatio`/`loadState` + `trend` + `range/from/to` |
+| G1 | 全局行业筛选落到地图点位 | ✅ 已交付(2026-09-18,Gogs #60) | 裁定走路径①:`map-snapshot` 增加 `industry`/`region`/`deviceType` 入参,服务端过滤 + `mapFilter` 回显生效范围;分页与 `snapshotAt` 语义不变;结论冻结在 `docs/cockpit/README.md` 第 3 节 |
+| G2 | 点位摘要补 `industry`、类型、徽标 | ✅ 已交付(2026-09-18,Gogs #60) | `CockpitMonitoringPointSummary` 补 `region`/`industry`/`typeCode`/`typeName`;徽标所需的设备数由 BT-1 的 `deviceCodes` 直接得出 |
+| G3 | 未关联设备补 `industry`、设备类型 | ✅ 已交付 | `CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker` |
+| G4 | 设备编码回联点位 | ✅ 已交付(2026-09-18,Gogs #59) | 点位摘要 `deviceCodes` + 四个 WS 增量 DTO 的 `monitoringPointCode`(真实库映射查询,未关联显式 `null`)+ 事件行 `monitoringPointCode`/`monitoringPointName`;REST 与 WS 契约测试两侧覆盖 |
+| H | 跨设备设备状态清单 | ✅ 已交付 | `GET /monitoring/situation`:`abnormalEquipment[]{deviceCode,deviceName,deviceType,industry,region,abnormalType,status,statusReasons,longitude,latitude,coordinateValidation}` + 分页 + `mapFilter.deviceCodes` |
 
-`monitoring/distribution`(#37)已能承接全局行业筛选的**意图**:入参 `industry/deviceType/pointStatus/region`,逐点返回 `industry`/`deviceType`,并给 `mapFilter{industry,deviceType,pointStatus,region,pointCodes,deviceCodes}`。因此「给 `map-snapshot` 加 `industry` 入参」不再是唯一可行路径——采用哪条路径属后端设计决定,本票不代为选择,改为在验收项里标注「实现路径待后端负责人定」。
+### 已交付但仍需注意的两条事实
 
-WS 侧仍未解决:`CockpitMonitoringDistributionDeviceStatusDelta`、`CockpitMonitoringOverviewDeviceStatusDelta`、`CockpitOverviewMonitoringDeviceStatusDelta` 三个增量 DTO 都只带 `deviceCode` + `monitoringState`(外加 `sampledAt`/`statusTime`/`serverUpdatedAt`),`DeviceStatusWebSocketHandler` 全文没有 `pointCode`/`monitoringPointCode`。但后端内部已持有映射(`CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint.devices`,且服务端 `applyDeviceStatusDelta` 能按 `deviceCode` 解析到点位),所以最小改动是二选一:点位摘要输出关联设备编码,或 WS 增量帧直接带上 `monitoringPointCode`。
+1. **`#43` 交付了一份移交与联调手册**:`E:\pipe-ner-server\docs\cockpit\README.md`(契约总则、字典冻结、端点清单、分页/快照/错误口径、WebSocket 增量、启动与联调检查表、容器化评估、已知未验证项)。票 25 里「字段名与语义」类问题已由它回答,不必再建票。
+2. **契约已冻结,真实数据未接线**:23 个 cockpit 适配器接口中 22 个只装配了 `Empty*Adapter`(返回 `...Snapshot.empty(...)`),唯一接入真实数据的是 `BusinessCockpitWorkOrderReadAdapter`(工单,`@Primary` 覆盖同名 Empty 实现)。前端切到真实接口会拿到全空快照(200 + `freshness.state = EMPTY`)。这是当前最大的交付差距,已作为 BT-5 写入交付包。(口径:若不计 `/foundation` 契约自检,则为 22 个读模型接口 / 21 个空实现。)
 
-## G1 全局行业筛选落不到地图点位(已核实;实现路径待后端负责人定)
+## G1 全局行业筛选落不到地图点位(**2026-09-18 已由 Gogs #60 交付:路径①完整**)
 
 `CONTEXT.md` 定义行业筛选是作用于整个驾驶舱的全局条件,但 `map-snapshot` 至今无法过滤地图点位:
 
 - `GET /api/cockpit/v1/map-snapshot` 无 `industry` 入参(`CockpitMapSnapshotController`)。
-- `CockpitMonitoringPointSummary` 字段仅 `pointCode / pointName / pointStatus / statusReasons / longitude / latitude / coordinateValidation`——没有 `industry` 与点位类型,服务端与客户端都无从过滤。
-- 对照:`overview/monitoring`、`infrastructure/base-archives`、`monitoring/overview`、`monitoring/distribution` 都已支持 `industry` 入参,说明后端具备该维度,只是没给地图摘要。
+- `CockpitMonitoringPointSummary` 没有 `industry` 与点位类型,服务端与客户端都无从过滤。
+- 对照:`overview/monitoring`、`infrastructure/base-archives`、`monitoring/overview`、`monitoring/distribution` 都已支持 `industry` 入参。
 
-**需要后端定路径(二选一或都做)**:① `map-snapshot` 增加 `industry` 入参,`CockpitMonitoringPointSummary` 增加 `industry` 与点位类型字段;② 地图点位改由 `monitoring/distribution` 提供——它已有 `industry`/`deviceType`/`pointStatus`/`region` 入参与逐点 `industry`/`deviceType`,但该端点尚未提交,且前端实现票需相应改取数路径。
+**需要后端定路径(二选一或都做)**:① `map-snapshot` 增加 `industry` 入参,`CockpitMonitoringPointSummary` 增加 `industry` 与点位类型字段;② 正式声明地图筛选以 `/monitoring/distribution` 为准——它已有 `industry`/`deviceType`/`pointStatus`/`region` 入参与逐点 `industry`/`deviceType`,但需在契约手册写明结论,前端实现票据此改取数路径。
 
-## G2 map-snapshot 丢弃设备编码,WS 增量无法回联点位(已核实,仍缺)
+## G2 map-snapshot 丢弃设备编码,WS 增量无法回联点位(**2026-09-18 已由 Gogs #59 / #60 交付**:`deviceCodes` + WS `monitoringPointCode` + 事件行点位 + 点位 `industry`/类型)
 
 - 内部快照 `CockpitMapSnapshot.SourceMonitoringPoint.devices` 存在,但 `CockpitMapSnapshotService.monitoringPoint()`(145–160 行)只用它解析点位状态,**没有把设备编码写进响应**。
 - WS `cockpitDeviceStatusDelta` 增量只带 `deviceCode` + 状态,前端拿到增量后无法知道它属于哪个监测点,点位状态无法联动。
-- 另外 `cockpitSubscribe` 回帧只有 `{scope, serverUpdatedAt}`,无初始状态数组(后端注释明确 REST 是初始快照、WS 只发增量——契约本身成立,前端需按此实现);线上帧的状态键是 `data.status`,而后端 DTO `CockpitOverviewMonitoringDeviceStatusDelta` 命名为 `monitoringState`,接入时注意别名。
+- 事件行同样没有归属点位:`CockpitCurrentEventsResponse.EventItem` 与 `history-analysis` 的行都缺 `monitoringPointCode`。
+- 现有替代路径代价高:`GET /monitoring-points/{monitoringPointCode}` 的 `devices[].deviceCode` 可反推 device→point,但需要逐点请求(N+1)。
+- 后端内部已持有该映射(`SourceMonitoringPoint.devices`;`applyDeviceStatusDelta` 能按 `deviceCode` 解析到点位),改动集中在响应层。
+- 另注:`cockpitSubscribe` 回帧只有 `{scope, serverUpdatedAt}`,无初始状态数组(后端注释明确 REST 是初始快照、WS 只发增量——契约本身成立,前端按此实现);线上帧状态键是 `data.status`,而 DTO 字段名是 `monitoringState`,接入时注意别名。
 
-**需要后端**:`CockpitMonitoringPointSummary` 输出关联设备编码列表,或 WS 增量帧带上 `monitoringPointCode`——后端两处都已持有该映射,改动量都不大。
+**需要后端**:二选一或全做——① `CockpitMonitoringPointSummary` 输出关联设备编码列表;② WS 增量帧带上 `monitoringPointCode`;③ 事件行补 `monitoringPointCode`。
 
 ## 补契约清单(A–H)
 
-11 个有缺口面板的完整字段表与分组补契约建议(含字段名、语义、是否需要新端点)见 [research/panel-field-coverage.md](../research/panel-field-coverage.md) 第 4 节,共 8 组;各组今日状态见上「逐项判定」:
+11 个有缺口面板的完整字段表与分组补契约建议(含字段名、语义、是否需要新端点)见 [research/panel-field-coverage.md](../research/panel-field-coverage.md) 第 4 节,共 8 组;各组今日状态见上「第二轮逐项判定」:
 
-- A 跨设备当前事件读模型(阻塞 `currentEvents`)— ❌ 未交付
-- B 跨设备带时间范围的历史流 + 趋势 `range`(阻塞 `historyAnalysis`)— ⚠️ 仅 `operating-information` 有 `range`;`warning-settings` 仍缺
-- C 响应/关闭时长聚合(阻塞 `responseEfficiency`)— ❌ 未交付
-- D 应急保障域三件套(阻塞 `emergency` 整版)— ✅ 已交付(`emergency-support`)
-- E 管理信息域 — ✅ 已交付(`management-information`)
-- F 设施运行信息域 — ✅ 已交付(`operating-information`)
-- G 地图行业/类型维度(即 G1,含 G2/G3/G4)— G1/G2/G4 ❌,G3 ✅
-- H 跨设备设备状态列表 — ❌ 未交付
+- A 跨设备事件读模型(阻塞 `currentEvents`、`historyAnalysis`、`eventAnalysis`、`operationOverview`)— ✅ 已交付
+- B 趋势时间范围可声明(阻塞 `historyAnalysis`、`situationSummary`)— ✅ 已交付(`range` 三档 + 显式区间)
+- C 响应/关闭时长聚合(阻塞 `responseEfficiency`)— ✅ 已交付
+- D 应急保障域三件套(阻塞 `emergency` 整版)— ✅ 已交付
+- E 管理信息域 — ✅ 已交付
+- F 设施运行信息域 — ✅ 已交付
+- G 地图行业/类型维度 — **G1 ✅ / G2 ✅ / G3 ✅ / G4 ✅**(2026-09-18 由 Gogs #59、#60 全部交付)
+- H 跨设备设备状态清单 — ✅ 已交付(`monitoring/situation`)
 
-实现票 17–20 的 mock 阶段可按已有的面板级聚合响应形状建模(原 4 个 + 新增 5 个):`/overview/infrastructure`、`/overview/monitoring`、`/overview/warning-settings`、`/infrastructure/base-archives`、`/infrastructure/operating-information`、`/infrastructure/management-information`、`/infrastructure/emergency-support`、`/monitoring/overview`、`/monitoring/distribution`。
+实现票 17–20 的 mock 阶段可按已有的面板级聚合响应形状建模:`/overview/infrastructure`、`/overview/monitoring`、`/overview/warning-settings`、`/infrastructure/base-archives`、`/infrastructure/operating-information`、`/infrastructure/management-information`、`/infrastructure/emergency-support`、`/monitoring/overview`、`/monitoring/distribution`、`/monitoring/situation`、`/events/current`、`/events/history-analysis`、`/events/handling-efficiency`、`/events/multidimensional-analysis`。
 
 ## 业务问题(待业务方确认,本票不发明结论)
 
 三条仍未答复;下列为复核后掌握的现状与建议口径,供业务方确认或否决:
 
-1. **「故障」是否等同 `stale`?** 现状:`monitoring/overview` 与 `overview/monitoring` 只有 `online/offline/stale/unknownOrMissing` 四个桶,没有独立「故障」桶。建议:业务确认前按「`stale` + `unknownOrMissing`,并显示原因」呈现;若要独立桶,需后端加字段。
-2. **「处置率」是否就是 `handled`/`total`(含已取消事件)?** 现状:`overview/warning-settings` 已把口径显式化——`handlingEfficiency{numerator,denominator,value,state,reason}` 配合 `total/handled/pending/cancelledCount/unlinkedEventCount/invalidWorkOrderStateCount`。建议:确认即为 `handled/total`(含已取消),并在面板标注分子分母;若业务口径要排除已取消,前端可用 `cancelledCount` 复算——但这属于业务定义,需答复。
-3. **「管理单位」与后端 `maintainer`(维护单位)是否同一概念?** 现状:新增的 `management-information` 用 `organizations` + `ownershipDistribution` + `unmaintained` 建模,与 `maintainer` 分开,倾向「不是同一概念」。建议:业务确认「管理单位 = 管理机构/权属单位,`maintainer` = 维护单位」这一区分,并回写术语表。
+1. **「故障」是否等同 `stale`?** 现状:点位六态 `ALARM/WARNING/WORK_ORDER/NORMAL/OFFLINE/EMPTY`;指标六态 `NORMAL/WARNING/ALARM/EXPIRED/OFFLINE/UNKNOWN`,且字典明确 `EXPIRED` 不与 `OFFLINE` 合并;`situation.abnormalEquipment.abnormalType` 是异常分类。建议:业务确认「故障」是否即这些集合的子集,还是要独立桶。
+2. **「处置率」是否就是 `handled`/`total`(含已取消事件)?** 现状:后端已冻结口径 `basis = CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS`(已结案工单数 ÷ 事件总数,见契约手册第 2 节),并显式给出 `cancelledCount` 等分量。建议:业务确认这就是所说的处置率,面板标注分子分母。
+3. **「管理单位」与后端 `maintainer`(维护单位)是否同一概念?** 现状:`management-information` 以 `organizations` + `ownershipDistribution` + `unmaintained` 建模,与 `maintainer` 分开,倾向「不是同一概念」。建议:业务确认「管理单位 = 管理机构/权属单位,`maintainer` = 维护单位」并回写术语表。
 
 ## 影响的实现票
 
 | 票 | 影响 |
 | --- | --- |
-| 06 全局筛选 | G1:筛选联动地图依赖后端定路径(`map-snapshot` 加参 或 改用 `monitoring/distribution`) |
-| 08 地图摘要 | G1、G2 |
-| 15 WebSocket | G2:增量回联点位依赖设备编码或增量帧带 `monitoringPointCode` |
-| 17 基础设施面板 | D/E/F 契约已交付,可对接 `operating-information` / `management-information` / `emergency-support`;真实数据源待后端接线 |
-| 18 运行监测面板 | F、G、H:`monitoring/overview`、`monitoring/distribution` 可对接;H 仍缺 |
-| 19 预警处置面板 | A、B、C 仍未交付,接真实接口仍受阻;mock 路径不受阻 |
-| 20 总体概览面板 | B:`situationSummary` 趋势窗口仍不可声明 |
+| 08 地图摘要 | ✅ G1/G2 已交付(Gogs #60):`map-snapshot` 承接 `industry`/`region`/`deviceType` 并回显 `mapFilter`;票 08 的分页/快照形状不变,装载器无需改动。另需注意真实数据未接线(BT-5) |
+| 15 WebSocket | ✅ G4 已交付(Gogs #59):增量帧带 `monitoringPointCode`,未关联显式 `null` |
+| 17 基础设施面板 | D/E/F 契约已交付,可对接;真实数据待接线 |
+| 18 运行监测面板(含筛选联动地图) | ✅ G1/G2 已交付(Gogs #60):票 18 只需把 `filterStore` 的 `scope`/`industry` 传给 `map-snapshot` 的 `region`/`industry` 入参,并按 `mapFilter` 回显生效范围;`filterStore` 已在票 01 落地。F/G/H:`monitoring/overview`、`monitoring/distribution`、`monitoring/situation` 可对接 |
+| 19 预警处置面板 | ✅ 契约已齐:A/C 已交付(`events/current`、`history-analysis`、`handling-efficiency`、`multidimensional-analysis`);`monitoringPointCode` 由 BT-1(#59)交付,`coordinateValidation` 与报警阈值由 BT-4(#62)交付。接真实接口前只剩真实数据未接线(BT-5) |
+| 20 总体概览面板 | ✅ B 已交付(Gogs #61):`overview/warning-settings` 支持 `range`/`startTime`/`endTime`,可直接声明「近 7 日」并回显生效区间;真实数据待接线(BT-5) |
 
 ## Acceptance criteria(后端负责人)
 
-- [ ] `map-snapshot` 支持 `industry` 入参并在点位摘要中返回 `industry` 与点位类型。**结论不变,实现路径待后端负责人定**(候选 ① map-snapshot 加参;候选 ② 由 `monitoring/distribution` 承接,前端改取数路径)。
-- [ ] 点位摘要返回关联设备编码列表,WS 增量可回联点位。**结论不变,实现路径待定**(候选 ① 点位摘要带设备编码;候选 ② WS 增量帧带 `monitoringPointCode`)。
-- [ ] A–H 各组补契约与后端负责人逐项确认排期(或明确拒绝并记录理由)。**当前进度:D/E/F/G3 已交付(4/11 项);A/B/C/H 与 G1/G2/G4 未排期、未确认。**
-- [ ] 3 个业务问题得到业务方答复并回写本票。**当前:0/3,待业务方确认(见上「业务问题」的建议口径)。**
+- [x] `map-snapshot` 支持 `industry` 入参并在点位摘要中返回 `industry` 与点位类型。**已达成(2026-09-18,Gogs #60 已关闭)**:裁定走路径①,落地为 `industry`/`region`/`deviceType` 三入参 + 逐点 `industry`/`typeCode`/`typeName` + `mapFilter` 回显;结论写入契约手册第 3 节。
+- [x] 点位摘要返回关联设备编码列表,WS 增量可回联点位。**已达成(2026-09-18,Gogs #59 已关闭)**:候选 ① 点位摘要 `deviceCodes`、② WS 增量帧 `monitoringPointCode`、③ 事件行 `monitoringPointCode` **三者全部实现**;REST 与 WS 契约测试两侧覆盖。
+- [ ] A–H 各组补契约与后端负责人逐项确认排期(或明确拒绝并记录理由)。**当前:11 个判定项全部已交付(A、B、C、D、E、F、G1、G2、G3、G4、H)**;Gogs 票 **#59、#60、#61 已关闭**,#62(事件坐标校验)、#63(真实数据接线)待排期。
+- [ ] 3 个业务问题得到业务方答复并回写本票。**当前:0/3,待业务方确认(见上「业务问题」)。**
 
-## 进度:30%
+## 进度:79%
 
-据实更新(原记 0%,因后端在 09-16~09-17 已交付 D/E/F/G3 四组契约):
+据实更新(第一轮 30% → 第二轮 55% → 第四轮 61% → 第五轮 75%,第六轮因 #61 交付 B 上修):
 
-- 计分口径:8 组补契约拆为 11 个可判定项(A、B、C、D、E、F、G1、G2、G3、G4、H)+ 3 个业务问题 = 14 项。已交付 4 项(D/E/F/G3),B 记部分完成,合计约 30%。
-- 未完成,不 close。理由:AC1/AC2 结论未达成;AC3 只完成一半且「与后端负责人逐项确认排期」没有记录;AC4 三条业务问题 0 答复。
-- 本票未改动任何实现票的验收项;未改动后端仓库任何文件。
+- 计分口径:11 个契约判定项(A、B、C、D、E、F、G1、G2、G3、G4、H)+ 3 个业务问题 = 14 项。已交付 11 项,合计 11/14 ≈ 79%。
+- 未完成,不 close。理由:AC4 三条业务问题 0 答复;AC3 余 BT-4(#62)与 BT-5(#63)未排期。
+- 本票未改动任何实现票的验收项;后端改动分别经 Gogs #59 / #60 / #61 走后端仓库自身流程。
 
 ## 下一步
 
-1. **后端负责人(决定项)**:定 G1/G2/G4 的实现路径(`map-snapshot` 加参 vs 由 `monitoring/distribution` 承接;设备编码回联取点位摘要还是 WS 增量帧)。
-2. **后端负责人(排期项)**:A 跨设备当前/历史事件流、B `warning-settings` 的 `range` 入参、C 响应/关闭时长聚合、H 跨设备设备状态清单——排期或明确拒绝并记录理由。
-3. **后端(回填项)**:`monitoring/distribution`(#37)当前在工作区未提交,提交后回填本票状态。
+1. **✅ 已建票(2026-09-18)**:[后端契约缺口交付包](../handoff/backend-contract-gaps.md) 的五张草案已在 Gogs 建成 #59–#63;**BT-1(#59)、BT-2(#60)已实施并关闭**,其余待排期;BT-6 可选,未建。
+2. **后端负责人(待办)**:**BT-5 真实数据接线(#63)已开工未完成**——按裁定先做 `map-snapshot` 纵向样板(第 1 片已落地,见第八轮),余 21 片待续;并需裁定第八轮列出的口径(坐标校核默认值、工单状态值域、窨井行业枚举)。BT-1…BT-4(#59–#62)均已交付并关闭。
+3. **地图筛选结论已冻结**:以 `map-snapshot` 为准(`industry`/`region`/`deviceType` + `mapFilter`),见 `docs/cockpit/README.md` 第 3 节;`/monitoring/distribution` 不作为地图取数路径。
+3. **后端(程序级)**:真实数据接线**进行中**——`map-snapshot` 已接真实库(第 1/22 片),其余 21 片仍为空实现;这是接真实接口的最大差距,`#63` 未关闭。
 4. **业务方**:答复上「业务问题」三条口径。
-5. **前端实现票 06/08/15/17–20**:mock 阶段不受阻;接真实接口前先取第 1、2 项的结论,避免按错端点建模。
+5. **前端实现票 08/15/17–20(含 18 的筛选联动地图)**:mock 阶段不受阻;接真实接口前先取第 2、3 项结论,避免按错端点建模。
+6. **提醒**:后端本地 `#43` 未推送,远端仍是 `#42`;各切片 `*-demo.html` 是 fixtures,不是真实数据。

+ 32 - 2
.scratch/cockpit-shell-panel-layout/issues/27-cesium-viewer-integration.md

@@ -64,8 +64,8 @@ Category: enhancement
 
 ### 遗留 / 已排除为本票回归
 
-- **本工作区 `yarn build` / `pre.build.one` 目前跑不通(预先存在、与本票无关)**:两个未纳入版本管理的插件缺依赖 —— `src/plugins/useThreeGeospatial` → `@takram/three-3d-tiles-support`、`src/plugins/map2BuildingRoad` → `leaflet`;Rollup 在渲染 chunk 之前就失败,因此 `manualChunks` 的 cesium 分包**只有配置证据、没有产物证据**。本票的运行时证据取自 dev / pre.dev.one(走查也在这两个模式下跑)。
-- 与票 26 同类的**新触发源**:`pre.dev.one` 首次启动时 watcher 被 `src/modules/cockpit/components/.MapCanvas.vue.<pid>.<guid>.tmpdir/MapCanvas.vue.tmp` 击穿(DSH 编辑审阅临时目录这次落在源码目录旁,不匹配现有忽略清单 `**/.scratch/**` + `**/.dsh-*`)。目录是瞬时的,重试即过,故本票未改忽略清单;建议票 26 补一条 `**/*.tmpdir/**`,或由维护者定夺。
+- **(已解除,见文末补记)本工作区 `yarn build` / `pre.build.one` 曾跑不通(预先存在、与本票无关)**:两个未纳入版本管理的插件缺依赖 —— `src/plugins/useThreeGeospatial` → `@takram/three-3d-tiles-support`、`src/plugins/map2BuildingRoad` → `leaflet`;Rollup 在渲染 chunk 之前就失败,因此 `manualChunks` 的 cesium 分包**当时只有配置证据、没有产物证据**。本票的运行时证据取自 dev / pre.dev.one(走查也在这两个模式下跑)。
+- 与票 26 同类的**新触发源**:`pre.dev.one` 首次启动时 watcher 被 `src/modules/cockpit/components/.MapCanvas.vue.<pid>.<guid>.tmpdir/MapCanvas.vue.tmp` 击穿(DSH 编辑审阅临时目录这次落在源码目录旁,不匹配当时的忽略清单 `**/.scratch/**` + `**/.dsh-*`)。目录是瞬时的,重试即过。**已由票 28 会话按本票实测补上 `**/*.tmpdir` + `**/*.tmpdir/**`,并已复核(见文末补记)**。
 - 相机三键契约与真底图分别归票 03 / 21;本票只保证「默认输入已关」与「不伪造配准」。默认相机仍是全球视角;无底图球体 + 默认星空背景会盖住外壳的全息网格/扫描线浮层(`.map-holo__grid` 等 `z-index: auto`,被后插入的画布遮住),观感收口建议随票 03/21 一并定。`MapCanvas` 底部那句「临时底图 aerial.webp · 票 02 已建常驻宿主」在引擎就绪后已过时,因票面要求「组件不改」未动,需维护者定夺改法。
 - `resizeMap()` / `unmountMap()` 的转调是**代码级**证据:走查不离开 `/cockpit`,所以 `viewer.destroy()` 未在运行时直接断言;`viewer.resize()` 因 Cesium 自带 postUpdate 自动 resize,也没有做「这次 resize 确由本服务发起」的观测。
 - 本票改动**已提交**:`4d83d8a`(由票 28 会话在独立复跑 20/20 后提交,含 `.fes.js`、`package.json` / `yarn.lock`、`mapService.js`、`cesiumStaticPlugin.js`、`cesiumAssets.js`、`cesiumEngine.js` 与走查脚本)。同一提交还带上了为解开本票遗留的 build 阻塞而装的 5 个包(`@takram/three-*` 四个 + `leaflet`),其插件目录本身仍未跟踪。
@@ -77,3 +77,33 @@ Category: enhancement
 此前同一份代码上出现过一次 19/20(A15 红、`widgetChrome: 1`)。两次唯一的差别是服务实例:那次打的是**长时间运行的旧 dev server**,最可能是它给浏览器发到了更早的 `cesiumEngine.js` 模块(陈旧模块),不是代码或断言问题;事后无法回溯证实,故只作推断记录。教训:涉及引擎/懒加载模块的走查,**复跑前重启 dev server**(该条已写进走查脚本头部与 `map.md` 的 Notes)。
 
 结论:票面 20/20 与关闭结论成立,本复核只是另起一份干净服务上的可复现证据;票面正文无需改动。
+
+## 补记:build 阻塞解除 + 分包产物证据 + 第三触发源验证(票 27 会话,2026-09-17 晚)
+
+### 1. `yarn build` 现在跑得通了
+
+三个原因,逐个解掉:
+
+- `map2BuildingRoad` 缺 `leaflet` → 装 `leaflet@1.9.4`(固定版本)。
+- `useThreeGeospatial` 的 `@takram/three-*` 导入按「能不能装」分成两类,判据是 **API 对不对得上**,不是「有没有包」:
+  - **已发布到 npm 的四个** → 装成依赖:`@takram/three-geospatial@0.9.1`、`three-atmosphere@0.19.1`、`three-clouds@0.7.6`、`three-geospatial-effects@0.6.4`。关键判据:`cloudsVanilla.vue` 用的是 `new DataTextureLoader(THREE.Data3DTexture, parseUint8Array, …)`,只有**发布版**的 `DataTextureLoader` 构造函数是 `(textureClass, parser, options?, manager?)` 与之相符;插件内置的 `lib/core`(`three-geospatial@0.2.0` 快照)里这个类已改名为 `createDataTextureLoader*` 工厂,直接别名过去会在 Rollup 报 `"DataTextureLoader" is not exported`。
+  - **没有发布到 npm 的四个** → 走插件自带副本:`@takram/three-3d-tiles-support`(registry 上 **404**)、`three-terrain`、`three-terrain-core`、`three-geospatial-worker`。新增 `src/common/vendoredPluginLibAliases.js`,扫描 `src/plugins/*/lib/*/package.json`,把包名与其 `exports` 子路径(如 `@takram/three-geospatial/shaders`)映射成 Vite alias;**已被 `node_modules` 解析到的包名一律不覆盖**,于是自然形成「能装的走 npm、装不上的走插件副本」。
+- `build` 脚本本身在 Node 默认堆(4288MB)上 OOM(15526 个模块 transform 完之后挂)→ 补 `NODE_OPTIONS=--max-old-space-size=8088`,与既有的 `pre.build` / `pre.build.one` / `pre.build.icegl` 三个脚本一致。
+
+证据:
+
+- `yarn build`(不加任何环境前缀)→ **exit 0,`✓ built in 50.95s`**,四个目录照旧进产物。
+- 分包产物证据(补上验收第 1 条缺的那半):`dist/js/cesium.CGXNeBo4….js` = **3.97 MB 独立 chunk**,`cesiumEngine.*.js` 是它的懒加载入口,主入口 chunk 里以 `cesium.<hash>.js` 的形式引用它。
+- 依赖变化:`+ cesium@1.145.0`、`+ leaflet@1.9.4`、`+ @takram/three-{geospatial@0.9.1, atmosphere@0.19.1, clouds@0.7.6, geospatial-effects@0.6.4}`,以及它们的传递依赖(`react-merge-refs`、`@petamoriken/float16`、`astronomy-engine`、`url-join`、`tagged-tag`)。
+
+### 2. watcher 第三触发源:已修 + 已验
+
+`.fes.js` 的 `ignored` 现为 `['**/.scratch/**', '**/.dsh-*', '**/*.tmpdir', '**/*.tmpdir/**']`。验证两层:
+
+- 确定性:用 chokidar 自己的匹配器(`anymatch(list, undefined, { dot: true })`)对**这次真实崩溃的路径**求值 —— 旧清单 `false`(确实没盖住),新清单 `true`;目录本身 `true`、普通源码 `false`、`.scratch` 旧行为仍 `true`。
+- 活体:`yarn dev` 运行中按 DSH 的形态建同名临时目录、写两次内容、再删除,`GET /` 仍 200,服务器没被击穿,期间真实源码的 HMR 照常工作。
+
+### 3. 提交状态(需要维护者收口)
+
+`4d83d8a` 里 `.fes.js` 已经 `import … from './src/common/vendoredPluginLibAliases.js'`,但那个文件当时**没被跟踪** —— 该提交的树是缺文件的,干净检出跑不起来 build。本次已把 `src/common/vendoredPluginLibAliases.js` 与 `package.json`(build 脚本那一行)`git add` 进暂存区:**下次提交务必带上,否则 HEAD 仍是坏的**。
+

+ 51 - 0
.scratch/cockpit-shell-panel-layout/issues/29-basemap-imagery-config.md

@@ -0,0 +1,51 @@
+# 29: 底图影像(配置驱动)
+
+**What to build:** 让中央一张图能按部署配置挂上底图影像:不配就维持现状(不伪造配准),配了就要真出图。四种来源:`none`(默认)/ `natural-earth`(Cesium 自带离线影像,无需账号与外网)/ `tianditu`(天地图,tk 由配置给)/ `xyz`(任意 `{z}/{x}/{y}` 模板,甲方本地瓦片走这条)。
+
+(本票来自维护者 2026-09-17 的提问:「我应该如何进行配置,获取一个基础的地图场景呢」。票 27 故意 `baseLayer: false`,票 21 覆盖地形 / 3D Tiles / 倾斜摄影与地面-管线视图切换,底图影像这一层原先无人负责,故独立成票。归属沿用票 02 → 票 27 的切法。)
+
+Blocked by: 04
+
+Status: resolved
+Labels: ready-for-agent
+
+> 请勿据标签重复接单:本票已完成并有关闭记录,`Labels` 保留只是为了让面板不把它退化成 `needs-triage`。
+
+- [x] `map.basemap.source` 支持 `none` / `natural-earth` / `tianditu` / `xyz`,默认 `none`(票 27 的「底图未配置不伪造配准」不变)。
+- [x] 配置缺字段时给明确失败态(`invalid` + 浮标写明缺什么),不白屏、不静默无底图、不挂坏底图。
+- [x] 底图真的挂上引擎并能取到瓦片(不是只把状态标签改个名字)。
+- [x] 天地图:开发走仓库自带 `/tianditu.map` 代理、生产直连 `t{s}.tianditu.gov.cn`;tk 缺失时不发请求。
+- [x] `xyz` 的 `url` / `subdomains` / `minimumLevel` / `maximumLevel` / `credit` 都能配。
+- [x] 有纯逻辑断言(计划层,含模板拼装)与真机走查(含真实瓦片 200)。
+- [x] 既有验收不回退:T01 走查 20/20、T04 走查 9/9、`yarn build` 通过。
+
+## 进度:100%
+
+**已完成并关闭(2026-09-17)。** 现在「怎么配出一个基础地图场景」有了确定答案:默认不挂底图;把 `map.basemap.source` 改成 `natural-earth` 刷新就能看到带大陆轮廓的地球(离线、无账号);有天地图 tk 就用 `tianditu`;甲方本地瓦片用 `xyz` 填模板。
+
+### 改动
+
+- 新增 `domain/basemap.js`(纯函数,不 import cesium):`BASEMAP_SOURCE` / `BASEMAP_STATE` 词汇、`planBasemap(mapConfig, { dev })` 计划、`tiandituTileUrl()` 模板拼装、`basemapStatusText()` 浮标措辞。计划层独立的好处:逻辑脚本能脱浏览器把四种来源(含天地图开发代理 vs 生产子域、缺 tk 的失败态)全测到。
+- `services/cockpitConfig.js`:`map.basemap` 归一化(source 白名单、url/credit 字符串、subdomains 字符串数组、级别非负整数);`map.tianditu.layer` 收 `img/vec/ter`;新增构建期键 `VITE_COCKPIT_BASEMAP_SOURCE` / `VITE_COCKPIT_BASEMAP_URL`。
+- `services/cesiumEngine.js`:`createViewer(host, basemapPlan)` 按计划建 `baseLayer` —— `tms` 走 `TileMapServiceImageryProvider.fromUrl(buildModuleUrl(...))`(Cesium 自带离线影像,随 `Assets` 目录进产物),`xyz` 走 `UrlTemplateImageryProvider`;未配置或计划无效仍是 `false`(不挂底图)。
+- `services/mapService.js`:引擎启动前算计划;`data-map-basemap` 与 `getLifecycleStats().basemap` 由计划决定(`not-configured` / `natural-earth` / `xyz` / `tianditu` / `invalid`);浮标就绪文案变成「Cesium 引擎已就绪 · 底图:…」,未配置时**保持票 27 原话**(T01 的 A15 断言不变)。
+- `public/cockpit/config.example.json` + `README.md`:三种写法的可直接复制配方,以及生效判据。
+
+### 验证证据(本机 2026-09-17,`yarn dev` @ 8001)
+
+- **纯逻辑**:`node .scratch/cockpit-shell-panel-layout/verify/t29-basemap.mjs` → 通过(归一化白名单/子域/级别/图层、四来源计划、天地图开发代埋与生产子域模板、缺 tk 与未知来源的失败态、浮标措辞)。
+- **真机走查**:`node .scratch/cockpit-shell-panel-layout/verify/t29-basemap-walkthrough.mjs` → **6/6**:
+  - 默认 `none`:`data-map-basemap=not-configured`、浮标照旧「底图未配置」、**零影像请求**;
+  - `natural-earth`:宿主标成 natural-earth、浮标显示底图名、**真取到 17 块离线瓦片(HTTP 200)**;
+  - `xyz`:按 `{z}/{x}/{y}` 模板逐块取图(16 块 200,占位符被替换成数字;用仓库自带 TMS 目录当靶子,只证明通道通,不证明该地址语义——TMS 的 y 轴与 XYZ 相反);
+  - `tianditu` 缺 tk:`invalid` + 浮标写明「缺少 tk…· 回退临时影像」,引擎仍就绪;
+  - 未知来源:归一化退回 `none`,不炸也不挂坏底图。
+- **截图**:`natural-earth` 下地区有真实大陆轮廓(界面同屏:顶栏「配置就绪 · 数据源 mock」标签 + 浮标「底图:内置离线影像 Natural Earth II」)。
+- **回归**:`t01-panel-catalog` / `t03-camera-contract` / `t06-assets` / `t28-fill-container` / `t29-basemap` 全通过;`t01` 走查 **20/20**、`t04` 走查 **9/9**、`yarn build` 通过。
+
+### 边界与遗留
+
+- **天地图真实取图未在本票跑**:需要真 tk,本票不伪造。模板拼装、开发代理/生产子域、缺 tk 失败态都有断言;真 key 联调留给维护者或票 21 的底图校验。
+- 地形、3D Tiles、倾斜摄影与地面-管线视图切换仍归票 21(本票只做影像底图,不做地形)。
+- 默认视角仍是「全球视角」(票 03 的相机契约不设初始预设);要落到沅陵县域需要票 21 的视角/地形一起定。
+- 本票改动**未提交**(工作区里另有票 03 会话的在途改动)。

+ 68 - 0
.scratch/cockpit-shell-panel-layout/issues/30-intro-gate-and-view-presets.md

@@ -0,0 +1,68 @@
+# 30: 进入系统的引导与视角预设(星球自转 + 点击进入 + 快速归位)
+
+**What to build:** 进入 `/cockpit` 时先看到一个自转的星球与中央闪烁的「点击进入系统」提示;点击后镜头飞到目标城市(沅陵县 110.39028164, 28.44367870)的预设视角;进入后有一个按钮可随时一键归位到该视角。视角坐标与intro开关都由部署配置给,不硬编码在组件里。
+
+(本票来自维护者 2026-09-17 反馈:「当前我完全找不到目标位置,无法判断 DEM 及精细影像是否成功部署」+ 明确要求自转星球/点击进入/归位按钮。原先没有任何票覆盖「进入系统的引导体验」与「命名视角预设」;票 03 只管三键输入契约,票 21 只管资源加载,故独立成票。)
+
+Blocked by: 03, 21
+
+Status: resolved
+Labels: ready-for-agent
+
+> 请勿据标签重复接单:本票已完成并有关闭记录,`Labels` 保留只是为了让面板不把它退化成 `needs-triage`。
+
+## 依赖与事实
+
+- 相机契约与引擎都已就绪:票 03(三键输入 + `cameraControl`)、票 21(地形/影像已接上)。
+- **地形感知的拾取**是本票顺手做掉的前置动作:原先中心点走 `pickEllipsoid`(纯椭球,高程 0),
+  而沅陵县城地面高程一百多米起,所以拉近后相机会落进山体、画面全黑。本票:
+  ① `pickGroundAtCenter()` 用 `globe.getHeight` 把拾取点抬到真实地表;
+  ② 地形就绪后用 `sampleTerrainMostDetailed` 采样「家」视角的**实测高程**,写进预设。
+  实测:拉到距离下限 120 m 时中心高程 111.6 m(不再是 0),近景截图内容丰富(约 1 MB),**不再黑屏**。
+- 坐标口径:维护者给的 `110.39028164, 28.44367870` 是 **CGCS2000 · 天地图**坐标(与 WGS-84 在本项目精度下一致)。
+
+## 验收
+
+- [x] 进入 `/cockpit` 先看到**自转的星球**(不是静止球),且中央有**闪烁的「点击进入系统」**提示。
+- [x] 点击后镜头**飞到沅陵县预设视角**(默认 `110.39028164, 28.44367870`,可被配置覆盖),落点与目标坐标一致(走查实测误差 `1.4e-14°`)。
+- [x] 进入后有**快速归位按钮**(顶栏「归位」):平移/缩放后点它回到同一预设视角(中心、heading、pitch、range 都回;走查实测距离误差 0)。
+- [x] 预设视角与 intro 开关**由配置给**(`map.home` / `map.intro`),组件里不硬编码坐标。
+- [x] 引导层不影响既有验收:`intro.enabled=false` 可直接进系统(其它票走查用它自隔离);进入后外壳与地图行为不变。
+- [x] 近景不黑屏:地形加载后拉到公里级/下限仍能看到地面(地形感知拾取生效)。
+- [x] 有纯逻辑断言(`verify/t30-view-presets.mjs`)与真机走查记录(`verify/t30-intro-walkthrough.mjs` 7/7)。
+
+## 进度:100%
+
+**已完成并关闭(2026-09-17)。** 维护者「完全找不到目标位置、无法判断 DEM 与影像是否部署成功」的问题解决了:
+进系统就是自转星球 + 点击进入 → 落到沅陵县城上空 12 km(县域级),顶栏「归位」随时回到该视角。
+
+### 改动
+
+- 新增 `domain/viewPresets.js`(纯函数):`planHome()` / `planIntro()` 计划层、`homeLabel()` 文案、`isAtHome()` 归位判定(含容差与 heading 环绕)。
+- 新增 `components/CockpitIntro.vue`:全屏引导层(点击/Enter/Space 进入、闪烁文案、`prefers-reduced-motion` 时不动画)。
+- `services/cameraControl.js`:新增 `applyViewPreset(pivot, hpr, { immediate })` —— 整段视角(中心 + heading/pitch/range)一次到位,与票 03 的 `setFocus()`(只换中心、保持距离)区分开。
+- `services/mapService.js`:`getHomeView()` / `goHome()` / `atHome()` / `setGlobeSpin()`;**自转与飞行互斥**(飞行前先 `stopSpin()` 并置 `spinPaused`,否则自转每帧 `immediate` 写中心会把飞行目标冲掉 —— 实测踩过);`getLifecycleStats()` 增 `spin`。
+- `services/cesiumEngine.js`:`pickGroundAtCenter()` 改为地形感知(`globe.getHeight`);新增 `sampleTerrainHeight()`(`sampleTerrainMostDetailed`)。
+- `services/cockpitConfig.js`:`map.home{lon,lat,height,heading,pitch,range,label}` 与 `map.intro{enabled,title,subtitle,spinSeconds}` 的归一化与校验。
+- `CockpitShell.vue`:引导层挂载 + 顶栏「归位」按钮(`data-home-at` 供走查)+ 进入/归位都要等引擎就绪(重试到相机接上)。
+- `public/cockpit/config.json`:本机配置补上 `home`(沅陵 110.39028164, 28.4436787,pitch -55,range 12 km)与 `intro`;`config.example.json` 同步补该两节与说明。
+
+### 验证证据(`yarn dev` @ 8001,Chrome 152 headless)
+
+- 纯逻辑 `t30-view-presets.mjs`:通过(默认值、归一化含三处错字段 issue、预设解析、`homeLabel` 文案、归位判定含 heading 360≡0 与「无相机状态」、并与本机配置的沅陵坐标对齐核对)。
+- 真机走查 `t30-intro-walkthrough.mjs` → **7/7**:引导层在位且闪烁动画生效(`animationName` 非 none);**星球确实在自转**(2.6 s 内中心经度 Δ≈-19.6°);点击后引导消失且**镜头落在沅陵**(Δlon 1.4e-14°,Δlat 0,range 12 km);进入后**自转停止**(2 s 内零漂移);手动平移+缩放推离后按钮显示未在家,点「归位」回到同一视角(中心误差 1.4e-14°、距离误差 0)且按钮显示已在家;全程无页面级报错。
+- 视觉:引导页(自转星球 + 「点击进入系统」)与进入后(沅陵县城 12 km,河道/桥/地形起伏,顶栏有「归位」)。
+- 近景回归:拉到距离下限 120 m 时中心高程 **111.6 m**(地形感知生效),近景截图约 1 MB(内容非纯黑)。
+  —— 注:先前用 `drawImage(canvas)` 读回判定「黑屏」是**测错了**:WebGL 默认 `preserveDrawingBuffer:false`,
+  读回永远全黑;改用 CDP 截图判定。
+- 回归:`t01` 走查 **20/20**、`t04` **9/9**、`t29` **6/6**、`t21` **9/9**、`t28` **7/7**;
+  8 个逻辑脚本(t01/t03/t04/t06/t21/t28/t29/t30)全通过;`yarn build` exit 0。
+- 兼容处理:票 30 起 `/cockpit` 默认有引导层(z-index 20,进入前挡住交互),所以 `t01` 与 `t21` 的走查
+  显式注入 `intro:{enabled:false}`(它们是测「进入之后」的行为)——这是配置开关的正当用法,不是绕过断言。
+
+### 遗留
+
+- 引导层目前是**每次进入 `/cockpit` 都显示**(刷新即重生)。要不要"记住已进入"(localStorage)由维护者定;
+  加的话是一行判断,但会影响走查的可重复性,故未擅自加。
+- 「自转」是绕地轴推进中心经度实现的(因为 `cameraControl` 每帧 `lookAtTransform` 会覆盖直接转相机);
+  观感是星球自转,但严格说转的是相机。若将来要真正的"地球自转"(经纬网动、相机不动),需另立票。

+ 71 - 0
.scratch/cockpit-shell-panel-layout/issues/32-production-ui-cleanup.md

@@ -0,0 +1,71 @@
+# 32: 生产环境界面降噪(诊断信息收到底部小字)
+
+**What to build:** 正式环境不要把一大块状态文本压在中央一张图的 3D 场景上。开发期的诊断读数
+(配置状态、当前模式、引擎浮标、占位标题与实例提示)在生产形态下收起或退成底部半透明小字;
+**用户需要观看的颜色语义(点位状态图例、状态灯、图层控件)必须保留**。
+
+(来源:维护者 2026-09-18 明确要求 —— 「UI 上不需要那么多过于明显的 UI 文本遮盖中央一张图的 3D 场景,
+当前的状态信息主要在开发中直观 debug 使用,正式环境中只需要使用半透明小字在底部显示即可,
+只有颜色语义等用户需要观看的信息需要在 UI 中体现」。)
+
+Status: resolved
+Labels: ready-for-agent
+
+## 依赖与事实
+
+- 诊断读数的现有落点:顶栏配置标签(票 04)、引擎状态浮标(票 21,服务层自绘挂在宿主内)、
+  中央图上的「中央一张图」标题 / 图层占位 / 实例提示 / 摘要行(票 02、08、09)。
+- 票 09 的点位状态图例(`[data-map-legend]` + 状态灯)是**颜色语义**,本票明确不动它。
+- 判定形态不能只靠构建模式:地产现场可能要在生产包里看诊断信息,所以给配置开关 `map.ui.debug`
+  (显式布尔值优先,缺省跟随 `import.meta.env.DEV`)。
+
+## 验收
+
+- [x] 生产形态下,中央图上的占位标题、图层占位、实例提示、顶栏配置标签与「当前模式」读数**都不显示**。
+- [x] 生产形态下,引擎状态仍在(可观测性不变),但表现为**贴底、无底色、半透明、字号更小**的一行小字。
+- [x] 生产形态下**颜色语义保留**:点位状态图例、状态灯、图层控件、归位按钮、范围/行业/视图筛选都在。
+- [x] 故障时颜色语义不丢:生产形态下引擎失败时那行小字转淡红。
+- [x] 形态可由配置切换(`map.ui.debug`),缺省跟随构建模式:开发构建 = 调试形态,生产构建 = 干净形态。
+- [x] 开发形态**完全不变**(票 04/09/21/29 走查依赖的选择器与文案都还在)。
+- [x] 有真机走查同时覆盖两种形态。
+
+## 进度:100%
+
+**已完成(2026-09-18)。** 生产构建默认就是干净形态,无需任何配置;要现场调试生产包时把
+`map.ui.debug` 写成 `true` 即可。
+
+### 改动
+
+- `services/cockpitConfig.js`:新增 `map.ui = { debug: null }` 归一化(`null` = 跟随构建模式;脏值退回默认并记 issue)。
+- `services/mapService.js`:引擎浮标两套样式 —— `STATUS_STYLE`(调试:顶部药丸、有边框底色、11px)
+  与 `STATUS_STYLE_QUIET`(生产:贴底 2px、无边框无底色、9.5px、`rgba(186,212,224,.5)`),
+  节点带 `data-ui-mode="debug|production"` 供走查与排查;`isUiDebug()` 读配置、缺省回落 `import.meta.env.DEV`。
+- `components/MapCanvas.vue`:新增 `uiDebug` prop;生产形态收起 `.map-holo__top`(标题 + 图层占位)、
+  `.map-canvas__title`(占位标题)、`.map-holo__hint`(实例提示);摘要行加 `.is-quiet`(更小更淡),
+  **图例与状态灯原样保留**。
+- `CockpitShell.vue`:`uiDebug` 计算属性(配置优先、缺省跟随构建模式),据此收起顶栏配置标签与「当前模式」读数,
+  并把形态下发给 `MapCanvas`。
+- `docs/cockpit-deployment.md` 新增 **2.8 界面形态:开发诊断 vs 生产干净**(两种形态对照表 + 开关写法)。
+
+### 验证证据(`yarn dev` @ 8001,Chrome 152 headless)
+
+- 真机走查 `t32-ui-mode-walkthrough.mjs` → **9/9**(同一 dev server,靠注入配置切换两种形态,不依赖构建模式):
+  - 干净形态:`holoTop/title/hint/configTag/modeReadout` 全为 0;图例 8 行、状态灯 1 个、归位 1 个、视图按钮 2 个、
+    范围按钮 4 个**都在**;引擎状态 `data-ui-mode=production`、**贴底 3px、无底色、9.5px、alpha 0.5**;摘要行 `is-quiet`;
+  - 调试形态:以上诊断元素全部在位(`uiMode=debug`、顶部药丸、有底色、11px),
+    与票 04/09/21/29 走查依赖的选择器一致;
+  - 两种形态都能读到引擎状态(形态只改呈现,不改可观测性);全程无页面级报错。
+- 回归:9 个浏览器套件全绿(t01 20/20、t04 9/9、t09 28/28、t21 9/9、t21视图 10/10、t28 7/7、t29 6/6、t30 7/7、t32 9/9)、
+  9 个逻辑脚本全通过、`yarn build` exit 0。
+
+### 一个踩到的真 bug(已修)
+
+浮标是在 `setEngineState(record,'booting')` 时创建的,而那一刻**配置还没加载完**(`map.ui.debug` 拿不到),
+于是永远走 dev 回落 —— 生产形态失效。修法:`paintEngineState()` 里比对 `data-ui-mode`,
+配置到手后的下一次绘制就按当前形态重刷样式(也顺带覆盖了运行期改配置的情形)。
+
+### 顺带修掉的测试问题
+
+`t21-view-modes-walkthrough.mjs` 原来用固定 `sleep(2500)` 等相机到位,在连续跑多个 Chrome 的回归批次里
+会误判(单独跑 10/10、批次里 8/10)。改为等 `settled === true`(判据容忍引擎重建空档里的 null),
+并把「放到目标点」改成与外壳一致的重试调用 —— 现在批次里稳定 10/10。

+ 69 - 0
.scratch/cockpit-shell-panel-layout/issues/33-overlay-layout.md

@@ -0,0 +1,69 @@
+# 33: 一张图浮层布局(图例置顶 + 摘要最小化)
+
+**What to build:** 点位状态图例移到**一张图最上方**;地图摘要**收成小胶囊**(不再占一大块、
+不再有「地图摘要」文字标题);顺手修掉票 32 引入的**引擎状态与底部摘要行叠字**。
+
+(来源:维护者 2026-09-18 的三条要求 —— 「地图摘要也需最小化其形态,不应过分遮挡一张图页面」
+「点位状态图例应放在一张图的最上方显示,当前位置显示效果不好」。)
+
+Status: resolved
+Labels: ready-for-agent
+
+## 依赖与事实
+
+- 图例(票 09)与底部摘要行(票 08)都在 `components/MapCanvas.vue`;引擎状态浮标由服务层自绘(票 21/32)。
+- 先做了**几何测量**再动手:筛选条(y 64–98)并不压在地图上(地图 y 102 起),
+  真正压在一张图上的是**底部那条 81px 高的浮层带**(图例 + 标题 + 实例提示 + 摘要行)。
+- 票 32 把生产形态的引擎状态放到 `bottom:2px`,与底部摘要行(913–927)**重叠**(924–935)——
+  这是本票要一并修掉的自身缺陷。
+
+## 验收
+
+- [x] 点位状态图例在**一张图最上方**(距画布顶 ≤ 24px),且图例(颜色语义)仍然显示。
+- [x] 地图摘要最小化:去掉「地图摘要」文字标题、带状态点、宽度收窄到 ≤300px;
+      动作按钮与状态文案的 `data-*` 选择器保留(票 07/08/09/10 走查依赖它们)。
+- [x] 图例 / 引擎状态 / 底部摘要行三者**互不重叠**(两种形态下都成立)。
+- [x] 调试形态仍能读到完整诊断信息(票 32 的形态契约不回退)。
+- [x] 有真机走查(几何断言,不靠肉眼):`verify/t33-overlay-layout-walkthrough.mjs`(14/14)。
+- [x] 「无地图位置」浮块在**一张图右下角**(让开右栏),生产形态下与引擎状态日志收成右下角一摞摘要。
+
+## 进度:100%
+
+**已完成(2026-09-18)。**
+
+### 改动
+
+- `components/MapCanvas.vue`:`.map-holo__top` 改为「顶部带」——**图例在最上**(任何形态都显示),
+  调试形态的标题 + 图层占位收进 `.map-holo__headline` 放在图例下方;图例从 `.map-holo__bottom` 移出。
+- `services/mapService.js`:调试药丸 `top:74px → 96px`(让开图例与标题);生产小字
+  `bottom:2px → 26px`(**让开底部摘要行**,修掉叠字)。
+- `CockpitShell.vue`:地图摘要块去掉 `cockpit-shell__scope-label`(「地图摘要」文字),
+  收成「状态点 + 短文案 + 动作按钮」的小胶囊(`role="status"` + `aria-label` 保留语义),
+  文案上限 210→150、字号 10.5→10、加淡边框与底色。
+
+### 追补二(维护者 2026-09-18 看渲染图后)
+
+维护者指出两点,均按几何测量落地:
+
+1. **「无地图位置」浮块挪到一张图右下角**,继续减少遮挡。
+   注意**右下角 ≠ 画布右下角**:右栏是 300px 宽的浮层(共享 token `--column-w`),
+   贴 canvas 右缘会被它压住。实现为
+   `right: calc(var(--column-w) + 10u); bottom: 26u; text-align: right`。
+   实测:胶囊 **x 1194–1357(距右栏 11px)、y 885**,在下半屏、不再占中央。
+2. **收紧为「右下角状态日志」**:原中央底部那行地图摘要(`.map-holo__bottom`)在生产形态下
+   也改到同一摞(`right: calc(var(--column-w) + 10u); bottom: 22u; justify-items: end`),
+   引擎状态日志落在最下(`bottom: 8px`)—— 右下角一小摞、中央视野完全空出。
+   实测生产形态几何:胶囊 1194–1357 / y 885,引擎日志 y 918–929,两者右边线对齐、竖向不叠。
+3. 关于图中那两块(标题 + 图层占位):**生产形态下本来就不渲染**(`counts.headline = 0`、`chip = 0`),
+   已用测量确认;它们只在调试形态出现(票 32 的形态契约)。
+
+### 验证证据(`yarn dev`,Chrome 152 headless)
+
+- 新走查 `t33-overlay-layout-walkthrough.mjs` → **14/14**(两种形态各 7 条),全部按真实几何断言:
+  - 图例距画布顶 **11px**(生产与调试形态都是),图例标签 8 个在位;
+  - **无重叠**:`legendVsStatus=false, pillVsStatus=false, pillVsLegend=false, pillVsHeadline=false`
+    (生产形态:图例 113–133、药丸 900–911、摘要行 915–929 —— 修掉了票 32 的 924–935 压 913–927);
+  - 摘要块:无「地图摘要」文字节点、有状态点、宽 **230px**(≤300)、动作与文案选择器都在。
+- 票 32 走查 `t32-ui-mode-walkthrough.mjs` → **9/9**(其几何断言已按新布局更新:
+  生产小字贴底抬高一档 16–40px;调试药丸改为断言"在顶部且让开图例")。
+- 回归:t08 16/16、t09 28/28、t07/t10/t01/t04/t21/t21视图/t29/t30/t28 全绿(见票面回归批次)。

+ 95 - 0
.scratch/cockpit-shell-panel-layout/issues/34-lightweight-city-massing.md

@@ -0,0 +1,95 @@
+# 34: 沅陵县城轻量体块模型(替代被否的 cesiumlab 导出)
+
+**What to build:** 甲方那份 `yuanlingSHP`(cesiumlab `model2tiles`)经维护者目测判定
+**「明显错位 + 只是白色立方体,效果非常差」**,不采用。按维护者定的替代方向做一版
+**轻量建模**:参照 GIS 数据(路网 / 建成区 / 水系 / 真实轮廓)程序化生成县城体块,
+**远景基本达标、不要求近景细节、楼房模型可复用**;地理配准由构造保证(不再靠猜原点)。
+
+Status: resolved
+Labels: ready-for-agent
+
+## Answer
+
+**已交付,但维护者目测否决** —— 本条就此关闭,重做见 **票 35**。
+
+否决理由(维护者 2026-09-18 看渲染图后给出,逐条记下以免重做时再犯):
+
+1. **远景看不清、近景完全不像房屋、屋顶是漏的**:楼房看起来是一个个漏顶的盒子。
+   根因(自查):`emitBuilding()` 里平屋顶那一片四边形的**绕序与法线不匹配**,而材质是
+   `doubleSided: false` → 顶面被背面剔除,于是"漏顶";坡屋顶那段还先 push 了一个占位四边形再回退,
+   几何不干净。**这是可以确定的技术缺陷,不是口味问题。**
+2. **建筑尺寸与卫星图完全不搭边**:单元尺寸(9×7.2 / 14×10 / 26×11 m)与实际县城房屋尺度不符,
+   且密度/排布方式一眼是"程序生成"。
+3. **模型图层压在 Cesium 之上、不被地形遮挡**(明显 bug):楼房没有真正贴合地形,
+   高处飘、洼处陷 —— 根因是**高程取自 17×17 的粗网格双线性插值**(94–188 m 区间,
+   局部误差可达十几米),不是逐栋按 DEM 采样。
+4. 维护者明确要求:**重做时房屋要稍微加点细节**,使中近景也能辨认出是房屋。
+
+**注意**:本条的机器验证(坐标链、GLB 结构、加载三态)都是真的,错在**几何质量与地形贴合**上 ——
+教训是"能加载 + 坐标对"不等于"看起来对",视觉验收必须由维护者做,且生成器要有可调的形态参数。
+
+## 依赖与事实
+
+- 被否的那版:`D:\GIS\GIS\yuanlingSHP` 缺根 `tileset.json`、分块是局部坐标。为看效果曾补过一版
+  合成根文件(锚定"县城稠密簇 == 目标坐标"),维护者目测**错位且只有白方块** → **该合成根文件已删除**,
+  `/gis/models` 挂载也已移除。教训:靠推测锚点 + 单色方块 = 不可接受。
+- 本机可用的真原料:OSM `buildings/roads/waterways`(城区内 169 个真实轮廓 + 85 条路 + 沅江)、
+  1:5 万居民地面 `output/resa.json`(148 个建成区多边形)、本地 DEM(`yuanlingDEM`)。
+- 关键洞察:**原料是经纬度**,所以这次的地理配准是"由构造保证"的 —— 投影到以目标点为原点的
+  ENU 米制坐标,再写成 3D Tiles 根的 `transform`,不存在"猜模型原点"。
+
+## 验收
+
+- [x] 有一套**可重跑**的生成工具(不是一次性手搓产物):`tools/city-massing/build-town.mjs`。
+- [x] 地理配准正确:真实轮廓在模型里的位置与源经纬度一致(回验脚本可证伪整条坐标链)。
+- [x] 不是单一白色:按单元类型分配 13 种材质(墙 8 + 屋顶 5),且**同一输入结果稳定**(可复现)。
+- [x] 体块**坐到地形上**(本地 DEM 采样网格插值),而不是浮在椭球 0 米里。
+- [x] 明确标注"示意体块、非实测建筑、不得用于量测/统计/决策"(`meta.json` + README + 本票)。
+- [x] 真机加载无报错、内容真的被请求(`tileset.json` + `town.glb` 都 200,瓦片 visited/selected=1)。
+- [x] **远景是否达标 —— 维护者目测判定:不达标**(三项缺陷见 Answer),故本条不采用其产物。
+
+## 进度:100%
+
+**实现与机器验证完成 → 维护者目测否决(三项缺陷,见 Answer)→ 本条关闭,重做转票 35。**
+
+### 做法
+
+1. **投影**:经纬度 → 以目标点(`110.39028164, 28.4436787`)为原点的 ENU 米制坐标;
+   glTF 是 Y-up,顶点写 `(east, up, -north)`,由 Cesium 按 `(x, -z, y)` 转回局部 Z-up。
+2. **体块来源**(优先级):① OSM 真实轮廓(74 个,按外接盒起楼)→ ② 沿街排布(每条可沿街线段
+   两侧各按 23 m 间距放楼,退让 11–17 m)→ ③ 建成区补白(居民地面内 30 m 抖动网格,留 28% 空隙)。
+3. **可复用单元**:`house`(9×7.2 m,1–2 层,坡屋顶)/ `block`(14×10 m,3–5 层,平顶)/
+   `slab`(26×11 m,6–8 层,平顶);层数按"离中心距离 + 位置哈希"给,配色从 13 种里按哈希取。
+4. **地形落地**:先用本地 DEM 采 17×17 高程网格(`terrain-grid.json`,94–188 m),
+   生成时按双线性插值给每栋楼定基准高程(略埋 0.6 m 避免露缝)。
+5. **避让**:中心点距水系 < 22–26 m 的体块直接不生成(沅江两岸不"长"楼)。
+6. **产物**:`D:\GIS\GIS\cityMassing\{town.glb, tileset.json, meta.json}`(4.8 MB,单瓦片 13 图元)。
+
+### 验证证据
+
+- **回验脚本** `tools/city-massing/verify-town.mjs` → 全部通过:
+  - **真实轮廓落点**:窗口内最大的 **8/8** 个真实轮廓,其外接盒角点在 GLB 顶点里 **2 m 内命中**
+    —— 这条专门证伪"经纬度 → ENU → glTF Y-up"整条链(写反任何一步都对不上);
+  - **transform 自洽**:局部原点经 transform 回算经纬度误差 **0.000 m**;包围盒中心落在目标城区窗口内;
+  - **GLB 结构**:header/JSON/BIN 长度自洽、13 个 POSITION accessor 的 min/max 合法、
+    16.9 万顶点 / 7.07 万三角、**NaN 0**;
+  - **免责声明**在 `meta.json` 里可检出。
+- **真机加载**:`stats.tilesets=1`、`tilesetErrors=[]`、零页面报错;网络里 `tileset.json` 与
+  `town.glb` 都是 **200**;三个视点(2400 m / 1200 m / 1800 m 侧视)下诊断均为
+  **visited=1, selected=1, contentReady=1**(没被视锥剔除、被选中、内容就绪)。
+- 截图(供维护者判定):`C:\Users\ASUS\AppData\Local\Temp\cockpit-city-probe\`
+  (`1-overview-2400m-pitch-55.png`、`2-mid-1200m-pitch-45.png`、`3-oblique-1800m-pitch-20.png`)。
+- 生成统计:5000 栋(真实轮廓 74 / 民居 3450 / 条楼 1449 / 板楼 27,避让水系 8),
+  局部范围 x ±1966 m、y ±1662 m、z 99–197 m。
+
+### 踩到并修掉的一个真 bug
+
+**tileset 级 `geometricError` 写成 0 时 Cesium 不请求根瓦片内容** —— 现象是 `tileset.json` 拉到了、
+`town.glb` 始终不下载,页面上一栋楼都没有。是靠新加的诊断读数(`getTilesetDiagnostics()`:
+visited/selected/contentReady/rootContentState)定位的。改成 `800` 后内容正常加载。
+
+### 下一步
+
+1. **等维护者目测**:远景是否达标。达标 → 把 `map.tilesets` 配上(`.env.local` 已挂 `/gis/city`);
+   不达标 → 调参数(层数分布、沿街间距、配色)或换更强的数据源重做。
+2. 若正式采用,需要在部署手册与项目说明里**显著标注这是示意体块**(避免被当作实测建筑使用)。

+ 48 - 0
.scratch/cockpit-shell-panel-layout/issues/35-city-massing-redo.md

@@ -0,0 +1,48 @@
+# 35: 重做沅陵县城体块模型(修漏顶 / 贴合地形 / 尺度过目)
+
+**What to build:** 票 34 交付的轻量体块模型被维护者目测否决(漏顶、飘在地形上、尺寸不搭边、
+近景不像房屋)。本票按维护者的四条要求重做,**目标仍只要求"远景基本达标",但中近景要能辨认出是房屋**。
+
+(维护者 2026-09-18 明确:「建议另起票另行执行」。)
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+## 依赖与事实
+
+- 上一版:**票 34**(已关闭,Answer 里记了否决理由与根因);生成器在
+  `tools/city-massing/build-town.mjs`,回验脚本 `tools/city-massing/verify-town.mjs`,产物在
+  `D:\GIS\GIS\cityMassing\`。**素材与工具都在,不是从零开始。**
+- 已确认可用的真原料:OSM 真实轮廓(城区内 169 个)与路网、1:5 万居民地面、水系(沅江)、
+  本地 DEM(`yuanlingDEM`,量化网格);`map.tilesets` 通道与三态可见性都已就绪(票 21)。
+- 地理配准链**已经是对的**(票 34 的坐标回验 8/8 命中),本票不要动它 —— 只修几何与地形贴合。
+- 否决产物当前**已从 `config.json` 移除**(`map.tilesets` 留空),重做达标后再启用。
+
+## 维护者提出的三条缺陷与本票的验收
+
+1. **漏顶 + 近景不像房屋**
+   - [ ] 修掉顶面绕序/法线不匹配(或对体块统一 `doubleSided`,并删掉坡屋顶那段"先 push 再回退"的脏写法)。
+   - [ ] 顶面在任何视角都完整可见:走查/截图证明**没有可以看穿楼体内部的视角**(正面 + 俯视 + 侧视)。
+   - [ ] 楼体加**少量细节**:分层线(腰线/窗带)、女儿墙或坡屋顶檐口、底层门洞色带之类,
+     使**中近景(≤500 m)能辨认出是房屋**;不追求近景细节。
+2. **建筑尺寸与卫星图不搭边**
+   - [ ] 单元尺寸回到真实县城尺度并给出依据(对照卫星图/OSM 轮廓统计:民居 ~8–12 m、
+       商住 ~12–20 m、大体量 ~30–60 m),尺寸分布要有长尾而不是几种固定尺寸。
+   - [ ] 排布不要一眼"程序生成":朝向跟路(不要出现整齐网格)、间距有变化、避免大范围同高。
+3. **模型没有贴合地形(明显的 bug)**
+   - [ ] **逐栋按 DEM 采样基准高程**(不是 17×17 粗网格插值):每栋取足印若干角点的地形高度,
+       取最小值作为基准,并向下延伸一段"基础裙边"(埋入地下 3–8 m),使斜坡上也**既不悬空也不悬顶**。
+   - [ ] **验证不被地形遮挡的问题已消失**:在低视角下,山体前方的楼应被地形正确遮挡
+       (即"图层压在 Cesium 之上"的现象不再复现)—— 需要一组能证伪的截图/几何断言。
+   - [ ] 明确深度/遮挡相关设置是否被误改(`scene.globe.depthTestAgainstTerrain` 等),并在票面记录结论。
+
+## 附带要求
+
+- [ ] 生成器参数化:**尺寸分布、层数、沿街间距、基础裙边、细节开关**都能一条命令调,便于按维护者反馈迭代。
+- [ ] 回验脚本继续守住"坐标链正确"(票 34 的那条 8/8 命中断言要保留,不能因为重做而退化)。
+- [ ] 产物仍必须显著标注**示意体块、非实测建筑、不得用于量测/统计/决策**(README + meta.json + 部署手册)。
+- [ ] 维护者目测判定"远景达标 + 中近景可辨认"才算通过 —— 我这边看不了图,需给出一组对比截图。
+
+## 进度:0%
+
+未动工(维护者要求另起票另行执行)。

+ 14 - 3
.scratch/cockpit-shell-panel-layout/map.md

@@ -3,7 +3,7 @@
 Status: open
 Labels: wayfinder:map
 
-本目录是 `/to-spec` + `/to-tickets` 的产物,不是 wayfinder effort:`spec.md` 是规格,`issues/01`–`23` 是实现票(01、02、03、04、06 已关闭),`issues/24`–`30` 是调研、待人工处理项与后续新增票(24 调研已关闭;25 待人工;26 watcher 崩溃已修复;**27 接入 Cesium 常驻 Viewer**,是票 03 的前置,已关闭;28 外壳填满容器已关闭;29 底图影像(配置驱动)已关闭;30 进入引导与视角预设**实施中**)。
+本目录是 `/to-spec` + `/to-tickets` 的产物,不是 wayfinder effort:`spec.md` 是规格,`issues/01`–`23` 是实现票(01、02、03、04、05、06、07、08、09、10 已关闭),`issues/24`–`30` 是调研、待人工处理项与后续新增票(24 调研已关闭;25 待人工;26 watcher 崩溃已修复;**27 接入 Cesium 常驻 Viewer**,是票 03 的前置,已关闭;28 外壳填满容器已关闭;29 底图影像(配置驱动)已关闭;30 进入引导与视角预设已关闭)。
 本 `map.md` 的唯一作用是把这个 effort 暴露给 issue tracker 面板——面板只枚举含 `map.md` 的 `.scratch/<effort>/`,没有 `map.md` 的目录连同它的票都不会被列出。
 `Labels: wayfinder:map` 在这里只用于让面板按**地图视图**渲染本 effort 的子票状态、进度与依赖分层;不代表本 effort 由 `/wayfinder` 驱动——推进仍走 `/implement`。
 
@@ -18,7 +18,7 @@ Labels: wayfinder:map
 - `issues/25`(ready-for-human)是后端补契约与业务定义缺口,需要人或后端负责人处理;不阻塞 `01`–`23` 的 mock 阶段。
 - `issues/26`(resolved)是 dev server watcher 的 EBUSY 崩溃修复:忽略清单须同时覆盖 `.scratch` 面板原子写与 DSH 编辑审阅临时文件。
 - `issues/27`(resolved)是引擎接入票:装 `cesium` + 静态资源/`CESIUM_BASE_URL` + 服务层创建唯一 Viewer;**票 03 依赖它**(相机契约必须落在真实引擎上)。已关闭:走查 20/20、`yarn build` 亦已跑通并产出 cesium 独立分包,验证证据与收口项见票面。
-- `issues/21`(claimed / 75%)是地形与 3D Tiles 基线:**已接上维护者本机 GIS 数据** —— `D:\GIS\GIS\yuanlingDEM`(量化网格 DEM)与 `D:\GIS\GIS\yuanlingPic3`(cesiumlab TMS 影像切片),dev 由 `.env.local` 的 `COCKPIT_GIS_MOUNTS` 挂载、地址写在 `public/cockpit/config.json`;全局粗糙层用 Cesium 自带离线影像(本机外网瓦片实测不可达,在线源只作可选项,维护者明确"能用内网离线就不用在线")。实测沅陵拉近 4.6km:DEM 192 块 + 影像 199 块全 200、零 404。**3D Tiles 入口已实现并验证**(`map.tilesets[]` → `Cesium3DTileset`,单项失败不影响其它),但 `D:\GIS\GIS\yuanlingSHP` 那份模型数据**缺根 tileset.json 且分块是局部坐标**,需重导或提供模型原点才能接上。**生产部署手册已写成 `docs/cockpit-deployment.md`**(运维改哪个文件、怎么改、怎么自检)。剩下:地面/管线视图切换、模型数据补齐、地形感知的相机拾取(现在拉到几百米会钻进山头)。
+- `issues/21`(claimed / **90%**)是地形与 3D Tiles 基线:**已接上维护者本机 GIS 数据** —— `D:\GIS\GIS\yuanlingDEM`(量化网格 DEM)与 `D:\GIS\GIS\yuanlingPic3`(cesiumlab TMS 影像切片),dev 由 `.env.local` 的 `COCKPIT_GIS_MOUNTS` 挂载、地址写在 `public/cockpit/config.json`;全局粗糙层用 Cesium 自带离线影像(本机外网瓦片实测不可达,在线源只作可选项,维护者明确「能用内网离线就不用在线」)。实测沅陵拉近 4.6km:DEM 192 块 + 影像 199 块全 200、零 404。**地面/管线视图切换已完成**(决策地图票 05 的语义:管线压低相机 + 放宽俯仰 + 地形与模型半透明;**构造性保证不重置中心**、三键不变、状态归 `filterStore` 且切模式不重置),逻辑 + 真机走查 10/10。**唯一阻塞**:`D:\GIS\GIS\yuanlingSHP` 那份模型数据**缺根 tileset.json 且分块是局部坐标** —— 2026-09-18 已按「CGCS2000 地理源 + 目标坐标锚定县城稠密簇」**补出根文件并渲染成功**(分块中心映射全部落在影像 bounds 内、簇中心与目标差≈5m、`tilesetErrors` 为空),**等维护者看截图判定采用与否**;不采用则删掉那个 `tileset.json` 即可,或改走轻量建模。生产部署手册在 `docs/cockpit-deployment.md`。
 - **真实启动路径是 `yarn dev`**(= `fes dev` → `.fes.js`:`layout.navigation: null`,无页头);**真实应用环境不使用 predev 启动**(维护者 2026-09-16 明确)。`yarn pre.dev` / `pre.dev.one` 是 TvT 预演站布局(`navigation: 'top'` + `isFixedHeader`,顶部多 54px 固定页头),那里出现的布局异常(如 A12 红)属环境差异。A12 证据里的 `pageHeaderH` 用来分辨这一点(见票 28)。
 - **复跑走查前重启 dev server**:长跑实例可能仍给浏览器发旧模块(懒加载的 `cesiumEngine.js` 曾因此把 A15 判红一次:`widgetChrome: 1`;重启后同一份代码 20/20、`widgetChrome: 0`)。涉及引擎、懒加载模块的走查尤其要先重启。
 - 相关技能:`implement`、`tdd`、`code-review`;TvT.js 侧基线是 `skills/tvt-js-plugin-builder/`。
@@ -38,10 +38,21 @@ Labels: wayfinder:map
 - [04: 配置与凭据注入](issues/04-config-and-credentials.md) — 已关闭:四层运行时配置(内置默认 → `VITE_COCKPIT_*` → `<base>cockpit/config.json` → 宿主注入 `__COCKPIT_CONFIG__`)由 `services/cockpitConfig.js` 唯一读取,三态 `ready/missing/failed` 在顶栏可演示(`data-config-state`);`services/cockpitApi.js` 是 mock/real 的唯一出口与唯一附加 `Authorization` 的地方(mock 分支不带凭据),失败一律回 `{ok:false, reason}` 且全仓零登录跳转;默认 mock、real 由部署配置显式开启;`map.cesium.enabled/baseUrl` 已被真消费。逻辑脚本 + 真机走查 9/9,回归 t01 20/20、build exit 0。坑:dev 的 HTML 回退只认 `Accept: text/html`,所以「默认探测配置文件」会刷 404 并打红 A11 —— 改为没有显式 URL 就不发请求。
 - [29: 底图影像(配置驱动)](issues/29-basemap-imagery-config.md) — 已关闭:`map.basemap.source` 四选一 `none`(默认,维持票 27 的不伪造配准)/ `natural-earth`(Cesium 自带离线影像,无需账号外网,实测取到 17 块瓦片 200)/ `tianditu`(tk 由 `map.tianditu.token` 给,开发走 `/tianditu.map` 代理、生产 `t{s}.tianditu.gov.cn`,缺 tk 为明确失败态)/ `xyz`(任意 `{z}/{x}/{y}` 模板,甲方本地瓦片走这条);计划层 `domain/basemap.js` 是纯函数,逻辑脚本 + 真机走查 6/6;默认行为与 T01 20/20、T04 9/9、build 均不回退。维护者「怎么配出基础地图场景」的答案就是改这一个字段。
 - [03: 相机控制契约](issues/03-camera-control-contract.md) — 已关闭(维护者人工验收「T03 验收完毕」):三键契约落在服务层(`domain/camera.js` 纯数学 + `services/cameraControl.js` 状态机与事件绑定 + `cesiumEngine.createCameraEngine()` 翻译 Cesium 方言),逻辑闸门 25/25 + 真机走查 10/10。四次复验共改五处:**平移方向反转为「抓着地图拖」**、**三类交互加帧率无关阻尼**(目标态/实际态双状态,`DAMPING_TAU_MS` 与 DEV 把手 `setCameraDamping` 可现场拖调)、**松手保留惯性**(不再用拾取点改写目标)、**极点保护**(中心硬限 ±85°,承认「经线在极点收敛、东西向失去意义」,避免在奇点上做特判)、**经度走最短弧**(`approachLongitude`;`lerpPivot`/`isSettled`/`pivotDistanceMeters` 同步,消除跨 ±180 的抽搐与绕地球一圈)。工具副产:走查新增「先关进入引导」「HMR 重载即判本轮作废」,并修掉 `projectPivot()` 的坐标口径(Cesium 返回的是画布局部坐标,需加画布原点才是窗口坐标)。
+- [30: 进入系统的引导与视角预设](issues/30-intro-gate-and-view-presets.md) — 已关闭:进 `/cockpit` 先看**自转星球 + 闪烁「点击进入系统」**,点击后飞到沅陵县城预设视角(实测落点误差 1.4e-14°),顶栏「归位」一键回该视角;坐标与开关由 `map.home` / `map.intro` 给(组件不硬编码)。顺带把**地形感知的拾取**做掉:`pickGroundAtCenter()` 改按地形、预设高程用 `sampleTerrainMostDetailed` 实测(沅陵 111.6m),**解决了拉到距离下限 120m 时的黑屏**(旧实现中心高程按椭球 0 米,相机会落进山体)。走查 7/7 + 全量回归(t01 20/20、t04 9/9、t21 9/9、t29 6/6、t28 7/7、8 个逻辑脚本、`yarn build` exit 0)。
+- [32: 生产环境界面降噪](issues/32-production-ui-cleanup.md) — 已关闭:正式环境不再把一大块状态文本压在中央 3D 场景上。形态判定 `map.ui.debug`(显式优先,缺省**跟随构建模式**:dev = 调试形态、生产构建 = 干净形态)。干净形态收起中央图占位标题/图层占位/实例提示与顶栏配置标签、「当前模式」读数;引擎状态退成**贴底 2px、无底色、9.5px、alpha 0.5** 的一行小字(故障转淡红,颜色语义不丢);**点位状态图例、状态灯、图层控件与归位按钮一律保留**。走查 9/9(同一 dev server 注入配置切换两形态)+ 9 个浏览器套件全绿 + `yarn build` exit 0。踩到并修掉的真 bug:浮标在 `booting` 就创建(那时配置还没加载),导致生产形态永远回落 dev —— 改为配置到手后按 `data-ui-mode` 重刷样式。
+- [33: 一张图浮层布局](issues/33-overlay-layout.md) — 已关闭:**图例移到一张图最上方**(距画布顶 11px,颜色语义任何形态都在),调试形态的标题/图层占位收进其下方的抬头;**地图摘要收成小胶囊**(去掉「地图摘要」文字标题,状态点 + 短文案 + 动作按钮,宽 230px,`role=status` 保留语义);并修掉票 32 引入的**引擎状态与底部摘要行叠字**(生产小字 bottom 2→26px、调试药丸 top 74→96px)。三条都按**真实几何**断言(`getBoundingClientRect` 相交判定),走查 8/8 + 全量回归 13 个浏览器套件全绿。**追补(维护者看渲染图后指出)**:真正挡屏幕的是「无地图位置」浮块 → 收起形态收成**一行小胶囊 160×26px**、展开上限 340→280px,两条口径与清单改为「常驻 DOM、视觉收起」以保住票 07 的断言面;走查 **12/12**。
+- [34: 沅陵县城轻量体块模型](issues/34-lightweight-city-massing.md) — 已关闭(**已交付但被否决**):用真实经纬度原料(OSM 轮廓/路网/水系 + 1:5 万居民地面 + 本地 DEM)程序化生成县城体块,`tools/city-massing/` 下留了可重跑生成器与回验脚本。**地理配准是对的**(投影到目标点 ENU 再写 `transform`;回验证伪整条坐标链:最大 8/8 个真实轮廓外接盒角点在 GLB 里 2m 内命中、原点回算误差 0.000m),**但维护者目测否决**:① 屋顶是漏的(顶面绕序与法线不匹配 + 材质 `doubleSided:false` → 背面剔除)② 尺寸与卫星图不搭边 ③ **模型压在地形之上、不被遮挡**(高程取自 17×17 粗网格插值,逐栋没按 DEM 采样 → 有的飘有的陷)。**教训:能加载 + 坐标对 ≠ 看起来对**;重做转 [35](issues/35-city-massing-redo.md),`map.tilesets` 已置空。
+- [05: Mock 领域适配层](issues/05-mock-domain-adapter.md) — 已关闭:驾驶舱的领域适配层落地。**交付边界经维护者裁定为「接缝 + 共享详情域」**(面板级聚合留给 17–20)。分区状态词汇收在 `domain/sectionState.js`:后端 `CockpitSectionState` 四态 1:1 + 前端特有 `UNAVAILABLE`(凭据/链路类失败),**`STALE`(有值但过期,`retained`)与 `EMPTY`(确实没值)严格分开**;判定一律走 `sectionFromResult()` 收口。`domain/` 全是纯函数(不 import service、不自己发请求),`services/{mapApi,detailApi,runtimeApi}.js` 只做「拼路径 → 走 04 的 `requestCockpitJson` → 收成一个分区」,**每个分区各自成败**(票 11「任一分区失败不遮盖其它」的契约层保证)。`REQUEST_REASON` 提到 domain 以免层次倒挂,04 的脚本与调用点零改动。契约化 mock 的字段名照抄后端演示载荷(`docs/cockpit/*.html`),五类样例 + 401 用宿主全局键切换、不改代码即可演示。逻辑闸门 178/178 + 真机走查 10/10 + 全量回归 exit 0。**真机走查抓到两条纯逻辑漏掉的真 bug**(红→绿):分区标签被 `labelOverride` 顶掉了状态词(成功/失败态肉眼几乎一样)、界面漏出内部原因码 `unavailable` —— 两条都已修并补了静态断言。下游 07–16 可直接消费 `section()`,不必再自造分区成败模型。
+- [08: 地图摘要分页与快照](issues/08-map-summary-pagination.md) — 已关闭:分页装载器落在 `domain/mapSummary.js`(纯函数 + 注入取数)+ `stores/mapStore.js`(ADR 0004 的 mapStore),语义照抄后端实现:**分页窗口是「监测点+未关联设备」一条总序列**、第 2 页起必须带第 1 页锁定的 `snapshotAt`、快照不一致即 `expired` 不拼接;组件零分页参数(静态断言守着),外壳有「地图摘要」指示器 + 加载下一页/刷新快照按钮,mock 按真实语义分页并可现场演示过期。逻辑闸门 44/44 + 真机走查 16/16 + 回归(t05 179/179、t28 7/7、t05 走查 10/10)全绿。**走查复盘修正两处**:部分翻完的分区不得标 `STALE`(票 16 词汇里那是"过期",没翻完是新鲜的 → 标 SUCCESS)、新增控件曾把 1280 宽筛选条撑溢出 202px(`min-width:0` + `flex-shrink:1` 修掉,走查守卫 F1 常驻防回归)。
+- [07: 坐标校验与无地图位置](issues/07-coordinate-validation.md) — 已关闭:坐标准入判据**收成唯一一处** `domain/coordinate.js` 的 `isRenderable()`,**只认后端 `coordinateValidation.renderable`**,前端不重算经纬度范围(那是第二套判据,分歧时错的会画到地图上);后端**全量**汇总与前端**已加载**口径**分开报**(读后端源码确认 `CockpitMapSnapshotService` 用整份快照算完再切片,故不随翻页跳动;原 `toMapBatch` 漏读这段,已补);`splitByCoordinate()` / `blockedEntries()` 保住「不上图 ≠ 不存在」,地图上新增 `MapCoordinateBlocked.vue` 作可见清单(真列表归票 10/18);顺带修掉票 08「已上图 N 个」把被拦下对象也算进去的虚报。逻辑 39/39 + 走查 20/20 + 回归(t08 44/44·16/16、t05 179/179·10/10、t01 20/20、t28/t29/t30 全绿)。**跑走查才抓到的假绿**:`data-map-blocked-backend` 同名挂在宿主与面板上,`querySelector` 命中宿主拿回整页文本使断言恒真 → 面板内选择器 + 改名后证据才为真。渲染归票 09/22。
+- [09: 点位状态图例与标记](issues/09-point-status-legend.md) — 已关闭:点位状态→视觉表现收成**唯一一张表** `domain/pointStatus.js`(六个后端状态照抄 `CockpitMonitoringPointStatus` 的标签与优先级,各带**色键+形状+文字**;另加 `UNKNOWN`「状态未发布」与 `UNASSOCIATED`「无关联设备」),地图标记(Cesium billboard,形状 SVG + 贴地)与地图图例同源于它,所以「图例与标记一致」是结构性的;语义色仍**只在 tokens.css 声明**,服务层从 `.cockpit-shell` 计算样式读出后烘焙进图片;认不出的后端状态如实落「状态未发布」,**不猜成正常**。逻辑 38/38 + 走查 28/28 + 16 个既有闸门全绿。**两个只有真机才暴露的 bug(视觉探针截图抓到,走查当时反而绿)**:① Cesium `DataSourceCollection.add()` 是**异步**的,同一 tick 先写后读会建出两个数据源(写进 A、从空 B 读回 0),表现为模块/dataset 说 8、组件渲染「点位标记 0」、DEV 把手又读回 8 → 用 `WeakMap` 缓存数据源实例;② 补画重试封顶 10 秒,引擎冷启动更慢时第一批点位永远画不上,只有点「加载下一页」才重画 → 退出判据改成**引擎终态**,并新增走查块 H(一次都不点,要求标记自己出现)。**假绿复盘**:走查第一版 C2 比的是「标记图片是否不同」,而图片含形状 → 颜色全撞也一样绿(对抗式复验抓到 25/25 假绿),已改成把颜色单独从 SVG 抠出来比、并要求逐个等于 token 实测值。地图上原**事件四级图例已移除**(本票正文禁止混用),事件图例归票 13/19。遗留:点位聚合不在本票内。
+- [10: 监测点详情与设备卡片](issues/10-monitoring-point-detail.md) — 已关闭:**两级详情落地**。层级是一条纯函数导航状态机 `domain/detailFlow.js`(`none → point → equipment`,换点位清设备、返回/关闭收口),取数只在点位级、且收口三件事(同点位不重复取数、A→B 迟到响应丢弃、选设备只换层级不取数——**设备详情聚合的加载归票 11**);地图标记点选 = 「观察型」DOM 绑定(`services/pointPick.js`,拖拽不误触发、票 03 手感不变)+ 只认 `pointCode` 的引擎拾取(`pickPointMarkerAt()` 回身份不回实体)+ `mapService.onPointPick()` 订阅;关联设备从不进标记计划,所以「不重复上图」是结构性保证。顺带修:整段 `deviceStatistics` 缺失记 `missing`,不再把契约缺口画成「0 台」。逻辑 **57/57** + 真机走查 **28/28**(真实鼠标点标记;走查脚本记下两个只有真机才暴露的坑:阻尼飞行途中点中心必空、贴地标记与高度 0 中心差 30px 地形高程)+ 回归 12 个逻辑闸门与 t01/t05/t09/t28 走查全绿。遗留:`readCoord(null, 默认)` 会得到 0(无配置文件时归位飞到 (0,0)),属票 30 的域,记在票面未改。
 
 ## Not yet specified
 
-- 甲方本地瓦片、地形与 3D Tiles 的实际交付物清单 —— **2026-09-17 已落实**:本地 DEM(`yuanlingDEM`,量化网格)与影像切片(`yuanlingPic3`,TMS)已接上并通过走查;**3D Tiles 数据也找到了**(`D:\GIS\GIS\yuanlingSHP`,cesiumlab `model2tiles`,16–20 级 769 分块 + 772 模型),但那批分块**缺根 `tileset.json`、`boundingVolume` 是局部坐标且无 `transform`**,故入口已就绪而数据尚未接上 —— 需重导(勾选生成根 tileset)或提供模型原点,见 `issues/21`。正式部署的操作手册在 **`docs/cockpit-deployment.md`**(改哪个文件、怎么改、8 条自检)。
+- 甲方本地瓦片、地形与 3D Tiles 的实际交付物清单 —— **2026-09-18 更新**:本地 DEM(`yuanlingDEM`,量化网格)与影像切片(`yuanlingPic3`,TMS)已接上并通过走查;**甲方那份 cesiumlab 3D Tiles 经目测否决**(错位 + 白模),合成根文件已删除;**轻量体块模型第一版也被否决**(漏顶 / 飘在地形上 / 尺寸不搭边,见 `issues/34` 的 Answer),重做在 **`issues/35`**(含三条缺陷的根因与验收)。在重做达标之前 `map.tilesets` 留空。正式部署的操作手册在 **`docs/cockpit-deployment.md`**。
+- **地图图例的进一步优化**(维护者 2026-09-18:「图例可能在之后需要进行优化,目前这个状态先保持即可」)——
+  图例已按上一轮要求移到一张图最上方(`issues/33`),**暂不再改**;待维护者提具体要求后再立票。
 - 设计公司视觉终稿如何替换驾驶舱占位 token。
 - 后端补契约的落地时间与责任人(见 `issues/25`)。
 

+ 18 - 0
.scratch/cockpit-shell-panel-layout/verify/fixtures/tiles/tileset.json

@@ -0,0 +1,18 @@
+{
+  "_readme": [
+    "3D Tiles 走查夹具(verify/t21-gis-walkthrough.mjs 的 A6 用)。",
+    "只有包围盒、没有 content:Cesium3DTileset.fromUrl 能正常解析并挂进场景,但什么也不画 —— ",
+    "目的只是证明「配置 → planTilesets → Cesium3DTileset → scene.primitives」这条入口链路通,",
+    "不伪造任何地理影像或模型内容。",
+    "box 是 ECEF 坐标(沅陵县城附近,半径 800m)。"
+  ],
+  "asset": { "version": "1.1", "generator": "cockpit-t21-fixture" },
+  "geometricError": 512,
+  "root": {
+    "boundingVolume": {
+      "box": [-1955773.99, 5260041.66, 3020932.99, 800, 0, 0, 0, 800, 0, 0, 0, 800]
+    },
+    "geometricError": 0,
+    "refine": "ADD"
+  }
+}

+ 12 - 0
.scratch/cockpit-shell-panel-layout/verify/t01-browser-walkthrough.mjs

@@ -115,6 +115,14 @@ try {
     })
 
     // Fes 默认 hash 路由;票里写的也是 #/cockpit。失败则退回 history 路由形态。
+    // 本机可能通过 .env.local 打开了运行时配置文件(票 04 的显式开关,指向票 21 的本地 GIS)。
+    // 本条走查测的是**内置默认**(票 27:底图未配置就不伪造配准),所以显式关掉文件层,
+    // 免得环境差异混进断言 —— 宿主给空串表示这个页面不要配置文件。
+    // 票 30:同时关掉「点击进入系统」引导层(本走查测的是进入之后的外壳行为,不需要它挡在前面)。
+    await page.evaluateOnNewDocument(() => {
+        window.__COCKPIT_CONFIG_URL__ = ''
+        window.__COCKPIT_CONFIG__ = { map: { intro: { enabled: false } } }
+    })
     let entry = ENTRY_URL
     await page.goto(entry, { waitUntil: 'networkidle2', timeout: 60000 })
     let ok = await page
@@ -586,6 +594,10 @@ try {
     // 断言「失败状态 + 失败文案 + 仍保留可读兜底(临时影像)」三者同时成立。
     const failedPage = await browser.newPage()
     try {
+        await failedPage.evaluateOnNewDocument(() => {
+            window.__COCKPIT_CONFIG_URL__ = ''
+            window.__COCKPIT_CONFIG__ = { map: { intro: { enabled: false } } }
+        })
         const cdp = await failedPage.createCDPSession()
         await cdp.send('Network.enable')
         // dev 下 cesium 由 Vite 预打包成 deps 分块;build 下是 cesium 单独分包。

+ 202 - 0
.scratch/cockpit-shell-panel-layout/verify/t04-config-walkthrough.mjs

@@ -0,0 +1,202 @@
+/**
+ * T04 真机走查:配置三态、四层来源与「凭据不进界面」在浏览器里确实成立(票 04)。
+ *
+ * 逻辑脚本(t04-config.mjs)用假 fetch 把语义测透了,这里只补浏览器里才成立的部分:
+ *   · 默认(仓库里没有 cockpit/config.json)= 缺失态,用内置 mock;
+ *   · 宿主注入 `window.__COCKPIT_CONFIG__` = 成功态,且**令牌不出现在 DOM 里**;
+ *   · 运行时配置文件(走 HTTP 真拉一份 JSON)= 成功态,source 记成 file;
+ *   · 配置文件坏掉 = 失败态,顶栏说「数据不可用」,且**不发生任何登录跳转**;
+ *   · `map.cesium.enabled=false` 确实让中央一张图退回临时影像。
+ *
+ * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/;改了引擎/懒加载模块先重启服务):
+ *   node .scratch/cockpit-shell-panel-layout/verify/t04-config-walkthrough.mjs
+ */
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
+
+/** 走查用的假令牌:它只要出现在 DOM 里就说明凭据泄漏到了界面层。 */
+const SECRET = 'walkthrough-readonly-token'
+
+const results = []
+function check(id, desc, pass, evidence) {
+    results.push({ id, desc, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ${desc}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+const puppeteer = (await import(PUPPETEER)).default
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: join(tmpdir(), 'cockpit-t04-profile'),
+    defaultViewport: { width: 1680, height: 950 },
+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
+})
+console.log(`Chrome: ${await browser.version()}`)
+
+/** 按注入脚本开一页,等外壳与配置标签就绪。 */
+async function openPage(inject) {
+    const page = await browser.newPage()
+    const errors = []
+    page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
+    page.on('console', (m) => {
+        if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
+    })
+    if (inject) await page.evaluateOnNewDocument(inject)
+
+    await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+    await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+    await page
+        .waitForFunction(() => !!document.querySelector('.cockpit-shell__config'), { timeout: 20000 })
+        .catch(() => {})
+    await sleep(300)
+    return { page, errors }
+}
+
+const readState = (page) =>
+    page.evaluate(() => {
+        const indicator = document.querySelector('.cockpit-shell__config')
+        const tag = indicator ? indicator.querySelector('.status-tag') : null
+        const host = document.querySelector('.cockpit-shell__map .map-canvas')
+        return {
+            state: (indicator && indicator.dataset.configState) || '',
+            dataSource: (indicator && indicator.dataset.dataSource) || '',
+            source: (indicator && indicator.dataset.source) || '',
+            tone: (tag && tag.dataset.tone) || '',
+            label: indicator ? indicator.textContent.trim() : '',
+            engine: (host && host.dataset.mapEngine) || '',
+            canvases: host ? host.querySelectorAll('canvas').length : -1,
+            photoLayers: host ? host.querySelectorAll('.map-canvas__photo').length : -1,
+            bodyText: document.body.innerText,
+            htmlHasSecret: document.documentElement.outerHTML.includes('walkthrough-readonly-token'),
+            href: location.href,
+        }
+    })
+
+try {
+    // ---------- A1 默认:没有配置文件 = 缺失态,内置 mock ----------
+    {
+        // 本机 .env.local 可能已打开配置文件层(指向票 21 的本地 GIS);本条测的是「没有运行时配置」
+        // 这个用例,所以显式给空串关掉文件层(宿主键优先于构建期环境变量)。
+        const { page, errors } = await openPage(() => {
+            window.__COCKPIT_CONFIG_URL__ = ''
+        })
+        const state = await readState(page)
+        check(
+            'A1-config-missing-default',
+            '仓库里没有 cockpit/config.json 时:缺失态 + 内置 mock,外壳照常渲染',
+            state.state === 'missing' &&
+                state.dataSource === 'mock' &&
+                state.source === 'defaults' &&
+                state.label.includes('配置缺失') &&
+                state.engine === 'ready',
+            JSON.stringify({ ...state, bodyText: undefined, htmlHasSecret: undefined }),
+        )
+        check('A1-no-page-errors', '默认态无页面级报错(配置缺失不该刷错误)', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        await page.close()
+    }
+
+    // ---------- A2 宿主注入 = 成功态;令牌不得进界面 ----------
+    {
+        const { page, errors } = await openPage(() => {
+            window.__COCKPIT_CONFIG__ = {
+                dataSource: 'real',
+                api: { baseUrl: '/api/cockpit/v1', token: 'walkthrough-readonly-token' },
+                ws: { url: 'ws://localhost:8000/ws/deviceStatus' },
+            }
+        })
+        const state = await readState(page)
+        check(
+            'A2-config-injected-ready',
+            '宿主注入 window.__COCKPIT_CONFIG__:成功态、数据源 real、来源记成 window',
+            state.state === 'ready' && state.dataSource === 'real' && state.source === 'window' && state.label.includes('real'),
+            JSON.stringify({ state: state.state, source: state.source, label: state.label }),
+        )
+        check(
+            'A2-credential-not-in-dom',
+            '配置里的令牌不出现在 DOM/顶栏(页面与 store 不碰凭据)',
+            state.htmlHasSecret === false && !state.bodyText.includes(SECRET),
+            `htmlHasSecret=${state.htmlHasSecret}`,
+        )
+        check('A2-no-page-errors', '注入成功态无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        await page.close()
+    }
+
+    // ---------- A3 运行时配置文件(真 HTTP 拉一份 JSON)= 成功态 ----------
+    {
+        const { page } = await openPage(() => {
+            window.__COCKPIT_CONFIG_URL__ = 'cockpit/config.example.json'
+        })
+        const state = await readState(page)
+        check(
+            'A3-config-file-ready',
+            '部署配置文件走 HTTP 真正加载:成功态、来源记成 file、地址可观测',
+            state.state === 'ready' && state.source === 'file' && state.dataSource === 'mock',
+            JSON.stringify({ state: state.state, source: state.source, label: state.label }),
+        )
+        await page.close()
+    }
+
+    // ---------- A4 配置文件坏掉 = 失败态,且绝不跳登录页 ----------
+    {
+        const { page } = await openPage(() => {
+            // aerial.webp 是真文件但不是 JSON:模拟「部署放了错东西」。
+            window.__COCKPIT_CONFIG_URL__ = 'cockpit/aerial.webp'
+        })
+        const state = await readState(page)
+        check(
+            'A4-config-failed',
+            '配置文件不可用:失败态、顶栏红色「数据不可用」、仍给出可跑的内置 mock',
+            state.state === 'failed' &&
+                state.tone === 'lv1' &&
+                state.label.includes('数据不可用') &&
+                state.photoLayers === 1,
+            JSON.stringify({ state: state.state, tone: state.tone, label: state.label }),
+        )
+        check(
+            'A4-no-login-redirect',
+            '失败时不跳登录页:URL 不变、页面里没有登录入口(ADR 0005)',
+            state.href === ENTRY_URL && !/登录|login/i.test(state.bodyText),
+            JSON.stringify({ href: state.href }),
+        )
+        await page.close()
+    }
+
+    // ---------- A5 Cesium 确实可由配置关闭 ----------
+    {
+        const { page } = await openPage(() => {
+            window.__COCKPIT_CONFIG__ = { map: { cesium: { enabled: false } } }
+        })
+        await page
+            .waitForFunction(
+                () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'disabled',
+                { timeout: 20000 },
+            )
+            .catch(() => {})
+        const state = await readState(page)
+        check(
+            'A5-cesium-disabled-by-config',
+            'map.cesium.enabled=false:引擎不启动,中央区域保留临时影像兜底',
+            state.state === 'ready' && state.engine === 'disabled' && state.canvases === 0 && state.photoLayers === 1,
+            JSON.stringify({ state: state.state, engine: state.engine, canvases: state.canvases, photoLayers: state.photoLayers }),
+        )
+        await page.close()
+    }
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((r) => !r.pass)
+console.log('')
+console.log(`T04 走查:${results.length - failed.length}/${results.length} 项通过。`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((f) => f.id).join(', '))
+    process.exit(1)
+}
+console.log('T04 走查全部通过。')

+ 339 - 0
.scratch/cockpit-shell-panel-layout/verify/t04-config.mjs

@@ -0,0 +1,339 @@
+/**
+ * T04 配置与凭据注入 纯逻辑检查(票 04)。
+ *
+ * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
+ * 运行:node .scratch/cockpit-shell-panel-layout/verify/t04-config.mjs
+ */
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import {
+    CONFIG_STATE,
+    CREDENTIAL,
+    DATA_SOURCE,
+    configStatusView,
+    isCredentialFailure,
+} from '../../../src/modules/cockpit/domain/configStatus.js'
+import {
+    CONFIG_FILE_PATH,
+    CONFIG_URL_AUTO,
+    ENV_KEYS,
+    builtinConfig,
+    getCockpitConfig,
+    getCockpitConfigState,
+    loadCockpitConfig,
+    mergeConfig,
+    normalizeConfig,
+    readConfigPayload,
+    readEnvConfig,
+    resetCockpitConfigForTest,
+    resolveConfigUrl,
+} from '../../../src/modules/cockpit/services/cockpitConfig.js'
+import {
+    REQUEST_REASON,
+    planCockpitRequest,
+    registerMockTransport,
+    requestCockpitJson,
+    resetMockTransportsForTest,
+} from '../../../src/modules/cockpit/services/cockpitApi.js'
+
+// ---------------------------------------------------------------- 假响应
+const okJson = (body) => ({
+    ok: true,
+    status: 200,
+    headers: { get: () => 'application/json' },
+    text: async () => JSON.stringify(body),
+    json: async () => body,
+})
+const okHtml = () => ({ ok: true, status: 200, text: async () => '<!doctype html><html><body>app</body></html>' })
+const brokenJson = () => ({ ok: true, status: 200, text: async () => '{ "dataSource": ' })
+const notFound = () => ({ ok: false, status: 404, text: async () => 'Not Found' })
+const serverError = () => ({ ok: false, status: 500, text: async () => 'boom' })
+const statusOnly = (status, body = '') => ({
+    ok: status >= 200 && status < 300,
+    status,
+    text: async () => body,
+    json: async () => JSON.parse(body),
+})
+
+function loadWith(fetchImpl, { scope = {}, env = {}, explicitUrl = CONFIG_URL_AUTO } = {}) {
+    resetCockpitConfigForTest()
+    return loadCockpitConfig({
+        scope,
+        env: explicitUrl ? { ...env, [ENV_KEYS.configUrl]: explicitUrl } : env,
+        fetchImpl,
+        base: './',
+        documentBase: 'http://localhost:8000/#/cockpit',
+        now: Date.UTC(2026, 8, 17),
+    })
+}
+
+// ---------------------------------------------------------------- 1. 内置默认
+const defaults = builtinConfig()
+assert.equal(defaults.dataSource, DATA_SOURCE.MOCK, '默认必须是 mock(真接口要显式开启)')
+assert.deepEqual(defaults.api, { baseUrl: '', token: '' }, '默认不得内建任何后端地址或令牌')
+assert.equal(defaults.map.cesium.enabled, true, '默认开启 Cesium 引擎')
+
+// ---------------------------------------------------------------- 2. 归一化 / 宽松校验
+const empty = normalizeConfig({})
+assert.deepEqual(empty.config, defaults, '空配置应等于内置默认')
+assert.deepEqual(empty.issues, [], '空配置不该报问题')
+
+const bad = normalizeConfig({ dataSource: 'real接口', api: { baseUrl: 123 }, map: { cesium: { enabled: 'no' } } })
+assert.equal(bad.config.dataSource, DATA_SOURCE.MOCK, '非法 dataSource 应退回 mock')
+assert.equal(bad.config.api.baseUrl, '', '非字符串地址应退回默认')
+assert.equal(bad.config.map.cesium.enabled, true, '非布尔开关应退回默认')
+assert.equal(bad.issues.length, 3, `三个错字段各记一条 issue,实际 ${JSON.stringify(bad.issues)}`)
+
+const real = normalizeConfig({ dataSource: 'real', api: { baseUrl: ' /api/cockpit/v1 ', token: ' tok ' }, map: { cesium: { enabled: false } } })
+assert.equal(real.config.dataSource, DATA_SOURCE.REAL, 'real 应被接受')
+assert.equal(real.config.api.baseUrl, '/api/cockpit/v1', '地址应去空白')
+assert.equal(real.config.api.token, 'tok', '令牌应去空白')
+assert.equal(real.config.map.cesium.enabled, false, '显式关闭应被接受')
+
+// ---------------------------------------------------------------- 3. 环境变量层 + 深合并
+const envConfig = readEnvConfig({
+    [ENV_KEYS.dataSource]: 'real',
+    [ENV_KEYS.apiBaseUrl]: 'https://gateway.example.com/api/cockpit/v1',
+    [ENV_KEYS.cesiumBaseUrl]: 'https://cdn.example.com/cesium/',
+    IGNORED_KEY: 'nope',
+})
+assert.equal(envConfig.dataSource, 'real', '环境变量应映射成 dataSource')
+assert.equal(envConfig.api.baseUrl, 'https://gateway.example.com/api/cockpit/v1', '环境变量应映射成 api.baseUrl')
+assert.equal(envConfig.map.cesium.baseUrl, 'https://cdn.example.com/cesium/', '环境变量应映射成 map.cesium.baseUrl')
+assert.equal(envConfig.IGNORED_KEY, undefined, '白名单之外的键不进配置')
+assert.deepEqual(readEnvConfig({}), {}, '没有相关环境变量时返回空对象')
+assert.deepEqual(readEnvConfig(undefined), {}, 'env 缺失也不应抛错')
+
+const merged = mergeConfig({ api: { baseUrl: 'a', token: 'x' }, ws: { url: 'w' } }, { api: { baseUrl: 'b' } })
+assert.deepEqual(merged, { api: { baseUrl: 'b', token: 'x' }, ws: { url: 'w' } }, '深合并应保留未覆盖的兄弟字段')
+
+// ---------------------------------------------------------------- 4. 配置文件地址解析
+assert.equal(
+    resolveConfigUrl({ base: './', documentBase: 'http://localhost:8000/#/cockpit' }),
+    `http://localhost:8000/${CONFIG_FILE_PATH}`,
+    '默认应相对应用基址取 cockpit/config.json(hash 路由不影响)',
+)
+assert.equal(
+    resolveConfigUrl({ base: './', documentBase: 'http://host/app/index.html' }),
+    `http://host/app/${CONFIG_FILE_PATH}`,
+    '子目录部署应落在文档同级目录',
+)
+assert.equal(
+    resolveConfigUrl({ base: 'https://cos.example.com/qiankun/tvt/', documentBase: 'http://host/' }),
+    `https://cos.example.com/qiankun/tvt/${CONFIG_FILE_PATH}`,
+    'qiankun 注入基址应被采用',
+)
+assert.equal(
+    resolveConfigUrl({ documentBase: 'http://host/app/', explicitUrl: 'cockpit/aerial.webp' }),
+    'http://host/app/cockpit/aerial.webp',
+    '显式相对 URL 应相对文档解析',
+)
+assert.equal(
+    resolveConfigUrl({ documentBase: 'http://host/', explicitUrl: 'https://cfg.example.com/cockpit.json' }),
+    'https://cfg.example.com/cockpit.json',
+    '显式绝对 URL 应原样采用',
+)
+assert.equal(
+    resolveConfigUrl({ base: './', documentBase: 'http://host/app/index.html', explicitUrl: CONFIG_URL_AUTO }),
+    `http://host/app/${CONFIG_FILE_PATH}`,
+    "'auto' 应按应用基址取约定路径(qiankun / 子目录下也对)",
+)
+
+// ---------------------------------------------------------------- 5. 载荷判定
+assert.deepEqual((await readConfigPayload(okJson({ a: 1 }))).raw, { a: 1 }, 'JSON 对象应被解析')
+assert.equal((await readConfigPayload(okHtml())).reason, 'missing', 'HTML 载荷 = 文件不存在(dev SPA 回退)')
+assert.equal((await readConfigPayload(notFound())).reason, 'missing', '404 = 文件不存在')
+assert.equal((await readConfigPayload(serverError())).reason, 'http-500', '5xx = 配置不可用')
+assert.equal((await readConfigPayload(brokenJson())).reason, 'invalid-json', '坏 JSON = 配置不可用')
+assert.equal((await readConfigPayload(okJson([1, 2]))).reason, 'not-an-object', '数组不是合法配置')
+
+// ---------------------------------------------------------------- 6. 三态:成功 / 缺失 / 失败
+const readyState = await loadWith(async () => okJson({ dataSource: 'real', api: { baseUrl: '/api/cockpit/v1', token: 'readonly' } }))
+assert.equal(readyState.state, CONFIG_STATE.READY, '拿到合法配置应为 ready')
+assert.equal(readyState.source, 'file', '来源应记成 file')
+assert.equal(readyState.config.dataSource, DATA_SOURCE.REAL, '文件里的 dataSource 应生效')
+assert.equal(readyState.credential, CREDENTIAL.CONFIG, '配置里带令牌时应标成 config')
+
+const missingState = await loadWith(async () => okHtml())
+assert.equal(missingState.state, CONFIG_STATE.MISSING, 'dev 下 200 HTML(SPA 回退)应判为缺失')
+assert.equal(missingState.config.dataSource, DATA_SOURCE.MOCK, '缺失时退回内置 mock')
+assert.equal(missingState.url, `http://localhost:8000/${CONFIG_FILE_PATH}`, '缺失态也应记录尝试过的地址')
+
+const failedState = await loadWith(async () => brokenJson())
+assert.equal(failedState.state, CONFIG_STATE.FAILED, '坏 JSON 应为 failed')
+assert.match(failedState.error, /invalid-json/, '失败原因应可读')
+assert.equal(failedState.config.dataSource, DATA_SOURCE.MOCK, '失败时仍要给出可跑的默认配置')
+
+const throwState = await loadWith(async () => {
+    throw new Error('network down')
+})
+assert.equal(throwState.state, CONFIG_STATE.FAILED, '网络异常应为 failed 而不是抛出')
+assert.match(throwState.error, /network down/, '应带上原始错误信息')
+
+const envOnly = await loadWith(async () => notFound(), { env: { [ENV_KEYS.apiBaseUrl]: '/api/cockpit/v1' } })
+assert.equal(envOnly.state, CONFIG_STATE.READY, '只有构建期环境变量时也算有配置(不是缺失)')
+assert.equal(envOnly.source, 'env', '来源应记成 env')
+assert.equal(envOnly.config.api.baseUrl, '/api/cockpit/v1', '环境变量地址应生效')
+
+// 没有显式配置 URL 时**一个请求都不发**:否则每页都会在控制台刷一个配置文件的 404,
+// 而走查 A11「无页面级报错」会因此变红(本项就是那条回归的守卫)。
+let silentCalls = 0
+const silentState = await loadWith(async () => {
+    silentCalls += 1
+    return notFound()
+}, { explicitUrl: '' })
+assert.equal(silentCalls, 0, '没配置 URL 时不得探测配置文件')
+assert.equal(silentState.state, CONFIG_STATE.MISSING, '没配置 URL 时应是缺失态')
+assert.equal(silentState.url, '', '缺失态且没探测时不该有尝试过的地址')
+
+// 'auto' = 按约定路径真的去拉(部署最常用的一种)
+let autoUrl = ''
+const autoState = await loadWith(
+    async (url) => {
+        autoUrl = url
+        return okJson({ dataSource: 'mock' })
+    },
+    { explicitUrl: CONFIG_URL_AUTO },
+)
+assert.equal(autoUrl, `http://localhost:8000/${CONFIG_FILE_PATH}`, "'auto' 应请求应用基址下的约定路径")
+assert.equal(autoState.state, CONFIG_STATE.READY, "'auto' 拉到合法配置应为 ready")
+assert.equal(autoState.source, 'file', '来源应记成 file')
+
+// 宿主注入:优先级最高,且给了就不再拉文件
+let fetchCalls = 0
+const injectedState = await loadWith(async () => {
+    fetchCalls += 1
+    return okJson({ dataSource: 'mock' })
+}, { scope: { __COCKPIT_CONFIG__: { dataSource: 'real', api: { baseUrl: '/from-window' } } } })
+assert.equal(injectedState.state, CONFIG_STATE.READY, '宿主注入应为 ready')
+assert.equal(injectedState.source, 'window', '来源应记成 window')
+assert.equal(injectedState.config.api.baseUrl, '/from-window', '宿主注入应生效')
+assert.equal(fetchCalls, 0, '宿主注入了就不该再请求配置文件')
+
+// 缓存:同一进程内只解析一次
+const cachedAgain = await loadCockpitConfig()
+assert.equal(cachedAgain, injectedState, '第二次调用应直接命中缓存')
+assert.equal(getCockpitConfig().api.baseUrl, '/from-window', '同步读应拿到同一份配置')
+assert.equal(getCockpitConfigState().state, CONFIG_STATE.READY, '同步状态应与载入结果一致')
+
+// ---------------------------------------------------------------- 7. 请求计划(凭据只在这里出现)
+const mockPlan = planCockpitRequest(builtinConfig(), 'map/summary')
+assert.equal(mockPlan.mode, DATA_SOURCE.MOCK, '默认走 mock')
+assert.equal(mockPlan.url, '', 'mock 不产生请求地址')
+assert.equal(mockPlan.headers.Authorization, undefined, 'mock 分支不得携带凭据')
+assert.equal(mockPlan.path, '/map/summary', '路径应归一化成以 / 开头')
+
+const realPlan = planCockpitRequest({ dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1/', token: 'readonly' } }, '/map/summary')
+assert.equal(realPlan.url, 'https://gw/api/cockpit/v1/map/summary', '真接口地址应由 baseUrl + 路径拼出')
+assert.equal(realPlan.headers.Authorization, 'Bearer readonly', '真接口应统一附加部署层给的令牌')
+assert.equal(realPlan.credential, CREDENTIAL.CONFIG, '凭据来源应可观测')
+
+const gatewayPlan = planCockpitRequest({ dataSource: 'real', api: { baseUrl: '/api/cockpit/v1' } }, '/map/summary')
+assert.equal(gatewayPlan.headers.Authorization, undefined, '没有配置令牌时不加头(靠部署层网关补)')
+assert.equal(gatewayPlan.credential, CREDENTIAL.NONE, '凭据来源应为 none')
+
+assert.equal(
+    planCockpitRequest({ dataSource: 'real', api: { baseUrl: '' } }, '/x').error,
+    REQUEST_REASON.NO_BASE_URL,
+    'real 模式缺地址应给出明确原因,而不是拿页面地址瞎拼',
+)
+
+// ---------------------------------------------------------------- 8. 统一出口:mock/real + 失败归类
+resetMockTransportsForTest()
+const noHandler = await requestCockpitJson('/map/summary', { config: builtinConfig() })
+assert.equal(noHandler.reason, REQUEST_REASON.MOCK_MISSING, '未注册 mock 处理器应给出明确原因(票 05 来注册)')
+
+registerMockTransport('map/summary', async () => ({ points: 3 }))
+const mockOk = await requestCockpitJson('/map/summary', { config: builtinConfig() })
+assert.equal(mockOk.ok, true, 'mock 命中应成功')
+assert.deepEqual(mockOk.data, { points: 3 }, 'mock 数据应原样返回')
+resetMockTransportsForTest()
+
+const realConfig = { dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1', token: 'readonly' } }
+const unauthorized = await requestCockpitJson('/map/summary', { config: realConfig, fetchImpl: async () => statusOnly(401) })
+assert.equal(unauthorized.reason, REQUEST_REASON.UNAUTHORIZED, '401 应归类成凭据类失败')
+assert.equal(isCredentialFailure(unauthorized.reason), true, '凭据类失败应可被分区识别')
+
+const realOk = await requestCockpitJson('/map/summary', { config: realConfig, fetchImpl: async () => statusOnly(200, '{"ok":1}') })
+assert.equal(realOk.ok, true, '真接口 200 JSON 应成功')
+assert.deepEqual(realOk.data, { ok: 1 }, '真接口数据应解析出来')
+
+const badPayload = await requestCockpitJson('/map/summary', { config: realConfig, fetchImpl: async () => okHtml() })
+assert.equal(badPayload.reason, REQUEST_REASON.BAD_PAYLOAD, 'dev 下 200 HTML 应判成坏载荷(接口没实现)')
+
+const unreachable = await requestCockpitJson('/map/summary', {
+    config: realConfig,
+    fetchImpl: async () => {
+        throw new Error('ECONNREFUSED')
+    },
+})
+assert.equal(unreachable.reason, REQUEST_REASON.UNREACHABLE, '网络异常应归类成不可达')
+assert.equal(isCredentialFailure(unreachable.reason), false, '不可达不属于凭据类失败')
+
+// ---------------------------------------------------------------- 9. 顶栏视图映射
+const viewReady = configStatusView(injectedState)
+assert.equal(viewReady.dataSource, DATA_SOURCE.REAL, '视图应带上数据源')
+assert.equal(viewReady.tone, 'lv4', '就绪用绿色键')
+assert.match(viewReady.label, /real/, '标签应能读出数据源')
+
+const viewFailed = configStatusView(failedState)
+assert.equal(viewFailed.tone, 'lv1', '载入失败用红色键')
+assert.equal(viewFailed.state, CONFIG_STATE.FAILED, '失败态应透出')
+
+const viewMissing = configStatusView(missingState)
+assert.equal(viewMissing.tone, 'neutral', '缺失用中性键(还在用内置默认,不是故障)')
+assert.match(viewMissing.label, /配置缺失/, '缺失文案应可读')
+
+const viewRealViaGateway = configStatusView({ state: CONFIG_STATE.READY, credential: CREDENTIAL.NONE, config: { dataSource: DATA_SOURCE.REAL } })
+assert.match(viewRealViaGateway.detail, /部署层/, 'real 且无配置令牌时应说明凭据来自部署层')
+
+// 顶栏视图不得把令牌带到界面层(哪怕配置里确实有)
+const viewWithSecret = configStatusView({ ...readyState, config: { ...readyState.config, api: { baseUrl: '/x', token: 'super-secret' } } })
+assert.equal(JSON.stringify(viewWithSecret).includes('super-secret'), false, '视图模型不得携带令牌')
+
+// ---------------------------------------------------------------- 10. 静态守卫(对抗式)
+const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
+const readModule = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
+
+// 注意:这里查的是**凭据**令牌,不是票 06 的视觉 token(`视觉 token` / `tokens.css` 是设计令牌,允许出现)。
+const CREDENTIAL_PATTERN = /Authorization|Bearer|api\.token|ws\.token/
+const credentialFiles = ['CockpitShell.vue', 'components/MapCanvas.vue', 'components/CockpitPanel.vue', 'components/PanelPlaceholder.vue', 'components/StatusTag.vue', 'components/CockpitIcon.vue', 'stores/uiStore.js', 'stores/filterStore.js']
+for (const file of credentialFiles) {
+    assert.equal(CREDENTIAL_PATTERN.test(readModule(file)), false, `${file} 不该出现凭据(页面与 store 不碰凭据)`)
+}
+
+const allModuleFiles = [
+    'CockpitShell.vue',
+    'components/MapCanvas.vue',
+    'components/CockpitPanel.vue',
+    'components/PanelPlaceholder.vue',
+    'components/StatusTag.vue',
+    'components/CockpitIcon.vue',
+    'domain/configStatus.js',
+    'domain/panelCatalog.js',
+    'domain/filterOptions.js',
+    'domain/statusTones.js',
+    'services/cockpitApi.js',
+    'services/cockpitConfig.js',
+    'services/mapService.js',
+    'stores/uiStore.js',
+    'stores/filterStore.js',
+]
+for (const file of allModuleFiles) {
+    const text = readModule(file)
+    assert.equal(
+        /location\.(assign|replace|href)\s*=|window\.location\s*=|['"`]\/login/.test(text),
+        false,
+        `${file} 不该有任何「送去登录页」的跳转(凭据类失败只标数据不可用)`,
+    )
+}
+
+const envReaders = allModuleFiles.filter((file) => /VITE_COCKPIT_|__COCKPIT_CONFIG__/.test(readModule(file)))
+assert.deepEqual(envReaders, ['services/cockpitConfig.js'], `配置来源只该在配置服务里读,实际:${envReaders.join(', ')}`)
+
+const addressFiles = allModuleFiles.filter((file) => /\/api\/cockpit/.test(readModule(file)))
+assert.deepEqual(addressFiles, [], `页面与组件不该硬编码后端地址,实际:${addressFiles.join(', ')}`)
+
+console.log(
+    `T04 检查通过:三态(ready/missing/failed)+ 四层来源 + 宽松校验 + 载荷判定 + mock/real 同一出口 + 凭据只在适配层(${credentialFiles.length} 个页面/store 文件零 Authorization/token,${allModuleFiles.length} 个模块文件零登录跳转)。`,
+)

+ 219 - 0
.scratch/cockpit-shell-panel-layout/verify/t05-adapter-walkthrough.mjs

@@ -0,0 +1,219 @@
+/**
+ * T05 真机走查:领域适配层在浏览器里确实成立(票 05)。
+ *
+ * 逻辑脚本(t05-adapter.mjs)已经把转换语义、mock 五类样例与分区隔离测透了;
+ * 这里只补**只有在真机才成立**的部分 —— 也就是票 05 验收第 6 条的渲染侧原句:
+ *   「401 或凭据类失败只把受影响分区标为数据不可用,保留已成功分区与地图骨架」。
+ * 纯逻辑断言证明不了「地图骨架还在」:那要求页面真的渲染出来、且 Cesium 宿主没被拆掉。
+ *
+ * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/;改了懒加载模块先重启服务):
+ *   node .scratch/cockpit-shell-panel-layout/verify/t05-adapter-walkthrough.mjs
+ */
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
+
+const results = []
+function check(id, desc, pass, evidence) {
+    results.push({ id, desc, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ${desc}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+const puppeteer = (await import(PUPPETEER)).default
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: join(tmpdir(), 'cockpit-t05-profile'),
+    defaultViewport: { width: 1680, height: 950 },
+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
+})
+console.log(`Chrome: ${await browser.version()}`)
+
+/**
+ * 开一页并等分区状态板就绪。
+ * `scenario` / `onlyPath` 通过宿主全局键注入(mocks/index.js 读它们),
+ * 因此走查不必改代码或重新构建就能演示五类样例。
+ */
+async function openPage(inject) {
+    const page = await browser.newPage()
+    const errors = []
+    page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
+    page.on('console', (m) => {
+        if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
+    })
+
+    await page.evaluateOnNewDocument(() => {
+        // 引导层会盖住页面,走查不需要它(票 30 的开关)。
+        window.__COCKPIT_CONFIG__ = { dataSource: 'mock', map: { intro: { enabled: false } } }
+    })
+    if (inject) await page.evaluateOnNewDocument(inject)
+
+    await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+    await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+    await page.waitForSelector('[data-section-board]', { timeout: 20000 }).catch(() => {})
+    // 等分区板从「未加载」变成有内容(请求 + 转换 + 渲染)。
+    await page
+        .waitForFunction(() => document.querySelectorAll('[data-section-board] [data-section-state]').length >= 3, { timeout: 20000 })
+        .catch(() => {})
+    // 引擎要拉静态资源/地形,比分区取数慢:等它落到终态再断言,否则会把 booting 误判成失败。
+    // (t04/t29 的走查同样先等 dataset.mapEngine 变成终态。)
+    await page
+        .waitForFunction(
+            () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
+            { timeout: 30000 },
+        )
+        .catch(() => {})
+    await sleep(400)
+    return { page, errors }
+}
+
+const readBoard = (page) =>
+    page.evaluate(() => {
+        const tags = Array.from(document.querySelectorAll('[data-section-board] [data-section-state]'))
+        const host = document.querySelector('.cockpit-shell__map .map-canvas')
+        return {
+            sections: tags.map((t) => ({
+                name: t.dataset.sectionName,
+                state: t.dataset.sectionState,
+                tone: t.dataset.sectionTone,
+                retained: t.dataset.sectionRetained,
+                text: t.textContent.trim(),
+            })),
+            engine: (host && host.dataset.mapEngine) || '',
+            mapHostCount: document.querySelectorAll('.cockpit-shell__map .map-canvas').length,
+            shellPresent: !!document.querySelector('.cockpit-shell'),
+            panels: document.querySelectorAll('.cockpit-panel').length,
+            bodyText: document.body.innerText,
+            href: location.href,
+        }
+    })
+
+try {
+    // ---------- A1 默认(成功样例):三条共享分区都就绪 ----------
+    {
+        const { page, errors } = await openPage()
+        const board = await readBoard(page)
+        check(
+            'A1-success-sections',
+            '默认 mock:地图摘要/设备详情/数据新鲜度三条共享分区都取到数据并标为已就绪',
+            board.sections.length === 3 && board.sections.every((s) => s.state === 'success' && s.tone === 'lv4'),
+            JSON.stringify(board.sections),
+        )
+        check(
+            'A1-map-skeleton-alive',
+            '地图骨架常驻:Cesium 引擎就绪、宿主只有一个(分区取数不影响地图)',
+            board.engine === 'ready' && board.mapHostCount === 1 && board.panels === 4,
+            `engine=${board.engine} hosts=${board.mapHostCount} panels=${board.panels}`,
+        )
+        check('A1-no-page-errors', '默认态无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        await page.close()
+    }
+
+    // ---------- A2 空数据:分区显示「无数据」,不是「失败」 ----------
+    {
+        const { page } = await openPage(() => {
+            window.__COCKPIT_MOCK_SCENARIO__ = 'empty'
+        })
+        const board = await readBoard(page)
+        check(
+            'A2-empty-sections',
+            '空数据样例:设备详情与新鲜度显示「无数据」(中性色),不误报成失败',
+            board.sections.length === 3 &&
+                board.sections.filter((s) => s.state === 'empty').length >= 2 &&
+                board.sections.every((s) => s.state !== 'failed' && s.state !== 'unavailable'),
+            JSON.stringify(board.sections),
+        )
+        await page.close()
+    }
+
+    // ---------- A3 过期:保留旧值但带过期标识 ----------
+    {
+        const { page } = await openPage(() => {
+            window.__COCKPIT_MOCK_SCENARIO__ = 'stale'
+        })
+        const board = await readBoard(page)
+        const fresh = board.sections.find((s) => s.name === 'equipmentFreshness')
+        check(
+            'A3-stale-flagged',
+            '过期样例:新鲜度分区显示「数据已过期」并带 retained 标识(不得伪装成实时)',
+            !!fresh && fresh.state === 'stale' && fresh.tone === 'lv2' && fresh.retained === 'true' && fresh.text.includes('过期'),
+            JSON.stringify(fresh),
+        )
+        await page.close()
+    }
+
+    // ---------- A4 失败:分区失败但地图与其它分区照常 ----------
+    {
+        const { page } = await openPage(() => {
+            window.__COCKPIT_MOCK_SCENARIO__ = 'failed'
+        })
+        const board = await readBoard(page)
+        check(
+            'A4-failed-keeps-skeleton',
+            '失败样例:分区标为加载失败,外壳、四个面板与地图骨架全部保留',
+            board.sections.every((s) => s.state === 'failed') &&
+                board.shellPresent &&
+                board.panels === 4 &&
+                board.mapHostCount === 1 &&
+                board.engine === 'ready',
+            JSON.stringify({ sections: board.sections, engine: board.engine, panels: board.panels }),
+        )
+        await page.close()
+    }
+
+    // ---------- A5 票 05 验收第 6 条:401 只标受影响分区 ----------
+    {
+        const { page, errors } = await openPage(() => {
+            window.__COCKPIT_MOCK_SCENARIO__ = 'unauthorized'
+            // 只让「设备详情聚合」这一个分区凭据失败。必须细到该端点:
+            // 若用 `/equipments/` 前缀,新鲜度分区会被一起打掉,就证不了「只标受影响分区」。
+            window.__COCKPIT_MOCK_ONLY__ = '/equipments/*/detail-aggregate'
+        })
+        const board = await readBoard(page)
+        const affected = board.sections.find((s) => s.name === 'equipmentAggregate')
+        const healthy = board.sections.filter((s) => s.name !== 'equipmentAggregate')
+
+        check(
+            'A5-401-marks-affected-only',
+            '401/凭据类失败:只有受影响分区标为「数据不可用」(红色),其余分区保持已就绪',
+            !!affected &&
+                affected.state === 'unavailable' &&
+                affected.tone === 'lv1' &&
+                affected.text.includes('数据不可用') &&
+                healthy.length === 2 &&
+                healthy.every((s) => s.state === 'success'),
+            JSON.stringify({ affected, healthy }),
+        )
+        check(
+            'A5-map-skeleton-preserved',
+            '同一次 401:地图骨架与外壳未被牵连(引擎就绪、宿主与面板都在)',
+            board.engine === 'ready' && board.mapHostCount === 1 && board.panels === 4 && board.shellPresent,
+            `engine=${board.engine} hosts=${board.mapHostCount} panels=${board.panels}`,
+        )
+        check(
+            'A5-no-login-redirect',
+            '凭据类失败不跳登录页:URL 不变、页面里没有登录入口(ADR 0005)',
+            board.href === ENTRY_URL && !/登录|login/i.test(board.bodyText),
+            JSON.stringify({ href: board.href }),
+        )
+        check('A5-no-page-errors', '401 场景无页面级报错(失败被收敛成分区状态,没有抛出)', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        await page.close()
+    }
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((r) => !r.pass)
+console.log('')
+console.log(`T05 走查:${results.length - failed.length}/${results.length} 项通过。`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((f) => f.id).join(', '))
+    process.exit(1)
+}
+console.log('T05 走查全部通过。')

+ 540 - 0
.scratch/cockpit-shell-panel-layout/verify/t05-adapter.mjs

@@ -0,0 +1,540 @@
+/**
+ * T05 领域适配层 纯逻辑检查(票 05)。
+ *
+ * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
+ * 运行:node .scratch/cockpit-shell-panel-layout/verify/t05-adapter.mjs
+ *
+ * 分四段:
+ *   A 分区状态词汇(成功/空/字段缺失/失败/过期/401)
+ *   B 领域转换(用**后端演示载荷的真实字段名**喂进来)
+ *   C 契约化 mock(五类样例逐一取数 + 只坏一个分区)
+ *   D 静态守卫(页面与 store 不碰后端裸字段、不碰凭据)
+ */
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import {
+    BACKEND_SECTION_STATE,
+    SECTION_STATE,
+    overallState,
+    overallStateText,
+    section,
+    sectionFromResult,
+    sectionStateFromBackend,
+    sectionStateFromFreshness,
+    sectionStateFromReason,
+    sectionView,
+} from '../../../src/modules/cockpit/domain/sectionState.js'
+import { REQUEST_REASON } from '../../../src/modules/cockpit/domain/requestReason.js'
+import { fieldReader, toLocation } from '../../../src/modules/cockpit/domain/fields.js'
+import {
+    MAP_OBJECT_KIND,
+    batchObjects,
+    summarizeCoordinateValidation,
+    toMapBatch,
+    toMonitoringPoint,
+    toUnassociatedEquipment,
+} from '../../../src/modules/cockpit/domain/mapSummary.js'
+import { toMonitoringPointDetail } from '../../../src/modules/cockpit/domain/pointDetail.js'
+import {
+    EQUIPMENT_SECTIONS,
+    toEquipmentDetailAggregate,
+    toSection,
+} from '../../../src/modules/cockpit/domain/equipmentDetail.js'
+import { EVENT_FAMILY, allEvents, eventsOfFamily, sortByEventTimeDesc, toEventRead } from '../../../src/modules/cockpit/domain/events.js'
+import {
+    WORK_ORDER_CLOSED_CODE,
+    WORK_ORDER_STATES,
+    toStateDistribution,
+    toWorkOrder,
+    toWorkOrderTimeline,
+} from '../../../src/modules/cockpit/domain/workOrder.js'
+import { EXPIRED_MISSED_CYCLES, dataAgeText, hasValue, isExpired, toFreshness } from '../../../src/modules/cockpit/domain/freshness.js'
+import { buildQuery } from '../../../src/modules/cockpit/services/domainRequest.js'
+import { fetchEquipmentDetailAggregate, fetchEvents, fetchMonitoringPointDetail, fetchWorkOrders } from '../../../src/modules/cockpit/services/detailApi.js'
+import { fetchFoundation, fetchMapSnapshot } from '../../../src/modules/cockpit/services/mapApi.js'
+import { WS_MESSAGE, fetchFreshnessSection, toDeviceStatusEvent, toSubscribedEvent } from '../../../src/modules/cockpit/services/runtimeApi.js'
+import {
+    getMockTransportPaths,
+    registerMockTransport,
+    requestCockpitJson,
+    resetMockTransportsForTest,
+} from '../../../src/modules/cockpit/services/cockpitApi.js'
+import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
+
+const checks = []
+function check(name, pass, evidence) {
+    checks.push({ name, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${name}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const builtinConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
+const realConfig = { dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1', token: 'readonly' } }
+
+// ================================================================ A 分区状态词汇
+{
+    // 后端四态 1:1 映射。
+    for (const key of Object.keys(BACKEND_SECTION_STATE)) {
+        const mapped = sectionStateFromBackend(BACKEND_SECTION_STATE[key])
+        check(`A1-backend-${key}`, mapped === SECTION_STATE[key], `→ ${mapped}`)
+    }
+    check('A1-unknown-backend-state', sectionStateFromBackend('SOMETHING_ELSE') === SECTION_STATE.FAILED, '认不出的后端态不得混进「已就绪」')
+
+    // 失败原因归类:凭据类 = 数据不可用(ADR 0005,不跳登录页)。
+    check('A2-401-unavailable', sectionStateFromReason(REQUEST_REASON.UNAUTHORIZED) === SECTION_STATE.UNAVAILABLE)
+    check('A2-no-base-url', sectionStateFromReason(REQUEST_REASON.NO_BASE_URL) === SECTION_STATE.UNAVAILABLE)
+    check('A2-mock-missing', sectionStateFromReason(REQUEST_REASON.MOCK_MISSING) === SECTION_STATE.UNAVAILABLE)
+    check('A2-network-failed', sectionStateFromReason(REQUEST_REASON.UNREACHABLE) === SECTION_STATE.FAILED, '网络失败是「加载失败」而不是「数据不可用」')
+
+    // 新鲜度:EXPIRED 是「有值但过期」,必须与「空」分开。
+    check('A3-stale-not-empty', sectionStateFromFreshness('EXPIRED') === SECTION_STATE.STALE)
+    check('A3-empty', sectionStateFromFreshness('EMPTY') === SECTION_STATE.EMPTY)
+
+    // 401 只标记这一个分区:另一个分区照常成功。
+    const denied = sectionFromResult({ ok: false, reason: REQUEST_REASON.UNAUTHORIZED, mode: 'real' })
+    const fine = sectionFromResult({ ok: true, mode: 'mock', data: { x: 1 } }, { toData: (p) => p, isEmpty: () => false })
+    check('A4-401-marks-own-section', denied.state === SECTION_STATE.UNAVAILABLE && denied.reason === REQUEST_REASON.UNAUTHORIZED)
+    check('A4-neighbour-untouched', fine.state === SECTION_STATE.SUCCESS, '相邻分区不因别的分区 401 而受影响')
+
+    // 过期分区保留旧值,且带 retained 标识(票 16)。
+    const stale = sectionFromResult(
+        { ok: true, mode: 'mock', data: { metrics: [] } },
+        { toData: () => ({ metrics: [{ value: 1 }] }), isEmpty: () => true, freshnessOf: () => 'EXPIRED' },
+    )
+    check('A5-stale-keeps-value', stale.state === SECTION_STATE.STALE && stale.retained === true && !!stale.data, '过期优先于空,且保留最后已知值')
+
+    // 空数据不显示虚假零值:data 为 null。
+    const emptySection = sectionFromResult({ ok: true, mode: 'mock', data: { metrics: [] } }, { isEmpty: () => true })
+    check('A6-empty-no-fake-zero', emptySection.state === SECTION_STATE.EMPTY && emptySection.data === null, '空分区不得带 0 值数据')
+
+    // 整体态:部分可用必须与全坏区分(票 16)。
+    const partial = overallState({ a: section(SECTION_STATE.SUCCESS), b: section(SECTION_STATE.UNAVAILABLE) })
+    const allBad = overallState({ a: section(SECTION_STATE.FAILED), b: section(SECTION_STATE.FAILED) })
+    const allDenied = overallState({ a: section(SECTION_STATE.UNAVAILABLE) })
+    check('A7-partial', partial === 'partial' && overallStateText(partial).includes('部分'))
+    check('A7-all-failed', allBad === SECTION_STATE.FAILED)
+    check('A7-all-denied', allDenied === SECTION_STATE.UNAVAILABLE)
+
+    // 视图映射:不可用用票 04 的统一文案,且 detail 可读。
+    const view = sectionView(section(SECTION_STATE.UNAVAILABLE, { reason: REQUEST_REASON.UNAUTHORIZED }))
+    check('A8-view-tone', view.tone === 'lv1' && view.label === '数据不可用' && view.detail.includes('凭据'), JSON.stringify(view))
+    check('A8-success-view', sectionView(section(SECTION_STATE.SUCCESS)).tone === 'lv4')
+    check('A8-stale-view', sectionView(section(SECTION_STATE.STALE)).tone === 'lv2' && sectionView(section(SECTION_STATE.STALE)).label.includes('过期'))
+
+    // 对抗式:内部原因码不得出现在任何面向界面的文案里。
+    // 这条是真机走查第一轮抓到的回归 —— 失败分区曾把 `unavailable` 直接写进标签文本。
+    const leaked = []
+    for (const reason of Object.values(REQUEST_REASON)) {
+        for (const state of [SECTION_STATE.FAILED, SECTION_STATE.UNAVAILABLE]) {
+            const text = sectionView(section(state, { reason }))
+            if (`${text.label}${text.detail}`.includes(reason)) leaked.push(`${state}:${reason}`)
+        }
+    }
+    check('A9-no-reason-code-in-ui', leaked.length === 0, leaked.length ? `泄漏 ${leaked.join(', ')}` : '全部原因都有中文文案,零代号泄漏')
+    check('A9-unknown-reason-safe', sectionView(section(SECTION_STATE.FAILED, { reason: 'some-new-code' })).detail === '', '认不出的原因回空串,不把代号甩给值班人员')
+    check('A9-failed-still-labelled', sectionView(section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNAVAILABLE })).label === '数据加载失败', '没有文案时仍要说出「加载失败」')
+}
+
+// ================================================================ B 领域转换(真实字段名)
+{
+    // ---- 字段读取:区分「本该有却没有」与「本来就可以为空」 ----
+    const reader = fieldReader({ a: 'x', zero: 0, empty: '' }, 'r')
+    check('B1-zero-is-value', reader.optionalNum('zero') === 0 && reader.num('zero') === 0, '后端真给的 0 是有效数据,不是缺失')
+    check('B1-empty-string-missing', reader.str('empty') === '' && reader.missing.includes('r.empty'), '空串记一笔缺失')
+    check('B1-optional-null-silent', reader.optionalStr('nope') === '' && !reader.missing.includes('r.nope'), '可选字段缺失不记问题')
+    check('B1-missing-number-null', reader.num('nope') === null && reader.missing.includes('r.nope'), '缺失数字回 null,不回 0')
+
+    const noValidation = toLocation(undefined)
+    check('B2-unvalidated-not-renderable', noValidation.renderable === false && noValidation.validated === false, '整段坐标校验缺失时不假装可绘制')
+
+    // ---- 地图摘要(字段名照抄 map-snapshot-demo.html)----
+    const batch = toMapBatch({
+        page: 1,
+        pageSize: 2,
+        snapshotAt: '2026-09-02T10:00:00+08:00',
+        batchUpdatedAt: '2026-09-02T10:00:00+08:00',
+        total: 7,
+        complete: false,
+        monitoringPoints: [
+            { pointCode: 'MP-001', pointName: '供水干管监测点', pointStatus: 'NORMAL', statusReasons: [], longitude: 110.141, latitude: 28.452, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } },
+            { pointCode: 'MP-003', pointName: '污水排口监测点', pointStatus: 'WARNING', statusReasons: ['EQ-FREE-003 预警'], longitude: 110.143, latitude: 28.454, coordinateValidation: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, crs: 'WGS84' } },
+        ],
+        unassociatedEquipment: [
+            { deviceCode: 'EQ-FREE-001', deviceName: '供水压力计-001', longitude: 181, latitude: 28.455, coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' } },
+        ],
+    })
+    check('B3-batch-shape', batch.page === 1 && batch.total === 7 && batch.complete === false && batch.snapshotAt !== '', '分页与快照原样带出(票 08 用)')
+    check('B3-points-mapped', batch.points.length === 2 && batch.points[0].code === 'MP-001' && batch.points[0].kind === MAP_OBJECT_KIND.MONITORING_POINT)
+    check('B3-status-is-backend', batch.points[1].status === 'WARNING', '点位状态用后端解析结果,前端不重算')
+    check('B3-equipment-kind', batch.equipment[0].kind === MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT && batch.equipment[0].status === '', '未关联设备没有点位状态,不得借用在线态')
+
+    const objects = batchObjects(batch)
+    const validation = summarizeCoordinateValidation(objects)
+    check('B4-validation-summary', validation.total === 3 && validation.renderable === 1 && validation.blocked === 2 && validation.states.VALID === 1 && validation.states.INVALID === 1 && validation.states.UNVERIFIED === 1, JSON.stringify(validation))
+    check('B4-out-of-range-blocked', batch.equipment[0].coordinate.renderable === false && batch.equipment[0].coordinate.reason === 'LONGITUDE_OUT_OF_RANGE', '超范围坐标不上图(票 07 输入)')
+
+    // 缺失字段:不得伪造。
+    const degradedPoint = toMonitoringPoint({ pointCode: 'MP-D', pointName: '缺字段点位', pointStatus: 'NORMAL', statusReasons: [] })
+    check('B5-missing-coord-null', degradedPoint.longitude === null && degradedPoint.latitude === null && degradedPoint.coordinate.renderable === false, '坐标缺失保持 null 且不上图')
+    check('B5-missing-recorded', degradedPoint.missing.includes('monitoringPoint.longitude') && degradedPoint.missing.includes('monitoringPoint.latitude'), JSON.stringify(degradedPoint.missing))
+    check('B5-unnaffected-fields', degradedPoint.code === 'MP-D' && degradedPoint.status === 'NORMAL', '一个字段缺失不影响其它字段')
+    check('B5-zero-not-faked', toUnassociatedEquipment({ deviceCode: 'E', deviceName: 'n', longitude: 0, latitude: 0 }).longitude === 0, '真 0 坐标照原样带出(是否上图由后端 renderable 决定)')
+
+    // ---- 监测点详情(字段名照抄 monitoring-point-detail-demo.html)----
+    const pointDetail = toMonitoringPointDetail({
+        monitoringPointCode: 'MP-002',
+        monitoringPointName: '燃气调压站监测点',
+        pointStatus: 'ALARM',
+        statusLabel: '报警',
+        statusReasons: [{ status: 'ALARM', deviceCode: 'EQ-001', reason: '压力超高' }],
+        deviceStatistics: { totalDeviceCount: 2, alarmDeviceCount: 1, warningDeviceCount: 0, workOrderDeviceCount: 0, normalDeviceCount: 1, offlineDeviceCount: 0 },
+        devices: [
+            { deviceCode: 'EQ-001', deviceName: '供水压力计-001', deviceTypeName: '压力计', deviceStatus: 'ALARM', statusLabel: '报警', statusReasons: ['压力超高'], statusTime: '2026-09-02T09:59:00+08:00' },
+            { deviceCode: 'EQ-002', deviceName: '智能井盖-002', deviceTypeName: '智能井盖', deviceStatus: 'NORMAL', statusLabel: '正常', statusReasons: [], statusTime: '2026-09-02T09:59:30+08:00' },
+        ],
+        coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
+        statusDictionary: [{ status: 'ALARM', label: '报警', priority: 1 }, { status: 'NORMAL', label: '正常', priority: 4 }],
+    })
+    check('B6-point-detail', pointDetail.code === 'MP-002' && pointDetail.statistics.total === 2 && pointDetail.statusReasons[0].reason === '压力超高')
+    check('B6-device-order-kept', pointDetail.devices.map((d) => d.code).join(',') === 'EQ-001,EQ-002', '设备卡片顺序由后端决定(票 10 验收)')
+    check('B6-dictionary-priority', pointDetail.statusDictionary[0].priority === 1 && pointDetail.statusDictionary.length === 2, '状态字典带后端优先级(票 09 用)')
+
+    // ---- 工单七态 ----
+    check('B7-seven-states', WORK_ORDER_STATES.length === 7 && WORK_ORDER_STATES.map((s) => s.code).join('') === '1234567')
+    check('B7-closed-terminal', WORK_ORDER_STATES.filter((s) => s.terminal).map((s) => s.code).join(',') === '6,7', '只有已结案/已取消是终态')
+
+    const order = toWorkOrder({ workOrderCode: 'WO-1', stateCode: 4, stateName: '处理中', realFinishTime: null, eventAssociation: { eventSource: 'ALARM', eventCode: 'ALM-1' } })
+    check('B7-work-order', order.code === 'WO-1' && order.stateCode === 4 && order.realFinishTime === '' && order.event.source === 'ALARM', '未结案 realFinishTime 为 null(本来就没有,不算缺失)')
+
+    // 状态名缺失时用字典补,不显示裸数字。
+    const degradedOrder = toWorkOrder({ workOrderCode: 'WO-2', stateCode: 3 })
+    check('B7-state-name-from-dictionary', degradedOrder.stateName === '已接单', `→ ${degradedOrder.stateName}`)
+
+    // 七态分布:缺失补 0 是真实计数;字典外的键不丢。
+    const distribution = toStateDistribution({ 4: 2, 6: 1, 99: 5 })
+    check('B8-distribution-fills-zero', distribution.distribution[1] === 0 && distribution.distribution[4] === 2)
+    check('B8-distribution-total', distribution.total === 3, '总数只算七态内的(3),不把未知键混进来')
+    check('B8-unknown-kept', distribution.unknown.length === 1 && distribution.unknown[0].key === '99', '字典外的键单独列出,不静默丢')
+    check('B8-closed-code', WORK_ORDER_CLOSED_CODE === 6)
+
+    // 时间线:按 operatedAt 升序 + 回退动作如实带出(票 12:不是独立终态)。
+    const timeline = toWorkOrderTimeline({
+        workOrder: { workOrderCode: 'WO-3', stateCode: 4 },
+        timelineEntries: [
+            { logId: 3, operationName: '验收驳回', operationDesc: '待验收回退处理中', operatorName: '李四', operatedAt: '2026-09-02T11:00:00+08:00', stateCode: 4, stateName: '处理中' },
+            { logId: 1, operationName: '创建工单', operationDesc: '待派单', operatorName: '系统', operatedAt: '2026-09-02T09:00:00+08:00', stateCode: 1, stateName: '待派单' },
+            { logId: 2, operationName: '提交验收', operationDesc: '待验收', operatorName: '张三', operatedAt: '2026-09-02T10:00:00+08:00', stateCode: 5, stateName: '待验收' },
+        ],
+    })
+    check('B9-timeline-sorted', timeline.entries.map((e) => e.operationName).join(',') === '创建工单,提交验收,验收驳回', '轨迹按时间升序(后端顺序不保证)')
+    check('B9-rollback-is-action', timeline.entries[2].stateCode === 4 && timeline.entries[2].operationName === '验收驳回', '验收驳回是回到「处理中」的动作,不是第八态')
+
+    // ---- 事件(字段名照抄 event-read-demo.html)----
+    const eventRead = toEventRead({
+        deviceCode: 'TC-1',
+        deviceTypeName: '污水环境监测设备',
+        state: 'READY',
+        stateReason: 'READY',
+        warnings: [{ warningNo: 'WRN-1', warningName: '污水COD预警', level: 2, levelColor: 'YELLOW', status: 'PROCESSING', eventTime: '2026-09-02T09:59:30+08:00', value: 18.2, unit: 'mg/L', deviceCode: 'TC-1' }],
+        alarms: [{ alarmId: 'ALM-1', warningCode: 'WS_ENV_HIGH', warningName: '污水COD报警', level: 1, levelColor: 'RED', status: 'UNHANDLED', eventTime: '2026-09-02T10:00:00+08:00', value: 42, thresholdMinValue: 40, thresholdMaxValue: 50, unit: 'mg/L', deviceCode: 'TC-1' }],
+        alarmWarningRelations: [{ relationId: 'REL-1', warningNo: 'WRN-1', alarmId: 'ALM-1', relatedAt: '2026-09-02T10:00:00+08:00' }],
+    })
+    check('B10-families', eventRead.warnings[0].family === EVENT_FAMILY.WARNING && eventRead.alarms[0].family === EVENT_FAMILY.ALARM)
+    check('B10-unified-code', eventRead.warnings[0].code === 'WRN-1' && eventRead.alarms[0].code === 'ALM-1', '两类事件统一成 code,列表不必分支')
+    check('B10-alarm-keeps-rule-code', eventRead.alarms[0].ruleCode === 'WS_ENV_HIGH', '报警的 warningCode 是规则编码,另存避免与事件编码混淆')
+    check('B10-threshold-on-alarm', eventRead.alarms[0].thresholdMaxValue === 50 && eventRead.warnings[0].thresholdMaxValue === null, '阈值只有报警有,预警不得伪造')
+    check('B10-relation', eventRead.relations[0].id === 'REL-1')
+    check('B10-family-switch', eventsOfFamily(eventRead, EVENT_FAMILY.ALARM).length === 1 && eventsOfFamily(eventRead, EVENT_FAMILY.WARNING).length === 1 && allEvents(eventRead).length === 2)
+    check('B10-sort-desc', sortByEventTimeDesc(allEvents(eventRead))[0].code === 'ALM-1', '最新在前(票 13 默认最新 10-15 条)')
+
+    // 等级配色:认不出就不猜。
+    const unknownColor = toEventRead({ warnings: [{ warningNo: 'W', level: 9, levelColor: 'PURPLE' }], alarms: [] }).warnings[0]
+    check('B11-unknown-level-color', unknownColor.levelColor === '' && unknownColor.level === 9, '认不出的配色回空串,不自造颜色')
+
+    // ---- 新鲜度 ----
+    const fresh = toFreshness({ updatedAt: '2026-09-02T09:59:50+08:00', freshness: 'CURRENT', calibrationPeriodSeconds: 60, missedCycles: 0, dataAgeSeconds: 5, retained: false })
+    const expired = toFreshness({ freshness: 'EXPIRED', staleReason: 'TWO_UPDATE_CYCLES_MISSED', missedCycles: 2, dataAgeSeconds: 120, retained: true })
+    const blank = toFreshness({ freshness: 'CURRENT' })
+    check('B12-expired-by-backend', isExpired(expired) === true && isExpired(fresh) === false)
+    check('B12-expired-fallback', isExpired({ missedCycles: EXPIRED_MISSED_CYCLES }) === true, '后端没给 freshness 时才用 missedCycles 兜底')
+    check('B12-retained-has-value', hasValue(expired) === true && hasValue(toFreshness({ freshness: 'EMPTY' })) === false)
+    check('B12-age-placeholder', dataAgeText(blank) === '—' && dataAgeText(fresh) === '5 秒前' && dataAgeText(expired) === '2 分钟前', '缺时间显示占位符,不显示「0 秒」')
+
+    // ---- 设备详情聚合:分区独立 ----
+    const aggregate = toEquipmentDetailAggregate({
+        deviceCode: 'TC-1',
+        aggregateState: 'PARTIAL',
+        archive: { state: 'FAILED', reason: 'READ_FAILURE', message: '设备档案数据暂不可用', data: null },
+        metrics: { state: 'SUCCESS', reason: 'SUCCESS', data: { deviceCode: 'TC-1', state: 'CURRENT', metrics: [{ metricCode: 'COD', metricName: 'COD', value: 18.2, unit: 'mg/L' }] } },
+        warnings: { state: 'SUCCESS', reason: 'SUCCESS', data: [] },
+        alarms: { state: 'EMPTY', reason: 'NO_EVENTS', data: null },
+        workOrders: { state: 'STALE', reason: 'SAMPLE_OUTDATED', data: [{ workOrderCode: 'WO-1', stateCode: 4 }] },
+    })
+    check('B13-sections-all-present', EQUIPMENT_SECTIONS.every((name) => !!aggregate.sections[name]), '八个分区都在(后端没给的分区也留位)')
+    check('B13-failed-isolated', aggregate.sections.archive.state === SECTION_STATE.FAILED && aggregate.sections.metrics.state === SECTION_STATE.SUCCESS, '档案失败不影响指标成功(票 11 验收第 3 条)')
+    check('B13-failed-keeps-message', aggregate.sections.archive.reason === '设备档案数据暂不可用')
+    check('B13-empty-distinct', aggregate.sections.alarms.state === SECTION_STATE.EMPTY && aggregate.sections.alarms.data === null)
+    check('B13-stale-retains', aggregate.sections.workOrders.state === SECTION_STATE.STALE && aggregate.sections.workOrders.retained === true && aggregate.sections.workOrders.data[0].code === 'WO-1', '过期分区保留旧值')
+    check('B13-missing-section', aggregate.sections.timelines.state === SECTION_STATE.FAILED, '后端整段缺失时该分区算失败,不算成功')
+    check('B13-metric-zero', toSection('metrics', { state: 'SUCCESS', data: { metrics: [{ metricCode: 'FLOW', value: 0, unit: 'm³/h' }] } }).data.metrics[0].value === 0, '指标真 0 不得被当成缺失')
+    check('B13-metric-missing-null', toSection('metrics', { state: 'SUCCESS', data: { metrics: [{ metricCode: 'COD', metricName: 'COD' }] } }).data.metrics[0].value === null, '缺 value 显示占位符,不显示虚假零值')
+    check('B13-aggregate-state-not-authoritative', aggregate.aggregateState === 'PARTIAL' && aggregate.sections.metrics.state === SECTION_STATE.SUCCESS, '界面分区状态以各分区自己的为准')
+
+    // 未知分区原样带出,不静默丢。
+    check('B13-unknown-section-kept', toSection('brandNew', { state: 'SUCCESS', data: { any: 1 } }).data.any === 1)
+}
+
+// ================================================================ C 契约化 mock
+{
+    const handlers = { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest }
+    const register = (options) => registerCockpitMocks({ ...options, handlers })
+
+    // ---- C1 成功样例:三个领域模块都能取到领域对象 ----
+    register({ scenario: 'success' })
+
+    // 票 08 起 mock 也按后端真实语义分页(分页窗口横跨监测点与未关联设备、第 2 页起要带快照),
+    // 所以这里取的是**第 1 页**:它只是一条总序列里的一段,不再是"一次拿到全部"。
+    const snapshot = await fetchMapSnapshot({ page: 1, pageSize: 2, config: builtinConfig })
+    check(
+        'C1-map-success',
+        snapshot.state === SECTION_STATE.SUCCESS && snapshot.data.points.length + snapshot.data.equipment.length === 2,
+        JSON.stringify({ state: snapshot.state, points: snapshot.data.points.length, equipment: snapshot.data.equipment.length }),
+    )
+    check(
+        'C1-map-snapshot-locked',
+        snapshot.data.snapshotAt !== '' && snapshot.data.complete === false,
+        JSON.stringify({ snapshotAt: snapshot.data.snapshotAt, complete: snapshot.data.complete, total: snapshot.data.total }),
+    )
+
+    // 后续页带着第 1 页的快照请求:它是同一次加载的一部分(票 08 验收第 3 条)。
+    const snapshotPage3 = await fetchMapSnapshot({ page: 3, pageSize: 2, snapshotAt: snapshot.data.snapshotAt, config: builtinConfig })
+    check(
+        'C1-map-page-locked-to-snapshot',
+        snapshotPage3.state === SECTION_STATE.SUCCESS && snapshotPage3.data.snapshotAt === snapshot.data.snapshotAt && snapshotPage3.data.complete === true,
+        JSON.stringify({ sameSnapshot: snapshotPage3.data.snapshotAt === snapshot.data.snapshotAt, complete: snapshotPage3.data.complete }),
+    )
+
+    const aggregate = await fetchEquipmentDetailAggregate('TC-PS-WS-202609020012', { config: builtinConfig })
+    check('C1-aggregate-success', aggregate.state === SECTION_STATE.SUCCESS && aggregate.data.sections.archive.data.name === '污水环境监测设备')
+    check('C1-aggregate-substates', aggregate.data.sections.metrics.state === SECTION_STATE.SUCCESS && aggregate.data.sections.alarms.data[0].code === 'ALM-20260902-001')
+
+    const point = await fetchMonitoringPointDetail('MP-002', { config: builtinConfig })
+    check('C1-point-success', point.state === SECTION_STATE.SUCCESS && point.data.code === 'MP-002' && point.data.devices.length === 2)
+    check('C1-point-path-param', point.state === SECTION_STATE.SUCCESS, '模板路径 /monitoring-points/* 命中(带路径参数的接口也要能 mock)')
+
+    const events = await fetchEvents({ deviceCode: 'TC-1' }, { config: builtinConfig })
+    check('C1-events-success', events.state === SECTION_STATE.SUCCESS && allEvents(events.data).length === 2)
+
+    const orders = await fetchWorkOrders({ deviceCode: 'TC-1' }, { config: builtinConfig })
+    check('C1-work-orders-success', orders.state === SECTION_STATE.SUCCESS && orders.data.orders.length === 1)
+
+    const freshness = await fetchFreshnessSection('TC-PS-WS-202609020012', { config: builtinConfig })
+    check('C1-freshness-success', freshness.state === SECTION_STATE.SUCCESS && freshness.data.freshness === 'CURRENT')
+
+    // ---- C2 空数据样例 ----
+    register({ scenario: 'empty' })
+    const emptyAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig })
+    check('C2-empty-aggregate', emptyAggregate.state === SECTION_STATE.SUCCESS && emptyAggregate.data.sections.archive.state === SECTION_STATE.EMPTY, '整体取到了,但每个分区各自是「无数据」')
+    const emptyOrders = await fetchWorkOrders({ deviceCode: 'TC-1' }, { config: builtinConfig })
+    check('C2-empty-orders', emptyOrders.state === SECTION_STATE.EMPTY && emptyOrders.data === null, '空列表分区不得带空数组冒充数据')
+    const emptyFreshness = await fetchFreshnessSection('TC-1', { config: builtinConfig })
+    check('C2-empty-freshness', emptyFreshness.state === SECTION_STATE.EMPTY)
+
+    // ---- C3 字段缺失样例 ----
+    register({ scenario: 'degraded' })
+    const degradedAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig })
+    const degradedArchive = degradedAggregate.data.sections.archive
+    check('C3-degraded-archive', degradedArchive.state === SECTION_STATE.SUCCESS && degradedArchive.data.code === 'TC-1' && degradedArchive.data.name === '', '缺字段的分区仍算成功,只是字段为空')
+    check('C3-degraded-missing-recorded', degradedArchive.data.missing.includes('archive.deviceName'), JSON.stringify(degradedArchive.data.missing))
+    check('C3-degraded-order-name', degradedAggregate.data.sections.workOrders.data[0].stateName === '已接单', '工单状态名缺失时用七态字典补')
+    const degradedFreshness = await fetchFreshnessSection('TC-1', { config: builtinConfig })
+    check('C3-degraded-age', dataAgeText(degradedFreshness.data) === '—', '缺 dataAgeSeconds 时显示占位符')
+    const degradedPoint = await fetchMonitoringPointDetail('MP-DEGRADED', { config: builtinConfig })
+    check('C3-degraded-point', degradedPoint.state === SECTION_STATE.SUCCESS && degradedPoint.data.code === 'MP-DEGRADED', '点位记录缺字段仍算成功(缺字段 ≠ 空记录)')
+
+    // ---- C4 失败样例 ----
+    register({ scenario: 'failed' })
+    const failedAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig })
+    check('C4-failed-aggregate', failedAggregate.state === SECTION_STATE.FAILED && failedAggregate.data === null, '处理器抛出 → 分区失败,不抛给页面')
+    const failedSnapshot = await fetchMapSnapshot({ config: builtinConfig })
+    check('C4-failed-snapshot', failedSnapshot.state === SECTION_STATE.FAILED && failedSnapshot.reason === REQUEST_REASON.UNAVAILABLE)
+
+    // ---- C5 过期样例 ----
+    register({ scenario: 'stale' })
+    const staleAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig })
+    check('C5-stale-metrics', staleAggregate.data.sections.metrics.state === SECTION_STATE.STALE && staleAggregate.data.sections.metrics.retained === true, '过期分区带 retained,界面须显示过期标识')
+    check('C5-stale-keeps-other', staleAggregate.data.sections.archive.state === SECTION_STATE.SUCCESS, '过期不牵连其它分区')
+    const staleFreshness = await fetchFreshnessSection('TC-1', { config: builtinConfig })
+    check('C5-stale-freshness', staleFreshness.state === SECTION_STATE.STALE && staleFreshness.data.freshness === 'EXPIRED' && isExpired(staleFreshness.data))
+
+    // ---- C6 401:只坏一个分区,其余照常成功(票 05 验收第 6 条)----
+    register({ scenario: 'unauthorized', onlyPath: '/equipments/*/detail-aggregate' })
+    const deniedAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig })
+    const okSnapshot = await fetchMapSnapshot({ config: builtinConfig })
+    const okFreshness = await fetchFreshnessSection('TC-1', { config: builtinConfig })
+    check('C6-401-isolated', deniedAggregate.state === SECTION_STATE.UNAVAILABLE && deniedAggregate.reason === REQUEST_REASON.UNAUTHORIZED, JSON.stringify({ state: deniedAggregate.state, reason: deniedAggregate.reason }))
+    check('C6-neighbours-survive', okSnapshot.state === SECTION_STATE.SUCCESS && okFreshness.state === SECTION_STATE.SUCCESS, '受影响分区之外照常成功(保留已成功分区)')
+    check('C6-overall-partial', overallState({ a: deniedAggregate, b: okSnapshot, c: okFreshness }) === 'partial')
+    check('C6-view-is-data-unavailable', sectionView(deniedAggregate).label === '数据不可用')
+
+    // 前缀只坏一族接口。
+    register({ scenario: 'unauthorized', prefix: '/equipments/' })
+    const prefixMap = await fetchMapSnapshot({ config: builtinConfig })
+    const prefixAggregate = await fetchEquipmentDetailAggregate('TC-1', { config: builtinConfig })
+    const prefixPoint = await fetchMonitoringPointDetail('MP-002', { config: builtinConfig })
+    check('C6-prefix-isolated', prefixAggregate.state === SECTION_STATE.UNAVAILABLE && prefixMap.state === SECTION_STATE.SUCCESS && prefixPoint.state === SECTION_STATE.SUCCESS, '前缀选中一族接口,其余分区与地图不受影响')
+
+    // ---- C7 未注册接口:明确原因,不静默 ----
+    resetMockTransportsForTest()
+    const missing = await fetchEvents({ deviceCode: 'TC-1' }, { config: builtinConfig })
+    check('C7-unregistered', missing.state === SECTION_STATE.UNAVAILABLE && missing.reason === REQUEST_REASON.MOCK_MISSING)
+
+    // ---- C8 真实模式:不带凭据的 mock 分支不泄漏,real 分支走同一适配层 ----
+    register({ scenario: 'success' })
+    let seenHeaders = null
+    const realResult = await fetchMapSnapshot({
+        config: realConfig,
+        fetchImpl: async (url, init) => {
+            seenHeaders = { url, headers: init.headers }
+            return { ok: true, status: 200, text: async () => JSON.stringify({ page: 1, pageSize: 1, total: 0, complete: true, monitoringPoints: [], unassociatedEquipment: [] }) }
+        },
+    })
+    check('C8-real-same-adapter', realResult.state === SECTION_STATE.EMPTY && realResult.mode === 'real', '真接口与 mock 走同一适配层与同一转换')
+    check('C8-real-attaches-credential', seenHeaders.headers.Authorization === 'Bearer readonly', '凭据只在适配层附加(票 04 的接缝未被绕过)')
+    check('C8-real-url', seenHeaders.url.startsWith('https://gw/api/cockpit/v1/map-snapshot'), seenHeaders.url)
+
+    // 假 401 走真接口路径:分区变「数据不可用」,不抛异常。
+    const realDenied = await fetchMapSnapshot({
+        config: realConfig,
+        fetchImpl: async () => ({ ok: false, status: 401, text: async () => '{"code":401}' }),
+    })
+    check('C8-real-401', realDenied.state === SECTION_STATE.UNAVAILABLE && realDenied.reason === REQUEST_REASON.UNAUTHORIZED)
+
+    // 真接口 200 HTML(dev 回退)→ 坏载荷,不当成成功。
+    const realHtml = await fetchMapSnapshot({
+        config: realConfig,
+        fetchImpl: async () => ({ ok: true, status: 200, text: async () => '<!doctype html><html></html>' }),
+    })
+    check('C8-real-bad-payload', realHtml.state === SECTION_STATE.FAILED && realHtml.reason === REQUEST_REASON.BAD_PAYLOAD)
+
+    // ---- C9 查询串拼装 ----
+    check('C9-query-empty', buildQuery({ a: '', b: null, c: undefined }) === '')
+    check('C9-query-encode', buildQuery({ page: 1, snapshotAt: '2026-09-02T10:00:00+08:00' }) === '?page=1&snapshotAt=2026-09-02T10%3A00%3A00%2B08%3A00', buildQuery({ page: 1, snapshotAt: '2026-09-02T10:00:00+08:00' }))
+    check('C9-query-array', buildQuery({ x: ['a', 'b'] }) === '?x=a&x=b')
+
+    // 历史事件走 history 路径并带时间范围。
+    register({ scenario: 'success' })
+    let historyUrl = ''
+    await fetchEvents(
+        { deviceCode: 'TC-1', startTime: '2026-09-01T00:00:00+08:00', endTime: '2026-09-02T00:00:00+08:00' },
+        { config: realConfig, fetchImpl: async (url) => { historyUrl = url; return { ok: true, status: 200, text: async () => JSON.stringify({ state: 'READY', warnings: [], alarms: [] }) } } },
+    )
+    check('C9-history-path', historyUrl.includes('/events/history?') && historyUrl.includes('startTime='), historyUrl)
+    let currentUrl = ''
+    await fetchEvents({ deviceCode: 'TC-1' }, { config: realConfig, fetchImpl: async (url) => { currentUrl = url; return { ok: true, status: 200, text: async () => JSON.stringify({ state: 'READY', warnings: [], alarms: [] }) } } })
+    check('C9-current-path', currentUrl.includes('/events/current') && !currentUrl.includes('startTime='), currentUrl)
+
+    // 缺编码:不发请求,直接给出可读原因。
+    let called = 0
+    const noCode = await fetchEquipmentDetailAggregate('', { config: realConfig, fetchImpl: async () => { called += 1; return { ok: true, status: 200, text: async () => '{}' } } })
+    check('C9-no-code-no-request', called === 0 && noCode.state === SECTION_STATE.FAILED && noCode.reason.includes('编码'), noCode.reason)
+
+    // ---- C10 foundation:地图帧不可绘制 → 空而不是成功 ----
+    registerMockTransport('/foundation', async () => ({ mapFrame: { state: 'EMPTY', renderable: false, reason: '驾驶舱基础数据为空' }, freshness: { status: 'FRESH' } }))
+    const emptyFrame = await fetchFoundation({ config: builtinConfig })
+    check('C10-foundation-empty-frame', emptyFrame.state === SECTION_STATE.EMPTY && emptyFrame.reason === '驾驶舱基础数据为空')
+    registerMockTransport('/foundation', async () => ({ mapFrame: { state: 'READY', renderable: true }, freshness: { status: 'FRESH' } }))
+    const readyFrame = await fetchFoundation({ config: builtinConfig })
+    check('C10-foundation-ready', readyFrame.state === SECTION_STATE.SUCCESS && readyFrame.data.freshness.status === 'FRESH')
+
+    // ---- C11 WS 增量帧 → 领域事件(字段名两个都认)----
+    const delta = toDeviceStatusEvent({ type: WS_MESSAGE.DELTA, deviceCode: 'DEV001', device_code: 'DEV001', data: { status: 'ALARM', statusTime: '2026-09-02T10:00:00+08:00', serverUpdatedAt: '2026-09-02T10:00:01+08:00', reason: '浓度超限' } })
+    check('C11-delta-online-frame', delta && delta.deviceCode === 'DEV001' && delta.status === 'ALARM' && delta.reason === '浓度超限', JSON.stringify(delta))
+    const dtoDelta = toDeviceStatusEvent({ type: WS_MESSAGE.DELTA, deviceCode: 'DEV002', data: { monitoringState: 'OFFLINE' } })
+    check('C11-delta-dto-name', dtoDelta && dtoDelta.status === 'OFFLINE', '后端 DTO 用 monitoringState,线上帧用 status,两个都要认')
+    check('C11-unknown-frame', toDeviceStatusEvent({ type: 'somethingElse' }) === null && toDeviceStatusEvent(null) === null, '认不出的帧回 null,不猜')
+    check('C11-no-code', toDeviceStatusEvent({ type: WS_MESSAGE.DELTA, data: { status: 'ALARM' } }) === null, '没有设备编码的增量帧无法回联点位,必须丢弃')
+    check('C11-subscribed', toSubscribedEvent({ type: WS_MESSAGE.SUBSCRIBED, scope: 'all', serverUpdatedAt: 't' }).scope === 'all')
+
+    // ---- C12 注册幂等:重复注册只覆盖不叠加 ----
+    register({ scenario: 'success' })
+    const firstPaths = getMockTransportPaths().length
+    register({ scenario: 'success' })
+    check('C12-idempotent', getMockTransportPaths().length === firstPaths, `${getMockTransportPaths().length} 个路径(两次注册后不翻倍)`)
+    check('C12-covers-shared-domain', ['/map-snapshot', '/monitoring-points/*', '/equipments/*/detail-aggregate'].every((p) => getMockTransportPaths().includes(p)), getMockTransportPaths().join(', '))
+}
+
+// ================================================================ D 静态守卫(对抗式)
+{
+    const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
+    const readModule = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
+
+    // 页面与 store 不得出现后端裸字段名(票 05 验收第 4 条)。
+    // 按键名清单查:这些是后端 JSON 里的字段,领域对象已全部改名。
+    const BACKEND_FIELD_PATTERN = /\b(monitoringPoints|unassociatedEquipment|coordinateValidation|pointStatusCode|deviceStatistics|aggregateState|timelineEntries|alarmWarningRelations|warningNo|alarmId|stateDictionary|stateReason|missingCycles|dataAgeSeconds|calibrationPeriodSeconds|staleReason|workOrders|deviceStatus)\b/
+    const pageAndStoreFiles = [
+        'CockpitShell.vue',
+        'components/MapCanvas.vue',
+        'components/CockpitPanel.vue',
+        'components/PanelPlaceholder.vue',
+        'components/StatusTag.vue',
+        'components/SectionStateTag.vue',
+        'components/CockpitIcon.vue',
+        // 票 10/11 的详情浮层与设备详情分区(新增文件一律补进守卫面,否则新代码是检查不到的荒地)。
+        'components/PointDetailCard.vue',
+        'components/EquipmentDetailSections.vue',
+        'stores/uiStore.js',
+        'stores/filterStore.js',
+        'stores/detailStore.js',
+    ]
+    for (const file of pageAndStoreFiles) {
+        const text = readModule(file)
+        const found = BACKEND_FIELD_PATTERN.exec(text)
+        check(`D1-${file}-no-raw-fields`, !found, found ? `出现后端裸字段 ${found[0]}` : '零后端裸字段')
+    }
+
+    // 页面与 store 不碰凭据(票 04 立的规矩,票 05 不得破坏)。
+    const CREDENTIAL_PATTERN = /Authorization|Bearer|api\.token|ws\.token/
+    for (const file of pageAndStoreFiles) {
+        check(`D2-${file}-no-credential`, !CREDENTIAL_PATTERN.test(readModule(file)), '页面与 store 不碰凭据')
+    }
+
+    // 领域模块不直接发请求:转换必须与传输分离(否则转换单测就得起网络)。
+    const domainFiles = [
+        'domain/sectionState.js',
+        'domain/mapSummary.js',
+        'domain/pointDetail.js',
+        'domain/equipmentDetail.js',
+        // 票 11:设备详情分区的界面视图(纯函数,不 import service、自己不发请求)。
+        'domain/equipmentDetailView.js',
+        'domain/workOrder.js',
+        'domain/events.js',
+        'domain/freshness.js',
+        'domain/fields.js',
+    ]
+    for (const file of domainFiles) {
+        const text = readModule(file)
+        check(`D3-${file}-pure`, !/\bfetch\s*\(|XMLHttpRequest/.test(text), '领域转换不自己发请求')
+    }
+
+    // domain 不得反向依赖 service(层次倒挂)。
+    for (const file of domainFiles) {
+        const text = readModule(file)
+        check(`D4-${file}-no-service-import`, !/from '\.\.\/services\//.test(text), 'domain 不 import service')
+    }
+
+    // 登录跳转:票 05 的场景(含 401)不得引入任何登录入口。
+    for (const file of [...pageAndStoreFiles, 'mocks/contractMocks.js', 'mocks/index.js', ...domainFiles]) {
+        const text = readModule(file)
+        check(`D5-${file}-no-login-redirect`, !/location\.(assign|replace|href)\s*=|window\.location\s*=|['"`]\/login/.test(text), '不跳登录页')
+    }
+}
+
+const failed = checks.filter((item) => !item.pass)
+console.log('')
+console.log(`T05 适配层检查:${checks.length - failed.length}/${checks.length} 项通过。`)
+if (failed.length) {
+    console.log('未通过:')
+    for (const item of failed) console.log(`  · ${item.name}`)
+    process.exit(1)
+}
+console.log('T05 领域适配层全部通过。')

+ 5 - 1
.scratch/cockpit-shell-panel-layout/verify/t06-assets.mjs

@@ -194,7 +194,11 @@ check('D2 结构定义里没有视觉信息', () => {
 console.log('\n[E] 资源引用走查记录')
 const references = []
 check('E1 public/cockpit 下每个资源都有引用点', () => {
-    const assets = readdirSync(publicCockpit).filter((f) => f !== 'README.md')
+    // 「资源」指要被代码引用的东西;说明与部署模板不是资源,不需要引用点:
+    //   README.md           —— 讲约定
+    //   *.example.json      —— 部署配置模板(票 04),由运维复制成 config.json,代码永远不引用它
+    const notAnAsset = /^(README\.md|.*\.example\.json)$/
+    const assets = readdirSync(publicCockpit).filter((f) => !notAnAsset.test(f))
     assert.ok(assets.length > 0, 'public/cockpit 下没有资源')
     assets.forEach((asset) => {
         const hits = cockpitSources.filter((file) => sourceText.get(file).includes(asset))

+ 332 - 0
.scratch/cockpit-shell-panel-layout/verify/t07-coordinate-walkthrough.mjs

@@ -0,0 +1,332 @@
+/**
+ * T07 真机走查:坐标准入与「无地图位置」在浏览器里确实成立(票 07)。
+ *
+ * 逻辑脚本(t07-coordinate.mjs)已把准入判据、六种原因文案、两个口径与清单渲染断言透了;
+ * 这里只补**只有在真机才成立**的部分 ——
+ *   1. 被拦下的对象确实**没进**「可上图」那一堆,而对象本身仍在界面上看得见(不是静默隐藏);
+ *   2. 「无地图位置」清单带着**具体中文原因**真的渲染出来了;
+ *   3. 后端全量口径与已加载口径在界面/宿主上是**两个数**,且与 mock 载荷对得上。
+ *
+ * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/;改了懒加载模块先重启服务):
+ *   node .scratch/cockpit-shell-panel-layout/verify/t07-coordinate-walkthrough.mjs
+ *
+ * 演示用的开关(宿主全局键注入,不改代码重新构建):
+ *   `window.__COCKPIT_MOCK_MAP__ = { pageSize }` —— mock 五条样例里就有三类坏坐标。
+ */
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
+
+const results = []
+/** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过的假绿坑)。 */
+function check(id, pass, evidence) {
+    results.push({ id, pass })
+    const rest = evidence === undefined ? '' : String(evidence)
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ::  ${rest}`)
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+const puppeteer = (await import(PUPPETEER)).default
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: join(tmpdir(), 'cockpit-t07-profile'),
+    defaultViewport: { width: 1680, height: 950 },
+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
+})
+console.log(`Chrome: ${await browser.version()}`)
+
+/** 读坐标准入的界面状态(断言读 dataset 与 `data-*`,不读中文串;文案另行核对)。 */
+const readCoordinate = (page) =>
+    page.evaluate(() => {
+        const host = document.querySelector('.cockpit-shell__map .map-canvas')
+        const panel = document.querySelector('[data-map-blocked-panel]')
+        const list = document.querySelector('[data-map-blocked-list]')
+        const items = list ? Array.from(list.querySelectorAll('li[data-map-blocked-reason]')) : []
+        return {
+            engine: (host && host.dataset.mapEngine) || '',
+            summary: (host && host.dataset.mapSummary) || '',
+            loaded: Number((host && host.dataset.mapSummaryLoaded) || 0),
+            total: Number((host && host.dataset.mapSummaryTotal) || 0),
+            renderable: Number((host && host.dataset.mapRenderable) || 0),
+            blocked: Number((host && host.dataset.mapBlocked) || 0),
+            blockedBackend: (host && host.dataset.mapBlockedBackend) || '',
+            crs: (host && host.dataset.mapCoordinateCrs) || '',
+            panelCount: document.querySelectorAll('[data-map-blocked-panel]').length,
+            panelExists: !!panel,
+            countText: (document.querySelector('[data-map-blocked-count]') || {}).textContent || '',
+            // 用**面板内**的选择器,不要用 `[data-map-blocked-backend]`:那个名字同时挂在
+            // 地图宿主上(dataset),宿主在 DOM 里更靠前,querySelector 会先命中宿主,
+            // 拿回整页文本 —— 断言就成了「拿整页文字做 includes」,是假绿(第一版踩过)。
+            backendText: (panel?.querySelector('[data-map-coordinate-backend]') || {}).textContent || '',
+            loadedText: (panel?.querySelector('[data-map-coordinate-loaded]') || {}).textContent || '',
+            listVisible: !!list,
+            reasons: items.map((li) => ({
+                code: li.dataset.mapBlockedReason || '',
+                text: (li.querySelector('.map-blocked__reason') || {}).textContent?.trim() || '',
+                name: (li.querySelector('.map-blocked__name') || {}).textContent?.trim() || '',
+            })),
+            // 宿主内的 Cesium 画布(口径与票 27 的 A15 一致:`.cesium-widget canvas`)
+            cesiumCanvas: host ? host.querySelectorAll('.cesium-widget canvas').length : -1,
+        }
+    })
+
+/** 点「加载下一页」并等已加载数真的变了(不知增量,所以等"变化"而不是等某个值)。 */
+async function clickAndWaitChange(page, previousLoaded, timeout = 8000) {
+    const clicked = await page.evaluate(() => {
+        const button = document.querySelector('[data-map-summary-action]')
+        if (!button || button.disabled) return false
+        button.click()
+        return true
+    })
+    if (!clicked) return false
+    await page
+        .waitForFunction(
+            (before) => {
+                const host = document.querySelector('.cockpit-shell__map .map-canvas')
+                const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
+                const phase = (host && host.dataset.mapSummary) || ''
+                return loaded !== before || ['failed', 'expired', 'empty'].includes(phase)
+            },
+            { timeout },
+            previousLoaded,
+        )
+        .catch(() => {})
+    return true
+}
+
+/**
+ * 把分页翻到底(完成/空/失败/失效)。
+ *
+ * 必须真的翻:外壳挂载后只自动取**第 1 批**(票 08 的渐进加载),
+ * 而 mock 的五条样例按页大小 2 分成三批 —— 不翻页就只持有第 1 批,
+ * 断言会以为"没有坏坐标对象",实际是还没取到(本脚本第一版正是这么写错的)。
+ */
+async function advanceUntilSettled(page, maxSteps = 10) {
+    const series = [await readCoordinate(page)]
+    for (let step = 0; step < maxSteps; step += 1) {
+        const current = series[series.length - 1]
+        if (['complete', 'empty', 'failed', 'expired'].includes(current.summary)) break
+        if (!(await clickAndWaitChange(page, current.loaded))) break
+        series.push(await readCoordinate(page))
+    }
+    return series
+}
+
+async function openPage(inject) {
+    const page = await browser.newPage()
+    const errors = []
+    page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
+    page.on('console', (m) => {
+        if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
+    })
+
+    await page.evaluateOnNewDocument(() => {
+        // 引导层会盖住页面,走查不需要它(票 30 的开关)。
+        window.__COCKPIT_CONFIG__ = { dataSource: 'mock', map: { intro: { enabled: false } } }
+    })
+    if (inject) await page.evaluateOnNewDocument(inject)
+
+    await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+    await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+    await page
+        .waitForFunction(
+            () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
+            { timeout: 30000 },
+        )
+        .catch(() => {})
+    // 外壳挂载后会自动取第 1 批:等它落定再开始(票 08 同一条前置)。
+    await page
+        .waitForFunction(
+            () => {
+                const host = document.querySelector('.cockpit-shell__map .map-canvas')
+                const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
+                const phase = (host && host.dataset.mapSummary) || ''
+                return loaded > 0 || ['empty', 'failed', 'expired'].includes(phase)
+            },
+            { timeout: 15000 },
+        )
+        .catch(() => {})
+    await sleep(300)
+    return { page, errors }
+}
+
+/** 把「无地图位置」清单展开(默认收起,避免挡住地图)。 */
+async function expandList(page) {
+    const clicked = await page.evaluate(() => {
+        const button = document.querySelector('[data-map-blocked-toggle]')
+        if (!button) return false
+        button.click()
+        return true
+    })
+    if (clicked) await sleep(250)
+    return clicked
+}
+
+try {
+    // ---------- A 被拦下的对象不上图,但仍在界面上看得见 ----------
+    {
+        const { page, errors } = await openPage(() => {
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
+        })
+        // 翻到底再断言:只取第 1 批时会看不到后两个坏坐标对象(见 advanceUntilSettled 的说明)。
+        await advanceUntilSettled(page)
+        const state = await readCoordinate(page)
+
+        // mock 五条:2 个 VALID、1 个 UNVERIFIED、2 个 INVALID(超范围 + 零坐标)。
+        check(
+            'A1-renderable-excludes-blocked',
+            state.renderable === 2 && state.blocked === 3 && state.loaded === 5 && state.summary === 'complete',
+            `可上图 ${state.renderable} · 不上图 ${state.blocked} · 已加载 ${state.loaded} · 终态 ${state.summary}`,
+        )
+        check(
+            'A2-objects-not-silently-dropped',
+            state.panelExists && state.panelCount === 1 && Number(state.countText) === 3,
+            `清单存在=${state.panelExists} · 计数=${state.countText}`,
+        )
+        // 后端全量口径(不随翻页跳动)与已加载口径是两个数,且都对得上 mock 载荷。
+        check(
+            'A3-backend-caliber-is-whole-snapshot',
+            state.blockedBackend === '3' && state.crs === 'WGS84',
+            `后端全量不上图 ${state.blockedBackend}(非法 2 + 未校验 1)· crs=${state.crs}`,
+        )
+        check(
+            'A4-loaded-caliber-is-this-batch',
+            state.loadedText.includes('已加载 5') && state.loadedText.includes('可上图 2') && state.loadedText.includes('无地图位置 3'),
+            state.loadedText,
+        )
+        check(
+            'A5-two-calibers-worded-apart',
+            state.backendText.includes('全量') && state.loadedText.includes('已加载') && state.backendText !== state.loadedText,
+            `${state.backendText} | ${state.loadedText}`,
+        )
+        // 摘要措辞不得把被拦下的 3 个算成「已上图」。
+        const label = await page.evaluate(() => (document.querySelector('[data-map-summary-label]') || {}).textContent || '')
+        check(
+            'A6-summary-label-not-inflated',
+            label.includes('已上图 2 个') && label.includes('无地图位置 3 个'),
+            label,
+        )
+        check('A7-engine-still-alive', state.engine === 'ready' && state.cesiumCanvas === 1, `引擎 ${state.engine} · 画布 ${state.cesiumCanvas}`)
+        check('A8-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        await page.close()
+    }
+
+    // ---------- B 「无地图位置」清单带具体中文原因 ----------
+    {
+        const { page, errors } = await openPage(() => {
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
+        })
+        await advanceUntilSettled(page)
+        const before = await readCoordinate(page)
+        check('B1-list-collapsed-by-default', before.listVisible === false, `默认展开=${before.listVisible}`)
+
+        const expanded = await expandList(page)
+        const after = await readCoordinate(page)
+        check('B2-list-expands', expanded && after.listVisible === true, `展开成功=${expanded}`)
+
+        // 三个被拦下的对象各带**具体原因**(不是笼统的「无地图位置」)。
+        const texts = after.reasons.map((item) => item.text)
+        check(
+            'B3-reasons-are-specific',
+            after.reasons.length === 3 &&
+                texts.includes('坐标未经校验') &&
+                texts.includes('经度超出合法范围') &&
+                texts.includes('零坐标(经纬度均为 0)'),
+            JSON.stringify(after.reasons.map((item) => `${item.name || '-'}:${item.text}`)),
+        )
+        check(
+            'B4-reason-codes-on-dataset-only',
+            after.reasons.map((item) => item.code).sort().join(',') === 'LONGITUDE_OUT_OF_RANGE,UNVERIFIED_COORDINATE,ZERO_COORDINATE',
+            JSON.stringify(after.reasons.map((item) => item.code)),
+        )
+        // 界面文案里不得出现后端原因码原文(票 05 立的纪律,票 07 沿用)。
+        const leaked = after.reasons.filter((item) => /^[A-Z_]{6,}$/.test(item.text))
+        check('B5-no-raw-reason-code-on-screen', leaked.length === 0, leaked.length ? JSON.stringify(leaked) : '文案全是中文')
+        check('B6-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        await page.close()
+    }
+
+    // ---------- C 空样例与字段缺失样例的坐标准入 ----------
+    {
+        // 空快照:没有对象 → 无地图位置 0,不报失败。
+        // 注意后端汇总是**空的**而不是 0:票 05 的 `sectionFromResult` 对 `EMPTY` 分区不带载荷,
+        // 那份全零汇总在适配层就被放下了(数据面确实拿到过,但领域层按契约丢掉)。
+        // 所以这里断言 dataset 为空串,且界面文案如实说「未取到」而不是编一个 0。
+        const { page } = await openPage(() => {
+            window.__COCKPIT_MOCK_SCENARIO__ = 'empty'
+            window.__COCKPIT_MOCK_ONLY__ = '/map-snapshot'
+        })
+        const empty = await readCoordinate(page)
+        check(
+            'C1-empty-scenario-zero-blocked',
+            empty.summary === 'empty' && empty.blocked === 0 && empty.renderable === 0 && empty.blockedBackend === '' && empty.backendText.includes('未取到'),
+            JSON.stringify({ summary: empty.summary, blocked: empty.blocked, backend: empty.blockedBackend, text: empty.backendText }),
+        )
+        await page.close()
+    }
+
+    {
+        // 字段缺失样例:点位没有 coordinateValidation → 「坐标未校验」,仍被列出来(不静默隐藏)。
+        // 这个样例是**单页且 complete:false**(票 05 的原样),所以只取第 1 批、不继续翻页。
+        const { page } = await openPage(() => {
+            window.__COCKPIT_MOCK_SCENARIO__ = 'degraded'
+            window.__COCKPIT_MOCK_ONLY__ = '/map-snapshot'
+        })
+        await expandList(page)
+        const degraded = await readCoordinate(page)
+        check(
+            'C2-degraded-is-unverified-not-hidden',
+            degraded.loaded === 1 && degraded.renderable === 0 && degraded.blocked === 1 && degraded.reasons[0]?.text === '坐标未校验',
+            JSON.stringify({ loaded: degraded.loaded, reasons: degraded.reasons.map((item) => item.text) }),
+        )
+        await page.close()
+    }
+
+    // ---------- D 清单不把地图或页面撑出问题 ----------
+    {
+        const { page, errors } = await openPage(() => {
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
+        })
+        await advanceUntilSettled(page)
+        await expandList(page)
+        const layout = await page.evaluate(() => {
+            const shell = document.querySelector('.cockpit-shell')
+            const host = document.querySelector('.cockpit-shell__map .map-canvas')
+            const panel = document.querySelector('[data-map-blocked-panel]')
+            const panelRect = panel ? panel.getBoundingClientRect() : null
+            const hostRect = host ? host.getBoundingClientRect() : null
+            return {
+                panelInsideHost:
+                    !!panelRect &&
+                    !!hostRect &&
+                    panelRect.left >= hostRect.left - 1 &&
+                    panelRect.right <= hostRect.right + 1 &&
+                    panelRect.bottom <= hostRect.bottom + 1,
+                docOverflowX: document.documentElement.scrollWidth - document.documentElement.clientWidth,
+                hosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length,
+                shellVisible: !!shell && shell.getBoundingClientRect().height > 100,
+            }
+        })
+        check('D1-panel-contained-in-map', layout.panelInsideHost, JSON.stringify(layout))
+        check('D2-no-horizontal-overflow', layout.docOverflowX <= 0, `横向溢出 ${layout.docOverflowX}px`)
+        check('D3-single-map-host', layout.hosts === 1 && layout.shellVisible, JSON.stringify({ hosts: layout.hosts }))
+        check('D4-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        await page.close()
+    }
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((item) => !item.pass)
+console.log('')
+console.log(`T07 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((item) => item.id).join(', '))
+    process.exit(1)
+}

+ 492 - 0
.scratch/cockpit-shell-panel-layout/verify/t07-coordinate.mjs

@@ -0,0 +1,492 @@
+/**
+ * T07 坐标校验与无地图位置 纯逻辑检查(票 07)。
+ *
+ * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs / t08-map-summary.mjs)。
+ * 运行:node .scratch/cockpit-shell-panel-layout/verify/t07-coordinate.mjs
+ *
+ * 分八段:
+ *   A 唯一准入判据(只认后端 renderable;前端不纠偏、不查范围、不用默认坐标)
+ *   B 原因文案(后端六个 reason 逐一命中;「缺失」与「非法」分开;认不出的不泄露原因码)
+ *   C 切分不静默丢对象(不上图 ≠ 不存在,两堆都要交出去)
+ *   D 两个口径分开(后端全量 vs 前端已加载,不混成一个数)
+ *   E 后端汇总进领域批次(票 05 遗留的漏读;且不得跨页累加)
+ *   F 契约化 mock 的三桶分法照抄后端
+ *   G 静态守卫(domain 不重算坐标范围;界面零原因码;不上图清单由领域层供货)
+ *   H 「已上图」措辞不再把被拦下的对象算进去
+ *
+ * 契约依据是**后端实现**:`CockpitCoordinateValidator`(六种 reason、state 三态、
+ * renderable 是结论)与 `CockpitMapSnapshotService`(汇总按整份快照算、三桶分法)。
+ */
+import { readFileSync } from 'node:fs'
+import { createPinia, setActivePinia } from 'pinia'
+import {
+    COORDINATE_REASON_TEXT,
+    NO_MAP_LOCATION_TEXT,
+    UNVALIDATED_TEXT,
+    blockReason,
+    blockedEntries,
+    coordinateOverview,
+    coordinateSummaryText,
+    isRenderable,
+    splitByCoordinate,
+    toCoordinateSummary,
+} from '../../../src/modules/cockpit/domain/coordinate.js'
+import { mergeMapPage, summaryView, toMapBatch } from '../../../src/modules/cockpit/domain/mapSummary.js'
+import { fetchMapSnapshot } from '../../../src/modules/cockpit/services/mapApi.js'
+import {
+    getMockTransportPaths,
+    registerMockTransport,
+    requestCockpitJson,
+    resetMockTransportsForTest,
+} from '../../../src/modules/cockpit/services/cockpitApi.js'
+import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
+import { MAP_SUMMARY_PAGE_SIZE, useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js'
+
+const checks = []
+function check(name, pass, evidence) {
+    checks.push({ name, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${name}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
+
+/** 造一个领域地图对象(形状同 `domain/mapSummary.js`)。 */
+function object({ code = 'X', kind = 'monitoring-point', name = '', longitude = 110.1, latitude = 28.4, coordinate = null } = {}) {
+    return {
+        kind,
+        code,
+        name: name || `${code} 对象`,
+        status: '',
+        statusReasons: [],
+        longitude,
+        latitude,
+        coordinate: coordinate || { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
+        missing: [],
+    }
+}
+
+/** 后端校验段的六种取值(照抄 `CockpitCoordinateValidator`)。 */
+const BACKEND_CASES = [
+    { state: 'VALID', reason: null, renderable: true },
+    { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false },
+    { state: 'INVALID', reason: 'MISSING_LONGITUDE', renderable: false },
+    { state: 'INVALID', reason: 'MISSING_LATITUDE', renderable: false },
+    { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false },
+    { state: 'INVALID', reason: 'LATITUDE_OUT_OF_RANGE', renderable: false },
+    { state: 'INVALID', reason: 'ZERO_COORDINATE', renderable: false },
+]
+
+/** 后端载荷里的对象 → 领域对象(走真实的适配层,不手搓 coordinate 段)。 */
+function rawToObject(raw, kind = 'monitoring-point') {
+    const payload =
+        kind === 'monitoring-point'
+            ? { monitoringPoints: [raw], unassociatedEquipment: [] }
+            : { monitoringPoints: [], unassociatedEquipment: [raw] }
+    return toMapBatch(payload).objects[0]
+}
+
+// ================================================================ A 唯一准入判据
+{
+    // 后端每一种结论都如实照搬,不做二次判断。
+    const mapped = BACKEND_CASES.map((item) => ({
+        expected: item.renderable,
+        actual: isRenderable(rawToObject({ pointCode: 'MP-A', coordinateValidation: item })),
+        state: item.state,
+        reason: item.reason,
+    }))
+    check(
+        'A1-admission-follows-renderable',
+        mapped.every((item) => item.actual === item.expected),
+        JSON.stringify(mapped.map((item) => `${item.state}/${item.reason || '-'}→${item.actual}`)),
+    )
+
+    // 关键对抗:state 说 VALID 但后端 renderable=false 时,**必须**听 renderable。
+    // 这一条守着「前端不另立一套判据」——否则两个答案必然有一个是错的,而错的会画到地图上。
+    const contradictory = rawToObject({
+        pointCode: 'MP-C',
+        coordinateValidation: { state: 'VALID', reason: null, renderable: false, crs: 'WGS84' },
+    })
+    check('A2-renderable-beats-state', isRenderable(contradictory) === false, 'state=VALID 但 renderable=false → 不上图')
+
+    // 反向:state 说 INVALID 但后端 renderable=true,同样听后端(前端不做"再校验一遍")。
+    const permissive = rawToObject({
+        pointCode: 'MP-P',
+        coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: true, crs: 'WGS84' },
+    })
+    check('A3-no-frontend-recheck', isRenderable(permissive) === true, '后端说可绘制就绘制,前端不推翻')
+
+    // 前端不纠偏、不用默认坐标:坐标值原样带出,不被改成"看起来合法"的数。
+    const outOfRange = toMapBatch({
+        page: 1,
+        pageSize: 1,
+        snapshotAt: 'S',
+        total: 1,
+        complete: true,
+        unassociatedEquipment: [
+            {
+                deviceCode: 'EQ-X',
+                deviceName: '超范围设备',
+                longitude: 181,
+                latitude: 28.455,
+                coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' },
+            },
+        ],
+    }).objects[0]
+    check(
+        'A4-no-correction-no-default',
+        outOfRange.longitude === 181 && outOfRange.latitude === 28.455 && isRenderable(outOfRange) === false,
+        `原值 ${outOfRange.longitude},${outOfRange.latitude} 未被纠正,且不上图`,
+    )
+}
+
+// ================================================================ B 原因文案
+{
+    const texts = BACKEND_CASES.filter((item) => item.reason).map((item) =>
+        blockReason(
+            rawToObject({
+                pointCode: 'MP-B',
+                coordinateValidation: { ...item, crs: 'WGS84' },
+            }),
+        ),
+    )
+    check(
+        'B1-six-reasons-have-text',
+        texts.length === 6 && texts.every((item) => item.text && item.text !== NO_MAP_LOCATION_TEXT && item.text !== UNVALIDATED_TEXT),
+        JSON.stringify(texts.map((item) => `${item.code}→${item.text}`)),
+    )
+    check(
+        'B2-reason-dictionary-covers-backend',
+        Object.keys(COORDINATE_REASON_TEXT).length === 6 &&
+            BACKEND_CASES.filter((item) => item.reason).every((item) => !!COORDINATE_REASON_TEXT[item.reason]),
+        JSON.stringify(Object.keys(COORDINATE_REASON_TEXT)),
+    )
+
+    // 后端整段没给校验段(票 05 的 validated:false)→「坐标未校验」,这是「缺失」而不是「非法」。
+    const missing = rawToObject({ pointCode: 'MP-N' })
+    const missingReason = blockReason(missing)
+    check(
+        'B3-unvalidated-is-missing-not-invalid',
+        missingReason.text === UNVALIDATED_TEXT && missingReason.code === '',
+        JSON.stringify(missingReason),
+    )
+
+    // 认不出的原因:只说「无地图位置」,不把原因码甩给值班人员。
+    const unknown = object({ code: 'MP-U', coordinate: { state: 'INVALID', reason: 'SOMETHING_NEW', crs: 'WGS84', renderable: false, validated: true } })
+    check('B4-unknown-reason-generic', blockReason(unknown).text === NO_MAP_LOCATION_TEXT, JSON.stringify(blockReason(unknown)))
+
+    // 界面上给的是中文:六个原因里不得出现后端原因码原文。
+    const leaked = texts.filter((item) => /^[A-Z_]+$/.test(item.text))
+    check('B5-no-reason-code-as-text', leaked.length === 0, leaked.length ? JSON.stringify(leaked) : '文案全是中文,零原因码')
+}
+
+// ================================================================ C 不静默丢对象
+{
+    const objects = [
+        object({ code: 'OK-1' }),
+        object({ code: 'BAD-1', coordinate: { state: 'INVALID', reason: 'ZERO_COORDINATE', crs: 'WGS84', renderable: false, validated: true } }),
+        object({ code: 'OK-2' }),
+        object({ code: 'BAD-2', coordinate: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', crs: 'WGS84', renderable: false, validated: true } }),
+    ]
+    const { renderable, blocked } = splitByCoordinate(objects)
+    check(
+        'C1-split-keeps-both',
+        renderable.length === 2 && blocked.length === 2 && renderable.length + blocked.length === objects.length,
+        `可上图 ${renderable.length} / 不上图 ${blocked.length} / 输入 ${objects.length}`,
+    )
+    check(
+        'C2-blocked-keeps-identity',
+        blocked.map((item) => item.code).join(',') === 'BAD-1,BAD-2',
+        '被拦下的对象仍带编码与名称(列表要用),不是被丢掉的黑洞',
+    )
+
+    const entries = blockedEntries(objects)
+    check(
+        'C3-entries-carry-reason',
+        entries.length === 2 && entries[0].reasonText === '零坐标(经纬度均为 0)' && entries[1].reasonText === '坐标未经校验',
+        JSON.stringify(entries.map((item) => `${item.code}:${item.reasonText}`)),
+    )
+    check('C4-empty-input-safe', splitByCoordinate(null).blocked.length === 0 && splitByCoordinate(undefined).renderable.length === 0, '空输入不抛错')
+}
+
+// ================================================================ D 两个口径分开
+{
+    // 后端说全量 20 个、可上图 12、非法 5、未校验 3;前端只加载了 4 个(3 好 1 坏)。
+    const backend = toCoordinateSummary({ crs: 'WGS84', totalObjectCount: 20, renderableCount: 12, invalidCount: 5, unverifiedCount: 3 })
+    const loaded = [
+        object({ code: 'L-1' }),
+        object({ code: 'L-2' }),
+        object({ code: 'L-3' }),
+        object({ code: 'L-4', coordinate: { state: 'INVALID', reason: 'ZERO_COORDINATE', crs: 'WGS84', renderable: false, validated: true } }),
+    ]
+    const overview = coordinateOverview(loaded, backend)
+
+    check(
+        'D1-two-calibers-kept-apart',
+        overview.backend.total === 20 && overview.backend.renderable === 12 && overview.loaded.total === 4 && overview.loaded.renderable === 3 && overview.loaded.blocked === 1,
+        `后端 ${overview.backend.renderable}/${overview.backend.total} · 已加载 ${overview.loaded.renderable}/${overview.loaded.total}`,
+    )
+    check(
+        'D2-texts-name-their-subject',
+        overview.backendText.includes('全量') && overview.backendText.includes('12/20') && overview.loadedText.includes('已加载'),
+        `${overview.backendText} | ${overview.loadedText}`,
+    )
+    check(
+        'D3-blocked-entry-listed',
+        overview.blockedEntries.length === 1 && overview.blockedEntries[0].code === 'L-4',
+        JSON.stringify(overview.blockedEntries),
+    )
+
+    // 后端没给汇总时:不补 0(0 会被读成"一个坏坐标都没有"),如实说没有。
+    const absent = coordinateOverview(loaded, toCoordinateSummary(null))
+    check(
+        'D4-missing-summary-not-zero-faked',
+        absent.backend.available === false && absent.backend.total === null && coordinateSummaryText(absent.backend).includes('未取到'),
+        coordinateSummaryText(absent.backend),
+    )
+
+    // 后端汇总字段缺失时同样不补 0。
+    const partial = toCoordinateSummary({ crs: 'WGS84', totalObjectCount: 7 })
+    check(
+        'D5-null-counts-stay-null',
+        partial.available === true && partial.total === 7 && partial.renderable === null && partial.invalid === null,
+        JSON.stringify(partial),
+    )
+}
+
+// ================================================================ E 汇总进领域批次(票 05 的漏读)
+{
+    const batch = toMapBatch({
+        page: 1,
+        pageSize: 2,
+        snapshotAt: '2026-09-02T10:00:00+08:00',
+        total: 5,
+        complete: false,
+        monitoringPoints: [
+            { pointCode: 'MP-1', pointName: '一', longitude: 110.1, latitude: 28.4, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } },
+        ],
+        unassociatedEquipment: [
+            { deviceCode: 'EQ-1', deviceName: '二', longitude: 181, latitude: 28.4, coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' } },
+        ],
+        coordinateValidation: { crs: 'WGS84', totalObjectCount: 5, renderableCount: 2, invalidCount: 2, unverifiedCount: 1 },
+    })
+    check(
+        'E1-batch-carries-backend-summary',
+        batch.coordinateSummary.available === true && batch.coordinateSummary.total === 5 && batch.coordinateSummary.invalid === 2 && batch.coordinateSummary.unverified === 1,
+        JSON.stringify(batch.coordinateSummary),
+    )
+
+    // 跨页合并:汇总是全量口径,取最新一次响应,**不得累加**(累加会变成"页数 × 全量")。
+    const first = mergeMapPage(null, batch)
+    const second = mergeMapPage(first, { ...batch, page: 2, coordinateSummary: batch.coordinateSummary })
+    check(
+        'E2-no-accumulation-across-pages',
+        second.coordinateSummary.total === 5 && second.coordinateSummary.renderable === 2,
+        `两页之后 total=${second.coordinateSummary.total}(不是 10)`,
+    )
+
+    // 后端没给汇总的页不会把已拿到的汇总清掉。
+    const third = mergeMapPage(second, { page: 3, objects: [], coordinateSummary: toCoordinateSummary(null) })
+    check('E3-absent-summary-keeps-last', third.coordinateSummary.total === 5, `total=${third.coordinateSummary.total}`)
+
+    // 空态的初值也有这个字段(界面不必判 undefined)。
+    check('E4-empty-state-has-field', mergeMapPage(null, { page: 1, objects: [] }).coordinateSummary.available === false, '初值 available=false')
+}
+
+// ================================================================ F mock 三桶分法照抄后端
+{
+    resetMockTransportsForTest()
+    registerCockpitMocks({
+        scenario: 'success',
+        mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE },
+        handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
+    })
+
+    const first = await fetchMapSnapshot({ page: 1, pageSize: MAP_SUMMARY_PAGE_SIZE, config: mockConfig })
+    const summary = first.data.coordinateSummary
+    // mock 的五条样例:2 个 VALID、1 个 UNVERIFIED、2 个 INVALID(超范围 + 零坐标)。
+    check(
+        'F1-mock-summary-three-buckets',
+        summary.total === 5 && summary.renderable === 2 && summary.invalid === 2 && summary.unverified === 1 && summary.crs === 'WGS84',
+        JSON.stringify(summary),
+    )
+
+    // 全量口径不随页码变化(后端是按整份快照算完再切片)。
+    const second = await fetchMapSnapshot({
+        page: 2,
+        pageSize: MAP_SUMMARY_PAGE_SIZE,
+        snapshotAt: first.data.snapshotAt,
+        config: mockConfig,
+    })
+    check(
+        'F2-summary-stable-across-pages',
+        second.data.coordinateSummary.total === summary.total && second.data.coordinateSummary.renderable === summary.renderable,
+        `第 2 页 total=${second.data.coordinateSummary.total}`,
+    )
+
+    // 已加载口径随翻页增长,与后端全量口径**本来就不等**——这正是要分开报的原因。
+    const merged = mergeMapPage(mergeMapPage(null, first.data), second.data)
+    const overview = coordinateOverview(merged.objects, merged.coordinateSummary)
+    check(
+        'F3-loaded-differs-from-backend',
+        overview.backend.renderable === 2 && overview.loaded.total === 4 && overview.loaded.renderable === 2 && overview.loaded.blocked === 2,
+        `后端可上图 ${overview.backend.renderable}/5 · 已加载 ${overview.loaded.renderable}/${overview.loaded.total}(无地图位置 ${overview.loaded.blocked})`,
+    )
+
+    // 空样例:0 是真实值(不是缺失)。
+    // 注意这里读**原始载荷**:`empty` 在票 05 的词汇里是没有领域载荷的(`data: null`),
+    // 所以 `fetchMapSnapshot` 看不到汇总段,得直接问统一出口要后端回的那一份。
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest } })
+    const emptyRaw = await requestCockpitJson('/map-snapshot?page=1&pageSize=2', { config: mockConfig })
+    const emptySummary = toCoordinateSummary(emptyRaw.data && emptyRaw.data.coordinateValidation)
+    check(
+        'F4-empty-scenario-zeroes',
+        emptyRaw.ok === true && emptySummary.available === true && emptySummary.total === 0 && emptySummary.invalid === 0,
+        JSON.stringify(emptySummary),
+    )
+
+    // 字段缺失样例:没给 coordinateValidation 的对象落进「未校验」桶(与后端同一分法)。
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'degraded', handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest } })
+    const degraded = await fetchMapSnapshot({ page: 1, pageSize: MAP_SUMMARY_PAGE_SIZE, config: mockConfig })
+    check(
+        'F5-degraded-counts-unverified',
+        degraded.data.coordinateSummary.total === 1 && degraded.data.coordinateSummary.unverified === 1 && degraded.data.coordinateSummary.renderable === 0,
+        JSON.stringify(degraded.data.coordinateSummary),
+    )
+}
+
+// ================================================================ G store 供货(组件不自己算)
+{
+    setActivePinia(createPinia())
+    resetMockTransportsForTest()
+    registerCockpitMocks({
+        scenario: 'success',
+        mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE },
+        handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
+    })
+
+    const mapStore = useMapStore()
+    mapStore.reset()
+    mapStore.setup({ config: mockConfig })
+    await mapStore.advance()
+    const afterFirst = mapStore.coordinate
+    await mapStore.advance()
+    const afterSecond = mapStore.coordinate
+
+    check(
+        'G1-store-supplies-overview',
+        afterFirst.loaded.total === 2 && afterFirst.loaded.renderable === 2 && afterFirst.loaded.blocked === 0,
+        JSON.stringify(afterFirst.loaded),
+    )
+    check(
+        'G2-overview-grows-with-pages',
+        afterSecond.loaded.total === 4 && afterSecond.loaded.renderable === 2 && afterSecond.loaded.blocked === 2 && afterSecond.blockedEntries.length === 2,
+        `${afterSecond.loadedText} | ${JSON.stringify(afterSecond.blockedEntries.map((item) => item.reasonText))}`,
+    )
+    check(
+        'G3-backend-caliber-unchanged',
+        afterSecond.backend.total === 5 && afterSecond.backend.renderable === 2,
+        afterSecond.backendText,
+    )
+}
+
+// ================================================================ H 「已上图」措辞不再骗人
+{
+    const blocked = object({ code: 'BAD', coordinate: { state: 'INVALID', reason: 'ZERO_COORDINATE', crs: 'WGS84', renderable: false, validated: true } })
+    const state = { phase: 'complete', loaded: 2, total: 2, objects: [object({ code: 'OK' }), blocked] }
+    const view = summaryView(state)
+    check(
+        'H1-complete-label-separates-blocked',
+        view.label.includes('已上图 1 个') && view.label.includes('无地图位置 1 个'),
+        view.label,
+    )
+
+    // 没有对象可判时(纯文案单测的老用法)保持票 08 的原措辞,不凭空造数。
+    const legacy = summaryView({ phase: 'complete', loaded: 7, total: 7 })
+    check('H2-legacy-usage-unchanged', legacy.label.includes('已上图 7 个') && !legacy.label.includes('无地图位置'), legacy.label)
+
+    // 终态措辞不得把被拦下的对象算进「已上图」。
+    const allBlocked = summaryView({ phase: 'complete', loaded: 2, total: 2, objects: [blocked, object({ code: 'BAD2', coordinate: { state: 'INVALID', reason: 'ZERO_COORDINATE', crs: 'WGS84', renderable: false, validated: true } })] })
+    check('H3-all-blocked-reports-zero', allBlocked.label.includes('已上图 0 个') && allBlocked.label.includes('无地图位置 2 个'), allBlocked.label)
+}
+
+// ================================================================ I 静态守卫
+{
+    const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
+    /**
+     * 读一个模块并**去掉注释**再断言。
+     * 守卫必须看代码而不是说明文字:本模块的注释里就写着「为什么连 `state === 'VALID'` 都不作为判据」
+     * 这句反例(第一版守卫正是被自己的注释判红的),拿注释当证据等于没守。
+     */
+    const stripComments = (text) =>
+        text
+            .replace(/\/\*[\s\S]*?\*\//g, ' ')
+            // 行注释;`://` 里的双斜杠是 URL,别误伤。
+            .replace(/(^|[^:])\/\/.*$/gm, '$1')
+    const read = (relative) => stripComments(readFileSync(new URL(relative, moduleRoot), 'utf8'))
+
+    // 票 07 验收第 4 条:前端不自动纠偏。落在代码上就是"没有任何地方再查一遍经纬度范围"。
+    // 查数值边界比较(180 / 90 与经纬度变量的比较)—— 判定只许来自后端 renderable。
+    const RANGE_PATTERN = /(longitude|latitude|lon|lat)\s*[<>]=?\s*-?(90|180)\b|-?(90|180)\s*[<>]=?\s*(longitude|latitude|lon|lat)\b/i
+    const domainFiles = ['domain/coordinate.js', 'domain/mapSummary.js', 'domain/fields.js', 'domain/pointDetail.js']
+    const rechecked = domainFiles.filter((file) => RANGE_PATTERN.test(read(file)))
+    check(
+        'I1-no-frontend-range-recheck',
+        rechecked.length === 0,
+        rechecked.length ? `出现前端范围重算:${rechecked.join(', ')}` : '领域层零范围重算,准入只认后端结论',
+    )
+
+    // 准入判据只有一处:只许与 renderable 比较,不许出现 `state === 'VALID'` 当第二套判据。
+    const coordinateText = read('domain/coordinate.js')
+    check(
+        'I2-single-admission-path',
+        /coordinate\.renderable === true/.test(coordinateText) && !/state === ['"]VALID['"]/.test(coordinateText),
+        '准入只看 renderable,没有第二套 state 判据',
+    )
+
+    // 界面不得自己判坐标:只许读 store 算好的数,不许调判据函数、不许碰后端校验段。
+    const UI_JUDGES = /coordinateValidation|isRenderable\(|blockReason\(|splitByCoordinate\(|\.renderable\s*===/
+    const uiFiles = [
+        'components/MapCanvas.vue',
+        'components/MapCoordinateBlocked.vue',
+        'CockpitShell.vue',
+    ]
+    const uiJudging = uiFiles.filter((file) => UI_JUDGES.test(read(file)))
+    check(
+        'I3-ui-does-not-judge',
+        uiJudging.length === 0,
+        uiJudging.length ? `界面自己判坐标:${uiJudging.join(', ')}` : '三个界面文件零坐标判据,全部取自 store',
+    )
+
+    // 内部原因码不上界面:清单组件只渲染领域层给的中文文案字段。
+    const listText = read('components/MapCoordinateBlocked.vue')
+    const rawCodes = Object.keys(COORDINATE_REASON_TEXT).filter((code) => listText.includes(code))
+    check('I4-no-reason-code-in-list-ui', rawCodes.length === 0, rawCodes.length ? JSON.stringify(rawCodes) : '清单组件零后端原因码')
+
+    // 「保留在列表里」的可达性:清单组件确实渲染了原因文案与对象名(不是只有计数)。
+    check(
+        'I5-list-renders-reason-and-name',
+        /item\.reasonText/.test(listText) && /item\.name/.test(listText) && /blockedEntries/.test(coordinateText),
+        '清单渲染对象名与中文原因',
+    )
+
+    // 后端全量口径只许经领域层转换,界面与 store 不出现后端裸字段名(票 05 的 D1 同一条纪律)。
+    const backendField = /totalObjectCount|renderableCount|invalidCount|unverifiedCount/
+    const leaked = ['stores/mapStore.js', 'components/MapCanvas.vue', 'components/MapCoordinateBlocked.vue'].filter((file) =>
+        backendField.test(read(file)),
+    )
+    check(
+        'I6-backend-summary-fields-confined',
+        leaked.length === 0,
+        leaked.length ? `裸字段泄漏:${leaked.join(', ')}` : '后端汇总字段只出现在 domain/coordinate.js 与 mock',
+    )
+}
+
+const failed = checks.filter((item) => !item.pass)
+console.log('')
+console.log(`T07 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((item) => item.name).join(', '))
+    process.exit(1)
+}

+ 328 - 0
.scratch/cockpit-shell-panel-layout/verify/t08-map-summary-walkthrough.mjs

@@ -0,0 +1,328 @@
+/**
+ * T08 真机走查:地图摘要分页与快照在浏览器里确实成立(票 08)。
+ *
+ * 逻辑脚本(t08-map-summary.mjs)已经把合并、装载器状态机与分区映射测透了;这里只补
+ * **只有在真机才成立**的部分 —— 票 08 验收第 1 条原句:
+ *   「地图先显示框架,再按分页批次增加摘要点位」。
+ * 纯逻辑断言证明不了「框架先于点位出现」:那要求页面真的渲染出来、地图宿主与 Cesium 引擎先就绪。
+ *
+ * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/;改了懒加载模块先重启服务):
+ *   node .scratch/cockpit-shell-panel-layout/verify/t08-map-summary-walkthrough.mjs
+ *
+ * 演示用的开关(都由宿主全局键注入,不改代码重新构建):
+ *   `window.__COCKPIT_MOCK_SCENARIO__` / `window.__COCKPIT_MOCK_MAP__`
+ *   人工演示同一件事:控制台里 `__cockpitMap.mapSummary.advance()` 一页页推进。
+ */
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
+
+const results = []
+/**
+ * `check(id, pass, evidence)` —— 注意参数顺序:第二参是**断言结果**,不是描述。
+ * (踩过一次:签名多了一个 `desc`,结果每个断言都把证据字符串当成了"通过",走查假绿 11/11。)
+ */
+function check(id, pass, evidence) {
+    results.push({ id, pass })
+    const rest = evidence === undefined ? '' : String(evidence)
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ::  ${rest}`)
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+const puppeteer = (await import(PUPPETEER)).default
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: join(tmpdir(), 'cockpit-t08-profile'),
+    defaultViewport: { width: 1680, height: 950 },
+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
+})
+console.log(`Chrome: ${await browser.version()}`)
+
+/** 读地图宿主上的摘要状态(断言不读中文文案,读 dataset)。 */
+const readSummary = (page) =>
+    page.evaluate(() => {
+        const host = document.querySelector('.cockpit-shell__map .map-canvas')
+        const action = document.querySelector('[data-map-summary-action]')
+        return {
+            phase: (host && host.dataset.mapSummary) || '',
+            loaded: Number((host && host.dataset.mapSummaryLoaded) || 0),
+            total: Number((host && host.dataset.mapSummaryTotal) || 0),
+            snapshot: (host && host.dataset.mapSummarySnapshot) || '',
+            page: Number((host && host.dataset.mapSummaryPage) || 0),
+            engine: (host && host.dataset.mapEngine) || '',
+            actionLabel: action ? action.textContent.trim() : '',
+            actionDisabled: action ? action.disabled : null,
+            label: (document.querySelector('[data-map-summary-label]') || {}).textContent || '',
+            board: Array.from(document.querySelectorAll('[data-section-board] [data-section-state]')).map((t) => ({
+                name: t.dataset.sectionName,
+                state: t.dataset.sectionState,
+                retained: t.dataset.sectionRetained,
+            })),
+            hosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length,
+        }
+    })
+
+async function openPage(inject) {
+    const page = await browser.newPage()
+    const errors = []
+    page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
+    page.on('console', (m) => {
+        if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
+    })
+
+    await page.evaluateOnNewDocument(() => {
+        // 引导层会盖住页面,走查不需要它(票 30 的开关)。
+        window.__COCKPIT_CONFIG__ = { dataSource: 'mock', map: { intro: { enabled: false } } }
+    })
+    if (inject) await page.evaluateOnNewDocument(inject)
+
+    await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+    await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+    // 引擎要拉静态资源/地形,比取数慢:先等它落到终态,否则「框架先就绪」会被误判。
+    await page
+        .waitForFunction(
+            () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
+            { timeout: 30000 },
+        )
+        .catch(() => {})
+    // 外壳挂载后会自动取第 1 批(票 08 的"再按分页批次增加"):等它落定再断言,
+    // 否则会把"正在取第 1 批"误判成"框架就绪但一个点都没有"。
+    await waitForFirstBatch(page)
+    await sleep(300)
+    return { page, errors }
+}
+
+/** 点「加载下一页」并等**已上图对象数真的变了**(不知增量,所以等"变化"而不是等某个值)。 */
+async function clickAndWaitChange(page, previousLoaded, timeout = 8000) {
+    const clicked = await page.evaluate(() => {
+        const button = document.querySelector('[data-map-summary-action]')
+        if (!button || button.disabled) return false
+        button.click()
+        return true
+    })
+    if (!clicked) return false
+    await page
+        .waitForFunction(
+            (before) => {
+                const host = document.querySelector('.cockpit-shell__map .map-canvas')
+                const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
+                const phase = (host && host.dataset.mapSummary) || ''
+                return loaded !== before || phase === 'failed' || phase === 'expired'
+            },
+            { timeout },
+            previousLoaded,
+        )
+        .catch(() => {})
+    return true
+}
+
+/** 等外壳自动取完第 1 批(票 08:地图挂载后自己先取一批,不等用户点)。 */
+async function waitForFirstBatch(page, timeout = 15000) {
+    await page
+        .waitForFunction(
+            () => {
+                const host = document.querySelector('.cockpit-shell__map .map-canvas')
+                const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
+                const phase = (host && host.dataset.mapSummary) || ''
+                return loaded > 0 || phase === 'empty' || phase === 'failed' || phase === 'expired'
+            },
+            { timeout },
+        )
+        .catch(() => {})
+}
+
+/** 一批一批点,直到不再有下一页(完成/空/失败/失效)或点不动了。返回每一步的状态。 */
+async function advanceUntilSettled(page, maxSteps = 10) {
+    const series = [await readSummary(page)]
+    for (let step = 0; step < maxSteps; step += 1) {
+        const current = series[series.length - 1]
+        if (['complete', 'empty', 'failed', 'expired'].includes(current.phase)) break
+        if (!(await clickAndWaitChange(page, current.loaded))) break
+        series.push(await readSummary(page))
+    }
+    return series
+}
+
+try {
+    // ---------- A 「地图先显示框架,再按分页批次增加摘要点位」----------
+    {
+        const { page, errors } = await openPage(() => {
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
+        })
+        const first = await readSummary(page)
+        // 外壳在挂载后自动取第 1 批(票 08 原句的"再按分页批次增加"):首屏就应看到框架 + 第 1 批,
+        // 而不是等全部取完。`loaded < total` 正是"渐进"的判据。
+        check(
+            'A1-frame-before-points',
+            ['ready', 'disabled'].includes(first.engine) && first.phase !== 'failed' && first.loaded > 0 && first.loaded < first.total,
+            JSON.stringify({ engine: first.engine, phase: first.phase, loaded: first.loaded, total: first.total }),
+        )
+        check('A1-host-alive', first.hosts === 1, `地图宿主 ${first.hosts} 个`)
+
+        const series = await advanceUntilSettled(page)
+        const loadedSeries = series.map((item) => item.loaded)
+        const snapshots = new Set(series.map((item) => item.snapshot).filter(Boolean))
+        const last = series[series.length - 1]
+
+        check(
+            'A2-progressive-batches',
+            loadedSeries.join(',') === '2,4,5' && last.phase === 'complete' && last.total === 5,
+            `已上图序列 ${loadedSeries.join(' → ')} · 总数 ${last.total} · 终态 ${last.phase}`,
+        )
+        check(
+            'A3-one-snapshot-per-load',
+            snapshots.size === 1 && [...snapshots][0] !== '',
+            `快照 ${[...snapshots].join(', ')}`,
+        )
+        check(
+            'A4-batch-count-consistent',
+            series.length === 3 && loadedSeries[0] === 2 && loadedSeries[1] === 4,
+            `批次数 ${series.length} · 序列 ${loadedSeries.join(' → ')}`,
+        )
+        // 取完之后按钮从「加载下一页」变成「刷新快照」:完成态是终态,再点就是换新快照。
+        check(
+            'A5-complete-switches-action',
+            last.actionLabel.includes('刷新') && first.actionLabel.includes('加载'),
+            `按钮:${first.actionLabel} → ${last.actionLabel}`,
+        )
+        check('A6-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        await page.close()
+    }
+
+    // ---------- B 刷新切换到新快照,旧快照的点不留在图上 ----------
+    {
+        const { page } = await openPage(() => {
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
+        })
+        const series = await advanceUntilSettled(page)
+        const completed = series[series.length - 1]
+        // 完成态下点这个按钮 = `mapStore.refresh()`:丢掉当前快照,从第 1 页重来。
+        await clickAndWaitChange(page, completed.loaded)
+        await sleep(400)
+        const refreshed = await readSummary(page)
+        check(
+            'B1-refresh-back-to-page-1',
+            refreshed.page === 1 && refreshed.loaded === 2 && refreshed.phase !== 'expired' && refreshed.phase !== 'failed',
+            JSON.stringify({ page: refreshed.page, loaded: refreshed.loaded, phase: refreshed.phase, snapshot: refreshed.snapshot }),
+        )
+        check(
+            'B2-old-snapshot-not-accumulated',
+            refreshed.loaded < completed.loaded,
+            `完成时 ${completed.loaded} → 刷新后 ${refreshed.loaded}(旧快照的点没有留下来)`,
+        )
+        await page.close()
+    }
+
+    // ---------- C 失败:分区标失败,地图框架与已上图点位保留 ----------
+    {
+        const { page } = await openPage(() => {
+            window.__COCKPIT_MOCK_SCENARIO__ = 'failed'
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
+        })
+        const failed = await readSummary(page)
+        const mapSection = failed.board.find((item) => item.name === 'mapSnapshot')
+        check(
+            'C1-failed-shows-on-board',
+            failed.phase === 'failed' && !!mapSection && mapSection.state === 'failed' && failed.engine !== '',
+            JSON.stringify({ phase: failed.phase, section: mapSection }),
+        )
+        check(
+            'C2-failed-keeps-frame',
+            failed.hosts === 1 && failed.label.includes('失败'),
+            JSON.stringify({ hosts: failed.hosts, label: failed.label }),
+        )
+        await page.close()
+    }
+
+    // ---------- D 快照过期:取完第 1 页后后端换快照,界面说「已失效」并保留已加载的点 ----------
+    {
+        const { page } = await openPage(() => {
+            window.__COCKPIT_MOCK_SCENARIO__ = 'stale'
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 2, expireAfterPage: 1 }
+        })
+        // 首屏自动取到的就是第 1 页(旧快照);再点一次,后端已经换快照 → 失效。
+        const before = await readSummary(page)
+        await clickAndWaitChange(page, before.loaded)
+        await sleep(500)
+        const expired = await readSummary(page)
+        check(
+            'D1-expired-after-first-page',
+            expired.phase === 'expired' && expired.loaded === before.loaded && expired.label.includes('失效'),
+            JSON.stringify({ before: `${before.phase}/${before.loaded}`, phase: expired.phase, loaded: expired.loaded, label: expired.label }),
+        )
+        check(
+            'D2-expired-not-merged',
+            expired.snapshot !== '' && expired.total === before.total,
+            JSON.stringify({ snapshot: expired.snapshot, total: expired.total }),
+        )
+        await page.close()
+    }
+
+    // ---------- E 空快照:不是失败,也不是「加载完成」的假象 ----------
+    {
+        const { page } = await openPage(() => {
+            window.__COCKPIT_MOCK_SCENARIO__ = 'empty'
+        })
+        const empty = await readSummary(page)
+        const mapSection = empty.board.find((item) => item.name === 'mapSnapshot')
+        check(
+            'E1-empty-is-neutral',
+            empty.phase === 'empty' && empty.loaded === 0 && (empty.label.includes('没有可上图对象') || empty.label.includes('无数据')),
+            JSON.stringify({ phase: empty.phase, label: empty.label, section: mapSection && mapSection.state }),
+        )
+        await page.close()
+    }
+
+    // ---------- F 布局:新增的「地图摘要」控件不得把筛选条撑破(票 08 改到了顶栏)----------
+    {
+        const { page } = await openPage(() => {
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
+        })
+        const probe = () =>
+            page.evaluate(() => {
+                const scope = document.querySelector('.cockpit-shell__scope')
+                const summary = document.querySelector('.cockpit-shell__map-summary')
+                const doc = document.documentElement
+                return {
+                    scopeOverflow: scope ? scope.scrollWidth - scope.clientWidth : -1,
+                    summaryVisible: !!summary && summary.getBoundingClientRect().width > 0,
+                    summaryInside: summary && scope ? summary.getBoundingClientRect().right <= scope.getBoundingClientRect().right + 1 : false,
+                    docOverflow: doc.scrollWidth - doc.clientWidth,
+                }
+            })
+
+        const at1680 = await probe()
+        await page.setViewport({ width: 1280, height: 720 })
+        await sleep(500)
+        const at1280 = await probe()
+
+        check(
+            'F1-filter-bar-not-overflowing',
+            at1680.scopeOverflow <= 0 && at1680.docOverflow <= 0 && at1280.scopeOverflow <= 0 && at1280.docOverflow <= 0,
+            JSON.stringify({ at1680, at1280 }),
+        )
+        check(
+            'F2-summary-widget-visible-and-inside',
+            at1680.summaryVisible && at1680.summaryInside && at1280.summaryVisible,
+            JSON.stringify({ at1680: { visible: at1680.summaryVisible, inside: at1680.summaryInside }, at1280: { visible: at1280.summaryVisible } }),
+        )
+        await page.close()
+    }
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((r) => !r.pass)
+console.log('')
+console.log(`T08 走查:${results.length - failed.length}/${results.length} 项通过。`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((f) => f.id).join(', '))
+    process.exit(1)
+}
+console.log('T08 走查全部通过。')

+ 570 - 0
.scratch/cockpit-shell-panel-layout/verify/t08-map-summary.mjs

@@ -0,0 +1,570 @@
+/**
+ * T08 地图摘要分页与快照 纯逻辑检查(票 08)。
+ *
+ * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
+ * 运行:node .scratch/cockpit-shell-panel-layout/verify/t08-map-summary.mjs
+ *
+ * 分四段:
+ *   A 批次合并(跨页拼接、按 kind+code 去重、完成/总数/快照标记)
+ *   B 演示态词(装载中 / 完成 / 失败 / 快照过期 / 空,都要有中文文案)
+ *   C 装载器状态机(同一批加载保持同一快照、刷新换新快照、失败后重试不跳页)
+ *   D 适配层分页参数(page / pageSize / snapshotAt 真的出现在查询串上)
+ *
+ * 契约依据是**后端实现**而不是演示页的乐观假设:
+ * `CockpitMapSnapshotService.validatePagination` —— 第 2 页起必须带 `snapshotAt`,
+ * 且带了就必须与后端当前快照一致,否则抛 `SNAPSHOT_EXPIRED`。
+ */
+import { readFileSync } from 'node:fs'
+import { createPinia, setActivePinia } from 'pinia'
+import {
+    MAP_SUMMARY_LIMITS,
+    MAP_SUMMARY_PHASE,
+    createMapSummaryLoader,
+    mergeMapPage,
+    summaryView,
+} from '../../../src/modules/cockpit/domain/mapSummary.js'
+import { fetchMapSnapshot } from '../../../src/modules/cockpit/services/mapApi.js'
+import {
+    getMockTransportPaths,
+    registerMockTransport,
+    requestCockpitJson,
+    resetMockTransportsForTest,
+} from '../../../src/modules/cockpit/services/cockpitApi.js'
+import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
+import { MAP_SUMMARY_PAGE_SIZE, useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js'
+import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js'
+
+const checks = []
+function check(name, pass, evidence) {
+    checks.push({ name, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${name}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
+
+/** 造一条**后端载荷**里的监测点/设备(字段名照抄 map-snapshot-demo.html)。 */
+const rawPoint = (code) => ({
+    pointCode: code,
+    pointName: `${code} 点位`,
+    pointStatus: 'NORMAL',
+    statusReasons: [],
+    longitude: 110.141,
+    latitude: 28.452,
+    coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
+})
+
+/** 造一页后端载荷。 */
+function page({ page: pageNo = 1, pageSize = 2, snapshotAt = '2026-09-02T10:00:00+08:00', total = 4, complete = false, points = [], equipment = [] } = {}) {
+    return { page: pageNo, pageSize, snapshotAt, batchUpdatedAt: snapshotAt, total, complete, monitoringPoints: points, unassociatedEquipment: equipment }
+}
+
+const point = (code, lon = 110.1, lat = 28.4) => ({
+    kind: 'monitoring-point',
+    code,
+    name: `${code} 点位`,
+    status: 'NORMAL',
+    statusReasons: [],
+    longitude: lon,
+    latitude: lat,
+    coordinate: { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
+    missing: [],
+})
+
+const device = (code, lon = 110.2, lat = 28.5) => ({
+    kind: 'unassociated-equipment',
+    code,
+    name: `${code} 设备`,
+    status: '',
+    statusReasons: [],
+    longitude: lon,
+    latitude: lat,
+    coordinate: { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
+    missing: [],
+})
+
+// ================================================================ A 批次合并
+{
+    const first = mergeMapPage(null, {
+        page: 1,
+        pageSize: 2,
+        snapshotAt: '2026-09-02T10:00:00+08:00',
+        total: 4,
+        complete: false,
+        objects: [point('MP-001'), device('EQ-001')],
+    })
+    check(
+        'A1-first-page',
+        first.page === 1 && first.snapshot === '2026-09-02T10:00:00+08:00' && first.total === 4 && first.complete === false && first.loaded === 2,
+        JSON.stringify({ page: first.page, loaded: first.loaded }),
+    )
+
+    // 后端的分页窗口是「监测点 + 未关联设备」这一条总序列,跨页会横跨两类。
+    const second = mergeMapPage(first, {
+        page: 2,
+        pageSize: 2,
+        snapshotAt: '2026-09-02T10:00:00+08:00',
+        total: 4,
+        complete: true,
+        objects: [point('MP-002'), device('EQ-002')],
+    })
+    check(
+        'A2-second-page-keeps-snapshot',
+        second.page === 2 && second.snapshot === first.snapshot && second.complete === true && second.loaded === 4,
+        `snapshot ${second.snapshot} / loaded ${second.loaded}`,
+    )
+    check(
+        'A2-both-kinds-on-map',
+        second.objects.filter((item) => item.kind === 'monitoring-point').length === 2 &&
+            second.objects.filter((item) => item.kind === 'unassociated-equipment').length === 2,
+        JSON.stringify(second.objects.map((item) => item.kind)),
+    )
+
+    // 后端分页是「快照内的切片」:同一对象重复出现(重放/重试)不得在地图上变两遍。
+    const replayed = mergeMapPage(second, {
+        page: 2,
+        snapshotAt: '2026-09-02T10:00:00+08:00',
+        total: 4,
+        complete: true,
+        objects: [point('MP-002'), device('EQ-002')],
+    })
+    check('A3-dedupe-by-kind-and-code', replayed.loaded === 4 && replayed.objects.length === 4, `loaded ${replayed.loaded}`)
+
+    // 不同 kind 的同名编码是不同对象(监测点 MP-1 与设备 MP-1 都要在图上)。
+    const crossKind = mergeMapPage(null, { page: 1, objects: [point('SAME-1'), device('SAME-1')] })
+    check('A3-cross-kind-not-merged', crossKind.loaded === 2 && crossKind.objects.length === 2, `objects ${crossKind.objects.length}`)
+
+    // 同一 kind 下没有编码的脏对象不得被当成「同一个」丢掉。
+    const noCode = mergeMapPage(null, { page: 1, objects: [{ kind: 'monitoring-point' }, { kind: 'monitoring-point' }] })
+    check('A3-codeless-not-dropped', noCode.loaded === 2, `loaded ${noCode.loaded}`)
+
+    // 刷新换快照 = 从空的合并态重来(旧快照的点不得留在地图上)。
+    const refreshed = mergeMapPage(null, {
+        page: 1,
+        snapshotAt: '2026-09-02T10:05:00+08:00',
+        total: 4,
+        complete: false,
+        objects: [point('MP-009')],
+    })
+    check(
+        'A4-refresh-drops-old-snapshot',
+        refreshed.snapshot === '2026-09-02T10:05:00+08:00' && refreshed.loaded === 1 && refreshed.objects[0].code === 'MP-009',
+        JSON.stringify(refreshed.objects.map((item) => item.code)),
+    )
+
+    // 长跑护栏:只保留 LIMITS 内的批次与对象(地图不是无限内存)。
+    let long = null
+    for (let index = 0; index < MAP_SUMMARY_LIMITS.maxBatches + 3; index += 1) {
+        long = mergeMapPage(long, { page: index + 1, objects: [point(`MP-${index}`)], total: 999 })
+    }
+    check(
+        'A5-long-run-bounded',
+        long.batches.length === MAP_SUMMARY_LIMITS.maxBatches && long.objects.length <= MAP_SUMMARY_LIMITS.maxObjects && long.page === MAP_SUMMARY_LIMITS.maxBatches + 3,
+        `batches ${long.batches.length} objects ${long.objects.length} page ${long.page}`,
+    )
+}
+
+// ================================================================ B 演示态词
+{
+    const view = (phase, extra = {}) => summaryView({ phase, ...extra })
+    check('B1-idle', view(MAP_SUMMARY_PHASE.IDLE).label.includes('框架已就绪'), view(MAP_SUMMARY_PHASE.IDLE).label)
+    check('B2-loading', view(MAP_SUMMARY_PHASE.LOADING, { attemptPage: 2, loaded: 2, total: 7 }).label.includes('第 2 页'), view(MAP_SUMMARY_PHASE.LOADING, { attemptPage: 2 }).label)
+    check('B3-complete', view(MAP_SUMMARY_PHASE.COMPLETE, { loaded: 7 }).label.includes('加载完成'), view(MAP_SUMMARY_PHASE.COMPLETE, { loaded: 7 }).label)
+    check('B3-empty', view(MAP_SUMMARY_PHASE.EMPTY).label.includes('没有可上图对象'), view(MAP_SUMMARY_PHASE.EMPTY).label)
+    check('B4-expired', view(MAP_SUMMARY_PHASE.EXPIRED).label.includes('已失效') && view(MAP_SUMMARY_PHASE.EXPIRED).tone === 'lv1', JSON.stringify(view(MAP_SUMMARY_PHASE.EXPIRED)))
+    check('B4-failed', view(MAP_SUMMARY_PHASE.FAILED).tone === 'lv1' && view(MAP_SUMMARY_PHASE.FAILED).label.includes('失败'), JSON.stringify(view(MAP_SUMMARY_PHASE.FAILED)))
+
+    // 对抗式:内部原因码不得出现在给人读的文案里(票 05 的同一条纪律)。
+    const leaked = []
+    for (const phase of Object.values(MAP_SUMMARY_PHASE)) {
+        const text = JSON.stringify(summaryView({ phase, reason: 'snapshot-expired' }))
+        if (text.includes('snapshot-expired')) leaked.push(phase)
+    }
+    check('B5-no-reason-code-in-ui', leaked.length === 0, leaked.length ? leaked.join(', ') : '五态都有中文文案')
+}
+
+// ================================================================ C 装载器状态机
+{
+    /** 一段可控的分页后端:记录每次请求的快照入参,第 2 页起要求与当前快照一致。 */
+    function fakeBackend({ total = 4, pageSize = 2, expireAtPage = 0, failAtPage = 0 } = {}) {
+        let snapshotAt = '2026-09-02T10:00:00+08:00'
+        const requests = []
+        let sequence = 0
+
+        const objects = Array.from({ length: total }, (_, index) =>
+            index < 2 ? point(`MP-00${index + 1}`) : device(`EQ-00${index + 1}`),
+        )
+
+        return {
+            requests,
+            setSnapshot: (value) => {
+                snapshotAt = value
+            },
+            async fetchPage({ page: pageNo, snapshotAt: requested }) {
+                requests.push({ page: pageNo, snapshotAt: requested })
+                if (failAtPage && pageNo === failAtPage) return { ok: false, reason: 'unavailable' }
+                if (pageNo > 1 && !requested) return { ok: false, reason: 'bad-payload' }
+                if (pageNo > 1 && requested !== snapshotAt) return { ok: false, reason: 'snapshot-expired' }
+                if (expireAtPage && pageNo === expireAtPage) snapshotAt = '2026-09-02T10:09:00+08:00'
+
+                const from = (pageNo - 1) * pageSize
+                const slice = objects.slice(from, from + pageSize)
+                return {
+                    ok: true,
+                    mode: 'mock',
+                    data: {
+                        snapshotAt,
+                        total,
+                        complete: from + pageSize >= total,
+                        objects: slice,
+                    },
+                }
+            },
+        }
+    }
+
+    // 第 1 页(后端默认页大小 500)与第 2 页用的是**同一个**快照标记。
+    {
+        const backend = fakeBackend()
+        const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
+        await loader.loadNext()
+        const afterFirst = loader.getState()
+        // 还有下一页时停在「等下一页」而不是「完成」——渐进加载不是加载到一半就算完。
+        check('C2-partial-is-not-complete', afterFirst.phase === MAP_SUMMARY_PHASE.IDLE && afterFirst.loaded === 2, afterFirst.phase)
+        await loader.loadNext()
+        const afterSecond = loader.getState()
+
+        check(
+            'C1-locked-snapshot',
+            backend.requests.length === 2 &&
+                backend.requests[0].snapshotAt === undefined &&
+                backend.requests[1].snapshotAt === afterFirst.snapshot &&
+                afterSecond.snapshot === afterFirst.snapshot,
+            JSON.stringify(backend.requests),
+        )
+        check(
+            'C2-progressive',
+            afterSecond.loaded === 4 && afterSecond.phase === MAP_SUMMARY_PHASE.COMPLETE,
+            `${afterFirst.phase}(${afterFirst.loaded}) → ${afterSecond.phase}(${afterSecond.loaded})`,
+        )
+    }
+
+    // 完成后再点:不再发请求,也不换快照。
+    {
+        const backend = fakeBackend()
+        const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
+        await loader.loadNext()
+        await loader.loadNext()
+        const callsAfterComplete = backend.requests.length
+        await loader.loadNext()
+        const state = loader.getState()
+        check(
+            'C3-complete-is-terminal',
+            backend.requests.length === callsAfterComplete && state.phase === MAP_SUMMARY_PHASE.COMPLETE,
+            `requests ${backend.requests.length}`,
+        )
+    }
+
+    // 刷新:换新快照,已加载对象从零开始(旧快照的点不再留在图上)。
+    {
+        const backend = fakeBackend()
+        const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
+        await loader.loadNext()
+        const oldSnapshot = loader.getState().snapshot
+        backend.setSnapshot('2026-09-02T10:05:00+08:00')
+        await loader.refresh()
+
+        const state = loader.getState()
+        check(
+            'C4-refresh-new-snapshot',
+            state.snapshot === '2026-09-02T10:05:00+08:00' &&
+                state.snapshot !== oldSnapshot &&
+                state.page === 1 &&
+                state.loaded === 2 &&
+                backend.requests[backend.requests.length - 1].snapshotAt === undefined,
+            JSON.stringify({ snapshot: state.snapshot, page: state.page, lastRequest: backend.requests[backend.requests.length - 1] }),
+        )
+    }
+
+    // 同一批加载中途快照被后端换掉:如实报「快照过期」,绝不把两个快照的对象拼在一起。
+    {
+        const backend = fakeBackend({ expireAtPage: 2 })
+        const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
+        await loader.loadNext()
+        const afterFirst = loader.getState()
+        await loader.loadNext()
+        const state = loader.getState()
+        check(
+            'C5-expired-not-merged',
+            state.phase === MAP_SUMMARY_PHASE.EXPIRED && state.reason === 'snapshot-expired' && state.loaded === afterFirst.loaded && state.objects.length === 2,
+            JSON.stringify({ phase: state.phase, loaded: state.loaded }),
+        )
+    }
+
+    // 失败:保留已加载的点,下一次点继续从**同一页**重试(不静默跳页丢数据)。
+    {
+        const backend = fakeBackend({ failAtPage: 2 })
+        const loader = createMapSummaryLoader({ fetchPage: backend.fetchPage })
+        await loader.loadNext()
+        await loader.loadNext()
+        const failed = loader.getState()
+        const retry = await loader.loadNext()
+        check(
+            'C6-failed-then-retry-same-page',
+            failed.phase === MAP_SUMMARY_PHASE.FAILED &&
+                failed.loaded === 2 &&
+                failed.page === 1 &&
+                retry.phase === MAP_SUMMARY_PHASE.FAILED &&
+                retry.nextPage === 2 &&
+                backend.requests[backend.requests.length - 1].page === 2,
+            JSON.stringify({ failedPhase: failed.phase, page: failed.page, retriedPage: backend.requests[backend.requests.length - 1].page }),
+        )
+    }
+
+    // 空快照:不是失败,也不是「加载完成」的假象。
+    {
+        const loader = createMapSummaryLoader({
+            fetchPage: async () => ({ ok: true, mode: 'mock', data: { snapshotAt: '2026-09-02T10:00:00+08:00', total: 0, complete: true, objects: [] } }),
+        })
+        await loader.loadNext()
+        const state = loader.getState()
+        check('C7-empty-phase', state.phase === MAP_SUMMARY_PHASE.EMPTY && state.loaded === 0, JSON.stringify(state.phase))
+    }
+
+    // 首屏框架与取数彼此独立:还没取数时装载器就说得出「框架已就绪,等待分页数据」。
+    {
+        const loader = createMapSummaryLoader({ fetchPage: async () => ({ ok: true, mode: 'mock', data: {} }) })
+        check('C8-frame-before-data', loader.getState().phase === MAP_SUMMARY_PHASE.IDLE && summaryView(loader.getState()).label.includes('等待'), loader.getState().phase)
+    }
+
+    // 分页参数由调用方给(票面:「分页参数和快照参数由适配层管理」)。
+    {
+        const seen = []
+        const loader = createMapSummaryLoader({
+            pageSize: 250,
+            fetchPage: async (options) => {
+                seen.push(options)
+                return { ok: true, mode: 'mock', data: { snapshotAt: 's1', total: 1, complete: true, objects: [point('MP-001')] } }
+            },
+        })
+        await loader.loadNext()
+        check('C9-pagination-params', seen[0].page === 1 && seen[0].pageSize === 250, JSON.stringify(seen[0]))
+    }
+}
+
+// ================================================================ D 适配层分页参数
+{
+    const seen = []
+    resetMockTransportsForTest()
+    registerMockTransport('/map-snapshot', async (context) => {
+        seen.push(context.path)
+        return page({ points: [rawPoint('MP-001')], complete: true, total: 1 })
+    })
+
+    const first = await fetchMapSnapshot({ page: 1, pageSize: 2, config: mockConfig })
+    const second = await fetchMapSnapshot({ page: 2, pageSize: 2, snapshotAt: '2026-09-02T10:00:00+08:00', config: mockConfig })
+
+    check('D1-query-carries-page-and-size', seen[0] === '/map-snapshot?page=1&pageSize=2', seen[0])
+    check(
+        'D2-query-carries-snapshot',
+        seen[1] === '/map-snapshot?page=2&pageSize=2&snapshotAt=2026-09-02T10%3A00%3A00%2B08%3A00',
+        seen[1],
+    )
+    check(
+        'D3-domain-batch',
+        first.state === 'success' && first.data.page === 1 && first.data.objects.length === 1 && second.data.complete === true,
+        JSON.stringify({ state: first.state, objects: first.data.objects.length }),
+    )
+    // 后端「快照已失效」是 400 + `CockpitErrorCategory.SNAPSHOT_EXPIRED`;适配层必须把它
+    // 当成可演示的独立原因交给界面,而不是笼统的「加载失败」。
+    const errorBody = { code: 400, msg: '地图快照已失效,请重新加载', data: { category: 'SNAPSHOT_EXPIRED', message: '地图快照已失效,请重新加载' } }
+    const expired = await requestCockpitJson('/map-snapshot?page=2&snapshotAt=old', {
+        config: { dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1', token: 'readonly' } },
+        fetchImpl: async () => ({ ok: false, status: 400, json: async () => errorBody, text: async () => JSON.stringify(errorBody) }),
+    })
+    const invalid = await requestCockpitJson('/map-snapshot?page=0', {
+        config: { dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1', token: 'readonly' } },
+        fetchImpl: async () => ({
+            ok: false,
+            status: 400,
+            json: async () => ({ code: 400, msg: '地图快照分页参数不合法', data: { category: 'INVALID_REQUEST' } }),
+        }),
+    })
+    check('D4-snapshot-expired-reason', expired.ok === false && expired.reason === 'snapshot-expired', JSON.stringify(expired))
+    check('D4-invalid-request-stays-generic', invalid.reason === 'unavailable', JSON.stringify(invalid))
+}
+
+// ================================================================ E 装载器抛错不发散
+{
+    const loader = createMapSummaryLoader({
+        fetchPage: async () => {
+            throw new Error('boom')
+        },
+    })
+    const state = await loader.loadNext()
+    check('E1-throw-becomes-failed', state.phase === MAP_SUMMARY_PHASE.FAILED && state.reason === 'unreachable', JSON.stringify(state.phase))
+}
+
+// ================================================================ F 契约化 mock 的真实分页
+{
+    // mock 也要按后端语义分页:分页窗口横跨两类对象、第 2 页起不带快照即失效。
+    resetMockTransportsForTest()
+    registerCockpitMocks({
+        scenario: 'success',
+        mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE },
+        handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
+    })
+
+    const first = await fetchMapSnapshot({ page: 1, pageSize: MAP_SUMMARY_PAGE_SIZE, config: mockConfig })
+    const second = await fetchMapSnapshot({ page: 2, pageSize: MAP_SUMMARY_PAGE_SIZE, snapshotAt: first.data.snapshotAt, config: mockConfig })
+    const third = await fetchMapSnapshot({ page: 3, pageSize: MAP_SUMMARY_PAGE_SIZE, snapshotAt: first.data.snapshotAt, config: mockConfig })
+    const noSnapshot = await fetchMapSnapshot({ page: 2, pageSize: MAP_SUMMARY_PAGE_SIZE, config: mockConfig })
+
+    check(
+        'F1-mock-pages-are-real-slices',
+        first.data.objects.length === MAP_SUMMARY_PAGE_SIZE &&
+            second.data.objects.length === MAP_SUMMARY_PAGE_SIZE &&
+            third.data.complete === true &&
+            first.data.snapshotAt === second.data.snapshotAt,
+        JSON.stringify({ total: first.data.total, thirdComplete: third.data.complete }),
+    )
+    check(
+        'F2-mock-spans-both-kinds',
+        new Set([...first.data.objects, ...second.data.objects, ...third.data.objects].map((item) => item.kind)).size === 2,
+        JSON.stringify([...first.data.objects, ...second.data.objects, ...third.data.objects].map((item) => `${item.kind}:${item.code}`)),
+    )
+    // 分区块状结果里没有 `ok` 字段(那是装载器的词汇):失效在票 05 的词汇里是 `failed` + 原因。
+    check(
+        'F3-mock-page2-without-snapshot-expires',
+        noSnapshot.state === 'failed' && noSnapshot.reason === 'snapshot-expired',
+        JSON.stringify({ state: noSnapshot.state, reason: noSnapshot.reason }),
+    )
+}
+
+// ================================================================ G 装载器 → store → 分区状态
+{
+    setActivePinia(createPinia())
+    resetMockTransportsForTest()
+    registerCockpitMocks({
+        scenario: 'success',
+        mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE },
+        handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
+    })
+
+    const mapStore = useMapStore()
+    const detailStore = useDetailStore()
+    const freshMapStore = mapStore
+    mapStore.setup({ config: mockConfig })
+
+    const before = mapStore.summary
+    await mapStore.advance()
+    const afterFirst = mapStore.summary
+    const partialSection = detailStore.syncMapSnapshotSection()
+    await detailStore.loadSharedSections({ config: mockConfig })
+    const section = detailStore.sections.mapSnapshot
+
+    check(
+        'G1-store-progressive',
+        before.loaded === 0 && before.phase === MAP_SUMMARY_PHASE.IDLE && afterFirst.loaded === MAP_SUMMARY_PAGE_SIZE && afterFirst.page === 1,
+        JSON.stringify({ before: before.phase, after: `${afterFirst.phase}/${afterFirst.loaded}` }),
+    )
+    // 还没翻完 ≠ 过期:取到的一部分是**新鲜**数据,分区就是成功(把"没翻完"说成"已过期"
+    // 是对值班人员撒谎,也会与地图上的真实进度互相矛盾)。
+    check(
+        'G2-partial-is-success-not-stale',
+        partialSection.state === 'success' && partialSection.retained === false && partialSection.data.loaded === MAP_SUMMARY_PAGE_SIZE,
+        JSON.stringify({ state: partialSection.state, retained: partialSection.retained }),
+    )
+
+    // 一个点都还没取到时不得改写分区:不能用「无数据」或「已过期」去描述"正在取第 1 页"。
+    // 用一个全新的 detailStore 验(上面那个已经载过成功值,断言会看不出"有没有被改写")。
+    {
+        freshMapStore.reset()
+        const freshDetailStore = useDetailStore()
+        freshDetailStore.reset()
+        const untouched = freshDetailStore.syncMapSnapshotSection()
+        check(
+            'G2b-nothing-loaded-does-not-write-section',
+            untouched.loaded === false && untouched.state === 'empty',
+            JSON.stringify({ loaded: untouched.loaded, state: untouched.state }),
+        )
+    }
+    check(
+        'G3-section-carries-snapshot',
+        section.loaded === true && !!section.data && section.data.snapshot === afterFirst.snapshot && section.data.loaded > 0,
+        JSON.stringify({ snapshot: section.data && section.data.snapshot, loaded: section.data && section.data.loaded }),
+    )
+    check('G4-page-size-hidden', MAP_SUMMARY_PAGE_SIZE === 2, `演示页大小 ${MAP_SUMMARY_PAGE_SIZE}`)
+
+    // 刷新换新快照:装载态清空后重新从第 1 页开始,快照标记不变(后端当前快照没变)。
+    const refreshed = await mapStore.refresh()
+    check(
+        'G5-refresh-restarts-at-page-1',
+        refreshed.page === 1 && refreshed.loaded === MAP_SUMMARY_PAGE_SIZE && refreshed.snapshot === afterFirst.snapshot,
+        JSON.stringify({ page: refreshed.page, loaded: refreshed.loaded }),
+    )
+
+    // 快照过期场景:取完第 1 页后后端换快照,第 2 页即失效,且已加载的点保留。
+    mapStore.reset()
+    resetMockTransportsForTest()
+    registerCockpitMocks({
+        scenario: 'stale',
+        mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE, expireAfterPage: 1 },
+        handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
+    })
+    mapStore.setup({ config: mockConfig })
+    await mapStore.advance()
+    const expired = await mapStore.advance()
+    check(
+        'G6-expired-keeps-loaded',
+        expired.phase === MAP_SUMMARY_PHASE.EXPIRED && expired.loaded === MAP_SUMMARY_PAGE_SIZE && mapStore.view.label.includes('失效'),
+        JSON.stringify({ phase: expired.phase, loaded: expired.loaded, label: mapStore.view.label }),
+    )
+}
+
+// ================================================================ H 静态守卫:分页参数不上界面
+{
+    const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
+    const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
+
+    // 票 08 验收第 2 条:分页参数和快照参数由适配层管理。
+    // 界面(组件)不得自己拼 `page`/`pageSize`/`snapshotAt` —— 它们只出现在
+    // `stores/mapStore.js`(把装载器的查询参数接到领域接口的唯一一处)与其下的适配层。
+    const uiFiles = [
+        'CockpitShell.vue',
+        'components/MapCanvas.vue',
+        'components/CockpitPanel.vue',
+        'components/PanelPlaceholder.vue',
+        'components/StatusTag.vue',
+        'components/CockpitIcon.vue',
+        'components/SectionStateTag.vue',
+        'components/CockpitIntro.vue',
+    ]
+    const leaked = []
+    for (const file of uiFiles) {
+        const text = read(file)
+        // 只看真正的接口参数名(`pageSize`/`snapshotAt`),不误伤 `page` 这类普通词。
+        if (/pageSize|snapshotAt/.test(text)) leaked.push(file)
+    }
+    check('H1-pagination-params-not-in-ui', leaked.length === 0, leaked.length ? leaked.join(', ') : '界面文件里零分页/快照参数')
+
+    const storeText = read('stores/mapStore.js')
+    check(
+        'H2-adapter-owns-the-params',
+        /page:\s*query\.page/.test(storeText) && /pageSize:\s*query\.pageSize/.test(storeText) && /snapshotAt:\s*query\.snapshotAt/.test(storeText),
+        '分页与快照参数只在 mapStore 的适配翻译处拼',
+    )
+    check(
+        'H3-shell-says-what-not-how',
+        !/fetchMapSnapshot|loadNext\(/.test(read('CockpitShell.vue')),
+        '外壳只说「再取一批/刷新」,不直接翻页',
+    )
+}
+
+const failed = checks.filter((item) => !item.pass)
+console.log('')
+console.log(`T08 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((item) => item.name).join(', '))
+    process.exit(1)
+}
+console.log('T08 逻辑检查全部通过。')

+ 406 - 0
.scratch/cockpit-shell-panel-layout/verify/t09-point-status-walkthrough.mjs

@@ -0,0 +1,406 @@
+/**
+ * T09 真机走查:点位状态标记与图例在浏览器里确实成立(票 09)。
+ *
+ * 逻辑脚本(t09-point-status.mjs)已经把状态词表、标记计划与「图例=标记」判据测透了;
+ * 这里只补**只有在真机才成立**的部分:
+ *   1. 标记真的作为 Cesium billboard 画在地图上(引擎实体存在、有图片、形状不同);
+ *   2. 每种状态的颜色确实不同 —— 颜色是服务层从 token 的**计算样式**读出来再烘焙进 SVG 的,
+ *      纯逻辑断言证明不了 CSS 变量真的取到了值(取不到会全体回退兜底色,颜色就都一样了);
+ *   3. 地图图例与地图上的标记逐行一致(同一个 DOM 与同一批实体对照,不是两份 JS 数据对比);
+ *   4. 图例不只靠颜色:每行有形状(真 SVG 片段)+ 文字。
+ *
+ * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/;改了懒加载模块先重启服务):
+ *   node .scratch/cockpit-shell-panel-layout/verify/t09-point-status-walkthrough.mjs
+ *
+ * 演示开关(宿主全局键注入,不改代码重新构建):
+ *   `window.__COCKPIT_MOCK_SCENARIO__ = 'point-statuses'` —— 后端六态 + 未关联设备 + 一个前端不认识的状态
+ *   人工演示同一件事:控制台里 `__cockpitMap.getPointMarkers()` 看计划与图例、
+ *   `__cockpitMap.mapSummary.advance()` 一页页推进。
+ */
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
+
+const results = []
+/** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */
+function check(id, pass, evidence) {
+    results.push({ id, pass })
+    const rest = evidence === undefined ? '' : String(evidence)
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ::  ${rest}`)
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+const puppeteer = (await import(PUPPETEER)).default
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: join(tmpdir(), 'cockpit-t09-profile'),
+    defaultViewport: { width: 1680, height: 950 },
+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
+})
+console.log(`Chrome: ${await browser.version()}`)
+
+/**
+ * 一次读全:宿主 dataset(走查口径)+ 地图上的真实实体(`__cockpitMap.getPointMarkers()`)
+ * + 图例 DOM(形状/色键/文字)。
+ *
+ * 关键:**实体这一侧读的是 `readPointMarkers(viewer)` 从 Cesium 实体上取的值**,
+ * 图例那一侧读的是真实 DOM。两者对照才叫「图例与标记一致」;
+ * 若两边都读同一个 JS 对象,那条断言就恒真(自己跟自己比)。
+ */
+const readMarkers = (page) =>
+    page.evaluate(() => {
+        const host = document.querySelector('.cockpit-shell__map .map-canvas')
+        const legendRows = Array.from(document.querySelectorAll('[data-map-legend] .status-tag'))
+        const dev = window.__cockpitMap && window.__cockpitMap.getPointMarkers ? window.__cockpitMap.getPointMarkers() : null
+
+        // 语义色的实际取法(与 mapService.toneColors 同一条路):从外壳的计算样式读。
+        const shell = document.querySelector('.cockpit-shell')
+        const styles = shell ? getComputedStyle(shell) : null
+        const tone = (name) => (styles ? styles.getPropertyValue(`--${name}`).trim() : '')
+
+        return {
+            engine: (host && host.dataset.mapEngine) || '',
+            phase: (host && host.dataset.mapSummary) || '',
+            loaded: Number((host && host.dataset.mapSummaryLoaded) || 0),
+            // dataset 口径(服务层写的)
+            dsMarkers: Number((host && host.dataset.mapMarkers) || 0),
+            dsPlan: Number((host && host.dataset.mapMarkersPlan) || 0),
+            dsStatus: (host && host.dataset.mapMarkersStatus) || '',
+            dsShapes: (host && host.dataset.mapMarkerShapes) || '',
+            // 真实实体口径(引擎里真有什么)
+            entities: dev ? dev.onMap : -1,
+            entityByStatus: dev ? dev.entityByStatus : {},
+            entityShapes: dev ? dev.shapes : [],
+            // 计划口径(图例的计数来自这里,与实体数必须相等)
+            planLegend: dev ? dev.legend.filter((row) => row.count > 0).map((row) => `${row.key}:${row.count}`) : [],
+            // 图例 DOM
+            legend: legendRows.map((el) => ({
+                key: el.dataset.legendKey || '',
+                tone: el.dataset.tone || '',
+                shape: el.dataset.shape || '',
+                label: (el.querySelector('.status-tag__label') || {}).textContent?.trim() || '',
+                hasShapeSvg: !!el.querySelector('.status-tag__shape'),
+                shapeMarkup: (el.querySelector('.status-tag__shape') || {}).innerHTML || '',
+                color: getComputedStyle(el.querySelector('.status-tag__shape') || el).color,
+            })),
+            legendCount: legendRows.length,
+            legendHosts: document.querySelectorAll('[data-map-legend]').length,
+            // 事件四级图例不该出现在地图上(票 09:不把事件等级色混用为点位状态色)
+            eventLevelTags: Array.from(document.querySelectorAll('[data-map-legend] .status-tag')).filter((el) =>
+                /^[1-4]级$/.test(((el.querySelector('.status-tag__label') || {}).textContent || '').trim()),
+            ).length,
+            // token 的实际值(证明颜色不是兜底常量)
+            tokens: { lv1: tone('lv1'), lv2: tone('lv2'), lv3: tone('lv3'), lv4: tone('lv4'), neutral: tone('neutral') },
+            // 每行的实际颜色算出来的 CSS 值,供「颜色各不相同」判定
+            markerImages: dev && dev.markerImages ? dev.markerImages : {},
+            engineStatusPill: (document.querySelector('[data-map-engine-status]') || {}).textContent || '',
+            mapHosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length,
+        }
+    })
+
+/** 打开页面并把地图摘要翻到底(mock 的状态样例:7 点 + 1 设备 = 8 个对象)。 */
+async function openPage(inject) {
+    const page = await browser.newPage()
+    const errors = []
+    page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
+    page.on('console', (m) => {
+        if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
+    })
+
+    await page.evaluateOnNewDocument(() => {
+        // 引导层会盖住页面,走查不需要它(票 30 的开关)。
+        window.__COCKPIT_CONFIG__ = { dataSource: 'mock', map: { intro: { enabled: false } } }
+    })
+    if (inject) await page.evaluateOnNewDocument(inject)
+
+    await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+    await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+    // 引擎要拉静态资源/地形,比取数慢:等它落到终态,否则「标记没画上」会被误判成失败。
+    await page
+        .waitForFunction(
+            () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
+            { timeout: 40000 },
+        )
+        .catch(() => {})
+    await sleep(600)
+    return { page, errors }
+}
+
+/** 点「加载下一页」直到翻完(mock 按页大小 2 分 4 页)。 */
+async function advanceUntilSettled(page, maxSteps = 12) {    for (let step = 0; step < maxSteps; step += 1) {
+        const state = await readMarkers(page)
+        if (['complete', 'empty', 'failed', 'expired'].includes(state.phase)) break
+        const clicked = await page.evaluate(() => {
+            const button = document.querySelector('[data-map-summary-action]')
+            if (!button || button.disabled) return false
+            button.click()
+            return true
+        })
+        if (!clicked) break
+        const before = state.loaded
+        await page
+            .waitForFunction(
+                (previous) => {
+                    const host = document.querySelector('.cockpit-shell__map .map-canvas')
+                    const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
+                    const phase = (host && host.dataset.mapSummary) || ''
+                    return loaded !== previous || ['failed', 'expired', 'empty', 'complete'].includes(phase)
+                },
+                { timeout: 10000 },
+                before,
+            )
+            .catch(() => {})
+        await sleep(350)
+    }
+    return readMarkers(page)
+}
+
+try {
+    // ---------- A 标记真的画在地图上,且按后端状态渲染 ----------
+    {
+        const { page, errors } = await openPage(() => {
+            window.__COCKPIT_MOCK_SCENARIO__ = 'point-statuses'
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
+        })
+        const settled = await advanceUntilSettled(page)
+
+        check(
+            'A1-engine-ready',
+            ['ready', 'disabled'].includes(settled.engine),
+            `engine=${settled.engine} · 浮标「${settled.engineStatusPill.slice(0, 60)}」`,
+        )
+        // 6 个状态点位 + 1 个未知状态点位 + 1 个未关联设备 = 8 个可上图对象。
+        check(
+            'A2-markers-rendered-as-entities',
+            settled.entities === 8 && settled.dsMarkers === 8 && settled.dsPlan === 8,
+            `真实实体 ${settled.entities} · dataset ${settled.dsMarkers} · 计划 ${settled.dsPlan}(加载 ${settled.loaded} 个)`,
+        )
+        check(
+            'A3-backend-six-statuses-present',
+            ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].every((key) => settled.entityByStatus[key] === 1),
+            JSON.stringify(settled.entityByStatus),
+        )
+        check(
+            'A4-unknown-status-not-guessed',
+            settled.entityByStatus.UNKNOWN === 1,
+            `后端新增的 MAINTENANCE 落「状态未发布」而不是任何真实状态:${JSON.stringify(settled.entityByStatus)}`,
+        )
+        check(
+            'A5-unassociated-separate-row',
+            settled.entityByStatus.UNASSOCIATED === 1,
+            '未关联设备自成一行(不借用在线态)',
+        )
+        check('A6-single-map-host', settled.mapHosts === 1, `地图宿主 ${settled.mapHosts} 个`)
+        check('A7-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+
+        // ---------- B 形状:不只靠颜色 ----------
+        const sixShapes = ['triangle', 'diamond', 'square', 'circle', 'ring', 'hollowSquare']
+        check(
+            'B1-eight-distinct-shapes-on-map',
+            sixShapes.every((shape) => settled.entityShapes.includes(shape)) &&
+                settled.entityShapes.includes('hollowDiamond') &&
+                settled.entityShapes.includes('cross') &&
+                settled.entityShapes.length === 8,
+            `地图上 ${settled.entityShapes.length} 种形状:${settled.entityShapes.join(', ')}`,
+        )
+        check(
+            'B2-dataset-exposes-shapes',
+            settled.dsShapes.split(',').filter(Boolean).length === 8,
+            `dataset shapes = ${settled.dsShapes}`,
+        )
+
+        // ---------- C 颜色真的从 token 读出来、且各状态不同 ----------
+        //
+        // 注意这里**必须把颜色单独抠出来比**,不能只比整张图片是否不同:
+        // 图片里还含形状,形状本来就各不相同 —— 那样写的话即使所有状态用同一个颜色
+        // (比如 token 没读到、全体回退兜底色),断言照样绿。第一版正是这么写的,
+        // 对抗式复验(把 toneColors 改成恒返回一个色)抓到它假绿 25/25。
+        const imageOf = (key) => (settled.markerImages[key] || {}).image || ''
+        const colorOf = (key) => {
+            const image = imageOf(key)
+            if (!image) return ''
+            const decoded = decodeURIComponent(image)
+            const match = decoded.match(/color="([^"]+)"/)
+            return match ? match[1] : ''
+        }
+        check(
+            'C1-tokens-resolved-from-css',
+            Object.values(settled.tokens).every((value) => /^#|rgb/.test(value)),
+            JSON.stringify(settled.tokens),
+        )
+
+        const colorsByStatus = {
+            ALARM: colorOf('ALARM'),
+            WARNING: colorOf('WARNING'),
+            WORK_ORDER: colorOf('WORK_ORDER'),
+            NORMAL: colorOf('NORMAL'),
+            OFFLINE: colorOf('OFFLINE'),
+        }
+        const distinctColors = new Set(Object.values(colorsByStatus).filter(Boolean))
+        check(
+            'C2-marker-colors-differ-per-status',
+            distinctColors.size === 5 && Object.values(colorsByStatus).every(Boolean),
+            `5 个状态 → ${distinctColors.size} 种颜色:${Object.entries(colorsByStatus).map(([k, v]) => `${k}=${v}`).join(' ')}`,
+        )
+        // 颜色必须**等于 token 的实测值**(而不只是"彼此不同"):证明是读出来的,不是常量。
+        const colorMismatch = Object.entries(colorsByStatus).filter(([key, color]) => {
+            const toneKey = { ALARM: 'lv1', WARNING: 'lv2', WORK_ORDER: 'lv3', NORMAL: 'lv4', OFFLINE: 'neutral' }[key]
+            return color.toLowerCase() !== settled.tokens[toneKey].toLowerCase()
+        })
+        check(
+            'C3-marker-color-equals-token',
+            colorMismatch.length === 0 && /^data:image\/svg\+xml/.test(imageOf('ALARM')),
+            colorMismatch.length
+                ? colorMismatch.map(([k, v]) => `${k} 图片是 ${v}`).join(' | ')
+                : `五个状态的标记颜色逐个等于 .cockpit-shell 上 --lv1/--lv2/--lv3/--lv4/--neutral 的实测值`,
+        )
+
+        // ---------- D 图例与标记一致(DOM 与真实实体对照)----------
+        check('D1-legend-rendered-once', settled.legendHosts === 1, `图例宿主 ${settled.legendHosts} 个`)
+        check('D2-legend-lists-eight-rows', settled.legendCount === 8, `图例 ${settled.legendCount} 行(把八种含义都列出来)`)
+
+        const legendByKey = new Map(settled.legend.map((row) => [row.key, row]))
+        const entityMismatch = Object.entries(settled.entityByStatus).filter(([key, count]) => {
+            const row = legendByKey.get(key)
+            if (!row) return true
+            // 图例文字形如「报警 1」;解析出计数与实体数对照。
+            const shown = Number((row.label.match(/(\d+)\s*$/) || [])[1])
+            return shown !== count
+        })
+        check(
+            'D3-legend-counts-equal-entities',
+            entityMismatch.length === 0,
+            entityMismatch.length ? JSON.stringify(entityMismatch) : `图例计数与地图实体逐个相等:${settled.planLegend.join(' · ')}`,
+        )
+
+        // 形状与色键必须逐行对上(图例那一行画出来的形状,与地图上该状态的标记形状同名)。
+        const shapeMismatch = settled.legend
+            .filter((row) => {
+                const image = settled.markerImages[row.key]
+                return image && image.shape !== row.shape
+            })
+            .map((row) => `${row.key}: 图例 ${row.shape} vs 标记 ${settled.markerImages[row.key].shape}`)
+        check('D4-legend-shape-matches-marker', shapeMismatch.length === 0, shapeMismatch.length ? shapeMismatch.join(' | ') : '八行形状逐个对上地图标记')
+
+        const toneMismatch = settled.legend
+            .filter((row) => {
+                const image = settled.markerImages[row.key]
+                return image && image.tone !== row.tone
+            })
+            .map((row) => `${row.key}: 图例 ${row.tone} vs 标记 ${settled.markerImages[row.key].tone}`)
+        check('D5-legend-tone-matches-marker', toneMismatch.length === 0, toneMismatch.length ? toneMismatch.join(' | ') : '八行色键逐个对上地图标记')
+
+        // 每行必须同时有形状 SVG 与文字(验收第 3 条)。
+        const lacking = settled.legend.filter((row) => !row.hasShapeSvg || !row.label)
+        check(
+            'D6-every-legend-row-has-shape-and-text',
+            lacking.length === 0,
+            lacking.length ? lacking.map((row) => row.key).join(', ') : settled.legend.map((row) => `${row.label}[${row.shape}]`).join(' '),
+        )
+        // 形状是真的画出来的(内联 SVG 片段非空),不是一个空元素占位。
+        const emptyShape = settled.legend.filter((row) => row.shapeMarkup.trim().length < 10)
+        check('D7-shape-markup-not-empty', emptyShape.length === 0, emptyShape.length ? emptyShape.map((r) => r.key).join(', ') : '每行形状都有真实 SVG 片段')
+        // 图例那一行的实际渲染颜色必须各不相同(颜色不是唯一线索,但仍要是对的)。
+        const legendColors = new Set(settled.legend.filter((row) => row.tone !== 'neutral').map((row) => row.color))
+        check(
+            'D8-legend-colors-distinct',
+            legendColors.size === 4,
+            `四个语义色在页面上渲染成 ${legendColors.size} 种颜色:${[...legendColors].join(', ')}`,
+        )
+
+        // ---------- E 不与事件等级混用 ----------
+        check(
+            'E1-no-event-level-legend-on-map',
+            settled.eventLevelTags === 0,
+            '地图图例里没有「1级..4级」事件等级行(票 09:不把事件等级色混用为点位状态色)',
+        )
+
+        console.log(`\n图例快照:${settled.legend.map((row) => `${row.label}(${row.tone}/${row.shape})`).join(' | ')}`)
+        console.log(`状态分布:${settled.dsStatus}`)
+        await page.close()
+    }
+
+    // ---------- F 默认样例不受影响(票 07/08 的证据不能被本票改动)----------
+    {
+        const { page, errors } = await openPage(() => {
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
+        })
+        const settled = await advanceUntilSettled(page)
+        // 默认样例是 3 点 + 2 设备 = 5 个对象、其中 2 个可上图(票 07/08 原样)。
+        check(
+            'F1-default-sample-unchanged',
+            settled.dsPlan === 2 && settled.entities === 2 && settled.loaded === 5,
+            `默认样例仍为 计划 ${settled.dsPlan} · 实体 ${settled.entities} · 已加载 ${settled.loaded}(票 07/08 的数)`,
+        )
+        check('F2-default-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        await page.close()
+    }
+
+    // ---------- G 空快照:图例在位、标记为 0(不是「假绿」而是真的没有)----------
+    {
+        const { page, errors } = await openPage(() => {
+            window.__COCKPIT_MOCK_SCENARIO__ = 'empty'
+            window.__COCKPIT_MOCK_ONLY__ = '/map-snapshot'
+        })
+        await sleep(900)
+        const state = await readMarkers(page)
+        check(
+            'G1-empty-snapshot-zero-markers',
+            state.dsMarkers === 0 && state.entities === 0 && state.legendCount === 8,
+            `标记 ${state.dsMarkers} · 实体 ${state.entities} · 图例仍 ${state.legendCount} 行(形状含义可查)`,
+        )
+        check('G2-empty-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        await page.close()
+    }
+    // ---------- H 首屏自动补画:不点任何按钮,标记也必须出现在地图上 ----------
+    //
+    // 这条是被**视觉探针**(截图里图例有计数、状态行却写「点位标记 0」)逼出来的:
+    // 引擎冷启动比装载器慢得多,第一版的重试封顶 10 秒,超时后第一批点位就再也没画上,
+    // 只有用户去点「加载下一页」才会因对象变化重新触发。走查当时没抓到 —— 因为它一路在点分页。
+    // 所以这里**一次都不点**:只等引擎 ready,然后要求标记自己出现。
+    {
+        const { page, errors } = await openPage(() => {
+            window.__COCKPIT_MOCK_SCENARIO__ = 'point-statuses'
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
+        })
+        // 只等"标记数 > 0",不点任何按钮;等不到就是没补画。
+        const painted = await page
+            .waitForFunction(
+                () => {
+                    const host = document.querySelector('.cockpit-shell__map .map-canvas')
+                    return Number((host && host.dataset.mapMarkers) || 0) > 0
+                },
+                { timeout: 30000 },
+            )
+            .then(() => true)
+            .catch(() => false)
+        const state = await readMarkers(page)
+        check(
+            'H1-first-batch-auto-painted',
+            painted && state.entities > 0 && state.dsMarkers === state.dsPlan,
+            `未点任何按钮:实体 ${state.entities} · dataset ${state.dsMarkers} · 计划 ${state.dsPlan}(引擎 ${state.engine})`,
+        )
+        check(
+            'H2-first-batch-legend-consistent',
+            state.legendCount === 8 && Object.entries(state.entityByStatus).every(([key, count]) => {
+                const row = state.legend.find((item) => item.key === key)
+                return row && Number((row.label.match(/(\d+)\s*$/) || [])[1]) === count
+            }),
+            `首屏图例 ${state.legendCount} 行,与实体 ${JSON.stringify(state.entityByStatus)} 逐个对上`,
+        )
+        check('H3-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        await page.close()
+    }
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((item) => !item.pass)
+console.log(`\nT09 走查:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`)
+process.exit(failed.length ? 1 : 0)

+ 396 - 0
.scratch/cockpit-shell-panel-layout/verify/t09-point-status.mjs

@@ -0,0 +1,396 @@
+/**
+ * T09 点位状态图例与标记 纯逻辑检查(票 09)。
+ *
+ * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
+ * 运行:node .scratch/cockpit-shell-panel-layout/verify/t09-point-status.mjs
+ *
+ * 六段:
+ *   A 状态词表:后端 `CockpitMonitoringPointStatus` 六态 + 未关联设备 + 认不出的状态;
+ *      每个词条必须同时有**形状 + 色键 + 文字**(验收第 3 条:不只靠颜色)。
+ *   B 认不出的状态不得被猜成正常(前端不重算点位状态)。
+ *   C 标记计划:只收坐标准入通过的对象,被拦下的仍留在 `blocked`(不上图 ≠ 不存在)。
+ *   D 图例与标记一致(验收第 4 条):同一份 `byStatus` 生成,计数与形状/色键逐个对齐;
+ *      并**故意注入不一致**,证明这条判据真的会红(不然它就是恒真的假绿)。
+ *   E 标记图片:形状 + 颜色烘焙进 SVG;色值由调用方传(本文件零硬编码颜色)。
+ *   F 静态断言:不把事件等级色混用为点位状态色(票 09 正文的硬约束)。
+ *
+ * 状态与中文标签的依据是**后端源码**而不是猜:
+ * `E:\pipe-ner-server\...\cockpit\point\CockpitMonitoringPointStatus.java`
+ * (ALARM 报警 1 / WARNING 预警 2 / WORK_ORDER 工单处置 3 / NORMAL 正常 4 / OFFLINE 离线 5 / EMPTY 无设备 6)。
+ * 本脚本不复读该文件(跨仓库路径不是本仓库的契约),所以把六个取值与优先级写死在断言里 ——
+ * 后端改词表时这里会红,那正是它该提醒的事。
+ */
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import {
+    POINT_STATUS,
+    POINT_STATUS_VISUALS,
+    SHAPES,
+    UNASSOCIATED_VISUAL,
+    UNKNOWN_VISUAL,
+    isKnownShape,
+    legendMatchesMarkers,
+    legendVisuals,
+    markerSvg,
+    planPointMarkers,
+    pointLegend,
+    pointVisual,
+    shapeSvg,
+    summarizeMarkerStatuses,
+    visualForStatus,
+} from '../../../src/modules/cockpit/domain/pointStatus.js'
+import { TONES, isKnownTone } from '../../../src/modules/cockpit/domain/statusTones.js'
+
+const checks = []
+function check(name, pass, evidence) {
+    checks.push({ name, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${name}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+/** 造一个地图对象(形状照抄 `domain/mapSummary.js` 的领域对象)。 */
+const object = (code, status, extras = {}) => ({
+    kind: 'monitoring-point',
+    code,
+    name: `${code} 点位`,
+    status,
+    statusReasons: [],
+    longitude: 110.141,
+    latitude: 28.452,
+    coordinate: { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
+    ...extras,
+})
+
+/** 造一个不上图的对象(renderable=false)。 */
+const blockedObject = (code, status, reason = 'ZERO_COORDINATE') => ({
+    ...object(code, status),
+    longitude: 0,
+    latitude: 0,
+    coordinate: { state: 'INVALID', reason, crs: 'WGS84', renderable: false, validated: true },
+})
+
+// ================================================================ A 状态词表
+
+const BACKEND_STATUSES = ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY']
+/** 后端 `CockpitMonitoringPointStatus` 的中文标签,逐字对照。 */
+const BACKEND_LABELS = {
+    ALARM: '报警',
+    WARNING: '预警',
+    WORK_ORDER: '工单处置',
+    NORMAL: '正常',
+    OFFLINE: '离线',
+    EMPTY: '无设备',
+}
+
+{
+    check(
+        'A1-six-backend-statuses',
+        BACKEND_STATUSES.every((key) => POINT_STATUS[key] === key && POINT_STATUS_VISUALS[key]),
+        `六态 ${BACKEND_STATUSES.join(', ')} 都有词条`,
+    )
+
+    const mismatched = BACKEND_STATUSES.filter((key) => POINT_STATUS_VISUALS[key].label !== BACKEND_LABELS[key])
+    check('A2-labels-match-backend', mismatched.length === 0, mismatched.length ? `不符:${mismatched.join(', ')}` : '中文标签与后端枚举逐字一致')
+
+    // 图例按后端优先级排序:ALARM 最先。前端不自己定序。
+    const priorities = BACKEND_STATUSES.map((key) => POINT_STATUS_VISUALS[key].priority)
+    check(
+        'A3-priority-from-backend',
+        priorities.join(',') === '1,2,3,4,5,6' &&
+            POINT_STATUS_VISUALS.ALARM.priority === 1 &&
+            POINT_STATUS_VISUALS.EMPTY.priority === 6,
+        `优先级 ${priorities.join(',')}`,
+    )
+
+    // 验收第 3 条:每种颜色都有文字或形状辅助。
+    const lacking = legendVisuals().filter(
+        (visual) => !visual.label || !visual.shape || !isKnownShape(visual.shape) || !isKnownTone(visual.tone),
+    )
+    check(
+        'A4-every-visual-has-shape-tone-label',
+        lacking.length === 0,
+        lacking.length ? `缺件:${lacking.map((v) => v.key).join(', ')}` : `${legendVisuals().length} 条词条都是 形状+色键+文字`,
+    )
+
+    // 形状必须两两不同:形状是「不只靠颜色」里真正与颜色无关的那一半。
+    const shapes = legendVisuals().map((visual) => visual.shape)
+    check(
+        'A5-shapes-pairwise-distinct',
+        new Set(shapes).size === shapes.length,
+        `${shapes.length} 条词条用到 ${new Set(shapes).size} 种形状:${shapes.join(', ')}`,
+    )
+
+    // 同一个色键下的多个状态靠形状区分(OFFLINE / EMPTY / UNKNOWN / UNASSOCIATED 都是中性灰)。
+    const neutralShapes = legendVisuals()
+        .filter((visual) => visual.tone === 'neutral')
+        .map((visual) => visual.shape)
+    check(
+        'A6-neutral-group-split-by-shape',
+        neutralShapes.length >= 2 && new Set(neutralShapes).size === neutralShapes.length,
+        `中性灰 ${neutralShapes.length} 个状态用 ${new Set(neutralShapes).size} 种形状`,
+    )
+
+    const badTones = legendVisuals().filter((visual) => !TONES.includes(visual.tone))
+    check('A7-tones-are-known-keys', badTones.length === 0, badTones.length ? badTones.map((v) => v.tone).join(', ') : `色键都取自 ${TONES.join('/')}`)
+
+    check('A8-legend-covers-eight-rows', legendVisuals().length === 8, `图例 ${legendVisuals().length} 行(六态 + 状态未发布 + 无关联设备)`)
+
+    check(
+        'A9-unknown-shape-safe',
+        isKnownShape('nope') === false && shapeSvg('nope') === SHAPES.circle,
+        '认不出的形状退回实心圆,不抛错也不留空',
+    )
+}
+
+// ================================================================ B 前端不重算
+
+{
+    check(
+        'B1-unknown-status-not-guessed-as-normal',
+        visualForStatus('MAINTENANCE').key === UNKNOWN_VISUAL.key &&
+            visualForStatus('MAINTENANCE').key !== POINT_STATUS.NORMAL &&
+            visualForStatus('MAINTENANCE').label !== BACKEND_LABELS.NORMAL,
+        `MAINTENANCE → ${visualForStatus('MAINTENANCE').key}「${visualForStatus('MAINTENANCE').label}」`,
+    )
+    check(
+        'B2-missing-status-not-guessed',
+        visualForStatus('').key === UNKNOWN_VISUAL.key && visualForStatus(undefined).key === UNKNOWN_VISUAL.key,
+        '没给状态时如实说「状态未发布」,不落成正常',
+    )
+    check(
+        'B3-lowercase-not-coerced',
+        visualForStatus('normal').key === UNKNOWN_VISUAL.key,
+        '后端发的是大写枚举;小写不当成同一个状态(不猜、不归一化)',
+    )
+    check(
+        'B4-unassociated-is-its-own-row',
+        pointVisual({ kind: 'unassociated-equipment', status: '' }).key === UNASSOCIATED_VISUAL.key &&
+            pointVisual({ kind: 'unassociated-equipment', status: 'NORMAL' }).key === UNASSOCIATED_VISUAL.key,
+        '未关联设备不借用在线态(票 05 同一立场:0/NORMAL 都算伪造)',
+    )
+    check(
+        'B5-status-passed-through-not-derived',
+        pointVisual(object('MP-1', 'ALARM')).key === 'ALARM' && pointVisual(object('MP-2', 'OFFLINE')).key === 'OFFLINE',
+        '状态原样取自后端字段,前端不做任何推导',
+    )
+}
+
+// ================================================================ C 标记计划
+
+{
+    const objects = [
+        object('MP-1', 'ALARM'),
+        object('MP-2', 'NORMAL'),
+        object('MP-3', 'WARNING'),
+        { ...object('EQ-1', '', { kind: 'unassociated-equipment' }) },
+        blockedObject('MP-4', 'ALARM'),
+        blockedObject('EQ-2', '', { kind: 'unassociated-equipment' }),
+    ]
+    const plan = planPointMarkers(objects)
+
+    check(
+        'C1-only-renderable-becomes-marker',
+        plan.total === 4 && plan.markers.length === 4 && plan.blocked.length === 2,
+        `标记 ${plan.total} · 拦下 ${plan.blocked.length}(共 ${objects.length} 个对象)`,
+    )
+    check(
+        'C2-blocked-kept-not-dropped',
+        plan.blocked.map((item) => item.code).join(',') === 'MP-4,EQ-2',
+        `拦下的仍在 blocked 里:${plan.blocked.map((item) => item.code).join(', ')}(不上图 ≠ 不存在)`,
+    )
+    check(
+        'C3-marker-carries-backend-status',
+        plan.markers.every((marker) => marker.status === (objects.find((o) => o.code === marker.code) || {}).status) &&
+            plan.markers.find((m) => m.code === 'MP-1').status === 'ALARM',
+        '标记带后端原始状态串,供排查与后续票消费',
+    )
+    check(
+        'C4-by-status-counts',
+        plan.byStatus.ALARM === 1 && plan.byStatus.NORMAL === 1 && plan.byStatus.WARNING === 1 && plan.byStatus.UNASSOCIATED === 1,
+        JSON.stringify(plan.byStatus),
+    )
+    check(
+        'C5-marker-visual-matches-table',
+        plan.markers.every((marker) => {
+            const visual = legendVisuals().find((item) => item.key === marker.visualKey)
+            return visual && visual.tone === marker.tone && visual.shape === marker.shape && visual.label === marker.label
+        }),
+        '每个标记的色键/形状/文字都能在词表里逐字对上(同一张表)',
+    )
+    check(
+        'C6-coordinate-criteria-not-reinvented',
+        planPointMarkers(null).total === 0 && planPointMarkers(undefined).markers.length === 0 && planPointMarkers([]).byStatus && Object.keys(planPointMarkers([]).byStatus).length === 0,
+        '空输入安全,且准入只走 domain/coordinate.js 的 isRenderable()',
+    )
+
+    const unknownObject = object('MP-9', 'MAINTENANCE')
+    const withUnknown = planPointMarkers([unknownObject])
+    check(
+        'C7-unknown-status-becomes-own-row',
+        withUnknown.byStatus.UNKNOWN === 1 && withUnknown.markers[0].shape === UNKNOWN_VISUAL.shape,
+        `认不出的状态自成一行,不落到真实状态上(${JSON.stringify(withUnknown.byStatus)})`,
+    )
+}
+
+// ================================================================ D 图例与标记一致
+
+{
+    const objects = [
+        object('MP-1', 'ALARM'),
+        object('MP-2', 'ALARM'),
+        object('MP-3', 'WORK_ORDER'),
+        object('MP-4', 'OFFLINE'),
+        object('MP-5', 'EMPTY'),
+        blockedObject('MP-6', 'NORMAL'),
+    ]
+    const plan = planPointMarkers(objects)
+    const legend = pointLegend(plan.byStatus)
+    const verdict = legendMatchesMarkers(legend, plan.markers)
+
+    check('D1-legend-matches-markers', verdict.ok, verdict.problems.length ? verdict.problems.join(' | ') : '图例与标记逐行一致')
+    check(
+        'D2-legend-counts-equal-markers',
+        legend.reduce((sum, row) => sum + row.count, 0) === plan.markers.length && legend.length === 8,
+        `图例合计 ${legend.reduce((sum, row) => sum + row.count, 0)} = 标记 ${plan.markers.length}`,
+    )
+    check(
+        'D3-legend-row-for-every-used-status',
+        ['ALARM', 'WORK_ORDER', 'OFFLINE', 'EMPTY'].every((key) => legend.find((row) => row.key === key).count === plan.byStatus[key]),
+        legend
+            .filter((row) => row.count > 0)
+            .map((row) => `${row.label} ${row.count}`)
+            .join(' · '),
+    )
+    check(
+        'D4-zero-count-rows-still-listed',
+        legend.find((row) => row.key === 'WARNING').count === 0 && legend.find((row) => row.key === 'WARNING').shape === 'diamond',
+        '没出现的状态也留在图例里(形状的含义要能查到),只是计数为 0',
+    )
+
+    // 对抗式:故意把图例改坏,判据必须变红 —— 否则 D1 就是恒真的假绿。
+    const wrongCount = legend.map((row) => (row.key === 'ALARM' ? { ...row, count: 99 } : row))
+    const wrongShape = legend.map((row) => (row.key === 'ALARM' ? { ...row, shape: 'circle' } : row))
+    const missingRow = legend.filter((row) => row.key !== 'ALARM')
+    const verdicts = [wrongCount, wrongShape, missingRow].map((broken) => legendMatchesMarkers(broken, plan.markers))
+    check(
+        'D5-inconsistency-detected',
+        verdicts.every((item) => item.ok === false),
+        `计数错/形状错/缺行 三类篡改都被抓到:${verdicts.map((item) => item.problems.length).join(', ')} 条问题`,
+    )
+
+    check(
+        'D6-summary-matches-plan',
+        JSON.stringify(summarizeMarkerStatuses(plan.markers)) === JSON.stringify(plan.byStatus),
+        'byStatus 就是按标记算出来的,不是另算一份',
+    )
+}
+
+// ================================================================ E 标记图片
+
+{
+    const red = markerSvg('triangle', '#ff4d5e')
+    const blue = markerSvg('triangle', '#4fd8ff')
+    const circle = markerSvg('circle', '#ff4d5e')
+
+    check('E1-svg-data-url', red.startsWith('data:image/svg+xml;charset=utf-8,'), red.slice(0, 40))
+    const decoded = decodeURIComponent(red.slice('data:image/svg+xml;charset=utf-8,'.length))
+    check(
+        'E2-color-baked-in',
+        decoded.includes('#ff4d5e') && decoded.includes('viewBox="0 0 12 12"'),
+        '颜色烘焙进图片(billboard 读不到页面 CSS 变量,必须由服务层传色进来)',
+    )
+    check('E3-color-is-parameter', red !== blue && red !== circle, '换颜色/换形状得到不同图片')
+    check('E4-shape-markup-included', decoded.includes('<path d="M6 1.5 11.2 10.5H0.8z"/>'), '形状片段来自 SHAPES,不另画一套')
+}
+
+// ================================================================ F 不混用事件等级色
+
+{
+    const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
+    const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
+    /**
+     * 去掉注释只留代码。
+     *
+     * 为什么必须这样做:本文件的注释**故意**提到 `LEVEL_TAGS` 与 `#ff4d5e`
+     * (解释「事件四级图例归票 13/19」「颜色由调用方传」),直接对全文做正则会把
+     * 「讲解」判成「违规」——那就成了拿假阳性换通过率。断言只该管代码。
+     */
+    const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
+
+    const pointStatusCode = code(read('domain/pointStatus.js'))
+    const mapCanvasCode = code(read('components/MapCanvas.vue'))
+    const engineCode = code(read('services/cesiumEngine.js'))
+
+    // 事件四级的词汇(1级..4级)与 LEVEL_TAGS 属于票 13/19 的图例,不得进入点位状态词表。
+    check(
+        'F1-no-event-level-words-in-point-status',
+        !/[1-4]级/.test(pointStatusCode) &&
+            !/LEVEL_TAGS/.test(pointStatusCode) &&
+            !/from '\.\/statusTones\.js'/.test(pointStatusCode),
+        'pointStatus.js 的代码里没有事件等级词汇,也没引用 LEVEL_TAGS',
+    )
+    check(
+        'F2-map-legend-is-point-status-not-event-level',
+        !/LEVEL_TAGS/.test(mapCanvasCode) &&
+            /getPointMarkerState|legendRows/.test(mapCanvasCode) &&
+            /data-map-legend/.test(mapCanvasCode),
+        '地图图例改成点位状态图例(不再是事件四级),带 data-map-legend 供走查断言',
+    )
+
+    // 票 06 的单一口径:语义色只在 styles/tokens.css 声明。这里断言 domain 层
+    // **不出现调色板的值、也不声明这些变量** —— 白色兜底不是调色板成员,故不在禁令内。
+    const PALETTE = {
+        '--lv1': '#ff4d5e',
+        '--lv2': '#ffa63d',
+        '--lv3': '#4fd8ff',
+        '--lv4': '#35e39a',
+        '--neutral': '#6d92ae',
+    }
+    const offenders = []
+    for (const [file, text] of [
+        ['domain/pointStatus.js', pointStatusCode],
+        ['components/StatusTag.vue', code(read('components/StatusTag.vue'))],
+    ]) {
+        for (const [name, value] of Object.entries(PALETTE)) {
+            // 允许 `var(--lv1, #ff4d5e)` 这种「读 token、带同值兜底」的写法(票 06 既有约定),
+            // 只禁止**另起一份**调色板:裸色值出现且不在 var() 兜底位里。
+            const bare = new RegExp(`(?<!var\\([^)]{0,40})${value.replace('#', '#')}`, 'i')
+            if (bare.test(text) && !new RegExp(`var\\(${name},\\s*${value}`, 'i').test(text)) {
+                offenders.push(`${file} 出现调色板裸色值 ${value}(${name})`)
+            }
+            if (new RegExp(`${name}\\s*:`).test(text)) offenders.push(`${file} 重复声明 ${name}`)
+        }
+    }
+    check('F3-no-second-palette-declaration', offenders.length === 0, offenders.length ? offenders.join(' | ') : 'domain/组件层没有第二份语义色调色板')
+    check(
+        'F3b-domain-has-no-palette-at-all',
+        !Object.values(PALETTE).some((value) => pointStatusCode.includes(value)),
+        'pointStatus.js 的代码里连调色板色值都不出现(颜色全靠调用方传)',
+    )
+
+    // 服务层读 token 而不是写第二份色表。
+    const mapServiceCode = code(read('services/mapService.js'))
+    check(
+        'F4-colors-read-from-tokens',
+        /getComputedStyle/.test(mapServiceCode) && /--\$\{tone\}/.test(mapServiceCode),
+        '地图标记的颜色从 .cockpit-shell 的计算样式读 --lv1..--neutral',
+    )
+    check(
+        'F4b-engine-and-domain-never-read-css',
+        !/getComputedStyle/.test(engineCode) && !/getComputedStyle/.test(pointStatusCode),
+        '读样式只发生在服务层一处(引擎与 domain 都不碰 DOM)',
+    )
+    // 引擎层不许认识业务状态词(状态→视觉的映射只有 domain 一份)。
+    check(
+        'F5-engine-knows-shape-not-status',
+        !/ALARM|WORK_ORDER|报警/.test(engineCode) && /marker\.shape/.test(engineCode) && /marker\.tone/.test(engineCode),
+        'cesiumEngine 只认 shape/tone,不认识业务状态名',
+    )
+}
+
+const failed = checks.filter((item) => !item.pass)
+console.log('')
+console.log(`T09 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((item) => item.name).join(', '))
+    process.exit(1)
+}

+ 449 - 0
.scratch/cockpit-shell-panel-layout/verify/t10-point-detail-walkthrough.mjs

@@ -0,0 +1,449 @@
+/**
+ * T10 真机走查:点地图上的监测点 → 点位摘要 + 设备卡片 → 选卡片进设备详情(票 10)。
+ *
+ * 逻辑脚本(t10-point-detail.mjs)已经把两级状态机、摘要/卡片视图、store 取数与竞态、
+ * 「关联设备不重复上图」的守卫测透了;这里只补**只有在真机才成立**的部分:
+ *   1. 地图上的点位标记**真的能被鼠标拾到**(pointerdown/up → Cesium 拾取 → 打开浮层整条路);
+ *   2. 拖拽地图**不会**误触发点选(票 03 的左键平移手感不能被新功能打坏);
+ *   3. 点位摘要五要素真的渲染出来,且状态标签与地图标记同源(同形状同色键同文字);
+ *   4. 设备卡片顺序 = 后端返回顺序(样例刻意逆状态优先级,界面一排序就露馅);
+ *   5. 选卡片进第二级、返回第一级、关闭浮层;打开详情不改动地图上的标记数量。
+ *
+ * 运行(需先起 `yarn dev`;本机 dev 端口见启动输出,默认 8000/8001):
+ *   node .scratch/cockpit-shell-panel-layout/verify/t10-point-detail-walkthrough.mjs
+ *   COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
+ *
+ * 两个只有真机才踩得到的坑(都写进了下面的实现,替换成"直接点屏幕中心"就会假失败):
+ *   · 相机落位是**阻尼飞行**(跨县要 1~2 秒),飞行途中中心还在半路上 —— 必须等 `shownPivot`
+ *     贴到目标再点,固定 sleep 会点在飞行路径上;
+ *   · 贴地标记与"高度 0 的中心"之间有地形高程差 —— 所以先用引擎拾取在中心附近
+ *     **找到标记的真实屏幕坐标**,再用真实鼠标点那个坐标(点选链路本身仍是 DOM 事件)。
+ *
+ * 演示开关(宿主全局键注入,不改代码重新构建):
+ *   `window.__COCKPIT_MOCK_SCENARIO__ = 'point-detail-order'` —— 设备卡片逆状态优先级排列
+ *   `window.__COCKPIT_MOCK_SCENARIO__ = 'point-statuses'`     —— 六态点位 + 一个可上图的未关联设备
+ *   人工演示同一件事:控制台里 `__cockpitMap.getPointMarkers()` 看标记计划、
+ *   `__cockpitMap.pickPoint(x, y)` 用屏幕坐标走同一条点选链路。
+ */
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
+
+const results = []
+/** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */
+function check(id, pass, evidence) {
+    results.push({ id, pass })
+    const rest = evidence === undefined ? '' : String(evidence)
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ::  ${rest}`)
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+const puppeteer = (await import(PUPPETEER)).default
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: join(tmpdir(), 'cockpit-t10-profile'),
+    defaultViewport: { width: 1680, height: 950 },
+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
+})
+console.log(`Chrome: ${await browser.version()}`)
+
+/** 一次读全:详情浮层 DOM(层级/摘要五要素/设备卡片)+ 地图侧口径(标记计划、宿主 dataset)。 */
+const readDetail = (page) =>
+    page.evaluate(() => {
+        const host = document.querySelector('.cockpit-shell__map .map-canvas')
+        const card = document.querySelector('[data-point-detail]')
+        const text = (selector) => {
+            const el = card && card.querySelector(selector)
+            return el ? el.textContent.trim() : ''
+        }
+        const tag = (el) =>
+            el
+                ? {
+                      label: ((el.querySelector('.status-tag__label') || {}).textContent || '').trim(),
+                      shape: el.dataset.shape || '',
+                      tone: el.dataset.tone || '',
+                      hasShapeSvg: !!el.querySelector('.status-tag__shape'),
+                  }
+                : null
+        const dev = window.__cockpitMap
+        const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
+
+        return {
+            hasCard: !!card,
+            level: card ? card.dataset.detailLevel : '',
+            pointCode: card ? card.dataset.pointCode : '',
+            deviceCode: card ? card.dataset.deviceCode : '',
+            state: card ? card.dataset.pointDetailState : '',
+            name: text('[data-point-name]'),
+            code: text('[data-point-code-value]'),
+            reason: text('[data-point-reason]'),
+            count: text('[data-point-device-count]'),
+            status: tag(card && card.querySelector('[data-point-status]')),
+            cards: card
+                ? Array.from(card.querySelectorAll('[data-device-card]')).map((el) => ({
+                      code: el.dataset.deviceCode || '',
+                      status: tag(el.querySelector('.status-tag')),
+                      reason: ((el.querySelector('.point-detail__card-reason') || {}).textContent || '').trim(),
+                  }))
+                : [],
+            deviceName: text('[data-device-name]'),
+            deviceCodeShown: text('[data-device-code-value]'),
+            hasBack: !!(card && card.querySelector('[data-detail-back]')),
+            unassociatedNote: text('[data-device-unassociated]'),
+            deviceBody: text('[data-device-detail-body]'),
+            // ---- 地图侧 ----
+            engine: (host && host.dataset.mapEngine) || '',
+            summaryLoaded: Number((host && host.dataset.mapSummaryLoaded) || 0),
+            summaryPhase: (host && host.dataset.mapSummary) || '',
+            dsMarkers: Number((host && host.dataset.mapMarkers) || 0),
+            dsPlan: Number((host && host.dataset.mapMarkersPlan) || 0),
+            picked: (host && host.dataset.mapPicked) || '',
+            entityCount: markers ? markers.onMap : -1,
+            planMarkers: markers
+                ? markers.markers.map((item) => ({ code: item.code, kind: item.kind, status: item.status, shape: item.shape, tone: item.tone, label: item.label }))
+                : [],
+            cardHosts: document.querySelectorAll('[data-point-detail]').length,
+            mapHosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length,
+        }
+    })
+
+/**
+ * 打开页面并**走真实的进入引导**(点一下「点击进入系统」→ 飞到沅陵预设)。
+ * 不注入 `__COCKPIT_CONFIG__`:注入了就不再拉 `public/cockpit/config.json`,
+ * 家视角会退化成 (0,0)(配置层把未配置的 home 归一化成 null,`readCoord(null)` 得到 0)。
+ */
+async function openPage(inject) {
+    const page = await browser.newPage()
+    const errors = []
+    page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
+    page.on('console', (m) => {
+        if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
+    })
+    if (inject) await page.evaluateOnNewDocument(inject)
+
+    await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+    await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+    await page
+        .waitForFunction(
+            () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
+            { timeout: 40000 },
+        )
+        .catch(() => {})
+
+    // 进入引导:真实用户路径(点一下进入系统)。
+    if (await page.$('[data-cockpit-intro]')) {
+        await page.click('[data-cockpit-intro]')
+        await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
+    }
+    await sleep(1500)
+    return { page, errors }
+}
+
+/** 点「加载下一页」直到翻完。 */
+async function advanceUntilSettled(page, maxSteps = 12) {
+    for (let step = 0; step < maxSteps; step += 1) {
+        const state = await readDetail(page)
+        if (['complete', 'empty', 'failed', 'expired'].includes(state.summaryPhase)) break
+        const clicked = await page.evaluate(() => {
+            const button = document.querySelector('[data-map-summary-action]')
+            if (!button || button.disabled) return false
+            button.click()
+            return true
+        })
+        if (!clicked) break
+        const before = state.summaryLoaded
+        await page
+            .waitForFunction(
+                (previous) => {
+                    const host = document.querySelector('.cockpit-shell__map .map-canvas')
+                    const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
+                    const phase = (host && host.dataset.mapSummary) || ''
+                    return loaded !== previous || ['failed', 'expired', 'empty', 'complete'].includes(phase)
+                },
+                { timeout: 10000 },
+                before,
+            )
+            .catch(() => {})
+        await sleep(350)
+    }
+    return readDetail(page)
+}
+
+/** 等相机**真的落在**目标上:阻尼飞行途中中心还在半路,这时点屏幕中心必然点空。 */
+async function waitForCameraNear(page, target, timeout = 25000) {
+    await page
+        .waitForFunction(
+            ({ lon, lat }) => {
+                const dev = window.__cockpitMap
+                const state = dev && dev.getCameraState ? dev.getCameraState() : null
+                const shown = state && state.shownPivot
+                return !!shown && Math.abs(shown.lon - lon) < 2e-4 && Math.abs(shown.lat - lat) < 2e-4
+            },
+            { timeout },
+            { lon: target.lon, lat: target.lat },
+        )
+        .catch(() => {})
+}
+
+/** 相机中心对准某类标记里的第一个(返回它的标记信息,供断言对照)。 */
+async function focusOnMarker(page, kind) {
+    const target = await page.evaluate((wantedKind) => {
+        const dev = window.__cockpitMap
+        const marker = dev.getPointMarkers().markers.find((item) => item.kind === wantedKind)
+        if (!marker) return null
+        dev.setCameraFocus({ lon: marker.longitude, lat: marker.latitude, height: 0 })
+        return { lon: marker.longitude, lat: marker.latitude, code: marker.code, status: marker.status, shape: marker.shape, tone: marker.tone, label: marker.label }
+    }, kind)
+
+    if (target) await waitForCameraNear(page, target)
+    return target
+}
+
+/**
+ * 在相机中心附近找到一个**真实标记**的屏幕坐标(先挑落点,再真点)。
+ *
+ * 用引擎拾取来找:它和真实点击走的是同一个 `scene.pick`,所以找到的坐标点下去必然命中;
+ * 「地形高程差」与「阻尼滞后」都因此不再是问题。每个候选点先确认鼠标落点确实是画布
+ * (浮层/图例盖住的地方不点),命中即返回。
+ */
+async function locateMarker(page, kind, radius = 300, stride = 10) {
+    return page.evaluate(
+        ([wantedKind, max, step]) => {
+            const dev = window.__cockpitMap
+            const canvas = document.querySelector('.cockpit-shell__map canvas')
+            const center = dev.projectCameraPivot()
+            if (!center || !canvas) return { x: 0, y: 0, code: '', kind: '', center }
+
+            for (let dy = -max; dy <= max; dy += step) {
+                for (let dx = -max; dx <= max; dx += step) {
+                    const x = Math.round(center.x + dx)
+                    const y = Math.round(center.y + dy)
+                    const hitElement = document.elementFromPoint(x, y)
+                    if (!hitElement || (hitElement !== canvas && !canvas.contains(hitElement))) continue
+                    const hit = dev.pickPoint(x, y)
+                    if (hit && hit.kind === wantedKind) return { x, y, dx, dy, code: hit.code, kind: hit.kind, center }
+                }
+            }
+            return { x: 0, y: 0, dx: 0, dy: 0, code: '', kind: '', center }
+        },
+        [kind, radius, stride],
+    )
+}
+
+/** 关掉可能已经被「找点」打开的浮层,好让真实点击成为唯一证据。 */
+async function closeCard(page) {
+    if (await page.$('[data-detail-close]')) {
+        await page.click('[data-detail-close]')
+        await sleep(250)
+    }
+}
+
+try {
+    // ================================================================ A-F 顺序样例
+    {
+        const { page, errors } = await openPage(() => {
+            window.__COCKPIT_MOCK_SCENARIO__ = 'point-detail-order'
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
+        })
+        const settled = await advanceUntilSettled(page)
+
+        check('A1-engine-ready', ['ready', 'disabled'].includes(settled.engine), `engine=${settled.engine}`)
+        check(
+            'A2-markers-on-map',
+            settled.dsPlan === 2 && settled.dsMarkers === 2 && settled.entityCount === 2,
+            `计划 ${settled.dsPlan} · dataset ${settled.dsMarkers} · 实体 ${settled.entityCount}`,
+        )
+        check('A3-no-card-before-click', settled.cardHosts === 0, `点之前浮层 ${settled.cardHosts} 个`)
+
+        // ---------- F 拖拽不误触发(票 03 的左键平移不能被新功能打坏)----------
+        const pivot = await page.evaluate(() => {
+            const dev = window.__cockpitMap
+            return dev && dev.projectCameraPivot ? dev.projectCameraPivot() : null
+        })
+        if (pivot) {
+            await page.mouse.move(pivot.x - 70, pivot.y)
+            await page.mouse.down()
+            await page.mouse.move(pivot.x + 70, pivot.y, { steps: 8 })
+            await page.mouse.up()
+        }
+        await sleep(500)
+        const afterDrag = await readDetail(page)
+        check(
+            'F1-drag-does-not-open-detail',
+            afterDrag.cardHosts === 0 && afterDrag.picked === '',
+            `拖拽 140px 后浮层 ${afterDrag.cardHosts} 个 · data-map-picked「${afterDrag.picked}」`,
+        )
+
+        // ---------- B 真实鼠标点中一个监测点标记 → 点位摘要 ----------
+        const target = await focusOnMarker(page, 'monitoring-point')
+        const located = await locateMarker(page, 'monitoring-point')
+        await closeCard(page)
+        const beforeClick = await readDetail(page)
+
+        if (located.code) {
+            await page.mouse.click(located.x, located.y)
+            await sleep(600)
+        }
+        const detail = await readDetail(page)
+
+        check(
+            'B0-marker-locatable-by-engine-pick',
+            !!target && !!located.code && detail.planMarkers.some((item) => item.code === located.code && item.kind === 'monitoring-point'),
+            `相机对准 ${target && target.code},引擎在中心偏移 ${located.dx},${located.dy}px 处拾到 ${located.code}(两个点位只隔约 7px,最上面那个先被拾到;偏移量就是贴地标记与中心的高程差)`,
+        )
+        check(
+            'B1-real-click-opens-summary',
+            !!located.code && beforeClick.cardHosts === 0 && detail.hasCard && detail.level === 'point' && detail.state === 'success',
+            located.code
+                ? `真实鼠标点 (${located.x},${located.y}) → 层级 ${detail.level} · 分区 ${detail.state}`
+                : '没找到可点的标记',
+        )
+        check(
+            'B2-card-is-the-picked-point',
+            !!located.code && detail.picked === `monitoring-point|${located.code}` && detail.pointCode === located.code,
+            `dataset 拾到「${detail.picked}」· 浮层显示 ${detail.pointCode}`,
+        )
+
+        // 摘要五要素:名称 / 编码 / 状态 / 原因 / 关联设备数量。
+        check(
+            'B3-summary-five-fields',
+            !!detail.name && detail.code === detail.pointCode && !!detail.status && !!detail.status.label && detail.reason === '流量超高' && detail.count === '3',
+            `名称「${detail.name}」编码「${detail.code}」状态「${detail.status && detail.status.label}」原因「${detail.reason}」关联设备 ${detail.count}`,
+        )
+        check(
+            'B4-status-tag-has-shape-and-text',
+            !!detail.status && detail.status.hasShapeSvg && !!detail.status.shape && !!detail.status.tone && !!detail.status.label,
+            `状态标签 ${detail.status && detail.status.tone}/${detail.status.shape}/${detail.status.label}(不只靠颜色)`,
+        )
+
+        // 状态标签必须与**地图上那个标记**同源:形状、色键、文字逐个对齐。
+        const marker = detail.planMarkers.find((item) => item.code === detail.pointCode) || null
+        check(
+            'B5-card-status-matches-map-marker',
+            !!marker && !!detail.status && marker.shape === detail.status.shape && marker.tone === detail.status.tone && marker.label === detail.status.label,
+            marker && detail.status
+                ? `标记 ${marker.tone}/${marker.shape}/${marker.label} vs 浮层 ${detail.status.tone}/${detail.status.shape}/${detail.status.label}`
+                : '浮层里的点位在地图标记计划里',
+        )
+
+        // ---------- C 设备卡片:顺序 = 后端返回顺序(样例刻意逆状态优先级)----------
+        const ORDER = ['EQ-ORDER-NORMAL', 'EQ-ORDER-ALARM', 'EQ-ORDER-WARNING']
+        check(
+            'C1-device-card-order-is-backend-order',
+            detail.cards.map((card) => card.code).join(',') === ORDER.join(','),
+            `卡片顺序 ${detail.cards.map((card) => card.code).join(' → ')}(后端给的就是这个顺序:正常 → 报警 → 预警)`,
+        )
+        check(
+            'C2-every-card-has-status-tag',
+            detail.cards.length === 3 && detail.cards.every((card) => card.status && card.status.hasShapeSvg && !!card.status.label),
+            detail.cards.map((card) => `${card.code}:${card.status && card.status.label}`).join(' · '),
+        )
+        check('C3-card-count-equals-summary-count', detail.cards.length === Number(detail.count), `摘要 ${detail.count} 台 = 卡片 ${detail.cards.length} 张`)
+
+        // ---------- E 关联设备不重复上图 ----------
+        const deviceCodes = detail.cards.map((card) => card.code)
+        check(
+            'E1-markers-unchanged-after-detail',
+            detail.dsMarkers === settled.dsMarkers && detail.entityCount === settled.entityCount && detail.planMarkers.length === settled.planMarkers.length,
+            `打开详情前后:dataset ${settled.dsMarkers} → ${detail.dsMarkers} · 实体 ${settled.entityCount} → ${detail.entityCount}`,
+        )
+        check(
+            'E2-associated-device-not-a-marker',
+            deviceCodes.length === 3 && deviceCodes.every((code) => !detail.planMarkers.some((item) => item.code === code)),
+            `设备 ${deviceCodes.join(', ')} 都不在标记计划里(地图上只有 ${detail.planMarkers.map((item) => item.code).join(', ')})`,
+        )
+        check('E3-picked-recorded-on-host', detail.picked === `monitoring-point|${detail.pointCode}`, `data-map-picked=「${detail.picked}」`)
+        check('E4-single-card-host', detail.cardHosts === 1 && detail.mapHosts === 1, `浮层 ${detail.cardHosts} 个 · 地图宿主 ${detail.mapHosts} 个`)
+
+        // ---------- D 选卡片进第二级 → 返回 → 关闭 ----------
+        await page.click('[data-device-card="0"]')
+        await sleep(300)
+        const second = await readDetail(page)
+        check(
+            'D1-select-card-enters-device-level',
+            second.level === 'equipment' && second.deviceCode === ORDER[0] && second.deviceCodeShown === ORDER[0] && !!second.deviceName,
+            `层级 ${second.level} · 设备 ${second.deviceCodeShown}「${second.deviceName}」`,
+        )
+        check('D2-back-button-present', second.hasBack === true && !!second.deviceBody, `第二级有「返回点位」· 分区区「${second.deviceBody}」`)
+
+        await page.click('[data-detail-back]')
+        await sleep(300)
+        const back = await readDetail(page)
+        check(
+            'D3-back-returns-to-point-level',
+            back.level === 'point' && back.pointCode === detail.pointCode && back.cards.length === 3,
+            `返回后层级 ${back.level} · 点位 ${back.pointCode} · 卡片 ${back.cards.length} 张`,
+        )
+
+        await page.click('[data-detail-close]')
+        await sleep(300)
+        const closed = await readDetail(page)
+        check(
+            'D4-close-hides-card-without-touching-map',
+            closed.cardHosts === 0 && closed.dsMarkers === detail.dsMarkers && closed.entityCount === detail.entityCount,
+            `浮层 ${closed.cardHosts} 个 · 标记仍 ${closed.dsMarkers} 个`,
+        )
+        check('D5-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+
+        await page.close()
+    }
+
+    // ================================================================ G 未关联设备直接进设备详情
+    {
+        const { page, errors } = await openPage(() => {
+            window.__COCKPIT_MOCK_SCENARIO__ = 'point-statuses'
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 10 }
+        })
+        await advanceUntilSettled(page)
+
+        const target = await focusOnMarker(page, 'unassociated-equipment')
+        const located = await locateMarker(page, 'unassociated-equipment')
+        await closeCard(page)
+        const beforeClick = await readDetail(page)
+
+        if (located.code) {
+            await page.mouse.click(located.x, located.y)
+            await sleep(600)
+        }
+        const detail = await readDetail(page)
+
+        check(
+            'G1-unassociated-device-enters-device-level',
+            !!located.code && beforeClick.cardHosts === 0 && detail.hasCard && detail.level === 'equipment' && detail.pointCode === '' && detail.deviceCode === located.code,
+            located.code ? `点中未关联设备 ${detail.deviceCode} → 层级 ${detail.level}(没有点位可摘要)` : '没找到可点的未关联设备标记',
+        )
+        check('G2-unassociated-note-visible', !!detail.unassociatedNote && !!detail.deviceBody, `提示「${detail.unassociatedNote}」· 分区区「${detail.deviceBody}」`)
+        check('G3-no-back-button', detail.hasBack === false, '没有点位可回时不显示「返回点位」')
+        check('G4-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+
+        await page.close()
+    }
+
+    // ================================================================ H 默认样例不受影响
+    {
+        const { page, errors } = await openPage(() => {
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
+        })
+        const settled = await advanceUntilSettled(page)
+        // 默认样例:3 点 + 2 未关联设备 = 5 个对象、其中 2 个可上图(票 07/08 的数)。
+        check(
+            'H1-default-sample-unchanged',
+            settled.summaryLoaded === 5 && settled.dsPlan === 2 && settled.entityCount === 2,
+            `已加载 ${settled.summaryLoaded} · 计划 ${settled.dsPlan} · 实体 ${settled.entityCount}(票 07/08 的数)`,
+        )
+        check('H2-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        await page.close()
+    }
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((item) => !item.pass)
+console.log(`\nT10 走查:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`)
+process.exit(failed.length ? 1 : 0)

+ 647 - 0
.scratch/cockpit-shell-panel-layout/verify/t10-point-detail.mjs

@@ -0,0 +1,647 @@
+/**
+ * T10 监测点详情与设备卡片 纯逻辑检查(票 10)。
+ *
+ * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
+ * 运行:node .scratch/cockpit-shell-panel-layout/verify/t10-point-detail.mjs
+ *
+ * 六段:
+ *   A 两级状态机:地图对象 → 选择 → 点位级 → 设备级 → 返回/关闭(纯函数,不取数、不碰地图)
+ *   B 点位摘要与设备卡片视图:名称/编码/状态/原因/关联设备数量;**卡片顺序原样来自后端**
+ *   C store:按编码取数、同一目标不重复取数、A→B 竞态收口、选设备不取数、失败不白屏
+ *   D 关联设备不重复上图:地图对象与标记计划在整条详情流程里一个字节不变(含对抗式反证)
+ *   E 点选手势与拾取边界:拖拽不点选、只认左键、只拾点位标记
+ *   F 静态守卫:组件不拼接口路径、不认后端裸字段、不对设备卡片排序
+ *
+ * 契约依据:CONTEXT.md「两级详情」(先点位摘要+设备卡片,再选设备进设备详情)与
+ * `CockpitMonitoringPointDetailResponse` 的字段(`domain/pointDetail.js` 已按它适配)。
+ */
+import { readFileSync } from 'node:fs'
+import { createPinia, setActivePinia } from 'pinia'
+import {
+    DETAIL_LEVEL,
+    DETAIL_PLACEHOLDER,
+    associatedDeviceCount,
+    backToPoint,
+    closeDetail,
+    deviceCardOf,
+    deviceCards,
+    emptyDetailSelection,
+    isSamePointTarget,
+    openDevice,
+    openPoint,
+    pointSummary,
+    reasonText,
+    selectionFromMapObject,
+    statusView,
+} from '../../../src/modules/cockpit/domain/detailFlow.js'
+import { toMonitoringPointDetail } from '../../../src/modules/cockpit/domain/pointDetail.js'
+import { POINT_STATUS_VISUALS, UNKNOWN_VISUAL, planPointMarkers } from '../../../src/modules/cockpit/domain/pointStatus.js'
+import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
+import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
+import { CLICK_MAX_DRAG_PX, attachPointPick, isClickGesture } from '../../../src/modules/cockpit/services/pointPick.js'
+import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js'
+import { useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js'
+
+const checks = []
+function check(name, pass, evidence) {
+    checks.push({ name, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${name}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
+const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
+const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
+/** 去掉注释只留代码:本文件的注释会**解释**被禁的东西,直接对全文正则会拿假阳性换通过率。 */
+const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
+const readCode = (relative) => code(read(relative))
+
+/** 造一张设备卡片(后端 `CockpitMonitoringPointDeviceCard`)。 */
+const rawDevice = ({ code: deviceCode = 'EQ-001', name = '供水压力计-001', typeName = '压力计', status = 'ALARM', label = '报警', reasons = [] } = {}) => ({
+    deviceCode,
+    deviceName: name,
+    deviceTypeName: typeName,
+    deviceStatus: status,
+    statusLabel: label,
+    statusReasons: reasons,
+    statusTime: '2026-09-02T09:59:00+08:00',
+})
+
+/** 造一个后端监测点详情载荷。 */
+const rawPointDetail = ({ code = 'MP-002', name = '燃气调压站监测点', status = 'ALARM', devices = [], statistics, reasonList } = {}) => ({
+    monitoringPointCode: code,
+    monitoringPointName: name,
+    pointStatus: status,
+    statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[status] || '',
+    statusReasons: reasonList || [{ status, deviceCode: 'EQ-001', reason: '压力超高' }],
+    deviceStatistics:
+        statistics === undefined
+            ? {
+                  totalDeviceCount: devices.length,
+                  alarmDeviceCount: 1,
+                  warningDeviceCount: 0,
+                  workOrderDeviceCount: 0,
+                  normalDeviceCount: 1,
+                  offlineDeviceCount: 0,
+              }
+            : statistics,
+    devices,
+    coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
+    statusDictionary: [{ status: 'ALARM', label: '报警', priority: 1 }],
+})
+
+const detailOf = (options) => toMonitoringPointDetail(rawPointDetail(options))
+
+/**
+ * 假元素:把 DOM 事件序列在 node 里跑一遍(`attachPointPick` 只要求
+ * `addEventListener`/`removeEventListener`,所以不需要 jsdom)。
+ */
+function fakeElement() {
+    const listeners = new Map()
+    return {
+        addEventListener(type, handler) {
+            listeners.set(type, [...(listeners.get(type) || []), handler])
+        },
+        removeEventListener(type, handler) {
+            listeners.set(type, (listeners.get(type) || []).filter((item) => item !== handler))
+        },
+        emit(type, event) {
+            for (const handler of listeners.get(type) || []) handler(event)
+        },
+        count(type) {
+            return (listeners.get(type) || []).length
+        },
+    }
+}
+
+/** 地图对象(`domain/mapSummary.js` 的形状)。 */const mapObject = (kind, code, name = `${code} 点位`) => ({
+    kind,
+    code,
+    name,
+    status: kind === 'monitoring-point' ? 'NORMAL' : '',
+    statusReasons: [],
+    longitude: 110.141,
+    latitude: 28.452,
+    coordinate: { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
+    missing: [],
+})
+
+// ================================================================ A 两级状态机
+
+{
+    const empty = emptyDetailSelection()
+    check(
+        'A1-empty-selection-is-none',
+        empty.level === DETAIL_LEVEL.NONE && empty.pointCode === '' && empty.deviceCode === '',
+        JSON.stringify(empty),
+    )
+
+    const pointLevel = openPoint(empty, 'MP-002')
+    check(
+        'A2-open-point-enters-first-level',
+        pointLevel.level === DETAIL_LEVEL.POINT && pointLevel.pointCode === 'MP-002' && pointLevel.deviceCode === '',
+        JSON.stringify(pointLevel),
+    )
+
+    const deviceLevel = openDevice(pointLevel, 'EQ-009')
+    check(
+        'A3-select-device-enters-second-level',
+        deviceLevel.level === DETAIL_LEVEL.EQUIPMENT && deviceLevel.pointCode === 'MP-002' && deviceLevel.deviceCode === 'EQ-009',
+        `点位编码保留:${deviceLevel.pointCode}`,
+    )
+
+    // 换点位要把设备选择清掉:否则会把上一台设备的详情挂在新的点位下面。
+    const switched = openPoint(deviceLevel, 'MP-003')
+    check(
+        'A4-switching-point-clears-device',
+        switched.level === DETAIL_LEVEL.POINT && switched.pointCode === 'MP-003' && switched.deviceCode === '',
+        JSON.stringify(switched),
+    )
+
+    const back = backToPoint(deviceLevel)
+    check(
+        'A5-back-returns-to-point-without-device',
+        back.level === DETAIL_LEVEL.POINT && back.pointCode === 'MP-002' && back.deviceCode === '',
+        JSON.stringify(back),
+    )
+
+    // 未关联设备直接进第二级:它没有点位摘要可回,「返回点位」不该把用户送到不存在的点位。
+    const unassociated = selectionFromMapObject(mapObject('unassociated-equipment', 'EQ-100', '井盖-100'))
+    check(
+        'A6-unassociated-equipment-goes-straight-to-device',
+        unassociated.level === DETAIL_LEVEL.EQUIPMENT && unassociated.pointCode === '' && unassociated.deviceCode === 'EQ-100' && backToPoint(unassociated).level === DETAIL_LEVEL.NONE,
+        JSON.stringify(unassociated),
+    )
+
+    check(
+        'A7-point-marker-maps-to-point-level',
+        selectionFromMapObject(mapObject('monitoring-point', 'MP-002')).level === DETAIL_LEVEL.POINT,
+        '点监测点 → 第一级',
+    )
+    check(
+        'A8-unknown-object-opens-nothing',
+        selectionFromMapObject({ kind: 'pipeline', code: 'PL-1' }) === null &&
+            selectionFromMapObject(mapObject('monitoring-point', '')) === null &&
+            selectionFromMapObject(null) === null,
+        '认不出的对象与没有编码的对象都不打开卡片(不猜)',
+    )
+    check(
+        'A9-close-is-empty',
+        closeDetail().level === DETAIL_LEVEL.NONE && openDevice(pointLevel, '').deviceCode === '',
+        '关闭回空选择;空编码不改变选择',
+    )
+    check(
+        'A10-same-point-target',
+        isSamePointTarget(deviceLevel, 'MP-002') === true && isSamePointTarget(deviceLevel, 'MP-003') === false && isSamePointTarget(empty, 'MP-002') === false,
+        '同一目标(含已选设备那一级)才判「不用重复取数」',
+    )
+}
+
+// ================================================================ B 摘要与卡片视图
+
+{
+    const detail = detailOf({
+        code: 'MP-002',
+        name: '燃气调压站监测点',
+        status: 'ALARM',
+        devices: [rawDevice({ code: 'EQ-002', name: '智能井盖-002', status: 'NORMAL', label: '正常' }), rawDevice({ code: 'EQ-001', reasons: ['压力超高'] })],
+    })
+    const summary = pointSummary(detail)
+
+    check(
+        'B1-summary-has-five-fields',
+        ['name', 'code', 'status', 'reason', 'deviceCount'].every((key) => key in summary),
+        Object.keys(summary).join(', '),
+    )
+    check(
+        'B2-summary-values',
+        summary.name === '燃气调压站监测点' && summary.code === 'MP-002' && summary.status.label === '报警' && summary.status.key === 'ALARM' && summary.deviceCount === 2,
+        JSON.stringify({ name: summary.name, code: summary.code, status: summary.status.label, count: summary.deviceCount }),
+    )
+    check('B3-summary-reason', summary.reason === '压力超高', `原因:${summary.reason}`)
+    check(
+        'B4-summary-status-has-shape-and-label',
+        !!summary.status.shape && !!summary.status.tone && !!summary.status.label,
+        `${summary.status.tone}/${summary.status.shape}/${summary.status.label}`,
+    )
+
+    // 原因:去重保序、混吃两种形状(点位是对象数组、设备卡片是字符串数组)。
+    check(
+        'B5-reason-dedupes-and-keeps-order',
+        reasonText([{ reason: '压力超高' }, { reason: '通信中断' }, { reason: '压力超高' }]) === '压力超高;通信中断' &&
+            reasonText(['压力超高', '压力超高']) === '压力超高',
+        reasonText([{ reason: '压力超高' }, { reason: '通信中断' }, { reason: '压力超高' }]),
+    )
+    check(
+        'B6-no-reason-is-empty-not-normal',
+        reasonText([]) === '' && reasonText(undefined) === '' && pointSummary(detailOf({ reasonList: [] })).reason === '',
+        '后端没给原因时是空串(界面显示「暂无原因」),不是「正常」',
+    )
+    check(
+        'B7-unknown-status-not-guessed',
+        statusView('MAINTENANCE').key === UNKNOWN_VISUAL.key && pointSummary(detailOf({ status: 'MAINTENANCE' })).status.label === UNKNOWN_VISUAL.label,
+        `认不出的点位状态 → ${UNKNOWN_VISUAL.label}`,
+    )
+    check(
+        'B8-backend-label-wins',
+        statusView('ALARM', '报警(后端词)').label === '报警(后端词)' && POINT_STATUS_VISUALS.ALARM.label === '报警',
+        '后端给了 statusLabel 就用后端的词,前端不另造业务词汇',
+    )
+    check(
+        'B9-missing-fields-placeholder',
+        pointSummary(detailOf({ name: '' })).name === DETAIL_PLACEHOLDER && deviceCards(detailOf({ devices: [rawDevice({ name: '' })] }))[0].name === DETAIL_PLACEHOLDER,
+        `缺字段显示 ${DETAIL_PLACEHOLDER},不留空白也不编造`,
+    )
+
+    // 关联设备数量:后端统计是权威值;统计整段缺失时按已返回卡片数如实报,不显示假 0。
+    check(
+        'B10-count-prefers-backend-statistics',
+        associatedDeviceCount(detailOf({ devices: [rawDevice(), rawDevice({ code: 'EQ-003' })], statistics: { totalDeviceCount: 7, alarmDeviceCount: 1, warningDeviceCount: 0, workOrderDeviceCount: 0, normalDeviceCount: 1, offlineDeviceCount: 0 } })) === 7,
+        '后端给 7 台、只回了 2 张卡片时,摘要仍说 7(统计是权威值)',
+    )
+    check(
+        'B11-count-falls-back-to-cards-when-statistics-missing',
+        associatedDeviceCount(detailOf({ devices: [rawDevice(), rawDevice({ code: 'EQ-003' })], statistics: null })) === 2,
+        '统计缺失时按已返回的 2 张卡片报,不显示「0 台」',
+    )
+    check(
+        'B12-count-zero-is-real-zero',
+        associatedDeviceCount(detailOf({ devices: [], statistics: { totalDeviceCount: 0, alarmDeviceCount: 0, warningDeviceCount: 0, workOrderDeviceCount: 0, normalDeviceCount: 0, offlineDeviceCount: 0 } })) === 0,
+        '后端确实说 0 台时就是 0(真 0 不是缺失)',
+    )
+
+    // 验收第 3 条:设备卡片顺序由后端返回顺序决定 —— 两种排列都原样输出,说明顺序来自输入。
+    const firstOrder = ['EQ-B', 'EQ-A', 'EQ-C']
+    const secondOrder = ['EQ-C', 'EQ-B', 'EQ-A']
+    const cardsOf = (order) => deviceCards(detailOf({ devices: order.map((code) => rawDevice({ code })) })).map((card) => card.code)
+    check(
+        'B13-device-card-order-is-backend-order',
+        cardsOf(firstOrder).join(',') === firstOrder.join(',') && cardsOf(secondOrder).join(',') === secondOrder.join(','),
+        `${cardsOf(firstOrder).join(',')} / ${cardsOf(secondOrder).join(',')}`,
+    )
+    check(
+        'B14-card-view-fields',
+        (() => {
+            const card = deviceCards(detailOf({ devices: [rawDevice({ code: 'EQ-1', typeName: '压力计', status: 'ALARM', label: '报警', reasons: ['压力超高'] })] }))[0]
+            return card.code === 'EQ-1' && card.typeName === '压力计' && card.status.label === '报警' && card.reason === '压力超高'
+        })(),
+        '卡片带编码/名称/类型/状态/原因',
+    )
+    check(
+        'B15-device-card-of',
+        deviceCardOf(detail, 'EQ-001').code === 'EQ-001' && deviceCardOf(detail, 'EQ-404') === null && deviceCardOf(detail, '') === null,
+        '第二级的表头按设备编码取卡片,取不到回 null',
+    )
+}
+
+// ================================================================ C store:取数与层级
+
+{
+    setActivePinia(createPinia())
+    const detailStore = useDetailStore()
+    const mapStore = useMapStore()
+    let pointRequests = 0
+
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'success', mapSnapshot: { pageSize: 2 }, handlers: { registerMockTransport, resetMockTransportsForTest } })
+    // 计数用:精确路径优先于 `/monitoring-points/*` 模板(票 05 的匹配顺序)。
+    registerMockTransport('/monitoring-points/MP-COUNT', () => {
+        pointRequests += 1
+        return rawPointDetail({ code: 'MP-COUNT', devices: [rawDevice({ code: 'EQ-002' }), rawDevice({ code: 'EQ-001' })] })
+    })
+
+    detailStore.configure({ config: mockConfig })
+    mapStore.setup({ config: mockConfig })
+    await mapStore.advance()
+
+    const selection = await detailStore.openPoint('MP-COUNT')
+    const section = detailStore.sections.pointDetail
+
+    check(
+        'C1-open-point-loads-first-level',
+        selection.level === DETAIL_LEVEL.POINT && selection.pointCode === 'MP-COUNT' && section.state === 'success' && section.data.code === 'MP-COUNT' && section.loaded === true,
+        JSON.stringify({ level: selection.level, state: section.state, code: section.data && section.data.code }),
+    )
+    check('C2-fetch-happened-once', pointRequests === 1, `请求 ${pointRequests} 次`)
+
+    // 同一个点位再点一次:层级回到第一级,但不再发请求(点位摘要没变)。
+    await detailStore.selectDevice('EQ-001')
+    const reopened = await detailStore.openPoint('MP-COUNT')
+    check(
+        'C3-same-point-does-not-refetch',
+        reopened.level === DETAIL_LEVEL.POINT && reopened.deviceCode === '' && pointRequests === 1,
+        `再次打开后请求数仍为 ${pointRequests}`,
+    )
+
+    // 第二级:换层级,并按设备编码加载设备详情聚合(票 11 接手了这条取数;
+    // 票 10 的票面原本就写明「设备详情聚合的加载归票 11」——那条接缝当时是**延期**,
+    // 不是永久契约,所以这里断言的是票 11 之后的行为)。
+    const deviceLevel = await detailStore.selectDevice('EQ-001')
+    check(
+        'C4-select-device-enters-level-and-loads-by-code',
+        deviceLevel.level === DETAIL_LEVEL.EQUIPMENT &&
+            deviceLevel.deviceCode === 'EQ-001' &&
+            deviceLevel.pointCode === 'MP-COUNT' &&
+            detailStore.sections.equipmentAggregate.loaded === true &&
+            detailStore.sections.equipmentAggregate.data.deviceCode === 'EQ-001' &&
+            pointRequests === 1,
+        JSON.stringify({
+            level: deviceLevel.level,
+            aggregateCode: detailStore.sections.equipmentAggregate.data.deviceCode,
+            pointRequests,
+        }),
+    )
+    check(
+        'C5-point-detail-kept-for-device-header',
+        deviceCardOf(detailStore.sections.pointDetail.data, deviceLevel.deviceCode).code === 'EQ-001',
+        '第二级的表头仍读第一级已加载的数据(不必再请求)',
+    )
+
+    // 返回第一级:不清掉已加载的点位摘要,也不再取数。
+    const back = detailStore.backToPoint()
+    check(
+        'C6-back-keeps-loaded-section',
+        back.level === DETAIL_LEVEL.POINT && detailStore.sections.pointDetail.loaded === true && pointRequests === 1,
+        `返回后请求数仍为 ${pointRequests}`,
+    )
+
+    // 竞态:先点 A(慢)再点 B(快),B 的数据必须赢,A 迟到不得覆盖。
+    let releaseA = null
+    let releaseBCalled = false
+    registerMockTransport('/monitoring-points/MP-A', () => new Promise((resolve) => {
+        releaseA = () => resolve(rawPointDetail({ code: 'MP-A', name: 'A 点位' }))
+    }))
+    registerMockTransport('/monitoring-points/MP-B', () => {
+        releaseBCalled = true
+        return Promise.resolve(rawPointDetail({ code: 'MP-B', name: 'B 点位' }))
+    })
+
+    const pendingA = detailStore.openPoint('MP-A')
+    const doneB = await detailStore.openPoint('MP-B')
+    const afterB = detailStore.sections.pointDetail.data.code
+    releaseA()
+    await pendingA
+    await new Promise((resolve) => setTimeout(resolve, 0))
+
+    check(
+        'C7-race-latest-wins',
+        releaseBCalled === true && afterB === 'MP-B' && doneB.pointCode === 'MP-B' && detailStore.sections.pointDetail.data.code === 'MP-B',
+        `B 落地后 section.code=${detailStore.sections.pointDetail.data.code}(A 迟到被丢弃)`,
+    )
+
+    // 关闭:选择回空,界面据此收掉浮层。
+    const closed = detailStore.closeDetail()
+    check('C8-close-clears-selection', closed.level === DETAIL_LEVEL.NONE && detailStore.selection.pointCode === '', JSON.stringify(closed))
+
+    // 失败样例:分区如实失败,但选择仍在点位级(界面显示失败态,不是白屏、也不自动关窗)。
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'failed', onlyPath: '/monitoring-points/*', handlers: { registerMockTransport, resetMockTransportsForTest } })
+    const failedStore = useDetailStore()
+    failedStore.reset()
+    failedStore.configure({ config: mockConfig })
+    await failedStore.openPoint('MP-FAIL')
+
+    check(
+        'C9-failure-keeps-selection-and-reports',
+        failedStore.selection.level === DETAIL_LEVEL.POINT &&
+            failedStore.sections.pointDetail.state === 'failed' &&
+            failedStore.sections.pointDetail.loaded === true &&
+            failedStore.sections.pointDetail.data === null,
+        JSON.stringify({ level: failedStore.selection.level, state: failedStore.sections.pointDetail.state }),
+    )
+
+    // 失败之后**再点同一个点位**要能重试(留下恢复路径):第一次抛错、第二次成功。
+    let flakyAttempts = 0
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
+    registerMockTransport('/monitoring-points/MP-FLAKY', () => {
+        flakyAttempts += 1
+        if (flakyAttempts === 1) throw new Error('mock: 第一次读取失败')
+        return rawPointDetail({ code: 'MP-FLAKY', name: '重试点位' })
+    })
+
+    const retryStore = useDetailStore()
+    retryStore.reset()
+    retryStore.configure({ config: mockConfig })
+    await retryStore.openPoint('MP-FLAKY')
+    const firstAttemptState = retryStore.sections.pointDetail.state
+    await retryStore.openPoint('MP-FLAKY')
+
+    check(
+        'C11-failed-point-can-be-retried',
+        firstAttemptState === 'failed' &&
+            flakyAttempts === 2 &&
+            retryStore.sections.pointDetail.state === 'success' &&
+            retryStore.sections.pointDetail.data.code === 'MP-FLAKY',
+        `第一次 ${firstAttemptState} → 再点一次(第 ${flakyAttempts} 次请求)后 ${retryStore.sections.pointDetail.state}`,
+    )
+
+    // 未关联设备入口:没有点位编码,不请求点位详情。
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
+    const deviceOnly = useDetailStore()
+    deviceOnly.reset()
+    deviceOnly.configure({ config: mockConfig })
+    await deviceOnly.openDevice('EQ-100')
+    check(
+        'C10-unassociated-device-needs-no-point-request',
+        deviceOnly.selection.level === DETAIL_LEVEL.EQUIPMENT &&
+            deviceOnly.selection.pointCode === '' &&
+            deviceOnly.sections.pointDetail.loaded === false,
+        JSON.stringify(deviceOnly.selection),
+    )
+
+    // ---- D 关联设备不重复上图 ----
+    // 回到默认契约样例:`/monitoring-points/*` 会给两台关联设备(EQ-001 / EQ-002)。
+    // 前后各拍一次地图对象与标记计划,验证整条详情流程(取数 → 设备卡片 → 选设备)都没碰地图。
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
+
+    const objectsBeforeDetail = mapStore.summary.objects.map((item) => `${item.kind}|${item.code}`)
+    const planBefore = planPointMarkers(mapStore.summary.objects).markers.map((marker) => marker.key)
+
+    await detailStore.openPoint('MP-COUNT')
+    detailStore.selectDevice('EQ-001')
+
+    const objectsAfter = mapStore.summary.objects.map((item) => `${item.kind}|${item.code}`)
+    const planAfter = planPointMarkers(mapStore.summary.objects).markers.map((marker) => marker.key)
+    const detailCode = detailStore.sections.pointDetail.data
+    const detailDeviceCodes = detailCode ? detailCode.devices.map((card) => card.code) : []
+
+    check(
+        'D1-map-objects-untouched-by-detail-flow',
+        objectsAfter.join(',') === objectsBeforeDetail.join(',') && planAfter.join(',') === planBefore.join(','),
+        `${objectsAfter.length} 个地图对象、${planAfter.length} 个标记,详情流程前后一致`,
+    )
+    check(
+        'D2-associated-devices-are-not-markers',
+        detailDeviceCodes.length === 2 && detailDeviceCodes.every((deviceCode) => !planAfter.some((key) => key.endsWith(`|${deviceCode}`))),
+        `详情里的设备 ${detailDeviceCodes.join(', ')} 都不在标记计划里(${planAfter.join(' / ')})`,
+    )
+    // 对抗式反证:把设备当**地图对象**喂进计划确实会多出标记 —— 所以上面那条不是恒真。
+    const wouldBeMarkers = planPointMarkers(
+        detailDeviceCodes.map((deviceCode) => mapObject('unassociated-equipment', deviceCode, deviceCode)),
+    )
+    check(
+        'D3-guard-has-teeth',
+        wouldBeMarkers.markers.length === detailDeviceCodes.length && wouldBeMarkers.markers.length > 0,
+        `把设备当地图对象喂进去会多出 ${wouldBeMarkers.markers.length} 个标记(因此「不喂」才是有意义的约束)`,
+    )
+}
+
+// ================================================================ E 点选手势与拾取边界
+
+{
+    check(
+        'E1-click-gesture-threshold',
+        isClickGesture({ x: 100, y: 100 }, { x: 100, y: 100 }) === true &&
+            isClickGesture({ x: 100, y: 100 }, { x: 102, y: 102 }) === true &&
+            isClickGesture({ x: 100, y: 100 }, { x: 100 + CLICK_MAX_DRAG_PX + 2, y: 100 }) === false &&
+            isClickGesture({ x: 100, y: 100 }, { x: 40, y: 220 }) === false,
+        `阈值 ${CLICK_MAX_DRAG_PX}px:拖拽不点选`,
+    )
+    check(
+        'E2-gesture-safe-on-missing-input',
+        isClickGesture(null, { x: 1, y: 1 }) === false && isClickGesture({ x: 0, y: 0 }, undefined) === false,
+    )
+
+    const pickCode = readCode('services/pointPick.js')
+    check(
+        'E3-pick-input-only-observes',
+        !/preventDefault|stopPropagation/.test(pickCode) && /event\.button === 0/.test(pickCode) && /pointerdown/.test(pickCode) && /pointerup/.test(pickCode),
+        '只观察、只认左键:相机拖拽手感不受影响',
+    )
+    check(
+        'E4-pick-input-has-detach',
+        /export function attachPointPick/.test(pickCode) && /removeEventListener/.test(pickCode),
+        '返回 detach:离开 /cockpit 时摘干净',
+    )
+
+    // 真行为(脱浏览器):拿一个假元素把 DOM 事件序列跑一遍 ——
+    // 「点一下开卡片、拖一下不开、右键不开、摘掉之后不再开」四件事在绑定层就定死。
+    {
+        const element = fakeElement()
+        const picks = []
+        const detach = attachPointPick(element, (point) => picks.push(point))
+
+        element.emit('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
+        element.emit('pointerup', { pointerId: 1, clientX: 101, clientY: 100 })
+        check(
+            'E4b-real-click-fires-pick',
+            picks.length === 1 && picks[0].x === 101 && picks[0].y === 100,
+            `点一下 → onPick(${JSON.stringify(picks[0] || null)})`,
+        )
+
+        element.emit('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
+        element.emit('pointerup', { pointerId: 1, clientX: 160, clientY: 100 })
+        element.emit('pointerdown', { button: 2, clientX: 100, clientY: 100, pointerId: 1 })
+        element.emit('pointerup', { pointerId: 1, clientX: 100, clientY: 100 })
+        element.emit('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
+        element.emit('pointercancel', { pointerId: 1, clientX: 100, clientY: 100 })
+        check('E4c-drag-right-button-cancel-do-not-pick', picks.length === 1, `拖拽/右键/取消之后仍只有 ${picks.length} 次点选`)
+
+        detach()
+        element.emit('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
+        element.emit('pointerup', { pointerId: 1, clientX: 100, clientY: 100 })
+        check(
+            'E4d-detach-removes-listeners',
+            element.count('pointerdown') === 0 && element.count('pointerup') === 0 && picks.length === 1,
+            '摘掉监听后不再触发点选',
+        )
+    }
+
+    const engineCode = readCode('services/cesiumEngine.js')
+    check(
+        'E5-engine-pick-only-marker-identity',
+        /export function pickPointMarkerAt/.test(engineCode) &&
+            /picked\.id/.test(engineCode) &&
+            /properties\.pointCode/.test(engineCode) &&
+            /return \{ kind: .*code: .*status: .*\} \| null|return \{ kind/.test(engineCode),
+        '只认带 pointCode 的实体,且回的是身份(kind/code/status),不漏 Cesium 实体',
+    )
+    check(
+        'E6-pick-uses-canvas-coordinates',
+        /getBoundingClientRect/.test(engineCode) && /windowX - rect\.left/.test(engineCode),
+        '事件给窗口坐标、scene.pick 吃画布坐标,先减去画布原点',
+    )
+
+    const mapServiceCode = readCode('services/mapService.js')
+    check(
+        'E7-pick-lifecycle-symmetric',
+        /record\.detachPickInput = attachPointPick/.test(mapServiceCode) &&
+            /record\.detachPickInput\(\)/.test(mapServiceCode) &&
+            /export function onPointPick/.test(mapServiceCode),
+        '引擎就绪时挂、离开时摘;订阅接口由组件使用',
+    )
+    check(
+        'E8-pick-failure-opens-nothing',
+        /if \(!picked\) return null/.test(mapServiceCode),
+        '拾不到标记(点空白/地形/影像)时不打开任何详情',
+    )
+}
+
+// ================================================================ F 静态守卫
+
+{
+    const cardCode = readCode('components/PointDetailCard.vue')
+    const shellCode = readCode('CockpitShell.vue')
+    const flowCode = readCode('domain/detailFlow.js')
+
+    check(
+        'F1-card-does-not-build-api-paths',
+        !/\/monitoring-points\/|\/equipments\//.test(cardCode) && !/fetch\(/.test(cardCode),
+        '接口路径只在 services/detailApi.js,组件不自己拼',
+    )
+    check(
+        'F2-card-has-no-backend-field-names',
+        !/monitoringPointCode|monitoringPointName|pointStatus|deviceStatistics|statusReasons|deviceStatus/.test(cardCode),
+        '组件只读领域字段(domain/detailFlow.js 的视图对象)',
+    )
+    check(
+        'F3-no-sorting-of-device-cards',
+        !/\.sort\(/.test(cardCode) && !/\.sort\(/.test(flowCode),
+        '设备卡片不排序(验收第 3 条:顺序由后端返回顺序决定)',
+    )
+
+    // 详情的三处(domain/store/组件)都不许碰地图标记:标记计划的唯一来源是 mapStore 的地图摘要。
+    // 注意分两级判:外壳本来就要用 mapService 的相机/归位入口(票 30),所以对「是否 import mapService」
+    // 只查详情自身那三处;而「碰不碰标记层」对四处都查。
+    const detailFiles = ['domain/detailFlow.js', 'stores/detailStore.js', 'components/PointDetailCard.vue']
+    const markerOffenders = [...detailFiles, 'CockpitShell.vue'].filter((file) =>
+        /planPointMarkers|syncPointMarkersForSummary|clearPointMarkers/.test(readCode(file)),
+    )
+    const serviceOffenders = detailFiles.filter((file) => /mapService/.test(readCode(file)))
+    check(
+        'F4-detail-path-never-touches-markers',
+        markerOffenders.length === 0 && serviceOffenders.length === 0,
+        markerOffenders.length || serviceOffenders.length
+            ? `碰了标记层:${markerOffenders.join(', ')};直接依赖 mapService:${serviceOffenders.join(', ')}`
+            : '详情流程与地图标记层零耦合(关联设备不可能被重复画上去)',
+    )
+
+    // 标记计划的唯一写入点:MapCanvas 拿 `mapStore.summary.objects` 调服务层。
+    const mapCanvasCode = readCode('components/MapCanvas.vue')
+    check(
+        'F5-marker-source-is-map-summary-only',
+        /syncPointMarkersForSummary\(mapStore\.summary\.objects\)/.test(mapCanvasCode) &&
+            (readCode('services/mapService.js').match(/planPointMarkers\(/g) || []).length === 1,
+        '全仓只有 mapService 的 syncPointMarkersForSummary 一处调 planPointMarkers,实参是地图摘要对象',
+    )
+    check(
+        'F6-card-mounts-detail-store',
+        /useDetailStore/.test(cardCode) && /detailStore\.selectDevice/.test(cardCode) && /detailStore\.closeDetail/.test(cardCode),
+        '组件的动作全部交回 store(组件里没有自己的状态机副本)',
+    )
+    check(
+        'F7-shell-positions-card-without-map-rebuild',
+        /v-if="detailOpen"/.test(shellCode) && /cockpit-shell__detail/.test(shellCode) && /PointDetailCard/.test(shellCode),
+        '浮层是 main 的兄弟并由外壳定位:地图节点结构不变(票 02 的常驻语义)',
+    )
+}
+
+const failed = checks.filter((item) => !item.pass)
+console.log('')
+console.log(`T10 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((item) => item.name).join(', '))
+    process.exit(1)
+}

+ 509 - 0
.scratch/cockpit-shell-panel-layout/verify/t11-equipment-detail-walkthrough.mjs

@@ -0,0 +1,509 @@
+/**
+ * T11 真机走查:设备详情的六个分区按设备编码加载、分区独立成败(票 11)。
+ *
+ * 逻辑脚本(t11-equipment-detail.mjs)已经把分区映射、视图占位符、store 按编码取数、
+ * 换设备竞态、分区独立与图片单值测透了;这里只补**只有在真机才成立**的部分:
+ *   1. 选设备卡片后,界面**真的**按这台设备的编码把六个分区画出来(DOM 实证,不是读 store);
+ *   2. 分区级部分失败时,坏掉的那块显示失败措辞,**好的三块仍然画出真实内容**
+ *      —— 这是本票验收第 3 条唯一在真机上才能验的形态;
+ *   3. 空 / 过期场景下每块分区都有明确 UI(验收第 4 条);
+ *   4. 最新图片只画出**一张** `<img>`(验收第 5 条);
+ *   5. 打开设备详情不改动地图上的标记数量(票 10 的常驻语义不回退)。
+ *
+ * 运行(需先起 `yarn dev`;本机 dev 端口见启动输出,默认 8000/8001):
+ *   node .scratch/cockpit-shell-panel-layout/verify/t11-equipment-detail-walkthrough.mjs
+ *   COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
+ *
+ * **场景注入的关键**:用 `__COCKPIT_MOCK_ONLY__` 把异常样例**只**施加在设备详情聚合这一个
+ * 端点上,地图与点位摘要保持 success。否则全局 `empty` 会把地图也清空 —— 那就没有可点的
+ * 标记,走查会退化成"点不到东西"的假失败(票 08/09 都踩过这类坑)。这样每个场景点下去
+ * 都是同一台设备的六个分区,只有聚合内容变了,对比才有意义。
+ *
+ * 演示开关(宿主全局键注入,不改代码重新构建;场景观察最直观的一屏是 equipment-partial):
+ *   `__COCKPIT_MOCK_SCENARIO__='equipment-partial'` + `__COCKPIT_MOCK_ONLY__` 取设备详情聚合路径
+ *      —— 档案/工单/图片各自失败,指标/预警/报警照常成功(一块坏了不遮其它块)
+ *   `...SCENARIO__='empty'`   —— 六块都是"没有数据"
+ *   `...SCENARIO__='stale'`   —— 指标块过期但保留旧值
+ *   (`__COCKPIT_MOCK_ONLY__` 的确切写法见下方 `AGGREGATE_ONLY` 常量)
+ */
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
+
+/** 只让设备详情聚合走异常样例,地图与点位摘要保持 success(见文件头说明)。 */
+const AGGREGATE_ONLY = '/equipments/*/detail-aggregate'
+
+const results = []
+/** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */
+function check(id, pass, evidence) {
+    results.push({ id, pass })
+    const rest = evidence === undefined ? '' : String(evidence)
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ::  ${rest}`)
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+const puppeteer = (await import(PUPPETEER)).default
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: join(tmpdir(), 'cockpit-t11-profile'),
+    defaultViewport: { width: 1680, height: 950 },
+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
+})
+console.log(`Chrome: ${await browser.version()}`)
+
+/**
+ * 一次读全:设备详情浮层 DOM(层级/表头/六块分区/图片)+ 地图侧口径(标记计划、宿主 dataset)。
+ * 分区内容是**从 DOM 读**的 —— 走查的价值就在于证明界面真的画出来了,而不是 store 里有值。
+ */
+const readDetail = (page) =>
+    page.evaluate(() => {
+        const host = document.querySelector('.cockpit-shell__map .map-canvas')
+        const card = document.querySelector('[data-point-detail]')
+        const text = (selector) => {
+            const el = card && card.querySelector(selector)
+            return el ? el.textContent.trim() : ''
+        }
+        const dataset = (el) => (el && el.dataset) || {}
+        const dev = window.__cockpitMap
+        const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
+
+        return {
+            hasCard: !!card,
+            level: card ? card.dataset.detailLevel : '',
+            pointCode: card ? card.dataset.pointCode : '',
+            deviceCode: card ? card.dataset.deviceCode : '',
+            deviceName: text('[data-device-name]'),
+            deviceCodeShown: text('[data-device-code-value]'),
+            deviceLoading: !!text('[data-device-loading]'),
+            deviceUnavailable: text('[data-device-unavailable]'),
+            hasRetry: !!(card && card.querySelector('[data-device-retry]')),
+            hasBack: !!(card && card.querySelector('[data-detail-back]')),
+            // ---- 六个分区:每块自带状态、内容与占位说明 ----
+            sections: card
+                ? Array.from(card.querySelectorAll('[data-equipment-section]')).map((el) => ({
+                      name: el.dataset.equipmentSection || '',
+                      // 分区容器自己的状态(data-panel-*,避免与 SectionStateTag 的 data-section-* 撞名)
+                      state: el.dataset.panelState || '',
+                      retained: el.dataset.panelRetained === 'true',
+                      // 分区自己的状态标签(SectionStateTag 的 data-section-*)
+                      tagName: dataset(el.querySelector('[data-section-name]')).sectionName || '',
+                      tagState: dataset(el.querySelector('[data-section-state]')).sectionState || '',
+                      tagText: ((el.querySelector('.section-state__label') || {}).textContent || '').trim(),
+                      title: ((el.querySelector('.equipment-section__title') || {}).textContent || '').trim(),
+                      rows: el.querySelectorAll('[data-archive-row], [data-metric], [data-event], [data-work-order]').length,
+                      imageCount: el.querySelectorAll('[data-latest-image] img').length,
+                      placeholder: ((el.querySelector('[data-section-placeholder]') || {}).textContent || '').trim(),
+                      // 缺失说明:界面上只有条数与 tooltip(字段路径不进可见文案)
+                      missingText: ((el.querySelector('[data-section-missing]') || {}).textContent || '').trim(),
+                      missingCount: Number(dataset(el.querySelector('[data-section-missing]')).missingCount || 0),
+                      missingTitle: (el.querySelector('[data-section-missing]') || {}).title || '',
+                  }))
+                : [],
+            archiveValues: card ? Array.from(card.querySelectorAll('[data-archive-row] dd')).map((el) => el.textContent.trim()) : [],
+            metricValues: card ? Array.from(card.querySelectorAll('[data-metric-value]')).map((el) => el.textContent.trim()) : [],
+            eventLevels: card ? Array.from(card.querySelectorAll('[data-event-level]')).map((el) => el.textContent.trim()) : [],
+            workOrderStates: card
+                ? Array.from(card.querySelectorAll('[data-work-order]')).map((el) => ({
+                      code: el.dataset.workOrder || '',
+                      state: el.dataset.workOrderState || '',
+                      text: ((el.querySelector('.equipment-section__item-name') || {}).textContent || '').trim(),
+                  }))
+                : [],
+            imageSrcs: card ? Array.from(card.querySelectorAll('[data-latest-image] img')).map((el) => el.getAttribute('src') || '') : [],
+            // ---- 地图侧 ----
+            engine: (host && host.dataset.mapEngine) || '',
+            summaryLoaded: Number((host && host.dataset.mapSummaryLoaded) || 0),
+            summaryPhase: (host && host.dataset.mapSummary) || '',
+            dsMarkers: Number((host && host.dataset.mapMarkers) || 0),
+            dsPlan: Number((host && host.dataset.mapMarkersPlan) || 0),
+            picked: (host && host.dataset.mapPicked) || '',
+            entityCount: markers ? markers.onMap : -1,
+            cardHosts: document.querySelectorAll('[data-point-detail]').length,
+            mapHosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length,
+        }
+    })
+
+/** 打开页面并走真实的进入引导(点一下「点击进入系统」)。 */
+async function openPage(scenario) {
+    const page = await browser.newPage()
+    const errors = []
+    page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
+    page.on('console', (m) => {
+        if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
+    })
+    await page.evaluateOnNewDocument(
+        (name) => {
+            window.__COCKPIT_MOCK_SCENARIO__ = name
+            window.__COCKPIT_MOCK_ONLY__ = '/equipments/*/detail-aggregate'
+            window.__COCKPIT_MOCK_MAP__ = { pageSize: 10 }
+        },
+        scenario,
+    )
+
+    await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+    await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+    await page
+        .waitForFunction(
+            () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
+            { timeout: 40000 },
+        )
+        .catch(() => {})
+
+    if (await page.$('[data-cockpit-intro]')) {
+        await page.click('[data-cockpit-intro]')
+        await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
+    }
+    await sleep(1500)
+    return { page, errors }
+}
+
+/** 点「加载下一页」直到翻完(地图上的监测点标记要等它出现)。 */
+async function advanceUntilSettled(page, maxSteps = 12) {
+    for (let step = 0; step < maxSteps; step += 1) {
+        const state = await readDetail(page)
+        if (['complete', 'empty', 'failed', 'expired'].includes(state.summaryPhase)) break
+        const clicked = await page.evaluate(() => {
+            const button = document.querySelector('[data-map-summary-action]')
+            if (!button || button.disabled) return false
+            button.click()
+            return true
+        })
+        if (!clicked) break
+        const before = state.summaryLoaded
+        await page
+            .waitForFunction(
+                (previous) => {
+                    const host = document.querySelector('.cockpit-shell__map .map-canvas')
+                    const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
+                    const phase = (host && host.dataset.mapSummary) || ''
+                    return loaded !== previous || ['failed', 'expired', 'empty', 'complete'].includes(phase)
+                },
+                { timeout: 10000 },
+                before,
+            )
+            .catch(() => {})
+        await sleep(350)
+    }
+    return readDetail(page)
+}
+
+/** 等相机真的落在目标上(阻尼飞行途中点屏幕中心必然点空)。 */
+async function waitForCameraNear(page, target, timeout = 25000) {
+    await page
+        .waitForFunction(
+            ({ lon, lat }) => {
+                const dev = window.__cockpitMap
+                const state = dev && dev.getCameraState ? dev.getCameraState() : null
+                const shown = state && state.shownPivot
+                return !!shown && Math.abs(shown.lon - lon) < 2e-4 && Math.abs(shown.lat - lat) < 2e-4
+            },
+            { timeout },
+            { lon: target.lon, lat: target.lat },
+        )
+        .catch(() => {})
+}
+
+/** 相机中心对准第一个监测点标记。 */
+async function focusOnPoint(page) {
+    const target = await page.evaluate(() => {
+        const dev = window.__cockpitMap
+        const marker = dev.getPointMarkers().markers.find((item) => item.kind === 'monitoring-point')
+        if (!marker) return null
+        dev.setCameraFocus({ lon: marker.longitude, lat: marker.latitude, height: 0 })
+        return { lon: marker.longitude, lat: marker.latitude, code: marker.code }
+    })
+    if (target) await waitForCameraNear(page, target)
+    return target
+}
+
+/** 用引擎拾取在相机中心附近找监测点标记的真实屏幕坐标(贴地标记与中心有高程差)。 */
+async function locatePoint(page, radius = 300, stride = 10) {
+    return page.evaluate(
+        ([max, step]) => {
+            const dev = window.__cockpitMap
+            const canvas = document.querySelector('.cockpit-shell__map canvas')
+            const center = dev.projectCameraPivot()
+            if (!center || !canvas) return { x: 0, y: 0, code: '' }
+
+            for (let dy = -max; dy <= max; dy += step) {
+                for (let dx = -max; dx <= max; dx += step) {
+                    const x = Math.round(center.x + dx)
+                    const y = Math.round(center.y + dy)
+                    const hitElement = document.elementFromPoint(x, y)
+                    if (!hitElement || (hitElement !== canvas && !canvas.contains(hitElement))) continue
+                    const hit = dev.pickPoint(x, y)
+                    if (hit && hit.kind === 'monitoring-point') return { x, y, dx, dy, code: hit.code }
+                }
+            }
+            return { x: 0, y: 0, dx: 0, dy: 0, code: '' }
+        },
+        [radius, stride],
+    )
+}
+
+/**
+ * 打开设备详情:真实鼠标点监测点标记 → 点位摘要 → 点第一张设备卡片 → 设备级。
+ * 这条路径是 CONTEXT.md 的「两级详情」正路,也是用户实际会走的。
+ */
+async function openDeviceDetail(scenario) {
+    const { page, errors } = await openPage(scenario)
+    await advanceUntilSettled(page)
+
+    const target = await focusOnPoint(page)
+    const locatedPoint = await locatePoint(page)
+    if (await page.$('[data-detail-close]')) {
+        await page.click('[data-detail-close]')
+        await sleep(250)
+    }
+
+    if (locatedPoint.code) {
+        await page.mouse.click(locatedPoint.x, locatedPoint.y)
+        await sleep(700)
+    }
+
+    const summary = await readDetail(page)
+    const cards = await page.evaluate(() =>
+        Array.from(document.querySelectorAll('[data-device-card]')).map((el) => el.dataset.deviceCode || ''),
+    )
+
+    if (cards.length) {
+        await page.click('[data-device-card="0"]')
+        // 等六块分区真的画出来(聚合要发一次请求)。
+        await page.waitForSelector('[data-equipment-section]', { timeout: 10000 }).catch(() => {})
+        await sleep(600)
+    }
+
+    return { page, errors, target, locatedPoint, summary, cards, detail: await readDetail(page) }
+}
+
+const SECTION_ORDER = ['archive', 'metrics', 'warnings', 'alarms', 'workOrders', 'latestImage']
+const sectionOf = (detail, name) => detail.sections.find((item) => item.name === name) || null
+
+try {
+    // ================================================================ A 成功场景:六块分区按设备编码画出来
+    {
+        const { page, errors, cards, detail } = await openDeviceDetail('success')
+        const code = cards[0] || ''
+
+        check(
+            'A1-device-card-enters-equipment-level',
+            !!code && detail.hasCard && detail.level === 'equipment' && detail.deviceCode === code,
+            code ? `选设备卡片 ${code} → 层级 ${detail.level}` : '点位摘要里没有设备卡片',
+        )
+        check(
+            'A2-six-sections-rendered-in-order',
+            detail.sections.length === 6 && detail.sections.map((item) => item.name).join(',') === SECTION_ORDER.join(','),
+            `六块分区:${detail.sections.map((item) => item.title).join(' / ')}`,
+        )
+        check(
+            'A3-every-section-has-its-own-status-tag',
+            detail.sections.length === 6 &&
+                detail.sections.every((item) => !!item.tagName && !!item.tagState && !!item.tagText) &&
+                detail.sections.every((item) => item.tagName === item.name),
+            detail.sections.map((item) => `${item.name}:${item.tagState}/${item.tagText}`).join(' · '),
+        )
+        check(
+            'A4-loaded-by-device-code',
+            detail.deviceCode === code && detail.deviceCodeShown === code && detail.deviceLoading === false,
+            `浮层表头 ${detail.deviceCodeShown} = 选中的设备 ${code},且已不在加载态`,
+        )
+
+        const archive = sectionOf(detail, 'archive')
+        const metrics = sectionOf(detail, 'metrics')
+        const warnings = sectionOf(detail, 'warnings')
+        const alarms = sectionOf(detail, 'alarms')
+        const orders = sectionOf(detail, 'workOrders')
+        const image = sectionOf(detail, 'latestImage')
+
+        check(
+            'A5-archive-has-real-rows',
+            archive && archive.rows >= 10 && detail.archiveValues.some((value) => value.includes('污水环境监测设备')),
+            `档案 ${archive && archive.rows} 行,含设备名`,
+        )
+        check(
+            'A6-metrics-have-readouts-including-real-zero',
+            metrics && metrics.rows >= 2 && detail.metricValues.length >= 2 && detail.metricValues.some((value) => value.startsWith('0')),
+            `指标 ${metrics && metrics.rows} 条:${detail.metricValues.join(' / ')}(含真 0 读数,没被当成缺失)`,
+        )
+        check(
+            'A7-warnings-and-alarms-listed',
+            warnings && warnings.rows >= 1 && alarms && alarms.rows >= 1 && detail.eventLevels.every((level) => level.endsWith('级')),
+            `预警 ${warnings && warnings.rows} 条 · 报警 ${alarms && alarms.rows} 条 · 等级 ${detail.eventLevels.join('/')}`,
+        )
+        check(
+            'A8-work-orders-show-state-name',
+            orders && orders.rows >= 1 && detail.workOrderStates.every((order) => order.text.includes('处理中')),
+            `工单 ${orders && orders.rows} 张:${detail.workOrderStates.map((order) => `${order.code}=${order.text}`).join(' · ')}`,
+        )
+        check(
+            'A9-latest-image-is-exactly-one',
+            image && image.imageCount === 1 && detail.imageSrcs.length === 1 && !!detail.imageSrcs[0],
+            `最新图片分区画出 ${detail.imageSrcs.length} 张 <img>:${detail.imageSrcs.join(', ')}`,
+        )
+        check(
+            'A10-detail-does-not-rebuild-map',
+            detail.cardHosts === 1 && detail.mapHosts === 1 && detail.entityCount === detail.dsPlan,
+            `浮层 ${detail.cardHosts} 个 · 地图宿主 ${detail.mapHosts} 个 · 实体 ${detail.entityCount} = 计划 ${detail.dsPlan}(地图节点未重建)`,
+        )
+        // 票 05 走过一次的坑:界面漏出内部代号。真机取一份**整卡可见文本**来查,
+        // 而不是查单个 data-* 节点 —— 漏在标题、meta 行或占位说明里同样要被抓到。
+        const cardText = await page.evaluate(() => (document.querySelector('[data-point-detail]') || {}).innerText || '')
+        const codeLeaks = ['READY', 'SAMPLE_OUTDATED', 'NO_EVENTS', 'NO_METRICS', 'NO_ARCHIVE', 'NO_WORK_ORDERS', 'NO_IMAGE', 'READ_FAILURE', 'SUCCESS', 'FAILED', 'STALE', 'EMPTY']
+            .filter((code) => new RegExp(`\\b${code}\\b`).test(cardText))
+        // 字段路径(`archive.deviceName` 这类)也属接口词汇,不该出现在可见文案里。
+        const pathLeaks = (cardText.match(/\b[a-zA-Z]+\.[a-zA-Z]+\b/g) || []).filter((token) => !token.includes('.webp') && !token.includes('.js'))
+        check(
+            'A10b-visible-text-has-no-internal-codes',
+            codeLeaks.length === 0 && pathLeaks.length === 0,
+            codeLeaks.length || pathLeaks.length
+                ? `可见文案泄漏:代号 ${codeLeaks.join(',') || '无'} · 字段路径 ${pathLeaks.join(',') || '无'}`
+                : '整卡可见文本零内部代号、零字段路径(缺失只报条数,路径在 tooltip 里)',
+        )
+        check('A11-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+
+        await page.close()
+    }
+
+    // ================================================================ B 分区级部分失败:坏块不遮好块
+    {
+        const { page, errors, cards, detail } = await openDeviceDetail('equipment-partial')
+
+        if (!cards.length) {
+            check('B0-partial-scenario-has-device-card', false, '点位摘要里没有设备卡片')
+        } else {
+            const archive = sectionOf(detail, 'archive')
+            const metrics = sectionOf(detail, 'metrics')
+            const warnings = sectionOf(detail, 'warnings')
+            const alarms = sectionOf(detail, 'alarms')
+            const orders = sectionOf(detail, 'workOrders')
+            const image = sectionOf(detail, 'latestImage')
+
+            check(
+                'B1-broken-sections-report-failure',
+                archive && archive.state === 'failed' && orders && orders.state === 'failed' && image && image.state === 'failed',
+                `坏块:档案=${archive && archive.state} 工单=${orders && orders.state} 图片=${image && image.state}`,
+            )
+            check(
+                'B2-broken-sections-say-so-in-words',
+                archive && archive.placeholder.includes('加载失败') && orders.placeholder.includes('加载失败') && image.placeholder.length > 0,
+                `坏块措辞:「${archive && archive.placeholder}」`,
+            )
+            check(
+                'B3-healthy-sections-still-render-real-content',
+                metrics && metrics.state === 'success' && metrics.rows >= 2 && detail.metricValues.length >= 2 &&
+                    warnings && warnings.state === 'success' && warnings.rows >= 1 &&
+                    alarms && alarms.state === 'success' && alarms.rows >= 1,
+                `好块照常:指标 ${metrics && metrics.rows} 条(${detail.metricValues.join('/')})· 预警 ${warnings && warnings.rows} 条 · 报警 ${alarms && alarms.rows} 条`,
+            )
+            check(
+                'B4-failure-does-not-blank-the-card',
+                detail.sections.length === 6 && detail.level === 'equipment' && detail.deviceCode === cards[0],
+                `六块分区都在(${detail.sections.map((item) => item.state).join('/')}),浮层没白屏也没自动关掉`,
+            )
+            check(
+                'B5-guard-has-teeth',
+                detail.sections.filter((item) => item.state === 'failed').length === 3 &&
+                    detail.sections.filter((item) => item.state === 'success').length === 3,
+                '恰好 3 坏 3 好(因此 B3「好块仍在」不是恒真)',
+            )
+            check('B6-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        }
+
+        await page.close()
+    }
+
+    // ================================================================ C 空场景:每块都有明确 UI
+    {
+        const { page, errors, cards, detail } = await openDeviceDetail('empty')
+
+        if (!cards.length) {
+            check('C0-empty-scenario-has-device-card', false, '点位摘要里没有设备卡片')
+        } else {
+            check(
+                'C1-empty-sections-all-say-no-data',
+                detail.sections.length === 6 &&
+                    detail.sections.every((item) => item.state === 'empty') &&
+                    detail.sections.every((item) => item.placeholder === '该分区当前没有数据'),
+                detail.sections.map((item) => `${item.name}=${item.state}`).join(' · '),
+            )
+            check(
+                'C2-empty-does-not-fake-zero-values',
+                detail.metricValues.length === 0 && detail.archiveValues.length === 0 && detail.workOrderStates.length === 0 && detail.imageSrcs.length === 0,
+                '空场景下没有任何伪造的 0 / 空行 / 空图',
+            )
+            check('C3-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        }
+
+        await page.close()
+    }
+
+    // ================================================================ D 过期场景:有值 + 过期标识
+    {
+        const { page, errors, cards, detail } = await openDeviceDetail('stale')
+
+        if (!cards.length) {
+            check('D0-stale-scenario-has-device-card', false, '点位摘要里没有设备卡片')
+        } else {
+            const metrics = sectionOf(detail, 'metrics')
+            const archive = sectionOf(detail, 'archive')
+
+            check(
+                'D1-stale-section-flagged-and-valued',
+                metrics && metrics.state === 'stale' && metrics.retained === true && metrics.rows >= 2 && detail.metricValues.length >= 2,
+                `指标块 ${metrics && metrics.state}(retained=${metrics && metrics.retained})但仍画出 ${detail.metricValues.length} 条读数`,
+            )
+            check(
+                'D2-stale-not-confused-with-empty',
+                metrics && metrics.placeholder === '' && metrics.tagText.includes('过期'),
+                `过期块说的是「${metrics && metrics.tagText}」,不是「无数据」`,
+            )
+            check(
+                'D3-stale-does-not-affect-other-sections',
+                archive && archive.state === 'success' && archive.rows >= 10,
+                `档案块照常 ${archive && archive.state}(${archive && archive.rows} 行)—— 过期不牵连`,
+            )
+            check('D4-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        }
+
+        await page.close()
+    }
+    // ================================================================ E 整包失败:一句实话 + 重试入口
+    {
+        const { page, errors, cards, detail } = await openDeviceDetail('failed')
+
+        if (!cards.length) {
+            check('E0-failed-scenario-has-device-card', false, '点位摘要里没有设备卡片')
+        } else {
+            check(
+                'E1-whole-failure-says-one-truthful-thing',
+                detail.level === 'equipment' && !!detail.deviceUnavailable && detail.sections.length === 0,
+                `整包失败时说的是「${detail.deviceUnavailable}」(不渲染六块,因此不会逐块谎报"其它分区不受影响")`,
+            )
+            check(
+                'E2-whole-failure-offers-retry',
+                detail.hasRetry === true && detail.deviceLoading === false,
+                '给了「重新加载」入口,不是死胡同',
+            )
+            check(
+                'E3-whole-failure-does-not-blank-or-close',
+                detail.hasCard === true && detail.cardHosts === 1 && detail.deviceCode === cards[0],
+                `浮层仍在、表头仍带设备 ${detail.deviceCode}(没白屏、没自动关窗)`,
+            )
+            check('E4-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+        }
+
+        await page.close()
+    }
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((item) => !item.pass)
+console.log(`\nT11 走查:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`)
+process.exit(failed.length ? 1 : 0)

+ 752 - 0
.scratch/cockpit-shell-panel-layout/verify/t11-equipment-detail.mjs

@@ -0,0 +1,752 @@
+/**
+ * T11 设备详情聚合与分区状态 纯逻辑检查(票 11)。
+ *
+ * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
+ * 运行:node .scratch/cockpit-shell-panel-layout/verify/t11-equipment-detail.mjs
+ *
+ * 六段:
+ *   A 分区映射:后端四态 → 前端分区词汇;每个分区各带自己的 state/reason/message
+ *   B 视图层:六块面板计划、缺值占位符、空/失败/不可用的措辞、字段缺失清单
+ *   C store:按**设备编码**取数、换设备竞态收口、已答过的同一台不重复取、失败可重试
+ *   D 分区独立:一块失败不遮盖其它成功块(含对抗式反证:真的只坏了一块)
+ *   E 最新图片:只展示后端指定的那一张(结构化验证:前端没有"挑一张"的路径)
+ *   F 静态守卫:组件不拼路径、不认后端裸字段、不排序、不抢票 12/13 的活
+ *
+ * 契约依据:`CockpitEquipmentDetailAggregateResponse`(后端演示页
+ * `equipment-detail-aggregate-demo.html`)—— 每个分区自带 `state/reason/message/data`。
+ */
+import { readFileSync } from 'node:fs'
+import { createPinia, setActivePinia } from 'pinia'
+import {
+    EQUIPMENT_SECTIONS,
+    toEquipmentDetailAggregate,
+    toDeviceImage,
+    toSection,
+} from '../../../src/modules/cockpit/domain/equipmentDetail.js'
+import { SECTION_STATE } from '../../../src/modules/cockpit/domain/sectionState.js'
+import {
+    DEFERRED_SECTIONS,
+    EQUIPMENT_SECTION_PANELS,
+    archiveRows,
+    collectMissing,
+    emptyListText,
+    aggregateDeviceCode,
+    aggregateUnavailable,
+    equipmentDetailMatches,
+    equipmentPanels,
+    eventRows,
+    imageView,
+    metricRows,
+    metricsSourceText,
+    missingNote,
+    quantityText,
+    sectionHasContent,
+    sectionPlaceholder,
+    valueText,
+    workOrderRows,
+} from '../../../src/modules/cockpit/domain/equipmentDetailView.js'
+import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js'
+import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
+import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
+import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js'
+
+const checks = []
+/** `check(name, pass, evidence)` —— 第二参是**断言结果**,不是描述。 */
+function check(name, pass, evidence) {
+    checks.push({ name, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${name}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
+const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
+const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
+/** 去掉注释只留代码:本文件的注释会**解释**被禁的东西,直接对全文正则会拿假阳性换通过率。 */
+const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
+const readCode = (relative) => code(read(relative))
+/**
+ * `.vue` 的注释是 `<!-- -->`(HTML),上面那个只剥 JS 注释的 `code()` 剥不掉 ——
+ * 组件的抬头注释里**故意**写着 `FAILED / STALE / EMPTY` 这些后端词来解释语义,
+ * 不剥就会把"解释"当成"泄漏"(本守卫第一版正是这么假红的)。
+ */
+const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
+const readVueCode = (relative) => codeVue(read(relative))
+
+setActivePinia(createPinia())
+
+/** 造一份后端分区(`CockpitSectionResult`)。 */
+const rawSection = (state, { reason = 'SUCCESS', message = null, data = null } = {}) => ({ state, reason, message, data })
+
+/** 造一份后端聚合载荷;只给想给的分区(其余留空=后端没给,前端应算失败)。 */
+const rawAggregate = (deviceCode, sections = {}) => ({
+    deviceCode,
+    aggregateState: 'READY',
+    serverTime: '2026-09-02T10:00:00+08:00',
+    ...sections,
+})
+
+// ================================================================ A 分区映射
+
+{
+    const aggregate = toEquipmentDetailAggregate({
+        deviceCode: 'EQ-A',
+        aggregateState: 'PARTIAL',
+        archive: rawSection('FAILED', { reason: 'READ_FAILURE', message: '档案读不到' }),
+        metrics: rawSection('SUCCESS', { data: { deviceCode: 'EQ-A', state: 'CURRENT', metrics: [] } }),
+        warnings: rawSection('SUCCESS', { data: [] }),
+        alarms: rawSection('EMPTY', { reason: 'NO_EVENTS' }),
+        workOrders: rawSection('STALE', { reason: 'SAMPLE_OUTDATED', data: [{ workOrderCode: 'WO-1', stateCode: 4 }] }),
+        latestImage: rawSection('SUCCESS', { data: { url: '/cockpit/x.webp', capturedAt: '2026-08-30T10:20:00+08:00' } }),
+    })
+
+    check(
+        'A1-all-eight-sections-present',
+        EQUIPMENT_SECTIONS.length === 8 && EQUIPMENT_SECTIONS.every((name) => !!aggregate.sections[name]),
+        `${EQUIPMENT_SECTIONS.length} 个分区都有位置(后端没给的也留位)`,
+    )
+    check(
+        'A2-backend-state-mapped-1to1',
+        aggregate.sections.archive.state === SECTION_STATE.FAILED &&
+            aggregate.sections.metrics.state === SECTION_STATE.SUCCESS &&
+            aggregate.sections.alarms.state === SECTION_STATE.EMPTY &&
+            aggregate.sections.workOrders.state === SECTION_STATE.STALE,
+        JSON.stringify({
+            archive: aggregate.sections.archive.state,
+            metrics: aggregate.sections.metrics.state,
+            alarms: aggregate.sections.alarms.state,
+            workOrders: aggregate.sections.workOrders.state,
+        }),
+    )
+    check(
+        'A3-failed-section-keeps-backend-message',
+        aggregate.sections.archive.reason === '档案读不到',
+        `界面文案取后端 message:「${aggregate.sections.archive.reason}」`,
+    )
+    check(
+        'A4-stale-keeps-value-and-flag',
+        aggregate.sections.workOrders.retained === true && aggregate.sections.workOrders.data[0].code === 'WO-1',
+        '过期分区保留最后已知值,并带 retained 标识(票 16:不得伪装成实时)',
+    )
+    check(
+        'A5-empty-has-no-data',
+        aggregate.sections.alarms.data === null,
+        '空分区不伪造空数组冒充数据',
+    )
+    check(
+        'A6-missing-section-counts-as-failed',
+        aggregate.sections.timelines.state === SECTION_STATE.FAILED,
+        '后端整段缺失的分区算失败,不算「成功但没内容」',
+    )
+    check(
+        'A7-aggregate-state-not-authoritative',
+        aggregate.aggregateState === 'PARTIAL' && aggregate.sections.metrics.state === SECTION_STATE.SUCCESS,
+        '界面分区状态以各分区自己为准,整体态漏标也不会拖垮成功分区',
+    )
+    check(
+        'A8-unknown-state-falls-to-failed',
+        toSection('archive', { state: 'SOMETHING_NEW', data: { deviceCode: 'X' } }).state === SECTION_STATE.FAILED,
+        '认不出的后端状态不静默混进「已就绪」',
+    )
+}
+
+// ================================================================ B 视图层
+
+{
+    // 缺值 vs 真 0:这是票 16 与票 11 验收第 4 条的交汇点。
+    check(
+        'B1-zero-is-a-value-not-missing',
+        valueText(0) === '0' && valueText(18.2) === '18.2' && quantityText(0, 'm³/h') === '0 m³/h',
+        '后端真给的 0 是有效读数,照原样显示',
+    )
+    check(
+        'B2-missing-shows-placeholder',
+        valueText(null) === DETAIL_PLACEHOLDER &&
+            valueText(undefined) === DETAIL_PLACEHOLDER &&
+            valueText('') === DETAIL_PLACEHOLDER &&
+            quantityText(null, 'mg/L') === DETAIL_PLACEHOLDER,
+        `缺值显示占位符(${DETAIL_PLACEHOLDER}),且不拼出「— mg/L」这种半截读数`,
+    )
+
+    check(
+        'B3-placeholder-text-per-state',
+        sectionPlaceholder({ state: SECTION_STATE.EMPTY }).includes('没有数据') &&
+            sectionPlaceholder({ state: SECTION_STATE.FAILED }).includes('加载失败') &&
+            sectionPlaceholder({ state: SECTION_STATE.UNAVAILABLE }).includes('不可用') &&
+            sectionPlaceholder({ state: SECTION_STATE.EMPTY }).includes('其它分区') === false &&
+            sectionPlaceholder({ state: SECTION_STATE.FAILED }).includes('其它分区') === true,
+        '空 / 失败 / 不可用各有明确且不同的措辞(失败态明确说「不影响其它分区」)',
+    )
+    check(
+        'B4-stale-and-success-have-content',
+        sectionHasContent({ state: SECTION_STATE.SUCCESS }) === true &&
+            sectionHasContent({ state: SECTION_STATE.STALE }) === true &&
+            sectionHasContent({ state: SECTION_STATE.EMPTY }) === false &&
+            sectionHasContent({ state: SECTION_STATE.FAILED }) === false,
+        '过期是有值(内容照画),空与失败才是没内容',
+    )
+
+    // 档案行:字段缺失显示占位符,不是空白、也不是 0。
+    const archive = archiveRows({ code: 'EQ-1', name: '污水设备', assetValue: 128000, maintainer: '' })
+    check(
+        'B5-archive-rows-placeholder',
+        archive.find((row) => row.key === 'assetValue').value === '128000' &&
+            archive.find((row) => row.key === 'maintainer').value === DETAIL_PLACEHOLDER &&
+            archive.find((row) => row.key === 'model').value === DETAIL_PLACEHOLDER,
+        '有值的照原样、缺值的占位符',
+    )
+
+    // 指标行:真 0 与缺值必须区分开(同一张表里)。
+    const metrics = metricRows({
+        metrics: [
+            { code: 'FLOW', name: '流量', value: 0, unit: 'm³/h' },
+            { code: 'COD', name: 'COD' },
+        ],
+    })
+    check(
+        'B6-metric-zero-vs-missing',
+        metrics[0].value === '0' && metrics[1].value === DETAIL_PLACEHOLDER,
+        `真 0 → ${metrics[0].value};缺 value → ${metrics[1].value}`,
+    )
+
+    // 工单行:状态名缺失时按七态字典补;未结案的真实 null 显示占位符。
+    const orders = workOrderRows([{ workOrderCode: 'WO-1', stateCode: 4, realFinishTime: null }, { workOrderCode: 'WO-2', stateCode: 99 }])
+    check(
+        'B7-work-order-state-from-dictionary',
+        orders[0].stateName === '处理中' && orders[1].stateName === DETAIL_PLACEHOLDER,
+        `码 4 → ${orders[0].stateName};认不出的码 99 → ${orders[1].stateName}(不猜)`,
+    )
+    check(
+        'B8-unclosed-order-placeholder',
+        orders[0].realFinishTime === DETAIL_PLACEHOLDER,
+        '未结案的真实 null 与字段缺失一样显示占位符',
+    )
+
+    // 事件行:等级只出文本,配色原样带出后端值(配色归票 13/19)。
+    const events = eventRows([
+        { warningNo: 'WRN-1', warningName: 'COD 预警', level: 2, levelColor: 'YELLOW', value: 18.2, unit: 'mg/L', eventTime: 'T' },
+        { warningNo: 'WRN-2', warningName: '缺等级预警' },
+    ])
+    check(
+        'B9-event-level-and-color',
+        events[0].levelText === '2 级' && events[0].levelColor === 'YELLOW' && events[0].quantity === '18.2 mg/L' && events[1].levelText === DETAIL_PLACEHOLDER,
+        '等级出文本、配色如实带后端值、缺等级占位符',
+    )
+
+    // 字段缺失清单:要能穿透到嵌套层(单个指标自己记的 missing)。
+    const missing = collectMissing({
+        code: 'EQ-1',
+        missing: ['archive.deviceName'],
+        metrics: [{ code: 'COD', missing: ['metric.value'] }],
+    })
+    check(
+        'B10-missing-collected-recursively',
+        missing.includes('archive.deviceName') && missing.includes('metric.value') && missing.length === 2,
+        `嵌套缺失也收上来:${missing.join(', ')}`,
+    )
+    check(
+        'B11-missing-collector-survives-cycles',
+        collectMissing((() => {
+            const node = { missing: ['self'] }
+            node.self = node
+            return node
+        })())[0] === 'self',
+        '自引用形状不炸(契约缺口造出的怪数据也不能让界面挂掉)',
+    )
+
+    // 给人读的缺失说明:只报条数,字段路径进 tooltip。
+    // 票 05 的真机走查逮到过「界面漏出内部代号」,所以这条是**回归**,不是新要求。
+    const note = missingNote({ missing: ['archive.deviceName', 'metric.value'] })
+    check(
+        'B11b-missing-note-hides-field-paths',
+        note.count === 2 &&
+            note.text === '后端未提供 2 项字段' &&
+            !note.text.includes('archive.') &&
+            note.tooltip.includes('archive.deviceName'),
+        `界面文案「${note.text}」不含字段路径;路径只在 tooltip:「${note.tooltip}」`,
+    )
+
+    // 指标来源文案:认得出的翻人话,认不出的**回空串**(绝不把 SAMPLE_OUTDATED 这类代号画上去)。
+    check(
+        'B11c-source-reason-never-leaks-codes',
+        metricsSourceText({ stateReason: 'READY' }) === '' &&
+            metricsSourceText({ stateReason: 'SAMPLE_OUTDATED' }) === '采样已超过配置周期' &&
+            metricsSourceText({ stateReason: 'SOMETHING_NEW' }) === '' &&
+            !metricsSourceText({ stateReason: 'SOMETHING_NEW' }).includes('SOMETHING_NEW'),
+        `SAMPLE_OUTDATED → 「${metricsSourceText({ stateReason: 'SAMPLE_OUTDATED' })}」;未知码 → 空串(不泄漏)`,
+    )
+
+    // 空列表提示:SUCCESS 但列表为空,仍是明确 UI 而不是一片空白。
+    check(
+        'B12-empty-list-is-explicit',
+        emptyListText('工单') === '工单当前没有记录',
+        `「${emptyListText('工单')}」`,
+    )
+}
+
+// ================================================================ C store 取数与竞态
+
+{
+    const store = useDetailStore()
+    store.reset()
+    store.configure({ config: mockConfig })
+
+    let aggregateRequests = 0
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
+    // 计数用:精确路径优先于 `/equipments/*/detail-aggregate` 模板。
+    registerMockTransport('/equipments/EQ-COUNT/detail-aggregate', () => {
+        aggregateRequests += 1
+        return rawAggregate('EQ-COUNT', {
+            archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-COUNT', deviceName: '计数设备' } }),
+            metrics: rawSection('SUCCESS', { data: { deviceCode: 'EQ-COUNT', state: 'CURRENT', metrics: [] } }),
+        })
+    })
+
+    const selection = await store.selectDevice('EQ-COUNT')
+    check(
+        'C1-select-device-loads-by-code',
+        selection.level === 'equipment' &&
+            selection.deviceCode === 'EQ-COUNT' &&
+            store.sections.equipmentAggregate.state === SECTION_STATE.SUCCESS &&
+            store.sections.equipmentAggregate.loaded === true &&
+            aggregateRequests === 1,
+        `层级 ${selection.level}、请求 ${aggregateRequests} 次`,
+    )
+    check(
+        'C2-aggregate-carries-requested-code',
+        store.sections.equipmentAggregate.data.deviceCode === 'EQ-COUNT',
+        '按设备编码取数(传错编码能看出来)',
+    )
+
+    // 已答过的同一台设备:不再重复取数。
+    await store.selectDevice('EQ-COUNT')
+    check(
+        'C3-same-device-does-not-refetch',
+        aggregateRequests === 1,
+        `再选同一台后请求数仍为 ${aggregateRequests}`,
+    )
+
+    // 返回点位级再点回来:仍不重复取数(已加载且是同一台)。
+    store.selectDevice('EQ-COUNT')
+    store.backToPoint()
+    await store.selectDevice('EQ-COUNT')
+    check(
+        'C4-back-and-forth-does-not-refetch',
+        aggregateRequests === 1 && store.sections.equipmentAggregate.data.deviceCode === 'EQ-COUNT',
+        `来回一次后请求数仍为 ${aggregateRequests}`,
+    )
+
+    // 换设备:A 慢、B 快 —— B 必须赢,A 迟到不得覆盖。
+    let releaseA = null
+    registerMockTransport('/equipments/EQ-SLOW-A/detail-aggregate', () => new Promise((resolve) => {
+        releaseA = () => resolve(rawAggregate('EQ-SLOW-A', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-SLOW-A', deviceName: 'A 设备' } }) }))
+    }))
+    registerMockTransport('/equipments/EQ-FAST-B/detail-aggregate', () =>
+        rawAggregate('EQ-FAST-B', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-FAST-B', deviceName: 'B 设备' } }) }),
+    )
+
+    const pendingA = store.selectDevice('EQ-SLOW-A')
+    await store.selectDevice('EQ-FAST-B')
+    const afterB = store.sections.equipmentAggregate.data.deviceCode
+    releaseA()
+    await pendingA
+    await new Promise((resolve) => setTimeout(resolve, 0))
+
+    check(
+        'C5-race-latest-wins',
+        afterB === 'EQ-FAST-B' &&
+            store.selection.deviceCode === 'EQ-FAST-B' &&
+            store.sections.equipmentAggregate.data.deviceCode === 'EQ-FAST-B',
+        `B 落地后聚合里是 ${store.sections.equipmentAggregate.data.deviceCode}(A 迟到被丢弃)`,
+    )
+    check(
+        'C6-stale-aggregate-not-shown-for-new-device',
+        equipmentDetailMatches(store.sections.equipmentAggregate, 'EQ-SLOW-A') === false &&
+            equipmentDetailMatches(store.sections.equipmentAggregate, 'EQ-FAST-B') === true,
+        'interface 的匹配判据挡住了「把 A 的载荷挂在 B 表头下」',
+    )
+
+    // 失败留恢复路径:第一次抛错、第二次成功(同一个入口重试)。
+    let flakyAttempts = 0
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
+    registerMockTransport('/equipments/EQ-FLAKY/detail-aggregate', () => {
+        flakyAttempts += 1
+        if (flakyAttempts === 1) throw new Error('mock: 第一次读取失败')
+        return rawAggregate('EQ-FLAKY', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-FLAKY', deviceName: '重试设备' } }) })
+    })
+
+    const retryStore = useDetailStore()
+    retryStore.reset()
+    retryStore.configure({ config: mockConfig })
+    await retryStore.selectDevice('EQ-FLAKY')
+    const firstState = retryStore.sections.equipmentAggregate.state
+    await retryStore.selectDevice('EQ-FLAKY')
+    check(
+        'C7-failed-device-can-be-retried',
+        firstState === SECTION_STATE.FAILED &&
+            flakyAttempts === 2 &&
+            retryStore.sections.equipmentAggregate.state === SECTION_STATE.SUCCESS,
+        `第一次 ${firstState} → 再选一次(第 ${flakyAttempts} 次请求)后 ${retryStore.sections.equipmentAggregate.state}`,
+    )
+
+    // 关闭详情:在途请求作废,选择回空。
+    const closeStore = useDetailStore()
+    closeStore.reset()
+    closeStore.configure({ config: mockConfig })
+    const inFlight = closeStore.selectDevice('EQ-SLOW-A')
+    const closed = closeStore.closeDetail()
+    await inFlight
+    check(
+        'C8-close-invalidates-in-flight',
+        closed.level === 'none' && closeStore.selection.deviceCode === '' && closeStore.loading === false && closeStore.deviceLoading === false,
+        JSON.stringify({ level: closed.level, deviceLoading: closeStore.deviceLoading }),
+    )
+
+    // 未关联设备直接进设备级:同样按编码取数(没有点位摘要这一层)。
+    const deviceStore = useDetailStore()
+    deviceStore.reset()
+    deviceStore.configure({ config: mockConfig })
+    registerMockTransport('/equipments/EQ-FREE-9/detail-aggregate', () =>
+        rawAggregate('EQ-FREE-9', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-FREE-9', deviceName: '未关联设备' } }) }),
+    )
+    await deviceStore.openDevice('EQ-FREE-9')
+    check(
+        'C9-unassociated-device-loads-aggregate',
+        deviceStore.selection.pointCode === '' &&
+            deviceStore.selection.deviceCode === 'EQ-FREE-9' &&
+            deviceStore.sections.equipmentAggregate.loaded === true,
+        JSON.stringify(deviceStore.selection),
+    )
+
+    // 没有编码:不发请求,也不把分区卡在加载中。
+    const blankStore = useDetailStore()
+    blankStore.reset()
+    blankStore.configure({ config: mockConfig })
+    await blankStore.selectDevice('')
+    check(
+        'C10-blank-code-does-not-load',
+        blankStore.sections.equipmentAggregate.loaded === false && blankStore.deviceLoading === false,
+        '缺编码不取数、不卡加载态',
+    )
+
+    // 预载(loadSharedSections)与用户进级抢同一个分区:慢的那份不得覆盖快的那份。
+    // 场景是外壳挂载时预载演示设备,用户抢先点了另一台 —— 预载的迟到响应必须被丢弃。
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
+    let releasePreload = null
+    registerMockTransport('/equipments/TC-PS-WS-202609020012/detail-aggregate', () => new Promise((resolve) => {
+        releasePreload = () => resolve(rawAggregate('TC-PS-WS-202609020012', { archive: rawSection('SUCCESS', { data: { deviceCode: 'TC-PS-WS-202609020012', deviceName: '预载演示设备' } }) }))
+    }))
+
+    const raceStore = useDetailStore()
+    raceStore.reset()
+    raceStore.configure({ config: mockConfig })
+    const preloading = raceStore.loadSharedSections({ config: mockConfig })
+    await raceStore.selectDevice('EQ-USER')
+    const userCode = raceStore.sections.equipmentAggregate.data.deviceCode
+    releasePreload()
+    await preloading
+    await new Promise((resolve) => setTimeout(resolve, 0))
+    check(
+        'C11-preload-does-not-clobber-user-device',
+        userCode === 'EQ-USER' && raceStore.sections.equipmentAggregate.data.deviceCode === 'EQ-USER',
+        `用户选的 ${userCode} 没被预载的演示设备覆盖(预载迟到被丢弃)`,
+    )
+
+    // 整包失败时的界面判据:它要说"这台没取到",而不是逐块说"其它分区不受影响"。
+    // 用真 store 走一遍失败,断言判据在这一刻成立、且重试入口能恢复。
+    let downAttempts = 0
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
+    registerMockTransport('/equipments/EQ-DOWN/detail-aggregate', () => {
+        downAttempts += 1
+        if (downAttempts === 1) throw new Error('mock: 整包读取失败')
+        return rawAggregate('EQ-DOWN', { archive: rawSection('SUCCESS', { data: { deviceCode: 'EQ-DOWN', deviceName: '恢复设备' } }) })
+    })
+
+    const downStore = useDetailStore()
+    downStore.reset()
+    downStore.configure({ config: mockConfig })
+    await downStore.selectDevice('EQ-DOWN')
+    const downSection = downStore.sections.equipmentAggregate
+    check(
+        'C12-whole-failure-shows-single-truthful-state',
+        downSection.state === SECTION_STATE.FAILED &&
+            aggregateUnavailable(downSection) === true &&
+            downSection.data === null,
+        '整包失败:判据成立(界面据此只说一句实话,不逐块谎报"其它分区不受影响")',
+    )
+
+    // 重试入口:再点一次同一台即可恢复(失败不算"已答过")。
+    await downStore.selectDevice('EQ-DOWN')
+    check(
+        'C13-whole-failure-retry-recovers',
+        downAttempts === 2 &&
+            downStore.sections.equipmentAggregate.state === SECTION_STATE.SUCCESS &&
+            aggregateUnavailable(downStore.sections.equipmentAggregate) === false,
+        `重试后第 ${downAttempts} 次请求 → ${downStore.sections.equipmentAggregate.state}(界面回到六块分区)`,
+    )
+}
+
+// ================================================================ D 分区独立(核心验收)
+
+{
+    // 真机走同一份契约:`equipment-partial` 场景里有三块分区各自失败、三块照常成功。
+    const store = useDetailStore()
+    store.reset()
+    store.configure({ config: mockConfig })
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'equipment-partial', handlers: { registerMockTransport, resetMockTransportsForTest } })
+
+    await store.selectDevice('TC-PS-WS-202609020012')
+    const panels = equipmentPanels(store.sections.equipmentAggregate)
+    const byName = Object.fromEntries(panels.map((panel) => [panel.name, panel]))
+
+    check(
+        'D1-six-panels-in-order',
+        panels.length === 6 &&
+            panels.map((panel) => panel.name).join(',') === 'archive,metrics,warnings,alarms,workOrders,latestImage',
+        `${panels.length} 块:${panels.map((panel) => panel.label).join(' / ')}`,
+    )
+    check(
+        'D2-one-failure-does-not-cover-others',
+        byName.archive.section.state === SECTION_STATE.FAILED &&
+            byName.metrics.section.state === SECTION_STATE.SUCCESS &&
+            byName.warnings.section.state === SECTION_STATE.SUCCESS &&
+            byName.alarms.section.state === SECTION_STATE.SUCCESS &&
+            byName.workOrders.section.state === SECTION_STATE.FAILED &&
+            byName.latestImage.section.state === SECTION_STATE.FAILED,
+        `档案=${byName.archive.section.state} 指标=${byName.metrics.section.state} 预警=${byName.warnings.section.state} 报警=${byName.alarms.section.state} 工单=${byName.workOrders.section.state} 图片=${byName.latestImage.section.state}`,
+    )
+    check(
+        'D3-successful-panels-still-render-content',
+        byName.metrics.hasContent === true &&
+            byName.metrics.section.data.metrics.length > 0 &&
+            byName.warnings.hasContent === true &&
+            byName.warnings.section.data.length > 0,
+        `指标 ${byName.metrics.section.data.metrics.length} 条、预警 ${byName.warnings.section.data.length} 条仍然画得出来`,
+    )
+    check(
+        'D4-failed-panels-say-why',
+        byName.archive.hasContent === false &&
+            byName.archive.placeholder.includes('加载失败') &&
+            byName.archive.section.reason === '设备档案数据暂不可用',
+        `失败块显示「${byName.archive.placeholder}」并带上后端原因`,
+    )
+
+    // 对抗式反证:换一个**整体成功**的场景,同样的判据必须全绿 ——
+    // 否则上面那条 D2 可能只是因为"所有块都失败了"而恒真。
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
+    const allGoodStore = useDetailStore()
+    allGoodStore.reset()
+    allGoodStore.configure({ config: mockConfig })
+    await allGoodStore.selectDevice('TC-PS-WS-202609020012')
+    const allGood = equipmentPanels(allGoodStore.sections.equipmentAggregate)
+    check(
+        'D5-guard-has-teeth',
+        allGood.every((panel) => panel.section.state === SECTION_STATE.SUCCESS) &&
+            allGood.every((panel) => panel.hasContent === true) &&
+            allGood.length === 6,
+        '成功场景下六块全绿(因此 D2 的"部分失败"是有信息量的,不是恒真)',
+    )
+
+    // 空场景:六块都该说「没有数据」,而不是显示虚假零值。
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, resetMockTransportsForTest } })
+    const emptyStore = useDetailStore()
+    emptyStore.reset()
+    emptyStore.configure({ config: mockConfig })
+    await emptyStore.selectDevice('TC-PS-WS-202609020012')
+    const emptyPanels = equipmentPanels(emptyStore.sections.equipmentAggregate)
+    check(
+        'D6-empty-scenario-all-placeholders',
+        emptyPanels.every((panel) => panel.section.state === SECTION_STATE.EMPTY && panel.hasContent === false) &&
+            emptyPanels.every((panel) => panel.placeholder === '该分区当前没有数据'),
+        '空场景六块都是明确占位说明,没有一块冒充有数据',
+    )
+
+    // 过期场景:指标块过期但有值,其它块照常成功(过期不牵连)。
+    resetMockTransportsForTest()
+    registerCockpitMocks({ scenario: 'stale', handlers: { registerMockTransport, resetMockTransportsForTest } })
+    const staleStore = useDetailStore()
+    staleStore.reset()
+    staleStore.configure({ config: mockConfig })
+    await staleStore.selectDevice('TC-PS-WS-202609020012')
+    const stalePanels = Object.fromEntries(equipmentPanels(staleStore.sections.equipmentAggregate).map((panel) => [panel.name, panel]))
+    check(
+        'D7-stale-is-value-not-empty',
+        stalePanels.metrics.section.state === SECTION_STATE.STALE &&
+            stalePanels.metrics.section.retained === true &&
+            stalePanels.metrics.hasContent === true &&
+            stalePanels.metrics.section.data.metrics.length > 0 &&
+            stalePanels.archive.section.state === SECTION_STATE.SUCCESS,
+        `指标块 ${stalePanels.metrics.section.state} 但仍画出 ${stalePanels.metrics.section.data.metrics.length} 条读数;档案块照常 ${stalePanels.archive.section.state}`,
+    )
+}
+
+// ================================================================ D2 整包失败 ≠ 分区级失败
+
+{
+    // 这两件事必须分开,否则界面会对值班人员撒谎:
+    //   · 整包失败(端点读不到,data 为 null)→ 六块一起没数据,「其它分区不受影响」是假话;
+    //   · 分区级失败(data 在、各分区自带 state)→ 真正的部分失败,六块照常渲染。
+    const wholeFailed = { state: SECTION_STATE.FAILED, data: null, loaded: true, reason: 'unreachable' }
+    const partialData = toEquipmentDetailAggregate({
+        deviceCode: 'EQ-P',
+        aggregateState: 'PARTIAL',
+        archive: rawSection('FAILED', { reason: 'READ_FAILURE', message: '档案读不到' }),
+        metrics: rawSection('SUCCESS', { data: { deviceCode: 'EQ-P', state: 'CURRENT', metrics: [{ metricCode: 'COD', value: 1 }] } }),
+    })
+    const partial = { state: SECTION_STATE.SUCCESS, data: partialData, loaded: true }
+
+    check(
+        'D8-whole-failure-detected',
+        aggregateUnavailable(wholeFailed) === true && aggregateUnavailable(partial) === false,
+        '整包失败与分区级失败分得开',
+    )
+    check(
+        'D9-partial-failure-still-renders-panels',
+        aggregateUnavailable(partial) === false && equipmentPanels(partial).length === 6,
+        `分区级失败仍渲染六块(档案=${equipmentPanels(partial)[0].section.state}、指标=${equipmentPanels(partial)[1].section.state})`,
+    )
+    // 未加载(还没请求)既不是失败也不是就绪:不该显示"这台设备详情没取到"。
+    check(
+        'D10-not-loaded-is-not-a-failure',
+        aggregateUnavailable({ state: SECTION_STATE.EMPTY, data: null, loaded: false }) === false &&
+            aggregateUnavailable(null) === false,
+        '还没取数时不报"没取到"',
+    )
+    // 整包失败**无法**从载荷里认出是哪台设备(data 为 null、带不出编码)——
+    // 所以判定刻意不比对编码,而靠 store 的 token 纪律保证「失败态必定属于当前这台」。
+    // 这条断言把这个前提钉住:一旦有人让失败分区带上编码,就该重新审视上面的推理。
+    check(
+        'D11-whole-failure-carries-no-device-code',
+        aggregateDeviceCode(wholeFailed) === '' && equipmentDetailMatches(wholeFailed, 'EQ-P') === false,
+        '失败分区带不出设备编码(故判定不依赖编码;靠 token 纪律保证归属当前设备)',
+    )
+}
+
+// ================================================================ E 最新图片只展示一张
+{
+    const one = imageView({ url: '/cockpit/a.webp', capturedAt: 'T' })
+    check(
+        'E1-image-shows-backend-designated-one',
+        one.hasImage === true && one.url === '/cockpit/a.webp',
+        `画的是后端给的那一张:${one.url}`,
+    )
+    check(
+        'E2-image-empty-and-missing-safe',
+        imageView(null).hasImage === false && imageView({}).hasImage === false && imageView({ url: '   ' }).hasImage === false,
+        '后端没给 / 给了空串都不画空图',
+    )
+    check(
+        'E3-image-never-indexes-a-list',
+        imageView([{ url: '/a.webp' }, { url: '/b.webp' }]).hasImage === false,
+        '后端若给数组,前端**不**挑第一张(挑图=替后端做业务判断)',
+    )
+    check(
+        'E4-adapter-image-is-single-value',
+        toDeviceImage({ url: '/cockpit/a.webp' }).url === '/cockpit/a.webp' &&
+            (() => {
+                const archive = toSection('archive', rawSection('SUCCESS', { data: { deviceCode: 'EQ-1', latestImage: { url: '/cockpit/a.webp' } } }))
+                return !!archive.data.image && !Array.isArray(archive.data.image) && archive.data.image.url === '/cockpit/a.webp'
+            })(),
+        '档案里的 latestImage 是单值对象,不是列表',
+    )
+
+    // 静态守卫:渲染层不该出现任何"从多张里挑一张"的代码。
+    const viewCode = readCode('domain/equipmentDetailView.js')
+    const componentCode = readCode('components/EquipmentDetailSections.vue')
+    check(
+        'E5-no-picking-among-images',
+        !/\.find\(|\.filter\(|\.sort\(|\.slice\(/.test(viewCode.split('export function imageView')[1].split('export function')[0] || '') &&
+            !/latestImage\s*\[|\bimages\b/.test(componentCode),
+        '图片路径里没有 find/filter/sort/slice,组件也不认数组',
+    )
+}
+
+// ================================================================ F 静态守卫
+
+{
+    const componentCode = readVueCode('components/EquipmentDetailSections.vue')
+    const cardCode = readVueCode('components/PointDetailCard.vue')
+    const viewCode = readCode('domain/equipmentDetailView.js')
+    const storeCode = readCode('stores/detailStore.js')
+
+    check(
+        'F1-component-does-not-build-api-paths',
+        !/\/equipments\/|\/monitoring-points\//.test(componentCode) && !/fetch\(/.test(componentCode),
+        '接口路径只在 services/detailApi.js,组件不自己拼',
+    )
+    check(
+        'F2-component-has-no-backend-field-names',
+        !/deviceModel|deviceSpec|ownershipUnit|maintainerPhone|expectedServiceYears|metricCode|warningNo|alarmId|workOrderCode|latestImage|aggregateState|stateReason/.test(componentCode),
+        '组件只读视图对象字段(后端裸字段名只在 domain 适配层出现)',
+    )
+    check(
+        'F3-component-does-not-sort',
+        !/\.sort\(/.test(componentCode) && !/\.sort\(/.test(viewCode),
+        '展示顺序由计划与后端顺序决定,前端不重排',
+    )
+    check(
+        'F4-section-state-comes-from-section-state-module',
+        /sectionView/.test(readCode('components/SectionStateTag.vue')) && !/is-lv1|#ff4d5e/.test(componentCode),
+        '分区状态色键与文案只来自 domain/sectionState.js(组件不写第二套)',
+    )
+
+    // 票 05 的真机走查逮到过「界面漏出内部原因码」(`unavailable` 那一次)。
+    // 设备详情多了一个同类风险面:后端 `stateReason` 与字段缺失清单都是接口词汇,
+    // 直接铺到大屏上就是给值班人员看噪音。所以两类词汇都必须在 domain 层翻掉,
+    // 组件里**不许**出现任何后端代号(当作词匹配,不要求带引号 ——
+    // 真正会漏到界面上的形态恰恰是模板里的裸文本 `<p>SAMPLE_OUTDATED</p>`,
+    // 只查引号字面量会放过它;这条守卫第一版就是这么假绿的)。
+    const reasonCodeLiterals = ['READY', 'SAMPLE_OUTDATED', 'NO_EVENTS', 'NO_METRICS', 'NO_ARCHIVE', 'NO_WORK_ORDERS', 'NO_IMAGE', 'READ_FAILURE', 'SUCCESS', 'FAILED', 'STALE', 'EMPTY']
+    const leaked = reasonCodeLiterals.filter((code) => new RegExp(`\\b${code}\\b`).test(componentCode))
+    check(
+        'F4b-no-backend-reason-codes-in-ui',
+        leaked.length === 0,
+        leaked.length ? `组件里出现后端代号:${leaked.join(', ')}` : '组件零后端代号(都是 domain 翻好的人话)',
+    )
+
+    // 字段缺失清单同理:组件只画条数,字段路径不许出现在可见文案里。
+    check(
+        'F4c-no-field-paths-in-visible-text',
+        !/\{\{\s*panel\.missing\.fields/.test(componentCode) &&
+            !/\{\{\s*panel\.missing\.join/.test(componentCode) &&
+            /:title="panel\.missing\.tooltip"/.test(componentCode),
+        '缺失只报条数,字段路径只进 title(排查拿得到,值班人员看不到)',
+    )
+    check(
+        'F5-deferred-sections-not-rendered',
+        DEFERRED_SECTIONS.every((name) => !EQUIPMENT_SECTION_PANELS.some((panel) => panel.name === name)) &&
+            DEFERRED_SECTIONS.every((name) => !new RegExp(name).test(componentCode)),
+        `时间线(票 12)与预警-报警关联(票 13)不在本票渲染计划内:${DEFERRED_SECTIONS.join(', ')}`,
+    )
+    check(
+        'F6-store-loads-aggregate-on-level-entry',
+        /export function fetchEquipmentDetailAggregate/.test(readCode('services/detailApi.js')) &&
+            /fetchEquipmentDetailAggregate\(deviceCode/.test(storeCode) &&
+            /async function enterDeviceLevel/.test(storeCode),
+        '设备级入口(selectDevice / openDevice)自己负责取数,调用方不必记得多调一次',
+    )
+    check(
+        'F7-card-does-not-hold-its-own-aggregate-state',
+        /useDetailStore/.test(cardCode) && /EquipmentDetailSections/.test(cardCode) && !/reactive\(|ref\(/.test(cardCode.split('<script setup>')[1] || ''),
+        '组件把状态交给 store,自己不存一份(票 10 的 F6 立场延续)',
+    )
+    check(
+        'F8-no-write-operations',
+        !/method:\s*'(POST|PUT|PATCH|DELETE)'|method:\s*"(POST|PUT|PATCH|DELETE)"/.test(readCode('services/detailApi.js')) &&
+            !/@click="[^"]*(save|submit|update|delete)/i.test(componentCode),
+        '驾驶舱只读:本票不引入任何写操作(spec 验收第 6 条)',
+    )
+}
+
+const failed = checks.filter((item) => !item.pass)
+console.log('')
+console.log(`T11 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((item) => item.name).join(', '))
+    process.exit(1)
+}

+ 293 - 0
.scratch/cockpit-shell-panel-layout/verify/t21-gis-walkthrough.mjs

@@ -0,0 +1,293 @@
+/**
+ * T21 真机走查:本地 DEM(地形)+ 本地影像切片 + 免费全局底图,真的按配置挂到引擎上。
+ *
+ * 关键是**用磁盘上那份真实配置**(`public/cockpit/config.json`)注入,而不是在脚本里另写一份,
+ * 这样跑通就同时证明「配置文件 → 配置服务 → 引擎」这条链路是对的。
+ *
+ * 断言里最有分量的一条:所有 `/gis/**` 响应必须全是 200 —— DEM 是 TMS(layer.json 里 scheme=tms,
+ * 由 Cesium 自己翻)而影像是 TMS 需要 `{reverseY}`;一旦 y 轴约定搞错,这里立刻会冒出 404。
+ *
+ * 运行(需先起 `yarn dev` 且 `.env.local` 配了 COCKPIT_GIS_MOUNTS):
+ *   node .scratch/cockpit-shell-panel-layout/verify/t21-gis-walkthrough.mjs
+ */
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
+const OUT = join(tmpdir(), 'cockpit-t21-yuanling.png')
+
+/** 沅陵县城附近(用来把相机压到精细数据上)。 */
+const YUANLING = { lon: 110.396, lat: 28.455, height: 0 }
+
+const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8'))
+
+/** Web Mercator XYZ 瓦片的经纬度范围(用来断言「只为声明范围内的瓦片发请求」)。 */
+function tileBounds(z, x, y) {
+    const n = 2 ** z
+    const lonLeft = (x / n) * 360 - 180
+    const lonRight = ((x + 1) / n) * 360 - 180
+    const latTop = (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))) * 180) / Math.PI
+    const latBottom = (Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 1)) / n))) * 180) / Math.PI
+    return [lonLeft, latBottom, lonRight, latTop]
+}
+
+function intersects(a, b) {
+    return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1]
+}
+
+const declaredBounds = config.map.overlays[0].bounds
+
+const results = []
+function check(id, desc, pass, evidence) {
+    results.push({ id, desc, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ${desc}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+const puppeteer = (await import(PUPPETEER)).default
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: join(tmpdir(), 'cockpit-t21-profile'),
+    defaultViewport: { width: 1680, height: 950 },
+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
+})
+console.log(`Chrome: ${await browser.version()}`)
+
+try {
+    const page = await browser.newPage()
+    const gisResponses = []
+    const errors = []
+    page.on('response', (response) => {
+        const url = response.url()
+        if (url.includes('/gis/')) gisResponses.push({ url: url.replace(BASE, ''), status: response.status() })
+    })
+    page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
+    page.on('console', (m) => {
+        if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
+    })
+
+    await page.evaluateOnNewDocument((injected) => {
+        // 票 30:本走查直接测"进入之后"的图层加载,所以关掉引导层(否则它会挡住滚轮/点击)。
+        window.__COCKPIT_CONFIG__ = { ...injected, map: { ...injected.map, intro: { enabled: false } } }
+    }, config)
+
+    await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+    await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+    await page
+        .waitForFunction(
+            () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready',
+            { timeout: 60000 },
+        )
+        .catch(() => {})
+    await page.waitForFunction(() => !!window.__cockpitMap, { timeout: 20000 }).catch(() => {})
+    await sleep(2500)
+
+    const readState = () =>
+        page.evaluate(() => {
+            const host = document.querySelector('.cockpit-shell__map .map-canvas')
+            return {
+                engine: (host && host.dataset.mapEngine) || '',
+                basemap: (host && host.dataset.mapBasemap) || '',
+                terrain: (host && host.dataset.mapTerrain) || '',
+                pill: host?.querySelector('[data-map-engine-status]')?.textContent?.trim() || '',
+                stats: window.__cockpitMap ? window.__cockpitMap.getLifecycleStats() : null,
+            }
+        })
+
+    // ---------- A1 三层都被认出并挂上 ----------
+    const state = await readState()
+    check(
+        'A1-layers-applied',
+        '离线全局底图 + 本地 DEM 地形 + 本地影像叠加层都按配置挂上(dataset 与浮标一致)',
+        state.engine === 'ready' &&
+            state.basemap === 'natural-earth' &&
+            state.terrain === 'ready' &&
+            state.stats?.terrain === 'ready' &&
+            state.stats?.overlays === 1 &&
+            state.pill.includes('叠加影像 1 层'),
+        JSON.stringify({ ...state, stats: state.stats && { terrain: state.stats.terrain, overlays: state.stats.overlays } }),
+    )
+
+    // ---------- A2 bounds 生效:本地切片只为声明范围内的瓦片发请求 ----------
+    // 注意:整球视角下 Cesium **会**为范围内那几块低层瓦片发请求,这是对的行为;
+    // 要断言的是「不越界」,不是「一个都不发」(越界才会刷一屏 404)。
+    const parsePicTile = (url) => {
+        const m = url.match(/^\/gis\/pic3\/(\d+)\/(\d+)\/(\d+)\.png$/)
+        return m ? { z: Number(m[1]), x: Number(m[2]), y: Number(m[3]) } : null
+    }
+    const picTilesSoFar = gisResponses.map((item) => parsePicTile(item.url)).filter(Boolean)
+    const detailedOutside = picTilesSoFar.filter((tile) => tile.z >= 5 && !intersects(tileBounds(tile.z, tile.x, tile.y), declaredBounds))
+    check(
+        'A2-bounds-respected',
+        '本地影像的**细层**(z≥5)只请求声明范围内的瓦片(低层是 Cesium 的祖先预加载,越界属正常)',
+        detailedOutside.length === 0,
+        JSON.stringify({ requested: picTilesSoFar.length, maxZ: Math.max(0, ...picTilesSoFar.map((t) => t.z)), outside: detailedOutside.slice(0, 3) }),
+    )
+
+    // ---------- A3 压到沅陵并拉近:地形与影像都开始取细瓦片,且全部 200 ----------
+    await page.evaluate((point) => {
+        window.__cockpitMap.setCameraFocus(point)
+    }, YUANLING)
+    await sleep(1500)
+
+    // 滚轮是票 03 契约里的「弹簧臂距离」:setCameraFocus 只换中心、保持距离(ADR 0008),
+    // 所以要真的看到精细数据,得像用户那样滚轮拉近。注意别拉过头:相机高度是按**椭球**算的
+    // (票 03 的地面拾取走 pickEllipsoid),拉到几百米就会钻进山头里,画面全黑 —— 停在公里级。
+    const canvas = await page.evaluate(() => {
+        const c = document.querySelector('.cockpit-shell__map .map-canvas canvas')
+        const r = c.getBoundingClientRect()
+        return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
+    })
+    const readCamera = () =>
+        page.evaluate(() => {
+            const state = window.__cockpitMap.getCameraState()
+            return { range: Math.round(state.hpr.range), pivot: state.pivot }
+        })
+
+    await page.mouse.move(canvas.x, canvas.y)
+    for (let step = 0; step < 16; step++) {
+        const camera = await readCamera()
+        if (camera.range <= 8000) break
+        await page.mouse.wheel({ deltaY: -900 })
+        await sleep(400)
+    }
+    await sleep(9000)
+    const cameraAtShot = await readCamera()
+
+    const bad = gisResponses.filter((item) => item.status !== 200)
+    const demTiles = gisResponses.filter((item) => item.url.startsWith('/gis/dem/') && item.url.endsWith('.terrain'))
+    const picTiles = gisResponses.filter((item) => item.url.startsWith('/gis/pic3/'))
+    const levels = demTiles.map((item) => Number((item.url.match(/\/(\d+)\/\d+\/\d+\.terrain$/) || [])[1])).filter(Number.isFinite)
+    check(
+        'A3-local-tiles-200',
+        '压到沅陵拉近后:本地 DEM 与本地影像都在取瓦片,且**没有一条 404**(y 轴约定与 availability 都对)',
+        demTiles.length > 0 && picTiles.length > 0 && bad.length === 0,
+        JSON.stringify({ dem: demTiles.length, pic: picTiles.length, maxLevel: Math.max(...levels), bad: bad.slice(0, 3) }),
+    )
+
+    check(
+        'A3-terrain-high-zoom',
+        '拉近后取到了高等级 DEM 瓦片(说明精细区域真的被加载,而不是只有 layer.json)',
+        Math.max(...levels) >= 8,
+        JSON.stringify({ maxLevel: Math.max(...levels), tail: demTiles.slice(-3) }),
+    )
+
+    check(
+        'A3-camera-over-yuanling',
+        '镜头停在沅陵上空且距离在公里级(避免钻进山头导致黑屏;也证明换中心真的生效)',
+        cameraAtShot.pivot &&
+            Math.abs(cameraAtShot.pivot.lon - YUANLING.lon) < 0.05 &&
+            Math.abs(cameraAtShot.pivot.lat - YUANLING.lat) < 0.05 &&
+            cameraAtShot.range > 500 &&
+            cameraAtShot.range <= 8000,
+        JSON.stringify(cameraAtShot),
+    )
+
+    await page.screenshot({ path: OUT })
+    check('A3-screenshot', `沅陵视角截图已落盘:${OUT}`, true, OUT)
+
+    check('A4-no-page-errors', '全程无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+
+    // ---------- A5 地形地址配错:明确失败态,外壳与底图照常 ----------
+    {
+        const brokenPage = await browser.newPage()
+        try {
+            await brokenPage.evaluateOnNewDocument((injected) => {
+                window.__COCKPIT_CONFIG__ = { ...injected, map: { ...injected.map, intro: { enabled: false } } }
+            }, { map: { basemap: { source: 'natural-earth' }, terrain: { url: '/gis/definitely-missing-dem' } } })
+            await brokenPage.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+            await brokenPage.waitForSelector('.cockpit-shell', { timeout: 20000 })
+            await brokenPage
+                .waitForFunction(
+                    () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapTerrain === 'invalid',
+                    { timeout: 30000 },
+                )
+                .catch(() => {})
+            const broken = await brokenPage.evaluate(() => {
+                const host = document.querySelector('.cockpit-shell__map .map-canvas')
+                return {
+                    engine: (host && host.dataset.mapEngine) || '',
+                    terrain: (host && host.dataset.mapTerrain) || '',
+                    basemap: (host && host.dataset.mapBasemap) || '',
+                    pill: host?.querySelector('[data-map-engine-status]')?.textContent?.trim() || '',
+                }
+            })
+            check(
+                'A5-terrain-failure-state',
+                'DEM 地址配错时:地形标成 invalid、浮标写明失败并说明回退椭球,引擎与底图不受影响',
+                broken.engine === 'ready' &&
+                    broken.terrain === 'invalid' &&
+                    broken.basemap === 'natural-earth' &&
+                    broken.pill.includes('地形加载失败') &&
+                    broken.pill.includes('回退椭球'),
+                JSON.stringify(broken),
+            )
+        } finally {
+            await brokenPage.close()
+        }
+    }
+
+    // ---------- A6/A7 3D Tiles 入口(票 21 第 2 条:入口留好并能如实报状态) ----------
+    // A6 用夹具(只有包围盒的合法 tileset,服务在 /gis/tiles)证明链路真通;
+    // A7 用维护者本机那份 yuanlingSHP(缺根 tileset.json)证明失败是**可见的**,不是静默吞掉。
+    {
+        const tilesPage = await browser.newPage()
+        try {
+            await tilesPage.evaluateOnNewDocument((injected) => {
+                window.__COCKPIT_CONFIG__ = { ...injected, map: { ...injected.map, intro: { enabled: false } } }
+            }, {
+                map: {
+                    basemap: { source: 'natural-earth' },
+                    tilesets: [{ url: '/gis/tiles/tileset.json' }, { url: '/gis/tiles/does-not-exist.json' }],
+                },
+            })
+            await tilesPage.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+            await tilesPage.waitForSelector('.cockpit-shell', { timeout: 20000 })
+            await tilesPage
+                .waitForFunction(
+                    () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapTilesets !== 'none',
+                    { timeout: 30000 },
+                )
+                .catch(() => {})
+            const tiles = await tilesPage.evaluate(() => {
+                const host = document.querySelector('.cockpit-shell__map .map-canvas')
+                return {
+                    engine: (host && host.dataset.mapEngine) || '',
+                    tilesets: (host && host.dataset.mapTilesets) || '',
+                    pill: host?.querySelector('[data-map-engine-status]')?.textContent?.trim() || '',
+                    stats: window.__cockpitMap ? window.__cockpitMap.getLifecycleStats() : null,
+                }
+            })
+            check(
+                'A6-tileset-loads-and-reports',
+                '3D Tiles 入口可用:合法 tileset 挂上(stats.tilesets==1),同批里坏的 URL 单独记失败且不影响引擎',
+                tiles.engine === 'ready' &&
+                    tiles.tilesets === 'invalid' &&
+                    tiles.stats?.tilesets === 1 &&
+                    (tiles.stats?.tilesetErrors || []).length === 1 &&
+                    tiles.pill.includes('3D Tiles 1 个') &&
+                    tiles.pill.includes('3D Tiles 加载失败'),
+                JSON.stringify({ ...tiles, stats: tiles.stats && { tilesets: tiles.stats.tilesets, errors: tiles.stats.tilesetErrors?.length } }),
+            )
+        } finally {
+            await tilesPage.close()
+        }
+    }
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((r) => !r.pass)
+console.log('')
+console.log(`T21 走查:${results.length - failed.length}/${results.length} 项通过。`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((f) => f.id).join(', '))
+    process.exit(1)
+}
+console.log('T21 走查全部通过。')

+ 110 - 0
.scratch/cockpit-shell-panel-layout/verify/t21-terrain.mjs

@@ -0,0 +1,110 @@
+/**
+ * T21 地形与本地影像(配置驱动)纯逻辑检查。
+ *
+ * 覆盖:`map.terrain` / `map.overlays` 的归一化与计划层;以及在**本机**数据存在时,
+ * 让配置与磁盘上的真实数据对齐核对(layer.json 的 scheme/投影、meta.json 的范围与配置里的 bounds)。
+ * 数据目录不存在时(别的机器)自动跳过对齐核对,只跑纯逻辑断言。
+ *
+ * 运行:node .scratch/cockpit-shell-panel-layout/verify/t21-terrain.mjs
+ */
+import assert from 'node:assert/strict'
+import { existsSync, readFileSync } from 'node:fs'
+import { planOverlays, planTerrain, terrainStatusText, TERRAIN_STATE } from '../../../src/modules/cockpit/domain/mapLayers.js'
+import { builtinConfig, normalizeConfig } from '../../../src/modules/cockpit/services/cockpitConfig.js'
+
+// ---------------------------------------------------------------- 1. 默认不配地形/叠加
+const defaults = builtinConfig()
+assert.equal(defaults.map.terrain.url, '', '默认不配地形')
+assert.equal(defaults.map.terrain.requestVertexNormals, true, '默认要顶点法线(有光照才立体)')
+assert.deepEqual(defaults.map.overlays, [], '默认没有叠加影像')
+assert.equal(planTerrain(defaults.map).state, TERRAIN_STATE.NONE, '未配置时地形状态是 none')
+assert.equal(planTerrain(defaults.map).ready, false, '未配置时不可用')
+assert.match(terrainStatusText(null), /椭球/, '未配置时的措辞应说明退化成椭球')
+
+// ---------------------------------------------------------------- 2. 归一化
+const normalized = normalizeConfig({
+    map: {
+        terrain: { url: ' /gis/dem ', requestVertexNormals: false },
+        overlays: [
+            { url: ' /gis/pic3/{z}/{x}/{reverseY}.png ', minimumLevel: 0, maximumLevel: 19, bounds: [109.9, 27.9, 111.1, 29.1], credit: ' 本地影像 ' },
+            { url: '', minimumLevel: 'x', bounds: [1, 2, 3] },
+        ],
+    },
+})
+assert.equal(normalized.config.map.terrain.url, '/gis/dem', '地形地址应去空白')
+assert.equal(normalized.config.map.terrain.requestVertexNormals, false, '显式关顶点法线应被接受')
+assert.equal(normalized.config.map.overlays.length, 2, '两个叠加项都保留(空 url 由计划层过滤)')
+assert.equal(normalized.config.map.overlays[0].url, '/gis/pic3/{z}/{x}/{reverseY}.png', '叠加模板应去空白')
+assert.deepEqual(normalized.config.map.overlays[0].bounds, [109.9, 27.9, 111.1, 29.1], '范围应透传')
+assert.equal(normalized.config.map.overlays[0].credit, '本地影像', '署名应去空白')
+assert.equal(normalized.config.map.overlays[1].minimumLevel, null, '非整数级别应被忽略')
+assert.equal(normalized.config.map.overlays[1].bounds, null, '长度不对的 bounds 应被忽略')
+assert.ok(
+    normalized.issues.some((issue) => /minimumLevel/.test(issue)) && normalized.issues.some((issue) => /bounds/.test(issue)),
+    `非法级别与非法范围应各记一条 issue,实际 ${JSON.stringify(normalized.issues)}`,
+)
+
+const badTypes = normalizeConfig({ map: { terrain: { url: 123, requestVertexNormals: 'yes' }, overlays: 'nope' } })
+assert.equal(badTypes.config.map.terrain.url, '', '非字符串地形地址应退回默认')
+assert.equal(badTypes.config.map.terrain.requestVertexNormals, true, '非布尔开关应退回默认')
+assert.deepEqual(badTypes.config.map.overlays, [], '非数组 overlays 应退回空')
+assert.equal(badTypes.issues.length, 3, `三类错各记一条,实际 ${JSON.stringify(badTypes.issues)}`)
+
+// ---------------------------------------------------------------- 3. 计划层
+const terrainPlan = planTerrain({ terrain: { url: '/gis/dem' } })
+assert.equal(terrainPlan.state, TERRAIN_STATE.READY, '配了地址即为 ready')
+assert.equal(terrainPlan.requestVertexNormals, true, '默认带顶点法线')
+assert.match(terrainStatusText(terrainPlan), /dem/, '措辞应能看出用的是哪个目录')
+
+const overlays = planOverlays({
+    overlays: [
+        { url: 'a/{z}/{x}/{reverseY}.png', minimumLevel: 0, maximumLevel: 19, bounds: [1, 2, 3, 4], credit: 'x' },
+        { url: '' },
+        { url: 'b/{z}/{x}/{y}.png', bounds: [1, 2, 3] },
+    ],
+})
+assert.equal(overlays.length, 2, '空 url 应被过滤')
+assert.equal(overlays[0].bounds.length, 4, '合法 bounds 应保留')
+assert.equal(overlays[1].bounds, null, '非法 bounds 应为 null(引擎侧不设 rectangle)')
+assert.equal(overlays[0].maximumLevel, 19, '级别应透传')
+
+// ---------------------------------------------------------------- 4. 与磁盘真实数据对齐(仅本机有数据时)
+const configPath = new URL('../../../public/cockpit/config.json', import.meta.url)
+if (existsSync(configPath)) {
+    const raw = JSON.parse(readFileSync(configPath, 'utf8'))
+    const { config } = normalizeConfig(raw)
+    const terrainDir = config.map.terrain.url
+
+    // 配置里的地形指向的目录里应真有 layer.json,且是量化网格 + EPSG:4326
+    const mounts = (process.env.COCKPIT_GIS_MOUNTS || '').includes('yuanlingDEM')
+    if (mounts) {
+        const demDir = (process.env.COCKPIT_GIS_MOUNTS.match(/"dir":"([^"]*yuanlingDEM)"/) || [])[1]
+        if (demDir && existsSync(`${demDir}/layer.json`)) {
+            const layer = JSON.parse(readFileSync(`${demDir}/layer.json`, 'utf8'))
+            assert.equal(layer.format, 'quantized-mesh-1.0', '地形应是量化网格')
+            assert.equal(layer.projection, 'EPSG:4326', '地形应是地理坐标(global-geodetic)')
+            assert.equal(layer.scheme, 'tms', '地形 layer.json 声明 tms —— Cesium 认这个字段,服务端不用翻 y')
+            assert.ok(layer.extensions.includes('octvertexnormals'), '地形带八面体顶点法线(对应 requestVertexNormals)')
+            assert.equal(config.map.terrain.url, '/gis/dem', '配置里的地形 URL 应与挂载前缀一致')
+        }
+
+        const picDir = (process.env.COCKPIT_GIS_MOUNTS.match(/"dir":"([^"]*yuanlingPic3)"/) || [])[1]
+        if (picDir && existsSync(`${picDir}/meta.json`)) {
+            const meta = JSON.parse(readFileSync(`${picDir}/meta.json`, 'utf8'))
+            assert.equal(meta.tiletrans, 'tms', '影像是 TMS —— 模板必须用 {reverseY}')
+            assert.equal(meta.proj, 3857, '影像是 Web Mercator')
+            assert.match(config.map.overlays[0].url, /\{reverseY\}/, '叠加模板应使用 {reverseY} 以匹配 TMS')
+            const [w, s, e, n] = config.map.overlays[0].bounds
+            assert.ok(
+                Math.abs(w - meta.bounds.west) < 1e-6 && Math.abs(s - meta.bounds.south) < 1e-6 &&
+                    Math.abs(e - meta.bounds.east) < 1e-6 && Math.abs(n - meta.bounds.north) < 1e-6,
+                '配置里的 bounds 应与 meta.json 一致(范围外不请求瓦片)',
+            )
+            console.log('  (已按本机数据核对:layer.json 的 tms/EPSG:4326/顶点法线,meta.json 的 tms/3857/bounds)')
+        }
+    }
+}
+
+console.log(
+    'T21 检查通过:地形与叠加影像的归一化 + 计划层(默认不配、缺字段忽略、bounds/级别校验),并在本机有数据时把配置与 layer.json / meta.json 对齐核对。',
+)

+ 257 - 0
.scratch/cockpit-shell-panel-layout/verify/t21-view-modes-walkthrough.mjs

@@ -0,0 +1,257 @@
+/**
+ * T21 真机走查:地面/管线视图切换(票 21 验收第 4、5 条)。
+ *
+ * 逻辑脚本(t21-view-modes.mjs)把取值测透了,这里只测真机上才成立的部分:
+ *   · 点「管线视图」:相机真的被压低 + pitch 真的加大,且**旋转中心一点没变**;
+ *   · 地形真的变半透明(globe.translucency 生效);
+ *   · 切换视图后**三键分工照旧**(左键仍只平移、滚轮仍只改距离);
+ *   · 回「地面视图」:倾角/距离按进管线前的基线还原;
+ *   · 视图条件在**切换一级模式时不重置**(与范围/行业同为全局条件)。
+ *
+ * 运行(需先起 `yarn dev`):node .scratch/cockpit-shell-panel-layout/verify/t21-view-modes-walkthrough.mjs
+ */
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
+const OUT = join(tmpdir(), 'cockpit-t21-pipeline.png')
+
+const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8'))
+/** 地面视图的默认倾角(与 domain/viewModes.js 的 GROUND_PITCH 一致;走查独立写死,避免"用被测值断言被测值")。 */
+const GROUND_PITCH = -55
+
+const results = []
+function check(id, desc, pass, evidence) {
+    results.push({ id, desc, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ${desc}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+/**
+ * 等相机停稳(`settled === true`)再断言,而不是死等固定毫秒。
+ * 依据:相机是阻尼收敛的,机器负载高时 2.5 秒可能还没到位 —— 实测在连续跑多个 Chrome 的
+ * 回归批次里,固定 sleep 会把 A2/A5 判红(单独跑则 10/10),是**测试的时序问题**而非功能回归。
+ *
+ * 判据里必须容 null:引擎重建的空档里 `getCameraState()` 会返回 null,
+ * 直接读 `.settled` 会在页面里抛错(进而被算成页面级报错)。
+ */
+async function waitSettled(page, timeoutMs = 25000) {
+    await page
+        .waitForFunction(
+            () => {
+                const api = window.__cockpitMap
+                if (!api) return false
+                const state = api.getCameraState()
+                return !!state && state.settled === true
+            },
+            { timeout: timeoutMs, polling: 250 },
+        )
+        .catch(() => {})
+    await sleep(250)
+}
+
+/**
+ * 把镜头放到目标点。**带重试** —— 与外壳里「归位 / 进入系统」同一套兜底:
+ * 相机契约还没挂上时 `goHome()` 返回 false(不静默生效),单次调用会让后续断言全部错位。
+ */
+async function goHome(page, home) {
+    for (let i = 0; i < 40; i++) {
+        const applied = await page.evaluate((h) => window.__cockpitMap.goHome(h), home).catch(() => false)
+        if (applied) return true
+        await sleep(250)
+    }
+    return false
+}
+
+const puppeteer = (await import(PUPPETEER)).default
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: join(tmpdir(), 'cockpit-t21vm-profile'),
+    defaultViewport: { width: 1680, height: 950 },
+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
+})
+console.log(`Chrome: ${await browser.version()}`)
+
+try {
+    const page = await browser.newPage()
+    const errors = []
+    page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
+    page.on('console', (m) => {
+        if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
+    })
+
+    await page.evaluateOnNewDocument((cfg) => {
+        window.__COCKPIT_CONFIG__ = { ...cfg, map: { ...cfg.map, intro: { enabled: false } } }
+    }, config)
+
+    await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+    await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+    await page.waitForFunction(
+        () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready',
+        { timeout: 60000 },
+    )
+    await page.waitForFunction(() => !!window.__cockpitMap, { timeout: 20000 })
+    await waitSettled(page)
+
+    const readState = () =>
+        page.evaluate(() => {
+            const s = window.__cockpitMap.getCameraState()
+            const host = document.querySelector('.cockpit-shell__map .map-canvas')
+            return {
+                pivot: s.pivot,
+                hpr: s.hpr,
+                viewMode: window.__cockpitMap.getViewMode(),
+                dataAttr: host?.dataset.mapViewMode || '',
+                pressed: Array.from(document.querySelectorAll('.cockpit-shell__view-btn'))
+                    .filter((b) => b.getAttribute('aria-pressed') === 'true')
+                    .map((b) => b.dataset.viewMode),
+            }
+        })
+
+    // 先把镜头放到沅陵(不然"管线视图"没有可看的对象)
+    await goHome(page, config.map.home)
+    await waitSettled(page)
+
+    // ---------- A1 初始是地面视图,控件状态正确 ----------
+    const ground = await readState()
+    check(
+        'A1-ground-default',
+        '初始为地面视图:状态与按钮 aria-pressed 同步,宿主带 data-map-view-mode',
+        ground.viewMode === 'ground' &&
+            ground.dataAttr === 'ground' &&
+            ground.pressed.length === 1 &&
+            ground.pressed[0] === 'ground',
+        JSON.stringify({ viewMode: ground.viewMode, dataAttr: ground.dataAttr, pressed: ground.pressed }),
+    )
+
+    const beforeSwitch = { pivot: ground.pivot, hpr: ground.hpr }
+
+    // ---------- A2 切到管线视图:压低相机 + 加大 pitch,中心不动 ----------
+    await page.click('.cockpit-shell__view-btn[data-view-mode="pipeline"]')
+    await waitSettled(page)
+    const pipeline = await readState()
+    const samePivot =
+        pipeline.pivot &&
+        Math.abs(pipeline.pivot.lon - beforeSwitch.pivot.lon) < 1e-9 &&
+        Math.abs(pipeline.pivot.lat - beforeSwitch.pivot.lat) < 1e-9 &&
+        Math.abs((pipeline.pivot.height || 0) - (beforeSwitch.pivot.height || 0)) < 1e-6
+
+    check(
+        'A2-pipeline-camera',
+        '管线视图真的压低相机(距离变小)且 pitch 加大(更接近平视)',
+        pipeline.viewMode === 'pipeline' &&
+            pipeline.hpr.range < beforeSwitch.hpr.range &&
+            pipeline.hpr.pitch > beforeSwitch.hpr.pitch,
+        JSON.stringify({ before: beforeSwitch.hpr, after: pipeline.hpr }),
+    )
+    check(
+        'A2-center-untouched',
+        '切换视图**不重置旋转中心**(经纬高完全一致)—— 决策地图票 05 的硬约束',
+        samePivot,
+        JSON.stringify({ before: beforeSwitch.pivot, after: pipeline.pivot }),
+    )
+    check(
+        'A2-controls-synced',
+        '按钮高亮/aria-pressed 与宿主 data-map-view-mode 都同步到 pipeline',
+        pipeline.pressed.length === 1 && pipeline.pressed[0] === 'pipeline' && pipeline.dataAttr === 'pipeline',
+        JSON.stringify({ pressed: pipeline.pressed, dataAttr: pipeline.dataAttr }),
+    )
+
+    // ---------- A3 管线视图的图层状态确实下发了 ----------
+    const viewState = await page.evaluate(() => ({
+        dataAttr: document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapViewMode || '',
+        engine: window.__cockpitMap.getViewMode(),
+    }))
+    check(
+        'A3-view-applied',
+        '管线视图已下发给地图服务(宿主 data-map-view-mode 与 getViewMode() 都为 pipeline)',
+        viewState.dataAttr === 'pipeline' && viewState.engine === 'pipeline',
+        JSON.stringify(viewState),
+    )
+    await page.screenshot({ path: OUT })
+
+    // ---------- A4 三键分工不变:左键仍只平移、滚轮仍只改距离 ----------
+    const canvas = await page.evaluate(() => {
+        const c = document.querySelector('.cockpit-shell__map .map-canvas canvas')
+        const r = c.getBoundingClientRect()
+        return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
+    })
+    const prePan = await readState()
+    await page.mouse.move(canvas.x, canvas.y)
+    await page.mouse.down({ button: 'left' })
+    await page.mouse.move(canvas.x + 120, canvas.y + 80, { steps: 8 })
+    await page.mouse.up({ button: 'left' })
+    await sleep(1200)
+    const postPan = await readState()
+    check(
+        'A4-pan-still-pans',
+        '管线视图里左键仍只平移:中心移动、距离与倾角基本不变',
+        Math.abs(postPan.pivot.lon - prePan.pivot.lon) > 1e-6 &&
+            Math.abs(postPan.hpr.range - prePan.hpr.range) < prePan.hpr.range * 0.02 &&
+            Math.abs(postPan.hpr.pitch - prePan.hpr.pitch) < 1,
+        JSON.stringify({ dLon: postPan.pivot.lon - prePan.pivot.lon, before: prePan.hpr, after: postPan.hpr }),
+    )
+
+    const preWheel = await readState()
+    await page.mouse.wheel({ deltaY: 400 })
+    await sleep(1200)
+    const postWheel = await readState()
+    check(
+        'A4-wheel-still-zooms',
+        '管线视图里滚轮仍只改距离:距离变化、倾角与中心基本不变',
+        Math.abs(postWheel.hpr.range - preWheel.hpr.range) > 1 &&
+            Math.abs(postWheel.hpr.pitch - preWheel.hpr.pitch) < 1,
+        JSON.stringify({ before: preWheel.hpr, after: postWheel.hpr }),
+    )
+
+    // ---------- A5 回地面:按进管线前的基线还原 ----------
+    await page.click('.cockpit-shell__view-btn[data-view-mode="ground"]')
+    await waitSettled(page)
+    const backGround = await readState()
+    check(
+        'A5-ground-restores',
+        '回地面视图:倾角与距离还原到进管线前(不是留在管线视角)',
+        backGround.viewMode === 'ground' &&
+            Math.abs(backGround.hpr.pitch - GROUND_PITCH) < 3 &&
+            Math.abs(backGround.hpr.range - beforeSwitch.hpr.range) < beforeSwitch.hpr.range * 0.05,
+        JSON.stringify({ restored: backGround.hpr, expectedPitch: GROUND_PITCH, expectedRange: beforeSwitch.hpr.range }),
+    )
+
+    // ---------- A6 视图是全局条件:切一级模式不重置 ----------
+    await page.click('.cockpit-shell__view-btn[data-view-mode="pipeline"]')
+    await sleep(1500)
+    const modeButtons = await page.$$('.cockpit-shell__mode')
+    await modeButtons[1].click()
+    await sleep(1200)
+    const afterModeSwitch = await page.evaluate(() => {
+        const pressed = Array.from(document.querySelectorAll('.cockpit-shell__view-btn'))
+            .filter((b) => b.getAttribute('aria-pressed') === 'true')
+            .map((b) => b.dataset.viewMode)
+        return { mode: window.__cockpitMap.getViewMode(), pressed }
+    })
+    check(
+        'A6-global-across-modes',
+        '视图条件是全局条件:切一级模式后仍是管线视图(与范围/行业同级,不随模式重置)',
+        afterModeSwitch.mode === 'pipeline' && afterModeSwitch.pressed[0] === 'pipeline',
+        JSON.stringify(afterModeSwitch),
+    )
+
+    check('A7-no-page-errors', '全程无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((r) => !r.pass)
+console.log('')
+console.log(`T21 视图走查:${results.length - failed.length}/${results.length} 项通过。截图:${OUT}`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((f) => f.id).join(', '))
+    process.exit(1)
+}
+console.log('T21 视图走查全部通过。')

+ 101 - 0
.scratch/cockpit-shell-panel-layout/verify/t21-view-modes.mjs

@@ -0,0 +1,101 @@
+/**
+ * T21 地面/管线视图条件 纯逻辑检查。
+ *
+ * 决策地图票 05 锁死了三条硬约束,这里逐条断言:
+ *   ① 管线视图压低相机 + 加大 pitch,且 pitch 上限放宽到允许平视;
+ *   ② **切换视图不重置旋转中心**(`viewHprFor()` 的签名里根本没有中心参数,从构造上杜绝);
+ *   ③ 三键分工不变 —— 管线视图只换 limits,不新增输入绑定(契约层面由 t03 覆盖,这里断言 limits 取值)。
+ * 另外覆盖:地面视图还原(用进管线前记下的倾角/距离)、图层透明度、配置覆盖与脏值兜底。
+ *
+ * 运行:node .scratch/cockpit-shell-panel-layout/verify/t21-view-modes.mjs
+ */
+import assert from 'node:assert/strict'
+import { PITCH_LIMITS } from '../../../src/modules/cockpit/domain/camera.js'
+import {
+    DEFAULT_VIEW_MODE,
+    GROUND_ALPHA,
+    GROUND_PITCH,
+    PIPELINE_ALPHA,
+    PIPELINE_CAMERA,
+    VIEW_MODES,
+    isKnownViewMode,
+    planViewMode,
+    viewHprFor,
+} from '../../../src/modules/cockpit/domain/viewModes.js'
+
+// ---------------------------------------------------------------- 1. 词汇
+assert.equal(DEFAULT_VIEW_MODE, VIEW_MODES.GROUND, '默认是地面视图')
+assert.equal(isKnownViewMode('ground'), true, 'ground 合法')
+assert.equal(isKnownViewMode('pipeline'), true, 'pipeline 合法')
+assert.equal(isKnownViewMode('管线'), false, '中文不是合法键(脏值要挡住)')
+
+// ---------------------------------------------------------------- 2. 地面视图
+const ground = planViewMode(VIEW_MODES.GROUND)
+assert.equal(ground.mode, VIEW_MODES.GROUND, '地面视图模式')
+assert.deepEqual(ground.limits, PITCH_LIMITS.ground, '地面视图用常规俯视上下限')
+assert.equal(ground.terrainAlpha, 1, '地面视图地形不透明(还原)')
+assert.equal(ground.tilesetAlpha, 1, '地面视图模型不透明(还原)')
+assert.equal(ground.rangeFactor, 1, '地面视图不改距离')
+
+// ---------------------------------------------------------------- 3. 管线视图
+const pipeline = planViewMode(VIEW_MODES.PIPELINE)
+assert.equal(pipeline.mode, VIEW_MODES.PIPELINE, '管线视图模式')
+assert.deepEqual(pipeline.limits, PITCH_LIMITS.pipeline, '管线视图放宽 pitch 上限(允许平视/轻微仰视)')
+assert.ok(pipeline.limits.max > PITCH_LIMITS.ground.max, '管线视图的 pitch 上限必须比地面视图更宽')
+assert.equal(pipeline.pitch, PIPELINE_CAMERA.pitch, '管线视图用预设倾角')
+assert.ok(pipeline.pitch > GROUND_PITCH, '管线视图的 pitch 要更"平"(比地面视图大)')
+assert.ok(pipeline.rangeFactor < 1, '管线视图要压低弹簧臂(距离变小)')
+assert.equal(pipeline.terrainAlpha, PIPELINE_ALPHA.terrain, '管线视图地形半透明')
+assert.equal(pipeline.tilesetAlpha, PIPELINE_ALPHA.tileset, '管线视图模型半透明')
+assert.ok(pipeline.terrainAlpha > 0 && pipeline.terrainAlpha < 1, '半透明而非完全隐藏(保留空间参照)')
+assert.ok(pipeline.tilesetAlpha > 0 && pipeline.tilesetAlpha < 1, '模型也是半透明而非隐藏')
+
+// 未知模式退回地面视图,不抛错
+assert.equal(planViewMode('nope').mode, VIEW_MODES.GROUND, '未知模式应退回地面视图')
+
+// 配置覆盖:只认有限数字
+const overridden = planViewMode(VIEW_MODES.PIPELINE, { pitch: -30, rangeFactor: 0.5, terrainAlpha: 0.7, tilesetAlpha: 0.6 })
+assert.equal(overridden.pitch, -30, '倾角可被配置覆盖')
+assert.equal(overridden.rangeFactor, 0.5, '距离系数可被配置覆盖')
+assert.equal(overridden.terrainAlpha, 0.7, '地形透明度可被配置覆盖')
+assert.equal(overridden.tilesetAlpha, 0.6, '模型透明度可被配置覆盖')
+const dirty = planViewMode(VIEW_MODES.PIPELINE, { pitch: 'x', rangeFactor: NaN, terrainAlpha: null })
+assert.equal(dirty.pitch, PIPELINE_CAMERA.pitch, '脏倾角应退回预设')
+assert.equal(dirty.rangeFactor, PIPELINE_CAMERA.rangeFactor, '脏系数应退回预设')
+assert.equal(dirty.terrainAlpha, PIPELINE_ALPHA.terrain, '脏透明度应退回预设')
+
+// ---------------------------------------------------------------- 4. 切换时的相机变换(不重置中心)
+const current = { heading: 42, pitch: -55, range: 12000 }
+const toPipeline = viewHprFor(current, pipeline, null)
+assert.equal(toPipeline.heading, 42, 'heading 原样带走(切换视图不转向)')
+assert.equal(toPipeline.pitch, PIPELINE_CAMERA.pitch, '倾角按管线预设')
+assert.equal(toPipeline.range, 12000 * PIPELINE_CAMERA.rangeFactor, '距离按系数压低')
+// 关键:返回值里没有 pivot/中心字段 —— 从构造上就不可能重置中心
+assert.equal('pivot' in toPipeline, false, '切换视图不得产出中心字段(不重置中心的构造性保证)')
+assert.equal('pivot' in pipeline, false, '视图计划里也不该有中心')
+
+// 管线视图的距离被夹在合理区间(贴着地面看,但不至于钻进地表)
+const tooFar = viewHprFor({ heading: 0, pitch: -55, range: 500000 }, pipeline, null)
+assert.equal(tooFar.range, PIPELINE_CAMERA.maxRange, '超远的距离应被夹到管线视图上限')
+const tooNear = viewHprFor({ heading: 0, pitch: -55, range: 200 }, pipeline, null)
+assert.equal(tooNear.range, PIPELINE_CAMERA.minRange, '过近的距离应被夹到管线视图下限')
+
+// 回地面:用进管线前记下的倾角/距离还原(决策地图「地面视图还原」)
+const baseline = { pitch: -62, range: 9000 }
+const backToGround = viewHprFor({ heading: 42, pitch: -18, range: 3000 }, ground, baseline)
+assert.equal(backToGround.heading, 42, '回地面也不改朝向')
+assert.equal(backToGround.pitch, -62, '回地面应还原进管线前的倾角')
+assert.equal(backToGround.range, 9000, '回地面应还原进管线前的距离')
+
+// 没有基线时用默认地面倾角兜底(比如直接以管线视角进系统)
+const groundNoBaseline = viewHprFor({ heading: 0, pitch: -18, range: 3000 }, ground, null)
+assert.equal(groundNoBaseline.pitch, GROUND_PITCH, '没有基线时回落到默认地面倾角')
+
+// 中心不变是行为契约:同一次切换,heading 与中心都不在变换范围内
+const stateBefore = { pivot: { lon: 110.39, lat: 28.44, height: 111.6 }, hpr: { heading: 90, pitch: -55, range: 8000 } }
+const afterSwitch = { ...stateBefore, hpr: { ...stateBefore.hpr, ...viewHprFor(stateBefore.hpr, pipeline, null) } }
+assert.deepEqual(afterSwitch.pivot, stateBefore.pivot, '切换视图后中心必须一模一样')
+
+console.log(
+    `T21 视图条件检查通过:地面/管线计划(管线 pitch ${PIPELINE_CAMERA.pitch}° / 距离 ×${PIPELINE_CAMERA.rangeFactor} / 地形 α${PIPELINE_ALPHA.terrain} / 模型 α${PIPELINE_ALPHA.tileset})、pitch 上限放宽到 ${PITCH_LIMITS.pipeline.max}°、切换不产中心字段(构造性不重置)、地面视图按基线还原、脏配置兜底。`,
+)

+ 183 - 0
.scratch/cockpit-shell-panel-layout/verify/t29-basemap-walkthrough.mjs

@@ -0,0 +1,183 @@
+/**
+ * T29 真机走查:底图影像确实按配置挂上引擎(票 29)。
+ *
+ * 逻辑脚本(t29-basemap.mjs)把计划层测透了,这里只测浏览器里才成立的部分:
+ *   · 默认(source=none)= 不挂底图,宿主 data-map-basemap 为 not-configured(票 27 行为不变);
+ *   · source=natural-earth = 真的去取 Cesium 自带离线瓦片(断言发生 NaturalEarthII 请求且 200);
+ *   · source=xyz = 真的按 `{z}/{x}/{y}` 模板逐块取图(用仓库自带那份 TMS 目录当靶子,
+ *     只证明通道通、模板被正确替换,不证明该地址的语义——TMS 的 y 轴与 XYZ 相反);
+ *   · source=tianditu 但没填 tk = 明确失败态(invalid + 浮标写明缺 tk),不是白屏也不是静默无底图。
+ *
+ * 天地图的**真实取图**需要真 tk,本票不伪造:模板拼装与开发代理/生产子域由逻辑脚本覆盖,
+ * 真 key 联调留给维护者(或票 21 的底图校验)。
+ *
+ * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/):
+ *   node .scratch/cockpit-shell-panel-layout/verify/t29-basemap-walkthrough.mjs
+ */
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
+
+/** 仓库自带的离线瓦片目录(TMS 布局,仅作 xyz 通道的靶子)。 */
+const LOCAL_TILES = 'cesium/Assets/Textures/NaturalEarthII'
+const TILE_HINT = 'Assets/Textures/NaturalEarthII'
+
+const results = []
+function check(id, desc, pass, evidence) {
+    results.push({ id, desc, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ${desc}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+const puppeteer = (await import(PUPPETEER)).default
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: join(tmpdir(), 'cockpit-t29-profile'),
+    defaultViewport: { width: 1680, height: 950 },
+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
+})
+console.log(`Chrome: ${await browser.version()}`)
+
+/** 开一页并记录它发出的瓦片请求(URL + 状态),等引擎到底。 */
+async function openPage(injectConfig) {
+    const page = await browser.newPage()
+    const tiles = []
+    page.on('response', (response) => {
+        const url = response.url()
+        if (url.includes(TILE_HINT)) tiles.push({ url: url.replace(BASE, ''), status: response.status() })
+    })
+    if (injectConfig) await page.evaluateOnNewDocument(injectConfig)
+
+    await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+    await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+    await page
+        .waitForFunction(
+            () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
+            { timeout: 60000 },
+        )
+        .catch(() => {})
+    await sleep(1500)
+    return { page, tiles }
+}
+
+const readState = (page) =>
+    page.evaluate(() => {
+        const host = document.querySelector('.cockpit-shell__map .map-canvas')
+        return {
+            engine: (host && host.dataset.mapEngine) || '',
+            basemap: (host && host.dataset.mapBasemap) || '',
+            pill: host?.querySelector('[data-map-engine-status]')?.textContent?.trim() || '',
+        }
+    })
+
+try {
+    // ---------- A1 默认:不配底图(票 27 行为不变) ----------
+    {
+        // 本机 .env.local 可能已打开配置文件层(票 21 的本地 GIS 配置);本条测内置默认,显式关掉。
+        const { page, tiles } = await openPage(() => {
+            window.__COCKPIT_CONFIG_URL__ = ''
+        })
+        const state = await readState(page)
+        check(
+            'A1-none-by-default',
+            '默认(map.basemap.source 缺省)不挂底图:data-map-basemap=not-configured,浮标照旧说「底图未配置」',
+            state.engine === 'ready' && state.basemap === 'not-configured' && state.pill.includes('底图未配置'),
+            JSON.stringify(state),
+        )
+        check('A1-no-tile-requests', '不配底图时不发任何影像请求', tiles.length === 0, `tiles=${tiles.length}`)
+        await page.close()
+    }
+
+    // ---------- A2 内置离线影像:真的取到瓦片 ----------
+    {
+        const { page, tiles } = await openPage(() => {
+            window.__COCKPIT_CONFIG__ = { map: { basemap: { source: 'natural-earth' } } }
+        })
+        const state = await readState(page)
+        const ok = tiles.filter((t) => t.status === 200)
+        check(
+            'A2-natural-earth-live',
+            'source=natural-earth:宿主标成 natural-earth、浮标显示底图名、且真的取到离线瓦片(HTTP 200)',
+            state.engine === 'ready' && state.basemap === 'natural-earth' && state.pill.includes('底图:') && ok.length > 0,
+            JSON.stringify({ state, tiles: tiles.slice(0, 3), okCount: ok.length }),
+        )
+        await page.close()
+    }
+
+    // ---------- A3 自定义 XYZ:模板被逐块替换 ----------
+    {
+        const { page, tiles } = await openPage(() => {
+            window.__COCKPIT_CONFIG__ = {
+                map: {
+                    basemap: {
+                        source: 'xyz',
+                        url: 'cesium/Assets/Textures/NaturalEarthII/{z}/{x}/{y}.jpg',
+                        minimumLevel: 0,
+                        maximumLevel: 2,
+                    },
+                },
+            }
+        })
+        const state = await readState(page)
+        const ok = tiles.filter((t) => t.status === 200)
+        const zxy = ok.filter((t) => /NaturalEarthII\/\d+\/\d+\/\d+\.jpg$/.test(t.url))
+        check(
+            'A3-xyz-template-live',
+            'source=xyz:按 {z}/{x}/{y} 模板真的逐块取图(HTTP 200),模板占位符被替换成数字',
+            state.basemap === 'xyz' && zxy.length > 0,
+            JSON.stringify({ basemap: state.basemap, sample: zxy.slice(0, 2), okCount: ok.length }),
+        )
+        await page.close()
+    }
+
+    // ---------- A4 天地图缺 tk:明确失败态,不白屏 ----------
+    {
+        const { page } = await openPage(() => {
+            window.__COCKPIT_CONFIG__ = { map: { basemap: { source: 'tianditu' } } }
+        })
+        const state = await readState(page)
+        check(
+            'A4-tianditu-without-token',
+            'source=tianditu 但没有 tk:data-map-basemap=invalid、浮标写明缺 tk 并说明回退,引擎仍就绪',
+            state.engine === 'ready' &&
+                state.basemap === 'invalid' &&
+                state.pill.includes('底图配置无效') &&
+                state.pill.includes('tk') &&
+                state.pill.includes('回退'),
+            JSON.stringify(state),
+        )
+        await page.close()
+    }
+
+    // ---------- A5 配置写错来源:归一化退回 none,不炸 ----------
+    {
+        const { page } = await openPage(() => {
+            window.__COCKPIT_CONFIG__ = { map: { basemap: { source: 'baidu' } } }
+        })
+        const state = await readState(page)
+        check(
+            'A5-unknown-source-falls-back',
+            '未知底图来源:退回 none(not-configured)而不是报错或挂出坏底图',
+            state.engine === 'ready' && state.basemap === 'not-configured' && state.pill.includes('底图未配置'),
+            JSON.stringify(state),
+        )
+        await page.close()
+    }
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((r) => !r.pass)
+console.log('')
+console.log(`T29 走查:${results.length - failed.length}/${results.length} 项通过。`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((f) => f.id).join(', '))
+    process.exit(1)
+}
+console.log('T29 走查全部通过。')

+ 120 - 0
.scratch/cockpit-shell-panel-layout/verify/t29-basemap.mjs

@@ -0,0 +1,120 @@
+/**
+ * T29 底图影像(配置驱动)纯逻辑检查。
+ *
+ * 覆盖:配置归一化(source 白名单 / 子域 / 级别)、四种来源的计划、天地图模板拼装(开发代理 vs 生产子域)、
+ * 缺 tk 与未知来源的失败态、以及宿主状态措辞。
+ *
+ * 运行:node .scratch/cockpit-shell-panel-layout/verify/t29-basemap.mjs
+ */
+import assert from 'node:assert/strict'
+import {
+    BASEMAP_SOURCE,
+    BASEMAP_STATE,
+    NATURAL_EARTH_TMS,
+    TIANDITU_SUBDOMAINS,
+    basemapStatusText,
+    planBasemap,
+    tiandituTileUrl,
+} from '../../../src/modules/cockpit/domain/basemap.js'
+import { builtinConfig, normalizeConfig } from '../../../src/modules/cockpit/services/cockpitConfig.js'
+
+// ---------------------------------------------------------------- 1. 默认不配底图(票 27 行为不变)
+const defaults = builtinConfig()
+assert.equal(defaults.map.basemap.source, BASEMAP_SOURCE.NONE, '默认必须是 none(不伪造配准)')
+assert.equal(defaults.map.tianditu.layer, 'img', '天地图默认影像底图')
+
+const nonePlan = planBasemap(defaults.map, { dev: true })
+assert.equal(nonePlan.ready, false, 'none 不可用')
+assert.equal(nonePlan.state, BASEMAP_STATE.NOT_CONFIGURED, 'none 的状态是 not-configured')
+assert.match(basemapStatusText(nonePlan), /底图未配置/, '未配置时保持票 27 的原话')
+
+// ---------------------------------------------------------------- 2. 归一化
+const normalized = normalizeConfig({
+    map: {
+        basemap: { source: 'natural-earth', url: ' x ', subdomains: ['a', ' b ', ''], minimumLevel: 2, maximumLevel: 'ten' },
+        tianditu: { token: ' tk ', layer: 'vec' },
+    },
+})
+assert.equal(normalized.config.map.basemap.source, 'natural-earth', '合法 source 应被接受')
+assert.equal(normalized.config.map.basemap.url, 'x', 'url 应去空白')
+assert.deepEqual(normalized.config.map.basemap.subdomains, ['a', 'b'], '子域应过滤空串并去空白')
+assert.equal(normalized.config.map.basemap.minimumLevel, 2, '级别应接受非负整数')
+assert.equal(normalized.config.map.basemap.maximumLevel, null, '非整数级别应被忽略')
+assert.equal(normalized.config.map.tianditu.layer, 'vec', '天地图图层应被接受')
+assert.equal(normalized.config.map.tianditu.token, 'tk', 'tk 应去空白')
+assert.equal(normalized.issues.length, 1, `只应记一条 issue,实际 ${JSON.stringify(normalized.issues)}`)
+
+const badSource = normalizeConfig({ map: { basemap: { source: 'baidu' }, tianditu: { layer: '影像' } } })
+assert.equal(badSource.config.map.basemap.source, BASEMAP_SOURCE.NONE, '未知来源应退回 none')
+assert.equal(badSource.config.map.tianditu.layer, 'img', '未知图层应退回 img')
+assert.equal(badSource.issues.length, 2, `两条非法取值各记一条,实际 ${JSON.stringify(badSource.issues)}`)
+
+const badSubdomains = normalizeConfig({ map: { basemap: { source: 'xyz', url: 't/{z}/{x}/{y}.png', subdomains: 't0' } } })
+assert.deepEqual(badSubdomains.config.map.basemap.subdomains, [], '非数组子域应被忽略')
+assert.equal(badSubdomains.issues.length, 1, '非数组子域应记一条 issue')
+
+// ---------------------------------------------------------------- 3. 内置离线影像
+const naturalPlan = planBasemap({ basemap: { source: 'natural-earth' } })
+assert.equal(naturalPlan.ready, true, 'natural-earth 应可用')
+assert.equal(naturalPlan.kind, 'tms', 'natural-earth 走 TMS')
+assert.equal(naturalPlan.url, NATURAL_EARTH_TMS, 'TMS 路径应是 Cesium 自带资源目录(相对 CESIUM_BASE_URL)')
+assert.match(naturalPlan.label, /离线/, '标签应说明是离线影像')
+
+// 免费免 key 的在线全局层(本机外网被挡,模板与署名在这里断言)
+const arcgisPlan = planBasemap({ basemap: { source: 'arcgis-imagery' } })
+assert.equal(arcgisPlan.ready, true, 'arcgis-imagery 应可用(免费免 key)')
+assert.equal(arcgisPlan.kind, 'xyz', 'arcgis 走 XYZ 模板')
+assert.match(arcgisPlan.url, /^https:\/\/services\.arcgisonline\.com\/.*\/tile\/\{z\}\/\{y\}\/\{x\}$/, 'ArcGIS 的模板顺序是 {z}/{y}/{x}')
+assert.match(arcgisPlan.credit, /Esri/, '应带上 Esri 署名')
+
+// ---------------------------------------------------------------- 4. 自定义 XYZ
+const xyzPlan = planBasemap({
+    basemap: { source: 'xyz', url: 'tiles/{z}/{x}/{y}.png', subdomains: ['a'], minimumLevel: 1, maximumLevel: 18, credit: '甲方' },
+})
+assert.equal(xyzPlan.ready, true, 'xyz 应可用')
+assert.equal(xyzPlan.kind, 'xyz', 'xyz 走模板 provider')
+assert.equal(xyzPlan.url, 'tiles/{z}/{x}/{y}.png', '模板应原样透传')
+assert.deepEqual(xyzPlan.subdomains, ['a'], '子域应透传')
+assert.equal(xyzPlan.minimumLevel, 1, '最小级别应透传')
+assert.equal(xyzPlan.maximumLevel, 18, '最大级别应透传')
+assert.equal(xyzPlan.credit, '甲方', '署名应透传')
+
+const xyzNoUrl = planBasemap({ basemap: { source: 'xyz' } })
+assert.equal(xyzNoUrl.ready, false, 'xyz 缺 url 不可用')
+assert.equal(xyzNoUrl.state, BASEMAP_STATE.INVALID, '缺字段应是 invalid 而不是静默无底图')
+assert.match(xyzNoUrl.issue, /url/, '失败原因应点明缺 url')
+
+// ---------------------------------------------------------------- 5. 天地图(开发代理 vs 生产子域)
+const tiandituDev = planBasemap({ basemap: { source: 'tianditu' }, tianditu: { token: 'TK', layer: 'img' } }, { dev: true })
+assert.equal(tiandituDev.ready, true, '带 tk 的天地图应可用')
+assert.match(tiandituDev.url, /^\/tianditu\.map\/img_w\/wmts\?/, '开发态应走仓库自带的 /tianditu.map 代理')
+assert.match(tiandituDev.url, /tk=TK/, 'tk 应拼进查询串')
+assert.match(tiandituDev.url, /TILEMATRIX=\{z\}&TILEROW=\{y\}&TILECOL=\{x\}/, 'WMTS 的占位符顺序应是 {z}/{y}/{x}')
+assert.deepEqual(tiandituDev.subdomains, [], '代理是单主机,不加子域')
+assert.equal(tiandituDev.credit, '天地图', '应有署名')
+
+const tiandituProd = planBasemap({ basemap: { source: 'tianditu' }, tianditu: { token: 'TK', layer: 'vec' } }, { dev: false })
+assert.match(tiandituProd.url, /^https:\/\/t\{s\}\.tianditu\.gov\.cn\/vec_w\//, '生产应直连带子域的主机')
+assert.deepEqual(tiandituProd.subdomains, TIANDITU_SUBDOMAINS, '生产应给出子域列表')
+assert.match(tiandituProd.label, /矢量/, '标签应反映图层')
+
+const tiandituNoToken = planBasemap({ basemap: { source: 'tianditu' } }, { dev: true })
+assert.equal(tiandituNoToken.ready, false, '缺 tk 不可用')
+assert.equal(tiandituNoToken.state, BASEMAP_STATE.INVALID, '缺 tk 应是 invalid')
+assert.match(tiandituNoToken.issue, /tk/, '失败原因应点明缺 tk')
+assert.match(basemapStatusText(tiandituNoToken), /配置无效.*回退临时影像/, '失败措辞应说明回退')
+
+assert.equal(
+    planBasemap({ basemap: { source: 'baidu' } }).state,
+    BASEMAP_STATE.INVALID,
+    '未知来源(绕过归一化直接调计划)也应判 invalid',
+)
+assert.match(tiandituTileUrl('/x/', 'T', 'ter'), /^\/x\/ter_w\/wmts\?.*tk=T$/, '模板拼装应处理尾部斜杠')
+
+// ---------------------------------------------------------------- 6. 状态措辞
+assert.match(basemapStatusText(naturalPlan), /底图:/, '可用时措辞应带底图名')
+assert.equal(basemapStatusText(null), '底图未配置(正式底图见票 21)', '缺省计划应回落到未配置措辞')
+
+console.log(
+    `T29 检查通过:默认不配底图(票 27 行为不变)/ 归一化(source 白名单、子域、级别、图层)/ 四来源计划(none · natural-earth · xyz · tianditu)/ 天地图开发代理与生产子域模板 / 缺 tk 与未知来源的失败态(${BASEMAP_STATE.INVALID})。`,
+)

+ 200 - 0
.scratch/cockpit-shell-panel-layout/verify/t30-intro-walkthrough.mjs

@@ -0,0 +1,200 @@
+/**
+ * T30 真机走查:进入引导(星球自转 + 点击进入)→ 飞到目标城市 → 快速归位(票 30)。
+ *
+ * 断言的是维护者提出的痛点能不能被证伪:他"完全找不到目标位置,无法判断 DEM 与精细影像是否部署成功"。
+ * 所以这里不只测 UI,还要证明:
+ *   · 引导层真的在、文案真的在闪(animation 生效);
+ *   · 引导期间星球**真的在转**(采样两次相机经度有位移);
+ *   · 点一下之后镜头**真的落到沅陵县城坐标**(经纬度误差 < 1e-4°,且距离是县域级);
+ *   · 手动把镜头折腾走后,点「归位」能回到同一视角(中心 + heading/pitch/range)。
+ *
+ * 运行(需先起 `yarn dev`):node .scratch/cockpit-shell-panel-layout/verify/t30-intro-walkthrough.mjs
+ */
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
+const OUT = join(tmpdir(), 'cockpit-t30-intro.png')
+const OUT_HOME = join(tmpdir(), 'cockpit-t30-home.png')
+
+const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8'))
+const HOME = config.map.home
+
+const results = []
+function check(id, desc, pass, evidence) {
+    results.push({ id, desc, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ${desc}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+const puppeteer = (await import(PUPPETEER)).default
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: join(tmpdir(), 'cockpit-t30-profile'),
+    defaultViewport: { width: 1680, height: 950 },
+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
+})
+console.log(`Chrome: ${await browser.version()}`)
+
+try {
+    const page = await browser.newPage()
+    const errors = []
+    page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
+    page.on('console', (m) => {
+        if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
+    })
+
+    await page.evaluateOnNewDocument((injected) => {
+        window.__COCKPIT_CONFIG__ = injected
+    }, config)
+
+    await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+    await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+
+    // 等引擎就绪(引导层此时应该已经盖在上面了)
+    await page.waitForFunction(
+        () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready',
+        { timeout: 60000 },
+    )
+    await page.waitForFunction(() => !!window.__cockpitMap, { timeout: 20000 })
+    await sleep(1200)
+
+    // ---------- A1 引导层存在、文案与闪烁 ----------
+    const intro = await page.evaluate(() => {
+        const layer = document.querySelector('[data-cockpit-intro]')
+        const title = layer?.querySelector('.cockpit-intro__title')
+        const style = title ? getComputedStyle(title) : null
+        return {
+            present: !!layer,
+            title: title?.textContent?.trim() || '',
+            animation: style ? style.animationName : '',
+            animationDuration: style ? style.animationDuration : '',
+            zIndex: layer ? getComputedStyle(layer).zIndex : '',
+        }
+    })
+    check(
+        'A1-intro-visible',
+        '进入时先看到引导层:中央「点击进入系统」文案在位,且闪烁动画真的生效',
+        intro.present && intro.title.includes('点击进入系统') && intro.animation !== 'none' && Number(intro.zIndex) >= 10,
+        JSON.stringify(intro),
+    )
+    await page.screenshot({ path: OUT })
+
+    // ---------- A2 星球真的在自转(采样相机经度) ----------
+    const sampleLon = () =>
+        page.evaluate(() => {
+            const state = window.__cockpitMap.getCameraState()
+            return state?.pivot ? state.pivot.lon : null
+        })
+    const spinA = await sampleLon()
+    await sleep(2600)
+    const spinB = await sampleLon()
+    check(
+        'A2-globe-spinning',
+        '引导期间星球确实在自转(2.6 秒内相机中心经度发生位移)',
+        spinA !== null && spinB !== null && Math.abs(spinB - spinA) > 0.05,
+        `lon ${spinA} → ${spinB} (Δ=${spinB !== null && spinA !== null ? (spinB - spinA).toFixed(3) : 'n/a'})`,
+    )
+
+    // ---------- A3 点击进入:引导消失 + 镜头落到沅陵县城 ----------
+    await page.click('[data-cockpit-intro]')
+    await sleep(2500)
+    const afterEnter = await page.evaluate(() => {
+        const state = window.__cockpitMap.getCameraState()
+        return {
+            introGone: !document.querySelector('[data-cockpit-intro]'),
+            pivot: state?.pivot || null,
+            hpr: state?.hpr || null,
+            settled: state?.settled,
+            atHomeAttr: document.querySelector('.cockpit-shell__home')?.dataset.homeAt || '',
+        }
+    })
+    const dLon = Math.abs((afterEnter.pivot?.lon ?? 999) - HOME.lon)
+    const dLat = Math.abs((afterEnter.pivot?.lat ?? 999) - HOME.lat)
+    check(
+        'A3-enter-flies-to-target',
+        '点击后引导层消失,镜头落到目标城市(经纬度误差 < 1e-4°,距离为县域级)',
+        afterEnter.introGone && dLon < 1e-4 && dLat < 1e-4 && afterEnter.hpr?.range > 1000,
+        JSON.stringify({ dLon, dLat, pivot: afterEnter.pivot, hpr: afterEnter.hpr, settled: afterEnter.settled }),
+    )
+    await sleep(1500)
+    await page.screenshot({ path: OUT_HOME })
+
+    // ---------- A4 自转在进入后停止 ----------
+    const stopA = await sampleLon()
+    await sleep(2000)
+    const stopB = await sampleLon()
+    check(
+        'A4-spin-stopped',
+        '进入系统后自转停止(2 秒内相机中心不再漂移)',
+        stopA !== null && stopB !== null && Math.abs(stopB - stopA) < 1e-6,
+        `lon ${stopA} → ${stopB}`,
+    )
+
+    // ---------- A5 折腾走之后,归位按钮能回到同一视角 ----------
+    const homeBefore = await page.evaluate(() => {
+        const s = window.__cockpitMap.getCameraState()
+        return { pivot: s.pivot, hpr: s.hpr }
+    })
+    // 用真实输入把它推开:左键拖拽平移 + 滚轮缩放
+    const canvas = await page.evaluate(() => {
+        const c = document.querySelector('.cockpit-shell__map .map-canvas canvas')
+        const r = c.getBoundingClientRect()
+        return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
+    })
+    await page.mouse.move(canvas.x, canvas.y)
+    await page.mouse.down({ button: 'left' })
+    await page.mouse.move(canvas.x + 180, canvas.y + 120, { steps: 8 })
+    await page.mouse.up({ button: 'left' })
+    await page.mouse.wheel({ deltaY: 600 })
+    await sleep(1500)
+
+    const moved = await page.evaluate(() => {
+        const s = window.__cockpitMap.getCameraState()
+        return { pivot: s.pivot, hpr: s.hpr, atHomeAttr: document.querySelector('.cockpit-shell__home')?.dataset.homeAt }
+    })
+    const movedAway =
+        Math.abs(moved.pivot.lon - homeBefore.pivot.lon) > 1e-4 || Math.abs(moved.hpr.range - homeBefore.hpr.range) > 1
+    check(
+        'A5a-moved-away',
+        '用手动操作把镜头推离目标(平移 + 缩放),且顶栏「归位」按钮显示未在家',
+        movedAway && moved.atHomeAttr === 'no',
+        JSON.stringify({ moved: { lon: moved.pivot.lon, range: moved.hpr.range }, atHome: moved.atHomeAttr }),
+    )
+
+    await page.click('.cockpit-shell__home')
+    await sleep(3000)
+    const backHome = await page.evaluate(() => {
+        const s = window.__cockpitMap.getCameraState()
+        return { pivot: s.pivot, hpr: s.hpr, atHomeAttr: document.querySelector('.cockpit-shell__home')?.dataset.homeAt }
+    })
+    const dLonBack = Math.abs(backHome.pivot.lon - HOME.lon)
+    const dLatBack = Math.abs(backHome.pivot.lat - HOME.lat)
+    const dRangeBack = Math.abs(backHome.hpr.range - HOME.range) / HOME.range
+    check(
+        'A5b-home-button-returns',
+        '点「归位」后回到目标视角(中心 < 1e-4°、距离误差 < 5%),按钮显示已在家',
+        dLonBack < 1e-4 && dLatBack < 1e-4 && dRangeBack < 0.05 && backHome.atHomeAttr === 'yes',
+        JSON.stringify({ dLon: dLonBack, dLat: dLatBack, rangeErr: dRangeBack, atHome: backHome.atHomeAttr }),
+    )
+
+    check('A6-no-page-errors', '全程无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((r) => !r.pass)
+console.log('')
+console.log(`T30 走查:${results.length - failed.length}/${results.length} 项通过。`)
+console.log(`截图:引导页 ${OUT} / 进入后 ${OUT_HOME}`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((f) => f.id).join(', '))
+    process.exit(1)
+}
+console.log('T30 走查全部通过。')

+ 111 - 0
.scratch/cockpit-shell-panel-layout/verify/t30-view-presets.mjs

@@ -0,0 +1,111 @@
+/**
+ * T30 进入引导与视角预设 纯逻辑检查。
+ *
+ * 覆盖:`map.home` / `map.intro` 的归一化与默认值;预设视角解析;归位判定 `isAtHome()` 的容差与
+ * 经度环绕;以及「配置里那组沅陵坐标」确实被解析成预期值(防止手抄坐标时写错)。
+ *
+ * 运行:node .scratch/cockpit-shell-panel-layout/verify/t30-view-presets.mjs
+ */
+import assert from 'node:assert/strict'
+import { existsSync, readFileSync } from 'node:fs'
+import {
+    HOME_DEFAULTS,
+    INTRO_DEFAULTS,
+    homeLabel,
+    isAtHome,
+    planHome,
+    planIntro,
+} from '../../../src/modules/cockpit/domain/viewPresets.js'
+import { builtinConfig, normalizeConfig } from '../../../src/modules/cockpit/services/cockpitConfig.js'
+
+// ---------------------------------------------------------------- 1. 默认值
+const defaults = planHome({})
+assert.equal(defaults.lon, HOME_DEFAULTS.lon, '缺省经度应回落到默认(沅陵)')
+assert.equal(defaults.lat, HOME_DEFAULTS.lat, '缺省纬度应回落到默认')
+assert.ok(defaults.range > 1000, '默认距离应是县域级(>1km)')
+assert.ok(defaults.pitch < 0, '默认俯角应为负(从上往下看)')
+
+const introDefaults = planIntro({})
+assert.equal(introDefaults.enabled, true, '默认开启引导')
+assert.equal(introDefaults.title, INTRO_DEFAULTS.title, '默认文案')
+assert.ok(introDefaults.spinSeconds > 0, '默认开启自转')
+
+// ---------------------------------------------------------------- 2. 归一化
+const normalized = normalizeConfig({
+    map: {
+        home: { lon: 110.39028164, lat: 28.4436787, pitch: -55, range: 12000, label: '沅陵县城' },
+        intro: { enabled: false, title: '进入', spinSeconds: 0 },
+    },
+})
+assert.equal(normalized.config.map.home.lon, 110.39028164, '经度应透传(不丢精度)')
+assert.equal(normalized.config.map.home.lat, 28.4436787, '纬度应透传')
+assert.equal(normalized.config.map.home.label, '沅陵县城', '标签应透传')
+assert.equal(normalized.config.map.intro.enabled, false, '引导开关应透传')
+assert.equal(normalized.config.map.intro.spinSeconds, 0, '自转秒数应透传')
+
+const badTypes = normalizeConfig({ map: { home: { lon: '东经110', pitch: {} }, intro: { enabled: 'yes' } } })
+assert.equal(badTypes.config.map.home.lon, null, '非数字经度应为 null(回落默认)')
+assert.equal(badTypes.config.map.home.pitch, null, '非数字俯角应为 null')
+assert.equal(badTypes.config.map.intro.enabled, true, '非布尔开关应回落默认 true')
+assert.equal(badTypes.issues.length, 3, `三个错字段各记一条 issue,实际 ${JSON.stringify(badTypes.issues)}`)
+
+// ---------------------------------------------------------------- 3. 计划层
+const home = planHome({ home: { lon: 110.5, lat: 28.5, heading: 30, pitch: -40, range: 5000 } })
+assert.equal(home.lon, 110.5, '配置优先')
+assert.equal(home.heading, 30, 'heading 透传')
+assert.equal(home.range, 5000, 'range 透传')
+assert.equal(home.label, '目标区域', '没给 label 时有默认文案')
+
+const introOff = planIntro({ intro: { enabled: false } })
+assert.equal(introOff.enabled, false, '可关闭引导(走查自隔离用)')
+assert.equal(planIntro({ intro: { spinSeconds: -5 } }).spinSeconds, 0, '负数自转秒数应夹到 0')
+
+// label 文案:归位按钮的 tooltip 用它,必须带上坐标
+const label = homeLabel({ lon: 110.39028164, lat: 28.4436787, label: '沅陵县城' })
+assert.match(label, /沅陵县城/, '标签应含地名')
+assert.match(label, /110\.3903/, '标签应含经度(4 位小数)')
+assert.match(label, /28\.4437/, '标签应含纬度(4 位小数)')
+
+// ---------------------------------------------------------------- 4. 归位判定
+const homeRef = { lon: 110.39028164, lat: 28.4436787, heading: 0, pitch: -55, range: 12000 }
+const atHomeState = { pivot: { lon: 110.39028164, lat: 28.4436787, height: 0 }, hpr: { heading: 0, pitch: -55, range: 12000 } }
+assert.equal(isAtHome(atHomeState, homeRef), true, '同一视角应判为在家')
+
+assert.equal(
+    isAtHome({ pivot: { lon: 110.4, lat: 28.44 }, hpr: { heading: 0, pitch: -55, range: 12000 } }, homeRef),
+    false,
+    '中心偏出容差(>1e-4°)不应判为在家',
+)
+assert.equal(
+    isAtHome({ pivot: atHomeState.pivot, hpr: { heading: 0, pitch: -55, range: 30000 } }, homeRef),
+    false,
+    '距离差太多不应判为在家',
+)
+assert.equal(
+    isAtHome({ pivot: atHomeState.pivot, hpr: { heading: 0, pitch: -20, range: 12000 } }, homeRef),
+    false,
+    '俯角差太多不应判为在家',
+)
+// 经度环绕:heading 0 与 360 是同一个朝向
+assert.equal(
+    isAtHome({ pivot: atHomeState.pivot, hpr: { heading: 360, pitch: -55, range: 12000 } }, homeRef),
+    true,
+    'heading 360 等价于 0(经度/朝向环绕)',
+)
+assert.equal(isAtHome(null, homeRef), false, '没有相机状态时不应判为在家')
+
+// ---------------------------------------------------------------- 5. 与真实配置对齐(本机)
+const configPath = new URL('../../../public/cockpit/config.json', import.meta.url)
+if (existsSync(configPath)) {
+    const raw = JSON.parse(readFileSync(configPath, 'utf8'))
+    const { config } = normalizeConfig(raw)
+    const actual = planHome(config.map)
+    assert.equal(actual.lon, 110.39028164, '配置里的经度应等于维护者给的沅陵坐标')
+    assert.equal(actual.lat, 28.4436787, '配置里的纬度应等于维护者给的沅陵坐标')
+    assert.ok(config.map.intro.enabled !== false, '本机配置应开启进入引导')
+    console.log(`  (已按本机配置核对:home=${actual.lon}, ${actual.lat} label=${actual.label} intro=${config.map.intro.enabled})`)
+}
+
+console.log(
+    'T30 检查通过:home/intro 默认值与归一化(含错字段 issue)、预设解析与 label 文案、归位判定(容差 + heading 环绕 + 无状态),并与本机配置里的沅陵坐标对齐核对。',
+)

+ 184 - 0
.scratch/cockpit-shell-panel-layout/verify/t32-ui-mode-walkthrough.mjs

@@ -0,0 +1,184 @@
+/**
+ * T32 真机走查:界面形态(开发诊断 vs 生产干净)。
+ *
+ * 维护者 2026-09-18 要求:正式环境不要那么多明显文本压在中央 3D 场景上,状态信息收成
+ * 底部半透明小字;**颜色语义(点位状态图例、状态灯)必须保留**。
+ *
+ * 断言的两种形态(同一个 dev server,靠注入配置切换,所以不依赖构建模式):
+ *   · `map.ui.debug: false` → 干净形态:标题/图层占位/实例提示/顶栏配置标签/当前模式读数都不在;
+ *     图例与状态灯在;引擎状态在**底部且半透明、字号更小**;
+ *   · `map.ui.debug: true`  → 调试形态:以上都在(且与票 04/09/21/29 走查依赖的选择器一致)。
+ *
+ * 运行(需先起 `yarn dev`):node .scratch/cockpit-shell-panel-layout/verify/t32-ui-mode-walkthrough.mjs
+ */
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
+const OUT_PROD = join(tmpdir(), 'cockpit-t32-production.png')
+const OUT_DEBUG = join(tmpdir(), 'cockpit-t32-debug.png')
+
+const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8'))
+
+const results = []
+function check(id, desc, pass, evidence) {
+    results.push({ id, desc, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ${desc}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+const puppeteer = (await import(PUPPETEER)).default
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: join(tmpdir(), 'cockpit-t32-profile'),
+    defaultViewport: { width: 1680, height: 950 },
+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
+})
+console.log(`Chrome: ${await browser.version()}`)
+
+/** 打开一页,注入 `map.ui.debug` 指定的形态,等引擎就绪后把「界面清单」读回来。 */
+async function openWith(uiDebug, screenshotPath) {
+    const page = await browser.newPage()
+    const errors = []
+    page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
+
+    await page.evaluateOnNewDocument(
+        (cfg, debug) => {
+            window.__COCKPIT_CONFIG__ = {
+                ...cfg,
+                map: { ...cfg.map, intro: { enabled: false }, ui: { debug } },
+            }
+        },
+        config,
+        uiDebug,
+    )
+
+    await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+    await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+    await page.waitForFunction(
+        () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready',
+        { timeout: 60000 },
+    )
+    await sleep(2500)
+    if (screenshotPath) await page.screenshot({ path: screenshotPath })
+
+    const inventory = await page.evaluate(() => {
+        const pill = document.querySelector('[data-map-engine-status]')
+        const pillStyle = pill ? getComputedStyle(pill) : null
+        const statusLine = document.querySelector('.map-holo__status')
+        const hostRect = document.querySelector('.cockpit-shell__map .map-canvas')?.getBoundingClientRect()
+        const pillRect = pill ? pill.getBoundingClientRect() : null
+        return {
+            uiMode: pill ? pill.dataset.uiMode : '',
+            // 票 33 起图例常驻一张图最上方,所以 `.map-holo__top` 在干净形态下也在;
+            // 干净形态要收起的是它里面的**调试抬头**(标题 + 图层占位)。
+            holoTop: document.querySelectorAll('.map-holo__top').length,
+            headline: document.querySelectorAll('.map-holo__headline').length,
+            placeholderTitle: document.querySelectorAll('.map-canvas__title').length,
+            devHint: document.querySelectorAll('.map-holo__hint').length,
+            configTag: document.querySelectorAll('.cockpit-shell__config').length,
+            modeReadout: document.querySelectorAll('.cockpit-shell__meta').length,
+            // 用户需要看的东西:颜色语义与图层控件
+            legend: document.querySelectorAll('[data-map-legend] .status-tag').length,
+            statusLine: document.querySelectorAll('.map-holo__status').length,
+            statusDot: document.querySelectorAll('.map-holo__status i').length,
+            homeButton: document.querySelectorAll('.cockpit-shell__home').length,
+            viewButtons: document.querySelectorAll('.cockpit-shell__view-btn').length,
+            scopeButtons: document.querySelectorAll('[data-scope]').length,
+            // 引擎状态那一行的位置与观感
+            pillBottomOffset: pillRect && hostRect ? Math.round(hostRect.bottom - pillRect.bottom) : null,
+            pillTopOffset: pillRect && hostRect ? Math.round(pillRect.top - hostRect.top) : null,
+            pillFontSize: pillStyle ? parseFloat(pillStyle.fontSize) : null,
+            pillOpacity: pillStyle ? Number(pillStyle.color.match(/[\d.]+\)$/) ? pillStyle.color.match(/[\d.]+\s*\)/)[0].replace(')', '') : 1) : null,
+            pillHasBackground: pillStyle ? pillStyle.backgroundColor !== 'rgba(0, 0, 0, 0)' : null,
+            quietClass: statusLine ? statusLine.classList.contains('is-quiet') : null,
+            statusFontSize: statusLine ? parseFloat(getComputedStyle(statusLine).fontSize) : null,
+            pillText: pill ? pill.textContent.trim() : '',
+        }
+    })
+    return { page, inventory, errors }
+}
+
+try {
+    // ---------------- 生产干净形态 ----------------
+    const prod = await openWith(false, OUT_PROD)
+    const p = prod.inventory
+    check(
+        'A1-prod-chrome-stripped',
+        '干净形态:中央图上的调试抬头(标题/图层占位)、实例提示与顶栏诊断读数全部收起',
+        p.headline === 0 && p.placeholderTitle === 0 && p.devHint === 0 && p.configTag === 0 && p.modeReadout === 0,
+        JSON.stringify({ headline: p.headline, title: p.placeholderTitle, hint: p.devHint, configTag: p.configTag, mode: p.modeReadout }),
+    )
+    check(
+        'A2-prod-keeps-color-semantics',
+        '干净形态**保留颜色语义**:点位状态图例、状态灯、图层控件与归位按钮都还在',
+        p.legend > 0 && p.statusDot > 0 && p.statusLine > 0 && p.homeButton > 0 && p.viewButtons >= 2 && p.scopeButtons > 0,
+        JSON.stringify({ legend: p.legend, dots: p.statusDot, home: p.homeButton, viewBtns: p.viewButtons, scopes: p.scopeButtons }),
+    )
+    check(
+        'A3-prod-pill-is-quiet-bottom',
+        '引擎状态收紧成**右下角状态日志**:贴底、无底色、字号更小、颜色带透明度',
+        p.uiMode === 'production' &&
+            p.pillBottomOffset !== null &&
+            p.pillBottomOffset <= 16 &&
+            p.pillHasBackground === false &&
+            p.pillFontSize <= 10 &&
+            p.pillOpacity !== null &&
+            p.pillOpacity < 1,
+        JSON.stringify({ uiMode: p.uiMode, bottom: p.pillBottomOffset, bg: p.pillHasBackground, font: p.pillFontSize, alpha: p.pillOpacity }),
+    )
+    check(
+        'A4-prod-status-line-quiet',
+        '底部摘要行同步收敛(is-quiet:字号更小、字色更淡)',
+        p.quietClass === true && p.statusFontSize <= 10,
+        JSON.stringify({ quietClass: p.quietClass, font: p.statusFontSize }),
+    )
+    check('A5-prod-no-errors', '干净形态无页面级报错', prod.errors.length === 0, JSON.stringify(prod.errors.slice(0, 3)))
+    await prod.page.close()
+
+    // ---------------- 调试形态(回归:既有走查依赖的选择器必须还在) ----------------
+    const debug = await openWith(true, OUT_DEBUG)
+    const d = debug.inventory
+    check(
+        'A6-debug-keeps-everything',
+        '调试形态保持原样:调试抬头/实例提示/配置标签/模式读数都在(票 04/09/21/29 走查依赖的选择器不变)',
+        d.headline === 1 &&
+            d.placeholderTitle === 1 &&
+            d.devHint === 1 &&
+            d.configTag === 1 &&
+            d.modeReadout === 1 &&
+            d.uiMode === 'debug',
+        JSON.stringify({ headline: d.headline, title: d.placeholderTitle, hint: d.devHint, configTag: d.configTag, mode: d.modeReadout, uiMode: d.uiMode }),
+    )
+    check(
+        'A7-debug-pill-keeps-reading',
+        '调试形态仍是可读的药丸(有底色、字号 11),位置让开顶部的图例与标题',
+        d.pillHasBackground === true && d.pillFontSize === 11 && d.pillTopOffset !== null && d.pillTopOffset >= 80,
+        JSON.stringify({ top: d.pillTopOffset, bg: d.pillHasBackground, font: d.pillFontSize }),
+    )
+    check(
+        'A8-both-forms-read-state',
+        '两种形态都能读到引擎状态(形态只改呈现,不改可观测性)',
+        p.pillText.includes('地形') && d.pillText.includes('地形'),
+        JSON.stringify({ prod: p.pillText.slice(0, 40), debug: d.pillText.slice(0, 40) }),
+    )
+    check('A9-debug-no-errors', '调试形态无页面级报错', debug.errors.length === 0, JSON.stringify(debug.errors.slice(0, 3)))
+    await debug.page.close()
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((r) => !r.pass)
+console.log('')
+console.log(`T32 走查:${results.length - failed.length}/${results.length} 项通过。截图:干净 ${OUT_PROD} / 调试 ${OUT_DEBUG}`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((f) => f.id).join(', '))
+    process.exit(1)
+}
+console.log('T32 走查全部通过。')

+ 207 - 0
.scratch/cockpit-shell-panel-layout/verify/t33-overlay-layout-walkthrough.mjs

@@ -0,0 +1,207 @@
+/**
+ * T33 真机走查:一张图浮层布局(维护者 2026-09-18 的三条要求)。
+ *
+ *   ① 点位状态图例放到**一张图最上方**(原来在底部,位置效果不好);
+ *   ② 地图摘要**最小化形态**(去掉「地图摘要」文字标题,收成状态点 + 短文案 + 动作的小胶囊);
+ *   ③ 浮层之间**不能互相叠字** —— 顺带修掉票 32 引入的「生产形态下引擎状态与底部摘要行重叠」。
+ *
+ * 断言方式是**真实几何**(getBoundingClientRect)而不是"看截图":图例是否贴顶、三块浮层是否相交、
+ * 摘要块宽度是否收窄、动作按钮与选择器是否还在(票 07/08/09/10 的走查依赖它们)。
+ *
+ * 运行(需先起 `yarn dev`):node .scratch/cockpit-shell-panel-layout/verify/t33-overlay-layout-walkthrough.mjs
+ */
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
+const OUT = join(tmpdir(), 'cockpit-t33-layout.png')
+
+const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8'))
+
+const results = []
+function check(id, desc, pass, evidence) {
+    results.push({ id, desc, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ${desc}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+/** 两个矩形是否相交(留 0 容差:边缘相接不算叠)。 */
+function overlaps(a, b) {
+    if (!a || !b) return false
+    return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
+}
+
+const puppeteer = (await import(PUPPETEER)).default
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: join(tmpdir(), 'cockpit-t33-profile'),
+    defaultViewport: { width: 1680, height: 950 },
+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
+})
+console.log(`Chrome: ${await browser.version()}`)
+
+async function openWith(uiDebug, shot) {
+    const page = await browser.newPage()
+    const errors = []
+    page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
+
+    await page.evaluateOnNewDocument(
+        (cfg, d) => {
+            window.__COCKPIT_CONFIG__ = { ...cfg, map: { ...cfg.map, intro: { enabled: false }, ui: { debug: d } } }
+        },
+        config,
+        uiDebug,
+    )
+    await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
+    await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+    await page.waitForFunction(
+        () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready',
+        { timeout: 60000 },
+    )
+    await sleep(2500)
+    if (shot) await page.screenshot({ path: shot })
+
+    const m = await page.evaluate(() => {
+        const r = (sel) => {
+            const el = document.querySelector(sel)
+            if (!el) return null
+            const b = el.getBoundingClientRect()
+            return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height) }
+        }
+        const panel = document.querySelector('[data-map-blocked-panel]')
+        const list = document.querySelector('[data-map-blocked-list]')
+        const readText = (sel) => {
+            const el = panel ? panel.querySelector(sel) : null
+            return el ? el.textContent.trim() : ''
+        }
+        return {
+            map: r('.cockpit-shell__map .map-canvas'),
+            rightCol: r('.cockpit-shell__column.is-right'),
+            legend: r('[data-map-legend]'),
+            statusLine: r('.map-holo__status'),
+            pill: r('[data-map-engine-status]'),
+            headline: r('.map-holo__headline'),
+            summary: r('.cockpit-shell__map-summary'),
+            summaryDot: r('.cockpit-shell__map-summary-dot'),
+            blockedPanel: r('[data-map-blocked-panel]'),
+            blockedCollapsed: panel ? panel.classList.contains('is-compact') : null,
+            blockedListPresent: !!list,
+            // 票 07 的断言面:收起形态下这两个文本也必须仍在(DOM 常驻,只是视觉收起)
+            backendText: readText('[data-map-coordinate-backend]'),
+            loadedText: readText('[data-map-coordinate-loaded]'),
+            hasSummaryLabelText: !!Array.from(document.querySelectorAll('.cockpit-shell__scope-right .cockpit-shell__scope-label'))
+                .find((el) => el.textContent.trim() === '地图摘要'),
+            summaryAction: !!document.querySelector('[data-map-summary-action]'),
+            summaryLabel: !!document.querySelector('[data-map-summary-label]'),
+            legendTags: document.querySelectorAll('[data-map-legend] .status-tag').length,
+            uiMode: (document.querySelector('[data-map-engine-status]') || {}).dataset?.uiMode || '',
+        }
+    })
+    return { page, m, errors }
+}
+
+try {
+    for (const uiDebug of [false, true]) {
+        const tag = uiDebug ? 'debug' : 'prod'
+        const { page, m, errors } = await openWith(uiDebug, uiDebug ? null : OUT)
+
+        // ① 图例贴顶
+        const legendTopOffset = m.legend && m.map ? m.legend.y - m.map.y : null
+        check(
+            `A1-${tag}-legend-at-top`,
+            '点位状态图例在**一张图最上方**(距画布顶 ≤ 24px),且图例本身仍在(颜色语义)',
+            legendTopOffset !== null && legendTopOffset >= 0 && legendTopOffset <= 24 && m.legendTags > 0,
+            JSON.stringify({ legendTopOffset, tags: m.legendTags, mode: m.uiMode }),
+        )
+
+        // ③ 浮层不叠字
+        const legendVsStatus = overlaps(m.legend, m.statusLine)
+        const pillVsStatus = overlaps(m.pill, m.statusLine)
+        const pillVsLegend = overlaps(m.pill, m.legend)
+        const pillVsHeadline = overlaps(m.pill, m.headline)
+        check(
+            `A2-${tag}-no-overlap`,
+            '图例 / 引擎状态 / 底部摘要行三者互不重叠(票 32 引入的叠字已修)',
+            !legendVsStatus && !pillVsStatus && !pillVsLegend && !pillVsHeadline,
+            JSON.stringify({ legendVsStatus, pillVsStatus, pillVsLegend, pillVsHeadline, legend: m.legend, pill: m.pill, statusLine: m.statusLine }),
+        )
+
+        // ② 摘要最小化
+        check(
+            `A3-${tag}-summary-minimized`,
+            '地图摘要收成小胶囊:去掉「地图摘要」文字标题、带状态点、宽度收窄,但动作按钮与状态文案选择器仍在',
+            m.hasSummaryLabelText === false &&
+                !!m.summaryDot &&
+                m.summary &&
+                m.summary.w <= 300 &&
+                m.summaryAction &&
+                m.summaryLabel,
+            JSON.stringify({ labelText: m.hasSummaryLabelText, dot: !!m.summaryDot, w: m.summary ? m.summary.w : null, action: m.summaryAction, label: m.summaryLabel }),
+        )
+
+        // 票 35 续:两块摘要都贴一张图**右下角**(让开右栏),且竖排不叠
+        const rightCol = m.rightCol
+        const gapOf = (r) => (r && rightCol ? Math.round(rightCol.x - r.x - r.w) : null)
+        const blockedGap = gapOf(m.blockedPanel)
+        const pillGap = gapOf(m.pill)
+        // 胶囊在**下半屏**、且整体在状态日志之上(不叠)
+        const blockedLow = m.map && m.blockedPanel ? m.blockedPanel.y > m.map.y + m.map.h * 0.5 : false
+        const stackedAbovePill = m.blockedPanel && m.pill ? m.blockedPanel.y + m.blockedPanel.h <= m.pill.y : false
+        // 生产形态:状态日志也贴右下角、并在胶囊下方;调试形态:药丸是顶部诊断读数(t32 A7 另测),不参与右下角。
+        const pillOk = uiDebug ? true : pillGap !== null && pillGap >= 4 && pillGap <= 60
+        check(
+            `A7-${tag}-bottom-right-stack`,
+            '「无地图位置」胶囊贴**右下角**(让开右栏、在下半屏),生产形态下引擎状态日志同为右下角且在胶囊下方、互不叠',
+            blockedGap !== null && blockedGap >= 4 && blockedGap <= 60 && blockedLow && pillOk && !overlaps(m.blockedPanel, m.pill) && (uiDebug || stackedAbovePill),
+            JSON.stringify({ blockedGap, pillGap, blockedLow, stackedAbovePill, blocked: m.blockedPanel, pill: m.pill, rightColX: rightCol ? rightCol.x : null }),
+        )
+
+        check(`A4-${tag}-no-errors`, `${tag} 形态无页面级报错`, errors.length === 0, JSON.stringify(errors.slice(0, 2)))
+
+        // 票 35:无地图位置浮块必须是「收起的一行小胶囊」,不能再占中央视野
+        check(
+            `A5-${tag}-blocked-panel-compact`,
+            '「无地图位置」默认收起成一行小胶囊(高度 ≤ 32px、宽度 ≤ 300px),且两条口径文本仍在 DOM 里(票 07 断言面不变)',
+            m.blockedCollapsed === true &&
+                m.blockedPanel &&
+                m.blockedPanel.h <= 32 &&
+                m.blockedPanel.w <= 300 &&
+                m.backendText.length > 0 &&
+                m.loadedText.length > 0,
+            JSON.stringify({ collapsed: m.blockedCollapsed, rect: m.blockedPanel, backend: m.backendText.slice(0, 24), loaded: m.loadedText.slice(0, 24) }),
+        )
+
+        // 展开后仍应比旧版窄(宽度上限 280,不再是 340)
+        await page.click('[data-map-blocked-toggle]')
+        await sleep(500)
+        const expanded = await page.evaluate(() => {
+            const el = document.querySelector('[data-map-blocked-panel]')
+            const b = el.getBoundingClientRect()
+            return { w: Math.round(b.width), h: Math.round(b.height), list: !!document.querySelector('[data-map-blocked-list]') }
+        })
+        check(
+            `A6-${tag}-blocked-panel-expanded-narrow`,
+            '展开后仍比旧版收窄(宽 ≤ 300px)且清单出现',
+            expanded.w <= 300 && expanded.list === true,
+            JSON.stringify(expanded),
+        )
+        await page.click('[data-map-blocked-toggle]')
+        await page.close()
+    }
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((r) => !r.pass)
+console.log('')
+console.log(`T33 走查:${results.length - failed.length}/${results.length} 项通过。截图:${OUT}`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((f) => f.id).join(', '))
+    process.exit(1)
+}
+console.log('T33 走查全部通过。')

+ 330 - 0
docs/cockpit-deployment.md

@@ -0,0 +1,330 @@
+# 驾驶舱生产部署操作手册
+
+> 面向部署人员(运维/实施)。**只改配置与静态目录,不需要重新构建前端。**
+> 阅读顺序:先看「一页速查」,按第 2 节改配置,按第 3 节挂数据,最后跑第 5 节自检。
+
+---
+
+## 一页速查
+
+| 我要做的事 | 改哪里 | 怎么改 |
+| --- | --- | --- |
+| 打开/关闭运行时配置 | 产物 `index.html` | 加/删一行 `window.__COCKPIT_CONFIG_URL__ = 'auto'` |
+| 改后端接口地址 | `dist/cockpit/config.json` → `api.baseUrl` | 填网关地址,如 `/api/cockpit/v1` |
+| 改数据源(mock / 真接口) | 同上 → `dataSource` | `"mock"`(默认)或 `"real"` |
+| 给真接口配只读令牌(仅联调) | 同上 → `api.token` | **生产留空**,由网关注入 `Authorization` |
+| 换全局底图 | 同上 → `map.basemap.source` | 默认 `natural-earth`(离线);可选 `xyz` / `tianditu` / `arcgis-imagery` |
+| 挂地形 DEM | 同上 → `map.terrain.url` | 指向含 `layer.json` 的目录 URL |
+| 挂本地精细影像 | 同上 → `map.overlays[]` | `url`(TMS 用 `{reverseY}`)+ `bounds` |
+| 挂 3D Tiles / 倾斜摄影 | 同上 → `map.tilesets[]` | 每项一个 `tileset.json` 的 URL |
+| 挂 GIS 静态目录(nginx) | 服务器 nginx | 见第 3 节的两段 `location` |
+| 改「目标城市」与归位视角 | `dist/cockpit/config.json` → `map.home` | 见 2.6 |
+| 开关进入引导(自转星球 + 点击进入) | 同上 → `map.intro.enabled` | 见 2.6 |
+| 地面/管线视图的倾角与半透明 | 同上 → `map.viewMode` | 见 2.7(不配则用默认:管线 pitch -18°、距离 ×0.35、地形 α0.45、模型 α0.4) |
+| 现场调试生产包(显示诊断信息) | 同上 → `map.ui.debug` | `true` 临时打开;`false` 强制干净形态(默认跟随构建) |
+| 完全关掉三维引擎 | 同上 → `map.cesium.enabled` | `false` → 中央区域退回临时影像 |
+
+**三个文件的分工**(先记住这句,后面就不会乱):
+
+1. **`dist/cockpit/config.json`** —— 运行时配置,**部署后改这个**(环境地址、数据源、图层)。
+2. **产物 `index.html`** —— 加一行开关,告诉前端「去读上面那个文件」。
+3. **服务器上的 GIS 目录 + nginx** —— DEM / 影像 / 3D Tiles 的静态数据。
+
+---
+
+## 1. 配置是怎么生效的
+
+前端按**四层**取值,后者覆盖前者(合并是深合并,只写要改的字段即可):
+
+| 层 | 来源 | 谁改 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 内置默认 | 前端 | `dataSource: mock`、`basemap: natural-earth`、不挂地形/影像/3D Tiles |
+| 2 | 构建期 `VITE_COCKPIT_*` | 前端构建者 | 打进产物,换环境要重建,**不推荐生产用** |
+| 3 | `<base>cockpit/config.json` | **部署人员** | 本次部署的主入口,改完刷新页面即生效 |
+| 4 | 宿主注入 `window.__COCKPIT_CONFIG__` | 集成方(qiankun 主应用等) | 整份对象注入,优先级最高;给了就不再读第 3 层 |
+
+**打开第 3 层**:默认前端**不探测**配置文件(免得每页刷 404)。二选一:
+
+- **部署后改产物**(推荐,无需重建):编辑 `dist/index.html`,在 `<head>` 里加一行 ——
+  ```html
+  <script>window.__COCKPIT_CONFIG_URL__ = 'auto';</script>
+  ```
+  `'auto'` = 取应用基址下的 `cockpit/config.json`(`publicPath: './'` 与 qiankun 子路径下都对)。
+  也可以给绝对 URL(例如配置由后端下发):`window.__COCKPIT_CONFIG_URL__ = 'https://cfg.example.com/cockpit.json';`
+- **构建期**:`.env` 里写 `VITE_COCKPIT_CONFIG_URL=auto` 再 `yarn build`。
+
+**顶栏能看到状态**:`.cockpit-shell__config` 标签显示 `配置就绪 / 配置缺失 / 配置载入失败` 与数据源;
+引擎浮标显示 `底图:… · 地形:… · 叠加影像 N 层`。排查问题时先看这两个地方。
+
+---
+
+## 2. 改 `dist/cockpit/config.json`
+
+模板见仓库 `public/cockpit/config.example.json`(带逐字段注释)。**只需写要覆盖的字段**:
+
+### 2.1 后端接口
+
+```jsonc
+{
+  "dataSource": "real",                    // 真接口;默认 mock
+  "api": { "baseUrl": "/api/cockpit/v1" }, // 网关地址;同源可写相对路径
+  "ws":  { "url": "wss://内网主机/ws/deviceStatus" }
+}
+```
+
+- **`api.token` 生产请留空**:按 ADR 0005,`Authorization: Bearer <JWT>` 由**网关/反向代理在转发时补**;
+  前端只在联调时用配置里的只读令牌。凭据缺失或 401 时只把受影响分区标「数据不可用」,**不会跳登录页**。
+- 想改回 mock 演示:`"dataSource": "mock"`,无需改其它字段。
+
+### 2.2 全局底图(离线优先)
+
+```jsonc
+"map": { "basemap": { "source": "natural-earth" } }   // 推荐:Cesium 自带离线影像,零外网
+```
+
+| 取值 | 是否离线 | 需要什么 |
+| --- | --- | --- |
+| `natural-earth` | ✅ 完全离线 | 无(影像随 `dist/cesium/Assets` 一起部署) |
+| `xyz` | ✅ 可离线 | `url` 模板,如 `/gis/world/{z}/{x}/{y}.png` |
+| `tianditu` | ❌ 在线 | `map.tianditu.token`(天地图 tk),开发走 `/tianditu.map` 代理 |
+| `arcgis-imagery` | ❌ 在线 | 无(免 key,Esri 署名),但需要外网 |
+| `none` | — | 不配底图,中央区域显示临时影像 |
+
+> **内网环境请用 `natural-earth` 或 `xyz`**。本项目优先本地/内网离线数据。
+
+### 2.3 地形 DEM
+
+```jsonc
+"map": { "terrain": { "url": "/gis/dem", "requestVertexNormals": true } }
+```
+
+- `url` 指向**含 `layer.json` 的目录**(量化网格 quantized-mesh)。
+- cesiumlab 导出的 `layer.json` 里 `scheme: "tms"` —— **Cesium 自己会认,服务端不要翻 y**。
+- 配错或不存在的现象:浮标显示 `地形加载失败(…)· 回退椭球`,页面照常(不白屏)。
+
+### 2.4 本地精细影像
+
+```jsonc
+"map": {
+  "overlays": [{
+    "url": "/gis/pic3/{z}/{x}/{reverseY}.png",
+    "minimumLevel": 0, "maximumLevel": 19,
+    "bounds": [109.96215820312366, 27.962869019358852, 111.04568213224277, 29.060971407383555],
+    "credit": "沅陵本地影像"
+  }]
+}
+```
+
+- **TMS 数据必须用 `{reverseY}`**(cesiumlab 导出的 `meta.json` 里 `tiletrans: "tms"`);
+  若你的切片是标准 XYZ(slippy map),则写 `{y}`。
+- `bounds` = `[西, 南, 东, 北]`,抄 `meta.json` 的 bounds。填了它,范围外不会发请求(否则整球视角刷一屏 404)。
+- 可以有多个 overlay,按数组顺序从下往上叠。
+
+### 2.5 3D Tiles / 倾斜摄影
+
+```jsonc
+"map": { "tilesets": [{ "url": "/gis/model/tileset.json" }] }
+```
+
+- 每项一个 `tileset.json` 的 URL(**根** tileset,不是分块 json),可选 `maximumScreenSpaceError`。
+- 加载失败只记状态、不影响其它图层:状态行显示 `3D Tiles 加载失败(…)`。
+- ⚠️ **tileset 级 `geometricError` 必须大于 0**(例如 `800`)。写成 `0` 时 Cesium 不请求根瓦片内容:
+  `tileset.json` 拉得到、内容 `.glb` 永远不下载,地图上一片空白 —— 这是实测踩过的坑。
+- 现有可选的两份数据:
+  1. **示意体块模型**(推荐先用,本机已生成):`D:\GIS\GIS\cityMassing\`,由
+     `tools/city-massing/build-town.mjs` 从真实路网/建成区/轮廓程序化生成,配
+     `"map": { "tilesets": [{ "url": "/gis/city/tileset.json" }] }`。
+     ⚠️ 它是**示意体块、不是实测建筑**,只保证远景观感,**不得用于量测/统计/决策**(见该目录 README)。
+  2. **甲方原始 3D Tiles**:`D:\GIS\GIS\yuanlingSHP`(cesiumlab `model2tiles` 导出)**缺根
+     `tileset.json`**、分块是局部坐标;曾补过一版合成根文件,经目测**错位且是白模,不采用**
+     (合成文件已删除)。若日后用 cesiumlab 重导并勾选生成根 tileset,直接填 `map.tilesets` 即可替换示意模型。
+
+### 2.6 「目标城市」与进入引导
+
+进系统时的落点、以及顶栏「归位」按钮回到的视角,都由 `map.home` 决定;引导层由 `map.intro` 控制。
+
+```jsonc
+"map": {
+  "home": {
+    "lon": 110.39028164,   // 目标点经度(CGCS2000/天地图口径,与 WGS-84 本项目视为一致)
+    "lat": 28.4436787,     // 目标点纬度
+    "heading": 0,          // 朝向(0 = 正北)
+    "pitch": -55,          // 俯角(负 = 从上往下看)
+    "range": 12000,        // 弹簧臂距离(米):12000 ≈ 县域级;4600 ≈ 街区级
+    "label": "沅陵县城"     // 归位按钮的提示文案
+  },
+  "intro": {
+    "enabled": true,       // false = 跳过引导,打开即进系统(不需要引导的部署设 false)
+    "title": "点击进入系统",
+    "spinSeconds": 48      // 星球自转一圈的秒数;0 = 不自转
+  }
+}
+```
+
+**要点**:
+
+- `home.lon/lat` 换成别的城市即可;`range` 决定"进入时看多细",**别小于 2000**(再近看不到县城轮廓)。
+- 中心高程**不用填**:地形就绪后系统会用 DEM 实测高程(沅陵实测 111.6 m),填了也会被实测值覆盖 —— 这是
+  为了避免相机按椭球 0 米定位而钻进山体(拉到 120 m 时曾经整屏黑)。
+- `intro.enabled=false` 时**不显示**引导层,但「归位」按钮仍在、`home` 仍然生效。
+- 需要"进入后记住,下次直接进"的话目前不支持(每次刷新都会显示引导),属已知取舍。
+
+---
+
+## 2.7 地面视图 / 管线视图
+
+驾驶舱顶栏筛选条里有「地面视图 / 管线视图」两个按钮(全局条件,切一级模式不重置)。
+管线视图会**压低相机 + 放宽俯仰**,并把地形与 3D Tiles **压成半透明**,以便露出管线;地面视图还原。
+**旋转中心不重置、三键操作完全不变**(左键平移、右键旋转、滚轮伸缩)。
+
+默认值一般够用;要调就写 `map.viewMode`:
+
+```jsonc
+"map": {
+  "viewMode": {
+    "pipeline": {
+      "pitch": -18,          // 管线视图倾角(度,越接近 0 越平视;允许到 +5)
+      "rangeFactor": 0.35,   // 弹簧臂压到当前距离的几成
+      "terrainAlpha": 0.45,  // 地形透明度(0–1;1 = 不透明,0 = 全透)
+      "tilesetAlpha": 0.4    // 3D Tiles / 倾斜摄影透明度
+    }
+  }
+}
+```
+
+写错类型/范围外的值会被忽略并退回默认(不会把相机顶到奇怪角度)。
+
+---
+
+## 2.8 界面形态:开发诊断 vs 生产干净
+
+驾驶舱有两套界面形态,**生产构建默认就是干净形态**,不需要配置:
+
+| | 调试形态(开发构建默认) | 生产干净形态 |
+| --- | --- | --- |
+| 中央图上的「中央一张图」标题、图层占位、实例提示 | 显示 | **收起** |
+| 引擎状态(底图/地形/影像/3D Tiles) | 顶部药丸,带边框底色 | **贴底半透明小字**(无边框无底色) |
+| 顶栏「配置就绪 · 数据源」标签、「当前模式」读数 | 显示 | **收起** |
+| 点位状态图例(颜色语义)、状态灯 | 显示 | **保留** |
+| 范围/行业/视图筛选、归位按钮、只读视图、时钟 | 显示 | 保留 |
+
+判定规则:`map.ui.debug` 显式给布尔值就听它,否则**跟随构建模式**(`yarn dev` → 调试;`yarn build` 产物 → 干净)。
+
+```jsonc
+"map": {
+  "ui": {
+    "debug": false   // 想在生产包里临时看诊断信息就写 true;不写则跟随构建模式
+  }
+}
+```
+
+故障时颜色语义仍在:引擎失败时那行小字会转成淡红(生产形态没有边框,靠字色传达)。
+
+---
+
+## 3. 挂 GIS 静态目录(nginx)
+DEM / 影像 / 3D Tiles 都是**静态文件**,直接从磁盘服务,**不要放进前端产物**(上百 MB 起)。
+
+```nginx
+# 地形 DEM(里面必须有 layer.json)
+location /gis/dem/ {
+    alias /data/gis/yuanlingDEM/;
+    add_header Cache-Control "public, max-age=86400";
+    types { application/octet-stream terrain; }
+    default_type application/octet-stream;
+}
+
+# 本地影像切片
+location /gis/pic3/ {
+    alias /data/gis/yuanlingPic3/;
+    add_header Cache-Control "public, max-age=86400";
+}
+
+# 3D Tiles(若启用)
+location /gis/model/ {
+    alias /data/gis/model/;
+    add_header Cache-Control "public, max-age=86400";
+    types { model/gltf-binary glb; application/json json; }
+}
+```
+
+**两条硬要求**:
+
+1. **别让 `.terrain` / `.png` / `.glb` / `layer.json` 走 SPA 回退**。回退成 `index.html` 时,
+   Cesium 会报 `Unexpected token '<'`(地形)或整片瓦片加载失败。上面的 `location` 前缀即是隔离手段。
+2. **允许跨域**(前端与数据不同源时):
+   ```nginx
+   add_header Access-Control-Allow-Origin "*";
+   ```
+
+**目录搬走/换机器**:只改 `config.json` 里对应的 `url`,**不用改前端、不用重建**。
+
+---
+
+## 4. 完整的最小可用配置(内网离线)
+
+```jsonc
+{
+  "dataSource": "mock",
+  "map": {
+    "basemap": { "source": "natural-earth" },
+    "home": { "lon": 110.39028164, "lat": 28.4436787, "heading": 0, "pitch": -55, "range": 12000, "label": "沅陵县城" },
+    "intro": { "enabled": true, "title": "点击进入系统", "spinSeconds": 48 },
+    "terrain": { "url": "/gis/dem", "requestVertexNormals": true },
+    "overlays": [{
+      "url": "/gis/pic3/{z}/{x}/{reverseY}.png",
+      "minimumLevel": 0,
+      "maximumLevel": 19,
+      "bounds": [109.96215820312366, 27.962869019358852, 111.04568213224277, 29.060971407383555]
+    }],
+    "tilesets": []
+  }
+}
+```
+
+---
+
+## 5. 部署后自检(逐条看现象)
+
+| 检查 | 期望现象 | 不通过时看哪里 |
+| --- | --- | --- |
+| 页面能打开 | `/#/cockpit` 出现驾驶舱外壳,无白屏 | 控制台报错、`index.html` 的配置开关 |
+| 进入引导 | 先看到自转星球 + 闪烁「点击进入系统」 | `map.intro.enabled` |
+| 进入落点 | 点一下后镜头**落在目标城市**(不是随机位置) | `map.home.lon/lat/range` |
+| 归位按钮 | 顶栏「归位」可点,平移后点它回到目标视角 | `map.home`、引擎是否就绪 |
+| 配置生效 | 顶栏标签「配置就绪 · 数据源 mock/real」 | `config.json` 路径、`__COCKPIT_CONFIG_URL__` |
+| 全局底图 | 地球有影像(不是黑球) | `map.basemap.source` |
+| 地形 | 浮标含「地形:dem」;拉近后有山体起伏 | nginx `/gis/dem/`、`layer.json` 能直接下载 |
+| 本地影像 | 压到目标区域能看到精细影像 | overlay 的 `url`/`{reverseY}`/`bounds` |
+| 无 404 噪声 | 浏览器网络面板里 `/gis/**` 全是 200 | 多半是 y 轴约定:TMS 未写 `{reverseY}` |
+| 3D Tiles(若启用) | 浮标含「3D Tiles N 个」 | 根 `tileset.json` 是否存在(见 2.5) |
+| 真接口联调 | 分区出数据;401 时只该分区「数据不可用」 | 网关是否补了 `Authorization` 头 |
+
+---
+
+## 6. 常见问题
+
+**Q:改了 `config.json` 没生效?**
+确认产物 `index.html` 里有 `window.__COCKPIT_CONFIG_URL__` 那一行;再硬刷新(配置请求带 `cache: 'no-store'`,
+但 `index.html` 可能被缓存)。
+
+**Q:地形/影像请求 404?**
+先看 URL 能不能直接下载(浏览器地址栏打开 `…/gis/dem/layer.json`)。全 404 = nginx `alias` 路径不对;
+个别 404 = 瓦片 y 轴约定不对(TMS 要 `{reverseY}`)或 `bounds` 与数据范围不一致。
+
+**Q:能不能用天地图/ArcGIS?**
+可以但有前提:天地图要免费 tk;ArcGIS(`arcgis-imagery`,免 key)要外网。
+**本项目约定优先用本地/内网离线数据**,在线源仅作可选补充。
+
+**Q:拉很近画面变黑?**
+已修(票 30):中心点高程现在按 **DEM 实测**取(不是椭球 0 米),拉到距离下限 120 m 仍能看到地面。
+若仍黑,检查地形是否加载成功(浮标应有「地形:dem」)。
+
+**Q:不想每次进来都点「点击进入系统」?**
+`map.intro.enabled = false`。归位按钮不受影响。
+
+**Q:进入引导挡住了自动化/走查?**
+同上,或由宿主页注入 `window.__COCKPIT_CONFIG__ = { map: { intro: { enabled: false } } }` 只关这一个用例。
+
+**Q:要换一批数据(另一个区县)?**
+把新数据按同样形态准备好 → nginx 挂新目录 → 改 `config.json` 的三个 `url`。前端不用动。

+ 109 - 0
public/cockpit/README.md

@@ -1,5 +1,8 @@
 # 驾驶舱自有静态资产
 
+> **生产部署请先看 [`docs/cockpit-deployment.md`](../../docs/cockpit-deployment.md)** —— 那里是给运维的操作手册
+> (改哪个文件、怎么改、怎么自检)。本文件偏「开发期怎么接数据」。
+
 本目录是**驾驶舱唯一**的静态资源位置:`public/plugins/**` 属于 TvT 插件体系,
 驾驶舱不得依赖它(票 06 验收第 1 条)。
 
@@ -26,3 +29,109 @@ const photoUrl = `${base}cockpit/aerial.webp`
   图片类资源(图例图标、底图、纹理)放本目录,替换时保持文件名或改用新名 + 更新引用点。
 - 替换资源**不应**引起 `mode` / `panelId` / 面板结构变化——票 06 验收第 4 条。
   结构由 `src/modules/cockpit/domain/panelCatalog.js` 决定,与视觉资源解耦。
+
+## 部署配置(票 04)
+
+后端地址、天地图、Cesium 与 WebSocket 一律从**配置服务**(`src/modules/cockpit/services/cockpitConfig.js`)读,
+前端不硬编码环境地址,也不保存账号或密钥。
+
+**放哪儿**:把 `config.example.json` 复制成同目录的 `config.json`(已进 `.gitignore`)。构建时它随
+`public/` 一起进产物(`dist/cockpit/config.json`)。部署后改环境**不需要重新构建**:直接改产物里那个文件,
+或在网关/反向代理把这个路径指向你自己维护的配置。
+
+**覆盖顺序**(后者覆盖前者,深合并):
+
+| 层 | 来源 | 用途 |
+| --- | --- | --- |
+| 1 | 内置默认 | `dataSource: mock`,一份配置都没有也能起 |
+| 2 | 构建期 `VITE_COCKPIT_*` | 本机联调,写 `.env.local`(已 gitignore) |
+| 3 | `cockpit/config.json` | 部署配置:同一个构建跑多环境 |
+| 4 | `globalThis.__COCKPIT_CONFIG__` | qiankun 主应用 / 网关注入(给了就不再拉文件) |
+
+构建期可用键:`VITE_COCKPIT_DATA_SOURCE`、`VITE_COCKPIT_API_BASE_URL`、`VITE_COCKPIT_API_TOKEN`、
+`VITE_COCKPIT_WS_URL`、`VITE_COCKPIT_WS_TOKEN`、`VITE_COCKPIT_TIANDITU_TOKEN`、
+`VITE_COCKPIT_CESIUM_BASE_URL`、`VITE_COCKPIT_CONFIG_URL`。
+
+**三态**:`ready`(载入成功)/ `missing`(没有运行时配置,用内置默认)/ `failed`(拿到了但不可用)。
+顶栏有对应标签,宿主带 `data-config-state` 供走查断言。
+
+**凭据**:`api.token` / `ws.token` 只用于开发与联调的**只读**令牌;生产由网关在转发
+`/api/cockpit/v1/**` 时补 `Authorization: Bearer <JWT>`,配置里留空即可。凭据缺失或 401 时只把受影响分区
+标成「数据不可用」,**不跳登录页、不整页遮罩**(ADR 0005)。
+
+**一个坑**:`yarn dev` 下 Vite 会把不存在的路径回退成 `index.html` + 200,所以配置服务把「HTML 载荷」
+也判定为「文件不存在」(missing)——不能只看状态码,见 `services/cockpitConfig.js` 顶部说明。
+
+## 怎么配出一个基础地图场景(票 29)
+
+中央一张图默认**不挂底图**(票 27:底图未配置就不伪造地理配准),所以你看到的是一个深色球。
+`map.basemap.source` 四选一:
+
+| source | 需要什么 | 适合 |
+| --- | --- | --- |
+| `none`(默认) | 无 | 还没有底图,用临时影像兜底 |
+| `natural-earth` | 无 —— Cesium 自带离线影像(随 `dist/cesium/Assets` 一起部署,不连外网) | 想马上看到一个有大陆轮廓的地球 |
+| `tianditu` | `map.tianditu.token`(天地图 tk);开发走仓库自带的 `/tianditu.map` 代理,生产直连 `t{s}.tianditu.gov.cn` | 有天地图 key,要看国内影像/矢量 |
+| `xyz` | `map.basemap.url` 形如 `https://host/tiles/{z}/{x}/{y}.png`(可带 `{s}` 子域) | 甲方本地瓦片、自建瓦片服务、第三方 XYZ |
+
+三种写法的例子:
+
+```jsonc
+// 1) 离线内置影像:改完刷新页面就是一个地球
+"map": { "basemap": { "source": "natural-earth" } }
+
+// 2) 天地图影像(token 由部署配置给;生产由网关注入也走同一个字段)
+"map": { "basemap": { "source": "tianditu" }, "tianditu": { "token": "<你的 tk>", "layer": "img" } }
+
+// 3) 本地/自建瓦片:url 可填相对路径(相对 index.html 解析),subdomains/levels 可选
+"map": { "basemap": { "source": "xyz", "url": "tiles/{z}/{x}/{y}.png", "subdomains": [], "minimumLevel": 1, "maximumLevel": 18, "credit": "甲方基础地理" } }
+```
+
+怎么确认生效:宿主 `.map-canvas` 上的 `data-map-basemap` 会变成 `natural-earth` / `xyz` / `tianditu`(未配置是
+`not-configured`,配置缺字段是 `invalid`),引擎浮标同时显示「底图:…」;配置无效时浮标会写明缺什么
+(例如「底图配置无效(天地图底图缺少 tk)· 回退临时影像」),不会白屏。
+
+**还没做的**:地形、3D Tiles、倾斜摄影与地面-管线视图切换归票 21。
+
+## 本地 / 甲方 GIS 数据怎么接(票 21)
+
+两类数据、两个字段,形态决定写法:
+
+| 数据 | 本机形态 | 配置字段 | 关键点 |
+| --- | --- | --- | --- |
+| 地形 DEM | `D:\GIS\GIS\yuanlingDEM`:cesiumlab 量化网格(`layer.json` + `{z}/{x}/{y}.terrain`,EPSG:4326、`scheme: "tms"`、带顶点法线) | `map.terrain.url` | `CesiumTerrainProvider.fromUrl(<目录>)` 直接吃 `layer.json`;`scheme: "tms"` **由 Cesium 自己认,服务端不要翻 y** |
+| 影像切片 | `D:\GIS\GIS\yuanlingPic3`:cesiumlab 切片(`meta.json` / `tilemapresource.xml`:EPSG:3857 + TMS,0–19 级 256px PNG) | `map.overlays[]` | TMS 的 y 自下而上 → 模板必须写 **`{reverseY}`**;把 `meta.json` 的 bounds 填进 `bounds`,范围外就不发请求(否则整球视角会刷 404) |
+
+### 本机开发
+
+`.env.local`(已 gitignore)里两条:
+
+```dotenv
+COCKPIT_GIS_MOUNTS=[{"prefix":"/gis/dem","dir":"D:/GIS/GIS/yuanlingDEM"},{"prefix":"/gis/pic3","dir":"D:/GIS/GIS/yuanlingPic3"}]
+VITE_COCKPIT_CONFIG_URL=auto
+```
+
+`COCKPIT_GIS_MOUNTS` 由 `src/common/localGisStaticPlugin.js` 在 dev server 上挂成静态路径 —— DEM 与影像上百 MB,
+**既不进仓库也不进产物**;第二条打开本目录的 `config.json`(同样 gitignore)。
+
+### 生产部署(改配置即可,不用重新构建)
+
+1. 两个目录放到服务器上(例如 `/data/gis/...`),nginx 挂出**同样的 URL 前缀**:
+
+```nginx
+location /gis/dem/  { alias /data/gis/yuanlingDEM/;  add_header Cache-Control "public, max-age=86400"; }
+location /gis/pic3/ { alias /data/gis/yuanlingPic3/; add_header Cache-Control "public, max-age=86400"; }
+```
+
+2. 改产物里的 `dist/cockpit/config.json`:`map.terrain.url` → `/gis/dem`,`map.overlays[0].url` → `/gis/pic3/{z}/{x}/{reverseY}.png`
+   (或指向另一台瓦片服务;前端只认这三个字段,换数据源就是换这几个 URL)。
+3. `.terrain` 与 `.png` 都要能直接下载,别做 SPA 回退 —— 回退成 HTML 时 Cesium 会报「Unexpected token '<'」。
+
+### 全局粗糙层
+
+`map.basemap.source` 用 `natural-earth`:Cesium 自带离线影像,**不依赖外网**(本机实测 ArcGIS / OSM 等外部瓦片不可达,
+所以这是离线环境的唯一可用项)。能连外网时可改 `arcgis-imagery`(ArcGIS 官方世界影像,免费免 key,带 Esri 署名)
+或 `tianditu`(要有免费 tk,开发走仓库自带的 `/tianditu.map` 代理)。
+
+
+

+ 54 - 0
public/cockpit/config.example.json

@@ -0,0 +1,54 @@
+{
+  "_readme": [
+    "驾驶舱部署配置模板。用法:复制成同目录的 config.json 再按环境填写 —— config.json 不入库(见根 .gitignore)。",
+    "分层覆盖顺序(后者覆盖前者):内置默认 → 构建期 VITE_COCKPIT_* → 本文件 → 宿主注入的 globalThis.__COCKPIT_CONFIG__。",
+    "启用本文件(默认不探测,免得每页刷 404):宿主页加一行 window.__COCKPIT_CONFIG_URL__ = 'auto',或构建期设 VITE_COCKPIT_CONFIG_URL=auto(也可以给绝对 URL 从后端拉)。",
+    "dataSource 默认 mock:只有显式写 real 才会请求真实后端,mock 与真接口走同一个适配层(services/cockpitApi.js)。",
+    "api.token / ws.token 是给开发与联调用的只读令牌;生产环境应由网关/反向代理在转发时补 Authorization 头,这里留空即可。",
+    "本项目优先用内网离线数据:底图默认 natural-earth(Cesium 自带离线影像,无需账号/外网/密钥)。在线源(tianditu 需 tk、arcgis-imagery 免 key)只作为可选项。",
+    "map.basemap.source 取值:none(不配底图)/ natural-earth(离线,推荐)/ xyz(任意 {z}/{x}/{y} 模板,本地或自建瓦片)/ tianditu(需 tk)/ arcgis-imagery(在线,免 key)。",
+    "map.terrain.url 指向量化网格 DEM 目录(内含 layer.json);map.overlays[] 叠本地精细影像(TMS 用 {reverseY},bounds 填数据实际范围)。",
+    "map.tilesets[] 是 3D Tiles / 倾斜摄影入口,每项一个 tileset.json 的 URL(没有根 tileset.json 的数据先别填;详见 docs/cockpit-deployment.md)。",
+    "map.ui.debug 控制界面形态:null(默认)= 跟随构建模式(开发构建显示诊断信息、生产构建自动收敛成干净形态);写 true 可在生产包里临时打开诊断读数。",
+    "map.viewMode.pipeline 可调管线视图的倾角/距离系数/地形与模型的半透明程度。",
+    "map.cesium.enabled=false 可以让中央一张图退回临时影像兜底;map.cesium.baseUrl 只在把 Cesium 静态资源放到别处时才填(需绝对 URL)。",
+    "ws.url 供票 15 的状态推送使用。"
+  ],
+  "version": 1,
+  "dataSource": "mock",
+  "api": {
+    "baseUrl": "",
+    "token": ""
+  },
+  "ws": {
+    "url": "",
+    "token": ""
+  },
+  "map": {
+    "basemap": {
+      "source": "natural-earth",
+      "url": "",
+      "subdomains": [],
+      "minimumLevel": null,
+      "maximumLevel": null,
+      "credit": ""
+    },
+    "tianditu": {
+      "token": "",
+      "layer": "img"
+    },
+    "terrain": {
+      "url": "",
+      "requestVertexNormals": true
+    },
+    "overlays": [],
+    "tilesets": [],
+    "ui": {
+      "debug": null
+    },
+    "cesium": {
+      "enabled": true,
+      "baseUrl": ""
+    }
+  }
+}

+ 97 - 0
src/common/localGisStaticPlugin.js

@@ -0,0 +1,97 @@
+import fs from 'node:fs'
+import path from 'node:path'
+
+/**
+ * Vite 插件:把**本机 / 甲方**的 GIS 数据目录挂成 `<prefix>/...` 静态路径(票 21)。
+ *
+ * 为什么要有它:DEM(量化网格 `layer.json` + `{z}/{x}/{y}.terrain`)与影像切片动辄上百 MB,
+ * 既不该进仓库也不该进产物;开发期直接把磁盘目录挂出来最省事,部署期按 README 的 nginx 片段
+ * 挂同一批目录即可(前端只认 URL,换个 URL 就能指向别处)。
+ *
+ * 目录由环境变量给,不在仓库里写死本机路径:
+ *   COCKPIT_GIS_MOUNTS='[{"prefix":"/gis/dem","dir":"D:/GIS/GIS/yuanlingDEM"},
+ *                        {"prefix":"/gis/pic3","dir":"D:/GIS/GIS/yuanlingPic3"}]'
+ * 放在 `.env.local`(已 gitignore)。没配就什么也不挂,别的机器照常跑。
+ *
+ * 注意:cesiumlab 之类工具导出的切片常是 **TMS**(y 轴自下而上)。影像用 Cesium 的 `{reverseY}`
+ * 占位符处理;地形不用管 —— Cesium 的 `CesiumTerrainProvider` 认 `layer.json` 里的 `scheme: "tms"`。
+ */
+const STATIC_MIME = {
+    '.terrain': 'application/octet-stream',
+    '.png': 'image/png',
+    '.jpg': 'image/jpeg',
+    '.jpeg': 'image/jpeg',
+    '.webp': 'image/webp',
+    '.json': 'application/json; charset=utf-8',
+    '.xml': 'application/xml',
+    '.bin': 'application/octet-stream',
+    '.wasm': 'application/wasm',
+    '.glb': 'model/gltf-binary',
+}
+
+/** 解析 `COCKPIT_GIS_MOUNTS`;坏数据只告警不阻断启动。 */
+export function parseGisMounts(raw) {
+    if (!raw) return []
+    try {
+        const parsed = JSON.parse(raw)
+        if (!Array.isArray(parsed)) return []
+        return parsed
+            .filter((item) => item && typeof item.prefix === 'string' && typeof item.dir === 'string')
+            .map((item) => ({
+                prefix: item.prefix.startsWith('/') ? item.prefix.replace(/\/+$/, '') : `/${item.prefix.replace(/\/+$/, '')}`,
+                dir: path.resolve(item.dir),
+            }))
+            .filter((item) => item.prefix && fs.existsSync(item.dir))
+    } catch (error) {
+        console.warn(`[cockpit-gis] COCKPIT_GIS_MOUNTS 不是合法 JSON,已忽略:${error.message}`)
+        return []
+    }
+}
+
+/** 把请求路径解析成挂载目录内的真实文件;越界、目录、不存在返回 null。 */
+function resolveMountedFile(dir, requestUrl) {
+    const clean = decodeURIComponent((requestUrl || '').split('?')[0].split('#')[0])
+    const relative = path.posix.normalize(clean).replace(/^\/+/, '')
+    if (!relative || relative.startsWith('..')) return null
+
+    const file = path.join(dir, relative)
+    if (!file.startsWith(dir + path.sep)) return null
+
+    try {
+        return fs.statSync(file).isFile() ? file : null
+    } catch {
+        return null
+    }
+}
+
+/**
+ * @param {object} [options]
+ * @param {string} [options.mounts] 同 `COCKPIT_GIS_MOUNTS` 的 JSON;缺省读环境变量
+ * @param {(message: string) => void} [options.log]
+ */
+export default function localGisStatic({ mounts, log } = {}) {
+    const raw = mounts === undefined ? process.env.COCKPIT_GIS_MOUNTS : mounts
+    const parsed = parseGisMounts(raw)
+    const say = log || ((message) => console.log(message))
+
+    return {
+        name: 'tvt:local-gis-static',
+
+        configureServer(server) {
+            if (!parsed.length) {
+                say('[cockpit-gis] 未配置 COCKPIT_GIS_MOUNTS:本机 GIS 数据目录不会挂载(前端会按配置里的 URL 取不到数据)')
+                return
+            }
+            for (const mount of parsed) {
+                server.middlewares.use(mount.prefix, (req, res, next) => {
+                    const file = resolveMountedFile(mount.dir, req.url)
+                    if (!file) return next()
+                    res.setHeader('Content-Type', STATIC_MIME[path.extname(file).toLowerCase()] || 'application/octet-stream')
+                    res.setHeader('Cache-Control', 'no-cache')
+                    fs.createReadStream(file).pipe(res)
+                })
+                say(`[cockpit-gis] ${mount.prefix} → ${mount.dir}`)
+            }
+        },
+    }
+}

+ 62 - 0
src/common/vendoredPluginLibAliases.js

@@ -0,0 +1,62 @@
+import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
+import { join } from 'node:path'
+
+/**
+ * 把 TvT 插件自带的依赖副本(`src/plugins/<插件>/lib/<包>/package.json`)映射成 Vite alias。
+ *
+ * 为什么需要:`useThreeGeospatial` 插件把 `@takram/three-*` 依赖随插件放在 `lib/` 下,其中
+ * `@takram/three-3d-tiles-support`、`@takram/three-terrain*`、`@takram/three-geospatial-worker`
+ * **没有发布到 npm**(registry 404),所以「补装依赖」这条路走不通;只能按插件作者的意图指回
+ * 插件内置副本,否则 `yarn build` 会直接在这些导入上失败。
+ *
+ * 规则:
+ *   · 已被 node_modules 解析到的包名不覆盖 —— 宿主装的版本优先;
+ *   · 只映射真实存在的入口文件;
+ *   · `exports` 的子路径(如 `@takram/three-geospatial/shaders`)排在包根之前 ——
+ *     Vite 的 alias 是「首个匹配生效」,对象形式按插入顺序排列。
+ *
+ * @param {string} pluginsDir `src/plugins` 绝对路径
+ * @param {string} nodeModulesDir 项目 `node_modules` 绝对路径
+ * @returns {Record<string, string>} 可展开进 `alias` 的映射
+ */
+export function vendoredPluginLibAliases(pluginsDir, nodeModulesDir) {
+    const aliases = {}
+    if (!existsSync(pluginsDir)) return aliases
+
+    for (const plugin of readdirSync(pluginsDir)) {
+        const libDir = join(pluginsDir, plugin, 'lib')
+        if (!existsSync(libDir)) continue
+
+        for (const dir of readdirSync(libDir)) {
+            const packageDir = join(libDir, dir)
+            const packageFile = join(packageDir, 'package.json')
+            if (!existsSync(packageFile) || !statSync(packageDir).isDirectory()) continue
+
+            const pkg = JSON.parse(readFileSync(packageFile, 'utf8'))
+            if (!pkg.name || existsSync(join(nodeModulesDir, pkg.name))) continue
+
+            const exportsField = pkg.exports && typeof pkg.exports === 'object' ? pkg.exports : {}
+
+            for (const [key, value] of Object.entries(exportsField)) {
+                if (key === '.' || !key.startsWith('./')) continue
+                const entry = pickImport(value)
+                if (entry) addAlias(aliases, `${pkg.name}/${key.slice(2)}`, join(packageDir, entry))
+            }
+
+            const rootEntry = pickImport(exportsField['.']) || pkg.module || pkg.main
+            if (rootEntry) addAlias(aliases, pkg.name, join(packageDir, rootEntry))
+        }
+    }
+
+    return aliases
+}
+
+function addAlias(aliases, specifier, file) {
+    if (existsSync(file)) aliases[specifier] = file
+}
+
+function pickImport(value) {
+    if (typeof value === 'string') return value
+    if (!value || typeof value !== 'object') return ''
+    return value.import || value.module || value.default || value.require || ''
+}

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

@@ -42,9 +42,32 @@
             </nav>
 
             <div class="cockpit-shell__top-meta">
+                <!-- 票 30:快速归位(回到目标城市预设视角) -->
+                <button
+                    type="button"
+                    class="cockpit-shell__chip cockpit-shell__home"
+                    :class="{ 'is-active': atHomeView }"
+                    :data-home-at="atHomeView ? 'yes' : 'no'"
+                    :title="homeTitle"
+                    aria-label="归位到目标区域"
+                    @click="returnHome()"
+                >
+                    归位
+                </button>
+                <!-- 票 04:配置三态 + 数据源,顶栏可演示;data-* 供走查断言。
+                     票 32:这是开发期的诊断读数,生产形态不占顶栏(状态退到底部小字)。 -->
+                <span
+                    v-if="configLoaded && uiDebug"
+                    class="cockpit-shell__config"
+                    :data-config-state="configView.state"
+                    :data-data-source="configView.dataSource"
+                    :data-source="configState.source"
+                >
+                    <StatusTag :tone="configView.tone" :label="configView.label" />
+                </span>
                 <span class="cockpit-shell__chip">只读视图</span>
                 <span class="cockpit-shell__clock">{{ clockText }}</span>
-                <p class="cockpit-shell__meta">当前模式 {{ mode }}</p>
+                <p v-if="uiDebug" class="cockpit-shell__meta">当前模式 {{ mode }}</p>
             </div>
         </header>
 
@@ -84,10 +107,77 @@
                         {{ item.label }}
                     </button>
                 </div>
+
+                <span class="cockpit-shell__divider" aria-hidden="true"></span>
+
+                <!--
+                    票 21:地面/管线视图(决策地图票 05 定的「全局视图条件」)。
+                    它是全局条件,与范围/行业同级 —— 切换一级模式不重置它。
+                -->
+                <span class="cockpit-shell__scope-label">视图</span>
+                <div class="cockpit-shell__chips">
+                    <button
+                        v-for="item in VIEW_MODE_OPTIONS"
+                        :key="item.key"
+                        type="button"
+                        class="cockpit-shell__chip cockpit-shell__view-btn"
+                        :class="{ 'is-active': item.key === viewMode }"
+                        :aria-pressed="item.key === viewMode"
+                        :data-view-mode="item.key"
+                        :title="item.hint"
+                        @click="switchViewMode(item.key)"
+                    >
+                        {{ item.label }}
+                    </button>
+                </div>
             </div>
 
             <div class="cockpit-shell__scope-right">
-                <span class="cockpit-shell__scope-note">筛选已生效于外壳状态 · 地图与列表联动待后续票接入</span>
+                <!--
+                    票 08:地图摘要的渐进加载。分页与快照参数不经这里 —— 这里只说「再取一批」或
+                    「刷新换快照」,参数在 services/mapApi.js 与装载器里拼。
+                    data-map-summary-* 既是走查的断言面,也是人工演示时看得见的状态。
+                    票 33(维护者 2026-09-18「地图摘要也需最小化其形态,不应过分遮挡一张图」):
+                    去掉「地图摘要」文字标题,收成一枚小胶囊 —— 状态点 + 短文案 + 一个动作按钮,
+                    靠 aria-label 保留语义,宽度与视觉重量都压下来。
+                -->
+                <div
+                    class="cockpit-shell__map-summary"
+                    :data-map-summary-phase="mapSummaryView.phase"
+                    role="status"
+                    aria-label="地图摘要"
+                >
+                    <i class="cockpit-shell__map-summary-dot" aria-hidden="true"></i>
+                    <span class="cockpit-shell__map-summary-text" data-map-summary-label :title="mapSummaryView.detail">
+                        {{ mapSummaryView.label }}
+                    </span>
+                    <button
+                        type="button"
+                        class="cockpit-shell__chip cockpit-shell__map-next"
+                        data-map-summary-action
+                        :disabled="mapStore.busy"
+                        :title="mapStore.settled ? '重新从第 1 页加载,并切换到后端最新快照' : '再取一批地图摘要点位'"
+                        @click="advanceMapSummary"
+                    >
+                        {{ mapStore.settled ? '刷新快照' : '加载下一页' }}
+                    </button>
+                </div>
+
+                <!--
+                    票 05:共享数据分区的状态板。验收第 6 条要的「只把受影响分区标为数据不可用、
+                    保留已成功分区与地图骨架」在这里可见;每个分区带 data-section-* 供走查断言。
+                    只有 mock 模式才自动取数(real 模式后端未就位,不刷 404)。
+                -->
+                <span class="cockpit-shell__scope-label">数据分区</span>
+                <div class="cockpit-shell__sections" data-section-board>
+                    <SectionStateTag
+                        v-for="item in sharedSections"
+                        :key="item.name"
+                        :name="item.name"
+                        :section="item.section"
+                        :label="item.label"
+                    />
+                </div>
             </div>
         </div>
 
@@ -97,7 +187,7 @@
                 一级模式切换只重渲染左右两栏,本节点保持原实例。
             -->
             <section class="cockpit-shell__map" aria-label="中央一张图">
-                <MapCanvas :mode-label="modeLabel(mode)" />
+                <MapCanvas :mode-label="modeLabel(mode)" :ui-debug="uiDebug" />
             </section>
 
             <aside class="cockpit-shell__column is-left" aria-label="左栏">
@@ -125,7 +215,25 @@
                     <PanelPlaceholder :panel-id="panelId" />
                 </CockpitPanel>
             </aside>
+
+            <!--
+                票 10:两级详情浮层(点地图上的监测点打开)。浮在图上、两栏之间;
+                它是 `<main>` 的兄弟而不是地图节点的子节点 —— MapCanvas 仍是常驻的那个实例。
+                位置与宽度由本外壳给(组件自己不假设身处哪里,票 18 的台账面板可复用同一个卡片)。
+            -->
+            <aside v-if="detailOpen" class="cockpit-shell__detail" aria-label="点位与设备详情">
+                <PointDetailCard />
+            </aside>
         </main>
+
+        <!-- 进入引导(票 30):覆盖全屏,点击进入后飞到目标城市预设视角 -->
+        <CockpitIntro
+            v-if="intro.enabled && introVisible"
+            :title="intro.title"
+            :subtitle="intro.subtitle"
+            :spin-seconds="intro.spinSeconds"
+            @enter="enterSystem"
+        />
     </div>
 </template>
 
@@ -134,19 +242,160 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
 import { storeToRefs } from 'pinia'
 import { MODES, columnsForMode, labelForPanel, modeLabel } from './domain/panelCatalog.js'
 import { INDUSTRIES, SCOPES } from './domain/filterOptions.js'
+import { VIEW_MODES } from './domain/viewModes.js'
 import { useUiStore } from './stores/uiStore.js'
 import { useFilterStore } from './stores/filterStore.js'
 import CockpitPanel from './components/CockpitPanel.vue'
 import CockpitIcon from './components/CockpitIcon.vue'
 import MapCanvas from './components/MapCanvas.vue'
 import PanelPlaceholder from './components/PanelPlaceholder.vue'
+import StatusTag from './components/StatusTag.vue'
+import CockpitIntro from './components/CockpitIntro.vue'
+import SectionStateTag from './components/SectionStateTag.vue'
+import PointDetailCard from './components/PointDetailCard.vue'
+import { DATA_SOURCE, configStatusView } from './domain/configStatus.js'
+import { DETAIL_LEVEL } from './domain/detailFlow.js'
+import { homeLabel } from './domain/viewPresets.js'
+import { ensureCockpitMocks } from './mocks/index.js'
+import { useDetailStore } from './stores/detailStore.js'
+import { useMapStore } from './stores/mapStore.js'
+import { MAP_SUMMARY_PHASE } from './domain/mapSummary.js'
+import { getCockpitConfigState, loadCockpitConfig } from './services/cockpitConfig.js'
+import { atHome, getHomeView, getIntroConfig, goHome, setGlobeSpin, setViewMode } from './services/mapService.js'
 // 视觉 token 的唯一来源(票 06)。挂在本组件根元素上,靠继承作用于驾驶舱子树。
 import './styles/tokens.css'
 
 const uiStore = useUiStore()
 const filterStore = useFilterStore()
+const detailStore = useDetailStore()
+const mapStore = useMapStore()
 const { mode, expandedPanelIds } = storeToRefs(uiStore)
-const { scope, industry } = storeToRefs(filterStore)
+const { scope, industry, viewMode } = storeToRefs(filterStore)
+
+/** 票 21:视图条件的两个选项(决策地图票 05:全局视图条件,与范围/行业同级)。 */
+const VIEW_MODE_OPTIONS = [
+    { key: VIEW_MODES.GROUND, label: '地面视图', hint: '常规俯视;管线隐藏、地形与模型不透明' },
+    { key: VIEW_MODES.PIPELINE, label: '管线视图', hint: '压低弹簧臂、放宽俯仰;地形与模型半透明以露出管线' },
+]
+
+/**
+ * 切换视图:状态先进 store(全局条件,切一级模式不重置),再下发给地图服务。
+ * 引擎没就绪时服务返回 false,稍后重试 —— 与「归位」同一套兜底。
+ */
+function switchViewMode(next) {
+    if (!VIEW_MODE_OPTIONS.some((item) => item.key === next)) return
+    filterStore.setViewMode(next)
+
+    // 票 21:倾角/透明度可由 map.viewMode 覆盖(缺省用 domain/viewModes.js 的默认)。
+    const mapConfig =
+        configState.value && configState.value.config && configState.value.config.map ? configState.value.config.map : null
+    const overrides = (mapConfig && mapConfig.viewMode) || {}
+
+    let tries = 0
+    const apply = () => {
+        tries += 1
+        if (setViewMode(next, overrides) || tries >= 40) return
+        window.setTimeout(apply, 250)
+    }
+    apply()
+}
+const { sections, selection } = storeToRefs(detailStore)
+
+/** 票 10:两级详情是否打开(浮层只在有选择时渲染;关掉后地图与面板保持原样)。 */
+const detailOpen = computed(() => selection.value.level !== DETAIL_LEVEL.NONE)
+
+// ---- 共享数据分区(票 05):三条共享详情域分区在筛选条右侧列出状态 ----
+// 标签用短名,状态语义仍由 SectionStateTag + domain/sectionState.js 决定。
+const SECTION_LABELS = {
+    mapSnapshot: '地图摘要',
+    equipmentAggregate: '设备详情',
+    equipmentFreshness: '数据新鲜度',
+}
+
+const sharedSections = computed(() =>
+    Object.keys(SECTION_LABELS).map((name) => ({
+        name,
+        label: SECTION_LABELS[name],
+        section: sections.value[name],
+    })),
+)
+
+// ---- 地图摘要渐进加载(票 08):顶栏只展示状态与一个动作,分页/快照参数都在适配层 ----
+const mapSummaryView = computed(() => mapStore.view)
+
+/** 「加载下一页」;已取完时这个按钮变成「刷新快照」(换新快照、从第 1 页重来)。 */
+async function advanceMapSummary() {
+    if (mapStore.settled) {
+        await mapStore.refresh()
+    } else {
+        await mapStore.advance()
+    }
+    // 地图摘要分区状态跟着装载状态走(票 05 的分区板据此变色)。
+    detailStore.syncMapSnapshotSection()
+}
+
+// ---- 配置状态(票 04):载入前不渲染标签,避免先闪一下「缺失」再变「就绪」 ----
+const configLoaded = ref(false)
+const configState = ref(null)
+const configView = computed(() => configStatusView(configState.value))
+
+/**
+ * 票 32:UI 形态。配置 `map.ui.debug` 显式给布尔值就听它,否则跟随构建模式
+ * —— 开发构建显示诊断信息,生产构建自动收敛成「干净模式」(维护者 2026-09-18 要求)。
+ */
+const uiDebug = computed(() => {
+    const configured =
+        configState.value && configState.value.config && configState.value.config.map && configState.value.config.map.ui
+            ? configState.value.config.map.ui.debug
+            : null
+    if (typeof configured === 'boolean') return configured
+    return !!(import.meta.env && import.meta.env.DEV)
+})
+
+// ---- 进入引导与视角预设(票 30)----
+// 引导默认开着;配置里 map.intro.enabled=false 时直接进系统(其它票的走查用它自隔离)。
+const intro = ref(getIntroConfig())
+const introVisible = ref(intro.value.enabled)
+const atHomeView = ref(false)
+const homeView = computed(() => getHomeView())
+const homeTitle = computed(() => `回到${homeLabel(homeView.value)}`)
+
+/** 进入系统:停自转 + 飞到目标城市预设视角。 */
+function enterSystem() {
+    introVisible.value = false
+    setGlobeSpin(0)
+
+    // 引导层可能比引擎先就绪(引擎要拉地形/影像):没下发成功就重试,直到相机接上。
+    let tries = 0
+    const fly = () => {
+        tries += 1
+        if (goHome({ immediate: false }) || tries >= 40) {
+            window.setTimeout(refreshHomeState, 900)
+            return
+        }
+        window.setTimeout(fly, 250)
+    }
+    fly()
+    window.setTimeout(refreshHomeState, 900)
+}
+
+function refreshHomeState() {
+    atHomeView.value = atHome()
+}
+
+/** 归位按钮:引擎未就绪时重试(与进入系统同一条路径)。 */
+function returnHome() {
+    let tries = 0
+    const fly = () => {
+        tries += 1
+        if (goHome({ immediate: false }) || tries >= 40) {
+            window.setTimeout(refreshHomeState, 900)
+            return
+        }
+        window.setTimeout(fly, 250)
+    }
+    fly()
+}
 
 const columns = computed(() => columnsForMode(mode.value))
 
@@ -157,6 +406,7 @@ function isExpanded(panelId) {
 // ---- 顶栏时钟 ----
 const clockText = ref('')
 let clockTimer = null
+let homeTimer = null
 
 function tick() {
     const now = new Date()
@@ -167,10 +417,39 @@ function tick() {
 onMounted(() => {
     tick()
     clockTimer = setInterval(tick, 1000)
+    // 归位按钮的高亮状态跟着相机走(用户手动平移后就不再算"在家")。
+    homeTimer = setInterval(refreshHomeState, 500)
+
+    // 票 04:配置只在这个入口载入一次(服务内部有缓存),地图服务按需 await 同一份。
+    loadCockpitConfig().then((result) => {
+        configState.value = result
+        configLoaded.value = true
+        // 配置到手才知道引导开关与预设坐标,重新取一次。
+        intro.value = getIntroConfig()
+        introVisible.value = intro.value.enabled
+
+        // 票 05:mock 模式才注册契约样例并取共享分区数据。
+        // real 模式下后端当前无可用数据源,自动取数只会刷 404 —— 与票 04 的
+        // 「没有显式配置就不发请求」同一条立场:不制造噪音。
+        if (result.config.dataSource === DATA_SOURCE.MOCK) {
+            ensureCockpitMocks()
+            detailStore.loadSharedSections()
+        }
+    })
+
+    // 引导期间显示自转;进入后停(引擎就绪前调用会被忽略,等引擎 ready 再兜一次)。
+    if (intro.value.enabled) {
+        setGlobeSpin(intro.value.spinSeconds)
+        const spinTimer = setInterval(() => {
+            if (!introVisible.value || setGlobeSpin(intro.value.spinSeconds)) clearInterval(spinTimer)
+        }, 600)
+    }
 })
 
 onBeforeUnmount(() => {
     if (clockTimer) clearInterval(clockTimer)
+    if (homeTimer) clearInterval(homeTimer)
+    setGlobeSpin(0)
 })
 </script>
 
@@ -442,6 +721,9 @@ onBeforeUnmount(() => {
     align-items: center;
     gap: calc(6 * var(--u));
     white-space: nowrap;
+    /* 票 08:flex 项默认不许收缩,右块加了「地图摘要」控件后,1280 宽会把筛选条撑溢出
+       (走查实测 +202px)。允许收缩,让摘要文案的 ellipsis 真正生效。 */
+    min-width: 0;
 }
 
 .cockpit-shell__scope-label {
@@ -504,6 +786,84 @@ onBeforeUnmount(() => {
     letter-spacing: 0.04em;
 }
 
+/* 票 08:地图摘要的渐进加载状态 + 一个动作按钮。
+   票 33:收成小胶囊(无标题、更小字号、更窄上限),不抢一张图。 */
+.cockpit-shell__map-summary {
+    display: flex;
+    align-items: center;
+    gap: calc(5 * var(--u));
+    padding: calc(1 * var(--u)) calc(6 * var(--u));
+    border: 1px solid rgba(58, 170, 226, 0.22);
+    background: rgba(4, 20, 38, 0.4);
+    /* 空间不足时先压这一块(文案 ellipsis 收缩),不去挤范围/行业/视图按钮。 */
+    flex-shrink: 1;
+    min-width: 0;
+    overflow: hidden;
+}
+
+/* 状态点:加载中呼吸、完成转绿、失败/过期转红 —— 颜色语义与小胶囊共存。 */
+.cockpit-shell__map-summary-dot {
+    flex: none;
+    width: calc(6 * var(--u));
+    height: calc(6 * var(--u));
+    border-radius: 50%;
+    background: var(--lv2, #ffa63d);
+    animation: map-summary-breathe 1.8s ease-in-out infinite;
+}
+
+.cockpit-shell__map-summary[data-map-summary-phase='complete'] .cockpit-shell__map-summary-dot {
+    background: var(--lv4, #35d6a4);
+    animation: none;
+}
+
+.cockpit-shell__map-summary[data-map-summary-phase='failed'] .cockpit-shell__map-summary-dot,
+.cockpit-shell__map-summary[data-map-summary-phase='expired'] .cockpit-shell__map-summary-dot {
+    background: var(--lv1, #ff4d5e);
+    animation: none;
+}
+
+@keyframes map-summary-breathe {
+    0%, 100% { opacity: 1; }
+    50% { opacity: 0.3; }
+}
+
+.cockpit-shell__map-summary-text {
+    max-width: calc(150 * var(--u));
+    overflow: hidden;
+    color: #9dc2d9;
+    font-size: calc(10 * var(--u));
+    letter-spacing: 0.03em;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+/* 加载中 / 快照失效时给状态词换色,让「还能不能继续取」一眼可辨。 */
+.cockpit-shell__map-summary[data-map-summary-phase='expired'] .cockpit-shell__map-summary-text,
+.cockpit-shell__map-summary[data-map-summary-phase='failed'] .cockpit-shell__map-summary-text {
+    color: var(--lv1);
+}
+
+.cockpit-shell__map-summary[data-map-summary-phase='complete'] .cockpit-shell__map-summary-text {
+    color: var(--lv4);
+}
+
+.cockpit-shell__map-next:disabled {
+    opacity: 0.5;
+    cursor: default;
+}
+
+/* 票 05:共享分区状态板。横向排列、可换行;空间不够时先压这一块,不去挤筛选按钮。 */
+.cockpit-shell__sections {
+    display: flex;
+    align-items: center;
+    gap: calc(4 * var(--u));
+    flex-wrap: nowrap;
+    /* 票 08:显式允许收缩——筛选条在 1280 宽时靠它和地图摘要块一起让位。 */
+    flex-shrink: 1;
+    min-width: 0;
+    overflow: hidden;
+}
+
 /* ============================================================
    主体:一张图铺满背景,左右两栏浮在图上
    ============================================================ */
@@ -526,7 +886,8 @@ onBeforeUnmount(() => {
     display: flex;
     flex-direction: column;
     gap: calc(8 * var(--u));
-    width: clamp(280px, calc(300 * var(--u)), 460px);
+    /* 栏宽是共享的布局尺寸:两级详情浮层要贴着它让位(见 styles/tokens.css 的 --column-w)。 */
+    width: var(--column-w);
     min-height: 0;
     pointer-events: none;
 }
@@ -538,4 +899,19 @@ onBeforeUnmount(() => {
 .cockpit-shell__column.is-left { left: 0; }
 
 .cockpit-shell__column.is-right { right: 0; }
+
+/* 票 10:两级详情浮层。贴在两栏之间的空档里、右栏左侧,
+   宽度与栏同量级;高度由内容决定、超长时卡片自己滚动。 */
+.cockpit-shell__detail {
+    position: absolute;
+    z-index: var(--z-columns);
+    top: 0;
+    right: calc(var(--column-w) + var(--space-2));
+    width: clamp(300px, calc(340 * var(--u)), 420px);
+    max-height: 100%;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
+    pointer-events: auto;
+}
 </style>

+ 165 - 0
src/modules/cockpit/components/CockpitIntro.vue

@@ -0,0 +1,165 @@
+<!--
+  进入引导层(票 30):星球自转 + 中央闪烁的「点击进入系统」。
+
+  · 覆盖在整个外壳之上(z-index 最高),点击任意处即"进入";
+  · 自转由服务层驱动(mapService.setGlobeSpin),本组件只负责开关与文案;
+  · 键盘可达:Enter / Space 也能进入(无障碍与自动化都方便)。
+
+  为什么不做成路由页:ADR 0003 规定 /cockpit 是唯一路由、不新增路由;
+  引导只是同一页面上的一个状态,进入后不重建地图(票 02 的常驻契约)。
+-->
+<template>
+    <div
+        v-if="visible"
+        class="cockpit-intro"
+        role="button"
+        tabindex="0"
+        :aria-label="title"
+        data-cockpit-intro
+        @click="enter"
+        @keydown.enter.prevent="enter"
+        @keydown.space.prevent="enter"
+    >
+        <div class="cockpit-intro__vignette" aria-hidden="true"></div>
+
+        <div class="cockpit-intro__center">
+            <p class="cockpit-intro__title">{{ title }}</p>
+            <p v-if="subtitle" class="cockpit-intro__subtitle">{{ subtitle }}</p>
+        </div>
+
+        <div class="cockpit-intro__hint" aria-hidden="true">
+            <i class="cockpit-intro__dot"></i>
+            <span>拖动可旋转星球 · 滚轮缩放</span>
+        </div>
+    </div>
+</template>
+
+<script setup>
+import { onBeforeUnmount, onMounted, ref } from 'vue'
+
+const props = defineProps({
+    title: { type: String, default: '点击进入系统' },
+    subtitle: { type: String, default: '' },
+    /** 自转一圈的秒数;0 或负数 = 不自转 */
+    spinSeconds: { type: Number, default: 48 },
+})
+
+const emit = defineEmits(['enter'])
+
+const visible = ref(true)
+
+function enter() {
+    if (!visible.value) return
+    visible.value = false
+    emit('enter')
+}
+
+// 进入引导期间只允许"看":把键盘事件也接住,避免空格把页面滚走。
+function onKeydown(event) {
+    if (!visible.value) return
+    if (event.key === ' ' || event.key === 'Enter') event.preventDefault()
+}
+
+onMounted(() => {
+    if (typeof window !== 'undefined') window.addEventListener('keydown', onKeydown)
+})
+
+onBeforeUnmount(() => {
+    if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown)
+})
+
+/** 供父组件/走查判断是否还在引导层。 */
+defineExpose({ isVisible: () => visible.value, enter })
+</script>
+
+<style scoped>
+.cockpit-intro,
+.cockpit-intro * {
+    box-sizing: border-box;
+}
+
+.cockpit-intro {
+    position: absolute;
+    inset: 0;
+    z-index: 20; /* 高于面板与地图浮标,进入前挡住一切交互 */
+    display: grid;
+    place-items: center;
+    cursor: pointer;
+    outline: none;
+}
+
+.cockpit-intro:focus-visible .cockpit-intro__title {
+    text-shadow: 0 0 26px rgba(79, 216, 255, 1), 0 2px 12px rgba(0, 0, 0, 0.9);
+}
+
+.cockpit-intro__vignette {
+    position: absolute;
+    inset: 0;
+    pointer-events: none;
+    background:
+        radial-gradient(120% 90% at 50% 45%, transparent 30%, rgba(2, 6, 14, 0.55) 100%),
+        linear-gradient(180deg, rgba(2, 8, 16, 0.5) 0%, transparent 22%, transparent 72%, rgba(2, 8, 16, 0.6) 100%);
+}
+
+.cockpit-intro__center {
+    position: relative;
+    text-align: center;
+    pointer-events: none;
+}
+
+.cockpit-intro__title {
+    margin: 0;
+    color: #eaf9ff;
+    font-size: calc(26 * var(--u, 1px));
+    font-weight: 700;
+    letter-spacing: 0.34em;
+    text-indent: 0.34em; /* 抵消尾部字距,视觉居中 */
+    text-shadow: 0 0 22px rgba(79, 216, 255, 0.9), 0 2px 10px rgba(0, 0, 0, 0.95);
+    animation: cockpit-intro-blink 1.7s ease-in-out infinite;
+}
+
+.cockpit-intro__subtitle {
+    margin: calc(10 * var(--u, 1px)) 0 0;
+    color: var(--muted, #6d92ae);
+    font-size: calc(12.5 * var(--u, 1px));
+    letter-spacing: 0.08em;
+}
+
+@keyframes cockpit-intro-blink {
+    0%, 100% { opacity: 1; }
+    50% { opacity: 0.28; }
+}
+
+/* 尊重"减少动态效果"偏好:不闪、不转(自转由服务层按同一偏好关闭)。 */
+@media (prefers-reduced-motion: reduce) {
+    .cockpit-intro__title { animation: none; }
+}
+
+.cockpit-intro__hint {
+    position: absolute;
+    bottom: calc(26 * var(--u, 1px));
+    left: 50%;
+    transform: translateX(-50%);
+    display: flex;
+    align-items: center;
+    gap: 7px;
+    color: var(--muted, #6d92ae);
+    font-size: calc(11 * var(--u, 1px));
+    letter-spacing: 0.06em;
+    pointer-events: none;
+}
+
+.cockpit-intro__dot {
+    width: calc(7 * var(--u, 1px));
+    height: calc(7 * var(--u, 1px));
+    border-radius: 50%;
+    background: var(--lv3, #4fd8ff);
+    box-shadow: 0 0 8px var(--lv3, #4fd8ff);
+    animation: cockpit-intro-breathe 1.8s ease-in-out infinite;
+}
+
+@keyframes cockpit-intro-breathe {
+    0%, 100% { opacity: 1; transform: scale(1); }
+    50% { opacity: 0.35; transform: scale(0.72); }
+}
+</style>

+ 366 - 0
src/modules/cockpit/components/EquipmentDetailSections.vue

@@ -0,0 +1,366 @@
+<!--
+  设备详情的六个分区(票 11):档案 / 指标 / 预警 / 报警 / 工单状态 / 最新图片。
+
+  为什么每个分区**各带自己的状态标签**(而不是整卡共用一个成败):
+  后端聚合契约本身就返回「分区独立成败」——档案读失败但指标仍可用是它的原生态。
+  界面若共用一个成败,等于把后端已经说清楚的信息又揉回一个含糊的「加载失败」,
+  正好违反本票验收第 3 条(任一分区失败不遮盖其它成功分区)。所以这里六块**各自**独立:
+  自己的状态标签 + 自己的内容或占位说明,互不牵连。
+
+  三种「没内容」在界面上的区别(票 16 的词汇,措辞由 domain/equipmentDetailView.js 给):
+    空数据 EMPTY  → 「该分区当前没有数据」
+    失败 FAILED   → 「该分区加载失败(其它分区不受影响)」
+    不可用 UNAVAILABLE → 「该分区数据不可用(其它分区不受影响)」
+  过期 STALE 是**有值**:内容照画,只是标签说「数据已过期,显示最后一次成功数据」。
+
+  刻意不做的事:**不画事件的四级配色**(票 13/19 的视觉语言)、**不渲染工单时间线**
+  (票 12)。等级只以文本 + `data-event-level-color` 如实带出后端给的值。
+-->
+<template>
+    <div class="equipment-sections" data-equipment-sections :data-device-code="deviceCode">
+        <section
+            v-for="panel in panels"
+            :key="panel.name"
+            class="equipment-section"
+            :data-equipment-section="panel.name"
+            :data-panel-state="panel.section.state || 'empty'"
+            :data-panel-retained="panel.section.retained ? 'true' : 'false'"
+        >
+            <header class="equipment-section__head">
+                <span class="equipment-section__title">{{ panel.label }}</span>
+                <SectionStateTag :name="panel.name" :section="panel.section" />
+            </header>
+
+            <!-- 过期分区在内容之上再点一次题(颜色之外的第二种提示,灰度下也读得出) -->
+            <p v-if="panel.section.retained" class="equipment-section__stale" data-section-stale-note>
+                {{ staleNote }}
+            </p>
+
+            <!-- ---------- 有内容:按 kind 分派 ---------- -->
+            <template v-if="panel.hasContent">
+                <!-- 档案:键值行 -->
+                <dl v-if="panel.kind === 'archive'" class="equipment-section__rows" data-archive-rows>
+                    <div v-for="row in rowsOf(panel)" :key="row.key" class="equipment-section__row" :data-archive-row="row.key">
+                        <dt>{{ row.label }}</dt>
+                        <dd :class="{ 'is-muted': isPlaceholder(row.value), 'equipment-section__mono': row.mono }">{{ row.value }}</dd>
+                    </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.sampledAt">{{ metric.sampledAt }}</span>
+                        </span>
+                    </article>
+                    <p v-if="!rowsOf(panel).length" class="equipment-section__hint" data-section-empty-list>
+                        {{ emptyList('指标') }}
+                    </p>
+                </div>
+
+                <!-- 预警 / 报警:等级文本 + 状态 + 时间 + 量值(配色留给票 13/19) -->
+                <div v-else-if="panel.kind === 'events'" class="equipment-section__list" :data-event-list="panel.name">
+                    <article
+                        v-for="(event, index) in rowsOf(panel)"
+                        :key="event.code || index"
+                        class="equipment-section__item"
+                        :data-event="event.code || index"
+                        :data-event-level-color="event.levelColor"
+                    >
+                        <span class="equipment-section__item-name">{{ event.name }}</span>
+                        <span class="equipment-section__meta">
+                            <span data-event-level>{{ event.levelText }}</span>
+                            <span v-if="event.status">{{ event.status }}</span>
+                        </span>
+                        <span class="equipment-section__meta">
+                            <span class="equipment-section__mono">{{ event.code }}</span>
+                            <span v-if="event.eventTime">{{ event.eventTime }}</span>
+                        </span>
+                        <span v-if="event.location" class="equipment-section__meta">{{ event.location }}</span>
+                        <span class="equipment-section__value" :class="{ 'is-muted': isPlaceholder(event.quantity) }" data-event-value>
+                            {{ event.quantity }}
+                        </span>
+                    </article>
+                    <p v-if="!rowsOf(panel).length" class="equipment-section__hint" data-section-empty-list>
+                        {{ emptyList(panel.label) }}
+                    </p>
+                </div>
+
+                <!-- 工单状态:状态名(缺则按七态字典补)+ 关键时间 -->
+                <div v-else-if="panel.kind === 'orders'" class="equipment-section__list" data-work-order-list>
+                    <article
+                        v-for="(order, index) in rowsOf(panel)"
+                        :key="order.code || index"
+                        class="equipment-section__item"
+                        :data-work-order="order.code || index"
+                        :data-work-order-state="order.stateCode"
+                    >
+                        <span class="equipment-section__item-name">
+                            {{ order.stateName }}
+                            <em v-if="order.typeName"> · {{ order.typeName }}</em>
+                        </span>
+                        <span class="equipment-section__meta">
+                            <span class="equipment-section__mono">{{ order.code }}</span>
+                            <span v-if="order.description">{{ order.description }}</span>
+                        </span>
+                        <span class="equipment-section__meta">
+                            <span>创建 {{ order.createTime || '—' }}</span>
+                            <span>结案 {{ order.realFinishTime }}</span>
+                        </span>
+                    </article>
+                    <p v-if="!rowsOf(panel).length" class="equipment-section__hint" data-section-empty-list>
+                        {{ emptyList('工单') }}
+                    </p>
+                </div>
+
+                <!-- 最新图片:只画后端指定的那一张(前端不索引、不挑选) -->
+                <figure v-else-if="panel.kind === 'image'" class="equipment-section__figure" data-latest-image>
+                    <template v-if="image(panel).hasImage">
+                        <img class="equipment-section__img" :src="image(panel).url" :alt="`${deviceCode} 最新图片`" />
+                        <figcaption class="equipment-section__meta">拍摄于 {{ image(panel).capturedAt }}</figcaption>
+                    </template>
+                    <p v-else class="equipment-section__hint" data-latest-image-empty>后端未提供该设备的图片</p>
+                </figure>
+            </template>
+
+            <!-- ---------- 没内容:如实说,不画空框冒充数据 ---------- -->
+            <p v-else class="equipment-section__hint" data-section-placeholder>{{ panel.placeholder }}</p>
+
+            <!-- 字段缺失:只说条数,具体路径进 tooltip(不漏接口字段名给值班人员) -->
+            <p
+                v-if="panel.missing.count"
+                class="equipment-section__missing"
+                data-section-missing
+                :data-missing-count="panel.missing.count"
+                :title="panel.missing.tooltip"
+            >
+                {{ panel.missing.text }}
+            </p>
+        </section>
+    </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { DETAIL_PLACEHOLDER } from '../domain/detailFlow.js'
+import {
+    archiveRows,
+    emptyListText,
+    equipmentPanels,
+    eventRows,
+    imageView,
+    metricRows,
+    metricsSourceText,
+    workOrderRows,
+} from '../domain/equipmentDetailView.js'
+import SectionStateTag from './SectionStateTag.vue'
+
+const props = defineProps({
+    /** `stores/detailStore.js` 的 `sections.equipmentAggregate`。 */
+    aggregate: { type: Object, default: null },
+    /** 当前选中的设备编码(分区头与图片 alt 用)。 */
+    deviceCode: { type: String, default: '' },
+})
+
+const panels = computed(() => equipmentPanels(props.aggregate))
+const staleNote = '数据已过期 · 显示最后一次成功数据,不是实时值'
+
+/** 按 kind 取行视图 —— 分派只在这里发生一次,模板里不再认后端字段。 */
+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)
+}
+
+function metricsSource(panel) {
+    return metricsSourceText(panel.section.data)
+}
+
+function image(panel) {
+    return imageView(panel.section.data)
+}
+
+function emptyList(label) {
+    return emptyListText(label)
+}
+
+function isPlaceholder(text) {
+    return text === DETAIL_PLACEHOLDER
+}
+</script>
+
+<style scoped>
+.equipment-sections,
+.equipment-sections *,
+.equipment-sections *::before,
+.equipment-sections *::after {
+    box-sizing: border-box;
+}
+
+.equipment-sections {
+    display: grid;
+    gap: calc(6 * var(--u, 1px));
+}
+
+/* 每块分区:自己的边框与抬头 —— 失败的那一块缩成一行提示,成功的照常展开。 */
+.equipment-section {
+    display: grid;
+    gap: calc(3 * var(--u, 1px));
+    padding: calc(5 * var(--u, 1px)) var(--space-2, 8px);
+    border: 1px solid rgba(58, 170, 226, 0.24);
+    background: var(--inset, rgba(2, 10, 20, 0.55));
+}
+
+.equipment-section[data-panel-state='failed'],
+.equipment-section[data-panel-state='unavailable'] {
+    border-color: rgba(255, 77, 94, 0.4);
+}
+
+.equipment-section[data-panel-state='stale'] {
+    border-color: rgba(255, 166, 61, 0.45);
+}
+
+.equipment-section__head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: var(--space-2, 8px);
+}
+
+.equipment-section__title {
+    min-width: 0;
+    overflow: hidden;
+    color: #eaf9ff;
+    font-size: var(--fs-body, 13px);
+    font-weight: 700;
+    letter-spacing: 0.06em;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+}
+
+.equipment-section__stale {
+    margin: 0;
+    color: var(--lv2, #ffa63d);
+    font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.04em;
+}
+
+.equipment-section__rows {
+    display: grid;
+    gap: calc(2 * var(--u, 1px));
+    margin: 0;
+}
+
+.equipment-section__row {
+    display: grid;
+    grid-template-columns: calc(58 * var(--u, 1px)) minmax(0, 1fr);
+    align-items: baseline;
+    gap: var(--space-2, 8px);
+    border-bottom: 1px dashed rgba(58, 170, 226, 0.14);
+}
+
+.equipment-section__row dt {
+    color: var(--muted, #6d92ae);
+    font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.04em;
+}
+
+.equipment-section__row dd {
+    margin: 0;
+    color: var(--text, #e6f5ff);
+    word-break: break-word;
+}
+
+.equipment-section__list {
+    display: grid;
+    gap: calc(4 * var(--u, 1px));
+}
+
+.equipment-section__item {
+    display: grid;
+    gap: 1px;
+    padding-bottom: calc(3 * var(--u, 1px));
+    border-bottom: 1px dashed rgba(58, 170, 226, 0.14);
+}
+
+.equipment-section__item-name {
+    color: var(--text, #e6f5ff);
+    font-size: var(--fs-caption, 10.5px);
+}
+
+.equipment-section__item-name em {
+    color: var(--muted, #6d92ae);
+    font-style: normal;
+}
+
+.equipment-section__value {
+    color: #eaf9ff;
+    font-family: var(--num, monospace);
+    font-size: var(--fs-body, 13px);
+}
+
+.equipment-section__value em {
+    color: var(--muted, #6d92ae);
+    font-style: normal;
+    font-size: var(--fs-caption, 10.5px);
+}
+
+.equipment-section__meta {
+    display: flex;
+    flex-wrap: wrap;
+    gap: var(--space-2, 8px);
+    color: var(--text-2, #a8cbe2);
+    font-size: var(--fs-caption, 10.5px);
+}
+
+.equipment-section__note {
+    margin: 0;
+    color: var(--muted, #6d92ae);
+    font-size: var(--fs-caption, 10.5px);
+}
+
+.equipment-section__figure {
+    display: grid;
+    gap: calc(3 * var(--u, 1px));
+    margin: 0;
+}
+
+.equipment-section__img {
+    display: block;
+    width: 100%;
+    max-height: calc(140 * var(--u, 1px));
+    object-fit: cover;
+    border: 1px solid rgba(58, 170, 226, 0.3);
+}
+
+.equipment-section__hint,
+.equipment-section__missing,
+.is-muted {
+    margin: 0;
+    color: var(--muted, #6d92ae);
+    font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.04em;
+}
+
+.equipment-section__missing {
+    color: var(--lv2, #ffa63d);
+}
+
+.equipment-section__mono {
+    font-family: var(--num, monospace);
+    letter-spacing: 0.04em;
+}
+</style>

+ 258 - 22
src/modules/cockpit/components/MapCanvas.vue

@@ -17,42 +17,89 @@
             <div class="map-holo__vignette"></div>
         </div>
 
+        <!--
+            一张图**最上方**:点位状态图例(维护者 2026-09-18:原来放在底部,位置效果不好)。
+            图例是颜色语义,任何形态都显示;调试形态在其下方再补标题与图层占位。
+        -->
         <div class="map-holo__top">
-            <b>中央“一张图”</b>
-            <div class="map-holo__layers">
-                <span v-for="layer in LAYERS" :key="layer.label">
-                    <CockpitIcon :name="layer.icon" size="calc(11 * var(--u, 1px))" />
-                    {{ layer.label }}
-                </span>
+            <div class="map-holo__legend" data-map-legend>
+                <StatusTag
+                    v-for="row in legendRows"
+                    :key="row.key"
+                    :tone="row.tone"
+                    :shape="row.shape"
+                    :label="row.count > 0 ? `${row.label} ${row.count}` : row.label"
+                    :data-legend-key="row.key"
+                />
             </div>
-        </div>
 
-        <div class="map-holo__bottom">
-            <!-- 事件四级配色(ADR 0008):1 级红 / 2 级黄 / 3 级蓝 / 4 级绿 -->
-            <div class="map-holo__legend">
-                <StatusTag v-for="tag in LEVEL_TAGS" :key="tag.tone" :tone="tag.tone" :label="tag.label" />
+            <div v-if="uiDebug" class="map-holo__headline">
+                <b>中央“一张图”</b>
+                <div class="map-holo__layers">
+                    <span v-for="layer in LAYERS" :key="layer.label">
+                        <CockpitIcon :name="layer.icon" size="calc(11 * var(--u, 1px))" />
+                        {{ layer.label }}
+                    </span>
+                </div>
             </div>
-            <p class="map-canvas__title">中央一张图占位</p>
-            <p class="map-holo__hint">
+        </div>
+
+        <div class="map-holo__bottom" :class="{ 'is-quiet': !uiDebug }">
+            <p v-if="uiDebug" class="map-canvas__title">中央一张图占位</p>
+            <p v-if="uiDebug" class="map-holo__hint">
                 临时底图 aerial.webp · 票 02 已建常驻宿主(实例 {{ instanceId }})· {{ modeLabel }}
             </p>
-            <div class="map-holo__status">
-                <i></i>
-                <span>影像为临时占位 · 引擎接入见票 06 调研 · 图层见票 21/22</span>
+            <div class="map-holo__status" :class="{ 'is-quiet': !uiDebug }">
+                <i :class="{ 'is-done': summarySettled, 'is-bad': summaryBad }"></i>
+                <span>{{ summaryText }}</span>
+                <!--
+                    票 09:已画到地图上的标记数与状态分布。
+                    放在**这一行内**而不是新起一行:底部信息带每多一行,就会顶到上面那块
+                    「无地图位置」浮块(实测多一行即相撞 10px)。图例本身已经带每状态的计数,
+                    这里只是把「地图上到底画了几个」再点一句。
+                -->
+                <span class="map-holo__markers" data-map-marker-count :title="`已上图状态分布:${markerStatusText}`">
+                    点位标记 {{ markerState.onMap }}
+                </span>
             </div>
         </div>
+
+        <!--
+            票 07:不上图对象的可见落点(「无地图位置」+ 具体原因)。
+            判定全在 `domain/coordinate.js`,本组件只把结果画出来。
+        -->
+        <MapCoordinateBlocked :overview="coordinateOverview" />
     </div>
 </template>
 
 <script setup>
-import { onBeforeUnmount, onMounted, ref } from 'vue'
-import { mountMap, resizeMap, unmountMap } from '../services/mapService.js'
-import { LEVEL_TAGS } from '../domain/statusTones.js'
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import {
+    clearPointMarkers,
+    getPointMarkerState,
+    mountMap,
+    onPointPick,
+    publishSummaryHandle,
+    resizeMap,
+    syncPointMarkersForSummary,
+    unmountMap,
+} from '../services/mapService.js'
+import { MAP_SUMMARY_PHASE } from '../domain/mapSummary.js'
+import { selectionFromMapObject } from '../domain/detailFlow.js'
+import { useMapStore } from '../stores/mapStore.js'
+import { useDetailStore } from '../stores/detailStore.js'
 import CockpitIcon from './CockpitIcon.vue'
+import MapCoordinateBlocked from './MapCoordinateBlocked.vue'
 import StatusTag from './StatusTag.vue'
 
 defineProps({
     modeLabel: { type: String, default: '' },
+    /**
+     * 票 32:调试形态。`false`(生产构建默认)时收起「中央一张图」标题、图层占位、实例提示等
+     * 开发期文本,只保留**颜色语义**(点位状态图例 + 状态灯)与一行贴底小字。
+     * 外壳按配置 `map.ui.debug` 下发,缺省跟随构建模式。
+     */
+    uiDebug: { type: Boolean, default: true },
 })
 
 // 图层开关的占位(真实开关见票 21/22);图标名对应 domain/icons.js。
@@ -70,7 +117,102 @@ const photoUrl = `${base}cockpit/aerial.webp`
 const hostRef = ref(null)
 const instanceId = ref('')
 
+// ---- 票 08:地图摘要的渐进加载状态(装载器归 mapStore,本组件只画出来)----
+// 「地图先显示框架」在代码里的落点:本组件与地图实例先挂好,装载器初始态是 idle
+// (文案「地图框架已就绪 · 等待分页数据」),随后每取一页才多出点位。
+const mapStore = useMapStore()
+const detailStore = useDetailStore()
+const summaryText = computed(() => mapStore.view.label)
+const summarySettled = computed(() => mapStore.view.phase === MAP_SUMMARY_PHASE.COMPLETE)
+const summaryBad = computed(() => ['failed', 'expired'].includes(mapStore.view.phase))
+// 票 07:坐标准入总览(后端全量口径 + 已加载口径 + 不上图清单)。判定归 domain/coordinate.js。
+const coordinateOverview = computed(() => mapStore.coordinate)
+
+// ---- 票 09:点位标记与图例(标记画在地图上,图例读同一份计划)----
+// 图例与标记同源于 `getPointMarkerState()`:不是"两边各自小心保持一致",
+// 而是结构上只有一份状态→视觉的映射(domain/pointStatus.js)。
+const markerState = ref(getPointMarkerState())
+const legendRows = computed(() => markerState.value.legend)
+const markerStatusText = computed(() => {
+    const byStatus = markerState.value.byStatus
+    const keys = Object.keys(byStatus).sort()
+    if (!keys.length) return '等待分页数据'
+    return keys.map((key) => `${key} ${byStatus[key]}`).join(' · ')
+})
+
 let resizeObserver = null
+let stopSummaryWatch = null
+let stopMarkerWatch = null
+let stopPointPick = null
+
+/**
+ * 把装载状态写进宿主 `dataset`(走查据此断言,不靠读中文字符串):
+ *   `data-map-summary`        = idle / loading / complete / empty / failed / expired
+ *   `data-map-summary-loaded` = 已上图对象数
+ *   `data-map-summary-snapshot` = 本次加载锁定的快照标记
+ *   `data-map-summary-total`  = 后端给的总数
+ *
+ * 票 07 追加坐标准入的四个数(同样给走查读,不读中文):
+ *   `data-map-renderable`     = 已加载范围内可上图数
+ *   `data-map-blocked`        = 已加载范围内不上图数
+ *   `data-map-blocked-backend`= 后端全量口径的不上图数(= 非法 + 未校验)
+ *   `data-map-coordinate-crs` = 后端汇总里的坐标系
+ */
+function paintSummary(host, state) {
+    if (!host || !host.dataset) return
+    host.dataset.mapSummary = state.phase
+    host.dataset.mapSummaryLoaded = String(state.loaded)
+    host.dataset.mapSummaryTotal = String(state.total)
+    host.dataset.mapSummarySnapshot = state.snapshot || ''
+    host.dataset.mapSummaryPage = String(state.page)
+
+    const overview = coordinateOverview.value
+    host.dataset.mapRenderable = String(overview.loaded.renderable)
+    host.dataset.mapBlocked = String(overview.loaded.blocked)
+    const backend = overview.backend
+    host.dataset.mapBlockedBackend = backend.available
+        ? String((backend.invalid === null ? 0 : backend.invalid) + (backend.unverified === null ? 0 : backend.unverified))
+        : ''
+    host.dataset.mapCoordinateCrs = backend.crs || ''
+}
+
+/**
+ * 票 09:把当前已加载的对象同步成地图标记,并把结果(含图例)取回来给组件渲染。
+ *
+ * 为什么要等:装载器几乎总是**比引擎先就绪**(引擎要动态拉 Cesium 分包、再拉地形与影像,
+ * 实测首屏 dev 冷启动可以到十几秒),这时 `syncPointMarkersForSummary()` 只更新计划、返回 false。
+ * 若不补画,第一批点位就永远上不了图 —— 而票 08 的渐进加载恰恰是从第一批开始的。
+ *
+ * 退出的判据是**引擎的终态**(`data-map-engine` 落到 ready/failed/disabled/unavailable),
+ * 不是"试够多少次就算了":第一版写的是 40 × 250ms = 10 秒封顶,实测引擎冷启动超过 10 秒时
+ * 重试已经放弃,地图上一个标记都没有(图例却有计数)—— 只有用户去点一下「加载下一页」
+ * 才会因为对象变化重新触发。那个 bug 是视觉探针(截图里「点位标记 0」)抓到的,
+ * 走查当时反而没抓到,因为它一路在点分页按钮。
+ *
+ * `markerTimer` 保证**同时只有一个**重试在排队:翻页会连触发 watch,若不收口,
+ * 几页就会叠出几个定时器(各自重画一遍,白烧 CPU 且最后谁赢不确定)。
+ */
+let markerTimer = null
+
+/** 引擎是否已落到终态;没落到就还得等它(booting/idle 都要等)。 */
+function engineSettled() {
+    const host = hostRef.value
+    const state = host && host.dataset ? host.dataset.mapEngine : ''
+    return ['ready', 'failed', 'disabled', 'unavailable'].includes(state)
+}
+
+function applyMarkers() {
+    const painted = syncPointMarkersForSummary(mapStore.summary.objects)
+    markerState.value = getPointMarkerState()
+
+    // 画上了就收工;引擎已定局(失败/关闭/没有宿主)也不用再等 —— 图例仍要显示。
+    if (painted || engineSettled()) return
+    if (markerTimer !== null) return
+    markerTimer = window.setTimeout(() => {
+        markerTimer = null
+        applyMarkers()
+    }, 250)
+}
 
 onMounted(() => {
     // 幂等挂载:模式切换或面板收放都不会再次走到这里(本组件常驻)。
@@ -82,9 +224,55 @@ onMounted(() => {
         resizeObserver = new ResizeObserver(() => resizeMap())
         resizeObserver.observe(hostRef.value)
     }
+
+    // 装载器状态 → 宿主 dataset(常驻组件,停止监听只在离开 /cockpit 时发生)。
+    mapStore.setup({})
+    paintSummary(hostRef.value, mapStore.summary)
+    stopSummaryWatch = watch(() => mapStore.summary, (state) => paintSummary(hostRef.value, state), { deep: false })
+
+    // 票 09:每成功合并一页就重画标记(旧对象保持在地图上,与票 08 的渐进加载语义一致)。
+    applyMarkers()
+    stopMarkerWatch = watch(() => mapStore.summary.objects, () => applyMarkers(), { deep: false })
+
+    // DEV 把手:人工演示与走查用 `window.__cockpitMap.mapSummary` 一页页推进(票 08)。
+    publishSummaryHandle({
+        advance: () => mapStore.advance(),
+        refresh: () => mapStore.refresh(),
+        getState: () => mapStore.summary,
+    })
+
+    // 票 10:点选地图上的点位标记 → 打开两级详情。
+    // 分工:地图服务只回答「拾到了谁」(拾不到、或拾到空白一律 null),本组件决定「打开什么」。
+    // 「关联设备不重复上图」在这条路径上是结构性的:它只把 kind/code 变成选择,
+    // 从不往地图里加对象(标记计划的唯一来源仍是 mapStore.summary.objects)。
+    stopPointPick = onPointPick((picked) => {
+        const selection = selectionFromMapObject(picked)
+        if (selection) detailStore.openSelection(selection)
+    })
 })
 
 onBeforeUnmount(() => {
+    publishSummaryHandle(null)
+    // 票 10:先退订点选,再拆地图(否则服务层还会往已卸载的 store 上写选择)。
+    if (stopPointPick) {
+        stopPointPick()
+        stopPointPick = null
+    }
+    // 票 09:离开 /cockpit 时要连点位标记一起收(标记挂在 viewer 上,viewer 随后销毁)。
+    if (markerTimer !== null) {
+        window.clearTimeout(markerTimer)
+        markerTimer = null
+    }
+    clearPointMarkers()
+    markerState.value = getPointMarkerState()
+    if (stopSummaryWatch) {
+        stopSummaryWatch()
+        stopSummaryWatch = null
+    }
+    if (stopMarkerWatch) {
+        stopMarkerWatch()
+        stopMarkerWatch = null
+    }
     if (resizeObserver) {
         resizeObserver.disconnect()
         resizeObserver = null
@@ -159,17 +347,25 @@ onBeforeUnmount(() => {
     background: linear-gradient(180deg, rgba(2, 8, 16, 0.55) 0%, transparent 26%, transparent 68%, rgba(2, 8, 16, 0.6) 100%);
 }
 
-/* ---------- 中间带浮层(左右浮栏之间居中) ---------- */
+/* ---------- 顶部带:图例在最上,调试形态的标题/图层占位在其下 ---------- */
 .map-holo__top {
     position: absolute;
     z-index: 2;
-    top: calc(12 * var(--u, 1px));
+    top: calc(10 * var(--u, 1px));
     left: 50%;
     transform: translateX(-50%);
+    display: grid;
+    gap: calc(6 * var(--u, 1px));
+    justify-items: center;
+    max-width: 92%;
     text-align: center;
     pointer-events: none;
 }
 
+.map-holo__headline {
+    text-align: center;
+}
+
 .map-holo__top b {
     display: block;
     color: #eaf9ff;
@@ -202,7 +398,7 @@ onBeforeUnmount(() => {
 .map-holo__bottom {
     position: absolute;
     z-index: 2;
-    bottom: calc(10 * var(--u, 1px));
+    bottom: calc(8 * var(--u, 1px));
     left: 50%;
     transform: translateX(-50%);
     display: grid;
@@ -220,6 +416,17 @@ onBeforeUnmount(() => {
     flex-wrap: wrap;
 }
 
+/* 票 35 续:生产形态把底部摘要行也收进**右下角状态日志**那一摞,
+   中央视野完全空出来(与引擎状态同一条右边线、在其上方)。 */
+.map-holo__bottom.is-quiet {
+    left: auto;
+    right: calc(var(--column-w, 300px) + 10 * var(--u, 1px));
+    bottom: calc(22 * var(--u, 1px));
+    transform: none;
+    justify-items: end;
+    text-align: right;
+}
+
 .map-canvas__title {
     margin: 0;
     color: #eaf9ff;
@@ -245,6 +452,25 @@ onBeforeUnmount(() => {
     text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9);
 }
 
+/* 票 32:生产形态的状态行 —— 再小一点、再淡一点,让位于 3D 场景(状态灯的颜色语义保留)。 */
+.map-holo__status.is-quiet {
+    color: rgba(186, 212, 224, 0.5);
+    font-size: calc(9.5 * var(--u, 1px));
+    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
+}
+
+.map-holo__status.is-quiet .map-holo__markers {
+    border-left-color: rgba(58, 170, 226, 0.2);
+}
+
+/* 票 09:标记数与状态灯同一行;左侧加一条分隔线,免得跟摘要文案连成一串。 */
+.map-holo__markers {
+    padding-left: 7px;
+    border-left: 1px solid rgba(58, 170, 226, 0.32);
+    white-space: nowrap;
+}
+
+/* 状态灯:默认(加载中/待取数)呼吸的橙点;加载完成转绿、失败/快照失效转红。 */
 .map-holo__status i {
     width: calc(7 * var(--u, 1px));
     height: calc(7 * var(--u, 1px));
@@ -254,6 +480,16 @@ onBeforeUnmount(() => {
     animation: map-breathe 1.8s ease-in-out infinite;
 }
 
+.map-holo__status i.is-done {
+    background: var(--lv4, #35d6a4);
+    box-shadow: 0 0 8px var(--lv4, #35d6a4);
+}
+
+.map-holo__status i.is-bad {
+    background: var(--lv1, #ff4d5e);
+    box-shadow: 0 0 8px var(--lv1, #ff4d5e);
+}
+
 @keyframes map-breathe {
     0%, 100% { opacity: 1; transform: scale(1); }
     50% { opacity: 0.35; transform: scale(0.72); }

+ 210 - 0
src/modules/cockpit/components/MapCoordinateBlocked.vue

@@ -0,0 +1,210 @@
+<!--
+  「无地图位置」清单(票 07)。
+
+  为什么必须有这块可见面:票 07 的验收第 3 条要求「UI 显示『无地图位置』或具体坐标原因」,
+  而今天全驾驶舱**没有任何列表界面**(监测点台账归票 18、两级详情归票 10),
+  被拦下的对象如果不在这里露面,就只能是「静默隐藏」—— 那正是 CONTEXT.md 在
+  「无地图位置」词条里点名禁止的(_Avoid_: 默认坐标点、静默隐藏)。
+
+  所以本组件是**早期可见面**,不是终态列表:它只渲染领域层已经判定好的清单
+  (`domain/coordinate.js` 的 `blockedEntries`),不自己做任何坐标判断。
+  等票 10/18 的真列表建起来,它们消费同一份数据,本组件自然退为地图上的辅助提示。
+
+  两个口径分开说(票 07 的 Q4 裁定):后端全量汇总不随翻页跳动,已加载口径只说这批。
+
+  票 35 的简化(维护者 2026-09-18:「遮挡屏幕的是这一块区域,需要进行简化修改」):
+  收起时只留**一行小胶囊**(图标 + 「无地图位置」+ 计数 + 查看原因),
+  两条口径与清单属于**展开后的细节**——但仍然常驻 DOM(只是视觉上收起),
+  这样票 07 走查读 `data-map-coordinate-backend/-loaded` 文本的断言不受形态影响。
+-->
+<template>
+    <div class="map-blocked" :class="{ 'is-compact': !expanded }" data-map-blocked-panel>
+        <div class="map-blocked__head">
+            <CockpitIcon name="points" size="calc(11 * var(--u, 1px))" />
+            <b>无地图位置</b>
+            <span class="map-blocked__count" data-map-blocked-count>{{ overview.loaded.blocked }}</span>
+            <button
+                type="button"
+                class="map-blocked__toggle"
+                :aria-expanded="expanded ? 'true' : 'false'"
+                data-map-blocked-toggle
+                @click="expanded = !expanded"
+            >
+                {{ expanded ? '收起' : '查看原因' }}
+            </button>
+        </div>
+
+        <!-- 收起时这两行、以及清单,都由 CSS 隐去(DOM 常驻,保证票 07 的文本断言与可访问性) -->
+        <div class="map-blocked__detail">
+            <!-- 后端全量口径(不随翻页跳动)与已加载口径必须分开说,混成一个数就是自相矛盾 -->
+            <p class="map-blocked__line" data-map-coordinate-backend>{{ overview.backendText }}</p>
+            <p class="map-blocked__line" data-map-coordinate-loaded>{{ overview.loadedText }}</p>
+
+            <ul v-if="expanded" class="map-blocked__list" data-map-blocked-list>
+                <li v-for="item in overview.blockedEntries" :key="item.key" :data-map-blocked-reason="item.reasonCode">
+                    <span class="map-blocked__name">{{ item.name || item.code || '未命名对象' }}</span>
+                    <span class="map-blocked__code">{{ item.code }}</span>
+                    <!-- 具体原因:命中文案给具体原因,认不出才回「无地图位置」 -->
+                    <span class="map-blocked__reason">{{ item.reasonText }}</span>
+                </li>
+                <li v-if="!overview.blockedEntries.length" class="map-blocked__empty">当前已加载范围内没有无地图位置的对象</li>
+            </ul>
+        </div>
+    </div>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import CockpitIcon from './CockpitIcon.vue'
+
+const props = defineProps({
+    /** `domain/coordinate.js` 的 `coordinateOverview()` 结果 */
+    overview: { type: Object, required: true },
+})
+
+const expanded = ref(false)
+const blockedCount = computed(() => (props.overview && props.overview.loaded ? props.overview.loaded.blocked : 0))
+</script>
+
+<style scoped>
+.map-blocked,
+.map-blocked * {
+    box-sizing: border-box;
+}
+
+.map-blocked {
+    position: absolute;
+    z-index: 2;
+    /* 位置是被左栏逼出来的:中央一张图是**满幅背景**,左右两栏浮在它上面
+       (`.cockpit-shell__column` z-index 2 > 地图 z-index 0,且地图自成一个层叠上下文),
+       所以贴左下的浮块会被 300px 宽的左栏整块盖住。 */
+    /* 票 35 续:维护者要求挪到**一张图右下角**并继续减少遮挡 ——
+       右边界要让开右栏(宽度就是共享 token `--column-w`),否则会被右栏压住看不见;
+       底边让开底部状态带(图例已在顶部,底部只剩一行状态)。 */
+    right: calc(var(--column-w, 300px) + 10 * var(--u, 1px));
+    /* 让开右下角的状态日志(生产形态在 bottom 8px 处,约 12px 高),浮块落在它上方。 */
+    bottom: calc(26 * var(--u, 1px));
+    left: auto;
+    transform: none;
+    text-align: right;
+    /* 票 35:收窄 + 更透明 —— 维护者指出这块最挡屏幕。 */
+    width: min(280px, 34%);
+    max-height: 30%;
+    overflow: hidden auto;
+    padding: calc(4 * var(--u, 1px)) calc(8 * var(--u, 1px));
+    border: 1px solid rgba(58, 170, 226, 0.28);
+    background: rgba(4, 18, 34, 0.72);
+    color: var(--text-2, #a8cbe2);
+    font-size: calc(10 * var(--u, 1px));
+    letter-spacing: 0.03em;
+}
+
+/* 票 35:收起形态 = 一行小胶囊(高度只由 head 撑开,宽度贴合内容而不是固定 280px)。 */
+.map-blocked.is-compact {
+    width: auto;
+    max-width: min(280px, 34%);
+    padding: calc(3 * var(--u, 1px)) calc(8 * var(--u, 1px));
+    max-height: none;
+    overflow: visible;
+}
+
+/* 收起时把「查看原因」推向右侧对齐(胶囊贴右下角,视觉上不散)。 */
+.map-blocked.is-compact .map-blocked__head {
+    gap: calc(5 * var(--u, 1px));
+}
+
+/* 细节(两条口径 + 清单)只在展开时显示;收起时用 CSS 隐去而**不卸载**,
+   保证票 07 走查随时能读到那两个 data-* 的文本。 */
+.map-blocked.is-compact .map-blocked__detail {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    margin: -1px;
+    padding: 0;
+    overflow: hidden;
+    clip: rect(0 0 0 0);
+    white-space: nowrap;
+    border: 0;
+}
+
+.map-blocked__head {
+    display: flex;
+    align-items: center;
+    gap: calc(4 * var(--u, 1px));
+}
+
+.map-blocked__head b {
+    color: #e6f5ff;
+    font-weight: 700;
+}
+
+.map-blocked__count {
+    display: inline-flex;
+    align-items: center;
+    padding: 0 calc(5 * var(--u, 1px));
+    color: var(--lv2, #ffa63d);
+    font-family: var(--num, monospace);
+    border: 1px solid rgba(255, 166, 61, 0.5);
+}
+
+.map-blocked__toggle {
+    margin-left: auto;
+    padding: calc(1 * var(--u, 1px)) calc(5 * var(--u, 1px));
+    border: 1px solid rgba(58, 170, 226, 0.32);
+    background: rgba(8, 34, 60, 0.6);
+    color: #9dc2d9;
+    font: inherit;
+    font-size: calc(9.5 * var(--u, 1px));
+    cursor: pointer;
+}
+
+.map-blocked__toggle:hover {
+    color: #fff;
+    border-color: rgba(79, 216, 255, 0.6);
+}
+
+.map-blocked__line {
+    margin: calc(3 * var(--u, 1px)) 0 0;
+    color: var(--muted, #6d92ae);
+    font-size: calc(9.5 * var(--u, 1px));
+    line-height: 1.35;
+}
+
+.map-blocked__list {
+    margin: calc(4 * var(--u, 1px)) 0 0;
+    padding: 0;
+    list-style: none;
+    display: grid;
+    gap: calc(2 * var(--u, 1px));
+}
+
+.map-blocked__list li {
+    display: grid;
+    grid-template-columns: 1fr auto;
+    gap: 0 calc(4 * var(--u, 1px));
+    padding-top: calc(2 * var(--u, 1px));
+    border-top: 1px dashed rgba(58, 170, 226, 0.22);
+}
+
+.map-blocked__name {
+    color: var(--text-2, #a8cbe2);
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.map-blocked__code {
+    color: var(--muted, #6d92ae);
+    font-family: var(--num, monospace);
+}
+
+/* 具体原因独占一行,保证读得到(颜色不是唯一线索,文字始终在) */
+.map-blocked__reason {
+    grid-column: 1 / -1;
+    color: var(--lv2, #ffa63d);
+}
+
+.map-blocked__empty {
+    color: var(--muted, #6d92ae);
+}
+</style>

+ 409 - 0
src/modules/cockpit/components/PointDetailCard.vue

@@ -0,0 +1,409 @@
+<!--
+  两级详情浮层(票 10;CONTEXT.md「两级详情」):点位摘要 + 设备卡片 → 设备详情。
+
+  第一级回答「这个点是什么、它下面挂着哪些设备」,第二级回答「这台设备怎么了」。
+  层级语义在 `domain/detailFlow.js`(纯函数),取数在 `stores/detailStore.js`,
+  本组件只做两件事:把领域对象画出来、把用户动作交回 store。
+
+  三个刻意的取舍:
+    · **设备卡片顺序原样来自后端**(`deviceCards()` 不排序)—— 顺序是后端的业务语义;
+    · 每个状态同时有形状 + 文字(票 09 的表),不只靠颜色;
+    · 第二级的**六个分区**(档案 / 指标 / 预警 / 报警 / 工单 / 图片)各自独立成败、各自渲染
+      —— 见 `EquipmentDetailSections.vue`;本组件只负责表头(设备身份与状态)与分区容器。
+-->
+<template>
+    <section
+        class="point-detail"
+        data-point-detail
+        :data-detail-level="level"
+        :data-point-code="selection.pointCode"
+        :data-device-code="selection.deviceCode"
+        :data-point-detail-state="pointSection.state || 'empty'"
+    >
+        <header class="point-detail__head">
+            <span class="point-detail__title">{{ title }}</span>
+            <button
+                v-if="level === LEVEL.EQUIPMENT && selection.pointCode"
+                type="button"
+                class="point-detail__action"
+                data-detail-back
+                @click="detailStore.backToPoint()"
+            >
+                返回点位
+            </button>
+            <button type="button" class="point-detail__action" data-detail-close @click="detailStore.closeDetail()">
+                关闭
+            </button>
+        </header>
+
+        <!-- ---------- 第一级:点位摘要 + 设备卡片 ---------- -->
+        <div v-if="level !== LEVEL.EQUIPMENT" class="point-detail__body" data-detail-point>
+            <p v-if="pointLoading" class="point-detail__hint" data-detail-loading>点位摘要加载中…</p>
+
+            <template v-else-if="summary">
+                <dl class="point-detail__rows" data-point-summary>
+                    <div class="point-detail__row">
+                        <dt>名称</dt>
+                        <dd data-point-name>{{ summary.name }}</dd>
+                    </div>
+                    <div class="point-detail__row">
+                        <dt>编码</dt>
+                        <dd class="point-detail__mono" data-point-code-value>{{ summary.code }}</dd>
+                    </div>
+                    <div class="point-detail__row">
+                        <dt>状态</dt>
+                        <dd>
+                            <StatusTag
+                                data-point-status
+                                :tone="summary.status.tone"
+                                :shape="summary.status.shape"
+                                :label="summary.status.label"
+                            />
+                        </dd>
+                    </div>
+                    <div class="point-detail__row">
+                        <dt>原因</dt>
+                        <dd :class="{ 'is-muted': !summary.reason }" data-point-reason>
+                            {{ summary.reason || '暂无原因' }}
+                        </dd>
+                    </div>
+                    <div class="point-detail__row">
+                        <dt>关联设备</dt>
+                        <dd><b class="point-detail__num" data-point-device-count>{{ summary.deviceCount }}</b> 台</dd>
+                    </div>
+                </dl>
+
+                <p class="point-detail__section-label">设备卡片 · 点一台进入设备详情</p>
+                <div class="point-detail__cards" data-device-cards>
+                    <button
+                        v-for="(card, index) in cards"
+                        :key="card.code"
+                        type="button"
+                        class="point-detail__card"
+                        :data-device-card="index"
+                        :data-device-code="card.code"
+                        @click="detailStore.selectDevice(card.code)"
+                    >
+                        <span class="point-detail__card-top">
+                            <b class="point-detail__card-name">{{ card.name }}</b>
+                            <StatusTag :tone="card.status.tone" :shape="card.status.shape" :label="card.status.label" />
+                        </span>
+                        <span class="point-detail__card-meta">
+                            <span class="point-detail__mono">{{ card.code }}</span>
+                            <span v-if="card.typeName">{{ card.typeName }}</span>
+                        </span>
+                        <span class="point-detail__card-reason">{{ card.reason || '暂无原因' }}</span>
+                    </button>
+                    <p v-if="!cards.length" class="point-detail__hint" data-device-cards-empty>
+                        该监测点当前没有关联设备
+                    </p>
+                </div>
+            </template>
+
+            <!-- 没取到数据时如实说:失败/空/不可用的措辞与色键归 domain/sectionState.js -->
+            <SectionStateTag v-else name="pointDetail" label="点位摘要" :section="pointSection" />
+        </div>
+
+        <!-- ---------- 第二级:设备详情(分区内容归票 11) ---------- -->
+        <div v-else class="point-detail__body" data-detail-device>
+            <dl class="point-detail__rows">
+                <div class="point-detail__row">
+                    <dt>设备名称</dt>
+                    <dd data-device-name>{{ deviceCard ? deviceCard.name : '—' }}</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"
+                        />
+                        <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>
+
+            <!--
+                票 11:六个分区(档案 / 指标 / 预警 / 报警 / 工单 / 图片)。每块各自成败、各自渲染,
+                一块坏了不影响其它块(验收第 3 条);空 / 缺失 / 过期各有明确 UI(第 4 条)。
+                聚合未就绪时只说「加载中」——不留一片空白,也不拿上一台设备的数据顶替。
+
+                整包失败(`FAILED`/`UNAVAILABLE`,`data` 为 null)时**不**去渲染六块面板:
+                那样每块都会说「其它分区不受影响」,而此刻六块一起没数据,那句话是假的。
+                这里改成一句如实的话,并给出重试路径(再点一次同一台设备即可重取)。
+            -->
+            <p v-if="aggregateLoading" class="point-detail__hint" data-device-loading>设备详情加载中…</p>
+            <template v-else-if="aggregateDown">
+                <SectionStateTag
+                    name="equipmentAggregate"
+                    label="设备详情"
+                    :section="sections.equipmentAggregate"
+                    data-device-aggregate-state
+                />
+                <p class="point-detail__hint" data-device-unavailable>
+                    这台设备的详情没能取到,档案 / 指标 / 预警 / 报警 / 工单 / 图片都没有数据。
+                </p>
+                <button
+                    type="button"
+                    class="point-detail__action"
+                    data-device-retry
+                    @click="detailStore.selectDevice(selection.deviceCode)"
+                >
+                    重新加载
+                </button>
+            </template>
+            <EquipmentDetailSections
+                v-else
+                data-device-detail-body
+                :aggregate="aggregateSection"
+                :device-code="selection.deviceCode"
+            />
+        </div>
+    </section>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useDetailStore } from '../stores/detailStore.js'
+import { DETAIL_LEVEL, deviceCardOf, deviceCards, pointSummary } from '../domain/detailFlow.js'
+import { equipmentDetailMatches, aggregateUnavailable } from '../domain/equipmentDetailView.js'
+import EquipmentDetailSections from './EquipmentDetailSections.vue'
+import SectionStateTag from './SectionStateTag.vue'
+import StatusTag from './StatusTag.vue'
+
+const LEVEL = DETAIL_LEVEL
+const detailStore = useDetailStore()
+const { selection, pointLoading, deviceLoading, sections } = storeToRefs(detailStore)
+
+const level = computed(() => selection.value.level)
+const pointSection = computed(() => sections.value.pointDetail)
+const pointData = computed(() => (pointSection.value && pointSection.value.data) || null)
+const summary = computed(() => (pointData.value ? pointSummary(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 : ''))
+const title = computed(() => (level.value === LEVEL.EQUIPMENT ? '设备详情' : '点位摘要'))
+
+/**
+ * 第二级的聚合分区。**只认当前这台设备那一份**:
+ * 换设备时请求还没回来,这里仍是上一台的载荷 —— 若直接传下去,界面就会把 A 的档案
+ * 挂在 B 的表头下。不匹配时当"没数据"传(`equipmentPanels` 会给每块分区一条失败态,
+ * 分区标签照常说话),配合 `aggregateLoading` 的加载提示,不会白屏也不会错画。
+ */
+const aggregateMatches = computed(() => equipmentDetailMatches(sections.value.equipmentAggregate, selection.value.deviceCode))
+const aggregateSection = computed(() => (aggregateMatches.value ? sections.value.equipmentAggregate : null))
+const aggregateLoading = computed(() => deviceLoading.value && !aggregateMatches.value)
+/** 整包失败(六块一起没数据):此时不该逐块说「其它分区不受影响」,那话是假的。 */
+const aggregateDown = computed(() => aggregateUnavailable(sections.value.equipmentAggregate))
+</script>
+
+<style scoped>
+.point-detail,
+.point-detail *,
+.point-detail *::before,
+.point-detail *::after {
+    box-sizing: border-box;
+}
+
+.point-detail {
+    display: flex;
+    flex-direction: column;
+    max-height: 100%;
+    min-height: 0;
+    overflow: hidden;
+    border: 1px solid var(--panel-line, rgba(58, 170, 226, 0.3));
+    background: linear-gradient(180deg, var(--panel-top, rgba(12, 48, 86, 0.58)) 0%, var(--panel-bot, rgba(3, 14, 28, 0.9)) 100%);
+    box-shadow: inset 0 1px 0 rgba(130, 225, 255, 0.14), var(--shadow, 0 18px 44px rgba(0, 0, 0, 0.55));
+    color: var(--text-2, #a8cbe2);
+    font-size: var(--fs-caption, 10.5px);
+    pointer-events: auto;
+}
+
+.point-detail__head {
+    display: flex;
+    align-items: center;
+    gap: var(--space-2, 8px);
+    min-height: calc(32 * var(--u, 1px));
+    padding: 0 var(--space-2, 8px);
+    border-bottom: 1px solid rgba(58, 170, 226, 0.22);
+    background: linear-gradient(90deg, rgba(24, 96, 152, 0.55) 0%, rgba(12, 52, 90, 0.3) 42%, transparent 78%);
+}
+
+.point-detail__title {
+    position: relative;
+    flex: 1 1 auto;
+    min-width: 0;
+    padding-left: calc(10 * var(--u, 1px));
+    overflow: hidden;
+    color: #eaf9ff;
+    font-size: var(--fs-title, 14.5px);
+    font-weight: 700;
+    letter-spacing: 0.1em;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    text-shadow: 0 0 12px rgba(79, 216, 255, 0.6);
+}
+
+.point-detail__title::before {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 50%;
+    width: calc(4 * var(--u, 1px));
+    height: calc(14 * var(--u, 1px));
+    transform: translateY(-50%);
+    background: linear-gradient(180deg, #b6f0ff, var(--cyan-d, #1b93cf));
+    box-shadow: 0 0 10px var(--cyan, #4fd8ff);
+}
+
+.point-detail__action {
+    flex: none;
+    padding: 1px var(--space-2, 8px);
+    border: 1px solid rgba(58, 170, 226, 0.42);
+    background: rgba(8, 34, 60, 0.6);
+    color: #9dc2d9;
+    font: inherit;
+    letter-spacing: 0.04em;
+    white-space: nowrap;
+    cursor: pointer;
+}
+
+.point-detail__action:hover,
+.point-detail__action:focus-visible {
+    color: #fff;
+    border-color: rgba(79, 216, 255, 0.75);
+    outline: none;
+}
+
+.point-detail__body {
+    flex: 1 1 auto;
+    min-height: 0;
+    padding: var(--space-2, 8px) calc(10 * var(--u, 1px)) calc(10 * var(--u, 1px));
+    overflow: auto;
+}
+
+.point-detail__rows {
+    display: grid;
+    gap: calc(4 * var(--u, 1px));
+    margin: 0;
+}
+
+.point-detail__row {
+    display: grid;
+    grid-template-columns: calc(52 * var(--u, 1px)) minmax(0, 1fr);
+    align-items: baseline;
+    gap: var(--space-2, 8px);
+    padding: calc(2 * var(--u, 1px)) 0;
+    border-bottom: 1px dashed rgba(58, 170, 226, 0.16);
+}
+
+.point-detail__row dt {
+    color: var(--muted, #6d92ae);
+    letter-spacing: 0.06em;
+}
+
+.point-detail__row dd {
+    margin: 0;
+    color: var(--text, #e6f5ff);
+    word-break: break-word;
+}
+
+.point-detail__row dd.is-muted,
+.is-muted {
+    color: var(--muted, #6d92ae);
+}
+
+.point-detail__mono {
+    font-family: var(--num, monospace);
+    letter-spacing: 0.04em;
+}
+
+.point-detail__num {
+    color: #eaf9ff;
+    font-family: var(--num, monospace);
+    font-size: var(--fs-body, 13px);
+}
+
+.point-detail__section-label {
+    margin: var(--space-3, 12px) 0 var(--space-1, 4px);
+    color: var(--muted, #6d92ae);
+    font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.08em;
+}
+
+.point-detail__cards {
+    display: grid;
+    gap: calc(5 * var(--u, 1px));
+}
+
+/* 设备卡片:整块可点(大屏用鼠标/触屏都够大),状态标签在右上。 */
+.point-detail__card {
+    display: grid;
+    gap: calc(3 * var(--u, 1px));
+    padding: calc(6 * var(--u, 1px)) var(--space-2, 8px);
+    border: 1px solid rgba(58, 170, 226, 0.3);
+    background: linear-gradient(180deg, rgba(12, 48, 86, 0.5), rgba(4, 16, 30, 0.7));
+    font: inherit;
+    text-align: left;
+    cursor: pointer;
+    transition: border-color 0.16s, background 0.16s, transform 0.16s;
+}
+
+.point-detail__card:hover,
+.point-detail__card:focus-visible {
+    border-color: rgba(96, 220, 255, 0.75);
+    background: linear-gradient(180deg, rgba(22, 76, 126, 0.62), rgba(6, 24, 44, 0.8));
+    outline: none;
+}
+
+.point-detail__card-top {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: var(--space-2, 8px);
+}
+
+.point-detail__card-name {
+    min-width: 0;
+    overflow: hidden;
+    color: #eaf9ff;
+    font-size: var(--fs-body, 13px);
+    white-space: nowrap;
+    text-overflow: ellipsis;
+}
+
+.point-detail__card-meta {
+    display: flex;
+    gap: var(--space-2, 8px);
+    color: var(--muted, #6d92ae);
+    font-size: var(--fs-caption, 10.5px);
+}
+
+.point-detail__card-reason {
+    color: var(--text-2, #a8cbe2);
+    font-size: var(--fs-caption, 10.5px);
+}
+
+.point-detail__hint {
+    margin: 0;
+    color: var(--muted, #6d92ae);
+    font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.04em;
+}
+</style>

+ 105 - 0
src/modules/cockpit/components/SectionStateTag.vue

@@ -0,0 +1,105 @@
+<!--
+  分区数据状态标签(票 05):把「哪个分区 + 它现在是就绪/无数据/过期/不可用/失败」画出来。
+
+  为什么必须带文字而不是只变色(沿用票 06 的立场):色盲与打印场景仍要能读出状态。
+  为什么**同时**显示分区名与状态词:只显示分区名等于没画状态(成功态与失败态长得一样),
+  只显示状态词又不知道是哪个分区 —— 走查实测踩过这个坑(真机走查第一轮:过期分区只剩
+  「显示最后一次成功数据」,肉眼与断言都读不出「已过期」)。所以两者都渲染。
+
+  为什么单独成组件:分区状态会出现在多个地方(外壳的分区总览、面板体内),措辞与色键必须同源,
+  否则两处会各写一套。色键与文案来自 domain/sectionState.js 的 `sectionView()`。
+
+  data-* 供走查断言:`data-section-state` 是 domain 词汇(success/empty/stale/failed/unavailable),
+  `data-section-name` 是这个分区的稳定英文键(面板按 panelId、共享域按分区名)。
+-->
+<template>
+    <span
+        class="section-state"
+        :class="`is-${view.tone}`"
+        :data-section-state="state"
+        :data-section-name="name"
+        :data-section-tone="view.tone"
+        :data-section-retained="retained ? 'true' : 'false'"
+        :title="tooltip"
+    >
+        <i class="section-state__dot" aria-hidden="true"></i>
+        <span v-if="label" class="section-state__name">{{ label }}</span>
+        <span class="section-state__label">{{ view.label }}</span>
+        <small v-if="view.detail" class="section-state__detail">{{ view.detail }}</small>
+    </span>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { sectionView } from '../domain/sectionState.js'
+
+const props = defineProps({
+    /** 分区稳定键(panelId 或共享域分区名)。 */
+    name: { type: String, required: true },
+    /** domain/sectionState.js 的 section() 结果。 */
+    section: { type: Object, default: null },
+    /** 分区展示名(可选);状态词永远来自 sectionView,不会被它顶掉。 */
+    label: { type: String, default: '' },
+})
+
+const view = computed(() => sectionView(props.section))
+const state = computed(() => (props.section && props.section.state) || 'empty')
+const retained = computed(() => !!(props.section && props.section.retained))
+const tooltip = computed(() => [props.label, view.value.label, view.value.detail].filter(Boolean).join(' · '))
+</script>
+
+<style scoped>
+.section-state,
+.section-state * {
+    box-sizing: border-box;
+}
+
+.section-state {
+    display: inline-flex;
+    align-items: center;
+    gap: var(--space-1, 4px);
+    padding: 1px var(--space-2, 8px);
+    border: 1px solid currentcolor;
+    border-radius: 999px;
+    background: rgba(4, 18, 34, 0.6);
+    font-size: var(--fs-caption, 10.5px);
+    line-height: 1.5;
+    letter-spacing: 0.04em;
+    white-space: nowrap;
+    color: var(--section-color, var(--neutral, #6d92ae));
+}
+
+.section-state__dot {
+    width: 6px;
+    height: 6px;
+    border-radius: 50%;
+    background: currentcolor;
+    box-shadow: 0 0 8px currentcolor;
+}
+
+.section-state__name {
+    color: var(--muted, #6d92ae);
+    font-size: var(--fs-caption, 10.5px);
+}
+
+.section-state__name::after {
+    content: '·';
+    margin-left: 4px;
+    color: rgba(109, 146, 174, 0.7);
+}
+
+.section-state__label {
+    color: var(--text-2, #a8cbe2);
+}
+
+.section-state__detail {
+    color: var(--muted, #6d92ae);
+    font-size: var(--fs-caption, 10.5px);
+}
+
+.section-state.is-lv1 { --section-color: var(--lv1, #ff4d5e); }
+.section-state.is-lv2 { --section-color: var(--lv2, #ffa63d); }
+.section-state.is-lv3 { --section-color: var(--lv3, #4fd8ff); }
+.section-state.is-lv4 { --section-color: var(--lv4, #35e39a); }
+.section-state.is-neutral { --section-color: var(--neutral, #6d92ae); }
+</style>

+ 36 - 8
src/modules/cockpit/components/StatusTag.vue

@@ -1,15 +1,30 @@
 <!--
-  状态标签:色块 + 文字,颜色取自 token(--lv1..--lv4 / --neutral)。
+  状态标签:**形状 + 颜色 + 文字**,颜色取自 token(--lv1..--lv4 / --neutral)。
 
-  为什么必须带文字(而不是纯色点):ADR 0006 要求状态不能只靠颜色区分,
-  色盲与打印场景下仍要能读出「报警 / 预警 / 离线」。
+  为什么必须同时有形状与文字(而不是纯色点):验收要求状态不能只靠颜色区分,
+  色盲、打印与灰度大屏下仍要能读出「报警 / 预警 / 离线」,而且形状与地图标记是同一份。
 
-  用法:`<StatusTag tone="lv1" label="1级" />`。tone 取值见 domain/statusTones.js;
-  未知 tone 退回中性灰,避免一个拼写错误让标签整块消失。
+  用法:
+    `<StatusTag tone="lv1" label="1级" />`                    事件四级(票 06 原用法)
+    `<StatusTag tone="lv1" shape="triangle" label="报警" />`  点位状态(票 09)
+  `tone` 取值见 domain/statusTones.js、`shape` 取值见 domain/pointStatus.js;
+  未知 tone / shape 各自退回中性灰与实心圆,避免一个拼写错误让标签整块消失。
 -->
 <template>
-    <span class="status-tag" :class="`is-${toneKey}`" :data-tone="toneKey">
-        <i class="status-tag__dot" aria-hidden="true"></i>
+    <span class="status-tag" :class="`is-${toneKey}`" :data-tone="toneKey" :data-shape="shapeKey || ''">
+        <!-- 形状:与地图标记同一份片段(domain/pointStatus.js 的 SHAPES) -->
+        <svg
+            v-if="shapeKey"
+            class="status-tag__shape"
+            viewBox="0 0 12 12"
+            width="1em"
+            height="1em"
+            fill="none"
+            aria-hidden="true"
+            focusable="false"
+            v-html="shapeMarkup"
+        />
+        <i v-else class="status-tag__dot" aria-hidden="true"></i>
         <span class="status-tag__label">{{ label }}</span>
     </span>
 </template>
@@ -17,13 +32,18 @@
 <script setup>
 import { computed } from 'vue'
 import { DEFAULT_TONE, isKnownTone } from '../domain/statusTones.js'
+import { isKnownShape, shapeSvg } from '../domain/pointStatus.js'
 
 const props = defineProps({
     tone: { type: String, default: DEFAULT_TONE },
     label: { type: String, required: true },
+    // 不传 = 保持票 06 的圆点外观(事件四级图例仍在用);传了就换成对应形状。
+    shape: { type: String, default: '' },
 })
 
 const toneKey = computed(() => (isKnownTone(props.tone) ? props.tone : DEFAULT_TONE))
+const shapeKey = computed(() => (isKnownShape(props.shape) ? props.shape : ''))
+const shapeMarkup = computed(() => shapeSvg(shapeKey.value))
 </script>
 
 <style scoped>
@@ -50,7 +70,15 @@ const toneKey = computed(() => (isKnownTone(props.tone) ? props.tone : DEFAULT_T
     box-shadow: 0 0 8px currentcolor;
 }
 
-/* 文字用亮色保证对比度,边框与圆点用语义色 */
+/* 形状与圆点同一个颜色(currentColor = 语义色),保证「图例那一行」与地图标记同形同色。 */
+.status-tag__shape {
+    display: block;
+    flex: none;
+    color: currentcolor;
+    filter: drop-shadow(0 0 4px currentcolor);
+}
+
+/* 文字用亮色保证对比度,边框与形状用语义色 */
 .status-tag__label {
     color: var(--text-2, #a8cbe2);
 }

+ 141 - 0
src/modules/cockpit/domain/basemap.js

@@ -0,0 +1,141 @@
+/**
+ * 底图计划(票 29):把配置里的 `map.basemap` 翻译成「用哪一类 Cesium 影像源、什么 URL」。
+ *
+ * 纯函数、**不 import cesium** —— 引擎侧(services/cesiumEngine.js)照着这份计划建 provider,
+ * 逻辑断言脚本因此能脱浏览器把每种来源(含天地图模板拼装、缺 tk 的失败态)都测到。
+ *
+ * 为什么单独一层:票 27 故意 `baseLayer: false`(底图未配置就不伪造地理配准),
+ * 票 21 负责地形 / 3D Tiles / 倾斜摄影与地面-管线视图切换;底图影像这一层独立成票。
+ */
+
+/** 底图来源(配置里 `map.basemap.source` 的取值)。 */
+export const BASEMAP_SOURCE = {
+    NONE: 'none',
+    NATURAL_EARTH: 'natural-earth',
+    ARCGIS_IMAGERY: 'arcgis-imagery',
+    XYZ: 'xyz',
+    TIANDITU: 'tianditu',
+}
+
+/** 底图状态(写进宿主 `data-map-basemap`,也是 getLifecycleStats().basemap 的取值)。 */
+export const BASEMAP_STATE = {
+    NOT_CONFIGURED: 'not-configured',
+    NATURAL_EARTH: 'natural-earth',
+    ARCGIS_IMAGERY: 'arcgis-imagery',
+    XYZ: 'xyz',
+    TIANDITU: 'tianditu',
+    INVALID: 'invalid',
+}
+
+/**
+ * ArcGIS 官方世界影像:免费、免 key(署名即可),模板顺序是 `{z}/{y}/{x}`。
+ * 注意本机实测**连不上**(外网被挡),离线环境请用 `natural-earth`。
+ */
+export const ARCGIS_WORLD_IMAGERY_URL =
+    'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
+
+/** Cesium 自带的离线影像(随 `Build/Cesium/Assets` 一起进产物,不需要账号或外网)。 */
+export const NATURAL_EARTH_TMS = 'Assets/Textures/NaturalEarthII'
+
+/** 天地图子域(生产直连时用;开发走仓库里已有的 `/tianditu.map` 代理,单主机不加子域)。 */
+export const TIANDITU_SUBDOMAINS = ['t0', 't1', 't2', 't3', 't4', 't5', 't6', 't7']
+
+export const TIANDITU_LAYERS = {
+    img: '影像底图',
+    vec: '矢量底图',
+    ter: '地形晕渲',
+}
+
+/**
+ * 天地图 WMTS 瓦片模板(tk 拼在查询串里;Cesium 负责替换 `{s}`/`{z}`/`{x}`/`{y}`)。
+ * @param {string} base 形如 `/tianditu.map`(开发代理)或 `https://t{s}.tianditu.gov.cn`(生产)
+ * @param {string} token 天地图 tk
+ * @param {string} layer `img` / `vec` / `ter`
+ */
+export function tiandituTileUrl(base, token, layer = 'img') {
+    const root = String(base || '').replace(/\/+$/, '')
+    return `${root}/${layer}_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=${layer}&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${token}`
+}
+
+function basePlan(state, source, label, extra = {}) {
+    return { state, source, label, ready: false, kind: '', url: '', subdomains: [], minimumLevel: null, maximumLevel: null, credit: '', issue: '', ...extra }
+}
+
+/**
+ * 配置 → 底图计划。
+ * @param {object} mapConfig 归一化后的 `config.map`
+ * @param {object} [options]
+ * @param {boolean} [options.dev] 开发态:天地图走仓库自带的 `/tianditu.map` 代理
+ * @returns {{state: string, source: string, label: string, ready: boolean, kind: string, url: string,
+ *            subdomains: string[], minimumLevel: number|null, maximumLevel: number|null, credit: string, issue: string}}
+ */
+export function planBasemap(mapConfig = {}, options = {}) {
+    const map = mapConfig || {}
+    const basemap = map.basemap || {}
+    const source = basemap.source || BASEMAP_SOURCE.NONE
+
+    if (source === BASEMAP_SOURCE.NONE) {
+        return basePlan(BASEMAP_STATE.NOT_CONFIGURED, source, '底图未配置')
+    }
+
+    if (source === BASEMAP_SOURCE.NATURAL_EARTH) {
+        return basePlan(BASEMAP_STATE.NATURAL_EARTH, source, '内置离线影像 Natural Earth II', {
+            ready: true,
+            kind: 'tms',
+            url: NATURAL_EARTH_TMS,
+        })
+    }
+
+    if (source === BASEMAP_SOURCE.ARCGIS_IMAGERY) {
+        return basePlan(BASEMAP_STATE.ARCGIS_IMAGERY, source, 'ArcGIS 全球影像(免费免 key)', {
+            ready: true,
+            kind: 'xyz',
+            url: ARCGIS_WORLD_IMAGERY_URL,
+            credit: 'Esri, Maxar, Earthstar Geographics',
+        })
+    }
+
+    if (source === BASEMAP_SOURCE.XYZ) {
+        if (!basemap.url) {
+            return basePlan(BASEMAP_STATE.INVALID, source, '底图配置无效', { issue: 'xyz 底图缺少 url 模板' })
+        }
+        return basePlan(BASEMAP_STATE.XYZ, source, '自定义瓦片(XYZ)', {
+            ready: true,
+            kind: 'xyz',
+            url: basemap.url,
+            subdomains: basemap.subdomains || [],
+            minimumLevel: basemap.minimumLevel,
+            maximumLevel: basemap.maximumLevel,
+            credit: basemap.credit || '',
+        })
+    }
+
+    if (source === BASEMAP_SOURCE.TIANDITU) {
+        const token = basemap.token || (map.tianditu && map.tianditu.token) || ''
+        if (!token) {
+            return basePlan(BASEMAP_STATE.INVALID, source, '底图配置无效', {
+                issue: '天地图底图缺少 tk(填 map.tianditu.token)',
+            })
+        }
+        const requested = (map.tianditu && map.tianditu.layer) || basemap.layer || 'img'
+        const layer = TIANDITU_LAYERS[requested] ? requested : 'img'
+        const dev = options.dev === true
+        return basePlan(BASEMAP_STATE.TIANDITU, source, `天地图${TIANDITU_LAYERS[layer]}`, {
+            ready: true,
+            kind: 'xyz',
+            url: tiandituTileUrl(dev ? '/tianditu.map' : 'https://t{s}.tianditu.gov.cn', token, layer),
+            subdomains: dev ? [] : TIANDITU_SUBDOMAINS,
+            credit: '天地图',
+        })
+    }
+
+    return basePlan(BASEMAP_STATE.INVALID, source, '底图配置无效', { issue: `未知的底图来源 ${source}` })
+}
+
+/** 引擎状态浮标上的底图措辞(未配置时保持票 27 的原话)。 */
+export function basemapStatusText(plan) {
+    const current = plan || planBasemap({})
+    if (current.ready) return `底图:${current.label}`
+    if (current.state === BASEMAP_STATE.INVALID) return `底图配置无效(${current.issue})· 回退临时影像`
+    return '底图未配置(正式底图见票 21)'
+}

+ 76 - 0
src/modules/cockpit/domain/configStatus.js

@@ -0,0 +1,76 @@
+/**
+ * 配置与数据源的词汇 + 「状态 → 视觉键」映射(票 04,纯函数、可脱浏览器断言)。
+ *
+ * 和 domain/statusTones.js 一样:这里只决定「状态叫什么、用哪个色键、标签怎么写」,
+ * 不做业务判断,也不碰网络。配置服务(services/cockpitConfig.js)反过来依赖这里的常量。
+ */
+
+/** 配置加载三态(票 04 验收:成功 / 失败 / 缺失都要能演示)。 */
+export const CONFIG_STATE = {
+    READY: 'ready',
+    MISSING: 'missing',
+    FAILED: 'failed',
+}
+
+/** 数据源两态:默认 mock,真实接口必须由部署配置显式开启。 */
+export const DATA_SOURCE = {
+    MOCK: 'mock',
+    REAL: 'real',
+}
+
+/** 前端是否自带只读 token;`none` 表示按 ADR 0005 由部署层网关补请求头。 */
+export const CREDENTIAL = {
+    CONFIG: 'config',
+    NONE: 'none',
+}
+
+/**
+ * 把配置加载结果映射成顶栏标签用的视图模型。
+ * @param {object} state services/cockpitConfig.js 的加载结果
+ * @returns {{state: string, tone: string, label: string, detail: string, dataSource: string}}
+ */
+export function configStatusView(state) {
+    const current = state || {}
+    const config = current.config || {}
+    const dataSource = config.dataSource === DATA_SOURCE.REAL ? DATA_SOURCE.REAL : DATA_SOURCE.MOCK
+
+    if (current.state === CONFIG_STATE.READY) {
+        const viaGateway = dataSource === DATA_SOURCE.REAL && current.credential === CREDENTIAL.NONE
+        return {
+            state: CONFIG_STATE.READY,
+            dataSource,
+            tone: 'lv4',
+            label: `配置就绪 · 数据源 ${dataSource}`,
+            detail: viaGateway ? '凭据由部署层注入' : '',
+        }
+    }
+
+    if (current.state === CONFIG_STATE.FAILED) {
+        return {
+            state: CONFIG_STATE.FAILED,
+            dataSource,
+            tone: 'lv1',
+            label: '配置载入失败 · 数据不可用',
+            detail: current.error || '',
+        }
+    }
+
+    return {
+        state: CONFIG_STATE.MISSING,
+        dataSource,
+        tone: 'neutral',
+        label: '配置缺失 · 用内置默认(mock)',
+        detail: current.url ? `尝试过 ${current.url}` : '',
+    }
+}
+
+/** 数据不可用分区统一文案(票 05 起各分区据此渲染,票 04 只定义措辞,避免各处自造)。 */
+export const DATA_UNAVAILABLE_LABEL = '数据不可用'
+
+/**
+ * 请求失败原因 → 是否属于「凭据类失败」(这类失败按 ADR 0005 只标数据不可用,绝不跳登录页)。
+ * @param {string} reason services/cockpitApi.js 的 REQUEST_REASON
+ */
+export function isCredentialFailure(reason) {
+    return reason === 'unauthorized' || reason === 'no-credential'
+}

+ 205 - 0
src/modules/cockpit/domain/coordinate.js

@@ -0,0 +1,205 @@
+/**
+ * 坐标准入与「无地图位置」(票 07)。
+ *
+ * 权威判据在后端,不在前端。后端 `CockpitCoordinateValidator.validate()` 已经给出结论:
+ *   `state` = `VALID` | `INVALID` | `UNVERIFIED`,`reason` 六取一,`renderable` 是布尔结论。
+ * 本文件**只消费这个结论**,绝不自己再查一遍经纬度范围 —— 票 07 的验收原句是
+ * 「前端不自动纠偏、不使用默认坐标」;重算一遍既会与后端分歧,也正是「纠偏」的入口。
+ * 唯一的准入判据因此是 `renderable === true`(票 09/22 的渲染必须走 `isRenderable()`)。
+ *
+ * 另一条纪律沿用票 05:**内部原因码不上界面**。`data-*` 上留 `reason` 原码供走查断言,
+ * 给人看的只有中文文案;认不出的原因宁可只说「无地图位置」,也不把 `LONGITUDE_OUT_OF_RANGE`
+ * 甩给值班人员。
+ *
+ * reason 词表照抄后端 `CockpitCoordinateValidator`(E:\pipe-ner-server,六个取值),
+ * 保证「文案」与「后端结论」一一对应,而不是前端猜一套。
+ */
+import { isPlainObject } from './fields.js'
+
+/** 后端坐标校验三态(`CockpitCoordinateValidation.state`)。 */
+export const COORDINATE_STATE = {
+    VALID: 'VALID',
+    INVALID: 'INVALID',
+    UNVERIFIED: 'UNVERIFIED',
+}
+
+/**
+ * 后端 reason 原码 → 中文文案。六个取值就是后端 `CockpitCoordinateValidator` 的全部返回值。
+ * 这里是「具体坐标原因」的唯一来源,做到词表与后端同步即可。
+ */
+export const COORDINATE_REASON_TEXT = {
+    UNVERIFIED_COORDINATE: '坐标未经校验',
+    MISSING_LONGITUDE: '缺少经度',
+    MISSING_LATITUDE: '缺少纬度',
+    LONGITUDE_OUT_OF_RANGE: '经度超出合法范围',
+    LATITUDE_OUT_OF_RANGE: '纬度超出合法范围',
+    ZERO_COORDINATE: '零坐标(经纬度均为 0)',
+}
+
+/** 认不出原因时的统一文案(票 07 验收原句点名的那一个)。 */
+export const NO_MAP_LOCATION_TEXT = '无地图位置'
+
+/** 后端整段没给 `coordinateValidation` 时的文案(票 05 的 `validated:false`)。 */
+export const UNVALIDATED_TEXT = '坐标未校验'
+
+/**
+ * 唯一准入判据:**只认后端 `renderable`**,不看经纬度数值、不看 state。
+ *
+ * 为什么连 `state === 'VALID'` 都不作为备选判据:那是第二套判据,一旦与 `renderable` 分歧
+ * (后端将来加了新 state),两个答案必然有一个是错的,而错的会画到地图上。
+ * @param {object} item 地图对象(`domain/mapSummary.js` 的形状)
+ */
+export function isRenderable(item) {
+    const coordinate = (item && item.coordinate) || null
+    return !!coordinate && coordinate.renderable === true
+}
+
+/**
+ * 对象不上图的原因(给界面看)。
+ *
+ * 三层,按票 07 的裁定:
+ *   1. 后端整段没给校验结果(`validated:false`)→「坐标未校验」——这是「缺失」,不是「非法」;
+ *   2. reason 命中后端词表 → 具体原因(缺少经度 / 超范围 / 零坐标 …);
+ *   3. 其余认不出 →「无地图位置」,不泄露原因码。
+ * @param {object} item 地图对象
+ * @returns {{code: string, text: string}} `code` 仅用于 `data-*` 与断言,不上界面
+ */
+export function blockReason(item) {
+    const coordinate = (item && item.coordinate) || {}
+
+    if (!coordinate.validated) {
+        return { code: '', text: UNVALIDATED_TEXT }
+    }
+
+    const code = coordinate.reason || ''
+    const text = COORDINATE_REASON_TEXT[code]
+    if (text) return { code, text }
+
+    return { code, text: NO_MAP_LOCATION_TEXT }
+}
+
+/**
+ * 按坐标准入把对象切成两堆(票 07 验收第 1 条)。
+ *
+ * **两堆都要交出去**:不上图 ≠ 不存在。对象必须仍在列表与详情里可见(`blocked` 就是给列表用的),
+ * 静默丢掉才是票 07 明确禁止的(CONTEXT.md「无地图位置」的 _Avoid_: 静默隐藏)。
+ * @param {Array} objects 地图对象数组
+ */
+export function splitByCoordinate(objects) {
+    const renderable = []
+    const blocked = []
+
+    for (const item of objects || []) {
+        if (isRenderable(item)) renderable.push(item)
+        else blocked.push(item)
+    }
+
+    return { renderable, blocked }
+}
+
+/**
+ * 不上图对象的清单(列表渲染用):带上能认人的名字与中文原因。
+ * @param {Array} objects 地图对象数组
+ */
+export function blockedEntries(objects) {
+    return splitByCoordinate(objects).blocked.map((item) => {
+        const reason = blockReason(item)
+        return {
+            key: `${(item && item.kind) || ''}|${(item && item.code) || ''}`,
+            kind: (item && item.kind) || '',
+            code: (item && item.code) || '',
+            name: (item && item.name) || '',
+            reasonCode: reason.code,
+            reasonText: reason.text,
+        }
+    })
+}
+
+/**
+ * 后端**整份快照**的坐标校验汇总 → 领域对象(票 07 的 Q4 裁定:如实带出后端口径)。
+ *
+ * 后端 `CockpitMapSnapshotResponse.coordinateValidation`(`CockpitCoordinateValidationSummary`)
+ * 是**全量**统计(`CockpitMapSnapshotService` 用整份 `points`/`equipment` 计算后再切片分页),
+ * 与前端按分页持有的已加载对象**必然不等**。两个口径都要,但用途不同:
+ *   · 后端口径回答「全城到底有多少点位没上图」——不随翻页跳动,是该给值班人员看的那个数;
+ *   · 前端口径(`mapSummary.js` 的 `summarizeCoordinateValidation`)只回答「我已加载的这批里有多少」。
+ *
+ * 缺失时不补 0:`available:false` + 各项 null —— 0 会被读成「一个坏坐标都没有」,
+ * 那是句谎话(票 05 的「不显示虚假零值」同一条纪律)。
+ * @param {object} raw 后端 `coordinateValidation` 汇总段
+ */
+export function toCoordinateSummary(raw) {
+    const has = isPlainObject(raw)
+    const count = (key) => {
+        const value = has ? raw[key] : null
+        return typeof value === 'number' && Number.isFinite(value) ? value : null
+    }
+
+    return {
+        available: has,
+        crs: has && typeof raw.crs === 'string' ? raw.crs : '',
+        total: count('totalObjectCount'),
+        renderable: count('renderableCount'),
+        invalid: count('invalidCount'),
+        unverified: count('unverifiedCount'),
+    }
+}
+
+/**
+ * 后端汇总 → 给人读的一行(票 07 验收第 3 条「显示明确原因」的全量口径)。
+ *
+ * 措辞刻意说「未取到」而不是「后端未发布」:这一段的缺失有两个来源 ——
+ * 后端没给这一段,**或者**快照为空(票 05 的 `sectionFromResult` 对 `EMPTY` 分区不带载荷,
+ * 那份全零汇总因此在适配层就被放下了)。说成「后端没发布」会把责任推错地方。
+ * 不管哪种来源,此时界面都只剩已加载口径可用,如实说清楚即可。
+ * @param {object} summary `toCoordinateSummary()` 的结果
+ */
+export function coordinateSummaryText(summary) {
+    const current = summary || {}
+    if (!current.available) {
+        return '未取到坐标校验汇总 · 仅显示已加载范围内的判定'
+    }
+    const total = current.total === null ? '—' : current.total
+    const renderable = current.renderable === null ? '—' : current.renderable
+    const invalid = current.invalid === null ? '—' : current.invalid
+    const unverified = current.unverified === null ? '—' : current.unverified
+    return `全量可上图 ${renderable}/${total} · 坐标非法 ${invalid} · 未校验 ${unverified}`
+}
+
+/**
+ * 已加载范围 → 给人读的一行(前端口径,与上面那行的主语必须分清,否则两个数会互相打脸)。
+ * @param {{total: number, renderable: number, blocked: number}} loaded
+ */
+export function loadedCoordinateText(loaded) {
+    const current = loaded || {}
+    return `已加载 ${current.total || 0} 个 · 可上图 ${current.renderable || 0} · 无地图位置 ${current.blocked || 0}`
+}
+
+/**
+ * 坐标准入总览(纯函数,界面与走查唯一读的那个对象)。
+ *
+ * 把**两个口径**并排给出,主语写清楚(票 07 的 Q4 裁定,也是本票最容易骗人的地方):
+ *   · `backend`  —— 后端全量汇总,不随翻页跳动;
+ *   · `loaded`   —— 前端按已加载对象算的自检口径。
+ * 二者不相等是**正常**的(后端是全量、前端只持有已翻到的页),界面必须分开说,不得混成一个数。
+ *
+ * @param {Array} objects 已加载的地图对象
+ * @param {object} backendSummary `toCoordinateSummary()` 的结果
+ */
+export function coordinateOverview(objects, backendSummary) {
+    const { renderable, blocked } = splitByCoordinate(objects)
+    const backend = backendSummary && backendSummary.available ? backendSummary : toCoordinateSummary(null)
+
+    return {
+        backend,
+        backendText: coordinateSummaryText(backend),
+        loaded: {
+            total: renderable.length + blocked.length,
+            renderable: renderable.length,
+            blocked: blocked.length,
+        },
+        loadedText: loadedCoordinateText({ total: renderable.length + blocked.length, renderable: renderable.length, blocked: blocked.length }),
+        // 不上图的清单:这是「保留在列表里」在本票的可见落点(后续票 10/18 的真列表接手同一份数据)。
+        blockedEntries: blockedEntries(objects),
+    }
+}

+ 189 - 0
src/modules/cockpit/domain/detailFlow.js

@@ -0,0 +1,189 @@
+/**
+ * 两级详情(票 10;CONTEXT.md「两级详情」):地图/台账 → 点位摘要 + 设备卡片 → 设备详情。
+ *
+ * 为什么单独成 domain 模块:这是一条**导航状态机**(先点位、再设备),与「取数成没成」
+ * (`domain/sectionState.js`)和「地图上画什么」(`domain/pointStatus.js`)都不是一回事。
+ * 把它放这里,store 只管取数、组件只管画,逻辑脚本能脱浏览器把每一级与每一次返回都走一遍。
+ *
+ * 「关联设备不作为独立地图标记重复渲染」在这里是**结构性**的:本模块只产出选择与卡片视图,
+ * 没有任何一条路径会把设备写回地图标记计划 —— 地图对象永远只来自 `mapStore` 的地图摘要
+ * (后端只给「监测点 + 未关联设备」,关联设备根本不在那份载荷里)。
+ */
+import { visualForStatus } from './pointStatus.js'
+
+/** 详情层级(也是 `data-detail-level` 的取值)。 */
+export const DETAIL_LEVEL = {
+    NONE: 'none',
+    POINT: 'point',
+    EQUIPMENT: 'equipment',
+}
+
+/** 缺失值的占位符:界面显示它,而不是把「没给」画成 0 或空白(票 16 的立场)。 */
+export const DETAIL_PLACEHOLDER = '—'
+
+/** 地图对象的两种来源(与 `domain/mapSummary.js` 的 `MAP_OBJECT_KIND` 同名,见那里的定义)。 */
+const KIND_MONITORING_POINT = 'monitoring-point'
+const KIND_UNASSOCIATED_EQUIPMENT = 'unassociated-equipment'
+
+/** 空选择:什么都没打开(界面据此不渲染详情浮层)。 */
+export function emptyDetailSelection() {
+    return { level: DETAIL_LEVEL.NONE, pointCode: '', deviceCode: '' }
+}
+
+/**
+ * 地图对象 → 详情选择。
+ *
+ * 监测点进**第一级**(点位摘要 + 设备卡片);未关联设备没有点位可摘要,
+ * 直接进**第二级**(设备详情)——「优先以监测点作为可点击地图对象」(CONTEXT.md)
+ * 不等于「别的对象点了没反应」。认不出的对象返回 `null`:不猜、也不打开一张空卡片。
+ * @param {object} object 地图对象(`domain/mapSummary.js` 的形状)
+ */
+export function selectionFromMapObject(object) {
+    const item = object || {}
+    const code = item.code || ''
+
+    if (!code) return null
+    if (item.kind === KIND_MONITORING_POINT) {
+        return { level: DETAIL_LEVEL.POINT, pointCode: code, deviceCode: '' }
+    }
+    if (item.kind === KIND_UNASSOCIATED_EQUIPMENT) {
+        return { level: DETAIL_LEVEL.EQUIPMENT, pointCode: '', deviceCode: code }
+    }
+    return null
+}
+
+/** 打开某个监测点:回到第一级,并把设备选择清掉(换点位不该留着上一台的选中态)。 */
+export function openPoint(selection, pointCode) {
+    if (!pointCode) return selection || emptyDetailSelection()
+    return { level: DETAIL_LEVEL.POINT, pointCode, deviceCode: '' }
+}
+
+/**
+ * 选择设备卡片:进第二级。点位编码**保留**(返回第一级要靠它),
+ * 从地图上的未关联设备进来时 `pointCode` 本来就是空的。
+ */
+export function openDevice(selection, deviceCode) {
+    const current = selection || emptyDetailSelection()
+    if (!deviceCode) return current
+    return { level: DETAIL_LEVEL.EQUIPMENT, pointCode: current.pointCode || '', deviceCode }
+}
+
+/**
+ * 返回第一级。没有点位可回(未关联设备直接进的第二级)时收成空选择 ——
+ * 「返回点位摘要」不该把用户送到一个并不存在的点位。
+ */
+export function backToPoint(selection) {
+    const current = selection || emptyDetailSelection()
+    if (!current.pointCode) return emptyDetailSelection()
+    return { level: DETAIL_LEVEL.POINT, pointCode: current.pointCode, deviceCode: '' }
+}
+
+/**
+ * 关闭详情:回到空选择(界面据此收掉浮层)。
+ */
+export function closeDetail() {
+    return emptyDetailSelection()
+}
+
+/**
+ * 同一个点位目标是否已经打开(含已经选到设备的那一级)。
+ * store 用它决定「点同一个点位不必再取一次数」,纯函数便于断言。
+ */
+export function isSamePointTarget(selection, pointCode) {
+    const current = selection || emptyDetailSelection()
+    if (!pointCode) return false
+    return current.pointCode === pointCode && current.level !== DETAIL_LEVEL.NONE
+}
+
+/** 后端状态 + 后端标签 → 标签视图;标签优先用后端给的词(前端不另造业务词汇)。 */
+export function statusView(status, labelOverride = '') {
+    const visual = visualForStatus(status)
+    return {
+        key: visual.key,
+        tone: visual.tone,
+        shape: visual.shape,
+        label: labelOverride || visual.label,
+    }
+}
+
+/**
+ * 状态原因 → 一句话。
+ *
+ * 两种形状都吃:点位详情是 `statusReasons[{status,deviceCode,reason}]`,
+ * 设备卡片是 `statusReasons: string[]`。去重保序(后端顺序即优先级顺序,前端不重排)。
+ * 一个原因都没有时回空串 —— 界面显示占位符,**不**写成「正常」。
+ */
+export function reasonText(reasons) {
+    const list = Array.isArray(reasons) ? reasons : []
+    const texts = list
+        .map((item) => (typeof item === 'string' ? item : (item && item.reason) || ''))
+        .map((text) => String(text).trim())
+        .filter(Boolean)
+
+    return [...new Set(texts)].join(';')
+}
+
+/**
+ * 关联设备数量。
+ *
+ * 后端 `deviceStatistics.totalDeviceCount` 是权威值(含「确实 0 台」);
+ * 整段统计缺失(`statistics.missing` 记着,见 `domain/pointDetail.js`)时退回
+ * 「已返回的卡片数」,而不是把「没给统计」显示成 0 台。
+ * @param {object} detail `toMonitoringPointDetail()` 的领域对象
+ */
+export function associatedDeviceCount(detail) {
+    const current = detail || {}
+    const statistics = current.statistics || null
+    const statisticsGiven = statistics && Array.isArray(statistics.missing) && statistics.missing.length === 0
+
+    if (statisticsGiven && Number.isFinite(statistics.total)) return statistics.total
+    return Array.isArray(current.devices) ? current.devices.length : 0
+}
+
+/**
+ * 设备卡片 → 卡片视图。顺序由调用方保持(`deviceCards()` 不排序)。
+ * @param {object} card `toDeviceCard()` 的领域对象
+ */
+export function deviceCardView(card) {
+    const current = card || {}
+    return {
+        code: current.code || DETAIL_PLACEHOLDER,
+        name: current.name || DETAIL_PLACEHOLDER,
+        typeName: current.typeName || '',
+        status: statusView(current.status, current.statusLabel),
+        reason: reasonText(current.statusReasons),
+        statusTime: current.statusTime || '',
+    }
+}
+
+/**
+ * 设备卡片列表 —— **原样保持后端返回顺序**(票 10 验收第 3 条)。
+ * 这里刻意不排序、不按状态优先级重排:顺序是后端的业务语义(谁重要由后端说)。
+ * @param {object} detail `toMonitoringPointDetail()` 的领域对象
+ */
+export function deviceCards(detail) {
+    const devices = (detail && detail.devices) || []
+    return devices.map(deviceCardView)
+}
+
+/**
+ * 点位摘要(第一级的五行):名称 / 编码 / 状态 / 原因 / 关联设备数量。
+ * @param {object} detail `toMonitoringPointDetail()` 的领域对象
+ */
+export function pointSummary(detail) {
+    const current = detail || {}
+    return {
+        name: current.name || DETAIL_PLACEHOLDER,
+        code: current.code || DETAIL_PLACEHOLDER,
+        status: statusView(current.status, current.statusLabel),
+        reason: reasonText(current.statusReasons),
+        deviceCount: associatedDeviceCount(current),
+    }
+}
+
+/** 从已加载的点位详情里取某台设备的卡片视图(第二级的表头用它;找不到回 null)。 */
+export function deviceCardOf(detail, deviceCode) {
+    if (!deviceCode) return null
+    const cards = deviceCards(detail)
+    return cards.find((card) => card.code === deviceCode) || null
+}

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

@@ -0,0 +1,205 @@
+/**
+ * 设备详情聚合领域对象(票 05;消费方是票 11「各分区可独立成功、失败、为空或过期」)。
+ *
+ * 后端形状(`equipment-detail-aggregate-demo.html`、`CockpitEquipmentDetailAggregateResponse`):
+ *   `{ deviceCode, aggregateState, serverTime,
+ *      archive|metrics|warnings|alarms|alarmWarningRelations|workOrders|timelines|latestImage:
+ *        { state: SUCCESS|EMPTY|STALE|FAILED, reason, message, data } }`
+ *
+ * **这是「分区独立」在契约层的原生形态**:每个分区自带 `state` 与 `reason`,
+ * 后端把「档案读取失败但指标仍可用」直接表达出来。所以本层的关键动作是
+ * 「把每个分区翻成一个 `section()`」,而**不是**让整个详情共用一个成败——
+ * 那正是票 05 验收第 6 条与票 11 验收第 3 条要的行为。
+ *
+ * 注意 `STALE` 分区的 `data` 仍带值(后端保留旧值),落进 `section.retained`。
+ */
+import { fieldReader } from './fields.js'
+import { SECTION_STATE, section, sectionStateFromBackend } from './sectionState.js'
+import { toFreshness } from './freshness.js'
+import { toAlarmEvent, toAlarmWarningRelation, toWarningEvent } from './events.js'
+import { toWorkOrder, toWorkOrderTimeline } from './workOrder.js'
+
+/** 聚合响应的分区名(顺序即界面顺序)。 */
+export const EQUIPMENT_SECTIONS = [
+    'archive',
+    'metrics',
+    'warnings',
+    'alarms',
+    'alarmWarningRelations',
+    'workOrders',
+    'timelines',
+    'latestImage',
+]
+
+/** 设备档案 → 领域对象。 */
+export function toEquipmentArchive(raw) {
+    const reader = fieldReader(raw, 'archive')
+    const image = reader.optionalObj('latestImage')
+
+    return {
+        // 编码与名称是档案的标识字段:契约里必有,缺了要记一笔(其余描述性字段允许为空)。
+        code: reader.str('deviceCode'),
+        name: reader.str('deviceName'),
+        model: reader.optionalStr('deviceModel'),
+        spec: reader.optionalStr('deviceSpec'),
+        manufacturer: reader.optionalStr('manufacturer'),
+        typeName: reader.optionalStr('deviceTypeName'),
+        assetValue: reader.optionalNum('assetValue'),
+        expectedServiceYears: reader.optionalNum('expectedServiceYears'),
+        location: reader.optionalStr('equipmentLocation'),
+        ownershipUnit: reader.optionalStr('ownershipUnit'),
+        maintainer: reader.optionalStr('maintainer'),
+        maintainerPhone: reader.optionalStr('maintainerPhone'),
+        installTime: reader.optionalStr('installTime'),
+        remark: reader.optionalStr('remark'),
+        // 票 11:「最新图片只展示后端指定的一张」——后端档案里就是这个单值。
+        image: image ? toDeviceImage(image) : null,
+        missing: reader.missing,
+    }
+}
+
+/** 设备图片(票 11:只认后端给的那一张,前端不挑)。 */
+export function toDeviceImage(raw) {
+    const reader = fieldReader(raw, 'image')
+    return {
+        url: reader.str('url'),
+        capturedAt: reader.optionalStr('capturedAt'),
+        missing: reader.missing,
+    }
+}
+
+/** 指标点位(趋势序列)。 */
+export function toMetricPoint(raw) {
+    const reader = fieldReader(raw, 'metricPoint')
+    return {
+        value: reader.optionalNum('value'),
+        status: reader.optionalStr('status'),
+        sampledAt: reader.optionalStr('sampledAt'),
+        freshness: reader.optionalStr('freshness'),
+    }
+}
+
+/** 单个指标。 */
+export function toMetric(raw) {
+    const reader = fieldReader(raw, 'metric')
+    return {
+        code: reader.str('metricCode'),
+        name: reader.optionalStr('metricName'),
+        // 缺失指标保持 null(票 16:不显示虚假零值);真实 0 会照原样保留。
+        value: reader.optionalNum('value'),
+        unit: reader.optionalStr('unit'),
+        status: reader.optionalStr('status'),
+        sampledAt: reader.optionalStr('sampledAt'),
+        freshness: reader.optionalStr('freshness'),
+        points: reader.optionalList('points').map(toMetricPoint),
+        missing: reader.missing,
+    }
+}
+
+/** 指标分区载荷(票 11 的「最新指标」)。 */
+export function toMetricsPayload(raw) {
+    const reader = fieldReader(raw)
+    return {
+        deviceCode: reader.optionalStr('deviceCode'),
+        state: reader.optionalStr('state'),
+        stateReason: reader.optionalStr('stateReason'),
+        metrics: reader.list('metrics').map(toMetric),
+        missing: reader.missing,
+    }
+}
+
+/**
+ * 把一个后端分区翻成前端分区。
+ * `data` 的转换按分区名分派;`null` 数据(失败/空)不调用转换函数。
+ * @param {string} name EQUIPMENT_SECTIONS 之一
+ * @param {object} raw 后端 `CockpitSectionResult`
+ */
+export function toSection(name, raw) {
+    const reader = fieldReader(raw, name)
+    const state = sectionStateFromBackend(reader.optionalStr('state'))
+    const reason = reader.optionalStr('reason')
+    const message = reader.optionalStr('message')
+    const payload = reader.raw.data
+
+    // 失败/空分区可能仍带 data(后端保留旧值);只有真有值才转换。
+    const hasPayload = payload !== undefined && payload !== null
+    let data = null
+    if (hasPayload) {
+        data = convertSectionData(name, payload)
+    }
+
+    return section(state, {
+        reason: message || reason,
+        data,
+        freshness: readSectionFreshness(name, payload),
+        // 过期分区保留的是旧值:票 16 要求界面带过期标识而不是当实时值。
+        retained: state === SECTION_STATE.STALE,
+    })
+}
+
+function readSectionFreshness(name, payload) {
+    if (name === 'metrics' && payload && typeof payload === 'object') {
+        return String(payload.state || '')
+    }
+    return ''
+}
+
+function convertSectionData(name, payload) {
+    if (name === 'archive') return toEquipmentArchive(payload)
+    if (name === 'metrics') return toMetricsPayload(payload)
+    if (name === 'warnings') return toList(payload, toWarningEvent)
+    if (name === 'alarms') return toList(payload, toAlarmEvent)
+    if (name === 'alarmWarningRelations') return toList(payload, toAlarmWarningRelation)
+    if (name === 'workOrders') return toList(payload, toWorkOrder)
+    if (name === 'timelines') return toList(payload, toWorkOrderTimeline)
+    if (name === 'latestImage') return toDeviceImage(payload)
+    // 后端加了新分区而前端还不认识:原样带出,不静默丢弃。
+    return payload
+}
+
+function toList(payload, mapper) {
+    if (!Array.isArray(payload)) return []
+    return payload.map(mapper)
+}
+
+/**
+ * 设备详情聚合 → 领域对象。每个分区各自成 `section()`,互不牵连。
+ * @param {object} raw 后端 `CockpitEquipmentDetailAggregateResponse`
+ */
+export function toEquipmentDetailAggregate(raw) {
+    const base = fieldReader(raw)
+
+    const sections = {}
+    for (const name of EQUIPMENT_SECTIONS) {
+        sections[name] = toSection(name, base.raw[name])
+    }
+
+    return {
+        deviceCode: base.optionalStr('deviceCode'),
+        // 后端给的整体态:只作为参考带出,界面分区状态以各分区自己的为准
+        // (这样即使整体态漏标,成功分区也不会被拖成不可用)。
+        aggregateState: base.optionalStr('aggregateState'),
+        serverTime: base.optionalStr('serverTime'),
+        sections,
+        missing: base.missing,
+    }
+}
+
+/** 设备新鲜度分区(后端 `freshness` 在独立端点上,不是聚合的一部分)。 */
+export function toDeviceFreshnessSection(raw) {
+    const freshness = toFreshness(raw)
+    const state = !freshness.freshness
+        ? SECTION_STATE.FAILED
+        : freshness.freshness === 'EXPIRED' || freshness.freshness === 'STALE'
+          ? SECTION_STATE.STALE
+          : freshness.freshness === 'EMPTY'
+            ? SECTION_STATE.EMPTY
+            : SECTION_STATE.SUCCESS
+
+    return section(state, {
+        reason: freshness.staleReason,
+        data: freshness,
+        freshness: freshness.freshness,
+        retained: freshness.retained,
+    })
+}

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

@@ -0,0 +1,321 @@
+/**
+ * 设备详情分区的**界面视图**(票 11):把 `domain/equipmentDetail.js` 的领域对象翻成
+ * 组件可以直接画的行/图,不把「怎么显示」散进模板里。
+ *
+ * 为什么单独成一个 domain 模块(而不是写在组件里):
+ *   1. 票 11 的核心不是「画六个框」,而是**分区独立**——某分区失败/为空/过期时,
+ *      其余分区照常画。这条判据必须能脱浏览器断言,所以判定与占位措辞都是纯函数;
+ *   2. 组件若要判断「值缺没缺、空没空、过没过期」,就必然去读后端裸字段
+ *      (票 10 的静态守卫已经禁止过一次),那一层判断就会在模板里长出第二份。
+ *
+ * 两条与票 16 一致的硬约束(在这里收口,组件不再自己判):
+ *   · **缺值显示占位符,不显示虚假零值**:`valueText(0)` 是 `'0'`,`valueText(null)` 是 `'—'`;
+ *   · **过期与空分开**:`STALE` 是有值但过期(内容照画,另带过期标识),
+ *     `EMPTY` 是确实没有值(画占位符)。
+ *
+ * 两处刻意的范围收边(都是「别抢下游票的活」):
+ *   · **只渲染六块面板**(票 11 正文点名的档案 / 指标 / 预警 / 报警 / 工单 / 图片)。
+ *     聚合契约里的 `timelines` 属票 12(工单七态时间线)、`alarmWarningRelations` 属票 13
+ *     (预警-报警关联)——它们仍在 store 的八个分区里,只是不在本票的渲染计划内;
+ *   · 事件只如实列出等级号/状态/时间/量值,**不在这里定事件的四级配色与标签词**:
+ *     那是票 13/19 的视觉语言(`domain/statusTones.js` 的事件四级),本票不预支。
+ */
+import { DETAIL_PLACEHOLDER } from './detailFlow.js'
+import { SECTION_STATE, section } from './sectionState.js'
+import { workOrderStateName } from './workOrder.js'
+
+/**
+ * 六块面板的渲染计划,顺序即界面顺序(与票 11 正文的点名顺序一致)。
+ * `kind` 决定用哪一组视图构造函数 —— 组件据此分派,不在模板里认后端字段。
+ *
+ * `kind` 刻意用自有词(`orders` / `events` / `image`)而不是照抄后端分区名
+ * (`workOrders` / `warnings` / `latestImage`):分区名是**契约**、只该在 `name` 里出现一次,
+ * 组件里若再散落后端键名,票 05 的静态守卫(页面/store 零后端裸字段名)就挡不住真泄漏了。
+ */
+export const EQUIPMENT_SECTION_PANELS = [
+    { name: 'archive', label: '设备档案', kind: 'archive' },
+    { name: 'metrics', label: '最新指标', kind: 'metrics' },
+    { name: 'warnings', label: '预警', kind: 'events' },
+    { name: 'alarms', label: '报警', kind: 'events' },
+    { name: 'workOrders', label: '工单状态', kind: 'orders' },
+    { name: 'latestImage', label: '最新图片', kind: 'image' },
+]
+
+/** 本票的渲染计划**不含**这两个聚合分区(票 12 / 票 13 的域)。 */
+export const DEFERRED_SECTIONS = ['timelines', 'alarmWarningRelations']
+/** 有内容可画的分区状态(`STALE` 是"有值但过期",内容照画)。 */
+const CONTENT_STATES = [SECTION_STATE.SUCCESS, SECTION_STATE.STALE]
+
+/** 该分区有没有内容可画。 */
+export function sectionHasContent(value) {
+    return CONTENT_STATES.includes((value && value.state) || '')
+}
+
+/**
+ * 没有内容时那一行占位说明。
+ * `SUCCESS` 不该走到这里(它一定有内容或有自己的空列表提示),所以回空串。
+ */
+export function sectionPlaceholder(value) {
+    const state = (value && value.state) || SECTION_STATE.FAILED
+
+    if (state === SECTION_STATE.EMPTY) return '该分区当前没有数据'
+    if (state === SECTION_STATE.FAILED) return '该分区加载失败(其它分区不受影响)'
+    if (state === SECTION_STATE.UNAVAILABLE) return '该分区数据不可用(其它分区不受影响)'
+    return ''
+}
+
+/**
+ * 值 → 文本。缺失一律占位符,**绝不**用 0 冒充(票 16 的验收原句);
+ * 反过来后端真给的 0 是有效读数,照原样显示。
+ */
+export function valueText(value) {
+    if (typeof value === 'number' && Number.isFinite(value)) return String(value)
+    if (typeof value === 'string' && value.trim()) return value.trim()
+    return DETAIL_PLACEHOLDER
+}
+
+/** 数值 + 单位(缺值只显示占位符,不显示「— mg/L」这种半截读数)。 */
+export function quantityText(value, unit) {
+    const text = valueText(value)
+    if (text === DETAIL_PLACEHOLDER) return text
+    return unit ? `${text} ${unit}` : text
+}
+
+/**
+ * 收集一段载荷里记下的字段缺失(`domain/fields.js` 的 `missing` 清单)。
+ *
+ * 为什么要递归找:缺失清单挂在**记它的那一层**上——档案挂在 `archive.data.missing`,
+ * 单个指标挂在 `metrics.data.metrics[i].missing`。只读顶层就会漏掉大部分。
+ * 去重保序、带环保护(领域对象是纯数据,但契约缺口可能造成自引用形状)。
+ */
+export function collectMissing(value) {
+    const found = []
+    const seen = new Set()
+
+    const walk = (node) => {
+        if (!node || typeof node !== 'object' || seen.has(node)) return
+        seen.add(node)
+
+        if (Array.isArray(node)) {
+            for (const item of node) walk(item)
+            return
+        }
+
+        if (Array.isArray(node.missing)) {
+            for (const item of node.missing) {
+                if (typeof item === 'string' && item && !found.includes(item)) found.push(item)
+            }
+        }
+
+        for (const key of Object.keys(node)) {
+            if (key === 'missing') continue
+            walk(node[key])
+        }
+    }
+
+    walk(value)
+    return found
+}
+
+/** 档案 → 行视图(键值对)。图片不在这里:它有自己的分区,见 `imageView()`。 */
+export function archiveRows(data) {
+    const archive = data || {}
+
+    return [
+        { key: 'name', label: '名称', value: valueText(archive.name) },
+        { key: 'code', label: '编码', value: valueText(archive.code), mono: true },
+        { key: 'typeName', label: '类型', value: valueText(archive.typeName) },
+        { key: 'model', label: '型号', value: valueText(archive.model) },
+        { key: 'spec', label: '规格', value: valueText(archive.spec) },
+        { key: 'manufacturer', label: '厂商', value: valueText(archive.manufacturer) },
+        { key: 'assetValue', label: '资产原值', value: valueText(archive.assetValue) },
+        { key: 'expectedServiceYears', label: '预计年限', value: valueText(archive.expectedServiceYears) },
+        { key: 'location', label: '安装位置', value: valueText(archive.location) },
+        { key: 'ownershipUnit', label: '产权单位', value: valueText(archive.ownershipUnit) },
+        { 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 || '',
+        sampledAt: metric.sampledAt || '',
+        missing: metric.missing || [],
+    }))
+}
+
+/**
+ * 后端 `stateReason` → 给人读的说明。
+ *
+ * **绝不把代号甩给值班人员**(票 05 的真机走查逮到过一次「界面漏出内部原因码」,
+ * 那是本仓已经付过学费的教训):认得出就翻成人话,认不出**回空串**——
+ * 宁可什么都不说,也不把 `SAMPLE_OUTDATED` 这类接口词汇画在大屏上。
+ * 「过期」这件事本身已由分区状态标签说明,这里只是补充"为什么"。
+ */
+export function metricsSourceText(data) {
+    const dictionary = {
+        READY: '',
+        SAMPLE_OUTDATED: '采样已超过配置周期',
+    }
+    const reason = (data && data.stateReason) || ''
+    return dictionary[reason] || ''
+}
+
+/**
+ * 记录一次「后端没给这些字段」的缺失。
+ *
+ * 界面**只报条数**,字段路径(`archive.deviceName` 这类)进 `fields` 供排查与 tooltip:
+ * 路径是接口契约词汇,直接铺在大屏上是给值班人员看噪音,与上面"不漏原因码"同一条立场。
+ * 排查时仍拿得到完整清单(`title` 属性 / DEV 把手),所以这不是把信息丢掉。
+ */
+export function missingNote(value) {
+    const fields = collectMissing(value)
+    return {
+        count: fields.length,
+        fields,
+        text: fields.length ? `后端未提供 ${fields.length} 项字段` : '',
+        tooltip: fields.join('、'),
+    }
+}
+
+/**
+ * 事件(预警/报警共用)→ 行视图。
+ * 等级只出「N 级」文本与后端色名(挂在 `data-event-level-color` 上供票 13 接管),
+ * 本票不把事件四级配色画出来(那是票 13/19 的域)。
+ */
+export function eventRows(data) {
+    const list = Array.isArray(data) ? data : []
+
+    return list.map((event) => ({
+        code: event.code || DETAIL_PLACEHOLDER,
+        name: event.name || DETAIL_PLACEHOLDER,
+        levelText: typeof event.level === 'number' && Number.isFinite(event.level) ? `${event.level} 级` : DETAIL_PLACEHOLDER,
+        levelColor: event.levelColor || '',
+        status: event.status || '',
+        eventTime: event.eventTime || '',
+        quantity: quantityText(event.value, event.unit),
+        location: event.location || '',
+    }))
+}
+
+/** 工单列表 → 行视图。状态名缺失时按七态字典补(不伪造:字典也认不出就回占位符)。 */
+export function workOrderRows(data) {
+    const list = Array.isArray(data) ? data : []
+
+    return list.map((order) => ({
+        code: order.code || DETAIL_PLACEHOLDER,
+        stateCode: typeof order.stateCode === 'number' ? String(order.stateCode) : '',
+        stateName: order.stateName || workOrderStateName(order.stateCode) || DETAIL_PLACEHOLDER,
+        typeName: order.orderTypeName || '',
+        description: order.description || '',
+        createTime: order.createTime || '',
+        planFinishTime: order.planFinishTime || '',
+        // 未结案时后端给 null("还没有"),与"字段缺失"一样显示占位符。
+        realFinishTime: order.realFinishTime || DETAIL_PLACEHOLDER,
+    }))
+}
+
+/**
+ * 图片 → **单张**视图(票 11 验收第 5 条「最新图片只展示后端指定的一张」)。
+ *
+ * 这里刻意只认一个 `url` 单值:前端不索引数组、不 `find`、不挑"第一张"——
+ * 挑图就是前端替后端做业务判断,而且"挑错了"没人看得出来。后端给几张是后端的事,
+ * 界面只画后端点名的那一张。
+ */
+export function imageView(data) {
+    const image = data || null
+    const url = image && typeof image.url === 'string' ? image.url.trim() : ''
+
+    return {
+        hasImage: !!url,
+        url,
+        capturedAt: (image && image.capturedAt) || DETAIL_PLACEHOLDER,
+    }
+}
+
+/** 分区的空列表提示(后端说 SUCCESS 但列表是空的,仍是"明确 UI"而不是一片空白)。 */
+export function emptyListText(label) {
+    return `${label}当前没有记录`
+}
+
+/** 聚合分区 → 设备编码(判定"已加载的这份是不是当前这台设备")。 */
+export function aggregateDeviceCode(aggregate) {
+    const data = (aggregate && aggregate.data) || null
+    return (data && data.deviceCode) || ''
+}
+
+/**
+ * 已加载的聚合是不是**当前这台设备**的。
+ *
+ * 用它挡住两类错画:请求还没回来时把上一台设备的数据挂在新的表头下;
+ * 以及失败分区(`data` 为 null)被当成"已经取过"而不再重试。
+ */
+export function equipmentDetailMatches(aggregate, deviceCode) {
+    if (!deviceCode) return false
+    if (!aggregate || aggregate.loaded !== true) return false
+    return aggregateDeviceCode(aggregate) === deviceCode
+}
+
+/**
+ * 整个聚合请求本身就没成功(`FAILED` / `UNAVAILABLE`,`data` 为 null)。
+ *
+ * 为什么需要单独一个判据:这种情况下六个分区**全都**没有数据,
+ * 若照常渲染六块面板,每块都会说「该分区加载失败(其它分区不受影响)」——
+ * 那句话在只有一块坏时是真的,在这里是**假的**(六块一起坏了,没有"其它分区"可依赖)。
+ * 谎报"别的还活着"会让值班人员以为还有可用数据,比直接说"这台设备的详情没取到"更坏。
+ *
+ * 注意与「后端把每个分区各自标 FAILED」的区别:后者 `data` 仍在、
+ * `aggregateState: PARTIAL` 且各分区自带 state —— 那才是真正的"部分失败",
+ * 必须照常渲染六块(那是本票验收第 3 条要的形态)。
+ *
+ * **刻意不比对设备编码**:失败分区 `data` 为 null、带不出是哪台设备(见 `aggregateDeviceCode`)。
+ * 而它一定属于**当前**选中那台——store 的 token 纪律保证迟到的响应(含失败)会被丢弃,
+ * 且换设备时 `deviceLoading` 为真、界面先走"加载中"分支,落不到这里。
+ * 所以「失败态 = 当前这台没取到」是构造性的,不需要(也无法)再从载荷里认。
+ * @param {object} aggregate `stores/detailStore.js` 的 `sections.equipmentAggregate`
+ */
+export function aggregateUnavailable(aggregate) {
+    if (!aggregate || aggregate.loaded !== true) return false
+    return [SECTION_STATE.FAILED, SECTION_STATE.UNAVAILABLE].includes(aggregate.state)
+}
+
+/** 取一个聚合子分区;后端整段缺失时给一条失败分区,**不静默当成功**。 */
+function panelSection(sections, name) {
+    const found = sections && sections[name]
+    return found || section(SECTION_STATE.FAILED)
+}
+
+/**
+ * 聚合分区 → 六块面板的渲染计划(组件只需遍历它)。
+ *
+ * 注意**不看** `aggregateState`:后端整体态漏标或标错时,成功分区不能被它拖成不可用
+ * (票 05 的 B13 已定下这条立场,这里是它的界面侧延续)。
+ * @param {object} aggregate `stores/detailStore.js` 的 `sections.equipmentAggregate`
+ */
+export function equipmentPanels(aggregate) {
+    const data = (aggregate && aggregate.data) || null
+    const sections = (data && data.sections) || {}
+
+    return EQUIPMENT_SECTION_PANELS.map((panel) => {
+        const value = panelSection(sections, panel.name)
+        return {
+            name: panel.name,
+            label: panel.label,
+            kind: panel.kind,
+            section: value,
+            hasContent: sectionHasContent(value),
+            missing: missingNote(value.data),
+            placeholder: sectionPlaceholder(value),
+        }
+    })
+}

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

@@ -0,0 +1,151 @@
+/**
+ * 事件领域对象(票 05;消费方是票 13 事件列表、票 14 事件定位、票 19 面板)。
+ *
+ * 为什么预警与报警合成一个词表:后端是两个响应类(`CockpitEventWarning` 用 `warningNo`、
+ * `CockpitEventAlarm` 用 `alarmId`,字段几乎重合),而票 13 要求「预警/报警可切换、列表字段同步变化」、
+ * 票 19 要求「四个面板均支持预警/报警切换」。若前端保留两套字段名,切换就要到处分支。
+ * 这里统一成 `family` + `code`,原始编码另存 `sourceCode` 以便回溯。
+ *
+ * 注意票 13 的边界:「事件不创建独立地图标记」,事件靠**设备编码**回联位置;
+ * 因此这里只带 `deviceCode`,位置由票 14 用设备编码去地图对象里找。
+ */
+import { fieldReader } from './fields.js'
+
+/** 事件家族(票 13 的分段切换键)。 */
+export const EVENT_FAMILY = {
+    WARNING: 'WARNING',
+    ALARM: 'ALARM',
+}
+
+/** 事件等级配色由后端给(`levelColor`:1→RED/2→YELLOW/3→BLUE/4→GREEN);认不出时回空串,不猜。 */
+const KNOWN_LEVEL_COLORS = ['RED', 'YELLOW', 'BLUE', 'GREEN']
+
+function toLevelColor(value) {
+    return KNOWN_LEVEL_COLORS.includes(value) ? value : ''
+}
+
+/**
+ * 一条预警事件 → 领域对象。
+ * @param {object} raw 后端 `CockpitEventWarning`
+ */
+export function toWarningEvent(raw) {
+    const reader = fieldReader(raw, 'warning')
+    return {
+        family: EVENT_FAMILY.WARNING,
+        // 统一键(票 13 列表用),保留后端原名以便回溯。
+        code: reader.str('warningNo'),
+        sourceCode: 'warningNo',
+        name: reader.optionalStr('warningName'),
+        type: reader.optionalStr('warningType'),
+        level: reader.optionalNum('level'),
+        levelColor: toLevelColor(reader.optionalStr('levelColor')),
+        status: reader.optionalStr('status'),
+        eventTime: reader.optionalStr('eventTime'),
+        value: reader.optionalNum('value'),
+        unit: reader.optionalStr('unit'),
+        // 报警才有阈值;预警本来就没有,所以是可选而不是缺失。
+        thresholdMinValue: reader.optionalNum('thresholdMinValue'),
+        thresholdMaxValue: reader.optionalNum('thresholdMaxValue'),
+        deviceCode: reader.optionalStr('deviceCode'),
+        deviceName: reader.optionalStr('deviceName'),
+        // 后端只有文本位置;经纬度见票 14(当前契约缺口,见票 25)。
+        location: reader.optionalStr('location'),
+        longitude: reader.optionalNum('longitude'),
+        latitude: reader.optionalNum('latitude'),
+        missing: reader.missing,
+    }
+}
+
+/**
+ * 一条报警事件 → 领域对象(字段名与预警对齐)。
+ * @param {object} raw 后端 `CockpitEventAlarm`
+ */
+export function toAlarmEvent(raw) {
+    const reader = fieldReader(raw, 'alarm')
+    return {
+        family: EVENT_FAMILY.ALARM,
+        code: reader.str('alarmId'),
+        sourceCode: 'alarmId',
+        name: reader.optionalStr('warningName'),
+        // 报警的 `warningCode` 是告警规则编码,不是预警事件编码:另存以免和 `code` 混。
+        ruleCode: reader.optionalStr('warningCode'),
+        type: reader.optionalStr('warningType'),
+        level: reader.optionalNum('level'),
+        levelColor: toLevelColor(reader.optionalStr('levelColor')),
+        status: reader.optionalStr('status'),
+        eventTime: reader.optionalStr('eventTime'),
+        value: reader.optionalNum('value'),
+        unit: reader.optionalStr('unit'),
+        thresholdMinValue: reader.optionalNum('thresholdMinValue'),
+        thresholdMaxValue: reader.optionalNum('thresholdMaxValue'),
+        deviceCode: reader.optionalStr('deviceCode'),
+        deviceName: reader.optionalStr('deviceName'),
+        location: reader.optionalStr('location'),
+        longitude: reader.optionalNum('longitude'),
+        latitude: reader.optionalNum('latitude'),
+        missing: reader.missing,
+    }
+}
+
+/** 预警-报警关联(后端权威结论,票 13 不自行推断关联)。 */
+export function toAlarmWarningRelation(raw) {
+    const reader = fieldReader(raw, 'relation')
+    return {
+        id: reader.str('relationId'),
+        warningNo: reader.optionalStr('warningNo'),
+        alarmId: reader.optionalStr('alarmId'),
+        relatedAt: reader.optionalStr('relatedAt'),
+        missing: reader.missing,
+    }
+}
+
+/**
+ * 事件读响应 → 领域对象。适用于设备维度与(票 25 补契约后的)跨设备维度。
+ * @param {object} raw 后端 `CockpitEventReadResponse`
+ */
+export function toEventRead(raw) {
+    const reader = fieldReader(raw)
+
+    const warnings = reader.list('warnings').map(toWarningEvent)
+    const alarms = reader.list('alarms').map(toAlarmEvent)
+
+    return {
+        deviceCode: reader.optionalStr('deviceCode'),
+        deviceTypeName: reader.optionalStr('deviceTypeName'),
+        state: reader.optionalStr('state'),
+        stateReason: reader.optionalStr('stateReason'),
+        warnings,
+        alarms,
+        relations: reader.optionalList('alarmWarningRelations').map(toAlarmWarningRelation),
+        // 时间范围(历史流才有;当前流是 null,属正常)。
+        startTime: reader.optionalStr('startTime'),
+        endTime: reader.optionalStr('endTime'),
+        timezone: reader.optionalStr('timezone'),
+        page: reader.optionalNum('page'),
+        pageSize: reader.optionalNum('pageSize'),
+        total: reader.optionalNum('total'),
+        missing: reader.missing,
+    }
+}
+
+/**
+ * 按家族取事件列表(票 13 的分段切换)。
+ * @param {object} read toEventRead() 的结果
+ * @param {string} family EVENT_FAMILY 之一
+ */
+export function eventsOfFamily(read, family) {
+    const current = read || {}
+    if (family === EVENT_FAMILY.ALARM) return current.alarms || []
+    return current.warnings || []
+}
+
+/** 全部事件(先预警后报警,与后端数组顺序一致)。 */
+export function allEvents(read) {
+    const current = read || {}
+    return [...(current.warnings || []), ...(current.alarms || [])]
+}
+
+/** 按时间倒序(票 13「默认展示最新 10-15 条」的排序依据)。 */
+export function sortByEventTimeDesc(events) {
+    return [...(events || [])].sort((a, b) => String(b.eventTime).localeCompare(String(a.eventTime)))
+}

+ 121 - 0
src/modules/cockpit/domain/fields.js

@@ -0,0 +1,121 @@
+/**
+ * 字段读取工具(票 05):适配层把后端裸字段翻成领域对象时,统一走这里。
+ *
+ * 为什么单独抽一层:票 05 验收要求 mock 覆盖「字段缺失」,而缺失有两种处置——
+ *   · 该字段本该有却没有(后端契约缺口/脏数据)→ 记一笔 `missing`,界面显示占位符;
+ *   · 该字段本来就允许为空(`staleReason`、`realFinishTime`)→ 静默给 null,不算问题。
+ * 把这条区分收在一处,转换函数才不会各写一遍「有没有值」的判断。
+ *
+ * 硬约束(票 16 的验收原句「缺失指标显示占位符,不显示虚假零值」):
+ * 缺失一律回 `null`(字符串回 `''`),**绝不**用 0 或 '—' 冒充真实值;
+ * 反过来,后端真给的 0 是有效数据,不得当成缺失。
+ */
+
+function isObject(value) {
+    return !!value && typeof value === 'object' && !Array.isArray(value)
+}
+
+export function isPlainObject(value) {
+    return isObject(value)
+}
+
+/**
+ * 建一个字段读取器。所有 `get` 都基于同一份原始对象,并共用一份 `missing` 清单。
+ * @param {object} source 后端原始对象(允许是 undefined / null / 非对象)
+ * @param {string} [prefix] 记 missing 时的字段名前缀,便于定位嵌套层级
+ */
+export function fieldReader(source, prefix = '') {
+    const raw = isObject(source) ? source : {}
+    const missing = []
+    const name = (key) => (prefix ? `${prefix}.${key}` : key)
+
+    function note(key) {
+        const label = name(key)
+        if (!missing.includes(label)) missing.push(label)
+    }
+
+    /** 必需字符串:空串/空白也记 missing。 */
+    function str(key) {
+        const value = raw[key]
+        if (typeof value === 'string' && value.trim()) return value.trim()
+        note(key)
+        return ''
+    }
+
+    /** 可选字符串:缺失给 '',不记 missing。 */
+    function optionalStr(key) {
+        const value = raw[key]
+        return typeof value === 'string' ? value.trim() : ''
+    }
+
+    /** 必需数字:0 是有效值;非有限数才记 missing 并回 null。 */
+    function num(key) {
+        const value = raw[key]
+        if (typeof value === 'number' && Number.isFinite(value)) return value
+        note(key)
+        return null
+    }
+
+    /** 可选数字:缺失给 null,不记 missing。 */
+    function optionalNum(key) {
+        const value = raw[key]
+        return typeof value === 'number' && Number.isFinite(value) ? value : null
+    }
+
+    /** 可选布尔:缺失给 fallback。 */
+    function bool(key, fallback = false) {
+        const value = raw[key]
+        return typeof value === 'boolean' ? value : fallback
+    }
+
+    /** 数组:非数组记 missing 并回 []。 */
+    function list(key) {
+        const value = raw[key]
+        if (Array.isArray(value)) return value
+        note(key)
+        return []
+    }
+
+    /** 可选数组:缺失给 [],不记 missing(列表端点天然可能没有这一段)。 */
+    function optionalList(key) {
+        const value = raw[key]
+        return Array.isArray(value) ? value : []
+    }
+
+    /** 嵌套对象;缺失记 missing 并回 {}。 */
+    function obj(key) {
+        const value = raw[key]
+        if (isObject(value)) return value
+        note(key)
+        return {}
+    }
+
+    /** 可选嵌套对象;缺失回 null。 */
+    function optionalObj(key) {
+        const value = raw[key]
+        return isObject(value) ? value : null
+    }
+
+    return { raw, missing, str, optionalStr, num, optionalNum, bool, list, optionalList, obj, optionalObj }
+}
+
+/**
+ * 把后端坐标校验结果翻成领域 `location`(票 07 的输入)。
+ *
+ * 注意分工:这里**只做字段改名与取值**,不发明校验规则——「什么算非法坐标」由后端判定
+ * (票 07 的验收也写明「前端不自动纠偏、不使用默认坐标」)。后端没给这一段时,
+ * `renderable:false` 且原因为空,交给 07 按「未校验」处置。
+ * @param {object} raw 后端 `coordinateValidation`
+ */
+export function toLocation(raw) {
+    const reader = fieldReader(raw, 'coordinateValidation')
+    const hasValidation = isObject(raw)
+    return {
+        state: reader.optionalStr('state'),
+        reason: reader.optionalStr('reason'),
+        crs: reader.optionalStr('crs'),
+        // 后端明确说不可绘制才不上图;整段缺失时不假装可绘制(票 07 接着判)。
+        renderable: hasValidation ? reader.bool('renderable', false) : false,
+        validated: hasValidation,
+    }
+}

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

@@ -0,0 +1,84 @@
+/**
+ * 新鲜度领域对象(票 05 交字段与词汇,票 16 交渲染与轮询判定)。
+ *
+ * 后端形状(`freshness-semantics-demo.html`、`CockpitFreshnessResponse`):
+ *   `{ updatedAt, statusTime, serverUpdatedAt, freshness: CURRENT|STALE|EXPIRED|EMPTY,
+ *      staleReason, calibrationPeriodSeconds, missedCycles, dataAgeSeconds, retained, message }`
+ *
+ * 票 16 的验收原句「连续两个配置周期未成功更新时显示过期或不可用」对应 `missedCycles >= 2`,
+ * 本层把这条**判据**定下来(纯函数、可断言),但「什么时候去拉、轮询还是推送」归票 15/16。
+ *
+ * 硬约束(票 16「缺失指标显示占位符,不显示虚假零值」):
+ * 时间为 null 时保持 null,界面显示占位符;`dataAgeSeconds` 缺失也不回 0
+ * (0 秒会被读成「刚刚更新过」,比空白更误导)。
+ */
+import { fieldReader } from './fields.js'
+
+/** 后端新鲜度四态。 */
+export const FRESHNESS = {
+    CURRENT: 'CURRENT',
+    STALE: 'STALE',
+    EXPIRED: 'EXPIRED',
+    EMPTY: 'EMPTY',
+}
+
+/** 判定为「已过期」所需的未更新周期数(票 16 的「连续两个配置周期」)。 */
+export const EXPIRED_MISSED_CYCLES = 2
+
+/**
+ * 新鲜度响应 → 领域对象。
+ * @param {object} raw 后端 `CockpitFreshnessResponse`
+ */
+export function toFreshness(raw) {
+    const reader = fieldReader(raw)
+
+    return {
+        deviceCode: reader.optionalStr('deviceCode'),
+        // 三个时间各自独立:缺失就是 null(界面显示占位符)。
+        updatedAt: reader.optionalStr('updatedAt'),
+        statusTime: reader.optionalStr('statusTime'),
+        serverUpdatedAt: reader.optionalStr('serverUpdatedAt'),
+        freshness: reader.optionalStr('freshness'),
+        staleReason: reader.optionalStr('staleReason'),
+        calibrationPeriodSeconds: reader.optionalNum('calibrationPeriodSeconds'),
+        missedCycles: reader.optionalNum('missedCycles'),
+        dataAgeSeconds: reader.optionalNum('dataAgeSeconds'),
+        // 「保留旧数据」:这是过期与不可用的分界(票 16 要求保留但不伪装成实时)。
+        retained: reader.bool('retained', false),
+        message: reader.optionalStr('message'),
+        missing: reader.missing,
+    }
+}
+
+/**
+ * 是否已过期。优先用后端给的 `freshness`,缺失时才按 `missedCycles` 回退判定
+ * ——前端不推翻后端结论,只在后端没给结论时兜底。
+ * @param {object} freshness toFreshness() 的结果
+ */
+export function isExpired(freshness) {
+    const current = freshness || {}
+    if (current.freshness === FRESHNESS.EXPIRED) return true
+    if (current.freshness === FRESHNESS.CURRENT || current.freshness === FRESHNESS.STALE) return false
+    if (typeof current.missedCycles === 'number') return current.missedCycles >= EXPIRED_MISSED_CYCLES
+    return false
+}
+
+/** 是否还有可用值(过期但保留旧值仍算「有值」)。 */
+export function hasValue(freshness) {
+    const current = freshness || {}
+    if (current.freshness === FRESHNESS.EMPTY) return false
+    if (current.retained) return true
+    return !!current.updatedAt
+}
+
+/**
+ * 数据年龄文案。缺失时给占位符而不是「0 秒」。
+ * @param {object} freshness toFreshness() 的结果
+ */
+export function dataAgeText(freshness) {
+    const age = (freshness && freshness.dataAgeSeconds)
+    if (typeof age !== 'number' || !Number.isFinite(age) || age < 0) return '—'
+    if (age < 60) return `${Math.round(age)} 秒前`
+    if (age < 3600) return `${Math.round(age / 60)} 分钟前`
+    return `${Math.round(age / 3600)} 小时前`
+}

+ 95 - 0
src/modules/cockpit/domain/mapLayers.js

@@ -0,0 +1,95 @@
+/**
+ * 地形与叠加影像的计划(票 21):把 `map.terrain` / `map.overlays` 翻译成引擎要的东西。
+ *
+ * 纯函数、不 import cesium,和 domain/basemap.js 一样:逻辑脚本能脱浏览器把「配了 / 没配 / 配错」测到,
+ * 引擎侧(services/cesiumEngine.js)只负责照着建 provider。
+ *
+ * 本机数据的形态(维护者 2026-09-17 提供,供对照):
+ *   · `D:\GIS\GIS\yuanlingDEM` —— cesiumlab 导出的量化网格地形:`layer.json`
+ *     (`format: quantized-mesh-1.0`、`projection: EPSG:4326`、`scheme: tms`、`extensions: ["octvertexnormals"]`、
+ *     `maxzoom: 14`、`valid_bounds` ≈ 沅陵县域 110.00–111.00E / 27.99–29.00N)+ `{z}/{x}/{y}.terrain`。
+ *     地形**不用**在服务端翻 y:Cesium 的 CesiumTerrainProvider 认 layer.json 的 `scheme: "tms"`。
+ *   · `D:\GIS\GIS\yuanlingPic3` —— cesiumlab 导出的影像切片:`meta.json` / `tilemapresource.xml`
+ *     写着 `proj: 3857`、`tiletrans: "tms"`、`minzoom: 0`、`maxzoom: 19`、256px PNG。
+ *     TMS 的 y 自下而上,所以叠加层的 URL 模板要用 Cesium 的 `{reverseY}` 占位符。
+ */
+
+export const TERRAIN_STATE = {
+    NONE: 'none',
+    READY: 'ready',
+    INVALID: 'invalid',
+}
+
+/**
+ * 地形计划。
+ * @param {object} mapConfig 归一化后的 `config.map`
+ * @returns {{state: string, ready: boolean, url: string, requestVertexNormals: boolean, issue: string}}
+ */
+export function planTerrain(mapConfig = {}) {
+    const terrain = (mapConfig && mapConfig.terrain) || {}
+    if (!terrain.url) {
+        return { state: TERRAIN_STATE.NONE, ready: false, url: '', requestVertexNormals: false, issue: '' }
+    }
+    return {
+        state: TERRAIN_STATE.READY,
+        ready: true,
+        url: terrain.url,
+        // cesiumlab 的地形带 octvertexnormals,默认要顶点法线(有光照才立体)。
+        requestVertexNormals: terrain.requestVertexNormals !== false,
+        issue: '',
+    }
+}
+
+/**
+ * 叠加影像计划(画在底图之上的本地精细影像;可多个,按数组顺序从下往上叠)。
+ * @param {object} mapConfig 归一化后的 `config.map`
+ */
+export function planOverlays(mapConfig = {}) {
+    const overlays = Array.isArray(mapConfig && mapConfig.overlays) ? mapConfig.overlays : []
+    return overlays
+        .filter((item) => item && typeof item.url === 'string' && item.url)
+        .map((item) => ({
+            url: item.url,
+            minimumLevel: Number.isInteger(item.minimumLevel) ? item.minimumLevel : null,
+            maximumLevel: Number.isInteger(item.maximumLevel) ? item.maximumLevel : null,
+            // 本地切片只覆盖一小片区域:声明 bounds 后 Cesium 不会去请求范围外的瓦片
+            // (否则整球视角会刷一屏 404,走查的「无页面级报错」也会被误伤)。
+            bounds: Array.isArray(item.bounds) && item.bounds.length === 4 ? item.bounds : null,
+            credit: item.credit || '',
+        }))
+}
+
+/** 引擎浮标上的地形措辞。 */
+export function terrainStatusText(plan) {
+    const current = plan || planTerrain({})
+    if (current.ready) return `地形:${current.url.split('/').filter(Boolean).pop() || '已配置'}`
+    return '地形:椭球(未配 DEM)'
+}
+
+/**
+ * 3D Tiles / 倾斜摄影计划(票 21):每个条目一个 tileset URL。
+ *
+ * 入口先留好:维护者本机的 `D:\GIS\GIS\yuanlingSHP` 是 cesiumlab `model2tiles` 导出
+ * (16–20 级 769 个分块 `*.json` + 772 个 `*.glb`),但**缺根 `tileset.json`**,
+ * 且分块 `boundingVolume.box` 是局部坐标(例:中心 6267, 2293 —— 不是 ECEF,需要根节点 `transform`
+ * 才能落到地球上)。所以本机这份数据要能看见,得满足其一:
+ *   · 用 cesiumlab 重新导出并勾选生成根 tileset(推荐,最省事);
+ *   · 或给出模型原点的经纬度,据此补一个带 `transform` 的根 `tileset.json`。
+ * 在本机数据补齐之前,本入口用其它 tileset(别的工程的 `tileset.json`)也能直接用。
+ * @param {object} mapConfig 归一化后的 `config.map`
+ */
+export function planTilesets(mapConfig = {}) {
+    const tilesets = Array.isArray(mapConfig && mapConfig.tilesets) ? mapConfig.tilesets : []
+    return tilesets
+        .filter((item) => item && typeof item.url === 'string' && item.url)
+        .map((item) => ({
+            url: item.url,
+            maximumScreenSpaceError: Number.isFinite(item.maximumScreenSpaceError) ? item.maximumScreenSpaceError : null,
+            credit: item.credit || '',
+        }))
+}
+
+/** 引擎浮标上的 3D Tiles 措辞。 */
+export function tilesetStatusText(count) {
+    return count > 0 ? `3D Tiles:${count} 个` : ''
+}

+ 419 - 0
src/modules/cockpit/domain/mapSummary.js

@@ -0,0 +1,419 @@
+/**
+ * 地图摘要领域对象(票 05;消费方是票 08 分页与票 07 坐标校验)。
+ *
+ * 后端原始形状(`CockpitMapSnapshotResponse` 的一页,见 `docs/cockpit/map-snapshot-demo.html`):
+ *   `{ page, pageSize, snapshotAt, batchUpdatedAt, total, complete,
+ *      monitoringPoints: [{ pointCode, pointName, pointStatus, statusReasons[], longitude, latitude, coordinateValidation }],
+ *      unassociatedEquipment: [{ deviceCode, deviceName, longitude, latitude, coordinateValidation }] }`
+ *
+ * 本文件**只做改名与取值**:坐标是否上图由后端 `coordinateValidation` 决定(票 07 不重算),
+ * 点位状态是后端解析后的结果(CONTEXT.md「点位状态」:前端只渲染,不重算)。
+ * 两种地图对象合成同一个 `kind` 词表,地图与列表因此不必各写一套分支。
+ */
+import { fieldReader, toLocation } from './fields.js'
+import { splitByCoordinate, toCoordinateSummary } from './coordinate.js'
+
+/** 地图对象的两种来源(CONTEXT.md:监测点优先作为可点击对象,未关联设备是设备)。 */
+export const MAP_OBJECT_KIND = {
+    MONITORING_POINT: 'monitoring-point',
+    UNASSOCIATED_EQUIPMENT: 'unassociated-equipment',
+}
+
+/**
+ * 监测点摘要 → 地图对象。
+ * @param {object} raw 后端 `CockpitMonitoringPointSummary`
+ */
+export function toMonitoringPoint(raw) {
+    const reader = fieldReader(raw, 'monitoringPoint')
+    // 坐标按契约是必给的:缺失要记一笔(票 07 据此区分「缺失」与「非法」),值仍保持 null。
+    const longitude = reader.num('longitude')
+    const latitude = reader.num('latitude')
+
+    return {
+        kind: MAP_OBJECT_KIND.MONITORING_POINT,
+        code: reader.str('pointCode'),
+        name: reader.str('pointName'),
+        // 后端解析并发布的点位状态:前端不重算(CONTEXT.md)。
+        status: reader.optionalStr('pointStatus'),
+        statusReasons: reader.optionalList('statusReasons').map((item) => String(item)),
+        longitude,
+        latitude,
+        coordinate: toLocation(raw && raw.coordinateValidation),
+        missing: reader.missing,
+    }
+}
+
+/**
+ * 未关联设备摘要 → 地图对象。
+ * @param {object} raw 后端 `CockpitUnassociatedEquipmentSummary`
+ */
+export function toUnassociatedEquipment(raw) {
+    const reader = fieldReader(raw, 'unassociatedEquipment')
+    const longitude = reader.num('longitude')
+    const latitude = reader.num('latitude')
+
+    return {
+        kind: MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT,
+        code: reader.str('deviceCode'),
+        name: reader.str('deviceName'),
+        // 未关联设备没有点位状态:留空而不是借用设备的在线态(0/NORMAL 都算伪造)。
+        status: '',
+        statusReasons: [],
+        longitude,
+        latitude,
+        coordinate: toLocation(raw && raw.coordinateValidation),
+        missing: reader.missing,
+    }
+}
+
+/**
+ * 一页地图摘要 → 领域批次。快照标记原样带出,供票 08 保证「同一次加载用同一快照」。
+ * @param {object} raw 后端一页
+ */
+export function toMapBatch(raw) {
+    const reader = fieldReader(raw)
+
+    const points = reader.list('monitoringPoints').map(toMonitoringPoint)
+    const equipment = reader.list('unassociatedEquipment').map(toUnassociatedEquipment)
+
+    return {
+        snapshotAt: reader.optionalStr('snapshotAt'),
+        batchUpdatedAt: reader.optionalStr('batchUpdatedAt'),
+        page: reader.num('page'),
+        pageSize: reader.num('pageSize'),
+        total: reader.num('total'),
+        complete: reader.bool('complete', false),
+        points,
+        equipment,
+        // 票 07:后端每页都带**全量**坐标校验汇总(`CockpitCoordinateValidationSummary`,
+        // `CockpitMapSnapshotService` 用整份快照算完再切片),如实带出供界面报「全城多少点位没上图」。
+        coordinateSummary: toCoordinateSummary(raw && raw.coordinateValidation),
+        // 分页窗口横跨两类对象(后端是一条总序列),所以批次对外也给一条合起来的序列:
+        // 装载器与地图不必各自记得「先监测点后未关联设备」。
+        objects: [...points, ...equipment],
+        missing: reader.missing,
+    }
+}
+
+/** 批次里的全部地图对象,监测点在前(与后端数组顺序一致)。 */
+export function batchObjects(batch) {
+    const current = batch || {}
+    return [...(current.points || []), ...(current.equipment || [])]
+}
+/**
+ * 坐标校验汇总(票 07 的计数依据):只数,不判——判定权在后端。
+ * @param {Array} objects 地图对象数组
+ */
+export function summarizeCoordinateValidation(objects) {
+    const summary = { total: 0, renderable: 0, blocked: 0, states: {} }
+
+    for (const item of objects || []) {
+        summary.total += 1
+        const validation = (item && item.coordinate) || {}
+        const state = validation.state || 'UNVERIFIED'
+        summary.states[state] = (summary.states[state] || 0) + 1
+        if (validation.renderable) summary.renderable += 1
+        else summary.blocked += 1
+    }
+
+    return summary
+}
+
+// ================================================================ 分页与快照(票 08)
+
+/**
+ * 地图摘要渐进加载的六个演示态(票 08 验收第 5 条:加载中/完成/失败/快照过期都要能演示)。
+ *
+ * 注意 `EMPTY` 与 `COMPLETE` 的区别:后端说「总数 0」时是**确实没有对象**(显示「没有可上图对象」),
+ * 而 `COMPLETE` 是**确实加载完了**(哪怕只加载到 1 个对象)。二者都不能伪装成对方。
+ */
+export const MAP_SUMMARY_PHASE = {
+    IDLE: 'idle',
+    LOADING: 'loading',
+    COMPLETE: 'complete',
+    EMPTY: 'empty',
+    FAILED: 'failed',
+    EXPIRED: 'expired',
+}
+
+/** 「不用再翻了」的两态:界面据此禁用「加载下一页」。 */
+export const MAP_SUMMARY_SETTLED_PHASES = [MAP_SUMMARY_PHASE.COMPLETE, MAP_SUMMARY_PHASE.EMPTY]
+
+/**
+ * 长跑护栏:地图摘要是全量点位的分页序列,理论上可以无限翻。
+ * 合并态只保留最近这么多个批次与对象——它服务于「一张图当前要画什么」,
+ * 不是台账(全量列表归票 10/18 的列表端点)。
+ */
+export const MAP_SUMMARY_LIMITS = {
+    maxBatches: 200,
+    maxObjects: 2000,
+}
+
+/** 合并态的初值:还没取过任何一页。 */
+export function emptyMapSummary(extra = {}) {
+    return {
+        phase: MAP_SUMMARY_PHASE.IDLE,
+        snapshot: '',
+        batchUpdatedAt: '',
+        // `page` = 已成功合并的最后一页;`nextPage` = 下一次要请求的页。
+        // 分开是为了让「失败后重试同一页」既真实(page 不前进)又能显示正在取第几页。
+        page: 0,
+        nextPage: 1,
+        pageSize: 0,
+        total: 0,
+        complete: false,
+        loaded: 0,
+        batches: [],
+        objects: [],
+        missing: [],
+        reason: '',
+        // 票 07:后端**全量**坐标校验汇总(每页都回同一份口径,取最新那次响应即可,不做累加)。
+        coordinateSummary: toCoordinateSummary(null),
+        ...extra,
+    }
+}
+
+/** 地图对象的去重键:`kind` + 编码(两张表各自的编码空间是独立的)。 */
+function objectKey(item) {
+    return `${(item && item.kind) || ''}|${(item && item.code) || ''}`
+}
+
+/**
+ * 把一页摘要并进当前合并态(纯函数,票 08 的核心逻辑)。
+ *
+ * 三条语义来自后端实现(`CockpitMapSnapshotService`)而不是猜测:
+ *   1. **一页是一个快照内的切片**:分页窗口横跨「监测点 + 未关联设备」这一条总序列,
+ *      所以同一页里两类对象可能同时出现,跨页拼接不需要按类分别翻页;
+ *   2. **快照标记取自第一页的响应**,之后的每一页都带着它请求(一致性由装载器保证);
+ *   3. 合并态**从空态重来**即为「换快照」:`prev = null` 时旧快照的对象不会残留
+ *      (票 08 验收第 3 条「同一次加载保持快照一致」的另一半:刷新就是换一份数据)。
+ *
+ * @param {object|null} prev 上一次的合并态(`null` = 新一次加载)
+ * @param {object} page 一页领域批次:`{ snapshotAt, batchUpdatedAt, total, complete, objects, missing }`
+ */
+export function mergeMapPage(prev, page = {}) {
+    const base = prev && prev.objects ? prev : emptyMapSummary()
+    const incoming = Array.isArray(page.objects) ? page.objects : []
+
+    // 去重按 kind+code:后端重放同一页、或第 2 页与第 1 页有重叠切片时,
+    // 同一个对象不得在地图上画两遍。没有编码的脏对象不参与去重(宁可多画一个,也不吞掉)。
+    const seen = new Set(base.objects.map(objectKey))
+    const fresh = incoming.filter((item) => {
+        const key = objectKey(item)
+        if (key.endsWith('|')) return true
+        if (seen.has(key)) return false
+        seen.add(key)
+        return true
+    })
+
+    const objects = [...base.objects, ...fresh]
+    const batches = [...base.batches, { page: page.page, loaded: fresh.length, total: page.total }]
+
+    let reason = base.reason
+    if (!page.complete) reason = ''
+
+    const loadedPage = Number.isFinite(page.page) ? page.page : base.page + 1
+
+    return {
+        ...base,
+        // 快照标记以「本次加载的第一次响应」为准;后续页带着同一个标记请求,正常不会变。
+        snapshot: base.snapshot || page.snapshotAt || '',
+        batchUpdatedAt: page.batchUpdatedAt || base.batchUpdatedAt,
+        page: loadedPage,
+        // 下一页总是「刚合并的那一页 + 1」:后端的分页是切片序列,没有别的算法。
+        nextPage: loadedPage + 1,
+        pageSize: Number.isFinite(page.pageSize) ? page.pageSize : base.pageSize,
+        total: Number.isFinite(page.total) ? page.total : base.total,
+        complete: page.complete === true,
+        loaded: objects.length,
+        batches: batches.slice(-MAP_SUMMARY_LIMITS.maxBatches),
+        objects: objects.slice(-MAP_SUMMARY_LIMITS.maxObjects),
+        missing: [...base.missing, ...((page && page.missing) || [])],
+        // 票 07:后端这份汇总是**全量**口径(不是本页切片),每页都回同一个数,
+        // 所以取最新那次响应即可 —— 累加会把它翻成「页数 × 全量」的荒唐值。
+        coordinateSummary: page.coordinateSummary && page.coordinateSummary.available
+            ? page.coordinateSummary
+            : base.coordinateSummary,
+        reason,
+    }
+}
+
+/**
+ * 合并态 → 给人读的演示文案(票 08 验收第 5 条)。
+ *
+ * 和 `domain/sectionState.js` 的 `sectionView` 同一立场:**内部原因码不上界面**,
+ * 认不出的原因宁可只说「加载失败」,也不把 `snapshot-expired` 这类接口词汇甩给值班人员。
+ *
+ * 票 07 修正了一处**会骗人的口径**:`loaded` 是「已加载对象数」(含不上图的对象),
+ * 而这里的措辞原本是「已上图 N 个」——票 07 起有对象因坐标被拦下,这两个数就不再相等,
+ * 照旧写法会把 5 个说成上图的 5 个(实际只有 3 个画得出来)。
+ * 所以:调用方给了 `objects` 时按坐标准入分开报,没给(如纯文案单测)时保持原措辞。
+ * @param {object} state 装载器的 `getState()`
+ */
+export function summaryView(state) {
+    const current = state || emptyMapSummary()
+    const phase = current.phase || MAP_SUMMARY_PHASE.IDLE
+    const pages = current.batches ? current.batches.length : 0
+    const onMap = onMapCount(current)
+
+    if (phase === MAP_SUMMARY_PHASE.LOADING) {
+        const attempt = current.attemptPage || current.nextPage || 1
+        return { phase, tone: 'lv2', label: `地图摘要加载中 · 第 ${attempt} 页 · ${onMap.label}`, detail: '' }
+    }
+    if (phase === MAP_SUMMARY_PHASE.COMPLETE) {
+        return { phase, tone: 'lv4', label: `地图摘要加载完成 · ${onMap.label}`, detail: '' }
+    }
+    if (phase === MAP_SUMMARY_PHASE.EMPTY) {
+        return { phase, tone: 'neutral', label: '地图摘要无数据 · 当前范围没有可上图对象', detail: '' }
+    }
+    if (phase === MAP_SUMMARY_PHASE.FAILED) {
+        return { phase, tone: 'lv1', label: `地图摘要加载失败 · ${onMap.label}`, detail: '刷新或稍后重试' }
+    }
+    if (phase === MAP_SUMMARY_PHASE.EXPIRED) {
+        // 「同一次加载保持快照一致」的反面:后端换了快照,已加载的点属于旧快照。
+        return { phase, tone: 'lv1', label: '地图摘要快照已失效 · 请刷新', detail: '后端数据已更新,重新加载会切换到新快照' }
+    }
+    return {
+        phase,
+        tone: 'accent',
+        label: pages ? `地图摘要就绪 · 已加载 ${pages} 页` : '地图框架已就绪 · 等待分页数据',
+        detail: '',
+    }
+}
+
+/**
+ * 「已上图」的计数口径(票 07):只认坐标准入通过的对象。
+ *
+ * 调用方没给 `objects` 时退回票 08 的原口径(`loaded`)——那是纯文案单测的用法,
+ * 没有对象可判;给了就必须分开报,否则「已上图」这个词就是假的。
+ * @param {object} state 装载器状态
+ */
+function onMapCount(state) {
+    if (!Array.isArray(state.objects) || !state.objects.length) {
+        return { label: `已上图 ${state.loaded} 个`, renderable: state.loaded, blocked: 0 }
+    }
+
+    const { renderable, blocked } = splitByCoordinate(state.objects)
+    const label = blocked.length
+        ? `已上图 ${renderable.length} 个 · 无地图位置 ${blocked.length} 个`
+        : `已上图 ${renderable.length} 个`
+    return { label, renderable: renderable.length, blocked: blocked.length }
+}
+
+/**
+ * 快照失效判据(唯一入口):本次加载锁定的快照与后端这一页回的快照不一致时必须停下。
+ * 拿它去判「后端换了数据」,也就不会再出现「两个快照的点位拼在同一张地图上」。
+ * @param {string} locked 本次加载锁定的快照标记(第 1 页响应给的)
+ * @param {string} incoming 本页响应回的快照标记
+ */
+export function isSnapshotChanged(locked, incoming) {
+    if (!locked || !incoming) return false
+    return locked !== incoming
+}
+
+/**
+ * 快照失效时的状态收敛(纯函数):不再发请求,也不再合并,已加载的对象保持原样
+ * (界面措辞是「请刷新」,而不是「已加载的点没了」)。
+ * @param {object} state 当前合并态
+ */
+export function expireMapSummary(state) {
+    const current = state || emptyMapSummary()
+    return { ...current, phase: MAP_SUMMARY_PHASE.EXPIRED, reason: 'snapshot-expired' }
+}
+
+/**
+ * 地图摘要分页装载器(票 08):渐进加载的状态机,一次只往前翻一页。
+ *
+ * 为什么是「装载器 + 注入取数函数」而不是直接在服务里翻页:
+ *   · 快照一致性是**状态**(第 1 页锁定、后续页带着它请求),必须有地方持有;
+ *   · 纯函数 + 注入让脱浏览器的逻辑脚本能完整演示六态(见 verify/t08-map-summary.mjs),
+ *     浏览器侧只是把同一个装载器的状态画出来(`stores/mapStore.js` 持有实例)。
+ *
+ * 硬约束(票 08 验收第 2、3 条):
+ *   · 分页与快照参数**由本层拼**(调用方只说「下一页」),组件与页面不出现 `page`/`snapshotAt`;
+ *   · 第 2 页起必须带第 1 页锁定的 `snapshotAt`(后端 `validatePagination` 的硬要求);
+ *   · 后端回的快照与锁定值不一致时立刻进 `expired`,不再往下翻。
+ *
+ * @param {object} options
+ * @param {(query: {page: number, pageSize: number, snapshotAt?: string}) => Promise<object>} options.fetchPage
+ *        取一页;返回 `{ ok, data: { snapshotAt, total, complete, objects, missing }, reason }`
+ * @param {number} [options.pageSize] 每页条数(后端默认 500、上限 1000)
+ */
+export function createMapSummaryLoader(options = {}) {
+    const fetchPage = options.fetchPage
+    const pageSize = Number.isFinite(options.pageSize) ? options.pageSize : 500
+
+    let state = emptyMapSummary({ pageSize })
+    let token = 0
+
+    function readState() {
+        return {
+            ...state,
+            batches: state.batches.map((item) => ({ ...item })),
+            objects: [...state.objects],
+            missing: [...state.missing],
+        }
+    }
+
+    /** 翻下一页。完成态下是空操作;失败/失效后可以再次调用(失败会重试同一页)。 */
+    async function loadNext() {
+        if (!fetchPage) return readState()
+        if (state.phase === MAP_SUMMARY_PHASE.COMPLETE || state.phase === MAP_SUMMARY_PHASE.EMPTY) return readState()
+
+        const wanted = state.nextPage
+        // 同一批加载里的后续页必须带第 1 页锁定的快照;第一页不带(后端据此开新快照)。
+        const query = { page: wanted, pageSize }
+        if (state.snapshot) query.snapshotAt = state.snapshot
+
+        // 并发点两次「加载下一页」不得重复翻页:只认最后一次(token 大的那次)。
+        token += 1
+        const mine = token
+        state = { ...state, phase: MAP_SUMMARY_PHASE.LOADING, attemptPage: wanted, reason: '' }
+
+        // 取数函数自己抛错(网络层意外)也要收敛成失败态:装载器绝不能卡在 loading。
+        let result = null
+        try {
+            result = await fetchPage(query)
+        } catch (error) {
+            result = { ok: false, reason: 'unreachable', error: String((error && error.message) || error) }
+        }
+        if (mine !== token) return readState()
+
+        if (!result || result.ok !== true) {
+            const reason = (result && result.reason) || 'unavailable'
+            if (reason === 'snapshot-expired') {
+                state = expireMapSummary(state)
+                return readState()
+            }
+            // 失败保持 page/nextPage 不动 = 下次点重试同一页,不静默丢一页数据。
+            state = { ...state, phase: MAP_SUMMARY_PHASE.FAILED, reason }
+            return readState()
+        }
+
+        const data = result.data || {}
+        if (isSnapshotChanged(state.snapshot, data.snapshotAt)) {
+            state = expireMapSummary(state)
+            return readState()
+        }
+
+        state = mergeMapPage(state, { ...data, page: wanted, pageSize: data.pageSize || pageSize })
+        if (state.complete) {
+            state = { ...state, phase: state.loaded === 0 ? MAP_SUMMARY_PHASE.EMPTY : MAP_SUMMARY_PHASE.COMPLETE }
+        } else {
+            state = { ...state, phase: MAP_SUMMARY_PHASE.IDLE }
+        }
+        // 合并成功后 attemptPage 与 page 一致;留着它只为读状态时少一层判断。
+        return readState()
+    }
+
+    /**
+     * 刷新:丢掉当前快照,从第 1 页重新加载(下一次请求不带 `snapshotAt`,后端据此给新快照)。
+     * 旧快照的对象不再保留——留着就是两个快照的数据混在一张图上。
+     */
+    async function refresh() {
+        state = emptyMapSummary({ pageSize })
+        return loadNext()
+    }
+
+    return { loadNext, refresh, getState: readState }
+}

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

@@ -0,0 +1,107 @@
+/**
+ * 监测点详情领域对象(票 05;消费方是票 10 两级详情、票 09 状态图例)。
+ *
+ * 后端形状(`monitoring-point-detail-demo.html`、`CockpitMonitoringPointDetailResponse`):
+ *   `{ monitoringPointCode, monitoringPointName, pointStatus, statusLabel, statusReasons[{status,deviceCode,reason}],
+ *      deviceStatistics{...}, devices[{deviceCode,deviceName,deviceTypeName,deviceStatus,statusLabel,statusReasons[],statusTime}],
+ *      coordinateValidation, statusDictionary[{status,label,priority}] }`
+ *
+ * 票 10 的验收原句「设备卡片顺序由后端返回顺序决定」→ 这里**不排序** `devices`,原样带出。
+ * 票 09「有状态到视觉表现的单测」用的是 `statusDictionary`(后端给优先级),前端不自己排优先级。
+ */
+import { fieldReader, isPlainObject, toLocation } from './fields.js'
+
+/**
+ * 关联设备卡片 → 领域对象。
+ * @param {object} raw 后端 `CockpitMonitoringPointDeviceCard`
+ */
+export function toDeviceCard(raw) {
+    const reader = fieldReader(raw, 'deviceCard')
+    return {
+        code: reader.str('deviceCode'),
+        name: reader.optionalStr('deviceName'),
+        typeName: reader.optionalStr('deviceTypeName'),
+        status: reader.optionalStr('deviceStatus'),
+        statusLabel: reader.optionalStr('statusLabel'),
+        statusReasons: reader.optionalList('statusReasons').map((item) => String(item)),
+        statusTime: reader.optionalStr('statusTime'),
+        missing: reader.missing,
+    }
+}
+
+/**
+ * 设备计数徽标。计数是真实值,缺失补 0(0 = 确实没有该状态的设备)。
+ *
+ * **整段统计缺失时记一笔 `missing`**(票 10 补):`missing` 为空才表示「后端确实给了统计」,
+ * 消费方(`domain/detailFlow.js` 的关联设备数量)据此区分「0 台设备」与「没给统计」——
+ * 否则界面会把后端的契约缺口画成「0 台」,那是票 16 明令禁止的虚假零值。
+ */
+export function toDeviceStatistics(raw) {
+    const reader = fieldReader(raw, 'deviceStatistics')
+    if (!isPlainObject(raw)) {
+        return {
+            total: 0,
+            alarm: 0,
+            warning: 0,
+            workOrder: 0,
+            normal: 0,
+            offline: 0,
+            missing: ['deviceStatistics'],
+        }
+    }
+
+    const count = (key) => {
+        const value = raw && raw[key]
+        return typeof value === 'number' && Number.isFinite(value) ? value : 0
+    }
+    return {
+        total: count('totalDeviceCount'),
+        alarm: count('alarmDeviceCount'),
+        warning: count('warningDeviceCount'),
+        workOrder: count('workOrderDeviceCount'),
+        normal: count('normalDeviceCount'),
+        offline: count('offlineDeviceCount'),
+        missing: reader.missing,
+    }
+}
+
+/** 状态原因明细(票 10 的「原因」一栏)。 */
+export function toStatusReason(raw) {
+    const reader = fieldReader(raw, 'statusReason')
+    return {
+        status: reader.optionalStr('status'),
+        deviceCode: reader.optionalStr('deviceCode'),
+        reason: reader.optionalStr('reason'),
+    }
+}
+
+/**
+ * 监测点详情 → 领域对象。
+ * @param {object} raw 后端 `CockpitMonitoringPointDetailResponse`
+ */
+export function toMonitoringPointDetail(raw) {
+    const reader = fieldReader(raw)
+
+    return {
+        code: reader.str('monitoringPointCode'),
+        name: reader.str('monitoringPointName'),
+        // 后端解析并发布的点位状态(前端只渲染,不重算)。
+        status: reader.optionalStr('pointStatus'),
+        statusLabel: reader.optionalStr('statusLabel'),
+        statusReasons: reader.optionalList('statusReasons').map(toStatusReason),
+        statistics: toDeviceStatistics(raw && raw.deviceStatistics),
+        // 不排序:票 10 明确「设备卡片顺序由后端返回顺序决定」。
+        devices: reader.list('devices').map(toDeviceCard),
+        coordinate: toLocation(raw && raw.coordinateValidation),
+        // 状态字典:后端给的优先级,票 09 的图例按它排。
+        statusDictionary: reader.optionalList('statusDictionary').map((item) => {
+            const entry = fieldReader(item, 'statusDictionary')
+            return {
+                status: entry.optionalStr('status'),
+                label: entry.optionalStr('label'),
+                priority: entry.optionalNum('priority'),
+            }
+        }),
+        missing: reader.missing,
+    }
+}

+ 223 - 0
src/modules/cockpit/domain/pointStatus.js

@@ -0,0 +1,223 @@
+/**
+ * 点位状态 → 视觉表现(票 09)。
+ *
+ * 这是「后端点位状态」与「地图上的样子」之间**唯一**的一张对照表:地图标记、地图图例、
+ * 将来台账卡片的状态标签都从这里取,所以图例不可能与标记走两套映射(验收第 4 条)。
+ *
+ * 三条纪律:
+ *   1. **前端不重算点位状态**(CONTEXT.md「点位状态」、ADR 0002):本文件只接收后端
+ *      `pointStatus` 字符串,不做任何推导;认不出的状态一律落 `UNKNOWN`(「状态未发布」),
+ *      宁可如实说不知道,也不猜成 NORMAL —— 猜成正常是最危险的一种谎话。
+ *   2. **颜色不是唯一线索**(验收第 3 条):每个状态同时有 `tone`(色键)+ `shape`(形状)+
+ *      `label`(文字)。色键**只写键名**不写颜色值:颜色仍由 `styles/tokens.css` 独家声明
+ *      (票 06 的单一口径),读色值的地方是服务层(`services/mapService.js` 读计算样式)。
+ *   3. **不把事件等级色混用为点位状态色**(票 09 正文):本表的键是后端
+ *      `CockpitMonitoringPointStatus` 六个取值,不是事件的 `level` 1–4。
+ *      `domain/statusTones.js` 的 `LEVEL_TAGS` 仍是事件四级的图例,属于票 13/19,不挂在地图上。
+ *
+ * 状态词表与形状的来源(不是猜的):
+ *   · 六个状态与中文标签照抄后端 `CockpitMonitoringPointStatus`(ALARM 报警 1 / WARNING 预警 2 /
+ *     WORK_ORDER 工单处置 3 / NORMAL 正常 4 / OFFLINE 离线 5 / EMPTY 无设备 6),优先级也是后端给的;
+ *   · 「未关联设备」不是点位状态(后端 `CockpitUnassociatedEquipmentSummary` 没有该字段,
+ *     票 05 明确「不得借用在线态」),所以它单独一条、带自己的形状,不从六个状态里挑一个冒充。
+ */
+import { splitByCoordinate } from './coordinate.js'
+
+/** 后端 `CockpitMonitoringPointStatus` 的六个取值(原样字符串,不改名、不缩写)。 */
+export const POINT_STATUS = {
+    ALARM: 'ALARM',
+    WARNING: 'WARNING',
+    WORK_ORDER: 'WORK_ORDER',
+    NORMAL: 'NORMAL',
+    OFFLINE: 'OFFLINE',
+    EMPTY: 'EMPTY',
+}
+
+/**
+ * 形状表:内联 SVG 片段(12×12 视框),只用 `currentColor`,**不含任何颜色值**。
+ *
+ * 同一份片段被两处消费:地图图例(`StatusTag` 的 shape 道具)与地图标记
+ * (`markerSvg()` 把它包成 billboard 用的图片)。共用一份标记,形状才是真的「图例与标记一致」。
+ */
+export const SHAPES = {
+    /* 报警:实心三角 */
+    triangle: '<path d="M6 1.5 11.2 10.5H0.8z"/>',
+    /* 预警:实心菱形 */
+    diamond: '<path d="M6 1.1 10.9 6 6 10.9 1.1 6z"/>',
+    /* 工单处置:实心方块 */
+    square: '<rect x="1.8" y="1.8" width="8.4" height="8.4" rx="1"/>',
+    /* 正常:实心圆 */
+    circle: '<circle cx="6" cy="6" r="4.4"/>',
+    /* 离线:空心圆环 */
+    ring: '<circle cx="6" cy="6" r="4" fill="none" stroke="currentColor" stroke-width="2"/>',
+    /* 无设备:空心方块 */
+    hollowSquare: '<rect x="2.2" y="2.2" width="7.6" height="7.6" fill="none" stroke="currentColor" stroke-width="2"/>',
+    /* 状态未发布:空心菱形(与预警的实心菱形成对,形状同族、填充不同) */
+    hollowDiamond: '<path d="M6 1.3 10.7 6 6 10.7 1.3 6z" fill="none" stroke="currentColor" stroke-width="1.8"/>',
+    /* 未关联设备:十字(既是形状,也表示「不在任何点位上」) */
+    cross: '<path d="M6 1.4v9.2M1.4 6h9.2" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>',
+}
+
+export const DEFAULT_SHAPE = 'circle'
+
+export function isKnownShape(shape) {
+    return Object.prototype.hasOwnProperty.call(SHAPES, shape)
+}
+
+/** 形状片段;认不出的形状回实心圆(视觉资源缺失不该让标记整块消失,与 StatusTag 同一条立场)。 */
+export function shapeSvg(shape) {
+    return isKnownShape(shape) ? SHAPES[shape] : SHAPES[DEFAULT_SHAPE]
+}
+
+/**
+ * 六个点位状态的视觉词表。`priority` 用后端的值(1 最紧急)—— 图例按它排,前端不自己定序。
+ *
+ * `label` 是**兜底文案**:详情接口会给 `statusLabel`(票 10),有则用后端的;地图摘要不带
+ * `statusLabel`,所以地图标记/图例用这里的词,与后端 `getLabel()` 逐字一致。
+ */
+export const POINT_STATUS_VISUALS = {
+    ALARM: { key: 'ALARM', tone: 'lv1', shape: 'triangle', label: '报警', priority: 1 },
+    WARNING: { key: 'WARNING', tone: 'lv2', shape: 'diamond', label: '预警', priority: 2 },
+    WORK_ORDER: { key: 'WORK_ORDER', tone: 'lv3', shape: 'square', label: '工单处置', priority: 3 },
+    NORMAL: { key: 'NORMAL', tone: 'lv4', shape: 'circle', label: '正常', priority: 4 },
+    OFFLINE: { key: 'OFFLINE', tone: 'neutral', shape: 'ring', label: '离线', priority: 5 },
+    EMPTY: { key: 'EMPTY', tone: 'neutral', shape: 'hollowSquare', label: '无设备', priority: 6 },
+}
+
+/** 后端没给状态、或给了认不出的状态:如实说「不知道」,不落到任何一个真实状态上。 */
+export const UNKNOWN_VISUAL = { key: 'UNKNOWN', tone: 'neutral', shape: 'hollowDiamond', label: '状态未发布', priority: 7 }
+
+/** 未关联设备:不是点位状态,因此单独一条(`mapSummary.js` 已明确不得借用在线态)。 */
+export const UNASSOCIATED_VISUAL = { key: 'UNASSOCIATED', tone: 'neutral', shape: 'cross', label: '无关联设备', priority: 8 }
+
+/** 点位状态字符串 → 视觉词条;认不出的一律 `UNKNOWN_VISUAL`。 */
+export function visualForStatus(status) {
+    return POINT_STATUS_VISUALS[status] || UNKNOWN_VISUAL
+}
+
+/**
+ * 一个地图对象(`domain/mapSummary.js` 的形状)→ 视觉词条。
+ *
+ * 未关联设备走自己的词条;监测点按后端 `status` 取值。**注意这里不读坐标**:
+ * 「能不能上图」是 `domain/coordinate.js` 的事,本函数只回答「长什么样」。
+ * @param {object} object 地图对象
+ */
+export function pointVisual(object) {
+    const item = object || {}
+    if (item.kind === 'unassociated-equipment') return UNASSOCIATED_VISUAL
+    return visualForStatus(item.status)
+}
+
+/**
+ * 已加载对象 → 标记计划(纯函数,逻辑脚本与走查都读它)。
+ *
+ * 只收**坐标准入通过**的对象:判据仍是 `domain/coordinate.js` 的 `isRenderable()`
+ * (票 07 的唯一准入判据,本票不另立一套)。被拦下的对象原样返回在 `blocked` 里 ——
+ * 「不上图 ≠ 不存在」,它们的可见清单仍归票 07 的 `MapCoordinateBlocked`。
+ *
+ * @param {Array} objects 地图对象数组(票 08 的 `mapSummary.objects`)
+ * @returns {{markers: Array, blocked: Array, byStatus: object, total: number}}
+ */
+export function planPointMarkers(objects) {
+    const { renderable, blocked } = splitByCoordinate(objects)
+
+    const markers = renderable.map((item) => {
+        const visual = pointVisual(item)
+        return {
+            key: `${(item && item.kind) || ''}|${(item && item.code) || ''}`,
+            kind: (item && item.kind) || '',
+            code: (item && item.code) || '',
+            name: (item && item.name) || '',
+            // 后端发布的点位状态原样带出(未关联设备为空串)。
+            status: (item && item.status) || '',
+            visualKey: visual.key,
+            tone: visual.tone,
+            shape: visual.shape,
+            // 文字线索:形状之外还带一句人话,弹层/图例计数都读它。
+            label: visual.label,
+            longitude: (item && item.longitude),
+            latitude: (item && item.latitude),
+        }
+    })
+
+    return { markers, blocked, byStatus: summarizeMarkerStatuses(markers), total: markers.length }
+}
+
+/** 标记数组 → 按视觉键计数(图例上的数字与「图例/标记一致」的判据都读它)。 */
+export function summarizeMarkerStatuses(markers) {
+    const byStatus = {}
+    for (const marker of markers || []) {
+        const key = (marker && marker.visualKey) || UNKNOWN_VISUAL.key
+        byStatus[key] = (byStatus[key] || 0) + 1
+    }
+    return byStatus
+}
+
+/** 图例的全部行(含未关联设备与「状态未发布」),按后端优先级排序。 */
+export function legendVisuals() {
+    return [
+        ...Object.keys(POINT_STATUS_VISUALS).map((key) => POINT_STATUS_VISUALS[key]),
+        UNKNOWN_VISUAL,
+        UNASSOCIATED_VISUAL,
+    ].sort((a, b) => a.priority - b.priority)
+}
+
+/**
+ * 地图图例(纯函数):行来自上面的同一张表,所以图例的色键与形状**必然**与标记一致。
+ *
+ * `count` = 当前已上图(坐标准入通过)的对象数。0 不隐藏行(形状的含义要能查到),
+ * 由界面决定要不要显数字 —— 「0」在别处会被读成「一个都没有」,那是另一回事。
+ * @param {object} byStatus `planPointMarkers()` 的 `byStatus`
+ */
+export function pointLegend(byStatus) {
+    const counts = byStatus || {}
+    return legendVisuals().map((visual) => ({
+        key: visual.key,
+        tone: visual.tone,
+        shape: visual.shape,
+        label: visual.label,
+        count: counts[visual.key] || 0,
+    }))
+}
+
+/** 图例与标记是否一致(走查与单测共用一条判据,避免两处各写各的)。 */
+export function legendMatchesMarkers(legend, markers) {
+    const rows = new Map((legend || []).map((row) => [row.key, row]))
+    const problems = []
+
+    for (const [key, count] of Object.entries(summarizeMarkerStatuses(markers))) {
+        const row = rows.get(key)
+        if (!row) problems.push(`${key} 有 ${count} 个标记但图例里没有这一行`)
+        else if (row.count !== count) problems.push(`${key} 图例写 ${row.count} 个、标记实际 ${count} 个`)
+    }
+    for (const row of legend || []) {
+        if ((row.count || 0) > 0 && !rows.has(row.key)) problems.push(`${row.key} 图例有计数却没有标记`)
+        const marker = (markers || []).find((item) => item.visualKey === row.key)
+        if (marker && (marker.tone !== row.tone || marker.shape !== row.shape)) {
+            problems.push(`${row.key} 图例是 ${row.tone}/${row.shape},标记却是 ${marker.tone}/${marker.shape}`)
+        }
+    }
+    // 计数总和必须等于标记总数:多一行少一行都说明两边不是同一份数据。
+    const legendTotal = (legend || []).reduce((sum, row) => sum + (row.count || 0), 0)
+    const markerTotal = (markers || []).length
+    if (legendTotal !== markerTotal) problems.push(`图例合计 ${legendTotal} 与标记总数 ${markerTotal} 不等`)
+
+    return { ok: problems.length === 0, problems }
+}
+
+/**
+ * 标记图片(billboard 用):把形状片段包成带颜色的 SVG data URL。
+ *
+ * 颜色**必须由调用方给**:色值只在 `styles/tokens.css` 声明一份,服务层从 `.cockpit-shell`
+ * 的计算样式里读出来传进来,本文件不写任何 `#rrggbb`(票 06 的单一口径)。
+ * @param {string} shape 形状名
+ * @param {string} color CSS 颜色(如 `#ff4d5e`)
+ * @param {number} [size] 视框像素尺寸
+ */
+export function markerSvg(shape, color, size = 12) {
+    // `color` 属性让片段里的 `currentColor` 有值:SVG 作为图片加载时读不到页面 CSS 变量。
+    const svg =
+        `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="${size}" height="${size}" ` +
+        `fill="currentColor" color="${color || '#ffffff'}">${shapeSvg(shape)}</svg>`
+    return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
+}

+ 24 - 0
src/modules/cockpit/domain/requestReason.js

@@ -0,0 +1,24 @@
+/**
+ * 请求失败原因词汇(票 05 从 services/cockpitApi.js 提到 domain)。
+ *
+ * 提上来的理由:分区状态(domain/sectionState.js)要按失败原因判定「数据不可用」还是「加载失败」,
+ * 而 domain 不该反过来 import service——那会形成 domain → service → domain 的层次倒挂。
+ * 词汇属于领域,怎么发请求属于服务,所以常量放这里。
+ *
+ * 兼容:`services/cockpitApi.js` 仍然对外导出 `REQUEST_REASON`(转出本模块),
+ * 票 04 的断言脚本与既有调用点因此不需要改。
+ */
+
+/** 失败原因。分区据此决定「数据不可用」措辞(见 domain/configStatus.js)。 */
+export const REQUEST_REASON = {
+    NO_BASE_URL: 'no-base-url',
+    MOCK_MISSING: 'mock-handler-missing',
+    UNAUTHORIZED: 'unauthorized',
+    UNAVAILABLE: 'unavailable',
+    UNREACHABLE: 'unreachable',
+    BAD_PAYLOAD: 'bad-payload',
+    // 票 08:地图摘要分页自带的失效态。后端在「第 2 页起没带快照」或「带的快照与当前不一致」时
+    // 回 400 + `CockpitErrorCategory.SNAPSHOT_EXPIRED`(`CockpitMapSnapshotService`)。
+    // 它是可恢复的业务态而不是链路失败:界面要说「请刷新」,不能笼统报「加载失败」。
+    SNAPSHOT_EXPIRED: 'snapshot-expired',
+}

+ 200 - 0
src/modules/cockpit/domain/sectionState.js

@@ -0,0 +1,200 @@
+/**
+ * 分区状态词汇(票 05):一个分区(面板或详情分区)当前是「有数据 / 空 / 过期 / 失败 / 不可用」。
+ *
+ * 为什么要单独一套词汇:票 05 验收要求「401 或凭据类失败只把受影响分区标为数据不可用,
+ * 保留已成功分区与地图骨架」,票 16 要求「数据新鲜、过期、不可用和部分失败状态可区分」。
+ * 判定逻辑散在各面板里就会各写一套措辞与色键,所以收在这里,纯函数、可脱浏览器断言。
+ *
+ * 与后端的关系(调研 §5 结论:`CockpitSectionState` 是全后端统一模式):
+ * 后端四态 `SUCCESS`/`EMPTY`/`STALE`/`FAILED` **1:1** 映射;前端只多一个 `UNAVAILABLE`——
+ * 它表示「凭据或链路问题,这批数据根本没取到」(ADR 0005 的 401 语义),
+ * 与 `FAILED`(取到了但是坏的/分区自己失败)分开,界面措辞也不同。
+ *
+ * 注意 `STALE` 与 `EMPTY` 的区别(票 16 原句「过期数据保留最后已知值」):
+ * `STALE` 是**有值但过期**(`retained: true`,界面必须带过期标识,不得伪装成实时),
+ * `EMPTY` 是**确实没有值**(界面显示占位符,不显示虚假零值)。
+ */
+import { DATA_UNAVAILABLE_LABEL } from './configStatus.js'
+import { REQUEST_REASON } from './requestReason.js'
+
+/** 前端分区状态(也是面板 `data-section-state` 的取值)。 */
+export const SECTION_STATE = {
+    SUCCESS: 'success',
+    EMPTY: 'empty',
+    STALE: 'stale',
+    FAILED: 'failed',
+    UNAVAILABLE: 'unavailable',
+}
+
+/** 后端 `CockpitSectionState` 的四态原文,仅在转换时出现。 */
+export const BACKEND_SECTION_STATE = {
+    SUCCESS: 'SUCCESS',
+    EMPTY: 'EMPTY',
+    STALE: 'STALE',
+    FAILED: 'FAILED',
+}
+
+const BACKEND_TO_SECTION = {
+    [BACKEND_SECTION_STATE.SUCCESS]: SECTION_STATE.SUCCESS,
+    [BACKEND_SECTION_STATE.EMPTY]: SECTION_STATE.EMPTY,
+    [BACKEND_SECTION_STATE.STALE]: SECTION_STATE.STALE,
+    [BACKEND_SECTION_STATE.FAILED]: SECTION_STATE.FAILED,
+}
+
+/** 新鲜度原文 → 分区状态:过期但仍保留旧值(`retained`)算 `STALE`,不是 `EMPTY`。 */
+const FRESHNESS_TO_SECTION = {
+    CURRENT: SECTION_STATE.SUCCESS,
+    FRESH: SECTION_STATE.SUCCESS,
+    STALE: SECTION_STATE.STALE,
+    EXPIRED: SECTION_STATE.STALE,
+    EMPTY: SECTION_STATE.EMPTY,
+}
+
+/** 后端状态字符串 → 前端分区状态;认不出来的一律当失败,不静默混进「已就绪」。 */
+export function sectionStateFromBackend(state) {
+    return BACKEND_TO_SECTION[state] || SECTION_STATE.FAILED
+}
+
+/**
+ * 失败原因 → 分区状态。
+ * 凭据类(401/403)与「没注册适配器/没配地址」都归 `UNAVAILABLE`:数据没取到,
+ * 界面显示统一文案,**不跳登录页**(ADR 0005)。
+ * @param {string} reason services/cockpitApi.js 的 REQUEST_REASON
+ */
+export function sectionStateFromReason(reason) {
+    if (reason === REQUEST_REASON.UNAUTHORIZED) return SECTION_STATE.UNAVAILABLE
+    if (reason === REQUEST_REASON.MOCK_MISSING) return SECTION_STATE.UNAVAILABLE
+    if (reason === REQUEST_REASON.NO_BASE_URL) return SECTION_STATE.UNAVAILABLE
+    return SECTION_STATE.FAILED
+}
+
+/** 新鲜度字符串 → 分区状态;未给出的返回 null(由其它判据决定)。 */
+export function sectionStateFromFreshness(freshness) {
+    return FRESHNESS_TO_SECTION[freshness] || null
+}
+
+/**
+ * 单个分区的描述对象。所有适配器都回这个形状,界面与断言只认它。
+ * @param {string} state SECTION_STATE 之一
+ * @param {object} [extra]
+ */
+export function section(state, extra = {}) {
+    const value = state || SECTION_STATE.FAILED
+    return {
+        state: value,
+        reason: extra.reason || '',
+        // 数据来源:mock / real(走查与排查时分辨「这份数据是谁给的」)。
+        mode: extra.mode || '',
+        // 有值但过期时保留的最后已知数据(ticket 16:保留但不伪装成实时)。
+        data: extra.data === undefined ? null : extra.data,
+        // 字段缺失清单(领域适配层记的,供排查;不直接上界面)。
+        missing: extra.missing || [],
+        freshness: extra.freshness || '',
+        retained: extra.retained === true,
+    }
+}
+
+/**
+ * 唯一入口:把 `requestCockpitJson` 的结果 + 领域载荷判据翻成一个分区。
+ *
+ * @param {object} result services/cockpitApi.js 的返回
+ * @param {object} [options]
+ * @param {(payload: any) => boolean} [options.isEmpty] 载荷是否算「空数据」(如列表长度为 0)
+ * @param {(payload: any) => string} [options.freshnessOf] 从载荷里取新鲜度字符串
+ * @param {(payload: any) => any} [options.toData] 载荷 → 领域对象(失败/空时不会被调用)
+ * @returns {object} section()
+ */
+export function sectionFromResult(result, options = {}) {
+    const current = result || {}
+
+    if (!current.ok) {
+        const state = sectionStateFromReason(current.reason)
+        return section(state, { reason: current.reason || '', mode: current.mode || '' })
+    }
+
+    const payload = current.data
+    const freshness = typeof options.freshnessOf === 'function' ? options.freshnessOf(payload) || '' : ''
+    const staleState = sectionStateFromFreshness(freshness)
+    const isEmpty = typeof options.isEmpty === 'function' ? options.isEmpty(payload) : false
+
+    // 过期优先于空:后端可能给「过期但保留旧值」,那是有值,不能当空。
+    if (staleState === SECTION_STATE.STALE) {
+        return section(SECTION_STATE.STALE, {
+            mode: current.mode,
+            freshness,
+            retained: true,
+            data: typeof options.toData === 'function' ? options.toData(payload) : payload,
+        })
+    }
+
+    if (isEmpty) {
+        return section(SECTION_STATE.EMPTY, { mode: current.mode, freshness })
+    }
+
+    const data = typeof options.toData === 'function' ? options.toData(payload) : payload
+    const missing = (data && data.missing) || []
+    return section(staleState || SECTION_STATE.SUCCESS, { mode: current.mode, freshness, data, missing })
+}
+
+/** 分区状态 → 界面用的色键与文案(色键来自票 06 的 token 词典)。 */
+export function sectionView(value) {
+    const state = (value && value.state) || SECTION_STATE.FAILED
+
+    if (state === SECTION_STATE.SUCCESS) {
+        return { tone: 'lv4', label: '数据已就绪', detail: '' }
+    }
+    if (state === SECTION_STATE.STALE) {
+        return { tone: 'lv2', label: '数据已过期', detail: '显示最后一次成功数据,不是实时值' }
+    }
+    if (state === SECTION_STATE.EMPTY) {
+        return { tone: 'neutral', label: '无数据', detail: '当前范围内没有记录' }
+    }
+    if (state === SECTION_STATE.UNAVAILABLE) {
+        // 票 04 定下的统一文案:凭据类失败只标分区,不跳登录页。
+        return { tone: 'lv1', label: DATA_UNAVAILABLE_LABEL, detail: reasonDetail(value && value.reason) }
+    }
+    return { tone: 'lv1', label: '数据加载失败', detail: reasonDetail(value && value.reason) }
+}
+
+/**
+ * 失败原因 → 给人读的说明。
+ * 界面上**不出现**内部原因码(`unavailable` 这类字符串是接口契约词汇,不是文案);
+ * 认不出的原因回空串,宁可只说「数据加载失败」,也不把代号甩给值班人员。
+ */
+function reasonDetail(reason) {
+    const dictionary = {
+        [REQUEST_REASON.UNAUTHORIZED]: '凭据未通过校验(由部署层注入)',
+        [REQUEST_REASON.NO_BASE_URL]: '未配置后端地址',
+        [REQUEST_REASON.MOCK_MISSING]: '该分区还没有注册数据源',
+        [REQUEST_REASON.UNREACHABLE]: '无法连接数据源',
+        [REQUEST_REASON.BAD_PAYLOAD]: '数据源返回的内容无法解析',
+        [REQUEST_REASON.UNAVAILABLE]: '数据源暂不可用',
+    }
+    return dictionary[reason] || ''
+}
+
+/**
+ * 一组分区的整体状态(票 16 的「部分失败状态可区分」)。
+ * @param {object} sections id → 分区
+ */
+export function overallState(sections) {
+    const values = Object.values(sections || {})
+    if (!values.length) return SECTION_STATE.EMPTY
+
+    const count = (state) => values.filter((item) => (item && item.state) === state).length
+    const good = count(SECTION_STATE.SUCCESS) + count(SECTION_STATE.STALE)
+    const bad = count(SECTION_STATE.FAILED) + count(SECTION_STATE.UNAVAILABLE)
+
+    if (bad === 0) return good === 0 ? SECTION_STATE.EMPTY : SECTION_STATE.SUCCESS
+    if (good > 0) return 'partial'
+    return bad === count(SECTION_STATE.UNAVAILABLE) ? SECTION_STATE.UNAVAILABLE : SECTION_STATE.FAILED
+}
+
+/** 整体状态文案(顶栏/浮标用)。 */
+export function overallStateText(state) {
+    if (state === 'partial') return '部分分区数据不可用'
+    if (state === SECTION_STATE.SUCCESS) return '全部分区数据已就绪'
+    if (state === SECTION_STATE.EMPTY) return '暂无分区数据'
+    if (state === SECTION_STATE.UNAVAILABLE) return DATA_UNAVAILABLE_LABEL
+    return '分区数据加载失败'
+}

+ 19 - 8
src/modules/cockpit/domain/statusTones.js

@@ -2,15 +2,20 @@
  * 状态/等级标签的语义色键(ADR 0006 点位状态、ADR 0008 事件四级共用一套调色板)。
  *
  * 这里只定义「视觉可用的色键」,不定义业务映射:
- * 「报警 → lv1」「离线 → neutral」这类判定属于数据层,由后续票(点位状态/图例)接。
- * 这样视觉资源可替换,业务规则也不会藏进样式里。
+ * 「报警 → lv1」「离线 → neutral」这类判定属于数据层,已由 `domain/pointStatus.js` 接
+ * (票 09:点位状态 → 色键 + 形状 + 文字)。这样视觉资源可替换,业务规则也不会藏进样式里。
+ *
+ * 两套词汇必须分清(票 09 正文的硬约束「不把事件等级色混用为点位状态色」):
+ *   · 本文件的 `LEVEL_TAGS` = **事件等级**(1级..4级),用于事件列表/预警面板(票 13/19);
+ *   · `pointStatus.js` 的点位状态(报警/预警/工单处置/正常/离线/无设备)= **地图标记与地图图例**。
+ * 两者共用调色板,但**标签词不同、形状不同**,不得互相冒充。
  */
 
 export const TONES = ['lv1', 'lv2', 'lv3', 'lv4', 'neutral']
 
 export const DEFAULT_TONE = 'neutral'
 
-/** 事件四级标签:地图图例与事件列表共用。 */
+/** 事件四级标签:事件列表与预警/报警面板共用(**不挂在地图上**,见上面的说明)。 */
 export const LEVEL_TAGS = [
     { tone: 'lv1', label: '1级' },
     { tone: 'lv2', label: '2级' },
@@ -18,12 +23,18 @@ export const LEVEL_TAGS = [
     { tone: 'lv4', label: '4级' },
 ]
 
+/**
+ * 色键的纯视觉描述(**只说颜色与深浅,不说业务**)。
+ *
+ * 刻意不再写「一级/报警」这类合并措辞:那正是「事件等级色与点位状态色混用」的温床 ——
+ * 同一个 lv1 在事件里是「1 级」、在点位上才是「报警」,写在一张表里迟早有人照抄错。
+ */
 export const TONE_DESCRIPTIONS = {
-    lv1: '红 · 一级/报警',
-    lv2: '黄 · 二级/预警',
-    lv3: '蓝 · 三级/处置中',
-    lv4: '绿 · 四级/正常',
-    neutral: '灰 · 离线/未关联',
+    lv1: '红 · 最高优先级',
+    lv2: '黄 · 次高优先级',
+    lv3: '蓝 · 进行中/提示',
+    lv4: '绿 · 正常',
+    neutral: '灰 · 无数据/离线',
 }
 
 export function isKnownTone(tone) {

+ 110 - 0
src/modules/cockpit/domain/viewModes.js

@@ -0,0 +1,110 @@
+/**
+ * 地面视图 / 管线视图(票 21,语义来自决策地图票 05「Ground and pipeline view composition」)。
+ *
+ * 决策地图锁死的三条,本模块就是它们的落点:
+ *   1. 地面/管线是**全局视图条件**,同时驱动「相机预设」与「图层可见性」,不是第二套相机;
+ *   2. 切换视图**不重置旋转中心**(中心仍是选中对象地表点/地面拾取点),只改倾角与弹簧臂;
+ *   3. **三键分工完全不变**:左键平移、右键绕中心旋转、滚轮只改弹簧臂长度;
+ *      管线视图只把 pitch 上限放宽到允许平视/轻微仰视。
+ *
+ * 纯函数、不 import cesium、不读 DOM —— 相机预设与图层透明度的取值都能脱浏览器断言
+ * (见 verify/t21-view-modes.mjs);Cesium 侧只负责照着这些数字设 globe/tileset。
+ */
+import { PITCH_LIMITS, clamp } from './camera.js'
+
+export const VIEW_MODES = {
+    GROUND: 'ground',
+    PIPELINE: 'pipeline',
+}
+
+export const DEFAULT_VIEW_MODE = VIEW_MODES.GROUND
+
+/** 地面视图的默认倾角(与票 30 的 home 预设一致,避免切来切去画面跳)。 */
+export const GROUND_PITCH = -55
+
+/**
+ * 管线视图的相机预设:pitch 抬到接近平视(-18°),弹簧臂压到当前长度的 35%(并夹在合理区间)。
+ * 「压低相机 + 加大 pitch」= 贴着地面侧看,管线才有纵深感;不采用剖面/掀盖方案(属票 23)。
+ */
+export const PIPELINE_CAMERA = {
+    pitch: -18,
+    rangeFactor: 0.35,
+    minRange: 400,
+    maxRange: 6000,
+}
+
+/** 管线视图下地形与 3D Tiles 的半透明程度(保留空间参照,不完全隐藏)。 */
+export const PIPELINE_ALPHA = {
+    terrain: 0.45,
+    tileset: 0.4,
+}
+
+/** 地面视图下的透明度(完全不透明 = 还原)。 */
+export const GROUND_ALPHA = {
+    terrain: 1,
+    tileset: 1,
+}
+
+export function isKnownViewMode(mode) {
+    return Object.values(VIEW_MODES).includes(mode)
+}
+
+/**
+ * 视图条件计划:把「当前是哪个视图」翻译成相机与图层该怎么变。
+ * @param {string} mode `ground` | `pipeline`
+ * @param {object} [overrides] 配置覆盖:`{ pitch, rangeFactor, terrainAlpha, tilesetAlpha }`
+ * @returns {{mode: string, limits: object, pitch: number, rangeFactor: number, minRange: number,
+ *            maxRange: number, terrainAlpha: number, tilesetAlpha: number}}
+ */
+export function planViewMode(mode, overrides = {}) {
+    const target = isKnownViewMode(mode) ? mode : DEFAULT_VIEW_MODE
+    const pipeline = target === VIEW_MODES.PIPELINE
+
+    // 配置覆盖只认有限数字,避免脏值把相机顶到奇怪的角度。
+    const pick = (value, fallback) => (Number.isFinite(value) ? value : fallback)
+
+    return {
+        mode: target,
+        // 管线视图放宽 pitch 上限到 +5(允许平视/轻微仰视);地面视图仍是常规俯视。
+        limits: pipeline ? PITCH_LIMITS.pipeline : PITCH_LIMITS.ground,
+        pitch: pipeline ? pick(overrides.pitch, PIPELINE_CAMERA.pitch) : GROUND_PITCH,
+        rangeFactor: pipeline ? pick(overrides.rangeFactor, PIPELINE_CAMERA.rangeFactor) : 1,
+        minRange: PIPELINE_CAMERA.minRange,
+        maxRange: PIPELINE_CAMERA.maxRange,
+        terrainAlpha: pipeline ? pick(overrides.terrainAlpha, PIPELINE_ALPHA.terrain) : GROUND_ALPHA.terrain,
+        tilesetAlpha: pipeline ? pick(overrides.tilesetAlpha, PIPELINE_ALPHA.tileset) : GROUND_ALPHA.tileset,
+    }
+}
+
+/**
+ * 切换视图时的相机变换(**只动 pitch 与 range,heading 与中心原样带走**)。
+ *
+ * 「不重置旋转中心」是决策地图的硬约束,所以本函数根本不接中心参数 —— 从签名上就杜绝了改中心的可能。
+ * @param {object} hpr 当前 `{ heading, pitch, range }`
+ * @param {object} plan `planViewMode()` 的结果
+ * @param {object} [baseline] 进管线前记下的地面视角 `{ pitch, range }`;回地面时用它还原
+ */
+export function viewHprFor(hpr, plan, baseline = null) {
+    const current = hpr || {}
+    const heading = Number.isFinite(current.heading) ? current.heading : 0
+    const range = Number.isFinite(current.range) ? current.range : 12000
+
+    if (plan.mode === VIEW_MODES.PIPELINE) {
+        // 压低弹簧臂:按系数缩短,再夹进「贴着地面看」的合理区间。
+        // 上限不按当前距离再夹一次 —— 那样 range 比 minRange 还小时会出现 min>max,clamp 反而放行原值。
+        return {
+            heading,
+            pitch: clamp(plan.pitch, plan.limits.min, plan.limits.max),
+            range: clamp(range * plan.rangeFactor, plan.minRange, plan.maxRange),
+        }
+    }
+
+    // 回地面:优先还原进管线前记下的地面倾角/距离(「地面视图还原」),没有记录就用默认倾角。
+    const pitch = baseline && Number.isFinite(baseline.pitch) ? baseline.pitch : GROUND_PITCH
+    const restoredRange = baseline && Number.isFinite(baseline.range) ? baseline.range : range
+    return {
+        heading,
+        pitch: clamp(pitch, plan.limits.min, plan.limits.max),
+        range: restoredRange,
+    }
+}

+ 94 - 0
src/modules/cockpit/domain/viewPresets.js

@@ -0,0 +1,94 @@
+/**
+ * 视角预设与进入引导(票 30):纯函数、不 import cesium、不读 DOM。
+ *
+ * 目的:维护者「完全找不到目标位置,无法判断 DEM 与精细影像是否部署成功」。
+ * 给出一个**命名视角**(home)与进入引导,让系统一进来就把镜头带到沅陵县城。
+ *
+ * 坐标口径:维护者给的 `110.39028164, 28.44367870` 是 **CGCS2000 · 天地图**坐标;
+ * CGCS2000 与 WGS-84 在本项目的精度要求下视为一致,直接喂 `Cartesian3.fromDegrees`。
+ */
+
+/** 进入引导的默认文案与时长。 */
+export const INTRO_DEFAULTS = {
+    enabled: true,
+    title: '点击进入系统',
+    subtitle: '',
+    /** 星球自转一圈的秒数(0 = 不自转)。 */
+    spinSeconds: 48,
+}
+
+/**
+ * 默认「家」视角:沅陵县城上空,俯角 -55°、距离 12 km —— 这个距离下能同时看到
+ * 县城轮廓与周边地形起伏(实测 4.6 km 是街区级、12 km 是县域级)。
+ */
+export const HOME_DEFAULTS = {
+    lon: 110.39028164,
+    lat: 28.4436787,
+    height: 0,
+    heading: 0,
+    pitch: -55,
+    range: 12000,
+}
+
+function readCoord(value, fallback) {
+    const num = Number(value)
+    return Number.isFinite(num) ? num : fallback
+}
+
+/**
+ * 取「家」视角(配置优先,缺项用默认)。
+ * @param {object} mapConfig 归一化后的 `config.map`
+ */
+export function planHome(mapConfig = {}) {
+    const home = (mapConfig && mapConfig.home) || {}
+    return {
+        lon: readCoord(home.lon, HOME_DEFAULTS.lon),
+        lat: readCoord(home.lat, HOME_DEFAULTS.lat),
+        height: readCoord(home.height, HOME_DEFAULTS.height),
+        heading: readCoord(home.heading, HOME_DEFAULTS.heading),
+        pitch: readCoord(home.pitch, HOME_DEFAULTS.pitch),
+        range: readCoord(home.range, HOME_DEFAULTS.range),
+        label: typeof home.label === 'string' && home.label ? home.label : '目标区域',
+    }
+}
+
+/**
+ * 取进入引导配置。
+ * @param {object} mapConfig 归一化后的 `config.map`
+ */
+export function planIntro(mapConfig = {}) {
+    const intro = (mapConfig && mapConfig.intro) || {}
+    return {
+        enabled: intro.enabled !== false,
+        title: typeof intro.title === 'string' && intro.title ? intro.title : INTRO_DEFAULTS.title,
+        subtitle: typeof intro.subtitle === 'string' ? intro.subtitle : INTRO_DEFAULTS.subtitle,
+        spinSeconds: Math.max(0, readCoord(intro.spinSeconds, INTRO_DEFAULTS.spinSeconds)),
+    }
+}
+
+/** 「归位」按钮与引导文案里用的短标签。 */
+export function homeLabel(home) {
+    const current = home || HOME_DEFAULTS
+    const lon = Number(current.lon).toFixed(4)
+    const lat = Number(current.lat).toFixed(4)
+    return `${current.label || '目标区域'}(${lon}, ${lat})`
+}
+
+/**
+ * 视角是否已经落在预设上(容差内)—— 归位按钮据此决定是否禁用/高亮,
+ * 走查也用它断言「点一下真的回去了」。
+ * @param {object} state `cameraControl.getState()`
+ * @param {object} home `planHome()` 的结果
+ * @param {object} [tolerance] 各分量容差
+ */
+export function isAtHome(state, home, tolerance = { lon: 1e-4, lat: 1e-4, range: 0.02, angle: 0.5 }) {
+    if (!state || !state.pivot || !state.hpr) return false
+    return (
+        Math.abs(state.pivot.lon - home.lon) <= tolerance.lon &&
+        Math.abs(state.pivot.lat - home.lat) <= tolerance.lat &&
+        // 距离用相对容差(2%):弹簧臂是连续量,绝对容差在不同量级下没意义。
+        Math.abs(state.hpr.range - home.range) <= Math.max(1, home.range * tolerance.range) &&
+        Math.abs(((state.hpr.heading - home.heading + 540) % 360) - 180) <= tolerance.angle &&
+        Math.abs(state.hpr.pitch - home.pitch) <= tolerance.angle
+    )
+}

+ 123 - 0
src/modules/cockpit/domain/workOrder.js

@@ -0,0 +1,123 @@
+/**
+ * 工单领域对象(票 05;消费方是票 11 设备详情与票 12 时间线)。
+ *
+ * 七态字典来自后端 `CockpitWorkOrderStateDictionary`(demo:`work-order-seven-states-demo.html`),
+ * 是**稳定业务词汇**,所以作为字典固化在这里,而不是每次从响应里读(响应缺字典时界面仍要能显示状态名)。
+ *
+ * 票 12 的验收原句「验收驳回显示为回退动作,不是独立终态」落在时间线转换上:
+ * 该语义属于**动作轨迹**(`timelineEntries[].stateCode` 可能从 5 回到 4),
+ * 所以这里只如实带上每条轨迹的状态码与时间,由票 12 渲染成回退动作,本层不发明第七态之外的终态。
+ */
+import { fieldReader } from './fields.js'
+
+/** 工单七态(码 → 名),顺序即业务顺序。 */
+export const WORK_ORDER_STATES = [
+    { code: 1, name: '待派单', terminal: false },
+    { code: 2, name: '待接单', terminal: false },
+    { code: 3, name: '已接单', terminal: false },
+    { code: 4, name: '处理中', terminal: false },
+    { code: 5, name: '待验收', terminal: false },
+    { code: 6, name: '已结案', terminal: true },
+    { code: 7, name: '已取消', terminal: true },
+]
+
+/** 结案态码(票 12 与工单七态分布都会用到)。 */
+export const WORK_ORDER_CLOSED_CODE = 6
+
+export function workOrderStateName(code) {
+    const found = WORK_ORDER_STATES.find((item) => item.code === code)
+    return found ? found.name : ''
+}
+
+/** 后端状态名缺失时用字典补(不伪造:字典认不出来就回空串)。 */
+function stateName(reader, code) {
+    return reader.optionalStr('stateName') || workOrderStateName(code)
+}
+
+/**
+ * 工单摘要 → 领域对象。
+ * @param {object} raw 后端 `CockpitWorkOrderSummary`
+ */
+export function toWorkOrder(raw) {
+    const reader = fieldReader(raw, 'workOrder')
+    const code = reader.num('stateCode')
+    const association = raw && raw.eventAssociation
+
+    return {
+        code: reader.str('workOrderCode'),
+        deviceCode: reader.optionalStr('deviceCode'),
+        orderTypeName: reader.optionalStr('orderTypeName'),
+        orderLevel: reader.optionalNum('orderLevel'),
+        description: reader.optionalStr('orderDesc'),
+        stateCode: code,
+        stateName: stateName(reader, code),
+        createTime: reader.optionalStr('createTime'),
+        updateTime: reader.optionalStr('updateTime'),
+        planFinishTime: reader.optionalStr('planFinishTime'),
+        // 未结案时后端给 null,这是正常的「还没有」而不是字段缺失。
+        realFinishTime: reader.optionalStr('realFinishTime'),
+        event: association
+            ? {
+                  source: fieldReader(association, 'eventAssociation').optionalStr('eventSource'),
+                  code: fieldReader(association, 'eventAssociation').optionalStr('eventCode'),
+              }
+            : null,
+        missing: reader.missing,
+    }
+}
+
+/**
+ * 时间线 → 领域对象。轨迹按 `operatedAt` 升序(后端顺序不保证时序,票 12 要按序讲动作)。
+ * @param {object} raw 后端 `CockpitWorkOrderTimelineResponse`
+ */
+export function toWorkOrderTimeline(raw) {
+    const reader = fieldReader(raw)
+    const order = reader.optionalObj('workOrder')
+
+    const entries = reader
+        .optionalList('timelineEntries')
+        .map((item) => {
+            const entry = fieldReader(item, 'timelineEntry')
+            const code = entry.num('stateCode')
+            return {
+                operationName: entry.optionalStr('operationName'),
+                operationDesc: entry.optionalStr('operationDesc'),
+                operatorName: entry.optionalStr('operatorName'),
+                operatedAt: entry.optionalStr('operatedAt'),
+                stateCode: code,
+                stateName: entry.optionalStr('stateName') || workOrderStateName(code),
+                missing: entry.missing,
+            }
+        })
+        .sort((a, b) => String(a.operatedAt).localeCompare(String(b.operatedAt)))
+
+    return {
+        workOrder: toWorkOrder(order),
+        entries,
+        missing: reader.missing,
+    }
+}
+
+/**
+ * 工单七态分布(`FamilyMetrics.workOrderStates`,键是状态码)。
+ * 缺失的态补 0 —— 这是**计数**,0 是真实值(「没有待派单的工单」),与「指标缺失」不同。
+ * @param {object} raw 后端 `Map<Integer,Integer>`
+ */
+export function toStateDistribution(raw) {
+    const source = raw && typeof raw === 'object' ? raw : {}
+    const distribution = {}
+
+    for (const state of WORK_ORDER_STATES) {
+        const value = source[state.code] !== undefined ? source[state.code] : source[String(state.code)]
+        distribution[state.code] = typeof value === 'number' && Number.isFinite(value) ? value : 0
+    }
+
+    return {
+        distribution,
+        total: Object.values(distribution).reduce((sum, value) => sum + value, 0),
+        // 后端字典里没有的键:不丢,单独列出来(否则会静默算错总数)。
+        unknown: Object.keys(source)
+            .filter((key) => !WORK_ORDER_STATES.some((state) => String(state.code) === String(key)))
+            .map((key) => ({ key, value: source[key] })),
+    }
+}

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

@@ -0,0 +1,707 @@
+/**
+ * 契约化 mock 样例(票 05):与真实接口**同一份字段名与嵌套形状**。
+ *
+ * 依据(Q3=A):字段名与结构照抄后端演示载荷 ——
+ * `E:\pipe-ner-server\docs\cockpit\*.html` 与 `research/panel-field-coverage.md` 的逐字段表。
+ * 因此适配器单测证明的是「转换吃得下真载荷」,而不是「吃得下我自己编的小样」。
+ *
+ * 覆盖票 05 要求的五类样例(每个接口都能取到):
+ *   `success`(正常)/ `empty`(空数据)/ `degraded`(字段缺失)/ `failed`(失败)/ `stale`(过期)。
+ * 另有 `unauthorized`:票 05 验收第 6 条的 401 场景,只影响被请求的那个分区。
+ *
+ * 数据是**精心构造的边界值**,不是随手填的:坐标覆盖 VALID/UNVERIFIED/INVALID/ZERO、
+ * 指标覆盖 0 与「无采样」、时间覆盖未结案(null)。
+ */
+import { REQUEST_REASON } from '../domain/requestReason.js'
+
+const SNAPSHOT_AT = '2026-09-02T10:00:00+08:00'
+
+// ---------------------------------------------------------------- 地图摘要
+
+const MAP_POINTS = [
+    {
+        pointCode: 'MP-001',
+        pointName: '供水干管监测点',
+        pointStatus: 'NORMAL',
+        statusReasons: [],
+        longitude: 110.141,
+        latitude: 28.452,
+        coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
+    },
+    {
+        pointCode: 'MP-002',
+        pointName: '燃气调压站监测点',
+        pointStatus: 'ALARM',
+        statusReasons: ['EQ-ASSOC-001 报警'],
+        longitude: 110.142,
+        latitude: 28.453,
+        coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
+    },
+    {
+        pointCode: 'MP-003',
+        pointName: '污水排口监测点',
+        pointStatus: 'WARNING',
+        statusReasons: ['EQ-FREE-003 预警'],
+        longitude: 110.143,
+        latitude: 28.454,
+        coordinateValidation: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, crs: 'WGS84' },
+    },
+]
+
+const MAP_EQUIPMENT = [
+    {
+        deviceCode: 'EQ-FREE-001',
+        deviceName: '供水压力计-001',
+        longitude: 181,
+        latitude: 28.455,
+        coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' },
+    },
+    {
+        deviceCode: 'EQ-FREE-004',
+        deviceName: '零坐标设备-004',
+        longitude: 0,
+        latitude: 0,
+        coordinateValidation: { state: 'INVALID', reason: 'ZERO_COORDINATE', renderable: false, crs: 'WGS84' },
+    },
+]
+
+/** 地图摘要的演示分页(票 08):契约允许任意页大小,演示用 2 —— 三页翻完,看得出来是渐进加载。 */
+const MAP_SNAPSHOT_PAGE_SIZE = 2
+
+/**
+ * 票 09 的点位状态样例:**后端六态各一个**,全部坐标合法(都该上图),
+ * 外加一个后端新增、前端不认识的第九个状态 —— 它必须落「状态未发布」而不是被猜成正常。
+ *
+ * 为什么要单独一份而不是改上面那三个点位:票 07/08 的走查把 `2,4,5` / 可上图 2 · 不上图 3
+ * 这些数写成了断言,改默认样例就会改掉那两张票的证据。所以状态演示走**独立场景**
+ * (`__COCKPIT_MOCK_SCENARIO__ = 'point-statuses'`),默认样例一个字节不动。
+ */
+const STATUS_SAMPLE_POINTS = [
+    { pointCode: 'MP-ST-ALARM', pointName: '燃气调压站报警监测点', pointStatus: 'ALARM' },
+    { pointCode: 'MP-ST-WARNING', pointName: '污水排口预警监测点', pointStatus: 'WARNING' },
+    { pointCode: 'MP-ST-WORKORDER', pointName: '供水干管工单处置监测点', pointStatus: 'WORK_ORDER' },
+    { pointCode: 'MP-ST-NORMAL', pointName: '排水泵站正常监测点', pointStatus: 'NORMAL' },
+    { pointCode: 'MP-ST-OFFLINE', pointName: '井盖离线监测点', pointStatus: 'OFFLINE' },
+    { pointCode: 'MP-ST-EMPTY', pointName: '预留无设备监测点', pointStatus: 'EMPTY' },
+    // 后端将来新增的状态:前端不认识,必须如实说「状态未发布」,不得落到任何真实状态上。
+    { pointCode: 'MP-ST-UNKNOWN', pointName: '未知状态监测点', pointStatus: 'MAINTENANCE' },
+].map((item, index) => ({
+    ...item,
+    statusReasons: [],
+    // 沿经纬度铺开一点,免得标记叠在一起看不出形状差异。
+    longitude: 110.141 + index * 0.002,
+    latitude: 28.452 + index * 0.001,
+    coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
+}))
+
+/** 状态样例里的未关联设备(第六态「无关联设备」的落点,形状是十字)。 */
+const STATUS_SAMPLE_EQUIPMENT = [
+    {
+        deviceCode: 'EQ-ST-FREE',
+        deviceName: '未关联设备-001',
+        longitude: 110.155,
+        latitude: 28.459,
+        coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
+    },
+]
+
+/**
+ * 按后端**真实语义**造一页地图摘要(票 08)。
+ *
+ * 照抄 `CockpitMapSnapshotService.load()` 的三条行为,而不是造一份"理想载荷":
+ *   1. 分页窗口是「监测点 + 未关联设备」这**一条总序列**:先排满监测点,再排未关联设备
+ *      (所以后一页可能整页都是设备,或两类混在一页);
+ *   2. 第 2 页起**必须**带 `snapshotAt`,且与当前快照一致,否则抛 `SNAPSHOT_EXPIRED`;
+ *   3. `complete` 只在「本页末尾已到总数」时为真。
+ *
+ * 票 07 起另有一条**必须照抄**:响应带 `coordinateValidation` **全量**汇总
+ * (后端用整份 points/equipment 算完再切片分页,所以它不随页码变化)。
+ *
+ * @param {object} options
+ * @param {number} [options.page] 页码(1 起)
+ * @param {number} [options.pageSize] 每页条数
+ * @param {string} [options.snapshotAt] 客户端带的快照标记(第 1 页不带)
+ * @param {string} [options.currentSnapshot] 本次请求后端当前快照(固定值,见 `planMapSnapshot`)
+ * @param {Array} [options.points] 覆盖监测点集合(票 09 的状态样例用)
+ * @param {Array} [options.equipment] 覆盖未关联设备集合
+ */
+function mapSnapshotPage({
+    page = 1,
+    pageSize = MAP_SNAPSHOT_PAGE_SIZE,
+    snapshotAt = '',
+    currentSnapshot = SNAPSHOT_AT,
+    points = MAP_POINTS,
+    equipment = MAP_EQUIPMENT,
+} = {}) {
+    if (page > 1 && !snapshotAt) {
+        const error = new Error('mock: 第 2 页起必须带 snapshotAt')
+        error.reason = REQUEST_REASON.SNAPSHOT_EXPIRED
+        throw error
+    }
+    if (snapshotAt && snapshotAt !== currentSnapshot) {
+        const error = new Error('mock: 快照标记与当前快照不一致')
+        error.reason = REQUEST_REASON.SNAPSHOT_EXPIRED
+        throw error
+    }
+
+    const all = [...points, ...equipment]
+    const from = Math.max(0, (page - 1) * pageSize)
+    const to = from + pageSize
+    const slice = all.slice(from, to)
+
+    return {
+        page,
+        pageSize,
+        snapshotAt: currentSnapshot,
+        batchUpdatedAt: currentSnapshot,
+        total: all.length,
+        complete: to >= all.length,
+        monitoringPoints: slice.filter((item) => 'pointCode' in item),
+        unassociatedEquipment: slice.filter((item) => 'deviceCode' in item),
+        // 全量口径:与 `CockpitMapSnapshotService.coordinateValidation(points, equipment)` 一致。
+        coordinateValidation: coordinateValidationSummary(all),
+    }
+}
+
+/**
+ * 坐标校验汇总(票 07):照抄后端 `CockpitCoordinateValidationSummary` 的字段名与判据。
+ *
+ * 后端的分法是**三桶**:`renderable` 优先,其余里 `state === 'INVALID'` 记非法,
+ * 剩下的一律记「未校验」(`CockpitMapSnapshotService` 第 205-231 行)。
+ * 这里照抄同一个分法 —— 换一套算法就会让前端显示的数与后端对不上。
+ */
+function coordinateValidationSummary(objects) {
+    let renderableCount = 0
+    let invalidCount = 0
+    let unverifiedCount = 0
+
+    for (const item of objects) {
+        const validation = item.coordinateValidation || {}
+        if (validation.renderable === true) renderableCount += 1
+        else if (validation.state === 'INVALID') invalidCount += 1
+        else unverifiedCount += 1
+    }
+
+    return {
+        crs: 'WGS84',
+        totalObjectCount: objects.length,
+        renderableCount,
+        invalidCount,
+        unverifiedCount,
+    }
+}
+
+/**
+ * 从请求路径的查询串里取一个整数参数。
+ * 走查与演示要能现场改页大小(`window.__COCKPIT_MOCK_MAP_PAGE_SIZE__`),
+ * 所以分页参数必须真的从请求上读,而不是写死一份。
+ */
+function queryInteger(path, key, fallback) {
+    const query = String(path || '').split('?')[1] || ''
+    const value = new URLSearchParams(query).get(key)
+    const parsed = Number(value)
+    return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
+}
+
+/** 查询串里的快照标记(第 1 页不带)。 */
+function querySnapshot(path) {
+    const query = String(path || '').split('?')[1] || ''
+    return new URLSearchParams(query).get('snapshotAt') || ''
+}
+
+/**
+ * 地图摘要样例(票 08):**按后端真实分页语义**造,而不是只给一页。
+ *
+ * 场景(`overrides` 由走查现场给,见 `mocks/index.js`):
+ *   · `success`  —— 正常分页,后端当前快照固定;
+ *   · `empty`    —— 后端说总数 0(不是「这一页恰好没有」);
+ *   · `degraded` —— 保持票 05 的字段缺失样例(单页、`complete: false`);
+ *   · `stale`    —— **取完第 1 页后后端换了快照**(`expireAfterPage`),第 2 页带着旧标记回来即失效;
+ *   · `failed`   —— 由统一出口抛出,不在这里给载荷。
+ *
+ * @param {string} scenario 五类样例
+ * @param {string} path 请求路径(分页参数在这里)
+ * @param {object} [overrides] `{ pageSize, snapshotAt, expireAfterPage }`
+ */
+function mapSnapshotPayload(scenario, path, overrides = {}) {
+    const pageSize = overrides.pageSize || MAP_SNAPSHOT_PAGE_SIZE
+    const page = queryInteger(path, 'page', 1)
+    const baseSnapshot = overrides.snapshotAt || SNAPSHOT_AT
+
+    if (scenario === 'empty') {
+        return {
+            page,
+            pageSize,
+            snapshotAt: baseSnapshot,
+            batchUpdatedAt: baseSnapshot,
+            total: 0,
+            complete: true,
+            monitoringPoints: [],
+            unassociatedEquipment: [],
+            // 真·空快照:全量口径各项都是 0(这里 0 是真实值,不是缺失)。
+            coordinateValidation: coordinateValidationSummary([]),
+        }
+    }
+
+    if (scenario === 'degraded') {
+        const degraded = [{ pointCode: 'MP-DEGRADED', pointName: '字段缺失点位', pointStatus: 'NORMAL', statusReasons: [] }]
+        return {
+            page: 1,
+            pageSize: 500,
+            snapshotAt: baseSnapshot,
+            complete: false,
+            monitoringPoints: degraded,
+            unassociatedEquipment: [],
+            // 后端没给 coordinateValidation 的点位:仍占一个位置,落在「未校验」桶里。
+            coordinateValidation: coordinateValidationSummary(degraded),
+        }
+    }
+
+    // 票 09:后端六态 + 未关联设备 + 一个前端不认识的状态,全部可上图(状态演示用)。
+    if (scenario === 'point-statuses') {
+        return mapSnapshotPage({
+            page,
+            pageSize,
+            snapshotAt: querySnapshot(path),
+            currentSnapshot: baseSnapshot,
+            points: STATUS_SAMPLE_POINTS,
+            equipment: STATUS_SAMPLE_EQUIPMENT,
+        })
+    }
+
+    // 过期样例:第 1 页照常给旧快照,之后的请求后端已经换成新快照 —— 客户端带着旧标记回来即失效。
+    const expired = scenario === 'stale' && Number.isInteger(overrides.expireAfterPage) && page > overrides.expireAfterPage
+    const currentSnapshot = expired ? '2026-09-02T10:30:00+08:00' : baseSnapshot
+    return mapSnapshotPage({ page, pageSize, snapshotAt: querySnapshot(path), currentSnapshot })
+}
+
+// ---------------------------------------------------------------- 监测点详情
+
+/**
+ * 监测点详情的样例载荷(票 05 的默认 + 四类异常样例)。
+ *
+ * 票 10 追加 `point-detail-order`:设备卡片刻意**逆状态优先级**排列
+ * (正常 → 报警 → 预警),后端的返回顺序 ≠ 前端按优先级排出来的顺序 ——
+ * 界面若自作主张排序,真机走查里那条「卡片顺序 = 后端顺序」就会当场露馅。
+ * 默认样例一个字节未动(票 05 的断言依赖它)。
+ */
+function pointDetailPayload(scenario) {
+    if (scenario === 'empty') {
+        return {
+            ...pointDetail({ code: 'MP-EMPTY', name: '预留监测点', status: 'EMPTY' }),
+            devices: [],
+            deviceStatistics: { totalDeviceCount: 0, alarmDeviceCount: 0, warningDeviceCount: 0, workOrderDeviceCount: 0, normalDeviceCount: 0, offlineDeviceCount: 0 },
+            statusReasons: [],
+        }
+    }
+
+    if (scenario === 'degraded') {
+        return { monitoringPointCode: 'MP-DEGRADED', monitoringPointName: '字段缺失点位', devices: [], statusReasons: [] }
+    }
+
+    if (scenario === 'point-detail-order') {
+        const devices = [
+            { deviceCode: 'EQ-ORDER-NORMAL', deviceName: '顺序样例-正常设备', deviceTypeName: '压力计', deviceStatus: 'NORMAL', statusLabel: '正常', statusReasons: [], statusTime: '2026-09-02T09:59:30+08:00' },
+            { deviceCode: 'EQ-ORDER-ALARM', deviceName: '顺序样例-报警设备', deviceTypeName: '流量计', deviceStatus: 'ALARM', statusLabel: '报警', statusReasons: ['流量超高'], statusTime: '2026-09-02T09:59:40+08:00' },
+            { deviceCode: 'EQ-ORDER-WARNING', deviceName: '顺序样例-预警设备', deviceTypeName: '液位计', deviceStatus: 'WARNING', statusLabel: '预警', statusReasons: ['液位偏高'], statusTime: '2026-09-02T09:59:50+08:00' },
+        ]
+        return {
+            ...pointDetail({ code: 'MP-ORDER', name: '顺序样例监测点', status: 'ALARM' }),
+            devices,
+            deviceStatistics: { totalDeviceCount: 3, alarmDeviceCount: 1, warningDeviceCount: 1, workOrderDeviceCount: 0, normalDeviceCount: 1, offlineDeviceCount: 0 },
+            statusReasons: [{ status: 'ALARM', deviceCode: 'EQ-ORDER-ALARM', reason: '流量超高' }],
+        }
+    }
+
+    return pointDetail()
+}
+
+function pointDetail({ code = 'MP-002', name = '燃气调压站监测点', status = 'ALARM' } = {}) {
+    const devices = [
+        {
+            deviceCode: 'EQ-001',
+            deviceName: '供水压力计-001',
+            deviceTypeName: '压力计',
+            deviceStatus: 'ALARM',
+            statusLabel: '报警',
+            statusReasons: ['压力超高'],
+            statusTime: '2026-09-02T09:59:00+08:00',
+        },
+        {
+            deviceCode: 'EQ-002',
+            deviceName: '智能井盖-002',
+            deviceTypeName: '智能井盖',
+            deviceStatus: 'NORMAL',
+            statusLabel: '正常',
+            statusReasons: [],
+            statusTime: '2026-09-02T09:59:30+08:00',
+        },
+    ]
+
+    return {
+        requestId: 'mock-point-detail',
+        apiVersion: 'v1',
+        readOnly: true,
+        monitoringPointCode: code,
+        monitoringPointName: name,
+        pointStatus: status,
+        statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[status] || '',
+        statusReasons: [{ status, deviceCode: 'EQ-001', reason: '压力超高' }],
+        deviceStatistics: {
+            totalDeviceCount: devices.length,
+            alarmDeviceCount: 1,
+            warningDeviceCount: 0,
+            workOrderDeviceCount: 0,
+            normalDeviceCount: 1,
+            offlineDeviceCount: 0,
+        },
+        devices,
+        coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
+        statusDictionary: ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].map((item, index) => ({
+            status: item,
+            label: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[item],
+            priority: index + 1,
+        })),
+    }
+}
+
+// ---------------------------------------------------------------- 设备详情聚合
+
+function aggregateSection(state, reason, data, message) {
+    return { state, reason, message: message || null, data }
+}
+
+const ARCHIVE = {
+    deviceCode: 'TC-PS-WS-202609020012',
+    deviceName: '污水环境监测设备',
+    deviceModel: 'WS-PRO-2000',
+    deviceSpec: '悬浮物/COD/氨氮/电导率/PH',
+    manufacturer: '沅陵专用仪表厂',
+    deviceTypeName: '污水环境监测设备',
+    assetValue: 128000,
+    expectedServiceYears: 10,
+    equipmentLocation: '太常片区',
+    ownershipUnit: '沅陵县排水管理所',
+    maintainer: '沅陵运维一组',
+    maintainerPhone: '0745-0000000',
+    installTime: '2024-03-01T00:00:00+08:00',
+    latestImage: { url: '/cockpit/aerial.webp', capturedAt: '2026-08-30T10:20:00+08:00' },
+}
+
+const METRICS = {
+    deviceCode: ARCHIVE.deviceCode,
+    state: 'CURRENT',
+    stateReason: 'READY',
+    metrics: [
+        { metricCode: 'COD', metricName: 'COD', value: 18.2, unit: 'mg/L', status: 'WARNING', sampledAt: '2026-09-02T09:59:30+08:00', freshness: 'CURRENT' },
+        // 边界:真实 0 是有效读数,适配层不得当成缺失。
+        { metricCode: 'FLOW', metricName: '流量', value: 0, unit: 'm³/h', status: 'NORMAL', sampledAt: '2026-09-02T09:59:30+08:00', freshness: 'CURRENT' },
+    ],
+}
+
+const WARNING = { warningNo: 'WRN-20260902-001', warningName: '污水COD预警', warningType: 'WS_ENV', level: 2, levelColor: 'YELLOW', status: 'PROCESSING', eventTime: '2026-09-02T09:59:30+08:00', value: 18.2, unit: 'mg/L', deviceCode: ARCHIVE.deviceCode, deviceName: ARCHIVE.deviceName, location: '太常片区' }
+const ALARM = { alarmId: 'ALM-20260902-001', warningCode: 'WS_ENV_HIGH', warningName: '污水COD报警', warningType: 'WS_ENV', level: 1, levelColor: 'RED', status: 'UNHANDLED', eventTime: '2026-09-02T10:00:00+08:00', value: 42, thresholdMinValue: 40, thresholdMaxValue: 50, unit: 'mg/L', deviceCode: ARCHIVE.deviceCode, deviceName: ARCHIVE.deviceName, location: '太常片区' }
+const RELATION = { relationId: 'REL-001', warningNo: WARNING.warningNo, alarmId: ALARM.alarmId, relatedAt: '2026-09-02T10:00:00+08:00' }
+const WORK_ORDER = { workOrderCode: 'WO-2026090200001', deviceCode: ARCHIVE.deviceCode, orderType: 1, orderTypeName: '故障维修', orderLevel: 2, orderDesc: '处理异常', stateCode: 4, stateName: '处理中', createTime: '2026-09-02T09:00:00+08:00', updateTime: '2026-09-02T10:00:00+08:00', planFinishTime: '2026-09-03T09:00:00+08:00', realFinishTime: null, eventAssociation: { eventSource: 'ALARM', eventCode: ALARM.alarmId, associatedAt: '2026-09-02T10:00:00+08:00' } }
+const TIMELINE = { workOrder: { ...WORK_ORDER }, timelineEntries: [{ logId: 1, operationName: '创建工单', operationDesc: '待派单', operatorName: '系统', operatedAt: '2026-09-02T09:00:00+08:00' }] }
+
+function equipmentAggregate(scenario, deviceCode = '') {
+    const success = aggregateSection('SUCCESS', 'SUCCESS', null)
+    // 请求哪台设备就报哪台(与真接口一致,客户端传错编码能看出来)。
+    const code = deviceCode || ARCHIVE.deviceCode
+    const archive = { ...ARCHIVE, deviceCode: code }
+
+    if (scenario === 'empty') {
+        return {
+            deviceCode: code,
+            aggregateState: 'EMPTY',
+            serverTime: SNAPSHOT_AT,
+            archive: aggregateSection('EMPTY', 'NO_ARCHIVE', null),
+            metrics: aggregateSection('EMPTY', 'NO_METRICS', null),
+            warnings: aggregateSection('EMPTY', 'NO_EVENTS', null),
+            alarms: aggregateSection('EMPTY', 'NO_EVENTS', null),
+            alarmWarningRelations: aggregateSection('EMPTY', 'NO_EVENTS', null),
+            workOrders: aggregateSection('EMPTY', 'NO_WORK_ORDERS', null),
+            timelines: aggregateSection('EMPTY', 'NO_WORK_ORDERS', null),
+            latestImage: aggregateSection('EMPTY', 'NO_IMAGE', null),
+        }
+    }
+
+    if (scenario === 'degraded') {
+        // 字段缺失:档案只剩编码,指标缺 value/unit,工单缺 stateName(靠七态字典补)。
+        return {
+            deviceCode: code,
+            aggregateState: 'READY',
+            serverTime: SNAPSHOT_AT,
+            archive: aggregateSection('SUCCESS', 'SUCCESS', { deviceCode: code }),
+            metrics: aggregateSection('SUCCESS', 'SUCCESS', { deviceCode: code, state: 'CURRENT', stateReason: 'READY', metrics: [{ metricCode: 'COD', metricName: 'COD', sampledAt: '2026-09-02T09:59:30+08:00' }] }),
+            warnings: aggregateSection('SUCCESS', 'SUCCESS', [WARNING]),
+            alarms: aggregateSection('SUCCESS', 'SUCCESS', [ALARM]),
+            alarmWarningRelations: aggregateSection('SUCCESS', 'SUCCESS', [RELATION]),
+            workOrders: aggregateSection('SUCCESS', 'SUCCESS', [{ workOrderCode: 'WO-DEGRADED', stateCode: 3 }]),
+            timelines: aggregateSection('SUCCESS', 'SUCCESS', [TIMELINE]),
+            latestImage: aggregateSection('EMPTY', 'NO_IMAGE', null),
+        }
+    }
+
+    if (scenario === 'stale') {
+        return {
+            deviceCode: code,
+            aggregateState: 'PARTIAL',
+            serverTime: SNAPSHOT_AT,
+            archive: aggregateSection('SUCCESS', 'SUCCESS', archive),
+            // 过期但保留旧值(票 16:保留最后已知值 + 过期标识)。
+            metrics: aggregateSection('STALE', 'SAMPLE_OUTDATED', { ...METRICS, state: 'STALE', stateReason: 'SAMPLE_OUTDATED' }),
+            warnings: aggregateSection('SUCCESS', 'SUCCESS', [WARNING]),
+            alarms: aggregateSection('SUCCESS', 'SUCCESS', [ALARM]),
+            alarmWarningRelations: aggregateSection('SUCCESS', 'SUCCESS', [RELATION]),
+            workOrders: aggregateSection('SUCCESS', 'SUCCESS', [WORK_ORDER]),
+            timelines: aggregateSection('SUCCESS', 'SUCCESS', [TIMELINE]),
+            latestImage: aggregateSection('SUCCESS', 'SUCCESS', archive.latestImage),
+        }
+    }
+
+    if (scenario === 'equipment-partial') {
+        // 票 11 验收第 3 条:任一分区失败不遮盖其它成功分区。
+        //
+        // 为什么要一个**独立场景名**而不是复用 `failed`:`failed` 在注册层就抛错
+        // (整个端点读不到,`data` 为 null),那是"这份详情根本没取到",
+        // 与"取到了、只是其中三块分区各自失败"是两件事。后端契约会把后者原样返回
+        // (`aggregateState: PARTIAL` + 每分区自带 state/reason/message),
+        // 所以必须有一个能真正走通的样例,否则这条验收在 mock 上无法演示。
+        return {
+            deviceCode: code,
+            aggregateState: 'PARTIAL',
+            serverTime: SNAPSHOT_AT,
+            archive: aggregateSection('FAILED', 'READ_FAILURE', null, '设备档案数据暂不可用'),
+            metrics: aggregateSection('SUCCESS', 'SUCCESS', METRICS),
+            warnings: aggregateSection('SUCCESS', 'SUCCESS', [WARNING]),
+            alarms: aggregateSection('SUCCESS', 'SUCCESS', [ALARM]),
+            alarmWarningRelations: aggregateSection('SUCCESS', 'SUCCESS', [RELATION]),
+            workOrders: aggregateSection('FAILED', 'READ_FAILURE', null, '工单数据暂不可用'),
+            timelines: aggregateSection('FAILED', 'SOURCE_SECTION_FAILED', null, '工单分区失败,时间线不可用'),
+            latestImage: aggregateSection('FAILED', 'SOURCE_SECTION_FAILED', null, '设备档案失败,图片不可用'),
+        }
+    }
+
+    return {
+        deviceCode: code,
+        aggregateState: 'READY',
+        serverTime: SNAPSHOT_AT,
+        archive: { ...success, data: archive },
+        metrics: { ...success, data: METRICS },
+        warnings: { ...success, data: [WARNING] },
+        alarms: { ...success, data: [ALARM] },
+        alarmWarningRelations: { ...success, data: [RELATION] },
+        workOrders: { ...success, data: [WORK_ORDER] },
+        timelines: { ...success, data: [TIMELINE] },
+        latestImage: { ...success, data: archive.latestImage },
+    }
+}
+
+// ---------------------------------------------------------------- 事件与工单
+
+function eventRead(scenario, deviceCode = '') {
+    const base = {
+        requestId: 'mock-event-read',
+        apiVersion: 'v1',
+        readOnly: true,
+        deviceCode: deviceCode || ARCHIVE.deviceCode,
+        deviceType: 'ws_sewage_environment',
+        deviceTypeName: '污水环境监测设备',
+        startTime: null,
+        endTime: null,
+        timezone: 'Asia/Shanghai',
+        page: 1,
+        pageSize: 0,
+    }
+
+    if (scenario === 'empty') {
+        return { ...base, state: 'EMPTY', stateReason: 'NO_EVENTS', warnings: [], alarms: [], alarmWarningRelations: [], total: 0 }
+    }
+
+    if (scenario === 'degraded') {
+        // 缺 levelColor 与设备编码:适配层不得猜颜色,也不得伪造归属设备。
+        return {
+            ...base,
+            state: 'READY',
+            stateReason: 'READY',
+            warnings: [{ warningNo: 'WRN-DEGRADED-001', warningName: '字段缺失预警', level: 2, status: 'PROCESSING', eventTime: '2026-09-02T09:59:30+08:00', unit: 'mg/L' }],
+            alarms: [],
+            alarmWarningRelations: [],
+            total: 1,
+        }
+    }
+
+    if (scenario === 'stale') {
+        return {
+            ...base,
+            state: 'STALE',
+            stateReason: 'SAMPLE_OUTDATED',
+            warnings: [{ ...WARNING, freshness: 'STALE' }],
+            alarms: [],
+            alarmWarningRelations: [],
+            total: 1,
+        }
+    }
+
+    return {
+        ...base,
+        state: 'READY',
+        stateReason: 'READY',
+        warnings: [WARNING],
+        alarms: [ALARM],
+        alarmWarningRelations: [RELATION],
+        total: 2,
+    }
+}
+
+// ---------------------------------------------------------------- 新鲜度
+
+function freshnessPayload(scenario, deviceCode = '') {
+    const base = {
+        requestId: 'mock-freshness',
+        apiVersion: 'v1',
+        readOnly: true,
+        deviceCode: deviceCode || ARCHIVE.deviceCode,
+        serverUpdatedAt: SNAPSHOT_AT,
+        calibrationPeriodSeconds: 60,
+    }
+
+    if (scenario === 'empty') {
+        return { ...base, updatedAt: null, statusTime: null, freshness: 'EMPTY', staleReason: null, missedCycles: 0, dataAgeSeconds: 0, retained: false, message: null }
+    }
+
+    if (scenario === 'degraded') {
+        // 只给了 freshness,没给任何时间:界面必须显示占位符而不是「0 秒前」。
+        return { ...base, freshness: 'CURRENT' }
+    }
+
+    if (scenario === 'stale') {
+        return { ...base, updatedAt: '2026-09-02T09:58:00+08:00', statusTime: null, freshness: 'EXPIRED', staleReason: 'TWO_UPDATE_CYCLES_MISSED', missedCycles: 2, dataAgeSeconds: 120, retained: true, message: '数据暂不可用/已过期' }
+    }
+
+    return { ...base, updatedAt: '2026-09-02T09:59:50+08:00', statusTime: '2026-09-02T09:59:55+08:00', freshness: 'CURRENT', staleReason: null, missedCycles: 0, dataAgeSeconds: 5, retained: false, message: null }
+}
+
+// ---------------------------------------------------------------- 注册
+
+/** 各接口在五类样例下的载荷(`failed` 由处理器抛出,不在这里给载荷)。 */
+const PAYLOADS = {
+    // 票 08:地图摘要按**真实分页语义**给(分页窗口横跨监测点与未关联设备、第 2 页起要求快照一致)。
+    '/map-snapshot': (s, _requested, context) => mapSnapshotPayload(s, context.path, context.mapSnapshot),
+    '/monitoring-points/*': (s) => pointDetailPayload(s),
+    '/equipments/*/detail-aggregate': (s, requested) => equipmentAggregate(s, requested),
+    '/equipments/*/events/current': (s, requested) => eventRead(s, requested),
+    '/equipments/*/events/history': (s, requested) => eventRead(s, requested),
+    '/equipments/*/work-orders': (s) => ({
+        requestId: 'mock-work-orders',
+        apiVersion: 'v1',
+        readOnly: true,
+        deviceCode: ARCHIVE.deviceCode,
+        eventSource: null,
+        eventCode: null,
+        state: s === 'empty' ? 'EMPTY' : 'READY',
+        stateReason: s === 'empty' ? 'NO_WORK_ORDERS' : 'READY',
+        stateDictionary: [
+            { code: 1, name: '待派单' },
+            { code: 2, name: '待接单' },
+            { code: 3, name: '已接单' },
+            { code: 4, name: '处理中' },
+            { code: 5, name: '待验收' },
+            { code: 6, name: '已结案' },
+            { code: 7, name: '已取消' },
+        ],
+        workOrders: s === 'empty' ? [] : [WORK_ORDER],
+    }),
+    '/work-orders/*/timeline': (s) => (s === 'empty' ? { workOrder: null, timelineEntries: [] } : TIMELINE),
+    '/equipments/*/freshness': (s, requested) => freshnessPayload(s, requested),
+    '/equipments/*/archive': (s) => (s === 'empty' ? null : ARCHIVE),
+}
+
+/**
+ * 从请求路径里取出模板 `*` 命中的那一段(即路径参数)。
+ * `('/equipments/TC-1/detail-aggregate', '/equipments/' + '*' + '/detail-aggregate')` → `TC-1`。
+ * 模板里有多个 `*` 时只取第一个——当前接口最多一个路径参数,够用且不猜。
+ */
+function pathParameter(requestPath, template) {
+    const star = template.indexOf('*')
+    if (star === -1) return ''
+
+    const prefix = template.slice(0, star)
+    const suffix = template.slice(star + 1)
+    const bare = String(requestPath || '').split('?')[0]
+
+    if (!bare.startsWith(prefix)) return ''
+    const rest = bare.slice(prefix.length)
+    const end = suffix ? rest.indexOf(suffix) : rest.length
+    if (end === -1) return ''
+    return decodeURIComponent(rest.slice(0, end))
+}
+
+/**
+ * 注册全部契约化 mock。
+ * @param {object} [options]
+ * @param {string} [options.scenario] `success`(默认)/`empty`/`degraded`/`stale`/`failed`/`unauthorized`
+ * @param {string} [options.onlyPath] 只让这一个路径走「异常样例」,其余保持 `success`
+ *        —— 票 05 验收第 6 条要的正是「只把**受影响分区**标为数据不可用」,
+ *        所以必须能只让一个分区坏掉、其余照常成功。
+ * @param {(path: string) => boolean} [options.only] 同上,函数形式(便于按前缀选中一族接口)
+ * @param {string} [options.prefix] 同上,按路径前缀选中一族接口(`/equipments/`)
+ * @param {object} [options.mapSnapshot] 地图摘要分页的现场覆盖(票 08 走查用):
+ *        `{ pageSize, snapshotAt, expireAfterPage }`
+ * @param {object} handlers services/cockpitApi.js 的三个注册函数(注入以便单测)
+ */
+export function registerCockpitMocks(options = {}) {
+    const { scenario = 'success', onlyPath = '', prefix = '', handlers } = options
+    const mapSnapshot = options.mapSnapshot || {}
+    // `prefix` 是 `only` 的便捷写法:两者都给时以 `only` 为准。
+    const only = options.only || (prefix ? (path) => path.startsWith(prefix) : null)
+    const register = (handlers && handlers.registerMockTransport) || null
+    if (!register) {
+        throw new Error('registerCockpitMocks 需要注入 registerMockTransport')
+    }
+    const reset = (handlers && handlers.resetMockTransportsForTest) || null
+
+    if (reset) reset()
+
+    const targets = new Set()
+
+    for (const [path, build] of Object.entries(PAYLOADS)) {
+        const applies = !onlyPath && !only ? true : only ? !!only(path) : path === onlyPath
+        const active = applies ? scenario : 'success'
+
+        register(path, async (context) => {
+            // `failed`:抛出让统一出口归类成「不可用」,模拟分区自己读取失败。
+            if (active === 'failed') {
+                throw new Error(`mock: ${path} 读取失败`)
+            }
+            if (active === 'unauthorized') {
+                // 401 不是载荷:把凭据类失败原因交给统一出口归类,
+                // 由它回 `{ok:false, reason:'unauthorized'}`(服务层只认白名单里的原因)。
+                const error = new Error('mock: 凭据未通过校验')
+                error.reason = REQUEST_REASON.UNAUTHORIZED
+                throw error
+            }
+
+            // 带路径参数的接口回显请求里的编码:这样「按编码取数」的行为与真接口一致
+            // (客户端传错编码时能看出来,而不是永远拿到同一台设备)。
+            const requested = pathParameter(context.path, path)
+            const payload = build(active, requested, { path: context.path, mapSnapshot })
+            if (!requested) return payload
+
+            if (path === '/monitoring-points/*') {
+                return { ...payload, monitoringPointCode: requested }
+            }
+            // 设备族接口:统一把请求的设备编码写回载荷(设备详情/事件/工单/新鲜度/档案都一样)。
+            if (typeof payload === 'object' && payload !== null && 'deviceCode' in payload) {
+                return { ...payload, deviceCode: requested }
+            }
+            return payload
+        })
+
+        targets.add(path)
+    }
+
+    return { scenario, paths: [...targets], isolated: onlyPath || prefix || (only ? 'predicate' : '') }
+}

+ 60 - 0
src/modules/cockpit/mocks/index.js

@@ -0,0 +1,60 @@
+/**
+ * 契约化 mock 的注册入口(票 05)。
+ *
+ * 为什么要有这一层而不是让页面直接 import `contractMocks.js`:
+ * 「注册」与「样例数据」分开——页面只调用 `ensureCockpitMocks()`,
+ * 换样例(走查要演示 401/空/过期)不改页面代码。
+ *
+ * 场景覆盖(走查与人工演示都用同一套开关,无需改代码重新构建):
+ *   `window.__COCKPIT_MOCK_SCENARIO__` = `success`(默认)| `empty` | `degraded` | `stale` | `failed` | `unauthorized`
+ *       票 09 追加 `point-statuses`:后端六态 + 未关联设备 + 一个前端不认识的状态,全部可上图。
+ *       它只影响 `/map-snapshot` 的样例内容,不改默认场景的任何数字(票 07/08 的断言依赖那些数)。
+ *       票 10 追加 `point-detail-order`:设备卡片逆状态优先级排列,验「顺序由后端返回顺序决定」。
+ *       票 11 追加 `equipment-partial`:设备详情**分区级部分失败**(档案/工单/图片各自 FAILED,
+ *       指标/预警/报警照常 SUCCESS),验「任一分区失败不遮盖其它成功分区」。它与 `failed` 不同:
+ *       `failed` 是端点整体读不到(`data` 为 null),那是"详情没取到"而不是"取到了但有坏块"。
+ *   `window.__COCKPIT_MOCK_ONLY__`     = 只让这一个路径走上面的异常样例,其余保持成功
+ *        (票 05 验收第 6 条要的正是「只把**受影响分区**标为数据不可用」)
+ *   `window.__COCKPIT_MOCK_PREFIX__`   = 同上,按路径前缀选中一族接口(如 `/equipments/`)
+ *   `window.__COCKPIT_MOCK_MAP__`      = 地图摘要分页的现场覆盖(票 08):
+ *        `{ pageSize, snapshotAt, expireAfterPage }`
+ *        —— 例如 `{ pageSize: 2, expireAfterPage: 1 }` 演示「取完第 1 页后快照失效」
+ *
+ * 注册是幂等的:重复调用只覆盖,不叠加。
+ */
+import {
+    getMockTransportPaths,
+    registerMockTransport,
+    resetMockTransportsForTest,
+} from '../services/cockpitApi.js'
+import { registerCockpitMocks } from './contractMocks.js'
+
+/** 只读全局开关(走查用;生产不会设这些键)。 */
+function readScenario() {
+    if (typeof globalThis === 'undefined') return {}
+    return {
+        scenario: globalThis.__COCKPIT_MOCK_SCENARIO__ || 'success',
+        onlyPath: globalThis.__COCKPIT_MOCK_ONLY__ || '',
+        prefix: globalThis.__COCKPIT_MOCK_PREFIX__ || '',
+        mapSnapshot: globalThis.__COCKPIT_MOCK_MAP__ || {},
+    }
+}
+
+/** 注册全部契约化 mock(幂等)。返回本次生效的场景,便于走查/排查。 */
+export function ensureCockpitMocks(overrides = {}) {
+    const global = readScenario()
+    const scenario = overrides.scenario || global.scenario
+    const onlyPath = overrides.onlyPath || global.onlyPath
+    const prefix = overrides.prefix || global.prefix
+    const mapSnapshot = overrides.mapSnapshot || global.mapSnapshot
+
+    const result = registerCockpitMocks({
+        scenario,
+        onlyPath,
+        prefix,
+        mapSnapshot,
+        handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
+    })
+
+    return result
+}

+ 27 - 0
src/modules/cockpit/services/cameraControl.js

@@ -322,6 +322,31 @@ export function createCameraControl({
         return true
     }
 
+    /**
+     * 换 pitch 上下限(票 21:地面/管线视图切换时放宽到允许平视)。
+     * 只换限制,不动当前角度 —— clamp 与 orbit 下一次就会用新范围。
+     */
+    function setPitchLimits(nextLimits) {
+        if (!nextLimits || !Number.isFinite(nextLimits.min) || !Number.isFinite(nextLimits.max)) return limits
+        limits = nextLimits
+        return limits
+    }
+
+    /**
+     * 直接设目标 hpr(票 21:切换视图时只改 pitch/range,heading 与中心不动)。
+     * 走同一套阻尼收敛,所以切换过程是平滑的;不动 `state.target.pivot` = 不重置中心。
+     */
+    function setHpr(nextHpr) {
+        if (!nextHpr) return false
+        state.target.hpr = {
+            heading: Number.isFinite(nextHpr.heading) ? normalizeHeading(nextHpr.heading) : state.target.hpr.heading,
+            pitch: clampPitch(Number.isFinite(nextHpr.pitch) ? nextHpr.pitch : state.target.hpr.pitch, limits),
+            range: Number.isFinite(nextHpr.range) ? clamp(nextHpr.range, RANGE_LIMITS.min, RANGE_LIMITS.max) : state.target.hpr.range,
+        }
+        schedule()
+        return true
+    }
+
     /**
      * 套用一个**完整视角预设**(票 30):中心 + heading/pitch/range 一起换。
      *
@@ -427,6 +452,8 @@ export function createCameraControl({
         wheel,
         setFocus,
         applyViewPreset,
+        setPitchLimits,
+        setHpr,
         getState,
         projectPivot,
         setDampingTau,

+ 13 - 3
src/modules/cockpit/services/cesiumAssets.js

@@ -33,10 +33,20 @@ function pickBase() {
     return './'
 }
 
-/** 幂等写入全局基址并返回它;非浏览器环境只返回计算值,不抛错。 */
-export function ensureCesiumBaseUrl() {
+/**
+ * 幂等写入全局基址并返回它;非浏览器环境只返回计算值,不抛错。
+ * @param {string} [override] 部署配置给的基址(`map.cesium.baseUrl`):给了就优先用它,
+ *   相对值按文档基址解析,并补尾斜杠(Cesium 是拼 `Workers/...` 这种相对路径)。
+ */
+export function ensureCesiumBaseUrl(override = '') {
     const documentBase = typeof document !== 'undefined' && document.baseURI ? document.baseURI : 'http://localhost/'
-    const url = resolveCesiumBaseUrl(pickBase(), documentBase)
+    let url
+    if (override) {
+        const resolved = new URL(override, documentBase).href
+        url = resolved.endsWith('/') ? resolved : `${resolved}/`
+    } else {
+        url = resolveCesiumBaseUrl(pickBase(), documentBase)
+    }
     if (typeof window !== 'undefined') {
         window.CESIUM_BASE_URL = url
     }

+ 404 - 19
src/modules/cockpit/services/cesiumEngine.js

@@ -6,7 +6,8 @@
  *
  * 本模块负责两件事:
  *   1. 「把引擎接到宿主上」(票 27):唯一 `Viewer`、自带控件与默认相机输入全关、
- *      不碰需要账号的能力、不配置底图;
+ *      不碰需要账号的能力;底图按 `domain/basemap.js` 的计划挂(票 29),
+ *      没配就不挂(票 27 的「底图未配置不伪造配准」);
  *   2. 「把相机契约接到引擎上」(票 03):`createCameraEngine()` 把三键契约需要的原语
  *      (弹簧臂 `lookAtTransform(东北天局部系, HeadingPitchRange)`、屏幕中心地面拾取、
  *      中心点投影)包成 services/cameraControl.js 的引擎接口 —— 相机语义本身不在本文件,
@@ -15,22 +16,33 @@
 import {
     Cartesian2,
     Cartesian3,
+    Cartographic,
+    Cesium3DTileset,
+    Cesium3DTileStyle,
+    CesiumTerrainProvider,
     Color,
+    CustomDataSource,
     Ellipsoid,
+    HeightReference,
     HeadingPitchRange,
+    ImageryLayer,
     Math as CesiumMath,
     Matrix4,
+    Rectangle,
     SceneTransforms,
+    TileMapServiceImageryProvider,
     Transforms,
+    UrlTemplateImageryProvider,
+    VerticalOrigin,
     Viewer,
+    buildModuleUrl,
+    sampleTerrainMostDetailed,
 } from 'cesium'
 import 'cesium/Build/Cesium/Widgets/widgets.css'
 
 const DEG = Math.PI / 180
 
 const VIEWER_OPTIONS = {
-    // 无底图 → 不往某个假坐标范围里塞临时影像。
-    baseLayer: false,
     // 自带控件全关(导航/时间轴/拾取框等)。
     animation: false,
     timeline: false,
@@ -46,13 +58,35 @@ const VIEWER_OPTIONS = {
     vrButton: false,
 }
 
+/** 按底图计划建 imagery layer;未配置或计划无效时返回 false = 不配底图(票 27:不伪造地理配准)。 */
+function createBaseLayer(plan) {
+    if (!plan || !plan.ready) return false
+
+    if (plan.kind === 'tms') {
+        // Cesium 自带的离线 TMS(随 Assets 目录进产物,不需要账号或外网)。
+        return ImageryLayer.fromProviderAsync(TileMapServiceImageryProvider.fromUrl(buildModuleUrl(plan.url)))
+    }
+
+    if (plan.kind === 'xyz') {
+        const options = { url: plan.url }
+        if (plan.subdomains && plan.subdomains.length) options.subdomains = plan.subdomains
+        if (Number.isInteger(plan.minimumLevel)) options.minimumLevel = plan.minimumLevel
+        if (Number.isInteger(plan.maximumLevel)) options.maximumLevel = plan.maximumLevel
+        if (plan.credit) options.credit = plan.credit
+        return new ImageryLayer(new UrlTemplateImageryProvider(options))
+    }
+
+    return false
+}
+
 /**
  * 在宿主要素上创建 Viewer。
  * @param {Element} host 宿主要素(`.map-canvas`)
+ * @param {object} [basemapPlan] `domain/basemap.js` 的底图计划(票 29);缺省 = 不配底图
  * @returns {Viewer}
  */
-export function createViewer(host) {
-    const viewer = new Viewer(host, VIEWER_OPTIONS)
+export function createViewer(host, basemapPlan) {
+    const viewer = new Viewer(host, { ...VIEWER_OPTIONS, baseLayer: createBaseLayer(basemapPlan) })
 
     const controller = viewer.scene.screenSpaceCameraController
     controller.enableRotate = false
@@ -61,7 +95,7 @@ export function createViewer(host) {
     controller.enableLook = false
     controller.enableTranslate = false
 
-    // 无底图时用驾驶舱底色画球体,避免 Cesium 默认亮蓝球穿帮。
+    // 球体底色用驾驶舱底色:没配底图时看到的就是这个球,配了底图会被影像盖住。
     viewer.scene.globe.baseColor = Color.fromCssColorString('#04121f')
     return viewer
 }
@@ -70,10 +104,332 @@ export function resizeViewer(viewer) {
     viewer.resize()
 }
 
+/** 叠加影像(票 21):本地精细切片画在底图之上,URL 模板里可用 `{reverseY}` 表示 TMS 的 y。 */
+function createOverlayProvider(overlay) {
+    const options = { url: overlay.url }
+    if (Number.isInteger(overlay.minimumLevel)) options.minimumLevel = overlay.minimumLevel
+    if (Number.isInteger(overlay.maximumLevel)) options.maximumLevel = overlay.maximumLevel
+    if (overlay.bounds) options.rectangle = Rectangle.fromDegrees(overlay.bounds[0], overlay.bounds[1], overlay.bounds[2], overlay.bounds[3])
+    if (overlay.credit) options.credit = overlay.credit
+    return new UrlTemplateImageryProvider(options)
+}
+
+/**
+ * 批量采样真实地表高程(票 34 的建模用途):一次请求把整张网格取回来,
+ * 比逐点调 `sampleTerrainHeight()` 快得多(逐点会重复拉同一批瓦片)。
+ * @param {Array<{lon: number, lat: number}>} points
+ * @returns {Promise<Array<number|null>>} 与入参等长;无地形或取不到的点为 null
+ */
+export async function sampleTerrainHeights(viewer, points) {
+    const list = Array.isArray(points) ? points : []
+    if (!viewer || !viewer.terrainProvider || !list.length) return list.map(() => null)
+    try {
+        const cartos = list.map((p) => Cartographic.fromDegrees(p.lon, p.lat))
+        const sampled = await sampleTerrainMostDetailed(viewer.terrainProvider, cartos)
+        return sampled.map((c) => (c && Number.isFinite(c.height) ? c.height : null))
+    } catch {
+        return list.map(() => null)
+    }
+}
+
+/**
+ * 采样一点的**真实地表高程**(票 30):地形感知的中心点/预设视角靠它把高度从「椭球 0 米」
+ * 换成实际地形高度,否则相机高度按 0 算,一拉近就落进山体(实测 range 120m 全黑)。
+ * @returns {Promise<number|null>} 米;没有地形或采样失败返回 null
+ */
+export async function sampleTerrainHeight(viewer, lon, lat) {
+    if (!viewer || !viewer.terrainProvider) return null
+    try {
+        const carto = Cartographic.fromDegrees(lon, lat)
+        const [sampled] = await sampleTerrainMostDetailed(viewer.terrainProvider, [carto])
+        const height = sampled && sampled.height
+        return Number.isFinite(height) ? height : null
+    } catch {
+        return null
+    }
+}
+
+/**
+ * 挂地形(票 21)。DEM 是**量化网格**:`CesiumTerrainProvider.fromUrl(<layer.json 所在目录>)`,
+ * `layer.json` 里的 `scheme: "tms"` 由 Cesium 自己认,服务端不需要翻 y。
+ * @returns {Promise<string>} `'ready'`;失败时抛错,由调用方记成失败态(保留椭球兜底)
+ */
+export async function applyTerrain(viewer, terrainPlan) {
+    const provider = await CesiumTerrainProvider.fromUrl(terrainPlan.url, {
+        requestVertexNormals: terrainPlan.requestVertexNormals !== false,
+    })
+    viewer.terrainProvider = provider
+    return 'ready'
+}
+
+/** 按配置顺序叠加影像层,返回实际挂上的层数。 */
+export function applyOverlays(viewer, overlays) {
+    let added = 0
+    for (const overlay of overlays || []) {
+        viewer.imageryLayers.add(new ImageryLayer(createOverlayProvider(overlay)))
+        added += 1
+    }
+    return added
+}
+
+/**
+ * 3D Tiles 的诊断读数(票 34):排查「tileset.json 拉到了、内容却没请求」这类问题时,
+ * 光看 dataset 的 ready/invalid 不够 —— 需要知道根瓦片**有没有被访问**(visited=0 就是被视锥剔了)、
+ * 内容状态是什么。返回纯数据,便于走查与人工排查打印。
+ */
+export function readTilesetDiagnostics(tilesets) {
+    const list = Array.isArray(tilesets) ? tilesets : []
+    return list.map((tileset) => {
+        const stats = tileset.statistics || {}
+        const root = tileset.root || {}
+        return {
+            visited: stats.visited || 0,
+            selected: stats.selected || 0,
+            contentReady: stats.numberOfTilesWithContentReady || 0,
+            tilesLoaded: stats.numberOfTilesLoaded || 0,
+            rootContentState: root.contentState,
+            rootHasContent: !!root.content,
+            geometricError: tileset.geometricError,
+            maximumScreenSpaceError: tileset.maximumScreenSpaceError,
+            ready: tileset.ready,
+        }
+    })
+}
+
+/**
+ * 挂 3D Tiles / 倾斜摄影(票 21):每个条目一个 tileset。
+ * 单项失败不影响其它项;返回 `{ ready, failed, instances }` 供上层如实报状态
+ * (页面不白屏、不静默),并把实例交出去给视图条件改透明度。
+ */
+export async function applyTilesets(scene, tilesets) {
+    const instances = []
+    const failed = []
+
+    for (const tileset of tilesets || []) {
+        try {
+            const options = {}
+            if (Number.isFinite(tileset.maximumScreenSpaceError)) {
+                options.maximumScreenSpaceError = tileset.maximumScreenSpaceError
+            }
+            const instance = await Cesium3DTileset.fromUrl(tileset.url, options)
+            scene.primitives.add(instance)
+            instances.push(instance)
+        } catch (error) {
+            failed.push({ url: tileset.url, error: String((error && error.message) || error) })
+        }
+    }
+
+    return { ready: instances.length, failed, instances }
+}
+
+/**
+ * 视图条件对图层的影响(票 21):管线视图把地形与 3D Tiles 压成半透明(保留空间参照,
+ * 不完全隐藏),地面视图还原为完全不透明。
+ *
+ * 地形用 Cesium 的 `globe.translucency`(前台/背面 alpha 一起设,避免只透一半);
+ * 3D Tiles 用 `Cesium3DTileStyle` 的 color alpha。两者都只改显示,不动数据,也不碰相机。
+ * @param {object} viewer
+ * @param {Array} tilesets 已挂上的 tileset 实例(applyTilesets 的 instances)
+ * @param {object} plan `domain/viewModes.js` 的 planViewMode()
+ */
+export function applyViewModeLayers(viewer, tilesets, plan) {
+    if (!viewer || !plan) return
+
+    const globe = viewer.scene && viewer.scene.globe
+    if (globe) {
+        const alpha = Number.isFinite(plan.terrainAlpha) ? plan.terrainAlpha : 1
+        globe.translucency.enabled = alpha < 1
+        globe.translucency.frontFaceAlpha = alpha
+        globe.translucency.backFaceAlpha = alpha
+    }
+
+    const alpha = Number.isFinite(plan.tilesetAlpha) ? plan.tilesetAlpha : 1
+    for (const tileset of tilesets || []) {
+        try {
+            // 地面视图还原 = 清掉样式(而不是设成 white/1,省得留一份无意义样式)。
+            tileset.style = alpha < 1 ? new Cesium3DTileStyle({ color: `color('white', ${alpha})` }) : undefined
+        } catch {
+            // 单个 tileset 不支持 style 时忽略,不影响其它图层。
+        }
+    }
+}
+
 export function destroyViewer(viewer) {
     viewer.destroy()
 }
 
+// ================================================================ 点位标记(票 09)
+
+/**
+ * 点位标记的数据源名:**唯一一个**,重新同步时整份替换而不是叠加
+ * (否则翻一页就多画一批,地图上会出现已经不存在于当前快照的点)。
+ */
+export const POINT_MARKER_SOURCE = 'cockpit-point-markers'
+
+/**
+ * 建/取点位标记的数据源(幂等)。
+ *
+ * **必须自己缓存实例,不能只靠 `dataSources.getByName()`**:
+ * Cesium 的 `DataSourceCollection.add()` 是**异步**的(`Promise.resolve(ds).then(...)` 里才 push
+ * 进 `_dataSources`)。同一个 tick 里「先写后读」会走成这样:
+ *   1. `syncPointMarkers()` 建了数据源 A(还没注册进集合)并把 8 个标记加进 A;
+ *   2. 紧接着 `readPointMarkers()` 调 `getByName()` —— 集合里还没有 A,于是**又建了一个空数据源 B**,
+ *      读回来 0 个标记。
+ * 症状极具迷惑性:模块计数与 dataset 都说 8,组件渲染出来的却是「点位标记 0」,
+ * 而晚一点的 DEV 把手调用(那时 A 已注册)又读回 8 —— 同一个页面两套答案。
+ * 这是视觉探针截图里「图例有计数、标记写 0」的真正原因(走查没抓到,因为它读的是 dataset 与 DEV 把手)。
+ *
+ * 用 WeakMap 按 viewer 缓存:viewer 销毁后条目自然消失,不会漏实例,也不会在集合里留空数据源。
+ */
+const markerSources = new WeakMap()
+
+function pointMarkerSource(viewer) {
+    const cached = markerSources.get(viewer)
+    if (cached) return cached
+
+    // 兜一道:热重载等情况下集合里可能已经有同名数据源,优先复用它。
+    const existing = viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0]
+    if (existing) {
+        markerSources.set(viewer, existing)
+        return existing
+    }
+
+    const source = new CustomDataSource(POINT_MARKER_SOURCE)
+    viewer.dataSources.add(source)
+    markerSources.set(viewer, source)
+    return source
+}
+
+/**
+ * 把标记计划同步到地图上(票 09 验收第 1 条:标记按后端返回状态渲染)。
+ *
+ * 形状与颜色**都来自计划**(`domain/pointStatus.js` + 服务层读出的语义色):
+ * 引擎这一层不认识「报警」这种业务词,只认 `shape` 与 `color`,所以状态→视觉的映射
+ * 只有一份,不会在这里长出第二份。
+ *
+ * `HeightReference.CLAMP_TO_GROUND` 让标记贴着地形(票 21 有 DEM,票 30 已有地形感知拾取),
+ * 否则点位会浮在山体上方或埋进去。缺坐标/非法坐标的对象**不在计划里**(票 07 已拦下)。
+ *
+ * @param {Viewer} viewer
+ * @param {Array} markers `domain/pointStatus.js` 的 `planPointMarkers().markers`
+ * @param {(marker: object) => string} colorOf 取该标记的 CSS 颜色(服务层从 token 读,引擎不写色值)
+ * @param {(shape: string, color: string) => string} imageOf 形状+颜色 → 图片 URL(domain/markerSvg)
+ * @returns {{rendered: number, skipped: number}}
+ */
+export function syncPointMarkers(viewer, markers, colorOf, imageOf) {
+    const source = pointMarkerSource(viewer)
+    source.entities.removeAll()
+
+    let rendered = 0
+    let skipped = 0
+
+    for (const marker of markers || []) {
+        // 经纬度缺失或非有限值不上图:计划层已按 `renderable` 过滤,
+        // 这里再挡一道,免得脏数据把整批 entity 建崩(一条坏数据不该让其余点位消失)。
+        if (!Number.isFinite(marker.longitude) || !Number.isFinite(marker.latitude)) {
+            skipped += 1
+            continue
+        }
+
+        const color = colorOf(marker)
+        source.entities.add({
+            id: `point|${marker.key}`,
+            position: Cartesian3.fromDegrees(marker.longitude, marker.latitude),
+            billboard: {
+                image: imageOf(marker.shape, color),
+                // 像素尺寸同步放大:大屏上 12px 的点位看不清形状(形状是「不只靠颜色」的那一半)。
+                width: 18,
+                height: 18,
+                // 贴地:有地形时点位落在地表而不是椭球面。
+                heightReference: HeightReference.CLAMP_TO_GROUND,
+                verticalOrigin: VerticalOrigin.CENTER,
+                disableDepthTestDistance: Number.POSITIVE_INFINITY,
+            },
+            // 供走查/人工排查读状态:不依赖中文字符串,也不依赖读图。
+            properties: {
+                pointCode: marker.code,
+                pointKind: marker.kind,
+                pointStatus: marker.status,
+                visualKey: marker.visualKey,
+                shape: marker.shape,
+                tone: marker.tone,
+            },
+        })
+        rendered += 1
+    }
+
+    return { rendered, skipped }
+}
+
+/**
+ * 只读回点位标记的**真实图层内容**(走查据此断言「标记真的画在地图上」,
+ * 而不是只信计划算出来的数)。
+ *
+ * 除了计数与状态分布,还回每个视觉键对应的 billboard 图片 URL:走查用它证明
+ * 「每个状态的颜色确实不同、形状确实是形状」——这两件事只有从引擎实体上读才算证据。
+ */
+export function readPointMarkers(viewer) {
+    const source = pointMarkerSource(viewer)
+    const byStatus = {}
+    const shapes = new Set()
+    const images = {}
+
+    for (const entity of source.entities.values) {
+        const properties = entity.properties || {}
+        const get = (key) => (properties[key] ? properties[key].getValue() : '')
+        const visualKey = get('visualKey') || 'UNKNOWN'
+        const shape = get('shape') || ''
+
+        byStatus[visualKey] = (byStatus[visualKey] || 0) + 1
+        if (shape) shapes.add(shape)
+        // 同一视觉键只留一份图片,够判「不同状态图片不同」。
+        if (!images[visualKey]) {
+            const billboard = entity.billboard
+            images[visualKey] = {
+                shape,
+                tone: get('tone'),
+                image: billboard ? billboard.image.getValue() : '',
+            }
+        }
+    }
+
+    return { count: source.entities.values.length, byStatus, shapes: [...shapes].filter(Boolean).sort(), images }
+}
+
+/** 清空点位标记(快照失效/刷新前用:旧快照的点不得留在图上)。 */
+export function clearPointMarkers(viewer) {
+    pointMarkerSource(viewer).entities.removeAll()
+}
+
+/**
+ * 屏幕坐标 → **点位标记身份**(票 10 的地图点选)。
+ *
+ * 三件事必须说清:
+ *   1. 只认点位标记数据源上的实体 —— 拾到地形、影像、3D Tiles 或空白一律回 `null`
+ *      (不是「拾到什么就打开详情」);判据是实体属性里有 `pointCode`;
+ *   2. 坐标要吃**画布坐标**,而事件给的是窗口坐标:`scene.pick` 用的是画布内部坐标系,
+ *      所以先减去画布原点(票 03 的 `projectPivot()` 踩过同一个坑,那里是加、这里是减);
+ *   3. 回的是**标记身份**而不是 Cesium 实体:引擎对象不出这一层,上层只认 kind/code/status
+ *      (与 `syncPointMarkers` 只认 shape/tone 是同一条纪律)。
+ *
+ * @returns {{kind: string, code: string, status: string}|null}
+ */
+export function pickPointMarkerAt(viewer, windowX, windowY) {
+    if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
+
+    const rect = viewer.canvas.getBoundingClientRect()
+    const picked = viewer.scene.pick(new Cartesian2(windowX - rect.left, windowY - rect.top))
+    const entity = picked && picked.id
+    const properties = entity && entity.properties
+    if (!properties || !properties.pointCode) return null
+
+    const read = (key) => (properties[key] ? properties[key].getValue() : '')
+    const code = read('pointCode')
+    if (!code) return null
+
+    return { kind: read('pointKind'), code, status: read('pointStatus') }
+}
+
 /**
  * 相机契约的 Cesium 适配(票 03)。返回 services/cameraControl.js 期望的引擎接口;
  * 相机语义(三键怎么算、中心是谁)在 domain/camera.js 与 cameraControl.js,本函数只翻译方言。
@@ -84,9 +440,18 @@ export function createCameraEngine(viewer) {
     const camera = viewer.camera
     let frameHandle = null
 
-    /** 经纬高 → 该点东北天局部系(弹簧臂原语要求中心是局部地表点)。 */
+    /**
+     * 经纬高 → 该点东北天局部系(弹簧臂原语要求中心是局部地表点)。
+     *
+     * 极点保护:`eastNorthUpToFixedFrame` 内部用 `east = up × north` 求东向,
+     * 纬度到 ±90 时 up 与 north 共线、叉积退化为零向量,局部系不再是正交基,
+     * `lookAtTransform` 拿它当参照就会出现「整个地球乱转」。
+     * 域层已把中心纬度夹在 ±POLAR_LAT_LIMIT 内(见 domain/camera.js),
+     * 这里是引擎侧的第二道防线:任何调用方直接喂端点纬度也不会把参照系搞坏。
+     */
     function frameAt(pivot) {
-        return Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(pivot.lon, pivot.lat, pivot.height || 0))
+        const lat = Math.min(89.9, Math.max(-89.9, pivot.lat))
+        return Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(pivot.lon, lat, pivot.height || 0))
     }
 
     return {
@@ -109,7 +474,14 @@ export function createCameraEngine(viewer) {
             return { heading: (heading + 360) % 360, pitch, range }
         },
 
-        /** 屏幕中心的地面拾取点:本票不配地形(票 21),按椭球求交;拾不到(看向天空)返回 null。 */
+        /**
+         * 屏幕中心的地面拾取点(票 21/30 改进):**优先按地形拾取**,拿不到高度再退回椭球。
+         *
+         * 为什么必须按地形:纯 `pickEllipsoid` 给的是椭球面交点(高程 0),而沅陵县城地面高程
+         * 两百多米起。用它当中心,弹簧臂 range 一降到几百米,相机就落进山体内部 —— 实测 range 120 m
+         * 时整屏全黑(票 21 走查记录)。地形就绪后用 `globe.getHeight` 把中心抬到真实地表,
+         * 相机高度随之正确,近景才不会钻进山里。
+         */
         pickGroundAtCenter() {
             const canvas = scene.canvas
             const at = new Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2)
@@ -118,13 +490,33 @@ export function createCameraEngine(viewer) {
             if (!hit) return null
 
             const carto = ellipsoid.cartesianToCartographic(hit)
-            return { lon: CesiumMath.toDegrees(carto.longitude), lat: CesiumMath.toDegrees(carto.latitude), height: 0 }
+            const lon = CesiumMath.toDegrees(carto.longitude)
+            const lat = CesiumMath.toDegrees(carto.latitude)
+
+            // 地形就绪时把中心抬到真实地表;取样不到就保持 0(等价于旧行为,不会更糟)。
+            let height = 0
+            const globe = scene.globe
+            if (globe && typeof globe.getHeight === 'function') {
+                const sampled = globe.getHeight(carto)
+                if (Number.isFinite(sampled)) height = sampled
+            }
+
+            return { lon, lat, height }
         },
 
-        /** 中心点在画布上的落点:走查用它断言「未选中时中心就是屏幕中心的地面拾取点」。 */
+        /**
+         * 中心点投影到**窗口坐标**(与 `getBoundingClientRect()` 同一坐标系)。
+         *
+         * 实测 `SceneTransforms.worldToWindowCoordinates()` 返回的是**相对画布左上角**的值
+         * (画布在壳里下移了一段,直接用会与 `getBoundingClientRect()` 差出一个画布原点偏移,
+         * 走查里表现为「中心点没落在画布中心」的假失败)。这里显式加上画布原点,
+         * 让消费方拿到的就是窗口坐标,别再靠语义猜。
+         */
         projectPivot(pivot) {
             const at = SceneTransforms.worldToWindowCoordinates(scene, Cartesian3.fromDegrees(pivot.lon, pivot.lat, pivot.height || 0))
-            return at ? { x: at.x, y: at.y } : null
+            if (!at) return null
+            const rect = viewer.canvas.getBoundingClientRect()
+            return { x: at.x + rect.left, y: at.y + rect.top }
         },
 
         inputTarget() {
@@ -146,12 +538,5 @@ export function createCameraEngine(viewer) {
         requestFrame(callback) {
             frameHandle = requestAnimationFrame(callback)
         },
-
-        cancelFrame() {
-            if (frameHandle !== null) {
-                cancelAnimationFrame(frameHandle)
-                frameHandle = null
-            }
-        },
     }
 }

+ 236 - 0
src/modules/cockpit/services/cockpitApi.js

@@ -0,0 +1,236 @@
+/**
+ * 驾驶舱接口适配层接缝(票 04):凭据注入 + mock/real 切换的唯一出口。
+ *
+ * 分工(避免和票 05 打架):
+ *   · 票 04(本文件):怎么发请求、带什么头、走 mock 还是真接口、失败怎么归类;
+ *   · 票 05:请求回来的字段怎么变成领域对象、mock 样例覆盖哪些情况、分区状态怎么渲染。
+ *
+ * 三条硬约束(ADR 0005 / 决策地图票 07):
+ *   1. `Authorization` 只在**配置里带了只读 token** 时才附加;没带就交给部署层网关补头,
+ *      前端不保存账号密码;
+ *   2. 失败一律收敛成 `{ ok:false, reason }`,**不抛异常、不跳登录页、不整页遮罩**;
+ *   3. 页面与 store 只调 `requestCockpitJson`,不拼环境地址、拿不到 token ——
+ *      token 只存在于本文件的 `planCockpitRequest` 里。
+ */
+import { DATA_SOURCE } from '../domain/configStatus.js'
+import { REQUEST_REASON } from '../domain/requestReason.js'
+import { getCockpitConfig } from './cockpitConfig.js'
+
+// 词汇已提到 domain/requestReason.js(domain 不该反向依赖 service);这里转出,调用点不变。
+export { REQUEST_REASON }
+
+const mockTransports = new Map()
+
+/** 注册一个 mock 处理器(票 05 用它挂各领域接口的契约样例)。 */
+export function registerMockTransport(path, handler) {
+    mockTransports.set(normalizePath(path), handler)
+}
+
+/**
+ * 查一个 mock 处理器:先精确命中,再按通配模板命中。
+ * 模板里的 `*` 匹配任意一段(可出现在中间或末尾),用来覆盖带路径参数的接口:
+ *   `/monitoring-points/` + `*` 命中 `/monitoring-points/MP-001`
+ *   `/equipments/` + `*` + `/detail-aggregate` 命中 `/equipments/TC-1/detail-aggregate`
+ * 查询串不参与匹配(参数由服务层拼好,处理器按需自己解析)。票 05 加。
+ */
+export function findMockTransport(path) {
+    const normalized = normalizePath(path)
+    if (mockTransports.has(normalized)) return mockTransports.get(normalized)
+
+    const bare = normalized.split('?')[0]
+    if (mockTransports.has(bare)) return mockTransports.get(bare)
+
+    for (const [key, handler] of mockTransports) {
+        if (!key.includes('*')) continue
+        // 把模板按 `*` 切成字面段,逐段顺序匹配;末段是 `*` 时允许尾部残留。
+        const parts = key.split('*')
+        let cursor = 0
+        let matched = true
+
+        for (let index = 0; index < parts.length; index += 1) {
+            const part = parts[index]
+            if (part === '') continue
+
+            if (index === 0) {
+                if (!bare.startsWith(part)) {
+                    matched = false
+                    break
+                }
+                cursor = part.length
+                continue
+            }
+
+            const found = bare.indexOf(part, cursor)
+            if (found === -1) {
+                matched = false
+                break
+            }
+            cursor = found + part.length
+        }
+
+        // 不以 `*` 结尾的模板要求整串匹配到底,避免 `/a/*/b` 命中 `/a/x/b/extra`。
+        if (matched && !key.endsWith('*') && cursor !== bare.length) matched = false
+        if (matched) return handler
+    }
+
+    return null
+}
+
+export function getMockTransportPaths() {
+    return [...mockTransports.keys()]
+}
+
+/** 仅测试用。 */
+export function resetMockTransportsForTest() {
+    mockTransports.clear()
+}
+
+export function normalizePath(path) {
+    const value = String(path || '')
+    return value.startsWith('/') ? value : `/${value}`
+}
+
+export function joinUrl(baseUrl, path) {
+    return `${String(baseUrl || '').replace(/\/+$/, '')}${normalizePath(path)}`
+}
+
+/**
+ * 纯函数:配置 + 路径 → 这次请求怎么走。
+ * @returns {{mode: string, path: string, url: string, headers: object, credential: string, error?: string}}
+ */
+export function planCockpitRequest(config, path) {
+    const current = config || {}
+    const mode = current.dataSource === DATA_SOURCE.REAL ? DATA_SOURCE.REAL : DATA_SOURCE.MOCK
+    const token = (current.api && current.api.token) || ''
+    const headers = { Accept: 'application/json' }
+
+    const plan = {
+        mode,
+        path: normalizePath(path),
+        url: '',
+        headers,
+        credential: token ? 'config' : 'none',
+    }
+
+    if (mode === DATA_SOURCE.REAL) {
+        // 只在真接口上带凭据:mock 分支连请求头都不会发出去,不给「凭据顺手泄漏」留口子。
+        if (token) headers.Authorization = `Bearer ${token}`
+
+        const baseUrl = (current.api && current.api.baseUrl) || ''
+        if (!baseUrl) return { ...plan, error: REQUEST_REASON.NO_BASE_URL }
+        plan.url = joinUrl(baseUrl, path)
+    }
+
+    return plan
+}
+
+async function readJsonPayload(response) {
+    try {
+        if (typeof response.json === 'function') return { ok: true, data: await response.json() }
+        const text = await response.text()
+        return { ok: true, data: JSON.parse(text) }
+    } catch {
+        // dev 的 SPA 回退会把不存在的接口回成 200 text/html,这里必须当成坏载荷。
+        return { ok: false }
+    }
+}
+
+/**
+ * 从后端错误载荷里取业务失效态(票 08)。
+ *
+ * 后端把「快照失效」放在 400 响应体 `AjaxResult.data.category`(`CockpitErrorCategory` 的**大写枚举**
+ * `SNAPSHOT_EXPIRED`,见 `CockpitMapSnapshotController` 的异常处理),**不在 HTTP 状态码里**——
+ * 只看状态码会把它和「分页参数不合法」(`INVALID_REQUEST`)混成同一个原因,界面就只能笼统说
+ * 「数据不可用」,而值班人员真正需要的是「刷新一下」。
+ *
+ * 映射:后端大写枚举 → 前端 `REQUEST_REASON` 词汇(两者是不同词汇表,不能直接比字符串)。
+ * 只认白名单里这一个取值,其它一律回空串——与 mock 分支同一纪律:不让后端塞任意原因进结果。
+ * @param {object} body 响应体(`AjaxResult`:`{ code, msg, data: { category } }`)
+ */
+const BACKEND_ERROR_CATEGORY = {
+    SNAPSHOT_EXPIRED: REQUEST_REASON.SNAPSHOT_EXPIRED,
+}
+
+function readBusinessReason(body) {
+    const outer = body || {}
+    const data = outer.data
+    const category = (data && data.category) || outer.category || ''
+    return BACKEND_ERROR_CATEGORY[category] || ''
+}
+
+/**
+ * 统一出口。任何失败都只回 `{ ok:false, reason }`,由调用方把**受影响分区**标成数据不可用。
+ * @param {string} path 领域路径,如 `/map/summary`
+ * @param {object} [options] `{ config, fetchImpl, ... }`,便于单测注入
+ */
+export async function requestCockpitJson(path, options = {}) {
+    const config = options.config || getCockpitConfig()
+    const plan = planCockpitRequest(config, path)
+
+    if (plan.error) {
+        return { ok: false, mode: plan.mode, reason: plan.error, path: plan.path }
+    }
+
+    if (plan.mode === DATA_SOURCE.MOCK) {
+        const handler = findMockTransport(plan.path)
+        if (!handler) {
+            return { ok: false, mode: plan.mode, reason: REQUEST_REASON.MOCK_MISSING, path: plan.path }
+        }
+        try {
+            const data = await handler({ path: plan.path, config, plan })
+            return { ok: true, mode: plan.mode, status: 200, path: plan.path, data }
+        } catch (error) {
+            // 处理器可以声明自己的失败原因(票 05 的 mock 用它演示「只这一个分区凭据失败」),
+            // 但只认白名单里的取值——不让 mock 往结果里塞任意原因字符串。
+            const declared = error && error.reason
+            const known = Object.values(REQUEST_REASON).includes(declared)
+            return {
+                ok: false,
+                mode: plan.mode,
+                reason: known ? declared : REQUEST_REASON.UNAVAILABLE,
+                path: plan.path,
+                error: String((error && error.message) || error),
+            }
+        }
+    }
+
+    const doFetch = options.fetchImpl || (typeof fetch !== 'undefined' ? fetch : null)
+    if (!doFetch) {
+        return { ok: false, mode: plan.mode, reason: REQUEST_REASON.UNREACHABLE, path: plan.path, error: '当前环境没有 fetch' }
+    }
+
+    try {
+        const response = await doFetch(plan.url, { headers: plan.headers, cache: 'no-store' })
+
+        // 401/403 = 凭据类失败:只回原因,绝不把用户送去登录页。
+        if (response.status === 401 || response.status === 403) {
+            return { ok: false, mode: plan.mode, status: response.status, reason: REQUEST_REASON.UNAUTHORIZED, path: plan.path }
+        }
+        if (!response.ok) {
+            // 票 08:400 里也藏着可恢复的业务失效态(快照过期),先读出来再判。
+            const payload = response.status === 400 ? await readJsonPayload(response) : { ok: false }
+            const business = payload.ok ? readBusinessReason(payload.data) : ''
+            return {
+                ok: false,
+                mode: plan.mode,
+                status: response.status,
+                reason: business || REQUEST_REASON.UNAVAILABLE,
+                path: plan.path,
+            }
+        }
+
+        const payload = await readJsonPayload(response)
+        if (!payload.ok) {
+            return { ok: false, mode: plan.mode, status: response.status, reason: REQUEST_REASON.BAD_PAYLOAD, path: plan.path }
+        }
+        return { ok: true, mode: plan.mode, status: response.status, path: plan.path, data: payload.data }
+    } catch (error) {
+        return {
+            ok: false,
+            mode: plan.mode,
+            reason: REQUEST_REASON.UNREACHABLE,
+            path: plan.path,
+            error: String((error && error.message) || error),
+        }
+    }
+}

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

@@ -0,0 +1,503 @@
+/**
+ * 驾驶舱配置服务(票 04):后端地址、天地图、Cesium、WebSocket 与凭据的唯一读入口。
+ *
+ * 为什么要有这一层(spec 第 13/14 条、ADR 0005):
+ * 前端不硬编码账号、密钥或环境地址;配置由部署层注入;页面与 store 只读归一化后的结果。
+ *
+ * 分层(后者覆盖前者,深合并):
+ *   1. 内置默认        —— `dataSource: mock`(spec:默认 mock,真接口必须显式开启)
+ *   2. 构建期环境变量  —— `import.meta.env.VITE_COCKPIT_*`(本机联调写 `.env.local`,已 gitignore)
+ *   3. 运行时配置文件  —— `<base>cockpit/config.json`(部署时放/改,同一个构建多环境)
+ *   4. 宿主注入对象    —— `globalThis.__COCKPIT_CONFIG__`(qiankun 主应用 / 网关注入;给了就不再拉文件)
+ *
+ * 三态:`ready`(载入成功)/ `missing`(没有任何配置来源,用内置默认)/ `failed`(拿到了但不可用)。
+ *
+ * 运行时文件**不会**在每次加载时盲试:没有显式配置 URL 就不发请求(否则每页都会刷一个 404,
+ * 生产日志也会变脏)。要启用文件层,二选一:
+ *   · 宿主页加一行 `window.__COCKPIT_CONFIG_URL__ = 'auto'`(`auto` = 按应用基址取 `cockpit/config.json`,
+ *     qiankun / 子目录部署下都对);给绝对 URL 则直接从后端或别处拉配置;给空串 = 这个页面不要配置文件;
+ *   · 构建期设 `VITE_COCKPIT_CONFIG_URL`(同样接受 `auto` 或具体 URL)。宿主页的键优先于构建期。
+ * 文件不存在(404)或载荷是 HTML(静态服务器的错误页)都判为缺失态,不当作故障。
+ */
+import { CONFIG_STATE, CREDENTIAL, DATA_SOURCE } from '../domain/configStatus.js'
+import { BASEMAP_SOURCE } from '../domain/basemap.js'
+
+/** 部署把配置文件放在这个相对路径下:`public/cockpit/config.json` → 产物 `cockpit/config.json`。 */
+export const CONFIG_FILE_PATH = 'cockpit/config.json'
+
+/** 显式 URL 的特殊取值:按应用基址取约定路径(qiankun / 子目录部署下也正确)。 */
+export const CONFIG_URL_AUTO = 'auto'
+
+/** 宿主注入用的全局键。 */
+export const INJECTED_CONFIG_KEY = '__COCKPIT_CONFIG__'
+export const INJECTED_CONFIG_URL_KEY = '__COCKPIT_CONFIG_URL__'
+
+/** 构建期环境变量键(Vite 只把 `VITE_` 前缀暴露给客户端)。 */
+export const ENV_KEYS = {
+    dataSource: 'VITE_COCKPIT_DATA_SOURCE',
+    apiBaseUrl: 'VITE_COCKPIT_API_BASE_URL',
+    apiToken: 'VITE_COCKPIT_API_TOKEN',
+    wsUrl: 'VITE_COCKPIT_WS_URL',
+    wsToken: 'VITE_COCKPIT_WS_TOKEN',
+    tiandituToken: 'VITE_COCKPIT_TIANDITU_TOKEN',
+    cesiumBaseUrl: 'VITE_COCKPIT_CESIUM_BASE_URL',
+    basemapSource: 'VITE_COCKPIT_BASEMAP_SOURCE',
+    basemapUrl: 'VITE_COCKPIT_BASEMAP_URL',
+    configUrl: 'VITE_COCKPIT_CONFIG_URL',
+}
+
+function isPlainObject(value) {
+    return !!value && typeof value === 'object' && !Array.isArray(value)
+}
+
+/** 内置默认:没有任何配置来源时也必须是可跑的状态(mock + 不伪造凭据)。 */
+export function builtinConfig() {
+    return {
+        version: 1,
+        dataSource: DATA_SOURCE.MOCK,
+        api: { baseUrl: '', token: '' },
+        ws: { url: '', token: '' },
+        map: {
+            basemap: {
+                source: BASEMAP_SOURCE.NONE,
+                url: '',
+                subdomains: [],
+                minimumLevel: null,
+                maximumLevel: null,
+                credit: '',
+            },
+            tianditu: { token: '', layer: 'img' },
+            terrain: { url: '', requestVertexNormals: true },
+            overlays: [],
+            tilesets: [],
+            home: { lon: null, lat: null, height: null, heading: null, pitch: null, range: null, label: '' },
+            intro: { enabled: true, title: '', subtitle: '', spinSeconds: null },
+            // 票 21:地面/管线视图的取值覆盖(不配就用 domain/viewModes.js 的默认)
+            viewMode: { pitch: null, rangeFactor: null, terrainAlpha: null, tilesetAlpha: null },
+            // 票 32:UI 形态。null = 跟随构建模式(dev 显示调试信息,生产构建自动收敛)
+            ui: { debug: null },
+            cesium: { enabled: true, baseUrl: '' },
+        },
+    }
+}
+
+function readString(value, fallback, issues, label) {
+    if (value === undefined || value === null || value === '') return fallback
+    if (typeof value !== 'string') {
+        issues.push(`${label} 应为字符串,收到 ${typeof value},已退回默认`)
+        return fallback
+    }
+    return value.trim()
+}
+
+function readBoolean(value, fallback, issues, label) {
+    if (value === undefined || value === null) return fallback
+    if (typeof value !== 'boolean') {
+        issues.push(`${label} 应为布尔值,收到 ${typeof value},已退回默认`)
+        return fallback
+    }
+    return value
+}
+
+function readStringArray(value, issues, label) {
+    if (value === undefined || value === null || value === '') return []
+    if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
+        issues.push(`${label} 应为字符串数组,已忽略`)
+        return []
+    }
+    return value.map((item) => item.trim()).filter(Boolean)
+}
+
+function readLevel(value, issues, label) {
+    if (value === undefined || value === null || value === '') return null
+    if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
+        issues.push(`${label} 应为非负整数,收到 ${JSON.stringify(value)},已忽略`)
+        return null
+    }
+    return value
+}
+
+function readNumber(value, issues, label) {
+    if (value === undefined || value === null || value === '') return null
+    if (typeof value !== 'number' || !Number.isFinite(value)) {
+        issues.push(`${label} 应为数字,收到 ${JSON.stringify(value)},已忽略`)
+        return null
+    }
+    return value
+}
+
+/**
+ * 归一化 + 宽松校验:不认识的取值退回默认并记一条 issue,不因为一个错字段整份配置作废。
+ * @param {object} raw 合并后的原始配置
+ * @returns {{config: object, issues: string[]}}
+ */
+export function normalizeConfig(raw) {
+    const config = builtinConfig()
+    const issues = []
+
+    if (!isPlainObject(raw)) {
+        if (raw !== undefined && raw !== null) issues.push('配置不是对象,已全部退回内置默认')
+        return { config, issues }
+    }
+
+    if (raw.dataSource !== undefined && raw.dataSource !== null) {
+        if (raw.dataSource === DATA_SOURCE.REAL || raw.dataSource === DATA_SOURCE.MOCK) {
+            config.dataSource = raw.dataSource
+        } else {
+            issues.push(`dataSource 只认 mock/real,收到 ${JSON.stringify(raw.dataSource)},已退回 mock`)
+        }
+    }
+
+    const api = isPlainObject(raw.api) ? raw.api : {}
+    const ws = isPlainObject(raw.ws) ? raw.ws : {}
+    const map = isPlainObject(raw.map) ? raw.map : {}
+    const basemap = isPlainObject(map.basemap) ? map.basemap : {}
+    const tianditu = isPlainObject(map.tianditu) ? map.tianditu : {}
+    const cesium = isPlainObject(map.cesium) ? map.cesium : {}
+
+    config.api.baseUrl = readString(api.baseUrl, config.api.baseUrl, issues, 'api.baseUrl')
+    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')
+
+    const knownSources = Object.values(BASEMAP_SOURCE)
+    if (basemap.source !== undefined && basemap.source !== null && basemap.source !== '') {
+        if (knownSources.includes(basemap.source)) {
+            config.map.basemap.source = basemap.source
+        } else {
+            issues.push(`map.basemap.source 只认 ${knownSources.join('/')},收到 ${JSON.stringify(basemap.source)},已退回 none`)
+        }
+    }
+    config.map.basemap.url = readString(basemap.url, '', issues, 'map.basemap.url')
+    config.map.basemap.credit = readString(basemap.credit, '', issues, 'map.basemap.credit')
+    config.map.basemap.subdomains = readStringArray(basemap.subdomains, issues, 'map.basemap.subdomains')
+    config.map.basemap.minimumLevel = readLevel(basemap.minimumLevel, issues, 'map.basemap.minimumLevel')
+    config.map.basemap.maximumLevel = readLevel(basemap.maximumLevel, issues, 'map.basemap.maximumLevel')
+
+    config.map.tianditu.token = readString(tianditu.token, '', issues, 'map.tianditu.token')
+    if (tianditu.layer !== undefined && tianditu.layer !== null && tianditu.layer !== '') {
+        if (['img', 'vec', 'ter'].includes(tianditu.layer)) {
+            config.map.tianditu.layer = tianditu.layer
+        } else {
+            issues.push(`map.tianditu.layer 只认 img/vec/ter,收到 ${JSON.stringify(tianditu.layer)},已退回 img`)
+        }
+    }
+    config.map.cesium.enabled = readBoolean(cesium.enabled, config.map.cesium.enabled, issues, 'map.cesium.enabled')
+    config.map.cesium.baseUrl = readString(cesium.baseUrl, '', issues, 'map.cesium.baseUrl')
+
+    // 票 21:地形(本地/甲方 DEM)与叠加影像(本地精细切片)
+    const terrain = isPlainObject(map.terrain) ? map.terrain : {}
+    config.map.terrain.url = readString(terrain.url, '', issues, 'map.terrain.url')
+    config.map.terrain.requestVertexNormals = readBoolean(
+        terrain.requestVertexNormals,
+        config.map.terrain.requestVertexNormals,
+        issues,
+        'map.terrain.requestVertexNormals',
+    )
+
+    if (map.overlays !== undefined && map.overlays !== null && !Array.isArray(map.overlays)) {
+        issues.push('map.overlays 应为数组,已忽略')
+    } else {
+        config.map.overlays = (map.overlays || []).map((item, index) => {
+            const overlay = isPlainObject(item) ? item : {}
+            const bounds = Array.isArray(overlay.bounds) && overlay.bounds.length === 4 && overlay.bounds.every((n) => typeof n === 'number')
+            if (overlay.bounds !== undefined && overlay.bounds !== null && !bounds) {
+                issues.push(`map.overlays[${index}].bounds 应为 [west, south, east, north] 四个数字,已忽略`)
+            }
+            return {
+                url: readString(overlay.url, '', issues, `map.overlays[${index}].url`),
+                minimumLevel: readLevel(overlay.minimumLevel, issues, `map.overlays[${index}].minimumLevel`),
+                maximumLevel: readLevel(overlay.maximumLevel, issues, `map.overlays[${index}].maximumLevel`),
+                bounds: bounds ? overlay.bounds.slice() : null,
+                credit: readString(overlay.credit, '', issues, `map.overlays[${index}].credit`),
+            }
+        })
+    }
+
+    // 3D Tiles / 倾斜摄影入口(票 21):每项一个 tileset URL
+    if (map.tilesets !== undefined && map.tilesets !== null && !Array.isArray(map.tilesets)) {
+        issues.push('map.tilesets 应为数组,已忽略')
+    } else {
+        config.map.tilesets = (map.tilesets || []).map((item, index) => {
+            const tileset = isPlainObject(item) ? item : {}
+            return {
+                url: readString(tileset.url, '', issues, `map.tilesets[${index}].url`),
+                maximumScreenSpaceError: readNumber(
+                    tileset.maximumScreenSpaceError,
+                    issues,
+                    `map.tilesets[${index}].maximumScreenSpaceError`,
+                ),
+                credit: readString(tileset.credit, '', issues, `map.tilesets[${index}].credit`),
+            }
+        })
+    }
+
+    // 视角预设与进入引导(票 30):坐标/开关都由配置给,组件不硬编码
+    const home = isPlainObject(map.home) ? map.home : {}
+    config.map.home = {
+        lon: readNumber(home.lon, issues, 'map.home.lon'),
+        lat: readNumber(home.lat, issues, 'map.home.lat'),
+        height: readNumber(home.height, issues, 'map.home.height'),
+        heading: readNumber(home.heading, issues, 'map.home.heading'),
+        pitch: readNumber(home.pitch, issues, 'map.home.pitch'),
+        range: readNumber(home.range, issues, 'map.home.range'),
+        label: readString(home.label, '', issues, 'map.home.label'),
+    }
+
+    const intro = isPlainObject(map.intro) ? map.intro : {}
+    config.map.intro = {
+        enabled: readBoolean(intro.enabled, config.map.intro.enabled, issues, 'map.intro.enabled'),
+        title: readString(intro.title, '', issues, 'map.intro.title'),
+        subtitle: readString(intro.subtitle, '', issues, 'map.intro.subtitle'),
+        spinSeconds: readNumber(intro.spinSeconds, issues, 'map.intro.spinSeconds'),
+    }
+
+    // 票 21:地面/管线视图的取值覆盖(缺省用 domain/viewModes.js 的默认;脏值忽略)
+    const viewMode = isPlainObject(map.viewMode) ? map.viewMode : {}
+    const pipeline = isPlainObject(viewMode.pipeline) ? viewMode.pipeline : {}
+    config.map.viewMode = {
+        pitch: readNumber(pipeline.pitch, issues, 'map.viewMode.pipeline.pitch'),
+        rangeFactor: readNumber(pipeline.rangeFactor, issues, 'map.viewMode.pipeline.rangeFactor'),
+        terrainAlpha: readNumber(pipeline.terrainAlpha, issues, 'map.viewMode.pipeline.terrainAlpha'),
+        tilesetAlpha: readNumber(pipeline.tilesetAlpha, issues, 'map.viewMode.pipeline.tilesetAlpha'),
+    }
+
+    // 票 32:UI 形态开关。缺省 null = 跟随构建模式(`import.meta.env.DEV`),
+    // 生产构建自动收敛为「干净模式」;要在地产现场调试生产包时可显式写 true。
+    const ui = isPlainObject(map.ui) ? map.ui : {}
+    config.map.ui = {
+        debug: readBoolean(ui.debug, null, issues, 'map.ui.debug'),
+    }
+
+    return { config, issues }
+}
+
+/** 构建期环境变量 → 原始配置形状(纯函数,便于单测直接喂一个 env 对象)。 */
+export function readEnvConfig(env) {
+    if (!isPlainObject(env)) return {}
+    const out = {}
+
+    if (env[ENV_KEYS.dataSource]) out.dataSource = env[ENV_KEYS.dataSource]
+
+    const api = {}
+    if (env[ENV_KEYS.apiBaseUrl]) api.baseUrl = env[ENV_KEYS.apiBaseUrl]
+    if (env[ENV_KEYS.apiToken]) api.token = env[ENV_KEYS.apiToken]
+    if (Object.keys(api).length) out.api = api
+
+    const ws = {}
+    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
+
+    const map = {}
+    if (env[ENV_KEYS.tiandituToken]) map.tianditu = { token: env[ENV_KEYS.tiandituToken] }
+    if (env[ENV_KEYS.cesiumBaseUrl]) map.cesium = { baseUrl: env[ENV_KEYS.cesiumBaseUrl] }
+
+    const basemap = {}
+    if (env[ENV_KEYS.basemapSource]) basemap.source = env[ENV_KEYS.basemapSource]
+    if (env[ENV_KEYS.basemapUrl]) basemap.url = env[ENV_KEYS.basemapUrl]
+    if (Object.keys(basemap).length) map.basemap = basemap
+
+    if (Object.keys(map).length) out.map = map
+
+    return out
+}
+
+/** 深合并(只对普通对象递归),后面的来源覆盖前面的。 */
+export function mergeConfig(base, patch) {
+    if (!isPlainObject(patch)) return base
+    const out = { ...base }
+    for (const [key, value] of Object.entries(patch)) {
+        out[key] = isPlainObject(value) && isPlainObject(base[key]) ? mergeConfig(base[key], value) : value
+    }
+    return out
+}
+
+/**
+ * 解析配置文件的地址。
+ * @param {object} [options]
+ * @param {string} [options.base] 应用基址(`import.meta.env.BASE_URL` / qiankun 注入)
+ * @param {string} [options.documentBase] `document.baseURI`
+ * @param {string} [options.explicitUrl] 显式 URL;`'auto'` 表示按应用基址取约定路径
+ * @returns {string} 绝对 URL
+ */
+export function resolveConfigUrl({ base = './', documentBase = 'http://localhost/', explicitUrl = '' } = {}) {
+    const docBase = documentBase || 'http://localhost/'
+    if (explicitUrl && explicitUrl !== CONFIG_URL_AUTO) return new URL(explicitUrl, docBase).href
+    return new URL(CONFIG_FILE_PATH, new URL(base || './', docBase)).href
+}
+
+/** 读一个配置来源的响应体;返回原始对象或一个失败原因。 */
+export async function readConfigPayload(response) {
+    if (!response || typeof response.text !== 'function') return { ok: false, reason: 'no-response' }
+
+    if (!response.ok) {
+        // 404 = 部署没放这个文件;其余非 2xx = 放了但服务端是坏的。
+        return { ok: false, reason: response.status === 404 ? 'missing' : `http-${response.status}` }
+    }
+
+    const text = await response.text()
+    const trimmed = text.trim()
+    // dev 的 SPA 回退 / 静态服务器的错误页都会返回 HTML,一律按「文件不存在」处理。
+    if (!trimmed || trimmed.startsWith('<')) return { ok: false, reason: 'missing' }
+
+    try {
+        const raw = JSON.parse(trimmed)
+        if (!isPlainObject(raw)) return { ok: false, reason: 'not-an-object' }
+        return { ok: true, raw }
+    } catch {
+        return { ok: false, reason: 'invalid-json' }
+    }
+}
+
+function readViteEnv() {
+    return import.meta && import.meta.env ? import.meta.env : {}
+}
+
+function readDocumentBase() {
+    return typeof document !== 'undefined' && document.baseURI ? document.baseURI : 'http://localhost/'
+}
+
+let cached = null
+let inFlight = null
+
+/**
+ * 载入配置(幂等、进程内缓存一次)。
+ * 所有依赖都可注入,便于脱浏览器断言:`{ scope, env, fetchImpl, base, documentBase, now }`。
+ * @returns {Promise<object>} `{ state, source, url, config, issues, error, credential, loadedAt }`
+ */
+export function loadCockpitConfig(options = {}) {
+    if (cached) return Promise.resolve(cached)
+    if (inFlight) return inFlight
+
+    inFlight = resolveCockpitConfig(options)
+        .then((result) => {
+            cached = result
+            inFlight = null
+            return result
+        })
+        .catch((error) => {
+            // 兜底:任何意外都收敛成 failed,绝不向外抛(页面不该因为配置服务崩掉而白屏)。
+            inFlight = null
+            cached = {
+                state: CONFIG_STATE.FAILED,
+                source: 'defaults',
+                url: '',
+                config: builtinConfig(),
+                issues: [],
+                error: String((error && error.message) || error),
+                credential: CREDENTIAL.NONE,
+                loadedAt: new Date().toISOString(),
+            }
+            return cached
+        })
+
+    return inFlight
+}
+
+async function resolveCockpitConfig({ scope, env, fetchImpl, base, documentBase, now } = {}) {
+    const globalScope = scope || (typeof globalThis !== 'undefined' ? globalThis : {})
+    const viteEnv = env !== undefined ? env : readViteEnv()
+    const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null)
+    const loadedAt = new Date(now === undefined ? Date.now() : now).toISOString()
+
+    const injected = isPlainObject(globalScope[INJECTED_CONFIG_KEY]) ? globalScope[INJECTED_CONFIG_KEY] : null
+    // 宿主页显式给了 URL 键就以它为准 —— 给空串表示「这个页面不要运行时配置文件」,
+    // 这样宿主能在构建期开关之外逐页控制(走查的「默认无配置」用例也靠它自隔离)。
+    const injectedUrl = globalScope[INJECTED_CONFIG_URL_KEY]
+    const explicitUrl =
+        injectedUrl === undefined || injectedUrl === null
+            ? readString(viteEnv ? viteEnv[ENV_KEYS.configUrl] : '', '', [], 'VITE_COCKPIT_CONFIG_URL')
+            : readString(injectedUrl, '', [], 'window.__COCKPIT_CONFIG_URL__')
+
+    // 1) + 2) 内置默认由 normalizeConfig 兜底,这里先叠构建期环境变量。
+    const envRaw = readEnvConfig(viteEnv)
+    let raw = mergeConfig({}, envRaw)
+    // 构建期环境变量也算「有配置」;只有一份来源都没有时才是缺失态(内置默认)。
+    const hasEnvConfig = Object.keys(envRaw).length > 0
+    let source = hasEnvConfig ? 'env' : 'defaults'
+    let state = hasEnvConfig ? CONFIG_STATE.READY : CONFIG_STATE.MISSING
+    let url = ''
+    let error = ''
+    const issues = []
+
+    if (injected) {
+        // 4) 宿主注入优先级最高:给了就不再拉文件。
+        raw = mergeConfig(raw, injected)
+        source = 'window'
+        state = CONFIG_STATE.READY
+    } else if (!explicitUrl) {
+        // 没有显式配置 URL:不发请求(避免每次加载都刷 404),维持在「缺失 / 仅环境变量」态。
+        url = ''
+    } else {
+        // 3) 运行时配置文件(显式 URL,或 'auto' = 应用基址下的约定路径)。
+        url = resolveConfigUrl({
+            base: base === undefined ? (viteEnv && viteEnv.BASE_URL) || './' : base,
+            documentBase: documentBase === undefined ? readDocumentBase() : documentBase,
+            explicitUrl,
+        })
+
+        if (!doFetch) {
+            error = '当前环境没有 fetch,无法加载配置文件'
+            state = CONFIG_STATE.FAILED
+            issues.push(error)
+        } else {
+            try {
+                const payload = await readConfigPayload(await doFetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' }))
+                if (payload.ok) {
+                    raw = mergeConfig(raw, payload.raw)
+                    source = 'file'
+                    state = CONFIG_STATE.READY
+                } else if (payload.reason === 'missing' || payload.reason === 'no-response') {
+                    // 文件不在:环境变量层给的配置仍然有效,状态维持原样(缺失 / 已就绪)。
+                    if (!hasEnvConfig) state = CONFIG_STATE.MISSING
+                } else {
+                    state = CONFIG_STATE.FAILED
+                    error = `配置文件不可用:${payload.reason}`
+                }
+            } catch (fetchError) {
+                state = CONFIG_STATE.FAILED
+                error = `配置文件请求失败:${String((fetchError && fetchError.message) || fetchError)}`
+            }
+        }
+    }
+
+    const { config, issues: normalizeIssues } = normalizeConfig(raw)
+
+    return {
+        state,
+        source,
+        url,
+        config,
+        issues: issues.concat(normalizeIssues),
+        error,
+        credential: config.api.token ? CREDENTIAL.CONFIG : CREDENTIAL.NONE,
+        loadedAt,
+    }
+}
+
+/** 同步读归一化后的配置;未载入时给内置默认(页面不必等配置就能渲染骨架)。 */
+export function getCockpitConfig() {
+    return cached ? cached.config : builtinConfig()
+}
+
+/** 同步读完整加载结果;未载入时按「缺失」对待。 */
+export function getCockpitConfigState() {
+    if (cached) return cached
+    return {
+        state: CONFIG_STATE.MISSING,
+        source: 'defaults',
+        url: '',
+        config: builtinConfig(),
+        issues: [],
+        error: '',
+        credential: CREDENTIAL.NONE,
+        loadedAt: '',
+    }
+}
+
+/** 仅测试用:清掉缓存,让下一次 loadCockpitConfig 重新解析。 */
+export function resetCockpitConfigForTest() {
+    cached = null
+    inFlight = null
+}

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

@@ -0,0 +1,160 @@
+/**
+ * 共享详情领域接口(票 05;消费方是票 10 两级详情、票 11 设备详情、票 12 时间线、票 13/14 事件)。
+ *
+ * ADR 0005 的 `detailApi`。本文件里全是「取一个分区」的函数,**每个分区各自成败**——
+ * 票 11 验收第 3 条要求「任一分区失败时不遮盖其他成功分区」,
+ * 所以调用方拿到的是若干并列的 `section()`,而不是一个共用成败的详情对象。
+ */
+import { toMonitoringPointDetail } from '../domain/pointDetail.js'
+import { toEquipmentArchive, toEquipmentDetailAggregate, toDeviceFreshnessSection, toMetricsPayload } from '../domain/equipmentDetail.js'
+import { toEventRead } from '../domain/events.js'
+import { toWorkOrder, toWorkOrderTimeline } from '../domain/workOrder.js'
+import { isEmptyBy, fetchSection } from './domainRequest.js'
+import { SECTION_STATE } from '../domain/sectionState.js'
+
+function missingCode(kind, code) {
+    return Promise.resolve({
+        state: SECTION_STATE.FAILED,
+        reason: `缺少${kind}编码`,
+        mode: '',
+        data: null,
+        missing: [],
+        freshness: '',
+        retained: false,
+    })
+}
+
+/**
+ * 监测点详情(票 10 的第一级 + 设备卡片)。
+ * @param {string} pointCode 监测点编码
+ */
+export function fetchMonitoringPointDetail(pointCode, options = {}) {
+    if (!pointCode) return missingCode('监测点', pointCode)
+
+    return fetchSection(`/monitoring-points/${encodeURIComponent(pointCode)}`, {
+        toData: toMonitoringPointDetail,
+        // 「空」只认后端明说的空(`pointStatus: EMPTY`):点位可以没有关联设备,
+        // 但那仍是一条有效记录;反过来,字段缺失也不等于空(那是 SUCCESS + missing)。
+        isEmpty: isEmptyBy((payload) => !!payload && payload.pointStatus === 'EMPTY'),
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+}
+
+/** 设备档案(票 11 的「档案」分区)。 */
+export function fetchEquipmentArchive(deviceCode, options = {}) {
+    if (!deviceCode) return missingCode('设备', deviceCode)
+
+    return fetchSection(`/equipments/${encodeURIComponent(deviceCode)}/archive`, {
+        toData: toEquipmentArchive,
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+}
+
+/** 当前指标(票 11 的「最新指标」分区)。 */
+export function fetchEquipmentMetrics(deviceCode, options = {}) {
+    if (!deviceCode) return missingCode('设备', deviceCode)
+
+    return fetchSection(`/equipments/${encodeURIComponent(deviceCode)}/metrics/current`, {
+        toData: toMetricsPayload,
+        isEmpty: isEmptyBy((payload) => Array.isArray(payload && payload.metrics) && payload.metrics.length === 0),
+        freshnessOf: (payload) => (payload && payload.state) || '',
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+}
+
+/**
+ * 设备详情聚合(票 11 的主路径):一次拿到档案/指标/预警/报警/工单/时间线/图片,
+ * 每个分区独立成败。
+ */
+export function fetchEquipmentDetailAggregate(deviceCode, options = {}) {
+    if (!deviceCode) return missingCode('设备', deviceCode)
+
+    return fetchSection(`/equipments/${encodeURIComponent(deviceCode)}/detail-aggregate`, {
+        toData: toEquipmentDetailAggregate,
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+}
+
+/** 设备新鲜度(票 16)。 */
+export function fetchEquipmentFreshness(deviceCode, options = {}) {
+    if (!deviceCode) return missingCode('设备', deviceCode)
+
+    return fetchSection(`/equipments/${encodeURIComponent(deviceCode)}/freshness`, {
+        toData: (payload) => payload,
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    }).then((result) => {
+        // 失败/不可用时保持原状;拿到数据再按新鲜度换算成分区。
+        if (result.state !== SECTION_STATE.SUCCESS) return result
+        return toDeviceFreshnessSection(result.data)
+    })
+}
+
+/** 工单列表(票 11/12:设备维度或事件维度,二选一给参)。 */
+export function fetchWorkOrders({ deviceCode = '', eventSource = '', eventCode = '' } = {}, options = {}) {
+    const path = deviceCode
+        ? `/equipments/${encodeURIComponent(deviceCode)}/work-orders`
+        : eventSource && eventCode
+          ? `/events/${encodeURIComponent(eventSource)}/${encodeURIComponent(eventCode)}/work-orders`
+          : ''
+
+    if (!path) return missingCode('设备或事件', '')
+
+    return fetchSection(path, {
+        toData: (payload) => ({
+            state: (payload && payload.state) || '',
+            stateReason: (payload && payload.stateReason) || '',
+            // 七态字典以领域字典为准,后端字典仅作为补充(响应缺字典时界面仍能显示状态名)。
+            dictionary: (payload && payload.stateDictionary) || [],
+            orders: Array.isArray(payload && payload.workOrders) ? payload.workOrders.map(toWorkOrder) : [],
+        }),
+        isEmpty: isEmptyBy((payload) => Array.isArray(payload && payload.workOrders) && payload.workOrders.length === 0),
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+}
+
+/** 单个工单的时间线(票 12)。 */
+export function fetchWorkOrderTimeline(workOrderCode, options = {}) {
+    if (!workOrderCode) return missingCode('工单', workOrderCode)
+
+    return fetchSection(`/work-orders/${encodeURIComponent(workOrderCode)}/timeline`, {
+        toData: toWorkOrderTimeline,
+        isEmpty: isEmptyBy((payload) => {
+            const entries = payload && payload.timelineEntries
+            return !payload || !payload.workOrder || (Array.isArray(entries) && entries.length === 0)
+        }),
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+}
+
+/**
+ * 事件读(票 13 当前、票 14 详情、票 19 面板)。
+ * @param {object} params `{ deviceCode, startTime, endTime, page, pageSize }`
+ *        —— 设备维度与历史流(带时间范围)走同一函数。
+ */
+export function fetchEvents(params = {}, options = {}) {
+    const { deviceCode = '', startTime = '', endTime = '' } = params
+    if (!deviceCode) return missingCode('设备', deviceCode)
+
+    const history = !!startTime || !!endTime
+    const path = `/equipments/${encodeURIComponent(deviceCode)}/events/${history ? 'history' : 'current'}`
+
+    return fetchSection(path, {
+        params: { startTime, endTime, page: params.page, pageSize: params.pageSize },
+        toData: toEventRead,
+        isEmpty: isEmptyBy((payload) => {
+            const warnings = Array.isArray(payload && payload.warnings) ? payload.warnings.length : 0
+            const alarms = Array.isArray(payload && payload.alarms) ? payload.alarms.length : 0
+            return warnings === 0 && alarms === 0
+        }),
+        freshnessOf: (payload) => (payload && payload.state) || '',
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+}

+ 66 - 0
src/modules/cockpit/services/domainRequest.js

@@ -0,0 +1,66 @@
+/**
+ * 领域接口服务的共用底座(票 05)。
+ *
+ * 三个领域模块(`mapApi` / `detailApi` / `runtimeApi`)都做同一件事:
+ *   拼查询串 → 走票 04 的 `requestCockpitJson` → 用 `sectionFromResult` 收成一个分区。
+ * 这段样板收在这里,领域模块只留「路径 + 转换函数 + 空判据」。
+ *
+ * 分工不变(票 04 的票面):地址拼接、凭据、mock/real 切换全在 `cockpitApi.js`;
+ * 本文件与领域模块**不出现** `Authorization`,也读不到令牌。
+ */
+import { requestCockpitJson } from './cockpitApi.js'
+import { sectionFromResult } from '../domain/sectionState.js'
+
+/** 拼查询串;空值(undefined/null/'')跳过,数组按重复键展开。 */
+export function buildQuery(params) {
+    const search = new URLSearchParams()
+
+    for (const [key, value] of Object.entries(params || {})) {
+        if (value === undefined || value === null || value === '') continue
+        if (Array.isArray(value)) {
+            for (const item of value) {
+                if (item !== undefined && item !== null && item !== '') search.append(key, String(item))
+            }
+            continue
+        }
+        search.append(key, String(value))
+    }
+
+    const text = search.toString()
+    return text ? `?${text}` : ''
+}
+
+/**
+ * 取一个分区。
+ * @param {string} path 领域路径
+ * @param {object} [options]
+ * @param {object} [options.params] 查询参数
+ * @param {(raw: any) => any} [options.toData] 载荷 → 领域对象
+ * @param {(raw: any) => boolean} [options.isEmpty] 空数据判据
+ * @param {(raw: any) => string} [options.freshnessOf] 取新鲜度字符串
+ * @param {object} [options.config] / [options.fetchImpl] 单测注入
+ */
+export async function fetchSection(path, options = {}) {
+    const result = await requestCockpitJson(`${path}${buildQuery(options.params)}`, {
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+
+    return sectionFromResult(result, {
+        toData: options.toData,
+        isEmpty: options.isEmpty,
+        freshnessOf: options.freshnessOf,
+    })
+}
+
+/** 「列表长度为 0」这类通用空判据。 */
+export function isEmptyBy(predicate) {
+    return (payload) => {
+        try {
+            return !!predicate(payload)
+        } catch {
+            // 载荷形状意外时不要当成空——交给转换层记 missing,别静默显示「无数据」。
+            return false
+        }
+    }
+}

+ 67 - 0
src/modules/cockpit/services/mapApi.js

@@ -0,0 +1,67 @@
+/**
+ * 地图领域接口(票 05;消费方是票 08 分页与票 07 坐标校验)。
+ *
+ * ADR 0005 把适配层按领域拆分(`cockpitApi`/`mapApi`/`detailApi`/`runtimeApi`),本文件是其中的 `mapApi`。
+ * 对外只给领域对象;后端裸字段不出这一层。
+ *
+ * 关于**分页与快照**:票 05 只定「一页怎么翻成领域批次」,不持状态——
+ * 「同一批加载用同一快照、刷新换新快照」是票 08 的活,所以这里只把 `snapshotAt` 如实带出。
+ */
+import { toMapBatch } from '../domain/mapSummary.js'
+import { fetchSection, isEmptyBy } from './domainRequest.js'
+import { SECTION_STATE, section } from '../domain/sectionState.js'
+
+/**
+ * 取一页地图摘要。
+ * @param {object} [options]
+ * @param {number} [options.page] 页码
+ * @param {number} [options.pageSize] 每页条数(后端默认 500、上限 1000)
+ * @param {string} [options.snapshotAt] 快照标记(票 08 用来锁定同一批加载)
+ * @param {object} [options.config] / [options.fetchImpl] 单测注入
+ */
+export function fetchMapSnapshot(options = {}) {
+    const empty = isEmptyBy((payload) => {
+        const points = Array.isArray(payload && payload.monitoringPoints) ? payload.monitoringPoints.length : 0
+        const equipment = Array.isArray(payload && payload.unassociatedEquipment) ? payload.unassociatedEquipment.length : 0
+        // 只有后端自己说"总数 0"才算空:某一页恰好没有点位不等于整个快照是空的。
+        return points === 0 && equipment === 0 && Number(payload && payload.total) === 0
+    })
+
+    return fetchSection('/map-snapshot', {
+        params: { page: options.page, pageSize: options.pageSize, snapshotAt: options.snapshotAt },
+        toData: toMapBatch,
+        isEmpty: empty,
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+}
+
+/**
+ * 基础帧(`/foundation`):地图能不能画、数据新不新鲜。
+ * 票 05 只交这一条读取路径,引导页与全域提示归后续票。
+ */
+export async function fetchFoundation(options = {}) {
+    const result = await fetchSection('/foundation', {
+        toData: (payload) => ({
+            mapFrame: (payload && payload.mapFrame) || null,
+            freshness: (payload && payload.freshness) || null,
+        }),
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+
+    if (result.state !== SECTION_STATE.SUCCESS) return result
+
+    const frame = (result.data && result.data.mapFrame) || {}
+    // 后端说地图帧是空/不可绘制时,如实降为 EMPTY,不让调用方拿到"成功但没东西"。
+    if (frame.state && frame.state !== 'READY') {
+        return section(SECTION_STATE.EMPTY, {
+            mode: result.mode,
+            reason: frame.reason || '',
+            data: result.data,
+            freshness: (result.data && result.data.freshness && result.data.freshness.status) || '',
+        })
+    }
+
+    return result
+}

+ 636 - 16
src/modules/cockpit/services/mapService.js

@@ -20,24 +20,33 @@
  * 因此仍可脱离浏览器直接断言(见 verify/t01-panel-catalog.mjs)。
  */
 import { ensureCesiumBaseUrl } from './cesiumAssets.js'
+import { getCockpitConfig, loadCockpitConfig } from './cockpitConfig.js'
 import { attachCameraInput, createCameraControl } from './cameraControl.js'
+import { attachPointPick } from './pointPick.js'
 import { formatCameraState } from '../domain/camera.js'
-
-/** 本票不做真底图(票 21 负责天地图/甲方瓦片);这里只保证「不伪造」。 */
-const BASEMAP_STATE = 'not-configured'
+import { BASEMAP_STATE, basemapStatusText, planBasemap } from '../domain/basemap.js'
+import { isAtHome, planHome, planIntro } from '../domain/viewPresets.js'
+import { VIEW_MODES, planViewMode, viewHprFor } from '../domain/viewModes.js'
+import { TERRAIN_STATE, planOverlays, planTerrain, planTilesets, terrainStatusText } from '../domain/mapLayers.js'
+import { markerSvg, planPointMarkers, pointLegend } from '../domain/pointStatus.js'
 
 const ENGINE_TEXT = {
     unavailable: 'Cesium 引擎未启动(无宿主要素)',
     booting: 'Cesium 引擎加载中…',
+    // ready 的措辞按底图计划生成(票 29):未配置时保持票 27 的原话。
     ready: 'Cesium 引擎已就绪 · 底图未配置(正式底图见票 21)',
     failed: 'Cesium 引擎初始化失败 · 中央区域回退临时底图',
+    disabled: 'Cesium 引擎已由部署配置关闭 · 中央区域用临时底图',
 }
 
 // 状态浮标由服务层自绘并挂在宿主内:外壳与 MapCanvas 组件按票面要求不改。
+// 票 32:**调试形态**(dev / 显式开 debug)用顶部药丸,**生产形态**用底部半透明小字 ——
+// 正式环境不该有一大块状态文本压在中央 3D 场景上(维护者 2026-09-18 要求)。
 const STATUS_STYLE = [
     'position:absolute',
     'z-index:3',
-    'top:74px',
+    // 图例已挪到一张图最上方,药丸改到「图例 + 标题」之下,避免压住颜色语义。
+    'top:96px',
     'left:50%',
     'transform:translateX(-50%)',
     'max-width:min(78%, 620px)',
@@ -54,40 +63,119 @@ const STATUS_STYLE = [
     'pointer-events:none',
 ].join(';')
 
+/** 生产形态:收紧成**右下角状态日志**(右对齐、无边框无底色、半透明小字)。
+ *  位置与「无地图位置」胶囊共用同一条右边线(让开右栏 `--column-w`),日志在最下、
+ *  胶囊在其上一档,形成右下角的一小摞摘要;中央视野因此完全空出来。
+ *  抬到 30px 而不是贴底 2px 的原因:底部中央还有一行地图摘要状态,贴底会与它同一水平线。 */
+const STATUS_STYLE_QUIET = [
+    'position:absolute',
+    'z-index:3',
+    'bottom:8px',
+    'right:calc(var(--column-w, 300px) + 10px)',
+    'left:auto',
+    'transform:none',
+    'text-align:right',
+    'max-width:min(40%, 420px)',
+    'color:rgba(186,212,224,.5)',
+    'font-size:9.5px',
+    'line-height:1.2',
+    'letter-spacing:.03em',
+    'white-space:nowrap',
+    'overflow:hidden',
+    'text-overflow:ellipsis',
+    'text-shadow:0 1px 3px rgba(0,0,0,.8)',
+    'pointer-events:none',
+].join(';')
+
+/** 当前是否走「调试形态」:配置显式给布尔值就听配置,否则跟随构建模式(生产构建自动收敛)。 */
+function isUiDebug() {    const mapConfig = getCockpitConfig().map || {}
+    const configured = mapConfig.ui ? mapConfig.ui.debug : null
+    if (typeof configured === 'boolean') return configured
+    return !!(import.meta.env && import.meta.env.DEV)
+}
+
 let instance = null
 let sequence = 0
 let createdCount = 0
 let destroyedCount = 0
 let resizeCount = 0
 let lastResizeAt = ''
-let engineState = 'idle' // idle | unavailable | booting | ready | failed
+let engineState = 'idle' // idle | unavailable | booting | ready | failed | disabled
 let engineError = ''
+// 底图状态由配置计划决定(票 29):not-configured | natural-earth | xyz | tianditu | invalid
+let basemapState = BASEMAP_STATE.NOT_CONFIGURED
+let basemapText = basemapStatusText(null)
+// 票 21:地形与叠加影像的状态(none / ready / invalid),叠加层数单独记。
+let terrainState = TERRAIN_STATE.NONE
+let terrainText = terrainStatusText(null)
+let terrainError = ''
+let overlayCount = 0
+// 票 21:3D Tiles / 倾斜摄影(入口先留好,本机数据缺根 tileset.json 时如实报失败)
+let tilesetReady = 0
+let tilesetFailed = []
+// 票 21:当前视图条件(ground / pipeline),由 setViewMode() 维护。
+let viewMode = VIEW_MODES.GROUND
+// 票 09:点位标记的最近一次计划与结果(上图层数 + 跳过的脏数据数),供 dataset 与走查读。
+let markerPlan = { markers: [], blocked: [], byStatus: {}, total: 0 }
+let markerRendered = 0
+let markerSkipped = 0
 
 /** 只有真元素才做 DOM / 引擎操作;单测传普通对象时整条引擎链路都不启动。 */
 function isDomHost(host) {
     return !!host && typeof host === 'object' && host.nodeType === 1 && typeof host.appendChild === 'function'
 }
 
+/** 开发态判定(天地图走仓库自带的 `/tianditu.map` 代理,生产直连)。 */
+function isDev() {
+    return !!(import.meta.env && import.meta.env.DEV)
+}
+
 /** 把引擎状态写进宿主(dataset 给走查读,浮标给人读)。 */
 function paintEngineState(record) {
     if (!isDomHost(record.host)) return
 
     record.host.dataset.mapEngine = engineState
-    record.host.dataset.mapBasemap = BASEMAP_STATE
-
+    record.host.dataset.mapBasemap = basemapState
+    record.host.dataset.mapTerrain = terrainState
+    record.host.dataset.mapTilesets = tilesetFailed.length ? 'invalid' : tilesetReady > 0 ? 'ready' : 'none'
+    // 票 21:视图条件从启动起就可观测(默认 ground),不是只有切换过才有。
+    record.host.dataset.mapViewMode = viewMode
+
+    // 票 32:形态在创建时定;`data-ui-mode` 让走查/排查一眼看出当前是哪一种。
+    // 注意**要能改**:浮标在 `booting` 就建出来了,那时配置还没加载完(拿不到 map.ui.debug),
+    // 所以配置到手后必须按当前形态重刷一次样式 —— 否则生产形态永远退回 dev 的样子。
+    const debug = isUiDebug()
+    const mode = debug ? 'debug' : 'production'
     if (!record.statusNode) {
         const node = record.host.ownerDocument.createElement('div')
         node.className = 'map-engine-status'
         node.setAttribute('data-map-engine-status', '')
-        node.style.cssText = STATUS_STYLE
+        node.style.cssText = debug ? STATUS_STYLE : STATUS_STYLE_QUIET
+        node.setAttribute('data-ui-mode', mode)
         record.host.appendChild(node)
         record.statusNode = node
+    } else if (record.statusNode.dataset.uiMode !== mode) {
+        record.statusNode.style.cssText = debug ? STATUS_STYLE : STATUS_STYLE_QUIET
+        record.statusNode.dataset.uiMode = mode
     }
 
     const failed = engineState === 'failed'
-    record.statusNode.textContent = `${ENGINE_TEXT[engineState] || engineState}${engineError ? `(${engineError})` : ''}`
-    record.statusNode.style.borderColor = failed ? 'rgba(255,77,94,.55)' : 'rgba(58,170,226,.42)'
-    record.statusNode.style.color = failed ? '#ffb3bb' : '#bad4e0'
+    const parts = [basemapText]
+    if (terrainState === TERRAIN_STATE.READY) parts.push(terrainText)
+    if (terrainState === TERRAIN_STATE.INVALID) parts.push(`地形加载失败(${terrainError})· 回退椭球`)
+    if (overlayCount > 0) parts.push(`叠加影像 ${overlayCount} 层`)
+    if (tilesetReady > 0) parts.push(`3D Tiles ${tilesetReady} 个`)
+    if (tilesetFailed.length) parts.push(`3D Tiles 加载失败(${tilesetFailed[0].error})`)
+    const text = engineState === 'ready' ? `Cesium 引擎已就绪 · ${parts.join(' · ')}` : ENGINE_TEXT[engineState] || engineState
+    record.statusNode.textContent = `${text}${engineError ? `(${engineError})` : ''}`
+
+    // 生产形态没有底色,靠字色传达「正常 / 故障」——颜色语义在任何形态下都不能丢。
+    if (isUiDebug()) {
+        record.statusNode.style.borderColor = failed ? 'rgba(255,77,94,.55)' : 'rgba(58,170,226,.42)'
+        record.statusNode.style.color = failed ? '#ffb3bb' : '#bad4e0'
+    } else {
+        record.statusNode.style.color = failed ? 'rgba(255,139,150,.8)' : 'rgba(186,212,224,.5)'
+    }
 }
 
 function setEngineState(record, state, error = '') {
@@ -102,6 +190,33 @@ function paintCameraState(record, state) {
     record.host.dataset.camera = formatCameraState(state)
 }
 
+/**
+ * 在给定窗口上采一张地形高程网格(票 34 的建模用途):
+ * 轻量建模要把体块坐在地形上,而模型是静态几何 —— 所以先在本地 DEM 上打网格,
+ * 生成时按双线性插值给每栋楼定基准高程(不然县城地面一百多米,整片楼会被埋进山里)。
+ *
+ * @param {number} lon 中心经度
+ * @param {number} lat 中心纬度
+ * @param {number} dLon 经度半宽(度)
+ * @param {number} dLat 纬度半宽(度)
+ * @param {number} n 每边采样点数
+ * @returns {Promise<Array<number|null>>} 行优先(先纬后经)的高程数组,单位米
+ */
+export async function sampleTerrainGrid(lon, lat, dLon, dLat, n) {
+    if (!instance || !instance.viewer || !instance.engine) return null
+    const count = Math.max(2, Math.min(64, Math.floor(n)))
+    const points = []
+    for (let row = 0; row < count; row++) {
+        for (let col = 0; col < count; col++) {
+            points.push({
+                lon: lon - dLon + (2 * dLon * col) / (count - 1),
+                lat: lat - dLat + (2 * dLat * row) / (count - 1),
+            })
+        }
+    }
+    return instance.engine.sampleTerrainHeights(instance.viewer, points)
+}
+
 /**
  * 开发态把手:三类交互与「选中换中心」都能在控制台/走查里复现,不必点着鼠标盲试。
  * 只在 DEV 且浏览器里挂,离开 /cockpit 即摘掉;生产构建不含它。
@@ -113,11 +228,49 @@ function publishDevHook() {
         getCameraState,
         setCameraFocus,
         clearCameraFocus,
+        // 手感是主观的:DEV 下现场拖调阻尼时间常数(毫秒,0 = 不做阻尼)。
+        setCameraDamping: (tauMs) => (instance && instance.cameraControl ? instance.cameraControl.setDampingTau(tauMs) : null),
+        // 票 30:视角预设 / 归位 / 星球自转,走查与人工排查都用这几个入口。
+        goHome: (options) => goHome(options),
+        getHomeView: () => getHomeView(),
+        isAtHome: () => atHome(),
+        setSpin: (seconds) => setGlobeSpin(seconds),
+        // 票 21:地面/管线视图切换(走查断言"切换不重置中心"用)。
+        setViewMode: (mode, overrides) => setViewMode(mode, overrides),
+        getViewMode: () => getViewMode(),
+        // 票 34:地形高程网格采样(轻量建模把体块坐到地形上用),只在 DEV 挂。
+        sampleTerrainGrid: (lon, lat, dLon, dLat, n) => sampleTerrainGrid(lon, lat, dLon, dLat, n),
+        // 票 34:3D Tiles 诊断(排查"根瓦片有没有被访问 / 内容为何没请求")。
+        getTilesetDiagnostics: () =>
+            instance && instance.engine ? instance.engine.readTilesetDiagnostics(instance.tilesetInstances || []) : [],
         getLifecycleStats,
         projectCameraPivot: () => (instance && instance.cameraControl ? instance.cameraControl.projectPivot() : null),
+        // 票 08:地图摘要的渐进加载(人工演示与走查驱动「取下一页 / 刷新换快照」)。
+        // 状态归 stores/mapStore.js 的装载器,这里只是把它接到同一个 DEV 把手上。
+        mapSummary: devSummaryHandle,
+        // 票 09:点位标记与图例(人工演示「刷新后点位换色」也走这里)。
+        getPointMarkers: () => getPointMarkerState(),
+        clearPointMarkers: () => clearPointMarkers(),
+        // 票 10:点位点选(人工演示与走查用屏幕坐标驱动同一条链路)。
+        pickPoint: (x, y) => pickPointAt(x, y),
+        getPicked: () => getPickedPoint(),
     }
 }
 
+/** 票 08:地图摘要装载器的 DEV 把手(由 MapCanvas 挂载时递进来,离开时清掉)。 */
+let devSummaryHandle = null
+
+/**
+ * 接上地图摘要把手:`{ advance, refresh, getState }`(`stores/mapStore.js` 的入口)。
+ * 传 null 表示摘掉(离开 /cockpit)。
+ */
+export function publishSummaryHandle(handle) {
+    devSummaryHandle = handle || null
+    publishDevHook()
+    if (handle) return
+    if (typeof window !== 'undefined' && window.__cockpitMap) window.__cockpitMap.mapSummary = null
+}
+
 function clearDevHook() {
     if (typeof window !== 'undefined' && window.__cockpitMap) {
         delete window.__cockpitMap
@@ -131,13 +284,40 @@ function clearDevHook() {
 async function bootEngine(record) {
     try {
         setEngineState(record, 'booting')
-        ensureCesiumBaseUrl()
+
+        // 票 04:引擎开关与静态资源基址都从配置服务读(默认开、默认按应用基址取 cesium/)。
+        const configState = await loadCockpitConfig()
+        if (instance !== record) return
+        const cesiumConfig = (configState.config.map && configState.config.map.cesium) || {}
+        if (cesiumConfig.enabled === false) {
+            setEngineState(record, 'disabled')
+            return
+        }
+        ensureCesiumBaseUrl(cesiumConfig.baseUrl || '')
+
+        // 票 29:底图由配置计划决定;未配置 / 配置无效一律不挂底图(票 27:不伪造配准)。
+        const mapConfig = (configState.config && configState.config.map) || {}
+        const basemapPlan = planBasemap(mapConfig, { dev: isDev() })
+        basemapState = basemapPlan.state
+        basemapText = basemapStatusText(basemapPlan)
+
+        // 票 21:地形(本地/甲方 DEM)、叠加影像(本地精细切片)与 3D Tiles/倾斜摄影。
+        const terrainPlan = planTerrain(mapConfig)
+        const overlayPlans = planOverlays(mapConfig)
+        const tilesetPlans = planTilesets(mapConfig)
+        terrainState = terrainPlan.ready ? TERRAIN_STATE.READY : TERRAIN_STATE.NONE
+        terrainText = terrainStatusText(terrainPlan)
+        terrainError = ''
+        overlayCount = 0
+        tilesetReady = 0
+        tilesetFailed = []
+
         const engine = await import('./cesiumEngine.js')
 
         // 加载期间已经离开 /cockpit:不留下实例。
         if (instance !== record) return
 
-        const viewer = engine.createViewer(record.host)
+        const viewer = engine.createViewer(record.host, basemapPlan)
 
         // 建 Viewer 是同步的,但状态可能在这一刻被销毁过 —— 补一次销毁。
         if (instance !== record) {
@@ -145,6 +325,44 @@ async function bootEngine(record) {
             return
         }
 
+        // 叠加影像(本地精细切片)与地形:地形是异步拉 layer.json,失败只回退椭球不影响外壳。
+        overlayCount = engine.applyOverlays(viewer, overlayPlans)
+        if (terrainPlan.ready) {
+            try {
+                await engine.applyTerrain(viewer, terrainPlan)
+            } catch (terrainFailure) {
+                if (instance !== record) return
+                terrainState = TERRAIN_STATE.INVALID
+                terrainError = String((terrainFailure && terrainFailure.message) || terrainFailure)
+            }
+        }
+
+        // 3D Tiles / 倾斜摄影:逐项挂,单项失败只记状态(入口已留好,数据缺失时不白屏)。
+        if (tilesetPlans.length) {
+            const tilesets = await engine.applyTilesets(viewer.scene, tilesetPlans)
+            if (instance !== record) return
+            tilesetReady = tilesets.ready
+            tilesetFailed = tilesets.failed
+            // 票 21:留住实例,视图条件(管线视图压半透明)要按它改样式。
+            record.tilesetInstances = tilesets.instances
+        }
+
+        // 票 30:地形就绪后把「家」视角的高程换成实测地形高度 —— 否则相机按椭球 0 米定位,
+        // 一拉近就落进山体(实测 range 120m 全屏黑)。采样失败保持 0,等价于旧行为。
+        try {
+            const homePlan = planHome(mapConfig)
+            const sampled = await engine.sampleTerrainHeight(viewer, homePlan.lon, homePlan.lat)
+            if (instance !== record) return
+            if (Number.isFinite(sampled)) record.homeHeight = sampled
+        } catch {
+            // 采样失败不影响启动:保持椭球高度。
+        }
+
+        if (instance !== record) {
+            engine.destroyViewer(viewer)
+            return
+        }
+
         record.engine = engine
         record.viewer = viewer
 
@@ -161,9 +379,17 @@ async function bootEngine(record) {
             onStateChange: (state) => paintCameraState(record, state),
         })
         record.detachCameraInput = attachCameraInput(cameraEngine.inputTarget(), record.cameraControl)
-        // 用当前真实相机反读弹簧臂参数,避免第一次交互时相机跳变(本票不设初始视角预设)。
+        // 票 10:点位点选(同一元素上再挂一套观察型监听:拖拽仍是相机平移,点一下才是选点位)。
+        record.detachPickInput = attachPointPick(record.host, ({ x, y }) => pickPointAt(x, y))
+        // 用当前真实相机反读弹簧臂参数,避免第一次交互时相机跳变。
         record.cameraControl.syncFromEngine()
 
+        // 票 30:进入引导期间让星球自转(进入系统时由 setGlobeSpin(0) 停)。
+        const introConfig = planIntro(mapConfig)
+        if (introConfig.enabled && introConfig.spinSeconds > 0) {
+            setGlobeSpin(introConfig.spinSeconds)
+        }
+
         setEngineState(record, 'ready')
         publishDevHook()
     } catch (error) {
@@ -193,6 +419,8 @@ export function mountMap(host) {
         resizePending: false,
         cameraControl: null,
         detachCameraInput: null,
+        // 票 10:点位点选的 DOM detach(与相机输入同一套生命周期)。
+        detachPickInput: null,
     }
 
     const record = instance
@@ -241,6 +469,9 @@ export function unmountMap() {
 
     clearDevHook()
 
+    // 票 30:先停自转循环,再拆其它资源(否则 rAF 会打到已销毁的 viewer 上)。
+    stopSpin()
+
     if (record.statusNode) {
         record.statusNode.remove()
         record.statusNode = null
@@ -250,13 +481,22 @@ export function unmountMap() {
         record.detachCameraInput()
         record.detachCameraInput = null
     }
+    // 票 10:点选监听同样先摘(它也会在销毁后读到空 viewer)。
+    if (record.detachPickInput) {
+        record.detachPickInput()
+        record.detachPickInput = null
+    }
     if (record.cameraControl) {
         record.cameraControl.dispose()
         record.cameraControl = null
     }
     if (isDomHost(record.host)) {
         delete record.host.dataset.camera
+        // 票 10:选中态随地图一起收(宿主上不留上一轮的选中标记)。
+        delete record.host.dataset.mapPicked
+        delete record.host.dataset.mapPickedStatus
     }
+    lastPicked = null
     if (record.viewer) {
         record.engine.destroyViewer(record.viewer)
         record.viewer = null
@@ -280,6 +520,360 @@ export function clearCameraFocus() {
     return setCameraFocus(null)
 }
 
+// ---- 票 30:视角预设(家)/ 快速归位 / 星球自转 ----
+
+/** 当前生效的「家」视角(配置优先,缺项用默认)。
+ *  地形就绪后中心高程用**实测地形高度**(`record.homeHeight`),否则相机按椭球 0 米算会钻进山体。 */
+export function getHomeView() {
+    const home = planHome((getCockpitConfig().map) || {})
+    if (instance && Number.isFinite(instance.homeHeight)) {
+        return { ...home, height: instance.homeHeight }
+    }
+    return home
+}
+
+/** 进入引导配置。 */
+export function getIntroConfig() {
+    return planIntro((getCockpitConfig().map) || {})
+}
+
+/**
+ * 飞到「家」视角(快速归位)。`options.immediate` = 不走阻尼直接落位(进系统首帧用)。
+ * @returns {boolean} 是否已下发
+ */
+export function goHome(options = {}) {
+    // 票 30:进入系统/归位都要先停自转 —— 否则自转循环每帧以 immediate 写中心,
+    // 会把这次飞行的目标一路冲掉(实测:点「进入」后镜头停在自转途中的经度上)。
+    stopSpin()
+    if (instance) instance.spinPaused = true
+    const home = getHomeView()
+    return applyViewPreset(home, home, options)
+}
+
+/**
+ * 内部共用:把预设下发给相机控件。
+ * @param {object} home planHome() 结果
+ * @param {object} hpr 预设的 heading/pitch/range
+ */
+function applyViewPreset(home, hpr, options = {}) {
+    if (!instance || !instance.cameraControl) return false
+    const applied = instance.cameraControl.applyViewPreset(home, hpr, options)
+    if (applied) publishDevHook()
+    return applied
+}
+
+/** 当前是否已停在「家」视角(归位按钮据此显示状态)。 */
+export function atHome() {
+    if (!instance || !instance.cameraControl) return false
+    return isAtHome(instance.cameraControl.getState(), getHomeView())
+}
+
+// ---- 票 21:地面视图 / 管线视图(全局视图条件,语义见决策地图票 05)----
+
+/**
+ * 切换地面/管线视图。
+ *
+ * 三件事,顺序有意为之:
+ *   1. **先记下**进管线前的地面视角(倾角 + 距离),回地面时按它还原;
+ *   2. 换相机的 pitch 上下限与预设(管线:pitch 抬到 -18°、弹簧臂压到 35%)——
+ *      **只动 pitch 与 range,heading 与旋转中心原样带走**(决策地图硬约束:切换视图不重置中心);
+ *   3. 调图层可见性:管线视图把地形与 3D Tiles 压成半透明,地面视图还原。
+ *
+ * 三键分工不受影响:本函数不碰 `cameraControl` 的输入绑定,只改它的 limits 与目标 hpr。
+ * @param {string} mode `ground` | `pipeline`
+ * @param {object} [overrides] 配置覆盖(pitch / rangeFactor / 透明度)
+ * @returns {boolean} 是否生效(引擎未就绪时 false,调用方可稍后重试)
+ */
+export function setViewMode(mode, overrides = {}) {
+    const plan = planViewMode(mode, overrides)
+    if (!instance || !instance.cameraControl) return false
+
+    const control = instance.cameraControl
+    const state = control.getState()
+    const hpr = state && state.hpr ? state.hpr : null
+    if (!hpr) return false
+
+    if (plan.mode === VIEW_MODES.PIPELINE) {
+        // 进管线:记下地面视角,供回地面时还原(只在第一次进管线时记,避免连续切换被覆盖)。
+        if (viewMode !== VIEW_MODES.PIPELINE) {
+            instance.groundHprBaseline = { pitch: hpr.pitch, range: hpr.range }
+        }
+    }
+
+    // limits 必须先换:否则回地面时 pitch 仍按管线的宽容范围夹,画面角度会不对。
+    control.setPitchLimits(plan.limits)
+
+    const nextHpr = viewHprFor(hpr, plan, instance.groundHprBaseline)
+    control.setHpr(nextHpr)
+
+    viewMode = plan.mode
+    if (instance.engine && instance.viewer) {
+        instance.engine.applyViewModeLayers(instance.viewer, instance.tilesetInstances || [], plan)
+    }
+    if (isDomHost(instance.host)) {
+        instance.host.dataset.mapViewMode = plan.mode
+    }
+    return true
+}
+
+/** 当前视图条件(ground / pipeline)。 */
+export function getViewMode() {
+    return viewMode
+}
+
+// ---- 票 09:点位状态标记与图例 ----
+
+/**
+ * 语义色的**唯一取法**:从 `.cockpit-shell` 的计算样式里读 `--lv1..--lv4 / --neutral`。
+ *
+ * 为什么绕这一道而不是在 JS 里写色值(票 06 的单一口径):色值只在 `styles/tokens.css`
+ * 声明一份,设计公司换视觉资源时改那一个文件即可;地图标记(SVG 图片)偏偏读不到 CSS 变量,
+ * 所以由服务层把「当前生效的颜色」读出来交给 domain 的 `markerSvg()` 烘焙进图片。
+ * 读不到(脱浏览器单测、样式未挂)时回退到与 token 同值的兜底色,标记不会变成透明。
+ */
+const TONE_FALLBACK = {
+    lv1: '#ff4d5e',
+    lv2: '#ffa63d',
+    lv3: '#4fd8ff',
+    lv4: '#35e39a',
+    neutral: '#6d92ae',
+}
+
+function toneColors() {
+    const colors = { ...TONE_FALLBACK }
+    if (typeof document === 'undefined' || !document.querySelector) return colors
+    const shell = document.querySelector('.cockpit-shell')
+    if (!shell || typeof getComputedStyle !== 'function') return colors
+
+    const styles = getComputedStyle(shell)
+    for (const tone of Object.keys(TONE_FALLBACK)) {
+        const value = styles.getPropertyValue(`--${tone}`)
+        if (value && value.trim()) colors[tone] = value.trim()
+    }
+    return colors
+}
+
+/**
+ * 同步点位标记到地图上(票 09 验收第 1、4 条)。**标记与图例读同一份计划**,
+ * 所以「图例与标记状态一致」不是靠两边各自小心,而是结构上没有第二份数据。
+ *
+ * 引擎没就绪时只更新计划(图例仍能显示),返回 false;调用方(`MapCanvas`)会在就绪后重试。
+ * @param {Array} objects 地图对象数组(票 08 的 `mapStore.summary.objects`)
+ * @param {object} [legendSource] 可选的图例行(默认由计划现算),便于界面直接复用同一份
+ * @returns {boolean} 是否已画到地图上
+ */
+export function syncPointMarkersForSummary(objects, legendSource) {
+    markerPlan = planPointMarkers(objects)
+
+    if (!instance || !instance.engine || !instance.viewer) {
+        paintMarkerState()
+        return false
+    }
+
+    const colors = toneColors()
+    const result = instance.engine.syncPointMarkers(
+        instance.viewer,
+        markerPlan.markers,
+        (marker) => colors[marker.tone] || colors.neutral,
+        (shape, color) => markerSvg(shape, color, 18),
+    )
+    markerRendered = result.rendered
+    markerSkipped = result.skipped
+    paintMarkerState()
+    return true
+}
+
+/**
+ * 清空地图上的点位标记(离开 /cockpit 用)。
+ *
+ * **计划一起清掉**:留着计数而地图上没有标记,会让「图例与标记一致」这条验收在自己的
+ * 收尾路径上失真。地图上什么都没有时,图例就该是 0。
+ */
+export function clearPointMarkers() {
+    if (instance && instance.engine && instance.viewer) {
+        instance.engine.clearPointMarkers(instance.viewer)
+    }
+    markerPlan = { markers: [], blocked: [], byStatus: {}, total: 0 }
+    markerRendered = 0
+    markerSkipped = 0
+    paintMarkerState()
+}
+
+/**
+ * 当前点位标记的计划与计数(纯数据,界面与走查唯一读它)。
+ * 图例由 `pointLegend()` 从同一份 `byStatus` 生成 —— 这是「图例与标记一致」的落点。
+ * @returns {{legend: Array, byStatus: object, markers: Array, blocked: Array, onMap: number, skipped: number}}
+ */
+export function getPointMarkerState() {
+    // 引擎就绪时以**真实图层内容**为准(走查与人工排查读的是地图上真有什么),
+    // 未就绪时退回计划数(图例仍要能显示)。
+    let onMap = markerRendered
+    let shapes = [...new Set(markerPlan.markers.map((item) => item.shape))].sort()
+    let markerImages = {}
+    // 实体口径与计划口径**分开给**:`byStatus` 是计划(图例由它生成),
+    // `entityByStatus` 是从 Cesium 实体上真读回来的。走查拿后者跟图例 DOM 对照,
+    // 才是「图例 vs 地图」的交叉验证;若两边都读 byStatus,那条断言就是自己跟自己比(恒真)。
+    let entityByStatus = {}
+    if (instance && instance.engine && instance.viewer) {
+        const actual = instance.engine.readPointMarkers(instance.viewer)
+        onMap = actual.count
+        shapes = actual.shapes
+        // 每个视觉键对应的**真实 billboard 图片**:走查据此证明「不同状态颜色确实不同」
+        // 与「图例形状=标记形状」,而不是拿两份 JS 数据互相比(那是自己跟自己比)。
+        markerImages = actual.images
+        entityByStatus = { ...actual.byStatus }
+    }
+
+    return {
+        legend: pointLegend(markerPlan.byStatus),
+        byStatus: { ...markerPlan.byStatus },
+        entityByStatus,
+        markers: [...markerPlan.markers],
+        blocked: [...markerPlan.blocked],
+        onMap,
+        skipped: markerSkipped,
+        shapes,
+        markerImages,
+    }
+}
+
+/**
+ * 标记状态写进宿主 dataset(走查读这一行,不读中文,也不读图):
+ *   `data-map-markers`        = 已画到地图上的标记数
+ *   `data-map-markers-plan`   = 本次计划要画的标记数(坐标准入通过)
+ *   `data-map-markers-status` = 状态分布,`ALARM:1,WARNING:1` 形式
+ *   `data-map-marker-shapes`  = 实际用到的形状名(证明「不只靠颜色」有形状这一半)
+ */
+function paintMarkerState() {
+    if (!instance || !isDomHost(instance.host)) return
+    const host = instance.host
+    const keys = Object.keys(markerPlan.byStatus).sort()
+    host.dataset.mapMarkers = String(markerRendered)
+    host.dataset.mapMarkersPlan = String(markerPlan.total)
+    host.dataset.mapMarkersStatus = keys.map((key) => `${key}:${markerPlan.byStatus[key]}`).join(',')
+    host.dataset.mapMarkerShapes = [...new Set(markerPlan.markers.map((item) => item.shape))].sort().join(',')
+}
+
+// ---- 票 10:点位点选(点地图上的标记打开两级详情) ----
+
+/**
+ * 点选订阅者(MapCanvas 挂载时订阅、离开时退订)。
+ *
+ * 为什么是订阅而不是让服务层直接调 store:本模块不依赖 Vue、也不认识 pinia
+ * (票 02 的边界),所以「拾到谁」由服务层说,「打开什么」由订阅者决定。
+ * 订阅者可以有几个(详情浮层、将来的联动面板),互不知情。
+ */
+const pointPickHandlers = new Set()
+
+/** 最近一次拾到的标记(DEV 把手与走查读它)。 */
+let lastPicked = null
+
+/**
+ * 订阅点位点选。返回退订函数(组件 `onBeforeUnmount` 调它)。
+ * @param {(picked: {kind: string, code: string, status: string}) => void} handler
+ */
+export function onPointPick(handler) {
+    if (typeof handler !== 'function') return () => {}
+    pointPickHandlers.add(handler)
+    return () => {
+        pointPickHandlers.delete(handler)
+    }
+}
+
+/** 选中标记写进宿主 dataset:`data-map-picked` = `kind|code`(走查读它,不读中文)。 */
+function paintPicked(record, picked) {
+    if (!isDomHost(record && record.host)) return
+    record.host.dataset.mapPicked = picked ? `${picked.kind}|${picked.code}` : ''
+    record.host.dataset.mapPickedStatus = picked ? picked.status || '' : ''
+}
+
+/**
+ * 窗口坐标 → 点位标记 → 通知订阅者(票 10)。
+ *
+ * 引擎未就绪、没拾到标记、拾到的不是点位标记:都返回 `null` 且**不改选中态**
+ * (点空白处不该把已经打开的详情莫名其妙地收掉,那是「关闭」按钮的事)。
+ * @param {number} clientX 窗口坐标(与 `getBoundingClientRect()` 同系)
+ * @param {number} clientY
+ * @returns {{kind: string, code: string, status: string}|null}
+ */
+export function pickPointAt(clientX, clientY) {
+    if (!instance || !instance.engine || !instance.viewer) return null
+
+    const picked = instance.engine.pickPointMarkerAt(instance.viewer, clientX, clientY)
+    if (!picked) return null
+
+    lastPicked = picked
+    paintPicked(instance, picked)
+    for (const handler of [...pointPickHandlers]) handler(picked)
+    return picked
+}
+
+/** 最近一次拾到的点位标记(没有就是 null)。 */
+export function getPickedPoint() {
+    return lastPicked
+}
+
+/**
+ * 星球自转(引导页用):`seconds` = 转一圈的秒数,0 = 停止。
+ *
+ * 实现要点:**不能直接转相机**(`camera.rotate`)—— 票 03 的 `cameraControl` 每帧都会用
+ * `lookAtTransform(中心, HeadingPitchRange)` 重写相机,直接转会被立刻抹掉(实测:经度纹丝不动)。
+ * 所以自转走同一条路:按时间推进**中心点的经度**,再以 `immediate` 套用预设,让相机跟着中心绕地轴转。
+ * @returns {boolean} 是否生效;相机还没接上时返回 false(调用方可以稍后重试)
+ */
+/** 兼容旧调用:`setGlobeSpin(0)` 也会停;
+ *  注意:**自转与飞行互斥** —— 只要飞行(进入系统/归位)开始,就必须先把自转停掉,
+ *  所以这里额外检查「正在飞行中就不要重新起转」。 */
+export function setGlobeSpin(seconds) {
+    const spinSeconds = Number(seconds)
+    if (!Number.isFinite(spinSeconds) || spinSeconds <= 0) {
+        stopSpin()
+        return true
+    }
+    if (!instance || !instance.cameraControl) return false
+    // 已经有明确目标(飞行中)就别再起转,免得把目标冲掉。
+    if (instance.spinPaused) return true
+
+    startSpin(instance, spinSeconds)
+    return true
+}
+
+function startSpin(record, spinSeconds) {
+    stopSpin()
+    record.spin = { spinSeconds, lastAt: Date.now(), raf: null }
+
+    const step = () => {
+        // 自转随时可能被「进入系统/归位」打断:只要 spin 被清掉就立刻退出这一帧循环,
+        // 绝不能再用 immediate 去覆盖飞行目标。
+        if (!record.spin || instance !== record || !record.cameraControl) return
+        const now = Date.now()
+        const dt = Math.min(now - record.spin.lastAt, 100)
+        record.spin.lastAt = now
+
+        // 绕地轴推进中心经度:一圈 2π ÷ spinSeconds;负号 = 自西向东(与真实自转同向)。
+        const state = record.cameraControl.getState()
+        const pivot = state && state.pivot ? state.pivot : null
+        if (pivot) {
+            const deltaLon = (-360 * dt) / (record.spin.spinSeconds * 1000)
+            record.cameraControl.applyViewPreset(
+                { lon: pivot.lon + deltaLon, lat: pivot.lat, height: pivot.height || 0 },
+                state.hpr,
+                { immediate: true },
+            )
+        }
+        record.spin.raf = requestAnimationFrame(step)
+    }
+
+    record.spin.raf = requestAnimationFrame(step)
+}
+
+function stopSpin() {
+    if (instance && instance.spin) {
+        if (instance.spin.raf !== null) cancelAnimationFrame(instance.spin.raf)
+        instance.spin = null
+    }
+}
+
 /** 当前相机状态快照(中心、弹簧臂参数、是否在拖动/飞行);引擎未就绪时为 null。 */
 export function getCameraState() {
     return instance && instance.cameraControl ? instance.cameraControl.getState() : null
@@ -294,12 +888,25 @@ export function getLifecycleStats() {
         lastResizeAt,
         mounted: !!instance,
         instanceId: instance ? instance.id : '',
-        // 票 27 追加:引擎状态与底图状态(上面 6 个字段语义不变)。
+        // 票 27 追加:引擎状态与底图状态(上面 6 个字段语义不变);票 29 起底图状态来自配置计划。
         engine: engineState,
         engineError,
-        basemap: BASEMAP_STATE,
+        basemap: basemapState,
+        // 票 21 追加:地形与叠加影像状态。
+        terrain: terrainState,
+        terrainError,
+        overlays: overlayCount,
+        // 票 21 追加:3D Tiles / 倾斜摄影(入口已留好,本机数据缺根 tileset.json 时如实报失败)。
+        tilesets: tilesetReady,
+        tilesetErrors: tilesetFailed.map((item) => item.error),
+        // 票 30 追加:自转是否在跑(引导页状态,走查与排查都看它)。
+        spin: !!(instance && instance.spin),
         // 票 03 追加:相机契约是否已挂上(三键输入、中心规则都在 cameraControl 内)。
         camera: instance && instance.cameraControl ? 'attached' : 'none',
+        // 票 09 追加:点位标记的计划数 / 实际画到地图上的数(引擎没就绪时只有计划)。
+        markers: markerPlan.total,
+        markersOnMap: markerRendered,
+        markerStatus: { ...markerPlan.byStatus },
     }
 }
 
@@ -318,4 +925,17 @@ export function resetLifecycleStatsForTest() {
     lastResizeAt = ''
     engineState = 'idle'
     engineError = ''
+    basemapState = BASEMAP_STATE.NOT_CONFIGURED
+    basemapText = basemapStatusText(null)
+    terrainState = TERRAIN_STATE.NONE
+    terrainText = terrainStatusText(null)
+    terrainError = ''
+    overlayCount = 0
+    tilesetReady = 0
+    tilesetFailed = []
+    viewMode = VIEW_MODES.GROUND
+    markerPlan = { markers: [], blocked: [], byStatus: {}, total: 0 }
+    markerRendered = 0
+    markerSkipped = 0
 }
+

+ 68 - 0
src/modules/cockpit/services/pointPick.js

@@ -0,0 +1,68 @@
+/**
+ * 点位点选的 DOM 绑定(票 10):「左键点一下」与「左键拖着地图走」必须分开。
+ *
+ * 为什么必须有这道区分:票 03 的相机契约里左键是**平移**(抓着地图拖)。如果按下就打开点位摘要,
+ * 用户每次拖完地图都会弹出一张卡片 —— 那不是新功能,是把已有交互弄坏。判据是**按下与松开的位移**:
+ * 不超过阈值算点选,超过算拖拽。
+ *
+ * 本模块只**观察**事件,不 preventDefault、不 stopPropagation、不抢 pointer capture:
+ * 相机的手感与三键契约完全不变(绑定在同一个元素上,两套监听互不干扰)。
+ */
+/** 点选允许的最大位移(像素):手指/鼠标总会有几像素抖动,0 会把正常点选判成拖拽。 */
+export const CLICK_MAX_DRAG_PX = 4
+
+/**
+ * 纯判据:两次坐标算不算「点一下」(脱浏览器可断言)。
+ * @param {{x: number, y: number}} down 按下时的窗口坐标
+ * @param {{x: number, y: number}} up 松开时的窗口坐标
+ * @param {number} [maxDrag] 允许的最大位移
+ */
+export function isClickGesture(down, up, maxDrag = CLICK_MAX_DRAG_PX) {
+    if (!down || !up) return false
+    const dx = Number(up.x) - Number(down.x)
+    const dy = Number(up.y) - Number(down.y)
+    if (!Number.isFinite(dx) || !Number.isFinite(dy)) return false
+    return Math.hypot(dx, dy) <= maxDrag
+}
+
+/**
+ * 绑定点选输入。
+ * @param {Element} element 事件源(地图宿主:Cesium 画布的事件会冒泡到这里)
+ * @param {(point: {x: number, y: number}) => void} onPick 判定为点选时回调(窗口坐标)
+ * @param {object} [options] `{ maxDrag }`
+ * @returns {() => void} detach
+ */
+export function attachPointPick(element, onPick, options = {}) {
+    // 与 `attachCameraInput` 同一道判据:不是可绑定元素就退回空 detach(脱浏览器调用不抛错)。
+    if (!element || typeof element.addEventListener !== 'function') return () => {}
+
+    const maxDrag = Number.isFinite(options.maxDrag) ? options.maxDrag : CLICK_MAX_DRAG_PX
+    let down = null
+
+    const onPointerDown = (event) => {
+        // 只认左键:右键是轨道旋转(票 03),中键与其它键不参与点选。
+        down = event.button === 0 ? { x: event.clientX, y: event.clientY, id: event.pointerId } : null
+    }
+
+    const onPointerUp = (event) => {
+        const start = down
+        down = null
+        if (!start || event.pointerId !== start.id) return
+        if (!isClickGesture(start, { x: event.clientX, y: event.clientY }, maxDrag)) return
+        if (typeof onPick === 'function') onPick({ x: event.clientX, y: event.clientY })
+    }
+
+    const onPointerCancel = () => {
+        down = null
+    }
+
+    element.addEventListener('pointerdown', onPointerDown)
+    element.addEventListener('pointerup', onPointerUp)
+    element.addEventListener('pointercancel', onPointerCancel)
+
+    return () => {
+        element.removeEventListener('pointerdown', onPointerDown)
+        element.removeEventListener('pointerup', onPointerUp)
+        element.removeEventListener('pointercancel', onPointerCancel)
+    }
+}

+ 91 - 0
src/modules/cockpit/services/runtimeApi.js

@@ -0,0 +1,91 @@
+/**
+ * 运行链路领域接口(票 05 只交 REST 侧的分区读取;消费方是票 15/16)。
+ *
+ * ADR 0005 的 `runtimeApi`:WebSocket 是运行链路,前端消费的是**版本化领域事件**,
+ * 不把 `/ws/deviceStatus` 的裸 JSON 当依赖。本文件先交 REST 侧能落地的部分
+ * (新鲜度、设备状态增量帧 → 领域事件的**转换**),连接与重连归票 15。
+ *
+ * 增量帧的字段名有个坑(调研 §1 已核实):WS 线上帧的状态键是 `data.status`,
+ * 而后端 DTO 命名为 `monitoringState`。转换层两个键都认,避免接错一边就静默丢状态。
+ */
+import { toFreshness } from '../domain/freshness.js'
+import { fetchSection } from './domainRequest.js'
+import { SECTION_STATE, section } from '../domain/sectionState.js'
+
+/** WS 消息类型(后端 `DeviceStatusWebSocketHandler`)。 */
+export const WS_MESSAGE = {
+    SUBSCRIBE: 'cockpitSubscribe',
+    SUBSCRIBED: 'cockpitSubscribed',
+    DELTA: 'cockpitDeviceStatusDelta',
+    AUTHENTICATE: 'authenticate',
+}
+
+/**
+ * 设备状态增量帧 → 领域事件。
+ * 只做字段归一,不做取舍:是否采纳(旧消息要丢弃)由票 15 按 `serverUpdatedAt` 判。
+ * @param {object} frame WS 线上帧
+ * @returns {object|null} 认不出的帧回 null(调用方据此忽略,不猜)
+ */
+export function toDeviceStatusEvent(frame) {
+    const raw = frame && typeof frame === 'object' ? frame : null
+    if (!raw || raw.type !== WS_MESSAGE.DELTA) return null
+
+    const payload = raw.data && typeof raw.data === 'object' ? raw.data : {}
+    const deviceCode = raw.deviceCode || raw.device_code || payload.deviceCode || ''
+    if (!deviceCode) return null
+
+    return {
+        deviceCode,
+        // 线上帧用 status,DTO 用 monitoringState:两个都认。
+        status: payload.status || payload.monitoringState || '',
+        statusTime: payload.statusTime || '',
+        serverUpdatedAt: payload.serverUpdatedAt || raw.serverUpdatedAt || '',
+        reason: payload.reason || '',
+        // 增量帧不带坐标与行业(只能靠 deviceCode 回联 REST 快照)——票 15 据此设计合并。
+        sampledAt: payload.sampledAt || '',
+    }
+}
+
+/** 订阅确认帧 → 领域事件。 */
+export function toSubscribedEvent(frame) {
+    const raw = frame && typeof frame === 'object' ? frame : null
+    if (!raw || raw.type !== WS_MESSAGE.SUBSCRIBED) return null
+    return { scope: raw.scope || '', serverUpdatedAt: raw.serverUpdatedAt || '' }
+}
+
+/**
+ * 设备新鲜度分区(票 16 的统一新鲜度模型)。
+ * @param {string} deviceCode 设备编码
+ * @param {object} [options] `{ calibrationPeriodSeconds, config, fetchImpl }`
+ */
+export async function fetchFreshnessSection(deviceCode, options = {}) {
+    if (!deviceCode) {
+        return section(SECTION_STATE.FAILED, { reason: '缺少设备编码' })
+    }
+
+    const result = await fetchSection(`/equipments/${encodeURIComponent(deviceCode)}/freshness`, {
+        params: { calibrationPeriodSeconds: options.calibrationPeriodSeconds },
+        toData: toFreshness,
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+
+    if (result.state !== SECTION_STATE.SUCCESS) return result
+
+    const freshness = result.data || {}
+    // 后端四态 → 分区状态:EXPIRED/STALE 归「过期但保留旧值」,EMPTY 归「确实没有」。
+    if (freshness.freshness === 'EMPTY') {
+        return section(SECTION_STATE.EMPTY, { mode: result.mode, data: freshness, freshness: 'EMPTY' })
+    }
+    if (freshness.freshness === 'EXPIRED' || freshness.freshness === 'STALE') {
+        return section(SECTION_STATE.STALE, {
+            mode: result.mode,
+            reason: freshness.staleReason || '',
+            data: freshness,
+            freshness: freshness.freshness,
+            retained: freshness.retained === true,
+        })
+    }
+
+    return section(SECTION_STATE.SUCCESS, { mode: result.mode, data: freshness, freshness: freshness.freshness || 'CURRENT' })
+}

+ 395 - 0
src/modules/cockpit/stores/detailStore.js

@@ -0,0 +1,395 @@
+/**
+ * 详情与分区状态(ADR 0004 的 `detailStore`:监测点摘要、设备卡片、设备详情**及各分区状态**)。
+ *
+ * 票 05 的职责边界:本 store 只做两件事——
+ *   1. 调领域接口(`services/*Api.js`),把结果收进 `sections`;
+ *   2. 对外给**领域对象**,不给后端裸字段(store 里不出现 `deviceCode` 之外的接口字段名)。
+ *
+ * 为什么各分区要并列存放(票 05 验收第 6 条、票 11 验收第 3 条):
+ * 「某个分区数据不可用」必须**不牵连**其它分区,所以状态天然是 `{ [分区名]: section() }`,
+ * 而不是一个整体的 `loading/error`。地图骨架不在这里——它归 `mapService`,本身就不受分区成败影响。
+ *
+ * 票 08 修正了一处分工:地图摘要这一条分区不再在这里「取一页了事」,而是交给 `mapStore` 的
+ * 渐进加载(分页 + 快照一致性归它)。本 store 只把它的装载状态同步成一条分区状态,
+ * 免得同一个端点被两处各拉一次(那还会各持一个快照,正好违反票 08 的一致性要求)。
+ *
+ * 票 10 追加**两级详情**:`selection`(在点位级还是设备级)+ 点位摘要分区。
+ * 层级语义是纯函数(`domain/detailFlow.js`),本文件只负责「该取数时取数」与收口竞态:
+ *   · 同一个点位重复打开不再发请求(`isSamePointTarget` + 已加载);
+ *   · 先后打开 A、B 时,A 的迟到响应被丢弃(token),不会把 A 的数据挂在 B 的表头下;
+ *   本文件与 `mapService` 零耦合:详情流程不会给地图加任何标记(关联设备因此不会被重复上图)。
+ *
+ * 票 11 补上第三件取数:**设备详情聚合**(按设备编码)。它接在票 10 留下的那个接缝上 ——
+ * 票 10 的「进第二级不取数」是**延期**(它的票面写明"归票 11"),不是永久契约;本票把这条
+ * 取数收进 `selectDevice` / `openDevice` 这两个**唯一**的进级入口,于是「进了设备级却没取数」
+ * 在结构上不可能发生(不用调用方记得额外调一次加载)。竞态收口与点位级同款:
+ * 换设备时旧的在途响应作废(token),并且**已答过的同一台设备不再重复取数**,
+ * 而失败/不可用要留恢复路径(再选一次同一台可以重试)。
+ */
+import { ref } from 'vue'
+import { defineStore } from 'pinia'
+import { fetchEquipmentDetailAggregate, fetchEquipmentFreshness, fetchMonitoringPointDetail } from '../services/detailApi.js'
+import { SECTION_STATE, overallState, section } 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 {
+    DETAIL_LEVEL,
+    backToPoint as backToPointSelection,
+    closeDetail as closeDetailSelection,
+    emptyDetailSelection,
+    isSamePointTarget,
+    openDevice as openDeviceSelection,
+    openPoint as openPointSelection,
+} from '../domain/detailFlow.js'
+import { useMapStore } from './mapStore.js'
+
+/** 演示/联调用的参考设备(契约化 mock 里有这一台的完整档案)。 */
+export const DEMO_DEVICE_CODE = 'TC-PS-WS-202609020012'
+
+function pending() {
+    return { state: SECTION_STATE.EMPTY, reason: '', mode: '', data: null, missing: [], freshness: '', retained: false, loaded: false }
+}
+
+export const useDetailStore = defineStore('detailStore', () => {
+    const mapStore = useMapStore()
+
+    /**
+     * 分区状态表:键是分区名,值是 `section()`。
+     * 挂了**共享详情域**的分区(票 05 的交付面):地图摘要、监测点摘要(票 10)、
+     * 设备详情聚合(8 个子分区)、设备新鲜度。
+     */
+    const sections = ref({
+        mapSnapshot: pending(),
+        pointDetail: pending(),
+        equipmentAggregate: pending(),
+        equipmentFreshness: pending(),
+    })
+
+    /** 当前的两级详情选择(`domain/detailFlow.js` 的状态)。 */
+    const selection = ref(emptyDetailSelection())
+
+    /** 点位摘要正在取数(界面只做轻提示;分区状态仍由 `sections.pointDetail` 说了算)。 */
+    const pointLoading = ref(false)
+
+    /** 设备详情聚合正在取数(票 11;同样只是轻提示,分区成败由各分区自己说)。 */
+    const deviceLoading = ref(false)
+
+    /** 最近一次加载是否进行中(界面只做轻提示,不影响分区各自的成功值)。 */
+    const loading = ref(false)
+
+    /** 请求注入(config / fetchImpl):取一次就让后续所有分区共用,单测与走查都靠它。 */
+    let requestOptions = {}
+
+    /** 点位摘要的请求序号:只有最后一次请求允许写分区(迟到响应丢弃)。 */
+    let pointToken = 0
+
+    /** 设备详情聚合的请求序号:换设备时旧的在途响应作废(与点位级同款收口)。 */
+    let deviceToken = 0
+
+    function configure(options = {}) {
+        requestOptions = { ...requestOptions, ...options }
+    }
+
+    function setSection(name, value) {
+        sections.value = { ...sections.value, [name]: { ...value, loaded: true } }
+    }
+
+    /**
+     * 把 `mapStore` 的渐进加载状态同步成「地图摘要」分区状态(票 08)。
+     *
+     * 映射刻意**不把"还没翻完"写成 `STALE`**:`STALE` 在票 16 的词汇里是「有值但过期」,
+     * 而分页没翻完的数据是**新鲜的**,只是还没取全 —— 说成"已过期"就是对值班人员撒谎,
+     * 也会让顶栏分区板与地图上真实进度互相矛盾。取到一部分就是**成功**(数据源健康、值新鲜),
+     * 「翻到第几页了」由地图摘要那条专用指示器与地图上的点位数体现。
+     *
+     * 真正该报异常的三种:真失败(`FAILED`)、快照失效(`STALE` + `retained`,这次确实是"有值但已作废")、
+     * 后端说没有对象(`EMPTY`)。
+     */
+    function syncMapSnapshotSection() {
+        const state = mapStore.summary
+        const phase = state.phase
+        let next = null
+
+        if (phase === MAP_SUMMARY_PHASE.EMPTY) {
+            next = section(SECTION_STATE.EMPTY, { data: null })
+        } else if (phase === MAP_SUMMARY_PHASE.FAILED) {
+            next = section(SECTION_STATE.FAILED, { reason: state.reason || '', data: state.loaded ? state : null })
+        } else if (phase === MAP_SUMMARY_PHASE.EXPIRED) {
+            next = section(SECTION_STATE.STALE, { reason: state.reason || '', data: state, retained: true })
+        } else if (state.loaded > 0) {
+            // 加载中 / 就绪(还有下一页):已上图的点位是新鲜的,分区就是成功。
+            next = section(SECTION_STATE.SUCCESS, { data: state })
+        }
+
+        // 一个点都还没取到、也还没失败时**不改写**分区:保持 pending(未取数),
+        // 不拿「无数据」或「已过期」去描述"正在取第 1 页"。
+        if (!next) return sections.value.mapSnapshot
+
+        setSection('mapSnapshot', next)
+        return next
+    }
+
+    /**
+     * 加载共享详情域分区。**并发取、各自成败**:一个分区失败不回滚其它已成功分区。
+     * 地图摘要走 `mapStore` 的第一页(票 08 的渐进加载入口)。
+     * @param {object} [options] `{ deviceCode, config, fetchImpl }`
+     */
+    async function loadSharedSections(options = {}) {
+        const deviceCode = options.deviceCode || DEMO_DEVICE_CODE
+        const shared = { config: options.config, fetchImpl: options.fetchImpl }
+        configure(shared)
+        loading.value = true
+
+        // 这条预载也领一个设备请求序号:用户若在预载途中就点进了某台设备的详情,
+        // 预载这份「演示设备」的载荷不得落地覆盖用户真正要看的那一台
+        // (同一条 token 纪律,见 `loadEquipmentAggregate`)。
+        deviceToken += 1
+        const mine = deviceToken
+
+        mapStore.setup(shared)
+        const [, aggregate, freshness] = await Promise.all([
+            mapStore.advance(),
+            fetchEquipmentDetailAggregate(deviceCode, shared),
+            fetchEquipmentFreshness(deviceCode, shared),
+        ])
+
+        syncMapSnapshotSection()
+        if (mine === deviceToken) setSection('equipmentAggregate', aggregate)
+        setSection('equipmentFreshness', freshness)
+        loading.value = false
+
+        return sections.value
+    }
+
+    /**
+     * 加载某个监测点的详情(票 10 的第一级:摘要 + 设备卡片)。
+     * @param {string} pointCode 监测点编码
+     * @returns {Promise<object>} 该分区(`section()`)
+     */
+    async function loadPointDetail(pointCode, options = {}) {
+        if (!pointCode) return sections.value.pointDetail
+
+        pointToken += 1
+        const mine = pointToken
+        pointLoading.value = true
+        const active = Object.keys(options).length ? { ...requestOptions, ...options } : requestOptions
+
+        try {
+            const result = await fetchMonitoringPointDetail(pointCode, active)
+            // 迟到的响应丢弃:A 的点位还没回来就点了 B,A 的数据不得盖在 B 的表头下。
+            if (mine !== pointToken) return sections.value.pointDetail
+            setSection('pointDetail', result)
+            return result
+        } catch (error) {
+            // 适配层本身不会抛(票 04 的失败一律收敛成 `{ok:false,reason}`),但转换层万一抛了
+            // 也不能把详情卡在「加载中」或冒成页面级报错:收成一条失败分区,用户还能重试。
+            if (mine !== pointToken) return sections.value.pointDetail
+            const failed = section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
+            setSection('pointDetail', failed)
+            return failed
+        } finally {
+            if (mine === pointToken) pointLoading.value = false
+        }
+    }
+
+    /**
+     * 加载设备详情聚合(票 11 的主路径)。一次拿到档案/指标/预警/报警/工单/时间线/图片,
+     * 每个分区独立成败(契约层保证,见 `domain/equipmentDetail.js`)。
+     *
+     * @param {string} deviceCode 设备编码
+     * @returns {Promise<object>} 该分区(`section()`)
+     */
+    async function loadEquipmentAggregate(deviceCode, options = {}) {
+        if (!deviceCode) return sections.value.equipmentAggregate
+
+        deviceToken += 1
+        const mine = deviceToken
+        deviceLoading.value = true
+        const active = Object.keys(options).length ? { ...requestOptions, ...options } : requestOptions
+
+        try {
+            const result = await fetchEquipmentDetailAggregate(deviceCode, active)
+            // 迟到的响应丢弃:A 还没回来就换了 B,A 的数据不得挂在新表头下。
+            if (mine !== deviceToken) return sections.value.equipmentAggregate
+            setSection('equipmentAggregate', result)
+            return result
+        } catch (error) {
+            // 与点位级同款兜底:适配层不抛,但转换层万一抛了要收成一条失败分区,
+            // 不能把设备详情卡在「加载中」(用户还得能看见其它已成功的区块)。
+            if (mine !== deviceToken) return sections.value.equipmentAggregate
+            const failed = section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
+            setSection('equipmentAggregate', failed)
+            return failed
+        } finally {
+            if (mine === deviceToken) deviceLoading.value = false
+        }
+    }
+
+    /**
+     * 进入设备级:换层级 + **按需取数**(票 11 把设备详情聚合的加载收在这里)。
+     *
+     * 「已答过」= 成功 / 空 / 过期(有过一次明确答复),且答复的是**同一台设备**:
+     * 满足就不再发请求(返回点位级再点回来不该重复拉)。
+     * **失败与不可用不算已答**:那两种要允许用户再选一次同一台重试,否则永远没有恢复路径
+     * (与票 10 点位级的立场一致)。
+     */
+    async function enterDeviceLevel(next, options = {}) {
+        const previous = selection.value
+        selection.value = next
+
+        const deviceCode = next.deviceCode || ''
+        if (!deviceCode) return selection.value
+
+        const aggregate = sections.value.equipmentAggregate
+        const answered =
+            equipmentDetailMatches(aggregate, deviceCode) &&
+            aggregate.loaded === true &&
+            [SECTION_STATE.SUCCESS, SECTION_STATE.EMPTY, SECTION_STATE.STALE].includes(aggregate.state)
+        // 同一台设备正在取数时再点一下不该叠第二个请求。
+        const inFlight = deviceLoading.value && (previous.deviceCode || '') === deviceCode
+
+        if (!answered && !inFlight) {
+            await loadEquipmentAggregate(deviceCode, options)
+        }
+        return selection.value
+    }
+
+    /**
+     * 打开一个详情选择 —— 地图点选与(票 18 的)台账卡片共用这一条入口。
+     *
+     * 两级都可能取数:点位级取点位摘要(票 10),设备级取设备详情聚合(票 11)。
+     * 同一个点位已经取过就不再发请求,但层级仍会回到第一级(用户可能只是想退回卡片列表)。
+     * @param {object} next `domain/detailFlow.js` 的选择
+     */
+    async function openSelection(next, options = {}) {
+        if (!next || next.level === DETAIL_LEVEL.NONE) return closeDetail()
+
+        if (next.level === DETAIL_LEVEL.EQUIPMENT) {
+            return enterDeviceLevel(
+                { level: next.level, pointCode: next.pointCode || '', deviceCode: next.deviceCode || '' },
+                options,
+            )
+        }
+
+        const samePoint = isSamePointTarget(selection.value, next.pointCode)
+        const state = sections.value.pointDetail.state
+        /**
+         * 「已经有确定结果」= 成功 / 空 / 过期(有过一次明确答复)。
+         * **失败或不可用不算**:那两种情况要允许用户再点一次同一个点位重试,
+         * 否则点同一个标记只会反复看到同一张失败卡片,永远没有恢复路径。
+         */
+        const answered = sections.value.pointDetail.loaded === true && [SECTION_STATE.SUCCESS, SECTION_STATE.EMPTY, SECTION_STATE.STALE].includes(state)
+        /** 同一点位正在取数时再点一下不该叠第二个请求。 */
+        const inFlight = pointLoading.value && samePoint
+
+        selection.value = {
+            level: next.level,
+            pointCode: next.pointCode || '',
+            deviceCode: next.deviceCode || '',
+        }
+
+        if (selection.value.level === DETAIL_LEVEL.POINT && !(samePoint && (answered || inFlight))) {
+            await loadPointDetail(selection.value.pointCode, options)
+        }
+        return selection.value
+    }
+
+    /** 打开监测点(第一级)。 */
+    function openPoint(pointCode, options = {}) {
+        return openSelection(openPointSelection(selection.value, pointCode), options)
+    }
+
+    /** 选择设备卡片(第二级):换层级并加载这台设备的详情聚合(票 11)。 */
+    function selectDevice(deviceCode, options = {}) {
+        return enterDeviceLevel(openDeviceSelection(selection.value, deviceCode), options)
+    }
+
+    /** 从地图上的未关联设备直接进设备详情(没有点位可摘要)。 */
+    function openDevice(deviceCode, options = {}) {
+        return enterDeviceLevel({ level: DETAIL_LEVEL.EQUIPMENT, pointCode: '', deviceCode }, options)
+    }
+
+    /**
+     * 返回点位摘要级;没有点位可回时收成空选择(未关联设备的入口)。
+     *
+     * 刻意**不清掉已加载的设备详情**:退回卡片列表再点回同一台时靠它免掉一次重复请求
+     * (`enterDeviceLevel` 的「已答过」判据)。作废的只是在途请求序号。
+     */
+    function backToPoint() {
+        deviceToken += 1
+        deviceLoading.value = false
+        selection.value = backToPointSelection(selection.value)
+        return selection.value
+    }
+
+    /** 关掉详情浮层。在途的点位与设备请求一并作废(它们已经不属于任何一次选择)。 */
+    function closeDetail() {
+        pointToken += 1
+        deviceToken += 1
+        pointLoading.value = false
+        deviceLoading.value = false
+        selection.value = closeDetailSelection()
+        return selection.value
+    }
+
+    /** 设备详情聚合里的一个子分区(票 11 按分区渲染时取用)。 */
+    function aggregateSection(name) {
+        const aggregate = sections.value.equipmentAggregate
+        const data = (aggregate && aggregate.data) || null
+        const found = data && data.sections ? data.sections[name] : null
+        return found || pending()
+    }
+
+    /** 全部子分区(票 11 遍历渲染)。 */
+    function aggregateSections() {
+        const aggregate = sections.value.equipmentAggregate
+        const data = (aggregate && aggregate.data) || null
+        return (data && data.sections) || {}
+    }
+
+    /** 三条顶层分区的整体状态:`partial` 表示"部分可用"(票 16 要求可区分)。 */
+    function overall() {
+        return overallState(sections.value)
+    }
+
+    /** 仅测试用:回到未加载状态。 */
+    function reset() {
+        sections.value = {
+            mapSnapshot: pending(),
+            pointDetail: pending(),
+            equipmentAggregate: pending(),
+            equipmentFreshness: pending(),
+        }
+        pointToken += 1
+        deviceToken += 1
+        pointLoading.value = false
+        deviceLoading.value = false
+        selection.value = emptyDetailSelection()
+        loading.value = false
+    }
+
+    return {
+        sections,
+        selection,
+        pointLoading,
+        deviceLoading,
+        loading,
+        configure,
+        setSection,
+        syncMapSnapshotSection,
+        loadSharedSections,
+        loadPointDetail,
+        loadEquipmentAggregate,
+        openSelection,
+        openPoint,
+        openDevice,
+        selectDevice,
+        backToPoint,
+        closeDetail,
+        aggregateSection,
+        aggregateSections,
+        overall,
+        reset,
+    }
+})

+ 16 - 3
src/modules/cockpit/stores/filterStore.js

@@ -6,12 +6,14 @@ import {
     isKnownIndustry,
     isKnownScope,
 } from '../domain/filterOptions.js'
+import { DEFAULT_VIEW_MODE, isKnownViewMode } from '../domain/viewModes.js'
 
 /**
  * 全局筛选(ADR 0004 的 filterStore)。
  *
  * 作用于整个驾驶舱:切换一级模式不得重置这里的任何状态(T01 验收第 ⑦ 条)。
- * 当前只持区域与行业;状态、地面/管线视图等后续票再并入。
+ * 持区域、行业,以及**地面/管线视图**(票 21;决策地图票 05 指定它属于 filterStore ——
+ * 与行业筛选同属「全局范围条件」,全局共用且切一级模式不重置)。
  */
 export const useFilterStore = defineStore('filterStore', () => {
     /** 当前范围(区域)。 */
@@ -20,6 +22,9 @@ export const useFilterStore = defineStore('filterStore', () => {
     /** 当前行业。 */
     const industry = ref(DEFAULT_INDUSTRY)
 
+    /** 当前视图条件:ground(地面)/ pipeline(管线)。 */
+    const viewMode = ref(DEFAULT_VIEW_MODE)
+
     /** 切换范围;未知键忽略,避免脏值进入全局条件。 */
     function setScope(next) {
         if (isKnownScope(next)) {
@@ -34,11 +39,19 @@ export const useFilterStore = defineStore('filterStore', () => {
         }
     }
 
-    /** 回到默认筛选(全域 / 全部行业)。 */
+    /** 切换地面/管线视图;未知键忽略。 */
+    function setViewMode(next) {
+        if (isKnownViewMode(next)) {
+            viewMode.value = next
+        }
+    }
+
+    /** 回到默认筛选(全域 / 全部行业 / 地面视图)。 */
     function reset() {
         scope.value = DEFAULT_SCOPE
         industry.value = DEFAULT_INDUSTRY
+        viewMode.value = DEFAULT_VIEW_MODE
     }
 
-    return { scope, industry, setScope, setIndustry, reset }
+    return { scope, industry, viewMode, setScope, setIndustry, setViewMode, reset }
 })

+ 128 - 0
src/modules/cockpit/stores/mapStore.js

@@ -0,0 +1,128 @@
+/**
+ * 地图摘要状态(ADR 0004 的 `mapStore`):分页快照、已加载点位、未关联设备、渐进加载状态。
+ *
+ * 职责边界(票 08):
+ *   · 分页与快照语义**不在本文件**——它们在 `domain/mapSummary.js` 的装载器里(纯函数 + 注入取数),
+ *     所以脱浏览器的逻辑脚本能完整演示六态(见 verify/t08-map-summary.mjs);
+ *   · 本文件只做两件事:把「取一页」接到 `services/mapApi.js`,把装载器状态转成 Vue 响应式,
+ *     供外壳与地图浮标渲染;
+ *   · 组件与页面**不出现** `page` / `pageSize` / `snapshotAt`(票 08 验收第 2 条:这些参数由适配层管理)。
+ *
+ * 「地图先显示框架,再按分页批次增加摘要点位」的落点是:装载器初始态是 `idle`(= 框架已就绪、
+ * 等待分页数据),宿主先渲染,`advance()` 每成功一页才把点位并进 `objects`(票 08 验收第 1 条)。
+ */
+import { computed, ref, shallowRef } from 'vue'
+import { defineStore } from 'pinia'
+import { MAP_SUMMARY_SETTLED_PHASES, createMapSummaryLoader, emptyMapSummary, summaryView } from '../domain/mapSummary.js'
+import { coordinateOverview } from '../domain/coordinate.js'
+import { fetchMapSnapshot } from '../services/mapApi.js'
+
+/** 演示与走查用的页大小:后端默认 500,这里用 2 才能在几页之内演示渐进加载。 */
+export const MAP_SUMMARY_PAGE_SIZE = 2
+
+/**
+ * 把装载器的查询参数接到领域接口,并把**分区块状结果**翻成装载器要的 `{ ok, data, reason }`。
+ *
+ * 为什么要有这一步翻译(票 08 踩到的第一处坑):`fetchMapSnapshot` 回的是票 05 的 `section()`
+ * (`{ state, reason, mode, data }`,**没有 `ok` 字段**),而装载器吃的是「一次取数的成败」。
+ * 直接把它塞进装载器会恒判失败。两者都是对的,只是词汇不同,所以翻译**只在这一处**:
+ *   · `success` → 成功,带上领域批次;
+ *   · `empty`   → 也算成功:后端确实说没数据,装载器据此收 `empty` 态;
+ *   · 其余(失败/不可用/过期)→ 失败,原因原样带给装载器(`snapshot-expired` 会被它认出来)。
+ */
+function fetchMapSummaryPage(query, options) {
+    return fetchMapSnapshot({
+        page: query.page,
+        pageSize: query.pageSize,
+        snapshotAt: query.snapshotAt,
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    }).then((result) => {
+        if (result.state === 'success' || result.state === 'empty') {
+            return { ok: true, data: result.data || { objects: [], complete: true, total: 0 }, mode: result.mode }
+        }
+        return { ok: false, reason: result.reason || 'unavailable', mode: result.mode }
+    })
+}
+
+export const useMapStore = defineStore('mapStore', () => {
+    /** 装载器状态(整体替换,不做深响应:点位数可能上千,深响应会白烧性能)。 */
+    const summary = shallowRef(emptyMapSummary({ pageSize: MAP_SUMMARY_PAGE_SIZE }))
+    /** 正在请求一页(界面据此禁掉「加载下一页」,避免连点重复翻页)。 */
+    const busy = ref(false)
+
+    let loader = null
+    let loadOptions = {}
+
+    /**
+     * 装好装载器(幂等)。取数一律走 `services/mapApi.js`;单测/走查可注入 `fetchImpl`。
+     * @param {object} [options] `{ config, fetchImpl }`
+     */
+    function setup(options = {}) {
+        loadOptions = options
+        if (!loader) {
+            loader = createMapSummaryLoader({
+                pageSize: MAP_SUMMARY_PAGE_SIZE,
+                fetchPage: (query) => fetchMapSummaryPage(query, loadOptions),
+            })
+            summary.value = loader.getState()
+        }
+        return loader
+    }
+
+    /**
+     * 取下一页(票 08 验收第 1 条:一批一批地增)。完成/空之后是空操作。
+     * @returns {Promise<object>} 取完后的装载器状态
+     */
+    async function advance() {
+        setup(loadOptions)
+        if (busy.value) return summary.value
+        busy.value = true
+        try {
+            summary.value = await loader.loadNext()
+        } finally {
+            busy.value = false
+        }
+        return summary.value
+    }
+
+    /**
+     * 刷新:换到后端的新快照,从第 1 页重新加载(票 08 验收第 3 条的另一半)。
+     * 快照已失效时也走这条路径——后端会开一份新快照。
+     */
+    async function refresh() {
+        setup(loadOptions)
+        if (busy.value) return summary.value
+        busy.value = true
+        try {
+            summary.value = await loader.refresh()
+        } finally {
+            busy.value = false
+        }
+        return summary.value
+    }
+
+    /** 界面用的文案与色键(`domain/mapSummary.js` 的 `summaryView`)。 */
+    const view = computed(() => summaryView(summary.value))
+
+    /**
+     * 票 07:坐标准入总览(后端全量口径 + 前端已加载口径 + 不上图清单)。
+     * 组件只读这一个对象,不自己算坐标 —— 判定归 `domain/coordinate.js`。
+     */
+    const coordinate = computed(() => coordinateOverview(summary.value.objects, summary.value.coordinateSummary))
+
+    /** 已上图对象数;走查与浮标都读它。 */
+    const loaded = computed(() => summary.value.loaded)
+
+    /** 是否已经取不动了(完成或空):界面据此把「加载下一页」置灰。 */
+    const settled = computed(() => MAP_SUMMARY_SETTLED_PHASES.includes(summary.value.phase))
+
+    /** 仅测试用:丢掉装载器与状态。 */
+    function reset() {
+        loader = null
+        summary.value = emptyMapSummary({ pageSize: MAP_SUMMARY_PAGE_SIZE })
+        busy.value = false
+    }
+
+    return { summary, view, coordinate, loaded, busy, settled, setup, advance, refresh, reset }
+})

+ 6 - 1
src/modules/cockpit/styles/tokens.css

@@ -29,7 +29,8 @@
     --cyan-d: #1b93cf;
 
     /* ---- 语义色:事件四级与点位状态共用一套调色板(ADR 0006 / 0008)----
-       lv1 红=1 级/报警;lv2 黄=2 级/预警;lv3 蓝=3 级/处置中;lv4 绿=4 级/正常;neutral 灰=离线或无关联 */
+       色键只说优先级与深浅,不说业务:同一个 --lv1 在事件里是「1 级」、在点位状态里是「报警」。
+       两套标签词与形状见 domain/statusTones.js(事件)与 domain/pointStatus.js(点位状态)。 */
     --lv1: #ff4d5e;
     --lv2: #ffa63d;
     --lv3: #4fd8ff;
@@ -56,6 +57,10 @@
     --space-2: calc(8 * var(--u));
     --space-3: calc(12 * var(--u));
 
+    /* ---- 布局尺寸 ---- */
+    /* 左右两栏的宽度(票 10 起两级详情浮层要贴着它让位,所以从组件样式提到这里只留一份)。 */
+    --column-w: clamp(280px, calc(300 * var(--u)), 460px);
+
     /* ---- 层级 ---- */
     --z-map: 0;
     --z-columns: 2;

+ 76 - 0
tools/city-massing/README.md

@@ -0,0 +1,76 @@
+# 沅陵县城「轻量体块」模型
+
+⚠️ **这是示意体块,不是实测建筑。** 除 OSM 的少量真实轮廓外,其余体块是按**真实路网 / 建成区 /
+水系**程序化排布的**可复用楼房单元**,用途是让一张图的**远景**看起来像一座县城。
+**不得用于任何量测、统计或应急决策** —— 产物里的 `meta.json` 也写了同一条免责声明。
+
+## 为什么存在这套工具
+
+甲方给的 `D:\GIS\GIS\yuanlingSHP`(cesiumlab `model2tiles` 导出)经维护者目测判定
+**「明显错位 + 只是白色立方体,效果非常差」**,不采用。维护者 2026-09-18 定的替代方向:
+**参照 GIS 数据做轻量建模,远景基本达标、不要求近景细节、楼房模型可复用**。
+
+## 地理配准由构造保证
+
+原料是经纬度,生成时投影到以**目标点**(沅陵县城 `110.39028164, 28.4436787`)为原点的
+ENU 米制坐标,再写成 3D Tiles 根的 `transform` —— 不存在"猜模型原点"这件事
+(上一版的错位正是因为原始导出缺根 `tileset.json`、分块是局部坐标)。
+
+## 原料(本机)
+
+| 输入 | 用途 |
+| --- | --- |
+| `planet_...-shp/shpOutput/buildings.json` | OSM 真实轮廓(城区内 169 个,直接按真实形状起楼) |
+| `planet_...-shp/shpOutput/roads.json` | OSM 路网 —— 体块**沿街**排布(贴街是我国县城的常态) |
+| `planet_...-shp/shpOutput/waterways.json` | 水系(沅江)—— 体块避让 ≥22–26 m |
+| `output/resa.json` | 1:5 万居民地面(建成区范围)—— 区内抖动网格补白 |
+| `cityMassing/terrain-grid.json` | 本地 DEM 采样网格 —— 体块**坐到地形上**(否则基准面在椭球 0 m,整片楼被埋进山里) |
+
+## 可复用楼房单元
+
+三种单元反复使用,只有位置/朝向/层数/配色不同("楼房模型可复用"的落点):
+
+| 单元 | 体量 | 层数 | 屋顶 | 出现位置 |
+| --- | --- | --- | --- | --- |
+| `house` | 9 × 7.2 m | 1–2 层 | 坡屋顶 | 城区外围 |
+| `block` | 14 × 10 m | 3–5 层 | 平顶 | 一般街区 |
+| `slab` | 26 × 11 m | 6–8 层 | 平顶 | 城区核心(≤400 m) |
+
+配色共 13 种(墙 8 + 屋顶 5),按位置哈希稳定分配 —— 同一输入每次生成结果一致,不会刷一次变一样。
+
+## 用法
+
+```bash
+# 1) 先在本地 DEM 上采一张高程网格(需要 dev server 在跑:yarn dev)
+node <一次性脚本> sample-terrain-grid.mjs        # 写到 <DATA_DIR>/terrain-grid.json
+
+# 2) 生成
+node tools/city-massing/build-town.mjs            # 产物写到 D:\GIS\GIS\cityMassing
+
+# 3) 回验(地理配准 / tileset 自洽 / GLB 结构 / 免责声明)
+node tools/city-massing/verify-town.mjs
+```
+
+生成后把它配到一张图上(`map.tilesets`):
+
+```jsonc
+"map": { "tilesets": [{ "url": "/gis/city/tileset.json" }] }
+```
+
+dev 环境由 `.env.local` 的 `COCKPIT_GIS_MOUNTS` 把 `D:\GIS\GIS\cityMassing` 挂到 `/gis/city`;
+生产环境按 `docs/cockpit-deployment.md` 第 3 节的 nginx 片段挂同一目录。
+
+## 踩过的坑(别再踩)
+
+- **tileset 级 `geometricError` 必须 > 0**:写成 0 时 Cesium 不请求根瓦片内容 ——
+  `tileset.json` 拉到了、`town.glb` 始终不下载,页面上一栋楼都没有(已用诊断读数定位并修掉)。
+- **glTF 是 Y-up,3D Tiles 的局部系是 Z-up**:顶点要写 `(east, up, -north)`,
+  Cesium 装载时按 `(x, -z, y)` 转回来。写反了模型会躺倒/镜像 —— `verify-town.mjs` 里那条
+  "真实轮廓外接盒角点 2 m 内命中"就是专门证伪这条链的。
+
+## 已知取舍
+
+- **不是实测**:只有 74 个真实轮廓(城区内可用部分),其余是程序化体块。
+- **高度是估的**:OSM 无 `height`/`building:levels`,层数按"离中心距离 + 位置哈希"给。
+- **地形做了简化**:高程来自 17×17 采样网格的双线性插值,局部起伏会有几米误差。
+- **覆盖窗口**:目标点 ±0.020 经 / ±0.015 纬(约 1.9 km × 1.7 km),超出窗口不生成。

+ 536 - 0
tools/city-massing/build-town.mjs

@@ -0,0 +1,536 @@
+/**
+ * 沅陵县城「轻量体块」生成器(票 34)。
+ *
+ * ⚠️ 这是**示意体块,不是实测建筑**:除 OSM 的少量真实轮廓外,其余是按真实路网/居民地面
+ * 程序化排布的**可复用楼房单元**。用途是让一张图**远景**看起来像一座县城(维护者 2026-09-18
+ * 对上一版「白色立方体 + 明显错位」的模型的替代方案:参照 GIS 数据做轻量建模,
+ * 远景基本达标、不要求近景细节、楼房模型可复用)。
+ *
+ * 地理配准**由构造保证**:原料是经纬度,生成时投影到以目标点(沅陵县城 110.39028164,
+ * 28.4436787)为原点的 ENU 米制坐标,再写成 3D Tiles 的 `transform` —— 不存在"猜原点"。
+ *
+ * 关键输入(本机):
+ *   · planet_...-shp/shpOutput/buildings.json   OSM 真实轮廓(173 个,城区内最可靠的部分)
+ *   · planet_...-shp/shpOutput/roads.json        OSM 路网(体块沿街排布)
+ *   · planet_...-shp/shpOutput/waterways.json    水系(沅江,体块避让)
+ *   · output/resa.json                           1:5 万居民地面(建成区范围)
+ *   · cityMassing/terrain-grid.json              本地 DEM 采样网格(体块坐落到地形上)
+ *
+ * 用法:node tools/city-massing/build-town.mjs
+ * 产物:<DATA_DIR>/{town.glb, tileset.json, meta.json}
+ */
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
+import { join } from 'node:path'
+
+const GIS = 'D:/GIS/GIS'
+const OSM = join(GIS, 'planet_110.059,28.258_110.744,28.625-shp/shpOutput')
+const DATA_DIR = process.env.CITY_DATA_DIR || join(GIS, 'cityMassing')
+
+/** 原点 = 维护者给的目标坐标(CGCS2000/WGS-84 本项目视为一致)。 */
+const ORIGIN = { lon: 110.39028164, lat: 28.4436787 }
+/** 出图窗口:目标点 ±0.020 经 / ±0.015 纬(约 1.9 km × 1.7 km,县城建成区尺度)。 */
+const WINDOW = { lon: 0.02, lat: 0.015 }
+/** 楼高随离中心距离衰减:城区(≤400m)出 6–8 层,边缘出 1–2 层。 */
+const CORE_RADIUS = 400
+const CAP = 5000
+
+const DEG = Math.PI / 180
+const M_PER_DEG_LAT = 110540
+const M_PER_DEG_LON = 111320 * Math.cos(ORIGIN.lat * DEG)
+
+const toLocal = (lon, lat) => ({
+    x: (lon - ORIGIN.lon) * M_PER_DEG_LON,
+    y: (lat - ORIGIN.lat) * M_PER_DEG_LAT,
+})
+const inWindow = (lon, lat) => Math.abs(lon - ORIGIN.lon) <= WINDOW.lon && Math.abs(lat - ORIGIN.lat) <= WINDOW.lat
+
+/** 稳定的整数哈希 → [0,1):同一输入每次生成结果一致(可复现,不是每次刷新都变样)。 */
+function hash01(...nums) {
+    let h = 2166136261
+    for (const n of nums) {
+        let v = Math.round(n * 1000) | 0
+        for (let i = 0; i < 4; i++) {
+            h ^= (v >> (i * 8)) & 0xff
+            h = Math.imul(h, 16777619)
+        }
+    }
+    return ((h >>> 0) % 100000) / 100000
+}
+
+/** 线性插值取名次(用于从几个候选里挑一个,且分布可控)。 */
+const pick = (arr, r) => arr[Math.min(arr.length - 1, Math.floor(r * arr.length))]
+
+// ---------------------------------------------------------------- 读入原料
+const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'))
+const buildings = readJson(join(OSM, 'buildings.json')).features
+const roads = readJson(join(OSM, 'roads.json')).features
+const waterways = readJson(join(OSM, 'waterways.json')).features
+const resa = readJson(join(GIS, 'output/resa.json')).features
+const terrain = JSON.parse(readFileSync(join(DATA_DIR, 'terrain-grid.json'), 'utf8'))
+
+/** 高程:网格双线性插值(缺失点回落到中心值)。 */
+function elevationAt(lon, lat) {
+    const gx = (lon - terrain.lon0) / terrain.dLon
+    const gy = (lat - terrain.lat0) / terrain.dLat
+    const n = terrain.n
+    const cx = Math.max(0, Math.min(n - 1.001, gx))
+    const cy = Math.max(0, Math.min(n - 1.001, gy))
+    const x0 = Math.floor(cx), y0 = Math.floor(cy)
+    const fx = cx - x0, fy = cy - y0
+    const at = (x, y) => terrain.heights[y * n + x]
+    const corners = [at(x0, y0), at(x0 + 1, y0), at(x0, y0 + 1), at(x0 + 1, y0 + 1)].map((v) => (typeof v === 'number' ? v : 111.6))
+    const top = corners[0] * (1 - fx) + corners[1] * fx
+    const bottom = corners[2] * (1 - fx) + corners[3] * fx
+    return top * (1 - fy) + bottom * fy
+}
+
+// ---------------------------------------------------------------- 避让与参考线
+/** 水系折线(局部坐标),用于避让。 */
+const waterLines = []
+for (const f of waterways) {
+    const ring = f.geometry && f.geometry.type === 'LineString' ? f.geometry.coordinates : null
+    if (!ring) continue
+    const seg = []
+    for (const [lon, lat] of ring) {
+        if (inWindow(lon, lat)) seg.push(toLocal(lon, lat))
+        else if (seg.length) { waterLines.push(seg); seg.length = 0 }
+    }
+    if (seg.length) waterLines.push(seg)
+}
+function distanceToWater(p) {
+    let best = Infinity
+    for (const line of waterLines) {
+        for (let i = 1; i < line.length; i++) {
+            const a = line[i - 1], b = line[i]
+            const dx = b.x - a.x, dy = b.y - a.y
+            const len2 = dx * dx + dy * dy || 1
+            let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2
+            t = Math.max(0, Math.min(1, t))
+            best = Math.min(best, Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)))
+        }
+    }
+    return best
+}
+
+/** 居民地面(建成区)多边形,局部坐标。 */
+const resaPolys = []
+for (const f of resa) {
+    const ring = f.geometry && f.geometry.type === 'Polygon' ? f.geometry.coordinates[0] : null
+    if (!ring || ring.length < 4) continue
+    if (!ring.some(([lon, lat]) => inWindow(lon, lat))) continue
+    resaPolys.push(ring.map(([lon, lat]) => toLocal(lon, lat)))
+}
+function inResa(p) {
+    for (const ring of resaPolys) {
+        let inside = false
+        for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
+            const a = ring[i], b = ring[j]
+            if ((a.y > p.y) !== (b.y > p.y) && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-9) + a.x) inside = !inside
+        }
+        if (inside) return true
+    }
+    return false
+}
+
+/** 可沿街的等级(trunk/primary/... → 体块贴街);纯步道/小径不排。 */
+const STREET_TYPES = new Set(['trunk', 'primary', 'secondary', 'tertiary', 'residential', 'unclassified', 'living_street'])
+const streetSegments = []
+for (const f of roads) {
+    const type = (f.properties && f.properties.type) || ''
+    if (!STREET_TYPES.has(type)) continue
+    const ring = f.geometry && f.geometry.type === 'LineString' ? f.geometry.coordinates : null
+    if (!ring) continue
+    for (let i = 1; i < ring.length; i++) {
+        const [lon1, lat1] = ring[i - 1]
+        const [lon2, lat2] = ring[i]
+        if (!inWindow(lon1, lat1) && !inWindow(lon2, lat2)) continue
+        streetSegments.push({ a: toLocal(lon1, lat1), b: toLocal(lon2, lat2), type })
+    }
+}
+
+// ---------------------------------------------------------------- 可复用楼房单元
+/**
+ * 三种**可复用**单元(同一种几何反复用,只是位置/朝向/高度/材质不同):
+ *   house 坡屋顶 1–2 层 · block 平顶 3–5 层 · slab 板楼 6–8 层。
+ */
+const ARCHETYPES = {
+    house: {
+        w: 9, d: 7.2, floors: [1, 2], floorH: 3.2, pitched: true,
+        walls: [0xf2efe6, 0xe8e2d4, 0xdfd8c8], roofs: [0x8c4a3a, 0x7a5140],
+    },
+    block: {
+        w: 14, d: 10, floors: [3, 5], floorH: 3.1, pitched: false,
+        walls: [0xe9e6df, 0xd9d6cf, 0xcfd2d4], roofs: [0x6f7478, 0x5c6165],
+    },
+    slab: {
+        w: 26, d: 11, floors: [6, 8], floorH: 3.0, pitched: false,
+        walls: [0xe4e6e8, 0xd6d9dc], roofs: [0x545a5f],
+    },
+}
+
+const UNIT_BOX = makeBox()
+/** 单位立方体(0..1 各轴),实例化时按尺寸缩放 —— 几何只有一份,靠矩阵复用。 */
+function makeBox() {
+    return {
+        // 6 面 × 4 顶点
+        positions: [
+            0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, // 底
+            0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, // 顶
+            0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, // 南
+            0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, // 北
+            0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, // 西
+            1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1, // 东
+        ],
+        normals: [
+            0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0,
+            0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0,
+            0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1,
+            0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1,
+            -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0,
+            1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0,
+        ],
+        indices: [
+            0, 1, 2, 0, 2, 3,
+            4, 5, 6, 4, 6, 7,
+            8, 9, 10, 8, 10, 11,
+            12, 13, 14, 12, 14, 15,
+            16, 17, 18, 16, 18, 19,
+            20, 21, 22, 20, 22, 23,
+        ],
+    }
+}
+
+/** 把一栋楼推成三角面,写进按材质分组的桶里。 */
+function emitBuilding(buckets, { x, y, z, heading, type, height, wallColor, roofColor, width, depth }) {
+    const cos = Math.cos(heading), sin = Math.sin(heading)
+    // 局部(x,y) → 世界;glTF 是 Y-up:写 (east, up, -north)
+    const put = (lx, ly, lz) => {
+        const ex = lx * cos - ly * sin
+        const ny = lx * sin + ly * cos
+        return [x + ex, z + lz, -(y + ny)]
+    }
+    const hw = width / 2, hd = depth / 2
+
+    const wallBucket = buckets.get(wallColor) || { positions: [], normals: [], indices: [], base: 0 }
+    const pushQuad = (bucket, quad, normal) => {
+        const base = bucket.positions.length / 3
+        for (const v of quad) bucket.positions.push(v[0], v[1], v[2])
+        for (let i = 0; i < 4; i++) bucket.normals.push(normal[0], normal[1], normal[2])
+        bucket.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
+    }
+    // 四片墙(用旋转后的法线)
+    const rot = (nx, ny) => [nx * cos - ny * sin, -(nx * sin + ny * cos)]
+    const corners = [
+        put(-hw, -hd, 0), put(hw, -hd, 0), put(hw, hd, 0), put(-hw, hd, 0),
+    ]
+    const top = [
+        put(-hw, -hd, height), put(hw, -hd, height), put(hw, hd, height), put(-hw, hd, height),
+    ]
+    const wallNormal = (i) => {
+        const n = [[0, -1], [1, 0], [0, 1], [-1, 0]][i]
+        const r = rot(n[0], n[1])
+        return [r[0], 0, r[1]]
+    }
+    for (let i = 0; i < 4; i++) {
+        const j = (i + 1) % 4
+        pushQuad(wallBucket, [corners[i], corners[j], top[j], top[i]], wallNormal(i))
+    }
+    buckets.set(wallColor, wallBucket)
+
+    if (type === 'house' && ARCHETYPES.house.pitched) {
+        // 坡屋顶:沿长边起脊
+        const roofBucket = buckets.get(roofColor) || { positions: [], normals: [], indices: [], base: 0 }
+        const ridgeH = 1.9
+        const r1 = put(0, -hd, height + ridgeH)
+        const r2 = put(0, hd, height + ridgeH)
+        pushQuad(roofBucket, [top[0], top[1], r1, r1], [0, 1, 0]) // 占位,替换如下
+        roofBucket.positions.length -= 12
+        roofBucket.normals.length -= 12
+        roofBucket.indices.length -= 6
+        const tri = (p1, p2, p3, n) => {
+            const base = roofBucket.positions.length / 3
+            for (const v of [p1, p2, p3]) roofBucket.positions.push(v[0], v[1], v[2])
+            for (let i = 0; i < 3; i++) roofBucket.normals.push(n[0], n[1], n[2])
+            roofBucket.indices.push(base, base + 1, base + 2)
+        }
+        tri(top[0], top[1], r1, [0, 1, 0])
+        tri(top[1], top[3], r2, [0, 1, 0])
+        tri(top[1], r2, r1, [0, 1, 0])
+        tri(top[3], top[2], r2, [0, 1, 0])
+        tri(top[2], top[0], r1, [0, 1, 0])
+        tri(top[2], r1, r2, [0, 1, 0])
+        tri(top[0], top[3], r1, [0, 1, 0])
+        tri(top[3], r2, r1, [0, 1, 0])
+        buckets.set(roofColor, roofBucket)
+        return
+    }
+    const roofBucket = buckets.get(roofColor) || { positions: [], normals: [], indices: [], base: 0 }
+    pushQuad(roofBucket, [top[0], top[3], top[2], top[1]], [0, 1, 0])
+    buckets.set(roofColor, roofBucket)
+}
+
+// ---------------------------------------------------------------- 生成实例
+const buckets = new Map()
+let placed = 0
+const stats = { house: 0, block: 0, slab: 0, real: 0, skippedWater: 0 }
+
+/** 按离中心距离选单元:近中心出板楼/条楼,边缘出坡顶民居。 */
+function chooseType(x, y, r) {
+    const dist = Math.hypot(x, y)
+    if (dist < CORE_RADIUS) return r < 0.28 ? 'slab' : 'block'
+    if (dist < CORE_RADIUS * 2.4) return r < 0.7 ? 'block' : 'house'
+    return r < 0.25 ? 'block' : 'house'
+}
+
+function emitInstance(x, y, heading, type, r) {
+    if (placed >= CAP) return
+    const spec = ARCHETYPES[type]
+    const lon = ORIGIN.lon + x / M_PER_DEG_LON
+    const lat = ORIGIN.lat + y / M_PER_DEG_LAT
+    if (!inWindow(lon, lat)) return
+    if (distanceToWater({ x, y }) < 26) { stats.skippedWater += 1; return }
+
+    const floors = spec.floors[0] + Math.floor(r * (spec.floors[1] - spec.floors[0] + 1))
+    const height = floors * spec.floorH
+    const width = spec.w * (0.85 + 0.3 * hash01(x, y, 7))
+    const depth = spec.d * (0.9 + 0.2 * hash01(x, y, 11))
+    emitBuilding(buckets, {
+        x, y, z: elevationAt(lon, lat) - 0.6, // 略埋 0.6m,避免与地形之间露缝
+        heading,
+        type,
+        height,
+        width,
+        depth,
+        wallColor: pick(spec.walls, hash01(x, y, 3)),
+        roofColor: pick(spec.roofs, hash01(x, y, 5)),
+    })
+    placed += 1
+    stats[type] += 1
+}
+
+// 1) 真实 OSM 轮廓:能用的都用(它们的形状是真的)
+for (const f of buildings) {
+    const geom = f.geometry
+    if (!geom) continue
+    const rings = geom.type === 'Polygon' ? [geom.coordinates[0]] : geom.type === 'MultiPolygon' ? geom.coordinates.map((p) => p[0]) : []
+    for (const ring of rings) {
+        if (!ring || ring.length < 4) continue
+        const local = ring.filter(([lon, lat]) => inWindow(lon, lat)).map(([lon, lat]) => toLocal(lon, lat))
+        if (local.length < 4) continue
+        // 以轮廓中心为实例位置,按外接盒尺寸建体块(真实轮廓保留下来做形状约束太细,远景不需要)
+        const xs = local.map((p) => p.x), ys = local.map((p) => p.y)
+        const cx = (Math.min(...xs) + Math.max(...xs)) / 2
+        const cy = (Math.min(...ys) + Math.max(...ys)) / 2
+        const w = Math.max(6, Math.max(...xs) - Math.min(...xs))
+        const d = Math.max(5, Math.max(...ys) - Math.min(...ys))
+        const lon = ORIGIN.lon + cx / M_PER_DEG_LON
+        const lat = ORIGIN.lat + cy / M_PER_DEG_LAT
+        if (distanceToWater({ x: cx, y: cy }) < 22) continue
+        const r = hash01(cx, cy, 13)
+        // 城区真实轮廓按条楼/条楼处理
+        const type = w * d > 420 ? 'slab' : 'block'
+        const spec = ARCHETYPES[type]
+        const floors = 2 + Math.floor(r * 4)
+        emitBuilding(buckets, {
+            x: cx, y: cy, z: elevationAt(lon, lat) - 0.6,
+            heading: 0, type, height: floors * spec.floorH,
+            width: w, depth: d,
+            wallColor: pick(spec.walls, r),
+            roofColor: pick(spec.roofs, hash01(cx, cy, 17)),
+        })
+        placed += 1
+        stats.real += 1
+    }
+}
+
+// 2) 沿街排布:每条可沿街的线段两侧按间距放楼(贴街是我国县城的常态)
+const SPACING = 23
+for (const seg of streetSegments) {
+    const dx = seg.b.x - seg.a.x, dy = seg.b.y - seg.a.y
+    const len = Math.hypot(dx, dy)
+    if (len < 12) continue
+    const heading = Math.atan2(dy, dx)
+    const steps = Math.floor(len / SPACING)
+    for (let s = 0; s <= steps; s++) {
+        const t = steps === 0 ? 0.5 : s / steps
+        const px = seg.a.x + dx * t
+        const py = seg.a.y + dy * t
+        for (const side of [-1, 1]) {
+            const off = 11 + 6 * hash01(px, py, side)
+            const ox = px - Math.sin(heading) * off * side
+            const oy = py + Math.cos(heading) * off * side
+            const r = hash01(ox, oy, 19)
+            emitInstance(ox, oy, heading + (r < 0.5 ? 0 : Math.PI / 2), chooseType(ox, oy, hash01(ox, oy, 23)), r)
+        }
+    }
+}
+
+// 3) 建成区补白:居民地面内做抖动网格,避免"只有街边有楼"的空旷感
+const FILL = 30
+for (const ring of resaPolys) {
+    const xs = ring.map((p) => p.x), ys = ring.map((p) => p.y)
+    for (let gx = Math.min(...xs); gx <= Math.max(...xs); gx += FILL) {
+        for (let gy = Math.min(...ys); gy <= Math.max(...ys); gy += FILL) {
+            const jx = gx + (hash01(gx, gy, 29) - 0.5) * FILL * 0.7
+            const jy = gy + (hash01(gx, gy, 31) - 0.5) * FILL * 0.7
+            if (!inResa({ x: jx, y: jy })) continue
+            const r = hash01(jx, jy, 37)
+            if (r > 0.72) continue // 留出空隙:不是每格都盖楼
+            emitInstance(jx, jy, (hash01(jx, jy, 41) - 0.5) * 0.6, chooseType(jx, jy, hash01(jx, jy, 43)), r)
+        }
+    }
+}
+
+// ---------------------------------------------------------------- 写 GLB
+function writeGlb(path, bucketList) {
+    const json = {
+        asset: { version: '2.0', generator: 'cockpit-city-massing' },
+        scene: 0,
+        scenes: [{ nodes: [0] }],
+        nodes: [{ mesh: 0, name: 'yuanling-town-massing' }],
+        meshes: [{ name: 'town', primitives: [] }],
+        materials: [],
+        accessors: [],
+        bufferViews: [],
+        buffers: [],
+    }
+    const bin = []
+    let byteOffset = 0
+    const pushView = (data, target) => {
+        const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength)
+        json.bufferViews.push({ buffer: 0, byteOffset, byteLength: buf.length, target })
+        bin.push(buf)
+        byteOffset += buf.length + ((4 - (buf.length % 4)) % 4)
+        return json.bufferViews.length - 1
+    }
+
+    for (const [color, bucket] of bucketList) {
+        if (!bucket.indices.length) continue
+        const positions = new Float32Array(bucket.positions)
+        const normals = new Float32Array(bucket.normals)
+        const indices = new Uint32Array(bucket.indices)
+        const min = [Infinity, Infinity, Infinity]
+        const max = [-Infinity, -Infinity, -Infinity]
+        for (let i = 0; i < positions.length; i += 3) {
+            for (let k = 0; k < 3; k++) {
+                min[k] = Math.min(min[k], positions[i + k])
+                max[k] = Math.max(max[k], positions[i + k])
+            }
+        }
+        const posView = pushView(positions, 34962)
+        const norView = pushView(normals, 34962)
+        const idxView = pushView(indices, 34963)
+        const posAcc = json.accessors.push({ bufferView: posView, componentType: 5126, count: positions.length / 3, type: 'VEC3', min, max }) - 1
+        const norAcc = json.accessors.push({ bufferView: norView, componentType: 5126, count: normals.length / 3, type: 'VEC3' }) - 1
+        const idxAcc = json.accessors.push({ bufferView: idxView, componentType: 5125, count: indices.length, type: 'SCALAR' }) - 1
+        const material = json.materials.push({
+            pbrMetallicRoughness: {
+                baseColorFactor: [((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255, 1],
+                metallicFactor: 0,
+                roughnessFactor: 0.85,
+            },
+            doubleSided: false,
+        }) - 1
+        json.meshes[0].primitives.push({
+            attributes: { POSITION: posAcc, NORMAL: norAcc },
+            indices: idxAcc,
+            material,
+        })
+    }
+
+    const binBuffer = Buffer.concat(bin)
+    json.buffers.push({ byteLength: binBuffer.length })
+
+    const jsonText = Buffer.from(JSON.stringify(json), 'utf8')
+    const jsonPad = (4 - (jsonText.length % 4)) % 4
+    const jsonChunk = Buffer.concat([jsonText, Buffer.alloc(jsonPad, 0x20)])
+    const binPad = (4 - (binBuffer.length % 4)) % 4
+    const binChunk = Buffer.concat([binBuffer, Buffer.alloc(binPad, 0)])
+    const total = 12 + 8 + jsonChunk.length + 8 + binChunk.length
+
+    const header = Buffer.alloc(12)
+    header.write('glTF', 0, 'ascii')
+    header.writeUInt32LE(2, 4)
+    header.writeUInt32LE(total, 8)
+    const jsonHeader = Buffer.alloc(8)
+    jsonHeader.writeUInt32LE(jsonChunk.length, 0)
+    jsonHeader.write('JSON', 4, 'ascii')
+    const binHeader = Buffer.alloc(8)
+    binHeader.writeUInt32LE(binChunk.length, 0)
+    binHeader.write('BIN\0', 4, 'ascii')
+
+    const out = Buffer.concat([header, jsonHeader, jsonChunk, binHeader, binChunk])
+    writeFileSync(path, out)
+    return { bytes: out.length, primitives: json.meshes[0].primitives.length, materials: json.materials.length }
+}
+
+// ---------------------------------------------------------------- 写 tileset + meta
+const R = 6378137
+const F = 1 / 298.257223563
+const E2 = 2 * F - F * F
+function enuTransform(lon, lat, height) {
+    const lonR = lon * DEG, latR = lat * DEG
+    const sinLat = Math.sin(latR), cosLat = Math.cos(latR)
+    const sinLon = Math.sin(lonR), cosLon = Math.cos(lonR)
+    const N = R / Math.sqrt(1 - E2 * sinLat * sinLat)
+    const ox = (N + height) * cosLat * cosLon
+    const oy = (N + height) * cosLat * sinLon
+    const oz = (N * (1 - E2) + height) * sinLat
+    return [-sinLon, cosLon, 0, 0, -sinLat * cosLon, -sinLat * sinLon, cosLat, 0, cosLat * cosLon, cosLat * sinLon, sinLat, 0, ox, oy, oz, 1]
+}
+
+mkdirSync(DATA_DIR, { recursive: true })
+const bucketList = [...buckets.entries()]
+const glbInfo = writeGlb(join(DATA_DIR, 'town.glb'), bucketList)
+
+// 包围盒(局部 ENU 米):从顶点直接算
+let bx = [-Infinity, Infinity], by = [-Infinity, Infinity], bz = [-Infinity, Infinity]
+for (const [, b] of bucketList) {
+    for (let i = 0; i < b.positions.length; i += 3) {
+        // glTF Y-up → ENU:east=x, up=y, north=-z
+        const east = b.positions[i], up = b.positions[i + 1], north = -b.positions[i + 2]
+        bx = [Math.max(bx[0], east), Math.min(bx[1], east)]
+        by = [Math.max(by[0], north), Math.min(by[1], north)]
+        bz = [Math.max(bz[0], up), Math.min(bz[1], up)]
+    }
+}
+const box = [
+    (bx[0] + bx[1]) / 2, (by[0] + by[1]) / 2, (bz[0] + bz[1]) / 2,
+    (bx[0] - bx[1]) / 2, 0, 0,
+    0, (by[0] - by[1]) / 2, 0,
+    0, 0, (bz[0] - bz[1]) / 2,
+]
+const tileset = {
+    asset: { version: '1.1', generator: 'cockpit-city-massing' },
+    // 注意:tileset 级 geometricError 必须 > 0 —— 实测写成 0 时 Cesium 不请求根瓦片内容
+    // (tileset.json 拉到了、town.glb 始终不下载,页面上一栋楼都没有)。
+    geometricError: 800,
+    root: {
+        transform: enuTransform(ORIGIN.lon, ORIGIN.lat, 0),
+        boundingVolume: { box },
+        // 根节点只有一块内容,不需要再细分:0 = 到此为止(refine 也就没机会生效)。
+        geometricError: 0,
+        refine: 'ADD',
+        content: { uri: 'town.glb' },
+    },
+}
+writeFileSync(join(DATA_DIR, 'tileset.json'), JSON.stringify(tileset, null, 2), 'utf8')
+
+const meta = {
+    _disclaimer:
+        '示意体块,不是实测建筑。除 OSM 少量真实轮廓外,其余体块按真实路网/建成区程序化排布,用于远景观感;不得用于任何量测、统计或应急决策。',
+    origin: ORIGIN,
+    window: WINDOW,
+    counts: { total: placed, ...stats, cap: CAP },
+    archetypes: Object.keys(ARCHETYPES),
+    materials: glbInfo.materials,
+    glbBytes: glbInfo.bytes,
+    localBox: { x: [bx[1], bx[0]], y: [by[1], by[0]], z: [bz[1], bz[0]] },
+    generatedAt: new Date().toISOString(),
+}
+writeFileSync(join(DATA_DIR, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8')
+
+console.log(`生成完成:${placed} 栋(真实轮廓 ${stats.real} / 民居 ${stats.house} / 条楼 ${stats.block} / 板楼 ${stats.slab},避让水系 ${stats.skippedWater})`)
+console.log(`GLB:${(glbInfo.bytes / 1024).toFixed(0)} KB,${glbInfo.primitives} 个图元 / ${glbInfo.materials} 种材质`)
+console.log(`局部范围(米):x ${bx[1].toFixed(0)}~${bx[0].toFixed(0)},y ${by[1].toFixed(0)}~${by[0].toFixed(0)},z ${bz[1].toFixed(0)}~${bz[0].toFixed(0)}`)
+console.log(`产物目录:${DATA_DIR}`)

+ 191 - 0
tools/city-massing/verify-town.mjs

@@ -0,0 +1,191 @@
+/**
+ * 沅陵轻量体块的独立回验(票 34)。
+ *
+ * 这套几何是**程序化生成**的,所以"看起来对"不够 —— 这里验的是三件可证伪的事:
+ *   1. **地理配准**:取一个真实 OSM 轮廓的质心,按生成时的同一条投影链推出它在 GLB 里的应有位置,
+ *      再在 GLB 顶点里找得到它(容差 1m)→ 证明"经纬度 → 局部 ENU → glTF Y-up"这条链没写反;
+ *   2. **tileset transform 自洽**:把 GLB 包围盒中心经 transform 送到经纬度,应落在目标城区窗口内;
+ *   3. **GLB 结构合法**:header/JSON/BIN 长度自洽、accessor 与 bufferView 对得上、无 NaN。
+ *
+ * 用法:node tools/city-massing/verify-town.mjs
+ */
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+const DATA_DIR = process.env.CITY_DATA_DIR || 'D:/GIS/GIS/cityMassing'
+const ORIGIN = { lon: 110.39028164, lat: 28.4436787 }
+const DEG = Math.PI / 180
+const M_PER_DEG_LAT = 110540
+const M_PER_DEG_LON = 111320 * Math.cos(ORIGIN.lat * DEG)
+const OSM = 'D:/GIS/GIS/planet_110.059,28.258_110.744,28.625-shp/shpOutput'
+
+const fails = []
+function check(name, pass, evidence) {
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${name}${evidence ? `  — ${evidence}` : ''}`)
+    if (!pass) fails.push(name)
+}
+
+// ---------------------------------------------------------------- 解析 GLB
+const buf = readFileSync(join(DATA_DIR, 'town.glb'))
+const magic = buf.toString('ascii', 0, 4)
+const version = buf.readUInt32LE(4)
+const totalLen = buf.readUInt32LE(8)
+check('GLB-header', magic === 'glTF' && version === 2 && totalLen === buf.length, `${magic} v${version} len=${totalLen}/${buf.length}`)
+
+const jsonLen = buf.readUInt32LE(12)
+const jsonType = buf.toString('ascii', 16, 20)
+const gltf = JSON.parse(buf.toString('utf8', 20, 20 + jsonLen))
+check('GLB-json-chunk', jsonType === 'JSON' && !!gltf.meshes, `${jsonType}, primitives=${gltf.meshes[0].primitives.length}`)
+
+const binStart = 20 + jsonLen + 8
+const binChunkLen = buf.readUInt32LE(20 + jsonLen)
+check('GLB-bin-chunk', binStart + binChunkLen <= buf.length, `bin=${binChunkLen}`)
+
+function readAccessor(index) {
+    const acc = gltf.accessors[index]
+    const view = gltf.bufferViews[acc.bufferView]
+    const offset = binStart + (view.byteOffset || 0)
+    const out = []
+    if (acc.componentType === 5126) {
+        for (let i = 0; i < acc.count * 3; i++) out.push(buf.readFloatLE(offset + i * 4))
+    } else if (acc.componentType === 5125) {
+        for (let i = 0; i < acc.count; i++) out.push(buf.readUInt32LE(offset + i * 4))
+    }
+    return out
+}
+
+// ---------------------------------------------------------------- 3. 结构 + NaN
+let vertexCount = 0, triCount = 0, nan = 0
+for (const prim of gltf.meshes[0].primitives) {
+    const pos = readAccessor(prim.attributes.POSITION)
+    const idx = readAccessor(prim.indices)
+    vertexCount += pos.length / 3
+    triCount += idx.length / 3
+    for (const v of pos) if (!Number.isFinite(v)) nan += 1
+    const acc = gltf.accessors[prim.attributes.POSITION]
+    check(
+        `POSITION-accessor-minmax-${prim.material}`,
+        Array.isArray(acc.min) && Array.isArray(acc.max) && acc.min.every((v, i) => v <= acc.max[i]),
+        `min=[${acc.min.map((v) => v.toFixed(0))}] max=[${acc.max.map((v) => v.toFixed(0))}]`,
+    )
+}
+check('geometry-sane', vertexCount > 1000 && triCount > 1000 && nan === 0, `顶点 ${vertexCount},三角 ${triCount},NaN ${nan}`)
+
+// ---------------------------------------------------------------- 1. 真实轮廓回验
+// 生成器把体块**居中**放在轮廓外接盒中心上,所以顶点落在**外接盒的四角**(真实轮廓不做旋转)。
+// 因此判据是「GLB 里存在一个顶点落在该轮廓的外接盒角点上」——这条能证伪整条坐标链
+// (经纬度 → 局部 ENU → glTF Y-up),写反任何一步都对不上。
+// 同时按生成器的规则剔掉贴水的轮廓(那些本来就被避让掉了)。
+const buildings = JSON.parse(readFileSync(join(OSM, 'buildings.json'), 'utf8')).features
+const waterways = JSON.parse(readFileSync(join(OSM, 'waterways.json'), 'utf8')).features
+const inWindow = (lon, lat) => Math.abs(lon - ORIGIN.lon) <= 0.02 && Math.abs(lat - ORIGIN.lat) <= 0.015
+const toLocal = (lon, lat) => ({ x: (lon - ORIGIN.lon) * M_PER_DEG_LON, y: (lat - ORIGIN.lat) * M_PER_DEG_LAT })
+
+const waterLines = []
+for (const f of waterways) {
+    const ring = f.geometry && f.geometry.type === 'LineString' ? f.geometry.coordinates : null
+    if (!ring) continue
+    const seg = []
+    for (const [lon, lat] of ring) {
+        if (inWindow(lon, lat)) seg.push(toLocal(lon, lat))
+        else if (seg.length) { waterLines.push(seg); seg.length = 0 }
+    }
+    if (seg.length) waterLines.push(seg)
+}
+function distanceToWater(p) {
+    let best = Infinity
+    for (const line of waterLines) {
+        for (let i = 1; i < line.length; i++) {
+            const a = line[i - 1], b = line[i]
+            const dx = b.x - a.x, dy = b.y - a.y
+            const len2 = dx * dx + dy * dy || 1
+            let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2
+            t = Math.max(0, Math.min(1, t))
+            best = Math.min(best, Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)))
+        }
+    }
+    return best
+}
+
+const targets = []
+for (const f of buildings) {
+    const ring = f.geometry && f.geometry.type === 'Polygon' ? f.geometry.coordinates[0] : null
+    if (!ring) continue
+    const pts = ring.filter(([lon, lat]) => inWindow(lon, lat))
+    if (pts.length < 4) continue
+    const ex = pts.map(([lon]) => (lon - ORIGIN.lon) * M_PER_DEG_LON)
+    const ny = pts.map(([, lat]) => (lat - ORIGIN.lat) * M_PER_DEG_LAT)
+    const minX = Math.min(...ex), maxX = Math.max(...ex)
+    const minY = Math.min(...ny), maxY = Math.max(...ny)
+    const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2
+    if (distanceToWater({ x: cx, y: cy }) < 22) continue // 生成器会避让贴水的
+    targets.push({ corners: [[minX, minY], [maxX, minY], [minX, maxY], [maxX, maxY]], area: (maxX - minX) * (maxY - minY) })
+}
+targets.sort((a, b) => b.area - a.area)
+const sample = targets.slice(0, 8)
+
+// 把所有顶点按 ENU 还原(glTF Y-up → ENU:east=x, up=y, north=-z)
+const allVerts = []
+for (const prim of gltf.meshes[0].primitives) {
+    const pos = readAccessor(prim.attributes.POSITION)
+    for (let i = 0; i < pos.length; i += 3) {
+        allVerts.push({ x: pos[i], z: -pos[i + 2] }) // east, north(忽略 up)
+    }
+}
+let hit = 0
+const details = []
+for (const t of sample) {
+    const found = t.corners.some(([cx2, cy2]) => allVerts.some((v) => Math.abs(v.x - cx2) <= 2 && Math.abs(v.z - cy2) <= 2))
+    if (found) hit += 1
+    details.push(`${t.area.toFixed(0)}m²@(${t.corners[0][0].toFixed(0)},${t.corners[0][1].toFixed(0)})${found ? '✓' : '✗'}`)
+}
+check(
+    'real-footprints-land-on-true-coords',
+    sample.length >= 5 && hit === sample.length,
+    `${hit}/${sample.length} 个最大真实轮廓的外接盒角点在 GLB 里 2m 内命中:${details.join(' ')}`,
+)
+
+// ---------------------------------------------------------------- 2. transform 自洽
+const T = JSON.parse(readFileSync(join(DATA_DIR, 'tileset.json'), 'utf8')).root.transform
+const box = JSON.parse(readFileSync(join(DATA_DIR, 'tileset.json'), 'utf8')).root.boundingVolume.box
+function applyTransform([x, y, z]) {
+    return [
+        T[0] * x + T[4] * y + T[8] * z + T[12],
+        T[1] * x + T[5] * y + T[9] * z + T[13],
+        T[2] * x + T[6] * y + T[10] * z + T[14],
+    ]
+}
+function ecefToLonLat([X, Y, Z]) {
+    const a = 6378137, f = 1 / 298.257223563, e2 = 2 * f - f * f
+    const lon = Math.atan2(Y, X)
+    const p = Math.hypot(X, Y)
+    let lat = Math.atan2(Z, p * (1 - e2))
+    for (let i = 0; i < 5; i++) {
+        const s = Math.sin(lat)
+        const N = a / Math.sqrt(1 - e2 * s * s)
+        lat = Math.atan2(Z + e2 * N * s, p)
+    }
+    return { lon: (lon * 180) / Math.PI, lat: (lat * 180) / Math.PI }
+}
+// 原点自身(局部 0,0,0)应回到目标坐标
+const originLL = ecefToLonLat(applyTransform([0, 0, 0]))
+const dOrigin = Math.hypot((originLL.lon - ORIGIN.lon) * M_PER_DEG_LON, (originLL.lat - ORIGIN.lat) * M_PER_DEG_LAT)
+check('transform-origin-roundtrip', dOrigin < 0.5, `原点误差 ${dOrigin.toFixed(3)} m(${originLL.lon.toFixed(6)}, ${originLL.lat.toFixed(6)})`)
+
+// 包围盒中心经 transform 后应落在窗口内
+const corner = ecefToLonLat(applyTransform([box[0], box[1], box[2]]))
+check(
+    'bbox-center-in-window',
+    Math.abs(corner.lon - ORIGIN.lon) <= 0.025 && Math.abs(corner.lat - ORIGIN.lat) <= 0.02,
+    `包围盒中心 → ${corner.lon.toFixed(5)}, ${corner.lat.toFixed(5)}`,
+)
+
+const meta = JSON.parse(readFileSync(join(DATA_DIR, 'meta.json'), 'utf8'))
+check('disclaimer-present', typeof meta._disclaimer === 'string' && meta._disclaimer.includes('不是实测建筑'), meta._disclaimer.slice(0, 40) + '…')
+
+console.log('')
+if (fails.length) {
+    console.log(`回验未通过:${fails.join(', ')}`)
+    process.exit(1)
+}
+console.log(`回验全部通过:${meta.counts.total} 栋体块(真实轮廓 ${meta.counts.real})、${(meta.glbBytes / 1024).toFixed(0)} KB、${meta.materials} 种材质。`)