Bläddra i källkod

feat(cockpit): paginate operating-information metrics and state block title honestly (#64)

维护者 2026-10-19 反馈第 2 条:基础设施「运行信息」面板没有分页,块标题「指标占位」
与实际内容不符。实测 /infrastructure/operating-information 一次回 6772 条指标
(GAS 699 / WATER_SUPPLY 2497 / DRAINAGE 3576)、响应 2.3 MB,而前端全量直渲。

- 块标题 指标占位 -> 运行指标;新增 sourceNote 如实说明数据来源与口径
  (指标按设施实体逐条生成、一条对应一个设施、取后端回显行业、不跨行业合并)。
- 该块接入统一分页(每页 <=10、页码直达、输入框跳页、一页不画控件),复用批次一的
  domain/pagination.js 与 PagerNav.vue,本票零新翻页算术:分页由 domain 逐块声明
  (listBlock 可选 paginate/pageScope),同一个 list 画法服务的其它清单
  (管理信息 9 行为规范排除项、应急保障归票 63)未被顺手接入。
- 顺带修真实缺陷:燃气 699 条指标共用同一个 metricCode,旧行键「行业|指标码」整块
  碰撞(699 张卡共用一个 :key),分页把潜伏问题变成每帧都碰;已补唯一键,行序不改。
- 复位走显式清槽 resetListPages(条文 5/6):第一版把筛选条件编进页码键,会让
  「切走再切回」停在旧页,违反条文 6;已改并与票 46/59 成例对齐。
- 验证:新增 t64 闸门 61/61 + 变异 17/17 打红按字节还原(含真实载荷 70 页逐页与
  领域切片对账);t59/t56/t44/t46/t20 等既有闸门面在模拟提交树内复跑全绿。
  按 AGENTS.md 跳过 build/dev/浏览器验证,真机走查归票 18 联调窗口。
- 收口时新暴露「同页 10 行标题相同、仅差设施编码尾号」的行级可读性缺陷,
  另立票 73(grilling)等维护者裁定。

整文件提交(维护者选定范围)携带两处连贯性依赖:PipeColorLegend.vue 被本组
CockpitShell.vue 的票 48 hunks 引用(不带上则远端运行时断裂);contractMocks.js 的
票 49/50/51/08 已收口 mock 侧是 t64 M 组断言的前提(不带上 M3 判红)。
t51 真实载荷随闸门入库(本仓 verify/artifacts 已有 410 文件先例)。
ChenJZ 4 timmar sedan
förälder
incheckning
0117a5eff7
23 ändrade filer med 1720 tillägg och 60 borttagningar
  1. 90 0
      .scratch/cockpit-real-data-wayfinding/issues/64-6772-2-3-mb.md
  2. 51 0
      .scratch/cockpit-real-data-wayfinding/issues/73-10.md
  3. 19 5
      .scratch/cockpit-real-data-wayfinding/map.md
  4. 0 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t51/oi-DRAINAGE.json
  5. 0 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t51/oi-GAS.json
  6. 1 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t51/oi-MANHOLE.json
  7. 0 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t51/oi-WATER_SUPPLY.json
  8. 0 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t51/oi-unfiltered.json
  9. 7 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t64/probe-first-page.mjs
  10. 20 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t64/probe-shell-compile.mjs
  11. 98 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t64/probe-t64-dupkey.mjs
  12. 107 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t64/probe-t64-pages.mjs
  13. 42 0
      .scratch/cockpit-real-data-wayfinding/verify/artifacts/t64/probe-t64-ssr.mjs
  14. 4 1
      .scratch/cockpit-real-data-wayfinding/verify/t56-panel-summary-disclosure.mjs
  15. 299 0
      .scratch/cockpit-real-data-wayfinding/verify/t64-mutations.mjs
  16. 511 0
      .scratch/cockpit-real-data-wayfinding/verify/t64-operation-metrics-pagination.mjs
  17. 31 1
      src/modules/cockpit/CockpitShell.vue
  18. 184 11
      src/modules/cockpit/components/PanelBlocks.vue
  19. 110 0
      src/modules/cockpit/components/PipeColorLegend.vue
  20. 76 22
      src/modules/cockpit/domain/infrastructurePanels.js
  21. 33 5
      src/modules/cockpit/domain/panelBlocks.js
  22. 35 14
      src/modules/cockpit/mocks/contractMocks.js
  23. 2 1
      src/modules/cockpit/services/infrastructureApi.js

+ 90 - 0
.scratch/cockpit-real-data-wayfinding/issues/64-6772-2-3-mb.md

@@ -0,0 +1,90 @@
+<!-- DSH-IDEMPOTENCY-KEY: pagination-batch5b-operation-metrics-v1 -->
+<!-- parentKey: 00 -->
+<!-- created: 2026-10-19T06:05:14.153Z -->
+# 前端执行票 —— 运行信息指标列表分页(实测 6772 条 / 2.3 MB)与「指标占位」块名纠正
+
+## Question
+
+维护者 2026-10-19 反馈第 2 条:基础设施「运行信息」面板没有分页,且块标题「指标占位」与实际内容不符(意义不明)。
+
+**实测(2026-10-19,`192.168.110.96:8301` 只读接口)**:`/infrastructure/operating-information` 一次返回 **6772 条指标**(GAS 699/WATER_SUPPLY 2497/DRAINAGE 3576/MANHOLE 0),响应体 **2.3 MB**;前端 `operationPanel` 用 `listBlock('指标占位', metrics)` 全量直渲。这是一个行业一屏就画几千条卡片——比监测点台账严重得多的「无限拉长页面」。
+
+**根因**:票 57 首版规范把它列为「排除项」,依据是代码推断的「单行业指标条数契约有界」;真实数据证伪(见规范修订记录)。同时块标题「指标占位」是占位期遗留词,实际是真实指标列表。
+
+## Acceptance
+
+- 运行信息指标列表接入统一分页:每页 ≤10 条、页码直达、输入框跳页、一页不画控件(复用批次一 `PagerNav.vue`)。
+- **块标题如实**:「指标占位」改为反映真实语义的说法(如「运行指标」),不再用占位期措辞;若维护者另有命名偏好,在票面记录裁定后落地。
+- 先按当前行业聚合的指标集合分页(`selectedIndustry` 回显块),**不在本票发明跨行业合并口径**。
+- 面板如实说明数据来源与口径(指标来自设施实体适配器、`pointCodes` 是设施编码——票 49/50 既有裁定,不重开)。
+- 换行业/片区筛选时页码复位;数据刷新不复位、越界夹回。
+- 闸门 + 变异打红;按 AGENTS.md 跳过 build/dev/浏览器验证。
+
+## Context
+
+- 代码:`domain/infrastructurePanels.js` 的 `operationPanel`(`listBlock('指标占位', …)`)、`services/infrastructureApi.js`(`/infrastructure/operating-information`)。
+- 既有裁定:[票 49](../cockpit-point-locate-and-device-link/issues/49-49-operating-information-pointcodes.md)(`pointCodes` 是设施编码)、[票 50](../cockpit-real-data-wayfinding/issues/50-operation-locate-buttons-removal.md)(撤定位按钮)、[票 51](../cockpit-real-data-wayfinding/issues/51-operation-industry-block-selection.md)(按回显 `selectedIndustry` 定位块)。
+- 规范:[全局分页规范](../specs/2026-10-18-global-list-pagination.md) 盘点表「基础设施 · 运行信息指标列表」。
+
+## Related spec
+
+[`../specs/2026-10-18-global-list-pagination.md`](../specs/2026-10-18-global-list-pagination.md)
+
+## 进度
+
+<!-- 做到哪一步了,在这里一行一行记 -->
+
+2026-10-19 认领并实施完毕(闸门 + 变异闭环)。**改动未提交**(工作树混有并行在制品,裁剪清单见下)。
+
+- [x] 领域层:块标题 `指标占位` → **`运行指标`**;`listBlock` 新增可选 `paginate` / `pageScope` 声明(不传时块形状一字不改,既有调用零影响)。
+- [x] 领域层:新增 `sourceNote`(数据来源与口径),并入票 56 的折叠摘要表(`data-panel-source-note` 断言面)。
+- [x] 单源:`domain/pagination.js` 增加 `listPageKey` / `readListPage` / `writeListPage` / `resetListPages`(页码寻址与复位也只有一份)。
+- [x] 渲染层:`PanelBlocks.vue` 的 list 分支接共享 `PagerNav.vue`(页码直达 + 跳页 + 一页不画控件),行数由 `windowOf(block)` 给;仅对 domain 声明的块切窗口。
+- [x] 外壳:`panelViewOf('operation')` 传 `{ scope }` 给 domain 算 `pageScope`(换片区复位判据)。
+- [x] **顺带修的真实缺陷**(见 Answer 第 3 条):指标行 `key` 在真实载荷里整块碰撞(燃气 699 条同 `metricCode`)。
+- [x] 闸门 [t64](../verify/t64-operation-metrics-pagination.mjs) **61/61** + 变异 [t64-mutations](../verify/t64-mutations.mjs) **17/17** 打红按字节还原。
+- [x] 既有闸门回归:t59 37/37、t56 33/33(+ 变异 9/9)、t51 20/20(+ 3/3)、t50 15/15(+ 4/4)、t44 109/109(+ 6/6)、t46(+ 12/12)、t38/t39/t40/t27/t54/t55/t20 全绿。
+- [x] 规范一致性补漏:`specs/2026-10-18-global-list-pagination.md` 第 13 行的排除清单**仍写着「运行信息指标占位」**,与同文件 2026-10-19 修订记录自相矛盾 → 已删该词条并把它补进「前端集合分页」清单。
+- [ ] 真实浏览器走查:按 AGENTS.md 未自动执行,待维护者授权(同票 39/48/50/51 收口方式)。
+- [x] **mock 样例扩面问题已裁定(2026-10-19 维护者)**:mock 每个行业块只有 2–3 条指标(实测:GAS/WATER_SUPPLY 3、DRAINAGE 2),`pageCount` 恒为 1 ⇒ 默认 mock 环境下整块 pager 不出现(符合条文 4)。曾问是否把样例补到 >10 条/块用于当场演示——**裁定:可以补但没有意义,验收与走查一律以真实数据为准**。已升格为本图 Notes 的常设偏好 + 规范「维护者裁定」第 3 条(批次二~五同循,不再各自来问)。多页行为维持由闸门用真实载荷证明(t64 V 组 70 页逐页对账);真实分页观感归[票 18](18-frontend-real-integration-gate.md) 的真实联调窗口。
+
+Status: resolved
+Assignees: agent
+Type: task
+Blocked by: #59
+Labels: wayfinder:task
+
+## Comments
+
+2026-10-19(实施者):块标题取 **`运行指标`** —— 票面 Acceptance 给的示例词,与面板名「运行信息」、空态文案「当前行业没有运行指标」、领域函数 `toOperatingMetric` 同一套词,不新造第四种说法。维护者若另有偏好,改 `infrastructurePanels.js` 里那一处字面量即可(标题是单源,闸门 D1/S4 会跟着判红提示)。
+
+2026-10-19(实施者):既有闸门两处按成例处理,均与本票判据语义无关——
+① [t56](../verify/t56-panel-summary-disclosure.mjs) 的 `EXPECTED` 表补 `sourceNote` 条目:**是它的 C 组双向普查自己判红的**(domain 新增说明字段不进折叠表=漏网隐形),不是本票绕过守卫;补完后 33/33 + 变异 9/9 全绿。
+② [t50-mutations](../verify/t50-mutations.mjs) M4 的变异锚点未命中(BAD):票 50 落盘后该行末句又被未提交改动改成「(地下管线详情视图)」,脚本里留的是「(重点对象局部增强)」——**锚点过期,不是缺陷复活**;本票未动过 `locateNote`(`git diff` 零交集已核)。仅重新对准锚点,判据(退回旧说法必须红)一字未动,改后 4/4。
+
+## Answer
+
+**验收逐条对账**(判据与证据都在 [t64 闸门](../verify/t64-operation-metrics-pagination.mjs),61 条断言,都不启浏览器、不联网):
+
+1. **接入统一分页** ✅ 复用批次一的 `domain/pagination.js` + `PagerNav.vue`,本票**零新翻页算术**:真实燃气 699 条 → 首屏只画 10 张卡、计数行「第 1/70 页 · 共 699 条」;页码直达 + 输入框跳页 + 到边 disabled 保占位 + 一页不画控件(S1/S2/S3/S7)。V12 是**逐页全扫**——70 页每一页画出来的 key/标题/读数必须等于领域行的对应切片(内容级判据;只数卡片数抓不到「页码显示对、画错行」)。
+
+2. **块标题如实** ✅ `运行指标`;「指标占位」在视图产物与真实渲染的 DOM 里**彻底不存在**(D1/S4,变异 M2 退回旧词当场转红)。
+
+3. **顺带修掉一个真实缺陷(分页把它从理论问题变成每帧都碰的问题)** 真实载荷里**燃气 699 条指标全是同一个 `metricCode`**(`gas_pipe_network_length`「太常片区燃气长度」,逐设施一条),旧行键 `${行业}|${指标码}` 于是**整块碰撞**:699 张卡共用一个 `:key`,而清单卡是 keyed v-for → 节点身份不存在。实测旧写法翻页内容仍正确(靠下标兜底),但这是运气不是保证;已给键加序号后缀(D7 判 699/699 唯一;变异 M4 退回旧键当场转红)。**没有**重排行序(D6 逐条对后端返回序,票 10/11「前端不重排」立场不变)。
+
+4. **按当前行业聚合、不发明合并口径** ✅ 分页的集合就是票 51 定位出的 `active` 块那一条 `metrics`,领域层仍交出**整块 699 行**(D5)——分页只切展示窗口,不裁数据、不改口径。
+
+5. **如实说明来源与口径** ✅ 新增 `sourceNote`:「运行指标由后端按设施实体逐条生成(水厂/泵站/管网/排水设施四类适配器),一条指标对应一个设施;当前行业取后端回显,不跨行业合并」。走票 56 既有折叠摘要(`v-show`,文本在 DOM 里不丢,S5)。票 49/50 裁定不重开:行 `points` 恒空、`locate` 恒空(D13)。
+
+6. **复位与保持** ✅ 换行业/换片区 → 页码回第 1 页(V4/V5);定时刷新 → 页码保持(V3);跳页超界 → 由 `pagedRows` 夹回最后一页、不画空表(V8/R5)。
+   **这一条返过工,教训留在这里**:第一版把筛选条件编进页码归属键,宣称「复位由构造成立、不写 watch」。对抗式自查时拿规范条文 6 逐字核对,发现它**切走再切回会读到旧槽位停在第 3 页**(「切回燃气」同样是一次条件变化),违反条文 6;且与票 46/59「换设备显式清页码」的既有成例分叉。改成显式清槽(`resetListPages`,只清点名的块),并补 V11a/b/c 三条断言钉住「切走—切回也回第 1 页」;变异 M16 成对复现第一版设计(条件进键 **且** 无清槽),只有 V11c 判得出——V4/V5/V3 全绿。**注**:只改「进键」而保留 watch 是**等价变异**(实测假绿,行为与正确版一致),故不列为有牙判据。
+
+7. **排除项没被顺手接入** ✅ 管理信息片区×行业明细(实测 9 行)不声明、不画控件、9 行照旧全画(N1)。**N8 才是这条的牙**:另造一个「12 行但未声明分页」的块,必须仍全量画出零控件——N1 的 9 行本来就 ≤10,抓不住「渲染层不分声明一律切窗口」(变异 M1 对应 N8)。`listBlock` 不传 `options` 时块形状一字不改(N2/N3)。
+
+8. **闸门 + 变异打红** ✅ t64 61/61、t64-mutations 17/17 全部被对应断言抓住并按字节还原。
+
+**验证层级如实说明**:判据覆盖到「真实载荷 → 领域层 → 共享渲染层(SSR + Vue 自定义渲染器真实 patch)」,含逐页内容对账与交互驱动;**未**在真实浏览器里走查(AGENTS.md 项目规则,且本轮未获授权),因此视觉排布(pager 在面板栏宽内的换行/贴边)未取证实物证据。按项目规则已跳过 build/dev/preview 验证。
+
+**落盘资产**:闸门 [verify/t64-operation-metrics-pagination.mjs](../verify/t64-operation-metrics-pagination.mjs)、变异 [verify/t64-mutations.mjs](../verify/t64-mutations.mjs);诊断探针(含 70 页全扫与「重复 key 会不会画错内容」的实测)留档在 [verify/artifacts/t64/](../verify/artifacts/t64/);载荷沿用票 51 的 `verify/artifacts/t51/`(未新取数)。
+
+**改动文件(本票范围,6 处)**:`domain/infrastructurePanels.js`(标题 / 分页声明 / 行键 / `sourceNote` / `pageScope`)、`domain/panelBlocks.js`(`listBlock` 可选第 4 参)、`domain/pagination.js`(**该文件由票 59 创建、同样尚未提交**,本票在其上追加 `listPageKey`/`readListPage`/`writeListPage`/`resetListPages`)、`components/PanelBlocks.vue`(分页状态 + 条件变化清槽 + 共享控件接线)、`CockpitShell.vue`(把当前片区传给运行信息面板)、`services/infrastructureApi.js`(一行注释与既成事实对齐)。`.scratch` 侧:本票票面、[map](../map.md)、[规范](../specs/2026-10-18-global-list-pagination.md) 排除清单矛盾修正、新票 [73](issues/73-10.md),verify 侧 `t64-operation-metrics-pagination.mjs` / `t64-mutations.mjs` / `t56-panel-summary-disclosure.mjs`(补 `sourceNote` 条目)/ `t50-mutations.mjs`(M4 锚点重新对准)/ `artifacts/t64/` 探针。工作树混有并行在制品,入库须单独裁剪这些 hunks。

+ 51 - 0
.scratch/cockpit-real-data-wayfinding/issues/73-10.md

@@ -0,0 +1,51 @@
+<!-- DSH-IDEMPOTENCY-KEY: operation-metric-row-title-legibility-v1 -->
+<!-- parentKey: 00 -->
+<!-- created: 2026-10-19T12:23:05.534Z -->
+# 运行指标逐行标题不可辨识(同页 10 行同名,仅差设施编码尾号)
+
+## Question
+
+票 64 给运行信息指标列表接上分页后,**第一页 10 张卡片的标题一模一样**(2026-10-19 真实载荷实测,`/infrastructure/operating-information` 燃气块):
+
+```
+太常片区燃气长度 | 1.03 m | TCRQ202609080001
+太常片区燃气长度 | 1.00 m | TCRQ202609080002
+...(10 条只差末位设施编码序号)
+```
+
+块级标题已按票 64 纠正为「运行指标」,但**逐行标题**是后端 `metricName` 直出:燃气整块 699 条只有 2 个指标名(太常 586 / 城南 113),供水 2497 条里「太常片区供水长度」1360 条同名,排水 3576 条里「太常片区雨水长度」1657 条同名。分页把「一屏几千张」收成「一屏十张」,但十行同题、只有编码尾号不同,值班人员仍然读不出区别。
+
+**要决策的是每行的可辨识标题**,不是分页本身(分页已由票 64 落地)。候选(只用后端 `metricName` 与行上已有字段,不新造第二数据源):
+- A 标题带设施编码:`太常片区燃气长度 · TCRQ202609080001`(票 49 裁定该码就是设施码,语义正确;但 20 字符进标题很挤)
+- B 标题带设施名(设施名当前不在指标行上,需后端补字段 → 另开后端票)
+- C 保持后端原样,靠左侧色键 + meta 区分(负荷/新鲜度/异常)——实测同页十行同为 NORMAL 同色时仍不可辨
+- D 其它维护者偏好的措辞
+
+**边界**:不在本票发明跨行业合并口径、不重排行序(票 51/64 立场);票 49/50 裁定(`pointCodes` 是设施编码、本面板不产定位入口)不重开。
+
+## Acceptance
+
+- 维护者就「每行标题怎么读得出区别」给一句裁定(A/B/C/D 或另定)。
+- 裁定若是纯前端拼接(A/C):落 `operationPanel` 行 `title` 单源,并把「同页标题两两可辨」补成 t64 的逐页判据;按 AGENTS.md 跳过 build/dev/浏览器验证。
+- 裁定若需后端补设施名(B):本票收口为决策记录,另开后端执行票(受本图 Notes「后端票须在仓库/负责人/权限明确后开启」约束)。
+- 若裁定为「保持现状」,如实记为决定并关闭本票,不硬造判据。
+
+## Context
+
+- 代码:`domain/infrastructurePanels.js` 的 `operationPanel`(行 `title: metric.name`)。
+- 上位票:[票 64](64-6772-2-3-mb.md)(分页 + 块名纠正,已关闭);[票 49](49-49-operating-information-pointcodes.md)、[票 50](50-operation-locate-buttons-removal.md)(设施编码语义)。
+- 证据载荷:`verify/artifacts/t51/`(复用票 51 落盘,未新取数);实测探针 `verify/artifacts/t64/probe-first-page.mjs`。
+
+## 进度
+
+<!-- 做到哪一步了,在这里一行一行记 -->
+
+Status: ready-for-agent
+Type: grilling
+Blocked by: None
+Labels: wayfinder:task
+
+## Comments
+
+
+## Answer

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 19 - 5
.scratch/cockpit-real-data-wayfinding/map.md


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t51/oi-DRAINAGE.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t51/oi-GAS.json


+ 1 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t51/oi-MANHOLE.json

@@ -0,0 +1 @@
+{"msg":"操作成功","code":200,"data":{"requestId":"6d28cef6d9ea47f999568b0d83af0808","apiVersion":"v1","readOnly":true,"displayName":"运行信息","timezone":"Asia/Shanghai","range":{"type":"LAST_24_HOURS","start":"2026-10-15T14:27:16.1169688+08:00","end":"2026-10-16T14:27:16.1169688+08:00"},"updatedAt":"2026-09-14T15:56:10+08:00","freshness":{"status":"EMPTY","reason":"NO_METRICS","updatedAt":"2026-09-14T15:56:10+08:00","serverUpdatedAt":"2026-10-16T14:27:16.5200871+08:00"},"selectedIndustry":{"code":"MANHOLE","key":"manhole","label":"MANHOLE"},"industries":[{"industry":{"code":"GAS","key":"gas","label":"燃气"},"state":"EMPTY","stateReason":"NO_METRICS","metrics":[],"abnormalMetricCount":0,"mapFilter":{"industry":"GAS","metricCodes":[],"pointCodes":[]}},{"industry":{"code":"WATER_SUPPLY","key":"waterSupply","label":"供水"},"state":"EMPTY","stateReason":"NO_METRICS","metrics":[],"abnormalMetricCount":0,"mapFilter":{"industry":"WATER_SUPPLY","metricCodes":[],"pointCodes":[]}},{"industry":{"code":"DRAINAGE","key":"drainage","label":"排水"},"state":"EMPTY","stateReason":"NO_METRICS","metrics":[],"abnormalMetricCount":0,"mapFilter":{"industry":"DRAINAGE","metricCodes":[],"pointCodes":[]}},{"industry":{"code":"MANHOLE","key":"manhole","label":"MANHOLE"},"state":"EMPTY","stateReason":"NO_METRICS","metrics":[],"abnormalMetricCount":0,"mapFilter":{"industry":"MANHOLE","metricCodes":[],"pointCodes":[]}}],"excluded":{"unknownIndustryCount":0,"nonFormalIndustryCount":0}}}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t51/oi-WATER_SUPPLY.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t51/oi-unfiltered.json


+ 7 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t64/probe-first-page.mjs

@@ -0,0 +1,7 @@
+import { readFileSync } from 'node:fs'
+const d = JSON.parse(readFileSync(new URL('../t51/oi-GAS.json', import.meta.url), 'utf8')).data
+const b = d.industries.find((x) => x.industry.code === 'GAS')
+console.log('前 10 条卡片(标题 | 当前读数 | 设施编码):')
+for (const m of b.metrics.slice(0, 10)) {
+    console.log(`  ${m.metricName} | ${m.value} ${m.unit} | ${(m.pointCodes || []).join(',')}`)
+}

+ 20 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t64/probe-shell-compile.mjs

@@ -0,0 +1,20 @@
+// t64 K 组补充:外壳 CockpitShell.vue 的 script 也必须编译过(本票改了它的 panelViewOf 分支)。
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
+
+// artifacts/t64 → artifacts → verify → effort → .scratch → repo root(5 级)
+const rel = '../../../../../src/modules/cockpit/CockpitShell.vue'
+const source = readFileSync(new URL(rel, import.meta.url), 'utf8')
+const { descriptor, errors } = parse(source, { filename: 'CockpitShell.vue' })
+assert.deepEqual(errors, [], 'CockpitShell parse')
+const script = compileScript(descriptor, { id: 't64' })
+const template = compileTemplate({
+    source: descriptor.template.content, filename: 'CockpitShell.vue', id: 't64',
+    compilerOptions: { bindingMetadata: script.bindings },
+})
+assert.deepEqual(template.errors, [], 'CockpitShell template')
+console.log('PASS CockpitShell.vue script+template 编译通过')
+// 本票接线:operation 面板要吃到当前片区键(换片区复位判据)
+assert.ok(/panelId === 'operation'\) extra = \{ scope: scope\.value \}/.test(source), '外壳没把 scope 传给运行信息面板')
+console.log('PASS 外壳把 scope 传给运行信息面板(换片区复位判据的输入)')

