Переглянути джерело

feat(cockpit): 票38 基础设施面板调整(删规模读数 / 定位修复两轮 / 片区×行业图表)

面板调整:
- 删除「规模」读数:真实后端 totals.scale 恒 null、facilityTypes[].scale 实为长度
  单位 "km",两处不再解析与透传;管线里程 lengthKm 保留。mock 同步改成真实形状,
  避免 mock 给值而真实为空时走查抓不到问题。CODE_TEXT 的 large/medium/ssmall
  翻译保留作防漏上屏的底。
- 基础档案「设施总数 + 管线里程」落回同一张 auto-fit 网格同行;概览那块里程数值最长,
  由 domain 声明 wide 独占整行(渲染层只认通用标志,不按 key 特判)。

运行信息定位「点了没反应」修复(两个真根因):
- 失败反馈原挂在默认收起的状态详情里,等于「说了也看不见」;改为地图底部可见浮层,
  自动收起 + 手动关 + 卸载拆定时器。
- 成功路径原走 setCameraFocus 只换中心,行星球尺度几乎无感;监测点改走完整视角定位,
  与点地图上同一点位同一相机语义,下发结果 await 核对、不假装成功。
- 关联设备(如 21119740)不以自身编码上图,编码挂在宿主监测点的 deviceCodes 上:
  新增 findDeviceOwner 回联,定位走宿主点位的点击入口(飞行 + 设备弹窗),不再把
  已加载的设备误报成「不在已加载范围」。

管理信息片区 × 行业图表化:
- 新增 dimensionBarsBlock,两个维度由 domain 预聚合,切换是纯展示动作、零后端往返、
  不进全局筛选;下方保留紧凑清单,退役字段零消费。

另修同族真缺陷(第一轮误判为「与本票无关的基线红」):
- overviewStore 取了 base-archives 却硬写 regions: null,导致概览管线里程永远取不到、
  与基础档案对不上;改为真的并入,两处里程按构造同源。
- linkToIndustry 拿到 filterStore 却不调用(点行业行静默无事发生)。
- situationData 用 {...spread} 复制破坏了「同源引用」保证;删裸后端字段名死变量。

验证:t38 67/67 + 变异 13/13 全部打红按字节还原;t20 由 60/66 升至 66/66 全绿;
t44 107/107、t15/t50/t43/t46/t42/t27/t16/t22/t23/t05/t41 回归全绿;新增编译与
导入自检脚本。真实后端浏览器走查:概览里程与基础档案同读 62.60552 km 且独占整行;
点「定位 21119740」飞到目标街景并弹出设备详情。

改动仅含票 38 hunks,工作树中并行票 32/35/43/45/46/47 的在制品已按 hunk 拆分排除。
ChenJZ 4 днів тому
батько
коміт
0dedddad19

+ 77 - 21
.scratch/cockpit-real-data-wayfinding/issues/38-infrastructure-panel-adjustments.md

@@ -1,9 +1,9 @@
 # 38: 前端执行票 —— 基础设施面板调整(基础档案同行 / 删除规模 / 运行信息定位修复 / 管理信息片区×行业图表)
 
 Type: task
-Status: ready-for-agent
+Status: resolved
 Label: ready-for-agent
-Blocked by: 37
+Blocked by: 37(已关闭,省略方法就位)
 Category: frontend-execution
 
 ## 来源
@@ -13,35 +13,91 @@ Category: frontend-execution
 ## 范围
 
 1. **基础档案:设施总数与规模放到同一行**(第 4 条)
-   - 现状 `facilityCount` 与 `scale` 是两个独立 KPI;改为同行呈现,不改变其它读数。
+2. **删除「规模」读数**(第 2 条)——基础设施面板与总体概览「基础设施」面板;保留 `lengthKm`
+3. **运行信息:定位能力修复**(第 5 条)——三种「点不动」必须分得开并给出可见文案
+4. **管理信息:片区 × 行业图表化**(第 6 条)——分类柱状图可切「按片区/按行业」,下方保留紧凑清单
 
-2. **删除「规模」读数**(第 2 条)
-   - 实测真实后端 `totals.scale = null`(**无数据**);而 `facilityTypes[].scale` 的值是 `"km"`,是**长度单位**不是规模语义,且长度已由「管线里程」(`lengthKm`) 在使用。
-   - 删除基础设施面板与总体概览「基础设施」面板里的「规模」KPI;**保留 `lengthKm`**(长度语义不重复计)。
-   - 同时清理 `facilityTypes[].scale` 被当作规模展示的用法(保留其长度单位语义或不再展示)。
+## 实施记录(2026-10-15,本会话)
 
-3. **运行信息:定位能力修复**(第 5 条)
-   - 现象:点击后没反应。
-   - 定位链路本质是「按 `pointCodes` 回联**已加载**的地图对象」(`domain/mapLocate.js` 的 `planLocate`)。需排查:`data-locate-point` 是否真的接到 `@locate`、`planLocate` 返回 `missing`/`blocked`/`unavailable` 时界面是否有可见反馈。
-   - **三种「点不动」必须分得开并给出可见文案**(既有纪律):不在已加载范围 / 后端判定不可上图 / 地图未就绪 —— 「没反应」本身是缺陷,不是可接受状态。
+**状态:四条范围全部实施、离线闸门 + 变异闸 + 真实浏览器走查(用户授权)完成,票关闭。改动未提交**(工作树混有票 43/45/46/47 等并行在制品,入库须单独裁剪,与票 33/36/37 同一条纪律)。
 
-4. **管理信息:片区 × 行业图表化**(第 6 条)
-   - 以**分类柱状图**为主(可切「按片区」/「按行业」两个维度),下方保留紧凑**卡片清单**。
-   - 消费后端已交付的 `districtIndustry[]`(片区×行业 的设施/设备计数);不再消费已删除的 `organizations`/`ownershipDistribution`/`unmaintained`。
-   - 「责任区域」= 片区别名,不新增独立概念(决策 06)。
+### 交付 1+2:删规模 + 同行呈现
+
+- `infrastructurePanels.js`:`toBaseArchives` 不再解析 `totals.scale`;`archivePanel` KPI 只剩设施总数 + 管线里程。
+- `overviewPanels.js`:`toOverviewRegions` 不再带 `scale`;`infrastructureSummaryPanel` 删规模 KPI;`toOverviewInfrastructure` 的 `facilityTypes[].scale` 不再透传(真实值是 `"km"` 长度单位串,留字段只会诱导出第二次误用;长度归 `lengthKm`)。
+- `PanelBlocks.vue`:删 `lengthKm` 的 `panel-kpi--wide` 整行特判与对应 CSS——「谁独占整行」由组件按 key 特判是第二份布局语义;KPI 网格 auto-fit 两项自然同行。
+- `panelBlocks.js`:`KPI_ICONS` 删 `scale` 位;`CODE_TEXT` 的 large/medium/small 翻译**保留**(后端仍透传自由字符串,词表删了将来误上屏就是英文枚举——票 17 逮过的教训)。
+- mock 同构:`contractMocks.js` 的 `totals.scale` 改真实形状 `null`、`facilityTypes[].scale` 改 `"km"`(mock 若继续给 `medium`,误留的消费点在真实下显示 `—`、mock 下显示「中型」,走查抓不到)。
+
+### 交付 3:定位「点了没反应」——两个真根因,都修了
+
+1. **反馈藏在折叠面板里**(主根因):旧 chip 挂在 `v-show="statusPanelExpanded"` 的状态详情区,默认收起 → 失败提示永远看不见。改为 `<main>` 底部居中**可见浮层**(`data-locate-notice`,带 `编码 · 原因` 与关闭按钮,8 秒自动收起;定时器卸载必拆——票 15 的漏取消坑)。`data-locate-result` 断言面随迁。
+2. **相机语义不对**:旧成功路径用 `setCameraFocus`(ADR 0008 只换中心、保持 heading/range,行星球尺度下几乎无感)。监测点改走 `locateMonitoringPoint`(完整视角定位,与点地图上同一个点位的 `pointSelection` 是同一条相机语义);非监测点保留中心换焦兜底;下发结果 `await` 核对,false 如实降级「地图尚未就绪」不假装成功。store 新增 `clearLocate`(反馈唯一持有者仍是 store)。
+
+### 交付 4:管理信息片区×行业图表化
+
+- `panelBlocks.js` 新增 `dimensionBarsBlock`:**两个维度都由 domain 预聚合**(按片区/按行业各一份,降序 + 并列保序复用 `sortByCountDesc`),切换是纯展示动作、零后端往返、不进全局筛选(与 `panel-action` 接缝刻意分开,互不冒充)。
+- `PanelBlocks.vue` 新增 `dimbars` 画法:`data-dim-tab`(真 button + aria-pressed)+ `data-dimbar-*`;行标签复用票 37 `EllipsisText` 接缝;设备计数进悬停说明不占柱。切换状态按「面板名+块标题」记在组件本地(按块对象记会被刷新顶掉)。
+- 下方保留紧凑清单(逐 片区×行业 组合的设施/设备计数);`organizations`/`ownershipDistribution`/`unmaintained` 零消费(t15 既有守卫复验);「责任区域」不作为独立概念(决策 06)。
+
+### 验收对照
+
+1. **逻辑闸门**:新增 [t38-infrastructure-panel-adjustments.mjs](../verify/t38-infrastructure-panel-adjustments.mjs) **57/57**(A 删规模/留里程/同行、B 三种失败各有互不相同的文案、C 两维度聚合正确+清单保留+退役字段零消费、D 真实 null-scale 形状、E 静态守卫(浮层不被折叠门控/相机走完整路径/无 key 特判/省略单份)、F store 行为含异步 false);[t38-mutations.mjs](../verify/t38-mutations.mjs) **10/10 全部打红**按字节还原。
+2. **浏览器走查(real 后端,用户授权)**:① 基础档案「设施总数 5520 处 | 管线里程 55.60494 km」同行、无规模(截图);② 管理信息「按片区/按行业」切换生效(片区:未分区 5520;行业:排水 3166/供水 1652/燃气 699 降序行业色),明细清单保留(截图两张);③ 运行信息点定位 → 底部当场弹出「TCRQ202609080002 · 该点位不在已加载的一张图范围…」+ 关闭按钮(截图),到点自动收起实测生效。
+3. **回归**:t44 107/107(dimbars 已进普查归类,两维度各自 RANK;B7 升级为「每一处 panel-bar__label 都走 EllipsisText」——dimbars 复用同写法后存在性断言失去牙,M6 变异当场暴露并修复)、t15 11/11、t50 30/30、t27/t16×2/t22/t42/t43/t46 全绿;t20 60/66 与 t05 282/283 的红名单与 HEAD 基线逐条一致(既有红:B4/C2/D2b/D3/E2/F3 与 D1-deviceStatistics,均非本票引入;B1b/C2d 已随本票契约更新转绿)。t17/t18 两脚本 import 旧导出在 HEAD 即崩(票 37 已归因),本票未新增破坏。
+
+### 走查暴露的新事实(移交,不属本票范围)
+
+**真实后端 `operating-information` 的 `pointCodes` 返回的是设施编码(TCRQ/CNRQ…),而 `map-snapshot` 对象序列只有监测点(GS001…)与未关联设备**——两个编码空间不重叠,所以运行信息面板的定位在真实数据下**永远 MISSING**。前端行为正确(如实报三种原因之一,验收条文「或如实报三种原因之一」按此后半成立),但数据语义可疑:`pointCodes` 本应引用可上图的监测点。已另立后端契约票承接排查(见地图子票)。
+
+### 遗留与入库裁剪清单
+
+改动未提交;本票 hunks:`domain/panelBlocks.js`(dimensionBarsBlock + KPI_ICONS 删 scale + 注释)、`domain/infrastructurePanels.js`、`domain/overviewPanels.js`、`components/PanelBlocks.vue`(dimbars 画法 + 删 --wide + 本地切换状态)、`CockpitShell.vue`(浮层 + 自动收起 + 样式;**混有票 45/47 在制品 hunks**)、`stores/infrastructureStore.js`(locatePoint 完整视角 + clearLocate)、`mocks/contractMocks.js`(scale 真实形状 2 处)、`verify/t38-*.mjs` 两个新脚本、`.scratch/.../t44-distribution-ranking.mjs`(dimbars 归类 + B7 升级)、`t20-overview-panels.mjs`(B1b/C2d 随契约更新)。
 
 ## 非目标
 
 - 不改后端契约(`districtIndustry` 已交付)。
 - 不改四模式面板目录与 panelId。
 
-## 验收
-
-- 逻辑闸门:规模读数已移除且 `lengthKm` 保留、管理信息按片区/行业两维度生成正确图表块、定位三种失败各有文案。
-- 浏览器走查:运行信息定位真的飞(或如实报三种原因之一)、管理信息图表可切换维度、基础档案同行呈现。
-- 按 `AGENTS.md`,不自动运行 build/dev/走查。
-
 ## 关联
 
 - 依赖:[37](37-shared-ellipsis-and-type-filter.md)(长文本省略方法)。
 - 相关:决策 06(片区/行业唯一维度)、票 15(管理面板片区化)。
