浏览代码

票48阶段验收:修复真实信封及联动生命周期局部缺口,记录未完成证据

Kazerin 1 周之前
父节点
当前提交
ead39442db

+ 11 - 1
.scratch/cockpit-shell-panel-layout/issues/41-monitoring-point-locate-and-panel-anchor.md

@@ -1,7 +1,10 @@
 # 41: 监测点定位与面板锚定接入
 
+
+## 进度
+
 Type: implementation
-Status: resolved
+Status: ready-for-agent
 Blocked by: 37
 Labels: ready-for-agent
 
@@ -140,3 +143,10 @@ t17/t18 **71**/t19/t21/t21-view/t29/t30/t36/t37/t38/t39/t41 **46**)。
 2. 若 1080p/4K 实测发现 300×280 CSS px 下限或帧耗不合适 → **回决策票 04 重议数值**
    (规格明确不许实现票自行改掉"贴点/暂隐"优先级)。
 3. 本票已 close,`42`(关联设备临时图层)解除阻塞。
+
+## Comments
+
+### local — 2026-10-10T07:10:36.628Z
+整体验收审查发现无位置监测点固定详情被modeLayerPlan.selectionVisible的marker判据阻止挂载(overview也受影响)。对应规格要求台账打开固定详情。详见../verify/artifacts/t48/acceptance-status.md;尚未修复/真实回验,当前关闭状态仅代表历史交付,不代表新整体验收通过。
+
+补充:本阶段已修复固定无位置点详情门控,t48-linkage-invariants回归通过;真实台账目前无样本,未按本阶段再关闭本票。

+ 12 - 2
.scratch/cockpit-shell-panel-layout/issues/42-associated-device-layer.md

@@ -1,9 +1,14 @@
 # 42: 关联监测设备临时图层
 
+
+## 进度
+
+整体验收复核重开:刷新/快照失效撤层与详情重载链尚未满足。已有同编码重绘修复当前回归通过,仍需真实复验。证据:verify/artifacts/t48/acceptance-status.md。
+
 Type: implementation
-Status: resolved
+Status: ready-for-agent
 Blocked by: 41
-Labels: ready-for-agent
+Labels: needs-triage
 
 ## What to build
 
@@ -118,3 +123,8 @@ t14/t15/t16/t17/t18 **71**/t19/t21/t21-view/t29/t30/t36/t37/t38/t39/t41 **46**/t
 1. 维护者在**前台标签页**复跑一次,补「真实鼠标点设备点」的双向联动观感(或直接由票 43 覆盖)。
 2. `43`(面板与三维设备点双向对应)、`44`(设备位置符号与状态词表)、`46`(模式图层计划)
    均已解除阻塞,可并行。
+
+## Comments
+
+### local — 2026-10-10T08:10:42.403Z
+整体验收审查:刷新等待首屏期间旧设备层仍在;快照失效未改变batchUpdatedAt时不撤层;缺重找点位、强制重取详情及完整找不到后关闭链。另同编码坐标/状态未重绘已在整体验收当前工作区修复(t48-linkage-invariants),尚待提交与真实样本回验。当前票因刷新生命周期未满足重新打开,先诊断再实施。

+ 12 - 2
.scratch/cockpit-shell-panel-layout/issues/47-device-status-effects.md

@@ -1,9 +1,14 @@
 # 47: 设备状态视觉特效与门控
 
+
+## 进度
+
+整体验收复核重开:折叠聚合异常特效未被组合验证且源码有隐藏缺口,需先诊断并补真实场景回归。证据:verify/artifacts/t48/acceptance-status.md。
+
 Type: implementation
-Status: resolved
+Status: ready-for-agent
 Blocked by: 38, 44, 45, 46
-Labels: ready-for-agent
+Labels: needs-triage
 
 ## What to build
 
@@ -116,3 +121,8 @@ Labels: ready-for-agent
 ## Out of scope
 
 - 监测点级扩散环/监测点自身特效仍未被设备五态决策裁定,需另立决策票,不得在本票自行实现;设备图层触发、资产选择、双向联动、重叠聚合、设施/管线/事件图层和后端状态来源改造。
+
+## Comments
+
+### local — 2026-10-10T08:10:42.790Z
+整体验收源码审查确认:折叠聚合使所有设备owner.show=false,外围环按owner.show隐藏;聚合实体无特效owner,因此异常聚合折叠时动效全不可见。对应规格重叠特效不因聚合开关失效,不能以原特效脚本全绿代替此组合。先构造折叠/已选NORMAL中心的场景回归再修复。

+ 28 - 2
.scratch/cockpit-shell-panel-layout/issues/48-point-device-linkage-quality-closeout.md

@@ -1,7 +1,13 @@
 # 48: 点位设备联动整体验收与质量收口
 
+
+
+## 进度
+
+进行中:全量41组初筛33通过/8失败;公共真实信封修复5项通过并获真实页面复验。双轴审查有未解决生命周期/模式/聚合特效缺口,部署自检已补,保持open。证据在verify/artifacts/t48/acceptance-status.md。
+
 Type: integration
