Procházet zdrojové kódy

feat(cockpit): unify scene device icons with map markers and paginate pipe status

ChenJZ před 12 hodinami
rodič
revize
c12f7748b6

+ 20 - 0
.scratch/cockpit-shell-panel-layout/verify/t23-click-flow.mjs

@@ -41,7 +41,14 @@ setActivePinia(createPinia())
 const store = usePipeEnhancementStore()
 const pipes = ['P-1', 'P-2'].map((code) => ({ code, positions: [[110.1, 28.4], [110.101, 28.401]] }))
 const demoDevice = { code: 'D-real', pointCode: 'MP-real', name: '真实设备', placement: 'nearby', placementText: '邻近设备 · 示意安装' }
+// 分页回归素材:12 条邻域管道(超过每页 8 条),断言翻页只切窗口、不挤走设备区。
+const manyPipeStates = Array.from({ length: 12 }, (_, i) => ({
+    sourceKind: 'pipe', code: `NP-${String(i + 1).padStart(2, '0')}`, title: `邻域管段 ${i + 1}`,
+    stateText: '状态未提供', diameterM: 0.4, diameter: 400, material: '', surface: { label: 'PE塑料' },
+    geographicLengthM: 10 + i, lengthM: null, clipped: i % 2 === 1,
+}))
 const plan = Vue.computed(() => ({ ...planPipeEnhancement({ selected: store.selected, visiblePipes: pipes, radiusM: store.radiusM }),
+    objects: manyPipeStates,
     deviceList: [demoDevice], devices: [demoDevice], deviceCoverage: { loading: false, partial: false } }))
 const selectContext = vm.createContext({ PIPE_ENHANCEMENT_KIND, pipeEnhancementStore: store })
 vm.runInContext(`${functions(shell.scriptSetup.content, ['selectPipeEnhancement'])}\nglobalThis.select = selectPipeEnhancement`, selectContext)
@@ -145,6 +152,19 @@ assert.equal(deviceStore.selected, null)
 assert.ok(find(root, 'data-device-list'), '关闭设备详情不关闭设备列表')
 assert.equal(mounts, 1, '设备详情关闭不重建三维场景')
 
+// ---- 附近管道状态分页(维护者 2026-10-22)----
+// 12 条邻域管道 → 分页控件出现,且首屏只渲染 8 条(不会把设备区挤下去)。
+assert.ok(find(root, 'data-pipe-pager'), '管道超过一页时必须有分页控件')
+function pipeRowTexts(node, out = []) {
+    if (node.tag === 'button' && String(node.props?.class || '').includes('pipe-enhancement__pipe')) out.push(JSON.stringify(node.children || []))
+    for (const child of node.children || []) pipeRowTexts(child, out)
+    return out
+}
+const firstPageRows = pipeRowTexts(root)
+assert.equal(firstPageRows.length, 8, '第 1 页只渲染 8 条管道')
+assert.ok(!firstPageRows.some((t) => t.includes('NP-12')), '第 1 页不应包含第 12 条')
+assert.ok(find(root, 'data-device-list'), '分页后设备区仍存在且可达')
+
 function findTag(node, tag) { return node.tag === tag ? node : (node.children || []).map((child) => findTag(child, tag)).find(Boolean) }
 findTag(root, 'select').props.onChange({ target: { value: '100' } })
 await Vue.nextTick(); await Vue.nextTick()

+ 29 - 3
.scratch/cockpit-shell-panel-layout/verify/t52-device-pick.mjs

