Bläddra i källkod

fix(cockpit): 管线详情视图宽屏自适应并让位两侧栏;修复点位分帧误删与管线视图切换不重建

票 60(地面视图管线降载 / 点位分帧落地 / 近景窗户诊断)与票 90(管线详情视图宽屏适配)收口。

点位分帧(票 60 AC2,核验时为半成品,本轮修复):
- 预算用尽由 break 改为 continue + backlog 计数。原实现截断点之后的标记既没进 wanted、也没渲染,
  紧随其后的「撤下计划外」循环把仍在计划里的既有实体当计划外删掉 ——
  实测「图上已有 500 点位 + 250 个状态变化散落全表 + budget 120」首帧误删 259 个,下一帧再重建。
- remaining 改取本帧真值 backlog,不再用 wanted.size - index.size 推算(截断时把「还差 380」报成「还差 1」)。
- mapService:data-map-markers 写「本帧新建 + 本轮未变」,不再是本帧增量(收敛后曾停在 20 且不自愈)。

管线视图(维护者现场反馈):
- 切回管线视图由适配器自己用 lastNetwork 重建。地面期已清空批次,而计划同步只由 MapCanvas 的
  watch 触发(不看 viewMode),于是切过去只剩地面视图的 2D 走向线,3D 模型要等下一次翻页/30s tick。
- 地形采样改为有限并发(上限 3 批,批大小仍 512 点):地面不再预采样后,这段全在切视图的关键路径上。

管线详情视图(票 90):
- 宽高改为相对一张图:宽 = 容器 − 2 × --rail-w − 边距(--rail-w 在超宽屏会自动变成两条栏宽,
  所以浮层不再盖住左右信息面板);高 = min(容器 − 边距, max(--u 下限, 84%))。删掉 1100×760 固定顶盖。
- 尺寸收为组件单一来源,外壳那条只留 z-index 与 pointer-events;内边距与间距改走 --u。
- 右侧「附近管道状态」与「监测设备」由上下堆叠改为左右并排,分隔线由横改竖,工作区右列放宽到 42%。

验证:离线闸门 t60 35/35(变异 6/6)、t90 14/14(变异 4/4)、t32-pipe-view-mode 通过;
回归 t09/t23/t46/t50-53/t54/t55/t61 全绿。按 AGENTS.md 未运行 build/dev/browser。
ChenJZ 1 dag sedan
förälder
incheckning
82831acb8a

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

@@ -2876,10 +2876,10 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
     z-index: var(--z-map);
 }
 
-/* 局部增强允许覆盖一张图,保持外壳交互层高于两侧业务栏。 */
+/* 局部增强允许覆盖一张图,保持外壳交互层高于两侧业务栏。
+   票 90:宽高**只在组件里声明一处**(它相对一张图自适应);这条只管层级与命中,
+   不再重复写尺寸 —— 两处各写一份正是「改一处、另一处不跟着变」的来源。 */
 .cockpit-shell__main > .cockpit-shell__enhancement {
-    width: min(1100px, calc(100% - 32px));
-    height: min(760px, calc(100% - 32px));
     z-index: calc(var(--z-columns) + 1);
     pointer-events: auto;
 }

+ 28 - 8
src/modules/cockpit/components/PipeEnhancement.vue