-Status: ready-for-agent
+Status: claimed
 Blocked by: 40, 41, 42, 43, 44, 45, 46, 47
 Labels: ready-for-agent
 
@@ -17,9 +23,29 @@ Labels: ready-for-agent
 - [ ] 坐标无效、后端清单-only、地形未就绪、资产失败、状态未知、性能降级均显示明确原因,不伪造空间位置或成功状态。
 - [ ] 重叠 K/N 计数、降级阈值、设备状态特效和资产模式在同屏组合下不互相覆盖语义。
 - [ ] 逻辑闸门、变异测试、真实走查、截图/帧差和性能读数全部留存;未完成项进入对应票面,不以原型通过冒充生产完成。
-- [ ] `docs/cockpit-deployment.md` 增加可操作的“关联设备点位已展开 / 设备状态特效已生效”自检步骤、可观察字段与失败降级说明。
+- [x] `docs/cockpit-deployment.md` 增加可操作的“关联设备点位已展开 / 设备状态特效已生效”自检步骤、可观察字段与失败降级说明。
 - [ ] `map.md`/执行票状态、部署自检文档和实现分支证据同步;明确后端真实 Redis/Kingbase 联调结果。
 
+## 本阶段实际交付
+
+- 公共真实AjaxResult信封修复:成功拆data、HTTP200业务错误不冒充成功、快照失效保留恢复原因;5项新回归通过,真实页从快照失效恢复完整读取13对象(均未校验不上图)。
+- 6项联动不变量回归通过:固定无位置点详情、同编码位置/状态刷新引擎、不可定位选择与关闭的异步定位取消、隐藏父点的设备门控、快速改选继承未兑现设备档。相关设备层/模式/相机/联动旧闸门通过。
+- 部署手册5.1已补设备展开、动效自检、可观察字段、退化与性能口径。双轴审查及截图指针见[验收证据](../verify/artifacts/t48/acceptance-status.md)。
+- 浏览器验证获本轮授权,只操作现有页面;未启动/重启server,未build。browser debug/performance与会话停止因工具参数互斥未成功,不能报告已取得capture、帧差、性能或已完成清理。
+
+## 未完成与下一步
+
+1. [关联监测设备临时图层](42-associated-device-layer.md)已因刷新/失效撤层、重找点位、重载详情链缺口重开needs-triage;先建立完整生命周期失败回归,再摘triage实施。
+2. [设备状态视觉特效与门控](47-device-status-effects.md)已因折叠聚合动效隐藏重开needs-triage;先建立折叠/选NORMAL中心的组合帧回归,再摘triage实施。
+3. 同步已修项到对应票面并复验。41无位置详情修复源码回归通过,真实台账无样本,不按本阶段再关闭任何其它票。
+4. 有效关联设备真实样本尚缺;13对象均未校验,监测台账无记录。与[真实接口联调与阶段收口](../../cockpit-real-data-wayfinding/issues/18-frontend-real-integration-gate.md)协调有效样本和运行实例Redis/Kingbase提交/日志证据,不能以mock替代真实联调。
+5. 全量41组初筛8失败已分类但未全部解决;真实场景帧差、性能和端到端交互尚未完成。修复完成且证据齐备前,保持本票claimed/open;不追加完成gist到父地图。
+
 ## Out of scope
 
 - 新增未在 41–47 锁定的业务语义、设施点完整契约、管线数据源、美术资产生产和移动端适配。
+
+## Comments
+
+### local — 2026-10-10T07:06:25.581Z
+本轮质量收口发现并修复:真实AjaxResult信封未拆解,首屏snapshotAt丢失,第二页400;HTTP200业务错误误判成功。公共请求出口新增5项回归通过,真实页面刷新后完整读取13对象、均未校验不上图。其它审查缺口和真实有效坐标样本/性能证据仍未完成,不关闭本票。

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

@@ -87,6 +87,8 @@ Labels: wayfinder:map
 
 ## Not yet specified
 
+整体验收当前状态以[点位设备联动整体验收与质量收口](issues/48-point-device-linkage-quality-closeout.md)为准:该票仍claimed/open;[关联监测设备临时图层](issues/42-associated-device-layer.md)与[设备状态视觉特效与门控](issues/47-device-status-effects.md)因新质量缺口重开needs-triage。历史Decisions记录不代表本次整链已验收。
+
 - 甲方本地瓦片、地形与 3D Tiles 的实际交付物清单 —— **2026-09-18 更新**:本地 DEM(`yuanlingDEM`,量化网格)与影像切片(`yuanlingPic3`,TMS)已接上并通过走查;**甲方那份 cesiumlab 3D Tiles 经目测否决**(错位 + 白模),合成根文件已删除;**轻量体块模型第一版也被否决**(漏顶 / 飘在地形上 / 尺寸不搭边,见 `issues/34` 的 Answer),重做在 **`issues/35`**(含三条缺陷的根因与验收)。在重做达标之前 `map.tilesets` 留空。正式部署的操作手册在 **`docs/cockpit-deployment.md`**。
 - **地图图例的进一步优化**(维护者 2026-09-18:「图例可能在之后需要进行优化,目前这个状态先保持即可」)——
   图例已按上一轮要求移到一张图最上方(`issues/33`),**暂不再改**;待维护者提具体要求后再立票。