+- 下游:走查暴露的 `pointCodes` 设施编码问题另立后端票(见上)。
+
+## Comments
+
+## Answer
+
+## 进度
+
+第二轮(维护者 2026-10-15 复验反馈 2 条)已实施并走查闭环,票重新关闭。改动未提交。
+
+### 反馈 1:概览里程与基础档案对不上 + 概览那处可独占整行
+
+**根因不是我以为的「两处各自取数不一致」**:`overviewStore.loadPanels` **确实**取了 `/infrastructure/base-archives`(管线里程的唯一来源),但合并进基础设施分区时**硬写 `regions: null`** —— 概览面板因此永远拿不到里程、显示占位符 `—`,而基础档案读同一个端点显示 62.60552。这正是 t20 里 `D3-regions-are-merged-into-infrastructure` 那条红;第一轮我把它归为「与本票无关的基线红」是失察,本轮连同同族真缺陷一并修。
+
+- `overviewStore.js`:真的把 `regions` 并进分区(`regionsLoaded ? regions.data : null`,附 `regionsState`/`regionsReason`);里程两处**按构造同源**(同一个端点的同一字段),不再是「两次取数期望一致」。
+- 整行排版:概览的 `lengthKm` 加 `wide: true`,渲染层只认通用 `item.wide`(`.panel-kpi--wide`),**不按 key 特判** —— 与第一轮删掉的那版的区别是「谁独占整行」的决定权在 domain,不在渲染层。第一轮的基础档案两项同行不受影响(都不声明 `wide`)。
+
+### 反馈 2:点设备 21119740 报「该点位不在已加载的一张图范围内」
+
+**实测根因**:`21119740`(测试环境监测设备01)是**关联设备**,不以自身编码上图 —— 它是宿主监测点 `CBWS202610150001`(城北排水监测点01)的 `deviceCodes` 成员。`findMapObject` 只按对象自身编码匹配,故永远 MISSING;维护者判语「实际上该点位是已加载在图中的真实设备」准确。
+
+- `domain/mapLocate.js`:新增 `findDeviceOwner`(按 `deviceCodes` 反查宿主监测点);`planLocate` 找不到对象时回联宿主,并带出 `deviceCode` 供上层区分。回联只在「找不到」时兜底:编码若真是对象自身编码仍直接命中;既非对象编码也非任何设备编码的,仍如实 MISSING(回联不吞真缺失)。
+- `stores/infrastructureStore.js`:`locatePoint` 成功时开**回联到的对象自己的编码**(宿主点位)再进设备级 —— 与点地图上同一点位、再点它的设备卡片是同一条详情路径,「返回点位」回得去。反馈文案仍显示用户点的那个码(回联是内部动作,不改写用户看到的对象)。
+- `monitoringStore.locateCode` 是死代码(外壳把所有 `@locate` 都接到 `infrastructureStore.locatePoint`),所以本修复落在**真实接线**的那条路径上。
+
+### 验收对照(第二轮)
+
+1. **逻辑闸门**:t38 **67/67**(新增 B5/B5b/B5c/B5d/B5e 回联判定、F9/F9b 设备定位链路、A3c/A3d wide 归属、E13 regions 真并入);[t38-mutations](../verify/t38-mutations.mjs) **13/13 全部打红**(新增 M11 回联被撤 / M12 regions 又写 null / M13 wide 被撤),按字节还原。
+2. **t20 从 60/66 升到 66/66 全绿** —— 第一轮标为「既有红」的 6 条,其中 4 条是真缺陷本轮修好:`regions: null`(里程根因)、`linkToIndustry` 拿到 filterStore 却不调用(点行业行静默无事发生,与「点了没反应」同类)、`situationData` 用 `{...spread}` 复制破坏「同源引用」证明、overviewStore 里的 `deviceStatistics` 死变量(裸后端字段名,触 t05 D1 面);另 2 条是过期夹具(B4/C2 仍按决策 06 之前的 `regionDistribution`/`区域分布`,已按真实后端形状 `districtDistribution`/片区分布 更新)。
+3. **回归**:t44 107/107 + 变异 6/6、t15 11/11、t50 30/30、t43 23/23、t46、t42 契约、t27、t16×3、t22、t23、t05 适配层、t41 定位锚定 全绿。新增 [t38-r2-compile-check](../verify/t38-r2-compile-check.mjs) 全通过(两个 .vue 编译 + 四个 .js 真实 import + 回联确实挂在 planLocate 判定链上)。t17/t18 在 **HEAD 即崩溃**(票 15 删了 `MANAGEMENT_REGION_GAP`/`REGION_MAP_GAP` 导出而脚本仍 import,多票已归因),本轮零新增破坏。
+4. **浏览器走查(real,用户已授权)**:① 总体概览-基础设施 管线里程 **62.60552 km**,与基础档案同读同值(修复前 `—`),且独占整行排版;② 运行监测-运行概况 点「定位 21119740」→ 相机飞到目标街景(城北排水监测点标记可见)+ 弹出**设备详情**弹窗(测试环境监测设备01/21119740/报警/设备档案·最新指标·预警5·报警66),无失败浮层(成功按设计不出文字)——正是「直接调用对应点位的点击事件,移动到目标位置并触发弹窗」。
+
+### 入库裁剪清单(第二轮 hunks)
+
+`stores/overviewStore.js`(regions 真并入 / linkToIndustry 真调用 / 同源引用 / 删死变量)、`domain/mapLocate.js`(findDeviceOwner + planLocate 回联 + deviceCode 带出)、`domain/overviewPanels.js`(lengthKm 的 wide)、`components/PanelBlocks.vue`(通用 `item.wide` 类绑定 + `.panel-kpi--wide` CSS;**该文件混有票 43/45/46/47 在制品,hunk 较大须逐段挑**)、`stores/infrastructureStore.js`(locatePoint 开宿主再进设备级)、`verify/t38-*.mjs` 三脚本 + `t38-r2-compile-check.mjs`、`t20-overview-panels.mjs`(B4/C2 夹具更新)。
+
+第二轮完成时间:2026-10-15(第一轮记录见上)。
+

+ 56 - 0
.scratch/cockpit-real-data-wayfinding/issues/49-49-operating-information-pointcodes.md

@@ -0,0 +1,56 @@
+<!-- DSH-IDEMPOTENCY-KEY: t49-pointcodes-facility-codes -->
+<!-- parentKey: 00 -->
+# 49: 后端契约诊断 —— operating-information 的 pointCodes 返回设施编码,运行信息定位永远不可达
+
+Category: backend-contract
+
+## 来源
+
+票 38(基础设施面板调整)真实浏览器走查暴露的新事实(2026-10-15,real 后端 `192.168.110.96:8301`)。
+
+## 现象
+
+基础设施 → 运行信息 → 点任一「定位 TCRQ…」按钮,前端如实弹出
+「TCRQ202609080002 · 该点位不在已加载的一张图范围内」(票 38 的 MISSING 反馈,行为正确)。
+但**每一个**定位按钮都是 MISSING —— 不是偶发数据缺失,而是编码空间不重叠:
+
+- `/infrastructure/operating-information` 的 `metrics[].pointCodes` 实测返回
+  **设施编码**(`TCRQ…` 太常燃气、`CNRQ…` 城南燃气,即 `facility` 序列的编码);
+- `/map-snapshot` 的对象序列只有**监测点**(`GS001`/`WS…` 等)与**未关联设备**(`deviceCode`);
+- 前端定位链路按编码回联已加载地图对象(`domain/mapLocate.js` 的 `planLocate`),
+  两个编码空间无交集 ⇒ 运行信息面板的定位在真实数据下**结构性不可达**。
+
+## 待裁定(诊断,不是直接改)
+
+1. 契约语义:`pointCodes` 本应引用「可上图的监测点」(字段名与 CONTEXT.md 的「监测点」术语都指向此),
+   还是后端有意用设施编码表达「该指标挂在哪些设施上」?
+2. 若语义应为监测点:根因是取数链路把 facility 编码写进了 pointCodes,还是设施本就无点位可回联?
+   (对照:应急保障的 `pointCodes` 实测指向快照里存在的设备/点位编码,链路可用。)
+3. 若语义就是设施编码:契约应改名(如 `facilityCodes`),且前端该面板应**撤下定位按钮**
+   而不是留一排永远 MISSING 的入口——「如实报原因」是兜底,不是把不可达做成常态。
+
+## 验收
+
+- 给出裁定与证据(真实端点逐行业取样:`pointCodes` 值集 ∩ `map-snapshot` 编码集)。
+- 若改后端:契约测试锁定「运行信息 pointCodes 必须能在 map-snapshot 里找到对象」。
+- 若改前端:另立前端票撤按钮/改文案,本票只出结论。
+
+## 关联
+
+- 上游:[38](38-infrastructure-panel-adjustments.md)(走查发现,前端反馈链路已在该票修好)。
+- 相关:票 07(坐标准入)、票 24(设施与管线几何是**另一条**图层链路,不进 map-snapshot 对象序列)。
+
+## 进度
+
+<!-- 做到哪一步了,在这里一行一行记 -->
+
+Status: ready-for-agent
+Type: task
+Blocked by:
+Labels: backend-contract, wayfinder:task
+
+## Comments
+
+
+## Answer
+

Різницю між файлами не показано, бо вона завелика
+ 4 - 4
.scratch/cockpit-real-data-wayfinding/map.md


+ 408 - 0
.scratch/cockpit-real-data-wayfinding/verify/t38-infrastructure-panel-adjustments.mjs

