Explorar el Código

feat(cockpit): collapse all panel-top summaries behind click-only disclosure (#56)

票 56 三轮收口:顶部说明(industryNote/typeNote/eventNote/rangeNote/gap/offlineNote/pageNote/locateNote/excludedNote/linkNote/note 共 11 个视图级字段)统一折叠为紧凑摘要入口,只有点击(原生 button,Enter/Space/触屏同走)展开收起;撤销一轮的 hover/focus 自动展开。含被点名的总体概览-运行监测故障口径 gap,并接上原本在渲染层无消费点而隐形的 note/linkNote/excludedNote。

每条保留原 data-* 断言面,v-show 折叠文本仍在 DOM;措辞与有无全部由 domain 单源,渲染层不重写、不认识 panelId。折叠态读数区分 有离线设备/有口径提示/N 条说明。

验证:t56 闸门 32/32(C 组为行为层双向完备性普查,真实注册表 15 面板)+ 变异 9/9(含 M8 改名绕表 / M9 新增不进表)+ SFC 编译;t20 66/66、t15/t27/t38/t43/t44 等回归全绿。按 AGENTS.md 跳过 build/dev/browser。

限定提交:工作树混有并行票在制品,本提交经临时 index 的 plumbing 只摘本票内容(PanelBlocks.vue 的票 56 hunks + map.md 本票两行 + 票面/闸门/探针),未触碰他人暂存区与工作树。
ChenJZ hace 1 día
padre
commit
6deb47408c

+ 73 - 0
.scratch/cockpit-real-data-wayfinding/issues/56-untitled.md

@@ -0,0 +1,73 @@
+<!-- DSH-IDEMPOTENCY-KEY: monitoring-feedback-operation-summary-disclosure-v1 -->
+# 前端执行票 —— 运行概况顶部摘要折叠消息交互
+
+Label: wayfinder:task
+Category: monitoring-feedback
+
+## Question
+
+运行概况顶部的统计窗口、离线摘要等纯文字信息占用过高。需要把它们收成紧凑的摘要消息入口,默认折叠;hover 与点击可以查看完整详情,键盘焦点与触屏点击均可用。折叠只改变信息呈现,不改变真实数据、状态词、失败态与列表分页。
+
+## Acceptance
+
+- 默认状态明显比当前纯文字块紧凑,且保留可读的摘要标题/状态。
+- hover 可查看详情,点击可固定展开;离开 hover 不应覆盖点击固定状态。
+- 键盘 focus/Enter/Space 和触屏 click 均可用,具备可访问名称与状态。
+- 真实数据、异常统计、时间窗口、离线设备摘要和失败态不丢失。
+- build/dev/browser 按 AGENTS.md 默认跳过,除非维护者明确授权。
+
+## Context
+
+相关文件:`domain/monitoringPanels.js`、`components/PanelBlocks.vue`、`EventListPane.vue` 的紧凑事件卡交互;相关已关闭票:40、44。
+
+## Related spec
+
+[`../specs/2026-10-17-monitoring-feedback.md`](../specs/2026-10-17-monitoring-feedback.md)
+
+## 进度
+
+<!-- 做到哪一步了,在这里一行一行记 -->
+
+Status: resolved
+Type: task
+Blocked by:
+Labels: wayfinder:task
+
+## Comments
+
+
+### local — 2026-10-19T05:44:50.719Z
+第二轮(维护者 2026-10-18 两条反馈)已实施:① 撤销 hover/focus 自动展开,只留点击 toggle(原生 button 覆盖 Enter/Space/触屏,删掉了会双触发的显式 keydown 处理器);② 折叠推广到所有面板顶部说明,含点名的总体概览-运行监测(其 gap=MONITORING_FAULT_GAP 原为头部外裸段落,一并收进)。八个 *Note/gap 字段单表声明,保留原 data-* 断言面,v-show 折叠信息不丢。t56 26/26 + 变异 8/8 + SFC 编译;t55/t20/t15/t27/t38/t39/t40/t43/t44/t50/t53/t54 回归全绿。t17/t18 导入期既有红与 t05 MapCanvas 既有红零交集。按 AGENTS.md 跳过 build/dev/browser。改动未提交。
+
+
+### local — 2026-10-19(第三轮)
+维护者第三条反馈「故障口径说明也包含到摘要里去」:故障口径本身已在第二轮的 gap 条目里,但据此做完备性普查时发现**折叠表本身不完备**——真实注册表(15 个面板)的视图级说明字段并集是 11 个,表里只有 8 个。漏掉的 note / linkNote / excludedNote 在渲染层**从来没有消费点**,即「预警设置两族口径」「态势总览与基础设施的联动出口」「基础设施口径剔除」三句话在界面上是隐形的(既有缺陷,非折叠引入;excludedNote 正是票 17/20「口径剔除要看得见」的立场,落盘时只写了 domain)。本轮补齐三条 + 把完备性做成机制(行为层双向普查,不靠正则扫源码、不靠人记得去查),并限定提交入库。
+
+
+## Answer
+
+**第一轮(已实施,后被第二轮裁定覆盖)**:为运行概况顶部摘要增加紧凑 disclosure 入口,hover / focus 临时展开、click 固定展开。
+
+**第二轮(维护者 2026-10-17 两条反馈)**:
+
+1. **撤销 hover 自动展开**——大屏上鼠标掠过就弹开等于没有折叠。现在只有一份状态 `summaryOpen`,初值 `false`,唯一 toggle 路径是原生 `<button>` 的 click(Enter / Space / 触屏点击都走同一条),组件里不再有 `summaryHover` / `summaryFocus` / `summaryPinned`。`aria-expanded` 绑定同一状态,`aria-controls` 按面板名唯一(多面板共存不串 target)。
+2. **推广到所有面板**,含被点名的「总体概览-运行监测」(`monitoringSummary`):该面板原先把契约缺口 `gap`(`MONITORING_FAULT_GAP`)作为头部外的裸 `<p>` 常驻占高,现与其他说明一并折叠。折叠条目由 `SUMMARY_SOURCES` 单表声明;旧的独立渲染全部删除(变异 M6 证实「同一句话画两遍」会被抓住)。没有摘要的面板连头部都不画。
+
+**第三轮(维护者 2026-10-19「故障口径说明也包含到摘要里去」,最终形态)**:
+
+故障口径**已经在**表里(`gap` 条目 → `monitoringSummary` 的 `MONITORING_FAULT_GAP`)。按这条反馈核验时做了完备性普查,暴露折叠表本身不完备:用真实注册表收集 15 个面板的视图级说明字段,并集 **11 个**,表里只有 **8 个**。漏掉的 `note` / `linkNote` / `excludedNote` 在渲染层**从来没有消费点**——「预警设置的两族口径说明」「态势总览与基础设施的联动出口说明」「基础设施的口径剔除」这些必须让值班人员看见的话,实际是**隐形的**。这是既有缺陷(`excludedNote` 正是票 17/20 立的「口径剔除要看得见」立场,落盘时只写了 domain、没写渲染层),本票把它一起接上。
+
+**把完备性做成机制**(同票 44 对普查、票 49/50 对定位入口的立场,不靠人记得去查):
+
+- [t56 闸门](../verify/t56-panel-summary-disclosure.mjs) C 组=**行为层双向普查**:用 `panelView()` 真算 15 个面板视图,「domain 声明的说明字段 ⊆ 折叠表」与「折叠表内字段仍有面板声明」两个方向都锁死;表外新增判红(会在界面隐形),表内无声明判红(死接线早晚漂移)。
+- 判据取**真实视图对象**而不是正则扫源码——正则会被换缩进/换行写法静默绕过(票 43 的「守卫比要守的东西窄就等于没守」)。变异 M8(把 `gap` 改名成表外字段名)与 M9(domain 新增说明字段不进表)实测都能被当场抓住。
+- 覆盖度哨兵:普查必须真的见到 ≥12 个面板视图、并集含 `gap`/`note`/`linkNote`/`excludedNote`/`offlineNote`/`pageNote`,防止守卫自己被写窄。
+
+**不丢信息的机制**:折叠用 `v-show`(不是 `v-if`),文本始终在 DOM;每条**保留原有 `data-*` 选择器**(另加 `data-summary="<键>"`),既有走查脚本 t17/t18/t20 零改动、读的还是同一句 domain 文案。措辞与有无全部由 domain 决定,渲染层不重写、不合成、不按面板名分支(票 18 解耦与票 05 的裸字段守卫仍然成立)。折叠态读数不谎报:有离线显示「有离线设备」,`gap || note || excludedNote` 任一存在显示「有口径提示」(三类口径说明同档,不得在折叠态被读成「一切正常」),否则显示条数。布局 `flex: 1 1 100%` 独占头部一行,详情区 `overflow-wrap: anywhere`,等宽 ISO 时间串不顶出横向溢出(票 13 的「右侧被裁」同类缺陷)。
+
+**验证(第三轮终态)**:t56 闸门 **32/32** + [变异闸](../verify/t56-mutations.mjs) **9/9** 打红并按字节还原(M1 hover 复活 / M2 漏条目 / M3 改 v-if / M4 默认展开 / M5 aria 写死 / M6 gap 裸渲染 / M7 撤下 note 条目 / M8 故障口径改挂表外字段名 / M9 domain 新增说明字段不进表)+ [SFC 编译自检](../verify/t56-sfc-check.mjs)。回归全绿:t55 5/5、t20 66/66、t15 11/11、t27、t38 68、t39 24、t40 16、t43 23、t44 109、t50 30、t53 29、t54 24;t05 的 PanelBlocks 三条守卫绿。普查证据留在 [verify/artifacts/t56/](../verify/artifacts/t56/)(字段并集与缩进覆盖探针)。
+
+**如实记录既有红(与本票零交集,未触碰)**:t17/t18 导入期即 `SyntaxError`(`infrastructurePanels.js` 缺 `MANAGEMENT_REGION_GAP`、`filterOptions.js` 缺 `REGION_MAP_GAP` 导出,票 50/51 已留档);t05 的 282/283 唯一红是 `MapCanvas.vue` 裸字段(并行在制品改动)。
+
+**入库**:按 AGENTS.md 跳过 build/dev/browser;本票只动 `components/PanelBlocks.vue`(domain 零改动,说明字段本来就由 domain 单源给出)。工作树混有并行票 39/40/54/55 在制品,故以临时 index 的 plumbing 限定提交,只摘本票 hunks(`map.md` 同样逐行裁剪,只带本票两行;他人暂存区与工作树未动)。
+

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

@@ -71,7 +71,7 @@ Labels: wayfinder:map
 - [ ] [前端执行票 —— 运行概况定位补齐地形高度与画面中心视角](issues/53-untitled.md) — 2026-10-17 维护者反馈 1:定位相机消费地形高程,避免飞到地下并稳定居中
 - [ ] [前端执行票 —— 监测概览数据时间对齐与四统计卡 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
-- [ ] [前端执行票 —— 运行概况顶部摘要折叠消息交互](issues/56-untitled.md) — 2026-10-17 维护者反馈 4:顶部纯文字摘要默认折叠,hover/click 查看详情
+- [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] [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 部署窗口
@@ -82,6 +82,8 @@ Labels: wayfinder:map
 
 ## Decisions so far
 
+- [前端执行票 —— 运行概况顶部摘要折叠消息交互](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,通过后关闭。
 
 - [后端执行票 —— 单设备业务优先级裁定落地(全部报警均有未结束工单时工单处置最高)](issues/52-untitled.md) — **已实施、验证并推送关闭(2026-10-17)**。维护者 5 项裁定后落地:单设备「全部**未处理**报警(`alarm_data.alarm_status=0`)都已由**未结束**工单(`work_order.order_status ∈ {0..4,6}`;5/7 为终态)承接」→ 判 `WORK_ORDER`(高于报警/预警),否则维持现行序;空集/读失败保守降级为 `ALARM`(「查不到承接」≠「已承接」)。只改单设备 `deviceStatus()` 单源,`resolve()` 聚合与枚举 `priority` **不动**,前端零耦合。提交 `3772220e`(+ 既有编译阻塞修复 `5d4d03c6`)已推 `origin/master`;`Cockpit*/BusinessCockpit*` **512/512**、变异闸 **7/7**(含两处事实透传接缝)、前端 t09 **38/38**。**两点如实留档**:① 票面「实测 21119740 应为工单处置」被真实数据证伪——该设备 2 条未处理报警均**无**工单承接,按裁定就该判 `ALARM`(票面把设备级 `workOrderInProgress` 误当成「未处理报警已逐条承接」);② 新规则在当前真实数据上**不改变任何设备读数**(今天不存在「未处理且已承接」的报警行),属正确行为而非未生效。`zksy-system` 全量另有 2 失败/1 错误为 HEAD 级既有(stash 对照逐条相同)。

+ 15 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t56/probe-indent-coverage.mjs

@@ -0,0 +1,15 @@
+// 只读探针:普查四个 domain 文件里所有「说明类」字段的声明位置与缩进。
+import fs from 'node:fs'
+
+const ROOT = 'E:/City-Lifeline-Cockpit-TvT/src/modules/cockpit/domain/'
+const FILES = ['monitoringPanels.js', 'overviewPanels.js', 'infrastructurePanels.js', 'warningResponsePanels.js']
+const RE = /^(\s*)(industryNote|typeNote|eventNote|rangeNote|offlineNote|pageNote|locateNote|excludedNote|linkNote|note|gap)\s*:/
+
+for (const file of FILES) {
+    const lines = fs.readFileSync(ROOT + file, 'utf8').split(/\r?\n/)
+    lines.forEach((line, i) => {
+        const m = line.match(RE)
+        if (!m) return
+        console.log(`${file}:${String(i + 1).padEnd(4)} indent=${m[1].length}  ${line.trim().slice(0, 58)}`)
+    })
+}

+ 43 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t56/probe-note-fields.mjs

@@ -0,0 +1,43 @@
+// 只读探针:逐面板列出视图对象里所有「说明类」字段,确认哪些进了摘要、哪些被渲染层丢掉。
+import {
+    monitoringOverviewPanel,
+    monitoringDistributionPanel,
+    operationOverviewPanel,
+    monitoringPointLedgerPanel,
+} from '../../../../../src/modules/cockpit/domain/monitoringPanels.js'
+import {
+    situationSummaryPanel,
+    infrastructureSummaryPanel,
+    monitoringSummaryPanel,
+    alertSettingsPanel,
+} from '../../../../../src/modules/cockpit/domain/overviewPanels.js'
+import {
+    archivePanel,
+    emergencyPanel,
+    managementPanel,
+    operationPanel,
+} from '../../../../../src/modules/cockpit/domain/infrastructurePanels.js'
+
+const NOTE_FIELDS = ['industryNote', 'typeNote', 'eventNote', 'rangeNote', 'gap', 'offlineNote', 'pageNote', 'locateNote', 'note', 'linkNote', 'excludedNote', 'stateText']
+const cases = [
+    ['monitoringOverview', () => monitoringOverviewPanel({}, null)],
+    ['monitoringDistribution', () => monitoringDistributionPanel({}, null)],
+    ['operationOverview', () => operationOverviewPanel({}, null)],
+    ['monitoringPointLedger', () => monitoringPointLedgerPanel({}, null)],
+    ['situationSummary', () => situationSummaryPanel({}, null)],
+    ['infrastructureSummary', () => infrastructureSummaryPanel({}, null)],
+    ['monitoringSummary', () => monitoringSummaryPanel({}, null)],
+    ['alertSettings', () => alertSettingsPanel({}, null)],
+    ['archive', () => archivePanel({}, null)],
+    ['emergency', () => emergencyPanel({}, null)],
+    ['management', () => managementPanel({}, null)],
+    ['operation(infra)', () => operationPanel({}, null)],
+]
+
+for (const [id, build] of cases) {
+    let view
+    try { view = build() } catch (e) { console.log(`${id.padEnd(24)} BUILD-ERROR ${e.message}`); continue }
+    const present = NOTE_FIELDS.filter((f) => typeof view?.[f] === 'string' && view[f])
+    console.log(`${id.padEnd(24)} ${present.length ? present.join(', ') : '(none)'}   [label=${view?.label ?? ''}]`)
+    for (const f of present) console.log(`      ${f} → ${String(view[f]).slice(0, 70)}…`.replace('…', view[f].length > 70 ? '…' : ''))
+}

+ 20 - 0
.scratch/cockpit-real-data-wayfinding/verify/artifacts/t56/probe-registry-notes.mjs

@@ -0,0 +1,20 @@
+// 只读探针:用真实注册表逐面板收集「视图级说明字段」,验证行为层普查可行且不为空。
+import { implementedPanelIds, panelView } from '../../../../../src/modules/cockpit/domain/panelViews.js'
+
+const ids = implementedPanelIds()
+console.log(`implemented panels: ${ids.length} → ${ids.join(', ')}`)
+const union = new Set()
+for (const id of ids) {
+    let view
+    try {
+        view = panelView(id, {}, null, undefined)
+    } catch (e) {
+        console.log(`  BUILD-ERROR ${id}: ${e.message}`)
+        continue
+    }
+    if (!view) { console.log(`  NULL-VIEW ${id}`); continue }
+    const notes = Object.keys(view).filter((k) => /Note$/.test(k) || k === 'gap' || k === 'note')
+    notes.forEach((k) => union.add(k))
+    console.log(`  ${id.padEnd(24)} ${notes.join(', ') || '(none)'}`)
+}
+console.log(`union: ${[...union].sort().join(', ')}`)

+ 115 - 0
.scratch/cockpit-real-data-wayfinding/verify/t56-mutations.mjs

@@ -0,0 +1,115 @@
+/**
+ * 票 56 变异闸(第二/三轮):把本票要防的错法逐一注入,确认 t56 真的一致红;
+ * 每条变异后按字节还原并校验哈希。判据只看退出码(不管道捕获子进程输出,票 37 的沙箱教训)。
+ *
+ *   node .scratch/cockpit-real-data-wayfinding/verify/t56-mutations.mjs
+ */
+import { spawnSync } from 'node:child_process'
+import { readFileSync, writeFileSync } from 'node:fs'
+import { createHash } from 'node:crypto'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = fileURLToPath(new URL('../../../', import.meta.url))
+const GATE = '.scratch/cockpit-real-data-wayfinding/verify/t56-panel-summary-disclosure.mjs'
+const RENDERER = 'src/modules/cockpit/components/PanelBlocks.vue'
+const OVERVIEW = 'src/modules/cockpit/domain/overviewPanels.js'
+
+const MUTATIONS = [
+    {
+        id: 'M1',
+        file: RENDERER,
+        from: 'data-panel-summary-toggle',
+        to: 'data-panel-summary-toggle @mouseenter="summaryOpen = true"',
+        why: 'hover 自动展开被复活(第二轮维护者明令撤销)→ A3 必须红',
+    },
+    {
+        id: 'M2',
+        file: RENDERER,
+        from: "    { key: 'locate', field: 'locateNote', hook: 'data-locate-note' },\n",
+        to: '',
+        why: '摘要条目被漏掉一条(locateNote 从折叠清单消失=说明丢失)→ B 组必须红',
+    },
+    {
+        id: 'M3',
+        file: RENDERER,
+        from: '<span v-show="summaryOpen"',
+        to: '<span v-if="summaryOpen"',
+        why: '折叠改成 v-if(DOM 里读不到文本,走查脚本与读屏都拿不到原话)→ E 组必须红',
+    },
+    {
+        id: 'M4',
+        file: RENDERER,
+        from: 'const summaryOpen = ref(false)',
+        to: 'const summaryOpen = ref(true)',
+        why: '默认展开(顶部照样占高,验收第 1 条落空)→ A1 必须红',
+    },
+    {
+        id: 'M5',
+        file: RENDERER,
+        from: ':aria-expanded="summaryOpen"',
+        to: ':aria-expanded="true"',
+        why: '可访问状态写死(读屏永远报"已展开",与屏上不一致)→ A4 必须红',
+    },
+    {
+        id: 'M6',
+        file: RENDERER,
+        from: '        <!-- 还没取过数:说"正在取数"',
+        to: '        <p v-if="view.gap" class="panel-blocks__gap" data-panel-gap>{{ view.gap }}</p>\n        <!-- 还没取过数:说"正在取数"',
+        why: 'gap 又在折叠入口之外裸渲染一份(同一句话画两遍、折叠形同虚设)→ E24 必须红',
+    },
+    {
+        id: 'M7',
+        file: RENDERER,
+        from: "    { key: 'note', field: 'note', hook: 'data-panel-note' },\n",
+        to: '',
+        why: '撤下第三轮补的 note 条目(预警设置两族口径说明又变隐形)→ A17 + C18 双向普查必须红',
+    },
+    {
+        id: 'M8',
+        file: OVERVIEW,
+        from: '        gap: MONITORING_FAULT_GAP,',
+        to: '        gapNotShown: MONITORING_FAULT_GAP,',
+        why: '故障口径改挂到表外字段名(值仍在 domain、渲染层拿不到=说明丢失)→ C 组漏网判红 + D 组行为判红',
+    },
+    {
+        id: 'M9',
+        file: OVERVIEW,
+        from: '        gap: MONITORING_FAULT_GAP,',
+        to: "        gap: MONITORING_FAULT_GAP,\n        futureNote: '新加的口径说明',",
+        why: 'domain 新增视图级说明字段而不进折叠表(这正是"靠人记得去查"会漏的场景)→ C 组双向普查必须红',
+    },
+]
+
+const hash = (path) => createHash('sha256').update(readFileSync(path)).digest('hex')
+
+/** 变异源文本按 CRLF 归一后再注入(票 37 的教训:锚点含 \n 而源文件是 CRLF 会假未命中)。 */
+const normalizeFor = (text, path) => (readFileSync(path, 'utf8').includes('\r\n') ? text.replace(/\n/g, '\r\n') : text)
+
+let failures = 0
+for (const m of MUTATIONS) {
+    const path = ROOT + m.file.replaceAll('/', process.platform === 'win32' ? '\\' : '/')
+    const before = hash(path)
+    const source = readFileSync(path, 'utf8')
+    const needle = normalizeFor(m.from, path)
+    if (!source.includes(needle)) {
+        console.log(`BAD  ${m.id}: 变异锚点未命中(源码已变,需更新脚本)`)
+        failures += 1
+        continue
+    }
+    writeFileSync(path, source.replace(needle, normalizeFor(m.to, path)))
+    const caught = spawnSync(process.execPath, [GATE], { cwd: ROOT, stdio: 'ignore' }).status !== 0
+    writeFileSync(path, source)
+    const restored = hash(path) === before
+    if (!caught) {
+        console.log(`MISS ${m.id}: 注入后闸门仍全绿 → 守卫没有牙(${m.why})`)
+        failures += 1
+    } else if (!restored) {
+        console.log(`BAD  ${m.id}: 还原后哈希不一致,文件状态可疑,立刻人工核对`)
+        failures += 1
+    } else {
+        console.log(`OK   ${m.id} ${m.why} → 闸门转红;还原按字节一致`)
+    }
+}
+
+console.log(`\nT56 MUTATIONS: ${MUTATIONS.length - failures}/${MUTATIONS.length} 全部被抓住`)
+process.exitCode = failures ? 1 : 0

+ 111 - 0
.scratch/cockpit-real-data-wayfinding/verify/t56-panel-summary-disclosure.mjs

@@ -0,0 +1,111 @@
+// 票 56(第二/三轮):所有面板顶部摘要统一折叠,**只有点击**展开/收起。
+// 离线闸门:静态断言渲染层 + 用真实注册表做说明字段的**行为层双向完备性普查**。
+import fs from 'node:fs'
+import assert from 'node:assert/strict'
+import { MONITORING_FAULT_GAP, monitoringSummaryPanel } from '../../../src/modules/cockpit/domain/overviewPanels.js'
+import { implementedPanelIds, panelView } from '../../../src/modules/cockpit/domain/panelViews.js'
+
+const ROOT = new URL('../../../', import.meta.url)
+const panelBlocks = fs.readFileSync(new URL('src/modules/cockpit/components/PanelBlocks.vue', ROOT), 'utf8')
+const monitoringPanels = fs.readFileSync(new URL('src/modules/cockpit/domain/monitoringPanels.js', ROOT), 'utf8')
+const overviewPanels = fs.readFileSync(new URL('src/modules/cockpit/domain/overviewPanels.js', ROOT), 'utf8')
+
+/**
+ * 把每个已实现面板的视图真的算一遍,收集「视图级说明字段」(`*Note` / `note` / `gap`)。
+ * 走注册表而不是正则扫源码:正则会被换缩进/换行写法静默绕过(票 43 的"守卫比要守的宽"教训)。
+ */
+function panelViewRegistryProbe() {
+    const found = new Set()
+    const built = []
+    for (const panelId of implementedPanelIds()) {
+        let view = null
+        try {
+            view = panelView(panelId, {}, null, undefined)
+        } catch {
+            view = null
+        }
+        if (!view) continue
+        built.push(panelId)
+        for (const key of Object.keys(view)) {
+            if (key === 'gap' || key === 'note' || /Note$/.test(key)) found.add(key)
+        }
+    }
+    return { fields: [...found], built }
+}
+
+const registry = panelViewRegistryProbe()
+
+let n = 0
+const ok = (cond, msg) => { assert.ok(cond, `A${++n} ${msg}`); console.log(`PASS A${n} ${msg}`) }
+
+// A 组:折叠入口只有点击一条路径
+ok(/summaryOpen = ref\(false\)/.test(panelBlocks), '默认折叠(summaryOpen 初值 false)')
+ok(/@click="summaryOpen = !summaryOpen"/.test(panelBlocks), '点击是唯一 toggle 路径')
+ok(!/summaryHover|summaryFocus|summaryPinned|@mouseenter="summary|@focusin="summary/.test(panelBlocks), 'hover/focus 自动展开已撤销(第二轮维护者裁定)')
+ok(/:aria-expanded="summaryOpen"/.test(panelBlocks), 'aria-expanded 绑定同一个状态')
+ok(/:aria-controls="summaryDetailsId"/.test(panelBlocks) && /panel-summary-details-\$\{props\.view\.name/.test(panelBlocks), 'aria-controls 按面板名唯一(多面板共存不串 target)')
+ok(!/@keydown\.(enter|space)/.test(panelBlocks), '不写显式 keydown:原生 button 的 Enter/Space 已触发 click,重复绑定会双触发互相抵消')
+
+// B 组:摘要条目完备 —— 每条保留原 data-* 断言面
+const EXPECTED = [
+    ['industryNote', 'data-panel-industry'],
+    ['typeNote', 'data-panel-type-note'],
+    ['eventNote', 'data-panel-event-note'],
+    ['rangeNote', 'data-panel-range-note'],
+    ['gap', 'data-panel-gap'],
+    ['offlineNote', 'data-panel-offline-note'],
+    ['pageNote', 'data-panel-page-note'],
+    ['locateNote', 'data-locate-note'],
+    ['excludedNote', 'data-panel-excluded-note'],
+    ['linkNote', 'data-panel-link-note'],
+    ['note', 'data-panel-note'],
+]
+for (const [field, hook] of EXPECTED) {
+    ok(
+        panelBlocks.includes(`field: '${field}', hook: '${hook}'`),
+        `摘要条目 ${field} 折叠并保留 ${hook}(走查脚本 t17/t18/t20 选择器不变)`,
+    )
+}
+
+// C 组:**行为层双向完备性普查**(t44 的机制,不靠人记得去查,也不靠正则匹缩进)。
+// 用真实注册表算出全部 15 个面板的视图,收集「视图级说明字段」的并集:
+//   · domain 新增了说明字段而不进折叠表 → 漏网判红(它会在界面上隐形);
+//   · 折叠表里有字段而任何面板都不再声明 → 死接线判红(无消费点的接线早晚漂移)。
+const declared = new Set(registry.fields)
+const tabled = new Set(EXPECTED.map(([field]) => field))
+const missing = [...declared].filter((f) => !tabled.has(f))
+ok(missing.length === 0, `domain 视图声明的说明字段全部进折叠表(漏网:${missing.join(', ') || '无'})`)
+const dead = [...tabled].filter((f) => !declared.has(f))
+ok(dead.length === 0, `折叠表内字段仍有面板在声明(死接线:${dead.join(', ') || '无'})`)
+ok(
+    ['gap', 'note', 'linkNote', 'excludedNote', 'offlineNote', 'pageNote'].every((f) => declared.has(f)),
+    `普查真的见到这六类(当前并集 ${declared.size} 条,守卫面没被写窄)`,
+)
+ok(
+    registry.built.length >= 12,
+    `普查覆盖到 12+ 个面板视图(实际 ${registry.built.length} 个:${registry.built.join(', ')})`,
+)
+
+// D 组:用户点名的「故障口径」真的落到运行监测面板的摘要里(行为层,不是正则)
+{
+    const view = monitoringSummaryPanel({}, null)
+    ok(view.gap === MONITORING_FAULT_GAP, '运行监测视图仍由 domain 单源给出故障口径 gap')
+    ok(view.gap.includes('故障'), '故障口径文案未被动过')
+    ok(tabled.has('gap'), '故障口径经折叠表的 gap 条目进入摘要(第三轮诉求)')
+}
+
+// E 组:旧的独立渲染必须清零(否则折叠形同虚设:同一句话画两遍或仍在头部外裸奔)
+ok(!/v-if="view\.(gap|offlineNote|pageNote|rangeNote|industryNote|typeNote|eventNote|locateNote|note|linkNote|excludedNote)\b/.test(panelBlocks), '没有任何说明字段在折叠入口之外独立渲染')
+ok(/v-show="summaryOpen"/.test(panelBlocks), '折叠用 v-show:文本仍在 DOM(信息不丢,只改呈现)')
+ok(/<header v-if="summaryItems\.length"/.test(panelBlocks), '没有摘要的面板不画空头部')
+
+// F 组:折叠态读数不误导
+ok(/有离线设备/.test(panelBlocks) && /当前窗口没有离线设备/.test(panelBlocks), '离线摘要在折叠态仍区分「有离线」与「无离线」')
+ok(/view\.gap \|\| view\.note \|\| view\.excludedNote/.test(panelBlocks), '三类口径说明(gap/note/excludedNote)都计入「有口径提示」')
+
+// G 组:domain 数据与措辞零改动(折叠只归渲染层)
+ok(/rangeNote: current\.rangeStart && current\.rangeEnd/.test(monitoringPanels), '运行概况统计窗口措辞仍由 domain 单源')
+ok(/offlineNote: offlineDevices\.length/.test(monitoringPanels), '离线设备摘要仍由 domain 从异常清单挑选,不推断')
+ok(/gap: MONITORING_FAULT_GAP/.test(overviewPanels), '总体概览-运行监测的故障口径缺口仍是 MONITORING_FAULT_GAP 单源')
+
+console.log(`t56 panel summary disclosure: ${n}/${n} passed`)

+ 17 - 0
.scratch/cockpit-real-data-wayfinding/verify/t56-sfc-check.mjs

@@ -0,0 +1,17 @@
+// 票 56(第二轮):PanelBlocks.vue SFC 编译自检(t48-sfc-check 同款,只查本票改动文件)。
+import { readFileSync } from 'node:fs'
+import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc'
+const ROOT = new URL('../../../', import.meta.url)
+const rel = 'src/modules/cockpit/components/PanelBlocks.vue'
+const source = readFileSync(new URL(rel, ROOT), 'utf8')
+const { descriptor, errors } = parse(source, { filename: rel })
+if (errors.length) { console.log(`FAIL parse — ${errors[0].message}`); process.exit(1) }
+try {
+    compileScript(descriptor, { id: rel })
+    const tpl = compileTemplate({ source: descriptor.template.content, filename: rel, id: rel })
+    if (tpl.errors.length) throw new Error(tpl.errors[0])
+    console.log('PASS compile PanelBlocks.vue')
+} catch (error) {
+    console.log(`FAIL compile — ${error.message}`)
+    process.exit(1)
+}

+ 137 - 18
src/modules/cockpit/components/PanelBlocks.vue

@@ -24,26 +24,49 @@
   `data-locate-point` 是可定位对象,`data-ledger-card` 是台账卡片,
   `data-pie-*` 是状态分布饼图(票 14:扇区/图例行/选中态;面板不再自带筛选入口),
   `data-panel-block` 是块类型,
-  `data-panel-gap` 是契约缺口说明。
+  票 56(二/三轮):顶部口径说明统一折叠进 `data-panel-summary-toggle` /
+  `data-panel-summary-details`,每条**保留自己原有的断言面**(折叠只是 `display:none`,
+  文本仍在 DOM 里):`data-panel-gap` 契约缺口、`data-panel-note` 面板口径说明、
+  `data-panel-link-note` 联动出口、`data-panel-excluded-note` 口径剔除、
+  `data-panel-industry` / `data-panel-type-note` / `data-panel-event-note` /
+  `data-panel-range-note` / `data-panel-offline-note` / `data-panel-page-note` /
+  `data-locate-note`,另带 `data-summary="<键>"` 供按条目定位。
 -->
 <template>
     <div ref="rootRef" class="panel-blocks" :data-panel-blocks="view.name" :data-panel-id="view.name">
-        <!-- 面板级状态(色键与文案只来自 domain/sectionState.js) -->
-        <header class="panel-blocks__head">
-            <span v-if="view.industryNote" class="panel-blocks__note" data-panel-industry>{{ view.industryNote }}</span>
-            <!-- 票 37:生效的设备类型(全局筛选条件之一)必须看得见,否则读者只看到"类型分布少了几行" -->
-            <span v-if="view.typeNote" class="panel-blocks__note" data-panel-type-note>{{ view.typeNote }}</span>
-            <span v-if="view.eventNote" class="panel-blocks__note" data-panel-event-note>{{ view.eventNote }}</span>
-            <span v-if="view.rangeNote" class="panel-blocks__note" data-panel-range-note>{{ view.rangeNote }}</span>
+        <!-- 面板级状态(色键与文案只来自 domain/sectionState.js)。票 56 二轮:整条收成折叠入口。 -->
+        <header v-if="summaryItems.length" class="panel-blocks__head">
+            <!--
+              票 56(第二轮,维护者 2026-10-17):**所有面板**的顶部摘要说明统一收成
+              一个紧凑入口,且**只有点击**才展开/收起(撤销第一轮的 hover 自动展开——
+              大屏上鼠标掠过就弹开,等于没有折叠)。键盘与触屏走原生 button:
+              Enter / Space / click 都会触发同一个 toggle,不必另写 keydown。
+              每条摘要**保留自己原有的 `data-*` 断言面**(折叠时 `v-show` 只是隐藏,
+              文本仍在 DOM 里),走查脚本读到的是同一句 domain 文案。
+            -->
+            <span class="panel-summary-disclosure">
+                <button
+                    type="button"
+                    class="panel-summary-disclosure__toggle"
+                    :aria-expanded="summaryOpen"
+                    :aria-controls="summaryDetailsId"
+                    :aria-label="`${view.label || '面板'}摘要,${summaryOpen ? '收起' : '展开'}完整说明`"
+                    data-panel-summary-toggle
+                    @click="summaryOpen = !summaryOpen"
+                >
+                    <span>{{ view.label || '面板' }}摘要</span><b>{{ summaryStatus }}</b><i aria-hidden="true">{{ summaryOpen ? '收起' : '展开' }}</i>
+                </button>
+                <span v-show="summaryOpen" :id="summaryDetailsId" class="panel-summary-disclosure__details" data-panel-summary-details>
+                    <span
+                        v-for="item in summaryItems"
+                        :key="item.key"
+                        :class="item.isGap ? 'panel-blocks__gap' : 'panel-blocks__note'"
+                        v-bind="item.attrs"
+                    >{{ item.text }}</span>
+                </span>
+            </span>
         </header>
 
-        <!-- 契约缺口:如实说明,不拿别的字段冒充(管理信息的「责任区域」、状态筛选不落图都是这一类) -->
-        <p v-if="view.gap" class="panel-blocks__gap" data-panel-gap>{{ view.gap }}</p>
-
-        <!-- 面板自己的附加说明(离线设备清单、分页口径),措辞来自 domain -->
-        <p v-if="view.offlineNote" class="panel-blocks__note" data-panel-offline-note>{{ view.offlineNote }}</p>
-        <p v-if="view.pageNote" class="panel-blocks__note" data-panel-page-note>{{ view.pageNote }}</p>
-
         <!-- 还没取过数:说"正在取数",不说"没有数据"(那是在报告一件还没问过的事) -->
         <p v-if="pending" class="panel-blocks__placeholder" data-panel-pending>{{ view.placeholder }}</p>
 
@@ -527,9 +550,6 @@
                 加载下一页监测点
             </button>
 
-            <!-- 可定位说明:有可定位对象才说,没有就说清为什么没有 -->
-            <p v-if="view.locateNote" class="panel-blocks__note" data-locate-note>{{ view.locateNote }}</p>
-
             <!-- 字段缺失:只说条数,路径进 tooltip(票 11 的立场) -->
             <p
                 v-if="view.missing.count"
@@ -580,6 +600,59 @@ const emit = defineEmits(['locate', 'open-card', 'ledger-more', 'badge-needed',
 /** 还没取过数(界面说"正在取数",不说"没有数据")。 */
 const pending = computed(() => sectionPending(props.view.section))
 
+// 票 56(第二轮):所有面板顶部的摘要说明统一折叠,**只有点击**才展开/收起。
+// 只有一份状态(`summaryOpen`):hover / focus 都不碰它,所以鼠标掠过不会弹开、
+// 移开也不会把点击展开的内容收掉。展开态是纯展示状态,不进 store(面板私有、即挂即弃)。
+const summaryOpen = ref(false)
+const summaryDetailsId = computed(() => `panel-summary-details-${props.view.name || 'default'}`)
+
+/**
+ * 折叠进去的摘要条目。三条纪律:
+ *   · **措辞与有无都由 domain 决定**,渲染层只按字段是否存在决定画不画,不重写、不合成;
+ *   · 每条**保留原有的 `data-*` 断言面**(走查脚本 t17/t18/t20 读的还是同一个选择器与同一句话)。
+ *     `v-show` 折叠时元素仍在 DOM 里,文本读得到 —— 折叠只改呈现,不改信息;
+ *   · 本表是顶部口径说明的**唯一**消费点([t56 闸门](../../.scratch/cockpit-real-data-wayfinding/verify/t56-panel-summary-disclosure.mjs)
+ *     E 组按 domain 声明双向锁定:domain 新增 `*Note`/`gap`/`note` 而不进这张表 → 判红;表内字段在 domain 已无声明 → 判红)。
+ *
+ * 后三条(`excludedNote` / `linkNote` / `note`)是第三轮补的:它们和前七条是同一件事,
+ * 但渲染层**从来没有消费点**——「故障口径」「两族口径」「联动出口」「口径剔除」这些必须
+ * 让值班人员看见的说明实际是隐形的。补法与票 49/50 的裁定一致:与其逐条发现,
+ * 不如把完备性做成机制。
+ */
+const SUMMARY_SOURCES = [
+    { key: 'industry', field: 'industryNote', hook: 'data-panel-industry' },
+    { key: 'type', field: 'typeNote', hook: 'data-panel-type-note' },
+    { key: 'event', field: 'eventNote', hook: 'data-panel-event-note' },
+    { key: 'range', field: 'rangeNote', hook: 'data-panel-range-note' },
+    { key: 'gap', field: 'gap', hook: 'data-panel-gap', isGap: true },
+    { key: 'offline', field: 'offlineNote', hook: 'data-panel-offline-note' },
+    { key: 'page', field: 'pageNote', hook: 'data-panel-page-note' },
+    { key: 'locate', field: 'locateNote', hook: 'data-locate-note' },
+    { key: 'excluded', field: 'excludedNote', hook: 'data-panel-excluded-note' },
+    { key: 'link', field: 'linkNote', hook: 'data-panel-link-note' },
+    { key: 'note', field: 'note', hook: 'data-panel-note' },
+]
+
+const summaryItems = computed(() =>
+    SUMMARY_SOURCES.flatMap(({ key, field, hook, isGap }) => {
+        const text = props.view[field]
+        if (!text) return []
+        return [{ key, text, isGap: Boolean(isGap), attrs: { [hook]: '', 'data-summary': key } }]
+    }),
+)
+
+/**
+ * 折叠态也要说得出「有没有要关注的东西」——只留标题会把口径缺口读成「一切正常」。
+ * 判据是 domain 的措辞本身(`'当前窗口没有离线设备'` 是 `monitoringPanels.js` 给的无离线句),
+ * 渲染层不自己算任何数。`note` / `gap` / `excludedNote` 同属口径说明一档。
+ */
+const summaryStatus = computed(() => {
+    const view = props.view
+    if (view.offlineNote && view.offlineNote !== '当前窗口没有离线设备') return '有离线设备'
+    if (view.gap || view.note || view.excludedNote) return '有口径提示'
+    return `${summaryItems.value.length} 条说明`
+})
+
 /**
  * 票 38:维度切换的**组件本地状态**(键=面板名 + 块标题,值=当前维度键)。
  * 为什么放组件而不是 store:「换哪个维度看」不是全局条件(与 industry/district/deviceType
@@ -834,6 +907,52 @@ onBeforeUnmount(() => {
 </script>
 
 <style scoped>
+.panel-summary-disclosure {
+    position: relative;
+    /* 头部是 flex-wrap 容器:折叠入口独占一行(`flex-basis: 100%`),长句才能在栏宽内换行,
+       不被 max-content 顶出横向溢出(票 13 的「右侧被裁」是同一类缺陷)。 */
+    flex: 1 1 100%;
+    min-width: 0;
+}
+
+.panel-summary-disclosure__toggle {
+    display: inline-flex;
+    align-items: center;
+    gap: calc(5 * var(--u, 1px));
+    min-height: calc(24 * var(--u, 1px));
+    padding: calc(2 * var(--u, 1px)) calc(7 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.38);
+    border-radius: 3px;
+    background: rgba(0, 20, 72, 0.58);
+    color: var(--text-2, #88b7e8);
+    font: inherit;
+    font-size: var(--fs-caption, 10.5px);
+    cursor: pointer;
+}
+.panel-summary-disclosure__toggle:hover,
+.panel-summary-disclosure__toggle:focus-visible {
+    border-color: var(--cyan-hi, #00b8ff);
+    color: var(--text-strong, #fff);
+    outline: none;
+}
+.panel-summary-disclosure__toggle b { color: var(--text, #d7ecff); font-weight: 700; }
+.panel-summary-disclosure__toggle i { color: var(--muted, #5680b0); font-style: normal; }
+.panel-summary-disclosure__details {
+    display: grid;
+    gap: calc(3 * var(--u, 1px));
+    margin-top: calc(4 * var(--u, 1px));
+    padding: calc(4 * var(--u, 1px)) calc(7 * var(--u, 1px));
+    border-left: 2px solid var(--cyan, #01abea);
+    background: rgba(0, 20, 72, 0.42);
+}
+/* 展开区内每条摘要自己收口:等宽长串(统计窗口的 ISO 时间)按字符断行,不溢出面板。 */
+.panel-summary-disclosure__details > span {
+    min-width: 0;
+    overflow-wrap: anywhere;
+}
+.panel-summary-disclosure__details > .panel-blocks__gap {
+    margin: 0;
+}
 .panel-blocks,
 .panel-blocks *,
 .panel-blocks *::before,