Pārlūkot izejas kodu

feat(cockpit): 外壳页面基础框架 T01 与 dev server watcher 修复 T26

T01 Shell 与模式/面板映射:
- 铺满视口的驾驶舱外壳(不锁 16:9):顶栏(徽标/渐变标题/切角模式页签/时钟)、全局筛选条、左右两栏 4 面板 + 中央一张图常驻
- 四个一级模式经 uiStore.mode 切换:不新增路由、不重建外壳与地图节点
- 面板标题可收放(v-show),切换模式重置为全展开
- 新增 filterStore(范围/行业):切换一级模式不重置全局筛选
- 视觉对齐《驾驶舱原型图-美化版》:切角面板、青色辉光、全息地图壳;尺寸随 --u 缩放适配大屏
- 验证:零依赖断言脚本通过;浏览器走查 15/15(含新增的筛选持久化与铺满几何断言)

T26 文件监视被 .scratch 原子写击穿:
- .fes.js 的 dev server watch 排除 **/.scratch/**(与 Vite 默认 ignore 合并而非覆盖)
- 3 轮独占锁原子写模拟 + src 热更实测通过

同时入库本 effort 的 spec、票据、调研与走查脚本。
Kazerin 1 mēnesi atpakaļ
vecāks
revīzija
f460a6b665
52 mainītis faili ar 4038 papildinājumiem un 0 dzēšanām
  1. 5 0
      .fes.js
  2. 194 0
      .scratch/cockpit-shell-panel-layout/issues/01-shell-mode-panel-mapping.md
  3. 18 0
      .scratch/cockpit-shell-panel-layout/issues/02-mapcanvas-persistent-mount.md
  4. 21 0
      .scratch/cockpit-shell-panel-layout/issues/03-camera-control-contract.md
  5. 20 0
      .scratch/cockpit-shell-panel-layout/issues/04-config-and-credentials.md
  6. 20 0
      .scratch/cockpit-shell-panel-layout/issues/05-mock-domain-adapter.md
  7. 18 0
      .scratch/cockpit-shell-panel-layout/issues/06-cockpit-static-assets.md
  8. 18 0
      .scratch/cockpit-shell-panel-layout/issues/07-coordinate-validation.md
  9. 19 0
      .scratch/cockpit-shell-panel-layout/issues/08-map-summary-pagination.md
  10. 18 0
      .scratch/cockpit-shell-panel-layout/issues/09-point-status-legend.md
  11. 19 0
      .scratch/cockpit-shell-panel-layout/issues/10-monitoring-point-detail.md
  12. 19 0
      .scratch/cockpit-shell-panel-layout/issues/11-equipment-detail-aggregate.md
  13. 18 0
      .scratch/cockpit-shell-panel-layout/issues/12-work-order-timeline.md
  14. 19 0
      .scratch/cockpit-shell-panel-layout/issues/13-event-list-and-mode-switch.md
  15. 19 0
      .scratch/cockpit-shell-panel-layout/issues/14-event-detail-and-locate.md
  16. 20 0
      .scratch/cockpit-shell-panel-layout/issues/15-websocket-status-updates.md
  17. 19 0
      .scratch/cockpit-shell-panel-layout/issues/16-freshness-and-stale-state.md
  18. 20 0
      .scratch/cockpit-shell-panel-layout/issues/17-infrastructure-panels.md
  19. 20 0
      .scratch/cockpit-shell-panel-layout/issues/18-monitoring-panels.md
  20. 20 0
      .scratch/cockpit-shell-panel-layout/issues/19-warning-response-panels.md
  21. 20 0
      .scratch/cockpit-shell-panel-layout/issues/20-overview-panels.md
  22. 20 0
      .scratch/cockpit-shell-panel-layout/issues/21-3d-tiles-terrain-baseline.md
  23. 20 0
      .scratch/cockpit-shell-panel-layout/issues/22-cesium-pipeline-rendering.md
  24. 19 0
      .scratch/cockpit-shell-panel-layout/issues/23-threejs-pipeline-enhancement.md
  25. 105 0
      .scratch/cockpit-shell-panel-layout/issues/24-panel-field-coverage.md
  26. 76 0
      .scratch/cockpit-shell-panel-layout/issues/25-backend-contract-gaps.md
  27. 57 0
      .scratch/cockpit-shell-panel-layout/issues/26-devserver-watch-scratch-crash.md
  28. 41 0
      .scratch/cockpit-shell-panel-layout/map.md
  29. 545 0
      .scratch/cockpit-shell-panel-layout/research/panel-field-coverage.md
  30. 66 0
      .scratch/cockpit-shell-panel-layout/spec.md
  31. 3 0
      .scratch/cockpit-shell-panel-layout/verify/.gitignore
  32. 448 0
      .scratch/cockpit-shell-panel-layout/verify/t01-browser-walkthrough.mjs
  33. 83 0
      .scratch/cockpit-shell-panel-layout/verify/t01-panel-catalog.mjs
  34. 86 0
      .scratch/cockpit-shell-panel-layout/verify/walkthrough/results.json
  35. 28 0
      .scratch/web-cockpit-decision-map/issues/01-mode-and-expand-state-ownership.md
  36. 40 0
      .scratch/web-cockpit-decision-map/issues/02-mode-panelid-dictionary-and-columns.md
  37. 35 0
      .scratch/web-cockpit-decision-map/issues/03-three-vs-four-cell-layout.md
  38. 39 0
      .scratch/web-cockpit-decision-map/issues/04-spring-arm-orbit-pivot.md
  39. 38 0
      .scratch/web-cockpit-decision-map/issues/05-ground-pipeline-camera-composition.md
  40. 36 0
      .scratch/web-cockpit-decision-map/issues/06-cesium-fes-vite-integration.md
  41. 49 0
      .scratch/web-cockpit-decision-map/issues/07-cockpit-api-auth-without-login.md
  42. 45 0
      .scratch/web-cockpit-decision-map/issues/08-cockpit-page-file-shape.md
  43. 60 0
      .scratch/web-cockpit-decision-map/map.md
  44. 300 0
      .scratch/web-cockpit-decision-map/research/cesium-fes-vite.md
  45. 560 0
      src/modules/cockpit/CockpitShell.vue
  46. 190 0
      src/modules/cockpit/components/CockpitPanel.vue
  47. 241 0
      src/modules/cockpit/components/MapPlaceholder.vue
  48. 42 0
      src/modules/cockpit/domain/filterOptions.js
  49. 106 0
      src/modules/cockpit/domain/panelCatalog.js
  50. 44 0
      src/modules/cockpit/stores/filterStore.js
  51. 42 0
      src/modules/cockpit/stores/uiStore.js
  52. 10 0
      src/pages/cockpit/index.vue

+ 5 - 0
.fes.js

@@ -131,6 +131,11 @@ export default defineBuildConfig({
                 'Access-Control-Allow-Origin': '*',
             },
             host: '0.0.0.0',
+            // 票26:面板对 .scratch 的原子写在 Windows 上会让 chokidar 抛 EBUSY 击穿 dev server。
+            // Vite 内部默认 ignore(node_modules/.git/cacheDir 等)与此合并而非覆盖,见 vite resolveChokidarOptions。
+            watch: {
+                ignored: ['**/.scratch/**'],
+            },
             proxy: {
                 '/resource.cos': {
                     target: 'https://opensource.cdn.icegl.cn',

+ 194 - 0
.scratch/cockpit-shell-panel-layout/issues/01-shell-mode-panel-mapping.md

@@ -0,0 +1,194 @@
+# 01: Shell 与模式/面板映射
+
+**What to build:** 提供 `/cockpit` 唯一外壳,支持四个一级模式切换,并根据当前模式渲染固定数量的左右两栏二级面板。二级面板不使用路由,不使用 `subview`。
+
+Blocked by: None
+
+Status: resolved
+Labels: ready-for-agent
+Category: enhancement
+
+- [x] `/cockpit` 可打开,顶栏、左右两栏和中央地图占位同时出现。
+- [x] 四个一级模式可切换:总体概览、基础设施、运行监测、预警处置。
+- [x] 面板映射固定为每个一级模式 4 个,并使用稳定英文 `mode` 与 `panelId`(词典见 ADR 0003)。
+- [x] 切换一级模式时不新增路由、不重建外壳。
+- [x] `CockpitShell` 公共契约可验证模式到面板集合的映射。
+- [x] 点击面板标题栏可收起/展开;切换一级模式后全部恢复默认展开。
+- [x] 切换一级模式不重置全局筛选。
+- [x] 有模式/面板映射纯逻辑单测和人工走查记录。**零依赖断言脚本通过;浏览器走查 15/15 通过**(A1–A12,含本轮新增的筛选持久化与铺满几何断言)。
+
+## 进度:100%
+
+**已关闭。** 维护者审查后给了三条修正,均已落地并重跑验证。
+
+**本轮改动(2026-09-16,按《驾驶舱原型图-美化版》对齐骨架与视觉)**
+
+语义(四个一级模式、模式内 4 面板同屏、无子视图、切模式不重建地图)一律不变;改动只在外壳、视觉与全局筛选状态。
+
+| 文件 | 改动 |
+| --- | --- |
+| `src/modules/cockpit/CockpitShell.vue` | 重构:**铺满视口**(不锁 16:9);三行栅格 顶栏 / 筛选条 / 主体;顶栏(徽标 + 渐变标题 + 切角模式页签 + 时钟);**去掉子导航整行**;范围/行业改为真按钮;主体 `clamp(300px,322u,560px) : 1fr : 同左` |
+| `src/modules/cockpit/domain/filterOptions.js` | 新增:范围(全域/城南/城北/太常)与行业(全部/燃气/供水/排水/井盖)选项 + 默认值 + 合法键校验 |
+| `src/modules/cockpit/stores/filterStore.js` | 新增:ADR 0004 的 `filterStore`,持 `scope`/`industry`;`uiStore.setMode` 触碰不到它 → 验收第 ⑦ 条成为真行为 |
+| `src/modules/cockpit/components/CockpitPanel.vue` | 重写样式:切角描边、青色竖条标题、`▸` 副标题、`−/+` 收放标记;尺寸随 `--u` 缩放 |
+| `src/modules/cockpit/components/MapPlaceholder.vue` | 重写样式:全息地图壳;随视口铺满并随 `--u` 缩放;仍是票 02 的接入位 |
+| `verify/t01-panel-catalog.mjs` | 改为校验范围/行业选项与默认值(原 16:9 缩放断言随方案撤销一并移除) |
+| `verify/t01-browser-walkthrough.mjs` | A10 升级为**真行为断言**(燃气/城南在切模式后保持);新增 **A12 铺满几何断言** |
+
+**按维护者意见做过的修正**
+
+1. **撤销 16:9 锁死**:删除 `stageScale.js`,页面铺满视口、中央一张图吃掉剩余宽高。大屏适配改用 `--u = clamp(1px, 0.0521vw, 2px)`(1920 宽为 1px、4K 为 2px、更窄不再缩),避免 4K 上版式空旷。
+2. **删掉子导航整行**:版式为三行(顶栏 / 筛选条 / 主体)。
+3. **范围与行业改为真按钮**:写入新建的 `filterStore`,`aria-pressed` 与 `is-active` 同步;当前驱动外壳状态,地图与列表联动待后续票接入。
+
+**A12 抓出并修掉的两个布局缺陷**(旧断言只看结构不看尺寸,所以此前漏过)
+
+- `.cockpit-shell > *` 与 `.cockpit-shell__atmosphere` 同权重且声明在后,把大气层的 `position: fixed` 覆盖成 `relative`,大气层变成普通网格项、三行栅格被挤乱 → 改为只对三个网格项分别定位。
+- 外壳缺 `box-sizing: border-box`:`height: 100vh` 叠加内边距后撑出滚动条(实测 1708×974 > 视口 1680×950)→ 三个组件各带自己的盒模型重置(不依赖全局 `*`)。
+
+**验证**
+
+- 零依赖断言脚本:`T01 检查通过:4 个模式 / 16 个面板 / 展开收放与默认展开一致 / 筛选选项(4 范围 · 5 行业)正确。`
+- 浏览器走查 **15/15 通过**:A1 打开 · A2 结构各一份 · A3/A4 四模式与默认高亮 · A5×4 词典比对 · A6 不新增路由 · A7 外壳与地图实例未重建 · A8 标题收放 · A9 切模式重置展开 · A10 筛选持久化 · A12 铺满视口 · A11 无页面报错。截图与 `results.json` 在 `%TEMP%\cockpit-t01-walkthrough`。
+- 几何快照:外壳 1680×950(等于视口,无纵向溢出)· 顶栏 74 · 筛选条 46 · 主体 790 · 中央地图 992×790。
+
+**标签说明**:本票已 `resolved`;`Labels: ready-for-agent` 只为避免面板对无标签票兜底显示 `needs-triage`,请勿据标签重复接单。
+
+**后续票接管项**:顶栏"数据在线/自动刷新"(票 15/16)、地图工具列与底图(票 03/21/22)、筛选与地图/列表联动。<br>16:9 方案下曾提示的"缩放画布下 Cesium 拾取"风险,已随该方案撤销而消失。
+
+**已验证(agent 侧全部能做的都做完了)**
+
+1. 零依赖断言脚本通过:`T01 检查通过:4 个模式 / 16 个面板 / 展开收放与默认展开一致。`(首次运行暴露断言对重展顺序的过严假设——重展是追加语义——已修正断言;实现逻辑无缺陷。)
+2. `npm run dev` 已能在本会话运行(权限放开后),dev server 起在 `http://localhost:8000/`。
+3. **路由生成确认**:`src/.fes/core/routes/routes.js` 已出现 `{"path": "/cockpit", "component": cockpitIndex, ...}`,入口 `src/pages/cockpit/index.vue` 生效——同时印证票 08 的目录形态决策。
+4. **Vite 编译确认**:入口 + `CockpitShell.vue`(模板编译产物含 `cockpit-shell__map`)+ `panelCatalog.js`(`toggleExpanded` 导出保留)+ `uiStore.js` + 两个组件,全部 200,无编译错误。
+
+**浏览器交互走查(本票原唯一剩项):2026-09-16 跑通。**
+
+`browser_*` 工具在本机不可用,所以本次走查不是用它做的。根因已定位到具体一层:
+
+- `dsh-builtin-browser@0.1.21` 与 `electron@44.4.1` 都装在本 profile,但 **Electron 二进制从未下载**:`node_modules/electron/` 下既无 `dist/` 也无 `path.txt`,且各 profile 与常见缓存目录均未搜到任何 `electron.exe`。
+- 本运行时不提供 `electronViewHost`,provider 走自托管分支;`RemoteElectronViewHost.available()` 因找不到可 spawn 的 Electron 二进制返回 `false`,于是 `ctx.browser.resolveProvider()` 抛 `BROWSER_PROVIDER_UNAVAILABLE` —— 这就是 `browser_*` 工具报的 `no usable browser provider is registered`。
+- 只读复现(不 spawn):`available() => false`,stderr 为 `[dsh-browser host] electron unavailable: cannot locate a spawnable Electron binary…`,该报错自带修复指引。
+
+**没等这个环境修好**:改用手头已有的 Chrome 152 + profile 里的 `puppeteer-core@25.10.0` 直接驱动页面,走查了同一份页面 —— 脚本是新增的 `verify/t01-browser-walkthrough.mjs`,可重跑。
+
+**dsh-browser 在本机仍然不可用**,后续要用 `browser_*` 工具仍需先修:`npx install-electron`(需要外网;本机 GitHub / npmjs / npmmirror 全部不可达,只有内网 GitLab 通,所以这一步目前做不到),或把 `ELECTRON_PATH` 指向一个真实 electron 可执行文件。打包形态的 DSH Desktop.exe **不能**复用。
+
+**agent 侧可做项已于 2026-09-16 全部复跑通过**:断言脚本再次通过(`T01 检查通过:4 个模式 / 16 个面板 / 展开收放与默认展开一致。`,exit 0);`src/modules/cockpit/` 下 `CockpitShell.vue`、`components/CockpitPanel.vue`、`components/MapPlaceholder.vue`、`domain/panelCatalog.js`、`stores/uiStore.js` 与薄入口 `src/pages/cockpit/index.vue` 均在位;`src/.fes/core/routes/routes.js` 仍生成 `{"path":"/cockpit","component":cockpitIndex,"name":"cockpit_index"}`。
+
+## 走查记录(2026-09-16)
+
+跑法:`npm run dev`(服务起在 `http://localhost:8000/`)→ `node .scratch/cockpit-shell-panel-layout/verify/t01-browser-walkthrough.mjs`。
+环境:Chrome 152.0.7977.84 + puppeteer-core 25.10.0,headless。
+结果:**14/14 全部通过**,走查期间无页面级报错。
+
+| 断言 | 验的是什么 | 结果 |
+| --- | --- | --- |
+| A1 / A2 | `/cockpit` 打开,顶栏 + 左栏 + 右栏 + 中央地图占位同时出现且各一份 | 通过 |
+| A3 / A4 | 四个模式按钮的文案与顺序;默认 `overview` 高亮与 `aria-pressed` 同步 | 通过 |
+| A5 ×4 | 四个模式各自左右各 2 块;16 个 `panelId` 与中文标题逐一对照 ADR 0003 表格 | 通过 |
+| A6 | 连切四个模式,URL 与 `history.length` 不变(不新增路由) | 通过 |
+| A7 | 外壳与中央地图占位仍是原 DOM 实例(未被重建) | 通过 |
+| A8 | 点标题栏收起(`aria-expanded=false` / `+` / 正文隐藏)并可再展开 | 通过 |
+| A9 | 收起后切走再切回,四块全部恢复默认展开 | 通过 |
+| A10 | 不重置全局筛选 | 通过(机制口径,见下) |
+| A11 | 走查期间无页面级报错 | 通过 |
+
+截图与机器可读结果在 `verify/walkthrough/`:`mode-1-overview.png` … `mode-4-warningResponse.png`、`mode-4-collapsed.png`、`results.json`。
+
+A10 的诚实口径:验收里写的「不重置全局筛选」在今天的代码里是**无可重置之物** —— 筛选状态属于后续票(04/05)的 `filterStore`,尚未存在。所以这里验的是机制(`uiStore.setMode` 只写 `mode` 与 `expandedPanelIds`,没有任何筛选状态可被重置),不是真行为;等 `filterStore` 落地后需补一次真行为断言。
+
+走查已通过;按维护者要求本票转为**待人工审查**(`ready-for-human`),审查通过后再 close。任一不符请附现象回本票。
+
+## Comments
+
+> *This was generated by AI during triage.*
+
+### 现象
+
+仓库没有 `/cockpit` 外壳、没有 `CockpitShell`、没有驾驶舱模块。Fes 页面目前只有首页和 `test`;`app.jsx` 只注册 `/plugins` 动态路由。验收项全部未满足。这不是运行时回归,而是尚未开工的 enhancement。
+
+### 影响范围
+
+挡住实现清单 02 及之后所有依赖外壳的票。不影响现有 TvT 插件预览。
+
+### 复现
+
+无需运行构建。只读确认:`src/pages` 无 cockpit 入口;源码无 `CockpitShell` / `modules/cockpit`。
+
+### 根因候选
+
+1. **(高)功能从未实现。** 票来自 `/to-tickets`,代码库仍是 TvT 基座。
+2. **(高)规格对 AFK agent 仍不完整。** 票要求稳定英文 `mode` 与 `panelId`,但 [English mode and panelId dictionary with column assignment](../../web-cockpit-decision-map/issues/02-mode-panelid-dictionary-and-columns.md) 仍开放。入口是单文件还是目录也未锁,见 [Cockpit page file shape](../../web-cockpit-decision-map/issues/08-cockpit-page-file-shape.md)。
+3. **(中)标签过早。** `Status: ready-for-agent` 是拆票时打上的,不是 triage 结论。
+4. **(低)已有等价实现。** 已搜 `CockpitShell`、`pages/cockpit`、`modules/cockpit`,无命中。`.out-of-scope/` 不存在,无先验拒绝。
+
+### 分流建议
+
+- 类别:`enhancement`
+- 建议终态:`needs-info`,直到决策地图锁死英文词典和入口形态,再回到 `ready-for-agent` 并补 Agent Brief。
+- 备选:维持 `ready-for-agent`,但 brief 必须写死「禁止发明 `panelId` / 入口路径」。
+- 不建议 `ready-for-human`(判断已在 wayfinder,不在这张实现票)。
+- 不建议 `wontfix`。
+
+维护者选择 A。已切到 `needs-info`。未改业务代码,未关单。
+
+## Triage Notes
+
+> *This was generated by AI during triage.*
+
+**What we've established so far:**
+
+- 这是 enhancement,不是 bug;代码库里没有驾驶舱外壳。
+- 每个一级模式 4 个二级面板已锁定;`mode`/`expandedPanelIds` 进 `uiStore`。
+- 不能发明英文键或入口路径。
+
+**What we still need from you (@reporter):**
+
+- 关闭 [Cockpit page file shape](../../web-cockpit-decision-map/issues/08-cockpit-page-file-shape.md),给出唯一入口形态。
+
+英文词典已由 [English mode and panelId dictionary with column assignment](../../web-cockpit-decision-map/issues/02-mode-panelid-dictionary-and-columns.md) 锁定并写入 ADR 0003。入口形态已由 [Cockpit page file shape](../../web-cockpit-decision-map/issues/08-cockpit-page-file-shape.md) 锁定并写入 ADR 0006/0007。两项前置决策均已关闭,本票回到 `ready-for-agent`。
+
+## Agent Brief
+
+**Category:** enhancement
+**Summary:** 建立 `/cockpit` 外壳:单路由、四个一级模式、每模式左右两栏共 4 个二级面板、常驻地图占位。
+
+**Current behavior:**
+仓库没有驾驶舱。Fes 按 `src/pages` 自动生成路由,目前只有默认首页与 `test` 页;`/plugins` 由应用运行配置显式注册。没有 `CockpitShell`,也没有驾驶舱模块。
+
+**Desired behavior:**
+打开 `/cockpit` 渲染外壳:顶栏、左栏、右栏、中央地图占位。用户在一级模式之间切换;每个模式恰好显示自己的 4 个二级面板,左栏两块、右栏两块。切换模式不导航、不重载、不重建外壳与地图占位。面板用稳定英文 `panelId` 标识,中文标题从独立映射解析。
+
+面板标题栏可点击收起/展开该面板(`expandedPanelIds`),切换一级模式时重置为默认展开。切换一级模式不重置行业筛选等全局条件。
+
+**Key interfaces:**
+- `mode` — camelCase 四值:`overview`、`infrastructure`、`monitoring`、`warningResponse`。
+- `panelId` — 16 个扁平、全局唯一字符串,不带模式前缀。
+- 模式 → 面板集合固定,每模式两块左、两块右,顺序见 ADR 0003 的表格。
+- `panelLabels` — `panelId → 中文标题` 映射;业务逻辑不读中文。
+- `CockpitShell` — 本票的公共契约:给定当前 `mode` 能解析出该模式的 4 个面板及左右归属。测试只通过这个契约驱动。
+- `uiStore` — 持有 `mode` 与 `expandedPanelIds`(ADR 0004)。本票需要读写这两项。
+- 外壳不持有领域数据;面板从 Pinia 领域 store 消费状态。
+- 位置约束(ADR 0006/0007 已锁):入口 `src/pages/cockpit/index.vue` 保持薄,只挂载外壳;`CockpitShell` 与面板都放 `src/modules/cockpit`。不使用 `layout.vue`,不在 `.fes.js` 写 `router.routes`。
+
+**Acceptance criteria:**
+- [x] `/cockpit` 可打开,顶栏、左栏、右栏与中央地图占位同时出现。
+- [x] 四个一级模式可切换:总体概览、基础设施、运行监测、预警处置。
+- [x] 每个模式恰好渲染自己的 4 个面板,左栏两块、右栏两块,顺序与 ADR 0003 一致。
+- [x] `mode` 使用四个 camelCase 键;没有中文被当作状态键。
+- [x] 面板标题由 `panelLabels` 解析。
+- [x] 切换一级模式不新增路由、不重建外壳与地图占位。
+- [x] 点击面板标题栏可收起/展开;切换一级模式后全部恢复默认展开。
+- [x] 切换一级模式不重置全局筛选。(机制口径:仓库尚无 `filterStore`,见走查记录 A10。)
+- [x] `CockpitShell` 暴露模式→面板映射,单测可对四个模式逐一断言。
+- [x] 有模式→面板映射与展开状态的纯逻辑单测,以及人工走查记录。
+
+**Out of scope:**
+- 面板内部业务内容与数据契约——本票中面板是占位。
+- Cesium 接入、相机控制、3D Tiles——各自独立票(02、03、21)。
+- 真实接口、mock 适配层与凭据——独立票(04、05)。
+- 鉴权、视觉终稿、静态资产——独立票(06)。
+
+**Map-sync 备注(2026-09-10):** 面板收起/展开(`expandedPanelIds`)此前只有 ADR 0007 与 spec 的决策、没有任何实现票认领。它属于外壳 chrome,故并入本票;若维护者希望拆成独立票,请在实施前提出。

+ 18 - 0
.scratch/cockpit-shell-panel-layout/issues/02-mapcanvas-persistent-mount.md

@@ -0,0 +1,18 @@
+# 02: MapCanvas 常驻挂载
+
+**What to build:** 将中央 `MapCanvas` 作为驾驶舱常驻组件挂载。切换一级模式或面板展开状态时,地图实例不销毁、不重建。
+
+Blocked by: 01
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] `MapCanvas` 在 `/cockpit` 中只创建一次。
+- [ ] 切换四个一级模式时地图容器保持存在。
+- [ ] 收起/展开任意面板时地图不重建。
+- [ ] 地图实例生命周期由地图服务层管理,不散落在页面组件中。
+- [ ] 有地图生命周期单测或可观测走查记录。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 21 - 0
.scratch/cockpit-shell-panel-layout/issues/03-camera-control-contract.md

@@ -0,0 +1,21 @@
+# 03: 相机控制契约
+
+**What to build:** 实现驾驶舱三维地图的显式相机控制:左键拖拽平移、右键绕中心旋转、滚轮调整弹簧臂距离,并替换 Cesium 默认交互。
+
+Blocked by: 02
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 鼠标左键拖拽只执行摄像机平移,不旋转、不缩放。
+- [ ] 鼠标右键拖拽绕旋转中心执行轨道旋转;右键按下时锁定中心,拖拽中不重新拾取。
+- [ ] 滚轮只改变摄像机与旋转中心的距离。
+- [ ] 选中监测点/设备时中心为该对象地表点;未选中时为地面拾取点;取消选中不回退中心。
+- [ ] 从无选中到选中时镜头平滑换中心,保持 heading 与 range。
+- [ ] Cesium 默认左键旋转、滚轮缩放等行为被替换。
+- [ ] 相机控制集中在地图服务层。
+- [ ] 三类交互各有可重复演示路径。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 20 - 0
.scratch/cockpit-shell-panel-layout/issues/04-config-and-credentials.md

@@ -0,0 +1,20 @@
+# 04: 配置与凭据注入
+
+**What to build:** 通过部署环境或后端运行时配置注入后端地址、天地图、Cesium、WebSocket 等配置与凭据。前端不硬编码账号、密钥或环境地址。
+
+Blocked by: 01
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 配置统一由配置服务读取,页面不直接拼接环境地址。
+- [ ] 后端接口、天地图、Cesium、WebSocket 均可由配置覆盖。
+- [ ] 真实接口的 `Authorization: Bearer <JWT>` 由部署层注入(生产网关补头,开发本地反代或部署配置提供只读 token);适配层统一附加,页面与 store 不触碰凭据。
+- [ ] 凭据缺失时不进入登录页,而是显示数据不可用状态。
+- [ ] 数据源默认 mock;真实接口由部署配置显式开启,两者走同一适配层。
+- [ ] 配置加载成功、失败和缺失三种状态可演示。
+- [ ] 有配置加载纯逻辑单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 20 - 0
.scratch/cockpit-shell-panel-layout/issues/05-mock-domain-adapter.md

@@ -0,0 +1,20 @@
+# 05: Mock 领域适配层
+
+**What to build:** 建立驾驶舱领域适配层,让 mock 数据与真实接口使用同一契约。页面组件只消费领域对象,不直接依赖后端字段。
+
+Blocked by: 04
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 存在可切换的 mock 数据源;默认 mock,真实接口由部署配置显式开启。
+- [ ] 真实模式下适配层统一附加部署层注入的 `Authorization` 请求头。
+- [ ] 接口字段到领域对象的转换集中在适配层。
+- [ ] 页面和 store 不直接处理后端裸字段。
+- [ ] mock 数据覆盖成功、空数据、字段缺失、失败和过期样例。
+- [ ] 401 或凭据类失败只把受影响分区标为数据不可用,保留已成功分区与地图骨架。
+- [ ] 有适配器转换单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 18 - 0
.scratch/cockpit-shell-panel-layout/issues/06-cockpit-static-assets.md

@@ -0,0 +1,18 @@
+# 06: 驾驶舱自有静态资产与视觉占位
+
+**What to build:** 建立驾驶舱自有静态资产目录、基础视觉 token 和占位图、图标、状态标签。正式设计资源后续可直接替换,不改变信息架构。
+
+Blocked by: 01
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 驾驶舱静态资源使用自有目录,不依赖插件静态资源目录。
+- [ ] 建立颜色、间距、字号、层级等基础 token。
+- [ ] 提供地图占位、面板占位、图标和状态标签。
+- [ ] 视觉资源可替换,不影响模式与面板结构。
+- [ ] 有资源引用走查记录。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 18 - 0
.scratch/cockpit-shell-panel-layout/issues/07-coordinate-validation.md

@@ -0,0 +1,18 @@
+# 07: 坐标校验与无地图位置
+
+**What to build:** 对后端发布坐标做防御性校验。非法、缺失、零值、超范围或未校验坐标的对象不上图,但保留在列表和详情中,并显示明确原因。
+
+Blocked by: 08
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 缺失、非法、零值、超范围和未校验坐标均不上图。
+- [ ] 对象仍出现在列表和详情中。
+- [ ] UI 显示“无地图位置”或具体坐标原因。
+- [ ] 前端不自动纠偏、不使用默认坐标。
+- [ ] 有坐标校验纯逻辑单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 19 - 0
.scratch/cockpit-shell-panel-layout/issues/08-map-summary-pagination.md

@@ -0,0 +1,19 @@
+# 08: 地图摘要分页与快照
+
+**What to build:** 接入地图摘要分页契约,渐进加载监测点和未关联设备。同一批加载使用相同快照标记,刷新时切换到新快照。
+
+Blocked by: 05
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 地图先显示框架,再按分页批次增加摘要点位。
+- [ ] 分页参数和快照参数由适配层管理。
+- [ ] 同一次加载保持快照一致。
+- [ ] 监测点与未关联设备都能进入地图摘要状态。
+- [ ] 加载中、完成、失败和快照过期状态可演示。
+- [ ] 有分页与快照合并单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 18 - 0
.scratch/cockpit-shell-panel-layout/issues/09-point-status-legend.md

@@ -0,0 +1,18 @@
+# 09: 点位状态图例与标记
+
+**What to build:** 将后端解析后的监测点状态渲染为地图标记、颜色和图例。前端不重算点位状态,不把事件等级色混用为点位状态色。
+
+Blocked by: 08
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 监测点标记按后端返回状态渲染。
+- [ ] 正常、预警、报警、处置中工单、离线/无关联设备状态均可表达。
+- [ ] 每种颜色都有文字或形状辅助,不单独依赖颜色。
+- [ ] 地图图例与标记状态一致。
+- [ ] 有状态到视觉表现的单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

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

@@ -0,0 +1,19 @@
+# 10: 监测点详情与设备卡片
+
+**What to build:** 点击监测点后加载点位摘要和关联设备卡片。用户选择设备卡片后,再进入设备详情;关联设备不重复绘制为独立地图点位。
+
+Blocked by: 09
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 点击监测点可打开点位摘要。
+- [ ] 点位摘要包含名称、编码、状态、原因和关联设备数量。
+- [ ] 设备卡片顺序由后端返回顺序决定。
+- [ ] 选择设备卡片后进入设备详情。
+- [ ] 关联设备不作为独立地图标记重复渲染。
+- [ ] 有两级详情状态单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 19 - 0
.scratch/cockpit-shell-panel-layout/issues/11-equipment-detail-aggregate.md

@@ -0,0 +1,19 @@
+# 11: 设备详情聚合与分区状态
+
+**What to build:** 通过设备详情聚合契约加载设备档案、最新指标、预警、报警、工单状态和最新图片。各分区可独立成功、失败、为空或过期。
+
+Blocked by: 10
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 设备详情按设备编码加载。
+- [ ] 档案、指标、预警、报警、工单和图片分区独立渲染。
+- [ ] 任一分区失败时不遮盖其他成功分区。
+- [ ] 空数据、字段缺失和过期状态均有明确 UI。
+- [ ] 最新图片只展示后端指定的一张。
+- [ ] 有分区状态映射单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 18 - 0
.scratch/cockpit-shell-panel-layout/issues/12-work-order-timeline.md

@@ -0,0 +1,18 @@
+# 12: 工单七态时间线
+
+**What to build:** 只读展示工单七态和状态时间线。验收驳回表现为“待验收回退处理中”的动作轨迹,不展示延期状态。
+
+Blocked by: 11
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 工单状态完整展示待派单、待接单、已接单、处理中、待验收、已结案、已取消。
+- [ ] 每张工单有独立时间线。
+- [ ] 验收驳回显示为回退动作,不是独立终态。
+- [ ] 不提供任何工单写操作。
+- [ ] 有工单状态映射单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 19 - 0
.scratch/cockpit-shell-panel-layout/issues/13-event-list-and-mode-switch.md

@@ -0,0 +1,19 @@
+# 13: 事件列表与预警/报警切换
+
+**What to build:** 提供预警/报警分段切换和只读事件列表。事件等级颜色与点位状态色分离,事件通过设备编码关联设备位置。
+
+Blocked by: 11
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 预警与报警可切换,列表字段和状态标签同步变化。
+- [ ] 事件列表默认展示最新 10-15 条。
+- [ ] 事件等级使用 1-4 级颜色,并有文字辅助。
+- [ ] 事件不创建独立地图标记。
+- [ ] 不判断预警真伪或误报。
+- [ ] 有事件适配和切换状态单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 19 - 0
.scratch/cockpit-shell-panel-layout/issues/14-event-detail-and-locate.md

@@ -0,0 +1,19 @@
+# 14: 事件详情与设备定位
+
+**What to build:** 点击事件后打开事件详情,并通过设备编码定位设备。地图高亮设备和关联管线,返回时保留原列表上下文。
+
+Blocked by: 13
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 事件详情包含编码、设备编码、指标、阈值、实际值、等级、时间和状态。
+- [ ] 点击事件可定位关联设备。
+- [ ] 地图高亮设备和关联管线。
+- [ ] 返回路径保留列表位置和筛选条件。
+- [ ] 无有效坐标时事件仍可查看并显示无地图位置原因。
+- [ ] 有事件定位与返回状态单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 20 - 0
.scratch/cockpit-shell-panel-layout/issues/15-websocket-status-updates.md

@@ -0,0 +1,20 @@
+# 15: WebSocket 状态更新
+
+**What to build:** 通过统一运行时服务接入设备状态推送。推送只更新受影响的点位和详情字段,断线后可重连,并保留当前选择上下文。
+
+Blocked by: 09, 13
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] WebSocket 连接、订阅、断线和重连集中在运行时服务中。
+- [ ] 连接成功后先发 `authenticate` 帧(token 与 REST 同源),再发 `cockpitSubscribe`;authenticate 失败只把状态推送标为不可用,REST 快照照常显示。
+- [ ] 设备状态增量按服务端更新时间接受,旧消息被忽略。
+- [ ] 推送更新受影响点位和已打开详情,不重建视图。
+- [ ] 当前选中对象、面板状态和滚动位置保持不变。
+- [ ] 连接、重连、过期和失败状态可演示。
+- [ ] 有增量合并与重连单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 19 - 0
.scratch/cockpit-shell-panel-layout/issues/16-freshness-and-stale-state.md

@@ -0,0 +1,19 @@
+# 16: 数据新鲜度与过期状态
+
+**What to build:** 建立统一新鲜度模型。连续两个配置周期未成功更新时,页面显示数据不可用或已过期,并保留最后一次成功数据但不伪装成实时数据。
+
+Blocked by: 15
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 显示最近一次成功更新时间。
+- [ ] 数据新鲜、过期、不可用和部分失败状态可区分。
+- [ ] 连续两个周期失败时显示过期或不可用状态。
+- [ ] 过期数据保留最后已知值,并带有过期标识。
+- [ ] 缺失指标显示占位符,不显示虚假零值。
+- [ ] 有新鲜度计算单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 20 - 0
.scratch/cockpit-shell-panel-layout/issues/17-infrastructure-panels.md

@@ -0,0 +1,20 @@
+# 17: 基础设施模式面板
+
+**What to build:** 在基础设施一级模式下同时展示基础档案、运行信息、管理信息、应急保障四个二级面板,并与中央地图保持联动。
+
+Blocked by: 06, 08
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 基础档案面板展示设施规模、行业分类、区域分布和档案摘要。
+- [ ] 运行信息面板展示供应、输送、承载能力和指标占位。
+- [ ] 管理信息面板展示管理单位、权属单位和责任区域。
+- [ ] 应急保障面板展示危险源、防护目标和应急资源。
+- [ ] 四个面板支持独立展开/收起。
+- [ ] 点击面板中的可定位对象可联动地图。
+- [ ] 有面板数据适配和联动单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 20 - 0
.scratch/cockpit-shell-panel-layout/issues/18-monitoring-panels.md

@@ -0,0 +1,20 @@
+# 18: 运行监测模式面板
+
+**What to build:** 在运行监测一级模式下同时展示监测概览、监测分布、运行概况、监测点台账四个二级面板,并支持地图状态筛选和点位联动。
+
+Blocked by: 06, 08
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 监测概览展示监测点/设备规模、在线率、类型分布和最近采集时间。
+- [ ] 监测分布展示覆盖情况、状态分布和地图筛选。
+- [ ] 运行概况展示事件趋势、离线设备和异常态势。
+- [ ] 监测点台账以滚动卡片列出当前行业筛选内的监测点;点击卡片走与地图相同的两级详情。
+- [ ] 面板筛选可作用于地图点位。
+- [ ] 面板可独立展开/收起。
+- [ ] 有统计映射和筛选联动单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 20 - 0
.scratch/cockpit-shell-panel-layout/issues/19-warning-response-panels.md

@@ -0,0 +1,20 @@
+# 19: 预警处置模式面板
+
+**What to build:** 在预警处置一级模式下同时展示当前预警、历史分析、预警效率、预警分析四个二级面板,并保持预警/报警双模式语义。
+
+Blocked by: 13, 16
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 当前预警展示当前事件、工单状态和设备定位。
+- [ ] 历史分析展示历史事件趋势和时间范围筛选。
+- [ ] 预警效率展示处置率、响应时长、关闭时长和工单七态分布。
+- [ ] 预警分析展示多维事件分布和重点识别。
+- [ ] 四个面板均支持预警/报警切换。
+- [ ] 面板可独立展开/收起。
+- [ ] 有双模式映射和统计单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 20 - 0
.scratch/cockpit-shell-panel-layout/issues/20-overview-panels.md

@@ -0,0 +1,20 @@
+# 20: 总体概览模式面板
+
+**What to build:** 在总体概览一级模式下同时展示态势总览、基础设施、运行监测、预警设置四个二级面板,形成城市生命线整体态势摘要。
+
+Blocked by: 17, 18, 19
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 态势总览展示跨行业 KPI:设施底数、监测规模、在线率、当前预警/报警、处置率、近 7 日趋势。
+- [ ] 基础设施面板汇总设施底数、行业构成和区域分布。
+- [ ] 运行监测面板汇总在线率、离线/故障统计和监测覆盖。
+- [ ] 预警设置面板保持合同名称,只读展示预警/报警统计。
+- [ ] 面板指标可联动地图或事件列表。
+- [ ] 面板可独立展开/收起。
+- [ ] 有汇总指标单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

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

@@ -0,0 +1,20 @@
+# 21: 3D Tiles 与地形基线
+
+**What to build:** 接入本地或甲方提供的 3D Tiles、地形和倾斜摄影资源,实现地面视图与管线视图切换。首期不依赖 Cesium Ion。
+
+Blocked by: 03, 04
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 支持本地或甲方资源地址配置。
+- [ ] 地形、3D Tiles 和倾斜摄影资源可加载。
+- [ ] 资源加载中、成功、失败和空状态可区分。
+- [ ] 地面视图与管线视图可切换:管线视图压低相机并把地形与 3D Tiles 压成半透明,地面视图还原。
+- [ ] 切换视图不重置旋转中心,也不改变左键/右键/滚轮三键分工。
+- [ ] 不使用硬编码 Cesium Ion token。
+- [ ] 有资源加载状态单测或走查记录。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 20 - 0
.scratch/cockpit-shell-panel-layout/issues/22-cesium-pipeline-rendering.md

@@ -0,0 +1,20 @@
+# 22: Cesium 地理管线渲染
+
+**What to build:** 使用 Cesium 渲染后端发布的管线折线、管径、埋深和材质属性。管线作为地理主图的一部分,与监测点交互保持正确层级。
+
+Blocked by: 21
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 管线折线、管径、埋深和材质由适配层提供。
+- [ ] Cesium 渲染地理管线,支持管线视图;管线在管线视图下可见,在地面视图下隐藏。
+- [ ] 管线视图下地形与 3D Tiles 为半透明参照,不完全隐藏。
+- [ ] 管线可按行业和状态筛选。
+- [ ] 管线交互优先级低于监测点和设备点位。
+- [ ] 无效坐标管线不上图,但保留在列表或详情中。
+- [ ] 有管线数据映射和筛选单测。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 19 - 0
.scratch/cockpit-shell-panel-layout/issues/23-threejs-pipeline-enhancement.md

@@ -0,0 +1,19 @@
+# 23: Three.js 管线增强
+
+**What to build:** 对选中的重点管段、接头、阀门或断面使用 Three.js 做局部增强。Cesium 继续承担地理主图,Three.js 不接管场景。
+
+Blocked by: 22
+
+Status: ready-for-agent
+Labels: ready-for-agent
+
+- [ ] 只有选中或配置为重点的对象进入 Three.js 增强视图。
+- [ ] Cesium 与 Three.js 通过选中对象和领域状态联动。
+- [ ] 支持重点管段、接头、阀门或断面占位展示。
+- [ ] 增强视图可关闭并返回地理主图上下文。
+- [ ] 不用 Three.js 重建全市地理管线。
+- [ ] 有选中对象联动单测或走查记录。
+
+## 进度:0%
+
+未动工。等待按 Blocked by 依赖顺序实施。

+ 105 - 0
.scratch/cockpit-shell-panel-layout/issues/24-panel-field-coverage.md

@@ -0,0 +1,105 @@
+# 24: 面板字段与后端契约覆盖调研
+
+Type: research
+Status: resolved
+Labels: wayfinder:research
+Blocked by: None
+
+**What to find:** 16 个二级面板各自需要的数据字段,现有 `/api/cockpit/v1/**` 与 `/ws/deviceStatus` 契约能不能供上;供不上的缺口要逐条列出,并给出补契约建议。
+
+注意:本票是 AFK 调研,不改业务代码。结论写入本票链接的调研资产。
+
+## 范围
+
+对 ADR 0003 锁定的 16 个 `panelId` 逐一对照后端契约:
+
+- `overview`:`situationSummary`、`infrastructureSummary`、`monitoringSummary`、`alertSettings`
+- `infrastructure`:`archive`、`operation`、`management`、`emergency`
+- `monitoring`:`monitoringOverview`、`monitoringDistribution`、`operationOverview`、`monitoringPointLedger`
+- `warningResponse`:`currentEvents`、`historyAnalysis`、`responseEfficiency`、`eventAnalysis`
+
+## 要产出
+
+每个面板一段,包含:
+
+- 面板需要的字段清单(按 ADR 0003 与 `CONTEXT.md` 的语义描述,不要发明业务)
+- 现有契约是否已提供:`map-snapshot`、`monitoring-points/{code}`、`equipments/{code}/detail-aggregate`、`equipments/{code}/archive`、`equipments/{code}/metrics/{current,history}`、`equipments/{code}/events/{current,history}`、`equipments/{code}/work-orders`、`work-orders/{code}/timeline`、`foundation`、`device-boundary`、`freshness`
+- 缺口分级:**已有** / **可推导**(由已有字段组合得出)/ **缺失**(需要后端补)
+- 缺失项的补契约建议(字段名、语义、是否需要新端点)
+
+## 约束
+
+- 只读:不改业务代码,不改后端代码,不跑构建或调试命令。
+- 后端契约以后端仓库实际控制器与响应结构为准(`E:\pipe-ner-server\pipe-network-server` 的 `com.zksy.web.controller.cockpit` 与 `com.zksy.cockpit.*`),后端演示页 `E:\pipe-ner-server\docs\cockpit\*.html` 作为样例响应的旁证。
+- 当前后端没有可用 OpenAPI 文档,所以逐类读控制器与响应类,不要假设。
+- 结论要能直接支撑实现票 17–20 的字段映射;不要写成泛泛的接口清单。
+
+## 与其他票的关系
+
+- 不阻塞实现票 17–20 的 mock 版实现(默认数据源是 mock)。
+- 阻塞的是这些面板**接真实接口**时的字段映射确认;结论供实现票 05(Mock 领域适配层)与 17–20 参考。
+- 本票不改任何实现票的验收项。
+
+## 完成标准
+
+- [ ] 16 个面板逐一给出字段清单与覆盖判定(已有 / 可推导 / 缺失)。
+- [ ] 所有「缺失」项聚合成一份补契约清单,含字段名与语义。
+- [ ] 每条判定都附后端类名或响应结构作为证据。
+- [ ] 结论写入 `.scratch/cockpit-shell-panel-layout/research/panel-field-coverage.md` 并从本票链接。
+- [ ] 不改业务代码,不跑构建或调试命令。
+
+## 进度:100%
+
+已完成。结论写入 [`.scratch/cockpit-shell-panel-layout/research/panel-field-coverage.md`](../research/panel-field-coverage.md)。
+
+## Answer
+
+**16 个面板:5 个字段完全覆盖,11 个存在缺口。** 完整字段表与补契约清单见调研资产。
+
+### 决策要点
+
+**1. 任务书给出的「现有契约」清单漏了 4 个面板级端点。** 后端已经为总体概览与基础档案提供了一整层聚合读模型:
+
+- `GET /api/cockpit/v1/overview/infrastructure`
+- `GET /api/cockpit/v1/overview/monitoring`
+- `GET /api/cockpit/v1/overview/warning-settings`
+- `GET /api/cockpit/v1/infrastructure/base-archives`
+- 另有 `GET /api/cockpit/v1/events/{eventSource}/{eventCode}/work-orders`
+
+因此 `infrastructureSummary`、`monitoringSummary`、`alertSettings`、`archive`、`monitoringOverview` 是**已有**的,不需要补契约。实现票 17-20 的 mock 首期可直接按这 4 个响应的形状建模。
+
+**2. 缺口不是「单设备字段缺失」,而是「跨设备/跨设施读模型缺失」。** 所有事件端点(current/history)与指标端点都以 `deviceCode` 为路径参数,没有跨设备视图。
+
+**3. 逐面板判定**
+
+| 模式 | 完全覆盖 | 存在缺口 |
+| --- | --- | --- |
+| `overview` | `infrastructureSummary`、`monitoringSummary`、`alertSettings` | `situationSummary`(趋势窗口不可声明,弱缺口) |
+| `infrastructure` | `archive` | `operation`、`management`、`emergency` |
+| `monitoring` | `monitoringOverview` | `monitoringDistribution`、`operationOverview`、`monitoringPointLedger` |
+| `warningResponse` | 无 | 4 个面板全部有缺口 |
+
+**4. Top 5 缺失字段**
+
+1. 跨设备当前事件流(`eventCode` + 归属设备/监测点/行业 + 分页)— 完全阻塞 `currentEvents`
+2. 跨设备带时间范围的历史事件流 + 趋势 `range` 参数(7/30/90/自定义)— 阻塞 `historyAnalysis`
+3. 应急保障域三件套(`hazardSources` / `protectionTargets` / `emergencyResources`)— `emergency` 整版,后端 cockpit 包内该域**完全不存在**
+4. 监测点 `industry` + 点位类型 + 设备计数徽标,含 `map-snapshot` 的 `industry` 入参 — 阻塞 `monitoringPointLedger` / `monitoringDistribution`
+5. 事件经纬度(`longitude`/`latitude` + `coordinateValidation`)与响应/关闭时长聚合(`averageResponseSeconds` / `averageCloseSeconds`)
+
+**5. 两个影响面超出单面板的发现**
+
+- **全局「行业筛选」目前落不到地图点位上**:`overview/monitoring` 与 `base-archives` 支持 `industry`,但 `map-snapshot` 既无 `industry` 入参,`CockpitMonitoringPointSummary` 也无 `industry`/类型字段,无法服务端或客户端过滤。CONTEXT.md 定义行业筛选是作用于整个驾驶舱的全局条件。
+- **`map-snapshot` 丢弃了设备编码**:内部快照 `CockpitMapSnapshot.SourceMonitoringPoint.devices` 存在,但 `CockpitMapSnapshotService.monitoringPoint()` 不写入响应,导致 WS `cockpitDeviceStatusDelta`(增量只带 `deviceCode` + `status`)无法回联到点位状态。
+
+**6. WS 契约现状**:`cockpitSubscribe` 回帧只有 `{scope, serverUpdatedAt}`,无初始设备状态数组——后端注释明确「REST 仍是初始快照,这里只接收后续状态增量」。另注意线上帧增量状态键是 `data.status`,而后端 DTO `CockpitOverviewMonitoringDeviceStatusDelta` 的字段名是 `monitoringState`。
+
+**7. 待业务确认(本票不发明结论)**:「故障」是否等同 `stale`;「处置率」当前定义是 `state==6` / 全部事件(含已取消),是否即业务所说处置率;「管理单位」与 `maintenance`(维护单位)是否同一概念。
+
+### 完成标准对照
+
+- [x] 16 个面板逐一给出字段清单与覆盖判定(已有 / 可推导 / 缺失)
+- [x] 所有「缺失」项聚合为补契约清单(A–H 共 8 组,含字段名与语义)
+- [x] 每条判定附后端类名或响应结构作为证据
+- [x] 结论写入 `.scratch/cockpit-shell-panel-layout/research/panel-field-coverage.md` 并从本票链接
+- [x] 未改业务代码,未跑构建或调试命令

+ 76 - 0
.scratch/cockpit-shell-panel-layout/issues/25-backend-contract-gaps.md

@@ -0,0 +1,76 @@
+# 25: 后端契约缺口待办(跨领域两项 + 补契约清单)
+
+Status: ready-for-human
+Labels: ready-for-human
+**Category:** enhancement
+Blocked by: None
+
+> 不阻塞 23 张实现票的 mock 阶段;阻塞的是这些票接真实接口。
+>
+> *This was generated by AI during triage.*
+
+## Why ready-for-human
+
+结论来自 [24: 面板字段与后端契约覆盖调研](24-panel-field-coverage.md)(已 resolved)与主会话的逐条核实。缺口分两类:
+
+1. **后端补契约**——需要后端负责人实现,agent 无法代做。
+2. **业务定义确认**——需要业务方回答,本票不发明结论。
+
+## G1 全局行业筛选落不到地图点位(已核实)
+
+`CONTEXT.md` 定义行业筛选是作用于整个驾驶舱的全局条件,但今天它无法过滤地图点位:
+
+- `GET /api/cockpit/v1/map-snapshot` 无 `industry` 入参(`CockpitMapSnapshotController`)。
+- `CockpitMonitoringPointSummary` 字段仅 `pointCode / pointName / pointStatus / statusReasons / longitude / latitude / coordinateValidation`——没有 `industry` 与点位类型,服务端与客户端都无从过滤。
+- 对照:`overview/monitoring` 与 `infrastructure/base-archives` 已支持 `industry` 入参,说明后端具备该维度,只是没给地图摘要。
+
+**需要后端**:`map-snapshot` 增加 `industry` 入参;`CockpitMonitoringPointSummary` 增加 `industry` 与点位类型字段。
+
+## G2 map-snapshot 丢弃设备编码,WS 增量无法回联点位(已核实)
+
+- 内部快照 `CockpitMapSnapshot.SourceMonitoringPoint.devices` 存在,但 `CockpitMapSnapshotService.monitoringPoint()`(145–160 行)只用它解析点位状态,**没有把设备编码写进响应**。
+- WS `cockpitDeviceStatusDelta` 增量只带 `deviceCode` + 状态,前端拿到增量后无法知道它属于哪个监测点,点位状态无法联动。
+- 另外 `cockpitSubscribe` 回帧只有 `{scope, serverUpdatedAt}`,无初始状态数组(后端注释明确 REST 是初始快照、WS 只发增量——契约本身成立,前端需按此实现);线上帧的状态键是 `data.status`,而后端 DTO `CockpitOverviewMonitoringDeviceStatusDelta` 命名为 `monitoringState`,接入时注意别名。
+
+**需要后端**:`CockpitMonitoringPointSummary` 输出关联设备编码列表。
+
+## 补契约清单(A–H)
+
+11 个有缺口面板的完整字段表与分组补契约建议(含字段名、语义、是否需要新端点)见 [research/panel-field-coverage.md](../research/panel-field-coverage.md) 第 4 节,共 8 组:
+
+- A 跨设备当前事件读模型(阻塞 `currentEvents`)
+- B 跨设备带时间范围的历史流 + 趋势 `range`(阻塞 `historyAnalysis`)
+- C 响应/关闭时长聚合(阻塞 `responseEfficiency`)
+- D 应急保障域三件套(阻塞 `emergency` 整版)
+- E 管理信息域
+- F 设施运行信息域
+- G 地图行业/类型维度(即 G1)
+- H 跨设备设备状态列表
+
+实现票 17–20 的 mock 阶段应按已有的 4 个面板级聚合响应形状建模:`/overview/infrastructure`、`/overview/monitoring`、`/overview/warning-settings`、`/infrastructure/base-archives`。
+
+## 业务问题(Q4 已决定:先挂着)
+
+- 「故障」是否等同 `stale`?
+- 「处置率」是否就是 `state==6` / 全部事件(含已取消)?
+- 「管理单位」与后端 `maintainer`(维护单位)是否同一概念?
+
+## 影响的实现票
+
+| 票 | 影响 |
+| --- | --- |
+| 06 全局筛选 | G1:筛选联动地图依赖后端补字段 |
+| 08 地图摘要 | G1、G2 |
+| 15 WebSocket | G2:增量回联点位依赖设备编码 |
+| 17–20 面板 | 真实接口路径依赖 A–H 补契约;mock 路径不受阻 |
+
+## Acceptance criteria(后端负责人)
+
+- [ ] `map-snapshot` 支持 `industry` 入参并在点位摘要中返回 `industry` 与点位类型。
+- [ ] 点位摘要返回关联设备编码列表,WS 增量可回联点位。
+- [ ] A–H 各组补契约与后端负责人逐项确认排期(或明确拒绝并记录理由)。
+- [ ] 3 个业务问题得到业务方答复并回写本票。
+
+## 进度:0%
+
+待办已记录,等后端负责人认领。不改任何实现票验收项。

+ 57 - 0
.scratch/cockpit-shell-panel-layout/issues/26-devserver-watch-scratch-crash.md

@@ -0,0 +1,57 @@
+# 26: Dev server 文件监视被 `.scratch` 面板原子写击穿
+
+> *This was generated by AI during triage.*
+
+Status: resolved
+Labels: ready-for-agent
+Category: bug
+Blocked by: None
+
+## 现象
+
+`npm run dev` 运行期间,issue tracker 面板(或任何对 `.scratch/**` 的原子改名式写入)保存单据时,Vite 文件监视器抛出 `EBUSY`,dev server 整个进程退出。
+
+实测复现一次(决策地图收口当轮):面板对 `map.md` 做原子写,产生临时目录 `.map.md.<pid>.<guid>.tmpdir/map.md.tmp`,chokidar 在 Windows 上 watch 该锁定路径失败:
+
+```
+Error: EBUSY: resource busy or locked, watch '...\.scratch\web-cockpit-decision-map\.map.md.<pid>.<guid>.tmpdir\map.md.tmp'
+  at createFsWatchInstance (vite .../dep-*.js)
+```
+
+## 最小复现
+
+1. `npm run dev`(起在 8000)。
+2. 在面板里编辑并保存任意 `.scratch/**/map.md` 或票据(触发原子写)。
+3. dev server 崩溃退出,stderr 出现上述 EBUSY 栈。
+
+## 影响范围
+
+- 只影响 dev 体验与走查(本票直接威胁「T01 浏览器走查」「后续每票的人工验收」:验收期间手一碰面板,服务就死)。
+- 不影响构建产物与运行时行为;生产页面无关。
+- `viteOption` 已有 `server`(proxy/headers),watch 默认监视整个项目根,`.scratch` 在树内。
+
+## 根因
+
+高置信(有栈有路径):Vite dev 的 chokidar 未排除 `.scratch/**`;面板原子写在 Windows 上生成短寿命锁定 tmp 路径,chokidar 遇 `EBUSY` 抛未捕获错误拖垮 watcher。
+
+## 修复建议(一行)
+
+`.fes.js` 的 `viteOption.server` 内增加:
+
+```
+watch: { ignored: ['**/.scratch/**'] }
+```
+
+## 验收
+
+- [x] dev server 运行期间,面板连续保存 3 张不同票据,服务器不退出。—— 以 3 轮「独占锁 tmp 写入 → rename 覆盖」模拟面板原子写(完全复刻崩溃路径的 `.map.md.<pid>.<guid>.tmpdir/map.md.tmp` 模式),服务器全程存活,HTTP 200。
+- [x] cockpit 页面热更新(改 `src/**` 文件)仍然正常。—— touch `src/modules/cockpit/domain/panelCatalog.js` 后 Vite 日志记录 `page reload`,模块重取 200。
+- [x] 不引入与本 bug 无关的 watch 排除(`src`、`public` 保持监视)。—— 配置只新增 `**/.scratch/**` 一条;已核 Vite `resolveChokidarOptions`:用户 ignored 与默认 ignore(node_modules/.git/cacheDir)合并而非覆盖。
+
+## 约束
+
+- 项目规则:修复后不自动运行 dev/build 自测,验证仅在用户明确要求时进行。—— 本轮验证由 /wayfinder 执行模板的「按验收标准自查」明确授权。
+
+## 进度:100%
+
+已修复并验证。改动:`.fes.js` 的 `viteOption.server` 内新增 `watch: { ignored: ['**/.scratch/**'] }`(含注释说明合并语义)。此后面板随时保存单据都不会击穿 dev server,T01 人工审查与后续各票验收可在面板开着的情况下进行。

+ 41 - 0
.scratch/cockpit-shell-panel-layout/map.md

@@ -0,0 +1,41 @@
+# Cockpit 外壳与面板布局(执行索引)
+
+Status: open
+Labels: wayfinder:map
+
+本目录是 `/to-spec` + `/to-tickets` 的产物,不是 wayfinder effort:`spec.md` 是规格,`issues/01`–`23` 是实现票(01 已实施,待人工审查),`issues/24`–`26` 是调研、待人工处理项与工程缺陷票(26:dev server watcher 崩溃,ready-for-agent)。
+本 `map.md` 的唯一作用是把这个 effort 暴露给 issue tracker 面板——面板只枚举含 `map.md` 的 `.scratch/<effort>/`,没有 `map.md` 的目录连同它的票都不会被列出。
+`Labels: wayfinder:map` 在这里只用于让面板按**地图视图**渲染本 effort 的子票状态、进度与依赖分层;不代表本 effort 由 `/wayfinder` 驱动——推进仍走 `/implement`。
+
+## Destination
+
+按 `spec.md`、`CONTEXT.md` 与 `docs/adr/0001`–`0008` 实现 `/cockpit` 外壳:单路由、四个一级模式、每个模式左右两栏共 4 个二级面板、中央地图常驻。语义已由 `.scratch/web-cockpit-decision-map/` 锁死,本 effort 不再发明 `mode`、`panelId`、相机或鉴权的含义。
+
+## Notes
+
+- 执行清单是 `issues/01`–`23`,按各票 `Blocked by` 的依赖顺序推进,一票一个全新上下文。
+- `issues/24`(resolved)是 AFK 调研,产物在 `research/panel-field-coverage.md`,不改业务代码。
+- `issues/25`(ready-for-human)是后端补契约与业务定义缺口,需要人或后端负责人处理;不阻塞 `01`–`23` 的 mock 阶段。
+- 相关技能:`implement`、`tdd`、`code-review`;TvT.js 侧基线是 `skills/tvt-js-plugin-builder/`。
+- 验收清单就在每张票正文里,完成时勾上并同步该票的 `Status:` 行。
+
+## Decisions so far
+
+- [spec.md](spec.md) — 二级面板改为同屏左右两栏加标题栏展开/收起,替代 `mode/subview` 切换。
+- [决策地图](../web-cockpit-decision-map/map.md) — `mode`/`panelId` 英文词典、三键相机契约、地面与管线视图合成、Cesium 集成方式、无登录页鉴权。
+- [ADR 0003](../../docs/adr/0003-web-page-information-architecture.md) — 页面信息架构与 `mode`/`panelId` 词典。
+- [01: Shell 与模式/面板映射](issues/01-shell-mode-panel-mapping.md) — 已关闭:外壳铺满视口(不锁 16:9)、四模式切换、每模式 4 面板同屏收放、范围/行业接入 filterStore 且切模式不重置;浏览器走查 15/15。
+- [26: Dev server 文件监视被 `.scratch` 面板原子写击穿](issues/26-devserver-watch-scratch-crash.md) — 已修复:`.fes.js` 的 dev server watch 排除 `**/.scratch/**`(Vite 默认 ignore 合并不覆盖),3 轮原子写模拟 + src 热更均实测通过。
+
+## Not yet specified
+
+- 甲方本地瓦片、地形与 3D Tiles 的实际交付物清单。
+- 设计公司视觉终稿如何替换驾驶舱占位 token。
+- 后端补契约的落地时间与责任人(见 `issues/25`)。
+
+## Out of scope
+
+- 登录、权限管理、工单处置等写操作。
+- UE5、Pixel Streaming、Docker、国产化认证。
+- CAD 转换、坐标精化、高精度模型生产。
+- 重写 `issues/01`–`23`。

+ 545 - 0
.scratch/cockpit-shell-panel-layout/research/panel-field-coverage.md

@@ -0,0 +1,545 @@
+# Research: 面板字段与后端契约覆盖
+
+**Ticket:** [24: 面板字段与后端契约覆盖调研](../issues/24-panel-field-coverage.md)
+**Date:** 2026-09-15
+**Scope:** 决策事实。只读调研,未改动任何业务代码、后端代码或构建产物。
+**Method:** 逐类阅读后端控制器(`com.zksy.web.controller.cockpit`)与响应/快照类(`com.zksy.cockpit.*`),不依赖 OpenAPI(Swagger 已注释)。演示页 `E:\pipe-ner-server\docs\cockpit\*.html` 仅作样例旁证。
+
+---
+
+## Decision
+
+**16 个二级面板中,5 个字段完全覆盖,11 个存在缺口。**
+
+关键纠偏:**本票与上游任务书列举的「现有契约」清单遗漏了 4 个面板级读模型端点**。后端已经为总体概览与基础档案提供了一整层聚合读模型(`/overview/infrastructure`、`/overview/monitoring`、`/overview/warning-settings`、`/infrastructure/base-archives`),还有一个 `GET /api/cockpit/v1/events/{eventSource}/{eventCode}/work-orders`。因此「总体概览三面板 + 基础档案 + 监测概览」实际上是**已有**的,不需要补契约。
+
+真正的缺口集中在四类,都是「**跨设备 / 跨设施的读模型**」缺失,而不是单设备字段缺失:
+
+1. 跨设备事件流(当前 + 历史 + 时间范围参数)— 后端事件读取一律以 `deviceCode` 为路径参数。
+2. 监测点的行业/类型维度(`map-snapshot` 既无 `industry` 入参,点位摘要也无 `industry`/类型字段)— 卡住 CONTEXT.md 定义的全局「行业筛选」。
+3. 应急保障域(危险源/防护目标/应急资源)在后端 cockpit 包内**完全不存在**。
+4. 效率时长聚合(响应时长/关闭时长)与事件多维分布聚合。
+
+---
+
+## 1. 后端契约基线(实测端点清单)
+
+`@GetMapping` 全量枚举结果(`com.zksy.web.controller.cockpit`,共 18 个 GET):
+
+| # | 端点 | 控制器 | 关键入参 | 响应类 |
+| --- | --- | --- | --- | --- |
+| 1 | `/api/cockpit/v1/foundation` | `CockpitFoundationController`(类级 `@RequestMapping("/api/cockpit/v1")`) | — | `CockpitFoundationResponse` |
+| 2 | `/api/cockpit/v1/map-snapshot` | `CockpitMapSnapshotController` | `page`,`pageSize`(默认500,上限1000),`snapshotAt` | `CockpitMapSnapshotResponse` |
+| 3 | `/api/cockpit/v1/monitoring-points/{monitoringPointCode}` | `CockpitMonitoringPointDetailController` | path | `CockpitMonitoringPointDetailResponse` |
+| 4 | `/api/cockpit/v1/device-boundary` | `CockpitDeviceBoundaryController` | — | `CockpitDeviceBoundaryResponse` |
+| 5 | `/api/cockpit/v1/overview/infrastructure` | `CockpitOverviewInfrastructureController` | — | `CockpitOverviewInfrastructureResponse` |
+| 6 | `/api/cockpit/v1/overview/monitoring` | `CockpitOverviewMonitoringController` | `industry` | `CockpitOverviewMonitoringResponse` |
+| 7 | `/api/cockpit/v1/overview/warning-settings` | `CockpitOverviewWarningSettingsController` | `mode`(WARNING\|ALARM) | `CockpitOverviewWarningSettingsResponse` |
+| 8 | `/api/cockpit/v1/infrastructure/base-archives` | `CockpitInfrastructureBaseArchivesController` | `industry`,`facilityType`,`ageBucket`,`region` | `CockpitInfrastructureBaseArchivesResponse` |
+| 9 | `/api/cockpit/v1/equipments/{deviceCode}/archive` | `CockpitEquipmentArchiveController` | path | `CockpitEquipmentArchiveResponse` |
+| 10 | `/api/cockpit/v1/equipments/{deviceCode}/detail-aggregate` | `CockpitEquipmentDetailAggregateController` | path | `CockpitEquipmentDetailAggregateResponse` |
+| 11 | `/api/cockpit/v1/equipments/{deviceCode}/metrics/current` | `CockpitTypeMetricsController` | path | `CockpitTypeMetricsResponse` |
+| 12 | `/api/cockpit/v1/equipments/{deviceCode}/metrics/history` | `CockpitTypeMetricsController` | `startTime`,`endTime`,`page`,`pageSize` | `CockpitTypeMetricsResponse` |
+| 13 | `/api/cockpit/v1/equipments/{deviceCode}/events/current` | `CockpitEventReadController` | path | `CockpitEventReadResponse` |
+| 14 | `/api/cockpit/v1/equipments/{deviceCode}/events/history` | `CockpitEventReadController` | `startTime`(必填),`endTime`(必填),`page`,`pageSize` | `CockpitEventReadResponse` |
+| 15 | `/api/cockpit/v1/equipments/{deviceCode}/work-orders` | `CockpitWorkOrderReadController` | path | `CockpitWorkOrderReadResponse` |
+| 16 | `/api/cockpit/v1/events/{eventSource}/{eventCode}/work-orders` | `CockpitWorkOrderReadController` | path | `CockpitWorkOrderReadResponse` |
+| 17 | `/api/cockpit/v1/work-orders/{workOrderCode}/timeline` | `CockpitWorkOrderReadController` | path | `CockpitWorkOrderTimelineResponse` |
+| 18 | `/api/cockpit/v1/equipments/{deviceCode}/freshness` | `CockpitFreshnessController` | `calibrationPeriodSeconds` | `CockpitFreshnessResponse` |
+
+端点 1、5、6、7、8、16 **不在**本票与上游任务书给出的清单里。这是本调研对字段覆盖判定影响最大的一条事实。
+
+响应统一包在 `AjaxResult` 里(`ResponseEntity<AjaxResult>`),业务负载在 `data` 下。
+
+### WebSocket `/ws/deviceStatus`
+
+`DeviceStatusWebSocketHandler`(`zksy-admin/.../web/websocket/`)消息类型:`authenticate`、`subscribe`、`unsubscribe`、`cockpitSubscribe`、`deviceStatus`、`workOrderReminder`、`ping`。
+
+驾驶舱相关链路:
+
+- 订阅:`→ {"type":"cockpitSubscribe","scope":"all"}`;`← {"type":"cockpitSubscribed","scope":"all","serverUpdatedAt":"..."}`
+- 增量:`← {"type":"cockpitDeviceStatusDelta","deviceCode":"DEV001","device_code":"DEV001","data":{"status":"ALARM","statusTime":"...","serverUpdatedAt":"...","reason":"..."}}`
+
+**重要语义(`DeviceStatusWebSocketHandler` 第 341-357 行注释原文)**:「驾驶舱全量范围订阅;**REST 仍是初始快照,这里只接收后续状态增量**」。`cockpitSubscribed` 回帧**不含**任何初始设备状态数组。
+
+**字段名不一致**:WS 线上帧的增量状态键是 `data.status`(`DeviceStatusRedisListener.cockpitDeltaMessage` 第 179 行),而后端 DTO `CockpitOverviewMonitoringDeviceStatusDelta` 的字段名是 `monitoringState`。前端映射时需按 WS 线上帧的 `status` 取值。
+
+**增量不含**坐标、行业、设备类型——只能靠 `deviceCode` 回联 REST 快照。
+
+---
+
+## 2. 判定口径
+
+| 等级 | 含义 |
+| --- | --- |
+| **已有** | 现有端点响应中存在对应字段(可能需换一个端点取) |
+| **可推导** | 需组合/回联现有字段,或有 N+1 代价;不需后端改契约 |
+| **缺失** | 现有契约无论怎么组合都拿不到,需后端补契约 |
+
+每条判定附后端类名。字段语义以 ADR 0003 的面板词典 + `CONTEXT.md` 术语为准,实现细节参考票 17-20 的验收项,未发明业务。
+
+---
+
+## 3. 逐面板字段覆盖
+
+### 3.1 `overview` 模式
+
+#### `situationSummary` 态势总览 — ⚠️ 1 处缺口
+
+`CONTEXT.md` 定义:跨行业 KPI,展示设施底数、监测规模、在线率、当前预警/报警、处置率和近 7 日趋势。
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 设施底数 | `CockpitOverviewInfrastructureResponse.Totals.facilityCount` | 已有 |
+| 监测规模(智能设备数) | `CockpitOverviewInfrastructureResponse.Totals.smartDeviceCount` | 已有 |
+| 监测点规模 | `CockpitOverviewMonitoringResponse.Counts.monitoringPointCount` | 已有 |
+| 在线率 | `CockpitOverviewMonitoringResponse.Counts.OnlineRate.{value,onlineCount,denominator,state,reason}` | 已有 |
+| 当前预警/报警数 | `CockpitOverviewWarningSettingsResponse.FamilyMetrics.{total,pending}`(`warning` / `alarm` 各一份) | 已有 |
+| 处置率 | `FamilyMetrics.handlingEfficiency.{numerator,denominator,value,state,reason}` | 已有(语义为「结案率」:分子=state 6,分母=全部事件,见 `CockpitOverviewWarningSettingsService` 第 32、133-146 行) |
+| 近 7 日趋势 | `FamilyMetrics.trend[]`(`TrendPoint.{bucket,total,handled,handlingEfficiency}`) | **缺失(窗口不可声明)** |
+| 可读的「无数据」态 | `FamilyMetrics.{state,stateReason}`、`Freshness.{status,reason}` | 已有 |
+
+**缺口证据**:`CockpitOverviewWarningSettingsService.load` 第 59 行硬编码 `new Range("CURRENT_BASELINE", null, null)`,且控制器只接受 `mode`(`CockpitOverviewWarningSettingsController` 第 35 行)。趋势桶完全由适配器快照决定,前端**无法声明「近 7 日」**。ADR 0003 明确要求「近 7 日趋势」。
+
+#### `infrastructureSummary` 基础设施 — ✅ 全覆盖
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 设施底数 | `CockpitOverviewInfrastructureResponse.Totals.facilityCount` | 已有 |
+| 行业构成 | `CockpitOverviewInfrastructureResponse.Industry[]`(`code,key,label,facilityCount,smartDeviceCount`) | 已有 |
+| 行业下的类型构成 | `Industry.facilityTypes[] / deviceTypes[]` → `TypeSummary.{code,name,count,lengthKm,scale,range}` | 已有 |
+| 区域分布 | `CockpitInfrastructureBaseArchivesResponse.regionDistribution[].{code,count}` | 已有(经 #8 端点) |
+| 规模里程 | `CockpitInfrastructureBaseArchivesResponse.Totals.{lengthKm,scale}` | 已有 |
+| 剔除口径可解释性 | `CockpitOverviewInfrastructureResponse.Excluded.{unknownTypeCount,typeKindMismatchCount}` | 已有 |
+
+`CockpitOverviewInfrastructureResponse` 自身不含区域字段,但 #8 端点提供,故判「已有」。
+
+#### `monitoringSummary` 运行监测 — ✅ 全覆盖
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 在线率 | `CockpitOverviewMonitoringResponse.Counts.OnlineRate` | 已有 |
+| 离线统计 | `Counts.Status.{online,offline,stale,unknownOrMissing,denominator}` | 已有 |
+| 故障统计 | 同上(无独立「故障」桶;只能映射到 `stale` / `unknownOrMissing`) | 已有(语义需确认,见 §5 待定项) |
+| 监测覆盖 | `Counts.{deviceCount,monitoringPointCount,monitoringFacilityCount,infrastructureFacilityCount}` + `FormalScope.{industries,keys}` | 已有 |
+| 最近采集时间 | `CockpitOverviewMonitoringResponse.MetricFreshness.{status,updatedAt,serverUpdatedAt}` | 已有 |
+| 数据新鲜度 | `CockpitOverviewMonitoringResponse.Freshness.{status,reason,...}` | 已有 |
+| 类型分布 | `CockpitOverviewMonitoringResponse.TypeDistribution[]`(`industry`,`deviceType{code,name}`,`deviceCount`) | 已有 |
+| 行业筛选回显 | `selectedIndustry{code,key,label}` | 已有 |
+
+#### `alertSettings` 预警设置 — ✅ 全覆盖
+
+票 20 要求「保持合同名称,只读展示预警/报警统计」,与 `CockpitOverviewWarningSettingsResponse` 一一对应。
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 预警/报警总览 | `FamilyMetrics.{total,handled,pending}` | 已有 |
+| 已取消 | `FamilyMetrics.cancelledCount` | 已有 |
+| 未关联工单 / 异常工单状态 | `FamilyMetrics.{unlinkedEventCount,invalidWorkOrderStateCount}` | 已有 |
+| 工单七态分布 | `FamilyMetrics.workOrderStates`(`Map<Integer,Integer>`,键 1-7,字典见 `CockpitWorkOrderStateDictionary`) | 已有 |
+| 处置率 | `FamilyMetrics.handlingEfficiency` | 已有 |
+| 趋势 | `FamilyMetrics.trend[]` | 已有 |
+| 双模式选择与地图过滤语义 | `selectedMode`、`MapFilter.{eventFamily,label}` | 已有 |
+| 时间范围声明 | `Range` | **缺失**(`type="CURRENT_BASELINE"`,start/end 为 null) |
+
+### 3.2 `infrastructure` 模式
+
+#### `archive` 基础档案 — ✅ 全覆盖
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 设施规模 | `CockpitInfrastructureBaseArchivesResponse.Totals.{facilityCount,lengthKm,scale}` | 已有 |
+| 行业分类 | `industryDistribution[].{code,count}` | 已有 |
+| 设施类型分布 | `facilityTypeDistribution[]` → `TypeSummary` | 已有 |
+| 区域分布 | `regionDistribution[].{code,count}` | 已有 |
+| 档案摘要(年限) | `ageDistribution[].{code,count}` + `ageReference.{asOfDate,commissionedCount,builtCount,unknownCount}` | 已有 |
+| 筛选回显 | `selectedFilters` + `mapFilter` | 已有 |
+| 口径剔除 | `excluded.{unknownTypeCount,typeKindMismatchCount,smartDeviceCount,futureDateCount}` | 已有 |
+| 单设备档案明细 | `CockpitEquipmentArchive`(`deviceModel,deviceSpec,manufacturer,productionDate,deviceType,deviceTypeName,assetValue,expectedServiceYears,longitude,latitude,equipmentLocation,installTime,maintainer,maintainerPhone,ownershipUnit,remark,coordinateSystem,latestImage`) | 已有(#9 端点) |
+
+#### `operation` 运行信息 — ❌ 2 处缺口
+
+票 17 要求「供应、输送、承载能力和指标占位」。
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 指标占位(当前值) | `CockpitMetric.{metricCode,metricName,value,unit,status,sampledAt,freshness}` | 已有(**设备级**,#11 端点) |
+| 指标历史序列 | `CockpitMetric.points[]` → `CockpitMetricPoint.{value,status,sampledAt,freshness}` | 已有(设备级,#12 端点) |
+| 指标可用性态 | `CockpitTypeMetricsResponse.{state,stateReason}` | 已有 |
+| 设施级「供应能力」 | 无任何设施属性契约(`CockpitFacilityBoundary` 只有 `facilityCode,facilityName,facilityType,objectKind,industry,formalScope`) | **缺失** |
+| 设施级「输送能力」 | 同上 | **缺失** |
+| 设施级「承载能力」 | 同上 | **缺失** |
+| 设施级指标聚合(一个设施下多设备的汇总) | #11/#12 只以 `deviceCode` 为路径参数;无 `facilityCode` 维度 | **缺失** |
+
+注:若「供应/输送/承载能力」首期只是**指标占位**,则可先以设备级 `CockpitMetric` 占位满足;但设施级聚合仍缺失。
+
+#### `management` 管理信息 — ❌ 3 处缺口
+
+票 17 要求「管理单位、权属单位和责任区域」。
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 权属单位 | `CockpitEquipmentArchive.ownershipUnit` | 已有(**仅设备级**) |
+| 维护方与联系人 | `CockpitEquipmentArchive.{maintainer,maintainerPhone}` | 已有 |
+| 管理单位 | 无该字段;最接近的是 `maintainer`(维护单位),与「管理单位」是不同的业务概念 | **缺失** |
+| 责任区域(设施维度) | `CockpitInfrastructureBaseArchivesSnapshot.SourceFacility.region` 存在,但**未在响应里逐设施暴露**;响应只有聚合的 `regionDistribution` | **可推导(弱)** |
+| 责任区域(设备维度) | `CockpitEquipmentArchive.equipmentLocation`(自由文本) | 可推导 |
+| 按区域聚合的管理单位/权属单位 | 无任何按 `ownershipUnit` / `maintainer` 分组的契约 | **缺失** |
+
+#### `emergency` 应急保障 — ❌ 全域缺失
+
+票 17 要求「危险源、防护目标和应急资源」。
+
+对 `com.zksy.cockpit` 全包按 `危险源|防护目标|应急|权属|管理单位|责任` 检索:**除 `region` 外无任何命中**。危险源、防护目标、应急资源、应急预案、应急物资、应急队伍在后端 cockpit 契约中均不存在。
+
+| 需要的字段 | 判定 |
+| --- | --- |
+| 危险源 | **缺失** |
+| 防护目标 | **缺失** |
+| 应急资源 | **缺失** |
+| 空间定位与可上图状态 | **缺失** |
+
+#### `monitoring` 模式
+
+#### `monitoringOverview` 监测概览 — ✅ 全覆盖
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 监测点规模 | `CockpitOverviewMonitoringResponse.Counts.monitoringPointCount` | 已有 |
+| 设备规模 | `Counts.deviceCount` | 已有 |
+| 在线率 | `Counts.OnlineRate` | 已有 |
+| 类型分布 | `typeDistribution[]` | 已有 |
+| 最近采集时间 | `metricFreshness.updatedAt` | 已有 |
+| 行业筛选 | 端点 `industry` 入参 + `selectedIndustry` | 已有 |
+| 监测设施/基础设施口径 | `Counts.{monitoringFacilityCount,infrastructureFacilityCount}` | 已有 |
+
+#### `monitoringDistribution` 监测分布 — ❌ 2 处缺口
+
+票 18 要求「覆盖情况、状态分布和地图筛选」。
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 覆盖情况 | `CockpitOverviewMonitoringResponse.FormalScope` + `Counts.{monitoringFacilityCount,infrastructureFacilityCount}` | 已有 |
+| 状态分布(点位状态) | `CockpitMonitoringPointSummary.pointStatus`(`CockpitMonitoringPointStatus` 6 值:ALARM/WARNING/WORK_ORDER/NORMAL/OFFLINE/EMPTY) | 已有(前端计数) |
+| 状态原因明细 | `CockpitMonitoringPointSummary.statusReasons[]` + `CockpitMonitoringPointStatusReason.{status,deviceCode,reason}` | 已有 |
+| 状态字典与优先级 | `CockpitMonitoringPointDetailResponse.statusDictionary[]` → `{status,label,priority}` | 已有 |
+| 无坐标点位分布 | `CockpitCoordinateValidationSummary` + 逐点 `CockpitCoordinateValidation.{state,reason,renderable,crs}` | 已有 |
+| 未关联设备 | `CockpitMapSnapshotResponse.unassociatedEquipment[]` | 已有 |
+| **地图按行业筛选** | `CockpitMapSnapshotController` 只接受 `page,pageSize,snapshotAt`(第 33-38 行);`CockpitMonitoringPointSummary` 无 `industry` 字段 | **缺失** |
+| **地图按点位类型筛选** | `CockpitMonitoringPointSummary` 无类型字段(内部 `CockpitMapSnapshot.SourceMonitoringPoint` 也无类型) | **缺失** |
+
+**影响面超出本面板**:CONTEXT.md 定义「行业筛选」是**作用于整个驾驶舱的全局范围条件**。`overview/monitoring` 与 `base-archives` 支持 `industry`,但一张图的 `map-snapshot` 不支持,且点位摘要无法回联行业(见 `monitoringPointLedger`),所以全局行业筛选目前**无法落到地图点位上**。
+
+#### `operationOverview` 运行概况 — ❌ 1 处缺口
+
+票 18 要求「事件趋势、离线设备和异常态势」。
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 事件趋势 | `CockpitOverviewWarningSettingsResponse.FamilyMetrics.trend[]` | 已有(窗口不可声明) |
+| 离线数量 | `CockpitOverviewMonitoringResponse.Counts.Status.offline` | 已有 |
+| 离线设备**清单** | 无跨设备设备状态列表;`map-snapshot` 只给点位状态,`CockpitOverviewMonitoringDeviceStatusDelta` 只是增量 | **缺失** |
+| 异常态势 | 设备级 `CockpitTypeMetricsResponse.{state,stateReason}`、`CockpitFreshnessResponse.freshness` | 已有(逐设备) |
+| 跨设备异常汇总 | 无 | **缺失** |
+
+#### `monitoringPointLedger` 监测点台账 — ❌ 4 处缺口
+
+CONTEXT.md:以滚动卡片列出当前筛选范围内监测点;点击卡片进入两级详情。
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 点位编码/名称 | `CockpitMonitoringPointSummary.{pointCode,pointName}` | 已有 |
+| 点位状态 + 原因 | `pointStatus`,`statusReasons[]` | 已有 |
+| 坐标与可上图状态 | `longitude`,`latitude`,`coordinateValidation` | 已有 |
+| 滚动分页 | `page`,`pageSize`,`total`,`complete`,`snapshotAt` | 已有 |
+| **所属行业**(筛选范围) | 点位摘要无 `industry`;`map-snapshot` 无 `industry` 入参 | **缺失** |
+| **点位类型** | 点位摘要无类型字段 | **缺失** |
+| **设备计数徽标**(总数/在线/报警/预警) | 需对每个点调 `monitoring-points/{code}` 取 `CockpitMonitoringPointDeviceStatistics.{totalDeviceCount,alarmDeviceCount,warningDeviceCount,workOrderDeviceCount,normalDeviceCount,offlineDeviceCount}` | **可推导(N+1)** |
+| **点位最近采集时间** | 需逐设备 `metrics/current` 或 `freshness` | **缺失(批量)** |
+| 关联设备编码 | `CockpitMapSnapshot.SourceMonitoringPoint.devices` 存在于**内部**快照,但 `CockpitMapSnapshotService.monitoringPoint()` 第 145-160 行**不写入响应** | **缺失** |
+
+台账面板因此无法在单次 `map-snapshot` 分页内渲染卡片,必须退化为 N+1 调用。
+
+### 3.3 `warningResponse` 模式
+
+#### `currentEvents` 当前预警 — ❌ 2 处缺口
+
+票 19 要求「当前事件、工单状态和设备定位」。ADR 0003:当前事件列表默认 10-15 条。
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 事件明细字段(预警) | `CockpitEventWarning.{warningNo,warningName,warningType,level,levelColor,status,eventTime,value,unit,deviceCode,deviceName,location}` | 已有(**设备级**) |
+| 事件明细字段(报警)+ 阈值 | `CockpitEventAlarm.{alarmId,warningCode,warningName,warningType,level,levelColor,status,eventTime,value,thresholdMinValue,thresholdMaxValue,unit,deviceCode,deviceName,location}` | 已有(设备级) |
+| 预警-报警关联 | `CockpitAlarmWarningRelation.{relationId,warningNo,alarmId,relatedAt}` | 已有 |
+| 等级配色 | `level` + `levelColor`(`CockpitEventLevelColor.resolve`:1→RED,2→YELLOW,3→BLUE,4→GREEN) | 已有 |
+| 工单状态(设备维度) | `CockpitWorkOrderReadResponse.workOrders[]` → `CockpitWorkOrderSummary.stateCode/stateName` + `stateDictionary` | 已有(#15) |
+| 工单状态(事件维度) | #16 `events/{eventSource}/{eventCode}/work-orders` | 已有 |
+| 动作轨迹 | `CockpitWorkOrderTimelineResponse.timelineEntries[]` → `CockpitWorkOrderTimelineEntry.{stateCode,stateName,operationName,operationDesc,operatorName,operatedAt}` | 已有(#17) |
+| **跨设备当前事件列表**(一次取 10-15 条) | 三个事件端点全部以 `deviceCode` 为路径参数;无跨设备事件读模型 | **缺失** |
+| **事件 → 地图坐标** | `CockpitEventWarning/Alarm` 只有 `location`(文本),无经纬度;`map-snapshot` 也不返回已关联设备的坐标 | **缺失** |
+| 事件排序/截断规则 | 无跨设备契约 | **缺失** |
+
+#### `historyAnalysis` 历史分析 — ❌ 2 处缺口
+
+票 19 要求「历史事件趋势和时间范围筛选」。ADR 0003:历史列表提供 7/30/90 天和自定义时间范围。
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 历史事件明细(单设备,带时间范围) | `equipments/{deviceCode}/events/history?startTime&endTime&page&pageSize` | 已有(**设备级**,`startTime`/`endTime` 必填) |
+| 历史分页 | `CockpitEventReadResponse.{page,pageSize,total,startTime,endTime,timezone}` | 已有 |
+| **跨设备历史事件列表** | 无 | **缺失** |
+| **趋势的时间范围参数(7/30/90/自定义)** | `overview/warning-settings` 只接受 `mode`;`Range("CURRENT_BASELINE",null,null)` 硬编码 | **缺失** |
+| 趋势桶序列 | `FamilyMetrics.trend[].bucket` | 已有(但窗口服务端固定) |
+
+#### `responseEfficiency` 预警效率 — ❌ 1 处缺口(含 2 个字段)
+
+票 19 要求「处置率、响应时长、关闭时长和工单七态分布」。
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 处置率 | `FamilyMetrics.handlingEfficiency` | 已有 |
+| 工单七态分布 | `FamilyMetrics.workOrderStates` + `CockpitWorkOrderStateDictionary`(7 态) | 已有 |
+| 趋势效率 | `trend[].handlingEfficiency` | 已有 |
+| 单工单时长(可算) | `CockpitWorkOrderSummary.{createTime,updateTime,planFinishTime,realFinishTime}` | 可推导(**逐工单**) |
+| 接单时点(算响应时长所需) | `CockpitWorkOrderTimelineEntry` 中 `stateCode=3`(已接单)的 `operatedAt` | 可推导(**逐工单,需拉 timeline**) |
+| **响应时长聚合 KPI** | 无任何时长字段 | **缺失** |
+| **关闭时长聚合 KPI** | 无任何时长字段 | **缺失** |
+
+对 `docs/cockpit/*.html` 按 `duration|响应时长|关闭时长|responseTime` 检索:**零命中**,旁证该维度确未实现。
+
+#### `eventAnalysis` 预警分析 — ❌ 1 处缺口(多个维度)
+
+票 19 要求「多维事件分布和重点识别」。
+
+| 需要的字段 | 来源 | 判定 |
+| --- | --- | --- |
+| 按类型(单设备) | `CockpitEventWarning.warningType` / `CockpitEventAlarm.warningType` | 可推导(**仅逐设备事件**) |
+| 按等级(单设备) | `level` + `levelColor` | 可推导(逐设备) |
+| 按设备类型(单设备) | `CockpitEventReadResponse.{deviceType,deviceTypeName}` | 可推导(逐设备,响应级字段) |
+| **按类型聚合分布** | 无聚合契约 | **缺失** |
+| **按等级聚合分布** | 无聚合契约 | **缺失** |
+| **按行业分布** | 事件对象无 `industry` 字段 | **缺失** |
+| **按区域分布** | 事件对象无区域字段 | **缺失** |
+| **重点识别 / Top-N / 热点** | 无 | **缺失** |
+
+---
+
+## 4. 补契约清单(汇总所有「缺失」项)
+
+### A. 跨设备事件读模型 —— 最高优先级
+
+覆盖面板:`currentEvents`、`historyAnalysis`、`eventAnalysis`、`operationOverview`(异常态势)。
+
+现有事件端点全部是 `equipments/{deviceCode}/...`,无法支撑「当前事件列表 10-15 条」这类跨设备视图。
+
+**A1. 建议新端点** `GET /api/cockpit/v1/events/current`
+
+| 参数 | 语义 |
+| --- | --- |
+| `industry` | 行业筛选(对齐 CONTEXT.md 全局行业筛选) |
+| `family` | `WARNING` \| `ALARM` \| 空=两者 |
+| `page`,`pageSize` | 分页,默认 1 / 15 |
+
+| 建议字段 | 语义 |
+| --- | --- |
+| `eventFamily` | `WARNING` \| `ALARM` |
+| `eventCode` | 预警用 `warningNo`,报警用 `alarmId`,统一键 |
+| `eventName`,`eventType`,`level`,`levelColor`,`status`,`eventTime`,`value`,`unit` | 复用 `CockpitEventWarning`/`CockpitEventAlarm` 同名语义 |
+| `thresholdMinValue`,`thresholdMaxValue` | 仅报警;复用 `CockpitEventAlarm` |
+| `deviceCode`,`deviceName` | 复用 |
+| `monitoringPointCode`,`monitoringPointName` | 新增:事件归属监测点,供两张图/台账联动 |
+| `industry`,`drainageScope`,`objectKind` | 新增:行业归属,供全局行业筛选与事件分析 |
+| `longitude`,`latitude` | **新增**:事件定位(当前只有文本 `location`) |
+| `coordinateValidation` | 复用 `CockpitCoordinateValidation`(`state,reason,renderable,crs`) |
+| `workOrderCode`,`workOrderStateCode`,`workOrderStateName` | 新增:事件当前工单状态,省掉按事件二次请求 |
+| `page`,`pageSize`,`total`,`updatedAt`,`freshness` | 复用既有分页/新鲜度语义 |
+
+**A2. 建议新端点** `GET /api/cockpit/v1/events/history`
+
+参数在 A1 基础上加 `startTime`、`endTime`(必填,支持 7/30/90 天与自定义)。响应体结构与 A1 一致,另加 `range{type,start,end}`。
+
+### B. 趋势时间范围可声明
+
+覆盖面板:`situationSummary`(近 7 日趋势)、`historyAnalysis`、`alertSettings`、`operationOverview`。
+
+**建议扩展** `GET /api/cockpit/v1/overview/warning-settings` 入参:`range` + `startTime` + `endTime`。
+
+| 建议字段 | 语义 |
+| --- | --- |
+| `range.type` | `LAST_7_DAYS` \| `LAST_30_DAYS` \| `LAST_90_DAYS` \| `CUSTOM`(替换硬编码的 `CURRENT_BASELINE`) |
+| `range.start`,`range.end` | 实际生效区间;`CUSTOM` 时回显请求值 |
+| `trend[].bucket` | 保持 `yyyy-MM-dd`;窗口跟随 `range` |
+
+`CockpitOverviewWarningSettingsResponse.Range` 与 `TrendPoint` 结构已存在,属**加参数 + 填值**,不需新端点。
+
+### C. 效率时长聚合
+
+覆盖面板:`responseEfficiency`。
+
+**建议在 `CockpitOverviewWarningSettingsResponse.FamilyMetrics` 内新增**:
+
+| 建议字段 | 语义 |
+| --- | --- |
+| `averageResponseSeconds` | 平均响应时长 = avg(首次接单时间 − 事件发生时间);接单时间取工单 timeline 中 `stateCode=3` 的 `operatedAt` |
+| `averageCloseSeconds` | 平均关闭时长 = avg(结案时间 − 事件发生时间);结案时间取 `CockpitWorkOrderSummary.realFinishTime` |
+| `medianResponseSeconds`,`medianCloseSeconds` | 中位数,抗长尾(大屏指标更稳健) |
+| `durationSampleCount` | 参与计算的样本数,供口径透明 |
+| `durationState`,`durationReason` | 复用既有 `state`/`reason` 模式表达 `EMPTY`/`NO_SAMPLES` |
+
+数据源已存在(`createTime`/`realFinishTime` + timeline `operatedAt`),缺的是聚合层。
+
+### D. 应急保障域 —— 需全新读模型
+
+覆盖面板:`emergency`。后端 cockpit 包内该域**完全不存在**。
+
+**建议新端点** `GET /api/cockpit/v1/infrastructure/emergency`,参数 `industry`、`facilityType`、`region`。
+
+| 建议结构 | 字段 |
+| --- | --- |
+| `totals` | `hazardSourceCount`,`protectionTargetCount`,`emergencyResourceCount` |
+| `hazardSources[]` | `code`,`name`,`hazardType`,`hazardLevel`,`industry`,`region`,`relatedFacilityCode`,`longitude`,`latitude`,`coordinateValidation` |
+| `protectionTargets[]` | `code`,`name`,`targetType`,`targetLevel`,`industry`,`region`,`distanceToSourceMeters`,`longitude`,`latitude`,`coordinateValidation` |
+| `emergencyResources[]` | `code`,`name`,`resourceType`,`quantity`,`unit`,`ownershipUnit`,`contactName`,`contactPhone`,`region`,`longitude`,`latitude` |
+| 信封 | `freshness`,`selectedFilters`,`mapFilter`,`excluded`(对齐 `CockpitInfrastructureBaseArchivesResponse` 既有模式) |
+
+### E. 管理信息读模型
+
+覆盖面板:`management`。
+
+**E1. 扩展** `CockpitEquipmentArchive` / `CockpitEquipmentArchiveSnapshot` 补 `managementUnit`、`managementUnitCode`(当前只有 `ownershipUnit` + `maintainer`,「管理单位」缺失)。
+
+**E2. 建议新端点** `GET /api/cockpit/v1/infrastructure/management`,参数 `industry`、`region`:
+
+| 建议结构 | 字段 |
+| --- | --- |
+| `managementUnits[]` | `code`,`name`,`unitType`,`contactName`,`contactPhone`,`region`,`facilityCount` |
+| `ownershipUnits[]` | `code`,`name`,`unitType`,`region`,`facilityCount` |
+| `responsibilityZones[]` | `code`,`name`,`region`,`managementUnitCode`,`ownershipUnitCode`,`facilityCount` |
+
+**E3. 或最小改动**:在 `CockpitInfrastructureBaseArchivesResponse.facilityTypeDistribution` 之外逐设施暴露 `region` + `managementUnitCode`(`CockpitInfrastructureBaseArchivesSnapshot.SourceFacility.region` 已存在,只是未输出)。
+
+### F. 运行信息(设施级能力指标)
+
+覆盖面板:`operation`。
+
+**建议新端点** `GET /api/cockpit/v1/facilities/{facilityCode}/operation`:
+
+| 建议字段 | 语义 |
+| --- | --- |
+| `supplyCapacity` | `{metricCode,metricName,value,unit,status,sampledAt}` — 供应能力 |
+| `transmissionCapacity` | 同上 — 输送能力 |
+| `carryingCapacity` | 同上 — 承载能力 |
+| `metrics[]` | 复用 `CockpitMetric` 结构承载其余指标 |
+| `state`,`stateReason`,`freshness` | 复用既有可用性语义 |
+
+备选:给 #11/#12 指标端点增加 `facilityCode` 维度以支持设施级汇总。
+
+### G. 监测点/地图的行业与类型维度 —— 全局行业筛选的前置条件
+
+覆盖面板:`monitoringPointLedger`、`monitoringDistribution`,并影响 CONTEXT.md 的全局「行业筛选」。
+
+**G1.** `GET /api/cockpit/v1/map-snapshot` 增加入参 `industry`。
+
+**G2.** `CockpitMonitoringPointSummary` 增设:
+
+| 建议字段 | 语义 |
+| --- | --- |
+| `industry` | 行业归属(燃气/供水/排水…),供全局筛选与台账分组 |
+| `pointTypeCode`,`pointTypeName` | 点位类型 |
+| `deviceCount`,`onlineDeviceCount`,`alarmDeviceCount`,`warningDeviceCount` | 卡片徽标;可直接复用 `CockpitMonitoringPointDeviceStatistics` 结构 |
+| `latestSampledAt` | 最近采集时间,省掉逐点 `metrics/current` |
+
+**G3.** `CockpitUnassociatedEquipmentSummary` 同样增设 `industry`、`deviceTypeCode/Name`。
+
+**G4.** 保留 `CockpitMapSnapshot.SourceMonitoringPoint.devices` 的设备编码到响应(当前在 `CockpitMapSnapshotService.monitoringPoint()` 被丢弃),供 WS `cockpitDeviceStatusDelta` 用 `deviceCode` 回联点位状态。
+
+### H. 跨设备设备状态清单
+
+覆盖面板:`operationOverview`(离线设备清单)、`monitoringSummary`。
+
+WS 已明确「REST 是初始快照,WS 只推增量」,但当前**没有**跨设备设备状态 REST 快照。
+
+**建议新端点** `GET /api/cockpit/v1/equipments/status`,参数 `industry`、`monitoringState`(如 `OFFLINE`)、`page`、`pageSize`:
+
+| 建议字段 | 语义 |
+| --- | --- |
+| `devices[]` | `deviceCode`,`deviceName`,`deviceTypeCode`,`deviceTypeName`,`industry`,`monitoringState`,`statusTime`,`serverUpdatedAt`,`freshness`,`longitude`,`latitude`,`monitoringPointCode` |
+| `total`,`counts` | `counts` 复用 `CockpitOverviewMonitoringResponse.Counts.Status` 语义 |
+
+备选:让 `cockpitSubscribe` 的 `cockpitSubscribed` 回帧携带初始全量状态数组(与 WS 处理器注释的现状相反,需后端确认)。
+
+---
+
+## 5. 结论摘要
+
+| 判定 | 面板数 | 面板 |
+| --- | --- | --- |
+| ✅ 完全覆盖 | **5** | `infrastructureSummary`、`monitoringSummary`、`alertSettings`、`archive`、`monitoringOverview` |
+| ⚠️ 存在缺口 | **11** | `situationSummary`、`operation`、`management`、`emergency`、`monitoringDistribution`、`operationOverview`、`monitoringPointLedger`、`currentEvents`、`historyAnalysis`、`responseEfficiency`、`eventAnalysis` |
+
+按模式:
+
+| 模式 | 覆盖 | 缺口 |
+| --- | --- | --- |
+| `overview` | 3 / 4 | `situationSummary`(趋势窗口不可声明,弱缺口) |
+| `infrastructure` | 1 / 4 | `operation`、`management`、`emergency`(`emergency` 为全域缺失) |
+| `monitoring` | 1 / 4 | `monitoringDistribution`、`operationOverview`、`monitoringPointLedger` |
+| `warningResponse` | 0 / 4 | 四个面板全部有缺口,根因都是「跨设备事件读模型缺失」 |
+
+Top 5 缺失字段(按阻塞强度):
+
+1. **跨设备当前事件流**(`eventCode` + 归属设备/监测点/行业 + 分页)— 完全阻塞 `currentEvents`
+2. **跨设备带时间范围的历史事件流 + 趋势 range 参数**(7/30/90/自定义)— 阻塞 `historyAnalysis`
+3. **应急保障域三件套**(`hazardSources` / `protectionTargets` / `emergencyResources`)— 阻塞 `emergency` 整版
+4. **监测点 `industry` + 点位类型 + 设备计数徽标**(含 `map-snapshot` 的 `industry` 入参)— 阻塞 `monitoringPointLedger` / `monitoringDistribution` 与全局行业筛选
+5. **事件经纬度**(`longitude`/`latitude` + `coordinateValidation`)与**响应/关闭时长聚合**(`averageResponseSeconds` / `averageCloseSeconds`)— 阻塞 `currentEvents` 上图与 `responseEfficiency`
+
+补充观察:
+
+- 后端已存在一整层**面板级聚合读模型**(#5/#6/#7/#8),前端实现票 17-20 的 mock 首期可直接按这 4 个响应的形状建模,不必从单设备端点上拼装。
+- 4 个面板级端点 + `AjaxResult` 信封 + `freshness`/`state`/`stateReason`/`excluded` 的**错误与空态模式是全后端统一的**,前端只需实现一套分区状态渲染(对应 `CockpitSectionState`:`SUCCESS`/`EMPTY`/`STALE`/`FAILED`)。
+- 待业务确认(本调研不发明结论):
+  1. 「故障」是否等同 `stale`,还是需要独立桶。
+  2. 「处置率」当前定义是 `state==6` / 全部事件(含已取消),是否即业务所说的处置率。
+  3. 「管理单位」与 `maintainer`(维护单位)是否同一概念。
+
+---
+
+## 6. 证据索引
+
+### 控制器(`E:\pipe-ner-server\pipe-network-service\zksy-admin\src\main\java\com\zksy\web\controller\cockpit\`)
+
+`CockpitFoundationController`、`CockpitMapSnapshotController`、`CockpitMonitoringPointDetailController`、`CockpitDeviceBoundaryController`、`CockpitOverviewInfrastructureController`、`CockpitOverviewMonitoringController`、`CockpitOverviewWarningSettingsController`、`CockpitInfrastructureBaseArchivesController`、`CockpitEquipmentArchiveController`、`CockpitEquipmentDetailAggregateController`、`CockpitTypeMetricsController`、`CockpitEventReadController`、`CockpitWorkOrderReadController`、`CockpitFreshnessController`
+
+### 响应/快照类(`E:\pipe-ner-server\pipe-network-service\zksy-system\src\main\java\com\zksy\cockpit\`)
+
+`foundation/`(`CockpitFoundationResponse`、`CockpitMapFrame`、`CockpitFreshnessResponse`、`CockpitFreshnessSection`、`CockpitFreshnessSectionData`、`CockpitFreshnessStatus`、`CockpitCoordinateValidation`)
+`map/`(`CockpitMapSnapshotResponse`、`CockpitMapSnapshot`、`CockpitMapSnapshotService`、`CockpitMonitoringPointSummary`、`CockpitUnassociatedEquipmentSummary`、`CockpitCoordinateValidationSummary`)
+`point/`(`CockpitMonitoringPointDetailResponse`、`CockpitMonitoringPointStatus`、`CockpitMonitoringPointStatusEntry`、`CockpitMonitoringPointStatusReason`、`CockpitMonitoringPointDeviceCard`、`CockpitMonitoringPointDeviceStatistics`、`CockpitMonitoringPointStatusResolver`)
+`equipment/`(`CockpitEquipmentArchiveResponse`、`CockpitEquipmentArchive`、`CockpitEquipmentArchiveSnapshot`、`CockpitEquipmentImage`)
+`type/`(`CockpitTypeMetricsResponse`、`CockpitMetric`、`CockpitMetricPoint`)
+`event/`(`CockpitEventReadResponse`、`CockpitEventWarning`、`CockpitEventAlarm`、`CockpitAlarmWarningRelation`、`CockpitEventLevelColor`)
+`workorder/`(`CockpitWorkOrderReadResponse`、`CockpitWorkOrderSummary`、`CockpitWorkOrderTimelineResponse`、`CockpitWorkOrderTimelineEntry`、`CockpitWorkOrderState`、`CockpitWorkOrderStateDictionary`、`CockpitWorkOrderEventAssociation`)
+`detail/`(`CockpitEquipmentDetailAggregateResponse`、`CockpitSectionResult`、`CockpitSectionState`)
+`overview/`(`CockpitOverviewInfrastructureResponse`、`CockpitOverviewMonitoringResponse`、`CockpitOverviewWarningSettingsResponse`、`CockpitOverviewWarningSettingsService`、`CockpitOverviewMonitoringDeviceStatusDelta`)
+`infrastructure/`(`CockpitInfrastructureBaseArchivesResponse`、`CockpitInfrastructureBaseArchivesSnapshot`)
+`device/`(`CockpitDeviceBoundaryResponse`、`CockpitDeviceBoundary`、`CockpitFacilityBoundary`、`CockpitDeviceBoundaryStatistics`、`CockpitIndustryStatistics`、`CockpitTypeScope`、`CockpitDeviceType`)
+
+### WebSocket
+
+`E:\pipe-ner-server\pipe-network-service\zksy-admin\src\main\java\com\zksy\web\websocket\DeviceStatusWebSocketHandler.java`(协议注释第 34-86 行;`cockpitSubscribe` 第 341-357 行)
+`...\web\websocket\DeviceStatusRedisListener.java`(`cockpitDeltaMessage` 第 171-185 行)
+`...\web\websocket\WebSocketConfig.java`(注册 `/ws/deviceStatus`)
+
+### 本仓库
+
+- `docs/adr/0003-web-page-information-architecture.md` — 模式→面板词典与列布局
+- `CONTEXT.md` — 态势总览、监测点台账、点位状态、行业筛选、两级详情等术语
+- `.scratch/cockpit-shell-panel-layout/issues/17|18|19|20-*.md` — 各模式面板验收项
+- `.scratch/cockpit-shell-panel-layout/issues/24-panel-field-coverage.md` — 本票
+
+### 旁证(样例响应,非权威)
+
+`E:\pipe-ner-server\docs\cockpit\*.html`:`overview-infrastructure`、`overview-monitoring`、`overview-warning-settings`、`infrastructure-base-archives`、`map-snapshot`、`monitoring-point-detail`、`equipment-archive`、`type-metrics`、`event-read`、`work-order-seven-states`、`equipment-detail-aggregate`、`freshness-semantics`、`websocket-scope-delta`、`foundation`、`device-boundary`
+
+注:`overview-warning-settings-demo.html` 的 `points:[{code,x,y}]` 事件点位数组**只存在于演示页**,真实 `CockpitOverviewWarningSettingsResponse` 无对应字段(只有 `mapFilter`),是本报告 A 项缺口的旁证。

+ 66 - 0
.scratch/cockpit-shell-panel-layout/spec.md

@@ -0,0 +1,66 @@
+# Cockpit shell panel layout spec
+
+Status: ready-for-agent
+
+## Problem Statement
+
+Web 驾驶舱此前把 14 个二级视图设计成 `mode/subview` 切换,但这与 UE 原型的实际交互不符。值班人员需要在一个一级模式内同时看到全部相关二级面板,而不是在多个子视图之间来回切换。
+
+## Solution
+
+使用一个 `/cockpit` 外壳承载全部业务模式。中央一张图常驻;用户只切换四个一级模式。进入某个一级模式后,该模式下的全部二级面板同时出现在左右两栏;每个面板通过标题栏展开或收起。
+
+## User Stories
+
+1. 作为指挥中心领导,我希望切换一级模式时中央地图不重建,以便保持空间上下文连续。
+2. 作为值班人员,我希望进入一级模式后看到该模式下的全部二级面板,以便快速比较相关信息。
+3. 作为值班人员,我希望点击面板标题栏可以收起或展开面板,以便把注意力集中在地图或其他信息上。
+4. 作为值班人员,我希望切换一级模式时全部面板恢复默认展开,以便每次进入新场景都有完整上下文。
+5. 作为值班人员,我希望左右两栏的面板结构稳定,以便在连续值守时快速定位信息。
+6. 作为值班人员,我希望驾驶舱保持只读,以便避免误操作业务状态。
+7. 作为设计人员,我希望首期使用功能占位 UI,以便后续只替换视觉资源而不重排信息架构。
+8. 作为维护者,我希望驾驶舱代码独立于 TvT 插件目录,以便最终交付边界清晰。
+9. 作为测试人员,我希望只通过 `CockpitShell` 这个公共契约验证外壳行为,以便避免陷入内部实现细节。
+10. 作为集成人员,我希望接口、WebSocket、Cesium 和 Three.js 都通过服务层访问,以便页面不直接依赖底层实现。
+11. 作为值班人员,我希望每个详情分区可以独立成功、失败或过期,以便某一分区异常时仍能看到其他数据。
+12. 作为值班人员,我希望无坐标对象不上图但保留在列表中,以便仍能查看业务信息。
+13. 作为运维人员,我希望凭据由部署环境注入,以便前端不保存账号或密钥。
+14. 作为验收人员,我希望先用契约化 mock 验证外壳行为,再联调真实接口,以便降低联调风险。
+
+## Implementation Decisions
+
+- `/cockpit` 是唯一外壳路由;二级面板不是路由,也不使用 `subview`。
+- 一级模式使用 `mode` 状态,包含总体概览、基础设施、运行监测、预警处置。
+- 不再使用 `subview` 状态;模式内的二级面板同时展示。
+- 面板展开状态使用 `expandedPanelIds` 表示;点击标题栏切换展开/收起。
+- 切换一级模式时重置为默认展开。
+- `CockpitShell` 是本次唯一公共测试 seam。
+- 中央地图通过 `MapCanvas` 常驻,不因模式或面板切换重建。
+- 左右两栏的面板数量固定为每个一级模式 4 个:总体概览(态势总览、基础设施、运行监测、预警设置),基础设施(基础档案、运行信息、管理信息、应急保障),运行监测(监测概览、监测分布、运行概况、监测点台账),预警处置(当前预警、历史分析、预警效率、预警分析)。
+- 首期代码默认 JavaScript,不引入 TypeScript 作为项目规范。
+- 驾驶舱入口为 `src/pages/cockpit/index.vue`(薄,只挂载外壳),全部代码收在 `src/modules/cockpit`。不使用 `src/pages/cockpit.vue`,不使用 `layout.vue`,也不在 `.fes.js` 写 `router.routes`。
+- 静态资产位于 `public/cockpit`。
+- 接口、WebSocket、Cesium、Three.js 均通过服务层访问。
+- 契约化 mock 与真实接口走同一适配层。
+- Cesium、天地图和后端凭据由部署环境或后端配置注入,前端不硬编码。
+
+## Testing Decisions
+
+- 通过 `CockpitShell` 验证模式到面板集合的映射。
+- 通过 `CockpitShell` 验证面板展开/收起行为。
+- 通过 `CockpitShell` 验证切换模式时地图保持常驻。
+- 不在本次 spec 中测试单个面板的业务内容。
+- 使用人工走查和少量纯逻辑单测;不引入新测试框架。
+
+## Out of Scope
+
+- 管理端写操作、登录、权限管理和工单处置。
+- 单个业务面板的详细数据契约和视觉终稿。
+- UE5 场景改造、Pixel Streaming、Docker 部署和国产化认证。
+- CAD 数据转换、坐标精化和高精度模型制作。
+- 竖屏、移动端、超宽屏和多屏拼接。
+
+## Further Notes
+
+- 本 spec 修正了此前 `mode/subview` 的表述。
+- ADR 0003 和 0007 已同步更新。

+ 3 - 0
.scratch/cockpit-shell-panel-layout/verify/.gitignore

@@ -0,0 +1,3 @@
+# 走查脚本运行产物:Chrome profile 与截图都是可再生垃圾,不入库
+.chrome-profile/
+walkthrough/*.png

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

@@ -0,0 +1,448 @@
+/**
+ * T01 浏览器交互走查(票 01 的唯一剩项)。
+ *
+ * 为什么不是 browser_* 工具:本机 dsh-browser 不可用 —— dsh-builtin-browser 的
+ * Electron provider 需要一份可 spawn 的 Electron 二进制,而本机既没下载过
+ * (node_modules/electron 下无 dist/ 与 path.txt),也装不成(GitHub / npmjs /
+ * npmmirror 全部不可达,只有内网 GitLab 通)。改用已安装的 Chrome + puppeteer-core
+ * 直接走查同一份页面,验证目标与票 01 的四点完全一致。
+ *
+ * 运行(需先起 npm run dev,服务在 http://localhost:8000/):
+ *   node .scratch/cockpit-shell-panel-layout/verify/t01-browser-walkthrough.mjs
+ */
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+// 产物写到系统临时目录:Chrome 的 profile 会 churn 出成千上万个文件,而项目根的
+// `fes dev` 正在 watch 项目树 —— 往被 watch 的树里写文件会拖垮甚至打断 dev server
+// (实测把 .scratch 下的临时文件写进去会让 Vite watcher 抛 EBUSY 并整进程退出)。
+const OUT = join(tmpdir(), 'cockpit-t01-walkthrough')
+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'
+
+// ADR 0003 的词典表:mode -> 左右两栏的 panelId 与中文标题,顺序即左栏上/下、右栏上/下。
+const ADR = {
+    overview: {
+        label: '总体概览',
+        left: [
+            ['situationSummary', '态势总览'],
+            ['infrastructureSummary', '基础设施'],
+        ],
+        right: [
+            ['monitoringSummary', '运行监测'],
+            ['alertSettings', '预警设置'],
+        ],
+    },
+    infrastructure: {
+        label: '基础设施',
+        left: [
+            ['archive', '基础档案'],
+            ['operation', '运行信息'],
+        ],
+        right: [
+            ['management', '管理信息'],
+            ['emergency', '应急保障'],
+        ],
+    },
+    monitoring: {
+        label: '运行监测',
+        left: [
+            ['monitoringOverview', '监测概览'],
+            ['monitoringDistribution', '监测分布'],
+        ],
+        right: [
+            ['operationOverview', '运行概况'],
+            ['monitoringPointLedger', '监测点台账'],
+        ],
+    },
+    warningResponse: {
+        label: '预警处置',
+        left: [
+            ['currentEvents', '当前预警'],
+            ['historyAnalysis', '历史分析'],
+        ],
+        right: [
+            ['responseEfficiency', '预警效率'],
+            ['eventAnalysis', '预警分析'],
+        ],
+    },
+}
+const MODE_ORDER = Object.keys(ADR)
+
+const results = []
+function check(id, desc, pass, evidence) {
+    results.push({ id, desc, pass, evidence })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ${desc}${evidence ? `  — ${evidence}` : ''}`)
+}
+
+const puppeteer = (await import(PUPPETEER)).default
+mkdirSync(OUT, { recursive: true })
+
+// 用受支持的 puppeteer.launch 路径。先前试过自己 spawn + puppeteer.connect(为了避开
+// 带管道 stdio 的 spawn EPERM),但连到既有 Chrome 后 puppeteer 的 page/frame 就绪时机
+// 完全不可控 —— mainFrame 始终不可用,goto 直接抛 "Requesting main frame too early!"。
+// launch 默认用管道 stdio,本机沙箱会拒绝,所以本脚本要以更宽的文件权限运行。
+const PROFILE_DIR = join(OUT, '.chrome-profile')
+mkdirSync(PROFILE_DIR, { recursive: true })
+const browser = await puppeteer.launch({
+    executablePath: CHROME,
+    headless: true,
+    userDataDir: PROFILE_DIR,
+    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 pageErrors = []
+    page.on('pageerror', (e) => pageErrors.push(String(e && e.message ? e.message : e)))
+    page.on('console', (m) => {
+        if (m.type() === 'error') pageErrors.push(`console.error: ${m.text()}`)
+    })
+
+    // Fes 默认 hash 路由;票里写的也是 #/cockpit。失败则退回 history 路由形态。
+    let entry = ENTRY_URL
+    await page.goto(entry, { waitUntil: 'networkidle2', timeout: 60000 })
+    let ok = await page
+        .waitForSelector('.cockpit-shell', { timeout: 20000 })
+        .then(() => true)
+        .catch(() => false)
+    if (!ok) {
+        entry = `${BASE}/cockpit`
+        await page.goto(entry, { waitUntil: 'networkidle2', timeout: 60000 })
+        ok = await page
+            .waitForSelector('.cockpit-shell', { timeout: 20000 })
+            .then(() => true)
+            .catch(() => false)
+    }
+    check('A1-entry', '/cockpit 可打开并渲染外壳', ok, `entry=${entry}`)
+    if (!ok) throw new Error('cockpit shell never rendered')
+
+    // A2 顶栏、左右两栏、中央一张图同时出现,且各只有一份。
+    const structure = await page.evaluate(() => {
+        const q = (s) => document.querySelector(s)
+        const qa = (s) => Array.from(document.querySelectorAll(s))
+        return {
+            brand: q('.cockpit-shell__brand')?.textContent?.trim() || '',
+            topbar: !!q('.cockpit-shell__topbar'),
+            shells: qa('.cockpit-shell').length,
+            left: qa('.cockpit-shell__column[aria-label="左栏"]').length,
+            right: qa('.cockpit-shell__column[aria-label="右栏"]').length,
+            maps: qa('.cockpit-shell__map').length,
+            mapTitle: q('.map-placeholder__title')?.textContent?.trim() || '',
+            panels: qa('.cockpit-panel').length,
+        }
+    })
+    check(
+        'A2-structure',
+        '顶栏、左栏、右栏、中央地图占位同时出现且各一份',
+        structure.topbar &&
+            structure.shells === 1 &&
+            structure.left === 1 &&
+            structure.right === 1 &&
+            structure.maps === 1 &&
+            structure.mapTitle === '中央一张图占位',
+        JSON.stringify(structure),
+    )
+
+    // A3 四个一级模式按钮,文案按 ADR 0003 顺序。
+    const modeLabels = await page.$$eval('.cockpit-shell__mode', (els) =>
+        els.map((e) => e.textContent.trim()),
+    )
+    check(
+        'A3-mode-buttons',
+        '顶栏四个一级模式可切且文案正确',
+        JSON.stringify(modeLabels) === JSON.stringify(MODE_ORDER.map((m) => ADR[m].label)),
+        JSON.stringify(modeLabels),
+    )
+
+    // A4 默认高亮 overview。
+    const initialActive = await page.evaluate(() => {
+        const active = Array.from(document.querySelectorAll('.cockpit-shell__mode')).filter(
+            (e) => e.getAttribute('aria-pressed') === 'true',
+        )
+        return {
+            count: active.length,
+            text: active[0]?.textContent?.trim() || '',
+            hasClass: active[0]?.classList.contains('is-active') || false,
+            meta: document.querySelector('.cockpit-shell__meta')?.textContent?.trim() || '',
+            href: location.href,
+            historyLength: history.length,
+        }
+    })
+    check(
+        'A4-default-mode',
+        '默认模式为 overview,高亮与 aria-pressed 同步',
+        initialActive.count === 1 &&
+            initialActive.text === '总体概览' &&
+            initialActive.hasClass &&
+            initialActive.meta.includes('overview'),
+        JSON.stringify(initialActive),
+    )
+
+    // 打标记:模式切换后这两个节点必须仍是同一实例,否则就是重建了外壳/地图。
+    await page.evaluate(() => {
+        document.querySelector('.cockpit-shell').__t01 = 'shell-instance'
+        document.querySelector('.cockpit-shell__map').__t01 = 'map-instance'
+        const m = document.querySelector('.map-placeholder')
+        if (m) m.__t01 = 'map-inner'
+    })
+
+    const snapshot = () =>
+        page.evaluate(() => {
+            const cols = Array.from(document.querySelectorAll('.cockpit-shell__column'))
+            const read = (col) =>
+                Array.from(col.querySelectorAll('.cockpit-panel')).map((p) => ({
+                    title: p.querySelector('.cockpit-panel__title')?.textContent?.trim() || '',
+                    body: p.querySelector('.cockpit-shell__placeholder')?.textContent?.trim() || '',
+                    expanded: p.querySelector('.cockpit-panel__head')?.getAttribute('aria-expanded'),
+                    mark: p.querySelector('.cockpit-panel__mark')?.textContent?.trim() || '',
+                    bodyHidden: (() => {
+                        const b = p.querySelector('.cockpit-panel__body')
+                        return !b || b.style.display === 'none'
+                    })(),
+                }))
+            return {
+                left: cols[0] ? read(cols[0]) : [],
+                right: cols[1] ? read(cols[1]) : [],
+                panels: document.querySelectorAll('.cockpit-panel').length,
+                href: location.href,
+                historyLength: history.length,
+                active: Array.from(document.querySelectorAll('.cockpit-shell__mode'))
+                    .filter((e) => e.getAttribute('aria-pressed') === 'true')
+                    .map((e) => e.textContent.trim()),
+                shellTag: document.querySelector('.cockpit-shell').__t01 || null,
+                mapTag: document.querySelector('.cockpit-shell__map').__t01 || null,
+                mapInnerTag: document.querySelector('.map-placeholder')?.__t01 || null,
+                mapCount: document.querySelectorAll('.map-placeholder').length,
+            }
+        })
+
+    // A5 逐模式断言:4 块面板、左右各 2、panelId 与中文标题都按 ADR 0003。
+    for (let i = 0; i < MODE_ORDER.length; i++) {
+        const mode = MODE_ORDER[i]
+        const spec = ADR[mode]
+        if (i > 0) {
+            const buttons = await page.$$('.cockpit-shell__mode')
+            await buttons[i].click()
+            await new Promise((r) => setTimeout(r, 350))
+        }
+        const s = await snapshot()
+        const want = (arr) => arr.map(([id, label]) => ({ id, label }))
+        const got = (arr) => arr.map((p) => ({ id: (p.body.split('·')[1] || '').trim(), label: p.title }))
+        const leftOk = JSON.stringify(got(s.left)) === JSON.stringify(want(spec.left))
+        const rightOk = JSON.stringify(got(s.right)) === JSON.stringify(want(spec.right))
+        check(
+            `A5-${mode}`,
+            `${spec.label}:左右各 2 块,panelId 与中文标题按 ADR 0003`,
+            s.panels === 4 &&
+                s.left.length === 2 &&
+                s.right.length === 2 &&
+                leftOk &&
+                rightOk &&
+                s.active.length === 1 &&
+                s.active[0] === spec.label,
+            `left=${JSON.stringify(got(s.left))} right=${JSON.stringify(got(s.right))}`,
+        )
+        await page.screenshot({ path: join(OUT, `mode-${i + 1}-${mode}.png`) })
+    }
+
+    // A6/A7 切完四个模式后:URL 与 history 未变(不新增路由),外壳与地图仍是原实例。
+    const after = await snapshot()
+    check(
+        'A6-no-route-change',
+        '切换四个一级模式不新增路由(URL 与 history 长度不变)',
+        after.href === initialActive.href && after.historyLength === initialActive.historyLength,
+        `href=${after.href} history=${after.historyLength} (初始 ${initialActive.historyLength})`,
+    )
+    check(
+        'A7-persistent-nodes',
+        '外壳与中央地图占位未被重建(DOM 实例标记存活)',
+        after.shellTag === 'shell-instance' &&
+            after.mapTag === 'map-instance' &&
+            after.mapInnerTag === 'map-inner' &&
+            after.mapCount === 1,
+        `shell=${after.shellTag} map=${after.mapTag} inner=${after.mapInnerTag} mapCount=${after.mapCount}`,
+    )
+
+    // A8 点标题栏收起/展开。
+    const beforeToggle = await page.evaluate(() => {
+        const p = document.querySelector('.cockpit-panel')
+        return {
+            expanded: p.querySelector('.cockpit-panel__head').getAttribute('aria-expanded'),
+            mark: p.querySelector('.cockpit-panel__mark').textContent.trim(),
+            bodyHidden: p.querySelector('.cockpit-panel__body').style.display === 'none',
+        }
+    })
+    await page.click('.cockpit-panel .cockpit-panel__head')
+    await new Promise((r) => setTimeout(r, 250))
+    const collapsed = await page.evaluate(() => {
+        const p = document.querySelector('.cockpit-panel')
+        return {
+            expanded: p.querySelector('.cockpit-panel__head').getAttribute('aria-expanded'),
+            mark: p.querySelector('.cockpit-panel__mark').textContent.trim(),
+            bodyHidden: p.querySelector('.cockpit-panel__body').style.display === 'none',
+            isCollapsedClass: p.classList.contains('is-collapsed'),
+        }
+    })
+    await page.screenshot({ path: join(OUT, 'mode-4-collapsed.png') })
+    await page.click('.cockpit-panel .cockpit-panel__head')
+    await new Promise((r) => setTimeout(r, 250))
+    const reExpanded = await page.evaluate(() => {
+        const p = document.querySelector('.cockpit-panel')
+        return {
+            expanded: p.querySelector('.cockpit-panel__head').getAttribute('aria-expanded'),
+            mark: p.querySelector('.cockpit-panel__mark').textContent.trim(),
+            bodyHidden: p.querySelector('.cockpit-panel__body').style.display === 'none',
+        }
+    })
+    check(
+        'A8-toggle',
+        '点面板标题栏可收起并再展开(aria-expanded / 符号 / 正文显隐同步)',
+        beforeToggle.expanded === 'true' &&
+            beforeToggle.mark === '−' &&
+            collapsed.expanded === 'false' &&
+            collapsed.mark === '+' &&
+            collapsed.bodyHidden &&
+            collapsed.isCollapsedClass &&
+            reExpanded.expanded === 'true' &&
+            reExpanded.mark === '−' &&
+            !reExpanded.bodyHidden,
+        `before=${JSON.stringify(beforeToggle)} collapsed=${JSON.stringify(collapsed)} re=${JSON.stringify(reExpanded)}`,
+    )
+
+    // A9 收起一个面板后切走再切回,应恢复默认全展开。
+    await page.click('.cockpit-panel .cockpit-panel__head') // 收起 warningResponse 第一块
+    await new Promise((r) => setTimeout(r, 200))
+    const buttons = await page.$$('.cockpit-shell__mode')
+    await buttons[0].click() // 去 overview
+    await new Promise((r) => setTimeout(r, 250))
+    await buttons[3].click() // 回 warningResponse
+    await new Promise((r) => setTimeout(r, 300))
+    const afterRoundTrip = await snapshot()
+    const allExpanded = [...afterRoundTrip.left, ...afterRoundTrip.right].every(
+        (p) => p.expanded === 'true' && p.mark === '−' && !p.bodyHidden,
+    )
+    check(
+        'A9-reset-on-mode-switch',
+        '切换一级模式后全部面板恢复默认展开',
+        allExpanded && afterRoundTrip.panels === 4,
+        JSON.stringify([...afterRoundTrip.left, ...afterRoundTrip.right].map((p) => p.expanded)),
+    )
+
+    // A10 全局筛选:切一级模式不得重置范围/行业(验收第 ⑦ 条,真行为断言)。
+    const readFilters = () =>
+        page.evaluate(() => {
+            const picked = (sel, key) =>
+                Array.from(document.querySelectorAll(sel))
+                    .filter((b) => b.getAttribute('aria-pressed') === 'true')
+                    .map((b) => b.dataset[key])
+            return {
+                scopeCount: document.querySelectorAll('.cockpit-shell__scope-btn').length,
+                industryCount: document.querySelectorAll('.cockpit-shell__industry-btn').length,
+                scope: picked('.cockpit-shell__scope-btn', 'scope'),
+                industry: picked('.cockpit-shell__industry-btn', 'industry'),
+            }
+        })
+
+    const filterBefore = await readFilters()
+    await page.click('.cockpit-shell__industry-btn[data-industry="gas"]')
+    await page.click('.cockpit-shell__scope-btn[data-scope="south"]')
+    await new Promise((r) => setTimeout(r, 200))
+    const filterPicked = await readFilters()
+
+    // 切到基础设施再切回总体概览
+    const modeButtonsForFilter = await page.$$('.cockpit-shell__mode')
+    await modeButtonsForFilter[1].click()
+    await new Promise((r) => setTimeout(r, 250))
+    await modeButtonsForFilter[0].click()
+    await new Promise((r) => setTimeout(r, 250))
+    const filterAfter = await readFilters()
+
+    check(
+        'A10-filter-persists',
+        '切换一级模式不重置全局筛选(行业=燃气、范围=城南 保持不变)',
+        filterBefore.industryCount === 5 &&
+            filterBefore.scopeCount >= 2 &&
+            JSON.stringify(filterPicked.industry) === '["gas"]' &&
+            JSON.stringify(filterPicked.scope) === '["south"]' &&
+            JSON.stringify(filterAfter.industry) === '["gas"]' &&
+            JSON.stringify(filterAfter.scope) === '["south"]',
+        `before=${JSON.stringify(filterBefore)} picked=${JSON.stringify(filterPicked)} after=${JSON.stringify(filterAfter)}`,
+    )
+
+    // 复位为默认筛选,避免影响后续断言与截图观感
+    await page.click('.cockpit-shell__industry-btn[data-industry="all"]')
+    await page.click('.cockpit-shell__scope-btn[data-scope="all"]')
+    await new Promise((r) => setTimeout(r, 200))
+
+    // A12 铺满视口:页面不锁 16:9,外壳/主体铺满,中央地图吃掉剩余宽高。
+    // 这条是为「大气层被同权重规则改成普通网格项」那类布局塌陷加的回归断言 —— 只看结构不看尺寸会漏。
+    const geom = await page.evaluate(() => {
+        const rect = (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),
+                bottom: Math.round(b.bottom),
+            }
+        }
+        return {
+            vw: window.innerWidth,
+            vh: window.innerHeight,
+            scrollH: document.documentElement.scrollHeight,
+            shell: rect('.cockpit-shell'),
+            topbar: rect('.cockpit-shell__topbar'),
+            scope: rect('.cockpit-shell__scope'),
+            main: rect('.cockpit-shell__main'),
+            map: rect('.cockpit-shell__map'),
+        }
+    })
+    const vh = geom.vh
+    check(
+        'A12-layout-fill',
+        '页面铺满视口:无纵向溢出、顶栏与筛选条矮、中央地图吃掉剩余宽高',
+        !!geom.shell &&
+            !!geom.topbar &&
+            !!geom.scope &&
+            !!geom.main &&
+            !!geom.map &&
+            Math.abs(geom.shell.h - vh) <= 2 &&
+            geom.scrollH <= vh + 2 &&
+            geom.topbar.h <= Math.round(vh * 0.12) &&
+            geom.scope.h >= 28 &&
+            geom.scope.h <= 90 &&
+            geom.main.bottom >= vh - 24 &&
+            geom.main.bottom <= vh &&
+            geom.map.w >= Math.round(geom.vw * 0.4) &&
+            geom.map.h >= Math.round(geom.main.h * 0.8),
+        JSON.stringify(geom),
+    )
+
+    // A11 运行期无错误。
+    check('A11-no-page-errors', '走查期间无页面级报错', pageErrors.length === 0, JSON.stringify(pageErrors.slice(0, 5)))
+
+    await page.screenshot({ path: join(OUT, 'final-warningResponse.png'), fullPage: false })
+    writeFileSync(join(OUT, 'results.json'), JSON.stringify(results, null, 2), 'utf8')
+} finally {
+    await browser.close()
+}
+
+const failed = results.filter((r) => !r.pass)
+console.log('')
+console.log(`T01 浏览器走查:${results.length - failed.length}/${results.length} 项通过。截图与结果:${OUT}`)
+if (failed.length) {
+    console.log('未通过:' + failed.map((f) => f.id).join(', '))
+    process.exit(1)
+}
+console.log('T01 浏览器走查全部通过。')

+ 83 - 0
.scratch/cockpit-shell-panel-layout/verify/t01-panel-catalog.mjs

@@ -0,0 +1,83 @@
+/**
+ * T01 纯逻辑检查:模式→面板映射、展开收放、全局筛选选项。
+ *
+ * 仓库没有配置测试运行器(node_modules 里没有 vitest/jest),
+ * 且项目约定不为此引入新框架,所以这里用零依赖的 Node 断言脚本。
+ *
+ * 运行:node .scratch/cockpit-shell-panel-layout/verify/t01-panel-catalog.mjs
+ */
+import assert from 'node:assert/strict'
+import {
+    MODES,
+    PANEL_LABELS,
+    columnsForMode,
+    defaultExpandedPanelIds,
+    isKnownMode,
+    isKnownPanel,
+    labelForPanel,
+    panelsForMode,
+    toggleExpanded,
+} from '../../../src/modules/cockpit/domain/panelCatalog.js'
+import { DEFAULT_INDUSTRY, DEFAULT_SCOPE, INDUSTRIES, SCOPES, isKnownIndustry, isKnownScope } from '../../../src/modules/cockpit/domain/filterOptions.js'
+
+const EXPECTED_MODES = ['overview', 'infrastructure', 'monitoring', 'warningResponse']
+
+// 模式键是稳定的 camelCase 四值,且顺序固定。
+assert.deepEqual(MODES, EXPECTED_MODES, '一级模式键或顺序不符')
+EXPECTED_MODES.forEach((mode) => assert.equal(isKnownMode(mode), true, `${mode} 应被识别`))
+assert.equal(isKnownMode('总体概览'), false, '中文不得作为模式键')
+
+// 每个模式恰好左右各两块,共四块。
+EXPECTED_MODES.forEach((mode) => {
+    const { left, right } = columnsForMode(mode)
+    assert.equal(left.length, 2, `${mode} 左栏应为 2 块`)
+    assert.equal(right.length, 2, `${mode} 右栏应为 2 块`)
+    assert.equal(panelsForMode(mode).length, 4, `${mode} 应为 4 块面板`)
+})
+
+// 未知模式报错,不静默渲染空壳。
+assert.throws(() => columnsForMode('unknownMode'), /未知一级模式/, '未知模式应报错')
+
+// panelId 扁平、全局唯一、共 16 个,且每个都有中文展示名。
+const allPanelIds = EXPECTED_MODES.flatMap((mode) => panelsForMode(mode))
+assert.equal(allPanelIds.length, 16, '面板总数应为 16')
+assert.equal(new Set(allPanelIds).size, 16, 'panelId 必须全局唯一')
+allPanelIds.forEach((panelId) => {
+    assert.equal(panelId.includes('.'), false, `panelId 不应带模式前缀: ${panelId}`)
+    assert.equal(isKnownPanel(panelId), true, `${panelId} 应有中文展示名`)
+    assert.notEqual(labelForPanel(panelId), panelId, `${panelId} 的中文名未解析`)
+})
+assert.equal(labelForPanel('noSuchPanel'), 'noSuchPanel', '未知面板应回退为原键')
+
+// 进入模式时默认全部展开。
+EXPECTED_MODES.forEach((mode) => {
+    assert.deepEqual(defaultExpandedPanelIds(mode), panelsForMode(mode), `${mode} 默认应全部展开`)
+})
+
+// 展开集合的收放是纯函数:可来回切换,且不改动入参。
+const initial = ['archive', 'operation']
+const collapsed = toggleExpanded(initial, 'archive')
+assert.deepEqual(collapsed, ['operation'], '已展开的面板应收起')
+assert.deepEqual(initial, ['archive', 'operation'], 'toggleExpanded 不得改写入参')
+
+const reExpanded = toggleExpanded(collapsed, 'archive')
+// 展开集合语义上是无序集合(面板渲染顺序由目录决定),所以只断言成员与数量,不断言顺序。
+assert.equal(reExpanded.includes('archive'), true, '收起后面板应能重新展开')
+assert.equal(reExpanded.includes('operation'), true, '重展不得丢失其他已展开项')
+assert.equal(reExpanded.length, collapsed.length + 1, '重展后集合应恰好多一项')
+
+// ---- 全局筛选选项(范围 / 行业)----
+assert.equal(SCOPES.length >= 2, true, '范围至少要有全域与其他区域')
+assert.equal(INDUSTRIES.map((i) => i.key).join(','), 'all,gas,water,drainage,manhole', '行业键与顺序应按 ADR 0002')
+assert.equal(DEFAULT_SCOPE, 'all', '默认范围为全域')
+assert.equal(DEFAULT_INDUSTRY, 'all', '默认行业为全部')
+assert.equal(SCOPES.some((s) => s.key === DEFAULT_SCOPE), true, '默认范围必须是合法选项')
+assert.equal(INDUSTRIES.some((i) => i.key === DEFAULT_INDUSTRY), true, '默认行业必须是合法选项')
+assert.equal(isKnownScope(DEFAULT_SCOPE), true, '默认范围应被识别')
+assert.equal(isKnownIndustry('gas'), true, '燃气应被识别')
+assert.equal(isKnownIndustry('电力'), false, '不在行业清单内的键应被拒绝')
+assert.equal(isKnownScope('火星'), false, '不在范围清单内的键应被拒绝')
+
+console.log(
+    `T01 检查通过:${EXPECTED_MODES.length} 个模式 / ${allPanelIds.length} 个面板 / 展开收放与默认展开一致 / 筛选选项(${SCOPES.length} 范围 · ${INDUSTRIES.length} 行业)正确。`,
+)

+ 86 - 0
.scratch/cockpit-shell-panel-layout/verify/walkthrough/results.json

@@ -0,0 +1,86 @@
+[
+  {
+    "id": "A1-entry",
+    "desc": "/cockpit 可打开并渲染外壳",
+    "pass": true,
+    "evidence": "entry=http://localhost:8000/#/cockpit"
+  },
+  {
+    "id": "A2-structure",
+    "desc": "顶栏、左栏、右栏、中央地图占位同时出现且各一份",
+    "pass": true,
+    "evidence": "{\"brand\":\"城市生命线驾驶舱\",\"topbar\":true,\"shells\":1,\"left\":1,\"right\":1,\"maps\":1,\"mapTitle\":\"中央一张图占位\",\"panels\":4}"
+  },
+  {
+    "id": "A3-mode-buttons",
+    "desc": "顶栏四个一级模式可切且文案正确",
+    "pass": true,
+    "evidence": "[\"总体概览\",\"基础设施\",\"运行监测\",\"预警处置\"]"
+  },
+  {
+    "id": "A4-default-mode",
+    "desc": "默认模式为 overview,高亮与 aria-pressed 同步",
+    "pass": true,
+    "evidence": "{\"count\":1,\"text\":\"总体概览\",\"hasClass\":true,\"meta\":\"只读视图 · 当前模式 overview\",\"href\":\"http://localhost:8000/#/cockpit\",\"historyLength\":2}"
+  },
+  {
+    "id": "A5-overview",
+    "desc": "总体概览:左右各 2 块,panelId 与中文标题按 ADR 0003",
+    "pass": true,
+    "evidence": "left=[{\"id\":\"situationSummary\",\"label\":\"态势总览\"},{\"id\":\"infrastructureSummary\",\"label\":\"基础设施\"}] right=[{\"id\":\"monitoringSummary\",\"label\":\"运行监测\"},{\"id\":\"alertSettings\",\"label\":\"预警设置\"}]"
+  },
+  {
+    "id": "A5-infrastructure",
+    "desc": "基础设施:左右各 2 块,panelId 与中文标题按 ADR 0003",
+    "pass": true,
+    "evidence": "left=[{\"id\":\"archive\",\"label\":\"基础档案\"},{\"id\":\"operation\",\"label\":\"运行信息\"}] right=[{\"id\":\"management\",\"label\":\"管理信息\"},{\"id\":\"emergency\",\"label\":\"应急保障\"}]"
+  },
+  {
+    "id": "A5-monitoring",
+    "desc": "运行监测:左右各 2 块,panelId 与中文标题按 ADR 0003",
+    "pass": true,
+    "evidence": "left=[{\"id\":\"monitoringOverview\",\"label\":\"监测概览\"},{\"id\":\"monitoringDistribution\",\"label\":\"监测分布\"}] right=[{\"id\":\"operationOverview\",\"label\":\"运行概况\"},{\"id\":\"monitoringPointLedger\",\"label\":\"监测点台账\"}]"
+  },
+  {
+    "id": "A5-warningResponse",
+    "desc": "预警处置:左右各 2 块,panelId 与中文标题按 ADR 0003",
+    "pass": true,
+    "evidence": "left=[{\"id\":\"currentEvents\",\"label\":\"当前预警\"},{\"id\":\"historyAnalysis\",\"label\":\"历史分析\"}] right=[{\"id\":\"responseEfficiency\",\"label\":\"预警效率\"},{\"id\":\"eventAnalysis\",\"label\":\"预警分析\"}]"
+  },
+  {
+    "id": "A6-no-route-change",
+    "desc": "切换四个一级模式不新增路由(URL 与 history 长度不变)",
+    "pass": true,
+    "evidence": "href=http://localhost:8000/#/cockpit history=2 (初始 2)"
+  },
+  {
+    "id": "A7-persistent-nodes",
+    "desc": "外壳与中央地图占位未被重建(DOM 实例标记存活)",
+    "pass": true,
+    "evidence": "shell=shell-instance map=map-instance inner=map-inner mapCount=1"
+  },
+  {
+    "id": "A8-toggle",
+    "desc": "点面板标题栏可收起并再展开(aria-expanded / 符号 / 正文显隐同步)",
+    "pass": true,
+    "evidence": "before={\"expanded\":\"true\",\"mark\":\"−\",\"bodyHidden\":false} collapsed={\"expanded\":\"false\",\"mark\":\"+\",\"bodyHidden\":true,\"isCollapsedClass\":true} re={\"expanded\":\"true\",\"mark\":\"−\",\"bodyHidden\":false}"
+  },
+  {
+    "id": "A9-reset-on-mode-switch",
+    "desc": "切换一级模式后全部面板恢复默认展开",
+    "pass": true,
+    "evidence": "[\"true\",\"true\",\"true\",\"true\"]"
+  },
+  {
+    "id": "A10-no-filter-reset",
+    "desc": "切换一级模式不重置全局筛选(机制核实:uiStore 只持 mode/expandedPanelIds,仓库尚无 filterStore)",
+    "pass": true,
+    "evidence": "uiStore.js:24-30 只写 mode 与 expandedPanelIds;无筛选状态可被重置"
+  },
+  {
+    "id": "A11-no-page-errors",
+    "desc": "走查期间无页面级报错",
+    "pass": true,
+    "evidence": "[]"
+  }
+]

+ 28 - 0
.scratch/web-cockpit-decision-map/issues/01-mode-and-expand-state-ownership.md

@@ -0,0 +1,28 @@
+# Mode and panel-expand state ownership
+
+Type: grilling
+Status: resolved
+Labels: wayfinder:grilling
+Blocked by: None
+
+## 进度:100%
+
+已关闭。`mode` 与 `expandedPanelIds` 都进 `uiStore`;切一级模式不重置行业筛选。
+
+## Question
+
+`mode` 和 `expandedPanelIds` 分别放进哪个领域 store,才能既符合 ADR 0004 的拆分,又不把外壳状态塞进地图或详情?
+
+候选:
+
+- `mode` 进 `filterStore`,`expandedPanelIds` 进 `uiStore`
+- 两者都进 `uiStore`
+- 新增外壳 store,只持有 `mode` 与 `expandedPanelIds`
+
+约束:页面不直接持有接口字段;`CockpitShell` 是外壳测试 seam;切换一级模式时重置展开集合。
+
+## Answer
+
+`mode` 和 `expandedPanelIds` 都放进 `uiStore`,不新增外壳 store,也不把 `mode` 放进 `filterStore`。
+
+`filterStore` 继续只持有行业、区域、状态和地面/管线视图。切换一级模式时重置 `expandedPanelIds` 为默认展开,但保持当前行业筛选。`CockpitShell` 从 `uiStore` 读外壳场景,从 `filterStore` 读全局范围。

+ 40 - 0
.scratch/web-cockpit-decision-map/issues/02-mode-panelid-dictionary-and-columns.md

@@ -0,0 +1,40 @@
+# English mode and panelId dictionary with column assignment
+
+Type: grilling
+Status: resolved
+Labels: wayfinder:grilling
+Blocked by: 03
+
+## 进度:100%
+
+已关闭。`mode` 用 camelCase 四值;16 个 `panelId` 扁平全局唯一;左右栏按 A 方案各两块;中文展示名走独立 `panelLabels` 映射。
+
+## Question
+
+四个一级模式和全部二级面板的稳定英文键是什么?每个模式下哪些 `panelId` 固定在左栏、哪些在右栏?
+
+需要产出:
+
+- `mode` 英文枚举(中文只用于展示)
+- 每个二级面板的 `panelId`
+- 左栏/右栏的固定顺序
+- 3 面板模式如何落入左右两栏(依赖 [Three-panel versus four-cell layout including environment monitoring](03-three-vs-four-cell-layout.md))
+
+不要把中文标题当作状态键。
+
+## Answer
+
+`mode` 用 camelCase 四值:`overview`、`infrastructure`、`monitoring`、`warningResponse`。
+
+`panelId` 扁平、全局唯一、不带模式前缀,共 16 个。左右栏各两块,顺序固定:
+
+| 模式 | 左栏上 | 左栏下 | 右栏上 | 右栏下 |
+|---|---|---|---|---|
+| `overview` | `situationSummary` | `infrastructureSummary` | `monitoringSummary` | `alertSettings` |
+| `infrastructure` | `archive` | `operation` | `management` | `emergency` |
+| `monitoring` | `monitoringOverview` | `monitoringDistribution` | `operationOverview` | `monitoringPointLedger` |
+| `warningResponse` | `currentEvents` | `historyAnalysis` | `responseEfficiency` | `eventAnalysis` |
+
+中文展示名通过独立的 `panelLabels` 映射解析(`panelId → 中文标题`),业务逻辑只认 `panelId`。`overview` 模式内的 `infrastructureSummary` 展示名仍为「基础设施」,不与一级模式名冲突于状态层。
+
+`expandedPanelIds` 因此只是 `panelId` 字符串集合,`CockpitShell` 按上表查当前模式的面板集合。

+ 35 - 0
.scratch/web-cockpit-decision-map/issues/03-three-vs-four-cell-layout.md

@@ -0,0 +1,35 @@
+# Three-panel versus four-cell layout including environment monitoring
+
+Type: grilling
+Status: resolved
+Labels: wayfinder:grilling
+Blocked by: None
+
+## 进度:100%
+
+已关闭。四个一级模式均为 4 块二级面板。运行监测第四块为监测点台账;总体概览第四块展示名为态势总览。不设环境监测二级面板。
+
+## Question
+
+总体概览和运行监测到底是 3 个二级面板,还是原型里的四格?“环境监测”是否进入运行监测?
+
+已知冲突:
+
+- spec / ADR 0003 / 实现票:总体概览 3、基础设施 4、运行监测 3、预警处置 4
+- `驾驶舱原型图.html` 的 A 方案四格:总体概览含“总体概览”自身面板;运行监测右下是“环境监测”
+
+需要决定:
+
+1. 运行监测是否包含环境监测面板
+2. 总体概览是否保留独立的“总体概览”汇总格,还是只有基础设施 / 运行监测 / 预警设置三块
+3. 若保持 3 面板,第三块放左还是右,空出来的格子如何处理
+
+## Answer
+
+四个一级模式都固定 4 个二级面板,不再使用 3/4/3/4。
+
+总体概览:态势总览、基础设施、运行监测、预警设置。态势总览只放跨行业 KPI(设施底数、监测规模、在线率、当前预警/报警、处置率、近 7 日趋势)。一级模式名仍是总体概览;该格展示名是态势总览。
+
+运行监测:监测概览、监测分布、运行概况、监测点台账。不设环境监测二级面板;污水环境指标留在设备详情。监测点台账以滚动卡片列出当前行业筛选内的监测点;点击卡片走与地图相同的两级详情。
+
+基础设施、预警处置仍各 4 块。左右栏具体 `panelId` 由 [English mode and panelId dictionary with column assignment](02-mode-panelid-dictionary-and-columns.md) 决定。

+ 39 - 0
.scratch/web-cockpit-decision-map/issues/04-spring-arm-orbit-pivot.md

@@ -0,0 +1,39 @@
+# Spring-arm orbit pivot
+
+Type: grilling
+Status: resolved
+Labels: wayfinder:grilling
+Blocked by: None
+
+## 进度:100%
+
+已关闭。旋转中心 = 当前选中对象的地表点;未选中时回退到地面拾取点。取消选中不回退,平移才回退。
+
+## Question
+
+右键轨道旋转的弹簧臂根部/旋转中心是什么?滚轮靠近或远离的也是同一个点。
+
+候选:
+
+- 场景或城市原点
+- 屏幕中心对应的地面拾取点,并在右键按下时锁定
+- 当前选中监测点/设备;无选中时回退到地面拾取点
+- 固定看向沅陵兴趣点,平移只移动该目标
+
+ADR 0008 已锁定:左键只平移,右键只绕中心旋转,滚轮只改弹簧臂长度。本票只决定中心是谁。
+
+## Answer
+
+旋转中心采用「选中对象优先」模型:
+
+- 选中监测点或设备时,中心是该对象的**地表高程点**(对象高程 + 0)。
+- 未选中任何对象时,中心是**地面拾取点**:屏幕中心对应的椭球/地形交点。
+- 右键按下时锁定当时的中心,拖拽过程中不重新拾取。
+
+中心变化时的相机行为:
+
+- 从无选中到选中某对象:镜头平滑飞向新中心,保持当前 heading 与 range,只换中心。
+- 取消选中(关闭详情或点空白)时中心**保留**在刚才的对象上,不立即回退。
+- 只有在左键平移时才把中心回退/更新为地面拾取点。
+
+调研已确认弹簧臂原语为 `camera.lookAtTransform(中心东北天局部系, HeadingPitchRange)`,中心必须是局部地表点,所以「场景/城市原点」候选不成立。实现落在同一地图服务内,不使用 Tres `OrbitControls`,也不使用 `vue-cesium` 的鼠标绑定。

+ 38 - 0
.scratch/web-cockpit-decision-map/issues/05-ground-pipeline-camera-composition.md

@@ -0,0 +1,38 @@
+# Ground and pipeline view composition with camera contract
+
+Type: grilling
+Status: resolved
+Labels: wayfinder:grilling
+Blocked by: 04
+
+## 进度:100%
+
+已关闭。地面/管线视图 = 全局视图条件,同时驱动相机预设与图层可见性;中心不重置、三键契约不变;状态归 `filterStore`。
+
+## Question
+
+地面视图和管线视图如何叠到已锁定的左键平移、右键旋转、滚轮伸缩,而不再引入一套 Cesium 默认相机?
+
+需要决定:
+
+- 地面/管线是改变弹簧臂目标高度/倾角的预设,还是切换图层并顺带改相机
+- 切换视图时是否重置旋转中心(依赖 [Spring-arm orbit pivot](04-spring-arm-orbit-pivot.md))
+- 是否允许在管线视图里继续用同一套三键契约
+
+行业筛选是全局过滤,不是相机模式。
+
+## Answer
+
+地面/管线视图是**全局视图条件**,同时驱动相机预设与图层可见性:
+
+- 相机预设:地面视图用常规俯视;管线视图压低弹簧臂、加大 pitch,并把 pitch 下限放宽到允许平视或轻微仰视。
+- 图层可见性:管线视图把管线图层提到可见,并把地形与 3D Tiles **压成半透明或线框**,保留空间参照而不完全隐藏。地面视图还原倾角并隐藏地下内容。
+- 不采用「靠剖面或掀盖露出管线」的方案;那属于 Three.js 局部增强(实现票 23),不在本视图定义内。
+
+对 ADR 0008 相机契约的影响:**三键分工完全不变**。左键仍平移、右键仍绕中心旋转、滚轮仍只改弹簧臂长度;管线视图只放宽 pitch 范围,不新增第二套鼠标绑定。
+
+对 ADR 0004 旋转中心的影响:**切换视图不重置中心**。沿用 [Spring-arm orbit pivot](04-spring-arm-orbit-pivot.md),中心仍是选中对象地表点或地面拾取点。不把中心下沉到「管线埋深」——埋深是逐管段属性,不存在单一深度。
+
+状态归属:地面/管线视图放进 `filterStore`,与行业筛选同属全局范围条件;`uiStore` 只持有 `mode` 与 `expandedPanelIds`。该视图状态**全局共用且切一级模式不重置**,与 [Mode and panel-expand state ownership](01-mode-and-expand-state-ownership.md) 的「切模式不重置全局筛选」一致。不采用「每个一级模式各记一份视图状态」。
+
+同时修正 ADR 0001 的措辞:原文把地面/管线写成「相机模式」,应表述为「全局视图条件(驱动相机预设与图层可见性)」。

+ 36 - 0
.scratch/web-cockpit-decision-map/issues/06-cesium-fes-vite-integration.md

@@ -0,0 +1,36 @@
+# Cesium integration into Fes and Vite
+
+Type: research
+Status: resolved
+Labels: wayfinder:research
+Blocked by: None
+
+## 进度:100%
+
+已关闭。结论见下方 Answer;调研资产 [cesium-fes-vite.md](../research/cesium-fes-vite.md)。
+
+## Question
+
+在当前 TvT.js / Fes.js / Vite 工程里,怎样引入原生 CesiumJS,才能支撑常驻 `MapCanvas`、替换默认相机,并且不把驾驶舱做成插件?
+
+必须查清:
+
+- `package.json` 目前没有 `cesium`;应使用哪个包和哪个加载方式
+- 与现有 TresJS / Three.js / Vite federation / publicPath `./` 的冲突
+- worker、静态资源、`CESIUM_BASE_URL` 如何配置
+- 现有 `simpleGIS` 等插件案例哪些可参考、哪些不能当驾驶舱主体依赖
+- 对实现票 02、03、21 的约束(结论,不是实现)
+
+发现写入本票链接的调研资产,不要改生产代码。
+
+## Answer
+
+Add official npm package `cesium` and ESM-import it from a cockpit map service under `src/modules/cockpit` — not a TvT plugin, not CDN, not `vue-cesium`, not `vite-plugin-cesium` as the primary path.
+
+Copy `node_modules/cesium/Build/Cesium/{Workers,ThirdParty,Assets,Widgets}` to `cesium/` and set `window.CESIUM_BASE_URL` **before** importing `cesium`, resolved from `publicPath: './'` (Fes → Vite `base`) with a trailing slash. Under qiankun, use `__INJECTED_PUBLIC_PATH_BY_QIANKUN__`. Do not hardcode `/cesium/`. Hash routing (Fes default) keeps `./cesium/` valid; do not add `cesium` to federation `shared`.
+
+MapCanvas is a persistent DOM host for one `Viewer`; it is not a `TresCanvas`. `simpleGIS` / `geokit` / `3d-tiles-renderer` / `three-tile` are reference-only. `simpleGIS` `cesiumIon` is 3d-tiles-renderer plus a hardcoded Ion token — forbidden as runtime.
+
+Constraints: **02** create Viewer once, `resize()` on panel layout, `destroy()` only on leaving `/cockpit`. **03** turn off SSCC `enableRotate/Zoom/Tilt/Look` (defaults are left-drag rotate, wheel/right-drag zoom) and own spring-arm via `lookAtTransform` + `HeadingPitchRange`. **21** `Cesium3DTileset.fromUrl` / `CesiumTerrainProvider.fromUrl` from config; no `Terrain.fromWorldTerrain` / Ion token.
+
+Asset: [cesium-fes-vite.md](../research/cesium-fes-vite.md) on branch `research/cesium-fes-vite`.

+ 49 - 0
.scratch/web-cockpit-decision-map/issues/07-cockpit-api-auth-without-login.md

@@ -0,0 +1,49 @@
+# Cockpit API auth without a login page
+
+Type: grilling
+Status: resolved
+Labels: wayfinder:grilling
+Blocked by: None
+
+## 进度:100%
+
+已关闭。Q1–Q5 全部确认。凭据由部署层注入请求头;401 只标相关分区为数据不可用;默认 mock;WebSocket 先 authenticate 再 cockpitSubscribe。
+
+## 后端事实(只读核实)
+
+来源:`E:\pipe-ner-server\pipe-network-service`。
+
+- **框架**:Spring Security + JWT,`SessionCreationPolicy.STATELESS`,CSRF 已禁用。
+- **REST 凭据**:`TokenService.getToken()` 只从请求头读取,头名由 `bootstrap.yaml` 的 `token.header` 给出,值为 `Authorization`;`Bearer ` 前缀会被剥离。
+- **驾驶舱 REST 不在白名单**:`SecurityConfig` 的 permitAll 只含 `/login`、`/register`、`/captchaImage`、`/**/visualization/**`、`/ws/deviceStatus`,其余 `.anyRequest().authenticated()`。因此 `/api/cockpit/v1/**` 必须带 token。
+- **未认证响应**:`/api/cockpit/**` 命中 `CockpitAuthenticationEntryPoint`,返回 HTTP 401,body 为 `AjaxResult(code=401, msg="驾驶舱接口需要认证", data=CockpitErrorPayload{category=UNAUTHORIZED, requestId, serverTime})`。
+- **token 来源**:只有 `POST /login` 能签发 token;驾驶舱首期不做登录页,所以 token 必须由外部提供。
+- **WebSocket**:`/ws/deviceStatus` 握手层是 permitAll;客户端可发 `{"type":"authenticate","token":"..."}` 完成 JWT/Redis 校验;而 `handleCockpitSubscribe` 当前**只校验 `scope`,不校验身份**,所以 `cockpitSubscribe` 今天无需 token 即可订阅。
+- **CORS**:`ResourcesConfig.corsFilter()` 设 `addAllowedOriginPattern("*")` 并允许全部头与方法,但**未开启 allowCredentials**。因此跨域 Cookie 会话不可行,请求头方案可行。
+- **未发现开发例外**:没有任何 profile 或调试头能让驾驶舱 REST 免鉴权。
+
+事实面结论:REST 必须带 `Authorization: Bearer <JWT>`;token 只能由 `/login` 签发;驾驶舱没有登录页,所以凭据只能由部署层提供。跨域 Cookie 不可行。
+
+## Question
+
+驾驶舱专用接口要求 `isAuthenticated()`,但首期明确不做登录页。mock 和真实联调分别怎么拿到凭据,失败时又如何只显示数据不可用?
+
+候选:
+
+- 首期只走契约化 mock;真实接口等独立登录方案
+- 部署注入已有 token(cookie / header / 网关),前端不管理账号
+- 开发环境使用固定调试头或反向代理注入,生产仍由网关注入
+
+约束:前端不硬编码账号或密钥;缺失凭据时显示数据不可用,不跳登录页。
+
+## Answer
+
+**凭据承载(Q4=A):** 部署层把 `Authorization: Bearer <JWT>` 注入到发往后端 `/api/cockpit/v1/**` 的请求:生产由网关/反向代理在转发时补头;开发环境用同一机制的本地反代或部署配置提供的只读 token。适配层统一附加请求头,页面与 store 不触碰 token,前端不保存账号密码、不建登录页。不采用前端内置固定 token。Cookie 会话不可行(后端 CORS 未开 allowCredentials)。
+
+**未认证体验(Q1=A):** 收到 401 或其他凭据类失败时,只把受影响的数据分区标为「数据不可用」,保留已成功分区与地图骨架;不跳登录页、不整页遮罩、不静默显示空数据。顶部显示一次性数据不可用提示。
+
+**默认数据源(Q2=A):** 默认走契约化 mock;真实接口必须在部署配置里显式开启(mock/real 走同一适配层,切换只改配置)。
+
+**轮询(Q3=A):** WebSocket 失败降级轮询与新鲜度语义不在本票定义,归实现票 16。
+
+**WebSocket 鉴权(Q5=A):** 连接 `/ws/deviceStatus` 后、发送 `cockpitSubscribe` 前先发 `{"type":"authenticate","token":"..."}`,token 与 REST 同源。后端当前 `cockpitSubscribe` 只校验 scope 不校验身份,authenticate 是为后端日后收紧时的前向兼容。鉴权失败只把状态推送标为不可用,REST 快照照常显示。

+ 45 - 0
.scratch/web-cockpit-decision-map/issues/08-cockpit-page-file-shape.md

@@ -0,0 +1,45 @@
+# Cockpit page file shape
+
+Type: grilling
+Status: resolved
+Labels: wayfinder:grilling
+Blocked by: None
+
+## 进度:100%
+
+已关闭。入口为 `src/pages/cockpit/index.vue`(薄);全部驾驶舱代码收在新建的 `src/modules/cockpit`;不使用 `layout.vue`。
+
+## Question
+
+Fes.js 约定下,驾驶舱入口文件是 `src/pages/cockpit.vue` 还是 `src/pages/cockpit/` 目录?
+
+已知冲突:
+
+- ADR 0006 写 `src/pages/cockpit.vue` 与 `src/modules/cockpit`
+- ADR 0007 与 spec 写 `src/pages/cockpit` 与 `src/modules/cockpit`
+
+需要决定唯一入口形态,以及模块代码是否全部放在 `src/modules/cockpit`。不要引入 TvT 插件目录作为交付主体。
+
+## Fes.js 路由事实(只读核实)
+
+来源:`node_modules/@fesjs/preset-built-in/dist/plugins/core/route/index.mjs`。
+
+- **两种入口都产出 `/cockpit`**:`src/pages/cockpit.vue`(文件名即路径段)与 `src/pages/cockpit/index.vue`(`index` 映射为空路径段,目录递归提供 `/cockpit` 前缀)。
+- **两者不能并存**:同一 `routePath` 冲突时只取先遇到的一个并打警告,因此必须二选一。
+- **pages 下的 `components` 目录不生成路由**:`isProcessDirectory` 明确排除 `components`。
+- **不要用 `.fes.js` 的 `router.routes`**:该配置一旦非空,路由生成器直接返回它,整个应用的文件路由(`src/pages/index.vue`、`test.vue`、`app.jsx` 里 `/plugins` 那套)全部失效。
+- **`layout.vue` 会生成 layout 路由**,供带子路由的目录使用;驾驶舱只有 `/cockpit` 一个路由,用不上。
+- **`src/modules` 目前不存在**:现有顶层目录为 `.fes`、`common`、`components`、`images`、`models`、`pages`、`plugins`、`stores`。
+- **`@` 别名指向 `src`**(`src/app.jsx` 已在用 `@/plugins/...`),因此 `@/modules/cockpit/...` 可解析。
+
+## Answer
+
+**入口形态(Q1=A):** `src/pages/cockpit/index.vue`,保持薄,只挂载外壳组件。目录形态给「入口薄 + 模块厚」留出位置,且改动文档面积最小。不采用 `src/pages/cockpit.vue`。**明确排除在 `.fes.js` 写 `router.routes`**:那会让全站文件路由失效。两种文件形态不得并存。
+
+**模块归属(Q2=A):** 新建 `src/modules/cockpit` 作为驾驶舱唯一领域边界,收纳外壳、面板、地图服务、适配层与 mock。这是新增顶层目录,已在 ADR 0006/0007 记录。不放进 `src/common/cockpit`,也不拆到 `src/components/cockpit` 与 `src/stores/cockpit`。
+
+**面板与外壳位置(Q3=A):** 全部放模块内,例如 `@/modules/cockpit/CockpitShell.vue` 与 `@/modules/cockpit/panels/...`。`CockpitShell` 是 spec 指定的公共测试 seam,与实现同址。面板不放 `src/pages/cockpit/components/`——虽然该目录已被路由排除、技术上安全,但会造成第二个边界并让导入方向别扭。
+
+**不使用 `layout.vue`(Q4=A):** 外壳是模块里的 `CockpitShell.vue`,不是路由层 layout。
+
+**文档同步:** ADR 0006 原写 `src/pages/cockpit.vue`,已改为 `src/pages/cockpit/index.vue`;ADR 0007 与 spec 的 `src/pages/cockpit` 表述补齐为 `src/pages/cockpit/index.vue`。

+ 60 - 0
.scratch/web-cockpit-decision-map/map.md

@@ -0,0 +1,60 @@
+# Web 驾驶舱实现前决策地图
+
+Labels: wayfinder:map
+Status: resolved
+
+地图收口:8/8 决策票 resolved,目的地达成(有执行侧实证),正式交给主流程。
+
+## Destination
+
+把实现 23 张 tracer-bullet tickets 前仍会发明语义的决策锁死。地图完成时,agent 可按现有 spec、ADR 和 `.scratch/cockpit-shell-panel-layout/issues/` 开工,不再发明 `mode`、面板、相机或鉴权语义。
+
+## Handoff
+
+地图只产出决策,不实现功能。后续按主流程走:
+
+- 23 张实现票仍是执行清单,位于 [`../cockpit-shell-panel-layout/issues/`](../cockpit-shell-panel-layout/issues/)。
+- [01: Shell 与模式/面板映射](../cockpit-shell-panel-layout/issues/01-shell-mode-panel-mapping.md) 已按本图决策实施;14/14 自动走查通过,按维护者要求进入人工审查,审查通过后收口。
+- 实施按依赖顺序做,每票一个全新上下文。
+
+## Notes
+
+- Domain: 城市生命线 Web 驾驶舱。词汇以 `CONTEXT.md` 为准。
+- 每次会话先读:`CONTEXT.md`、`docs/adr/0001`–`0008`、`.scratch/cockpit-shell-panel-layout/spec.md`。
+- 23 张实现票仍是执行清单,不在这张地图里重写。
+- 本地图只产出决策,不实现功能。
+- 技能:grilling、domain-modeling、research、prototype。
+- 决策票的状态词是 wayfinder 自己的 `open`/`claimed`/`resolved`,**不套 triage 的五种标签**。要推进决策票走 `/wayfinder`;`/triage` 只面向实现票与外来问题。
+- 已锁定:唯一 `/cockpit` 外壳、四个一级模式、无 `subview`、`expandedPanelIds`、JavaScript 不升为新规范、Cesium 主图 / Three.js 增强、凭据由部署注入。
+
+## Decisions so far
+
+<!-- 本地图已关闭的决策票索引;一条决策只活在票里 -->
+
+- [Cesium integration into Fes and Vite](issues/06-cesium-fes-vite-integration.md) — 用 npm `cesium` + 四目录静态拷贝 + 相对 `CESIUM_BASE_URL`(`./` / qiankun),地图服务建常驻 Viewer;simpleGIS/geokit 只参考不依赖。
+- [Mode and panel-expand state ownership](issues/01-mode-and-expand-state-ownership.md) — `mode` 与 `expandedPanelIds` 都进 `uiStore`;切一级模式重置展开、不重置行业筛选。
+- [Three-panel versus four-cell layout including environment monitoring](issues/03-three-vs-four-cell-layout.md) — 四个模式均为 4 面板;总体概览加态势总览,运行监测加监测点台账;不设环境监测二级面板。
+- [English mode and panelId dictionary with column assignment](issues/02-mode-panelid-dictionary-and-columns.md) — `mode` 用 camelCase 四值,16 个 `panelId` 扁平全局唯一,左右栏各两块,中文走独立 `panelLabels` 映射。
+- [Spring-arm orbit pivot](issues/04-spring-arm-orbit-pivot.md) — 中心取选中对象地表点,未选中回退地面拾取点;选中时平滑换中心并保持 heading/range,取消选中不回退。
+- [Ground and pipeline view composition with camera contract](issues/05-ground-pipeline-camera-composition.md) — 地面/管线是全局视图条件,同时改相机预设与图层可见性(管线视图把地表压半透明);中心不重置、三键不变、状态归 `filterStore`。
+- [Cockpit API auth without a login page](issues/07-cockpit-api-auth-without-login.md) — 凭据由部署层注入 `Authorization` 请求头;401 只标相关分区不可用;默认 mock、real 显式开启;WS 先 authenticate 再订阅。
+- [Cockpit page file shape](issues/08-cockpit-page-file-shape.md) — 入口为 `src/pages/cockpit/index.vue`(薄),全部代码收在新建 `src/modules/cockpit`;不用 `layout.vue`,不写 `router.routes`。
+- ——收束:目的地判据成立——mode/面板(票 01/02/03)、相机(票 04/05 + ADR 0008)、Cesium 集成(票 06)、鉴权(票 07)、工程结构(票 08)全部锁死;实证:实现票 T01 已按此建成并通过脚本断言、路由生成与 Vite 编译验证,全程未发明任何语义。交接去向:[Cockpit 外壳与面板布局(执行索引)](../cockpit-shell-panel-layout/map.md) 承载 T01–T25 的执行状态;后端补契约与 3 个业务定义问题归实现票 25(ready-for-human),不属本图职责。
+
+## Not yet specified
+
+- 甲方本地瓦片、地形、3D Tiles 的实际交付物清单。
+- 设计公司视觉终稿如何替换占位 token。
+- Three.js 管线增强的视觉语言和选中精度。
+
+<!-- 已毕业:面板字段覆盖 → 实现票 24(resolved,产物 research/panel-field-coverage.md);衍生的后端补契约与业务定义问题 → 实现票 25。均随收口移出雾区。 -->
+
+## Out of scope
+
+- 登录、权限管理、工单处置等写操作。
+- UE5、Pixel Streaming、Docker、国产化认证。
+- CAD 转换、坐标精化、高精度模型生产。
+- 设计终稿本身。
+- 竖屏、移动端、超宽屏、多屏拼接。
+- 首期用 query 记录 `mode` 以便分享。
+- 重写 23 张实现票。

+ 300 - 0
.scratch/web-cockpit-decision-map/research/cesium-fes-vite.md

@@ -0,0 +1,300 @@
+# Research: native CesiumJS in TvT.js / Fes.js / Vite
+
+**Ticket:** [Cesium integration into Fes and Vite](../issues/06-cesium-fes-vite-integration.md)
+**Branch:** `research/cesium-fes-vite`
+**Date:** 2026-04-10
+**Scope:** decision facts only. No production cockpit code.
+
+## Decision
+
+Add the official npm package `cesium`. Load it with ESM named imports from a cockpit **map service** (not a TvT plugin). Copy Cesium’s four static directories and set `CESIUM_BASE_URL` **before** the `cesium` import, using this app’s relative `publicPath: './'` (and qiankun’s injected public path when present). Do **not** use `vite-plugin-cesium`, `vue-cesium`, CDN `Cesium.js`, `simpleGIS`, `geokit`, `3d-tiles-renderer`, or `three-tile` as the MapCanvas runtime.
+
+---
+
+## 1. Package and load method
+
+### Repo fact
+
+`package.json` has no `cesium`. GIS-adjacent deps that **are** present: `three@0.180.0`, `@tresjs/core@5.2.1`, `3d-tiles-renderer@0.4.16`, `three-tile@0.11.10`, `@icegl/geokit@^2.0.3`. (`package.json`)
+
+### Official load method
+
+CesiumJS Quickstart (Cesium 1.145 docs) gives two supported setups: CDN script, or **`npm install cesium`** for a module bundler. The npm recipe is:
+
+```js
+window.CESIUM_BASE_URL = '/';
+
+import { Cartesian3, Ion, Terrain, Viewer } from 'cesium';
+import 'cesium/Build/Cesium/Widgets/widgets.css';
+```
+
+> “The `window.CESIUM_BASE_URL` global variable must be set **before CesiumJS is imported**. It must point to the URL where those four directories are served.”
+>
+> Directories to copy and serve as static files:
+> - `node_modules/cesium/Build/Cesium/Workers`
+> - `node_modules/cesium/Build/Cesium/ThirdParty`
+> - `node_modules/cesium/Build/Cesium/Assets`
+> - `node_modules/cesium/Build/Cesium/Widgets`
+
+Source: [CesiumJS Quickstart — Install with NPM](https://cesium.com/learn/cesiumjs-learn/cesiumjs-quickstart/#install-with-npm)
+
+The first-party bundler example is Webpack, not Vite: [CesiumGS/cesium-webpack-example TUTORIAL.md](https://github.com/CesiumGS/cesium-webpack-example/blob/main/TUTORIAL.md). It copies Workers / Assets / Widgets (and later ThirdParty in the quickstart) and defines `CESIUM_BASE_URL` at compile time.
+
+### What not to use as the primary integration
+
+| Option | Why not |
+| --- | --- |
+| CDN `Cesium.js` | Second global copy; fights `publicPath: './'`, qiankun, and federation ESM. Quickstart lists it as the non-bundler path. |
+| `vite-plugin-cesium` (nshen) | Community convenience wrapper, **not** first-party. Injects HTML/scripts and copies the same four dirs. High collision risk with Fes HTML plugin, `@originjs/vite-plugin-federation`, and qiankun public-path injection. Acceptable only as a last-resort adapter of the official copy recipe. [nshen/vite-plugin-cesium](https://github.com/nshen/vite-plugin-cesium) |
+| `vue-cesium` / Resium | Vue/React wrappers. Ticket 03 needs replacing default camera in a map service; wrappers fight that. |
+| `@cesium/engine` + `@cesium/widgets` split | Official quickstart uses the `cesium` meta-package. No reason to split. |
+| `3d-tiles-renderer` / `three-tile` / `@icegl/geokit` | Three.js GIS, not native CesiumJS. ADR 0004 already chose Cesium as the main map. |
+
+### ESM import-order constraint
+
+ES module `import` is hoisted. `window.CESIUM_BASE_URL = x` in the same file as `import { Viewer } from 'cesium'` is **too late**.
+
+Required pattern:
+
+1. A tiny module that **only** sets `window.CESIUM_BASE_URL` (and has no `cesium` import).
+2. Map service imports that module first, then `cesium`.
+3. Optionally also `define: { CESIUM_BASE_URL: JSON.stringify(...) }` like the Webpack example, but qiankun needs a **runtime** `window.CESIUM_BASE_URL` override.
+
+---
+
+## 2. How this repo’s Vite / Fes stack maps onto Cesium
+
+### `publicPath: './'` → Vite `base`
+
+`.fes.js`:
+
+```js
+publicPath: './', // './' 若在线部署用于生产环境 且 是使用qiankun微前端时,需要配置base为主应用地址
+```
+
+`@fesjs/builder-vite` `getInnerCommonConfig`:
+
+```js
+const publicPath = base || api.config.publicPath || '/';
+return deepmerge({
+  base: publicPath,
+  define: getDefine(api, publicPath),
+  ...
+}, otherViteOption);
+```
+
+`@fesjs/utils` `resolveRuntimeEnv`: `env.BASE_URL = publicPath`.
+
+So in this app:
+
+- Vite `base` is `./`
+- `process.env.BASE_URL` (Fes convention used in `src/`) is `./`
+- `import.meta.env.BASE_URL` is also `./`
+
+Vite relative base: all generated URLs are relative to each file. Dynamic URLs must use `import.meta.env.BASE_URL` written exactly that way. Source: [Vite — Relative base](https://vitejs.dev/guide/build.html#relative-base)
+
+### Hash router makes `./cesium/` safe
+
+Fes default `router.mode` is `"hash"` (`@fesjs/preset-built-in` route plugin). `.fes.js` does not override it. Document URL stays at the HTML file (`index.html`); `#/cockpit` does not change how `./cesium/Workers/...` resolves.
+
+If someone later switches to `history` mode, `./cesium/` from `/cockpit` vs `/cockpit/` would split. Do **not** silently change router mode without revisiting `CESIUM_BASE_URL`.
+
+### Recommended `CESIUM_BASE_URL`
+
+Join, always with a trailing slash:
+
+```text
+<resolved-base> + 'cesium/'
+```
+
+Resolution order:
+
+1. `qiankunWindow.__INJECTED_PUBLIC_PATH_BY_QIANKUN__` when `__POWERED_BY_QIANKUN__` (already handled in `src/app.jsx` `beforeRender`).
+2. Else `new URL('cesium/', new URL(process.env.BASE_URL || './', document.baseURI)).href` so `./` becomes an absolute URL against the HTML document, not a JS chunk.
+
+Do **not** hardcode `CESIUM_BASE_URL = '/cesium/'`. That ignores `publicPath: './'` and qiankun.
+
+Workers, Assets, Widgets, ThirdParty are loaded from that base. They must **not** be processed by Vite’s worker plugin; copy the prebuilt files from `node_modules/cesium/Build/Cesium/*`.
+
+### Where to copy static files
+
+Put them at dist/dev root `cesium/{Workers,ThirdParty,Assets,Widgets}/`, **not** under `public/plugins/`. Spec already assigns cockpit statics to `public/cockpit`. Cesium engine files are a build-time copy from `node_modules`, not a committed public tree.
+
+Implement the copy as an extra plugin in `.fes.js` `viteOption.plugins` (small `vite-plugin-static-copy` or a 20-line copy plugin). Must run in:
+
+| Mode | Why it matters |
+| --- | --- |
+| `yarn dev` / `yarn build` | default Vite `copyPublicDir: true` |
+| `yarn pre.dev` / `yarn pre.build` | same host app |
+| `yarn pre.dev.one` / `yarn pre.build.one` | `.fes.predev.one.js` sets `copyPublicDir: false` and uses `copySelectedPublicDirsPlugin`, which copies **all non-`plugins` public dirs**. A `public/cesium` tree would copy; a Vite plugin copy from `node_modules` is still required for dev and to avoid committing Cesium blobs. |
+
+Cockpit is a `src/pages` route, not a plugin, so `FES_APP_PLSNAME` filtering does not drop it.
+
+### Federation
+
+`.fes.js` federation:
+
+```js
+federation({
+  name: 'tvt-host',
+  remotes: { home: { external: `Promise.resolve('https://dcser.icegl.cn/assets/remoteEntry.js')`, externalType: 'promise' } },
+  shared: { vue: {}, three: { version: '0.180.0' } }
+})
+```
+
+Constraints:
+
+- Do **not** add `cesium` to `shared`. Remotes do not need it. Cesium is huge and `CESIUM_BASE_URL` is a process-wide singleton.
+- Keep `build.target: 'esnext'` (already set; good for Cesium ESM).
+- Add `cesium` as its own `manualChunks` entry next to existing `three` / `3d-tiles-renderer` splits.
+- Do **not** let a Cesium Vite plugin rewrite `index.html` in ways that break federation’s `__rf_placeholder__shareScope` or Fes HTML injection.
+
+### TresJS / Three.js
+
+- `src/app.jsx` does `app.use(Tres)` globally. That only registers custom elements; it does **not** create a canvas.
+- Default `src/pages/index.vue` is a `TresCanvas`. MapCanvas must **not** be a `TresCanvas`.
+- ADR 0004 / ticket 23: Cesium is the geographic main map; Three.js is a **separate** enhancement for selected objects and must not take over the Cesium scene. They share domain state, not a WebGL context.
+- No need to put Three.js inside Cesium’s renderer for tickets 02/03/21.
+
+Conflicts that **do** exist if ignored:
+
+- Wrapping MapCanvas in `TresCanvas` / `OrbitControls` (simpleGIS pattern) would steal pointer events from the Cesium camera contract.
+- Two live WebGL contexts at once (Cesium globe + Tres overlay) is allowed by browsers but expensive; ticket 23 already says close the enhancement and return to the map.
+- `optimizeDeps.exclude` currently only lists `@fesjs/fes`. Cesium should be explicitly `optimizeDeps.include` (or left to Vite) — do not exclude it in a way that double-bundles workers.
+
+### qiankun
+
+`.fes.js` enables `qiankun.micro.useDevMode: true`. `src/app.jsx` copies `__INJECTED_PUBLIC_PATH_BY_QIANKUN__` onto `window`. Comment on `publicPath` says production-as-micro must set base to the host URL (example `https://cos.icegl.cn/qiankun/tvt/`). Cesium workers will 404 unless `CESIUM_BASE_URL` uses that injected path.
+
+Cockpit is still **not** delivered as a TvT plugin; it is a first-class page **inside** this host, which itself can be a qiankun micro.
+
+---
+
+## 3. Existing plugin cases: reference vs forbidden runtime
+
+ADR 0007: development may look at `src/plugins`; delivery is `src/pages/cockpit` + `src/modules/cockpit`. Large plugins are not a cockpit runtime dependency. Spec: static assets in `public/cockpit`.
+
+### Must NOT be MapCanvas runtime
+
+| Case | What it actually is | Why forbidden |
+| --- | --- | --- |
+| `src/plugins/simpleGIS` | TresJS + `3d-tiles-renderer` / `three-tile` / custom satellite map. Preview pages under `/plugins/simpleGIS/*`. | TvT plugin. Three.js globe, not Cesium Viewer. |
+| `simpleGIS/pages/cesiumIon.vue` + `cesiumIon3DTile.vue` | `TilesRenderer` + `CesiumIonAuthPlugin` with a **hardcoded Ion JWT** (`assetId: '354759'`). | Name says Cesium; implementation is NASA AMMOS 3d-tiles-renderer. Ticket 21 forbids hardcoded Ion tokens. |
+| `simpleGIS/pages/renderer3DTiles.vue` | TresCanvas + OrbitControls + `tilesMesh` (`TilesRenderer` on a CDN `tileset.json`). | Three.js tiles, OrbitControls fights ticket 03. |
+| `src/plugins/geokit` + `@icegl/geokit` | Marketing copy: “是否在为cesium复杂的sdk烦恼?” — a **Three.js alternative** to Cesium. | Explicitly not Cesium. |
+| `src/plugins/useThreeGeospatial` | Ports Cesium math into Three.js. | Algorithm reference only. |
+| `three-tile` examples in simpleGIS | Three.js tile map. | Wrong renderer. |
+
+### Useful as **reference only** (copy ideas, not imports)
+
+- Relative asset habit: `process.env.BASE_URL`, Draco path `'./draco/'` in `cesiumIon3DTile.vue` — same class of bug as `CESIUM_BASE_URL` with `publicPath: './'`.
+- `.fes.js` `/tianditu.map` proxy — ticket 21 says 天地图 is for basemap **validation**, not the production globe source.
+- 3D Tiles loading / empty / error UX from `tilesMesh.vue` (`load-model`, bounding sphere) — reimplement with `Cesium3DTileset` events, do not import `PLS/simpleGIS`.
+- `copySelectedPublicDirsPlugin.js` — cockpit + cesium static dirs that live outside `public/plugins` survive `pre.build.one`.
+
+Do **not** `import … from 'PLS/simpleGIS'` (or geokit) from cockpit modules.
+
+---
+
+## 4. Camera: replacing Cesium defaults
+
+Official defaults on `ScreenSpaceCameraController` (Cesium 1.145):
+
+| Flag / event | Default | Meaning in 3D |
+| --- | --- | --- |
+| `enableInputs` | `true` | Master switch; **temporary** (flights / drag-select). Docs: to keep inputs off, use the other booleans. |
+| `enableRotate` | `true` | `rotateEventTypes` default `LEFT_DRAG` |
+| `enableZoom` | `true` | `zoomEventTypes` default `[RIGHT_DRAG, WHEEL, PINCH]` |
+| `enableTilt` | `true` | middle-drag / pinch / ctrl+drag |
+| `enableLook` | `true` | shift+left-drag |
+| `enableTranslate` | `true` | **2D / Columbus only** |
+
+Sources: [ScreenSpaceCameraController](https://cesium.com/learn/cesiumjs/ref-doc/ScreenSpaceCameraController.html), [Control the camera](https://cesium.com/learn/cesiumjs-learn/cesiumjs-camera/)
+
+ADR 0008 / ticket 03 want: left-drag **pan**, right-drag **orbit around pivot**, wheel **spring-arm length**. That is the inverse of Cesium 3D defaults (left = rotate, wheel/right = zoom).
+
+Spring-arm primitive already in official camera guide:
+
+```js
+const transform = Cesium.Transforms.eastNorthUpToFixedFrame(center);
+viewer.scene.camera.lookAtTransform(transform, new Cesium.HeadingPitchRange(0, -Math.PI / 4, 2900));
+```
+
+Then orbit with `camera.rotateRight` on tick, or (for the cockpit) a custom `ScreenSpaceEventHandler` that writes heading/pitch/range.
+
+Implementation constraint: disable `enableRotate`, `enableZoom`, `enableTilt`, `enableLook` (and do not rely on `enableInputs = false` alone). Own the handler in the map service. Do not keep Viewer chrome (geocoder, home, timeline, …) that reintroduces default navigation.
+
+---
+
+## 5. Constraints for implementation tickets (conclusions only)
+
+### 02 — MapCanvas persistent mount
+
+- Host is a **plain DOM container** in `/cockpit` (`src/pages/cockpit*`), not `src/plugins/**` and not `TresCanvas`.
+- `new Viewer(container, { …widgets off })` once in `src/modules/cockpit` map service. Mode / `expandedPanelIds` must not `v-if` the container.
+- On panel expand/collapse the container size changes → `viewer.resize()`.
+- `viewer.destroy()` only when leaving `/cockpit`.
+- `cesium` import is isolated to the map service so `CESIUM_BASE_URL` is set once.
+
+### 03 — Camera control contract
+
+- After Viewer construct, turn off default SSCC rotate/zoom/tilt/look.
+- Implement pan / orbit / spring-arm on `lookAtTransform` + `HeadingPitchRange` + `ScreenSpaceEventHandler` in the **same** map service.
+- Do not use Tres `OrbitControls` (simpleGIS) and do not use vue-cesium mouse bindings.
+
+### 21 — 3D Tiles and terrain baseline
+
+- Load tiles with `Cesium3DTileset.fromUrl(configUrl)` (official example uses `http://localhost:8002/tilesets/Seattle/tileset.json`). Source: [Cesium3DTileset](https://cesium.com/learn/cesiumjs/ref-doc/Cesium3DTileset.html)
+- Load terrain with `CesiumTerrainProvider.fromUrl(configUrl)` for quantized-mesh / heightmap. No terrain → `EllipsoidTerrainProvider`. Source: [CesiumTerrainProvider.fromUrl](https://cesium.com/learn/cesiumjs/ref-doc/CesiumTerrainProvider.html#.fromUrl), [Visualizing 3D Terrain](https://cesium.com/learn/cesiumjs-learn/cesiumjs-terrain/)
+- **Forbidden:** `Terrain.fromWorldTerrain()`, `CesiumTerrainProvider.fromIonAssetId`, `createOsmBuildingsAsync`, `Ion.defaultAccessToken`, simpleGIS’s hardcoded Ion JWT.
+- URLs and any future token come from ticket 04 config injection, not source.
+- Loading / success / fail / empty: `fromUrl` try/catch + `tileset.allTilesLoaded` / provider `errorEvent`. Do not use `3d-tiles-renderer` events.
+- Ground vs pipeline view is camera + data layers on this same Viewer, not a second engine.
+
+---
+
+## 6. Suggested wiring (not implemented)
+
+In `.fes.js` `viteOption` (later implementation ticket):
+
+1. Dependency: `cesium` (runtime).
+2. Plugin: copy the four `Build/Cesium/{Workers,ThirdParty,Assets,Widgets}` dirs to `cesium/`.
+3. `manualChunks.cesium = ['cesium']`.
+4. Do not add Cesium to federation `shared`.
+5. Map service: set `CESIUM_BASE_URL`, import `cesium` + `widgets.css`, construct Viewer, disable default camera, expose resize/destroy.
+
+Cockpit page: mount `<div ref>` once; pass the element to the map service.
+
+---
+
+## Sources
+
+### High-trust, this repo
+
+- `package.json` — no `cesium`; Tres / three / 3d-tiles-renderer / three-tile / geokit present
+- `.fes.js` — `publicPath: './'`, federation shared `vue`+`three`, qiankun micro, tianditu proxy, `manualChunks`
+- `.fes.predev.one.js` — `copyPublicDir: false`
+- `src/common/copySelectedPublicDirsPlugin.js` — copies all non-`plugins` public dirs
+- `src/app.jsx` — `app.use(Tres)`, qiankun public path, plugin page glob
+- `src/pages/index.vue` — TresCanvas homepage, not cockpit
+- `src/plugins/simpleGIS/**` — Tres GIS demos; `cesiumIon3DTile.vue` is 3d-tiles-renderer + Ion token
+- `src/plugins/geokit/config.js` — positions geokit as a Cesium alternative
+- `node_modules/@fesjs/builder-vite/lib/common/getConfig.js` — `publicPath` → Vite `base`
+- `node_modules/@fesjs/preset-built-in` — default `router.mode: 'hash'`
+- `docs/adr/0004`, `0005`, `0006`, `0007`, `0008`
+- `.scratch/cockpit-shell-panel-layout/spec.md` and issues `02`, `03`, `21`, `23`
+
+### High-trust, official docs
+
+- [CesiumJS Quickstart](https://cesium.com/learn/cesiumjs-learn/cesiumjs-quickstart/) — npm `cesium`, four static dirs, `CESIUM_BASE_URL` before import (CDN build **1.145**)
+- [cesium-webpack-example TUTORIAL.md](https://github.com/CesiumGS/cesium-webpack-example/blob/main/TUTORIAL.md) — first-party copy + define pattern
+- [Control the camera](https://cesium.com/learn/cesiumjs-learn/cesiumjs-camera/) — `lookAtTransform` / `HeadingPitchRange` / SSCC
+- [ScreenSpaceCameraController](https://cesium.com/learn/cesiumjs/ref-doc/ScreenSpaceCameraController.html) — default left-drag rotate, wheel/right-drag zoom
+- [Cesium3DTileset.fromUrl](https://cesium.com/learn/cesiumjs/ref-doc/Cesium3DTileset.html)
+- [CesiumTerrainProvider.fromUrl](https://cesium.com/learn/cesiumjs/ref-doc/CesiumTerrainProvider.html#.fromUrl)
+- [Visualizing 3D Terrain](https://cesium.com/learn/cesiumjs-learn/cesiumjs-terrain/) — `fromWorldTerrain` is Ion; local uses a terrain provider URL
+- [Vite relative base](https://vitejs.dev/guide/build.html#relative-base)
+
+### Medium-trust (community, cited only as a rejected option)
+
+- [nshen/vite-plugin-cesium](https://github.com/nshen/vite-plugin-cesium)

+ 560 - 0
src/modules/cockpit/CockpitShell.vue

@@ -0,0 +1,560 @@
+<!--
+  驾驶舱外壳:唯一 /cockpit 路由的全部内容。
+
+  骨架与视觉参考《驾驶舱原型图-美化版》(切角面板 / 青色辉光 / 深空蓝底 / 全息地图壳),
+  但**不锁死 16:9**:页面铺满视口,中央一张图吃掉全部剩余宽高(大屏、超宽屏自动拉伸)。
+
+  语义按 ADR 0003 / 0007:四个一级模式、模式内 4 个二级面板同屏展示、无子视图切换。
+  一级模式只通过 uiStore.mode 切换,不新增路由、不重建外壳与地图节点。
+  范围与行业是全局筛选(filterStore),切换一级模式不得重置它们(验收第 ⑦ 条)。
+-->
+<template>
+    <div class="cockpit-shell">
+        <div class="cockpit-shell__atmosphere" aria-hidden="true"></div>
+
+        <header class="cockpit-shell__topbar">
+            <div class="cockpit-shell__topbar-bg" aria-hidden="true"></div>
+            <span class="cockpit-shell__wing is-left" aria-hidden="true"></span>
+            <span class="cockpit-shell__wing is-right" aria-hidden="true"></span>
+
+            <div class="cockpit-shell__brand">
+                <span class="cockpit-shell__mark" aria-hidden="true">
+                    <svg viewBox="0 0 48 48" fill="none">
+                        <path
+                            d="M24 3 42 13.5v21L24 45 6 34.5v-21L24 3Z"
+                            stroke="url(#cockpitBrandGrad)"
+                            stroke-width="2"
+                        />
+                        <path d="M24 13v10l7 5" stroke="#bff0ff" stroke-width="2" stroke-linecap="round" />
+                        <circle cx="24" cy="24" r="3" fill="#4fd8ff" />
+                        <defs>
+                            <linearGradient id="cockpitBrandGrad" x1="6" y1="3" x2="42" y2="45">
+                                <stop stop-color="#bff0ff" />
+                                <stop offset="1" stop-color="#1b93cf" />
+                            </linearGradient>
+                        </defs>
+                    </svg>
+                </span>
+                <span class="cockpit-shell__brand-text">
+                    <strong>城市生命线驾驶舱系统</strong>
+                    <em>沅陵县 · 只读展示</em>
+                </span>
+            </div>
+
+            <nav class="cockpit-shell__modes" aria-label="一级模式">
+                <button
+                    v-for="item in MODES"
+                    :key="item"
+                    type="button"
+                    class="cockpit-shell__mode"
+                    :class="{ 'is-active': item === mode }"
+                    :aria-pressed="item === mode"
+                    @click="uiStore.setMode(item)"
+                >
+                    {{ modeLabel(item) }}
+                </button>
+            </nav>
+
+            <div class="cockpit-shell__top-meta">
+                <span class="cockpit-shell__chip">只读视图</span>
+                <span class="cockpit-shell__clock">{{ clockText }}</span>
+                <p class="cockpit-shell__meta">当前模式 {{ mode }}</p>
+            </div>
+        </header>
+
+        <!-- 全局筛选:作用于整个驾驶舱,切换一级模式不重置 -->
+        <div class="cockpit-shell__scope">
+            <div class="cockpit-shell__scope-left">
+                <span class="cockpit-shell__scope-label">当前范围</span>
+                <div class="cockpit-shell__chips">
+                    <button
+                        v-for="item in SCOPES"
+                        :key="item.key"
+                        type="button"
+                        class="cockpit-shell__chip cockpit-shell__scope-btn"
+                        :class="{ 'is-active': item.key === scope }"
+                        :aria-pressed="item.key === scope"
+                        :data-scope="item.key"
+                        @click="filterStore.setScope(item.key)"
+                    >
+                        {{ item.label }}
+                    </button>
+                </div>
+
+                <span class="cockpit-shell__divider" aria-hidden="true"></span>
+
+                <span class="cockpit-shell__scope-label">行业</span>
+                <div class="cockpit-shell__chips">
+                    <button
+                        v-for="item in INDUSTRIES"
+                        :key="item.key"
+                        type="button"
+                        class="cockpit-shell__chip cockpit-shell__industry-btn"
+                        :class="{ 'is-active': item.key === industry }"
+                        :aria-pressed="item.key === industry"
+                        :data-industry="item.key"
+                        @click="filterStore.setIndustry(item.key)"
+                    >
+                        {{ item.label }}
+                    </button>
+                </div>
+            </div>
+
+            <div class="cockpit-shell__scope-right">
+                <span class="cockpit-shell__scope-note">筛选已生效于外壳状态 · 地图与列表联动待后续票接入</span>
+            </div>
+        </div>
+
+        <main class="cockpit-shell__main">
+            <aside class="cockpit-shell__column" aria-label="左栏">
+                <CockpitPanel
+                    v-for="panelId in columns.left"
+                    :key="panelId"
+                    :title="labelForPanel(panelId)"
+                    meta="面板占位"
+                    :expanded="isExpanded(panelId)"
+                    @toggle="uiStore.togglePanel(panelId)"
+                >
+                    <p class="cockpit-shell__placeholder">面板占位 · {{ panelId }}</p>
+                </CockpitPanel>
+            </aside>
+
+            <!--
+                中央一张图常驻:不放在 v-for 内,也不加 v-if/key。
+                一级模式切换只重渲染左右两栏,本节点保持原实例;尺寸随视口拉伸。
+            -->
+            <section class="cockpit-shell__map" aria-label="中央一张图">
+                <MapPlaceholder :mode-label="modeLabel(mode)" />
+            </section>
+
+            <aside class="cockpit-shell__column" aria-label="右栏">
+                <CockpitPanel
+                    v-for="panelId in columns.right"
+                    :key="panelId"
+                    :title="labelForPanel(panelId)"
+                    meta="面板占位"
+                    :expanded="isExpanded(panelId)"
+                    @toggle="uiStore.togglePanel(panelId)"
+                >
+                    <p class="cockpit-shell__placeholder">面板占位 · {{ panelId }}</p>
+                </CockpitPanel>
+            </aside>
+        </main>
+    </div>
+</template>
+
+<script setup>
+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 { useUiStore } from './stores/uiStore.js'
+import { useFilterStore } from './stores/filterStore.js'
+import CockpitPanel from './components/CockpitPanel.vue'
+import MapPlaceholder from './components/MapPlaceholder.vue'
+
+const uiStore = useUiStore()
+const filterStore = useFilterStore()
+const { mode, expandedPanelIds } = storeToRefs(uiStore)
+const { scope, industry } = storeToRefs(filterStore)
+
+const columns = computed(() => columnsForMode(mode.value))
+
+function isExpanded(panelId) {
+    return expandedPanelIds.value.includes(panelId)
+}
+
+// ---- 顶栏时钟 ----
+const clockText = ref('')
+let clockTimer = null
+
+function tick() {
+    const now = new Date()
+    const pad = (n) => String(n).padStart(2, '0')
+    clockText.value = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
+}
+
+onMounted(() => {
+    tick()
+    clockTimer = setInterval(tick, 1000)
+})
+
+onBeforeUnmount(() => {
+    if (clockTimer) clearInterval(clockTimer)
+})
+</script>
+
+<style scoped>
+/* 组件内自带的盒模型重置(原型图靠全局 `*` 重置,我们这里不依赖全局样式)。
+   缺了它 height:100vh + padding 会撑出滚动条。 */
+.cockpit-shell,
+.cockpit-shell *,
+.cockpit-shell *::before,
+.cockpit-shell *::after {
+    box-sizing: border-box;
+}
+
+/* ============================================================
+   主题令牌(原型图同名变量;子组件通过继承取用)
+   --u 是「设计像素」:1920 宽时为 1px,随视口线性放大、封顶 2px(4K 时正好一倍),
+   小于 1920 时不再缩小。这样 4K 大屏不会显得空旷,同时页面仍是铺满而非等比留边。
+   ============================================================ */
+.cockpit-shell {
+    --u: clamp(1px, 0.0521vw, 2px);
+
+    --bg-0: #01060d;
+    --panel-top: rgba(12, 48, 86, 0.58);
+    --panel-bot: rgba(3, 14, 28, 0.9);
+    --panel-line: rgba(58, 170, 226, 0.3);
+    --cyan: #4fd8ff;
+    --cyan-d: #1b93cf;
+    --lv1: #ff4d5e;
+    --lv2: #ffa63d;
+    --lv3: #4fd8ff;
+    --lv4: #35e39a;
+    --text: #e6f5ff;
+    --text-2: #a8cbe2;
+    --muted: #6d92ae;
+    --font: "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif;
+    --num: "Bahnschrift SemiBold", "Bahnschrift", "DIN Alternate", "Segoe UI Semibold", var(--font);
+    --shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
+
+    position: relative;
+    display: grid;
+    grid-template-rows: calc(74 * var(--u)) calc(46 * var(--u)) minmax(0, 1fr);
+    gap: calc(8 * var(--u));
+    width: 100%;
+    height: 100vh;
+    overflow: hidden;
+    padding: calc(10 * var(--u)) calc(14 * var(--u)) calc(14 * var(--u));
+    color: var(--text);
+    font: calc(13 * var(--u)) / 1.45 var(--font);
+}
+
+/* 全局氛围底:径向辉光 + 细网格(原型 body::before / ::after 的等价实现) */
+.cockpit-shell__atmosphere {
+    position: fixed;
+    inset: 0;
+    z-index: 0;
+    pointer-events: none;
+    background:
+        radial-gradient(1500px 760px at 50% -12%, rgba(28, 104, 182, 0.4), transparent 62%),
+        radial-gradient(1000px 620px at 4% 108%, rgba(18, 108, 168, 0.2), transparent 62%),
+        radial-gradient(1000px 620px at 96% 108%, rgba(18, 108, 168, 0.2), transparent 62%),
+        radial-gradient(760px 460px at 50% 46%, rgba(10, 62, 112, 0.3), transparent 70%),
+        linear-gradient(180deg, #020a15 0%, #04121f 42%, #02080f 100%);
+}
+
+.cockpit-shell__atmosphere::after {
+    content: "";
+    position: absolute;
+    inset: 0;
+    opacity: 0.5;
+    background-image:
+        linear-gradient(rgba(70, 160, 220, 0.045) 1px, transparent 1px),
+        linear-gradient(90deg, rgba(70, 160, 220, 0.045) 1px, transparent 1px);
+    background-size: 56px 56px, 56px 56px;
+    mask-image: radial-gradient(120% 90% at 50% 30%, #000 30%, transparent 88%);
+}
+
+/* 只给真正的三行网格项加定位;不要用 `.cockpit-shell > *`,
+   否则会以同权重覆盖 .cockpit-shell__atmosphere 的 position: fixed。 */
+.cockpit-shell__topbar,
+.cockpit-shell__scope,
+.cockpit-shell__main {
+    position: relative;
+    z-index: 1;
+    min-width: 0;
+}
+
+/* ============================================================
+   顶栏
+   ============================================================ */
+.cockpit-shell__topbar {
+    position: relative;
+    display: grid;
+    grid-template-columns: minmax(280px, 1fr) auto minmax(280px, 1fr);
+    align-items: center;
+    gap: calc(16 * var(--u));
+    height: calc(74 * var(--u));
+    padding: 0 calc(4 * var(--u));
+}
+
+.cockpit-shell__topbar-bg {
+    position: absolute;
+    inset: 0;
+    pointer-events: none;
+    background:
+        linear-gradient(180deg, rgba(10, 46, 84, 0.58) 0%, rgba(4, 18, 34, 0.12) 62%, transparent 100%),
+        radial-gradient(560px 92px at 50% 6%, rgba(48, 156, 240, 0.4), transparent 72%);
+    border-bottom: 1px solid rgba(79, 216, 255, 0.22);
+    clip-path: polygon(0 0, 100% 0, 100% 74%, calc(100% - 46px) 74%, calc(100% - 62px) 100%, 62px 100%, 46px 74%, 0 74%);
+}
+
+.cockpit-shell__wing {
+    position: absolute;
+    top: 50%;
+    width: calc(210 * var(--u));
+    height: 2px;
+    pointer-events: none;
+    background: linear-gradient(90deg, transparent, rgba(79, 216, 255, 0.75));
+    box-shadow: 0 0 12px rgba(79, 216, 255, 0.55);
+}
+
+.cockpit-shell__wing::after {
+    content: "";
+    position: absolute;
+    right: 0;
+    top: -3px;
+    width: 8px;
+    height: 8px;
+    background: var(--cyan);
+    transform: rotate(45deg);
+    box-shadow: 0 0 12px var(--cyan);
+}
+
+.cockpit-shell__wing.is-left { left: calc(50% - 460px); }
+
+.cockpit-shell__wing.is-right {
+    right: calc(50% - 460px);
+    transform: scaleX(-1);
+}
+
+.cockpit-shell__brand {
+    position: relative;
+    display: flex;
+    align-items: center;
+    gap: calc(12 * var(--u));
+    min-width: 0;
+    padding-left: calc(8 * var(--u));
+}
+
+.cockpit-shell__mark {
+    display: grid;
+    place-items: center;
+    flex: none;
+    width: calc(42 * var(--u));
+    height: calc(42 * var(--u));
+    filter: drop-shadow(0 0 10px rgba(79, 216, 255, 0.6));
+}
+
+.cockpit-shell__mark svg {
+    width: 100%;
+    height: 100%;
+}
+
+.cockpit-shell__brand-text {
+    min-width: 0;
+}
+
+.cockpit-shell__brand-text strong {
+    display: block;
+    font-size: calc(25 * var(--u));
+    font-weight: 700;
+    letter-spacing: 0.13em;
+    white-space: nowrap;
+    background: linear-gradient(180deg, #ffffff 8%, #bfeaff 52%, #4fb6e8 100%);
+    -webkit-background-clip: text;
+    background-clip: text;
+    color: transparent;
+    filter: drop-shadow(0 2px 10px rgba(20, 90, 150, 0.9));
+}
+
+.cockpit-shell__brand-text em {
+    display: block;
+    margin-top: 2px;
+    color: var(--muted);
+    font-size: calc(10.5 * var(--u));
+    font-style: normal;
+    letter-spacing: 0.24em;
+}
+
+.cockpit-shell__modes {
+    position: relative;
+    display: flex;
+    gap: calc(4 * var(--u));
+    justify-content: center;
+}
+
+.cockpit-shell__mode {
+    position: relative;
+    min-height: calc(38 * var(--u));
+    padding: calc(9 * var(--u)) calc(26 * var(--u));
+    border: 0;
+    color: #a9cde4;
+    font: inherit;
+    font-size: calc(15 * var(--u));
+    letter-spacing: 0.12em;
+    text-shadow: 0 1px 3px rgba(0, 12, 24, 0.9);
+    background: linear-gradient(180deg, rgba(20, 66, 108, 0.6), rgba(8, 30, 54, 0.2));
+    clip-path: polygon(11px 0, calc(100% - 11px) 0, 100% 100%, 0 100%);
+    cursor: pointer;
+    transition: color 0.18s, background 0.18s, filter 0.18s;
+}
+
+.cockpit-shell__mode:hover {
+    color: #fff;
+    background: linear-gradient(180deg, rgba(34, 108, 168, 0.7), rgba(12, 46, 80, 0.35));
+    filter: brightness(1.12);
+}
+
+.cockpit-shell__mode:focus-visible {
+    outline: 1px solid var(--cyan);
+    outline-offset: 2px;
+}
+
+.cockpit-shell__mode.is-active {
+    color: #fff;
+    background: linear-gradient(180deg, #2a8fd0 0%, #1467a8 55%, #0b4b83 100%);
+    filter: drop-shadow(0 0 14px rgba(79, 216, 255, 0.7));
+    text-shadow: 0 0 12px rgba(190, 240, 255, 0.95);
+}
+
+.cockpit-shell__mode.is-active::after {
+    content: "";
+    position: absolute;
+    left: 26%;
+    right: 26%;
+    bottom: 3px;
+    height: 2px;
+    background: #cdf3ff;
+    box-shadow: 0 0 10px #7fe0ff, 0 0 20px #4fd8ff;
+}
+
+.cockpit-shell__top-meta {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    gap: calc(14 * var(--u));
+    padding-right: calc(10 * var(--u));
+    color: var(--text-2);
+    font-size: calc(12 * var(--u));
+    white-space: nowrap;
+}
+
+.cockpit-shell__clock {
+    font-family: var(--num);
+    font-size: calc(15 * var(--u));
+    letter-spacing: 0.06em;
+    color: #d7f1ff;
+    text-shadow: 0 0 12px rgba(79, 216, 255, 0.55);
+}
+
+.cockpit-shell__meta {
+    margin: 0;
+    color: var(--muted);
+    font-size: calc(11.5 * var(--u));
+    letter-spacing: 0.06em;
+}
+
+/* ============================================================
+   全局筛选条
+   ============================================================ */
+.cockpit-shell__scope {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: calc(12 * var(--u));
+    overflow: hidden;
+    padding: 0 calc(12 * var(--u));
+    border: 1px solid rgba(58, 170, 226, 0.24);
+    background: linear-gradient(180deg, rgba(7, 30, 54, 0.72), rgba(4, 15, 28, 0.88));
+    box-shadow: inset 0 1px 0 rgba(120, 220, 255, 0.1), var(--shadow);
+}
+
+.cockpit-shell__scope-left,
+.cockpit-shell__scope-right {
+    display: flex;
+    align-items: center;
+    gap: calc(8 * var(--u));
+    white-space: nowrap;
+}
+
+.cockpit-shell__scope-label {
+    color: var(--muted);
+    font-size: calc(11 * var(--u));
+    letter-spacing: 0.1em;
+}
+
+.cockpit-shell__chips {
+    display: flex;
+    gap: calc(4 * var(--u));
+}
+
+.cockpit-shell__divider {
+    width: 1px;
+    height: calc(18 * var(--u));
+    margin: 0 calc(4 * var(--u));
+    background: linear-gradient(180deg, transparent, rgba(79, 216, 255, 0.5), transparent);
+}
+
+/* 范围 / 行业按钮:作用于整个驾驶舱的全局条件(切模式不重置) */
+.cockpit-shell__chip {
+    display: inline-flex;
+    align-items: center;
+    padding: calc(3 * var(--u)) calc(11 * var(--u));
+    border: 1px solid rgba(58, 170, 226, 0.32);
+    background: rgba(8, 34, 60, 0.6);
+    color: #9dc2d9;
+    font: inherit;
+    font-size: calc(11.5 * var(--u));
+    letter-spacing: 0.05em;
+    clip-path: polygon(5px 0, 100% 0, 100% calc(100% - 5px), calc(100% - 5px) 100%, 0 100%, 0 5px);
+    cursor: pointer;
+    transition: all 0.16s;
+}
+
+.cockpit-shell__chip:hover {
+    color: #fff;
+    border-color: rgba(79, 216, 255, 0.6);
+    background: rgba(20, 78, 124, 0.62);
+}
+
+.cockpit-shell__chip:focus-visible {
+    outline: 1px solid var(--cyan);
+    outline-offset: 2px;
+}
+
+.cockpit-shell__chip.is-active {
+    color: #fff;
+    border-color: rgba(140, 235, 255, 0.85);
+    background: linear-gradient(180deg, rgba(34, 132, 196, 0.85), rgba(12, 66, 108, 0.85));
+    box-shadow: inset 0 0 16px rgba(120, 220, 255, 0.35), 0 0 14px rgba(79, 216, 255, 0.35);
+    text-shadow: 0 0 10px rgba(180, 240, 255, 0.9);
+}
+
+.cockpit-shell__scope-note {
+    color: var(--muted);
+    font-size: calc(11 * var(--u));
+    letter-spacing: 0.04em;
+}
+
+/* ============================================================
+   主体:左右侧栏 + 中央一张图(铺满,地图吃掉剩余宽高)
+   ============================================================ */
+.cockpit-shell__main {
+    display: grid;
+    grid-template-columns: clamp(300px, calc(322 * var(--u)), 560px) minmax(0, 1fr) clamp(300px, calc(322 * var(--u)), 560px);
+    gap: calc(8 * var(--u));
+    min-height: 0;
+}
+
+.cockpit-shell__column {
+    display: grid;
+    grid-template-rows: 1fr 1fr;
+    gap: calc(8 * var(--u));
+    min-height: 0;
+}
+
+.cockpit-shell__map {
+    min-height: 0;
+    height: 100%;
+}
+
+.cockpit-shell__placeholder {
+    margin: 0;
+    color: var(--muted);
+    font-size: calc(12 * var(--u));
+}
+</style>

+ 190 - 0
src/modules/cockpit/components/CockpitPanel.vue

@@ -0,0 +1,190 @@
+<!--
+  二级面板外框:切角科技面板 + 可点击收放的标题栏。
+  视觉参考《驾驶舱原型图-美化版》的 .panel / .panel-head;
+  尺寸随外壳的 --u 设计像素缩放(大屏不留空,小屏不缩小)。
+  标题栏是 button,其文本即按钮的可访问名(标记符号 aria-hidden,不参与命名)。
+-->
+<template>
+    <section class="cockpit-panel" :class="{ 'is-collapsed': !expanded }">
+        <button
+            type="button"
+            class="cockpit-panel__head"
+            :aria-expanded="expanded"
+            @click="emit('toggle')"
+        >
+            <span class="cockpit-panel__title">{{ title }}</span>
+            <small class="cockpit-panel__meta">{{ meta }}</small>
+            <span class="cockpit-panel__mark" aria-hidden="true">{{ expanded ? '−' : '+' }}</span>
+        </button>
+
+        <!-- v-show 而非 v-if:收起再展开不应重建面板内容或触发重新取数。 -->
+        <div v-show="expanded" class="cockpit-panel__body">
+            <slot />
+        </div>
+    </section>
+</template>
+
+<script setup>
+defineProps({
+    title: { type: String, required: true },
+    meta: { type: String, default: '' },
+    expanded: { type: Boolean, default: true },
+})
+
+const emit = defineEmits(['toggle'])
+</script>
+
+<style scoped>
+/* 组件内自带的盒模型重置(不依赖全局 `*` 规则)。 */
+.cockpit-panel,
+.cockpit-panel *,
+.cockpit-panel *::before,
+.cockpit-panel *::after {
+    box-sizing: border-box;
+}
+
+/* 切角描边:四角各一段青色 L 形,与原型图同一套 SVG(内联为自定义属性)。 */
+.cockpit-panel {
+    --corner-tl: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath d='M0.8 16 V3.3 A2.5 2.5 0 0 1 3.3 0.8 H16' fill='none' stroke='%234fd8ff' stroke-width='1.7'/%3E%3C/svg%3E");
+    --corner-tr: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath d='M0 0.8 H12.7 A2.5 2.5 0 0 1 15.2 3.3 V16' fill='none' stroke='%234fd8ff' stroke-width='1.7'/%3E%3C/svg%3E");
+    --corner-bl: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath d='M0.8 0 V12.7 A2.5 2.5 0 0 0 3.3 15.2 H16' fill='none' stroke='%234fd8ff' stroke-width='1.7'/%3E%3C/svg%3E");
+    --corner-br: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath d='M0 15.2 H12.7 A2.5 2.5 0 0 0 15.2 12.7 V0' fill='none' stroke='%234fd8ff' stroke-width='1.7'/%3E%3C/svg%3E");
+
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+    min-height: 0;
+    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%,
+        rgba(6, 24, 44, 0.82) 26%,
+        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));
+}
+
+.cockpit-panel::before {
+    content: '';
+    position: absolute;
+    inset: -1px;
+    z-index: 3;
+    pointer-events: none;
+    background:
+        var(--corner-tl) left top / 15px 15px no-repeat,
+        var(--corner-tr) right top / 15px 15px no-repeat,
+        var(--corner-bl) left bottom / 15px 15px no-repeat,
+        var(--corner-br) right bottom / 15px 15px no-repeat;
+    filter: drop-shadow(0 0 4px rgba(79, 216, 255, 0.5));
+}
+
+.cockpit-panel__head {
+    position: relative;
+    z-index: 2;
+    display: flex;
+    align-items: center;
+    gap: calc(10 * var(--u, 1px));
+    width: 100%;
+    min-height: calc(40 * var(--u, 1px));
+    padding: 0 calc(12 * var(--u, 1px)) 0 calc(8 * var(--u, 1px));
+    border: 0;
+    border-bottom: 1px solid rgba(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%),
+        linear-gradient(180deg, rgba(90, 200, 255, 0.1), transparent);
+    font: inherit;
+    text-align: left;
+    cursor: pointer;
+}
+
+.cockpit-panel__head::after {
+    content: '';
+    position: absolute;
+    left: 12px;
+    right: 12px;
+    bottom: -1px;
+    height: 1px;
+    background: linear-gradient(90deg, rgba(79, 216, 255, 0.85), rgba(79, 216, 255, 0.1) 46%, transparent);
+}
+
+.cockpit-panel__head:hover {
+    background:
+        linear-gradient(90deg, rgba(34, 116, 176, 0.6) 0%, rgba(16, 66, 110, 0.34) 42%, transparent 78%),
+        linear-gradient(180deg, rgba(90, 200, 255, 0.14), transparent);
+}
+
+.cockpit-panel__head:focus-visible {
+    outline: 1px solid var(--cyan, #4fd8ff);
+    outline-offset: -2px;
+}
+
+.cockpit-panel.is-collapsed .cockpit-panel__head {
+    border-bottom-color: transparent;
+}
+
+.cockpit-panel.is-collapsed .cockpit-panel__head::after {
+    display: none;
+}
+
+/* 标题:左侧一段青色竖条 + 辉光,与原型 .panel-head h2 一致 */
+.cockpit-panel__title {
+    position: relative;
+    flex: none;
+    padding-left: calc(10 * var(--u, 1px));
+    color: #eaf9ff;
+    font-size: calc(14.5 * var(--u, 1px));
+    font-weight: 700;
+    letter-spacing: 0.1em;
+    white-space: nowrap;
+    text-shadow: 0 0 12px rgba(79, 216, 255, 0.6);
+}
+
+.cockpit-panel__title::before {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 50%;
+    width: calc(4 * var(--u, 1px));
+    height: calc(15 * var(--u, 1px));
+    transform: translateY(-50%);
+    background: linear-gradient(180deg, #b6f0ff, var(--cyan-d, #1b93cf));
+    box-shadow: 0 0 10px var(--cyan, #4fd8ff);
+    clip-path: polygon(0 0, 100% 3px, 100% 100%, 0 calc(100% - 3px));
+}
+
+.cockpit-panel__meta {
+    flex: 1 1 auto;
+    min-width: 0;
+    overflow: hidden;
+    color: var(--muted, #6d92ae);
+    font-size: calc(10.5 * var(--u, 1px));
+    letter-spacing: 0.04em;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+}
+
+.cockpit-panel__meta::before {
+    content: '▸ ';
+    color: rgba(79, 216, 255, 0.7);
+}
+
+.cockpit-panel__mark {
+    flex: none;
+    width: calc(16 * var(--u, 1px));
+    color: var(--cyan, #4fd8ff);
+    font-size: calc(15 * var(--u, 1px));
+    line-height: 1;
+    text-align: center;
+    text-shadow: 0 0 10px rgba(79, 216, 255, 0.7);
+}
+
+.cockpit-panel__body {
+    position: relative;
+    z-index: 1;
+    flex: 1 1 auto;
+    min-height: 0;
+    padding: calc(10 * var(--u, 1px)) calc(11 * var(--u, 1px));
+    overflow: auto;
+}
+</style>

+ 241 - 0
src/modules/cockpit/components/MapPlaceholder.vue

@@ -0,0 +1,241 @@
+<!--
+  中央一张图占位:全息地图壳(底纹 / 网格 / 扫描线 / 图层 / 分级图例 / 状态行)。
+  视觉参考《驾驶舱原型图-美化版》的 .map 区块;尺寸随视口铺满并随 --u 缩放。
+  数据层与相机控制由后续票接入(底图见票 21/22,相机见票 03)。
+
+  常驻:一级模式切换与面板收展都不得重建本组件(实现票 02 会在此接入 Cesium MapCanvas)。
+-->
+<template>
+    <div class="map-placeholder">
+        <div class="map-holo" aria-hidden="true">
+            <div class="map-holo__base"></div>
+            <div class="map-holo__grid"></div>
+            <div class="map-holo__scan"></div>
+            <div class="map-holo__vignette"></div>
+        </div>
+
+        <div class="map-holo__title">
+            <b>中央“一张图”</b>
+            <span>{{ modeLabel }} · 只读展示 · 管网与设备态势待接入</span>
+        </div>
+
+        <div class="map-holo__layers">
+            <span>管线图层</span>
+            <span>监测点</span>
+            <span>事件定位</span>
+            <span>应急资源</span>
+        </div>
+
+        <!-- 事件四级配色(ADR 0008):1 级红 / 2 级黄 / 3 级蓝 / 4 级绿 -->
+        <div class="map-holo__legend">
+            <span><i class="map-holo__chip c1"></i>1级</span>
+            <span><i class="map-holo__chip c2"></i>2级</span>
+            <span><i class="map-holo__chip c3"></i>3级</span>
+            <span><i class="map-holo__chip c4"></i>4级</span>
+        </div>
+
+        <div class="map-holo__stage">
+            <p class="map-placeholder__title">中央一张图占位</p>
+            <p class="map-holo__hint">实现票 02 接入 Cesium 常驻 MapCanvas</p>
+        </div>
+
+        <div class="map-holo__status">
+            <i></i>
+            <span>底图与数据未接入 · 相机控制见票 03 · 图层见票 21/22</span>
+        </div>
+    </div>
+</template>
+
+<script setup>
+defineProps({
+    modeLabel: { type: String, default: '' },
+})
+</script>
+
+<style scoped>
+/* 组件内自带的盒模型重置(不依赖全局 `*` 规则)。 */
+.map-placeholder,
+.map-placeholder *,
+.map-placeholder *::before,
+.map-placeholder *::after {
+    box-sizing: border-box;
+}
+
+.map-placeholder {
+    position: relative;
+    width: 100%;
+    height: 100%;
+    min-height: 0;
+    overflow: hidden;
+    border: 1px solid var(--panel-line, rgba(58, 170, 226, 0.3));
+    background: linear-gradient(180deg, rgba(4, 20, 38, 0.9), rgba(2, 10, 20, 0.96));
+    box-shadow: inset 0 1px 0 rgba(130, 225, 255, 0.12), var(--shadow, 0 18px 44px rgba(0, 0, 0, 0.55));
+}
+
+/* ---------- 全息底:径向辉光 + 网格 + 扫描线 ---------- */
+.map-holo,
+.map-holo > div {
+    position: absolute;
+    inset: 0;
+    pointer-events: none;
+}
+
+.map-holo__base {
+    background:
+        radial-gradient(760px 420px at 50% 42%, rgba(20, 92, 150, 0.42), transparent 72%),
+        radial-gradient(900px 520px at 50% 108%, rgba(16, 74, 126, 0.3), transparent 70%),
+        linear-gradient(180deg, #03101f 0%, #04182c 48%, #02080f 100%);
+}
+
+.map-holo__grid {
+    opacity: 0.55;
+    background-image:
+        linear-gradient(rgba(70, 170, 230, 0.07) 1px, transparent 1px),
+        linear-gradient(90deg, rgba(70, 170, 230, 0.07) 1px, transparent 1px);
+    background-size: 58px 58px, 58px 58px;
+    mask-image: radial-gradient(120% 92% at 50% 40%, #000 34%, transparent 90%);
+}
+
+.map-holo__scan {
+    height: 22%;
+    top: -22%;
+    inset: auto 0 auto 0;
+    background: linear-gradient(180deg, transparent 0%, rgba(79, 216, 255, 0.16) 50%, transparent 100%);
+    animation: map-scan 7s linear infinite;
+}
+
+@keyframes map-scan {
+    0% { transform: translateY(0); opacity: 0; }
+    12% { opacity: 1; }
+    88% { opacity: 1; }
+    100% { transform: translateY(560%); opacity: 0; }
+}
+
+.map-holo__vignette {
+    background: radial-gradient(120% 96% at 50% 46%, transparent 52%, rgba(0, 6, 14, 0.72) 100%);
+}
+
+/* ---------- 标题 / 图层 / 图例 / 状态 ---------- */
+.map-holo__title {
+    position: absolute;
+    z-index: 2;
+    top: calc(14 * var(--u, 1px));
+    left: calc(16 * var(--u, 1px));
+}
+
+.map-holo__title b {
+    display: block;
+    color: #eaf9ff;
+    font-size: calc(17 * var(--u, 1px));
+    font-weight: 700;
+    letter-spacing: 0.12em;
+    text-shadow: 0 0 14px rgba(79, 216, 255, 0.7);
+}
+
+.map-holo__title span {
+    display: block;
+    margin-top: 3px;
+    color: var(--muted, #6d92ae);
+    font-size: calc(11.5 * var(--u, 1px));
+    letter-spacing: 0.04em;
+}
+
+.map-holo__layers {
+    position: absolute;
+    z-index: 2;
+    top: calc(62 * var(--u, 1px));
+    left: calc(16 * var(--u, 1px));
+    display: flex;
+    gap: calc(5 * var(--u, 1px));
+    flex-wrap: wrap;
+}
+
+.map-holo__layers span {
+    padding: calc(3 * var(--u, 1px)) calc(8 * var(--u, 1px));
+    border: 1px solid rgba(58, 170, 226, 0.34);
+    background: rgba(6, 30, 54, 0.7);
+    color: #bad4e0;
+    font-size: calc(10 * var(--u, 1px));
+    letter-spacing: 0.04em;
+}
+
+.map-holo__legend {
+    position: absolute;
+    z-index: 2;
+    top: calc(16 * var(--u, 1px));
+    right: calc(16 * var(--u, 1px));
+    display: flex;
+    gap: calc(10 * var(--u, 1px));
+    color: var(--muted, #6d92ae);
+    font-size: calc(11 * var(--u, 1px));
+}
+
+.map-holo__legend span {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+}
+
+.map-holo__chip {
+    width: calc(9 * var(--u, 1px));
+    height: calc(9 * var(--u, 1px));
+    border-radius: 50%;
+}
+
+.map-holo__chip.c1 { background: var(--lv1, #ff4d5e); box-shadow: 0 0 8px var(--lv1, #ff4d5e); }
+.map-holo__chip.c2 { background: var(--lv2, #ffa63d); box-shadow: 0 0 8px var(--lv2, #ffa63d); }
+.map-holo__chip.c3 { background: var(--lv3, #4fd8ff); box-shadow: 0 0 8px var(--lv3, #4fd8ff); }
+.map-holo__chip.c4 { background: var(--lv4, #35e39a); box-shadow: 0 0 8px var(--lv4, #35e39a); }
+
+/* 中央占位说明 */
+.map-holo__stage {
+    position: absolute;
+    z-index: 2;
+    inset: 0;
+    display: grid;
+    place-content: center;
+    gap: 6px;
+    text-align: center;
+}
+
+.map-placeholder__title {
+    margin: 0;
+    color: #eaf9ff;
+    font-size: calc(16 * var(--u, 1px));
+    font-weight: 700;
+    letter-spacing: 0.14em;
+    text-shadow: 0 0 16px rgba(79, 216, 255, 0.6);
+}
+
+.map-holo__hint {
+    margin: 0;
+    color: var(--muted, #6d92ae);
+    font-size: calc(11.5 * var(--u, 1px));
+}
+
+.map-holo__status {
+    position: absolute;
+    z-index: 2;
+    left: calc(16 * var(--u, 1px));
+    bottom: calc(12 * var(--u, 1px));
+    display: flex;
+    align-items: center;
+    gap: 7px;
+    color: var(--muted, #6d92ae);
+    font-size: calc(11 * var(--u, 1px));
+}
+
+.map-holo__status i {
+    width: calc(7 * var(--u, 1px));
+    height: calc(7 * var(--u, 1px));
+    border-radius: 50%;
+    background: var(--lv2, #ffa63d);
+    box-shadow: 0 0 8px var(--lv2, #ffa63d);
+    animation: map-breathe 1.8s ease-in-out infinite;
+}
+
+@keyframes map-breathe {
+    0%, 100% { opacity: 1; transform: scale(1); }
+    50% { opacity: 0.35; transform: scale(0.72); }
+}
+</style>

+ 42 - 0
src/modules/cockpit/domain/filterOptions.js

@@ -0,0 +1,42 @@
+/**
+ * 全局筛选的选项与默认值(ADR 0002 / 0004:行业与区域是作用于整个驾驶舱的全局条件)。
+ *
+ * 纯数据 + 纯函数,不依赖 Vue,便于直接断言。
+ * 当前只供 T01 外壳的范围/行业按钮占位使用;真正的联动过滤在后续票接入。
+ */
+
+/** 区域范围。原型演示值,待业务确认正式清单。 */
+export const SCOPES = [
+    { key: 'all', label: '全域' },
+    { key: 'south', label: '城南' },
+    { key: 'north', label: '城北' },
+    { key: 'taichang', label: '太常' },
+]
+
+/** 行业筛选。燃气 / 供水 / 排水(含雨水、污水与环境监测)/ 井盖,见 ADR 0002。 */
+export const INDUSTRIES = [
+    { key: 'all', label: '全部' },
+    { key: 'gas', label: '燃气' },
+    { key: 'water', label: '供水' },
+    { key: 'drainage', label: '排水' },
+    { key: 'manhole', label: '井盖' },
+]
+
+export const DEFAULT_SCOPE = 'all'
+export const DEFAULT_INDUSTRY = 'all'
+
+function isKnown(options, key) {
+    return options.some((item) => item.key === key)
+}
+
+export function isKnownScope(key) {
+    return isKnown(SCOPES, key)
+}
+
+export function isKnownIndustry(key) {
+    return isKnown(INDUSTRIES, key)
+}
+
+export function isDefaultFilter(scope, industry) {
+    return scope === DEFAULT_SCOPE && industry === DEFAULT_INDUSTRY
+}

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

@@ -0,0 +1,106 @@
+/**
+ * 驾驶舱一级模式与二级面板目录。
+ *
+ * 稳定英文键是唯一状态标识;中文只用于展示(见 ADR 0003 与 CONTEXT.md)。
+ * 本文件是纯逻辑,不引用 Vue、DOM 或任何接口字段,便于直接单测。
+ */
+
+/** 一级模式英文键。 */
+export const MODES = ['overview', 'infrastructure', 'monitoring', 'warningResponse']
+
+/** 一级模式展示名。 */
+export const MODE_LABELS = {
+    overview: '总体概览',
+    infrastructure: '基础设施',
+    monitoring: '运行监测',
+    warningResponse: '预警处置',
+}
+
+/** 二级面板展示名,按 panelId 解析;业务逻辑不读中文。 */
+export const PANEL_LABELS = {
+    situationSummary: '态势总览',
+    infrastructureSummary: '基础设施',
+    monitoringSummary: '运行监测',
+    alertSettings: '预警设置',
+    archive: '基础档案',
+    operation: '运行信息',
+    management: '管理信息',
+    emergency: '应急保障',
+    monitoringOverview: '监测概览',
+    monitoringDistribution: '监测分布',
+    operationOverview: '运行概况',
+    monitoringPointLedger: '监测点台账',
+    currentEvents: '当前预警',
+    historyAnalysis: '历史分析',
+    responseEfficiency: '预警效率',
+    eventAnalysis: '预警分析',
+}
+
+/**
+ * 模式 → 左右两栏面板,顺序固定(ADR 0003)。
+ * panelId 扁平且全局唯一,不带模式前缀。
+ */
+const COLUMNS = {
+    overview: {
+        left: ['situationSummary', 'infrastructureSummary'],
+        right: ['monitoringSummary', 'alertSettings'],
+    },
+    infrastructure: {
+        left: ['archive', 'operation'],
+        right: ['management', 'emergency'],
+    },
+    monitoring: {
+        left: ['monitoringOverview', 'monitoringDistribution'],
+        right: ['operationOverview', 'monitoringPointLedger'],
+    },
+    warningResponse: {
+        left: ['currentEvents', 'historyAnalysis'],
+        right: ['responseEfficiency', 'eventAnalysis'],
+    },
+}
+
+export function isKnownMode(mode) {
+    return MODES.includes(mode)
+}
+
+export function isKnownPanel(panelId) {
+    return Object.prototype.hasOwnProperty.call(PANEL_LABELS, panelId)
+}
+
+export function modeLabel(mode) {
+    return MODE_LABELS[mode] || mode
+}
+
+export function labelForPanel(panelId) {
+    return PANEL_LABELS[panelId] || panelId
+}
+
+/** 返回模式的左右两栏面板;未知模式直接报错,避免静默渲染空壳。 */
+export function columnsForMode(mode) {
+    const columns = COLUMNS[mode]
+    if (!columns) {
+        throw new Error(`未知一级模式: ${mode}`)
+    }
+    return { left: [...columns.left], right: [...columns.right] }
+}
+
+/** 返回模式的全部面板,左栏在前。 */
+export function panelsForMode(mode) {
+    const { left, right } = columnsForMode(mode)
+    return [...left, ...right]
+}
+
+/** 进入模式时的默认展开集合:全部面板展开。 */
+export function defaultExpandedPanelIds(mode) {
+    return panelsForMode(mode)
+}
+
+/**
+ * 收起或展开单个面板,返回新集合(不改原数组)。
+ * 纯函数,store 只做薄封装,便于脱离 Vue 单测。
+ */
+export function toggleExpanded(expandedPanelIds, panelId) {
+    return expandedPanelIds.includes(panelId)
+        ? expandedPanelIds.filter((id) => id !== panelId)
+        : [...expandedPanelIds, panelId]
+}

+ 44 - 0
src/modules/cockpit/stores/filterStore.js

@@ -0,0 +1,44 @@
+import { ref } from 'vue'
+import { defineStore } from 'pinia'
+import {
+    DEFAULT_INDUSTRY,
+    DEFAULT_SCOPE,
+    isKnownIndustry,
+    isKnownScope,
+} from '../domain/filterOptions.js'
+
+/**
+ * 全局筛选(ADR 0004 的 filterStore)。
+ *
+ * 作用于整个驾驶舱:切换一级模式不得重置这里的任何状态(T01 验收第 ⑦ 条)。
+ * 当前只持区域与行业;状态、地面/管线视图等后续票再并入。
+ */
+export const useFilterStore = defineStore('filterStore', () => {
+    /** 当前范围(区域)。 */
+    const scope = ref(DEFAULT_SCOPE)
+
+    /** 当前行业。 */
+    const industry = ref(DEFAULT_INDUSTRY)
+
+    /** 切换范围;未知键忽略,避免脏值进入全局条件。 */
+    function setScope(next) {
+        if (isKnownScope(next)) {
+            scope.value = next
+        }
+    }
+
+    /** 切换行业;未知键忽略。 */
+    function setIndustry(next) {
+        if (isKnownIndustry(next)) {
+            industry.value = next
+        }
+    }
+
+    /** 回到默认筛选(全域 / 全部行业)。 */
+    function reset() {
+        scope.value = DEFAULT_SCOPE
+        industry.value = DEFAULT_INDUSTRY
+    }
+
+    return { scope, industry, setScope, setIndustry, reset }
+})

+ 42 - 0
src/modules/cockpit/stores/uiStore.js

@@ -0,0 +1,42 @@
+import { ref } from 'vue'
+import { defineStore } from 'pinia'
+import { MODES, defaultExpandedPanelIds, toggleExpanded } from '../domain/panelCatalog.js'
+
+const DEFAULT_MODE = 'overview'
+
+/**
+ * 驾驶舱外壳状态(ADR 0004 的 uiStore)。
+ *
+ * 只持有外壳场景:当前一级模式与面板展开集合。
+ * 行业、区域、状态、地面/管线视图属于 filterStore,不在这里。
+ */
+export const useUiStore = defineStore('uiStore', () => {
+    /** 当前一级模式。 */
+    const mode = ref(DEFAULT_MODE)
+
+    /** 已展开的二级面板集合。 */
+    const expandedPanelIds = ref(defaultExpandedPanelIds(DEFAULT_MODE))
+
+    /**
+     * 切换一级模式:换一套面板并重置为默认展开。
+     * 不重置全局筛选(行业等),因为那不是外壳场景。
+     */
+    function setMode(nextMode) {
+        if (!MODES.includes(nextMode) || nextMode === mode.value) {
+            return
+        }
+        mode.value = nextMode
+        expandedPanelIds.value = defaultExpandedPanelIds(nextMode)
+    }
+
+    /** 收起或展开单个面板;纯逻辑在 domain/panelCatalog。 */
+    function togglePanel(panelId) {
+        expandedPanelIds.value = toggleExpanded(expandedPanelIds.value, panelId)
+    }
+
+    function isExpanded(panelId) {
+        return expandedPanelIds.value.includes(panelId)
+    }
+
+    return { mode, expandedPanelIds, setMode, togglePanel, isExpanded }
+})

+ 10 - 0
src/pages/cockpit/index.vue

@@ -0,0 +1,10 @@
+<!--
+  /cockpit 入口。保持薄:只挂载外壳,业务都在 src/modules/cockpit(ADR 0006/0007)。
+-->
+<template>
+    <CockpitShell />
+</template>
+
+<script setup>
+import CockpitShell from '@/modules/cockpit/CockpitShell.vue'
+</script>