@@ -0,0 +1,408 @@
+/**
+ * T38 基础设施面板调整 纯逻辑闸门(票 38,前端执行票)。
+ *
+ * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
+ * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t38-infrastructure-panel-adjustments.mjs
+ *
+ * 六段,对号票面四条范围 + 验收三句:
+ *   A 规模读数已删除(archivePanel / infrastructureSummaryPanel / 领域层不再透传 scale),
+ *     而 `lengthKm` 与既有读数不受影响(验收第 1 条前半)。
+ *   B 定位三种「点不动」各有自己的文案、分得开(「没反应」本身是缺陷)。
+ *   C 管理信息按片区/行业两维度生成正确图表块 + 下方保留紧凑清单(验收第 1 条中段)。
+ *   D 真实后端 `scale` 恒 null 的形状灌进来,不再画空读数;`"km"` 不当规模展示。
+ *   E 静态守卫:反馈可见性搬出折叠面板;相机走完整视角路径;同行呈现无 key 特判。
+ *   F store 行为:成功走 flyPoint、失败不动相机、三种 result 各自落到 locate、异步 false 被接住。
+ *
+ * 变异归 t38-mutations.mjs(本脚本自己只跑绿)。
+ */
+import { readFileSync } from 'node:fs'
+import { createPinia, setActivePinia } from 'pinia'
+import {
+    archivePanel,
+    managementPanel,
+    toBaseArchives,
+    toManagementInformation,
+} from '../../../src/modules/cockpit/domain/infrastructurePanels.js'
+import { infrastructureSummaryPanel, toOverviewRegions } from '../../../src/modules/cockpit/domain/overviewPanels.js'
+import { LOCATE_RESULT, LOCATE_TEXT, planLocate } from '../../../src/modules/cockpit/domain/mapLocate.js'
+import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js'
+import { SECTION_STATE, section } from '../../../src/modules/cockpit/domain/sectionState.js'
+import { useInfrastructureStore } from '../../../src/modules/cockpit/stores/infrastructureStore.js'
+
+const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
+const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
+/** 去注释(注释会解释被禁的东西,全文正则会拿假阳性换通过率)。 */
+const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
+const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
+
+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}`)
+    }
+}
+const sv = (data) => section(SECTION_STATE.SUCCESS, { data, loaded: true })
+
+setActivePinia(createPinia())
+
+// ================================================================ A 规模读数已删除
+
+{
+    const data = toBaseArchives({
+        updatedAt: 'T',
+        range: { type: 'CURRENT_BASELINE' },
+        // 故意把 scale 给成有值(真实后端是 null):界面仍不得出现「规模」读数。
+        totals: { facilityCount: 5, lengthKm: '12.40', scale: 'medium' },
+        industryDistribution: [{ code: 'GAS', count: 2 }],
+        districtDistribution: [{ district: 0, count: 3 }],
+        ageDistribution: [{ code: 'UNDER_5_YEARS', count: 1 }],
+        facilityTypeDistribution: [{ code: 'GAS_PIPE', name: '燃气管线', count: 2, lengthKm: 3.1, scale: 'km' }],
+        ageReference: { asOfDate: 'D', commissionedCount: 1, builtCount: 1, unknownCount: 0 },
+        excluded: { unknownTypeCount: 0, typeKindMismatchCount: 0 },
+    })
+    check('A1-domain-drops-scale', data.scale === undefined, `领域对象不再带出 scale(实测:${String(data.scale)})`)
+    check('A1b-domain-keeps-length', data.lengthKm === 12.4, `lengthKm 保留(BigDecimal 字符串被认下:${data.lengthKm})`)
+
+    const view = archivePanel(data, sv(data))
+    const kpi = view.blocks[0]
+    const labels = kpi.items.map((item) => item.label)
+    const keys = kpi.items.map((item) => item.key)
+    check(
+        'A2-archive-kpi-has-no-scale-reading',
+        !keys.includes('scale') && !labels.includes('规模') && !JSON.stringify(view).includes('中型'),
+        `基础档案 KPI 只剩 ${keys.join('/')}(规模读数与其中文翻译都不再上屏)`,
+    )
+    check(
+        'A2b-archive-keeps-facility-count-and-length',
+        keys.includes('facilityCount') && keys.includes('lengthKm') && kpi.items.find((i) => i.key === 'facilityCount').value === '5' && kpi.items.find((i) => i.key === 'lengthKm').value === '12.4',
+        '设施总数与管线里程读数不变(票面「不改变其它读数」)',
+    )
+    check(
+        'A3-two-readings-share-one-kpi-block',
+        kpi.items.length === 2 && view.blocks.filter((b) => b.kind === 'kpi').length === 1,
+        `两项读数在同一条 KPI 网格(auto-fit → 同行呈现),没有第三块抢行`,
+    )
+    check('A3b-no-per-key-wide-class', !/item\.key === 'lengthKm'/.test(codeVue(read('components/PanelBlocks.vue'))), '渲染层不按 key 特判整行独占(第二轮:`wide` 改由 domain 声明)')
+    // 票 38 第二轮(反馈第 1 条):概览的管线里程独占整行(数值最长,并排挤压),
+    // 但「谁独占」的决定权在 domain —— 渲染层只认通用 `item.wide`。
+    const ovWide = infrastructureSummaryPanel(
+        { infrastructure: { facilityCount: 5, smartDeviceCount: 9, industries: [], excluded: {} }, regions: { regions: [], lengthKm: 12.4 } },
+        sv(null),
+    )
+    check(
+        'A3c-overview-length-is-wide',
+        ovWide.blocks[0].items.find((i) => i.key === 'lengthKm').wide === true &&
+            ovWide.blocks[0].items.filter((i) => i.wide === true).length === 1,
+        '概览基础设施:只有管线里程声明 wide(独占整行排版更好看)',
+    )
+    check(
+        'A3d-archive-row-not-wide',
+        kpi.items.every((item) => item.wide === undefined),
+        '基础档案两项都不声明 wide → 仍同行(第一轮的第 4 条不被撤销)',
+    )
+
+    // 总体概览「基础设施」面板同样删规模。
+    const overviewRegions = toOverviewRegions({
+        totals: { facilityCount: 9, lengthKm: 12.4, scale: 'medium' },
+        districtDistribution: [{ district: 0, count: 3 }],
+        ageReference: {},
+        excluded: {},
+    })
+    check('A4-overview-regions-drop-scale', overviewRegions.scale === undefined && overviewRegions.lengthKm === 12.4, 'toOverviewRegions 不再带 scale,lengthKm 保留')
+    const ov = infrastructureSummaryPanel(
+        { infrastructure: { facilityCount: 5, smartDeviceCount: 9, industries: [], excluded: {} }, regions: overviewRegions },
+        sv(null),
+    )
+    const ovKeys = ov.blocks[0].items.map((item) => item.key)
+    check(
+        'A5-overview-kpi-has-no-scale-reading',
+        !ovKeys.includes('scale') && ovKeys.includes('lengthKm'),
+        `概览基础设施 KPI:${ovKeys.join('/')}(规模已删、里程保留)`,
+    )
+    check('A6-both-panels-clean', !JSON.stringify(ov).includes('规模') && !JSON.stringify(view.blocks[0]).includes('规模'), '两块面板的 KPI 里零「规模」字样')
+}
+
+// ================================================================ B 三种「点不动」各有文案
+
+{
+    const objects = [
+        { kind: 'monitoring-point', code: 'MP-001', name: '甲', longitude: 110.1, latitude: 34.2, coordinate: { renderable: true, validated: true } },
+        { kind: 'monitoring-point', code: 'MP-003', name: '丙', longitude: 110.3, latitude: 34.4, coordinate: { renderable: false, validated: true, reason: 'ZERO_COORDINATE' } },
+    ]
+    const missing = planLocate({ objects, pointCode: 'NOPE', mapReady: true })
+    const blocked = planLocate({ objects, pointCode: 'MP-003', mapReady: true })
+    const unavailable = planLocate({ objects, pointCode: 'MP-001', mapReady: false })
+    const ok = planLocate({ objects, pointCode: 'MP-001', mapReady: true })
+
+    check('B1-three-failures-distinct', missing.result === LOCATE_RESULT.MISSING && blocked.result === LOCATE_RESULT.BLOCKED && unavailable.result === LOCATE_RESULT.UNAVAILABLE && ok.result === LOCATE_RESULT.OK, 'missing/blocked/unavailable/ok 四态分得开')
+    check(
+        'B2-each-failure-has-visible-text',
+        [missing, blocked, unavailable].every((plan) => typeof plan.reasonText === 'string' && plan.reasonText.trim().length > 0),
+        `三种失败都有可读文案:${missing.reasonText} / ${blocked.reasonText} / ${unavailable.reasonText}`,
+    )
+    check('B2b-texts-differ', new Set([missing.reasonText, blocked.reasonText, unavailable.reasonText]).size === 3, '三句文案互不相同(读者能分清是自己点错还是数据问题)')
+    check('B3-blocked-carries-backend-reason', blocked.reasonText === '零坐标(经纬度均为 0)' && !/ZERO_COORDINATE/.test(blocked.reasonText), `不可上图说具体原因且不泄原因码:${blocked.reasonText}`)
+    check('B4-locate-text-constants', LOCATE_TEXT.MISSING.includes('已加载') && LOCATE_TEXT.UNAVAILABLE.includes('就绪'), '两种自带文案的语义锚点(范围 / 就绪)在 domain 一处定义')
+
+    // 票 38 第二轮(反馈第 2 条):**关联设备**(真实数据 `21119740`)不以自身编码上图,
+    // 它是宿主监测点 `deviceCodes` 的成员。点它的定位必须回联宿主点位并成功,
+    // 而不是误报「该点位不在已加载范围」——维护者:「实际上该点位是已加载在图中的真实设备」。
+    const deviceObjects = [
+        {
+            kind: 'monitoring-point',
+            code: 'CBWS202610150001',
+            name: '城北排水监测点01',
+            longitude: 110.399905,
+            latitude: 28.459147,
+            deviceCodes: ['21119740', '21119741'],
+            coordinate: { renderable: true, validated: true },
+        },
+    ]
+    const viaDevice = planLocate({ objects: deviceObjects, pointCode: '21119740', mapReady: true })
+    check(
+        'B5-associated-device-resolves-to-host-point',
+        viaDevice.result === LOCATE_RESULT.OK && viaDevice.object.code === 'CBWS202610150001' && viaDevice.deviceCode === '21119740',
+        `点关联设备回联宿主监测点并定位成功(${viaDevice.object && viaDevice.object.code},设备码带出 ${viaDevice.deviceCode})`,
+    )
+    check('B5b-point-code-leaves-device-code-empty', planLocate({ objects: deviceObjects, pointCode: 'CBWS202610150001', mapReady: true }).deviceCode === '', '直接点位编码时 deviceCode 为空(不误开设备级)')
+    const stillMissing = planLocate({ objects: deviceObjects, pointCode: 'TCRQ202609080001', mapReady: true })
+    check('B5c-unknown-code-still-missing', stillMissing.result === LOCATE_RESULT.MISSING && stillMissing.object === null, '既非对象编码也非任何点位的设备编码 → 仍如实 MISSING(回联不吞真缺失)')
+    const blockedDevice = planLocate({ objects: [{ ...deviceObjects[0], coordinate: { renderable: false, validated: true, reason: 'ZERO_COORDINATE' } }], pointCode: '21119740', mapReady: true })
+    check('B5d-host-blocked-reports-blocked', blockedDevice.result === LOCATE_RESULT.BLOCKED, '宿主点位不可上图 → BLOCKED(准入判据照宿主点位自己,不因为换了入口就放行)')
+    check('B5e-device-code-not-matched-as-object', planLocate({ objects: [{ kind: 'monitoring-point', code: '21119740', coordinate: { renderable: true } }], pointCode: '21119740', mapReady: true }).object.code === '21119740', '编码若真是对象自身编码,直接命中(回联只在找不到时兜底,不改既有语义)')
+}
+
+// ================================================================ C 管理信息:片区 × 行业图表化
+
+{
+    const data = toManagementInformation({
+        totals: { facilityCount: 12, equipmentCount: 8 },
+        districtIndustry: [
+            { district: 0, districtName: '城南', industry: 'GAS', facilityCount: 5, equipmentCount: 2 },
+            { district: 0, districtName: '城南', industry: 'WATER_SUPPLY', facilityCount: 4, equipmentCount: 3 },
+            { district: 1, districtName: '城北', industry: 'GAS', facilityCount: 2, equipmentCount: 1 },
+            { district: null, districtName: '未分区', industry: 'DRAINAGE', facilityCount: 1, equipmentCount: 2 },
+        ],
+        // 已退役字段:给了也不许被消费。
+        organizations: [{ organizationName: 'FORBIDDEN-ORG' }],
+        ownershipDistribution: [{ ownershipType: 'OWNED' }],
+        unmaintained: [{ code: 'FORBIDDEN-UNMAINTAINED' }],
+    })
+    const view = managementPanel(data, sv(data))
+    const dim = view.blocks.find((b) => b.kind === 'dimbars')
+    check('C1-dimbars-block-exists', !!dim, `管理信息有可切换维度的柱状图块(块类型:${view.blocks.map((b) => b.kind).join('/')})`)
+    check(
+        'C2-two-dimensions',
+        dim && dim.dimensions.length === 2 && dim.dimensions.map((d) => d.key).join(',') === 'district,industry',
+        `两维度都在:${dim ? dim.dimensions.map((d) => `${d.label}(${d.key})`).join(' / ') : '—'}`,
+    )
+
+    const districtDim = dim.dimensions.find((d) => d.key === 'district')
+    const industryDim = dim.dimensions.find((d) => d.key === 'industry')
+    check(
+        'C3-district-rows-aggregate-and-rank',
+        districtDim.rows.map((r) => r.count).join(',') === '9,2,1' && districtDim.rows.map((r) => r.label).join('、') === '城南、城北、未分区',
+        `按片区聚合城南=5+4=9 并降序:${districtDim.rows.map((r) => `${r.label}${r.count}`).join(' ')}`,
+    )
+    check(
+        'C4-industry-rows-aggregate',
+        industryDim.rows.map((r) => r.count).join(',') === '7,4,1' && industryDim.rows.map((r) => r.label).join('、') === '燃气、供水、排水',
+        `按行业聚合燃气=5+2=7:${industryDim.rows.map((r) => `${r.label}${r.count}`).join(' ')}`,
+    )
+    check('C5-ratio-normalized-per-dimension', districtDim.rows[0].ratio === 1 && industryDim.rows[0].ratio === 1, '条长按该维度最大项归一(各维度独立)')
+    check('C5b-equipment-count-in-hint', districtDim.rows[0].hint.includes('设备'), `设备计数进悬停说明不占柱:${districtDim.rows[0].hint}`)
+    check('C6-default-active-district', dim.active === 'district', `默认维度=片区(图面先回答「分布在哪」):${dim.active}`)
+
+    const list = view.blocks.find((b) => b.kind === 'list')
+    check('C7-compact-list-kept', !!list && list.rows.length === 4 && list.rows[0].meta.join(' ').includes('设备'), '下方保留紧凑卡片清单(逐 片区×行业 组合)')
+    check('C7b-list-label', list.label.includes('片区') && list.label.includes('行业'), `清单标题仍是片区×行业口径:${list.label}`)
+
+    const dumped = JSON.stringify(view)
+    check('C8-retired-fields-not-consumed', !/FORBIDDEN-ORG|FORBIDDEN-UNMAINTAINED|OWNED|管理单位|权属/.test(dumped), '退役的机构/权属/未维护维度零消费')
+    check('C8b-no-responsibility-area-concept', !/责任区域/.test(dumped), '「责任区域」不作为独立概念出现(决策 06:它就是片区别名)')
+
+    // 空范围:不假装有数据。
+    const empty = managementPanel(toManagementInformation({ totals: { facilityCount: 0, equipmentCount: 0 }, districtIndustry: [] }), sv(null))
+    const emptyDim = empty.blocks.find((b) => b.kind === 'dimbars')
+    check('C9-empty-says-empty', emptyDim.dimensions.every((d) => d.rows.length === 0), '空计数 → 两维度都没有行(界面走 emptyText,不画假柱)')
+
+    // 行业未知 / 全 0 计数保留行(0 是真实值)。
+    const zero = managementPanel(
+        { facilityCount: 0, equipmentCount: 1, districtIndustry: [{ district: 1, industry: 'GAS', facilityCount: 0, equipmentCount: 1 }] },
+        sv(null),
+    )
+    const zeroDim = zero.blocks.find((b) => b.kind === 'dimbars')
+    const gasRow = zeroDim.dimensions.find((d) => d.key === 'district').rows[0]
+    check('C10-zero-count-row-kept', gasRow.count === 0 && gasRow.ratio === 0 && Number.isFinite(gasRow.ratio), '0 计数保留行且条长 0(不 NaN、不隐藏)')
+    const unk = managementPanel(toManagementInformation({ totals: { facilityCount: 0, equipmentCount: 0 }, districtIndustry: [{ district: 9, industry: '', facilityCount: 0, equipmentCount: 0 }] }), sv(null))
+    const unkInd = unk.blocks.find((b) => b.kind === 'dimbars').dimensions.find((d) => d.key === 'industry').rows[0]
+    check('C10b-unknown-bucket-labeled', unkInd.key === 'UNASSIGNED' && unkInd.label === '行业未知', `认不出的行业进显式「行业未知」桶:${unkInd.label}`)
+    const unkDist = unk.blocks.find((b) => b.kind === 'dimbars').dimensions.find((d) => d.key === 'district').rows[0]
+    check('C10c-invalid-district-unknown', unkDist.key === 'UNASSIGNED' && unkDist.label === '未分区', `认不出的片区经适配层归一后进「未分区」桶:${unkDist.label}`)
+}
+
+// ================================================================ D 真实后端形状(scale 恒 null)
+
+{
+    // 照抄 t18 真实审计载荷:totals.scale = null、facilityTypes[].scale = "km"。
+    const real = toBaseArchives({
+        totals: { facilityCount: 5283, lengthKm: 67.60944, scale: null },
+        industryDistribution: [{ code: 'GAS', count: 537 }],
+        districtDistribution: [{ district: 0, count: 500 }],
+        facilityTypeDistribution: [{ code: 'GAS_PIPE', name: '燃气管线', count: 537, lengthKm: 3.118, scale: 'km' }],
+        ageReference: {},
+        excluded: {},
+    })
+    const view = archivePanel(real, sv(real))
+    check('D1-null-scale-not-rendered', !JSON.stringify(view.blocks[0]).includes('规模'), `真实 null 规模不再画成一行空读数(KPI:${view.blocks[0].items.map((i) => i.key).join('/')})`)
+    check('D2-km-unit-not-shown-as-scale', JSON.stringify(view).indexOf('规模') === -1, `facilityTypes[].scale 的 "km" 不被当规模展示(长度归 lengthKm)`)
+    check('D3-facility-count-unchanged', view.blocks[0].items.find((i) => i.key === 'facilityCount').value === '5283', '设施总数读数不受影响')
+    check('D3b-length-unchanged', view.blocks[0].items.find((i) => i.key === 'lengthKm').value === '67.60944', '管线里程读数不受影响')
+}
+
+// ================================================================ E 静态守卫
+
+{
+    const panelVue = codeVue(read('components/PanelBlocks.vue'))
+    const shellVue = codeVue(read('CockpitShell.vue'))
+    const storeCode = code(read('stores/infrastructureStore.js'))
+    const domainCode = code(read('domain/infrastructurePanels.js'))
+
+    check('E1-dimbars-rendered', /block\.kind === 'dimbars'/.test(panelVue) && /data-dimbar=/.test(panelVue) && /data-dim-tab=/.test(panelVue), 'dimbars 块有画法与走查断言面')
+    check('E2-switch-is-display-only', /setDimension\(/.test(panelVue) && !/emit\('panel-action'[\s\S]{0,120}dimbar/.test(panelVue), '维度切换是组件本地展示状态,不冒充 panel-action 筛选接缝')
+    check('E2b-switch-uses-semantic-button', /<button[\s\S]{0,500}?data-dim-tab=/.test(panelVue) && /:aria-pressed=/.test(panelVue), '切换标签是真 button + aria-pressed(状态读得出,不靠颜色)')
+    check('E3-dimbars-labels-ellipsis', (panelVue.match(/<EllipsisText class="panel-bar__label" :text="row\.label" \/>/g) || []).length >= 2, 'dimbars 行标签复用票 37 共用省略方法(bars 与 dimbars 两处同写法)')
+    check('E3b-single-ellipsis-declaration', !/text-overflow/.test(panelVue), 'PanelBlocks.vue 仍零 text-overflow 声明(省略只有一份)')
+    check('E4-domain-does-not-fetch-or-import-service', !/\bfetch\s*\(|XMLHttpRequest/.test(domainCode) && !/from '\.\.\/services\//.test(domainCode), 'domain 纯函数层不发请求、不 import service')
+    check('E5-locate-seam-still-shared', /planLocate/.test(code(read('domain/mapLocate.js'))) && /isRenderable/.test(code(read('domain/mapLocate.js'))), '定位判定仍收在 domain 一处(复用票 07 坐标准入)')
+
+    // 反馈可见性:不得再藏在默认收起的状态详情里。
+    const noticeAt = shellVue.indexOf('data-locate-notice')
+    check('E6-visible-notice-exists', noticeAt >= 0, '定位反馈有独立可见浮层(data-locate-notice)')
+    const foldRegion = shellVue.slice(shellVue.indexOf('v-show="statusPanelExpanded"'), noticeAt)
+    check('E6b-feedback-not-behind-collapse', noticeAt >= 0 && foldRegion.indexOf('data-locate-result') < 0, '失败反馈不再挂在折叠的状态详情区(折叠=「没反应」的来源)')
+    const noticeTag = shellVue.slice(shellVue.lastIndexOf('<div', noticeAt), noticeAt + 60)
+    check('E6c-notice-not-gated-on-collapse', /v-if="locateFeedback"\s+class="cockpit-shell__notice"/.test(shellVue) && !/locateFeedback[\s\S]{0,40}statusPanelExpanded/.test(noticeTag), '浮层显隐只由反馈结果决定,不被折叠状态二次门控(门回折叠=「没反应」换写法复发)')
+    check('E7-result-attribute-surface', /:data-locate-result="locateFeedback\.result"/.test(shellVue), 'data-locate-result 断言面随浮层保留')
+    check('E8-auto-dismiss', /setTimeout\(\(\) => \{[\s\S]{0,120}?clearLocate\(\)/.test(shellVue) && /dismissLocateFeedback/.test(shellVue) && /clearTimeout\(locateDismissTimer\)/.test(shellVue), '浮层自动收起 + 可手动关 + 卸载拆定时器(票 15 的漏取消坑)')
+    check('E9-locate-flies-full-view', /locateMonitoringPoint/.test(storeCode) && /flyPoint\(plan\.object\)/.test(storeCode) && /await flyPoint/.test(storeCode), '监测点走完整视角定位(不是只换中心的 setCameraFocus)')
+    check('E10-store-clear-exports', /clearLocate/.test(storeCode), 'store 暴露 clearLocate(反馈唯一持有者仍是 store,壳不自己抹状态)')
+    check('E11-shell-forwards-locate', (shellVue.match(/@locate="locateOnMap"/g) || []).length === 2, '左右两栏的 @locate 仍接到外壳(data-locate-point → @locate → store)')
+    check('E12-no-backend-names-in-ui', !/districtIndustry|ownershipDistribution|organizations/.test(panelVue), '渲染层不认识后端字段名(维度语义归 domain)')
+
+    // 票 38 第二轮(反馈第 1 条):概览的管线里程与基础档案对不上 —— 根因是 overviewStore
+    // 明明取了 base-archives(里程唯一来源)却在合并时硬写 `regions: null`。
+    // 静态守卫钉住「真的并进数据」;行为守卫见 t20 的 D3(loadPanels 端到端)。
+    const overviewStoreCode = code(read('stores/overviewStore.js'))
+    check('E13-regions-merged-not-nulled', /regions: regionsLoaded \? regions\.data : null/.test(overviewStoreCode) && !/regions: null,\s*\n\s*regionsState: SECTION_STATE\.FAILED/.test(overviewStoreCode), '区域分布真的并进基础设施分区(里程两处同源,不再硬写 null)')
+}
+
+// ================================================================ F store 行为
+
+{
+    const store = useInfrastructureStore()
+    store.reset()
+    const objects = [
+        { kind: 'monitoring-point', code: 'MP-001', name: '甲', longitude: 110.1, latitude: 34.2, coordinate: { renderable: true, validated: true } },
+        { kind: 'monitoring-point', code: 'MP-003', name: '丙', longitude: 110.3, latitude: 34.4, coordinate: { renderable: false, validated: true, reason: 'ZERO_COORDINATE' } },
+    ]
+    const flyCalls = []
+    const openCalls = []
+    const deps = {
+        objects,
+        mapReady: true,
+        focus: () => {
+            throw new Error('监测点定位不该再走中心换焦')
+        },
+        flyPoint: (object) => {
+            flyCalls.push(object.code)
+            return true
+        },
+        openPoint: (codeArg) => {
+            openCalls.push(codeArg)
+        },
+    }
+
+    const ok = await store.locatePoint('MP-001', deps)
+    const blocked = await store.locatePoint('MP-003', deps)
+    const missing = await store.locatePoint('NOPE', deps)
+    const notReady = await store.locatePoint('MP-001', { ...deps, mapReady: false })
+
+    check('F1-success-flies-once', ok.result === LOCATE_RESULT.OK && flyCalls.join(',') === 'MP-001' && openCalls.join(',') === 'MP-001', `成功:飞行 1 次 + 详情 1 次(${flyCalls.join(',')})`)
+    check('F2-failures-dont-fly', blocked.result === LOCATE_RESULT.BLOCKED && missing.result === LOCATE_RESULT.MISSING && notReady.result === LOCATE_RESULT.UNAVAILABLE && flyCalls.length === 1, '三种失败都不动相机(不可上图/找不到/未就绪各归各)')
+    check('F3-each-failure-has-text', [blocked, missing, notReady].every((r) => r.reasonText.trim().length > 0), '三种失败结果都带着要显示的文案(外壳据此出浮层,不存在「无反馈」状态)')
+    check('F4-camera-refusal-degrades-honestly', (await store.locatePoint('MP-001', { ...deps, flyPoint: () => false })).result === LOCATE_RESULT.UNAVAILABLE, '相机没吃下指令 → 如实「地图尚未就绪」,不假装成功')
+    check('F5-async-fly-false-awaited', (await store.locatePoint('MP-001', { ...deps, flyPoint: async () => false })).result === LOCATE_RESULT.UNAVAILABLE && openCalls.length === 1, '异步下发的 false 也被接住(详情不误开)')
+    store.clearLocate()
+    check('F6-clear-locate-resets', store.locate.result === '', 'clearLocate 收起反馈(浮层到点/手动关的落点)')
+    await store.locatePoint('NOPE', deps)
+    store.reset()
+    check('F7-reset-clears-locate', store.locate.result === '', 'reset 一并清掉定位状态')
+
+    // 未关联设备(非监测点)仍走中心换焦,不误用点位级完整视角。
+    const deviceObjects = [{ kind: 'unassociated-equipment', code: 'EQ-1', longitude: 110.9, latitude: 34.9, coordinate: { renderable: true, validated: true } }]
+    let focusHits = 0
+    const device = await store.locatePoint('EQ-1', {
+        objects: deviceObjects,
+        mapReady: true,
+        focus: () => (focusHits += 1, true),
+        flyPoint: () => {
+            throw new Error('未关联设备不该走监测点视角')
+        },
+        openPoint: () => {},
+    })
+    check('F8-non-point-keeps-center-focus', device.result === LOCATE_RESULT.OK && focusHits === 1, `非监测点仍走中心换焦(${focusHits} 次)`)
+
+    // 票 38 第二轮(反馈第 2 条):点**关联设备** → 飞宿主点位 + 开宿主详情 + 进设备级弹窗。
+    // 维护者的要求原话是「直接调用对应点位的点击事件,移动到目标位置并触发弹窗」。
+    const hostObjects = [
+        {
+            kind: 'monitoring-point',
+            code: 'CBWS202610150001',
+            name: '城北排水监测点01',
+            longitude: 110.399905,
+            latitude: 28.459147,
+            deviceCodes: ['21119740'],
+            coordinate: { renderable: true, validated: true },
+        },
+    ]
+    const hostFlights = []
+    const hostOpened = []
+    const deviceOpened = []
+    const fromDevice = await store.locatePoint('21119740', {
+        objects: hostObjects,
+        mapReady: true,
+        focus: () => {
+            throw new Error('回联到监测点后不该走中心换焦')
+        },
+        flyPoint: (object) => {
+            hostFlights.push(object.code)
+            return true
+        },
+        openPoint: (codeArg) => {
+            hostOpened.push(codeArg)
+        },
+        openDevice: (codeArg) => {
+            deviceOpened.push(codeArg)
+        },
+    })
+    check(
+        'F9-device-locates-host-and-opens-device',
+        fromDevice.result === LOCATE_RESULT.OK &&
+            hostFlights.join(',') === 'CBWS202610150001' &&
+            hostOpened.join(',') === 'CBWS202610150001' &&
+            deviceOpened.join(',') === '21119740',
+        `设备定位=飞宿主点位 + 开宿主详情 + 进设备级(飞行 ${hostFlights.join(',')} / 详情 ${deviceOpened.join(',')})`,
+    )
+    check('F9b-notice-keeps-clicked-code', fromDevice.pointCode === '21119740', '反馈里显示的仍是用户点的那个编码(回联是内部动作,不改写用户看到的对象)')
+    store.clearLocate()
+}
+
+console.log('')
+console.log(`T38 infrastructure panel adjustments: ${failed ? `${failed} FAILED` : `${passed} passed`}`)
+if (failed) process.exit(1)

+ 138 - 0
.scratch/cockpit-real-data-wayfinding/verify/t38-mutations.mjs

@@ -0,0 +1,138 @@
+/**
+ * 票 38 变异闸:把本票要防的错法逐一注入,确认 t38 真的转红;每次变异后按字节还原并校验哈希。
+ *
+ * 判据只看退出码(不用管道捕获子进程输出——那是本环境的已知边界,票 37 记过)。
+ * 用法:先单跑 t38 看绿,再跑本脚本看每条变异都被抓住。
+ * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t38-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/t38-infrastructure-panel-adjustments.mjs'
+
+const MUTATIONS = [
+    {
+        id: 'M1',
+        why: '规模 KPI 回流到基础档案(第 2 条被撤销)→ A2/A6/D1 必须红',
+        file: 'src/modules/cockpit/domain/infrastructurePanels.js',
+        from: "            { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' },\n        ]),",
+        to: "            { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' },\n            { key: 'scale', label: '规模', value: current.scale ? codeText(current.scale) : DETAIL_PLACEHOLDER },\n        ]),",
+    },
+    {
+        id: 'M2',
+        why: '规模 KPI 回流到总体概览基础设施面板 → A5 必须红',
+        file: 'src/modules/cockpit/domain/overviewPanels.js',
+        from: "            { key: 'lengthKm', label: '管线里程', value: valueText(regionData && regionData.lengthKm), unit: 'km', wide: true },\n        ]),",
+        to: "            { key: 'lengthKm', label: '管线里程', value: valueText(regionData && regionData.lengthKm), unit: 'km', wide: true },\n            { key: 'scale', label: '规模', value: '中型' },\n        ]),",
+    },
+    {
+        id: 'M3',
+        why: 'lengthKm 也被顺手删掉(票面明说保留)→ A1b/A2b/A5/D3b 必须红',
+        file: 'src/modules/cockpit/domain/infrastructurePanels.js',
+        from: "            { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' },\n            { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' },\n        ]),",
+        to: "            { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' },\n        ]),",
+    },
+    {
+        id: 'M4',
+        why: '监测点定位退回只换中心的 setCameraFocus(「没反应」的相机根因复发)→ E9/F1 必须红',
+        file: 'src/modules/cockpit/stores/infrastructureStore.js',
+        from: '        const applied = plan.object.kind === MAP_OBJECT_KIND.MONITORING_POINT\n            ? await flyPoint(plan.object)\n            : focus(target)',
+        to: '        const applied = focus(target)',
+    },
+    {
+        id: 'M5',
+        why: '相机下发结果不核对(把没飞说成飞了)→ F4/F5 必须红',
+        file: 'src/modules/cockpit/stores/infrastructureStore.js',
+        from: '        if (applied !== true) {\n            locate.value = { result: LOCATE_RESULT.UNAVAILABLE, pointCode: plan.pointCode, reasonText: LOCATE_TEXT.UNAVAILABLE }\n            return locate.value\n        }',
+        to: '        if (false) {\n            locate.value = { result: LOCATE_RESULT.UNAVAILABLE, pointCode: plan.pointCode, reasonText: LOCATE_TEXT.UNAVAILABLE }\n            return locate.value\n        }',
+    },
+    {
+        id: 'M6',
+        why: '管理信息主图退回普通 bars(可切换维度的图表块被撤,第 6 条不成立)→ C1/C2 必须红',
+        file: 'src/modules/cockpit/domain/infrastructurePanels.js',
+        from: "        dimensionBarsBlock('片区 × 行业', rows, {",
+        to: "        barsBlock('片区 × 行业', rows, {",
+    },
+    {
+        id: 'M7',
+        why: '维度聚合退回逐行直画(城南两行不合并)→ C3/C4 必须红',
+        file: 'src/modules/cockpit/domain/panelBlocks.js',
+        from: '                entry.count += Number(countOf(row)) || 0\n                for (const [name, source] of Object.entries(extras)) entry[name] += Number(row[source]) || 0\n            }\n            const sorted = sortByCountDesc([...byValue.values()], (row) => row.count)',
+        to: '                entry.count = Number(countOf(row)) || 0\n                for (const [name, source] of Object.entries(extras)) entry[name] = Number(row[source]) || 0\n                byValue.set(key, { ...entry })\n            }\n            const sorted = [...byValue.values()]',
+    },
+    {
+        id: 'M8',
+        why: 'lengthKm 整行独占**退回按 key 特判**(决定权应从 domain 的 wide 滑回渲染层)→ A3b 必须红',
+        file: 'src/modules/cockpit/components/PanelBlocks.vue',
+        from: "                        :class=\"{ 'panel-kpi--wide': item.wide === true }\"",
+        to: "                        :class=\"{ 'panel-kpi--wide': item.key === 'lengthKm' }\"",
+    },
+    {
+        id: 'M9',
+        why: '浮层被重新门控在折叠状态上(「点了没反应」换写法复发)→ E6c 必须红',
+        file: 'src/modules/cockpit/CockpitShell.vue',
+        from: '<div v-if="locateFeedback" class="cockpit-shell__notice" data-locate-notice>',
+        to: '<div v-if="locateFeedback" v-show="statusPanelExpanded" class="cockpit-shell__notice" data-locate-notice>',
+    },
+    {
+        id: 'M10',
+        why: '浮层不自动收起(常驻噪音 + 定时器纪律丢失)→ E8 必须红',
+        // 锚点含驻留毫秒数(票 38 走查定为 8s):以后若调时长,这里要同步改。
+        file: 'src/modules/cockpit/CockpitShell.vue',
+        from: '    locateDismissTimer = window.setTimeout(() => {\n        locateDismissTimer = null\n        infrastructureStore.clearLocate()\n    }, 8000)',
+        to: '    locateDismissTimer = null',
+    },
+    {
+        id: 'M11',
+        why: '第二轮:关联设备回联被撤(`21119740` 又报「不在已加载范围」)→ B5/B5d 必须红',
+        file: 'src/modules/cockpit/domain/mapLocate.js',
+        from: '        found = findDeviceOwner(objects, code)',
+        to: '        found = null',
+    },
+    {
+        id: 'M12',
+        why: '第二轮:概览区域分布又被硬写 null(管线里程再次对不上)→ E13 必须红',
+        file: 'src/modules/cockpit/stores/overviewStore.js',
+        from: '            regions: regionsLoaded ? regions.data : null,',
+        to: '            regions: null,',
+    },
+    {
+        id: 'M13',
+        why: '第二轮:wide 标志从 domain 撤掉(独占整行的呈现意图丢失)→ A3c 必须红',
+        file: 'src/modules/cockpit/domain/overviewPanels.js',
+        from: "unit: 'km', wide: true },",
+        to: "unit: 'km' },",
+    },
+]
+
+const hash = (path) => createHash('sha256').update(readFileSync(path)).digest('hex')
+
+/** 变异源文本按 CRLF 归一后再注入(票 37/43 的教训:PowerShell 写盘是 CRLF,锚点含 \\n 会假未命中)。 */
+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 status = spawnSync(process.execPath, [GATE], { cwd: ROOT, stdio: 'ignore' }).status
+    writeFileSync(path, source)
+    const restored = hash(path) === before
+    const caught = status !== 0
+    console.log(`${caught && restored ? 'OK  ' : 'BAD '} ${m.id} — 闸门${caught ? '转红' : '**没抓住**'};还原${restored ? '按字节一致' : '**失败(哈希不符)**'}。${m.why}`)
+    if (!caught || !restored) failures += 1
+}
+
+console.log('')
+console.log(`T38 mutations: ${MUTATIONS.length - failures}/${MUTATIONS.length} caught`)
+if (failures) process.exit(1)

+ 60 - 0
.scratch/cockpit-real-data-wayfinding/verify/t38-r2-compile-check.mjs

@@ -0,0 +1,60 @@
+/**
+ * T38 第二轮:改动文件的编译/导入自检(票 38 复验反馈)。
+ *
+ * 只做两件事:① 三个 .vue 能被 @vue/compiler-sfc 解析并编译 script+template;
+ * ② 三个改过的 .js 能被 node 真实 import(导出面完整、无语法错)。
+ * 不启动 dev server、不控制浏览器(AGENTS.md 的验证纪律)。
+ */
+import { readFileSync } from 'node:fs'
+import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc'
+
+const ROOT = new URL('../../../', import.meta.url)
+const VUES = ['src/modules/cockpit/components/PanelBlocks.vue', 'src/modules/cockpit/CockpitShell.vue']
+let failed = 0
+
+for (const rel of VUES) {
+    const source = readFileSync(new URL(rel, ROOT), 'utf8')
+    const { descriptor, errors } = parse(source, { filename: rel })
+    if (errors.length) {
+        console.log(`FAIL parse ${rel} — ${errors[0].message}`)
+        failed += 1
+        continue
+    }
+    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 ${rel}`)
+    } catch (error) {
+        console.log(`FAIL compile ${rel} — ${error.message}`)
+        failed += 1
+    }
+}
+
+for (const rel of [
+    'src/modules/cockpit/domain/mapLocate.js',
+    'src/modules/cockpit/domain/overviewPanels.js',
+    'src/modules/cockpit/stores/overviewStore.js',
+    'src/modules/cockpit/stores/infrastructureStore.js',
+]) {
+    try {
+        await import(new URL(rel, ROOT).href)
+        console.log(`PASS import ${rel}`)
+    } catch (error) {
+        console.log(`FAIL import ${rel} — ${error.message}`)
+        failed += 1
+    }
+}
+
+// 回联入口必须真的被 planLocate 用到(不是导出了没人调)。
+const locate = readFileSync(new URL('src/modules/cockpit/domain/mapLocate.js', ROOT), 'utf8')
+const planBody = locate.slice(locate.indexOf('export function planLocate'))
+if (/findDeviceOwner\(objects, code\)/.test(planBody)) console.log('PASS 回联挂在 planLocate 判定链上')
+else {
+    console.log('FAIL planLocate 没有调用 findDeviceOwner(回联是死导出)')
+    failed += 1
+}
+
+console.log('')
+console.log(failed ? `T38 round-2 compile check: ${failed} FAILED` : 'T38 round-2 compile check: all passed')
+if (failed) process.exit(1)