@@ -10,10 +10,27 @@ Object.assign(plan, planEnhancementDevices(plan, [{ code: 'D', name: '真实供
 const canvas = new EventTarget()
 canvas.getBoundingClientRect = () => ({ left: 0, top: 0, width: 900, height: 500 })
 let scene, camera, picked
+let resolvedDevice = null
+let textureUrl = ''
 const host = { clientWidth: 900, clientHeight: 500, appendChild(n) { n.parentNode = this }, removeChild(n) { n.parentNode = null } }
 const viewer = mountPipeEnhancement(host, plan, {
-    onDevicePick: (d) => { picked = d }, rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s, c) { scene = s; camera = c } }),
+    onDevicePick: (d) => { picked = d },
+    rendererFactory: () => ({ domElement: canvas, setSize() {}, dispose() {}, render(s, c) { scene = s; camera = c } }),
+    // 离线 seam:图标必须来自主图同款合成产物(`renderMarker`),这里注入一个可观测替代,
+    // 并注入同步假贴图加载器(Node 无 DOM Image,真 TextureLoader 不可用)。
+    iconResolver: async (device) => {
+        resolvedDevice = device
+        return { url: `mock-marker://${device.code}`, width: 26.7, height: 33.5 }
+    },
+    loadTexture: (url, onLoad) => {
+        textureUrl = url
+        const texture = new THREE.Texture()
+        texture.image = { data: new Uint8Array([255, 255, 255, 255]), width: 1, height: 1 }
+        texture.userData = { seeded: true }
+        onLoad(texture)
+    },
 })
+await new Promise((resolve) => setImmediate(resolve))
 scene.updateMatrixWorld(true)
 camera.updateMatrixWorld()
 const device = scene.getObjectByName('device:D')
@@ -21,16 +38,25 @@ assert.ok(device)
 const icon = device.getObjectByName('device-icon')
 assert.ok(icon?.isSprite, '每个可显示设备必须有朝向镜头的点击图标')
 assert.equal(icon.material.depthTest, false, '设备图标必须在放大管体前可见')
-assert.ok(icon.material.map?.image?.data?.some((value) => value > 0), '设备图标必须有可渲染图案')
+assert.equal(resolvedDevice?.code, 'D', '图标必须由主图同款合成链路按设备身份生成')
+assert.equal(textureUrl, 'mock-marker://D', 'Sprite 贴图必须采用合成产物 URL,而不是自绘图元')
+assert.ok(icon.material.map?.userData?.seeded, '贴图必须挂到 Sprite 材质上')
+assert.equal(icon.visible, true)
+assert.equal(icon.center.y, 0, '水滴尖端锚定:Sprite 底部中心为原点')
 const initialWorldSize = icon.getWorldScale(new THREE.Vector3()).y
 const initialDistance = camera.position.distanceTo(icon.getWorldPosition(new THREE.Vector3()))
 const screenSize = initialWorldSize / initialDistance
 assert.ok(screenSize > 0)
 assert.equal(viewer.focusDevice('D'), true)
+// focusDevice 改了相机姿态;假渲染器不会替我们刷新矩阵,投影前必须显式更新。
+scene.updateMatrixWorld(true)
+camera.updateMatrixWorld()
 const focusedWorldSize = icon.getWorldScale(new THREE.Vector3()).y
 const focusedDistance = camera.position.distanceTo(icon.getWorldPosition(new THREE.Vector3()))
 assert.ok(Math.abs(focusedWorldSize / focusedDistance - screenSize) / screenSize < 0.03, '图标保持固定屏幕尺寸')
-const screen = icon.getWorldPosition(new THREE.Vector3()).project(camera)
+// 点击点取「图标可见区域中心」——锚点是水滴尖端(Sprite 底部),直接点锚点会落在图形之外。
+const clickPoint = new THREE.Box3().setFromObject(icon).getCenter(new THREE.Vector3())
+const screen = clickPoint.project(camera)
 const clientX = (screen.x + 1) * 450
 const clientY = (1 - screen.y) * 250
 function pointer(type, x = clientX) {

+ 2 - 1
.scratch/cockpit-shell-panel-layout/verify/t52-device-plan.mjs

@@ -15,7 +15,8 @@ assert.equal(result.devices.find((d) => d.code === 'linked').placement, 'linked'
 assert.equal(result.devices.find((d) => d.code === 'wrong-id').placement, 'nearby')
 assert.equal(result.devices.find((d) => d.code === 'near').placementText, '邻近设备 · 示意安装')
 assert.equal(result.devices.find((d) => d.code === 'gas').placement, 'standalone')
-assert.equal(result.deviceList.find((d) => d.code === 'no-position').placement, 'list-only')
+assert.ok(!result.deviceList.some((d) => d.code === 'no-position'), '无坐标且无真实关联的设备在详情视图中必须完全隐藏(维护者 2026-10-22)')
+assert.ok(!result.devices.some((d) => d.code === 'no-position'))
 assert.ok(!result.devices.some((d) => d.code === 'denied'))
 assert.equal(new Set(result.devices.filter((d) => d.pipeCode === 'P').map((d) => d.slot)).size, 3)
 assert.equal(JSON.stringify(rows), original)

+ 47 - 0
.scratch/cockpit-shell-panel-layout/verify/t52-facility-relations.mjs

@@ -0,0 +1,47 @@
+import assert from 'node:assert/strict'
+import { planPipeEnhancement } from '../../../src/modules/cockpit/domain/pipeEnhancement.js'
+import { planEnhancementDevices } from '../../../src/modules/cockpit/domain/enhancementDevices.js'
+import { fetchDeviceFacilityRelations, relationIndex } from '../../../src/modules/cockpit/services/deviceFacilityRelations.js'
+
+const pipe = { code: 'P', networkId: 'raw_P11920', industry: 'WATER_SUPPLY', positions: [[110, 28], [110.001, 28]] }
+const plan = planPipeEnhancement({ selected: { kind: 'pipe', code: 'P' }, visiblePipes: [pipe] })
+const device = { code: 'D-1', industry: 'WATER_SUPPLY', longitude: 110.0005, latitude: 28, coordinate: { renderable: true } }
+
+const hit = planEnhancementDevices(plan, [device], {
+    facilityRelations: new Map([['D-1', { deviceCode: 'D-1', facilityCode: 'raw_P11920', facilityKind: 'PIPE', relationType: 'GAS_PIPE_POINT_DEVICE' }]]),
+})
+assert.equal(hit.devices[0].placement, 'linked')
+assert.equal(hit.devices[0].placementText, '真实设施关联 · 位置示意')
+assert.equal(hit.devices[0].relationType, 'GAS_PIPE_POINT_DEVICE')
+assert.equal(hit.devices[0].pipeCode, 'P')
+
+const miss = planEnhancementDevices(plan, [device], {
+    facilityRelations: new Map([['D-1', { deviceCode: 'D-1', facilityCode: 'not-a-pipe', facilityKind: 'MANHOLE', relationType: 'MANHOLE_DEVICE' }]]),
+})
+assert.equal(miss.devices[0].placement, 'nearby', '设施编码对不上时必须降级为邻近示意')
+assert.equal(miss.devices[0].placementText, '邻近设备 · 示意安装')
+
+const empty = planEnhancementDevices(plan, [device], { facilityRelations: new Map() })
+assert.equal(empty.devices[0].placement, 'nearby')
+assert.equal(empty.devices[0].relationType, '')
+
+const section = await fetchDeviceFacilityRelations({
+    config: { dataSource: 'real', api: { baseUrl: 'http://test.invalid/api/cockpit/v1' } },
+    fetchImpl: async () => new Response(JSON.stringify({ code: 200, data: { relations: [
+        { deviceCode: 'D-1', facilityCode: 'raw_P11920', facilityKind: 'PIPE', relationType: 'GAS_PIPE_POINT_DEVICE' },
+        { deviceCode: null, facilityCode: 'raw_X', facilityKind: 'MANHOLE', relationType: 'MANHOLE_DEVICE' },
+        { deviceCode: '', facilityCode: 'raw_Y', facilityKind: 'MANHOLE', relationType: 'MANHOLE_DEVICE' },
+        { deviceCode: 'D-2', facilityCode: '', facilityKind: 'MANHOLE', relationType: 'MANHOLE_DEVICE' },
+    ], statistics: { skippedManholeFacilityMissing: 4, deviceCodeUnresolved: 2 } } })),
+})
+assert.equal(section.state, 'success')
+const index = relationIndex(section)
+assert.equal(index.size, 1, '只有 deviceCode 与 facilityCode 都齐全的行可回联')
+assert.equal(index.get('D-1').facilityCode, 'raw_P11920')
+
+const failed = await fetchDeviceFacilityRelations({
+    config: { dataSource: 'real', api: { baseUrl: 'http://test.invalid/api/cockpit/v1' } },
+    fetchImpl: async () => new Response(JSON.stringify({ code: 500, msg: 'boom' })),
+})
+assert.equal(relationIndex(failed).size, 0)
+console.log('PASS 真实设施关联优先/设施编码不匹配降级/空索引降级/只回联齐全行/取数失败降级')

+ 57 - 14
src/modules/cockpit/CockpitShell.vue

@@ -376,7 +376,7 @@
                     v-for="panelId in columns.left"
                     :key="panelId"
                     :panel-id="panelId"
-                    :title="labelForPanel(panelId)"
+                    :title="panelTitle(panelId)"
                     :meta="panelMeta(panelId)"
                     :section="sectionOf(panelId)"
                     :expanded="isExpanded(panelId)"
@@ -387,7 +387,6 @@
                         :view="panelViewOf(panelId)"
                         @locate="locateOnMap"
                         @open-card="openLedgerCard"
-                        @ledger-more="loadMoreLedger"
                         @badge-needed="loadLedgerBadge"
                         @panel-action="handlePanelAction"
                     />
@@ -409,7 +408,7 @@
                     v-for="panelId in columns.right"
                     :key="panelId"
                     :panel-id="panelId"
-                    :title="labelForPanel(panelId)"
+                    :title="panelTitle(panelId)"
                     :meta="panelMeta(panelId)"
                     :section="sectionOf(panelId)"
                     :expanded="isExpanded(panelId)"
@@ -420,7 +419,6 @@
                         :view="panelViewOf(panelId)"
                         @locate="locateOnMap"
                         @open-card="openLedgerCard"
-                        @ledger-more="loadMoreLedger"
                         @badge-needed="loadLedgerBadge"
                         @panel-action="handlePanelAction"
                     />
@@ -495,6 +493,8 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { storeToRefs } from 'pinia'
 import { MODES, columnsForMode, labelForPanel, modeLabel } from './domain/panelCatalog.js'
 import { panelView, hasPanelView } from './domain/panelViews.js'
+// 票 65:「预警效率」面板标题随预警/报警切换,词表复用 domain 单一词条。
+import { responseEfficiencyTitle } from './domain/warningResponsePanels.js'
 // 票 20:概览的联动映射(`link` 键 → 目标模式);外壳不自己发明这张表。
 import { OVERVIEW_LINK_MODES } from './domain/overviewPanels.js'
 import { LOCATE_RESULT } from './domain/mapLocate.js'
@@ -545,6 +545,7 @@ import { pipeColorCounts } from './domain/pipeVisual.js'
 import { usePipeEnhancementStore } from './stores/pipeEnhancementStore.js'
 import { useEnhancementDevicesStore } from './stores/enhancementDevicesStore.js'
 import { planEnhancementDevices } from './domain/enhancementDevices.js'
+import { fetchDeviceFacilityRelations, relationIndex } from './services/deviceFacilityRelations.js'
 import { useDetailStore } from './stores/detailStore.js'
 import { useDeviceLayerStore } from './stores/deviceLayerStore.js'
 import { useInfrastructureStore } from './stores/infrastructureStore.js'
@@ -628,6 +629,16 @@ const { sections: overviewSections } = storeToRefs(overviewStore)
  */
 const lifecycleReady = ref(false)
 
+/**
+ * 面板外框标题(票 65):预警处置「预警效率」面板的标题随预警/报警切换一起换,
+ * 词表复用 domain 的 `responseEfficiencyTitle`(与面板内部标题同一份,不新增第二张)。
+ * 其余面板标题仍是静态的 `panelCatalog.js` 中文名。
+ */
+function panelTitle(panelId) {
+    if (panelId === 'responseEfficiency') return responseEfficiencyTitle(eventStore.mode)
+    return labelForPanel(panelId)
+}
+
 /** 该 panelId 的分区(各 store 各查各的;都没接就回 null)。 */
 function sectionOf(panelId) {
     // 票 20:态势总览**不自己取数**,它的分区由另外三块合成(见 overviewStore 的说明)。
@@ -669,6 +680,14 @@ function panelViewOf(panelId) {
     // 票 64:运行信息面板要当前**片区**键,用来给指标列表算页码归属(换片区 ⇒ 页码回第 1 页,
     // 周期刷新 ⇒ 页码保持)。只传标量、不传块对象,与票 43 第四轮「别拿对象当键」同一条纪律。
     else if (panelId === 'operation') extra = { scope: scope.value }
+    // 票 62 追加需求:运行概况的**设备台账**数据在 `monitoringOverview` 分区(`devices[]`,
+    // 与那边 `counts.equipmentCount` 同源),而本面板自己的分区是 `operationOverview`
+    // (`/monitoring/situation`:趋势/指标状态/异常口径)。跨分区取数只在这一处搬运 ——
+    // 与上面「态势总览横跨三块」同一立场,注册表签名保持 `(panelId, data, section, extra)`。
+    else if (panelId === 'operationOverview') {
+        const overview = monitoringSections.value.monitoringOverview
+        extra = { devices: (overview && overview.data && overview.data.devices) || [] }
+    }
     // 票 20:态势总览要**三份领域对象**(它横跨基础设施/运行监测/预警设置三块),
     // 与基础设施面板要「设施底数 + 行业构成 + 区域分布」一样,都是"这一块的数据来自哪几处"
     // 的搬运 —— 写在壳里,注册表签名保持 `(panelId, data, section, extra)` 不变。
@@ -688,7 +707,11 @@ function panelMeta(panelId) {
         const section = eventStore.sections.currentEvents
         if (!section || section.loaded !== true) return '正在取数'
         if (!view.hasContent) return '暂无事件'
-        return view.limited ? `${view.rows.length} 条事件(最新)` : `${view.rows.length} 条事件`
+        // 票 61:条数读服务端 total(不由当前页反推);多页时带当前页读数。
+        const total = view.pagination ? view.pagination.total : view.rows.length
+        return view.pagination && view.pagination.pageCount > 1
+            ? `${total} 条事件 · 第 ${view.pagination.page}/${view.pagination.pageCount} 页`
+            : `${total} 条事件`
     }
     if (!hasPanelView(panelId)) return '面板占位'
     const section = sectionOf(panelId)
@@ -752,13 +775,7 @@ function openLedgerCard(pointCode) {
  * 全局条件只有一个可操作面(决策 02 的 Q19)。
  */
 
-/** 票 18:台账「加载下一页」。 */
-function loadMoreLedger() {
-    return monitoringStore.loadMoreLedger()
-}
-
-/**
- * 票 18:台账卡片进视口 → 补拉该点位的设备徽标(Q14 裁定:进视口才拉、逐点位、失败不牵连其它卡片)。
+/** 票 18:台账卡片进视口 → 补拉该点位的设备徽标(Q14 裁定:进视口才拉、逐点位、失败不牵连其它卡片)。
  *
  * 触发点在 `PanelBlocks.vue` 的 `IntersectionObserver`(组件只回答"这张卡露出来了"),
  * 取数与记账在 store —— 组件不认识接口,也不自己发请求。
@@ -927,6 +944,25 @@ const modeLayerPlan = computed(() => planModeLayers({
     entered: !introVisible.value,
 }))
 
+/**
+ * 设备→设施真实关联索引(维护者 2026-10-22 批准的后端契约)。
+ *
+ * 后端 `GET /api/cockpit/v1/device-facility-relations` 只按真实主键解析出 `deviceCode` 的行;
+ * 当前库数据下通常为空(关联表存的是设备 id、管点 network_id 悬空、equipment_id 查不到),
+ * 因此这里拿到空 Map 属**正常降级**,界面继续标「邻近设备 · 示意安装」,不伪报真实绑定。
+ */
+const facilityRelations = ref(new Map())
+const facilityRelationsState = ref({ state: 'idle', loaded: false })
+async function loadFacilityRelations() {
+    try {
+        const section = await fetchDeviceFacilityRelations()
+        facilityRelations.value = relationIndex(section)
+        facilityRelationsState.value = { state: section.state, loaded: true }
+    } catch {
+        facilityRelations.value = new Map()
+        facilityRelationsState.value = { state: 'failed', loaded: true }
+    }
+}
 const pipeEnhancementPlan = computed(() => {
     const plan = planPipeEnhancement({
         selected: pipeEnhancementSelection.value,
@@ -934,10 +970,17 @@ const pipeEnhancementPlan = computed(() => {
         visiblePipes: modeLayerPlan.value.pipelines,
         visibleComponents: modeLayerPlan.value.facilities,
     })
-    return { ...plan, ...planEnhancementDevices(plan, enhancementDevicesStore.source.rows, { mode: mode.value, abnormalDevices: modeAbnormalDevices.value }),
-        deviceCoverage: enhancementDevicesStore.source }
+    return { ...plan, ...planEnhancementDevices(plan, enhancementDevicesStore.source.rows, {
+        mode: mode.value, abnormalDevices: modeAbnormalDevices.value, facilityRelations: facilityRelations.value,
+    }),
+        deviceCoverage: enhancementDevicesStore.source,
+        relationCoverage: facilityRelationsState.value }
 })
 watch(pipeEnhancementSelection, () => enhancementDevicesStore.closeDetail())
+// 首次打开详情视图时取一次真实关联(只读、免认证);失败按空索引降级,绝不阻塞场景。
+watch(() => pipeEnhancementPlan.value.visible, (visible) => {
+    if (visible && !facilityRelationsState.value.loaded) void loadFacilityRelations()
+})
 watch(() => pipeEnhancementPlan.value.deviceList, (list) => enhancementDevicesStore.reconcile(list))
 watch(() => mapStore.refreshing, (refreshing) => {
     if (refreshing) enhancementDevicesStore.invalidate()

+ 59 - 12
src/modules/cockpit/components/PipeEnhancement.vue

@@ -18,16 +18,36 @@
         </div>
         <div class="pipe-enhancement__workspace">
             <div ref="viewport" class="pipe-enhancement__viewport" data-pipe-enhancement-viewport role="region" aria-label="地下管线详情视图三维场景"></div>
+            <!--
+                设备详情浮层:维护者 2026-10-22 要求「点击后在三维场景中展现浮动 UI 面板」,
+                所以它脱离右侧列表、浮在场景之上(保持场景/相机不动),可单独关闭。
+            -->
+            <section v-if="deviceState.selected" class="pipe-enhancement__device-detail" data-enhancement-device-detail>
+                <header>
+                    <strong>{{ deviceState.selected.name || deviceState.selected.code }}</strong>
+                    <button type="button" data-enhancement-device-close @click="$emit('device-close')">关闭详情</button>
+                </header>
+                <p>{{ deviceState.selected.placementText }}</p>
+                <p v-if="deviceState.selected.pointCode">所属监测点:{{ deviceState.selected.pointCode }}</p>
+                <p v-if="deviceState.loadingDetail">设备详情加载中</p>
+                <template v-else-if="!deviceState.aggregate?.data"><p>设备详情暂不可用</p><button type="button" @click="$emit('device-retry')">重新加载</button></template>
+                <EquipmentDetailSections v-else :aggregate="deviceState.aggregate" :device-code="deviceState.selected.code" />
+            </section>
             <aside class="pipe-enhancement__devices" aria-label="附近设备与详情">
                 <section v-if="localPipeStates.length" class="pipe-enhancement__pipe-list" data-pipe-status-list aria-label="附近管道状态">
                     <h3>附近管道状态({{ localPipeStates.length }})</h3>
                     <div class="pipe-enhancement__pipe-scroll">
-                        <p v-for="pipe in localPipeStates" :key="pipe.code">
+                        <p v-for="pipe in pagedPipeStates" :key="pipe.code">
                             <button type="button" class="pipe-enhancement__pipe" :aria-pressed="plan.object.sourceKind === 'pipe' && plan.object.code === pipe.code" @click="$emit('object-pick', { objectKind: 'pipe', code: pipe.code })">{{ pipe.title }}({{ pipe.code }})</button>:{{ pipe.stateText || '状态未提供' }}
                             <small class="pipe-enhancement__dimensions">源管径 {{ pipe.diameterM ? pipe.diameter + ' mm' : '未提供(仅导引)' }} · 源材质 {{ pipe.material || '未提供' }}({{ pipe.surface?.label || '材质未识别' }})</small>
                             <small class="pipe-enhancement__dimensions">{{ pipe.clipped ? '当前裁片' : '地理路径' }} {{ pipe.geographicLengthM?.toFixed(2) }} m · 后端管长 {{ pipe.lengthM ?? '未提供' }}{{ pipe.lengthM !== null ? '(源值)' : '' }}</small>
                         </p>
                     </div>
+                    <nav v-if="pipePageCount > 1" class="pipe-enhancement__pager" data-pipe-pager aria-label="附近管道状态翻页">
+                        <button type="button" :disabled="pipePage <= 1" @click="pipePage -= 1">上一页</button>
+                        <span>第 {{ pipePage }} / {{ pipePageCount }} 页</span>
+                        <button type="button" :disabled="pipePage >= pipePageCount" @click="pipePage += 1">下一页</button>
+                    </nav>
                 </section>
                 <section class="pipe-enhancement__device-list" data-device-list aria-label="监测设备">
                     <h3>监测设备({{ plan.deviceList?.length || 0 }})</h3>
@@ -43,14 +63,6 @@
                         </button>
                     </div>
                 </section>
-                <section v-if="deviceState.selected" class="pipe-enhancement__device-detail" data-enhancement-device-detail>
-                    <header><strong>{{ deviceState.selected.name || deviceState.selected.code }}</strong><button type="button" @click="$emit('device-close')">关闭详情</button></header>
-                    <p>{{ deviceState.selected.placementText }}</p>
-                    <p v-if="deviceState.selected.pointCode">所属监测点:{{ deviceState.selected.pointCode }}</p>
-                    <p v-if="deviceState.loadingDetail">设备详情加载中</p>
-                    <template v-else-if="!deviceState.aggregate?.data"><p>设备详情暂不可用</p><button type="button" @click="$emit('device-retry')">重新加载</button></template>
-                    <EquipmentDetailSections v-else :aggregate="deviceState.aggregate" :device-code="deviceState.selected.code" />
-                </section>
             </aside>
         </div>
         <dl class="pipe-enhancement__facts">
@@ -79,6 +91,20 @@ const emit = defineEmits(['close', 'radius-change', 'device-pick', 'device-close
 const viewport = ref(null)
 const localPipeStates = computed(() => [...new Map((props.plan.objects || [])
     .filter((object) => object.sourceKind === 'pipe').map((object) => [object.code, object])).values()])
+/**
+ * 附近管道状态分页(维护者 2026-10-22):邻域管网很容易几十条,
+ * 一屏铺开会把设备区挤到看不见。每页固定 8 条,翻页只切窗口、不改数据。
+ */
+const PIPE_PAGE_SIZE = 8
+const pipePage = ref(1)
+const pipePageCount = computed(() => Math.max(1, Math.ceil(localPipeStates.value.length / PIPE_PAGE_SIZE)))
+const pagedPipeStates = computed(() => {
+    const page = Math.min(Math.max(1, pipePage.value), pipePageCount.value)
+    const start = (page - 1) * PIPE_PAGE_SIZE
+    return localPipeStates.value.slice(start, start + PIPE_PAGE_SIZE)
+})
+// 换选管段/邻域对象变化时回到第 1 页,避免停在一个已不存在的页码上。
+watch(localPipeStates, () => { pipePage.value = 1 })
 let viewer = null
 let mountGeneration = 0
 const open = ref(false)
@@ -145,13 +171,34 @@ 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 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 32%); gap: 12px; min-height: 0; }
+.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; }
 .pipe-enhancement__pipe-scroll, .pipe-enhancement__device-scroll { min-height: 0; overflow: auto; overscroll-behavior: contain; }
-.pipe-enhancement__device-detail { position: absolute; z-index: 4; inset: 0; min-height: 0; overflow: auto; padding: 12px; border: 1px solid #36b8d4; border-radius: 5px; background: rgba(2, 12, 32, .98); box-shadow: 0 0 24px rgba(0, 0, 0, .65); }
-.pipe-enhancement__device-detail :deep(.equipment-sections) { max-height: calc(100% - 64px); overflow: auto; }
+/* 设备详情浮层:浮在三维场景之上(维护者 2026-10-22),不占用右侧列表空间。 */
+.pipe-enhancement__device-detail {
+    position: absolute;
+    z-index: 6;
+    left: 12px;
+    top: 12px;
+    width: min(340px, calc(100% - 24px));
+    max-height: calc(100% - 24px);
+    overflow: auto;
+    box-sizing: border-box;
+    padding: 12px;
+    border: 1px solid #36b8d4;
+    border-radius: 6px;
+    background: rgba(2, 12, 32, .97);
+    box-shadow: 0 10px 30px rgba(0, 0, 0, .7);
+    font-size: 12px;
+}
+.pipe-enhancement__device-detail header { display: flex; justify-content: space-between; gap: 8px; align-items: center; margin-bottom: 8px; }
+.pipe-enhancement__device-detail button { color: #e4f4ff; background: #08213a; border: 1px solid #21618a; padding: 5px 8px; cursor: pointer; }
+.pipe-enhancement__device-detail :deep(.equipment-sections) { max-height: 60vh; overflow: auto; }
+.pipe-enhancement__pager { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-top: 6px; font-size: 11px; color: #a7c4d9; }
+.pipe-enhancement__pager button { padding: 3px 8px; }
+.pipe-enhancement__pager button:disabled { opacity: .4; cursor: default; }
 .pipe-enhancement__pipe { padding: 3px 5px; border: 1px solid #226286; background: #072039; color: #d9f7ff; cursor: pointer; text-align: left; }
 .pipe-enhancement__pipe[aria-pressed="true"] { border-color: #d8fbff; box-shadow: inset 0 0 4px #38bac9; }
 .pipe-enhancement__dimensions { display: block; margin-top: 4px; color: #a7c4d9; }

+ 18 - 5
src/modules/cockpit/domain/enhancementDevices.js

@@ -28,10 +28,14 @@ function deviceSlot(code) {
     return (hash ^ (hash >>> 13)) >>> 0
 }
 
-export function planEnhancementDevices(plan, rows = [], { mode = 'overview', abnormalDevices = null } = {}) {
+export function planEnhancementDevices(plan, rows = [], { mode = 'overview', abnormalDevices = null, facilityRelations = null } = {}) {
     if (!plan.visible) return { devices: [], deviceList: [] }
     const corridor = createNeighborhood(plan.object.path, plan.radiusM || 50)
     const pipes = (plan.objects || [plan.object]).filter((o) => o.sourceKind === 'pipe')
+    // 真实设施关联(`Map<deviceCode, { facilityCode, facilityKind }>`)。
+    // 只有后端按**真实主键**解析出 deviceCode 的行会进来;当前库数据下通常为空,
+    // 届时按下一条的「同业邻近示意」降级 —— 不按名字/编码猜关联。
+    const relations = facilityRelations instanceof Map ? facilityRelations : new Map()
     const devices = []
     const deviceList = []
     const seen = new Set()
@@ -42,12 +46,18 @@ export function planEnhancementDevices(plan, rows = [], { mode = 'overview', abn
         if (!isAssociatedDeviceVisible(detailMode, abnormal?.status || row.status)
             || (mode === 'warningResponse' && !abnormal)) continue
         seen.add(row.code)
-        // Only compare actual source identifiers; never compare a display code to networkId.
-        const linked = row.networkId ? pipes.find((p) => p.networkId && p.networkId === row.networkId) : null
+        // 关联优先级:真实设施关系(后端按主键解析)> 载荷自带 networkId(旧路径,当前数据里设备侧不存在)> 同业邻近示意。
+        // 只比较真实标识符,绝不拿展示编码去比 networkId。
+        const relation = relations.get(row.code) || null
+        const linked = (relation && pipes.find((p) => p.networkId && p.networkId === relation.facilityCode))
+            || (row.networkId ? pipes.find((p) => p.networkId && p.networkId === row.networkId) : null)
         const position = [row.longitude, row.latitude]
         const valid = admitted(row)
         if (!linked && valid && !corridor.contains(position)) continue
-        if (!valid && !linked) { deviceList.push({ ...row, placement: 'list-only', placementText: '位置未提供或未准入 · 仅清单' }); continue }
+        // 维护者 2026-10-22:无坐标/未准入且无真实关联的设备在详情视图中**直接隐藏**。
+        // 此前按「仅清单」保留,会把一堆无位置设备塞进设备列表、挤占有位置设备的可读性;
+        // 详情视图是空间视图,无法定位的设备在这里没有意义(主图台账仍可查)。
+        if (!valid && !linked) continue
         const candidates = linked ? [linked] : pipes.filter((p) => p.industry === row.industry)
         let chosen = null
         for (const pipe of candidates) {
@@ -62,7 +72,10 @@ export function planEnhancementDevices(plan, rows = [], { mode = 'overview', abn
             pipePiece: chosen?.pipe.piece ?? 0, slot,
             pipeDisplayRadiusM: (chosen?.pipe.displayDiameterM || 0) / 2,
             placement: linked ? 'linked' : chosen ? 'nearby' : 'standalone',
-            placementText: linked ? '关联设备 · 示意安装' : chosen ? '邻近设备 · 示意安装' : '设备原位置 · 无同业管段',
+            placementText: linked
+                ? (relation ? '真实设施关联 · 位置示意' : '关联设备 · 示意安装')
+                : chosen ? '邻近设备 · 示意安装' : '设备原位置 · 无同业管段',
+            relationType: relation?.relationType || '',
             retained: row.retained === true }
         devices.push(device)
         deviceList.push(device)

+ 59 - 0
src/modules/cockpit/services/deviceFacilityRelations.js

@@ -0,0 +1,59 @@
+/**
+ * 设备→设施真实关联读模型(维护者 2026-10-22 批准的后端契约落地)。
+ *
+ * 端点:`GET /api/cockpit/v1/device-facility-relations`(免认证只读,提交 `83bd5ee`)。
+ * 响应:`{ generatedAt, readOnly, relations: [{ deviceId, deviceCode, facilityCode, facilityKind, relationType }], statistics }`
+ *
+ * **为什么仍要降级**(2026-10-22 实测,勿把「契约已就绪」当成「真实关联已生效」):
+ *   · `manhole_device_rel` 6 条未删行里只有 2 条 `manhole_id` 是窨井主键,另 4 条存的是设备 id;
+ *   · `gas_pipe_point.network_id` 指向的管段主键在 `pipe_network_base` 里不存在(悬空),4 条全跳过;
+ *   · 保留下来的 2 条关系里 `equipment_id` 在 `equipment_base` 查不到 → `deviceCode` 为 `null`。
+ * 所以当前真实可匹配的关系数是 **0**,前端必须继续使用「同业邻近示意」并在界面如实标注。
+ *
+ * 本模块只做「响应 → Map<deviceCode, relation>」这一步,不做任何按名字/编码的猜测匹配。
+ */
+import { fetchSection } from './domainRequest.js'
+import { SECTION_STATE } from '../domain/sectionState.js'
+
+export const DEVICE_FACILITY_RELATION_PATH = '/device-facility-relations'
+
+function toRelationData(payload) {
+    const rows = Array.isArray(payload?.relations) ? payload.relations : []
+    const statistics = payload?.statistics || {}
+    const relations = []
+    for (const row of rows) {
+        const deviceCode = typeof row?.deviceCode === 'string' ? row.deviceCode.trim() : ''
+        const facilityCode = typeof row?.facilityCode === 'string' ? row.facilityCode.trim() : ''
+        // 只接受能**按设备编码**回联的行;只有 deviceId 的行无法与快照设备匹配,不猜。
+        if (!deviceCode || !facilityCode) continue
+        relations.push({
+            deviceCode,
+            facilityCode,
+            facilityKind: typeof row.facilityKind === 'string' ? row.facilityKind : '',
+            relationType: typeof row.relationType === 'string' ? row.relationType : '',
+        })
+    }
+    return { relations, statistics, generatedAt: payload?.generatedAt || '' }
+}
+
+/**
+ * 读取设备→设施真实关联。
+ * @param {object} [options] `{ config, fetchImpl }`
+ * @returns 分区(`section()`):成功时 `data = { relations, statistics, generatedAt }`
+ */
+export function fetchDeviceFacilityRelations(options = {}) {
+    return fetchSection(DEVICE_FACILITY_RELATION_PATH, {
+        toData: toRelationData,
+        // 「没有关联」是合法结果(空数组),不是取数失败。
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+}
+
+/** 分区 → `Map<deviceCode, relation>`;非成功态回空 Map(调用方按降级处理)。 */
+export function relationIndex(section) {
+    const map = new Map()
+    if (!section || section.state !== SECTION_STATE.SUCCESS) return map
+    for (const relation of section.data?.relations || []) map.set(relation.deviceCode, relation)
+    return map
+}

+ 130 - 57
src/modules/cockpit/services/threePipeEnhancement.js

@@ -7,6 +7,49 @@ import { addDetailedPipeSegment } from './pipeDetailGeometry.js'
 import { attachPointPick } from './pointPick.js'
 import { pipeSurfaceMaterial, outlineSelectedPipe } from './pipeSurfaceMaterial.js'
 import { sourceDiameterM, pipeSurface } from '../domain/pipePhysical.js'
+import { planMarker } from '../domain/markerPlan.js'
+import { renderMarker } from './markerAssets.js'
+
+/**
+ * 状态色键兜底表(与 `mapService.toneColors()` 同值、与 `styles/tokens.css` 同值)。
+ * 色值真源仍是 tokens.css;本表只在读不到计算样式时兜底(脱浏览器回归、样式未挂载)。
+ * 刻意不与 mapService 共享常量:那两个文件带有其它在制品改动,本票不触碰它们。
+ */
+const TONE_FALLBACK = Object.freeze({
+    lv1: '#ff3b52',
+    lv2: '#ffb020',
+    lv3: '#01abea',
+    lv4: '#27d17a',
+    neutral: '#5680b0',
+})
+
+/**
+ * 状态色键 → 十六进制。与 `mapService` 的 `toneColors()` 同一口径:
+ * 色值唯一真源是 `styles/tokens.css`,这里从 `.cockpit-shell` 计算样式读,
+ * 脱浏览器(Node 回归)读不到时用 `markerPlan.TONE_FALLBACK` 同值兜底。
+ */
+function toneHex(tone) {
+    const fallback = TONE_FALLBACK[tone] || TONE_FALLBACK.neutral
+    if (typeof document === 'undefined' || !document.querySelector || typeof getComputedStyle !== 'function') return fallback
+    const shell = document.querySelector('.cockpit-shell')
+    if (!shell) return fallback
+    const value = getComputedStyle(shell).getPropertyValue(`--${tone}`)
+    return value && value.trim() ? value.trim() : fallback
+}
+
+/**
+ * 设备图标的**唯一来源**:复用主图 marker 的合成链路(`planMarker` + `renderMarker`),
+ * 因此地下管线详情视图里的设备图标与一张图上的**同款**,不是另画一套。
+ *
+ * 维护者 2026-10-22:「场景设备图标没有按照要求使用地图中的同款。要保持图标一致性。」
+ * 所以这里不再自绘任何图标;`renderMarker` 的产物(水滴母版 + 行业内层 + 白描边)
+ * 直接作为 Sprite 贴图,尺寸也沿用它的 `draw`(含描边余量)。
+ */
+async function defaultDeviceIcon(device) {
+    const plan = planMarker({ kind: 'monitoring-device', status: device.status, industry: device.industry })
+    const url = await renderMarker(plan, { tintHex: toneHex(plan.tint) })
+    return { url, width: plan.draw.width, height: plan.draw.height }
+}
 
 function defaultRendererFactory({ canvas }) {
     return new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true })
@@ -21,45 +64,6 @@ function project([lon, lat], center, scale) {
     )
 }
 
-function makeIconTexture(kind = 'meter', glyphOnly = false) {
-    const size = 64
-    const data = new Uint8Array(size * size * 4)
-    const put = (x, y, alpha = 255) => {
-        if (x < 0 || y < 0 || x >= size || y >= size) return
-        const i = (y * size + x) * 4
-        data[i] = data[i + 1] = data[i + 2] = 255
-        data[i + 3] = alpha
-    }
-    if (!glyphOnly) {
-        for (let y = 3; y < 61; y++) for (let x = 3; x < 61; x++) {
-            const d = Math.hypot(x - 32, y - 32)
-            if (d < 29) put(x, y, d > 24 ? 255 : 210)
-        }
-        for (let y = 14; y <= 49; y++) for (let x = 14; x <= 49; x++) {
-            const d = Math.hypot(x - 32, y - 32)
-            if (d >= 13 && d <= 16) put(x, y)
-        }
-    }
-    const bars = kind === 'flow' ? [[22, 37, 27], [29, 34, 36], [36, 30, 42], [43, 25, 45]]
-        : kind === 'pressure' ? [[22, 36, 43], [29, 31, 43], [36, 26, 43], [43, 21, 43]]
-        : [[22, 33, 43], [29, 29, 43], [36, 34, 43], [43, 27, 43]]
-    for (const [x, top, bottom] of bars) for (let y = top; y <= bottom; y++) for (let dx = -1; dx <= 1; dx++) put(x + dx, y)
-    const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat)
-    texture.colorSpace = THREE.SRGBColorSpace
-    texture.magFilter = THREE.LinearFilter
-    texture.minFilter = THREE.LinearMipmapLinearFilter
-    texture.generateMipmaps = true
-    texture.needsUpdate = true
-    return texture
-}
-
-function metricIconKind(device) {
-    const type = `${device.typeName || ''} ${device.name || ''}`
-    if (/流量|flow/i.test(type)) return 'flow'
-    if (/压力|pressure/i.test(type)) return 'pressure'
-    return 'meter'
-}
-
 function disposeObject(root) {
     const resources = new Set()
     root.traverse((node) => {
@@ -188,22 +192,27 @@ function buildNeighborhood(plan) {
             emissive: color, emissiveIntensity: 0.28, depthTest: false, depthWrite: false }))
         body.name = 'device-body'
         group.add(body)
-        const iconTexture = makeIconTexture(metricIconKind(device))
-        const icon = new THREE.Sprite(new THREE.SpriteMaterial({ map: iconTexture, color, transparent: true,
-            depthTest: false, depthWrite: false, sizeAttenuation: true }))
+        // 图标:贴图来自主图同款合成(异步到达后替换);尖端锚点 = Sprite 底部中心。
+        const icon = new THREE.Sprite(new THREE.SpriteMaterial({ transparent: true, depthTest: false, depthWrite: false, sizeAttenuation: true }))
         icon.name = 'device-icon'
+        icon.center.set(0.5, 0)
         icon.position.set(0, 0.72, 0)
         icon.renderOrder = 30
         icon.userData.device = device
+        icon.visible = false
         group.add(icon)
-        const glyphTexture = makeIconTexture(metricIconKind(device), true)
-        const iconGlyph = new THREE.Sprite(new THREE.SpriteMaterial({ map: glyphTexture, color: '#ffffff', transparent: true,
-            depthTest: false, depthWrite: false, sizeAttenuation: true }))
-        iconGlyph.name = 'device-icon-glyph'
-        iconGlyph.position.copy(icon.position)
-        iconGlyph.renderOrder = 31
-        iconGlyph.scale.setScalar(0.68)
-        group.add(iconGlyph)
+        // 点击代理:与图标同尺寸的透明平面,专供射线命中。
+        // 不用 Sprite 自身做拾取——Sprite 在嵌套缩放下的 raycast 不稳定(实测同一图标
+        // 最小用例能命中、放进场景就 miss);代理平面只承担命中,视觉仍由 Sprite 提供。
+        const hit = new THREE.Mesh(
+            new THREE.PlaneGeometry(1, 1.2566),
+            new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false, depthTest: false }),
+        )
+        hit.name = 'device-icon-hit'
+        hit.position.copy(icon.position)
+        hit.renderOrder = 29
+        hit.userData.device = device
+        group.add(hit)
         root.add(group)
     }
     return root
@@ -213,7 +222,7 @@ function buildNeighborhood(plan) {
  * Mount a bounded local viewer. The optional rendererFactory is a system-boundary seam
  * for offline tests; production uses WebGLRenderer.
  */
-export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRendererFactory, pixelRatio = 1, onDevicePick = () => {}, onObjectPick = () => {} } = {}) {
+export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRendererFactory, pixelRatio = 1, onDevicePick = () => {}, onObjectPick = () => {}, iconResolver = defaultDeviceIcon, loadTexture = null } = {}) {
     if (!host || !plan || plan.visible !== true || !plan.object) {
         return { status: 'unavailable', sync: () => {}, destroy: () => {}, getState: () => ({ status: 'unavailable' }) }
     }
@@ -274,13 +283,20 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
             if (node.name === 'selected-pipe-outline') node.material.uniforms.resolution.value.set(host.clientWidth || 320, host.clientHeight || 220)
             if (node.name === 'device-icon') {
                 root.updateMatrixWorld(true)
+                // 尺寸 = 主图 marker 的 draw 像素(含描边余量),按视距换算成固定屏显尺寸,
+                // 与主图 billboard 的视觉重量一致;高度按图标自身宽高比。
+                const iconWidth = node.userData.iconWidth || 26.7
+                const aspect = node.userData.iconAspect || 1.2566
                 const world = 2 * camera.position.distanceTo(node.getWorldPosition(new THREE.Vector3()))
-                    * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * 48 / (host.clientHeight || 220)
+                    * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * iconWidth / (host.clientHeight || 220)
                 const parentScale = Math.max(0.0001, node.parent.getWorldScale(new THREE.Vector3()).x)
-                node.scale.setScalar(world / parentScale)
-            } else if (node.name === 'device-icon-glyph') {
-                const badge = node.parent.getObjectByName('device-icon')
-                node.scale.copy(badge.scale).multiplyScalar(0.68)
+                node.scale.set(world / parentScale, (world * aspect) / parentScale, 1)
+                // 点击代理跟随图标尺寸,并把自身中心抬到图标可见区域中心(图标锚点在底部)。
+                const hit = node.parent.getObjectByName('device-icon-hit')
+                if (hit) {
+                    hit.scale.set(node.scale.x, node.scale.y, 1)
+                    hit.position.y = node.position.y + node.scale.y / 2
+                }
             }
         })
         camera.near = Math.max(0.0001, distance > sceneRadius * 1.05 ? distance - sceneRadius * 1.05 : distance / 1000)
@@ -289,6 +305,51 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
         camera.lookAt(target)
         renderer.render?.(scene, camera)
     }
+    /**
+     * 设备图标异步装配:贴图来自主图同款合成(`iconResolver`)。
+     * 图标未到达前 Sprite 保持 `visible=false`,避免出现白色占位方块;
+     * 到达后写入 `userData.iconWidth/iconAspect` 供 `render` 按屏显尺寸缩放。
+     * 场景已销毁/换选(`root` 变化)时丢弃迟到结果。
+     */
+    let iconEpoch = 0
+    const textureLoader = new THREE.TextureLoader()
+    /**
+     * 贴图加载 seam:生产用 `THREE.TextureLoader`(需要浏览器 Image),
+     * 脱浏览器回归可注入同步假加载器,从而离线断言「用的是主图同款图标产物」。
+     * 回调签名 `(texture) => void`,失败时调用 `onError()`。
+     */
+    const loadTextureImpl = typeof loadTexture === 'function'
+        ? loadTexture
+        : (url, onLoad, onError) => textureLoader.load(url, onLoad, undefined, onError)
+    const loadDeviceIcons = () => {
+        const epoch = ++iconEpoch
+        const sceneRoot = root
+        if (!sceneRoot) return
+        sceneRoot.traverse((node) => {
+            if (node.name !== 'device-icon' || node.material.map) return
+            const device = node.userData.device
+            if (!device) return
+            Promise.resolve()
+                .then(() => iconResolver(device))
+                .then((icon) => {
+                    if (destroyed || epoch !== iconEpoch || sceneRoot !== root || !icon?.url) return
+                    node.userData.iconWidth = Number.isFinite(icon.width) ? icon.width : undefined
+                    node.userData.iconAspect = Number.isFinite(icon.height) && Number.isFinite(icon.width) && icon.width > 0
+                        ? icon.height / icon.width
+                        : undefined
+                    loadTextureImpl(icon.url, (texture) => {
+                        if (destroyed || epoch !== iconEpoch || sceneRoot !== root) { texture.dispose?.(); return }
+                        texture.colorSpace = THREE.SRGBColorSpace
+                        node.material.map = texture
+                        node.material.color.set('#ffffff')
+                        node.material.needsUpdate = true
+                        node.visible = true
+                        render()
+                    }, () => { /* 贴图失败:保持不可见,不画自绘替代品。 */ })
+                })
+                .catch(() => { /* 合成失败:保持不可见,不画自绘替代品。 */ })
+        })
+    }
     const sync = (next) => {
         const preserveView = root && current.object.code === next.object.code && current.object.sourceKind === next.object.sourceKind
         current = next
@@ -312,6 +373,7 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
             camera.far = Math.max(100, sceneRadius * 100)
             camera.updateProjectionMatrix()
         }
+        loadDeviceIcons()
         render()
     }
     const detachFocus = attachSceneFocus(host)
@@ -326,8 +388,19 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
         return raycaster.intersectObject(root, true)[0] || null
     }
     const detachPick = attachPointPick(canvas, ({ x, y }) => {
+        // 必须在本次点击坐标上重新投射射线:不能复用 `onDown` 留下的 raycaster 状态,
+        // 否则 pointerdown 未进入画布(或未触发)时拾取会落在上一次的射线上。
+        if (!root || !canvas.getBoundingClientRect) return
+        const rect = canvas.getBoundingClientRect()
+        if (!rect.width || !rect.height) return
+        camera.updateMatrixWorld()
+        root.updateMatrixWorld(true)
+        raycaster.setFromCamera(new THREE.Vector2((x - rect.left) / rect.width * 2 - 1, 1 - (y - rect.top) / rect.height * 2), camera)
         const intersections = raycaster.intersectObject(root, true)
-        const hit = intersections.find((item) => item.object.name === 'device-icon' || item.object.name === 'device-icon-glyph') || intersections[0]
+        // 图标点击优先:命中可见图标即派发设备,即使放大后的管壁在它前方
+        // (图标是屏幕可见交互层,用户点的是看得见的东西)。
+        const iconHit = intersections.find((item) => item.object.name === 'device-icon-hit')
+        const hit = iconHit || intersections[0]
         let node = hit?.object
         while (node) {
             if (node.userData.device) { onDevicePick(node.userData.device); return }