+ 98 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t64/probe-t64-dupkey.mjs

@@ -0,0 +1,98 @@
+// 探针:重复 :key 会不会让翻页画出错内容(合成载荷:key 全同、值各不同)。
+import { readFileSync } from 'node:fs'
+import { registerHooks } from 'node:module'
+import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
+import { createRenderer, h, ref, nextTick } from '@vue/runtime-core'
+
+const root = new URL('../../../../../src/modules/cockpit/', import.meta.url)
+registerHooks({
+    load(url, context, nextLoad) {
+        if (url.endsWith('.json')) return { format: 'module', shortCircuit: true, source: `export default ${readFileSync(new URL(url), 'utf8')}` }
+        if (url.endsWith('.vue')) {
+            const { descriptor } = parse(readFileSync(new URL(url), 'utf8'), { filename: new URL(url).pathname })
+            const script = compileScript(descriptor, { id: 'probe', genDefaultAs: '__sfc__' })
+            const template = compileTemplate({
+                source: descriptor.template.content, filename: new URL(url).pathname,
+                id: 'probe', compilerOptions: { bindingMetadata: script.bindings },
+            })
+            return { format: 'module', shortCircuit: true, source: `${script.content}\n${template.code}\n__sfc__.render = render; export default __sfc__;` }
+        }
+        return nextLoad(url, context)
+    },
+})
+
+const nodeOps = {
+    createElement: (tag) => ({ tag, props: {}, children: [], parent: null, text: '', style: {}, addEventListener() {}, removeEventListener() {} }),
+    createText: (text) => ({ tag: '#text', text, children: [], parent: null, props: {}, style: {}, addEventListener() {}, removeEventListener() {} }),
+    createComment: (text) => ({ tag: '#comment', text, children: [], parent: null, props: {}, style: {}, addEventListener() {}, removeEventListener() {} }),
+    setText: (node, text) => { node.text = text },
+    setElementText: (node, text) => { node.text = text; node.children = [] },
+    insert: (child, parent, anchor) => {
+        if (child.parent) child.parent.children = child.parent.children.filter((c) => c !== child)
+        child.parent = parent
+        if (!parent) return
+        if (anchor) { const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, child) }
+        else parent.children.push(child)
+    },
+    remove: (child) => { if (child.parent) child.parent.children = child.parent.children.filter((c) => c !== child); child.parent = null },
+    parentNode: (node) => node.parent,
+    nextSibling: (node) => (node.parent ? node.parent.children[node.parent.children.indexOf(node) + 1] || null : null),
+    querySelector: () => null,
+    setScopeId: () => {},
+    patchProp: (el, key, prev, next) => { el.props[key] = next },
+}
+const { createApp } = createRenderer(nodeOps)
+
+const { default: PanelBlocks } = await import(new URL('components/PanelBlocks.vue', root))
+const { operationPanel } = await import(new URL('domain/infrastructurePanels.js', root))
+const { section } = await import(new URL('domain/sectionState.js', root))
+
+function findAll(node, predicate, acc = []) {
+    if (!node) return acc
+    if (predicate(node)) acc.push(node)
+    for (const c of node.children || []) findAll(c, predicate, acc)
+    return acc
+}
+function textOf(node) {
+    if (!node) return ''
+    const own = node.tag === '#text' ? node.text : typeof node.props?.text === 'string' ? node.props.text : ''
+    return own + (node.children || []).map(textOf).join('')
+}
+
+// 合成载荷:699 条指标,metricCode 全同(真实 GAS 就是这个形状),value 各不相同 V0..V698
+const mk = (i) => ({
+    metricCode: 'same_code', metricName: `M${i}`, category: 'SUPPLY', unit: 'MPa',
+    value: String(i), capacityValue: '1', loadRatio: '0.5', loadState: 'NORMAL',
+    pointCodes: [`FAC${i}`], freshness: { status: 'CURRENT' },
+})
+const raw = { industries: [{ industry: { code: 'GAS', label: '燃气' }, state: 'READY', metrics: Array.from({ length: 699 }, (_, i) => mk(i)) }], excluded: {} }
+const data = operationPanel.length > 2 ? null : null
+const { toOperatingInformation } = await import(new URL('domain/infrastructurePanels.js', root))
+const view = operationPanel(toOperatingInformation(raw), section('success', { loaded: true }), { scope: 'all' })
+const rows = view.blocks[1].rows
+console.log('domain row keys distinct:', new Set(rows.map((r) => r.key)).size, '/', rows.length)
+console.log('row[0] meta:', rows[0].meta.join(' · '))
+console.log('row[11] meta:', rows[11].meta.join(' · '))
+
+const viewRef = ref(view)
+const app = createApp({ render: () => h(PanelBlocks, { view: viewRef.value }) })
+app.config.warnHandler = () => {}
+const container = nodeOps.createElement('#root')
+app.mount(container)
+
+function renderedMetas() {
+    return findAll(container, (n) => n.props && 'data-item' in n.props).map((card) => {
+        const metas = findAll(card, (n) => n.props && n.props['class'] && String(n.props['class']).includes('panel-item__meta'))
+        return metas.length ? textOf(metas[0]) : '(no meta)'
+    })
+}
+console.log('page1 rendered:', renderedMetas().slice(0, 2).join(' || '))
+const btn2 = findAll(container, (n) => String(n.props?.['data-page-number']) === '2')[0]
+btn2.props.onClick()
+await nextTick()
+console.log('page2 rendered:', renderedMetas().slice(0, 2).join(' || '))
+console.log('page2 expected:', rows[10].meta.join(' · '), '||', rows[11].meta.join(' · '))
+const btn70 = findAll(container, (n) => String(n.props?.['data-page-number']) === '70')[0]
+btn70?.props.onClick()
+await nextTick()
+console.log('page70 rendered:', renderedMetas().join(' || '))

+ 107 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t64/probe-t64-pages.mjs

@@ -0,0 +1,107 @@
+// 探针:真实载荷(GAS 699 条同码)下的分页正确性 —— 逐页读数必须等于 domain 行读数。
+import { readFileSync } from 'node:fs'
+import { registerHooks } from 'node:module'
+import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
+import { createRenderer, h, ref, nextTick } from '@vue/runtime-core'
+
+const root = new URL('../../../../../src/modules/cockpit/', import.meta.url)
+registerHooks({
+    load(url, context, nextLoad) {
+        if (url.endsWith('.json')) return { format: 'module', shortCircuit: true, source: `export default ${readFileSync(new URL(url), 'utf8')}` }
+        if (url.endsWith('.vue')) {
+            const { descriptor } = parse(readFileSync(new URL(url), 'utf8'), { filename: new URL(url).pathname })
+            const script = compileScript(descriptor, { id: 'probe', genDefaultAs: '__sfc__' })
+            const template = compileTemplate({
+                source: descriptor.template.content, filename: new URL(url).pathname,
+                id: 'probe', compilerOptions: { bindingMetadata: script.bindings },
+            })
+            return { format: 'module', shortCircuit: true, source: `${script.content}\n${template.code}\n__sfc__.render = render; export default __sfc__;` }
+        }
+        return nextLoad(url, context)
+    },
+})
+const nodeOps = {
+    createElement: (tag) => ({ tag, props: {}, children: [], parent: null, text: '', style: {}, addEventListener() {}, removeEventListener() {} }),
+    createText: (text) => ({ tag: '#text', text, children: [], parent: null, props: {}, style: {}, addEventListener() {}, removeEventListener() {} }),
+    createComment: (text) => ({ tag: '#comment', text, children: [], parent: null, props: {}, style: {}, addEventListener() {}, removeEventListener() {} }),
+    setText: (n, t) => { n.text = t },
+    setElementText: (n, t) => { n.text = t; n.children = [] },
+    insert: (child, parent, anchor) => {
+        if (child.parent) child.parent.children = child.parent.children.filter((c) => c !== child)
+        child.parent = parent
+        if (!parent) return
+        if (anchor) { const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, child) }
+        else parent.children.push(child)
+    },
+    remove: (c) => { if (c.parent) c.parent.children = c.parent.children.filter((x) => x !== c); c.parent = null },
+    parentNode: (n) => n.parent,
+    nextSibling: (n) => (n.parent ? n.parent.children[n.parent.children.indexOf(n) + 1] || null : null),
+    querySelector: () => null,
+    setScopeId: () => {},
+    patchProp: (el, k, _p, next) => { el.props[k] = next },
+}
+const { createApp } = createRenderer(nodeOps)
+
+const { default: PanelBlocks } = await import(new URL('components/PanelBlocks.vue', root))
+const { operationPanel, toOperatingInformation } = await import(new URL('domain/infrastructurePanels.js', root))
+const { section } = await import(new URL('domain/sectionState.js', root))
+
+function findAll(node, predicate, acc = []) {
+    if (!node) return acc
+    if (predicate(node)) acc.push(node)
+    for (const c of node.children || []) findAll(c, predicate, acc)
+    return acc
+}
+function textOf(node) {
+    if (!node) return ''
+    // 元素若被 setElementText 直接落文本(Vue 对纯文本元素走这条路),文本在 node.text 上。
+    const own = node.tag === '#text' ? node.text : (typeof node.text === 'string' ? node.text : '') + (typeof node.props?.text === 'string' ? node.props.text : '')
+    return own + (node.children || []).map(textOf).join('')
+}
+
+const raw = JSON.parse(readFileSync(new URL('../t51/oi-GAS.json', import.meta.url), 'utf8')).data
+const data = toOperatingInformation(raw)
+const view = operationPanel(data, section('success', { data, loaded: true }), { scope: 'all' })
+const rows = view.blocks[1].rows
+console.log('row keys distinct:', new Set(rows.map((r) => r.key)).size, '/', rows.length)
+
+const viewRef = ref(view)
+const app = createApp({ render: () => h(PanelBlocks, { view: viewRef.value }) })
+app.config.warnHandler = () => {}
+const container = nodeOps.createElement('#root')
+app.mount(container)
+
+function rendered() {
+    return findAll(container, (n) => n.props && 'data-item' in n.props).map((card) => ({
+        key: card.props['data-item'],
+        title: textOf(findAll(card, (n) => n.props && 'class' in n.props && String(n.props['class']).includes('panel-item__title'))[0]),
+        meta: textOf(findAll(card, (n) => n.props && 'class' in n.props && String(n.props['class']).includes('panel-item__meta'))[0]),
+    }))
+}
+let bad = 0
+let pagesChecked = 0
+for (let page = 1; page <= 70; page += 1) {
+    const btn = findAll(container, (n) => String(n.props?.['data-page-number']) === String(page))[0]
+    if (!btn) { console.log('page button missing', page); bad += 1; continue }
+    btn.props.onClick()
+    await nextTick()
+    const got = rendered()
+    const want = rows.slice((page - 1) * 10, page * 10)
+    pagesChecked += 1
+    if (got.length !== want.length) { console.log(`page ${page}: rendered ${got.length} want ${want.length}`); bad += 1; continue }
+    for (let i = 0; i < want.length; i += 1) {
+        if (got[i].key !== want[i].key || got[i].meta !== want[i].meta.join(' · ') || got[i].title !== want[i].title) {
+            console.log(`page ${page} row ${i} mismatch:\n  got  ${JSON.stringify(got[i])}\n  want ${JSON.stringify({ key: want[i].key, title: want[i].title, meta: want[i].meta.join(' · ') })}`)
+            bad += 1
+            break
+        }
+    }
+}
+console.log(`pages checked: ${pagesChecked}, mismatches: ${bad}`)
+
+// 计数行 + 到边禁用
+const state = findAll(container, (n) => n.props && 'data-page-state' in n.props)[0]
+console.log('state on last page:', textOf(state))
+const prev = findAll(container, (n) => 'data-page-prev' in (n.props || {}))[0]
+const next = findAll(container, (n) => 'data-page-next' in (n.props || {}))[0]
+console.log('prev disabled:', prev?.props.disabled, 'next disabled:', next?.props.disabled)