+ 34 - 9
.scratch/cockpit-real-data-wayfinding/verify/t44-distribution-ranking.mjs

@@ -92,21 +92,32 @@ const isDesc = (counts) => counts.every((c, i) => i === 0 || counts[i - 1] >= c)
  * @param {object} view 视图计划
  * @param {Array<{label: string, mode: 'RANK'|'FIXED', counts: number[], keys?: string[]}>} expect 期望表
  */
+/**
+ * 把一块视图展开成待审的分布序列(票 38 新增 dimbars 后必需):
+ * 普通块=一条;dimbars=每个维度一条(标题带维度名,两个维度各自进普查,一个都逃不掉)。
+ * 不这样展开的话,新块类型会**绕过普查过滤器**——那正是本闸门要防的「漏归类」漏洞本身。
+ */
+function seriesOf(block) {
+    if (block.kind !== 'dimbars') return [{ label: block.label, rows: block.rows }]
+    return (block.dimensions || []).map((dim) => ({ label: `${block.label} · ${dim.label}`, rows: dim.rows }))
+}
+
 function audit(name, view, expect) {
-    const blocks = ((view && view.blocks) || []).filter((b) => ['bars', 'donut', 'stacked'].includes(b.kind))
+    const blocks = ((view && view.blocks) || []).filter((b) => ['bars', 'donut', 'stacked', 'dimbars'].includes(b.kind))
     const seen = new Set()
     const orphans = []
     for (const block of blocks) {
-        const hit = expect.filter((e) => e.label === block.label)
+      for (const series of seriesOf(block)) {
+        const hit = expect.filter((e) => e.label === series.label)
         if (hit.length !== 1) {
-            orphans.push(`${block.label}(命中 ${hit.length} 条期望)`)
+            orphans.push(`${series.label}(命中 ${hit.length} 条期望)`)
             continue
         }
         const e = hit[0]
         seen.add(e.label)
-        const counts = block.rows.map((r) => r.count)
-        const keys = block.rows.map((r) => r.key)
-        let detail = `「${block.label}」次序 ${counts.join('、')}`
+        const counts = (series.rows || []).map((r) => r.count)
+        const keys = (series.rows || []).map((r) => r.key)
+        let detail = `「${series.label}」次序 ${counts.join('、')}`
         if (e.keys) detail += `;键 ${keys.join('、')}`
         check(
             `A-${name}-${e.label}`,
@@ -118,6 +129,7 @@ function audit(name, view, expect) {
         } else {
             check(`A-${name}-${e.label}-not-desc`, !isDesc(e.counts), `FIXED 期望必须**非单调**,否则证明不了没被强行降序:${e.counts.join('、')}`)
         }
+      }
     }
     check(`A-${name}-no-unclassified-block`, orphans.length === 0, `存在未归类的分布块:${orphans.join(';')} —— 新增块必须在期望表里显式声明 RANK/FIXED`)
     const ghosts = expect.filter((e) => !seen.has(e.label)).map((e) => e.label)
@@ -440,10 +452,19 @@ function audit(name, view, expect) {
     const history = historyAnalysisPanel(toEventHistory({ data: [], trend: [] }), sv(null))
     audit('history-analysis', history, [])
     const management = managementPanel(
-        { facilityCount: 9, equipmentCount: 11, districtIndustry: [{ district: 0, industry: 'GAS', facilityCount: 5, equipmentCount: 2 }] },
+        { facilityCount: 9, equipmentCount: 11, districtIndustry: [
+            { district: 0, industry: 'GAS', facilityCount: 2, equipmentCount: 2 },
+            { district: 1, industry: 'GAS', facilityCount: 5, equipmentCount: 6 },
+            { district: 1, industry: 'WATER_SUPPLY', facilityCount: 2, equipmentCount: 3 },
+        ] },
         sv(null),
     )
-    audit('management', management, [])
+    // 票 38:管理信息新增 dimbars(片区 × 行业可切换柱状图),两个维度都是排名类别 → RANK。
+    // 标题带维度名(seriesOf 展开),一维漏归类即孤儿。
+    audit('management', management, [
+        { label: '片区 × 行业 · 按片区', mode: 'RANK', counts: [7, 2], keys: ['1', '0'] },
+        { label: '片区 × 行业 · 按行业', mode: 'RANK', counts: [7, 2], keys: ['GAS', 'WATER_SUPPLY'] },
+    ])
     // 运行信息:KPI + 指标清单(票 38 才把它图表化);现在长出分布块就是未声明的改动。
     const operation = operationPanel(
         toOperatingInformation({ industries: [{ code: 'GAS', label: '燃气', metrics: [] }] }),
@@ -511,7 +532,11 @@ function audit(name, view, expect) {
     check('B6c-track-height-6u', /height:\s*calc\(6 \* var\(--u/.test(trackRule), '轨道 5u→6u 承载描边与端帽,但不越过读数行高')
 
     // B7 三条接缝原样(票 37 收口条件:EllipsisText / panel-action / data-bar-* 不变)。
-    check('B7-ellipsis-seam-intact', /<EllipsisText class="panel-bar__label" :text="row\.label" \/>/.test(panelVue), '条形行标签必须仍走共用省略方法')
+    // 票 38:dimbars 复用了同一条 `panel-bar__label` 省略接缝后,该行不再唯一(bars + dimbars 两处)——
+    // 存在性断言在重复行上没有牙(退回一处裸 span,另一处照样命中)。改断「每一处标签行都走 EllipsisText」。
+    const labelLines = (panelVue.match(/class="panel-bar__label"/g) || []).length
+    const ellipsisLabelLines = (panelVue.match(/<EllipsisText class="panel-bar__label"/g) || []).length
+    check('B7-ellipsis-seam-intact', labelLines >= 2 && labelLines === ellipsisLabelLines, `条形行标签必须**全部**走共用省略方法(标签行 ${labelLines} 处 / EllipsisText ${ellipsisLabelLines} 处)`)
     check('B7b-panel-action-seam-intact', /emit\('panel-action', \{ kind: block\.action, key: row\.actionKey \}\)/.test(panelVue), 'panel-action 仍只转发 actionKey(视觉改动不碰语义接缝)')
     check('B7c-assertion-surface-intact', /data-bar="row\.key"/.test(panelVue) && /data-bar-count/.test(panelVue) && /data-bar-selected/.test(panelVue) && /data-bar-tone/.test(panelVue), '走查断言面 data-bar-* 原样保留')
 

+ 15 - 11
.scratch/cockpit-shell-panel-layout/verify/t20-overview-panels.mjs

@@ -108,10 +108,12 @@ const read = (path) => readFileSync(new URL(path, moduleRoot), 'utf8')
         `设施 ${infra.facilityCount} · 智能设备 ${infra.smartDeviceCount} · 行业 ${infra.industries.length}`,
     )
     // BigDecimal 在线上是字符串(`@JsonFormat(shape=STRING)`):必须认字符串,不能变假 0。
+    // 票 38(第 2 条):`facilityTypes[].scale` 不再透传——真实值是 "km"(长度单位)不是规模语义,
+    // 长度已由 lengthKm 承担,界面零「规模」展示位;留字段只会诱导出第二次误用。
     check(
         'B1b-bigdecimal-string-is-read',
-        infra.industries[0].facilityTypes[0].lengthKm === 12.4 && infra.industries[0].facilityTypes[0].scale === 'medium',
-        `lengthKm "12.4" → ${infra.industries[0].facilityTypes[0].lengthKm}(字符串被认下,不是 0)`,
+        infra.industries[0].facilityTypes[0].lengthKm === 12.4 && !('scale' in infra.industries[0].facilityTypes[0]),
+        `lengthKm "12.4" → ${infra.industries[0].facilityTypes[0].lengthKm}(字符串被认下,不是 0;规模已随票 38 退役)`,
     )
     check(
         'B1c-industry-label-falls-back-to-dictionary',
@@ -247,12 +249,14 @@ const read = (path) => readFileSync(new URL(path, moduleRoot), 'utf8')
 
     const regions = toOverviewRegions({
         totals: { facilityCount: 5, lengthKm: '12.4', scale: 'medium' },
-        regionDistribution: [{ code: '城区A', count: 3 }, { code: '未分区', count: 2 }],
+        // 票 38 第二轮:灌**真实后端形状**(决策 06 后是 `districtDistribution[{district,districtName,count}]`,
+        // 不是早期的 `regionDistribution[{code,count}]`)。片区编码走 `normalizeDistrict`/`districtLabel`。
+        districtDistribution: [{ district: 0, districtName: '城南', count: 3 }, { district: null, districtName: '未分区', count: 2 }],
     })
     check(
         'B4-regions-come-from-the-sibling-endpoint',
-        regions.regions.length === 2 && regions.regions[0].label === '城区A' && regions.lengthKm === 12.4,
-        `区域分布借票 17 的端点:${regions.regions.map((row) => `${row.label}${row.count}`).join(' ')}`,
+        regions.regions.length === 2 && regions.regions[0].label === '城南' && regions.lengthKm === 12.4,
+        `片区分布借票 17 的端点:${regions.regions.map((row) => `${row.label}${row.count}`).join(' ')}`,
     )
 }
 
@@ -331,9 +335,9 @@ const read = (path) => readFileSync(new URL(path, moduleRoot), 'utf8')
     check(
         'C2-infrastructure-has-industry-and-region',
         infra.blocks.some((block) => block.label === '行业构成 · 设施') &&
-            infra.blocks.some((block) => block.label === '区域分布') &&
-            infra.blocks.find((block) => block.label === '区域分布').rows.length === 1,
-        `设施底数 + 行业构成 + 区域分布(验收第 2 条三项齐)`,
+            infra.blocks.some((block) => block.label === '片区分布') &&
+            infra.blocks.find((block) => block.label === '片区分布').rows.length === 1,
+        `设施底数 + 行业构成 + 片区分布(验收第 2 条三项齐;决策 06 后统一叫「片区分布」)`,
     )
     check(
         'C2b-industry-rows-are-actionable',
@@ -347,10 +351,10 @@ const read = (path) => readFileSync(new URL(path, moduleRoot), 'utf8')
         '区域取不到时如实说明它来自哪个端点,不假装有',
     )
     check(
-        'C2d-length-and-scale-come-from-the-sibling-endpoint',
+        'C2d-length-comes-from-the-sibling-endpoint',
         infra.blocks[0].items.find((item) => item.key === 'lengthKm').value === '12.4' &&
-            infra.blocks[0].items.find((item) => item.key === 'scale').value === '中型',
-        '里程与规模取自票 17 的档案端点(本概览端点不给),英文规模枚举被翻成中文',
+            !infra.blocks[0].items.some((item) => item.key === 'scale'),
+        '里程取自票 17 的档案端点(本概览端点不给);票 38 起「规模」读数已删除(真实后端 totals.scale 恒 null)',
     )
 
     const monitoring = monitoringSummaryPanel(data.monitoring, ready(data.monitoring))

+ 101 - 11
src/modules/cockpit/CockpitShell.vue

@@ -238,18 +238,10 @@
                 />
 
                 <!--
-                    票 17:定位反馈。点面板里的可定位对象时,只有三种结果值得说话 ——
-                    点位不在已加载范围内、后端判定不可上图、地图还没就绪。
-                    成功不给文字(地图自己会动、详情卡会开,再弹一句是噪音)。
+                    票 17:定位反馈。**票 38(第 5 条)把它从这块折叠详情里挪走了**——
+                    「状态详情」默认收起,失败提示藏在里面等于「点了没反应」;
+                    现在它是主体区底部的浮层(见 cockpit-shell__notice),这里只留注释锚点。
                 -->
-                <span
-                    v-if="locateFeedback"
-                    class="cockpit-shell__chip cockpit-shell__locate"
-                    :data-locate-result="locateFeedback.result"
-                    role="status"
-                >
-                    {{ locateFeedback.reasonText }}
-                </span>
 
                 <span v-if="detailOpen && !introVisible && !fixedLocationReason && !anchorLayout.visible"
                     class="cockpit-shell__chip cockpit-shell__anchor-status" data-anchor-hidden
@@ -447,6 +439,23 @@
                 class="cockpit-shell__event-detail" aria-label="事件详情" @keydown.esc="eventStore.closeEvent()">
                 <EventDetailCard />
             </aside>
+            <!--
+                票 38(第 5 条):定位反馈浮层。原来它挂在顶部「状态详情」里,而那块**默认收起**——
+                点「定位」后失败提示永远看不见,维护者读作「点了没反应」。「没反应」是缺陷,
+                所以三种失败(不在已加载范围 / 后端判定不可上图 / 地图未就绪)必须**当场可见**:
+                居中底部浮层,自动收起。成功仍不给文字(地图真的会动、详情卡会开,再弹一句是噪音)。
+                data-locate-result 断言面随它搬到这里,走查仍按该属性取数。
+            -->
+            <div v-if="locateFeedback" class="cockpit-shell__notice" data-locate-notice>
+                <span
+                    class="cockpit-shell__notice-chip cockpit-shell__locate"
+                    :data-locate-result="locateFeedback.result"
+                    role="status"
+                >
+                    {{ locateFeedback.pointCode }} · {{ locateFeedback.reasonText }}
+                </span>
+                <button type="button" class="cockpit-shell__notice-close" data-locate-close @click="dismissLocateFeedback">关闭</button>
+            </div>
         </main>
 
         <!-- 进入引导(票 30):覆盖全屏,点击进入后飞到目标城市预设视角 -->
@@ -665,6 +674,33 @@ const locateFeedback = computed(() => {
     return current
 })
 
+/**
+ * 票 38:反馈浮层自动收起。失败提示要**当场看得见**(不再藏在折叠面板里),
+ * 但也不能常驻——值班人员接着点下一个点位时,上一条的说明早该走了。
+ * 定时器随结果变化重建:同一点位重取(result 相同但 pointCode 变)也算一次新反馈。
+ * 手动「关闭」与自动到点走同一个 `clearLocate`(store 是唯一持有者,壳不自己抹状态)。
+ */
+let locateDismissTimer = null
+watch(locateFeedback, (current) => {
+    if (locateDismissTimer) {
+        window.clearTimeout(locateDismissTimer)
+        locateDismissTimer = null
+    }
+    if (!current) return
+    locateDismissTimer = window.setTimeout(() => {
+        locateDismissTimer = null
+        infrastructureStore.clearLocate()
+    }, 8000)
+})
+
+function dismissLocateFeedback() {
+    if (locateDismissTimer) {
+        window.clearTimeout(locateDismissTimer)
+        locateDismissTimer = null
+    }
+    infrastructureStore.clearLocate()
+}
+
 /** 点面板里的可定位对象 → 地图定位(接缝在 store,本组件只管转发)。 */function locateOnMap(pointCode) {
     return infrastructureStore.locatePoint(pointCode)
 }
@@ -1755,6 +1791,11 @@ onBeforeUnmount(() => {
     runtimeStore.stop()
     // 票 16:刷新周期的定时器同理必须停(票 15 刚在"漏取消定时器"上栽过一次)。
     runtimeStore.stopFreshness()
+    // 票 38:定位反馈的自动收起定时器同理必须拆——离开外壳后它不该再去动 store 状态。
+    if (locateDismissTimer) {
+        window.clearTimeout(locateDismissTimer)
+        locateDismissTimer = null
+    }
 })
 // ---- 票 25:状态面板里的地图信息读数 ----
 // 计数一律取自**同一份模式图层计划**(`modeLayerPlan`),不另开一份真相;
@@ -2367,6 +2408,55 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
     border-color: rgba(255, 176, 32, 0.5);
 }
 
+/*
+   票 38(第 5 条):定位失败的可见反馈浮层。挂在 `<main>` 底部居中——
+   两张面板栏之间、地图之上,不随「状态详情」的折叠而消失(那正是「点了没反应」的来源)。
+   pointer-events 只给关闭按钮以外的胶囊本体与边框(浮层不该挡地图操作)。
+*/
+.cockpit-shell__notice {
+    position: absolute;
+    z-index: 6;
+    bottom: calc(14 * var(--u));
+    left: 50%;
+    transform: translateX(-50%);
+    display: flex;
+    align-items: center;
+    gap: calc(6 * var(--u));
+    max-width: min(72%, calc(100% - 2 * var(--column-w)));
+    pointer-events: none;
+}
+
+.cockpit-shell__notice-chip {
+    min-width: 0;
+    padding: calc(4 * var(--u)) var(--space-2);
+    border: 1px solid rgba(255, 176, 32, 0.5);
+    border-radius: calc(8 * var(--u));
+    background: rgba(2, 10, 20, 0.88);
+    box-shadow: var(--shadow);
+    font-family: var(--font-topbar);
+    font-size: var(--fs-caption, 11.5px);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.cockpit-shell__notice-close {
+    pointer-events: auto;
+    padding: calc(3 * var(--u)) calc(8 * var(--u));
+    border: 1px solid rgba(1, 171, 234, 0.4);
+    border-radius: calc(8 * var(--u));
+    background: rgba(2, 10, 20, 0.88);
+    color: var(--text-2, #88b7e8);
+    font-family: var(--font-topbar);
+    font-size: var(--fs-caption, 11.5px);
+    cursor: pointer;
+}
+
+.cockpit-shell__notice-close:hover {
+    color: var(--text-strong);
+    border-color: rgba(1, 171, 234, 0.7);
+}
+
 /* 票 15:状态推送连接状态。连上时是常态色(正常运行),未连上/鉴权失败转告警色。 */
 .cockpit-shell__ws {
     color: var(--muted, #5680b0);

+ 120 - 1
src/modules/cockpit/components/PanelBlocks.vue

@@ -61,7 +61,17 @@
                      2026-10-13 维护者要求:每项读数左侧带语义图标(domain 的 KPI_ICONS
                      按 key 映射,未命中不画);图标 aria-hidden 不参与命名。 -->
                 <div v-if="block.kind === 'kpi'" class="panel-kpis" data-kpi-row>
-                    <div v-for="item in block.items" :key="item.key" class="panel-kpi" :class="{ 'panel-kpi--wide': item.key === 'lengthKm' }" :data-kpi="item.key">
+                    <!-- 票 38(第 4 条):基础档案的「设施总数 + 管线里程」两项落回同一张
+                         auto-fit 网格自然同行。
+                         票 38 第二轮(反馈第 1 条):整行独占改由 domain 的通用 `wide` 标志声明
+                         (概览基础设施的管线里程独占一行排版更好看),渲染层不认识任何 key。 -->
+                    <div
+                        v-for="item in block.items"
+                        :key="item.key"
+                        class="panel-kpi"
+                        :class="{ 'panel-kpi--wide': item.wide === true }"
+                        :data-kpi="item.key"
+                    >
                         <!-- 票 37 走查扩展:读数图标走 mask 染语义色(KPI_ICON_COLORS,未命中主蓝)。
                              色画在容器上(currentColor),图标本体是白剪影。 -->
                         <span
@@ -137,6 +147,48 @@
                     <p v-if="!block.rows.length" class="panel-block__empty" data-bar-empty>该维度当前没有记录</p>
                 </div>
 
+                <!--
+                    可切换维度的分类柱状图(票 38 · 管理信息「片区 × 行业」)。
+                    维度切换是**纯展示状态**(组件本地):两个维度的行都由 domain 预先聚合好,
+                    点标签只换 rows,不回后端、不进全局筛选——它回答「同一份计数换哪个维度看」,
+                    不是筛选条件(与条形图可点行的 `panel-action` 接缝刻意分开,互不冒充)。
+                    柱/条/读数与 `.panel-bar` 同一套画法与色键(barColor + 语义 tone),
+                    `data-dimbar-*` 与 `data-dim-tab-*` 是走查断言面。
+                -->
+                <div v-else-if="block.kind === 'dimbars'" class="panel-dimbars" data-dimbars-list>
+                    <div class="panel-dimbars__tabs" role="group" :aria-label="`${block.label} · 维度切换`">
+                        <button
+                            v-for="dim in block.dimensions"
+                            :key="dim.key"
+                            type="button"
+                            class="panel-dimbars__tab"
+                            :class="{ 'is-active': dim.key === activeDimension(block) }"
+                            :aria-pressed="dim.key === activeDimension(block)"
+                            :data-dim-tab="dim.key"
+                            @click="setDimension(block, dim.key)"
+                        >
+                            {{ dim.label }}
+                        </button>
+                    </div>
+                    <div v-if="currentDim(block)" class="panel-bars panel-dimbars__bars">
+                        <div
+                            v-for="row in currentDim(block).rows"
+                            :key="row.key"
+                            class="panel-bar"
+                            :data-dimbar="row.key"
+                            :data-dim-tone="row.tone || null"
+                            @mouseenter="showChartTip($event, row.label, row.hint || `${row.count}${block.unit}`, barColor(row.tone))"
+                            @mouseleave="hideChartTip"
+                        >
+                            <EllipsisText class="panel-bar__label" :text="row.label" />
+                            <i class="panel-bar__track"><i class="panel-bar__fill" :style="{ width: `${Math.round(row.ratio * 100)}%`, background: barColor(row.tone) }"></i></i>
+                            <span class="panel-bar__count" data-dimbar-count>{{ row.count }}</span>
+                        </div>
+                        <p v-if="!currentDim(block).rows.length" class="panel-block__empty" data-dimbar-empty>{{ block.emptyText }}</p>
+                    </div>
+                    <p v-else class="panel-block__empty" data-dimbar-empty>{{ block.emptyText }}</p>
+                </div>
+
                 <!--
                     趋势:分桶序列(桶粒度与顺序由后端给,本层不重采样)。
                     2026-10-13 维护者图表选型:时间趋势优先折线;<2 点或柱形态仍画柱列。
@@ -506,6 +558,33 @@ const emit = defineEmits(['locate', 'open-card', 'ledger-more', 'badge-needed',
 /** 还没取过数(界面说"正在取数",不说"没有数据")。 */
 const pending = computed(() => sectionPending(props.view.section))
 
+/**
+ * 票 38:维度切换的**组件本地状态**(键=面板名 + 块标题,值=当前维度键)。
+ * 为什么放组件而不是 store:「换哪个维度看」不是全局条件(与 industry/district/deviceType
+ * 不同层,切模式/刷新都不该动它);两个维度的行 domain 已经算完,这里只决定显示哪一份。
+ * 键不用块对象:视图计划每次重算都生成新块对象,按对象记会被刷新顶掉(表现为「切了又跳回」)。
+ */
+const dimensionOverrides = ref({})
+
+function blockKey(block) {
+    return `${props.view.name}|${block.label}`
+}
+
+function activeDimension(block) {
+    const override = dimensionOverrides.value[blockKey(block)]
+    if (override && (block.dimensions || []).some((dim) => dim.key === override)) return override
+    return block.active || (block.dimensions && block.dimensions[0] ? block.dimensions[0].key : '')
+}
+
+function currentDim(block) {
+    const key = activeDimension(block)
+    return (block.dimensions || []).find((dim) => dim.key === key) || null
+}
+
+function setDimension(block, key) {
+    dimensionOverrides.value = { ...dimensionOverrides.value, [blockKey(block)]: key }
+}
+
 function isPlaceholder(text) {
     return text === DETAIL_PLACEHOLDER
 }
@@ -790,6 +869,12 @@ onBeforeUnmount(() => {
     grid-column: 1 / -1;
 }
 
+/* 票 38 第二轮(反馈第 1 条):整行独占**回归**,但改由 domain 的通用 `wide` 标志声明
+   (概览基础设施的管线里程数值最长,独占一行排版更好看)。
+   与第一轮删掉的那版区别:那时是渲染层按 `item.key === 'lengthKm'` 特判(第二份布局语义),
+   现在组件只认 `item.wide` 这个通用呈现意图,「谁独占」的决定权在 domain。
+   列布局沿用 HEAD 原样(图标 / 标签 / 右对齐数值三列)——「占一整行」由 `grid-column`
+   表达,不需要顺带改写卡片内部排布。 */
 .panel-kpi--wide {
     grid-column: 1 / -1;
     grid-template-columns: auto minmax(0, 1fr) auto;
@@ -863,6 +948,40 @@ onBeforeUnmount(() => {
     gap: calc(2 * var(--u, 1px));
 }
 
+/* 票 38:dimbars 的维度切换标签 + 柱列。柱/条复用 .panel-bar 的同一套画法,
+   这里只管切换器本身——小胶囊按钮,选中态与顶栏筛选 chips 同一视觉语言(is-active)。 */
+.panel-dimbars {
+    display: grid;
+    gap: var(--space-2, 8px);
+}
+
+.panel-dimbars__tabs {
+    display: flex;
+    gap: calc(4 * var(--u, 1px));
+}
+
+.panel-dimbars__tab {
+    padding: calc(2 * var(--u, 1px)) calc(8 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.24);
+    border-radius: calc(8 * var(--u, 1px));
+    background: var(--inset, rgba(2, 10, 20, 0.55));
+    color: var(--text-2, #88b7e8);
+    font-family: var(--font-topbar);
+    font-size: var(--fs-caption, 10.5px);
+    cursor: pointer;
+}
+
+.panel-dimbars__tab.is-active {
+    color: var(--text-strong, #fff);
+    border-color: rgba(0, 184, 255, 0.85);
+    background: rgba(1, 171, 234, 0.16);
+}
+
+.panel-dimbars__tab:focus-visible {
+    outline: 1px solid var(--cyan, #01abea);
+    outline-offset: 2px;
+}
+
 /* 可点的状态分布行:按钮要抹掉浏览器默认外观,但仍保留可聚焦与 hover 反馈。 */
 .panel-bars--clickable .panel-bar--button {
     width: 100%;

+ 29 - 6
src/modules/cockpit/domain/infrastructurePanels.js

@@ -26,6 +26,7 @@ import {
     barsBlock,
     childHasContent,
     codeText,
+    dimensionBarsBlock,
     donutBlock,
     kpiBlock,
     listBlock,
@@ -100,10 +101,9 @@ export function toBaseArchives(raw) {
     return {
         updatedAt: reader.optionalStr('updatedAt'),
         rangeType: (reader.optionalObj('range') || {}).type || '',
-        // 设施规模
+        // 设施底数与里程(票 38:`totals.scale` 不再解析——真实后端恒 null,「规模」读数已删除)
         facilityCount: numberish(totals.raw.facilityCount),
         lengthKm: numberish(totals.raw.lengthKm),
-        scale: totals.optionalStr('scale'),
         // 行业分类 / 区域分布 / 档案摘要(这三个分布的行都取值在 `code` 上)
         industries: distributionRows(reader.raw.industryDistribution, 'code', industryLabel),
         regions: reader.optionalList('districtDistribution').map((row) => ({ code: normalizeDistrict(row.district), label: districtLabel(row.district), count: numberish(row.count) })),
@@ -282,10 +282,12 @@ export function toEmergencySupport(raw) {
 export function archivePanel(data, section) {
     const current = data || {}
     const blocks = [
+        // 票 38(第 2、4 条):删除「规模」KPI(真实后端 totals.scale 恒 null,是空读数);
+        // 设施总数与管线里程回到**同一行**呈现 —— 渲染层不再有 lengthKm 独占整行的特判,
+        // KPI 网格 auto-fit 让两项并排,不改变其它读数。
         kpiBlock([
             { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' },
             { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' },
-            { key: 'scale', label: '规模', value: current.scale ? codeText(current.scale) : DETAIL_PLACEHOLDER },
         ]),
         // 2026-10-13 维护者反馈:条形图太多易疲劳——行业分类改环形图(行业语义色),
         // 片区/年限保留条形(条形适合作排名比较与多类别)。
@@ -392,18 +394,39 @@ export function operationPanel(data, section) {
     }
 }
 
-/** 管理信息面板:保留 panelId 和布局,展示片区 × 行业设施/设备计数。 */
+/**
+ * 管理信息面板(票 38 第 6 条):片区 × 行业**图表化**。
+ *
+ * 主图是**可切换维度的分类柱状图**(按片区 / 按行业两维,domain 各聚合一遍,切换纯展示、
+ * 不回后端);下方保留紧凑**卡片清单**(逐 片区×行业 行给设施/设备计数,图看不出的组合明细看它)。
+ * 只消费 `districtIndustry[]`;已退役的 `organizations`/`ownershipDistribution`/`unmaintained` 不再读取。
+ * 「责任区域」=片区别名(决策 06),不新增独立维度。
+ */
 export function managementPanel(data, section) {
     const current = data || {}
+    const rows = current.districtIndustry || []
 
     const blocks = [
         kpiBlock([
             { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' },
             { key: 'equipmentCount', label: '设备总数', value: valueText(current.equipmentCount), unit: '台' },
         ]),
+        // 主图:按片区 / 按行业切换的分类柱状图(主计数=设施数,设备数进悬停说明)。
+        dimensionBarsBlock('片区 × 行业', rows, {
+            countOf: (row) => row.facilityCount,
+            extras: { equipmentCount: 'equipmentCount' },
+            hintOf: (row) => `设备 ${valueText(row.equipmentCount)} 台`,
+            unit: '处',
+            emptyText: '当前范围没有片区与行业计数',
+            dimensions: [
+                { key: 'district', label: '按片区', field: 'district', emptyLabel: '未分区', labelOf: (value) => districtLabel(value) },
+                { key: 'industry', label: '按行业', field: 'industry', emptyLabel: '行业未知', labelOf: (value) => industryLabel(value) || codeText(value) },
+            ],
+        }),
+        // 下方紧凑清单:片区×行业逐组合的设施/设备计数(图看合计,清单看明细)。
         listBlock(
-            '片区 × 行业',
-            (current.districtIndustry || []).map((row) => ({
+            '片区 × 行业明细',
+            rows.map((row) => ({
                 key: `${row.district ?? 'UNASSIGNED'}|${row.industry}`,
                 title: `${districtLabel(row.district)} · ${industryLabel(row.industry) || '行业未知'}`,
                 meta: [`设施 ${valueText(row.facilityCount)} 处`, `设备 ${valueText(row.equipmentCount)} 台`],

+ 38 - 5
src/modules/cockpit/domain/mapLocate.js

@@ -51,6 +51,29 @@ export function findMapObject(objects, pointCode) {
     return matched.find((item) => item.kind === MAP_OBJECT_KIND.MONITORING_POINT) || matched[0]
 }
 
+/**
+ * 按**设备编码**回联它的宿主监测点(票 38 第二轮,维护者 2026-10-15 复验反馈第 2 条)。
+ *
+ * 关联设备(如真实数据里的 `21119740`)不以自己的编码上图:它是宿主点位标记的
+ * `deviceCodes` 成员(设备点画在点位坐标上)。`findMapObject` 按对象编码找当然找不到——
+ * 之前把它如实报成「不在已加载范围」,维护者的判语是「实际上该点位是已加载在图中的真实设备」。
+ * 回联规则与 `domain/eventDetail.js` 的事件定位同一立场:先精确编码,再 `deviceCodes` 反查。
+ */
+export function findDeviceOwner(objects, deviceCode) {
+    const code = typeof deviceCode === 'string' ? deviceCode.trim() : ''
+    if (!code) return null
+    const list = Array.isArray(objects) ? objects : []
+    return (
+        list.find(
+            (item) =>
+                item &&
+                item.kind === MAP_OBJECT_KIND.MONITORING_POINT &&
+                Array.isArray(item.deviceCodes) &&
+                item.deviceCodes.some((c) => String(c).trim() === code),
+        ) || null
+    )
+}
+
 /**
  * 规划一次定位(纯函数,不移动相机、不打开详情)。
  *
@@ -65,20 +88,30 @@ export function findMapObject(objects, pointCode) {
  */
 export function planLocate({ objects, pointCode, mapReady = true } = {}) {
     const code = typeof pointCode === 'string' ? pointCode.trim() : ''
-    const found = findMapObject(objects, code)
+    let found = findMapObject(objects, code)
+    // 票 38 第二轮(维护者复验反馈第 2 条):**关联设备不以自身编码上图**(如真实数据里的
+    // `21119740`「测试环境监测设备01」——它是宿主监测点 `deviceCodes` 的成员,画在宿主点位上)。
+    // 直接按编码找不到对象时回联宿主监测点:定位 = 走宿主**点位的点击事件**(飞到目标 + 弹详情),
+    // 而不是把「设备已加载在图中」误报成「该点位不在已加载范围」。
+    // 回联规则与 `domain/eventDetail.js` 的事件定位同源(先精确编码,再 `deviceCodes` 反查)。
+    let deviceCode = ''
+    if (!found) {
+        found = findDeviceOwner(objects, code)
+        if (found) deviceCode = code
+    }
 
     if (!found) {
-        return { result: LOCATE_RESULT.MISSING, object: null, pointCode: code, reasonText: LOCATE_TEXT.MISSING }
+        return { result: LOCATE_RESULT.MISSING, object: null, pointCode: code, deviceCode: '', reasonText: LOCATE_TEXT.MISSING }
     }
     if (!isRenderable(found)) {
         // 票 07:不上图 ≠ 不存在。原因用同一个词表(`blockReason`),界面照实说。
-        return { result: LOCATE_RESULT.BLOCKED, object: found, pointCode: code, reasonText: blockReason(found).text }
+        return { result: LOCATE_RESULT.BLOCKED, object: found, pointCode: code, deviceCode, reasonText: blockReason(found).text }
     }
     if (!mapReady) {
-        return { result: LOCATE_RESULT.UNAVAILABLE, object: found, pointCode: code, reasonText: LOCATE_TEXT.UNAVAILABLE }
+        return { result: LOCATE_RESULT.UNAVAILABLE, object: found, pointCode: code, deviceCode, reasonText: LOCATE_TEXT.UNAVAILABLE }
     }
 
-    return { result: LOCATE_RESULT.OK, object: found, pointCode: code, reasonText: '' }
+    return { result: LOCATE_RESULT.OK, object: found, pointCode: code, deviceCode, reasonText: '' }
 }
 
 /** 定位后的相机目标(只给地图服务,不在界面出现坐标数字)。 */

+ 11 - 5
src/modules/cockpit/domain/overviewPanels.js

@@ -119,7 +119,9 @@ export function toOverviewInfrastructure(raw) {
                 smartDeviceCount: numberish(row.raw.smartDeviceCount),
                 facilityTypes: (row.raw.facilityTypes || []).map((type) => {
                     const row2 = fieldReader(type, 'facilityType')
-                    return { code: row2.optionalStr('code'), name: row2.optionalStr('name') || row2.optionalStr('code'), count: numberish(row2.raw.count), lengthKm: numberish(row2.raw.lengthKm), scale: row2.optionalStr('scale') }
+                    // 票 38(第 2 条):`scale` 不再透传——真实值是 "km"(长度单位)不是规模语义,
+                    // 长度已由 lengthKm 承担,界面没有任何「规模」展示位(保留它只会诱导出第二次误用)。
+                    return { code: row2.optionalStr('code'), name: row2.optionalStr('name') || row2.optionalStr('code'), count: numberish(row2.raw.count), lengthKm: numberish(row2.raw.lengthKm) }
                 }),
             }
         }),
@@ -287,10 +289,11 @@ export function toOverviewWarningSettings(raw) {
  *
  * 单独一个薄函数而不是在视图里直接调 `toBaseArchives`:这样"本面板的区域分布来自哪个端点"
  * 只有一处可查,且视图层不必认识票 17 的载荷形状。
+ * 票 38:`scale` 不再带出(真实后端恒 null,「规模」读数已从界面删除);`lengthKm` 保留。
  */
 export function toOverviewRegions(raw) {
     const archives = toBaseArchives(raw)
-    return { regions: archives.regions || [], lengthKm: archives.lengthKm, scale: archives.scale, missing: archives.missing }
+    return { regions: archives.regions || [], lengthKm: archives.lengthKm, missing: archives.missing }
 }
 
 // ================================================================ 视图计划
@@ -405,9 +408,12 @@ export function infrastructureSummaryPanel(data, section) {
             { key: 'facilityCount', label: '设施总数', value: valueText(infra.facilityCount), unit: '处' },
             { key: 'smartDeviceCount', label: '智能设备', value: valueText(infra.smartDeviceCount), unit: '台' },
             { key: 'industryCount', label: '行业数', value: String((infra.industries || []).length), unit: '个' },
-            // 里程与规模来自票 17 那份档案(本端点不给),取不到时显示占位符。
-            { key: 'lengthKm', label: '管线里程', value: valueText(regionData && regionData.lengthKm), unit: 'km' },
-            { key: 'scale', label: '规模', value: regionData && regionData.scale ? codeText(regionData.scale) : DETAIL_PLACEHOLDER },
+            // 里程来自票 17 那份档案(本端点不给),取不到时显示占位符。
+            // 票 38(第 2 条):「规模」读数已删除——真实后端 totals.scale 恒 null,是空读数。
+            // 票 38 第二轮(维护者复验反馈第 1 条):概览这块 `wide` 让管线里程独占整行——
+            // 四个读数里它数值最长(真实 62.60552),并排会挤压;`wide` 由 domain 声明,
+            // 渲染层只认这个通用标志、不再按 key 特判(与「谁独占整行归 domain」同一条纪律)。
+            { key: 'lengthKm', label: '管线里程', value: valueText(regionData && regionData.lengthKm), unit: 'km', wide: true },
         ]),
         // 2026-10-13 维护者反馈:条形图太多易疲劳——行业构成是「类别少、占比」场景,改环形图;
         // 行业行走行业语义色(与行业切图同源);图例行保留联动(点它写全局行业筛选,接缝不变)。

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

@@ -51,9 +51,9 @@ export const CODE_TEXT = {
     PRESSURE: '压力',
     QUALITY: '水质',
     CAPACITY: '承载',
-    // 设施规模。**后端不翻译它**:`scale` 是数据源透传的自由字符串,
-    // 契约手册与 `CockpitContractDictionaryTest` 都只把它当值(测试断言 `"medium"`)。
-    // 漏了这三个词,界面上就会直接印出英文枚举(票 17 真机走查逮到过一次:`规模=medium`)。
+    // 设施规模枚举(票 38 已删除界面上唯一的「规模」读数——真实后端 totals.scale 恒 null)。
+    // 这三行翻译**保留**:`scale` 仍是后端透传的自由字符串,词表若删,将来任何一处误把它
+    // 画上屏就直接漏出英文枚举(票 17 真机走查逮到过「规模=medium」)。翻译只有一份。
     large: '大型',
     medium: '中型',
     small: '小型',
@@ -219,9 +219,8 @@ export const KPI_ICONS = {
     handlingRateView: 'efficiency',
     responseDuration: 'time',
     closeDuration: 'time',
-    // 基础档案
+    // 基础档案(票 38:`scale` 规模读数已删除——真实后端 totals.scale 恒 null)
     lengthKm: 'facility',
-    scale: 'scale',
     // 应急保障
     hazardSourceCount: 'unknownOrMissing',
     protectionTargetCount: 'shield',
@@ -432,6 +431,81 @@ export function donutBlock(label, rows, options = {}) {
     }
 }
 
+/**
+ * 可切换维度的**分类柱状图**块(票 38 · 管理信息「片区 × 行业」)。
+ *
+ * 与 `barsBlock`(单维度横条)的分工:本块服务「同一份行数据、按两个字段各聚合一遍」的
+ * 场景(决策 06:本项目只有片区/行业两个维度)。**两个维度都由 domain 预先聚合**
+ * (`dimensions: [{key,label,rows}]`),切换是纯展示动作 —— 零后端往返、零第二次聚合,
+ * 组件也不认识「片区」「行业」是什么(它只按 `block.kind` 分派画法)。
+ *
+ * 行柱长口径与 `barsBlock` 一致:相对该维度最大项归一(全 0 时给 0 而不是 NaN)。
+ * 计数为 0 的类别保留行(画零高柱)——0 是真实值,不是缺失。
+ *
+ * @param {string} label 块标题
+ * @param {Array} rows 原始行(如 `districtIndustry[]`:每行带 district/industry/计数)
+ * @param {object} options
+ *   `dimensions: [{key,label,field,emptyLabel,labelOf}]` 维度清单(field=行上取值字段);
+ *   `countOf(row)` 取主计数(缺省 `row.count`);
+ *   `extras: {名字: 行字段}` 逐维度累加的辅助计数;
+ *   `hintOf(aggRow)` 由聚合行生成悬停说明(如「设备 N 台」);
+ *   `active` 默认维度键(认不出取第一个);`unit`/`emptyText` 文案。
+ */
+export function dimensionBarsBlock(label, rows, options = {}) {
+    const list = Array.isArray(rows) ? rows : []
+    const countOf = typeof options.countOf === 'function' ? options.countOf : (row) => (row && row.count) || 0
+    const hintOf = typeof options.hintOf === 'function' ? options.hintOf : null
+    const extras = options.extras || {}
+    const isBlank = (value) => value === null || value === undefined || String(value).trim() === ''
+
+    const dimensions = (Array.isArray(options.dimensions) ? options.dimensions : [])
+        .filter((dim) => dim && dim.key && dim.field)
+        .map((dim) => {
+            const labelOf = typeof dim.labelOf === 'function' ? dim.labelOf : (value) => codeText(value)
+            const byValue = new Map()
+            for (const row of list) {
+                if (!row) continue
+                const raw = row[dim.field]
+                const blank = isBlank(raw)
+                const key = blank ? 'UNASSIGNED' : String(raw).trim()
+                let entry = byValue.get(key)
+                if (!entry) {
+                    entry = { key, label: blank ? dim.emptyLabel || CODE_EMPTY : labelOf(raw), count: 0 }
+                    for (const name of Object.keys(extras)) entry[name] = 0
+                    byValue.set(key, entry)
+                }
+                entry.count += Number(countOf(row)) || 0
+                for (const [name, source] of Object.entries(extras)) entry[name] += Number(row[source]) || 0
+            }
+            const sorted = sortByCountDesc([...byValue.values()], (row) => row.count)
+            const max = sorted.reduce((top, row) => Math.max(top, row.count), 0)
+            return {
+                key: dim.key,
+                label: dim.label,
+                rows: sorted.map((row, index) => ({
+                    key: row.key,
+                    label: row.label,
+                    count: row.count,
+                    ratio: max > 0 ? row.count / max : 0,
+                    tone: barTone(row.label, index),
+                    hint: hintOf ? hintOf(row) : '',
+                })),
+            }
+        })
+
+    const wanted = options.active || ''
+    const active = dimensions.find((dim) => dim.key === wanted) || dimensions[0] || null
+    return {
+        kind: 'dimbars',
+        label,
+        unit: options.unit || '',
+        emptyText: options.emptyText || '该维度当前没有记录',
+        dimensions,
+        // `active` 只是**默认显示**的维度;另一维度的 rows 已经算好,切换不回 domain。
+        active: active ? active.key : '',
+    }
+}
+
 /** 键值行块。 */
 export function rowsBlock(label, rows) {
     return { kind: 'rows', label, rows }

+ 9 - 3
src/modules/cockpit/mocks/contractMocks.js

@@ -1595,9 +1595,13 @@ function baseArchivesPayload(scenario, query = {}) {
     return {
         ...infraEnvelope('基础设施-基础档案', status),
         range: { type: status === 'EXPIRED' ? 'LAST_30_DAYS' : 'CURRENT_BASELINE' },
-        totals: { facilityCount: selected.length, lengthKm: selected.length ? selected.reduce((sum, row) => sum + row.length, 0).toFixed(2) : null, scale: selected.length ? 'medium' : null },
+        // 票 38 mock 同构:真实后端 `totals.scale` **恒 null**(t18 审计实证,五端如此)——
+        // mock 若给 'medium',误留的规模消费点在真实下显示 `—`、mock 下却显示「中型」,
+        // 走查抓不到(票 08/18 记过的同构纪律)。`facilityTypes[].scale` 给真实形状:
+        // 管线行是 "km"(长度单位串,不是规模语义),其余 null。
+        totals: { facilityCount: selected.length, lengthKm: selected.length ? selected.reduce((sum, row) => sum + row.length, 0).toFixed(2) : null, scale: null },
         industryDistribution: countBy('industry'),
-        facilityTypeDistribution: selected.map((row) => ({ code: row.type, name: row.name, count: 1, lengthKm: row.length || null, scale: null, range: null })),
+        facilityTypeDistribution: selected.map((row) => ({ code: row.type, name: row.name, count: 1, lengthKm: row.length || null, scale: row.length ? 'km' : null, range: null })),
         ageDistribution: countBy('age'),
         ageReference: { asOfDate: '2026-09-02', commissionedCount: selected.filter((row) => row.commissioned).length, builtCount: selected.filter((row) => !row.commissioned).length, unknownCount: 0 },
         districtDistribution: countBy('district').map((row) => ({ district: row.code, districtName: row.code === null ? '未分区' : ['城南', '城北', '太常'][row.code], count: row.count })),
@@ -2292,7 +2296,9 @@ function overviewInfrastructurePayload(scenario) {
         ...overviewEnvelope(SNAPSHOT_AT),
         totals: { facilityCount: 5, smartDeviceCount: 9 },
         industries: [
-            { code: 'GAS', key: 'gas', label: '燃气', facilityCount: 2, smartDeviceCount: 4, facilityTypes: [{ code: 'PIPE', name: '燃气管线', count: 2, lengthKm: 12.4, scale: 'medium' }], deviceTypes: [] },
+            // 票 38 mock 同构:概览端点 `facilityTypes[].scale` 照抄真实形状(管线行是长度单位串 "km",
+            // 其余 null)——不再给 'medium' 这种规模语义值(真实后端从不给,见 t18 审计)。
+            { code: 'GAS', key: 'gas', label: '燃气', facilityCount: 2, smartDeviceCount: 4, facilityTypes: [{ code: 'PIPE', name: '燃气管线', count: 2, lengthKm: 12.4, scale: 'km' }], deviceTypes: [] },
             { code: 'WATER_SUPPLY', key: 'water', label: '供水', facilityCount: 2, smartDeviceCount: 3, facilityTypes: [], deviceTypes: [] },
             { code: 'DRAINAGE', key: 'drainage', label: '排水', facilityCount: 1, smartDeviceCount: 2, facilityTypes: [], deviceTypes: [] },
         ],

+ 34 - 11
src/modules/cockpit/stores/infrastructureStore.js

@@ -19,13 +19,14 @@ import { SECTION_STATE, section } from '../domain/sectionState.js'
 import { REQUEST_REASON } from '../domain/requestReason.js'
 import { INFRASTRUCTURE_PANEL_IDS } from '../domain/infrastructurePanels.js'
 import { LOCATE_RESULT, LOCATE_TEXT, locateCameraTarget, planLocate } from '../domain/mapLocate.js'
+import { MAP_OBJECT_KIND } from '../domain/mapSummary.js'
 import {
     fetchBaseArchives,
     fetchEmergencySupport,
     fetchManagementInformation,
     fetchOperatingInformation,
 } from '../services/infrastructureApi.js'
-import { getCameraState, setCameraFocus } from '../services/mapService.js'
+import { getCameraState, locateMonitoringPoint, setCameraFocus } from '../services/mapService.js'
 import { useDetailStore } from './detailStore.js'
 import { useMapStore } from './mapStore.js'
 
@@ -108,16 +109,20 @@ export const useInfrastructureStore = defineStore('infrastructureStore', () => {
     }
 
     /**
-     * 点面板里的可定位对象 → 联动地图(票 17 验收第 6 条)。
+     * 点面板里的可定位对象 → 联动地图(票 17 验收第 6 条;票 38 第 5 条修复「点了没反应」)。
      *
      * 三步,顺序有意为之:
      *   1. **先判定**能否定位(纯函数:找不到 / 后端说不可上图 / 地图未就绪,三种分得开);
-     *   2. 判定通过再动相机(`mapService.setCameraFocus` —— 票 03 的契约,本层不碰 Cesium);
-     *   3. 相机下发成功再打开两级详情(票 10 的入口,台账/事件定位将来复用同一条)。
-     *
-     * 相机没吃下指令时如实降级成「地图尚未就绪」,**不假装定位成功**。
-     * @param {string} pointCode 目标点位编码
-     * @param {object} [deps] 注入点(单测用):`{ objects, mapReady, focus, openPoint }`
+     *   2. 判定通过再动相机。**监测点走 `locateMonitoringPoint`(完整视角定位)**,与点地图
+     *      上同一个点位是**同一条相机语义**(`pointSelection` 内部用的就是它)——
+     *      票 38 实测的「没反应」根因之一:旧写法 `setCameraFocus` 按 ADR 0008 只换中心、
+     *      保持 heading/range,行星球尺度下镜头几乎无感,而点地图上的同一个点位是真的会飞;
+     *      同一件事两种手感,慢的那个就被读成缺陷。非监测点对象仍走中心换焦。
+     *   3. 相机没吃下指令(引擎未接上 / 地形高度取不到 / 被更新指令顶掉)时如实降级成
+     *      「地图尚未就绪」,**不假装定位成功**;成功才开两级详情(设备图层由外壳既有的
+     *      selection 监听按同一条件 force 同步,本层不越权)。
+     * @param {string} pointCode 目标编码(点位编码,或**关联设备编码** —— 后者由 `planLocate` 回联宿主监测点)
+     * @param {object} [deps] 注入点(单测用):`{ objects, mapReady, focus, flyPoint, openPoint, openDevice }`
      */
     async function locatePoint(pointCode, deps = {}) {
         const mapStore = useMapStore()
@@ -127,22 +132,40 @@ export const useInfrastructureStore = defineStore('infrastructureStore', () => {
         // 就绪判据用相机状态:引擎还没接上时 `getCameraState()` 回 null(票 03 的交接点)。
         const mapReady = deps.mapReady === undefined ? !!getCameraState() : !!deps.mapReady
         const focus = deps.focus || setCameraFocus
+        const flyPoint = deps.flyPoint || locateMonitoringPoint
         const openPoint = deps.openPoint || ((code) => detailStore.openPoint(code))
+        const openDevice = deps.openDevice || ((code) => detailStore.selectDevice(code))
 
         const plan = planLocate({ objects, pointCode, mapReady })
         locate.value = { result: plan.result, pointCode: plan.pointCode, reasonText: plan.reasonText }
         if (plan.result !== LOCATE_RESULT.OK) return locate.value
 
         const target = locateCameraTarget(plan.object)
-        if (!target || focus(target) !== true) {
+        if (!target) {
+            locate.value = { result: LOCATE_RESULT.UNAVAILABLE, pointCode: plan.pointCode, reasonText: LOCATE_TEXT.UNAVAILABLE }
+            return locate.value
+        }
+        const applied = plan.object.kind === MAP_OBJECT_KIND.MONITORING_POINT
+            ? await flyPoint(plan.object)
+            : focus(target)
+        if (applied !== true) {
             locate.value = { result: LOCATE_RESULT.UNAVAILABLE, pointCode: plan.pointCode, reasonText: LOCATE_TEXT.UNAVAILABLE }
             return locate.value
         }
 
-        await openPoint(plan.pointCode)
+        // 开的是**回联到的对象自己的编码**(票 38 第二轮:点关联设备时那是它的宿主监测点),
+        // 再进设备级 —— 与点地图上同一个点位、再点它的设备卡片是同一条详情路径,
+        // 「返回点位」才回得去。`plan.pointCode`(点击的那个码)只用于反馈显示。
+        await openPoint(plan.object.code)
+        if (plan.deviceCode) await openDevice(plan.deviceCode)
         return locate.value
     }
 
+    /** 收起定位反馈(浮层到点自动消失或手动关闭时由外壳调用;成功结果本来就不显示)。 */
+    function clearLocate() {
+        locate.value = { result: '', pointCode: '', reasonText: '' }
+    }
+
     /** 仅测试用:回到未加载状态。 */
     function reset() {
         token += 1
@@ -152,5 +175,5 @@ export const useInfrastructureStore = defineStore('infrastructureStore', () => {
         locate.value = { result: '', pointCode: '', reasonText: '' }
     }
 
-    return { sections, loading, industry, locate, configure, setSection, loadPanels, locatePoint, reset }
+    return { sections, loading, industry, locate, configure, setSection, loadPanels, locatePoint, clearLocate, reset }
 })

+ 17 - 6
src/modules/cockpit/stores/overviewStore.js

@@ -53,7 +53,6 @@ const INDEPENDENT_PANELS = ['infrastructureSummary', 'monitoringSummary', 'alert
 
 export const useOverviewStore = defineStore('overviewStore', () => {
     /** 四块面板各自的分区状态(态势总览的那份由另外三块合成,见 `situationSection`)。 */
-    const deviceStatistics = null
     const sections = ref(Object.fromEntries(OVERVIEW_PANEL_IDS.map((panelId) => [panelId, pending()])))
 
     /** 正在取数(界面只做轻提示;分区成败仍由各分区自己说)。 */
@@ -126,12 +125,18 @@ export const useOverviewStore = defineStore('overviewStore', () => {
         for (const [panelId, value] of results) next[panelId] = { ...value, loaded: true }
 
         // 区域分布并进基础设施分区(它是同一块面板的第四个读数,不是独立分区)。
+        // 票 38 第二轮(维护者复验反馈第 1 条):这里曾**硬写 `regions: null`**,
+        // 于是概览「管线里程」永远取不到(显示占位符 `—`),而「基础设施-基础档案」
+        // 读的是**同一个** `/infrastructure/base-archives` 端点的 `lengthKm` → 两处对不上。
+        // 里程的唯一来源就是这份档案(概览自己的端点不给 lengthKm),所以把它真的并进来,
+        // 两处数字由**同一个数据源**构造相等,而不是"各自取一次期望一致"。
+        // 区域取不到**不算整块失败**:设施底数与行业构成照画(分区内的一段缺失)。
         const infraSection = next.infrastructureSummary
+        const regionsLoaded = regions && (regions.state === SECTION_STATE.SUCCESS || regions.state === SECTION_STATE.STALE)
         next.infrastructureSummary = {
             ...infraSection,
-            // 区域取不到**不算整块失败**:设施底数与行业构成照画(分区内的一段缺失)。
-            regions: null,
-            regionsState: SECTION_STATE.FAILED,
+            regions: regionsLoaded ? regions.data : null,
+            regionsState: regions ? regions.state : SECTION_STATE.FAILED,
             regionsReason: regions ? regions.reason : '',
         }
 
@@ -168,7 +173,9 @@ export const useOverviewStore = defineStore('overviewStore', () => {
             // 区域分布并进基础设施数据(面板按同一个领域对象读)。
             regions: infra.regions || null,
             monitoring: monitoring.data || null,
-            warningSettings: warnings.data ? { ...warnings.data } : null,
+            // 票 38 第二轮(t20 的 D2b 红复验):**原样引用**,不再 `{ ...spread }` 复制一份 ——
+            // 态势总览与预警设置必须读**同一份**载荷(引用相同),两处数字才由构造不可能对不上。
+            warningSettings: warnings.data || null,
         }
     }
 
@@ -196,12 +203,16 @@ export const useOverviewStore = defineStore('overviewStore', () => {
 
     /**
      * 点基础设施面板的行业行 → 写全局行业筛选(真的驱动一张图与所有面板)。
+     *
+     * 票 38 第二轮(t20 的 E2 红复验):这里曾拿到 `filterStore` 却**没有调用**它,
+     * 于是「点行业行」静默无事发生 —— 与维护者反馈第 5 条「点了没反应」同一类缺陷。
      * @param {string} industryKey 前端行业键(`gas`/`water`/`drainage`/`manhole`)
      */
     function linkToIndustry(industryKey) {
         if (!industryKey) return false
         const filterStore = useFilterStore()
-        return true
+        filterStore.setIndustry(industryKey)
+        return filterStore.industry === industryKey
     }
 
     /** 仅测试用:回到未加载状态。 */

Деякі файли не було показано, через те що забагато файлів було змінено