@@ -163,15 +163,30 @@ onBeforeUnmount(() => {
     left: 50%;
     top: 50%;
     transform: translate(-50%, -50%);
-    width: min(1100px, calc(100% - 32px));
-    height: min(760px, calc(100% - 32px));
+    /* 票 90(维护者 2026-10-27):尺寸跟着一张图的分辨率走,不再有固定的 1100×760 顶盖。
+       包含块就是这个浮层的定位祖先 .cockpit-shell__main(一张图铺满的那块),
+       所以 % 指的就是「一张图多大」。
+
+       宽度(2026-10-27 第二条反馈):**不得覆盖左右两侧信息面板**。
+       两侧栏各占一条 `--rail-w`(超宽屏 ≥3:1 时栏内两块并排,该 token 自动变成两条栏宽;
+       见 tokens.css 的注释「消费点用这一条而不是直接乘 --column-w」),
+       所以可用宽度 = 容器 − 2 × rail − 边距。浮层仍是 50% 居中:两侧栏等宽 ⇒ 中间带也居中,
+       不需要额外偏移。这里刻意**不留比例/下限顶盖**——任何「至少多宽」的下限都会在窄容器上
+       重新压到面板上,而「不覆盖面板」是本条唯一的硬约束;容器窄时浮层就跟着窄。
+
+       高度(不受两侧栏影响):min(容器 − 边距, max(--u 下限, 84%)),随分辨率长大且不溢出。
+       三维视口不必另写 resize:threePipeEnhancement.js 已用 ResizeObserver 观察宿主并 setSize。 */
+    width: calc(100% - 2 * var(--rail-w, 300px) - calc(24 * var(--u, 1px)));
+    height: min(calc(100% - 24px), max(calc(340 * var(--u, 1px)), 84%));
     display: grid;
     grid-template-rows: auto auto minmax(0, 1fr) auto auto;
-    gap: 12px;
+    /* 内边距与间距也走设计像素:外框与字号都随 --u 放大,只留固定 px 会在大屏上显得拥挤。
+       取值按 1080p(--u≈1.3338)反算,1080p 下与原来的 12px / 16px 逐像素一致。 */
+    gap: calc(9 * var(--u, 1px));
     min-height: 0;
     overflow: hidden;
     box-sizing: border-box;
-    padding: 16px;
+    padding: calc(12 * var(--u, 1px));
     border: 1px solid rgba(1, 171, 234, .58);
     border-radius: 6px;
     background: rgba(2, 12, 32, .94);
@@ -184,10 +199,15 @@ onBeforeUnmount(() => {
 .pipe-enhancement__controls { margin: 6px 0 0; color: var(--text-3, #7ba4c3); font-size: 12px; }
 .pipe-enhancement__close { width: 28px; height: 28px; border: 1px solid rgba(1, 171, 234, .46); border-radius: 4px; background: transparent; color: inherit; font-size: 20px; line-height: 1; cursor: pointer; }
 .pipe-enhancement__close:hover, .pipe-enhancement__close:focus-visible { border-color: var(--cyan, #01abea); color: #fff; outline: none; }
-.pipe-enhancement__workspace { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 32%); gap: 12px; min-height: 0; }
-.pipe-enhancement__devices { position: relative; display: flex; flex-direction: column; gap: 8px; min-height: 0; overflow: hidden; border-left: 1px solid #174969; padding-left: 12px; font-size: 12px; }
-.pipe-enhancement__pipe-list { flex: 0 1 28%; min-height: 60px; max-height: 34%; display: flex; flex-direction: column; overflow: hidden; }
-.pipe-enhancement__device-list { flex: 1 1 72%; min-height: 120px; display: flex; flex-direction: column; overflow: hidden; border-top: 1px solid #174969; padding-top: 7px; }
+/* 右侧块(附近管道状态 + 监测设备)从 32% 放宽到 42%:两块改成左右并排后,
+   每块仍需要能读下「源管径 / 源材质 / 地理路径」那几行小字,否则并排只是把两列都挤扁。 */
+.pipe-enhancement__workspace { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(calc(270 * var(--u, 1px)), 42%); gap: 12px; min-height: 0; }
+/* 票 90 第二条(维护者 2026-10-27):右侧的「附近管道状态」与「监测设备」改为**左右并排**,
+   不再上下堆叠 —— 两块本就各自独立滚动,竖排等于把同一块纵向空间切成两半,谁都读不全。
+   分隔线跟着从「上边框」改成两列之间的竖线。 */
+.pipe-enhancement__devices { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px; min-height: 0; overflow: hidden; border-left: 1px solid #174969; padding-left: 12px; font-size: 12px; }
+.pipe-enhancement__pipe-list { display: flex; flex-direction: column; min-height: 0; overflow: hidden; border-right: 1px solid #174969; padding-right: 10px; }
+.pipe-enhancement__device-list { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
 .pipe-enhancement__pipe-scroll, .pipe-enhancement__device-scroll { min-height: 0; overflow: auto; overscroll-behavior: contain; }
 /* 设备详情浮层:浮在三维场景之上(维护者 2026-10-22),不占用右侧列表空间。 */
 .pipe-enhancement__device-detail {

+ 15 - 5
src/modules/cockpit/services/cesiumEngine.js

@@ -1095,6 +1095,9 @@ export function syncPointMarkers(viewer, markers, colorOf, imageOf, planOf, tint
     let skipped = 0
     let unchanged = 0
     let truncated = false
+    // 本帧因预算被跳过、但**确实需要重建**的标记数。它就是调用方下一帧还欠的工作量,
+    // 也是 remaining 的唯一真值来源(旧实现用 wanted.size - index.size 推算,截断时恒错)。
+    let backlog = 0
 
     for (const marker of markers || []) {
         const id = `point|${marker.key}`
@@ -1119,9 +1122,13 @@ export function syncPointMarkers(viewer, markers, colorOf, imageOf, planOf, tint
             continue
         }
 
-        // 分帧预算用尽:本帧不再新建实体。剩余标记保持"未渲染"状态,
-        // 下一次(下一帧)调用会继续——指纹机制保证已建好的不会被重建。
-        if (rendered >= budget) { truncated = true; break }
+        // 分帧预算用尽:本帧不再新建实体,但**必须继续遍历**,不能 break。
+        // 缺陷复盘(票 60 自查):这里原先是 break,于是截断点之后的标记既没被 wanted 收录、
+        // 也没被渲染;紧随其后的「撤下计划外」循环便把**仍在计划里**的既有实体当成计划外删掉
+        // (实测:图上已有 500 个点位 + 250 个状态变化散落全表 + budget=120 ⇒ 首帧误删 259 个,
+        // 下一帧再重建回来,既闪烁又白花 GPU)。用 continue 只「跳过新建」,
+        // 让计划成员资格(wanted)与分帧预算彻底解耦。
+        if (rendered >= budget) { truncated = true; backlog += 1; continue }
 
         const color = colorOf(marker)
         if (record) source.entities.remove(record.entity)
@@ -1207,8 +1214,11 @@ export function syncPointMarkers(viewer, markers, colorOf, imageOf, planOf, tint
     }
 
     // 分帧落地:还有没建完的标记时如实告诉调用方,由它下一帧继续(DOM 层已经是全量)。
-    const complete = !truncated && wanted.size <= index.size
-    return { rendered, skipped, unchanged, complete, remaining: Math.max(0, wanted.size - index.size) }
+    // remaining 直接取本帧记下的欠账,**不再**用 wanted.size - index.size 推算:
+    // 那个式子在截断时只数到 break 点,实测把「还差 380」报成「还差 1」,
+    // 任何按它做进度/重试的下游都会读到假数字。
+    const complete = !truncated
+    return { rendered, skipped, unchanged, complete, remaining: backlog }
 }
 
 /**

+ 37 - 8
src/modules/cockpit/services/cesiumPipeModels.js

@@ -61,15 +61,31 @@ async function groupsFor(viewer, plan, heights, alive) {
         if(!heights.has(key)) needed.set(key,point)
     }
     const entries=[...needed]
-    for(let i=0;i<entries.length;i+=512) {
-        if(!alive()) return []
-        const chunk=entries.slice(i,i+512)
-        try {
-            const values=await sampleTerrainMostDetailed(viewer.terrainProvider,chunk.map(([,p])=>Cartographic.fromDegrees(p[0],p[1])))
-            if(!alive()) return []
-            for(let j=0;j<chunk.length;j+=1) if(Number.isFinite(values[j]?.height)) heights.set(chunk[j][0],values[j].height)
-        } catch { /* No invented ground elevation: retain fallback. */ }
+    const chunks=[]
+    for(let i=0;i<entries.length;i+=512) chunks.push(entries.slice(i,i+512))
+    // 有限并发(最多 3 批在飞)。批大小仍是 512 点 —— 单批请求规模不变,只是把批与批之间
+    // 从"串行等待"改成"最多 3 批并行",所以峰值由 Cesium 的 RequestScheduler 兜底、不会翻倍。
+    //
+    // 为什么需要:票 60 之后地面视图不再预先采样地形(那本是被否掉的浪费),于是这一轮采样
+    // 落在**切到管线视图的关键路径**上 —— 串行等待表现为"切过去要等很久才出现模型"。
+    // 落地顺序与结果都没变(heights 只是缓存,谁先写谁后写不影响取值),只是等待时间变短。
+    const TERRAIN_CHUNK_CONCURRENCY=3
+    let cursor=0
+    const sampleChunk=async()=>{
+        while(alive()) {
+            const index=cursor
+            cursor+=1
+            if(index>=chunks.length) return
+            const chunk=chunks[index]
+            try {
+                const values=await sampleTerrainMostDetailed(viewer.terrainProvider,chunk.map(([,p])=>Cartographic.fromDegrees(p[0],p[1])))
+                if(!alive()) return
+                for(let j=0;j<chunk.length;j+=1) if(Number.isFinite(values[j]?.height)) heights.set(chunk[j][0],values[j].height)
+            } catch { /* No invented ground elevation: retain fallback. */ }
+        }
     }
+    await Promise.all(Array.from({length:Math.min(TERRAIN_CHUNK_CONCURRENCY,chunks.length)},sampleChunk))
+    if(!alive()) return []
     const world=(p,depthM=2)=>{
         const height=heights.get(keyOf(p))
         return Number.isFinite(height)?Cartesian3.fromDegrees(p[0],p[1],height-depthM):null
@@ -107,6 +123,10 @@ export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=i
     let lastGroups=[], signatures=new Map(), terrainFailed=false, lastTerrainAttempt=0
     let diameterSummary={reported:0,interpolated:0,missing:0}, visualDefaultCount=0
     let pipelineView=viewMode==='pipeline'
+    // 最近一次拿到的管网数据。地面视图虽然不建模型,但必须**记住数据**:
+    // 计划同步(`syncPipeLayerForPlan`)只由计划数组变化触发,切视图模式不会叫它,
+    // 所以切回管线时只有这里能立刻重建(详见 setViewMode 的说明)。
+    let lastNetwork=null
     const publish=(state)=>{
         if(!active) return
         const covered=new Set(state.covered.filter((id)=>!missing.has(id)))
@@ -159,6 +179,7 @@ export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=i
     })
     function sync(network) {
         if (!active || viewer.isDestroyed()) return Promise.resolve()
+        lastNetwork = network
         // 维护者要求(第 352 轮):**地面视图不建任何管线 3D 模型**,只保留地图上的走向线。
         // 原实现即使 `ground` 也照样采样地形、拼 GLTF、上传 GPU 模型 —— 那是纯浪费:
         // 地面视图下这些模型本来就被 `show=false` 隐藏,等于白花 CPU/GPU/内存。
@@ -229,6 +250,14 @@ export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=i
             batches.clear()
         }
         publish(batches.state())
+        // 切回管线视图必须**自己重建**,不能等下一次计划同步。
+        // 缺陷复盘(维护者 2026-10-27 现场反馈):地面期已把批次清空,而计划同步只由
+        // MapCanvas 的 watch(依赖 markers/byStatus/pipelines/facilities,**不看 viewMode**)
+        // 触发 —— 切视图模式根本叫不动它。旧实现里地面视图"顺手"把模型建好了,
+        // 所以切过去立刻能看见;票 60 把地面建模去掉后这条顺带路径消失,现象变成:
+        // 切到管线视图仍只显示地面视图的 2D 走向线,3D 模型要等下一次翻页/30s tick
+        // 才可能出现,甚至一直不出现。
+        if (pipelineView && lastNetwork) void sync(lastNetwork)
     }
     return {sync,setViewMode,destroy,state:batches.state}
 }

+ 6 - 1
src/modules/cockpit/services/mapService.js

@@ -2808,7 +2808,12 @@ export function syncPointMarkersForPlan(plan, legendSource) {
                 // 避免再出现「点位修好、设备点没修」。
                 { hideVisual: billboardVisualHidden(), budget: 120 },
             )
-            markerRendered = result.rendered
+            // A9 修复(票 60 自查):`data-map-markers` 的契约是「**已画到地图上的**标记数」,
+            // 而 `result.rendered` 只是**本帧增量** —— 分帧期间两者差一个量级
+            // (实测 500 点位收敛后读数停在 20),而 t09/t46 走查正是读这一行断言
+            // 「标记数 == 计划数」,于是会长期假红且不会自愈。
+            // 真值 = 本帧新建 + 本轮未变(两者都在图上);未建的记在 remaining 里,三者互斥且穷尽。
+            markerRendered = result.rendered + result.unchanged
             markerSkipped = result.skipped
             paintMarkerState()
             if (!result.complete) requestAnimationFrame(runBatch)