Ver código fonte

feat(cockpit): shared pager batch1+2 - pagination single source, PagerNav, detail-list pagination (#59 #60)

Batch 1 (#59): promote pagedRows to domain/pagination.js single source
(PAGE_SIZE/pageWindow/clampPage + ticket-64 slot helpers), shared
PagerNav.vue (controlled, one @page outlet), EquipmentDetailSections
migration (warnings/alarms/ work orders unified at 10/page), t46/t12
gates rewritten per convention.

Batch 2 (#60): PointDetailCard three lists (device cards, associated
device layer list, ticket-14 unmatched sub-list) windowed via the shared
pager; independent pages keyed by stable scalars; reset watch collapsed
to one scalar string (ticket-43 lesson); PagerNav gains show-count
(default true, existing consumers unchanged); ticket-42 totals/classif-
ication untouched (asserted). t60 42/42 + mutations 10/10.

Tracker: spec rev, issues 57/59/60, map (57/59/60 hunks only, surgical
blob; parallel map edits stay uncommitted). Gates verified on the pure
commit content in a worktree. Real-host walkthrough stays with ticket 18.
ChenJZ 6 horas atrás
pai
commit
18263a6c07

+ 71 - 0
.scratch/cockpit-real-data-wayfinding/issues/57-untitled.md

@@ -0,0 +1,71 @@
+<!-- DSH-IDEMPOTENCY-KEY: monitoring-feedback-global-pagination-spec-v1 -->
+# 规格票 —— 驾驶舱所有长列表统一分页(每页十条、页码与前后页)
+
+Label: wayfinder:task
+Category: monitoring-feedback-spec
+
+## Question
+
+建立并执行驾驶舱全局分页规范:凡可能包含表格或卡片列表、且新增数据会无限拉长页面的区域,统一每页最多 10 条,支持页码直达、上一页、下一页;一页时不画控件。需要先盘点所有列表/表格区域,定义哪些是服务端分页、哪些是前端已加载集合分页,明确筛选/分类/快照/失败态/异步竞态与无障碍规则,再分批落地,避免各组件各写一套分页状态机。
+
+## Acceptance
+
+- 规格落盘并列出全仓驾驶舱可能增长的表格/卡片列表区域及当前数据来源、现有分页状态。
+- 默认 `pageSize = 10`,前端展示不超过 10 条;可页码直达、上一页、下一页。
+- 过滤/分类先于分页;条件变化回到第 1 页;总数不由当前页反推。
+- 一页不显示无意义 pager;空态、失败态、加载态、禁用态有统一语义。
+- 明确服务端分页与前端分页边界,以及地图渐进加载不被误改为页码分页。
+- 产出后再分别创建实施票;本票只收口规范与盘点,不要求一次改完所有列表。
+
+## Context
+
+现有先例:`domain/equipmentDetailView.js` + `EquipmentDetailSections.vue` 已实现预警/报警每页 10 条;事件清单、监测点台账、监测分布/地图摘要、设备关联列表等需盘点。
+
+## Related spec
+
+[`../specs/2026-10-17-monitoring-feedback.md`](../specs/2026-10-17-monitoring-feedback.md)
+
+## 交付物
+
+- **规范落盘**:[`../specs/2026-10-18-global-list-pagination.md`](../specs/2026-10-18-global-list-pagination.md) —— 含 11 条统一行为条规、服务端/前端集合/渐进装载三分法、全仓 14 行盘点表(含数据来源与现状)、实施批次、非目标。
+- **维护者裁定(2026-10-18)**:① 监测点台账数据保持自动渐进式加载(与地图点位同款),UI 做页码翻页;② 事件清单走**服务端**页码分页。
+
+## 实施票(已创建,批次二~五 blocked by 批次一)
+
+- [批次一:共享分页基建(`domain/pagination.js` + `PagerNav.vue`)与设备详情三清单](59-pagination-js-pagernav-vue.md)
+- [批次二:点位摘要两级清单(设备卡片、关联设备图层清单)](60-untitled.md) —— blocked by 59
+- [批次三:预警处置事件清单服务端页码分页(当前事件表 + 历史事件)](61-untitled.md) —— blocked by 59
+- [批次四:运行监测(异常设备清单分页;台账自动渐进装载 + 页码 UI)](62-ui.md) —— blocked by 59
+- [批次五:基础设施应急保障三段接入统一分页](63-untitled.md) —— blocked by 59
+
+## 进度
+
+<!-- 做到哪一步了,在这里一行一行记 -->
+
+- 2026-10-18 认领(@me)。无 needs-triage(`ready-for-agent`、无既有实施、无阻塞),阶段闸门=直接调查后落盘规范。
+- 调查(先查后问,仓内既有资料足够,未新开 research 笔记):读票 57 全文/评论/标签/父子与阻塞边、上位规格 `specs/2026-10-17-monitoring-feedback.md` §5、先例 `domain/equipmentDetailView.js`(`pagedRows`/`HISTORY_PAGE_SIZE`)+ `EquipmentDetailSections.vue`、共享渲染器 `PanelBlocks.vue`/`domain/panelBlocks.js`、事件清单三件套(`eventStore`/`domain/eventList.js`/`EventListPane.vue`)、`monitoringStore`/`domain/monitoringPanels.js`(台账与异常清单)、`domain/warningResponsePanels.js`/`services/warningResponseApi.js`、`domain/infrastructurePanels.js`、`PointDetailCard.vue`、`mapStore`/`domain/mapSummary.js`、`mocks/contractMocks.js` 分页信封、`domain/eventRange.js`(分窗)、票 18/23/40/42/46 既有记录。
+- **盘点发现的关键缺口**:① 先例 pager 只有前后页,**缺页码直达**(规范要求补);② 「加载下一页监测点」每点一次追加后端一页 ≈500 卡,是"无限拉长页面"重灾区;③ 事件表前端砍最新 12 条、历史事件 `slice(0,12)`;④ 运行概况异常设备清单、点位摘要两清单、设备详情工单状态全量直渲无分页;⑤ `historyWindows` 近 30/90 天为多窗合并,不存在"全局页"→ 需回退规则。
+- 提问与裁定(两问均带推荐项,用户按推荐+一条更正答复):台账"数据渐进、UI 翻页"(非二选一)、事件清单"服务端页码分页"。
+- 规范落盘 + 五张分批实施票创建 + 依赖边(60/61/62/63 blocked by 59)建成 4/4。
+- 验收自查:六条验收全部达成(规格落盘含盘点表/pageSize=10 与三件控件入规范/先筛后翻与复位与总数口径成文/一页不画控件与各态语义成文/三分法明确且地图渐进不改页码/五张实施票已建)。本票按要求**只收口规范与盘点、不实施**。
+
+Status: resolved
+Assignees: codex
+Type: task
+Blocked by:
+Labels: wayfinder:task
+
+## Comments
+
+### local — 2026-10-18T10:13:16.556Z
+认领并开始:规格票——驾驶舱长列表统一分页(每页十条、页码与前后页)。先盘点全仓列表区域与既有先例,再落盘规格并拆实施票。
+
+## Answer
+
+**规范已收口、实施已拆票,本票关闭。**
+
+规格文件 [`specs/2026-10-18-global-list-pagination.md`](../specs/2026-10-18-global-list-pagination.md) 定下:`PAGE_SIZE = 10`;控件「上一页|页码直达|下一页 + 第 X/Y 页 · 共 N 条」;先筛后翻、条件变化回第 1 页、定时刷新不复位;一页不画控件;到边禁用保占位;总数不反推;态与无障碍语义统一;翻页算术与 pager 控件各只有一份单源。
+
+三分法定型:**服务端页码**(当前事件表、历史事件单窗)/**前端集合**(设备详情三清单、点位摘要两清单、运行概况异常清单、历史事件多窗合并)/**渐进装载 + 页码 UI**(监测点台账:数据与地图同款自动渐进,UI 翻页);**地图摘要点位装载明确排除**,保持自动渐进不改页码。
+
+全仓盘点 14 行落表(8 处接入、5 处排除、1 处地图渐进),五张分批实施票 59–63 已建并挂依赖(60/61/62/63 blocked by 59)。本票不含实施,实施状态以各执行票为准。

+ 78 - 0
.scratch/cockpit-real-data-wayfinding/issues/59-pagination-js-pagernav-vue.md

@@ -0,0 +1,78 @@
+<!-- DSH-IDEMPOTENCY-KEY: pagination-batch1-shared-pager-v1 -->
+<!-- parentKey: 00 -->
+<!-- created: 2026-10-19T05:36:40.639Z -->
+# 前端执行票 —— 统一分页批次一:共享分页基建(pagination.js + PagerNav.vue)与设备详情三清单
+
+
+## Question
+
+按 [全局分页规范](../specs/2026-10-18-global-list-pagination.md) 落地批次一:把 `domain/equipmentDetailView.js` 的 `pagedRows` 升格为单源 `domain/pagination.js`(`PAGE_SIZE = 10`、`pagedRows`、页码窗口函数;`HISTORY_PAGE_SIZE`/`pagedRows` 原名保留导出兼容),新建共享 `PagerNav.vue`(上一页|页码直达|下一页 + **数字输入框跳页** + 计数行「第 X/Y 页 · 共 N 条」,一页不渲染、到边禁用保占位、`aria-current="page"`、总页数 >7 收缩「1 … c−1 c c+1 … Y」窗口);`EquipmentDetailSections.vue` 预警/报警迁移到共享 pager 并**补页码直达**,工单状态新接入分页(10/页)。
+
+## Acceptance
+
+- 页码窗口规则有纯函数闸门(≤7 全列;>7 收缩含首末与省略号;非法输入不抛错)。
+- **跳页(维护者 2026-10-19 追加,规范「跳页」节)**:pager 提供输入框 + 「跳转」按钮/Enter;非数字、0、超 `pageCount` 一律夹回 1..pageCount,不报错、不发请求;`aria-label="跳转到指定页"`、placeholder=当前页;跳页与页码/前后页共用同一翻页出口,不新增第二套页码状态;总页数 ≤1 时整块不渲染。
+- 预警/报警翻页行为与现状等价(分类变化回第 1 页、换设备全复位、越界夹回)+ 新增页码直达与跳页;t46 既有闸门按成例改写后全绿。
+- 工单状态每页 ≤10、一页不画控件、分类变化回第 1 页。
+- pager 控件唯一(`PagerNav.vue`),组件内不再有第二份翻页算术;`data-pager`/`data-page-prev`/`data-page-next`/`data-page-state` 断言面保留,跳页新增 `data-page-jump`/`data-page-jump-input`。
+- 新增闸门 + 变异打红;按 AGENTS.md 跳过 build/dev/浏览器验证。
+
+## Context
+
+先例:`domain/equipmentDetailView.js`(`pagedRows` 纯函数)+ 票 46 实施记录。教训复用:票 43(视图状态必须用稳定标量键,不能用对象身份);复位触发器在组件 watch(`deviceCode`)。维护者裁定(2026-10-18 台账/事件、2026-10-19 跳页)见规范「维护者裁定」「跳页」「修订记录」节。
+
+## Related spec
+
+[`../specs/2026-10-18-global-list-pagination.md`](../specs/2026-10-18-global-list-pagination.md)
+
+Status: resolved
+Assignees: Qoder
+Type: task
+Blocked by:
+Labels: wayfinder:task
+
+## Comments
+
+- 批次一开工说明:本票为纯执行票(ready-for-agent,无 needs-triage),无阻塞边,完成后解锁批次二~五(票 60–68 共 9 张 blocked by 本票)。仓内既有资料(规范 + 票 46 实施记录 + `equipmentDetailView.pagedRows` 先例)足以回答本次全部问题,未新开 research 笔记。
+
+## Answer
+
+**已实施、闸门 + 变异闭环并关闭(2026-10-19)。按 AGENTS.md 跳过 build/dev/浏览器验证。**
+
+### 交付物
+
+| 文件 | 内容 |
+|---|---|
+| `src/modules/cockpit/domain/pagination.js`(新) | 翻页窗口计算单源:`PAGE_SIZE = 10`、`PAGE_ELLIPSIS = '…'`、`pagedRows`(自票 46 逐字升格,行为不变)、`pageWindow(current, total)`(≤7 全列;>7 收缩「1 … c−1 c c+1 … Y」,恒含首末+当前页+至少一个省略号;脏输入按最小安全形状回、不抛)、`clampPage(value, pageCount)`(跳页夹回 1..pageCount;非数字/空/null→1,绝不回 NaN;小数截断;脏 pageCount→1) |
+| `src/modules/cockpit/domain/equipmentDetailView.js` | `pagedRows`/`HISTORY_PAGE_SIZE` 改为**原名兼容再导出** `export { PAGE_SIZE as HISTORY_PAGE_SIZE, pagedRows } from './pagination.js'`(同一函数引用,非复制实现),旧调用与 t46 闸门零改动可跑 |
+| `src/modules/cockpit/components/PagerNav.vue`(新) | 全仓唯一共享 pager:上一页|页码直达|下一页 + 数字输入框跳页 + 计数行「第 X/Y 页 · 共 N 条」;`pageCount<=1` 整块不渲染;到边 `disabled` 保占位;当前页 `aria-current="page"` 且点击不跳;`role="group"` + `「列表名」分页` aria-label;跳页 `aria-label="跳转到指定页"`、`:placeholder="String(page)"`、Enter + 按钮双入口 |
+| `src/modules/cockpit/components/EquipmentDetailSections.vue` | 预警/报警/工单三清单都接共享 `PagerNav`;内联 pager 与 `.equipment-pager__*` 样式退役;工单列表循环 `visibleRows(panel)` → `pagedOf(panel).rows`(新接入分页);`pagedOf` 收敛为**单一委托**(不再按 kind 分叉兜底形状) |
+
+### 关键设计(唯一翻页出口)
+
+规范「跳页」节要求「跳页与页码/前后页共用同一个翻页出口,不新增第二套页码状态」。落地形态:**`PagerNav` 是受控组件**——本地只有跳页输入缓冲(`jumpValue`),页码一律从 `props.page` 进;`defineEmits(['page'])` 单一事件,所有交互(前后页/页码直达/跳页)统一 emit **绝对目标页码(1 起)**,由 `go()` 一处出口发出。
+
+> 第一版草稿曾用相对增量(`emit('change', -1|1|target)`),点第 1 页(当前 2 页)emit `-1` 与「上一页」撞车、父组件必须靠 `!== -1 && !== 1` 猜意图——那不是同一出口,是两套状态挤在一个通道里。改为绝对页码后父侧只需 `gotoPage(panel, target)` 存状态,越界夹回由 `pagedRows` 下一帧渲染时做(条文 6),组件内零 `Math.min/max`。
+
+跳页同值不 emit(不发请求、不清列表);输入经 `clampPage` 后走同一 `go()`。复位规则不变:`toggleCategory` 回第 1 页、`watch(deviceCode)` 全复位。
+
+### 验证
+
+- **新增闸门 [t59-shared-pager.mjs](../verify/t59-shared-pager.mjs) 37/37**:P 单源与兼容(P2 判**同一函数引用**、P3 判旧模块无残留实现、P5 越界夹回);W 页码窗口九条(含 7 页阈值边界、8 页起步收缩、贴左不出现假省略号、恒含首末与当前页、数字升序无重复、脏输入不抛);J 跳页夹回五条;S PagerNav 静态守卫九条(按**绑定**判不按字样判);C 消费方七条(C4 判组件零 `Math.ceil/trunc/floor`、零 `.slice(`、零内联 pager、零 `hasPrev/hasNext`——第二份算术即红);K 两份 `.vue` 用 `@vue/compiler-sfc` **真实编译过**(票 41 教训:拼错的绑定只有加载层判得出)。
+- **变异闸 [t59-mutations.mjs](../verify/t59-mutations.mjs) 13/13 打红并按字节还原**:M1 阈值 7→5 / M2 右侧省略号不推 / M3 夹回放 NaN 过 / M4 只夹下不夹上 / M5 一页也画控件 / M6 出口改名出第二个事件 / M7 到边不再禁用 / M8 丢 `aria-current` / M9 跳页绕过 `clampPage` / M10 兼容导出改名(崩溃式抓住)/ M11 组件重长翻页算术 / M12 工单退回全量直渲 / M13 换分类不再回第 1 页。
+- **t46 既有闸门按成例改写后全绿**(54/54 + 变异 12/12):E3 的「工单走过滤行」改为「工单也走分页行」;E3b 从「内联按钮按 hasPrev/hasNext 置灰」改判「内联 pager 退役 + 接共享 PagerNav + 唯一 `@page→gotoPage` 出口」;M5/M8 变异锚点随升格从 `equipmentDetailView.js` 移到 `pagination.js`(G 节测的仍是兼容再导出,同一份实现)。
+- **回归与 HEAD 基线对照**(本票 diff 只 5 个文件,余为并行在制品):t41 SSR 9/9、t23、t15 11/11 全绿;t12 由基线 51/52 保持 51/52(`E8` 经 worktree 基线证实为改前既有红,与本票零交集)。
+- **t12 扫描器假红修正**:`F11` 把事件处理器里的箭头形参 `target` 误报成未绑定标识符(与本文件既有「v-for 局部名」同类的判据过宽,注释里已记三次同类实测)。按成例把 `([^)]*)\s*=>` 的形参收进模板局部名,不改组件写法迁就守卫。
+
+### 验收对照
+
+1. ✅ 窗口纯函数闸门 W1–W9 + 变异 M1/M2。
+2. ✅ 跳页:输入框 + 按钮/Enter;非数字/0/超界夹回不报错不发请求(J1–J5、M3/M4);`aria-label`/placeholder(S5);同一出口不新增第二套状态(S2/S6/S8、M6/M9);≤1 页整块不渲染(S1、M5)。
+3. ✅ 预警/报警行为等价(C5 复位规则、P5 越界夹回)+ 页码直达与跳页;t46 改写后 54/54。
+4. ✅ 工单 ≤10/页(C3、M12)、一页不画控件(S1)、分类变化回第 1 页(C5、M13)。
+5. ✅ pager 唯一 + 组件无第二份算术(C4、M11);`data-pager`/`prev`/`next`/`state` 保留、新增 `data-page-jump`/`data-page-jump-input`(S9)。
+6. ✅ 新增闸门 + 变异打红;按 AGENTS.md 跳过 build/dev/浏览器验证。
+
+### 状态与下一步
+
+批次一收口,**票 60–68 九张批次的共享前置已就位**(`pagination.js` 单源 + `PagerNav.vue` 唯一控件可直接接入;服务端分页与渐进装载批次另需按规范三分法扩参)。两点如实留档:① **改动未提交**(工作树混票 46/50/51 等并行在制品,入库须按票 33/36/37 纪律单独裁剪本票 5 个文件的 hunks);② 浏览器走查按项目规则未自动执行,真机验收归票 18/17 联调窗口。

+ 104 - 0
.scratch/cockpit-real-data-wayfinding/issues/60-untitled.md

@@ -0,0 +1,104 @@
+<!-- DSH-IDEMPOTENCY-KEY: pagination-batch2-point-detail-lists-v1 -->
+<!-- parentKey: 00 -->
+<!-- created: 2026-10-19T05:36:40.736Z -->
+# 前端执行票 —— 统一分页批次二:点位摘要两级清单(设备卡片、关联设备图层清单)
+
+## Question
+
+按 [全局分页规范](../specs/2026-10-18-global-list-pagination.md) 落地批次二:`PointDetailCard.vue` 两个全量直渲清单接入共享 pager——「设备卡片」(`data-device-cards`,详情端点 `devices[]`)与「关联设备图层清单」(`data-device-layer-list`,票 42 的快照 `deviceCodes` 全量保留)。前端集合分页,每页 10 条、页码直达/前后页,两清单各自独立页码;换点位/设备时复位。
+
+## Acceptance
+
+- 两清单每页 ≤10 条、`pageCount <= 1` 不画控件、到边禁用保占位。
+- **票 42 口径不变**:关联设备清单数据仍保留全部编码(含待确认),分页只是窗口化渲染;互斥归类(已上图/无地图位置/模式隐藏/待确认)与差额可追溯不受影响。
+- 换点位/设备/返回第一级时页码复位;数据刷新(freshness tick)不复位,越界夹回。
+- 空态文案沿用既有(「该监测点当前没有关联设备」等);新增闸门 + 变异打红;既有相关闸门按成例改写后全绿。
+
+## Context
+
+共享 pager 由批次一提供(`domain/pagination.js` + `PagerNav.vue`)。教训复用:票 43(稳定标量键);票 42(清单总数只认当前快照、每台设备恰好落一类)。
+
+## Related spec
+
+[`../specs/2026-10-18-global-list-pagination.md`](../specs/2026-10-18-global-list-pagination.md)
+
+## 进度
+
+- [x] 认领(Assignees: Qoder;阻塞票 #59 已关闭,处于 frontier)
+- [x] 读票全(正文/评论/标签/认领/父 00/阻塞边 #59)+ 规范 + 票 59 成例 + 既有闸门断言面
+- [x] 调查:仓内资料足以回答,未新开 research 笔记;两处票面未写死的分叉已向维护者裁定(未命中子清单分页 / 不画计数行),记在开工说明评论
+- [x] 实施:`PagerNav.vue` 新增 `showCount`(默认 true,既有消费方零改动);`PointDetailCard.vue` 三清单接共享 pager + `pages` 稳定标量键 + 复位 watch
+- [x] 新增闸门 [t60-point-detail-lists.mjs](../verify/t60-point-detail-lists.mjs) 42/42 + 变异 [t60-mutations.mjs](../verify/t60-mutations.mjs) 10/10 打红并按字节还原
+- [x] 回归全绿:t59 37/37 + 变异 13/13、t46 54/54 + 变异 12/12、t10 57/57、t41 SSR 9/9、t42 39/39、t43 40/40、t44 47/47、并行票 64 61/61
+- [x] 规范「修订记录」补两条当次裁定(口径只留一份)
+- [x] 收口(本票 resolved)
+
+Status: resolved
+Assignees: Qoder
+Type: task
+Blocked by: #59(已关闭)
+Labels: wayfinder:task
+
+## Comments
+
+- 批次二开工说明 + 维护者当次两条裁定(见本票 Answer 的「两条当次裁定」节;已读资料路径与一句话结论逐条记在该评论)。
+
+### local — 2026-10-20T16:51:39.623Z
+(收口时正文整篇替换误覆盖此条,补回以保留 provenance)已读资料与一句话结论:
+- 规范 `specs/2026-10-18-global-list-pagination.md` — 条文 2/3/4/6/7/11 + 三分法「前端集合」明列本票两清单。
+- 票 59 Answer — 共享件已就位:`domain/pagination.js`(pagedRows/pageWindow/clampPage)+ `PagerNav.vue`(受控、唯一 @page 出口携带绝对页码);接线成例 = `pages` 组件本地状态 + `pagedOf()` + `gotoPage()`,复位在组件 watch。
+- `components/PointDetailCard.vue`、`stores/deviceLayerStore.js`、`domain/detailFlow.js` — 设备卡片读 `pointData.devices[]`;关联清单读 store `rows`(保留全部编码),总数读 `total`(只认当前快照 `deviceCodes`),归类/图例读 `counts`/`legend`,票 14 未命中行读 `placement === 'unmatched'`。
+- 既有闸门/走查断言面:t42 B1b(总数 ≠ 清单行数)、t43 F9(卡片与清单 hover/select 绑定)、t44 F7/F10/F12、t46 E5/E6/E7b/E8、t59 S7/C 组、t18/t44/t45 走查探针(`data-device-layer-row`/`data-device-more` 读行)。
+调查结论:仓内既有资料足以回答本次全部问题,未新开 research 笔记。两条票面未写死分叉已向维护者裁定(见 Answer「两条当次裁定」)。
+
+
+## Answer
+
+**已实施、闸门 + 变异闭环并关闭(2026-10-19)。按 AGENTS.md 跳过 build/dev/浏览器验证。**
+
+### 交付物
+
+| 文件 | 内容 |
+|---|---|
+| `src/modules/cockpit/components/PointDetailCard.vue` | 三个清单接入共享 pager:「设备卡片」(`data-device-cards`,详情端点 `devices[]`)、「关联设备图层清单」(`data-device-layer-list`,命中子集)、「未命中设备清单」(票 14 展开子清单,当次裁定 1)。窗口行 `pagedCards/pagedLayerRows/pagedUnmatchedRows` 全部经 `pagedOf()` → `domain/pagination.js` 的 `pagedRows`(条文 11 单源,组件零 `Math.*`/零 `.slice(`);页码是视图本地状态 `pages = { cards, layer, unmatched }`(稳定标量键,票 43 教训),唯一出口 `gotoPage(key, target)`;复位 watch 的 getter 收成**一个标量字符串** `` `${pointCode}|${deviceCode}` `` |
+| `src/modules/cockpit/components/PagerNav.vue` | 新增 `showCount` prop(`Boolean`,**默认 `true`**):计数行是唯一受它门控的块(`v-if="showCount"`),前后页/页码直达/跳页能力不受影响。批次一既有消费方(设备详情三清单)不传该 prop ⇒ 形态逐字不变 |
+| `.scratch/.../specs/2026-10-18-global-list-pagination.md` | 「修订记录」追加票 60 两条当次裁定(规范是唯一口径来源,不在别处另写第二份) |
+
+### 两条当次裁定(票面未写死,已向维护者确认后按此实施)
+
+1. **票 14 的「另有 N 台 · 未命中当前筛选」展开子清单同样 ≤10/页,第三套独立页码。** 规范判据一视同仁(可能拉长页面就分页);`pageCount<=1` 时共享层自己 v-if 掉 ⇒ 常态界面零新增控件。「另有 N 台」的 N 仍读**全量** `unmatchedDeviceRows.length`(`data-device-more-count` 不变),差额可追溯是票 14 的口径。
+2. **本组件三处 pager 不画计数行**(`:show-count="false"`)。理由:上方已各有权威总数——设备卡片侧是 KPI「设备合计」(读后端统计),关联清单侧是标题「总数 X 台(已上图 Y)」(只认当前快照);再并排放一个「共 N 条」(窗口口径)就是并排的第二个总数,且窗口条数 ≠ 快照总数正是票 42 明写的「总数 ≠ 清单行数」。
+
+> 实施中途的一次自我纠正:pager 容器在 `<ul>` 里必须是 `<li>`(否则 HTML 非法),而普通 `<li>` 在 `PagerNav` 不渲染时会留一条空行 —— 用 `.point-detail__pager { display: contents }` 收口,**不在组件里重复判 `pageCount>1`**(那条规则仍只在共享层有一份)。复位 watch 第一版写成数组 getter,会让「重进同一点位」(`selection` 换新对象、标量同值)假复位,按票 43 教训改成标量字符串。
+
+### 票 42 口径未动的证明(不是声明,是断言)
+
+分页只发生在渲染窗口:`store.rows` 仍是全量 27 行、`total` 仍是快照口径 27(15 命中 + 12 未命中)、`counts` 互斥归类之和仍 = 全量、`legend` 合计仍 = 全量;标题的两个数与图例的绑定**一字未动**。闸门 K 组五条逐条钉住(K1/K1b/K2/K3/K4),并有变异 M5「标题总数改读窗口条数 → K2 转红」证明它抓得住。
+
+### 验证
+
+- **新增闸门 [t60-point-detail-lists.mjs](../verify/t60-point-detail-lists.mjs) 42/42**:真实链路灌数(mock 传输 + `mapStore.advance` + `detailStore.openPoint` + `deviceLayerStore.syncForSelection({filterActive:true})`),渲染用 Vue 自定义渲染器在纯对象树上跑**真实 patch**(票 64 成例,不引 jsdom)。
+  - R 组:每页 ≤10、点页码 2 逐条对编码(画的是第 11–20 台)、两清单页码互相独立、末页只画余数、到边 `disabled` 且节点仍在、上一页走同一出口;`data-device-card` **续全局下标**(第 2 页是 10..19 —— 走查/探针按「第 N 台」取数,翻页不得让它每页都从 0 数)。
+  - T 组:折叠钮文案仍报全量 12 台、展开后 ≤10、第三套独立 pager、翻末页只画余数、不牵连前两处。
+  - K 组:票 42 口径(见上)。
+  - Z 组(条文 5/6 正反两半):数据刷新**不复位**(还停第 2 页)、跳 999 夹回最后一页且画的是余数、**同点位重进不误复位**、换点位回第 1 页、短列表(2 条)三处控件都不画、返回第一级复位、空态文案与「空清单不画控件」。
+  - S 组:`showCount` 默认 `true`、计数行是唯一受控块、`EquipmentDetailSections` 不传 `show-count`(既有计数行保留)、三处都 `:show-count="false"`、三处共用唯一出口、单源一条委托、复位键是标量、标题总数与归类仍读 store。
+  - C 组:三份 `.vue` 用 `@vue/compiler-sfc` 真实编译过(票 41/59 教训:拼错的绑定只有加载层判得出)。
+- **变异闸 [t60-mutations.mjs](../verify/t60-mutations.mjs) 10/10 打红并按字节还原**:M1 卡片下标退回页内 0 起 / M2 关联清单退回全量直渲 / M3 未命中退回全量展开 / M4 差额读数改读窗口 / M5 标题总数改读窗口条数 / M6 `showCount` 默认改 false(批次一形态回归)/ M7 组件重长翻页算术 / M8 翻页多开一个出口 / M9 换点位不再复位 / M10 复位触发器退回数组 getter。
+- **既有闸门「无需改写即全绿」**(本票未动任何既有判据,如实记):t59 37/37 + 变异 13/13、t46 54/54 + 变异 12/12、t10 57/57、t41 SSR 9/9、t42 39/39、t43 40/40、t44 47/47、并行票 64 61/61。t59 的 S7(计数行措辞)之所以仍绿,正因为 `showCount` 默认 `true`。t11/t05 的崩溃与本票无关:改动前后同点位、同行号失败(`t11:527`、`t05:343`,均在详情静态守卫之前),属并行在制品既有红。
+- **浏览器走查探针面逐条预判**(真机复验归票 18/17 联调窗口):t45 `W4`(`rows === 2`)与 t44(`DEVICE_CODES` 2 台)都落在 ≤10 场景;t44 的行同源断言读的是引擎计划 `getAssociatedDevices().plan`(store 全量),不是 DOM 窗口;t18 `D2e`(`moreCount === '1'`、展开含 EQ-001)在未命中 1 条时 `pageCount=1` ⇒ 不画控件、行仍在;t10/t11/t12 点 `[data-device-card="0"]` 在第 1 页仍存在。**没有一条既有走查判据需要因本票改写**(多页场景的真实证据归票 18 走查窗口)。
+
+### 验收对照
+
+1. ✅ 每页 ≤10(R1/R2/T2)、一页不画控件(Z4b/Z6b)、到边禁用保占位(R5/R10)。
+2. ✅ 票 42 口径不变(K1/K1b/K2/K3/K4 + 变异 M4/M5);数据仍保留全部编码含待确认,分页只窗口化渲染。
+3. ✅ 复位与保持(Z1 刷新不复位、Z2 越界夹回、Z3 同点位重进不误复位、Z4 换点位、Z5 返回第一级)。
+4. ✅ 空态文案沿用既有(Z6 + S9);新增闸门 + 变异打红;既有闸门无需改写即全绿。
+5. ✅ 单源:翻页算术与控件各只有一份(S4/S5/S6 + 变异 M7/M8)。
+6. ✅ 按 AGENTS.md 跳过 build/dev/浏览器验证。
+
+### 状态与下一步
+
+批次二收口。**两点如实留档**:① **改动未提交**(工作树混有票 46/50/51/64 等并行在制品;本票足迹只有 `components/PointDetailCard.vue`、`components/PagerNav.vue` 两份源文件 + `verify/t60-*.mjs` 两份脚本 + 规范修订记录,入库须按票 33/36/37 纪律单独裁剪);② 浏览器/真机验收按项目规则未执行,归票 18/17 联调窗口(≥10 台设备的点位复验建议用 `data-point-pager="cards|layer|unmatched"` 三处包装面取数)。
+
+后续批次(票 61–68)仍按批次一的共享件接入,无需再改本票两份源文件;**唯一例外**:若某消费方也需要「上方已有权威总数、不画计数行」,复用本票的 `show-count`(判据已写进规范修订记录,不要再在组件里另写第二份 pageCount 判断)。

+ 7 - 1
.scratch/cockpit-real-data-wayfinding/map.md

@@ -72,7 +72,9 @@ Labels: wayfinder:map
 - [ ] [前端执行票 —— 监测概览数据时间对齐与四统计卡 2×2 布局](issues/54-2-2.md) — 2026-10-17 维护者反馈 2:数据时间与上一行对齐,四卡 2×2
 - [x] [前端执行票 —— 监测分布口径块字号与共享行块视觉对齐](issues/55-untitled.md) — 已实施并关闭(2026-10-17):rows renderer 的标签/值统一共享 `--fs-caption` 与 `line-height: 1.35`,静态守卫 5/5;业务字段与统计口径不变,按 AGENTS.md 跳过 build/dev/browser
 - [x] [前端执行票 —— 运行概况顶部摘要折叠消息交互](issues/56-untitled.md) — 已实施并关闭(**三轮**,提交随本票入库):顶部说明默认折叠、**只有点击**展开/收起;折叠推广到全部面板的 11 个视图级说明字段(含总体概览-运行监测的故障口径 gap,及原本在渲染层无消费点而隐形的 note/linkNote/excludedNote);每条保留原 data-* 断言面、v-show 折叠信息不丢;t56 32/32 + 变异 9/9(行为层双向普查),回归全绿。
-- [ ] [规格票 —— 驾驶舱所有长列表统一分页(每页十条、页码与前后页)](issues/57-untitled.md) — 2026-10-17 维护者反馈 5:先盘点再分批落地,默认每页最多 10 条
+- [x] [规格票 —— 驾驶舱所有长列表统一分页(每页十条、页码与前后页)](issues/57-untitled.md) — 已收口(2026-10-18,closed):规范落盘 [specs/2026-10-18-global-list-pagination.md](specs/2026-10-18-global-list-pagination.md),14 行全仓盘点 + 三分法定型,拆出实施票 59–63(60/61/62/63 blocked by 59)
+- [x] [批次一:共享分页基建与设备详情三清单](issues/59-pagination-js-pagernav-vue.md) — **已实施、闸门 + 变异闭环并关闭(2026-10-19,代码随本 commit 入库)**;批次二~五的共享前置(`domain/pagination.js` 单源 + `PagerNav.vue` 唯一控件)已就位,票 60–68 解锁
+- [x] [批次二:点位摘要两级清单分页](issues/60-untitled.md) — **已实施、闸门 + 变异闭环并关闭(2026-10-19,代码随本 commit 入库)**;见 Decisions so far
 - [x] [41: 后端执行票 —— 预警设置端点改为预警自身生命周期统计](issues/41-warning-settings-endpoint-rework.md) — 已实施、测试、第二实例真实复验并推送关闭;预警族改预警自身生命周期(WarningWorkflow 六态/等级/行业/片区分布 + 已解除率),报警族保留工单口径并存 `alarm_status` 生命周期;契约测试 24/24、全量 498/498、真实分布四组和恒等于 total;提交 `b047a60d` 已推 origin/master;基线卡片 [warning-settings-lifecycle](handoff/baselines/warning-settings-lifecycle.md)
 - [x] [42: 前端执行票 —— 预警设置面板消费预警自身生命周期契约](issues/42-42.md) — 已实施、守卫、mock 同构并收口(提交 `0cd16ac` 本地未推送,推送需维护者裁剪协调,见票面);预警族工单口径 null 保真 + 四分布块,报警族工单口径与 `alarm_status` 分布并存,态势总览改预警已解除率;t42 守卫全绿、t20 守卫随契约更新(6 条既有红经 HEAD 基线对照与本票无涉);真实走查归票 17 部署窗口
 - [x] [35: 后端执行票 —— 监测三端点 createdAt 真实契约缺失](issues/35-createdat.md) — 已实施、测试、真实复验并关闭;新增单一口径来源 `CockpitCreatedAt`,三端点信封级 + 逐对象 `createdAt` 齐备,`t18-real-audit` 140/143 → **143/143**;提交 `05c23774` 已推 origin/master
@@ -82,6 +84,10 @@ Labels: wayfinder:map
 
 ## Decisions so far
 
+- [批次二:点位摘要两级清单分页](issues/60-untitled.md) — **已实施并关闭(2026-10-19,代码随本 commit 入库)**。`PointDetailCard.vue` 三清单(设备卡片、关联设备图层清单、票 14「另有 N 台」展开子清单)接共享 `PagerNav` + `pagedRows` 单源,各自独立页码(稳定标量键),复位触发器收成 `${pointCode}|${deviceCode}` 一个标量(票 43 教训:数组 getter 会让「同点位重进」假复位);**票 42 口径逐条断言未动**(store 仍全量、标题总数只认快照、互斥归类之和与图例合计不变);`data-device-card` 续**全局下标**(走查按第 N 台取数)。两条当次裁定:① 未命中子清单也 ≤10/页(「另有 N 台」的 N 仍报全量);② 本组件三处 pager **不画计数行**——`PagerNav` 新增 `show-count`,默认 `true` ⇒ 批次一既有形态零变化(上方已有权威总数,并排再画「共 N 条」就是第二个总数口径)。t60 42/42(真实链路灌数 + 自定义渲染器跑真实 patch,成例同设备详情侧)+ 变异 10/10;t59/t46/t10/t41/t42/t43/t44 全绿且**无需改写**。真机走查归票 18/17。
+
+- [批次一:共享分页基建与设备详情三清单](issues/59-pagination-js-pagernav-vue.md) — **已实施并关闭(2026-10-19,代码随本 commit 入库)**。`pagedRows` 升格为单源 [domain/pagination.js](../../src/modules/cockpit/domain/pagination.js)(`PAGE_SIZE=10`/`pageWindow` ≤7 全列、>7 收缩「1 … c−1 c c+1 … Y」/`clampPage` 跳页夹回不抛错;`equipmentDetailView.js` 原名兼容再导出=同一函数引用);共享 [PagerNav.vue](../../src/modules/cockpit/components/PagerNav.vue) 落地页码直达 + 输入框跳页 + 计数行,**受控组件、唯一 `@page` 出口携带绝对目标页码**;设备详情三清单接入(预警/报警迁移 + **工单状态新分页**)。t59 37/37 + 变异 13/13、t46 按成例改写后 54/54 + 变异 12/12、t41 SSR 9/9;t12 F11 箭头形参假红按成例修扫描器。真机走查归票 18 联调窗口。
+
 - [前端执行票 —— 运行概况顶部摘要折叠消息交互](issues/56-untitled.md) — **三轮收口(2026-10-19,关闭并提交)**:① 一轮 hover/focus 展开被维护者裁定撤销,只留单一 `summaryOpen` + 原生 button click(Enter/Space/触屏同走,删掉会双触发的显式 keydown);② 折叠从运行概况推广到**全部面板**(用户点名总体概览-运行监测——其 `gap`=`MONITORING_FAULT_GAP` 原为头部外裸 `<p>`,一并收进);③ 第三轮按「故障口径也要进摘要」做完备性普查,发现折叠表本身不完备:真实注册表 15 面板的视图级说明字段并集 11 个而表内只有 8 个,漏掉的 `note`/`linkNote`/`excludedNote` 在渲染层从无消费点、界面上**隐形**(既有缺陷,非折叠引入;`excludedNote` 正是票 17/20「口径剔除要看得见」立场)。补齐三条并把完备性做成机制:t56 C 组=行为层双向普查(`panelView()` 真算 15 视图,domain 新增说明字段不进表判红、表内无声明判死接线;判据取真实视图对象而非正则,防票 43「守卫比要守的窄」),覆盖度哨兵钉 ≥12 面板与六类字段。每条保留原 `data-*`(`v-show` 折叠文本仍在 DOM,t17/t18/t20 零改动)、措辞全由 domain 单源、渲染层不认识 panelId;折叠态「有口径提示」判据扩到 `gap||note||excludedNote`。t56 32/32 + 变异 9/9(M8 改名绕表 / M9 新增不进表均被抓住)+ SFC 编译;t55/t20/t15/t27/t38/t39/t40/t43/t44/t50/t53/t54 回归全绿;t17/t18 导入期缺 export 与 t05 MapCanvas 为既有红、零交集。PanelBlocks.vue 与 map.md 均以 plumbing 限定提交只摘本票 hunks(工作树混有并行 57/59–68 在制品)。
 
 - [前端执行票 —— 监测分布口径块字号与共享行块视觉对齐](issues/55-untitled.md) — rows renderer 统一 `--fs-caption` 与 `line-height: 1.35`,字段/数据口径不变;静态守卫 5/5,通过后关闭。

+ 102 - 0
.scratch/cockpit-real-data-wayfinding/specs/2026-10-18-global-list-pagination.md

@@ -0,0 +1,102 @@
+# 驾驶舱长列表统一分页规范(2026-10-18)
+
+来源:[票 57](../issues/57-untitled.md)(维护者 2026-10-17 反馈 5)。上位规格:[2026-10-17-monitoring-feedback.md](2026-10-17-monitoring-feedback.md) §5。
+
+## 目标与适用判据
+
+凡**可能包含表格或卡片列表、且新增数据会无限拉长页面**的区域,统一分页行为:
+
+- 每页最多 10 条(`PAGE_SIZE = 10`),前端展示窗口恒 ≤10 条;
+- 控件三件:上一页、下一页、**页码直达**;只有一页时不画任何控件;
+- 先筛选/分类,后分页;条件变化回第 1 页;总数不由当前页反推。
+
+**排除**(不是条目清单,或数量契约有界,详见盘点表):聚合图表(柱/环/堆叠/趋势/饼/维度柱)、KPI 行、键值行块、一张图顶部状态栏图例、地图重叠聚合 fan(渲染层)、契约有界小清单(管理信息片区×行业明细、预警分析重点识别)、地图摘要点位装载(渐进保护,见三分法)。
+
+## 统一行为(规范条文)
+
+1. **页大小**:`PAGE_SIZE = 10`。任何接入区域的展示窗口每页 ≤10 条。
+2. **控件**:`上一页|页码|下一页` + 计数行「第 X/Y 页 · 共 N 条」。页码直达=页码按钮:当前页不可点并标 `aria-current="page"`;总页数 >7 时收缩为「1 … c−1 c c+1 … Y」窗口(恒含首末页与省略号)。
+3. **禁用态**:到边按钮 `disabled` 且**保留占位**(按钮消失会让计数行跳动,读起来像出错——票 46 既有语义)。
+4. **一页不画控件**:`pageCount <= 1` 时整个 pager 不渲染(短列表不多出两个死按钮)。
+5. **筛选先于分页**:分类 chips、全局筛选(行业/片区/设备类型/点位状态/范围)、模式切换(预警↔报警)、设备·点位切换、时间范围变化,都先作用于集合或请求参数,再分页。
+6. **复位规则**:上述任何条件变化 → 页码回第 1 页。**新鲜度/定时刷新不复位页码**(用户停在第 3 页时,周期刷新不得把人弹回第 1 页);数据变少导致页码越界时由分页函数夹回最后一页。
+7. **总数口径**:服务端分页读响应 `pagination.total`;前端集合分页读**过滤后**集合长度;渐进装载用「已装载 M · 共 N」双口径。任何区域不得用「当前页 × 页大小」反推总数。
+8. **态语义**(沿用既有分区词汇,不另造一套):
+   - 加载:`正在取数…`(分区 pending 语义);翻页请求进行中保持旧内容或同一占位,不闪空白;
+   - 空态:「真没有」与「条件下没有」分文案(`当前没有记录` vs `当前分类下没有记录(换一个分类或点「全部」)`);
+   - 失败:分区 FAILED/UNAVAILABLE 占位 + 分区状态标签;渐进装载下一批失败**不回滚已装载内容**(台账既有语义);
+   - 竞态:沿用各 store 既有 token/纪元纪律;翻页请求迟到即作废,不落进新页码。
+9. **无障碍**:pager `role="group"` + `aria-label="「列表名」分页"`;页码当前项 `aria-current="page"`;计数行是随页码更新的文本。
+10. **文案纪律**:不把后端 `page`/`pageSize` 参数名暴露到界面文案(上位规格 §5 原文);只说「页」「条」。
+11. **单源**:翻页窗口计算只有一份(`domain/pagination.js`,自 `equipmentDetailView.pagedRows` 升格,`HISTORY_PAGE_SIZE`/`pagedRows` 原名保留导出兼容),pager 控件只有一份(`PagerNav.vue`)。禁止任何组件重写翻页算术或另画一套 pager。
+
+## 三分法:服务端 / 前端集合 / 渐进装载
+
+- **服务端页码分页**(翻页即请求,`page`+`pageSize=10` 进查询串):
+  预警处置·当前事件表(`/events/current`);预警处置·历史事件**单窗范围**(`/events/history-analysis` 近 7 天)。
+- **前端集合分页**(数据已一次装载,pager 只切窗口、零请求):
+  设备详情预警/报警/工单状态;点位摘要设备卡片与关联设备图层清单;运行概况异常设备清单;基础设施·运行信息指标列表(票 64 补入,原误列排除);历史事件**多窗合并范围**(近 30/90 天——`historyWindows` 分窗合并后不存在「全局页」,回退已加载合并集合分页并在页脚如实说明;全局服务端分页需后端另立票,不在本图范围)。
+- **渐进装载 + 页码 UI**(数据链路保持自动渐进,界面只对已装载集合翻页):
+  监测点台账——装载与一张图点位数据同款自动渐进链(条件变化 → 新快照 → 自动连翻到 settled,沿用快照 token 与纪元纪律);UI 面板对已装载集合做统一页码分页(10/页、页码直达)。
+- **排除**:地图摘要点位装载保持自动渐进、无列表 UI(票 14/23 契约,**不得**误改为页码分页)。
+
+## 维护者裁定(2026-10-18;2026-10-19 追加第 3 条)
+
+1. **监测点台账**:监测点数据保持自动渐进式加载(与地图中点位数据的加载方式一致),但 UI 页面中要能翻页——「加载下一页监测点」按钮随之退役。
+2. **事件清单**:走服务端页码分页(翻页带 `page` 参数发请求);多窗合并范围的回退规则见三分法。
+3. **验收与走查以真实数据为准,不为演示扩写 mock 样例**(票 64 引出):mock 样例数据量小 ⇒ 某些接入区域在 mock 下**不足一页、按条文 4 正确地不画控件**,这是预期行为而非缺陷,**不得**为此把 mock 样例补到 >10 条(扩了也没有演示价值,真实数据才是验收口径)。多页与边界行为一律在**闸门里用真实载荷或合成载荷证明**(往闸门灌数据 ≠ 改 mock 样例:票 62 的 mock 判据、票 64 的 t64 V 组都是这条路);真实观感归票 18 联调窗口。批次二~五执行票同样适用。
+
+## 全仓盘点
+
+| 区域 | 代码接缝 | 数据源 · 现状 | 目标 | 批次 |
+|---|---|---|---|---|
+| 设备详情 · 预警信息 / 报警信息 | `EquipmentDetailSections.vue`(`data-event-list`)、`domain/equipmentDetailView.js` | 设备详情聚合两分区;已每页 10、先分类后翻、一页无控件 | 迁共享 pager,**补页码直达** | 一 |
+| 设备详情 · 工单状态 | 同上(`data-work-order-list`) | 设备详情聚合 workOrders;全量直渲 | 前端集合分页 10/页 | 一 |
+| 预警处置 · 当前事件表 | `EventListPane.vue`(`data-event-table`)、`stores/eventStore.js` | `/events/current`(后端默认 100、上限 1000);前端砍最新 12 条 | 服务端页码分页(`pageSize=10`);mode/筛选/范围变化回第 1 页 | 三 |
+| 预警处置 · 历史事件 | `domain/warningResponsePanels.js historyAnalysisPanel`(PanelBlocks list 块) | `/events/history-analysis` 分窗合并;`events.slice(0,12)` | 单窗(近 7 天)服务端分页;多窗(近 30/90 天)合并集合分页 + 页脚如实说明 | 三 |
+| 运行概况 · 异常设备清单 | `domain/monitoringPanels.js operationOverview`(list 块) | `/monitoring/situation` 一次响应;全量直渲 | 前端集合分页 10/页(页脚用 `pagination.total`) | 四 |
+| 运行监测 · 监测点台账 | `domain/monitoringPanels.js monitoringPointLedger`、`stores/monitoringStore.js loadMoreLedger`、PanelBlocks `data-ledger-more` | `/monitoring/distribution` 服务端分页 + 快照 token(票 23);「加载下一页」每次追加后端一页 ≈500 卡 | 装载保持自动渐进(同地图链路);UI 对已装载集合页码分页 10/页;按钮退役 | 四 |
+| 点位摘要 · 设备卡片 | `PointDetailCard.vue`(`data-device-cards`) | 设备详情端点 `devices[]`;全量直渲 | 前端集合分页 10/页 | 二 |
+| 点位摘要 · 关联设备图层清单 | `PointDetailCard.vue`(`data-device-layer-list`,票 42) | 地图快照 `deviceCodes`,**保留全部编码**(含待确认) | 前端集合分页 10/页(数据仍保留全部,窗口化渲染;互斥归类与差额口径不变) | 二 |
+| 基础设施 · 运行信息指标列表 | `domain/infrastructurePanels.js operationPanel` → `listBlock('运行指标', …, { paginate: true, pageScope })` | `/infrastructure/operating-information` 一次回全部行业指标;**实测 6772 条**(供水 2497/排水 3576/燃气 699),响应 **2.3 MB**;原全量直渲、块标题「指标占位」与实际内容不符 | 前端集合分页 10/页(**本票 57 曾误列为排除项,2026-10-19 由维护者反馈纠正**);块标题改为如实说法 —— **已由票 64 落地** | 五 ✅ |
+| 基础设施 · 管理信息片区×行业明细 | `managementPanel`(list 块) | `districtIndustry[]`,**实测 9 行**,组合数契约有界(片区×行业) | **排除**(有界小清单,实测 <10) | — |
+| 基础设施 · 应急保障三段 | `domain/infrastructurePanels.js emergencySectionBlock`(list 块) | `/infrastructure/emergency-support` 三段 items;**实测 8+0+20=28 条**(防护目标 20 条已超一页) | 前端集合分页 10/页(一页时无控件) | 五 |
+| 预警分析 · 重点识别 | `eventAnalysisPanel`(list 块) | 后端聚合重点,数量少 | **排除** | — |
+| 一张图 · 地图摘要点位装载 | `stores/mapStore.js`、`domain/mapSummary.js` | `/map/snapshot` 快照自动链 | **排除**:保持自动渐进装载,不改页码 | — |
+| 其余 | 图表块(bars/donut/stacked/trend/pie/dimbars)、KPI、键值行、状态栏图例、地图重叠聚合 fan | 非条目清单 | **排除** | — |
+
+## 跳页(维护者 2026-10-19 追加)
+
+页码直达按钮之外,pager 还必须提供**输入框跳页**:
+
+- 一个数字输入框 + 「跳转」按钮(或 Enter 提交),输入目标页号后直接跳到该页;
+- 输入非数字/0/超出 `pageCount` 时**夹回合法范围**(1..pageCount),不报错、不发请求、不清空列表;
+- 输入框占位提示当前页(`placeholder = 当前页`),`aria-label="跳转到指定页"`;
+- 总页数 ≤1 时整块 pager(含输入框)不渲染,与本规范第 4 条一致;
+- 跳页与「上一页/下一页/页码」共用同一个翻页出口,**不新增第二套页码状态**。
+
+## 实施批次
+
+每批一张执行票(本票只收口规范与盘点,不实施):
+
+- **批次一**:共享分页基建(`domain/pagination.js` + `PagerNav.vue`,含**页码直达与输入框跳页**)+ 设备详情三清单接入(预警/报警迁移、工单状态新接入)。
+- **批次二**:点位摘要两级清单(设备卡片、关联设备图层清单)分页。
+- **批次三**:预警处置事件清单服务端页码分页(当前事件表 + 历史事件单窗/多窗规则 + mock 同构)。
+- **批次四**:运行监测(异常设备清单前端集合分页;台账自动渐进装载链 + 页码 UI、撤「加载下一页」按钮)。
+- **批次五**:基础设施运行信息指标列表(6772 条,最高优先)+ 应急保障三段接入(PanelBlocks list 块路径收尾)。
+
+执行票:见票 57 正文(批次五已按 2026-10-19 维护者反馈扩为两张:运行信息指标列表 + 应急保障)。
+
+## 非目标
+
+- 不改后端只读契约与快照 token 语义(票 23);地图渐进装载不改页码。
+- 不改排序口径(「前端不重排」立场不变;服务端分页按后端返回序渲染)。
+- 不给聚合图表、KPI、键值行与契约有界小清单加分页。
+- 按 AGENTS.md,实施票默认跳过 build/dev/浏览器验证,除非维护者当次明确授权。
+
+## 修订记录
+
+- 2026-10-18 首版(票 57)。
+- **2026-10-19 维护者 8 条反馈后的纠正**:① 补齐「输入框跳页」要求;② **撤销运行信息指标列表的排除**——实测 6772 条 / 2.3 MB,改列入批次五(原判断基于代码推断"单行业指标条数契约有界",被真实数据证伪);③ 应急保障盘点点亮实测量(8+0+20=28);④ 管理信息实测 9 行,维持排除并补实测依据。
+- **2026-10-19 票 64 收口时的追加裁定**(见「维护者裁定」第 3 条):验收以真实数据为准,**不为演示扩写 mock 样例**;mock 下不足一页不画控件是预期行为,多页行为归闸门用真实/合成载荷证明。
+- **2026-10-19 票 60(批次二)维护者当次两条裁定**:① 点位摘要里票 14 的「另有 N 台 · 未命中当前筛选」**展开子清单同样 ≤10/页**、独立第三套页码(判据一视同仁:它可能拉长页面;「另有 N 台」的 N 仍报全量,差额可追溯不变);② **计数行(条文 2/9)在 `PointDetailCard` 的三处 pager 一律省略**——`PagerNav` 新增 `show-count` 开关(默认 `true`,批次一既有消费方形态不变);该组件上方已各有权威总数(设备合计 KPI 读后端统计;清单标题「总数 X 台(已上图 Y)」只认当前快照),并排再画「共 N 条」就成了窗口口径的第二个总数。

+ 12 - 11
.scratch/cockpit-real-data-wayfinding/verify/t46-detail-cards-history.mjs

@@ -293,26 +293,27 @@ function check(name, condition, message) {
     check('E1-history-items-collapsed-by-default', /<details[\s\S]*?data-history-item/.test(sectionsCode) && !/<details[^>]*\sopen(\s|>)/.test(sectionsCode), '历史条目用 details 且不带 open——默认折叠是票 46 范围第 3 条')
     // E2 每条可展开详情(data-history-detail 两块都有:events 与 orders)。
     check('E2-expandable-detail-present', (sectionsCode.match(/data-history-detail/g) || []).length === 2, '预警/报警与工单条目各有一条展开详情')
-    // E3 分类绑定齐全:chip 键、按下态、切换入口、过滤行(工单)与分页行(预警/报警)。
+    // E3 分类绑定齐全:chip 键、按下态、切换入口,且预警/报警/工单**都走分页行**(票 59 工单接入分页)。
     check(
         'E3-category-bindings-complete',
         /:data-category-chip="`\$\{panel\.name\}:\$\{chip\.key \|\| 'all'\}`"/.test(sectionsCode) &&
             /:aria-pressed="chip\.key === activeCategory\(panel\.name\)"/.test(sectionsCode) &&
             /@click="toggleCategory\(panel\.name, chip\.key\)"/.test(sectionsCode) &&
             /v-for="\(event, index\) in pagedOf\(panel\)\.rows"/.test(sectionsCode) &&
-            /v-for="\(order, index\) in visibleRows\(panel\)"/.test(sectionsCode),
-        'chip 键/按下态/点击 + 预警报警走分页行 + 工单走过滤行(守卫按绑定判,不按字样判)',
+            /v-for="\(order, index\) in pagedOf\(panel\)\.rows"/.test(sectionsCode),
+        'chip 键/按下态/点击 + 预警报警走分页行 + 工单也走分页行(票 59:三清单统一 10/页,守卫按绑定判)',
     )
-    // E3b 分页控件绑定齐全(返工第 1 条:每页最多 10 项;单页不画控件由 pageCount>1 门控)。
+    // E3b 分页控件迁移到共享 PagerNav(票 59,条文 11:组件不再自画 pager/自写翻页算术)。
+    //   判据从"内联按钮按 hasPrev/hasNext 置灰"改为"内联 pager 退役、PagerNav 接线、前后页/页码/跳页共用 @page→gotoPage 单出口"。
+    //   控件自身的 data-pager/data-page-* 断言面在 t59 主闸门里逐条守。
     check(
         'E3b-pager-bindings-complete',
-        /v-if="pagedOf\(panel\)\.pageCount > 1"/.test(sectionsCode) &&
-            /:disabled="!pagedOf\(panel\)\.hasPrev"/.test(sectionsCode) &&
-            /:disabled="!pagedOf\(panel\)\.hasNext"/.test(sectionsCode) &&
-            /@click="turnPage\(panel, -1\)"/.test(sectionsCode) &&
-            /@click="turnPage\(panel, 1\)"/.test(sectionsCode) &&
-            /data-page-state/.test(sectionsCode),
-        '上一页/下一页按 hasPrev/hasNext 置灰 + 页码读数 + 单页不出现控件',
+        /<PagerNav\b/.test(sectionsCode) &&
+            /@page="\(target\) => gotoPage\(panel, target\)"/.test(sectionsCode) &&
+            /:page-count="pagedOf\(panel\)\.pageCount"/.test(sectionsCode) &&
+            !/turnPage\(/.test(sectionsCode) &&
+            !/equipment-pager__btn/.test(sectionsCode),
+        '预警/报警/工单接共享 PagerNav,唯一翻页出口 @page→gotoPage;内联 pager 与第二套翻页算术已退役',
     )
     // E3c 标签页绑定齐全(返工第 1 条:弃用长滚动改切换;默认页与换设备复位都要有)。
     check(

+ 8 - 4
.scratch/cockpit-real-data-wayfinding/verify/t46-mutations.mjs

@@ -124,9 +124,11 @@ mutate(
 
 // ---- 返工(2026-10-15 维护者第 1/2 条)新增的三条变异 ----
 
+// 票 59 升格:pagedRows 实现移到单源 domain/pagination.js(equipmentDetailView 只按原名再导出),
+// 夹页锚点随之改到 pagination.js;G 节测的仍是 equipmentDetailView 的兼容再导出(同一份实现)。
 mutate(
     'M5-pager-does-not-clamp-out-of-range',
-    'domain/equipmentDetailView.js',
+    '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))',
     'G3-out-of-range-clamped',
@@ -150,11 +152,13 @@ mutate(
 
 // E11 的牙:把 domain 导出改名(组件 import 的名字就落空)→ 必须被"按真实导出判"抓住。
 // 这条改的是 t46 **自己也 import** 的名字,所以闸门直接加载失败(崩溃式抓住,最硬的一种)。
+// 票 59 升格后:单源 pagination.js 里改名 pagedRows → equipmentDetailView 的原名兼容再导出落空,
+// 闸门具名 import 直接加载失败(崩溃式抓住,仍是 M8 要的"改名必被抓")。
 mutate(
     'M8-domain-export-renamed',
-    'domain/equipmentDetailView.js',
-    'export function pagedRows(rows, page, pageSize = HISTORY_PAGE_SIZE) {',
-    'export function pagedRowsRenamed(rows, page, pageSize = HISTORY_PAGE_SIZE) {',
+    'domain/pagination.js',
+    'export function pagedRows(rows, page, pageSize = PAGE_SIZE) {',
+    'export function pagedRowsRenamed(rows, page, pageSize = PAGE_SIZE) {',
     'E11-imported-names-are-real-exports',
 )
 

+ 215 - 0
.scratch/cockpit-real-data-wayfinding/verify/t59-mutations.mjs

@@ -0,0 +1,215 @@
+/**
+ * 票 59 闸门变异测试:证明 t59 的每条关键断言**抓得住它要防的缺陷**(与 t46-mutations 同一纪律)。
+ *
+ * 做法:对源文件做**单点**变异注入 → 跑 t59 → 断言指定断言转红 → 按字节还原并校验一致。
+ * 变异只在本脚本运行期间存活几秒,跑完即还原。
+ *
+ * 十三条变异对应票 59 的真实风险:
+ *   M1 窗口全列阈值被改成 5 → W2 必须红(7 页就收缩=条文 2 的「≤7 全列」失守)
+ *   M2 右侧省略号不推了 → W3 必须红(收缩窗口缺省略号=「恒含省略号」失守)
+ *   M3 跳页夹回放 NaN 过 → J3 必须红(页码状态被 NaN 污染,控件画成「第 NaN 页」)
+ *   M4 跳页只夹下不夹上 → J2 必须红(超界页跳进空页)
+ *   M5 一页也画控件 → S1 必须红(条文 4「一页不画」失守)
+ *   M6 翻页出口改名出第二个事件 → S2 必须红(「共用同一翻页出口」失守)
+ *   M7 到边按钮不再禁用 → S3 必须红(票 46 既有语义回退)
+ *   M8 当前页 aria-current 丢了 → S4 必须红(条文 9 失守)
+ *   M9 跳页绕过 clampPage → S6 必须红(夹回判据名存实亡)
+ *   M10 兼容再导出被改名 → t59 具名 import 加载失败=崩溃式抓住(条文 11 的单源锚)
+ *   M11 组件里重新长出翻页算术 → C4 必须红(条文 11「禁止组件重写算术」)
+ *   M12 工单回退成全量直渲 → C3 必须红(本票「工单状态新接入分页」交付被吃掉)
+ *   M13 换分类不再回第 1 页 → C5 必须红(条文 6 复位规则失守)
+ *
+ * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t59-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('t59-shared-pager.mjs', import.meta.url))
+const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}`
+
+/**
+ * 跑闸门并取回输出。
+ * 刻意用**文件重定向**而不是管道捕获(t46 实测:Windows 沙箱下捕获子进程 stdio 的命名管道
+ * 是文档化的边界(EPERM),管道版拿回空输出 → 变异全部"未转红"的假信号)。
+ */
+const reportDir = mkdtempSync(join(tmpdir(), 't59-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')
+    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)) {
+            // 崩溃也算抓住(导出改名后闸门具名 import 加载失败),单列不与"断言转红"混淆。
+            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}: 还原失败`)
+    }
+}
+
+// ---- pagination.js:窗口与夹回(纯函数闸门) ----
+
+mutate(
+    'M1-window-full-list-threshold-shrunk',
+    'domain/pagination.js',
+    '    if (total <= 7) {',
+    '    if (total <= 5) {',
+    'W2-seven-pages-still-full',
+)
+
+mutate(
+    'M2-shrink-drops-right-ellipsis',
+    'domain/pagination.js',
+    '    if (windowEnd < total - 1) items.push(PAGE_ELLIPSIS)',
+    '    if (false) items.push(PAGE_ELLIPSIS)',
+    'W3-eight-pages-shrinks',
+)
+
+mutate(
+    'M3-clampPage-lets-NaN-through',
+    'domain/pagination.js',
+    '    if (!Number.isFinite(num)) return 1',
+    '    if (!Number.isFinite(num)) return num',
+    'J3-non-numeric-clamps-to-one',
+)
+
+mutate(
+    'M4-clampPage-loses-upper-bound',
+    'domain/pagination.js',
+    '    return Math.min(Math.max(1, Math.trunc(num)), limit)',
+    '    return Math.max(1, Math.trunc(num))',
+    'J2-clamp-to-range',
+)
+
+// ---- PagerNav.vue:共享控件的行为契约 ----
+
+mutate(
+    'M5-single-page-still-renders-pager',
+    'components/PagerNav.vue',
+    'const visible = computed(() => props.pageCount > 1)',
+    'const visible = computed(() => props.pageCount >= 1)',
+    'S1-one-page-renders-nothing',
+)
+
+mutate(
+    'M6-second-page-outlet',
+    'components/PagerNav.vue',
+    "    emit('page', target)",
+    "    emit('turn', target)",
+    'S2-single-page-outlet',
+)
+
+mutate(
+    'M7-edge-disable-gone',
+    'components/PagerNav.vue',
+    ':disabled="page <= 1" data-page-prev',
+    ':disabled="false" data-page-prev',
+    'S3-edge-disabled-with-placeholder',
+)
+
+mutate(
+    'M8-aria-current-dropped',
+    'components/PagerNav.vue',
+    `:aria-current="item === page ? 'page' : undefined"`,
+    ':aria-current="undefined"',
+    'S4-aria-group-current',
+)
+
+mutate(
+    'M9-jump-skips-clamp',
+    'components/PagerNav.vue',
+    '    const target = clampPage(jumpValue.value, props.pageCount)',
+    '    const target = Number(jumpValue.value)',
+    'S6-jump-goes-through-clamp-and-same-outlet',
+)
+
+// ---- 兼容再导出(条文 11 的单源锚):改名后闸门加载失败=崩溃式抓住 ----
+
+mutate(
+    'M10-compat-export-renamed',
+    'domain/equipmentDetailView.js',
+    "export { PAGE_SIZE as HISTORY_PAGE_SIZE, pagedRows } from './pagination.js'",
+    "export { PAGE_SIZE as HISTORY_PAGE_SIZE, pagedRows as pagedRowsV2 } from './pagination.js'",
+    'P2-compat-export-is-same-function',
+)
+
+// ---- 消费方:设备详情三清单 ----
+
+mutate(
+    'M11-second-arithmetic-in-component',
+    'components/EquipmentDetailSections.vue',
+    '    return pagedRows(visibleRows(panel), pages.value[panel.name] || 1)',
+    '    const list = visibleRows(panel); const p = pages.value[panel.name] || 1; return { rows: list.slice((p - 1) * 10, p * 10), page: p, pageCount: Math.ceil(list.length / 10), total: list.length }',
+    'C4-no-local-pagination-arithmetic',
+)
+
+mutate(
+    'M12-orders-revert-to-full-render',
+    'components/EquipmentDetailSections.vue',
+    'v-for="(order, index) in pagedOf(panel).rows"',
+    'v-for="(order, index) in visibleRows(panel)"',
+    'C3-orders-paginated',
+)
+
+mutate(
+    'M13-category-keeps-page',
+    'components/EquipmentDetailSections.vue',
+    '    pages.value = { ...pages.value, [panelName]: 1 }',
+    '    pages.value = pages.value',
+    'C5-reset-rules-intact',
+)
+
+console.log('')
+console.log(`T59 变异测试:${proved}/${injected} 处变异被对应断言抓住。`)
+rmSync(reportDir, { recursive: true, force: true })
+if (proved < injected) process.exit(1)

+ 242 - 0
.scratch/cockpit-real-data-wayfinding/verify/t59-shared-pager.mjs

@@ -0,0 +1,242 @@
+/**
+ * 票 59 离线闸门:统一分页批次一 —— 共享分页基建(pagination.js + PagerNav.vue)与设备详情三清单。
+ *
+ * 五组断言(都不启浏览器、不联网,遵循 t46 成例):
+ *   P. 单源:PAGE_SIZE=10;equipmentDetailView 的 pagedRows/HISTORY_PAGE_SIZE 是**兼容再导出**
+ *      (同一函数引用),组件里不再有两份翻页算术。
+ *   W. 页码窗口纯函数:≤7 全列;>7 收缩「1 … c−1 c c+1 … Y」恒含首末与省略号;非法输入不抛错。
+ *   J. 跳页夹回纯函数:非数字/0/超 pageCount 一律夹回 1..pageCount,不报错(规范「跳页」节)。
+ *   S. PagerNav 静态守卫:一页不渲染、到边禁用保占位、aria-current="page"、
+ *      跳页 aria-label/placeholder、**唯一 @page 出口**(不新增第二套页码状态)、断言面 data-* 齐全。
+ *   C. 消费方守卫:EquipmentDetailSections 三清单(预警/报警/工单)都走 pagedOf 窗口行 +
+ *      共享 PagerNav;组件零第二份翻页算术;既有 data-pager/data-page-prev/next/state 面迁移保留。
+ *   K. SFC 真实编译:两份 .vue 用 @vue/compiler-sfc 编译过(t41 教训:拼错的绑定与坏语法只有加载层判得出)。
+ *
+ * 守卫有牙(变异见 verify/t59-mutations.mjs):
+ *   窗口全列阈值收缩/省略号丢失/夹回失效/单页画控件/第二套出口/aria 丢失/组件重写算术/兼容导出改名 → 对应断言转红。
+ *
+ * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t59-shared-pager.mjs
+ */
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
+import { PAGE_ELLIPSIS, PAGE_SIZE, clampPage, pageWindow, pagedRows as pagedRowsCore } from '../../../src/modules/cockpit/domain/pagination.js'
+import { HISTORY_PAGE_SIZE, pagedRows as pagedRowsCompat } from '../../../src/modules/cockpit/domain/equipmentDetailView.js'
+
+const read = (rel) => readFileSync(new URL(`../../../src/modules/cockpit/${rel}`, import.meta.url), 'utf8')
+const stripComments = (text) => text.replace(/<!--[\s\S]*?-->/g, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
+
+let passed = 0
+let failed = 0
+function check(name, condition, message) {
+    if (condition) {
+        passed += 1
+        console.log(`PASS ${name}`)
+    } else {
+        failed += 1
+        console.error(`FAIL ${name}: ${message}`)
+    }
+}
+
+// ================================================================ P. 单源与兼容导出
+
+{
+    // P1 页大小单源 = 10(条文 1);旧名 HISTORY_PAGE_SIZE 与 PAGE_SIZE 同值。
+    check('P1-page-size-ten', PAGE_SIZE === 10 && HISTORY_PAGE_SIZE === PAGE_SIZE && PAGE_ELLIPSIS === '…', `PAGE_SIZE=${PAGE_SIZE} HISTORY=${HISTORY_PAGE_SIZE}`)
+    // P2 兼容是**同一函数引用**,不是复制粘贴的第二份实现(条文 11 的判据形状)。
+    check('P2-compat-export-is-same-function', pagedRowsCompat === pagedRowsCore, 'equipmentDetailView.pagedRows 必须是 pagination.pagedRows 的再导出(同一引用)')
+    // P3 旧模块里不许再留有本地实现体(复制粘贴升格=将来必漂移)。
+    const viewCode = read('domain/equipmentDetailView.js')
+    check('P3-no-second-implementation', !/function pagedRows\(/.test(viewCode) && /from '\.\/pagination\.js'/.test(viewCode), 'equipmentDetailView 只再导出,不再自己实现 pagedRows')
+    // P4 脏输入不抛错(非数组、NaN 页码、非法页大小全部兜底)。
+    const dirty = pagedRowsCore(null, NaN)
+    check('P4-dirty-input-safe', dirty.page === 1 && dirty.pageCount === 1 && dirty.rows.length === 0, JSON.stringify(dirty))
+    // P5 越界页码夹回最后一页(条文 6:数据变少导致的越界由分页函数夹回,不画空表)。
+    const many = pagedRowsCore(Array.from({ length: 23 }, (_, index) => ({ code: `W${index}` })), 99)
+    check('P5-out-of-range-clamps-to-last', many.page === 3 && many.rows.length === 3 && many.pageCount === 3, JSON.stringify({ page: many.page, n: many.rows.length }))
+}
+
+// ================================================================ W. 页码窗口(条文 2)
+
+{
+    // W1 少于 7 页全列,不带省略号。
+    check('W1-five-pages-full', pageWindow(3, 5).join(',') === '1,2,3,4,5', pageWindow(3, 5).join(','))
+    // W2 恰好 7 页仍全列(阈值边界)。
+    check('W2-seven-pages-still-full', pageWindow(4, 7).join(',') === '1,2,3,4,5,6,7', pageWindow(4, 7).join(','))
+    // W3 8 页起步即收缩:恒含首末 + 当前±1 + 省略号。
+    check('W3-eight-pages-shrinks', pageWindow(5, 8).join(',') === '1,…,4,5,6,…,8', pageWindow(5, 8).join(','))
+    // W4 中段窗口:10 页看第 5 页 →「1 … 4 5 6 … 10」。
+    check('W4-middle-window', pageWindow(5, 10).join(',') === '1,…,4,5,6,…,10', pageWindow(5, 10).join(','))
+    // W5 贴左:当前页邻到首页时左侧不出现假省略号(1,2,3 … 10)。
+    check('W5-left-edge-no-fake-ellipsis', pageWindow(2, 10).join(',') === '1,2,3,…,10', pageWindow(2, 10).join(','))
+    // W6 贴右:对称(1 … 8,9,10)。
+    check('W6-right-edge', pageWindow(9, 10).join(',') === '1,…,8,9,10', pageWindow(9, 10).join(','))
+    // W7 恒含首末页(条文 2「恒含首末」),任意当前页成立。
+    const alwaysFirstLast = [1, 3, 5, 8, 15].every((c) => {
+        const items = pageWindow(c, 20)
+        return items[0] === 1 && items[items.length - 1] === 20 && items.includes(Math.min(c, 20))
+    })
+    check('W7-first-last-current-always', alwaysFirstLast, '每个窗口都要含首页、末页与当前页')
+    // W8 数字升序且无重复(省略号除外)——渲染层据此直接出按钮。
+    const nums = pageWindow(6, 12).filter((item) => item !== PAGE_ELLIPSIS)
+    check('W8-ascending-unique', nums.every((n, i) => i === 0 || n > nums[i - 1]), nums.join(','))
+    // W9 非法输入不抛错:脏页码/脏总数/超界页码都回合法窗口。
+    let threw = false
+    try {
+        const zero = pageWindow(0, 0)
+        const junk = pageWindow('x', undefined)
+        const over = pageWindow(999, 10)
+        threw = !(zero.length === 1 && junk.length === 1 && over[over.length - 1] === 10 && over.includes(10))
+    } catch {
+        threw = true
+    }
+    check('W9-illegal-input-no-throw', !threw, '脏输入必须按最小安全形状回,不许抛')
+}
+
+// ================================================================ J. 跳页夹回(规范「跳页」节)
+
+{
+    // J1 合法页照收。
+    check('J1-valid-page', clampPage(5, 10) === 5 && clampPage('7', 10) === 7, `${clampPage(5, 10)}/${clampPage('7', 10)}`)
+    // J2 0/负数夹到 1,超 pageCount 夹到 pageCount(不报错、不越界)。
+    check('J2-clamp-to-range', clampPage(0, 10) === 1 && clampPage(-4, 10) === 1 && clampPage(99, 10) === 10 && clampPage('100', 3) === 3, `${clampPage(0, 10)}/${clampPage(99, 10)}`)
+    // J3 非数字回 1,**绝不回 NaN**(NaN 会污染页码状态并把控件画成「第 NaN 页」)。
+    check('J3-non-numeric-clamps-to-one', clampPage('abc', 10) === 1 && clampPage('', 10) === 1 && clampPage(null, 10) === 1 && clampPage(undefined, 10) === 1, `abc→${clampPage('abc', 10)}`)
+    // J4 小数截断、空白容错(输入框里粘进来的值)。
+    check('J4-truncate-and-trim', clampPage(2.7, 10) === 2 && clampPage(' 4 ', 10) === 4, `${clampPage(2.7, 10)}/${clampPage(' 4 ', 10)}`)
+    // J5 pageCount 本身脏时兜底为 1(宁可不跳,不跳进非法页)。
+    check('J5-dirty-pagecount-safe', clampPage(5, 0) === 1 && clampPage(5, NaN) === 1, `pageCount=0→${clampPage(5, 0)}`)
+}
+
+// ================================================================ S. PagerNav 静态守卫
+
+{
+    const pager = stripComments(read('components/PagerNav.vue'))
+
+    // S1 总页数 ≤1 整块不渲染(条文 4 + 跳页节第 4 条),门控在 visible 计算属性。
+    check(
+        'S1-one-page-renders-nothing',
+        /const visible = computed\(\(\) => props\.pageCount > 1\)/.test(pager) && /<div v-if="visible"[^>]*data-pager/.test(pager),
+        'pageCount>1 是唯一渲染门;整块(含跳页)由 v-if 收口',
+    )
+    // S2 唯一翻页出口:defineEmits 只声明 page;别处不许再 emit 第二个事件(不新增第二套页码状态)。
+    check(
+        'S2-single-page-outlet',
+        /defineEmits\(\['page'\]\)/.test(pager) && !/emit\((?!'page')/.test(pager) && pager.match(/emit\('page'/g).length === 1,
+        '只允许一个 emit("page") 出口(前后页/页码/跳页共用)',
+    )
+    // S3 到边禁用保占位(票 46 既有语义:disabled 而不是消失)。
+    check(
+        'S3-edge-disabled-with-placeholder',
+        /:disabled="page <= 1" data-page-prev/.test(pager) && /:disabled="page >= pageCount" data-page-next/.test(pager),
+        '上一页按 page<=1、下一页按 page>=pageCount 置灰,按钮恒在',
+    )
+    // S4 无障碍:role=group + 「X分页」aria-label(条文 9);当前页 aria-current="page"(不可点由表达式守卫)。
+    check(
+        'S4-aria-group-current',
+        /role="group" :aria-label="`\$\{listName\}分页`"/.test(pager) && /:aria-current="item === page \? 'page' : undefined"/.test(pager) && /@click="item !== page && go\(item\)"/.test(pager),
+        'pager 有 group 语义与列表名;当前页 aria-current 且点了不跳',
+    )
+    // S5 跳页控件:数字输入框 + 跳转按钮 + Enter;aria-label 与 placeholder=当前页(条文逐字)。
+    check(
+        'S5-jump-controls',
+        /data-page-jump\b/.test(pager) && /data-page-jump-input/.test(pager) && /aria-label="跳转到指定页"/.test(pager) && /:placeholder="String\(page\)"/.test(pager) && /@keydown\.enter\.prevent="jump"/.test(pager) && /data-page-jump-btn/.test(pager),
+        '输入框/跳转按钮/Enter 三入口 + aria-label + placeholder=当前页',
+    )
+    // S6 跳页走夹回函数与同一出口;同值不 emit(不发请求、不清列表)。
+    check(
+        'S6-jump-goes-through-clamp-and-same-outlet',
+        /const target = clampPage\(jumpValue\.value, props\.pageCount\)/.test(pager) && /if \(target === props\.page\) return/.test(pager) && /go\(target\)/.test(pager),
+        '跳页先 clampPage 再 go(),与当前页同值什么都不做',
+    )
+    // S7 计数行「第 X/Y 页 · 共 N 条」,total 是 prop(条文 7:不由当前页反推)。
+    check(
+        'S7-count-row-wording',
+        /data-page-state>第 \{\{ page \}\}\/\{\{ pageCount \}\} 页 · 共 \{\{ total \}\} 条/.test(pager),
+        '计数行措辞与票 46 一致;总数从 props 来,不做 页×大小 反推',
+    )
+    // S8 组件是受控的:页码只从 props 进,本地只有跳页输入缓冲;零请求零裸算术。
+    check(
+        'S8-controlled-no-local-page-no-fetch',
+        !/\bfetch\(|XMLHttpRequest/.test(pager) && !/Math\.(ceil|trunc|floor)/.test(pager) && !/= ref\(1\)/.test(pager) && /const jumpValue = ref\(''\)/.test(pager),
+        'PagerNav 不存第二套页码状态、不算窗口、不发请求',
+    )
+    // S9 断言面迁移完整:票 46 的 data-pager/data-page-prev/next/state 逐字保留,新面是追加不是替换。
+    check(
+        'S9-assertion-hooks-present',
+        ['data-pager', 'data-page-prev', 'data-page-next', 'data-page-state', 'data-page-jump', 'data-page-jump-input', 'data-page-number'].every((hook) => new RegExp(hook).test(pager)),
+        '旧面保留 + 跳页新面就位(探针/测试按这些取数)',
+    )
+}
+
+// ================================================================ C. 消费方:设备详情三清单
+
+{
+    const sections = stripComments(read('components/EquipmentDetailSections.vue'))
+
+    // C1 三清单同一控件两处接线(events 模板覆盖预警+报警两分区;orders 独立一处)。
+    const pagerUses = sections.match(/<PagerNav\b/g) || []
+    check('C1-shared-pager-wired-into-both-blocks', pagerUses.length === 2 && /import PagerNav from '\.\/PagerNav\.vue'/.test(sections), `PagerNav 接线 ${pagerUses.length} 处(events 覆盖预警/报警 + orders)`)
+    // C2 唯一翻页出口接线:三处都只接 @page → gotoPage。
+    const pageHandlers = sections.match(/@page="\(target\) => gotoPage\(panel, target\)"/g) || []
+    check('C2-single-handler', pageHandlers.length === 2 && /function gotoPage\(panel, target\)/.test(sections), `@page→gotoPage 接线 ${pageHandlers.length} 处`)
+    // C3 工单接入分页(票 59 的新要求):窗口行渲染 + 计数/空态都读 pagedOf。
+    check(
+        'C3-orders-paginated',
+        /v-for="\(order, index\) in pagedOf\(panel\)\.rows"/.test(sections) && /v-if="!pagedOf\(panel\)\.total"[\s\S]{0,160}换一个分类或点「全部」[\s\S]{0,260}emptyList\('工单'\)/.test(sections),
+        '工单走窗口行;空态文案区分"分类下没有"与"真没有"',
+    )
+    // C4 组件内零第二份翻页算术(条文 11:窗口计算只在 pagination.js 一份)。
+    check(
+        'C4-no-local-pagination-arithmetic',
+        !/Math\.(ceil|trunc|floor)/.test(sections) && !/\.slice\(/.test(sections) && !/equipment-pager__btn/.test(sections) && !/hasPrev|hasNext/.test(sections),
+        '组件不出现 ceil/trunc/slice/内联 pager/hasPrev-hasNext 判断(窗口与禁用态都归共享层)',
+    )
+    // C5 复位规则(条文 5/6):分类变化回第 1 页;换设备全复位;gotoPage 不做夹取(夹回归 pagedRows)。
+    check(
+        'C5-reset-rules-intact',
+        /toggleCategory[\s\S]{0,260}\[panelName\]: 1/.test(sections) && /watch\(\s*\(\) => props\.deviceCode,[\s\S]{0,200}pages\.value = \{\}/.test(sections) && !/Math\.(min|max)/.test((sections.match(/function gotoPage[\s\S]*?\n\}/) || [''])[0]),
+        '换分类/换设备都复位;翻页出口只存状态,夹回由 domain 单源做',
+    )
+    // C6 分页读的是**过滤后**集合(先筛后翻,条文 5)。
+    check('C6-filter-before-paginate', /return pagedRows\(visibleRows\(panel\), pages\.value\[panel\.name\] \|\| 1\)/.test(sections), 'pagedRows 输入必须是分类过滤后的行')
+    // C7 pagedOf 是单一委托(一条路径给三清单;不再按 kind 分叉出兜底形状)。
+    check('C7-pagedof-is-single-delegate', /function pagedOf\(panel\) \{\s*return pagedRows\(visibleRows\(panel\), pages\.value\[panel\.name\] \|\| 1\)/.test(sections), 'pagedOf 只有 pagedRows 一条出口(分叉兜底=第二份形状)')
+}
+
+// ================================================================ K. SFC 真实编译(t41 教训)
+
+{
+    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: 't59' })
+            const template = compileTemplate({
+                source: descriptor.template.content,
+                filename: rel,
+                id: 't59',
+                compilerOptions: { bindingMetadata: script.bindings },
+            })
+            assert.deepEqual(template.errors, [], `${rel} template`)
+            ok = true
+        } catch (error) {
+            why = error.message.split('\n')[0]
+        }
+        check(`K1-${label}-compiles`, ok, why || `${rel} 编译应无错误`)
+    }
+    compileSfc('pagernav', 'components/PagerNav.vue')
+    compileSfc('sections', 'components/EquipmentDetailSections.vue')
+}
+
+console.log('')
+console.log(`T59 共享分页基建与设备详情三清单:${passed}/${passed + failed} 项通过。`)
+if (failed) {
+    console.log('未通过项见上方 FAIL。')
+    process.exit(1)
+}
+assert.equal(1, 1)
+console.log('T59 全部通过(浏览器走查按项目规则未自动执行)。')

+ 187 - 0
.scratch/cockpit-real-data-wayfinding/verify/t60-mutations.mjs

@@ -0,0 +1,187 @@
+/**
+ * 票 60 闸门变异测试:证明 t60 的每条关键断言**抓得住它要防的缺陷**(t59/t46 同一纪律)。
+ *
+ * 做法:对源文件做**单点**变异注入 → 跑 t60 → 断言指定断言转红 → 按字节还原并校验一致。
+ *
+ * 十条变异对应本票的真实风险:
+ *   M1 卡片下标退回页内 0 起      → R8 红(走查/探针按第 N 台取数会串位)
+ *   M2 关联清单退回全量直渲       → R2 红(本票交付本身被吃掉)
+ *   M3 未命中子清单退回全量展开   → T2 红(维护者裁定 1 被吃掉)
+ *   M4 差额读数改读当前窗口       → T5 红(票 14「另有 N 台」不再可追溯)
+ *   M5 标题总数改读窗口条数       → K2 红(票 42 口径:总数只认当前快照)
+ *   M6 计数行开关默认改 false     → S1 红(批次一既有消费方静默丢计数行)
+ *   M7 组件里重新长翻页算术       → S6 红(条文 11 单源失守)
+ *   M8 翻页出口多开一个事件名     → S5 红(两套翻页通道)
+ *   M9 换点位不再复位页码         → Z4 红(条文 5/6)
+ *   M10 复位触发器退回数组 getter → Z3 红(票 43 教训:同值也触发=假复位)
+ *
+ * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t60-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('t60-point-detail-lists.mjs', import.meta.url))
+const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}`
+
+/**
+ * 跑闸门并取回输出。刻意用**文件重定向**而不是管道捕获(t46/t59 实测:
+ * Windows 沙箱下捕获子进程 stdio 的命名管道是文档化边界(EPERM),管道版拿回空输出
+ * → 变异全部"未转红"的假信号)。
+ */
+const reportDir = mkdtempSync(join(tmpdir(), 't60-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')
+    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}: 还原失败`)
+    }
+}
+
+const CARD = 'components/PointDetailCard.vue'
+const PAGER = 'components/PagerNav.vue'
+
+// ---- 窗口与断言面(本票交付本身) ----
+
+mutate(
+    'M1-card-index-back-to-page-local',
+    CARD,
+    ':data-device-card="pagedCards.from - 1 + index"',
+    ':data-device-card="index"',
+    'R8',
+)
+
+mutate(
+    'M2-layer-list-reverts-to-full-render',
+    CARD,
+    'v-for="row in pagedLayerRows.rows"',
+    'v-for="row in hitDeviceRows"',
+    'R2',
+)
+
+mutate(
+    'M3-unmatched-list-reverts-to-full-expand',
+    CARD,
+    'v-for="row in pagedUnmatchedRows.rows"',
+    'v-for="row in unmatchedDeviceRows"',
+    'T2',
+)
+
+mutate(
+    'M4-more-count-reads-current-window',
+    CARD,
+    ':data-device-more-count="unmatchedDeviceRows.length"',
+    ':data-device-more-count="pagedUnmatchedRows.rows.length"',
+    'T5',
+)
+
+mutate(
+    'M5-layer-total-reads-window-size',
+    CARD,
+    'data-device-layer-total>{{ deviceLayerTotal }}</b>',
+    'data-device-layer-total>{{ pagedLayerRows.total }}</b>',
+    'K2',
+)
+
+// ---- 共享层的默认形态(批次一既有消费方不得被本票静默改掉) ----
+
+mutate(
+    'M6-show-count-default-false',
+    PAGER,
+    'showCount: { type: Boolean, default: true }',
+    'showCount: { type: Boolean, default: false }',
+    'S1',
+)
+
+mutate(
+    'M7-second-arithmetic-in-component',
+    CARD,
+    '    return pagedRows(rows, pages.value[key])',
+    '    const p = Number(pages.value[key]) || 1; const pc = Math.max(1, Math.ceil(rows.length / 10)); const c = Math.min(p, pc); return { rows: rows.slice((c - 1) * 10, c * 10), page: c, pageCount: pc, total: rows.length, from: rows.length ? (c - 1) * 10 + 1 : 0 }',
+    'S6',
+)
+
+mutate(
+    'M8-second-page-outlet',
+    CARD,
+    "@page=\"(target) => gotoPage('cards', target)\"",
+    "@turnPage=\"(target) => gotoPage('cards', target)\"",
+    'S5',
+)
+
+// ---- 复位规则(条文 5/6 + 票 43 的稳定标量键教训) ----
+
+mutate(
+    'M9-point-change-keeps-page',
+    CARD,
+    '        pages.value = { cards: 1, layer: 1, unmatched: 1 }',
+    '        pages.value = pages.value',
+    'Z5',
+)
+
+mutate(
+    'M10-reset-trigger-back-to-array-getter',
+    CARD,
+    '    () => `${selection.value.pointCode}|${selection.value.deviceCode}`,',
+    '    () => [selection.value.pointCode, selection.value.deviceCode],',
+    'Z3',
+)
+
+console.log('')
+console.log(`T60 变异测试:${proved}/${injected} 处变异被对应断言抓住。`)
+rmSync(reportDir, { recursive: true, force: true })
+if (proved < injected) process.exit(1)

+ 404 - 0
.scratch/cockpit-real-data-wayfinding/verify/t60-point-detail-lists.mjs

@@ -0,0 +1,404 @@
+/**
+ * 票 60 离线闸门:统一分页批次二 —— 点位摘要两级清单(设备卡片、关联设备图层清单、未命中子清单)。
+ *
+ * 五组断言(不启浏览器、不联网;渲染层用 Vue 自定义渲染器在纯对象树上跑真实 patch,t64 成例):
+ *   S. SSR/静态守卫:PagerNav 的 showCount 默认 true(票 46/59 既有形态不变)、计数行是唯一受控块;
+ *      点位卡三处接线 :show-count="false" 且零第二份翻页算术;设备详情侧未被牵连。
+ *   R. 真实渲染(行为级):每页 ≤10;窗口逐条对内容;三清单页码互相独立;
+ *      pageCount<=1 不画控件;到边禁用保占位;页码直达/跳页夹回;空态文案沿用既有。
+ *   K. 票 42 口径不变:store 仍持全部编码(含待确认/未命中),总数只认快照口径;
+ *      DOM 窗口化后总数/归类计数/图例纹丝不动(互斥归类之和仍等于全量行数)。
+ *   Z. 复位与保持(条文 5/6):换点位/换设备/返回第一级 → 页码回第 1 页;
+ *      同点位重进与数据刷新(selection 换了对象但标量键同值)→ **不**复位;越界夹回最后一页。
+ *   C. SFC 真实编译(t41/59 教训:拼错的绑定只有加载层判得出)。
+ *
+ * 变异有牙见 t60-mutations.mjs。
+ * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t60-point-detail-lists.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 } from '@vue/runtime-core'
+import { createPinia, setActivePinia } from 'pinia'
+
+const SRC = '../../../src/modules/cockpit/'
+const read = (rel) => readFileSync(new URL(SRC + rel, import.meta.url), 'utf8')
+const stripComments = (text) => text.replace(/<!--[\s\S]*?-->/g, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
+
+// runtime-dom 的 v-model 在 beforeUpdate 读 document.activeElement;自定义渲染器的宿主是纯对象树。
+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 传播,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: 't60', genDefaultAs: '__sfc__' })
+            const template = compileTemplate({
+                source: descriptor.template.content,
+                filename: path,
+                id: 't60',
+                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 pinia = createPinia()
+setActivePinia(pinia)
+const config = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
+const { useDetailStore } = await import(`${SRC}stores/detailStore.js`)
+const { useMapStore } = await import(`${SRC}stores/mapStore.js`)
+const { useDeviceLayerStore } = await import(`${SRC}stores/deviceLayerStore.js`)
+const { registerMockTransport, resetMockTransportsForTest } = await import(`${SRC}services/cockpitApi.js`)
+const { default: ClientCard } = await import(`${SRC}components/PointDetailCard.vue?client`)
+
+// ================================================================ 场景数据(真实链路灌入,不喂视图半成品)
+
+const VALIDATION = { state: 'VALID', reason: '', crs: 'WGS84', renderable: true }
+// P-MANY:快照 15 台 + 详情独有 12 台(筛选生效 → 未命中)→ 卡片 27、命中清单 15、未命中 12。
+const SNAPSHOT_15 = Array.from({ length: 15 }, (_, i) => `EQ-${String(i + 1).padStart(2, '0')}`)
+const DETAIL_27 = Array.from({ length: 27 }, (_, i) => `EQ-${String(i + 1).padStart(2, '0')}`)
+const deviceRaw = (code) => ({
+    deviceCode: code,
+    deviceName: `设备${code}`,
+    deviceTypeName: '压力计',
+    deviceStatus: 'NORMAL',
+    statusLabel: '正常',
+    longitude: 110.4,
+    latitude: 28.44,
+    coordinateValidation: VALIDATION,
+})
+const detailPayload = (pointCode, codes) => ({
+    monitoringPointCode: pointCode,
+    monitoringPointName: `${pointCode} 点位`,
+    pointStatus: 'NORMAL',
+    statusLabel: '正常',
+    devices: codes.map(deviceRaw),
+    deviceStatistics: { totalDeviceCount: codes.length, normalDeviceCount: codes.length },
+    coordinateValidation: VALIDATION,
+})
+const snapshotPayload = (pointCode, codes, extraPoints = []) => ({
+    page: 1, pageSize: 50, total: 1 + extraPoints.length,
+    complete: true, snapshotAt: '2026-10-20T10:00:00+08:00',
+    monitoringPoints: [{
+        pointCode, pointName: `${pointCode} 点位`, pointStatus: 'NORMAL',
+        deviceCodes: codes, longitude: 110.4, latitude: 28.44, coordinateValidation: VALIDATION,
+    }, ...extraPoints],
+    unassociatedEquipment: [],
+})
+
+const detail = useDetailStore()
+detail.configure({ config })
+const map = useMapStore()
+map.setup({ config })
+const layer = useDeviceLayerStore()
+
+resetMockTransportsForTest()
+registerMockTransport('/map-snapshot', () => snapshotPayload('P-MANY', SNAPSHOT_15, [
+    {
+        pointCode: 'P-FEW', pointName: 'P-FEW 点位', pointStatus: 'NORMAL',
+        deviceCodes: ['EQ-01', 'EQ-02'], longitude: 110.5, latitude: 28.45, coordinateValidation: VALIDATION,
+    },
+    {
+        pointCode: 'P-ZERO', pointName: 'P-ZERO 点位', pointStatus: 'EMPTY',
+        deviceCodes: [], longitude: 110.6, latitude: 28.46, coordinateValidation: VALIDATION,
+    },
+]))
+registerMockTransport('/monitoring-points/P-MANY', () => detailPayload('P-MANY', DETAIL_27))
+registerMockTransport('/monitoring-points/P-FEW', () => detailPayload('P-FEW', ['EQ-01', 'EQ-02']))
+registerMockTransport('/monitoring-points/P-ZERO', () => detailPayload('P-ZERO', []))
+
+await map.advance()
+await detail.openPoint('P-MANY')
+// 票 14:设备级筛选生效(快照是命中子集)→ 详情独有落「未命中」,展开子清单才有得翻。
+layer.syncForSelection({ force: true, mode: 'overview', filterActive: true })
+
+// ================================================================ 假 DOM 渲染器(t64 成例)
+
+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 pagerWrap = (which) => findNode(container, (n) => String(n.props && n.props['data-point-pager']) === which)
+
+const app = createRenderer(nodeOps).createApp({ render: () => h(ClientCard, { locationReason: '' }) })
+app.config.warnHandler = () => {}
+app.use(pinia)
+const container = nodeOps.createElement('#root')
+app.mount(container)
+
+const cardCodes = () => findAll(container, byAttr('data-device-card')).map((n) => n.props['data-device-code'])
+const cardIndexAttr = () => findAll(container, byAttr('data-device-card')).map((n) => Number(n.props['data-device-card']))
+const layerCodes = () => findAll(container, (n) => 'data-device-layer-code' in (n.props || {}) && n.props['data-device-layer-row'] !== 'unmatched').map((n) => n.props['data-device-layer-code'])
+const unmatchedCodes = () => findAll(container, (n) => n.props && n.props['data-device-layer-row'] === 'unmatched').map((n) => n.props['data-device-layer-code'])
+const pagers = () => findAll(container, byAttr('data-pager'))
+const clickPage = async (which, n) => {
+    const wrap = pagerWrap(which)
+    assert.ok(wrap, `pager 包装层 ${which} 不在 DOM`)
+    const btn = findNode(wrap, (node) => String(node.props && node.props['data-page-number']) === String(n))
+    assert.ok(btn && typeof btn.props.onClick === 'function', `pager ${which} 的页码按钮 ${n} 不在`)
+    btn.props.onClick(new Proxy({}, { get: () => undefined }))
+    await nextTick()
+}
+const clickPrevNext = async (which, attr) => {
+    const btn = findNode(pagerWrap(which), byAttr(attr))
+    assert.ok(btn && typeof btn.props.onClick === 'function', `${which} 缺 ${attr}`)
+    btn.props.onClick(new Proxy({}, { get: () => undefined }))
+    await nextTick()
+}
+
+// ================================================================ R. 真实渲染(行为级)
+
+{
+    // R1 设备卡片首屏只画 10 张(27 条 → 条文 1)。
+    check('R1 设备卡片每页 ≤10(27 条首屏 10 张)', cardCodes().length === 10, `got=${cardCodes().length}`)
+    // R2 关联清单首屏 10 行(命中 15 台)。
+    check('R2 关联设备清单每页 ≤10(15 台首屏 10 行)', layerCodes().length === 10, `got=${layerCodes().length}`)
+    // R3 三处 pager 都在(cards/layer 两页起步;unmatched 折叠未展开 ⇒ 其 pager 随折叠不存在)。
+    check('R3 两清单的 pager 都接线', !!pagerWrap('cards') && !!pagerWrap('layer') && pagers().length === 2, `pagers=${pagers().length}`)
+    // R4 计数行不画(维护者裁定 2):三处 pager 里都没有 data-page-state。
+    const stateNodes = pagers().flatMap((p) => findAll(p, byAttr('data-page-state')))
+    check('R4 本组件 pager 一律不画计数行(无第二个总数口径)', stateNodes.length === 0, `state=${stateNodes.length}`)
+    // R5 到边禁用保占位:第 1 页「上一页」disabled 但节点在。
+    const prev = findNode(pagerWrap('cards'), byAttr('data-page-prev'))
+    check('R5 到边按钮 disabled 且保留占位', !!prev && prev.props.disabled === true, `prev=${prev && prev.props.disabled}`)
+
+    // R6 点页码 2 → 窗口真的移动(逐条对编码,不是只数卡片)。
+    await clickPage('cards', 2)
+    check('R6 卡片点页码 2 → 画第 11–20 台(逐条对编码)',
+        cardCodes().join(',') === DETAIL_27.slice(10, 20).join(','), cardCodes().join(','))
+    // R7 独立页码:卡片翻页不牵连关联清单(它还在第 1 页)。
+    check('R7 两清单页码互相独立', layerCodes().join(',') === SNAPSHOT_15.slice(0, 10).join(','), layerCodes().join(','))
+
+    // R8 data-device-card 下标是**全局位置**(走查/探针按它取第 N 台,翻页不得让每页都从 0 数)。
+    check('R8 卡片 data-device-card 续全局下标(第 2 页是 10..19)',
+        cardIndexAttr().join(',') === '10,11,12,13,14,15,16,17,18,19', cardIndexAttr().join(','))
+
+    // R9 关联清单翻页:第 2 页只剩余数 5 行(不补空行)。
+    await clickPage('layer', 2)
+    check('R9 关联清单末页只画余数', layerCodes().join(',') === SNAPSHOT_15.slice(10, 15).join(','), layerCodes().join(','))
+    // R10 末页「下一页」disabled 且仍在。
+    const nextOnLast = findNode(pagerWrap('layer'), byAttr('data-page-next'))
+    check('R10 末页下一页 disabled 保占位', !!nextOnLast && nextOnLast.props.disabled === true, `next=${nextOnLast && nextOnLast.props.disabled}`)
+
+    // R11 上一页走同一出口:layer 从 2 回 1。
+    await clickPrevNext('layer', 'data-page-prev')
+    check('R11 上一页回到第 1 页窗口', layerCodes().join(',') === SNAPSHOT_15.slice(0, 10).join(','), layerCodes().join(','))
+}
+
+// ================================================================ T. 未命中子清单(裁定 1:第三套独立页码)
+
+{
+    const toggle = findNode(container, byAttr('data-device-more-toggle'))
+    assert.ok(toggle && typeof toggle.props.onClick === 'function', '「另有 N 台」入口不在 DOM')
+    check('T1 折叠钮文案仍是全量 12 台', textOf(toggle).includes('另有 12 台'), textOf(toggle))
+    toggle.props.onClick(new Proxy({}, { get: () => undefined }))
+    await nextTick()
+    // T2 展开 → 第一页 ≤10 且独立第三套 pager 出现。
+    check('T2 未命中子清单每页 ≤10(12 条 → 首屏 10 行)', unmatchedCodes().length === 10, `got=${unmatchedCodes().length}`)
+    check('T3 第三套独立页码就位', !!pagerWrap('unmatched') && pagers().length === 3, `pagers=${pagers().length}`)
+    // T4 翻第 2 页 → 余数 2 行;前两清单窗口不受牵连。
+    const cardsBefore = cardCodes().join(',')
+    const layerBefore = layerCodes().join(',')
+    await clickPage('unmatched', 2)
+    check('T4 未命中第 2 页只画余数', unmatchedCodes().join(',') === DETAIL_27.slice(25, 27).join(','), unmatchedCodes().join(','))
+    check('T4b 第三套翻页不牵连前两处', cardCodes().join(',') === cardsBefore && layerCodes().join(',') === layerBefore, '')
+    // T5 折叠再展开:另一套语义——差额计数(另有 N 台)始终读**全量**,不是当前页。
+    const more = findNode(container, byAttr('data-device-more'))
+    check('T5 另有 N 台读数仍是全量 12(与窗口无关)', more && String(more.props['data-device-more-count']) === '12', `count=${more && more.props['data-device-more-count']}`)
+}
+
+// ================================================================ K. 票 42 口径不变(窗口化只发生在渲染)
+// pinia 的 store 实例把 ref 解包成本体:这里读 layer.rows / layer.total 直接是数组与数字。
+
+{
+    // K1 store 全量保留:27 行、总数 27(15 快照 + 12 未命中——命中子集口径下含未命中)。
+    check('K1 store.rows 仍持全部编码(27 行,窗口化只在渲染层)', layer.rows.length === 27, `rows=${layer.rows.length}`)
+    check('K1b 总数只认当前快照口径(15 命中 + 12 未命中 = 27)', layer.total === 27, `total=${layer.total}`)
+    // K2 DOM 当前页 ≠ 全量:标题读 27,清单只画 5(layer 在第 2 页)。
+    const totalNode = findNode(container, byAttr('data-device-layer-total'))
+    const drawnNode = findNode(container, byAttr('data-device-layer-drawn'))
+    check('K2 标题「总数 27 台(已上图 15)」不受窗口影响',
+        totalNode && textOf(totalNode) === '27' && drawnNode && textOf(drawnNode) === '15',
+        `total=${totalNode && textOf(totalNode)} drawn=${drawnNode && textOf(drawnNode)}`)
+    // K3 互斥归类之和 = 全量行数(每台恰好落一类,与窗口无关)。
+    const countSum = Object.values(layer.counts).reduce((acc, v) => acc + v, 0)
+    check('K3 互斥归类之和仍等于全量 27', countSum === 27 && layer.counts.drawn === 15 && layer.counts.unmatched === 12, JSON.stringify(layer.counts))
+    // K4 图例合计 = 全量(差额可追溯不受分页影响)。
+    const legendSum = layer.legend.reduce((acc, row) => acc + row.count, 0)
+    check('K4 设备图例合计 = 全量 27', legendSum === 27, `legend=${legendSum}`)
+}
+
+// ================================================================ Z. 复位与保持(条文 5/6)
+
+{
+    // Z1 刷新数据(同点位重取,selection 标量键不变)→ 页码**保持**。
+    await detail.reloadPointDetail('P-MANY')
+    layer.syncForSelection({ mode: 'overview', filterActive: true })
+    await nextTick()
+    check('Z1 数据刷新不复位页码(卡片还在第 2 页)', cardCodes()[0] === DETAIL_27[10], `first=${cardCodes()[0]}`)
+
+    // Z2 越界夹回:刷新后数据变少(改开 P-FEW 前先把卡片停末页再换数据)——用跳 999 夹回第 3 页。
+    const jumpInput = findNode(pagerWrap('cards'), byAttr('data-page-jump-input'))
+    assert.ok(jumpInput && typeof jumpInput.props['onUpdate:modelValue'] === 'function', '跳页输入框未接线')
+    jumpInput.props['onUpdate:modelValue']('999')
+    await nextTick()
+    findNode(pagerWrap('cards'), byAttr('data-page-jump-btn')).props.onClick()
+    await nextTick()
+    check('Z2 跳页超界夹回最后一页(7 条余数,空表不出现)',
+        cardCodes().join(',') === DETAIL_27.slice(20, 27).join(','), cardCodes().join(','))
+
+    // Z3 重进同一点位(selection 换新对象、标量键同值)→ **不**误复位(票 43 的稳定键教训)。
+    await detail.openPoint('P-MANY')
+    await nextTick()
+    check('Z3 同点位重进不误复位(标量键同值不触发 watch)', cardCodes()[0] === DETAIL_27[20], `first=${cardCodes()[0]}`)
+
+    // Z4 换点位 → 三处页码全回第 1 页;P-FEW 只有 2 条 → pageCount<=1 → 控件**整块不画**。
+    await detail.openPoint('P-FEW')
+    layer.syncForSelection({ force: true, mode: 'overview', filterActive: true })
+    await nextTick()
+    check('Z4 换点位回第 1 页窗口', cardCodes().join(',') === 'EQ-01,EQ-02', cardCodes().join(','))
+    check('Z4b 短列表(≤10)三处控件都不画(条文 4)', pagers().length === 0, `pagers=${pagers().length}`)
+
+    // Z5 进设备级再返回第一级 → 页码复位(返回第一级也算一次层级条件变化)。
+    await detail.openPoint('P-MANY')
+    layer.syncForSelection({ force: true, mode: 'overview', filterActive: true })
+    await clickPage('cards', 2)
+    layer.selectDevice('EQ-03')
+    await nextTick()
+    layer.backToPointLevel()
+    await nextTick()
+    check('Z5 返回第一级页码回第 1 页', cardCodes()[0] === 'EQ-01' && layerCodes().length === 10, `first=${cardCodes()[0]}`)
+
+    // Z6 空态文案沿用既有:P-ZERO(详情 0 台)→「该监测点当前没有关联设备」。
+    await detail.openPoint('P-ZERO')
+    layer.syncForSelection({ force: true, mode: 'overview', filterActive: true })
+    await nextTick()
+    const empty = findNode(container, byAttr('data-device-cards-empty'))
+    check('Z6 空态文案原样保留(未被分页改写)', !!empty && textOf(empty).includes('该监测点当前没有关联设备'), textOf(empty))
+    check('Z6b 空清单不画控件', pagers().length === 0, `pagers=${pagers().length}`)
+}
+
+// ================================================================ S. 静态守卫(单源与默认形态不变)
+
+{
+    const card = stripComments(read('components/PointDetailCard.vue'))
+    const pager = stripComments(read('components/PagerNav.vue'))
+    const sections = stripComments(read('components/EquipmentDetailSections.vue'))
+
+    // S1 PagerNav 的 showCount 默认 true:票 46/59 既有消费方(设备详情三清单)形态零改动。
+    check('S1 showCount 默认 true(既有计数行不回归)',
+        /showCount: \{ type: Boolean, default: true \}/.test(pager), '默认值被改了=批次一形态回归')
+    check('S2 计数行是唯一受 showCount 门控的块(翻页能力不受它影响)',
+        /v-if="showCount" class="pager-nav__state" data-page-state/.test(pager) && (pager.match(/v-if="showCount"/g) || []).length === 1, '')
+    // S3 设备详情侧没被顺手关计数行(data-page-state 断言面保留,t59-S9 同源)。
+    check('S3 EquipmentDetailSections 不传 show-count(既有计数行保留)',
+        !/show-count/.test(sections) && (sections.match(/<PagerNav\b/g) || []).length === 2, '设备详情三清单的计数行不该在本票变')
+    // S4 点位卡三处接线都关计数行、都走同一出口(唯一 @page→gotoPage;无第二套翻页算术)。
+    check('S4 点位卡三处接共享 PagerNav 且都 :show-count="false"',
+        (card.match(/<PagerNav\b/g) || []).length === 3 && (card.match(/:show-count="false"/g) || []).length === 3,
+        `uses=${(card.match(/<PagerNav\b/g) || []).length}`)
+    check('S5 三处共用唯一翻页出口 gotoPage(key, target)',
+        (card.match(/@page="\(target\) => gotoPage\('[a-z]+', target\)"/g) || []).length === 3 && /function gotoPage\(key, target\)/.test(card), '')
+    // S6 窗口取数只有一条 pagedRows 委托;组件零 Math/slice(条文 11)。
+    check('S6 窗口计算单源一条委托',
+        /return pagedRows\(rows, pages\.value\[key\]\)/.test(card) && !/Math\.(ceil|trunc|floor)/.test(card) && !/\.slice\(/.test(card), '组件里长出第二份算术')
+    // S7 页码状态是稳定标量键三元表 + 复位 watch 收**标量字符串**(票 43 教训:getter 回数组会引用不等假复位)。
+    check('S7 三清单各自独立页码 + 复位键是标量',
+        /const pages = ref\(\{ cards: 1, layer: 1, unmatched: 1 \}\)/.test(card) &&
+        /watch\(\s*\(\) => `\$\{selection\.value\.pointCode\}\|\$\{selection\.value\.deviceCode\}`/.test(card) &&
+        !/watch\(\s*\(\) => \[selection\.value\.pointCode/.test(card) &&
+        /pages\.value = \{ cards: 1, layer: 1, unmatched: 1 \}/.test(card), '复位触发器形状变了')
+    // S8 数据源不被窗口反噬:store 的 rows/total/counts/legend 仍直接进标题与图例(t42/t44 断言面)。
+    check('S8 标题总数仍读 store total、归类仍读 store counts',
+        /data-device-layer-total>\{\{ deviceLayerTotal \}\}</.test(card) && /data-device-layer-drawn>\{\{ deviceCounts\.drawn \|\| 0 \}\}</.test(card), '总数口径被组件改写')
+    // S9 空态文案原样在源码里(判绑定文本而不是新措辞)。
+    check('S9 空态文案沿用既有', card.includes('该监测点当前没有关联设备'), '文案被改了')
+}
+
+// ================================================================ C. SFC 真实编译
+
+for (const [label, rel] of [['card', 'components/PointDetailCard.vue'], ['pagernav', 'components/PagerNav.vue'], ['sections', 'components/EquipmentDetailSections.vue']]) {
+    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: 't60' })
+        const template = compileTemplate({ source: descriptor.template.content, filename: rel, id: 't60', compilerOptions: { bindingMetadata: script.bindings } })
+        assert.deepEqual(template.errors, [], `${rel} template`)
+        ok = true
+    } catch (error) {
+        why = error.message.split('\n')[0]
+    }
+    check(`C1-${label}-compiles`, ok, why)
+}
+
+resetMockTransportsForTest()
+layer.reset()
+detail.reset()
+console.log('')
+console.log(`T60 点位摘要两级清单分页:${passed}/${passed + failed} 项通过。`)
+if (failed) { console.log('未通过项见上方 FAIL。'); process.exit(1) }
+console.log('T60 全部通过(浏览器走查按 AGENTS.md 未自动执行)。')

+ 9 - 1
.scratch/cockpit-shell-panel-layout/verify/t12-work-order-timeline.mjs

@@ -543,11 +543,19 @@ const track = (code, entries) =>
         for (const match of template.matchAll(/\{\{([\s\S]*?)\}\}/g)) expressions.push(match[1])
         for (const match of template.matchAll(/(?:^|\s)(?:v-[a-z-]+|[:@][a-zA-Z-]+)="([^"]*)"/g)) expressions.push(match[1])
 
-        // 2) v-for 引入的局部名(`x in y`、`(a, b) in y`)。
+        // 2) v-for 引入的局部名(`x in y`、`(a, b) in y`),以及内联箭头函数的形参
+        //    (`@page="(target) => gotoPage(panel, target)"` —— 票 59 实测:扫描器不认
+        //    形参就把事件处理器里的合法参数误报成未绑定,与 v-for 那版假红同一形状)。
         const locals = new Set()
         for (const match of template.matchAll(/v-for="\(?([^)"]*?)\)?\s+in\s/g)) {
             for (const piece of match[1].split(',')) locals.add(piece.trim())
         }
+        for (const match of template.matchAll(/\(([^)]*)\)\s*=>/g)) {
+            for (const piece of match[1].split(',')) {
+                const name = piece.trim().split(/[\s:=]+/)[0]
+                if (name) locals.add(name)
+            }
+        }
 
         // 3) script 里的顶层绑定:import(默认/具名)、const/let/var/function、defineProps 的 prop 名。
         const bound = new Set(locals)

+ 35 - 73
src/modules/cockpit/components/EquipmentDetailSections.vue

@@ -3,8 +3,12 @@
 
   票 46 返工(维护者 2026-10-15 第 1 条):**弃用六块竖排的长滚动**,改为标签页切换
   设备档案(默认,图片归此页下方)|最新指标|预警信息|报警信息|工单状态;
-  预警/报警列表每页最多 10 条(上一页/下一页 + 页码)。页签计划与分页边界都是
+  预警/报警列表每页最多 10 条。页签计划与分页边界都是
   domain 纯函数(`EQUIPMENT_TABS`/`panelsForTab`/`pagedRows`),组件只画结论。
+  票 59(统一分页批次一):**三清单(预警/报警/工单)都接共享 `PagerNav.vue`**
+  (页码直达 + 输入框跳页 + 计数行),翻页窗口计算归 `domain/pagination.js` 单源,
+  本组件不再自画 pager、不再有第二份翻页算术;前后页/页码/跳页共用唯一出口
+  `gotoPage(panel, target)`,越界夹回由 `pagedRows` 做。
   **渲染计划仍是六块独立分区**(t11 D1/D9):标签页只改呈现归属,
   每块仍各带自己的状态标签与成败 —— 档案读失败但指标可用是后端契约的原生态,
   界面共用一个成败等于把后端已说清的信息揉回含糊的「加载失败」。
@@ -165,43 +169,25 @@
                         {{ rowsOf(panel).length ? '当前分类下没有记录(换一个分类或点「全部」)' : emptyList(panel.label) }}
                     </p>
 
-                    <!--
-                        票 46 返工第 1 条:分页控件(每页最多 10 项;上一页/下一页 + 页码)。
-                        只有一页时**不画**控件 —— 分页是为"翻不动"准备的,短列表不该多出两个死按钮。
-                    -->
-                    <div v-if="pagedOf(panel).pageCount > 1" class="equipment-pager" role="group" :aria-label="`${panel.label}分页`" data-pager>
-                        <button
-                            type="button"
-                            class="equipment-pager__btn"
-                            :disabled="!pagedOf(panel).hasPrev"
-                            data-page-prev
-                            @click="turnPage(panel, -1)"
-                        >
-                            上一页
-                        </button>
-                        <span class="equipment-pager__state" data-page-state>
-                            第 {{ pagedOf(panel).page }}/{{ pagedOf(panel).pageCount }} 页 · 共 {{ pagedOf(panel).total }} 条
-                        </span>
-                        <button
-                            type="button"
-                            class="equipment-pager__btn"
-                            :disabled="!pagedOf(panel).hasNext"
-                            data-page-next
-                            @click="turnPage(panel, 1)"
-                        >
-                            下一页
-                        </button>
-                    </div>
+                    <!-- 分页控件(票 59 共享 PagerNav,前后页/页码/跳页同一个 @page 出口) -->
+                    <PagerNav
+                        :page="pagedOf(panel).page"
+                        :page-count="pagedOf(panel).pageCount"
+                        :total="pagedOf(panel).total"
+                        :list-name="panel.label"
+                        @page="(target) => gotoPage(panel, target)"
+                    />
                 </div>
 
                 <!--
                     工单状态:状态名(缺则按状态字典补)+ 关键时间 + 每张工单自己的状态时间线(票 12)。
                     票 46:条目默认可折叠,展开保留数据时间/状态/来源/关联对象 + 计划与实际结案时间。
                     「没有动作记录」那句仍由 `timeline.hasContent` 门控(票 12 的 F9 守卫逐字钉住)。
+                    票 59:接入统一分页(每页 ≤10,先分类后翻页)。
                 -->
                 <div v-else-if="panel.kind === 'orders'" class="equipment-section__list" data-work-order-list>
                     <details
-                        v-for="(order, index) in visibleRows(panel)"
+                        v-for="(order, index) in pagedOf(panel).rows"
                         :key="order.code || index"
                         class="equipment-section__item equipment-section__item--collapsible"
                         :data-work-order="order.code || index"
@@ -250,9 +236,18 @@
                     <p v-if="timeline.placeholder" class="equipment-section__hint" data-wo-timeline-placeholder>
                         {{ timeline.placeholder }}
                     </p>
-                    <p v-if="!visibleRows(panel).length" class="equipment-section__hint" data-section-empty-list>
+                    <p v-if="!pagedOf(panel).total" class="equipment-section__hint" data-section-empty-list>
                         {{ rowsOf(panel).length ? '当前分类下没有记录(换一个分类或点「全部」)' : emptyList('工单') }}
                     </p>
+
+                    <!-- 分页控件(票 59 共享 PagerNav,与预警/报警同一控件、同一出口) -->
+                    <PagerNav
+                        :page="pagedOf(panel).page"
+                        :page-count="pagedOf(panel).pageCount"
+                        :total="pagedOf(panel).total"
+                        :list-name="panel.label"
+                        @page="(target) => gotoPage(panel, target)"
+                    />
                 </div>
 
                 <!-- 最新图片:只画后端指定的那一张(前端不索引、不挑选) -->
@@ -321,6 +316,7 @@ import {
 import { pathOnlyTrack } from '../domain/workOrderTimeline.js'
 import SectionStateTag from './SectionStateTag.vue'
 import WorkOrderTimeline from './WorkOrderTimeline.vue'
+import PagerNav from './PagerNav.vue'
 
 const props = defineProps({
     /** `stores/detailStore.js` 的 `sections.equipmentAggregate`。 */
@@ -361,7 +357,7 @@ const staleNote = '数据已过期 · 显示最后一次成功数据,不是实
 /** 各分区当前生效的分类键(`{ [分区名]: 分类键 }`;空串/缺失=全部)。 */
 const categories = ref({})
 
-/** 各分区当前页码(1 起;只给预警/报警用,工单不分页)。 */
+/** 各分区当前页码(1 起;预警/报警/工单三块都接入分页)。 */
 const pages = ref({})
 
 /** 某分区的行视图(未过滤)。 */
@@ -401,15 +397,17 @@ function visibleRows(panel) {
     return filterByCategory(rowsOf(panel), activeCategory(panel.name))
 }
 
-/** 过滤 + 分页后的窗口(domain 的 `pagedRows` 收边界;工单不分页 → 全量)。 */
+/** 过滤 + 分页后的窗口(domain 单源 `pagedRows` 收边界;票 59 三清单同一条路径,组件不再备第二份兜底形状)。 */
 function pagedOf(panel) {
-    if (panel.kind !== 'events') return { rows: visibleRows(panel), total: visibleRows(panel).length, pageCount: 1, page: 1, hasPrev: false, hasNext: false }
     return pagedRows(visibleRows(panel), pages.value[panel.name] || 1)
 }
 
-/** 翻页(越界由 `pagedRows` 夹回,这里只管加/减)。 */
-function turnPage(panel, delta) {
-    pages.value = { ...pages.value, [panel.name]: (pages.value[panel.name] || 1) + delta }
+/**
+ * 翻页唯一出口(票 59):PagerNav 的前后页/页码直达/跳页都带**目标页码(1 起)**回来。
+ * 这里只存状态,不另做夹取——越界统一由 domain `pagedRows` 在下一帧渲染时夹回(条文 6)。
+ */
+function gotoPage(panel, target) {
+    pages.value = { ...pages.value, [panel.name]: Number(target) || 1 }
 }
 
 function categoryNoteText(panel) {
@@ -538,43 +536,7 @@ function isPlaceholder(text) {
     gap: calc(6 * var(--u, 1px));
 }
 
-/* 票 46 返工:分页控件(每页 10 项;只有一页时不出现)。 */
-.equipment-pager {
-    display: flex;
-    align-items: center;
-    gap: var(--space-2, 8px);
-    margin-top: calc(2 * var(--u, 1px));
-}
-
-.equipment-pager__btn {
-    padding: 1px calc(8 * var(--u, 1px));
-    border: 1px solid rgba(1, 171, 234, 0.42);
-    background: rgba(0, 20, 72, 0.6);
-    color: var(--text-2);
-    font: inherit;
-    font-size: var(--fs-caption, 10.5px);
-    cursor: pointer;
-}
-
-.equipment-pager__btn:hover:not(:disabled),
-.equipment-pager__btn:focus-visible:not(:disabled) {
-    color: var(--text-strong);
-    border-color: rgba(1, 171, 234, 0.75);
-    outline: none;
-}
-
-/* 到边的按钮置灰但**保留占位**(按钮消失会让页码行跳动,读起来像出了错)。 */
-.equipment-pager__btn:disabled {
-    opacity: 0.4;
-    cursor: default;
-}
-
-.equipment-pager__state {
-    color: var(--muted, #5680b0);
-    font-family: var(--num, monospace);
-    font-size: var(--fs-caption, 10.5px);
-    letter-spacing: 0.04em;
-}
+/* 票 59:分页控件与样式移交共享 PagerNav.vue,组件不再自画 pager。 */
 
 /* 每块分区:自己的边框与抬头 —— 失败的那一块缩成一行提示,成功的照常展开。 */
 .equipment-section {

+ 183 - 0
src/modules/cockpit/components/PagerNav.vue

@@ -0,0 +1,183 @@
+<!--
+  共享分页导航 PagerNav.vue(票 59,规范条文 2/3/4/9 + 跳页节)。
+
+  全仓 pager 控件只允许这一份:上一页|页码直达|下一页 + 输入框跳页 + 计数行。
+  · 总页数 ≤1 → 整块不渲染(含跳页输入框);
+  · 到边按钮 disabled 但保留占位(按钮消失会让计数行跳动,读起来像出错——票 46 既有语义);
+  · 当前页标 aria-current="page" 且不可点;>7 页收缩「1 … c−1 c c+1 … Y」(pageWindow 单源);
+  · 跳页输入非数字/0/超界一律夹回 1..pageCount(clampPage 单源),不报错、不发请求;
+  · **翻页出口只有一个**:所有交互(前后页/页码/跳页)都 emit `page` 携带目标页码(1 起),
+    组件内不存第二套页码状态,父组件只需把一个 `gotoPage(panel, n)` 接上。
+
+  断言面:data-pager / data-page-prev / data-page-next / data-page-state 保留票 46 语义,
+  跳页新增 data-page-jump / data-page-jump-input。
+-->
+<script setup>
+import { computed, ref } from 'vue'
+import { clampPage, pageWindow } from '../domain/pagination.js'
+
+const props = defineProps({
+    /** 当前页(1 起,由父组件的 pages 状态提供——本组件不存页码)。 */
+    page: { type: Number, required: true },
+    /** 总页数(pagedRows().pageCount)。 */
+    pageCount: { type: Number, required: true },
+    /** 过滤后总条数(pagedRows().total,不由当前页反推)。 */
+    total: { type: Number, required: true },
+    /** 列表名(aria-label「X分页」与 data-list 归属用)。 */
+    listName: { type: String, required: true },
+    /**
+     * 计数行开关(票 60 批次二,维护者当次裁定 2)。默认 `true`=票 46/59 既有形态不变;
+     * 传 `false` 的场合:清单上方**已经**由领域结论给出权威总数(点位摘要的设备合计 KPI、
+     * 关联设备清单的「总数 X 台(已上图 Y)」)。那里再画一条「共 N 条」就是并排的第二个
+     * 总数口径——窗口条数与快照总数本来就是两个数(票 42 的「总数 ≠ 清单行数」),
+     * 摆在一起读必定被当成数据不一致。翻页能力(前后页/页码/跳页)不受它影响。
+     */
+    showCount: { type: Boolean, default: true },
+})
+
+const emit = defineEmits(['page'])
+
+/** 一页都不到 → 整块不渲染(条文 4)。 */
+const visible = computed(() => props.pageCount > 1)
+/** 页码直达窗口(pageWindow 单源,>7 页收缩)。 */
+const pageItems = computed(() => pageWindow(props.page, props.pageCount))
+
+/** 前后页/页码/跳页共用同一出口:目标页码(越界仍由 domain pagedRows 夹回兜底)。 */
+function go(target) {
+    emit('page', target)
+}
+
+/** 跳页:夹回合法范围后走同一出口;与当前页同值则什么都不做(不发请求、不清列表)。 */
+const jumpValue = ref('')
+function jump() {
+    const target = clampPage(jumpValue.value, props.pageCount)
+    jumpValue.value = ''
+    if (target === props.page) return
+    go(target)
+}
+</script>
+
+<template>
+    <div v-if="visible" class="pager-nav" role="group" :aria-label="`${listName}分页`" data-pager>
+        <button type="button" class="pager-nav__btn" :disabled="page <= 1" data-page-prev @click="go(page - 1)">上一页</button>
+
+        <span class="pager-nav__pages" data-page-numbers>
+            <template v-for="(item, index) in pageItems" :key="`${item}-${index}`">
+                <span v-if="item === '…'" class="pager-nav__ellipsis" data-page-ellipsis aria-hidden="true">…</span>
+                <button
+                    v-else
+                    type="button"
+                    class="pager-nav__btn pager-nav__btn--num"
+                    :class="{ 'is-current': item === page }"
+                    :aria-current="item === page ? 'page' : undefined"
+                    :aria-disabled="item === page ? 'true' : undefined"
+                    :data-page-number="item"
+                    @click="item !== page && go(item)"
+                >{{ item }}</button>
+            </template>
+        </span>
+
+        <button type="button" class="pager-nav__btn" :disabled="page >= pageCount" data-page-next @click="go(page + 1)">下一页</button>
+
+        <span class="pager-nav__jump" data-page-jump>
+            <input
+                v-model="jumpValue"
+                type="text"
+                inputmode="numeric"
+                class="pager-nav__input"
+                :placeholder="String(page)"
+                aria-label="跳转到指定页"
+                data-page-jump-input
+                @keydown.enter.prevent="jump"
+            />
+            <button type="button" class="pager-nav__btn pager-nav__btn--jump" data-page-jump-btn @click="jump">跳转</button>
+        </span>
+
+        <span v-if="showCount" class="pager-nav__state" data-page-state>第 {{ page }}/{{ pageCount }} 页 · 共 {{ total }} 条</span>
+    </div>
+</template>
+
+<style scoped>
+/* 与票 46 的 equipment-pager 同一套视觉语言(大屏暗底、胶囊按钮、到边置灰保占位)。 */
+.pager-nav {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: var(--space-2, 8px);
+    margin-top: calc(2 * var(--u, 1px));
+}
+
+.pager-nav__pages {
+    display: flex;
+    align-items: center;
+    gap: calc(2 * var(--u, 1px));
+}
+
+.pager-nav__btn {
+    padding: 1px calc(8 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.42);
+    background: rgba(0, 20, 72, 0.6);
+    color: var(--text-2);
+    font: inherit;
+    font-size: var(--fs-caption, 10.5px);
+    cursor: pointer;
+}
+
+.pager-nav__btn:hover:not(:disabled):not(.is-current),
+.pager-nav__btn:focus-visible:not(:disabled):not(.is-current) {
+    color: var(--text-strong);
+    border-color: rgba(1, 171, 234, 0.75);
+    outline: none;
+}
+
+/* 到边置灰但保留占位(计数行不跳)。 */
+.pager-nav__btn:disabled {
+    opacity: 0.4;
+    cursor: default;
+}
+
+.pager-nav__btn--num {
+    padding: 1px calc(6 * var(--u, 1px));
+    font-family: var(--num, monospace);
+}
+
+.pager-nav__btn--num.is-current {
+    border-color: rgba(0, 184, 255, 0.85);
+    background: linear-gradient(180deg, rgba(1, 171, 234, 0.85), rgba(0, 82, 168, 0.85));
+    color: var(--text-strong);
+    cursor: default;
+}
+
+.pager-nav__ellipsis {
+    color: var(--muted, #5680b0);
+    user-select: none;
+}
+
+.pager-nav__input {
+    width: calc(52 * var(--u, 1px));
+    padding: 1px calc(5 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.42);
+    background: rgba(0, 20, 72, 0.6);
+    color: var(--text, #d7ecff);
+    font-family: var(--num, monospace);
+    font-size: var(--fs-caption, 10.5px);
+    text-align: center;
+}
+
+.pager-nav__input::placeholder {
+    color: var(--muted, #5680b0);
+}
+
+.pager-nav__input:hover,
+.pager-nav__input:focus-visible {
+    border-color: rgba(1, 171, 234, 0.75);
+    outline: none;
+}
+
+.pager-nav__state {
+    color: var(--muted, #5680b0);
+    font-family: var(--num, monospace);
+    font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.04em;
+}
+</style>

+ 110 - 5
src/modules/cockpit/components/PointDetailCard.vue

@@ -104,11 +104,11 @@
                 <p class="point-detail__section-label">设备卡片 · 点一台进入设备详情</p>
                 <div class="point-detail__cards" data-device-cards>
                     <button
-                        v-for="(card, index) in cards"
+                        v-for="(card, index) in pagedCards.rows"
                         :key="card.code"
                         type="button"
                         class="point-detail__card"
-                        :data-device-card="index"
+                        :data-device-card="pagedCards.from - 1 + index"
                         :data-device-code="card.code"
                         :data-device-selected="deviceLayerStore.selectedDevice === card.code ? 'yes' : 'no'"
                         :data-device-hovered="deviceLayerStore.hoveredDevice === card.code ? 'yes' : 'no'"
@@ -132,6 +132,16 @@
                         该监测点当前没有关联设备
                     </p>
                 </div>
+                <!-- 票 60:设备卡片分页(共享控件;计数行不画 —— 权威总数是上面的「设备合计」KPI)。 -->
+                <PagerNav
+                    :page="pagedCards.page"
+                    :page-count="pagedCards.pageCount"
+                    :total="pagedCards.total"
+                    list-name="设备卡片"
+                    :show-count="false"
+                    data-point-pager="cards"
+                    @page="(target) => gotoPage('cards', target)"
+                />
 
                 <!--
                     票 42:**关联监测设备清单**(保留全部编码,含不上图的)。
@@ -171,7 +181,7 @@
                     </p>
                     <ul class="point-detail__devices" data-device-layer-list>
                         <li
-                            v-for="row in hitDeviceRows"
+                            v-for="row in pagedLayerRows.rows"
                             :key="`${row.pointCode}|${row.code}`"
                             class="point-detail__device"
                             :class="{ 'is-selectable': row.placement === 'drawn' }"
@@ -215,6 +225,24 @@
                                 :data-device-asset-reason="row.assetReason"
                             >{{ row.assetReasonText }}</span>
                         </li>
+                        <!--
+                            票 60:关联设备清单的翻页(共享控件,窗口化的只是**命中子集**这份渲染列表)。
+                            `display: contents` 让这层 li 不占栅格位:一页不画控件时(PagerNav 自己 v-if 掉)
+                            不留一条空行 —— 组件不重复判「pageCount>1」,那条规则仍只在共享层有一份。
+                            上方「总数 X 台(已上图 Y)」与图例读的是 store 的快照口径(票 42),本 pager
+                            只翻窗口,所以 :show-count="false"(不再并排给出第二个「总数」)。
+                        -->
+                        <li class="point-detail__pager" data-point-pager-wrap="layer">
+                            <PagerNav
+                                :page="pagedLayerRows.page"
+                                :page-count="pagedLayerRows.pageCount"
+                                :total="pagedLayerRows.total"
+                                list-name="关联设备图层清单"
+                                :show-count="false"
+                                data-point-pager="layer"
+                                @page="(target) => gotoPage('layer', target)"
+                            />
+                        </li>
                         <!--
                             票 14:「另有 N 台」(决策 02 的 Q8/Q14/Q15)——设备级筛选生效时,
                             快照的 `deviceCodes` 是命中子集,未命中设备收进这一行;展开后
@@ -238,7 +266,7 @@
                         </li>
                         <template v-if="unmatchedOpen">
                             <li
-                                v-for="row in unmatchedDeviceRows"
+                                v-for="row in pagedUnmatchedRows.rows"
                                 :key="`${row.pointCode}|${row.code}`"
                                 class="point-detail__device is-unmatched"
                                 :data-device-layer-row="row.placement"
@@ -258,6 +286,19 @@
                                 />
                                 <span class="point-detail__device-placement">{{ row.placementText }}</span>
                             </li>
+                            <!-- 票 60(维护者当次裁定 1):展开后的未命中子清单同样 ≤10/页,独立第三套页码。
+                                 「另有 N 台」的 N 仍报**全量**条数(差额可追溯是票 14 的口径,不受窗口影响)。 -->
+                            <li class="point-detail__pager" data-point-pager-wrap="unmatched">
+                                <PagerNav
+                                    :page="pagedUnmatchedRows.page"
+                                    :page-count="pagedUnmatchedRows.pageCount"
+                                    :total="pagedUnmatchedRows.total"
+                                    list-name="未命中设备清单"
+                                    :show-count="false"
+                                    data-point-pager="unmatched"
+                                    @page="(target) => gotoPage('unmatched', target)"
+                                />
+                            </li>
                         </template>
                     </ul>
                     <!-- 独立图例:只统计当前模式实际绘制的设备,与摘要图例互不计入(票 09 的边界)。 -->
@@ -351,11 +392,14 @@
 </template>
 
 <script setup>
-import { computed, ref } from 'vue'
+import { computed, ref, watch } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useDetailStore } from '../stores/detailStore.js'
 import { useDeviceLayerStore } from '../stores/deviceLayerStore.js'
 import { DETAIL_LEVEL, deviceCards, deviceCardOf, pointDeviceCountsView, pointSummary } from '../domain/detailFlow.js'
+// 票 60(统一分页批次二):翻页窗口计算归 `domain/pagination.js` 单源(条文 11),
+// 控件用共享 `PagerNav.vue`;本组件只存「哪一屏看第几页」这份视图状态,不自画 pager。
+import { pagedRows } from '../domain/pagination.js'
 // 票 44:设备状态走**独立词表**(不借用点位状态表);清单行文本走**资产接缝的标签口径**。
 // 票 46 返工第 2 条:设备状态视觉走设备词表的视图函数(含图例图标单源)。
 // **刻意用别名导入**:静态守卫按子串禁一个与函数名同头的后端裸字段名
@@ -366,6 +410,7 @@ import { deviceLabel } from '../domain/deviceAssets.js'
 import { equipmentDetailMatches, aggregateUnavailable } from '../domain/equipmentDetailView.js'
 import EllipsisText from './EllipsisText.vue'
 import EquipmentDetailSections from './EquipmentDetailSections.vue'
+import PagerNav from './PagerNav.vue'
 import SectionStateTag from './SectionStateTag.vue'
 import StatusTag from './StatusTag.vue'
 
@@ -426,6 +471,57 @@ const hitDeviceRows = computed(() => deviceListRows.value.filter((row) => row.pl
 const unmatchedDeviceRows = computed(() => deviceListRows.value.filter((row) => row.placement === 'unmatched'))
 const unmatchedOpen = ref(false)
 
+/**
+ * 票 60(统一分页批次二):三个清单各自的页码 —— **视图本地状态**,与票 46/59 同一立场
+ * (「这一屏看第几页」不是业务事实,不进 store、不发请求,组件卸载即消失)。
+ * 稳定标量键(票 43 教训:不要用对象身份当键):`cards`/`layer`/`unmatched` 三个固定字符串。
+ *
+ * 三处都用同一套共享件:窗口计算归 `domain/pagination.js`(条文 11 单源),控件归
+ * `PagerNav.vue`;本组件不再有第二份翻页算术。
+ *
+ * 与票 42 口径的关系(验收第 2 条):分页**只窗口化渲染**。
+ *   · 关联设备清单的数据仍是 store 的 `rows` 全量(含「待确认」那些只有编码的行);
+ *   · 上方那行「总数 X 台(已上图 Y)」仍只认当前快照(store 的 `total`/`counts`/`legend` 一字未动);
+ *   · 互斥归类与差额可追溯因此不受影响 —— 翻到的窗口之和仍是那份全量。
+ * 计数行按维护者当次裁定**不画**(`show-count=false`):权威总数已由上面那行与设备合计 KPI 给出,
+ * 再并排放一个「共 N 条」(窗口口径)会被读成第二个总数。
+ */
+const pages = ref({ cards: 1, layer: 1, unmatched: 1 })
+
+/** 某清单的窗口(`pagedRows` 单源:越界/脏页码夹回,空表也是 1 页)。 */
+function pagedOf(key, rows) {
+    return pagedRows(rows, pages.value[key])
+}
+
+const pagedCards = computed(() => pagedOf('cards', cards.value))
+const pagedLayerRows = computed(() => pagedOf('layer', hitDeviceRows.value))
+const pagedUnmatchedRows = computed(() => pagedOf('unmatched', unmatchedDeviceRows.value))
+
+/**
+ * 翻页唯一出口(与票 59 同款):PagerNav 的前后页/页码直达/跳页都带**目标页码(1 起)**回来。
+ * 这里只存状态、不另做夹取 —— 越界统一由 `pagedRows` 在下一帧渲染时夹回(条文 6)。
+ */
+function gotoPage(key, target) {
+    pages.value = { ...pages.value, [key]: Number(target) || 1 }
+}
+
+/**
+ * 复位触发器(条文 5/6):换点位、换设备、返回第一级都把三处页码收回第 1 页
+ * —— 留着就是拿 A 的窗口看 B。
+ * 新鲜度/定时刷新**不动** `selection` 的这两个字段 ⇒ 不触发复位(停在第 3 页的人不会被弹回),
+ * 数据变少导致的越界由 `pagedRows` 夹回最后一页。
+ *
+ * getter 收成**一个标量字符串**而不是数组(票 43 教训):返回新数组的 getter 每次
+ * `selection` 被替换(哪怕 pointCode/deviceCode 同值、例如重进同一张卡片)都会因引用不等
+ * 而误触发复位;同值字符串才真正只在「换了谁」时变。
+ */
+watch(
+    () => `${selection.value.pointCode}|${selection.value.deviceCode}`,
+    () => {
+        pages.value = { cards: 1, layer: 1, unmatched: 1 }
+    },
+)
+
 /** 未命中设备进详情:走同一选中入口,但**不联动镜头**(它没有上图,飞行没有目标)。 */
 function selectUnmatchedDevice(row) {
     if (!row || !row.code) return
@@ -818,6 +914,15 @@ const aggregateDown = computed(() => aggregateUnavailable(sections.value.equipme
     white-space: nowrap;
 }
 
+/*
+    票 60:清单里那层 pager 的容器不占栅格位(`display: contents`)。
+    共享 PagerNav 在一页时整块不渲染,若这里是个普通 `<li>` 就会留下一条空行;
+    而"一页不画控件"的判据必须只留在共享层一份,组件不再重复判 pageCount。
+*/
+.point-detail__pager {
+    display: contents;
+}
+
 /* 设备图例与上面那条的归类文字同形,独立成一行(不挤进点位状态图例)。 */
 .point-detail__legend {
     display: flex;

+ 4 - 26
src/modules/cockpit/domain/equipmentDetailView.js

@@ -105,34 +105,12 @@ export function tabCount(panels, tabName) {
 }
 
 /**
- * 分页视图(票 46 返工第 1 条:预警/报警列表每页最多 10 项)。
+ * 分页视图 —— **已升格到单源 `domain/pagination.js`(票 59,条文 11)**。
  *
- * 为什么是纯函数而不是组件里的算术:页码边界(空表、只有 1 页、越界页码回第 1 页)
- * 都得能脱浏览器断言,散在模板里就守不住。
- * 刻意**不排序、不改行次序**(票 10/11 的立场),只在已过滤的序列上切窗口。
- * @param {Array} rows 待分页的行(已按分类过滤)
- * @param {number} page 1 起的页码
- * @param {number} [pageSize] 每页条数(默认 10)
+ * 这里按原名保留导出兼容(`HISTORY_PAGE_SIZE`/`pagedRows`),实现转调单源:
+ * 翻页算术只允许有一份,组件与旧调用方都不再各写一份(旧 t46 闸门与调用点不受影响)。
  */
-export const HISTORY_PAGE_SIZE = 10
-
-export function pagedRows(rows, page, pageSize = HISTORY_PAGE_SIZE) {
-    const list = Array.isArray(rows) ? rows : []
-    const size = Number.isFinite(pageSize) && pageSize > 0 ? pageSize : HISTORY_PAGE_SIZE
-    const pageCount = list.length > 0 ? Math.ceil(list.length / size) : 1
-    const current = Math.min(Math.max(1, Math.trunc(Number(page) || 1)), pageCount)
-    const start = (current - 1) * size
-    return {
-        rows: list.slice(start, start + size),
-        page: current,
-        pageCount,
-        total: list.length,
-        from: list.length ? start + 1 : 0,
-        to: list.length ? Math.min(start + size, list.length) : 0,
-        hasPrev: current > 1,
-        hasNext: current < pageCount,
-    }
-}
+export { PAGE_SIZE as HISTORY_PAGE_SIZE, pagedRows } from './pagination.js'
 
 /** 本票的渲染计划**不含**这一个聚合分区(票 13 的域)。 */
 export const DEFERRED_SECTIONS = ['alarmWarningRelations']

+ 138 - 0
src/modules/cockpit/domain/pagination.js

@@ -0,0 +1,138 @@
+/**
+ * 全局分页单源(票 59,规范条文 11):翻页窗口计算只允许有这一份。
+ *
+ * 自 `domain/equipmentDetailView.js` 的 `pagedRows` 升格而来;equipmentDetailView
+ * 按原名保留导出兼容(`HISTORY_PAGE_SIZE`/`pagedRows`),旧调用与 t46 闸门不受影响。
+ * 判据全部是纯函数(可脱浏览器断言):页码边界、页码窗口、跳页夹回都在这里收口,
+ * 组件(`PagerNav.vue` 与消费方)只画结论、不再各写一份翻页算术。
+ */
+
+/** 每页固定 10 条(规范条文 1:PAGE_SIZE = 10)。 */
+export const PAGE_SIZE = 10
+
+/** 页码窗口里省略号占位(恒含首末页,中间按当前页收缩,规范条文 2)。 */
+export const PAGE_ELLIPSIS = '…'
+
+/**
+ * 分页视图(票 46 成例保留):在已过滤的序列上切窗口。
+ * 刻意**不排序、不改行次序**(票 10/11 立场),越界/脏页码夹回,空表也是 1 页。
+ * @param {Array} rows 待分页的行(已按分类过滤)
+ * @param {number} page 1 起的页码
+ * @param {number} [pageSize] 每页条数(默认 10)
+ */
+export function pagedRows(rows, page, pageSize = PAGE_SIZE) {
+    const list = Array.isArray(rows) ? rows : []
+    const size = Number.isFinite(pageSize) && pageSize > 0 ? pageSize : PAGE_SIZE
+    const pageCount = list.length > 0 ? Math.ceil(list.length / size) : 1
+    const current = Math.min(Math.max(1, Math.trunc(Number(page) || 1)), pageCount)
+    const start = (current - 1) * size
+    return {
+        rows: list.slice(start, start + size),
+        page: current,
+        pageCount,
+        total: list.length,
+        from: list.length ? start + 1 : 0,
+        to: list.length ? Math.min(start + size, list.length) : 0,
+        hasPrev: current > 1,
+        hasNext: current < pageCount,
+    }
+}
+
+/**
+ * 页码直达窗口(规范条文 2):
+ *   · 总页数 ≤7 → 全列(1..Y,不带省略号);
+ *   · 总页数 >7 → 收缩为「1 … c−1 c c+1 … Y」,恒含首页、末页与至少一个省略号;
+ *   · 非法输入不抛错(脏页码/脏总数按最小安全形状回)。
+ * @param {number} currentPage 当前页(1 起)
+ * @param {number} totalPages 总页数
+ * @returns {Array<number|string>} 页码序列(数字或 PAGE_ELLIPSIS)
+ */
+export function pageWindow(currentPage, totalPages) {
+    const total = Number.isFinite(totalPages) && totalPages >= 1 ? Math.trunc(totalPages) : 1
+    const raw = Number(currentPage)
+    const current = Math.min(Math.max(1, Number.isFinite(raw) ? Math.trunc(raw) : 1), total)
+    if (total <= 7) {
+        return Array.from({ length: total }, (_, index) => index + 1)
+    }
+    const windowStart = Math.max(2, current - 1)
+    const windowEnd = Math.min(total - 1, current + 1)
+    const items = [1]
+    if (windowStart > 2) items.push(PAGE_ELLIPSIS)
+    for (let page = windowStart; page <= windowEnd; page += 1) items.push(page)
+    if (windowEnd < total - 1) items.push(PAGE_ELLIPSIS)
+    items.push(total)
+    return items
+}
+
+/**
+ * 跳页输入夹回(规范「跳页」节,2026-10-19 维护者追加):
+ * 非数字、0、超出 pageCount 一律夹回 1..pageCount,不报错。
+ * @param {number|string} value 用户输入(按字符串或数字都认)
+ * @param {number} pageCount 总页数
+ */
+export function clampPage(value, pageCount) {
+    const limit = Number.isFinite(pageCount) && pageCount >= 1 ? Math.trunc(pageCount) : 1
+    const num = Number(String(value === undefined || value === null ? '' : value).trim())
+    if (!Number.isFinite(num)) return 1
+    return Math.min(Math.max(1, Math.trunc(num)), limit)
+}
+
+/**
+ * 页码状态的**归属键**(票 64):同一面板内按「块」分槽,两块不共用一个页码。
+ *
+ * 键**只用块归属,不把筛选条件编进去**。把条件编进键(第一版做法)会让「切走再切回」
+ * 读到上次留下的旧槽位 —— 燃气看到第 3 页 → 切排水 → 切回燃气又回到第 3 页,
+ * 而条文 6 要求「任何条件变化 → 页码回第 1  页」,切回来也是一次条件变化。
+ * 复位走 `resetListPages` 显式清槽(票 46/59「换设备清页码」的同一条成例):
+ * 到第 1 页只有一条路,读侧的默认值不会被旧槽位顶掉。
+ * 脏输入收成空串段,不造出 `undefined` 这种会漂移的假键。
+ *
+ * @param {string} blockKey 归属标识(渲染层给:面板名 + 块标题)
+ */
+export function listPageKey(blockKey) {
+    const owner = typeof blockKey === 'string' ? blockKey : ''
+    return `list@${owner}`
+}
+
+/**
+ * 读某一块当前生效的页码(键不存在=这块还没翻过 ⇒ 第 1 页)。
+ * 脏值收成 1,绝不把 NaN 漏给 `pagedRows`(那会把控件画成「第 NaN 页」)。
+ * @param {object} store 渲染层持有的页码表(`{ [listPageKey]: 页码 }`)
+ */
+export function readListPage(store, blockKey) {
+    const table = store && typeof store === 'object' ? store : {}
+    const value = table[listPageKey(blockKey)]
+    const num = Number(value)
+    return Number.isFinite(num) && num >= 1 ? Math.trunc(num) : 1
+}
+
+/**
+ * 写页码(返回新表,不改入参——渲染层按 Vue 的替换语义整体赋值)。
+ * 与 `readListPage` 共用同一个键函数:读写键不一致就会造出「该复位的没复位、
+ * 不该复位的复位了」这类最难查的漂移,所以寻址也只允许有一份。
+ * 越界**不在这里夹**(条文 6:夹回是渲染窗口的事,统一由 `pagedRows` 兜底)。
+ */
+export function writeListPage(store, blockKey, target) {
+    const table = store && typeof store === 'object' ? store : {}
+    return { ...table, [listPageKey(blockKey)]: Number(target) || 1 }
+}
+
+/**
+ * 筛选条件变化时清掉这些块的页码槽(条文 5/6 的复位半边),返回新表。
+ *
+ * 为什么是「清槽」而不是「把页码写成 1」:清槽与「这块还没翻过」是同一个状态,
+ * 读侧只剩一条默认路径;写成 1 就有两条路都能到第 1 页,将来哪条先坏看不出来。
+ * 只清点名的块,别的面板/块的页码不受牵连。
+ * @param {object} store 当前页码表
+ * @param {Array<string>} blockKeys 要复位的块归属键
+ */
+export function resetListPages(store, blockKeys) {
+    const table = store && typeof store === 'object' ? store : {}
+    const keys = new Set((Array.isArray(blockKeys) ? blockKeys : []).map((blockKey) => listPageKey(blockKey)))
+    if (!keys.size) return table
+    const next = {}
+    for (const [key, value] of Object.entries(table)) {
+        if (!keys.has(key)) next[key] = value
+    }
+    return next
+}