+ 46 - 0
.scratch/cockpit-shell-panel-layout/verify/artifacts/t48/acceptance-status.md

@@ -0,0 +1,46 @@
+# 点位设备联动整体验收:当前证据与缺口
+
+基线:`bd80f7d2e65eae6f1b00b40339e48340ba80ebf4...0db9992`,7个提交;本报告另含当前工作区真实信封修复,不包含并发城市体块/蓝湖/其它真实联调票改动。
+
+## 实际检查
+
+- 全量脚本筛查:41组,33通过/8失败。失败为t06资产JSON导入缺attribute;t11旧静态守卫误把unmatchedOpen视图折叠ref当聚合业务状态;t15显式刷新期望只返回第一页与当前自动连翻语义不一致;t17/t18依赖已删除的旧片区gap导出;t20概览片区契约断言与现行字段变化不一致,变异还原后基线仍红,不能声称变异有效;t28缺Puppeteer依赖且它实际含浏览器操作,应从非浏览器清单排除。尚未为绿灯修改这些失败断言。
+- 新回归t48-real-envelope:首次4项失败;修复公共requestCockpitJson后5项通过。修复真实AjaxResult成功拆data、HTTP200业务错误失败、过期类别保留、缺data坏载荷,裸载荷兼容不变。t04配置与t08快照44/44回归通过。
+- 真实页面:现有http://localhost:8000/#/cockpit,real配置指向本地联调后端192.168.110.96:8301;未启动/重启server,未build。
+- 修复前console记录第二页 `map-snapshot?page=2&pageSize=2` 400;页面快照失效/对象0。修复后刷新并进入系统,页面显示完整读取13个对象、0可上图/13未校验;预警设置等业务失败分区显示加载失败而非假成功。无数据库写入,不修改配置或后端。
+- 切换运行监测:监测概览有7台设备,监测点台账无记录;运行概况真实加载失败。无法通过地图/台账开展完整设备链;不将空样本判为验收成功。
+- 截图:`real-before-envelope.jpg`、`real-after-envelope.jpg`,来自browser_*正常截图。页面可视口1680x950,初始工具误应用ipad preset后视觉仍报告此视口,但desktop模式未独立确认,故不作桌面性能证据。两张是修复前后页面,不是动效帧差。
+- Browser debug capture/performance因工具参数互斥未成功开始,未产生可导出capture。未通过其它浏览器进程/脚本绕过工具。停止session lsal也报session/requestId互斥,多次重复未有进展,当前Agent Window尚未成功清理;不报告已关闭。
+- 最终当前修复范围16组相关闸门全部通过(t04/t05/t08/t41/t42/t43/t44/t45聚合/持续选择/高程/t46/t47动效/清晰度/主场景/t48两组),源码语法检查通过;不等于全量初筛8个失败已经消失。
+- 历史t45真实用户脚本改写pointer-events、t46若干宽松OR/markerCount>=0等断言,不能当本票新端到端证据。
+
+## 本轮修复进展
+
+新增`t48-linkage-invariants.mjs`六项通过:固定无位置点详情、同编码坐标/状态变化触发引擎计划刷新、不可定位选择取消旧定位、关闭详情取消旧定位、异常回联缺失时父点与关联设备一并暂停、快速A→B继承尚未兑现的设备档位。修复前分别失败,修复后通过。计划指纹纳入坐标/状态/资产/动效配置;父点可见性接入实际设备层;定位增加epoch与待兑现设备档意图。相关设备层/模式/相机/联动闸门通过。以下双轴结果保留为修复前审查记录,不把已修源码项继续报为未修。
+
+## Standards
+
+静态审查没有证明新的硬性编码标准违规。
+
+- P2:首次point-tier→A→B快速切换,A地形采样未兑现,store已先记lastLevel=equipment;B用CENTER_ONLY取消A,最终保留point档而不是device档。设备图层store applyDeviceFlight需继承未兑现档位意图。
+- P2:关闭设备详情或改选不可定位设备没有取消待地形采样,旧样本仍能下发相机移动;每次选择生命周期终止都要取消定位,包括不能飞的分支。
+- 判断项:monitoringApi与filterOptions重复维护状态桶白名单,可集中,但不是本票必须重构项。旧service/store双选中状态风险为既有问题,不冒充本次新增回归。
+
+## Spec
+
+- P1:真实信封消费/分页token丢失、HTTP200业务错误假成功:本轮已修,生产页重新读取13对象;新回归5/5。
+- P1:刷新第1页响应前不公布重载状态,旧层留存;失效只watch批次时间;缺重找点位/重取详情/完整找不到才关闭链。对应关联监测设备临时图层。
+- P1:planKey只哈希编码,不含坐标/状态/资产/effect配置,导致同编码新数据只改store、不改引擎。对应关联监测设备临时图层和资产接缝。
+- P1:父点因异常回联缺失而隐藏时,关联ALARM设备仍按子状态绘制;详情暂停但设备层未暂停。对应一级模式图层计划与选择态矩阵。
+- P2:无位置监测点在overview也因selectionVisible要求marker而不能显示固定详情。对应定位与面板锚定/模式矩阵。
+- P2:折叠聚合隐藏全部设备owner,光环跟owner show而消失,聚合实体无动画owner;不能宣称折叠聚合异常动效可见。对应重叠聚合和状态特效。
+
+## 尚未通过的验收
+
+完整地图/台账用户链;刷新/失效层生命周期;四模式父子门控和固定无位置点详情;降级/聚合/特效同屏;有效变异+真实帧差+性能采样;真实Redis/Kingbase运行实例绑定和联调。
+
+真实后端当前所有坐标未校验,6点/7未关联设备且缺有效关联样本,不能自行造坐标或数据库关系。真实联调外部票前端真实接口联调与阶段收口已记录33项阻断,本报告不重复宣称它们已修;设施/管线/后端数据库字段超出本票边界。
+
+## 下一步
+
+在对应执行票记录以上缺口并完成复现→修复→定向回归,再用获准可上图样例和真实已校验关联设备开展整链操作、场景范围帧差与性能采样。部署自检已补入docs/cockpit-deployment.md 5.1。仅文档项具备完成证据,整体验收票不关闭,不向Decisions so far添加完成gist。