+ 42 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t64/probe-t64-ssr.mjs

@@ -0,0 +1,42 @@
+// 探针:SSR 渲染 PanelBlocks(分页块)验证真实渲染行为。
+import { readFileSync } from 'node:fs'
+import { registerHooks } from 'node:module'
+import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
+import { createSSRApp, h } from 'vue'
+import { renderToString } from '@vue/server-renderer'
+
+const root = new URL('../../../../../src/modules/cockpit/', import.meta.url)
+registerHooks({
+    load(url, context, nextLoad) {
+        if (url.endsWith('.json')) return { format: 'module', shortCircuit: true, source: `export default ${readFileSync(new URL(url), 'utf8')}` }
+        if (url.endsWith('.vue')) {
+            const { descriptor } = parse(readFileSync(new URL(url), 'utf8'), { filename: new URL(url).pathname })
+            const script = compileScript(descriptor, { id: 'probe', genDefaultAs: '__sfc__' })
+            const template = compileTemplate({
+                source: descriptor.template.content, filename: new URL(url).pathname,
+                id: 'probe', ssr: true, compilerOptions: { bindingMetadata: script.bindings },
+            })
+            return { format: 'module', shortCircuit: true, source: `${script.content}\n${template.code}\n__sfc__.ssrRender = ssrRender; export default __sfc__;` }
+        }
+        return nextLoad(url, context)
+    },
+})
+
+const { default: PanelBlocks } = await import(new URL('components/PanelBlocks.vue', root))
+const { operationPanel, managementPanel, toOperatingInformation, toManagementInformation } = await import(new URL('domain/infrastructurePanels.js', root))
+const { section } = await import(new URL('domain/sectionState.js', root))
+
+const payload = JSON.parse(readFileSync(new URL('../t51/oi-unfiltered.json', import.meta.url), 'utf8')).data
+const data = toOperatingInformation(payload)
+const view = operationPanel(data, section('success', { data, loaded: true }), { scope: 'all' })
+
+const html = await renderToString(createSSRApp({ render: () => h(PanelBlocks, { view }) }))
+console.log('cards:', (html.match(/data-item="/g) || []).length)
+console.log('pager:', /data-pager/.test(html), 'state:', (html.match(/data-page-state>([^<]*)</) || [])[1])
+console.log('title 运行指标:', /运行指标/.test(html), 'old title gone:', !/指标占位/.test(html))
+console.log('sourceNote rendered(hidden):', /设施实体|逐条生成/.test(html))
+
+const m = toManagementInformation({ totals: { facilityCount: 9, equipmentCount: 9 }, districtIndustry: Array.from({ length: 9 }, (_, i) => ({ district: i % 3, industry: 'GAS', facilityCount: 1, equipmentCount: 1 })) })
+const mv = managementPanel(m, section('success', { data: m, loaded: true }))
+const mh = await renderToString(createSSRApp({ render: () => h(PanelBlocks, { view: mv }) }))
+console.log('mgmt cards:', (mh.match(/data-item="/g) || []).length, 'mgmt pager:', /data-pager/.test(mh))

+ 4 - 1
.scratch/cockpit-real-data-wayfinding/verify/t56-panel-summary-disclosure.mjs

@@ -59,6 +59,9 @@ const EXPECTED = [
     ['excludedNote', 'data-panel-excluded-note'],
     ['linkNote', 'data-panel-link-note'],
     ['note', 'data-panel-note'],
+    // 票 64:运行信息新增「数据来源与口径」说明。**这条是普查自己抓出来的**——
+    // 加进 domain 而没加进本表时 A18 当场判红(漏网即隐形),按成例补条目并保留断言面。
+    ['sourceNote', 'data-panel-source-note'],
 ]
 for (const [field, hook] of EXPECTED) {
     ok(
@@ -95,7 +98,7 @@ ok(
 }
 
 // E 组:旧的独立渲染必须清零(否则折叠形同虚设:同一句话画两遍或仍在头部外裸奔)
-ok(!/v-if="view\.(gap|offlineNote|pageNote|rangeNote|industryNote|typeNote|eventNote|locateNote|note|linkNote|excludedNote)\b/.test(panelBlocks), '没有任何说明字段在折叠入口之外独立渲染')
+ok(!/v-if="view\.(gap|offlineNote|pageNote|rangeNote|industryNote|typeNote|eventNote|locateNote|note|linkNote|excludedNote|sourceNote)\b/.test(panelBlocks), '没有任何说明字段在折叠入口之外独立渲染')
 ok(/v-show="summaryOpen"/.test(panelBlocks), '折叠用 v-show:文本仍在 DOM(信息不丢,只改呈现)')
 ok(/<header v-if="summaryItems\.length"/.test(panelBlocks), '没有摘要的面板不画空头部')
 

+ 299 - 0
.scratch/cockpit-real-data-wayfinding/verify/t64-mutations.mjs

@@ -0,0 +1,299 @@
+/**
+ * 票 64 闸门变异测试:证明 t64 的每条关键断言**抓得住它要防的缺陷**(t46/t59 同一纪律)。
+ *
+ * 做法:对源文件做**单点**变异注入(M16 是唯一例外:两处必须成对才等价该缺陷,见其说明)
+ * → 跑 t64 → 断言指定断言转红 → 按字节还原并校验一致。
+ * 十七处变异对应票 64 的真实风险:
+ *   M1 渲染层不分声明一律切窗口 → N8 必须红(排除项被「顺手接入」,规范边界失守)
+ *   M2 块标题退回「指标占位」     → D1 必须红(维护者点名的块名纠正被吃掉)
+ *   M3 domain 撤下分页声明        → D3 必须红(全量直渲复活=一屏几千张卡)
+ *   M4 行键退回 行业|指标码        → D7 必须红(真实 699 条同码整块碰撞,keyed v-for 无身份)
+ *   M5 pageScope 不吃片区         → D11 必须红(换片区不复位=停在第 20 页看新范围)
+ *   M6 domain 撤下来源说明        → D8 必须红(票 64 第 4 条「如实说明口径」丢失)
+ *   M7 来源说明不进折叠表         → S5 必须红(说明在界面上隐形=票 56 记过的同类缺陷)
+ *   M8 总数由当前页反推           → S2 必须红(共 699 变共 10,条文 7 失守)
+ *   M9 组件重写翻页算术           → N4 必须红(条文 11 的单源失守)
+ *   M10 一页也画控件              → S7 必须红(条文 4 失守)
+ *   M11 页码键每次读都漂移        → R2 必须红(写进去的页码读不回来=控件永远显示第 1 页)
+ *   M12 pagedRows 丢掉越界夹回    → R5 必须红(跳 999 画空表冒充「没有数据」)
+ *   M13 窗口起点差一格            → V12 必须红(页码显示对、画的是上一批行;只有逐页对内容抓得住)
+ *   M14 清槽变成全清              → P4 必须红(换一次筛选把别的块页码也抹了,牵连复位)
+ *   M15 复位 watch 被撤           → V4 必须红(条文 5/6 的复位半边失守)
+ *   M16 条件编进键且无清槽 watch  → V11c 必须红(本票第一版设计:切回旧行业停在第 3 页)
+ *   M17 读写键不一致              → V1 必须红(点了页码没反应)
+ *
+ * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t64-mutations.mjs
+ */
+import assert from 'node:assert/strict'
+import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { spawnSync } from 'node:child_process'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
+const GATE = fileURLToPath(new URL('t64-operation-metrics-pagination.mjs', import.meta.url))
+const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}`
+
+/**
+ * 跑闸门并取回输出。刻意用**文件重定向**而不是管道捕获(t46/t59 实测:
+ * Windows 沙箱下捕获子进程 stdio 的命名管道是文档化边界(EPERM),
+ * 管道版拿回空输出 → 变异全部「未转红」的假信号)。
+ */
+const reportDir = mkdtempSync(join(tmpdir(), 't64-mutations-'))
+function runGate() {
+    const outPath = join(reportDir, 'stdout.txt')
+    const errPath = join(reportDir, 'stderr.txt')
+    const out = openSync(outPath, 'w')
+    const err = openSync(errPath, 'w')
+    let status = 0
+    try {
+        status = spawnSync(process.execPath, [GATE], { stdio: ['ignore', out, err] }).status
+    } finally {
+        closeSync(out)
+        closeSync(err)
+    }
+    return { output: `${readFileSync(outPath, 'utf8')}${readFileSync(errPath, 'utf8')}`, status }
+}
+
+function failingNames(output) {
+    return [...output.matchAll(/^FAIL ([^:]+):/gm)].map((match) => match[1])
+}
+
+let injected = 0
+let proved = 0
+function mutate(label, file, from, to, expectRed) {
+    const path = srcPath(file)
+    const original = readFileSync(path, 'utf8')
+    // 变异源文本按 CRLF 归一后再匹配(票 37 实测踩过:行尾不一致时 replace 静默不命中)。
+    const candidates = [from, from.replace(/\n/g, '\r\n')]
+    const needle = candidates.find((candidate) => original.includes(candidate))
+    if (!needle) {
+        console.error(`FAIL ${label}: 变异源未命中(文件形状已变,需人工更新变异脚本)`)
+        injected += 1
+        return
+    }
+    injected += 1
+    writeFileSync(path, original.replace(needle, to), 'utf8')
+    runAndJudge(label, original, path, expectRed)
+}
+
+/**
+ * 多点变异(一次改同一文件的多处)——用于**必须成对才成立**的缺陷。
+ * 典型:把筛选条件编进页码键**并且**没有清槽 watch,才等于本票第一版的设计;
+ * 只编键不清槽是等价变异(行为与正确版一致),抓住它没有意义,见 M16 的说明。
+ */
+function mutateMany(label, file, edits, expectRed) {
+    const path = srcPath(file)
+    const original = readFileSync(path, 'utf8')
+    let mutated = original
+    for (const [from, to] of edits) {
+        const candidates = [from, from.replace(/\n/g, '\r\n')]
+        const needle = candidates.find((candidate) => mutated.includes(candidate))
+        if (!needle) {
+            console.error(`FAIL ${label}: 变异源未命中(第 ${edits.findIndex((e) => e[0] === from) + 1} 处,需人工更新脚本)`)
+            injected += 1
+            return
+        }
+        mutated = mutated.replace(needle, to)
+    }
+    injected += 1
+    writeFileSync(path, mutated, 'utf8')
+    runAndJudge(label, original, path, expectRed)
+}
+
+function runAndJudge(label, original, path, expectRed) {
+    let caught = false
+    let how = ''
+    try {
+        const { output, status } = runGate()
+        const red = failingNames(output)
+        if (red.includes(expectRed)) {
+            caught = true
+            how = `${expectRed} 转红`
+        } else if (status !== 0 && /does not provide an export|SyntaxError|Cannot find (module|export)/.test(output)) {
+            caught = true
+            how = '闸门整体加载失败(导出缺失)— 崩溃式抓住'
+        } else if (status !== 0) {
+            how = `退出码 ${status} 但目标断言未红(红项:${red.join(', ') || '无'})— 判据不明,按未抓住记`
+        } else {
+            how = '未转红(假绿!闸门仍全绿)'
+        }
+        console.log(`${caught ? 'PASS' : 'FAIL'}  ${label} → ${how}`)
+        if (caught) proved += 1
+    } finally {
+        writeFileSync(path, original, 'utf8')
+        assert.equal(readFileSync(path, 'utf8'), original, `${label}: 还原失败`)
+    }
+}
+
+// ---- 渲染层:声明驱动 / 单源 / 反推 ----
+
+mutate(
+    'M1-windowing-ignores-declaration',
+    'components/PanelBlocks.vue',
+    '    if (block.paginate !== true) return { rows, page: 1, pageCount: 1, total: rows.length }',
+    '    if (false) return { rows, page: 1, pageCount: 1, total: rows.length }',
+    'N8 未声明分页的 12 行块全量画出、零控件(排除项由声明保护,不由运气)',
+)
+
+mutate(
+    'M9-second-arithmetic-in-render',
+    'components/PanelBlocks.vue',
+    '    return pagedRows(rows, pageOf(block))',
+    '    const p = pageOf(block); const pc = Math.ceil(rows.length / 10) || 1; return { rows: rows.slice((p - 1) * 10, p * 10), page: p, pageCount: pc, total: rows.length }',
+    'N4 渲染层不自写翻页算术(无 rows.slice/Math.ceil/自画 pager)',
+)
+
+mutate(
+    'M8-total-reverse-derived',
+    'components/PanelBlocks.vue',
+    ':total="windowOf(block).total"',
+    ':total="windowOf(block).rows.length"',
+    'S2 计数行「第 1/70 页 · 共 699 条」',
+)
+
+mutate(
+    'M7-source-note-not-disclosed',
+    'components/PanelBlocks.vue',
+    "    { key: 'source', field: 'sourceNote', hook: 'data-panel-source-note' },\n",
+    '',
+    'S5 来源说明在 DOM 里(折叠不丢文本)',
+)
+
+mutate(
+    'M10-pager-renders-for-single-page',
+    'components/PagerNav.vue',
+    'const visible = computed(() => props.pageCount > 1)',
+    'const visible = computed(() => props.pageCount >= 1)',
+    'S7 不足一页不画控件(条文 4)',
+)
+
+// ---- 领域层:标题 / 声明 / 行键 / 作用域 / 来源说明 ----
+
+mutate(
+    'M2-title-reverts-to-placeholder',
+    'domain/infrastructurePanels.js',
+            "            '运行指标',",
+            "            '指标占位',",
+    'D1 块标题改为如实说法(「指标占位」已不在视图里)',
+)
+
+mutate(
+    'M3-pagination-declaration-dropped',
+    'domain/infrastructurePanels.js',
+    '            { paginate: true, pageScope: operationPageScope(active, extra) },',
+    '            { paginate: false, pageScope: operationPageScope(active, extra) },',
+    'D3 领域层声明本块分页',
+)
+
+mutate(
+    'M4-row-key-collides-on-real-payload',
+    'domain/infrastructurePanels.js',
+    "                key: `${active.code || ''}|${metric.code}|${index}`,",
+    "                key: `${active.code || ''}|${metric.code}`,",
+    'D7 每行 key 唯一(真实 699 条同码,旧键整块碰撞)',
+)
+
+mutate(
+    'M5-pageScope-ignores-district',
+    'domain/infrastructurePanels.js',
+    "    const district = extra && typeof extra.scope === 'string' ? extra.scope : ''",
+    "    const district = ''",
+    'D11 换片区 pageScope 跟着换',
+)
+
+mutate(
+    'M6-source-note-emptied',
+    'domain/infrastructurePanels.js',
+    "        sourceNote: '运行指标由后端按设施实体逐条生成(水厂/泵站/管网/排水设施四类适配器),一条指标对应一个设施;当前行业取后端回显,不跨行业合并',",
+    "        sourceNote: '',",
+    'D8 来源与口径说明到位(设施逐条生成、不跨行业合并、不说成监测点)',
+)
+
+// ---- 单源:寻址稳定性 / 越界夹回 ----
+
+// ---- 单源:寻址稳定性 / 复位点名 / 越界夹回 ----
+
+mutate(
+    'M11-page-slot-key-drifts-per-read',
+    'domain/pagination.js',
+    '    return `list@${owner}`',
+    '    return `list@${owner}@${Math.random()}`',
+    'R2 写后读回同值且不改入参',
+)
+
+// M14 清槽变成「全清」:一次换筛选把别的面板/别的块的页码也一并抹掉。
+//      条文 5 只说「条件变化的那块回第 1 页」,牵连复位会让用户在无关块上被莫名弹回。
+mutate(
+    'M14-reset-clears-every-block',
+    'domain/pagination.js',
+    '        if (!keys.has(key)) next[key] = value',
+    '        if (false) next[key] = value',
+    'P4 清槽只清点名块(其它块页码不受牵连)',
+)
+
+// M15 复位半边被撤(条件变化不再清槽)→ 停在第 3 页的人切了行业仍看第 3 页。
+mutate(
+    'M15-reset-watch-gone',
+    'components/PanelBlocks.vue',
+    '    if (changed.length) listPages.value = resetListPages(listPages.value, changed)',
+    '    if (false) listPages.value = resetListPages(listPages.value, changed)',
+    'V4 换片区筛选页码复位第 1 页',
+)
+
+mutate(
+    'M12-pagedRows-loses-clamp',
+    'domain/pagination.js',
+    '    const current = Math.min(Math.max(1, Math.trunc(Number(page) || 1)), pageCount)',
+    '    const current = Math.max(1, Math.trunc(Number(page) || 1))',
+    'R5 越界由 pagedRows 夹回最后一页(写入端不另做夹取)',
+)
+
+// M13 窗口起点差一格(分页最典型也最难看的错:页码显示对、画的是上一批行)。
+//      只有 V11 那种**逐页对内容**的判据抓得住;数卡片条数(S1/V10)会照样绿。
+mutate(
+    'M13-window-start-off-by-one',
+    'domain/pagination.js',
+    '    const start = (current - 1) * size',
+    '    const start = Math.max(0, current - 2) * size',
+    'V12 全 70 页逐页与 domain 切片完全一致(窗口算术与内容不错位)',
+)
+
+// M16 本票**第一版设计**的忠实复现(成对变异,两处一起改才等价):
+//      ① 把筛选条件编进页码归属键;② 同时没有清槽 watch(第一版就是「复位由构造成立」,
+//      不写 watch)。切走再切回时读到**旧槽位**,停在第 3 页的人切回燃气仍看第 3 页——
+//      违反条文 6。只有 V11c 判得出:V4/V5(第一次切走)与 V3(刷新保持)照样绿。
+//      注:只改 ①(保留 watch)是**等价变异**——changed 过滤恰好会把那个旧槽位点名清掉,
+//      行为与正确版一致,实测假绿;所以本条按第一版的成对形态注入。
+mutateMany(
+    'M16-condition-baked-into-page-key-no-watch',
+    'components/PanelBlocks.vue',
+    [
+        [
+            '    return `${props.view.name}|${block.label}`',
+            "    return `${props.view.name}|${block.label}|${block.pageScope || ''}`",
+        ],
+        [
+            '    if (changed.length) listPages.value = resetListPages(listPages.value, changed)',
+            '    if (false) listPages.value = resetListPages(listPages.value, changed)',
+        ],
+    ],
+    'V11c 切回燃气仍回第 1 页(旧设计的缺陷:会停在第 3 页)',
+)
+
+// M17 读写键不一致(写用块键、读用「块键+条件」):翻页写进去的页码读不回来。
+//      条文 11 要求寻址只有一份;这类缺陷表现为「点了页码没反应」,
+//      必须被 V1(点页码→画第 11–20 行)抓住。
+mutate(
+    'M17-read-write-key-mismatch',
+    'components/PanelBlocks.vue',
+    '    return readListPage(listPages.value, blockKey(block))',
+    '    return readListPage(listPages.value, `${blockKey(block)}|${block.pageScope || \'\'}`)',
+    'V1 点页码 2 → 画的是第 11–20 行(逐条对 key/标题/读数)',
+)
+
+console.log('')
+console.log(`T64 变异测试:${proved}/${injected} 处变异被对应断言抓住。`)
+rmSync(reportDir, { recursive: true, force: true })
+if (proved < injected) process.exit(1)

+ 511 - 0
.scratch/cockpit-real-data-wayfinding/verify/t64-operation-metrics-pagination.mjs

@@ -0,0 +1,511 @@
+/**
+ * 票 64 离线闸门:基础设施「运行信息」指标列表接入统一分页 +「指标占位」块名纠正。
+ *
+ * 背景(票面):`/infrastructure/operating-information` 一次回 **6772 条**指标
+ * (GAS 699/WATER_SUPPLY 2497/DRAINAGE 3576,2026-10-19 实测),响应 2.3 MB;
+ * 前端曾 `listBlock('指标占位', metrics)` 全量直渲 —— 一个行业一屏几千张卡。
+ *
+ * 九组断言(都不启浏览器、不联网;渲染层用 Vue 自定义渲染器在纯对象树上跑真实 patch):
+ *   P/R 页码寻址与复位单源 —— 键**只用块归属**(不把筛选条件编进键,否则切走再切回会停在旧页,
+ *       违反条文 6);复位走显式清槽 `resetListPages`(票 46/59 成例),且只清点名的块。
+ *   D   领域层(真实载荷):标题如实 + 声明分页 + 来源口径 + 行键唯一 + 行序不改 + 票 50 裁定不回归。
+ *   S   渲染层 SSR:每页 ≤10、计数行、旧措辞不上屏、空块/短列表不画控件。
+ *   N   排除项没被顺手接入(9 行的、以及**12 行但未声明**的块都照旧全画)+ 渲染层零第二份算术/出口唯一。
+ *   V   真实交互:点页码→窗口真的移动;刷新→页码保持;换片区/换行业→回第 1 页;
+ *       **切走再切回也回第 1 页**(V11);全 70 页逐页对内容(V12);到边禁用保占位;跳页超界夹回。
+ *   M   mock 同构:mock 载荷走同一条分页声明路径;不足一页不画控件=**裁定后的预期行为**
+ *       (维护者 2026-10-19:以真实数据为准,不改 `contractMocks` 静态样例扩面;闸门灌数照旧)。
+ *   K   SFC 真实编译(t41 教训)+ 外壳接线(当前片区传给运行信息面板=pageScope 的片区半边)。
+ *   Z   pagination 单源未回归。
+ *
+ * 变异有牙见 t64-mutations.mjs。
+ * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t64-operation-metrics-pagination.mjs
+ */
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { registerHooks } from 'node:module'
+import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
+import { createRenderer, h, nextTick, ref } from '@vue/runtime-core'
+import { createSSRApp } from 'vue'
+import { renderToString } from '@vue/server-renderer'
+import { PAGE_SIZE, clampPage, listPageKey, pageWindow, pagedRows, readListPage, resetListPages, writeListPage } from '../../../src/modules/cockpit/domain/pagination.js'
+
+const SRC = '../../../src/modules/cockpit/'
+const read = (rel) => readFileSync(new URL(SRC + rel, import.meta.url), 'utf8')
+/** 判据钉代码,不钉说明文字(t59 成例):注释里的字样不算命中。 */
+const stripComments = (text) => text.replace(/<!--[\s\S]*?-->/g, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
+
+// runtime-dom 的 v-model 指令在 beforeUpdate 里读 `document.activeElement`;
+// 自定义渲染器的宿主是纯对象树,没有 DOM。这里只补它读的那一个属性(不引 jsdom:
+// 判据要的是 Vue 的 patch 行为,不是浏览器布局)。
+if (typeof globalThis.document === 'undefined') globalThis.document = { activeElement: null }
+
+let passed = 0
+let failed = 0
+function check(name, condition, detail = '') {
+    if (condition) { passed += 1; console.log(`PASS ${name}`) }
+    else { failed += 1; console.error(`FAIL ${name}: ${detail}`) }
+}
+
+// .vue 一律走编译钩子:默认 SSR 版;`?client` 版给自定义渲染器用(子导入按 parentURL 传播,
+// 否则共享子组件只有 ssrRender、客户端模式画不出东西——probe-t64 实测踩过)。
+registerHooks({
+    resolve(specifier, context, nextResolve) {
+        const resolved = nextResolve(specifier, context)
+        const parent = context.parentURL || ''
+        if (parent.endsWith('.vue?client') && resolved.url.endsWith('.vue')) {
+            return { url: `${resolved.url}?client`, format: 'module', shortCircuit: true }
+        }
+        return resolved
+    },
+    load(url, context, nextLoad) {
+        if (url.endsWith('.json')) return { format: 'module', shortCircuit: true, source: `export default ${readFileSync(new URL(url), 'utf8')}` }
+        const isClient = url.endsWith('.vue?client')
+        if (url.endsWith('.vue') || isClient) {
+            const path = isClient ? url.slice(0, -'?client'.length) : url
+            const source = readFileSync(new URL(path), 'utf8')
+            const { descriptor } = parse(source, { filename: path })
+            const script = compileScript(descriptor, { id: 't64', genDefaultAs: '__sfc__' })
+            const template = compileTemplate({
+                source: descriptor.template.content,
+                filename: path,
+                id: 't64',
+                ssr: !isClient,
+                compilerOptions: { bindingMetadata: script.bindings },
+            })
+            assert.deepEqual(template.errors, [], `${path} template`)
+            const tail = isClient ? '__sfc__.render = render; export default __sfc__;' : '__sfc__.ssrRender = ssrRender; export default __sfc__;'
+            return { format: 'module', shortCircuit: true, source: `${script.content}\n${template.code}\n${tail}` }
+        }
+        return nextLoad(url, context)
+    },
+})
+
+const { operationPanel, managementPanel, listBlock, toManagementInformation, toOperatingInformation } = await import(
+    `${SRC}domain/infrastructurePanels.js`
+).then(async (m) => ({ ...m, listBlock: (await import(`${SRC}domain/panelBlocks.js`)).listBlock }))
+const { SECTION_STATE, section } = await import(`${SRC}domain/sectionState.js`)
+const { default: PanelBlocks } = await import(`${SRC}components/PanelBlocks.vue`)
+const { default: ClientPanel } = await import(`${SRC}components/PanelBlocks.vue?client`)
+
+const okSection = (data) => section(SECTION_STATE.SUCCESS, { data, loaded: true })
+const panelOf = (raw, extra) => {
+    const data = toOperatingInformation(raw)
+    return operationPanel(data, okSection(data), extra)
+}
+const listBlockOf = (view) => view.blocks.find((block) => block.kind === 'list')
+
+// ================================================================ P. 页码寻址单源(条文 5/6)
+
+{
+    // P1 键只用块归属,且跨重算稳定(同一块永远同一个槽)。
+    //    **刻意不把筛选条件编进键**:编进去会让「切走再切回」读到旧槽位停在第 3 页,
+    //    违反条文 6(切回来同样是一次条件变化)。复位走显式清槽(见 P4/R5)。
+    check('P1 同块同键(键跨重算稳定)', listPageKey('operation|运行指标') === listPageKey('operation|运行指标'), '')
+    check('P2 不同块各自槽位', listPageKey('operation|运行指标') !== listPageKey('management|片区 × 行业明细'), '')
+    check('P3 脏输入不造出 undefined 假键', listPageKey(undefined) === listPageKey('') && !listPageKey(null).includes('undefined'), listPageKey(null))
+    // P4 清槽只清点名的块,别的面板不受牵连。
+    const seeded = writeListPage(writeListPage({}, 'operation|运行指标', 3), 'emergency|危险源', 2)
+    const cleared = resetListPages(seeded, ['operation|运行指标'])
+    check('P4 清槽只清点名块(其它块页码不受牵连)',
+        readListPage(cleared, 'operation|运行指标') === 1 && readListPage(cleared, 'emergency|危险源') === 2,
+        `operation=${readListPage(cleared, 'operation|运行指标')} emergency=${readListPage(cleared, 'emergency|危险源')}`)
+    // P5 清槽返回新表、不改入参;空点名表是恒等(不白清一次)。
+    check('P5 清槽不改入参且空点名是恒等',
+        readListPage(seeded, 'operation|运行指标') === 3 && resetListPages(seeded, []) === seeded, '')
+}
+
+// ================================================================ R. 读写页码
+
+{
+    check('R1 没记过的槽位读回第 1 页', readListPage({}, 'operation|运行指标') === 1, '')
+    const table = writeListPage({}, 'operation|运行指标', 5)
+    check('R2 写后读回同值且不改入参', readListPage(table, 'operation|运行指标') === 5 && Object.keys({}).length === 0, JSON.stringify(table))
+    check('R3 脏值一律收成 1(NaN 不得漏进控件)',
+        readListPage({ x: 'abc' }, 'x') === 1 && readListPage({ x: NaN }, 'x') === 1 && readListPage({ x: 0 }, 'x') === 1 && readListPage(null, 'x') === 1, '')
+    // R4 条文 6 的正反两半:条件变化→回第 1 页;定时刷新→**不**回第 1 页。
+    //     刷新保持=同一槽位不动;复位=清槽后读回默认 1。
+    check('R4 刷新不动页码、清槽才复位(条文 6 两条语义互不牵连)',
+        readListPage(table, 'operation|运行指标') === 5 && readListPage(resetListPages(table, ['operation|运行指标']), 'operation|运行指标') === 1, '')
+    // R5 越界不在写入端夹(夹回归 pagedRows 单源):999 写进去,渲染时夹到最后一页。
+    const clamped = pagedRows(Array.from({ length: 25 }, (_, i) => ({ key: i })), readListPage(writeListPage({}, 'a', 999), 'a'))
+    check('R5 越界由 pagedRows 夹回最后一页(写入端不另做夹取)', clamped.page === 3 && clamped.rows.length === 5, JSON.stringify({ page: clamped.page, n: clamped.rows.length }))
+}
+
+// ================================================================ D. 领域层(真实载荷)
+
+const payloadOf = (name) => JSON.parse(readFileSync(new URL(`./artifacts/t51/${name}`, import.meta.url), 'utf8')).data
+const unfiltered = payloadOf('oi-unfiltered.json')
+const gasRaw = payloadOf('oi-GAS.json')
+const drainageRaw = payloadOf('oi-DRAINAGE.json')
+const manholeRaw = payloadOf('oi-MANHOLE.json')
+const gasPayloadBlock = (raw) => raw.industries.find((block) => block.industry.code === 'GAS')
+
+{
+    const view = panelOf(unfiltered, { scope: 'all' })
+    const block = listBlockOf(view)
+    const gasBlock = gasPayloadBlock(unfiltered)
+
+    // D1 块标题如实:占位期遗留词不再上屏。
+    check('D1 块标题改为如实说法(「指标占位」已不在视图里)',
+        block.label === '运行指标' && !JSON.stringify(view.blocks).includes('指标占位'), `label=${block.label}`)
+    check('D2 空态文案同样如实', block.emptyText === '当前行业没有运行指标', `empty=${block.emptyText}`)
+    // D3 分页由 domain 声明(渲染层据此切窗口;不声明就还是全量直渲)。
+    check('D3 领域层声明本块分页', block.paginate === true, `paginate=${block.paginate}`)
+    // D4 pageScope 与票 51 的选中块同源(不另算一套行业口径)。
+    check('D4 pageScope=选中行业块 + 片区', block.pageScope === 'GAS|all', `scope=${block.pageScope}`)
+    // D5 领域仍交出全部行:分页只切展示窗口,不裁数据、不改口径。
+    check('D5 领域行数仍是整块 699(分页不裁数据)',
+        block.rows.length === (gasBlock.metrics || []).length && block.rows.length === 699, `rows=${block.rows.length}`)
+    // D6 行序不改(票 10/11:前端不重排)。
+    check('D6 行次序与后端返回逐条一致(不重排)',
+        block.rows.every((row, i) => gasBlock.metrics[i].metricName === row.title), '行序漂了就是第二个口径')
+    // D7 每行身份唯一:真实燃气 699 条全是同一个 metricCode,
+    //    旧键 `${industry}|${metricCode}` 整块碰撞,而清单卡是 keyed v-for(分页让身份缺失每帧都碰)。
+    check('D7 每行 key 唯一(真实 699 条同码,旧键整块碰撞)',
+        new Set(block.rows.map((row) => row.key)).size === 699, `distinct=${new Set(block.rows.map((row) => row.key)).size}/699`)
+    // D8 来源与口径如实说明(票 49/50/51 既有裁定,不重开、不发明跨行业合并口径)。
+    check('D8 来源与口径说明到位(设施逐条生成、不跨行业合并、不说成监测点)',
+        typeof view.sourceNote === 'string' && view.sourceNote.includes('设施') && view.sourceNote.includes('不跨行业合并') && !view.sourceNote.includes('监测点'),
+        `note=${view.sourceNote}`)
+    check('D9 industryNote 仍与选中块同源(票 51 不回归)', view.industryNote === '当前行业:燃气', `note=${view.industryNote}`)
+    check('D10 换行业 pageScope 跟着换', listBlockOf(panelOf(drainageRaw, { scope: 'all' })).pageScope === 'DRAINAGE|all', '')
+    check('D11 换片区 pageScope 跟着换', listBlockOf(panelOf(unfiltered, { scope: 'south' })).pageScope === 'GAS|south', '')
+    check('D12 extra 缺失时 pageScope 仍合法(无脏值入键)',
+        listBlockOf(panelOf(unfiltered, undefined)).pageScope === 'GAS|' && !listBlockOf(panelOf(unfiltered, {})).pageScope.includes('undefined'), '')
+    // D13 票 49/50 裁定不重开:定位入口整体撤下。
+    check('D13 行 points 仍恒空、locate 仍为空(票 49/50 裁定不回归)',
+        view.locate.length === 0 && block.rows.every((row) => (row.points || []).length === 0), '定位入口复活了')
+    // D14 燃气块单独取数时口径一致(选中哪块说哪块)。
+    check('D14 单行业载荷同样分页 + 同口径', listBlockOf(panelOf(gasRaw, { scope: 'all' })).paginate === true, '')
+}
+
+// ================================================================ S. 渲染层 SSR 真实渲染
+
+const renderPanel = async (view) => renderToString(createSSRApp({ render: () => h(PanelBlocks, { view }) }))
+
+{
+    const html = await renderPanel(panelOf(unfiltered, { scope: 'all' }))
+    // S1 一页 ≤10 条(条文 1):真实 699 条 → 首屏只画 10 张卡。
+    const cards = (html.match(/data-item="/g) || []).length
+    check('S1 每页最多 10 条(699 条首屏只画 10 张卡)', cards > 0 && cards <= PAGE_SIZE, `cards=${cards}`)
+    // S2 计数行是「已分页」的明证;总数取整块,不由当前页反推(条文 7)。
+    const state = (html.match(/第 (\d+)\/(\d+) 页 · 共 (\d+) 条/) || []).slice(1)
+    check('S2 计数行「第 1/70 页 · 共 699 条」', state.join('|') === '1|70|699', `state=${state.join('|')}`)
+    // S3 复用批次一控件:页码直达 + 输入框跳页都在(不是另画一套)。
+    check('S3 复用共享 PagerNav(页码直达 + 跳页输入框)',
+        /data-page-numbers/.test(html) && /data-page-number="1"/.test(html) && /data-page-jump-input/.test(html) && /aria-label="跳转到指定页"/.test(html), '控件缺件')
+    // S4 旧措辞彻底不上屏,如实标题上屏。
+    check('S4 标题上屏且「指标占位」彻底消失', /运行指标/.test(html) && !/指标占位/.test(html), '')
+    // S5 来源说明进 DOM(折叠只是隐藏,信息不丢——票 56 的 v-show 纪律)。
+    check('S5 来源说明在 DOM 里(折叠不丢文本)', /不跨行业合并/.test(html), 'sourceNote 没上屏')
+    // S6 空块(MANHOLE 实测 0 条)不画任何控件(条文 4)。
+    const emptyHtml = await renderPanel(panelOf(manholeRaw, { scope: 'manhole' }))
+    check('S6 空块不画控件不画卡,只说没有运行指标',
+        !/data-pager/.test(emptyHtml) && (emptyHtml.match(/data-item="/g) || []).length === 0 && /当前行业没有运行指标/.test(emptyHtml), '空块多画了控件')
+    // S7 不足一页同样不画控件。
+    const small = toOperatingInformation({
+        industries: [{ industry: { code: 'GAS', label: '燃气' }, state: 'READY', metrics: Array.from({ length: 3 }, (_, i) => ({ metricCode: `m${i}`, metricName: `指标${i}`, category: 'SUPPLY', value: '1', unit: 'MPa', loadState: 'NORMAL', pointCodes: [`F${i}`] })) }],
+        excluded: {},
+    })
+    const smallHtml = await renderPanel(operationPanel(small, okSection(small), { scope: 'all' }))
+    check('S7 不足一页不画控件(条文 4)', !/data-pager/.test(smallHtml) && (smallHtml.match(/data-item="/g) || []).length === 3, '')
+}
+
+// ================================================================ N. 排除项没被顺手接入 + 单源纪律
+
+{
+    const mgrData = toManagementInformation({
+        totals: { facilityCount: 9, equipmentCount: 9 },
+        districtIndustry: Array.from({ length: 9 }, (_, i) => ({ district: i % 3, industry: 'GAS', facilityCount: 1, equipmentCount: 1 })),
+        excluded: {},
+    })
+    const mgrView = managementPanel(mgrData, okSection(mgrData))
+    const mgrBlock = listBlockOf(mgrView)
+    // N1 管理信息明细是规范排除项(实测 9 行):不声明、不画控件、9 行照旧全画。
+    const mgrHtml = await renderPanel(mgrView)
+    check('N1 管理信息明细未被顺手接入(9 行全画、零控件)',
+        mgrBlock.paginate === undefined && !/data-pager/.test(mgrHtml) && (mgrHtml.match(/data-item="/g) || []).length === 9,
+        `cards=${(mgrHtml.match(/data-item="/g) || []).length}`)
+    // N8 **超过一页但未声明分页**的块(12 行的合成清单)必须仍全量画出、不画控件。
+    //     N1 抓不住「渲染层不分声明一律切窗口」这类缺陷(9 行本来就 ≤10),
+    //     这条才有牙——它是变异 M1 的对应判据。
+    const twelve = listBlock('危险源', Array.from({ length: 12 }, (_, i) => ({ key: `H${i}`, title: `危险源${i}`, meta: [], points: [] })), '没有危险源对象')
+    const undeclared = { name: 'emergency', label: '应急保障', hasContent: true, section: okSection({}), blocks: [twelve], missing: { count: 0 } }
+    const twelveHtml = await renderPanel(undeclared)
+    check('N8 未声明分页的 12 行块全量画出、零控件(排除项由声明保护,不由运气)',
+        (twelveHtml.match(/data-item="/g) || []).length === 12 && !/data-pager/.test(twelveHtml),
+        `cards=${(twelveHtml.match(/data-item="/g) || []).length}`)
+    // N2 listBlock 不传 options 时的形状一字不改(既有调用零影响)。
+    check('N2 listBlock 默认形状不变(无新键)',
+        JSON.stringify(Object.keys(listBlock('x', [], 'y'))) === JSON.stringify(['kind', 'label', 'rows', 'emptyText']),
+        JSON.stringify(Object.keys(listBlock('x', [], 'y'))))
+    // N3 rows 交出的是传入数组本身(分页不复制、不改数据)。
+    const identity = [{ key: 'a' }]
+    check('N3 listBlock 不复制行数组', listBlock('x', identity, 'y').rows === identity, '')
+    // N4 渲染层零第二份翻页算术(条文 11)。
+    //     判据**只钉分页相关**的形状:rows 切片、向上取整、自画 pager。
+    //     不写「整个文件不许出现 .slice(」——KPI 图标名大小写就用了一次 slice(第 110 行),
+    //     那是既有无关代码;写宽了就是票 53 M9 记过的「守卫比要守的宽」(会把好改动判红,
+    //     早晚被人当噪音绕过去,那时它才真的没牙)。
+    const panelSrc = stripComments(read('components/PanelBlocks.vue'))
+    check('N4 渲染层不自写翻页算术(无 rows.slice/Math.ceil/自画 pager)',
+        !/rows\.slice\(/.test(panelSrc) && !/Math\.(ceil|trunc)\(/.test(panelSrc) && !/equipment-pager|panel-pager__btn|>上一页</.test(panelSrc), '长出第二份算术')
+    // N5 翻页出口唯一:list 分支只接 PagerNav 的 @page 一处。
+    check('N5 翻页出口唯一(@page→gotoListPage 一处 + 共享控件导入)',
+        (panelSrc.match(/@page="\(target\) => gotoListPage\(block, target\)"/g) || []).length === 1 && /import PagerNav from '\.\/PagerNav\.vue'/.test(panelSrc), '')
+    // N6 分页只画给声明块,且不由渲染层按名字/面板特判(谁该分页只有 domain 知道)。
+    check('N6 PagerNav 由 block.paginate 门控(不按块标题/面板名特判)',
+        /v-if="block\.paginate === true"/.test(panelSrc) && !/block\.label === '运行指标'/.test(panelSrc) && !/props\.view\.name === 'operation'/.test(panelSrc), '渲染层在按名字猜')
+    // N7 声明块走 pagedRows 单源、未声明块走原数组(一条委托,不分叉出第二份兜底形状)。
+    check('N7 窗口取数只有一条委托(pagedRows 单源)',
+        /return pagedRows\(rows, pageOf\(block\)\)/.test(panelSrc) && /if \(block\.paginate !== true\) return \{ rows, page: 1, pageCount: 1, total: rows\.length \}/.test(panelSrc), '')
+}
+
+// ================================================================ V. 真实交互(客户端 patch)
+
+const nodeOps = {
+    createElement: (tag) => ({ tag, props: {}, children: [], parent: null, text: '', style: {}, addEventListener() {}, removeEventListener() {} }),
+    createText: (text) => ({ tag: '#text', text, children: [], parent: null, props: {}, style: {}, addEventListener() {}, removeEventListener() {} }),
+    createComment: (text) => ({ tag: '#comment', text, children: [], parent: null, props: {}, style: {}, addEventListener() {}, removeEventListener() {} }),
+    setText: (node, text) => { node.text = text },
+    setElementText: (node, text) => { node.text = text; node.children = [] },
+    insert: (child, parent, anchor) => {
+        if (child.parent) child.parent.children = child.parent.children.filter((c) => c !== child)
+        child.parent = parent
+        if (!parent) return
+        if (anchor) { const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, child) }
+        else parent.children.push(child)
+    },
+    remove: (child) => { if (child.parent) child.parent.children = child.parent.children.filter((c) => c !== child); child.parent = null },
+    parentNode: (node) => node.parent,
+    nextSibling: (node) => (node.parent ? node.parent.children[node.parent.children.indexOf(node) + 1] || null : null),
+    querySelector: () => null,
+    setScopeId: () => {},
+    patchProp: (el, key, _prev, next) => { el.props[key] = next },
+}
+
+function findAll(node, predicate, acc = []) {
+    if (!node) return acc
+    if (predicate(node)) acc.push(node)
+    for (const child of node.children || []) findAll(child, predicate, acc)
+    return acc
+}
+function findNode(node, predicate) {
+    if (!node) return null
+    if (predicate(node)) return node
+    for (const child of node.children || []) { const hit = findNode(child, predicate); if (hit) return hit }
+    return null
+}
+function textOf(node) {
+    if (!node) return ''
+    const own = node.tag === '#text' ? node.text : typeof node.text === 'string' ? node.text : ''
+    return own + (node.children || []).map(textOf).join('')
+}
+const byAttr = (name) => (node) => !!node.props && name in node.props
+const hasClass = (name) => (node) => String((node.props && node.props['class']) || '').includes(name)
+
+{
+    const viewRef = ref(panelOf(unfiltered, { scope: 'all' }))
+    const app = createRenderer(nodeOps).createApp({ render: () => h(ClientPanel, { view: viewRef.value }) })
+    app.config.warnHandler = () => {}
+    const container = nodeOps.createElement('#root')
+    app.mount(container)
+
+    const pageRows = () => viewRef.value.blocks[1].rows
+    const cards = () => findAll(container, byAttr('data-item')).map((card) => ({
+        key: card.props['data-item'],
+        title: textOf(findNode(card, hasClass('panel-item__title'))),
+        meta: textOf(findNode(card, hasClass('panel-item__meta'))),
+    }))
+    const stateText = () => textOf(findNode(container, byAttr('data-page-state')))
+    const clickPage = async (n) => {
+        const btn = findNode(container, (node) => String(node.props && node.props['data-page-number']) === String(n))
+        assert.ok(btn && typeof btn.props.onClick === 'function', `页码按钮 ${n} 不在`)
+        btn.props.onClick(new Proxy({}, { get: () => undefined }))
+        await nextTick()
+    }
+
+    // V1 点页码直达 → 窗口真的移动到第 11–20 行(逐条对内容,不是只数卡片)。
+    await clickPage(2)
+    const want2 = pageRows().slice(10, 20)
+    const got2 = cards()
+    check('V1 点页码 2 → 画的是第 11–20 行(逐条对 key/标题/读数)',
+        got2.length === 10 && got2.every((c, i) => c.key === want2[i].key && c.meta === want2[i].meta.join(' · ') && c.title === want2[i].title),
+        `got=${JSON.stringify(got2[0])} want=${JSON.stringify({ key: want2[0].key, title: want2[0].title })}`)
+    // V2 计数行随页码更新(条文 9)。
+    check('V2 计数行随页码更新为「第 2/70 页 · 共 699 条」', stateText() === '第 2/70 页 · 共 699 条', `state=${stateText()}`)
+
+    // V3 同条件刷新(周期 tick 会重算出一批全新块对象)→ 页码必须保持第 2 页。
+    viewRef.value = panelOf(unfiltered, { scope: 'all' })
+    await nextTick()
+    check('V3 数据刷新不复位页码(停在第 3 页的人不回第 1 页)',
+        stateText() === '第 2/70 页 · 共 699 条' && cards()[0].key === want2[0].key, `state=${stateText()}`)
+
+    // V4 换片区 → 回第 1 页。
+    viewRef.value = panelOf(unfiltered, { scope: 'south' })
+    await nextTick()
+    check('V4 换片区筛选页码复位第 1 页', stateText() === '第 1/70 页 · 共 699 条' && cards()[0].key === pageRows()[0].key, `state=${stateText()}`)
+
+    // V5 换行业 → 回第 1 页并按新行业重算页数。
+    const drainageView = panelOf(drainageRaw, { scope: 'south' })
+    viewRef.value = drainageView
+    await nextTick()
+    const dPages = Math.ceil(drainageView.blocks[1].rows.length / PAGE_SIZE)
+    check('V5 换行业页码复位并按新行业重算页数',
+        stateText() === `第 1/${dPages} 页 · 共 ${drainageView.blocks[1].rows.length} 条` && cards()[0].key === drainageView.blocks[1].rows[0].key,
+        `state=${stateText()}`)
+
+    // V6 到末页:下一页 disabled 但按钮仍在(保占位,计数行不跳——票 46 既有语义)。
+    await clickPage(dPages)
+    const next = findNode(container, byAttr('data-page-next'))
+    const prev = findNode(container, byAttr('data-page-prev'))
+    check('V6 末页下一页 disabled 且保留占位(按钮不消失)',
+        !!next && next.props.disabled === true && !!prev && prev.props.disabled === false,
+        `next=${next && next.props.disabled} prev=${prev && prev.props.disabled}`)
+    // V7 末页只画余数(不补空卡)。
+    const tail = drainageView.blocks[1].rows.length % PAGE_SIZE
+    check('V7 末页只画余数', cards().length === (tail || PAGE_SIZE), `cards=${cards().length} tail=${tail}`)
+
+    // V8 跳页输入超界 → 由 pagedRows 夹回最后一页(条文 6 + 跳页节:不报错、不清列表)。
+    viewRef.value = panelOf(unfiltered, { scope: 'all' })
+    await nextTick()
+    const jumpInput = findNode(container, byAttr('data-page-jump-input'))
+    assert.ok(jumpInput && typeof jumpInput.props['onUpdate:modelValue'] === 'function', '跳页输入框接线缺失')
+    jumpInput.props['onUpdate:modelValue']('999')
+    await nextTick()
+    findNode(container, byAttr('data-page-jump-btn')).props.onClick()
+    await nextTick()
+    check('V8 跳页超界夹回最后一页(不空表、不报错)',
+        stateText() === '第 70/70 页 · 共 699 条' && cards().length === 699 - 690, `state=${stateText()} cards=${cards().length}`)
+    // V9 跳页与页码共用同一个翻页出口(不新增第二套页码状态)。
+    check('V9 跳页后当前页标记同步(同一套页码状态)',
+        String(findNode(container, (n) => n.props && n.props['aria-current'] === 'page')?.props['data-page-number']) === '70', '两套页码')
+
+    // V10 整页 DOM 只有 10 张卡:一屏几千张卡的形态被构造上堵死。
+    await clickPage(1)
+    check('V10 任意时刻画出的卡 ≤ 10(含刷新后)', cards().length <= PAGE_SIZE, `cards=${cards().length}`)
+
+    // V11 条文 6 的反例回归(本票第一版踩过的坑):切走再**切回**也必须回第 1 页。
+    //      把筛选条件编进页码键时,「切回燃气」会读到上次留下的槽位停在第 3 页——
+    //      看起来像「记住了用户位置」,实际违反条文 6(切回来同样是一次条件变化)。
+    {
+        const gasView = panelOf(unfiltered, { scope: 'all' })
+        viewRef.value = gasView
+        await nextTick()
+        // 窗口在 >7 页时收缩(第 1 页的窗口是「1 2 … 70」),先到第 2 页,第 3 页才出现在按钮里。
+        await clickPage(2)
+        await clickPage(3)
+        check('V11a 切回全行业后先停在第 3 页(前置条件成立)', stateText() === '第 3/70 页 · 共 699 条', `state=${stateText()}`)
+        // 切到排水(条件变)→ 复位
+        viewRef.value = panelOf(drainageRaw, { scope: 'all' })
+        await nextTick()
+        check('V11b 切到排水复位第 1 页', stateText().startsWith('第 1/'), `state=${stateText()}`)
+        // 再切回燃气(又一次条件变化)→ 仍回第 1 页,**不得**回到刚才的第 3 页
+        viewRef.value = gasView
+        await nextTick()
+        check('V11c 切回燃气仍回第 1 页(旧设计的缺陷:会停在第 3 页)',
+            stateText() === '第 1/70 页 · 共 699 条' && cards()[0].key === gasView.blocks[1].rows[0].key,
+            `state=${stateText()}`)
+    }
+
+    // V12 **逐页全扫**(把诊断探针固化成闸门):699 条 → 70 页,每一页画出来的
+    //     key/标题/读数必须等于 domain 行的对应切片。这是内容级判据 ——
+    //     只数卡片数(S1/V10)抓不到「页码显示对、实际画错行」这类窗口起点算错的缺陷。
+    {
+        viewRef.value = panelOf(unfiltered, { scope: 'all' })
+        await nextTick()
+        const all = pageRows()
+        const pages = Math.ceil(all.length / PAGE_SIZE)
+        let mismatches = 0
+        let firstBad = ''
+        for (let page = 1; page <= pages; page += 1) {
+            await clickPage(page)
+            const got = cards()
+            const want = all.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
+            if (got.length !== want.length) { mismatches += 1; firstBad = firstBad || `page ${page}: 画 ${got.length} 张 vs 应有 ${want.length} 张`; continue }
+            for (let i = 0; i < want.length; i += 1) {
+                if (got[i].key !== want[i].key || got[i].meta !== want[i].meta.join(' · ') || got[i].title !== want[i].title) {
+                    mismatches += 1
+                    firstBad = firstBad || `page ${page} row ${i}: got ${JSON.stringify(got[i])} want ${JSON.stringify({ key: want[i].key, title: want[i].title, meta: want[i].meta.join(' · ') })}`
+                    break
+                }
+            }
+        }
+        check(`V12 全 ${pages} 页逐页与 domain 切片完全一致(窗口算术与内容不错位)`, mismatches === 0, `${mismatches} 页错配 · ${firstBad}`)
+    }
+}
+
+// ================================================================ K/Z 编译与单源未回归
+
+// ================================================================ M. mock 同构(票 08/18/38/51 同一条纪律)
+//   真实链路与 mock 必须过同一个 domain + 同一个渲染层,读出同一个结论;
+//   否则分页只在真机有效、mock 上是另一套行为(票 51 记过的假绿)。
+{
+    const { registerCockpitMocks } = await import(`${SRC}mocks/contractMocks.js`)
+    const transports = {}
+    registerCockpitMocks({ handlers: { registerMockTransport: (path, handler) => { transports[path] = handler } } })
+    const mockGet = async (query = '') => (await transports['/infrastructure/operating-information']({ path: '/infrastructure/operating-information' + query }))
+
+    const mockView = panelOf(await mockGet(), { scope: 'all' })
+    const mockBlock = listBlockOf(mockView)
+    // M1 mock 载荷也声明分页并按同一规则寻址(同一张嘴,不按数据源分叉)。
+    check('M1 mock 载荷同样声明分页(同一张嘴,不因数据源分叉)',
+        mockBlock.paginate === true && mockBlock.label === '运行指标' && mockBlock.pageScope === 'GAS|all',
+        `scope=${mockBlock.pageScope} paginate=${mockBlock.paginate}`)
+    // M2 mock 每个行业块只有 2–3 条 ⇒ pageCount 恒 1 ⇒ 整块 pager **不出现**。
+    //    这是**裁定后的预期行为**(维护者 2026-10-19:验收与走查以真实数据为准,
+    //    不为演示扩写 mock 样例)——符合条文 4,不是缺陷,也**不要**为此把样例补到 >10 条。
+    //    多页与边界行为归 V 组(真实载荷逐页对账);真实观感归票 18 联调窗口。
+    const mockHtml = await renderPanel(mockView)
+    check('M2 mock 载荷不足一页 ⇒ 不画控件(预期行为;已裁定不为演示扩写 mock 样例)',
+        !/data-pager/.test(mockHtml) && mockBlock.rows.length < PAGE_SIZE && (mockHtml.match(/data-item="/g) || []).length === mockBlock.rows.length,
+        `rows=${mockBlock.rows.length}`)
+    // M3 选行业后 mock 仍是单页(mock 样例没跨到 >10 条),行为与真实同形而非特例。
+    const waterMock = panelOf(await mockGet('?industry=WATER_SUPPLY'), { scope: 'all' })
+    check('M3 mock 选行业仍走同一条分页路径(pageScope 随回显块换)',
+        listBlockOf(waterMock).pageScope === 'WATER_SUPPLY|all' && listBlockOf(waterMock).paginate === true,
+        `scope=${listBlockOf(waterMock).pageScope}`)
+}
+
+// ================================================================ K/Z 编译与单源未回归
+
+{
+    const compileSfc = (label, rel) => {
+        let ok = false
+        let why = ''
+        try {
+            const source = read(rel)
+            const { descriptor, errors } = parse(source, { filename: rel })
+            assert.deepEqual(errors, [], `${rel} parse`)
+            const script = compileScript(descriptor, { id: 't64' })
+            const template = compileTemplate({
+                source: descriptor.template.content, filename: rel, id: 't64',
+                compilerOptions: { bindingMetadata: script.bindings },
+            })
+            assert.deepEqual(template.errors, [], `${rel} template`)
+            ok = true
+        } catch (error) { why = error.message.split('\n')[0] }
+        check(`K ${label} 编译通过`, ok, why || `${rel} 应无编译错误`)
+    }
+    compileSfc('PanelBlocks', 'components/PanelBlocks.vue')
+    compileSfc('PagerNav', 'components/PagerNav.vue')
+    compileSfc('EquipmentDetailSections', 'components/EquipmentDetailSections.vue')
+}
+
+// K2 外壳接线(不整份 import CockpitShell——它会拉起 store/cesium 全图,且那是并行在制品的共享面;
+// 这里钉住「换片区复位判据的输入」这一条接线本身,源码级判据)。
+{
+    const shellSrc = read('CockpitShell.vue')
+    check('K2 外壳把当前片区传给运行信息面板(pageScope 的片区半边有真实输入)',
+        /panelId === 'operation'\) extra = \{ scope: scope\.value \}/.test(shellSrc),
+        '外壳没接线 ⇒ 换片区不复位(D11 只测 domain 侧,抓不到这条断链)')
+}
+
+check('Z1 pagination 单源未回归(PAGE_SIZE/窗口/夹回/空表 1 页)',
+    PAGE_SIZE === 10 && pageWindow(5, 8).join(',') === '1,…,4,5,6,…,8' && clampPage(99, 10) === 10 && pagedRows([], 1).pageCount === 1, '')
+
+console.log('')
+console.log(`T64 运行指标分页与块名纠正:${passed}/${passed + failed} 项通过。`)
+if (failed) { console.log('未通过项见上方 FAIL。'); process.exit(1) }
+console.log('T64 全部通过(浏览器走查按 AGENTS.md 未自动执行)。')

+ 31 - 1
src/modules/cockpit/CockpitShell.vue

@@ -347,6 +347,15 @@
                     :plan="modeLayerPlan"
                     @pipeline-pick="selectPipeEnhancement"
                 />
+                <!--
+                    票 48:管线颜色图例——中央「一张图」**正下方、水平居中、持久显示**
+                    (不随模式切换/筛选/悬停消失;与顶部状态图例是两条不同的东西,不合并不替换)。
+                    计数 = `pipeNetworkStore` **全量**口径(不随行业筛选变化,对齐顶部状态图例的
+                    全局未筛口径立场);图层还没取数时 counts.available=false → 不画计数,不补 0。
+                    放在地图段内(而非 main 顶层):局部增强窗是本段的兄弟且层级更高,
+                    窗开着时把图例一并盖住——图例描述的是被窗遮住的那些管线,此刻不该浮在窗上。
+                -->
+                <PipeColorLegend :counts="pipeColorCountsView" />
             </section>
 
             <PipeEnhancement
@@ -507,6 +516,8 @@ import CockpitPanel from './components/CockpitPanel.vue'
 import CockpitIcon from './components/CockpitIcon.vue'
 import MapCanvas from './components/MapCanvas.vue'
 import PipeEnhancement from './components/PipeEnhancement.vue'
+// 票 48:管线颜色图例(一张图正下方居中持久显示;色值/词条/计数全部同源 domain/pipeVisual.js)。
+import PipeColorLegend from './components/PipeColorLegend.vue'
 // 票 25:地图信息内容块(挂在本外壳的状态面板内,不另起容器)。
 import MapInfoPanel from './components/MapInfoPanel.vue'
 import PanelPlaceholder from './components/PanelPlaceholder.vue'
@@ -529,6 +540,8 @@ import { selectMonitoringPoint } from './pointSelection.js'
 // 票 46:模式图层计划(唯一裁决点)+ 模式切换的选择态语义判定。
 import { planModeLayers, planModeSelectionTransition } from './domain/modeLayerPlan.js'
 import { PIPE_ENHANCEMENT_KIND, planPipeEnhancement } from './domain/pipeEnhancement.js'
+// 票 48:图例计数纯函数(色值/词条/计数与地图管线同源的 domain 单源)。
+import { pipeColorCounts } from './domain/pipeVisual.js'
 import { usePipeEnhancementStore } from './stores/pipeEnhancementStore.js'
 import { useEnhancementDevicesStore } from './stores/enhancementDevicesStore.js'
 import { planEnhancementDevices } from './domain/enhancementDevices.js'
@@ -653,6 +666,9 @@ function panelViewOf(panelId) {
     else if (panelId === 'monitoringDistribution') {
         extra = { statusDistribution: mapStore.statusDistribution, selectedStatuses: pointStatus.value }
     }
+    // 票 64:运行信息面板要当前**片区**键,用来给指标列表算页码归属(换片区 ⇒ 页码回第 1 页,
+    // 周期刷新 ⇒ 页码保持)。只传标量、不传块对象,与票 43 第四轮「别拿对象当键」同一条纪律。
+    else if (panelId === 'operation') extra = { scope: scope.value }
     // 票 20:态势总览要**三份领域对象**(它横跨基础设施/运行监测/预警设置三块),
     // 与基础设施面板要「设施底数 + 行业构成 + 区域分布」一样,都是"这一块的数据来自哪几处"
     // 的搬运 —— 写在壳里,注册表签名保持 `(panelId, data, section, extra)` 不变。
@@ -934,6 +950,15 @@ watch(() => [pipeEnhancementSelection.value, modeLayerPlan.value.pipelines, mode
     else enhancementDevicesStore.clear()
 })
 
+/**
+ * 票 48:管线颜色图例的计数(`pipeColorCounts()` 的视图入口)。
+ *
+ * 读 `pipeNetworkStore.layer.data` 的**全量**图层(不读 `filtered`):
+ * 图例回答的是「图上这类颜色是什么」,计数不随行业筛选变化——与顶部状态图例
+ * 「计数=全局未筛全集」的既定立场(决策 02)同一条纪律。图层没取数/取数失败时
+ * `data` 为 null → `available:false` → 图例各行不画计数(不补 0)。
+ */
+const pipeColorCountsView = computed(() => pipeColorCounts(pipeNetworkStore.layer.data))
 
 function selectPipeEnhancement(hit) {
     const kind = hit && hit.objectKind === 'component'
@@ -2480,7 +2505,12 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
 .cockpit-shell__notice {
     position: absolute;
     z-index: 6;
-    bottom: calc(14 * var(--u));
+    /*
+     * 票 48:本浮层与底部图例同处一张图的底部带,必须起算在图例**之上**,
+     * 否则图例贴底后会被这条反馈压住(或互相叠字)。偏移量读共享的
+     * --map-bottom-band,而不是在这里另写一个数——两处各写魔法数是漂移的根。
+     */
+    bottom: calc(var(--map-bottom-band) + 4 * var(--u));
     left: 50%;
     transform: translateX(-50%);
     display: flex;

+ 184 - 11
src/modules/cockpit/components/PanelBlocks.vue

@@ -417,7 +417,7 @@
                 -->
                 <div v-else-if="block.kind === 'pie'" class="panel-pie" data-pie>
                     <template v-if="block.ready && block.total > 0">
-                        <svg class="panel-pie__chart" viewBox="0 0 100 100" role="group" aria-label="点位状态分布(全局未筛)">
+                        <svg class="panel-pie__chart" viewBox="0 0 100 100" role="group" :aria-label="block.label">
                             <path
                                 v-for="slice in block.slices"
                                 :key="slice.key"
@@ -455,7 +455,13 @@
                                 @mouseenter="showChartTip($event, row.label, row.count, pieToneColor(row.tone, row.key)); setChartHover(chartScope(index, block), row.key)"
                                 @mouseleave="hideChartTip(); clearChartHover(chartScope(index, block), row.key)"
                             >
-                                <StatusTag :tone="row.tone" :shape="row.shape" :label="row.label" :style="{ '--tag-color': pieToneColor(row.tone, row.key) }" />
+                                <!--
+                                    票 39 二轮(维护者走查反馈):图例行与一张图顶部状态栏**同一形态**——
+                                    `legendIconFor` 单源图标 + 文字(StatusTag 传 icon 就只画图标、不画形状,
+                                    票 46 返工定稿的同一条接缝)。颜色不再走 pieToneColor 这份第二映射:
+                                    StatusTag 的 tone 类解析出完全相同的 token,顶部图例也是这么取色的。
+                                -->
+                                <StatusTag :tone="row.tone" :label="row.label" :icon="row.icon || ''" />
                                 <b class="panel-pie__count" data-pie-count>{{ row.count }}</b>
                             </li>
                         </ul>
@@ -477,14 +483,23 @@
                 <!--
                     条目清单:两种形态共用这一块 —— 普通条目(带可定位点位)与**台账卡片**
                     (整张卡可点,走与地图相同的两级详情)。判据是行上有没有 `code`。
+                    票 40:清单卡升级为「事件卡片」形态(样式参考当前预警面板的事件卡,
+                    维护者 2026-10-16:卡片高度不需要那么高,主要参考设计及交互效果)。
+                    整卡可点:有 `code` 的卡(台账监测点)打开两级详情;没有 `code` 的卡
+                    (运行概况异常设备)走既有 `locate` 接缝 —— 诚实的三种「点不动」反馈
+                    + 设备级详情,不新造第二条路径。卡内按钮自己 `.stop`,不二次触发。
+                    票 64:行数由 `windowOf(block)` 给 —— domain 声明 `paginate` 的块
+                    每页 ≤10(统一分页规范),其余块交出原数组,画法与行为一字不改。
                 -->
                 <div v-else-if="block.kind === 'list'" class="panel-list" data-item-list>
                     <article
-                        v-for="row in block.rows"
+                        v-for="row in windowOf(block).rows"
                         :key="row.key"
                         class="panel-item"
+                        :class="{ 'panel-item--clickable': row.code || (row.points && row.points.length) }"
                         :data-item="row.key"
                         :data-ledger-card="row.code || null"
+                        @click="onCardClick(row, $event)"
                     >
                         <div class="panel-item__head">
                             <!-- 有编码的行整张卡可点(台账);没有编码的只有点位按钮可点(票 17 的形态)。
@@ -496,7 +511,7 @@
                                 class="panel-item__title panel-item__title--button"
                                 :text="row.name"
                                 hint="点击打开两级详情"
-                                @click="emit('open-card', row.code)"
+                                @click.stop="emit('open-card', row.code)"
                             />
                             <EllipsisText v-else class="panel-item__title" :text="row.title" />
                             <!-- 色键必带文字(票 06:标签不能只靠颜色);文案由 domain 给 -->
@@ -520,7 +535,7 @@
                                 class="panel-locate"
                                 :data-locate-point="point.pointCode"
                                 :title="`在地图上定位 ${point.label || point.pointCode}`"
-                                @click="emit('locate', point.pointCode)"
+                                @click.stop="emit('locate', point.pointCode)"
                             >
                                 定位 {{ point.pointCode }}
                             </button>
@@ -533,9 +548,23 @@
                         (有 stateText 就必然没有 rows),那个分支永远不执行,是条死代码。
                     -->
                     <p v-if="block.stateText" class="panel-block__empty" data-item-state>{{ block.stateText }}</p>
-                    <p v-else-if="!block.rows.length" class="panel-block__empty" data-item-empty>
+                    <p v-else-if="!windowOf(block).total" class="panel-block__empty" data-item-empty>
                         {{ block.emptyText }}
                     </p>
+
+                    <!--
+                        票 64:统一分页控件(共享 PagerNav,规范条文 2/3/4/9 + 跳页节)。
+                        只有 domain 声明 `paginate` 的块才画;一页不画(条文 4,由控件自己按
+                        pageCount>1 收口)。总数取**过滤前**集合长度,不由当前页反推(条文 7)。
+                    -->
+                    <PagerNav
+                        v-if="block.paginate === true"
+                        :page="windowOf(block).page"
+                        :page-count="windowOf(block).pageCount"
+                        :total="windowOf(block).total"
+                        :list-name="block.label"
+                        @page="(target) => gotoListPage(block, target)"
+                    />
                 </div>
             </section>
 
@@ -583,8 +612,10 @@
 import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
 import { DETAIL_PLACEHOLDER } from '../domain/detailFlow.js'
 import { sectionPending } from '../domain/panelBlocks.js'
+import { pagedRows, readListPage, resetListPages, writeListPage } from '../domain/pagination.js'
 import CockpitIcon from './CockpitIcon.vue'
 import EllipsisText from './EllipsisText.vue'
+import PagerNav from './PagerNav.vue'
 import StatusTag from './StatusTag.vue'
 
 const props = defineProps({
@@ -597,6 +628,24 @@ const props = defineProps({
 
 const emit = defineEmits(['locate', 'open-card', 'ledger-more', 'badge-needed', 'panel-action'])
 
+/**
+ * 票 40:整卡可点(交互参考「当前预警」的事件卡片)。
+ *   · 有 `code` 的卡(台账监测点)→ 打开两级详情(与标题按钮同一出口,不新造路径);
+ *   · 没有 `code` 的卡(运行概况异常设备)→ 走既有 `locate` 接缝:诚实的三种
+ *     「点不动」反馈 + 设备级详情(`monitoringStore.locateCode` 的既有分流)。
+ * 卡内按钮已 `.stop`,走到这里的就是卡片空白处的点击;再兜一层 closest('button')
+ * 防将来有人忘了加 stop —— 双保险便宜,误触发的代价(飞镜头)不便宜。
+ */
+function onCardClick(row, event) {
+    if (event && event.target && typeof event.target.closest === 'function' && event.target.closest('button')) return
+    if (row.code) {
+        emit('open-card', row.code)
+        return
+    }
+    const point = (row.points || [])[0]
+    if (point) emit('locate', point.pointCode)
+}
+
 /** 还没取过数(界面说"正在取数",不说"没有数据")。 */
 const pending = computed(() => sectionPending(props.view.section))
 
@@ -631,6 +680,10 @@ const SUMMARY_SOURCES = [
     { key: 'excluded', field: 'excludedNote', hook: 'data-panel-excluded-note' },
     { key: 'link', field: 'linkNote', hook: 'data-panel-link-note' },
     { key: 'note', field: 'note', hook: 'data-panel-note' },
+    // 票 64:运行信息的数据来源与口径说明(设施实体适配器逐条生成、一条指标对应一个设施、
+    // 不跨行业合并)。表是顶部说明的唯一消费点——domain 新增说明字段而不进这张表即判红
+    // (t56 C 组的双向普查)。
+    { key: 'source', field: 'sourceNote', hook: 'data-panel-source-note' },
 ]
 
 const summaryItems = computed(() =>
@@ -665,6 +718,63 @@ function blockKey(block) {
     return `${props.view.name}|${block.label}`
 }
 
+// ---- 票 64:list 块的统一分页(运行指标列表实测 6772 条,全量直渲=一屏几千张卡)----
+//
+// 四条纪律:
+//   · **只有 domain 声明 `paginate` 的块切窗口**:同一个 list 画法服务全仓多处清单,
+//     管理信息明细(实测 9 行)与预警分析重点识别是规范排除项,应急保障另有专票(63),
+//     渲染层不猜、不顺手全翻;未声明的块 `windowOf` 原样交出 `block.rows`(同一引用、零 slice)。
+//   · **翻页算术只有一份**(条文 11):窗口、越界夹回、页码寻址与复位全在
+//     `domain/pagination.js`(`listPageKey`/`readListPage`/`writeListPage`/`resetListPages`
+//     共用同一个键函数——读写键不一致就会造出「该复位的没复位」这类最难查的漂移),
+//     控件只有 `PagerNav.vue` 一份;本组件不出现 ceil/trunc/rows.slice/自画 pager。
+//   · **复位=显式清槽**(条文 5/6):domain 给每块一个 `pageScope`(选中行业 + 片区),
+//     它变了就清掉那一块的页码槽;定时刷新时它不变 ⇒ 页码保持。
+//     第一版把条件编进页码键,被条文 6 判住:切走再切回会读到旧槽位停在第 3 页,
+//     而「切回来」同样是一次条件变化。清槽与票 46/59「换设备清页码」同一条成例。
+//   · **数据变少越界由 `pagedRows` 夹回**(条文 6),界面上的页码即夹回后的值。
+const listPages = ref({})
+
+/**
+ * 分页块的条件快照(`{ [块归属键]: pageScope }`)。只收分页块——非分页块没有页码,
+ * 也就没有可复位的槽;把它们放进来会让这张表每次刷新都变,白清一次别的块。
+ */
+const listScopeSnapshot = computed(() => {
+    const snapshot = {}
+    for (const block of props.view.blocks || []) {
+        if (block.paginate === true) snapshot[blockKey(block)] = block.pageScope || ''
+    }
+    return snapshot
+})
+
+// 哪一块的条件变了,就只清那一块的页码(别的面板/别的块不受牵连)。
+// 只按**值**逐块比较:computed 每次重算都是新对象,按对象身份比会把每次刷新都当成换筛选。
+watch(listScopeSnapshot, (next, prev) => {
+    const before = prev || {}
+    const changed = Object.keys(next).filter((key) => before[key] !== next[key])
+    if (changed.length) listPages.value = resetListPages(listPages.value, changed)
+})
+
+/** 这一块当前生效的页码(没记过=第 1 页)。 */
+function pageOf(block) {
+    return readListPage(listPages.value, blockKey(block))
+}
+
+/** 这一块要画几条:分页块交窗口行,其余块交全部行(引用不变,既有渲染零影响)。 */
+function windowOf(block) {
+    const rows = block.rows || []
+    if (block.paginate !== true) return { rows, page: 1, pageCount: 1, total: rows.length }
+    return pagedRows(rows, pageOf(block))
+}
+
+/**
+ * 翻页唯一出口(与票 59 同一条):前后页/页码直达/跳页都带**目标页码(1 起)**回来。
+ * 这里只存状态,不另做夹取——越界统一由 domain `pagedRows` 在下一帧渲染时夹回。
+ */
+function gotoListPage(block, target) {
+    listPages.value = writeListPage(listPages.value, blockKey(block), target)
+}
+
 function activeDimension(block) {
     const override = dimensionOverrides.value[blockKey(block)]
     if (override && (block.dimensions || []).some((dim) => dim.key === override)) return override
@@ -688,6 +798,9 @@ function isPlaceholder(text) {
  * 一条 KPI 读数的**完整原文**(值 + 单位),供共用省略方法当 `title` 用(票 37)。
  * 显示仍走模板里的子元素(单位是独立的 `<em>`,有自己的弱化样式),
  * 但 title 取的是**整条读数**——所以窄列里被省略时,hover 读到的是"12 台"而不是"12"。
+ *
+ * 票 54:domain 可给 `full` 声明「屏上短读、原文另有其值」(数据时间的短读法 + 完整 ISO)。
+ * 渲染层只认这个通用字段,不再按 `item.key` 特判哪个读数是时间(票 38 立的同一条纪律)。
  */
 function kpiReading(item) {
     const unit = item.unit && !isPlaceholder(item.value) ? ` ${item.unit}` : ''
@@ -1013,7 +1126,25 @@ onBeforeUnmount(() => {
     gap: var(--space-2, 8px);
 }
 
-/* 票 54:监测概览常规宽度 2×2;窄态按容器降级。 */
+/* 票 54:监测概览四卡在常规宽度固定 2×2,避免 auto-fit 展开成一行。
+
+   **降级按容器判、不按视口判**:面板栏宽是 `--column-w = clamp(280px, 330u, 460px)`
+   (styles/tokens.css:135),`--u = clamp(1px, 0.0694vw, 2px)`。栏内容宽实测:1920 视口
+   ≈410px、1440 视口(u 收到 1)≈308px —— 两档都稳在 2×2。写视口 media query 等于写一条
+   在大屏驾驶舱里永不触发的规则,所以这里查的是**容器**宽度。
+
+   **260px 这个阈值的来源**(不是拍的):单格宽 =(容器 − gap 8u)/2;读数行 = 单格 − 卡片
+   左右内边距 16u;`--fs-kpi = 20u` 下「10-12 15:15」11 个等宽字符 ≈ 110u。
+   读数不省略要求 单格 ≥ 126u → 容器 ≥ 2×126 + 8 = 260u。低于它退单列。
+
+   两档都不横向溢出:列宽 `minmax(0, 1fr)`(不是 auto),卡片内文本走共用省略方法。
+
+   形状说明:**容器查不了自己的尺寸**(CSS containment 的规则,`container-type` 加在谁身上,
+   `@container` 就只对谁的后代生效),所以降级写在子卡上 `grid-column: 1 / -1`
+   横贯两列,而不是改父元素的 `grid-template-columns`。
+
+   `container-type` 只加在本变体上:本元素内没有绝对定位后代(图表 hover 浮层的定位基准
+   仍是 `.panel-blocks` 根),因此不影响其它块的布局。 */
 .panel-kpis--two-by-two {
     grid-template-columns: repeat(2, minmax(0, 1fr));
     container-type: inline-size;
@@ -1090,6 +1221,10 @@ onBeforeUnmount(() => {
     line-height: 1.2;
 }
 
+/* 票 54:原先这里有一条**时间读数专用降档**(把数据时间压到 caption 字号),
+   它正是「数据时间与上一行四卡不在同一视觉层级」的根因。所有 KPI 数值共用
+   `.panel-kpi__value` 一档之后它失去全部消费点,按规格 03「无消费点不提交」整条删除;
+   短读法归 domain 声明(`createdAtKpi`),渲染层不再按 `item.key` 认哪个读数是时间。 */
 
 .panel-kpi__value em {
     color: var(--muted, #5680b0);
@@ -1649,9 +1784,16 @@ onBeforeUnmount(() => {
     border-bottom: 1px dashed rgba(1, 171, 234, 0.14);
 }
 
+/* 票 55:rows 口径块与共享 bars/donut 图例统一字号、行高和间距。
+   只作用于 rows renderer,避免把面板正文或其它块的字号一起放大/缩小。 */
+.panel-row dt,
+.panel-row dd {
+    font-size: var(--fs-caption, 10.5px);
+    line-height: 1.35;
+}
+
 .panel-row dt {
     color: var(--muted, #5680b0);
-    font-size: var(--fs-caption, 10.5px);
 }
 
 .panel-row dd {
@@ -1665,20 +1807,51 @@ onBeforeUnmount(() => {
     gap: calc(4 * var(--u, 1px));
 }
 
+/* 票 40:清单卡升级为「事件卡片」形态(样式参考当前预警面板的事件卡;维护者:
+   卡片高度不需要那么高,主要参考设计及交互效果)。独立卡框 + 内衬底 + 紧凑两列网格,
+   比事件卡更矮(行距与内边距再收一档)。渲染形态只有一份 —— 应急保障/管理单位等
+   同一 list 块的卡片一并升级,不为单个面板长第二套样式。 */
 .panel-item {
     display: grid;
-    gap: 1px;
-    padding-bottom: calc(3 * var(--u, 1px));
-    border-bottom: 1px dashed rgba(1, 171, 234, 0.14);
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: calc(2 * var(--u, 1px)) calc(8 * var(--u, 1px));
+    align-items: start;
+    padding: calc(4 * var(--u, 1px)) calc(6 * var(--u, 1px));
+    border: 1px solid var(--panel-line, rgba(1, 171, 234, 0.35));
+    border-radius: 4px;
+    background: var(--inset, rgba(0, 31, 92, 0.5));
+    min-width: 0;
+}
+
+/* 可点卡(台账有 code、异常设备有定位点):交互语言与事件卡同一套 ——
+   hover/focus 高亮边框、底色抬亮、左缘压 3px 竖条。 */
+.panel-item--clickable {
+    cursor: pointer;
+    transition: border-color 0.15s, background-color 0.15s, box-shadow 0.15s;
+}
+
+.panel-item--clickable:hover,
+.panel-item--clickable:focus-within {
+    border-color: var(--cyan-hi, #00b8ff);
+    background: rgba(0, 82, 168, 0.5);
+    box-shadow: inset 3px 0 0 var(--cyan-hi, #00b8ff);
 }
 
 .panel-item__head {
+    grid-column: 1 / -1;
     display: flex;
     align-items: center;
     justify-content: space-between;
     gap: var(--space-2, 8px);
 }
 
+/* 原因/徽标/定位按钮行横贯整卡;两条 meta(说明与数据时间)并排一行收矮卡高。 */
+.panel-item__reason,
+.panel-item__badge,
+.panel-item__points {
+    grid-column: 1 / -1;
+}
+
 .panel-item__title {
     min-width: 0;
     color: var(--text, #d7ecff);

+ 110 - 0
src/modules/cockpit/components/PipeColorLegend.vue

@@ -0,0 +1,110 @@
+<!--
+  管线颜色图例(票 48):中央「一张图」**正下方、水平居中、持久显示**。
+
+  · 颜色与词条**引用 `domain/pipeVisual.js` 的 `PIPE_PALETTE` 本体**(`pipeLegendEntries()`
+    返回的就是同一对象上的字段),不另抄一份十六进制——改色时地图与图例必然同源。
+  · 词条覆盖 `pipeColorKey()` 的全部可能返回值(含 `DRAINAGE_UNKNOWN` / `UNKNOWN` 两个灰桶
+    ——地图真会画出这两种颜色,图例必须覆盖地图实际会画出的颜色,不得静默丢)。
+  · 计数(票 48 第 4 条):来自 `domain/pipeVisual.js` 的 `pipeColorCounts()`,**全量口径**
+    (与顶部状态图例「计数=全局未筛全集、不随筛选变化」的既定立场一致,决策 02);
+    统计缺失(还没取数 / 取数失败)→ 不画该行的计数,**不补 0**;计数 0 是真实值照常显示。
+  · 与一张图**顶部**的状态图例(`data-map-legend`,全局组合筛选入口)是两条不同的东西:
+    本条是「颜色 → 含义」的纯读数,不可点、无选中态、不做图例点击筛选(本票非目标)。
+  · 窄屏形态:词条整行换行居中(flex wrap + justify-content center),不横向溢出、
+    不遮挡管线本体(读数条 `pointer-events: none`,地图操作穿透)。
+-->
+<template>
+    <div class="pipe-color-legend" data-pipe-legend role="list" aria-label="管线颜色图例">
+        <span
+            v-for="row in rows"
+            :key="row.key"
+            class="pipe-color-legend__item"
+            role="listitem"
+            :style="{ '--pipe-color': row.color }"
+            :data-pipe-legend-key="row.key"
+            :data-pipe-legend-count="row.count === null ? '' : String(row.count)"
+        >
+            <i class="pipe-color-legend__swatch" aria-hidden="true"></i>
+            <span class="pipe-color-legend__label">{{ row.label }}</span>
+            <span v-if="row.count !== null" class="pipe-color-legend__count">{{ row.count }}</span>
+        </span>
+    </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { pipeLegendEntries } from '../domain/pipeVisual.js'
+
+/**
+ * 票 48 第 4 条:色块同时带文字 + 该桶的真实计数。
+ *
+ * @prop {{available: boolean, byKey: object}} [counts] `pipeColorCounts()` 的结果
+ * (计数缺失 → 该行 `count` 为 `null`,不画数字,不补 0;全量口径不随筛选变化)。
+ */
+const props = defineProps({
+    counts: { type: Object, default: null },
+})
+
+const rows = computed(() => pipeLegendEntries().map((entry) => {
+    const byKey = props.counts && props.counts.available === true ? props.counts.byKey : null
+    const value = byKey ? byKey[entry.key] : undefined
+    return { ...entry, count: Number.isFinite(value) ? value : null }
+}))
+</script>
+
+<style scoped>
+/* 持久读数条:与底部定位反馈浮层同一套材质(票 38),但无关闭入口、无自动收起。 */
+.pipe-color-legend {
+    position: absolute;
+    left: 50%;
+    /*
+     * 票 48 返工(维护者反馈「离最底层还有这么长一段距离」):**bottom: 0**。
+     * 这不是随手取 0,是有结构判据的:`.cockpit-shell__map` 是 `inset: 0`、
+     * 两侧栏 `.cockpit-shell__column` 是 `top: 0; bottom: 0`,面板 `flex: 1 1 0` ——
+     * 三者底边都等于 `<main>` 的底边,所以贴底 0 就是「与两边面板底边并排对齐」;
+     * 写任何正数都是把自己抬离那条线(原先 40u 在 1080p 约 50px,正是那段空隙)。
+     * 要让开的是**上方**的定位反馈浮层(它用 --map-bottom-band 起算),不是本条自己抬高。
+     */
+    bottom: 0;
+    transform: translateX(-50%);
+    z-index: 4; /* 高于局部增强窗(3),低于定位反馈浮层(6)——不与既有浮层抢层级 */
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex-wrap: wrap; /* 窄屏按词条整行换行居中,不横向溢出 */
+    column-gap: calc(14 * var(--u, 1px));
+    row-gap: calc(4 * var(--u, 1px));
+    /* 只占两栏之间的空档(与顶部状态图例同一水平可用区),兜底 240px 后按词条换行。 */
+    max-width: max(240px, calc(100% - 2 * var(--column-w) - 28 * var(--u, 1px)));
+    padding: calc(4 * var(--u, 1px)) var(--space-3);
+    border: 1px solid rgba(1, 171, 234, 0.32);
+    border-radius: 999px;
+    background: rgba(2, 10, 20, 0.88);
+    box-shadow: var(--shadow, 0 18px 44px rgba(0, 0, 0, 0.55));
+    font-family: var(--font-topbar);
+    font-size: var(--fs-caption, 11.5px);
+    pointer-events: none; /* 纯读数:不挡地图点击/悬停 */
+}
+
+.pipe-color-legend__item {
+    display: inline-flex;
+    align-items: center;
+    gap: calc(4 * var(--u, 1px));
+    white-space: nowrap; /* 词条内部不折行,换行发生在词条之间 */
+    color: var(--text-2, #88b7e8);
+}
+
+/* 色块颜色 = var(--pipe-color) = PIPE_PALETTE[key].color(与地图管线逐字节同源) */
+.pipe-color-legend__swatch {
+    width: calc(10 * var(--u, 1px));
+    height: calc(10 * var(--u, 1px));
+    flex: none;
+    border-radius: 50%;
+    background: var(--pipe-color);
+    box-shadow: 0 0 6px color-mix(in srgb, var(--pipe-color) 65%, transparent);
+}
+
+.pipe-color-legend__count {
+    font-family: var(--num, monospace);
+}
+</style>

+ 76 - 22
src/modules/cockpit/domain/infrastructurePanels.js

@@ -146,7 +146,9 @@ function toOperatingMetric(raw) {
         freshness: freshness.optionalStr('status'),
         ageSeconds: numberish(freshness.raw.ageSeconds),
         abnormal: reader.bool('abnormal', false),
-        // 可定位对象:后端只给点位编码(**不给经纬度**),坐标要靠已加载的地图摘要回联。
+        // 票 49 裁定:这里装的是**源设施编码**(水厂 plantCode / 泵站 stationCode / 管网 networkCode /
+        // 排水设施 id),与一张图的监测点/设备编码空间结构性不重叠(后端将改名 `facilityCodes`)。
+        // 字段照契约透传(实测恒单元素),但**不再喂定位**——票 50 起运行信息面板不产定位入口。
         pointCodes: reader.optionalList('pointCodes').map((item) => String(item)).filter(Boolean),
         missing: reader.missing,
     }
@@ -154,8 +156,8 @@ function toOperatingMetric(raw) {
 
 /**
  * 运行信息(`/infrastructure/operating-information`)→ 领域对象。
- * 票 17 要求「供应、输送、承载能力和指标占位」—— 三类能力对应后端的
- * `SUPPLY` / `FLOW` / `CAPACITY` 三个 `category`。
+ * 票 17 要求「供应、输送、承载能力和运行指标」—— 三类能力对应后端的
+ * `SUPPLY` / `FLOW` / `CAPACITY` 三个 `category`。(票 64:原措辞「指标占位」已按真实内容纠正。)
  */
 export function toOperatingInformation(raw) {
     const reader = fieldReader(raw, 'operatingInformation')
@@ -165,7 +167,9 @@ export function toOperatingInformation(raw) {
         rangeType: (reader.optionalObj('range') || {}).type || '',
         rangeStart: (reader.optionalObj('range') || {}).start || '',
         rangeEnd: (reader.optionalObj('range') || {}).end || '',
-        selectedIndustry: (reader.optionalObj('selectedIndustry') || {}).label || '',
+        // 票 51:回显只留**码**(`{code,key,label}` 里 code 是稳定锚)—— 块选择用它定位对应块;
+        // 未筛行业时后端回 null,这里如实回空串。
+        selectedIndustry: (reader.optionalObj('selectedIndustry') || {}).code || '',
         // 按行业分组:后端本来就是按行业聚合的(一个行业一块指标),这里保持后端顺序,不重排。
         industries: reader
             .optionalList('industries')
@@ -322,14 +326,46 @@ export function archivePanel(data, section) {
     }
 }
 
+/**
+ * 票 64:运行指标列表**页码归属的筛选条件标识**(翻页复位判据的输入,不是页码本身)。
+ *
+ * 为什么要有这个串:规范条文 5/6 要求「条件变化 → 页码回第 1 页;定时刷新不复位」,
+ * 而视图计划在这两种情况下**都会**重算出新块对象——按对象身份判条件变没变,
+ * 会把每次刷新都读成换筛选(票 43 第四轮记过的同一条教训:别拿对象当键)。
+ * 所以由 domain 给一个跨刷新稳定的标量:当前行业块 + 全局片区键。
+ * 换行业/换片区 ⇒ 串变 ⇒ 渲染层清掉该块页码(回第 1 页);周期刷新(同一筛选)⇒ 串不变 ⇒ 页码保持。
+ * **切走再切回也算一次条件变化**(同样回第 1 页)——所以这个串只当**判据**用,
+ * 不编进页码归属键:编进键会让切回时读到上次留下的旧槽位(条文 6 不许)。
+ *
+ * `extra.scope` 由外壳传入(与台账徽标、饼图选中态同一个 extra 通道);
+ * 缺失或不是字符串时回空串——不拿脏值造出一个会漂移的假作用域。
+ */
+function operationPageScope(active, extra) {
+    const district = extra && typeof extra.scope === 'string' ? extra.scope : ''
+    return `${active.code || ''}|${district}`
+}
+
 /**
  * 运行信息面板视图。
- * 验收四项:供应 / 输送 / 承载能力 + 指标占位。
+ * 验收四项:供应 / 输送 / 承载能力 + **运行指标**(票 64:原措辞「指标占位」是占位期遗留词,
+ * 实际内容是真实指标清单;真实数据 6772 条/2.3 MB 已证伪「契约有界」,本块接入统一分页)。
  */
-export function operationPanel(data, section) {
+export function operationPanel(data, section, extra) {
     const current = data || {}
-    // 后端按行业分块返回;没筛行业时取第一块(与后端的回显口径一致,不自己合并多块)。
-    const active = (current.industries || [])[0] || { metrics: [], label: '' }
+    // 票 51:按**选中行业**选块,不按下标 —— 后端恒定按 GAS/WATER_SUPPLY/DRAINAGE/MANHOLE 四块返回,
+    // 筛选只把未选中块置空(EMPTY/NO_METRICS)不删块、不改变块序,块序恒以燃气打头;
+    // 只读 industries[0] 会把"选了供水/排水"说成"燃气没有指标"(数据就在同一响应里)。
+    // 定位用后端回显的 selectedIndustry(码);未筛行业时回显为空,保持既有"取第一块"的口径
+    // (与后端回显一致,不自己合并多块)。industryNote 与 metrics 都由这一个 active 推出
+    // —— 两处真相不漂移(票 50 后 locate 不再随 active 变化:定位入口整体撤下,恒为空)。
+    const industryBlocks = current.industries || []
+    const selected = current.selectedIndustry || ''
+    const matched = selected ? industryBlocks.find((block) => block.code === selected) : null
+    const active = matched
+        // 回显的码在 industries 里定位不到(契约外形状)时,归因仍用回显自身(码翻人话),不说成"燃气"或"全部"。
+        || (selected ? { code: selected, label: industryLabel(selected) || selected, metrics: [] } : null)
+        || industryBlocks[0]
+        || { code: '', label: '', metrics: [] }
     const metrics = active.metrics || []
 
     /** 按 category 取一类能力指标(验收的"供应/输送/承载"就是这三个 category)。 */
@@ -350,9 +386,18 @@ export function operationPanel(data, section) {
     const blocks = [
         kpiBlock([headline('SUPPLY'), headline('FLOW'), headline('CAPACITY')]),
         listBlock(
-            '指标占位',
-            metrics.map((metric) => ({
-                key: `${active.code || ''}|${metric.code}`,
+            // 票 64(维护者 2026-10-19 反馈第 2 条):块标题如实 —— 「指标占位」是占位期遗留词,
+            // 这里一直是真实运行指标清单。
+            '运行指标',
+            metrics.map((metric, index) => ({
+                // 票 64 顺带修的键缺陷(分页把它从「理论问题」变成「每帧都碰的问题」):
+                // 旧键 `${active.code}|${metric.code}` 在真实载荷里**整块碰撞**——燃气 699 条
+                // 全是同一个 metricCode(太常片区燃气长度),699 行共用一个键,
+                // 而清单卡是 keyed v-for(`:key="row.key"`)。实测翻页内容仍正确,
+                // 但节点身份已经不存在(换筛选/刷新时靠下标兜底重排),所以加序号后缀
+                // 保证**块内每行身份唯一**;行序由后端给、前端不重排(票 10/11 立场),
+                // 所以序号在同一条件下稳定。
+                key: `${active.code || ''}|${metric.code}|${index}`,
                 title: metric.name,
                 tone: loadTone(metric.loadState),
                 // 色键必须**带文字**(票 06 的立场:标签不能只靠颜色,灰度/色盲下也要读得出)。
@@ -365,15 +410,21 @@ export function operationPanel(data, section) {
                     codeText(metric.freshness),
                     metric.abnormal ? '异常' : '',
                 ].filter(Boolean),
-                points: metric.pointCodes.map((pointCode) => ({
-                    key: `${metric.code}|${pointCode}`,
-                    label: metric.name,
-                    pointCode,
-                    objectCode: metric.code,
-                    objectName: metric.name,
-                })),
+                // 票 50:指标行**不产出定位点**(`points` 恒空)。`pointCodes` 实为源设施编码(票 49 裁定),
+                // 不进一张图对象序列,画按钮就是「不可达做成常态」+ 撞名假阳性(PS001/PS002 会飞向
+                // 不相干的排水监测点)。「没有可定位对象就不画按钮」由这里的构造保证(应急保障三段
+                // 的既有形态),不再靠渲染层过滤。
+                points: [],
             })),
             '当前行业没有运行指标',
+            // 票 64:本块接入统一分页(规范条文 1/2/4 + 跳页节)。**为什么这里必须声明**:
+            // 同一个 list 渲染分支服务五处面板,其中管理信息明细(实测 9 行)与应急保障三段
+            // 是规范排除项或另有专票——只有 domain 逐块声明,渲染层才知道该不该切窗口。
+            // 行序不改(票 10/11 立场:前端不重排),分页只切展示窗口。
+            // `pageScope`=页码归属的筛选条件标识:换行业/换片区时它变,页码自然回第 1 页;
+            // 定时刷新时它不变,页码保持(条文 6)。翻页算术与控件都在单源
+            // (`domain/pagination.js` + `PagerNav.vue`),本层只声明「这一块要分页、按什么条件归页」。
+            { paginate: true, pageScope: operationPageScope(active, extra) },
         ),
     ]
 
@@ -385,10 +436,13 @@ export function operationPanel(data, section) {
         blocks,
         // 当前选中行业(后端回显为空时不写"未选择",那会把"全部行业"说成缺陷)。
         industryNote: active.label ? `当前行业:${active.label}` : '当前行业:全部',
-        locate: metrics.flatMap((metric) =>
-            locateEntries(metric.code, metric.name, { code: metric.code, name: metric.name }, metric.pointCodes),
-        ),
-        locateNote: '指标关联点位可定位;点位坐标由一张图回联,未加载到的点位不下发飞行',
+        // 票 64 第 4 条:如实说明数据来源与口径(票 49/50/51 既有裁定,不重开、不发明合并口径)。
+        sourceNote: '运行指标由后端按设施实体逐条生成(水厂/泵站/管网/排水设施四类适配器),一条指标对应一个设施;当前行业取后端回显,不跨行业合并',
+        // 票 50:定位入口整体撤下(locate 恒空)。该面板的关联编码是**设施**编码、不进一张图对象序列,
+        // 对它跑 `locateEntries` 就是「不可达做成常态 + 撞名假阳性」;正确的对象交互**在图上**——
+        // 管段/设施构件拾取走既有「重点对象局部增强」(pipeEnhancement,票 32),本票不动那条链路。
+        locate: [],
+        locateNote: '指标关联的是设施编码(水厂/泵站/管网/排水设施),不在一张图的点位序列里,本面板没有可定位对象;管段与设施构件请在图上拾取(地下管线详情视图)',
         missing: panelMissingNote(current),
         placeholder: sectionPlaceholder(section),
     }

+ 33 - 5
src/modules/cockpit/domain/panelBlocks.js

@@ -317,9 +317,13 @@ export function barTone(label, index) {
     if (/^(正常|在线|已完成|已处理|4\s*级|4级|已解除)$/.test(text)) return 'lv4'
     if (/^离线$/.test(text)) return 'point-offline'
     if (/^无设备$/.test(text)) return 'point-empty'
-    if (/^(未发布|状态未发布|未知)$/.test(text)) return 'point-unknown'
+    // 票 39:「未知/缺失」是监测概览设备状态桶的正式措辞(后端 `unknownOrMissingCount`),
+    // 与「状态未知」(总体概览同一四态)同语义;不登记就落图表色序——
+    // 同一个语义在两个面板一个有色一个没有,正是「早晚不一致」的起点。
+    if (/^(未发布|状态未发布|未知|未知\/缺失)$/.test(text)) return 'point-unknown'
     if (/^(未关联设备|无关联设备)$/.test(text)) return 'point-unassociated'
-    if (/^(过期|未关联工单|未关联|滞后|无数据)$/.test(text)) return 'neutral'
+    // 票 39:「数据滞后」是同一桶的第二措辞(`staleCount`),语义与「滞后」相同(关注色)。
+    if (/^(过期|未关联工单|未关联|滞后|数据滞后|无数据)$/.test(text)) return 'neutral'
     // 4) 其余类别 → 图表色序(按行序,同块内不重色)
     return `chart:${(index % 8) + 1}`
 }
@@ -516,9 +520,33 @@ export function rowsBlock(label, rows) {
     return { kind: 'rows', label, rows }
 }
 
-/** 条目列表块(应急保障三段、管理单位的机构清单都用它)。 */
-export function listBlock(label, rows, emptyText) {
-    return { kind: 'list', label, rows, emptyText }
+/**
+ * 条目列表块(应急保障三段、管理单位的机构清单都用它)。
+ *
+ * 票 64 新增可选 `options`(不传就是原形状,既有调用零影响):
+ *   · `paginate: true` —— 声明「这一块的行由渲染层做统一分页」(规范条文 1/2/4:
+ *     每页 ≤10、页码直达 + 输入框跳页、一页不画控件)。用于**行数可达数千**的块
+ *     (运行信息指标列表实测 6772 条 / 2.3 MB,票 57 首版误按「契约有界」排除,
+ *     2026-10-19 维护者反馈纠正为接入)。
+ *   · 其余字段原样透传(`emptyText` 等)。
+ * 块**不携带页码状态**:分页是「这一屏怎么看」的视图私有状态(票 46 成例),
+ * 渲染层按 `blockKey` 记页码、用 `domain/pagination.js` 的 `pagedRows` 单源切窗口。
+ * @param {string} label 块标题
+ * @param {Array} rows 条目行
+ * @param {string} [emptyText] 空态文案
+ * @param {{paginate?: boolean}} [options]
+ */
+export function listBlock(label, rows, emptyText, options = {}) {
+    const block = { kind: 'list', label, rows, emptyText }
+    if (options.paginate === true) {
+        block.paginate = true
+        // `pageScope`=页码归属的筛选条件标识(换行业/片区⇒变,定时刷新⇒不变)。
+        // 渲染层据此兑现条文 5/6「条件变化回第 1 页、刷新不复位」——键必须是跨重算
+        // **稳定的标量**,不是块对象身份(票 43 第四轮记过的教训:视图计划每次重算
+        // 都生成全新对象,按对象记会把每次刷新都读成换筛选)。
+        block.pageScope = typeof options.pageScope === 'string' ? options.pageScope : ''
+    }
+    return block
 }
 
 /**

+ 35 - 14
src/modules/cockpit/mocks/contractMocks.js

@@ -1548,8 +1548,10 @@ function freshnessPayload(scenario, deviceCode = '') {
  *      `numberish` 而不是 `Number()` 直吃;
  *   2. **`Distribution` 是三类维度共用结构**:应急保障的行业分布值在 `industry` 上、区域分布在
  *      `area` 上,`code` 一律是 `null`(后端的三个构造函数就是这么填的);
- *   3. **`pointCodes` 指向 `MAP_POINTS` 里真实存在的点位**(`MP-001`/`MP-002`/`MP-003`),
- *      所以「点面板对象联动地图」在 mock 上真的能走到定位那一步,而不是永远查不到点位。
+ *   3. **`pointCodes` 按面板各自的真实语义给**(票 49 裁定、票 50 落实):
+ *      运行信息指标装的是**源设施编码**(真实载荷恒单元素、与一张图编码空间零交集——
+ *      面板因此不产定位按钮,票 50);应急保障样例仍指向 `MAP_POINTS` 真实点位(该面板保留
+ *      定位入口,票 50 非目标;真实侧该字段实测恒空、一个按钮都不画,出入见票 49 证据二)。
  */
 
 /** 四个面板的信封公共段(后端的 `requestId/apiVersion/readOnly/displayName/timezone`)。 */
@@ -1612,11 +1614,15 @@ function baseArchivesPayload(scenario, query = {}) {
 }
 
 /** 运行信息样例:三个行业块,指标覆盖三类能力 + 边界值(真 0 / 缺能力基准 / 超载)。 */
-function operatingInformationPayload(scenario) {
+function operatingInformationPayload(scenario, query = {}) {
     const status = infraStatus(scenario)
     const envelope = infraEnvelope('基础设施-运行信息', status)
     const blank = status === 'EMPTY'
 
+    // 票 49 裁定:`pointCodes` 实为**源设施编码**(真实载荷 6362 条无一例外恒单元素),不进一张图
+    // 对象序列 → 运行信息面板不产定位按钮(票 50)。样例按行业用真实同形的设施码
+    // (TCRQ/CNRQ 燃气、TCGC/CNGC 供水、TCWS/TCYS 排水),**不得**用 `MAP_POINTS` 的 MP-*——
+    // 否则 mock 下按钮能点、真实下全 MISSING,同构纪律(票 08/18/38)被打破。
     const metric = (code, name, category, unit, value, capacity, ratio, load, pointCodes) => ({
         metricCode: code,
         metricName: name,
@@ -1643,9 +1649,9 @@ function operatingInformationPayload(scenario) {
                   stateReason: 'READY',
                   abnormalMetricCount: 0,
                   metrics: [
-                      metric('gas_supply_pressure', '燃气供应压力', 'SUPPLY', 'MPa', '0.32', '0.40', '0.8000', 'WATCH', ['MP-002']),
-                      metric('gas_flow_rate', '燃气流量', 'FLOW', 'm3/h', '1200.5', '2000', '0.6003', 'NORMAL', ['MP-001']),
-                      metric('gas_remaining_capacity', '燃气剩余承载能力', 'CAPACITY', 'm3', '800', '2000', '0.4000', 'NORMAL', []),
+                      metric('gas_supply_pressure', '燃气供应压力', 'SUPPLY', 'MPa', '0.32', '0.40', '0.8000', 'WATCH', ['TCRQ202609020001']),
+                      metric('gas_flow_rate', '燃气流量', 'FLOW', 'm3/h', '1200.5', '2000', '0.6003', 'NORMAL', ['CNRQ202609020001']),
+                      metric('gas_remaining_capacity', '燃气剩余承载能力', 'CAPACITY', 'm3', '800', '2000', '0.4000', 'NORMAL', ['TCRQ202609020002']),
                   ],
               },
               {
@@ -1655,10 +1661,10 @@ function operatingInformationPayload(scenario) {
                   abnormalMetricCount: 1,
                   metrics: [
                       // 边界:真 0 是有效读数;超载项走 lv1 色键。
-                      metric('water_supply_flow', '供水流量', 'SUPPLY', 'm3/h', '0', '4000', '0.0000', 'NORMAL', ['MP-001']),
-                      metric('water_supply_load', '供水负荷', 'CAPACITY', 'm3/h', '5000', '4000', '1.2500', 'OVERLOAD', ['MP-001', 'MP-002']),
+                      metric('water_supply_flow', '供水流量', 'SUPPLY', 'm3/h', '0', '4000', '0.0000', 'NORMAL', ['TCGC202609020001']),
+                      metric('water_supply_load', '供水负荷', 'CAPACITY', 'm3/h', '5000', '4000', '1.2500', 'OVERLOAD', ['CNGC202609020001']),
                       // 边界:能力基准缺失 → 负荷率也算不出来(界面显示占位符,不显示 0)。
-                      metric('water_pressure', '供水压力', 'PRESSURE', 'MPa', '0.45', null, null, 'UNKNOWN', []),
+                      metric('water_pressure', '供水压力', 'PRESSURE', 'MPa', '0.45', null, null, 'UNKNOWN', ['TCGC202609020002']),
                   ],
               },
               {
@@ -1667,17 +1673,32 @@ function operatingInformationPayload(scenario) {
                   stateReason: 'READY',
                   abnormalMetricCount: 0,
                   metrics: [
-                      metric('sewage_cod', '污水 COD', 'QUALITY', 'mg/L', '18.2', '50', '0.3640', 'NORMAL', ['MP-003']),
-                      metric('rainwater_flow', '雨水流量', 'FLOW', 'm3/h', '880', '1000', '0.8800', 'WATCH', []),
+                      metric('sewage_cod', '污水 COD', 'QUALITY', 'mg/L', '18.2', '50', '0.3640', 'NORMAL', ['TCWS202609020001']),
+                      metric('rainwater_flow', '雨水流量', 'FLOW', 'm3/h', '880', '1000', '0.8800', 'WATCH', ['TCYS202609020001']),
                   ],
               },
           ]
 
+    // 票 51:mock 必须**吃请求查询串**(至少 industry)—— 不吃的话 mock 下永远所有块全非空、
+    // 选行业不改变响应,真实缺陷在 mock 上演示不出来(票 08/18/38 记过的同一条纪律)。
+    // 照抄线上契约(2026-10-16 实测,载荷落盘 verify/artifacts/t51/):筛选把未选中块置空
+    // (`state: EMPTY` / `stateReason: NO_METRICS`,不删块、不改变块序);selectedIndustry 回显
+    // 命中块,未筛时回 null(线上未筛载荷同款,不再硬编码 GAS);mock 没有的行业也回显请求码。
+    const requested = blank ? '' : query.industry || ''
+    const hit = requested ? industries.find((block) => block.industry.code === requested) : null
+    const filtered = requested
+        ? industries.map((block) =>
+              block.industry.code === requested
+                  ? block
+                  : { industry: block.industry, state: 'EMPTY', stateReason: 'NO_METRICS', abnormalMetricCount: 0, metrics: [] },
+          )
+        : industries
+
     return {
         ...envelope,
         range: { type: 'LAST_24_HOURS', start: '2026-09-01T10:00:00+08:00', end: '2026-09-02T10:00:00+08:00' },
-        selectedIndustry: blank ? null : { code: 'GAS', key: 'gas', label: '燃气' },
-        industries,
+        selectedIndustry: hit ? hit.industry : requested ? { code: requested, key: '', label: '' } : null,
+        industries: filtered,
         excluded:
             scenario === 'degraded'
                 ? { unknownIndustryCount: null }
@@ -2573,7 +2594,7 @@ const PAYLOADS = {
     '/equipments/*/archive': (s) => (s === 'empty' ? null : ARCHIVE),
     // 票 17:基础设施模式四块面板的面板级聚合读模型(各块独立成败,互不牵连)。
     '/infrastructure/base-archives': (s, _requested, context) => baseArchivesPayload(s, context.query),
-    '/infrastructure/operating-information': (s) => operatingInformationPayload(s),
+    '/infrastructure/operating-information': (s, _requested, context) => operatingInformationPayload(s, context.query),
     '/infrastructure/management-information': (s, _requested, context) => managementInformationPayload(s, context.query),
     '/infrastructure/emergency-support': (s) => emergencySupportPayload(s),
     // 票 18:运行监测模式三块面板(台账与监测分布**共用** distribution 的这一个响应)。

+ 2 - 1
src/modules/cockpit/services/infrastructureApi.js

@@ -64,7 +64,8 @@ export function fetchBaseArchives(options = {}) {
 }
 
 /**
- * 运行信息(票 17 验收:供应 / 输送 / 承载能力 + 指标占位)。
+ * 运行信息(票 17 验收:供应 / 输送 / 承载能力 + 指标清单;票 64 把该块标题按真实内容
+ * 定为「运行指标」并接入统一分页)。
  * 「空」= 后端一个行业块都没给,或所有行业块的指标都为空。
  */
 export function fetchOperatingInformation(options = {}) {

Vissa filer visades inte eftersom för många filer har ändrats