二进制
.scratch/cockpit-shell-panel-layout/verify/artifacts/t48/real-after-envelope.jpg


二进制
.scratch/cockpit-shell-panel-layout/verify/artifacts/t48/real-before-envelope.jpg


+ 61 - 0
.scratch/cockpit-shell-panel-layout/verify/t48-linkage-invariants.mjs

@@ -0,0 +1,61 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import vm from 'node:vm'
+import { planModeLayers } from '../../../src/modules/cockpit/domain/modeLayerPlan.js'
+const store = readFileSync(new URL('../../../src/modules/cockpit/stores/deviceLayerStore.js', import.meta.url), 'utf8')
+const fn = (name) => store.match(new RegExp(`    function ${name}\\([^]*?\\n    \\}`))[0]
+let failures = 0
+function check(name, action) { try { action(); console.log(`PASS ${name}`) } catch(e) { failures++;console.error(`FAIL ${name}: ${String(e.message).split('\n')[0]}`) } }
+check('overview permits fixed detail for no-location monitoring point',()=>{
+    const point={kind:'monitoring-point',code:'P',deviceCodes:['A'],coordinate:{renderable:false,state:'UNVERIFIED'}}
+    const plan=planModeLayers({mode:'overview',entered:true,objects:[point],selection:{level:'point',pointCode:'P'}})
+    assert.equal(plan.selection.visible,true)
+    assert.equal(plan.markers.length,0)
+})
+check('same identities with changed coordinates/status invalidate engine plan',()=>{
+    const ctx=vm.createContext({});vm.runInContext(fn('planKeyOf'),ctx)
+    const row={code:'A',longitude:1,latitude:2,status:'NORMAL',shape:'circle',size:14}
+    ctx.a=[row];ctx.b=[{...row,longitude:3}];ctx.c=[{...row,status:'ALARM'}]
+    assert.notEqual(vm.runInContext("planKeyOf('P',a)",ctx),vm.runInContext("planKeyOf('P',b)",ctx))
+    assert.notEqual(vm.runInContext("planKeyOf('P',a)",ctx),vm.runInContext("planKeyOf('P',c)",ctx))
+    assert.equal(vm.runInContext("planKeyOf('P',a)",ctx),vm.runInContext("planKeyOf('P',a)",ctx))
+})
+check('non-drawn device selection cancels previous terrain work',()=>{
+    let cancelled=0
+    const ctx=vm.createContext({deviceFlightToken:0,pendingDeviceTier:false,cancelPointLocate(){cancelled++},DEVICE_FLIGHT:{NONE:'none',CENTER_ONLY:'center-only'},rows:{value:[{code:'B',placement:'no-location'}]},locateMonitoringPoint(){throw Error('must not locate')}})
+    vm.runInContext(fn('applyDeviceFlight'),ctx)
+    vm.runInContext("applyDeviceFlight('center-only','B')",ctx)
+    assert.equal(cancelled,1)
+})
+check('closing detail through level change cancels terrain work',()=>{
+    let cancelled=0
+    const ctx=vm.createContext({deviceFlightToken:0,pendingDeviceTier:false,cancelPointLocate(){cancelled++},DETAIL_LEVEL:{NONE:'none',EQUIPMENT:'equipment'},lastLevel:'equipment',lastDeviceCode:'A'})
+    vm.runInContext(fn('noteLevel'),ctx);vm.runInContext("noteLevel('none','')",ctx)
+    assert.equal(cancelled,1)
+})
+check('hidden parent gates associated devices when abnormal linkage is unavailable',()=>{
+    const point={kind:'monitoring-point',code:'P',deviceCodes:['A'],coordinate:{renderable:true}}
+    const plan=planModeLayers({mode:'warningResponse',entered:true,objects:[point],abnormalDevices:null,
+        associatedDevices:[{code:'A',pointCode:'P',status:'ALARM',placement:'drawn'}],selection:{level:'equipment',pointCode:'P',deviceCode:'A'}})
+    assert.equal(plan.markers.length,0)
+    assert.equal(plan.associatedDevices.length,0)
+    assert.equal(plan.selection.visible,false)
+})
+let requests=[]
+const flightContext=vm.createContext({
+    DEVICE_FLIGHT:{NONE:'none',CENTER_ONLY:'center-only',DEVICE_TIER:'device-tier'},
+    rows:{value:[{code:'A',placement:'drawn'},{code:'B',placement:'drawn'}]},
+    deviceFlightToken:0,pendingDeviceTier:false,cancelPointLocate(){},
+    locateMonitoringPoint(device,options){return new Promise((resolve)=>requests.push({device,options,resolve}))},
+})
+vm.runInContext(fn('applyDeviceFlight'),flightContext)
+vm.runInContext("applyDeviceFlight('device-tier','A');applyDeviceFlight('center-only','B')",flightContext)
+check('rapid A to B inherits an unapplied device tier',()=>{
+    assert.equal(requests.length,2)
+    assert.equal(requests[0].options.centerOnly,false)
+    assert.equal(requests[1].options.centerOnly,false)
+})
+requests.forEach((request)=>request.resolve(true))
+await Promise.resolve()
+console.log(`T48 linkage invariants: ${failures?'FAILED':'passed'}`)
+process.exitCode=failures?1:0

+ 53 - 0
.scratch/cockpit-shell-panel-layout/verify/t48-real-envelope.mjs

@@ -0,0 +1,53 @@
+import assert from 'node:assert/strict'
+import { requestCockpitJson } from '../../../src/modules/cockpit/services/cockpitApi.js'
+import { fetchMapSnapshot } from '../../../src/modules/cockpit/services/mapApi.js'
+import { createMapSummaryLoader } from '../../../src/modules/cockpit/domain/mapSummary.js'
+
+const config = { dataSource: 'real', api: { baseUrl: 'http://acceptance.invalid/api/cockpit/v1' } }
+const response = (body, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body })
+const rawPage = (page) => ({ snapshotAt: '2026-10-01T10:00:00+08:00', page, pageSize: 1, total: 2, complete: page === 2,
+    monitoringPoints: [{ pointCode: `P-${page}`, pointName: `Point ${page}`, pointStatus: 'NORMAL', deviceCodes: [],
+        longitude: 110.4, latitude: 28.44, coordinateValidation: { renderable: false, state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE' } }],
+    unassociatedEquipment: [],
+})
+let failed = 0
+async function check(name, action) {
+    try { await action(); console.log(`PASS ${name}`) }
+    catch (error) { failed += 1; console.error(`FAIL ${name}: ${String(error.message).split('\n')[0]}`) }
+}
+await check('real AjaxResult unwraps before map adaptation and page2 keeps snapshot token', async () => {
+    const urls = []
+    const fetchImpl = async (url) => { urls.push(url); const page = Number(new URL(url).searchParams.get('page')); return response({ code: 200, msg: 'success', data: rawPage(page) }) }
+    const loader = createMapSummaryLoader({ pageSize: 1, fetchPage: async (query) => {
+        const section = await fetchMapSnapshot({ ...query, config, fetchImpl })
+        return { ok: section.state === 'success', data: section.data, reason: section.reason }
+    } })
+    await loader.loadNext()
+    await loader.loadNext()
+    assert.equal(new URL(urls[1]).searchParams.get('snapshotAt'), rawPage(1).snapshotAt)
+    assert.equal(loader.getState().objects.length, 2)
+    assert.equal(loader.getState().phase, 'complete')
+    assert.ok(loader.getState().objects.every((object) => object.coordinate.renderable === false), 'never promote unverified devices to renderable')
+})
+await check('HTTP200 business failure is not a successful section', async () => {
+    const result = await requestCockpitJson('/map-snapshot', { config, fetchImpl: async () => response({ code: 500, msg: 'failure', data: null }) })
+    assert.equal(result.ok, false)
+    assert.equal(result.reason, 'unavailable')
+})
+await check('HTTP200 expired snapshot keeps recoverable category', async () => {
+    const result = await requestCockpitJson('/map-snapshot', { config, fetchImpl: async () => response({ code: 500, msg: 'failure', data: { category: 'SNAPSHOT_EXPIRED' } }) })
+    assert.equal(result.ok, false)
+    assert.equal(result.reason, 'snapshot-expired')
+})
+await check('successful envelope missing data is invalid instead of fake success', async () => {
+    const result = await requestCockpitJson('/map-snapshot', { config, fetchImpl: async () => response({ code: 200, msg: 'success' }) })
+    assert.equal(result.ok, false)
+    assert.equal(result.reason, 'bad-payload')
+})
+await check('plain compatibility payload remains unchanged', async () => {
+    const payload = { value: 0 }
+    const result = await requestCockpitJson('/foundation', { config, fetchImpl: async () => response(payload) })
+    assert.equal(result.data, payload)
+})
+console.log(`T48 real envelope: ${failed ? 'FAILED' : 'passed'}`)
+process.exitCode = failed ? 1 : 0

+ 35 - 0
docs/cockpit-deployment.md

@@ -527,6 +527,41 @@ location /gis/model/ {
 
 ---
 
+## 5.1 关联设备点位与状态特效自检
+
+先记录前端提交、运行页面 URL、配置数据源(real/mock)、后端运行版本及时间。`mock` 受控样例通过不等于 real 数据通过;HTTP 200 不等于业务成功,业务错误分区应显示加载失败。不得为走查绕过坐标校验或修改面板 pointer-events。
+
+### 操作链
+
+1. 进入系统,在「运行监测」的监测点台账打开一个点,再关闭详情,通过地图标记打开同一点。两种入口都应加载点位摘要并定位;点位名称可读,详情连接线指向该标记。
+2. 有有效自身坐标的关联设备应出现;无坐标/未校验设备只留清单与具体原因,不借父点坐标。若真实数据均未校验,本项记为“无可上图样本”,不能勾为通过。
+3. 聚合点显示“聚合 K 台”。hover 高亮预览,click 固定展开且入口变暗;点子设备进入设备详情,该设备持续可见并接替聚合入口。再次 hover/click 可切换另一设备。K>maxFan 时本屏保留上限内设备,其余仍在清单;选中清单尾部设备也应保持可见。
+4. 设备详情与短线跟随设备,返回摘要恢复监测点锚点。平移/旋转后再次选设备应保留地形高程,不钻入山体;关闭/换点后无旧设备层或旧定位响应复活。
+5. 同一快照翻页不应闪烁设备层;刷新/快照失效应先撤旧层,新范围完整加载后按所选点位重新核对。切四个模式以及引导态,逐项核对暂停/恢复、图例计数和事件回联,不能只检查按钮能点击。
+6. 报警/预警/工单外围环应随帧变化,中心图标保持清晰不透明;正常/离线/未知无动画。静止相机时连续截取同一设备区域,比较实际外围环帧差;全屏变化可能来自时钟、地图瓦片或模型加载,不能单独证明动效。
+7. 关闭动效只停动画,静态状态色/形和清单不消失。异常台数超过 `map.effects.abnormalLimit`(默认30)时停动画并显示异常数;这与 `map.overlap.maxFan`(默认10)的展开上限是两种不同降级,不能互代。
+
+### 可观察字段
+
+字段位于 `.cockpit-shell__map .map-canvas`,DEV只读句柄为 `window.__cockpitMap`;生产没有DEV句柄时查看元素属性和真实画面。
+
+| 检查 | 字段/句柄 | 判据 |
+| --- | --- | --- |
+| 详情身份 | `data-map-panel-selection`、`data-map-device-picked` | point/equipment 与面板当前对象一致;不把聚合符号当虚构设备 |
+| 聚合 | `data-map-overlap-state`、`data-map-overlap-count`;`getOverlapState()` | collapsed/expanded/selected,超量为degraded;count为K,`selectedCode`为真实编码 |
+| 展开子点 | `getOverlapTargets()`、`getAssociatedDevices()` | 可见子点有真实编码,`overlapEntry`只标记中心已选设备;位置偏移仅用于屏幕呈现 |
+| 设备状态动画 | `data-map-device-effects`、`data-map-device-effects-animated`、`data-map-device-effects-abnormal`;`getDeviceEffects()` | on/off/degraded,台数真实;tick增长只是驱动证据,还要确认可见外围环跨帧变化 |
+| 面板锚定 | `.cockpit-shell__detail` 的 `data-anchor-code`、`data-anchor-state` | 点位摘要指向点位,设备详情指向设备;离屏暂隐不改身份 |
+| 位置退化 | 详情/清单的原因、无地图位置列表 | 坐标无效、未校验、地形失败/待确认、资产降级分别记录,不冒充成功 |
+
+### 证据与排障边界
+
+- 性能使用同机、同视口、同视角、相同加载条件的开/关动效重复采样,保留样本数、P50/P95及口径。浏览器长任务/请求延迟不是Cesium GPU帧耗时,不能代替该读数。
+- 真实Redis/Kingbase结论需运行实例提交绑定、服务端日志/部署证据与真实接口/WS样本;页面显示“已连接”或只读GET通过不能证明两种存储均联调成功。
+- 当前工作区已修:同编码数据更新、父点门控、固定无位置点详情、定位取消与首次设备档继承;定向回归通过,真实样本仍需回验。仍未完成:刷新/失效撤层重载链与折叠聚合动效。完整真实交互、帧差与性能证据齐备前,不将本节操作链记为全通过。
+
+---
+
 ## 6. 常见问题
 
 **Q:改了 `config.json` 没生效?**

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

@@ -719,6 +719,8 @@ function syncDeviceLayer(options = {}) {
         ...options,
         mode: mode.value,
         entered: !introVisible.value,
+        parentVisible: !selection.value.pointCode || [...modeLayerPlan.value.markers, ...modeLayerPlan.value.blocked]
+            .some((item) => item.code === selection.value.pointCode),
         maxFan,
         filterActive: deviceFilterActive.value,
     })
@@ -1442,6 +1444,7 @@ onMounted(() => {
     // 选择与详情分开监听是有意的:选择变化要**立即**撤旧图层,而详情到达只需重算同一份计划。
     stopDeviceWatches.push(
         watch(() => [selection.value.pointCode, selection.value.level], () => syncDeviceLayer({ force: true }), { deep: false }),
+        watch(modeAbnormalDevices, () => syncDeviceLayer(), { deep: false }),
         watch(() => sections.value.pointDetail, () => syncDeviceLayer(), { deep: false }),
         watch(() => mapStore.summary.batchUpdatedAt, (stamp) => syncDeviceLayerForSnapshot(stamp), { deep: false }),
         // A filter reload can retain the backend timestamp while changing device membership.

+ 12 - 3
src/modules/cockpit/domain/modeLayerPlan.js

@@ -125,7 +125,12 @@ function selectionVisible({ mode, selection, objects, markers, associatedDevices
         //   点设备卡片后 `selectionVisible=false` ⇒ 外壳 `<aside v-if>` 卸载 ⇒ 摘要面板消失。)
         const row = (Array.isArray(associatedDevices) ? associatedDevices : [])
             .find((item) => item && item.code === current.deviceCode)
-        if (row) return isAssociatedDeviceVisible(mode, row.status)
+        if (row) {
+            const parent = (Array.isArray(objects) ? objects : []).find((item) =>
+                item && item.kind === MAP_OBJECT_KIND.MONITORING_POINT && item.code === row.pointCode)
+            return Boolean(parent && isAssociatedDeviceVisible(mode, row.status) &&
+                (mode !== MODE_LAYER.WARNING_RESPONSE || isAbnormalPoint(parent, abnormalCodes)))
+        }
         // 未关联设备:以地图对象身份判(基础设施整体隐藏;预警处置只认异常设备)。
         const object = (Array.isArray(objects) ? objects : [])
             .find((item) => item && item.kind === MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT && item.code === current.deviceCode)
@@ -133,7 +138,10 @@ function selectionVisible({ mode, selection, objects, markers, associatedDevices
             (mode !== MODE_LAYER.WARNING_RESPONSE || abnormalCodes.has(object.code)))
     }
     if (current.pointCode) {
-        return markers.some((item) => item.code === current.pointCode)
+        const point = (Array.isArray(objects) ? objects : []).find((item) =>
+            item && item.kind === MAP_OBJECT_KIND.MONITORING_POINT && item.code === current.pointCode)
+        return Boolean(point && mode !== MODE_LAYER.INFRASTRUCTURE &&
+            (mode !== MODE_LAYER.WARNING_RESPONSE || isAbnormalPoint(point, abnormalCodes)))
     }
     return false
 }
@@ -183,8 +191,9 @@ export function planModeLayers({
         degradedNotes.push(note('abnormal-devices-unready', '异常设备数据未就绪,预警处置不猜测异常点', normalizedMode))
     }
 
+    const visiblePointCodes = new Set(source.filter((item) => item.kind === MAP_OBJECT_KIND.MONITORING_POINT).map((item) => item.code))
     const visibleDevices = (Array.isArray(associatedDevices) ? associatedDevices : [])
-        .filter((row) => row && row.placement === 'drawn' && isAssociatedDeviceVisible(normalizedMode, row.status))
+        .filter((row) => row && visiblePointCodes.has(row.pointCode) && row.placement === 'drawn' && isAssociatedDeviceVisible(normalizedMode, row.status))
     const nonPoint = planNonPointLayers({
         mode: normalizedMode,
         entered: true,

+ 16 - 1
src/modules/cockpit/services/cockpitApi.js

@@ -218,7 +218,22 @@ export async function requestCockpitJson(path, options = {}) {
         if (!payload.ok) {
             return { ok: false, mode: plan.mode, status: response.status, reason: REQUEST_REASON.BAD_PAYLOAD, path: plan.path }
         }
-        return { ok: true, mode: plan.mode, status: response.status, path: plan.path, data: payload.data }
+        const body = payload.data
+        // 真实后端 AjaxResult 的业务状态不等同于 HTTP 状态;mock/兼容裸载荷不走信封拆解。
+        if (body && typeof body.code === 'number') {
+            if (body.code !== 200) {
+                return {
+                    ok: false, mode: plan.mode, status: response.status, path: plan.path,
+                    reason: readBusinessReason(body) || ([401, 403].includes(body.code)
+                        ? REQUEST_REASON.UNAUTHORIZED : REQUEST_REASON.UNAVAILABLE),
+                }
+            }
+            if (!Object.prototype.hasOwnProperty.call(body, 'data')) {
+                return { ok: false, mode: plan.mode, status: response.status, path: plan.path, reason: REQUEST_REASON.BAD_PAYLOAD }
+            }
+            return { ok: true, mode: plan.mode, status: response.status, path: plan.path, data: body.data }
+        }
+        return { ok: true, mode: plan.mode, status: response.status, path: plan.path, data: body }
     } catch (error) {
         return {
             ok: false,

+ 30 - 7
src/modules/cockpit/stores/deviceLayerStore.js

@@ -42,6 +42,7 @@ import {
     nudgeOverlap as nudgeOverlapService,
     resolveDeviceEffectsOptions,
     locateMonitoringPoint,
+    cancelPointLocate,
     setHoveredDevice,
     setSelectedDevice,
     syncAssociatedDevices,
@@ -108,8 +109,11 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
         return list.filter((row) => row.placement === 'drawn')
     }
 
-    function planKeyOf(code, drawn) {
-        return `${code}|${drawn.map((row) => row.code).join(',')}`
+    function planKeyOf(code, drawn, maxFan = 10, effects = null) {
+        return JSON.stringify([code, maxFan, effects, drawn.map((row) => [
+            row.code, row.longitude, row.latitude, row.status, row.shape, row.size,
+            row.assetMode, row.assetReason, row.tone,
+        ])])
     }
 
     /**
@@ -128,7 +132,7 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
 
         // 票 46:引导态(进入前)不显示任何业务对象 —— 关联设备图层直接撤下。
         // 数据仍可预取(详情/快照照常),只是**呈现**被门控。
-        if (options.entered === false) {
+        if (options.entered === false || options.parentVisible === false) {
             token += 1
             pointCode.value = ''
             rows.value = []
@@ -245,7 +249,7 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
         const effects = planDeviceEffects({ devices: drawn, ...resolveDeviceEffectsOptions() })
         effectsPlan.value = effects
 
-        const key = planKeyOf(code, drawn)
+        const key = planKeyOf(code, drawn, options.maxFan || 10, effects)
 
         // 同点位的同一份计划**不重画**(翻页/无关重渲染都不会让设备层闪烁)。
         // `force` 用于「快照换了、旧图层必须先撤下」这条路径(规格要求先撤后取)。
@@ -294,6 +298,8 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
     /** 上一次的详情层级与设备编码 —— 镜头联动判"该怎么动"要拿它做对照。 */
     let lastLevel = DETAIL_LEVEL.NONE
     let lastDeviceCode = ''
+    let pendingDeviceTier = false
+    let deviceFlightToken = 0
 
     /** 是否需要弱化(该点位设备数 ≥ 阈值)。界面按它决定要不要给"其余设备压暗"的样式。 */
     const dimming = ref(false)
@@ -345,10 +351,16 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
      */
     function applyDeviceFlight(flight, code) {
         if (flight === DEVICE_FLIGHT.NONE) return ''
+        const mine = ++deviceFlightToken
+        if (flight === DEVICE_FLIGHT.DEVICE_TIER) pendingDeviceTier = true
         const device = rows.value.find((row) => row.code === code) || null
-        if (!device || device.placement !== 'drawn') return ''
-        void locateMonitoringPoint(device, {
-            tier: 'device', centerOnly: flight === DEVICE_FLIGHT.CENTER_ONLY,
+        if (!device || device.placement !== 'drawn') {
+            cancelPointLocate()
+            return ''
+        }
+        const centerOnly = flight === DEVICE_FLIGHT.CENTER_ONLY && !pendingDeviceTier
+        void locateMonitoringPoint(device, { tier: 'device', centerOnly }).then((applied) => {
+            if (applied && mine === deviceFlightToken && !centerOnly) pendingDeviceTier = false
         })
         return flight
     }
@@ -369,6 +381,9 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
 
     /** 「返回点位」:清设备强化、回点位级(**监测点仍选中**,不是取消选中)。 */
     function backToPointLevel() {
+        deviceFlightToken += 1
+        pendingDeviceTier = false
+        cancelPointLocate()
         const detailStore = useDetailStore()
         const before = { level: lastLevel, deviceCode: lastDeviceCode }
         detailStore.backToPoint()
@@ -393,6 +408,11 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
 
     /** 详情层级变了(外壳订阅)→ 同步内部对照值,避免下一次判定拿错"变化前"。 */
     function noteLevel(level, deviceCode) {
+        if (level !== DETAIL_LEVEL.EQUIPMENT) {
+            deviceFlightToken += 1
+            pendingDeviceTier = false
+        }
+        if (level === DETAIL_LEVEL.NONE) cancelPointLocate()
         lastLevel = level || DETAIL_LEVEL.NONE
         lastDeviceCode = deviceCode || ''
     }
@@ -442,6 +462,9 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
         dimming.value = false
         lastLevel = DETAIL_LEVEL.NONE
         lastDeviceCode = ''
+        pendingDeviceTier = false
+        deviceFlightToken += 1
+        cancelPointLocate()
         clearAssociatedDevices()
     }