Bladeren bron

feat(cockpit): 设施点独立上图、点击定位与设施详情浮层(票68/101)

票 68:四类设施点(水厂/泵站/水源地/用水户)作为第三条独立图层上图,
新增独立设施图例与 filterStore.facilityTypes 新维度(不碰 pointStatus);
markerPlan/modeLayerPlan/markerMetrics/图标资产接入设施点行。

票 101:管线构件点位(阀门/窨井/特殊管点)独立上图并接管地面视图兜底;
设施点点击=与独立设备同一条「选中→镜头定位→详情浮层」链
(pickMarkerByIdentity 带 key 身份,MapCanvas 走 facilityPointStore.select
共享入口,不新造第二套取数);设施详情为设备式浮层
(FacilityDetailCard + EntityDetailSections presentation=device),
台账卡片/定位按钮/地图点选/重新定位共用同一 select 链;
locateFacilityPoint 与 locateMonitoringPoint 共用 runTerrainLocate。

2026-10-30 15:57 三项细节:管段定位取沿折线几何中点(polylineMidpoint,
非起止点);台账打开的无位置设施详情固定台账侧(is-fixed-left +
data-fixed-side,pending 不算无位置);泵站类型 water_supply/drainage
中文化(认不出回原码,依据后端 PumpStationType)。

说明:两票关闭时因并行在制品未即时入库,本提交只取两票 hunk——
票 98 顶栏时钟、面板视图缓存、按需取数,readoutCache,票 70 KPI 版式、
自适应分辨率等其它在制品仍留工作树,未混入。
ChenJZ 10 uur geleden
bovenliggende
commit
110e17a24f
33 gewijzigde bestanden met toevoegingen van 1963 en 106 verwijderingen
  1. BIN
      public/cockpit/assets/icons/markers/marker-facility-plant.png
  2. BIN
      public/cockpit/assets/icons/markers/marker-facility-pump.png
  3. BIN
      public/cockpit/assets/icons/markers/marker-facility-source.png
  4. BIN
      public/cockpit/assets/icons/markers/marker-facility-user.png
  5. BIN
      public/cockpit/assets/icons/markers/marker-pipe-branch.png
  6. BIN
      public/cockpit/assets/icons/markers/marker-pipe-elbow.png
  7. BIN
      public/cockpit/assets/icons/markers/marker-pipe-special.png
  8. BIN
      public/cockpit/assets/icons/markers/marker-pipe-valve.png
  9. 256 0
      public/cockpit/assets/manifest.json
  10. 82 23
      src/modules/cockpit/CockpitShell.vue
  11. 3 3
      src/modules/cockpit/components/EntityDetailSections.vue
  12. 67 0
      src/modules/cockpit/components/FacilityDetailCard.vue
  13. 146 28
      src/modules/cockpit/components/MapCanvas.vue
  14. 8 1
      src/modules/cockpit/components/PanelBlocks.vue
  15. 15 0
      src/modules/cockpit/domain/coordinate.js
  16. 11 0
      src/modules/cockpit/domain/detailAnchor.js
  17. 3 1
      src/modules/cockpit/domain/entityDetail.js
  18. 426 0
      src/modules/cockpit/domain/facilityPoints.js
  19. 114 0
      src/modules/cockpit/domain/facilitySelection.js
  20. 6 0
      src/modules/cockpit/domain/icons.js
  21. 40 7
      src/modules/cockpit/domain/infrastructurePanels.js
  22. 5 0
      src/modules/cockpit/domain/markerMetrics.js
  23. 54 5
      src/modules/cockpit/domain/markerPlan.js
  24. 14 3
      src/modules/cockpit/domain/modeLayerPlan.js
  25. 66 1
      src/modules/cockpit/domain/pipeNetwork.js
  26. 2 2
      src/modules/cockpit/services/cesiumPipeLayer.js
  27. 8 4
      src/modules/cockpit/services/cesiumPipeModels.js
  28. 98 0
      src/modules/cockpit/services/facilityPointService.js
  29. 241 23
      src/modules/cockpit/services/mapService.js
  30. 98 1
      src/modules/cockpit/services/markerOverlay.js
  31. 14 3
      src/modules/cockpit/stores/entityDetailStore.js
  32. 141 0
      src/modules/cockpit/stores/facilityPointStore.js
  33. 45 1
      src/modules/cockpit/stores/filterStore.js

BIN
public/cockpit/assets/icons/markers/marker-facility-plant.png


BIN
public/cockpit/assets/icons/markers/marker-facility-pump.png


BIN
public/cockpit/assets/icons/markers/marker-facility-source.png


BIN
public/cockpit/assets/icons/markers/marker-facility-user.png


BIN
public/cockpit/assets/icons/markers/marker-pipe-branch.png


BIN
public/cockpit/assets/icons/markers/marker-pipe-elbow.png


BIN
public/cockpit/assets/icons/markers/marker-pipe-special.png


BIN
public/cockpit/assets/icons/markers/marker-pipe-valve.png


+ 256 - 0
public/cockpit/assets/manifest.json

@@ -1825,6 +1825,262 @@
       },
       "fallbackKey": null,
       "core": false
+    },
+    {
+      "key": "marker.inner.facilityPlant",
+      "path": "assets/icons/markers/marker-facility-plant.png",
+      "source": {
+        "board": null,
+        "delivery": "agent-generated-temporary",
+        "layer": "markers/marker-facility-plant.png",
+        "meaning": "内层设施图标·水厂(票 68 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "format": "png",
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
+      "pixelRatio": 1,
+      "transparent": true,
+      "renderMode": "image",
+      "version": "2026.10-t68-agent-temporary",
+      "hash": "sha256:33138b725c127f4d380a231139d189b982d34462728acee02c716f7618628839",
+      "status": "temporary",
+      "appliesTo": {
+        "areas": [
+          "marker.inner.facility"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "内层设施图标·水厂(票 68 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "fallbackKey": null,
+      "core": false
+    },
+    {
+      "key": "marker.inner.facilityPump",
+      "path": "assets/icons/markers/marker-facility-pump.png",
+      "source": {
+        "board": null,
+        "delivery": "agent-generated-temporary",
+        "layer": "markers/marker-facility-pump.png",
+        "meaning": "内层设施图标·泵站(票 68 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "format": "png",
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
+      "pixelRatio": 1,
+      "transparent": true,
+      "renderMode": "image",
+      "version": "2026.10-t68-agent-temporary",
+      "hash": "sha256:70fbec4ef71c3696834b5270ad95c4c312c18df51e5a32f13f8b91464677591e",
+      "status": "temporary",
+      "appliesTo": {
+        "areas": [
+          "marker.inner.facility"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "内层设施图标·泵站(票 68 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "fallbackKey": null,
+      "core": false
+    },
+    {
+      "key": "marker.inner.facilitySource",
+      "path": "assets/icons/markers/marker-facility-source.png",
+      "source": {
+        "board": null,
+        "delivery": "agent-generated-temporary",
+        "layer": "markers/marker-facility-source.png",
+        "meaning": "内层设施图标·水源地(票 68 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "format": "png",
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
+      "pixelRatio": 1,
+      "transparent": true,
+      "renderMode": "image",
+      "version": "2026.10-t68-agent-temporary",
+      "hash": "sha256:eb0a654de6897801445f3cd2839f8ffcca3b16d834c2e29b085f23590a3d654a",
+      "status": "temporary",
+      "appliesTo": {
+        "areas": [
+          "marker.inner.facility"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "内层设施图标·水源地(票 68 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "fallbackKey": null,
+      "core": false
+    },
+    {
+      "key": "marker.inner.facilityUser",
+      "path": "assets/icons/markers/marker-facility-user.png",
+      "source": {
+        "board": null,
+        "delivery": "agent-generated-temporary",
+        "layer": "markers/marker-facility-user.png",
+        "meaning": "内层设施图标·用水户(票 68 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "format": "png",
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
+      "pixelRatio": 1,
+      "transparent": true,
+      "renderMode": "image",
+      "version": "2026.10-t68-agent-temporary",
+      "hash": "sha256:a5264ff739c77419009d8d1c9f0a57751e1a7c7f11aff5518ca57251c2fa1e0f",
+      "status": "temporary",
+      "appliesTo": {
+        "areas": [
+          "marker.inner.facility"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "内层设施图标·用水户(票 68 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "fallbackKey": null,
+      "core": false
+    },
+    {
+      "key": "marker.inner.pipeValve",
+      "path": "assets/icons/markers/marker-pipe-valve.png",
+      "source": {
+        "board": null,
+        "delivery": "agent-generated-temporary",
+        "layer": "markers/marker-pipe-valve.png",
+        "meaning": "内层管线构件图标·阀门(票 101 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "format": "png",
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
+      "pixelRatio": 1,
+      "transparent": true,
+      "renderMode": "image",
+      "version": "2026.10-t101-agent-temporary",
+      "hash": "sha256:ca838f64982eeef3c58a0a40f24f1593657f26e08218aa5de0b26bb6cb2b475b",
+      "status": "temporary",
+      "appliesTo": {
+        "areas": [
+          "marker.inner.pipe-component"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "内层管线构件图标·阀门(票 101 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "fallbackKey": null,
+      "core": false
+    },
+    {
+      "key": "marker.inner.pipeElbow",
+      "path": "assets/icons/markers/marker-pipe-elbow.png",
+      "source": {
+        "board": null,
+        "delivery": "agent-generated-temporary",
+        "layer": "markers/marker-pipe-elbow.png",
+        "meaning": "内层管线构件图标·角管道(弯头)(票 101 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "format": "png",
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
+      "pixelRatio": 1,
+      "transparent": true,
+      "renderMode": "image",
+      "version": "2026.10-t101-agent-temporary",
+      "hash": "sha256:192b9defa98276a3069773802a6ff7a383726d7acbe278a0636904bd44714381",
+      "status": "temporary",
+      "appliesTo": {
+        "areas": [
+          "marker.inner.pipe-component"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "内层管线构件图标·角管道(弯头)(票 101 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "fallbackKey": null,
+      "core": false
+    },
+    {
+      "key": "marker.inner.pipeBranch",
+      "path": "assets/icons/markers/marker-pipe-branch.png",
+      "source": {
+        "board": null,
+        "delivery": "agent-generated-temporary",
+        "layer": "markers/marker-pipe-branch.png",
+        "meaning": "内层管线构件图标·支管管道(三通)(票 101 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "format": "png",
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
+      "pixelRatio": 1,
+      "transparent": true,
+      "renderMode": "image",
+      "version": "2026.10-t101-agent-temporary",
+      "hash": "sha256:def7479138c2713e23cf052044bb532bfb05031206dc35833aa4bf96dc7e311f",
+      "status": "temporary",
+      "appliesTo": {
+        "areas": [
+          "marker.inner.pipe-component"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "内层管线构件图标·支管管道(三通)(票 101 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "fallbackKey": null,
+      "core": false
+    },
+    {
+      "key": "marker.inner.pipeSpecial",
+      "path": "assets/icons/markers/marker-pipe-special.png",
+      "source": {
+        "board": null,
+        "delivery": "agent-generated-temporary",
+        "layer": "markers/marker-pipe-special.png",
+        "meaning": "内层管线构件图标·特殊管点/其它构件(票 101 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "format": "png",
+      "dimensions": {
+        "width": 256,
+        "height": 256
+      },
+      "pixelRatio": 1,
+      "transparent": true,
+      "renderMode": "image",
+      "version": "2026.10-t101-agent-temporary",
+      "hash": "sha256:00916956baeced4b33583aacfa2d7e7a7dc5029d3114f8e505c743d21cdc11e0",
+      "status": "temporary",
+      "appliesTo": {
+        "areas": [
+          "marker.inner.pipe-component"
+        ],
+        "states": [
+          "default"
+        ],
+        "meaning": "内层管线构件图标·特殊管点/其它构件(票 101 临时占位,设计正式切图到位后按同 key 替换)"
+      },
+      "fallbackKey": null,
+      "core": false
     }
   ]
 }

+ 82 - 23
src/modules/cockpit/CockpitShell.vue

@@ -249,7 +249,7 @@
                     :data-anchor-reason="anchorLayout.reason" role="status">
                     {{ selectedDetailCode }} · {{ hiddenAnchorText }}
                     <button v-if="canRelocate" type="button" data-anchor-relocate @click="relocateSelected">重新定位</button>
-                    <button type="button" data-anchor-close @click="detailStore.closeDetail()">关闭</button>
+                    <button type="button" data-anchor-close @click="closeActiveDetail">关闭</button>
                 </span>
 
                 <!--
@@ -369,7 +369,7 @@
                 -->
                 <PipeColorLegend :counts="pipeColorCountsView" />
                 <!-- 实体详情只占中央一张图;按来源栏靠近对应侧边,绝不覆盖侧栏。 -->
-                <EntityDetailDrawer v-if="!introVisible" :side="entityDetailSide" />
+                <EntityDetailDrawer v-if="!introVisible && !facilityDetailOpen" :side="entityDetailSide" @locate-device="locateDeviceFromDetail" />
             </section>
 
             <PipeEnhancement
@@ -411,6 +411,7 @@
                         @locate="locateOnMap"
                         @open-card="openLedgerCard"
                         @card-action="(action) => handleCardAction(action, panelSideOf(panelId))"
+                        @locate-facility="(action) => handleCardAction(action, panelSideOf(panelId))"
                         @badge-needed="loadLedgerBadge"
                         @panel-action="handlePanelAction"
                     />
@@ -448,6 +449,7 @@
                         @locate="locateOnMap"
                         @open-card="openLedgerCard"
                         @card-action="(action) => handleCardAction(action, panelSideOf(panelId))"
+                        @locate-facility="(action) => handleCardAction(action, panelSideOf(panelId))"
                         @badge-needed="loadLedgerBadge"
                         @panel-action="handlePanelAction"
                     />
@@ -474,14 +476,17 @@
             <div v-if="detailOpen && !introVisible && selectionVisibleInMode && anchorLayout.visible && !fixedLocationReason"
                 class="cockpit-shell__anchor-lead" data-anchor-lead :style="anchorLeadStyle" aria-hidden="true"></div>
             <aside v-if="detailOpen && !introVisible && selectionVisibleInMode" ref="detailRef" class="cockpit-shell__detail"
-                :class="{ 'is-anchored': !fixedLocationReason }"
+                :class="{ 'is-anchored': !fixedLocationReason, 'is-fixed-left': facilityFixedLeft }"
                 :style="detailStyle"
                 :data-anchor-state="fixedLocationReason ? 'fixed' : anchorLayout.visible ? 'visible' : 'hidden'"
                 :data-anchor-side="anchorLayout.side"
+                :data-fixed-side="facilityFixedLeft ? 'left' : 'right'"
                 :data-anchor-code="selectedDetailCode"
                 :data-selection-paused="selectionVisibleInMode ? 'no' : 'yes'"
-                aria-label="点位与设备详情">
-                <PointDetailCard :location-reason="fixedLocationReason" />
+                :aria-label="facilityDetailOpen ? '设施详情' : '点位与设备详情'"
+                :data-facility-anchor-id="facilityDetailOpen ? entityDetailStore.identity.id : null">
+                <FacilityDetailCard v-if="facilityDetailOpen" :location-reason="fixedLocationReason" :location-hint="facilityPointStore.selectedPoint?.locationHint || ''" @locate-device="locateDeviceFromDetail" />
+                <PointDetailCard v-else :location-reason="fixedLocationReason" />
             </aside>
             <aside v-if="eventStore.selectedEvent && uiStore.mode === 'warningResponse' && !introVisible"
                 class="cockpit-shell__event-detail" aria-label="事件详情" @keydown.esc="eventStore.closeEvent()">
@@ -531,7 +536,7 @@ import { responseEfficiencyTitle } from './domain/warningResponsePanels.js'
 // 唯一的联动是预警类型排行 → 切「预警处置」并做本地类型筛选(见 handlePanelAction)。
 import { EVENT_FAMILY } from './domain/events.js'
 import { LOCATE_RESULT } from './domain/mapLocate.js'
-import { resolveDetailAnchor, detailAnchorLocation } from './domain/detailAnchor.js'
+import { resolveDetailAnchor, resolveFacilityDetailAnchor, detailAnchorLocation } from './domain/detailAnchor.js'
 import { DEVICE_LAYER_KIND } from './domain/deviceLayer.js'
 import { ANCHOR_HIDDEN, solvePointAnchor } from './domain/pointAnchor.js'
 import {
@@ -545,6 +550,7 @@ import { VIEW_MODES } from './domain/viewModes.js'
 import { useUiStore } from './stores/uiStore.js'
 import { useFilterStore } from './stores/filterStore.js'
 import { usePipeNetworkStore } from './stores/pipeNetworkStore.js'
+import { useFacilityPointStore } from './stores/facilityPointStore.js'
 import CockpitPanel from './components/CockpitPanel.vue'
 import CockpitIcon from './components/CockpitIcon.vue'
 import MapCanvas from './components/MapCanvas.vue'
@@ -562,9 +568,11 @@ import WarningResponsePane from './components/WarningResponsePane.vue'
 import CockpitIntro from './components/CockpitIntro.vue'
 import SectionStateTag from './components/SectionStateTag.vue'
 import PointDetailCard from './components/PointDetailCard.vue'
+import FacilityDetailCard from './components/FacilityDetailCard.vue'
 import { DETAIL_LEVEL } from './domain/detailFlow.js'
 import { homeLabel } from './domain/viewPresets.js'
 import { ensureCockpitMocks } from './mocks/index.js'
+// real 模式的首屏不再为一个用不到的样例包付出解析成本。
 // 票 14:启动计划(规格 09 的主接缝)——real 与 mock 同构启动,差别只剩 registerMocks。
 import { STARTUP_ACTION, planCockpitStartup } from './domain/startupPlan.js'
 // 票 41:监测点选择的统一入口(外壳的「重新定位」也走它,不发明第二条定位路径)。
@@ -632,6 +640,9 @@ const filterStore = useFilterStore()
 // 票 24:设施点位与管线是**独立于地图摘要**的一条读取路径(不同业务对象、不同口径),
 // 单独一个 store 持有,避免两种对象在同一个 store 里互相污染统计。
 const pipeNetworkStore = usePipeNetworkStore()
+// 票 68:四类设施点(水厂/泵站/水源地/用水户)是第三条读取路径(设施台账 + 实体详情补坐标),
+// 同样独立成 store:设施点不进摘要序列、也不与管线/构件混算。
+const facilityPointStore = useFacilityPointStore()
 const detailStore = useDetailStore()
 const mapStore = useMapStore()
 const infrastructureStore = useInfrastructureStore()
@@ -639,6 +650,7 @@ const monitoringStore = useMonitoringStore()
 const overviewStore = useOverviewStore()
 const eventStore = useEventStore()
 const entityDetailStore = useEntityDetailStore()
+const facilityDetailOpen = computed(() => entityDetailStore.open && entityDetailStore.identity?.kind === 'facility')
 const runtimeStore = useRuntimeStore()
 const statusPanelExpanded = ref(false)
 const { mode, expandedPanelIds } = storeToRefs(uiStore)
@@ -848,7 +860,7 @@ function panelMeta(panelId) {
 
 /** 定位反馈:只有失败情形值得给一行字(成功时地图与详情卡自己会说话)。 */
 const locateFeedback = computed(() => {
-    const current = infrastructureStore.locate
+    const current = facilityPointStore.locate || infrastructureStore.locate
     if (!current || !current.result || current.result === LOCATE_RESULT.OK) return null
     return current
 })
@@ -869,6 +881,7 @@ watch(locateFeedback, (current) => {
     locateDismissTimer = window.setTimeout(() => {
         locateDismissTimer = null
         infrastructureStore.clearLocate()
+        facilityPointStore.clearLocate()
     }, 8000)
 })
 
@@ -878,6 +891,7 @@ function dismissLocateFeedback() {
         locateDismissTimer = null
     }
     infrastructureStore.clearLocate()
+    facilityPointStore.clearLocate()
 }
 
 /** 点面板里的可定位对象 → 地图定位(接缝在 store,本组件只管转发)。 */function locateOnMap(pointCode) {
@@ -887,9 +901,19 @@ function dismissLocateFeedback() {
 /**
  * 票 18:点台账卡片 → 打开与地图**相同**的两级详情。
  *
- * 刻意不移动相机:地图上点标记也不动相机(ADR 0008 的既有语义),
- * 台账若顺手把镜头飞走,同一份详情会有两种手感(Q4 裁定的复用方式)。
+ * 监测点/设备沿用各自已有入口;设施台账按维护者追加要求同时定位并打开点旁浮层,
+ * 与地图设施点点击共用 facilityPointStore.select,不再走历史固定抽屉。
  */
+/**
+ * 详情里的「关联设备定位」——沿用运行监测「设备台账」的定位方法(维护者 2026-10-29)。
+ * 即 `monitoringStore.locateCode`:判定能否定位 → 地形感知飞行 → 进设备详情;
+ * 三种「点不动」由 `domain/mapLocate.js` 如实反馈,不另造第二套。
+ */
+function locateDeviceFromDetail(deviceCode) {
+    if (facilityDetailOpen.value) entityDetailStore.close()
+    return monitoringStore.locateCode(deviceCode)
+}
+
 function openLedgerCard(pointCode) {
     return monitoringStore.openLedgerPoint(pointCode)
 }
@@ -897,6 +921,10 @@ function openLedgerCard(pointCode) {
 /** 统一实体卡片分派:点位/设备保留成熟链,其余进入统一只读详情抽屉。 */
 function handleCardAction(action, sourceSide = 'right') {
     if (!action || !action.code) return false
+    if ([CARD_ACTION.POINT, CARD_ACTION.DEVICE].includes(action.kind)) {
+        facilityPointStore.clearLocate()
+        if (facilityDetailOpen.value) entityDetailStore.close()
+    }
     if (action.kind === CARD_ACTION.POINT) return selectMonitoringPoint(action.code, { mode: mode.value })
     // 维护者 2026-10-29:设备台账卡要像监测点台账一样「定位 + 进设备级详情」。
     // `locateCode` 是既有的设备维度定位接缝(判定 → 相机 → 详情),不新造第二条路径。
@@ -911,7 +939,10 @@ function handleCardAction(action, sourceSide = 'right') {
             family: row.identityFamily || row.family || eventStore.mode,
         }, prefill, { side: sourceSide })
     }
-    if ([CARD_ACTION.WORK_ORDER, CARD_ACTION.FACILITY, CARD_ACTION.EMERGENCY].includes(action.kind)) {
+    if (action.kind === CARD_ACTION.FACILITY) {
+        return facilityPointStore.select({ kind: 'facility', id: action.code, category: row.identityCategory || row.category || '' }, prefill, { side: sourceSide })
+    }
+    if ([CARD_ACTION.WORK_ORDER, CARD_ACTION.EMERGENCY].includes(action.kind)) {
         return entityDetailStore.show({
             kind: action.kind,
             id: action.code,
@@ -1112,6 +1143,9 @@ const modeLayerPlan = computed(() => planModeLayers({
     // 过滤只在这一层做(`pipeNetworkStore.filtered`),引擎与组件都不做第二次过滤。
     facilities: pipeNetworkStore.facilities,
     pipelines: pipeNetworkStore.pipelines,
+    // 票 68:设施点图层(水厂/泵站/水源地/用水户)。筛选同样只在这一层做:
+    // 设施类型(独立维度)+ 全局行业/片区;点位/设备状态筛选不作用于本图层。
+    facilityPoints: facilityPointStore.renderPoints,
     associatedDevices: deviceRows.value,
     selection: {
         level: selection.value.level,
@@ -1194,7 +1228,9 @@ function closePipeEnhancement() {
     pipeEnhancementStore.clear()
     enhancementDevicesStore.clear()
 }
-const selectionVisibleInMode = computed(() => modeLayerPlan.value.selection.visible === true)
+const selectionVisibleInMode = computed(() => facilityDetailOpen.value
+    ? ['overview', 'infrastructure'].includes(mode.value)
+    : modeLayerPlan.value.selection.visible === true)
 
 /**
  * 快照变化 → 先撤下旧设备图层(规格:刷新或快照失效时先撤下,免得旧快照的设备被误读成新范围的)。
@@ -1266,6 +1302,7 @@ function applyModeSelectionTransition(fromMode, toMode) {
     if (verdict.action !== 'clear') return verdict
     // 分项→分项:清空。closeDetail 是唯一的关闭入口(不动相机、不换快照)。
     detailStore.closeDetail()
+    if (facilityDetailOpen.value) entityDetailStore.close()
     return verdict
 }
 
@@ -1283,7 +1320,11 @@ function syncLayersForEntered(nextEntered) {
 const stopDeviceWatches = []
 
 /** 票 10:两级详情是否打开(浮层只在有选择时渲染;关掉后地图与面板保持原样)。 */
-const detailOpen = computed(() => selection.value.level !== DETAIL_LEVEL.NONE)
+const detailOpen = computed(() => facilityDetailOpen.value || selection.value.level !== DETAIL_LEVEL.NONE)
+function closeActiveDetail() {
+    if (facilityDetailOpen.value) entityDetailStore.close()
+    else detailStore.closeDetail()
+}
 
 // ---- 票 41:详情面板锚定跟随监测点标记(决策票 04 / 规格票 12 的修订节)----
 //
@@ -1304,11 +1345,9 @@ const anchorOrigin = ref({ left: 0, top: 0 })
 let lastAnchorKey = ''
 
 /** 摘要锚向监测点;设备详情锚向所选设备;业务归属编码保持不变。 */
-const detailTarget = computed(() => resolveDetailAnchor({
-    selection: selection.value,
-    objects: mapStore.summary.objects,
-    devices: deviceRows.value,
-}))
+const detailTarget = computed(() => facilityDetailOpen.value
+    ? resolveFacilityDetailAnchor(facilityPointStore.selectedPoint)
+    : resolveDetailAnchor({ selection: selection.value, objects: mapStore.summary.objects, devices: deviceRows.value }))
 
 /** 详情卡上要显示的对象编码(顶栏暂隐入口读它)。 */
 const selectedDetailCode = computed(() => {
@@ -1336,7 +1375,7 @@ const hiddenAnchorText = computed(() => ANCHOR_HIDDEN[anchorLayout.value.reason]
 /** 仅**标记仍有效**时才给重新定位入口(不对已消失的标记发无效定位)。 */
 const canRelocate = computed(() => {
     if (introVisible.value || !detailTarget.value || fixedLocationReason.value) return false
-    return hasMapMarker(detailTarget.value.kind, detailTarget.value.code, detailTarget.value.pointCode)
+    return hasMapMarker(detailTarget.value.kind, detailTarget.value.code, detailTarget.value.pointCode, detailTarget.value.key)
 })
 
 /**
@@ -1351,8 +1390,8 @@ function measureAnchor() {
     const hidden = (reason) => ({ canvas, layout: { visible: false, reason, rect: null, line: null, side: '' } })
     const target = detailTarget.value
     if (!target) return hidden('marker-missing')
-    if (!hasMapMarker(target.kind, target.code, target.pointCode)) return hidden('marker-missing')
-    const anchor = getMapMarkerAnchor(target.kind, target.code, target.pointCode)
+    if (!hasMapMarker(target.kind, target.code, target.pointCode, target.key)) return hidden('marker-missing')
+    const anchor = getMapMarkerAnchor(target.kind, target.code, target.pointCode, target.key)
     // 投影拿不到(相机背面 / 地平线后 / 投影无效)与标记在画布外,都归"完全离开画面"。
     if (!anchor) return hidden('marker-offscreen')
 
@@ -1447,6 +1486,7 @@ const anchorLeadStyle = computed(() => {
 function relocateSelected() {
     const target = detailTarget.value
     if (!target) return
+    if (target.kind === 'facility-point') return facilityPointStore.select(entityDetailStore.identity, entityDetailStore.detail?.raw)
     if (target.kind === DEVICE_LAYER_KIND) deviceLayerStore.selectDevice(target.code)
     else if (target.kind === MAP_OBJECT_KIND.MONITORING_POINT) selectMonitoringPoint(target.code)
 }
@@ -1457,7 +1497,7 @@ function relocateSelected() {
  * 直接量会把长卡判成"没内容"而永远按 auto 摆放。
  */
 watch(
-    [() => selection.value.level, () => selection.value.pointCode, () => selection.value.deviceCode, () => mapStore.summary.objects, () => deviceRows.value],
+    [() => selection.value.level, () => selection.value.pointCode, () => selection.value.deviceCode, () => mapStore.summary.objects, () => deviceRows.value, () => entityDetailStore.identity, () => entityDetailStore.detail, () => facilityPointStore.renderPoints],
     () => {
         lastAnchorKey = ''
         nextTick(() => refreshAnchor({ force: true }))
@@ -1758,6 +1798,15 @@ function panelSideOf(panelId) {
 }
 const entityDetailSide = computed(() => entityDetailStore.sourceSide || 'right')
 
+/**
+ * 维护者 2026-10-30 15:57 细节2:台账(左栏)打开且**无地图位置**的设施详情,
+ * 固定到台账侧(左),不占右侧那个所有详情共用的固定位。侧别只信
+ * entityDetailStore.sourceSide(handleCardAction 已把面板所在栏传进来),外壳不自己判左右;
+ * 无位置判定仍走 fixedLocationReason(pending 的"暂时无法锚定"不算无位置,不进固定态)。
+ */
+const facilityFixedLeft = computed(() =>
+    facilityDetailOpen.value && !!fixedLocationReason.value && entityDetailSide.value === 'left')
+
 function isExpanded(panelId) {
     return expandedPanelIds.value.includes(panelId)
 }
@@ -1903,6 +1952,9 @@ onMounted(() => {
                 // 所以跟共享分区一起启动,而不是塞进某个模式的 loadPanels。
                 // 票 67 更正:片区也要交给管线图层(本地按编码前缀裁剪,不额外发请求)。
                 pipeNetworkStore.load({ industry: industry.value, scope: scope.value, config: result.config })
+                // 票 24/68:设施点位与管线是**跨模式共享**的图层(不随一级模式切换重取)。
+                // 票 68:行业/片区/设施类型筛选都在 store 的 filtered 里本地生效。
+                facilityPointStore.load({ config: result.config })
                 continue
             }
             // 筛选用全局条件的当前值(CONTEXT.md:行业筛选作用于整个驾驶舱)。
@@ -2021,13 +2073,13 @@ onMounted(() => {
         // 一级模式变化只改**可见性与选择态**(不换快照、不重建地图):
         // 先按矩阵执行选择态语义(总览↔分项保留 / 分项↔分项清空),再重算设备图层计划。
         watch(mode, (next, prev) => {
-            entityDetailStore.close()
+            if (!facilityDetailOpen.value) entityDetailStore.close()
             applyModeSelectionTransition(prev, next)
             syncDeviceLayer({ force: true })
         }, { deep: false }),
         // 票 46:引导态 ⇄ 进入系统的门控(规格 18:entered 决定一切业务对象显隐)。
         watch(introVisible, (next) => {
-            if (next) entityDetailStore.close()
+            if (next && !facilityDetailOpen.value) entityDetailStore.close()
             syncLayersForEntered(!next)
         }, { deep: false }),
         // 票 43:详情层级写进宿主 `data-map-panel-selection`,并把层级对照同步给设备图层 store
@@ -3110,6 +3162,13 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
     box-shadow: var(--shadow, 0 18px 44px rgba(0, 0, 0, 0.55));
 }
 
+/* 维护者 2026-10-30 15:57 细节2:台账(左栏)打开且无地图位置的设施详情固定到台账侧 ——
+   镜像右侧固定位的让位方式(--rail-w + --space-2),宽度仍由基类 .cockpit-shell__detail 给。 */
+.cockpit-shell__detail.is-fixed-left {
+    right: auto;
+    left: calc(var(--rail-w) + var(--space-2));
+}
+
 /* 归属短线:从图标外缘指向卡片,`transform-origin` 在左端,长度由 JS 给。 */
 .cockpit-shell__anchor-lead {
     position: absolute;

+ 3 - 3
src/modules/cockpit/components/EntityDetailSections.vue

@@ -1,6 +1,6 @@
 <template>
-    <div class="entity-sections" data-entity-detail-sections>
-        <div v-if="view.summary.length" class="entity-sections__summary">
+    <div class="entity-sections" :class="{ 'is-device': presentation === 'device' }" data-entity-detail-sections>
+        <div v-if="view.summary.length && presentation !== 'device'" class="entity-sections__summary">
             <div v-for="item in view.summary" :key="item.label">
                 <span>{{ item.label }}</span>
                 <!-- 等级作为带色的指标卡(维护者 2026-10-29:不要重复的第二个等级字段)。 -->
@@ -30,7 +30,7 @@
 import { computed, ref, watch } from 'vue'
 import { entityDetailView } from '../domain/entityDetail.js'
 import WorkOrderTimeline from './WorkOrderTimeline.vue'
-const props = defineProps({ detail: { type: Object, default: null } })
+const props = defineProps({ detail: { type: Object, default: null }, presentation: { type: String, default: 'entity' } })
 const emit = defineEmits(['locate-device'])
 const view = computed(() => entityDetailView(props.detail))
 const activeTab = ref('')

+ 67 - 0
src/modules/cockpit/components/FacilityDetailCard.vue

@@ -0,0 +1,67 @@
+<!-- Device-style floating card; business data remains the existing entity detail model. -->
+<template>
+    <section class="facility-detail" data-facility-detail :data-facility-id="store.identity?.id" :data-facility-category="store.identity?.category" @keydown.esc.stop="store.close()">
+        <p v-if="locationReason" class="facility-detail__notice" data-facility-no-location>无地图位置 · {{ locationReason }}</p>
+        <header class="facility-detail__head">
+            <span class="facility-detail__title">{{ view.title }}</span>
+            <button type="button" class="facility-detail__action" data-facility-detail-close @click="store.close()">关闭</button>
+        </header>
+        <div class="facility-detail__body">
+            <dl class="facility-detail__rows">
+                <div><dt>设施名称</dt><dd>{{ view.title }}</dd></div>
+                <div><dt>设施编码</dt><dd class="facility-detail__mono">{{ code }}</dd></div>
+                <div><dt>设施类别</dt><dd>{{ view.categoryText || '—' }}</dd></div>
+                <div><dt>运行状态</dt><dd><StatusTag :tone="view.tone" :label="view.status || '状态未发布'" /></dd></div>
+            </dl>
+            <p v-if="locationHint" class="facility-detail__notice">{{ locationHint }}</p>
+            <p v-if="store.loading" class="facility-detail__notice" data-facility-loading>设施详情加载中…</p>
+            <p v-if="store.error" class="facility-detail__notice is-error" data-facility-error>
+                设施详情未取到,已保留现有信息。
+                <button type="button" class="facility-detail__action" @click="store.retry()">重试</button>
+            </p>
+            <EntityDetailSections :detail="store.detail" presentation="device" @locate-device="code => emit('locate-device', code)" />
+        </div>
+    </section>
+</template>
+<script setup>
+import { computed } from 'vue'
+import { entityDetailView } from '../domain/entityDetail.js'
+import { useEntityDetailStore } from '../stores/entityDetailStore.js'
+import EntityDetailSections from './EntityDetailSections.vue'
+import StatusTag from './StatusTag.vue'
+defineProps({ locationReason: { type: String, default: '' }, locationHint: { type: String, default: '' } })
+const emit = defineEmits(['locate-device'])
+const store = useEntityDetailStore()
+const view = computed(() => entityDetailView(store.detail))
+const code = computed(() => store.detail?.raw?.code || store.identity?.id || '—')
+</script>
+<style scoped>
+/* Same frame language as PointDetailCard: content-height flex body, one token gradient/blur, vertical-only scroll. */
+.facility-detail, .facility-detail * { box-sizing: border-box; }
+.facility-detail {
+    display: flex; flex-direction: column; max-height: inherit; min-height: 0; overflow: hidden;
+    border: 1px solid var(--panel-line, rgba(1,171,234,.35)); border-radius: calc(8 * var(--u, 1px));
+    background: linear-gradient(180deg, var(--panel-top, rgba(2,9,27,.28)) 0%, var(--panel-mid, rgba(0,53,156,.2)) 26%, var(--panel-bot, rgba(0,15,52,.38)) 100%);
+    backdrop-filter: blur(16px) saturate(115%); -webkit-backdrop-filter: blur(16px) saturate(115%);
+    box-shadow: inset 0 1px 0 rgba(1,171,234,.14), var(--shadow, 0 18px 44px rgba(0,0,0,.55));
+    color: var(--text-2, #88b7e8); font-size: var(--fs-caption, 10.5px); pointer-events: auto;
+}
+.facility-detail__head { display:flex; align-items:center; gap:var(--space-2,8px); min-height:calc(32 * var(--u,1px)); padding:0 var(--space-2,8px); border-bottom:1px solid rgba(1,171,234,.22); background:linear-gradient(90deg,var(--panel-mid) 0%,var(--panel-bot) 42%,transparent 78%); }
+.facility-detail__title { position:relative; flex:1; min-width:0; padding-left:calc(10 * var(--u,1px)); color:var(--text); font-size:var(--fs-title,14.5px); font-weight:700; letter-spacing:.1em; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; text-shadow:0 0 12px rgba(1,171,234,.6); }
+.facility-detail__title::before { content:''; position:absolute; left:0; top:50%; width:calc(4 * var(--u,1px)); height:calc(14 * var(--u,1px)); transform:translateY(-50%); background:linear-gradient(180deg,var(--cyan-hi),var(--cyan-d)); box-shadow:0 0 10px var(--cyan); }
+.facility-detail__action { flex:none; padding:1px var(--space-2,8px); border:1px solid rgba(1,171,234,.42); border-radius:calc(8 * var(--u,1px)); background:rgba(0,20,72,.6); color:var(--text-2); font:inherit; cursor:pointer; white-space:nowrap; }
+.facility-detail__action:hover,.facility-detail__action:focus-visible { color:var(--text-strong); border-color:rgba(1,171,234,.75); }
+.facility-detail__body { flex:1; min-height:0; padding:var(--space-2,8px) calc(10 * var(--u,1px)); overflow-y:auto; overflow-x:hidden; }
+.facility-detail__rows { display:grid; gap:calc(4 * var(--u,1px)); margin:0 0 var(--space-2,8px); }
+.facility-detail__rows > div { display:grid; grid-template-columns:calc(72 * var(--u,1px)) minmax(0,1fr); gap:var(--space-2,8px); }
+.facility-detail__rows dt { color:var(--muted); }
+.facility-detail__rows dd { min-width:0; margin:0; color:var(--text); overflow-wrap:anywhere; }
+.facility-detail__mono { font-family:var(--num,monospace); }
+.facility-detail__notice { margin:0 0 var(--space-2,8px); color:var(--muted); line-height:1.6; }
+.facility-detail__notice.is-error { color:var(--lv1); }
+.facility-detail__body :deep(.entity-sections__tabs button) { font:inherit; font-size:var(--fs-caption); }
+.facility-detail__body :deep(.entity-sections__grid) { display:grid; grid-template-columns:1fr; gap:calc(4 * var(--u,1px)); }
+.facility-detail__body :deep(.entity-sections__grid > div) { display:grid; grid-template-columns:calc(90 * var(--u,1px)) minmax(0,1fr); gap:calc(8 * var(--u,1px)); }
+.facility-detail__body :deep(.entity-sections__grid .is-wide) { grid-column:auto; }
+.facility-detail__body :deep(.entity-sections__grid dd) { margin:0; min-width:0; }
+</style>

+ 146 - 28
src/modules/cockpit/components/MapCanvas.vue

@@ -48,9 +48,47 @@
             </div>
 
             <!--
-                票 25:中央「一张图」标题与图层占位已并入右下角的地图信息集合面板。
-                此处**只保留状态筛选图例**——它是决策 02 冻结的全局组合筛选入口,必须常驻可点。
+                票 68:**设施图例(独立块 + 独立筛选维度)**。
+                与上面状态图例**换行分开**(两个容器、两套 data-* 断言面),不是塞进同一行:
+                  · 状态图例筛选监测点/设备(filterStore.pointStatus);
+                  · 本块筛选四类设施点(filterStore.facilityTypes),互不改写。
+                行常驻可点(计数 0 / 未发布的类型也可点,不造死胡同);
+                计数口径 = 全局档案数(不随筛选变化),「上图 N」= 当前图层可上图数。
             -->
+            <div
+                v-if="entered"
+                class="map-holo__legend map-holo__legend--facility"
+                data-map-legend
+                data-facility-legend
+                data-facility-filter
+            >
+                <button
+                    v-for="row in facilityRows"
+                    :key="row.key"
+                    type="button"
+                    class="map-holo__legend-row"
+                    :class="{ 'is-selected': row.selected }"
+                    :style="{ '--legend-color': 'var(--cyan)' }"
+                    :data-facility-key="row.key"
+                    :data-facility-selected="row.selected ? 'yes' : 'no'"
+                    :data-facility-count="row.count === null ? '' : String(row.count)"
+                    :data-facility-onmap="String(row.onMap)"
+                    :data-facility-state="row.state"
+                    :title="row.title"
+                    :aria-pressed="row.selected"
+                    @click="toggleFacilityFilter(row.key)"
+                >
+                    <CockpitIcon
+                        v-if="row.symbol"
+                        :name="row.symbol"
+                        size="calc(13 * var(--u, 1px))"
+                        class="map-holo__legend-icon"
+                    />
+                    <span class="map-holo__legend-label">{{ row.label }}</span>
+                    <span class="map-holo__legend-count" data-facility-count-text>:{{ row.countText }}</span>
+                    <span class="map-holo__legend-onmap" data-facility-onmap-text>· 上图 {{ row.onMap }}</span>
+                </button>
+            </div>
         </div>
 
         <!--
@@ -78,6 +116,10 @@ import {
     // 票 24:设施点位与管线图层。与摘要标记分属不同数据源与不同业务对象,
     // 各自同步,互不写对方的状态。
     syncPipeLayerForPlan,
+    // 票 68:四类设施点(水厂/泵站/水源地/用水户)—— 又一次独立同步,同样不写别人的状态。
+    syncFacilityMarkersForPlan,
+    // 票 101:设施点点击后的镜头定位(与点位/设备同一条地形感知飞行管线)。
+    setFacilitySelection,
     unmountMap,
 } from '../services/mapService.js'
 import { MAP_SUMMARY_PHASE } from '../domain/mapSummary.js'
@@ -90,8 +132,12 @@ import { findMapObject } from '../domain/mapLocate.js'
 import { selectMonitoringPoint } from '../pointSelection.js'
 // 票 14:八桶词表(唯一一张表)与全局状态筛选(顶部状态栏的计数与选中态来源)。
 import { legendVisuals } from '../domain/pointStatus.js'
+// 票 68:设施图例会数据(档案口径 + 可上图数)与选中集合的唯一来源。
+import { facilityLegendRows } from '../domain/facilityPoints.js'
 import { storeToRefs } from 'pinia'
 import { useDeviceLayerStore } from '../stores/deviceLayerStore.js'
+import { useEntityDetailStore } from '../stores/entityDetailStore.js'
+import { useFacilityPointStore } from '../stores/facilityPointStore.js'
 import { useFilterStore } from '../stores/filterStore.js'
 import { useMapStore } from '../stores/mapStore.js'
 import { useDetailStore } from '../stores/detailStore.js'
@@ -150,7 +196,12 @@ const markerStatusText = computed(() => {
 
 // ---- 票 14:状态栏 = 全局组合筛选入口(八桶多选 OR、全局未筛计数、选中高亮、行常驻可点)----
 const filterStore = useFilterStore()
-const { pointStatus: selectedStatuses } = storeToRefs(filterStore)
+const { pointStatus: selectedStatuses, facilityTypes: selectedFacilityTypes } = storeToRefs(filterStore)
+// 票 68:设施点的图例口径来自设施点图层 store(档案计数 + 可上图数),只读不写。
+const facilityPointStore = useFacilityPointStore()
+// 票 101:设施点点击打开的详情与「设施台账」卡片点击**同一个** store(同一份设施实体详情)。
+const entityDetailStore = useEntityDetailStore()
+
 
 // 票 45:附加状态行的**双轴解释**(保持图例可解释)。
 // 「无关联设备」的计数是后端八桶的**类别口径**(该类全部对象,含业务异常者),
@@ -194,6 +245,15 @@ function toggleStatusFilter(key) {
     filterStore.togglePointStatus(key)
 }
 
+// ---- 票 68:设施图例(独立筛选维度)----
+// 行的口径全部来自 domain/facilityPoints.js(图例只渲染,不再自己算计数)。
+const facilityRows = computed(() => facilityLegendRows(facilityPointStore.inventory, selectedFacilityTypes.value))
+
+/** 点设施类型 → 只写 filterStore.facilityTypes,**不碰** pointStatus。 */
+function toggleFacilityFilter(key) {
+    filterStore.toggleFacilityType(key)
+}
+
 // ---- 票 46:模式图层计划(唯一裁决点)----
 // 计划由**外壳**算好传入(外壳握有设备行/异常设备/选择态全部输入),
 // 本组件只消费:markers 喂服务层、图例/降级说明直接渲染。不二次过滤。
@@ -213,7 +273,13 @@ const modePlan = computed(() => props.plan || EMPTY_PLAN)
 /** 引导态(计划层以 `entered` 表达):图例/计数浮标/降级面板都门控掉。 */
 const entered = computed(() => modePlan.value.entered === true)
 
+watch(() => [entityDetailStore.open, entityDetailStore.identity, modePlan.value.facilityPoints], () => {
+    setFacilitySelection(entityDetailStore.open ? entityDetailStore.identity : null)
+}, { flush: 'sync' })
+
 let resizeObserver = null
+/** 卸载后到达的延迟挂载回调必须早退,避免在已销毁的宿主上建 viewer。 */
+let mapDisposed = false
 let stopSummaryWatch = null
 let stopMarkerWatch = null
 let stopPointPick = null
@@ -291,6 +357,10 @@ function applyMarkers() {
     // 必然一致(计划里 hidden 就是空数组 → 自动撤下),不会出现两套判定打架。
     syncPipeLayerForPlan(modePlan.value)
 
+    // 票 68:设施点同样只消费计划(模式显隐已在计划层收口)。
+    // 语义:设施筛选/行业/片区已在 store 的 filtered 里裁好,这里不再二次过滤。
+    syncFacilityMarkersForPlan(modePlan.value.facilityPoints)
+
     // 票 46:降级说明写进宿主 dataset(走查断言「数据未就绪 ≠ 空数据」的断言面)。
     const host = hostRef.value
     if (host && host.dataset) {
@@ -335,10 +405,60 @@ function applyMarkersMerged() {
     applyMarkers()
 }
 
+/** 地图点选的唯一组件出口;挂载订阅和链路回归执行同一函数。 */
+function handlePointPick(picked) {
+    if (picked.kind !== 'facility-point' && entityDetailStore.identity?.kind === 'facility') entityDetailStore.close()
+    // 票 43:三维点选**设备点** → 选中该设备并进设备级(复用同一入口,含镜头联动)。
+    if (picked.kind === 'monitoring-device') {
+        deviceLayerStore.selectDevice(picked.code)
+        return
+    }
+    // 票 101:设施点点击 = 与独立设备同一条「镜头定位 + 详情面板」交互链。
+    // 详情与「设施台账」卡片点击**同源**(entityDetailStore + entity-detail 契约,
+    // kind='facility' + 实体主键 + 类别,prefill 先显、远端后补),不新造第二套取数;
+    // 镜头定位走 locateFacilityPoint(与 locateMonitoringPoint 同一条地形感知飞行管线,
+    // 唯一差别是准入按票 68 口径「有坐标即可上图」)。缺主键/类别的身份不会走到这里
+    //(领域层 `clickable` 已拦),这里再兜一层,不伪装可点。
+    if (picked.kind === 'facility-point') {
+        if (!picked.id || !picked.category) return
+        return facilityPointStore.select(
+            { kind: 'facility', id: picked.id, category: picked.category },
+            { category: picked.category, industry: picked.industry, status: picked.status, name: picked.name, code: picked.code },
+            { side: 'right' },
+        )
+    }
+    // 票 41:监测点走**统一选择入口**(与台账卡片同一条)——它负责点位级定位 + 打开两级详情。
+    if (picked.kind === 'monitoring-point') selectMonitoringPoint(picked.code)
+    else {
+        const selection = selectionFromMapObject(picked)
+        if (selection) {
+            detailStore.openSelection(selection)
+            // 批次 3 第五轮修复:点选**未关联设备**也要像监测点一样飞镜头——
+            // 走查发现「只出面板不移动」。复用 `locateMonitoringPoint` 同一条定位链路
+            // (它只要求对象坐标准入通过,不关心 kind);坐标来自 map-snapshot(未关联
+            // 设备自带经纬度),与标记上图同一份数据,不借用、不伪造。
+            const mapStore = useMapStore()
+            const object = findMapObject(mapStore.summary.objects, picked.code)
+            if (object && isRenderable(object)) void locateMonitoringPoint(object)
+        }
+    }
+}
+
 onMounted(() => {
     // 幂等挂载:模式切换或面板收放都不会再次走到这里(本组件常驻)。
-    const instance = mountMap(hostRef.value)
-    instanceId.value = instance.id
+    // 2026-10-30 加载优化:先让外壳/引导页/面板画完第一帧,再把 Cesium 引擎
+    // (dev 预构建约 11MB)放进主线程 —— mountMap 内部仍是异步加载,幂等,
+    // 这里只是把「开始加载」推迟一帧,不改变任何地图语义。
+    const startMap = () => {
+        if (mapDisposed || !hostRef.value) return
+        const instance = mountMap(hostRef.value)
+        instanceId.value = instance.id
+    }
+    if (typeof requestAnimationFrame === 'function') {
+        requestAnimationFrame(() => setTimeout(startMap, 0))
+    } else {
+        startMap()
+    }
 
     // 宿主尺寸变化上报给地图服务(接入 Cesium 后即 viewer.resize())。
     if (typeof ResizeObserver !== 'undefined' && hostRef.value) {
@@ -355,7 +475,7 @@ onMounted(() => {
     // (服务层只消费计划,重画是唯一路径;一个 watch 一份退订,不叠两份)。
     // 票 33:重画走**批间合并**入口,不再批批全量同步。
     stopMarkerWatch = watch(
-        () => [mapStore.summary.objects, modePlan.value.markers, modePlan.value.byStatus, modePlan.value.pipelines, modePlan.value.facilities],
+        () => [mapStore.summary.objects, modePlan.value.markers, modePlan.value.byStatus, modePlan.value.pipelines, modePlan.value.facilities, modePlan.value.facilityPoints],
         () => applyMarkersMerged(),
         { deep: false },
     )
@@ -371,28 +491,7 @@ onMounted(() => {
     // 分工:地图服务只回答「拾到了谁」(拾不到、或拾到空白一律 null),本组件决定「打开什么」。
     // 「关联设备不重复上图」在这条路径上是结构性的:它只把 kind/code 变成选择,
     // 从不往地图里加对象(标记计划的唯一来源仍是 mapStore.summary.objects)。
-    stopPointPick = onPointPick((picked) => {
-        // 票 43:三维点选**设备点** → 选中该设备并进设备级(复用同一入口,含镜头联动)。
-        if (picked.kind === 'monitoring-device') {
-            deviceLayerStore.selectDevice(picked.code)
-            return
-        }
-        // 票 41:监测点走**统一选择入口**(与台账卡片同一条)——它负责点位级定位 + 打开两级详情。
-        if (picked.kind === 'monitoring-point') selectMonitoringPoint(picked.code)
-        else {
-            const selection = selectionFromMapObject(picked)
-            if (selection) {
-                detailStore.openSelection(selection)
-                // 批次 3 第五轮修复:点选**未关联设备**也要像监测点一样飞镜头——
-                // 走查发现「只出面板不移动」。复用 `locateMonitoringPoint` 同一条定位链路
-                // (它只要求对象坐标准入通过,不关心 kind);坐标来自 map-snapshot(未关联
-                // 设备自带经纬度),与标记上图同一份数据,不借用、不伪造。
-                const mapStore = useMapStore()
-                const object = findMapObject(mapStore.summary.objects, picked.code)
-                if (object && isRenderable(object)) void locateMonitoringPoint(object)
-            }
-        }
-    })
+    stopPointPick = onPointPick(handlePointPick)
 
     // 票 43:三维 hover 设备点 → 面板对应卡片高亮(服务层已做节流,这里只转发)。
     stopDeviceHover = onDeviceHover((hit) => {
@@ -402,6 +501,7 @@ onMounted(() => {
 })
 
 onBeforeUnmount(() => {
+    mapDisposed = true
     publishSummaryHandle(null)
     // 票 10:先退订点选,再拆地图(否则服务层还会往已卸载的 store 上写选择)。
     if (stopPointPick) {
@@ -540,6 +640,24 @@ onBeforeUnmount(() => {
     flex-wrap: wrap;
 }
 
+/* 票 68:设施图例是**独立一块**——容器、data-* 断言面、筛选维度都与上面的状态图例分开
+   (父容器是 grid,两块天然各占一行,不是靠 CSS 换行 trick 挤在一条线上)。 */
+.map-holo__legend--facility {
+    margin-top: calc(2 * var(--u, 1px));
+}
+
+.map-holo__legend-label {
+    font-size: var(--fs-caption, 11.5px);
+}
+
+/* 「上图 N」是设施点图层的能力数(不随筛选变化);与档案计数用弱化小字区分,
+   避免两个数被读成同一套口径(票面「计数口径写清」的界面落点)。 */
+.map-holo__legend-onmap {
+    color: var(--text-2, #88b7e8);
+    font-family: var(--num, monospace);
+    font-size: var(--fs-note, 9.2px);
+}
+
 /* 票 14:状态栏行是**按钮**(全局筛选入口)。抹掉按钮默认外观,保留可聚焦/hover/选中反馈。 */
 .map-holo__legend-row {
     pointer-events: auto;

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

@@ -779,6 +779,13 @@
                             设备 {{ row.badge.total }} · 报警 {{ row.badge.alarm }} · 预警 {{ row.badge.warning }} · 离线 {{ row.badge.offline }}
                         </p>
                         <p v-else-if="row.badgePending" class="panel-item__badge is-muted" data-ledger-badge-pending>设备统计取数中…</p>
+                        <div v-if="row.facilityLocate && cardAction(row).kind === CARD_ACTION.FACILITY" class="panel-item__points">
+                            <button type="button" class="panel-locate"
+                                :data-locate-facility="cardAction(row).code" :data-facility-category="row.identityCategory"
+                                :aria-label="'定位设施 ' + (row.name || row.title)"
+                                @click.stop="emit('locate-facility', { ...cardAction(row), row })"
+                                @keydown.enter.stop @keydown.space.stop>定位</button>
+                        </div>
                         <div v-if="row.points && row.points.length" class="panel-item__points">
                             <button
                                 v-for="point in row.points"
@@ -912,7 +919,7 @@ const props = defineProps({
 })
 
 // 票 62:`ledger-more` 已退役(台账装载是自动链,界面不再有「加载下一页」入口)。
-const emit = defineEmits(['locate', 'open-card', 'card-action', 'badge-needed', 'panel-action', 'list-page'])
+const emit = defineEmits(['locate', 'locate-facility', 'open-card', 'card-action', 'badge-needed', 'panel-action', 'list-page'])
 
 /** 统一卡片点击:身份由 domain 显式声明,渲染层不再拿 code 猜对象类型。 */
 function onCardClick(row, event) {

+ 15 - 0
src/modules/cockpit/domain/coordinate.js

@@ -236,6 +236,21 @@ export function facilityCoordinateExtra(layer) {
     }
 }
 
+/**
+ * 合并多份「无地图位置」附加项(票 68)。
+ *
+ * 管线/构件一份、设施点一份,各自如实说明自己那批;合并只做加法,
+ * 不改任何一份的判定 —— 这样「徽标与清单自洽」在所有附加项上同时成立。
+ * @param {...object} extras `facilityCoordinateExtra()` / `facilityPointCoordinateExtra()` 的输出
+ */
+export function mergeCoordinateExtras(...extras) {
+    const list = extras.filter(Boolean)
+    return {
+        renderable: list.reduce((sum, item) => sum + (Number.isFinite(item.renderable) ? item.renderable : 0), 0),
+        blockedEntries: list.flatMap((item) => (Array.isArray(item.blockedEntries) ? item.blockedEntries : [])),
+    }
+}
+
 /**
  * 坐标准入总览(纯函数,界面与走查唯一读的那个对象)。
  *

+ 11 - 0
src/modules/cockpit/domain/detailAnchor.js

@@ -20,7 +20,18 @@ export function resolveDetailAnchor({ selection, objects = [], devices = [] } =
     return { kind: MAP_OBJECT_KIND.MONITORING_POINT, code: current.pointCode, object: point }
 }
 
+/** Facility placement is its own evidence; no fabricated device coordinateValidation. */
+export function resolveFacilityDetailAnchor(point) {
+    return point ? { kind: 'facility-point', code: point.code, key: point.key, category: point.detailCategory, id: point.id, object: point } : null
+}
+
 export function detailAnchorLocation(target) {
+    if (target?.kind === 'facility-point') {
+        const point = target.object
+        return !point || point.placement === 'pending'
+            ? { present: false, renderable: false, reasonText: '' }
+            : { present: true, renderable: point.placement === 'drawn', reasonText: point.reasonText || '' }
+    }
     if (!target || (target.object && target.object.placement === 'pending')) {
         return { present: false, renderable: false, reasonText: '' }
     }

+ 3 - 1
src/modules/cockpit/domain/entityDetail.js

@@ -270,6 +270,8 @@ const NETWORK_TYPE_TEXT = { '0': '燃气管网', '1': '排水管网', '2': '供
 const NETWORK_LEVEL_TEXT = { '0': '污水', '1': '雨水' }
 /** 排水工程设施类型(0 闸门 / 1 调蓄设施 / 2 泵站):同样是码值,一并直译。 */
 const ENGINEERING_FACILITY_TYPE_TEXT = { '0': '闸门', '1': '调蓄设施', '2': '泵站' }
+/** 泵站表供水/排水共用(后端 PumpStationType:water_supply / drainage),码值直译;认不出回原码。 */
+const PUMP_STATION_TYPE_TEXT = { water_supply: '供水泵站', drainage: '排水泵站' }
 /** 类别中文名:界面上用「中文 + 英文码」,只印英文等于把接口词汇漏上大屏。 */
 const FACILITY_CATEGORY_TEXT = {
     PIPE_NETWORK: '管网',
@@ -361,7 +363,7 @@ const ASSET_FIELD_ROWS = {
         { label: '备注', key: 'remark', wide: true },
     ],
     PUMP_STATION: [
-        { label: '泵站类型', key: 'stationType' },
+        { label: '泵站类型', key: 'stationType', format: (v) => codedText(v, PUMP_STATION_TYPE_TEXT) },
         { label: '水泵数量', key: 'pumpCount' },
         { label: '设计流量', key: 'designFlow' },
         { label: '设计扬程', key: 'designHead' },

+ 426 - 0
src/modules/cockpit/domain/facilityPoints.js

@@ -0,0 +1,426 @@
+/**
+ * 设施点(水厂 / 泵站 / 水源地 / 用水户)—— 票 68 的领域层。
+ *
+ * 与「管线与构件」(`domain/pipeNetwork.js`)分属不同对象域,不共用口径:
+ *   · 管线/构件来自 `/infrastructure/geometry`,每个 feature 带后端 `coordinateValidation`;
+ *   · 四类设施来自设施台账:`/infrastructure/operating-information` 给出可枚举的设施实体
+ *     (`facilityId` + `facilityKind`),坐标取自 `/entities/{id}/detail` 的 `coordinates`。
+ *
+ * **准入纪律(票 07 的边界,写清差异而不是假装没有)**:
+ *   实体详情不带几何图层那套 `coordinateValidation`,所以本层**不做**第二套范围校核、
+ *   不纠偏、不补默认坐标 —— 有坐标(有限数字)就按设施点上图,没有就进「无地图位置」清单,
+ *   与票 07「宁可不上图,也不摆一个假位置」同一条立场。该契约差异登记在票 68 的契约缺口里。
+ *
+ * 四类设施与后端命名空间:
+ *   · `WATER_PLANT`(水厂)—— `facilityTypeDistribution` / 实体类别同名;
+ *   · `PUMP_STATION`(泵站)—— 后端两类 `WATER_PUMP_STATION` / `DRAINAGE_PUMP_STATION`
+ *     在图例上并为一类,实体类别统一为 `PUMP_STATION`;
+ *   · `WATER_SOURCE`(水源地)—— 档案有计数,但 cockpit 未下发实体/坐标(契约缺口);
+ *   · `WATER_USER`(用水户)—— 全部 cockpit 端点均未下发(数据/契约缺口)。
+ */
+
+import { NO_MAP_LOCATION_TEXT } from './coordinate.js'
+import { facilityStatusTone } from './entityDetail.js'
+import { districtQuery, industryQuery } from './filterOptions.js'
+
+export const FACILITY_DETAIL_CATEGORIES = Object.freeze(['WATER_PLANT', 'PUMP_STATION', 'PIPE_NETWORK', 'ENGINEERING_FACILITY'])
+
+/** 四类设施的稳定键(进 filterStore、data-* 断言面与测试,不用中文)。 */
+export const FACILITY_POINT_TYPE = Object.freeze({
+    WATER_PLANT: 'WATER_PLANT',
+    PUMP_STATION: 'PUMP_STATION',
+    WATER_SOURCE: 'WATER_SOURCE',
+    WATER_USER: 'WATER_USER',
+})
+
+/**
+ * 展示词表(顺序=图例行顺序)。`symbol` 是图标语义名(`domain/icons.js` 注册),
+ * 不在本层拼物理路径;`archiveCodes` 是基础档案 `facilityTypeDistribution[].code` 的归属。
+ */
+export const FACILITY_POINT_TYPES = Object.freeze([
+    {
+        key: FACILITY_POINT_TYPE.WATER_PLANT,
+        label: '水厂',
+        unit: '座',
+        symbol: 'facilityPlant',
+        archiveCodes: ['WATER_PLANT'],
+        detailCategory: 'WATER_PLANT',
+    },
+    {
+        key: FACILITY_POINT_TYPE.PUMP_STATION,
+        label: '泵站',
+        unit: '座',
+        symbol: 'facilityPump',
+        archiveCodes: ['WATER_PUMP_STATION', 'DRAINAGE_PUMP_STATION'],
+        detailCategory: 'PUMP_STATION',
+    },
+    {
+        key: FACILITY_POINT_TYPE.WATER_SOURCE,
+        label: '水源地',
+        unit: '处',
+        symbol: 'facilitySource',
+        archiveCodes: ['WATER_SOURCE'],
+        detailCategory: '',
+    },
+    {
+        key: FACILITY_POINT_TYPE.WATER_USER,
+        label: '用水户',
+        unit: '户',
+        symbol: 'facilityUser',
+        archiveCodes: ['WATER_USER'],
+        detailCategory: '',
+    },
+])
+
+/** 设施点的两种落位(与 `domain/pipeNetwork.js` 的 blocked 同义:不上图 ≠ 不存在)。 */
+export const FACILITY_PLACEMENT = Object.freeze({
+    DRAWN: 'drawn',
+    NO_LOCATION: 'no-location',
+})
+
+/** 不上图原因(`reasonCode` 只进 data-* 与断言,`reasonText` 才上界面)。 */
+export const FACILITY_NO_LOCATION_REASON = Object.freeze({
+    MISSING_COORDINATE: 'MISSING_COORDINATE',
+    DETAIL_UNAVAILABLE: 'DETAIL_UNAVAILABLE',
+})
+
+/** 图例行的数据状态(决定 tooltip 的口径说明,不进色键)。 */
+export const FACILITY_LEGEND_STATE = Object.freeze({
+    /** 实体可枚举且全部可上图。 */
+    READY: 'ready',
+    /** 实体可枚举,但有一部分没有坐标(进「无地图位置」)。 */
+    PARTIAL: 'partial',
+    /** 档案有计数,但 cockpit 没有下发可上图的实体。 */
+    NO_ENTITY: 'no-entity',
+    /** 档案都没有这个类型(后端未发布)。 */
+    UNPUBLISHED: 'unpublished',
+    /** 设施点图层本身没取到(不是「这里没有设施」)。 */
+    UNAVAILABLE: 'unavailable',
+})
+
+const TYPE_META = new Map(FACILITY_POINT_TYPES.map((meta) => [meta.key, meta]))
+const ARCHIVE_TYPE = new Map(FACILITY_POINT_TYPES.flatMap((meta) => meta.archiveCodes.map((code) => [code, meta.key])))
+
+function text(value) {
+    return typeof value === 'string' ? value.trim() : ''
+}
+
+/** 设施类型元数据;认不出的返回 null(调用方如实跳过,不猜)。 */
+export function facilityTypeMeta(type) {
+    return TYPE_META.get(type) || null
+}
+
+/** 是否四类设施之一(filterStore 的取值域)。 */
+export function isKnownFacilityType(type) {
+    return TYPE_META.has(type)
+}
+
+/**
+ * 后端设施类型 → 前端四类之一;认不出的返回空串(不硬塞进某一类)。
+ * 泵站合并两类后端码;其余按同名映射。
+ */
+export function normalizeFacilityType(value) {
+    const code = text(value).toUpperCase()
+    if (!code) return ''
+    if (ARCHIVE_TYPE.has(code)) return ARCHIVE_TYPE.get(code)
+    // 实体详情的 `category` 与设施台账的 `facilityKind` 都可能是 WATER_PLANT / PUMP_STATION。
+    if (code === 'WATER_PLANT' || code === 'PUMP_STATION' || code === 'WATER_SOURCE' || code === 'WATER_USER') return code
+    return ''
+}
+
+/**
+ * 运行信息指标 → 可枚举的设施实体行(去重,按 `facilityKind + facilityId`)。
+ *
+ * 为什么从运行信息枚举:cockpit 没有「设施列表」端点,而运行信息端点**每条指标对应一个设施**,
+ * 行上带 `facilityId` / `facilityKind` / `facilityStatus` / `pointCodes`(票 73/65 已消费)。
+ * 只取四类设施;管线实体(`PIPE_NETWORK`)不属本层(管线有自己的几何图层)。
+ *
+ * @param {object} payload `/infrastructure/operating-information` 的 data
+ * @returns {Array<{key:string,type:string,id:string,code:string,name:string,status:string,industry:string}>}
+ */
+export function facilityRowsFromOperating(payload) {
+    const blocks = payload && Array.isArray(payload.industries) ? payload.industries : []
+    const rows = []
+    const seen = new Set()
+    for (const block of blocks) {
+        // 真实载荷的行业名在 `block.industry.code`(不是 `block.code`),mock 同形。
+        const industry = text(block && block.industry && block.industry.code) || text(block && block.code)
+        const metrics = block && Array.isArray(block.metrics) ? block.metrics : []
+        for (const metric of metrics) {
+            const type = normalizeFacilityType(metric && metric.facilityKind)
+            const id = text(metric && metric.facilityId)
+            if (!type || !id) continue
+            const key = type + '|' + id
+            if (seen.has(key)) continue
+            seen.add(key)
+            const codes = metric && Array.isArray(metric.pointCodes) ? metric.pointCodes : []
+            rows.push({
+                key,
+                type,
+                id,
+                code: text(codes[0]),
+                name: text(metric && metric.metricName),
+                status: metric && metric.facilityStatus !== null && metric.facilityStatus !== undefined
+                    ? String(metric.facilityStatus)
+                    : '',
+                industry,
+                // 片区的权威值来自台账行(设施档案没有片区字段时就是 null=未分区),不按编码前缀猜。
+                district: metric && Number.isInteger(metric.district) ? metric.district : null,
+            })
+        }
+    }
+    return rows
+}
+
+/** 需要向实体详情补坐标的行(只有后端声明了实体类别的两类)。 */
+export function facilityDetailRequests(rows) {
+    return (Array.isArray(rows) ? rows : [])
+        .map((row) => ({ key: row.key, id: row.id, category: (facilityTypeMeta(row.type) || {}).detailCategory || '' }))
+        .filter((request) => request.id && request.category)
+}
+
+/** 实体详情里的坐标:只认有限数字;null / 缺字段 / 非数字一律 null(不补 0、不猜)。 */
+function coordinateOf(detail) {
+    const source = detail && detail.coordinates
+    if (!source || typeof source !== 'object') return null
+    const longitude = typeof source.longitude === 'number' && Number.isFinite(source.longitude) ? source.longitude : null
+    const latitude = typeof source.latitude === 'number' && Number.isFinite(source.latitude) ? source.latitude : null
+    return longitude === null || latitude === null ? null : { longitude, latitude }
+}
+
+/**
+ * 设施行 + 实体详情 → 设施点(带落位结论)。
+ *
+ * `details` 是 `key → { ok, detail }` 的映射(key 与 `facilityRowsFromOperating()` 的 `key` 同)。
+ * 详情失败与「详情成功但没有坐标」是两句不同的话,原因码分开记。
+ *
+ * @param {Array} rows `facilityRowsFromOperating()` 的输出
+ * @param {object} details 实体详情结果映射
+ * @returns {Array} 设施点(`placement` 为 drawn / no-location)
+ */
+export function facilityPointsWithDetails(rows, details) {
+    const source = details && typeof details === 'object' ? details : {}
+    return (Array.isArray(rows) ? rows : []).map((row) => {
+        const result = source[row.key] || null
+        const detail = result && result.ok ? result.detail : null
+        const coordinates = coordinateOf(detail)
+        const drawn = coordinates !== null
+        const reason = drawn
+            ? { code: '', text: '' }
+            : result && !result.ok
+                ? { code: FACILITY_NO_LOCATION_REASON.DETAIL_UNAVAILABLE, text: '设施详情未取到,坐标不可用' }
+                : { code: FACILITY_NO_LOCATION_REASON.MISSING_COORDINATE, text: '设施档案未登记坐标' }
+        const status = detail && detail.status !== undefined && detail.status !== null ? String(detail.status) : row.status
+        return {
+            ...row,
+            key: 'facility-point|' + row.type + '|' + row.id,
+            code: text(detail && detail.code) || row.code,
+            name: text(detail && detail.name) || row.name,
+            status,
+            // 状态色键与设施详情/运行信息面板同一份(`entityDetail.facilityStatusTone`),不另写一套。
+            tone: facilityStatusTone(status),
+            longitude: coordinates ? coordinates.longitude : null,
+            latitude: coordinates ? coordinates.latitude : null,
+            placement: drawn ? FACILITY_PLACEMENT.DRAWN : FACILITY_PLACEMENT.NO_LOCATION,
+            reasonCode: reason.code,
+            reasonText: reason.text,
+        }
+    })
+}
+
+/** 设施点 → 地图标记计划输入(只含可上图的行;形状由 markerPlan 决定,本层不拼素材)。 */
+export function facilityPointMarkers(points) {
+    return (Array.isArray(points) ? points : [])
+        .filter((row) => row && row.placement === FACILITY_PLACEMENT.DRAWN
+            && typeof row.longitude === 'number' && Number.isFinite(row.longitude)
+            && typeof row.latitude === 'number' && Number.isFinite(row.latitude))
+        .map((row) => {
+            const meta = facilityTypeMeta(row.type) || {}
+            return {
+                // 覆盖层节点池的键:用实体主键(现网两座水厂共用编码 WP001,编码做键会互相顶掉)。
+                key: row.key,
+                kind: 'facility-point',
+                code: row.code || row.id,
+                name: row.name || row.code || row.id,
+                type: row.type,
+                symbol: meta.symbol || (FACILITY_DETAIL_CATEGORIES.includes(row.type) ? 'pipeSpecial' : ''),
+                status: row.status,
+                // 状态色键随标记下发(`facilityStatusTone` 的结果),渲染层不再自己判设施状态色。
+                tone: row.tone || '',
+                // 票 101:点击身份 —— 与「设施台账」卡片点击构造同一份 entity identity
+                // (kind='facility' + 实体主键 + 类别)。缺主键/类别的行**不可点**(不伪装)。
+                entityId: row.id || '',
+                detailCategory: meta.detailCategory || (FACILITY_DETAIL_CATEGORIES.includes(row.type) ? row.type : ''),
+                industry: row.industry || '',
+                clickable: Boolean(row.id && FACILITY_DETAIL_CATEGORIES.includes(row.type)),
+                longitude: row.longitude,
+                latitude: row.latitude,
+            }
+        })
+}
+
+/** 选中集合归一:只保留四类设施键、去重;空集合=不筛(与 pointStatus 的多选语义一致)。 */
+export function selectedFacilityTypes(selected) {
+    const list = Array.isArray(selected) ? selected : []
+    const result = new Set()
+    for (const item of list) {
+        const value = text(item).toUpperCase()
+        if (isKnownFacilityType(value)) result.add(value)
+    }
+    return result
+}
+
+/**
+ * 设施筛选(多选 OR):空数组=全部;认不出的键忽略。
+ * 只裁设施点本身 —— 监测点/设备的筛选是另一个维度(`filterStore.pointStatus`),
+ * 两类对象互不重叠,同时生效时并非「取交集」,而是各自裁剪(写进票面与图例说明)。
+ *
+ * 行业/片区是**全局条件**,与管线图层同一条纪律(`domain/pipeNetwork.js` 的
+ * `filterPipeNetwork`):设施行带台账给的 `industry` 与 `district`,这里按同一张
+ * 前后端映射表裁剪;认不出/缺失的如实归「未分区」,不按编码前缀猜。
+ *
+ * @param {Array} points 设施点
+ * @param {Array} selected 设施类型集合(filterStore.facilityTypes)
+ * @param {{industry?: string, scope?: string}} [filters] 全局行业/片区条件(前端键)
+ */
+export function filterFacilityPoints(points, selected, filters = {}) {
+    const list = Array.isArray(points) ? points : []
+    const chosen = selectedFacilityTypes(selected)
+    const industry = industryQuery(filters.industry)
+    const district = districtQuery(filters.scope)
+    return list.filter((row) => {
+        if (!row) return false
+        if (chosen.size && !chosen.has(row.type)) return false
+        if (industry && row.industry !== industry) return false
+        if (district === '') return true
+        if (district === 'UNASSIGNED') return row.district === null
+        return row.district === district
+    })
+}
+
+function countOf(value) {
+    const number = Number(value)
+    return Number.isFinite(number) ? number : 0
+}
+
+/**
+ * 四类设施的档案口径与图层口径(图例与票面对账的唯一来源)。
+ *
+ * 两个计数**主语不同、都不随筛选变化**(与顶部状态图例「全局未筛口径」同一条纪律):
+ *   · `archiveCount` —— 基础档案 `facilityTypeDistribution` 的全局计数(后端未发布该类型=null,不是 0);
+ *   · `mappableCount` —— 当前设施点图层里真正能上图的条数(实体可枚举且坐标齐)。
+ * `enumerableCount` 是「运行信息能枚举出的实体数」,与 `archiveCount` 的差如实留给
+ * `archiveGapCount`(如排水泵站不在运行信息下发范围内),不拿它冒充完整。
+ *
+ * @param {object|null} archivePayload `/infrastructure/base-archives` 的 data(取不到=null)
+ * @param {Array|null} points 设施点(图层失败=null,不是空数组)
+ */
+export function facilityInventory(archivePayload, points) {
+    const list = Array.isArray(points) ? points : null
+    const distribution = archivePayload && Array.isArray(archivePayload.facilityTypeDistribution)
+        ? archivePayload.facilityTypeDistribution
+        : null
+    return FACILITY_POINT_TYPES.map((meta) => {
+        let archiveCount = null
+        if (distribution) {
+            // 档案类型码经同一个归一函数归到四类(真实 `WATER_PUMP_STATION`/mock `PUMP_STATION` 都认)。
+            const matched = distribution.filter((row) => normalizeFacilityType(row && row.code) === meta.key)
+            // 类型没出现在档案分布里=后端未发布该类型(用水户),不是「0 个」。
+            archiveCount = matched.length ? matched.reduce((sum, row) => sum + countOf(row && row.count), 0) : null
+        }
+        const rows = list ? list.filter((row) => row && row.type === meta.key) : []
+        const mappableCount = rows.filter((row) => row.placement === FACILITY_PLACEMENT.DRAWN).length
+        const enumerableCount = rows.length
+        let state
+        if (!list) state = FACILITY_LEGEND_STATE.UNAVAILABLE
+        else if (archiveCount === null && enumerableCount === 0) state = FACILITY_LEGEND_STATE.UNPUBLISHED
+        else if (enumerableCount === 0) state = FACILITY_LEGEND_STATE.NO_ENTITY
+        else if (mappableCount < enumerableCount) state = FACILITY_LEGEND_STATE.PARTIAL
+        else state = FACILITY_LEGEND_STATE.READY
+        return {
+            key: meta.key,
+            label: meta.label,
+            unit: meta.unit,
+            symbol: meta.symbol,
+            archiveCount,
+            enumerableCount,
+            mappableCount,
+            blockedCount: enumerableCount - mappableCount,
+            archiveGapCount: archiveCount === null ? null : Math.max(0, archiveCount - enumerableCount),
+            state,
+        }
+    })
+}
+
+function legendHint(row) {
+    if (!row) return ''
+    if (row.state === FACILITY_LEGEND_STATE.UNAVAILABLE) return '设施点数据未取到'
+    if (row.state === FACILITY_LEGEND_STATE.UNPUBLISHED) return '后端尚未下发该类设施'
+    if (row.state === FACILITY_LEGEND_STATE.NO_ENTITY && row.archiveCount !== null) {
+        return '档案有 ' + row.archiveCount + ' ' + row.unit + ',但后端未下发可上图的设施实体'
+    }
+    if (row.state === FACILITY_LEGEND_STATE.PARTIAL && row.blockedCount > 0) {
+        return row.blockedCount + ' ' + row.unit + '未登记坐标,已进「无地图位置」'
+    }
+    if (row.archiveGapCount > 0) {
+        return '档案共 ' + row.archiveCount + ' ' + row.unit + ',其中 ' + row.archiveGapCount + ' ' + row.unit + '不在设施点台账下发范围'
+    }
+    return ''
+}
+
+/**
+ * 设施图例行(独立块的数据源)。
+ *
+ * 计数口径(写进行 title,避免两个数被读成一套):
+ *   `count`  = 全局未筛档案数(不随筛选变化;null=未发布/未取到);
+ *   `onMap`  = 当前图层可上图数(不随筛选变化 —— 筛选只改标记显隐,不改这个数)。
+ * 实际画到图上的条数由地图宿主的 `data-map-facility-points` 单独报(随筛选变化),
+ * 两个数各说各的主语,不互相冒充。
+ *
+ * @param {Array} inventory `facilityInventory()` 的输出
+ * @param {Array} selected filterStore 里选中的设施类型
+ */
+export function facilityLegendRows(inventory, selected) {
+    const chosen = selectedFacilityTypes(selected)
+    return (Array.isArray(inventory) ? inventory : []).map((row) => {
+        const hint = legendHint(row)
+        const countText = row.archiveCount === null ? '—' : String(row.archiveCount)
+        return {
+            key: row.key,
+            label: row.label,
+            unit: row.unit,
+            symbol: row.symbol,
+            count: row.archiveCount,
+            countText,
+            onMap: row.mappableCount,
+            selected: chosen.has(row.key),
+            state: row.state,
+            hint,
+            title: chosen.has(row.key)
+                ? '取消「' + row.label + '」筛选(多选;计数为全局档案口径,不随筛选变化)' + (hint ? ';' + hint : '')
+                : '按「' + row.label + '」筛选(多选;计数为全局档案口径,可上图 ' + row.mappableCount + ' ' + row.unit + ')' + (hint ? ';' + hint : ''),
+        }
+    })
+}
+
+/**
+ * 设施点 → 「无地图位置」总览附加项(票 07 纪律:不上图也要在清单里,不静默丢弃)。
+ * 与 `domain/coordinate.js` 的 `facilityCoordinateExtra()` 同形,由调用方合并。
+ */
+export function facilityPointCoordinateExtra(points) {
+    const list = Array.isArray(points) ? points : []
+    const drawn = list.filter((row) => row && row.placement === FACILITY_PLACEMENT.DRAWN)
+    const blocked = list.filter((row) => row && row.placement === FACILITY_PLACEMENT.NO_LOCATION)
+    return {
+        renderable: drawn.length,
+        blockedEntries: blocked.map((row) => ({
+            // key 用实体主键而不是编码:现网两座水厂共用编码 WP001,编码做键会互相顶掉。
+            key: row.key || ('facility-point|' + row.type + '|' + row.id),
+            kind: 'facility-point',
+            code: row.code || row.id,
+            name: row.name || row.code || row.id,
+            reasonCode: row.reasonCode || '',
+            reasonText: row.reasonText || NO_MAP_LOCATION_TEXT,
+        })),
+    }
+}

+ 114 - 0
src/modules/cockpit/domain/facilitySelection.js

@@ -0,0 +1,114 @@
+/** Facility selection uses entity category+primary key, never monitoring point codes. */
+import { facilityStatusTone, entityIdentity } from './entityDetail.js'
+import { COORDINATE_REASON_TEXT, NO_MAP_LOCATION_TEXT } from './coordinate.js'
+import { filterFacilityPoints, FACILITY_DETAIL_CATEGORIES } from './facilityPoints.js'
+export function facilityIdentity(input) {
+    const identity = entityIdentity(input, 'facility')
+    return { ...identity, valid: identity.valid && identity.kind === 'facility' && FACILITY_DETAIL_CATEGORIES.includes(identity.category) }
+}
+const coordinatesOf = value => value && Number.isFinite(value.longitude) && Number.isFinite(value.latitude)
+    ? [value.longitude, value.latitude] : null
+
+/** Resolve only current identity evidence; pipeline location is the along-line geometric midpoint of its validated polyline — never an endpoint, never invented. */
+/**
+ * 管线定位点 = 沿真实折线的**长度中点**(维护者 2026-10-30 15:57:不许再取起点/终点)。
+ * 每段按大圆近似计长(与 pipeDiameter 同一公式),半程落在哪一段就在段内线性插值;
+ * 顶点不足或全长为零(重合点)时回那个真实顶点 —— 仍是几何证据,不是编造。
+ */
+function polylineMidpoint(positions) {
+    const points = (Array.isArray(positions) ? positions : [])
+        .filter((point) => Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1]))
+    if (!points.length) return null
+    if (points.length === 1) return points[0]
+    const rad = Math.PI / 180
+    const segmentLength = (a, b) => {
+        const h = Math.sin((b[1] - a[1]) * rad / 2) ** 2
+            + Math.cos(a[1] * rad) * Math.cos(b[1] * rad) * Math.sin((b[0] - a[0]) * rad / 2) ** 2
+        return 12742017.6 * Math.asin(Math.sqrt(Math.min(1, h)))
+    }
+    const lengths = []
+    let total = 0
+    for (let i = 1; i < points.length; i += 1) {
+        const length = segmentLength(points[i - 1], points[i])
+        lengths.push(length)
+        total += length
+    }
+    if (!(total > 0)) return points[0]
+    let walked = 0
+    for (let i = 0; i < lengths.length; i += 1) {
+        const length = lengths[i]
+        if (walked + length >= total / 2) {
+            const t = length > 0 ? (total / 2 - walked) / length : 0
+            return [
+                points[i][0] + (points[i + 1][0] - points[i][0]) * t,
+                points[i][1] + (points[i + 1][1] - points[i][1]) * t,
+            ]
+        }
+        walked += length
+    }
+    return points[points.length - 1]
+}
+
+export function resolveFacilityPoint({ identity: input, points, detail, network } = {}) {
+    const identity = facilityIdentity(input)
+    if (!identity.valid) return null
+    const row = (points || []).find(item => item.id === identity.id && item.type === identity.category)
+    const current = detail && detail.kind === 'facility' && detail.id === identity.id && detail.category === identity.category ? detail : null
+    const raw = current?.raw || {}
+    let source = row || {}
+    let position = null
+    let placement = 'pending'
+    let reasonText = ''
+    let locationHint = ''
+    if (identity.category === 'PIPE_NETWORK') {
+        const matches = (network?.pipes || []).filter(pipe => String(pipe.sourceId ?? '') === identity.id || String(pipe.networkId ?? '') === identity.id)
+        const byCode = matches.length ? matches : (network?.pipes || []).filter(pipe => raw.code && pipe.code === raw.code)
+        const pipe = byCode.length === 1 ? byCode[0] : null
+        if (pipe) {
+            source = pipe
+            position = polylineMidpoint(pipe.positions)
+            placement = position ? 'drawn' : 'no-location'
+            locationHint = position ? '定位到管段几何中点(沿管线长度取中点,非起止点)' : ''
+        } else if (network) {
+            const blocked = (network.blocked || []).find(item => raw.code && item.code === raw.code)
+            placement = blocked ? 'no-location' : 'pending'
+            reasonText = blocked?.reasonText || '当前地图尚未取到该管段的可定位几何'
+        }
+    } else if (current && Object.prototype.hasOwnProperty.call(raw, 'coordinates')) {
+        position = coordinatesOf(current.coordinates)
+        placement = position ? 'drawn' : 'no-location'
+        reasonText = position ? '' : '设施档案未登记坐标'
+        // 工程设施不是旧四类设施点契约;新定位必须有后端明确的可渲染结论。
+        if (position && identity.category === 'ENGINEERING_FACILITY' && raw.coordinateValidation?.renderable !== true) {
+            position = null
+            placement = raw.coordinateValidation?.renderable === false ? 'no-location' : 'pending'
+            reasonText = placement === 'pending' ? '设施坐标尚未获得地图可渲染校验' : (COORDINATE_REASON_TEXT[raw.coordinateValidation.reason] || NO_MAP_LOCATION_TEXT)
+        }
+    } else if (row) {
+        position = coordinatesOf(row)
+        placement = row.placement
+        reasonText = row.reasonText || ''
+    }
+    const status = raw.status ?? row?.status ?? ''
+    return {
+        key: 'facility-point|' + identity.category + '|' + identity.id,
+        id: identity.id, type: identity.category, detailCategory: identity.category,
+        code: String(raw.code || source.code || identity.id),
+        name: current?.name || source.name || String(raw.name || identity.id),
+        status: String(status), tone: facilityStatusTone(status),
+        industry: source.industry || raw.industry || '',
+        district: source.district ?? raw.district ?? null,
+        placement, longitude: position?.[0] ?? null, latitude: position?.[1] ?? null,
+        reasonText, locationHint,
+    }
+}
+
+/** Selected ledger-only entities get one temporary marker, under the SAME filters; global legend counts stay unchanged. */
+export function mergeFacilitySelectionPoints(base, selected, filters = {}) {
+    if (!selected || selected.placement !== 'drawn') return base
+    if (!filterFacilityPoints([selected], filters.types || [], filters).length) return base
+    const list = Array.isArray(base) ? base : []
+    const index = list.findIndex(row => row.key === selected.key)
+    if (index < 0) return [...list, selected]
+    return list.map((row, i) => i === index ? selected : row)
+}

+ 6 - 0
src/modules/cockpit/domain/icons.js

@@ -68,6 +68,12 @@ const ICON_SPECS = {
     kpiTime: 'icon.kpi.time',
     kpiScale: 'icon.kpi.scale',
     kpiShield: 'icon.kpi.shield',
+    // 票 68:四类设施点符号(地图标记内层 + 设施图例共用同一枚剪影;agent 生成的临时占位,
+    // 设计正式切图到位后按同 manifest key 替换文件即可,代码零改动)。
+    facilityPlant: 'marker.inner.facilityPlant',
+    facilityPump: 'marker.inner.facilityPump',
+    facilitySource: 'marker.inner.facilitySource',
+    facilityUser: 'marker.inner.facilityUser',
 }
 
 export const ICON_NAMES = Object.keys(ICON_SPECS)

+ 40 - 7
src/modules/cockpit/domain/infrastructurePanels.js

@@ -21,6 +21,7 @@
 import { fieldReader } from './fields.js'
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
 import { facilityStatusText, facilityStatusTone } from './entityDetail.js'
+import { FACILITY_DETAIL_CATEGORIES } from './facilityPoints.js'
 import { collectMissing, valueText } from './equipmentDetailView.js'
 import { industryLabel, normalizeDistrict, districtLabel } from './filterOptions.js'
 import {
@@ -335,6 +336,29 @@ export function toEmergencySupport(raw) {
 
 // ================================================================ 视图计划
 
+/**
+ * 片区固定四项(维护者第 16 轮):城南 / 城北 / 太常 / 未分区,**顺序与字典固定**。
+ *
+ * 后端 `regions[]` 只回实际出现的片区(且顺序随数据),读者在不同时刻看到同一指标换位置。
+ * 片区是**固定字典**(码 0/1/2,其余归未分区),固定字典下缺项就是真实的 0 ——
+ * 所以这里补齐四项并照实画 0(这不是「把没给的数据画成 0」:码位是确定的)。
+ */
+function fixedDistrictRows(regions) {
+    const list = Array.isArray(regions) ? regions : []
+    const buckets = [
+        { code: 0, label: '城南', count: 0 },
+        { code: 1, label: '城北', count: 0 },
+        { code: 2, label: '太常', count: 0 },
+        { code: null, label: '未分区', count: 0 },
+    ]
+    for (const row of list) {
+        const code = normalizeDistrict(row && row.code)
+        const bucket = buckets.find((item) => item.code === code) || buckets[3]
+        bucket.count += Number(row && row.count) || 0
+    }
+    return buckets
+}
+
 /**
  * 基础档案面板视图。
  * 验收四项:设施规模 / 行业分类 / 区域分布 / 档案摘要。
@@ -348,7 +372,11 @@ export function archivePanel(data, section) {
         (row) => row.count,
     )
     const industryRows = sortByCountDesc(current.industries || [], (row) => row.count)
-    const regionRows = sortByCountDesc(current.regions || [], (row) => row.count)
+    // 维护者第 16 轮:片区分布改**固定四项**(城南 / 城北 / 太常 / 未分区)——
+    // 片区是固定字典(后端码 0/1/2,其余归未分区),随数据出现的顺序会让同行指标在
+    // 不同时刻换位置(读者对不上「上次那条」)。固定字典下 0 是**真实值**(确实没有),
+    // 与「后端没给」分得开,所以照实画 0。
+    const regionRows = fixedDistrictRows(current.regions || [])
     const ageRows = current.ages || []
 
     const blocks = [
@@ -359,12 +387,16 @@ export function archivePanel(data, section) {
             { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' },
             { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' },
         ]),
-        // 构成 → 环形图(类别不多、看占比)。
+        // 维护者第 15 轮补充:还原为上下两块(并排在窄面板里把图压得太小)。
+        // 另:原「设施类型分布」环与「设施类型构成」堆叠条表达同一份八类构成(重复)——
+        // 已删掉那张环,只留堆叠条(这条修改保留)。
         donutBlock('行业分类', industryRows, { layout: 'compact' }),
-        // 维护者第 15 轮:环与堆叠条表达的是同一份八类构成(重复)—— 删环、留堆叠条。
-        stackedBlock('设施类型构成', typeRows),
-        // 对比分析 → 柱状条(片区之间比大小)。
-        barsBlock('片区分布', regionRows),
+        // 维护者第 16 轮:八类设施要「对不同颜色做出更明显的区分」—— 声明 `distinct`,
+        // 按行序取八个互不相近的色相(默认语义色里排水/供水/燃气之外都落进同一族蓝)。
+        stackedBlock('设施类型构成', typeRows, { distinct: true }),
+        // 维护者第 16 轮:片区分布改**饼图**(四个固定片区的构成比),图例带横置柱状条
+        // 给直观对比(`legendBar`);环心合计=本面板片区口径的设施总数。
+        donutBlock('片区分布', regionRows, { legendBar: true }),
         // 时间维度 → 面积折线(年限是固定分桶,靠顺序读"新老结构",不排序)。
         trendBlock('建成年限结构', ageRows.map((row) => ({
             key: row.code,
@@ -428,7 +460,7 @@ function metricTitle(metric) {
 /** 设施详情类别必须由摘要显式给出;未知类别不猜、不下钻错误表。 */
 function facilityDetailCategory(metric) {
     const explicit = String(metric.facilityKind || metric.facilityType || '').trim().toUpperCase()
-    return ['WATER_PLANT', 'PUMP_STATION', 'PIPE_NETWORK', 'ENGINEERING_FACILITY'].includes(explicit) ? explicit : ''
+    return FACILITY_DETAIL_CATEGORIES.includes(explicit) ? explicit : ''
 }
 
 const EMERGENCY_DETAIL_CATEGORY = Object.freeze({
@@ -545,6 +577,7 @@ export function operationPanel(data, section, extra) {
                 // 票 73 裁定 A:标题带设施编码且**编码在前**(同页十行同名;编码放后面会被省略号切掉尾号)。
                 title: metricTitle(metric),
                 identityKind: CARD_ACTION.FACILITY,
+                facilityLocate: Boolean(metric.facilityId && facilityDetailCategory(metric)),
                 identityCode: facilityDetailCategory(metric) ? metric.facilityId || '' : '',
                 identityCategory: facilityDetailCategory(metric),
                 identityPayload: { ...metric, category: facilityDetailCategory(metric), industry: metric.industryCode || scope.code, status: metric.facilityStatus },

+ 5 - 0
src/modules/cockpit/domain/markerMetrics.js

@@ -88,6 +88,11 @@ const TIER_OF_KIND = {
     'monitoring-device': 'device',
     'unassociated-equipment': 'device',
     'overlap-aggregate': 'device',
+    // 票 68:设施点(水厂/泵站/水源地/用水户)与独立设备点**同档**(形态参考独立设备点),
+    // 不新开尺寸档:多一档就多一套「谁比谁大」的口径。
+    'facility-point': 'device',
+    // 票 101:管线构件点位(阀门/窨井/特殊管点)同样与设备点同档。
+    'pipe-component': 'device',
 }
 
 const round2 = (value) => Math.round(value * 100) / 100

+ 54 - 5
src/modules/cockpit/domain/markerPlan.js

@@ -51,6 +51,43 @@ export function innerIconFor(industry) {
     return INNER_ICONS[industry] || ''
 }
 
+/**
+ * 设施点内层图标(票 68):四类设施各一枚可辨符号(语义名 → manifest `marker.inner.*`)。
+ *
+ * 与行业图标**分开一张表**:设施类型不是行业(水源地/用水户没有行业图标语义),
+ * 借用行业图标会让「水厂」和「供水设备」看起来是同一类对象。
+ */
+const FACILITY_INNER_ICONS = {
+    WATER_PLANT: 'facilityPlant',
+    PUMP_STATION: 'facilityPump',
+    WATER_SOURCE: 'facilitySource',
+    WATER_USER: 'facilityUser',
+    PIPE_NETWORK: 'pipeSpecial',
+    ENGINEERING_FACILITY: 'pipeSpecial',
+}
+
+/** @param {string} facilityType 设施类型(WATER_PLANT/PUMP_STATION/WATER_SOURCE/WATER_USER) */
+export function innerIconForFacilityType(facilityType) {
+    return FACILITY_INNER_ICONS[facilityType] || ''
+}
+
+/**
+ * 管线构件内层图标(票 101):阀门/弯头/三通/特殊管点各一枚,窨井复用既有窨井图标。
+ * 与行业图标、设施图标**各一张表**:构件类别既不是行业也不是设施类型。
+ */
+const PIPE_COMPONENT_ICONS = {
+    valve: 'pipeValve',
+    elbow: 'pipeElbow',
+    branch: 'pipeBranch',
+    other: 'pipeSpecial',
+}
+
+/** @param {string} componentKind 构件类别(valve/elbow/branch/other);窨井按行业判定 */
+export function innerIconForPipeComponent(componentKind, industry) {
+    if (industry === 'MANHOLE') return 'manhole'
+    return PIPE_COMPONENT_ICONS[componentKind] || PIPE_COMPONENT_ICONS.other
+}
+
 /**
  * 状态 → 色键(tokens 语义键)。色值本身仍只在 tokens.css 一份,本表只定「用哪一档」。
  * 与 domain/pointStatus.js 的 POINT_STATUS_VISUALS 同一状态词表口径。
@@ -69,9 +106,11 @@ const TONE_BY_STATUS = {
  * 合成计划(纯函数,无副作用)。
  *
  * @param {object} input
- * @param {'monitoring-point'|'monitoring-device'} input.kind 对象种类
+ * @param {'monitoring-point'|'monitoring-device'|'facility-point'|'pipe-component'} input.kind 对象种类
  * @param {string} input.status 点位聚合状态(ALARM/WARNING/WORK_ORDER/NORMAL/OFFLINE/EMPTY/UNKNOWN)或设备自身状态
  * @param {string} [input.industry] 后端行业枚举(GAS/WATER_SUPPLY/DRAINAGE/MANHOLE)
+ * @param {string} [input.facilityType] 设施类型(仅 kind='facility-point':四类设施之一)
+ * @param {string} [input.componentKind] 构件类别(仅 kind='pipe-component':valve/elbow/branch/other)
  * @param {boolean} [input.unassociated] 未关联设备(无监测点归属)
  * @param {string} [input.tone] 覆盖色键(默认按状态词表推导;调用方读 tokens 后也可直接给)
  * @returns {{
@@ -86,12 +125,18 @@ const TONE_BY_STATUS = {
  *   draw: { width: number, height: number, pad: number },
  * }}
  */
-export function planMarker({ kind, status, industry = '', unassociated = false, tone }) {
+export function planMarker({ kind, status, industry = '', facilityType = '', componentKind = '', unassociated = false, tone }) {
     const isPoint = kind === 'monitoring-point'
+    // 票 68:设施点用**设施类型图标**而不是行业图标;母版恒实心、不降饱和
+    //(设施点的「类型」靠图标区分,状态靠色键,与独立设备点同一套读法)。
+    const isFacility = kind === 'facility-point'
+    // 票 101:管线构件点位同理——图标按构件类别(阀门/弯头/三通/特殊管点/窨井),
+    // 颜色由调用方给行业色(toneHex 直传),不走状态色键。
+    const isComponent = kind === 'pipe-component'
     // 无设备的监测点:只对监测点语义成立(EMPTY 是点位六态,设备五态没有它)。
     const empty = isPoint && status === 'EMPTY'
     // 未关联设备:无监测点归属的设备,走镂空 + 行业图标 + 降饱和。
-    const unassoc = !isPoint && unassociated === true
+    const unassoc = !isPoint && !isFacility && !isComponent && unassociated === true
 
     // 色键:默认按状态词表推导;调用方可显式覆盖(如设备自身状态表与点位表并行的场合)。
     const tint = tone || TONE_BY_STATUS[status] || 'neutral'
@@ -99,8 +144,12 @@ export function planMarker({ kind, status, industry = '', unassociated = false,
     // 母版归属(规格 10):监测点与关联监测设备 = 实心;EMPTY 点位与未关联设备 = 镂空。
     const master = empty || unassoc ? 'hollow' : 'solid'
 
-    // 内层图标:EMPTY 点位不用;未关联设备用行业图标;其余按行业(未知行业不画内层)。
-    const innerIcon = innerIconFor(industry)
+    // 内层图标:EMPTY 点位不用;设施点按设施类型;构件点按构件类别;其余按行业(未知行业不画内层)。
+    const innerIcon = isFacility
+        ? innerIconForFacilityType(facilityType)
+        : isComponent
+            ? innerIconForPipeComponent(componentKind, industry)
+            : innerIconFor(industry)
     const innerEnabled = !empty && innerIcon !== ''
 
     // 尺寸全部来自单源 `markerMetrics`:本体宽、描边 pad、绘制宽。pad 按**本档**比例缩放,

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

@@ -7,6 +7,7 @@
  */
 import { MAP_OBJECT_KIND } from './mapSummary.js'
 import { planPointMarkers, pointLegend } from './pointStatus.js'
+import { facilityPointMarkers } from './facilityPoints.js'
 
 export const MODE_LAYER = {
     OVERVIEW: 'overview',
@@ -125,14 +126,17 @@ function visibleMapObjects(mode, objects, abnormalDevices) {
     ))
 }
 
-function planNonPointLayers({ mode, entered, facilities, pipelines, degradedNotes }) {
+function planNonPointLayers({ mode, entered, facilities, pipelines, facilityPoints, degradedNotes }) {
     const facilityVisible = mode === MODE_LAYER.OVERVIEW || mode === MODE_LAYER.INFRASTRUCTURE
     const pipelineVisible = entered && MODE_KEYS.has(mode)
     const facilityReady = hasArray(facilities)
     const pipelineReady = hasArray(pipelines)
+    // 票 68:设施点(水厂/泵站/水源地/用水户)与管线/构件同属基础设施对象,显隐跟同一档模式。
+    const facilityPointReady = hasArray(facilityPoints)
 
     if (facilityVisible && !facilityReady) degradedNotes.push(note('facilities-unready', '设施数据未就绪', mode))
     if (pipelineVisible && !pipelineReady) degradedNotes.push(note('pipelines-unready', '管线数据未就绪', mode))
+    if (facilityVisible && !facilityPointReady) degradedNotes.push(note('facility-points-unready', '设施点数据未就绪', mode))
 
     // 票 33:**原样引用,不 slice()**。每 30s freshness tick 会让计划整体重算——
     // 以前这里每次 `slice()` 出新数组,`MapCanvas` 的 `deep:false` watcher 靠引用判变化,
@@ -141,9 +145,12 @@ function planNonPointLayers({ mode, entered, facilities, pipelines, degradedNote
     return {
         facilities: facilityVisible && facilityReady ? facilities : [],
         pipelines: pipelineVisible && pipelineReady ? pipelines : [],
+        // 设施 store 持有档案行;只有此处转换为带唯一身份/可点标志的渲染标记。
+        facilityPoints: facilityVisible && facilityPointReady ? facilityPointMarkers(facilityPoints) : [],
         layerStates: {
             facilities: facilityVisible ? (facilityReady ? 'ready' : 'degraded') : 'hidden',
             pipelines: pipelineVisible ? (pipelineReady ? 'ready' : 'degraded') : 'hidden',
+            facilityPoints: facilityVisible ? (facilityPointReady ? 'ready' : 'degraded') : 'hidden',
         },
     }
 }
@@ -188,7 +195,7 @@ function selectionVisible({ mode, selection, objects, markers, associatedDevices
  *
  * @returns {{mode:string, entered:boolean, markers:Array, blocked:Array, legend:Array,
  *   locateTargets:Array, degradedNotes:Array, facilities:Array, pipelines:Array,
- *   associatedDevices:Array, layerStates:object, selection:object}}
+ *   facilityPoints:Array, associatedDevices:Array, layerStates:object, selection:object}}
  */
 export function planModeLayers({
     mode = MODE_LAYER.OVERVIEW,
@@ -196,6 +203,7 @@ export function planModeLayers({
     abnormalDevices,
     facilities,
     pipelines,
+    facilityPoints,
     associatedDevices = [],
     selection,
     entered = true,
@@ -214,8 +222,9 @@ export function planModeLayers({
             degradedNotes: [],
             facilities: [],
             pipelines: [],
+            facilityPoints: [],
             associatedDevices: [],
-            layerStates: { facilities: 'hidden', pipelines: 'hidden', associatedDevices: 'hidden' },
+            layerStates: { facilities: 'hidden', pipelines: 'hidden', facilityPoints: 'hidden', associatedDevices: 'hidden' },
             selection: { visible: false, action: MODE_SELECTION.PRESERVE },
         }
     }
@@ -236,6 +245,7 @@ export function planModeLayers({
         entered: true,
         facilities,
         pipelines,
+        facilityPoints,
         degradedNotes,
     })
 
@@ -259,6 +269,7 @@ export function planModeLayers({
         degradedNotes,
         facilities: nonPoint.facilities,
         pipelines: nonPoint.pipelines,
+        facilityPoints: nonPoint.facilityPoints,
         associatedDevices: visibleDevices,
         blockedAssociatedDevices: (Array.isArray(associatedDevices) ? associatedDevices : [])
             .filter((row) => row && row.placement !== 'drawn'),

+ 66 - 1
src/modules/cockpit/domain/pipeNetwork.js

@@ -18,6 +18,7 @@
  */
 import { COORDINATE_REASON_TEXT, NO_MAP_LOCATION_TEXT } from './coordinate.js'
 import { DISTRICT_CODES, INDUSTRY_CODES, districtQuery, industryQuery } from './filterOptions.js'
+import { PIPE_PALETTE, pipeColorKey } from './pipeVisual.js'
 
 /** 四个正式行业码(燃气/供水/排水/窨井)——`INDUSTRY_CODES` 的**值**才是后端码。 */
 const INDUSTRY_LIST = Object.freeze(Object.values(INDUSTRY_CODES))
@@ -56,6 +57,69 @@ export const COMPONENT_TEXT = {
     [PIPE_COMPONENT.OTHER]: '其它构件',
 }
 
+/**
+ * 管线构件 → 点位标记(票 101)。
+ *
+ * 构件在地面视图升级为「点位标记」(DOM overlay,与设备点/设施点同一套视觉语言):
+ *   · 内层符号按构件类别各一枚(阀门/弯头/三通/特殊管点;窨井复用既有窨井图标);
+ *   · 色值沿用**管线行业色**(`PIPE_PALETTE`,与管线图层同一份),不另造一套颜色语义;
+ *   · 标签优先设施名,缺名回退类别中文名;「特殊管点」保留后端 pointType 原文
+ *     (票 24 纪律:未建模类型如实显示,不伪装成已知类别)。
+ *
+ * 坐标准入已在 `toPipeNetwork()` 收口(renderable 才进 `components`),这里不再二次判定;
+ * 上图与点击的显隐/派发由渲染层与模式计划负责,本层只给身份与视觉计划。
+ */
+
+/** 构件类别 → 内层图标语义名(manifest `marker.inner.*`)。 */
+export const PIPE_COMPONENT_INNER_ICONS = Object.freeze({
+    [PIPE_COMPONENT.VALVE]: 'pipeValve',
+    [PIPE_COMPONENT.ELBOW]: 'pipeElbow',
+    [PIPE_COMPONENT.BRANCH]: 'pipeBranch',
+    [PIPE_COMPONENT.OTHER]: 'pipeSpecial',
+})
+
+/** 构件的内层图标语义名;窨井走既有窨井图标,认不出的类别落「特殊管点」。 */
+export function pipeComponentIcon(component) {
+    if (!component) return ''
+    if (component.industry === 'MANHOLE') return 'manhole'
+    return PIPE_COMPONENT_INNER_ICONS[component.kind] || PIPE_COMPONENT_INNER_ICONS[PIPE_COMPONENT.OTHER]
+}
+
+/** 构件的点位标签:设施名优先;缺名时窨井=「窨井」、特殊管点=后端原文、其余=类别中文名。 */
+export function pipeComponentLabel(component) {
+    if (!component) return ''
+    if (component.name) return component.name
+    if (component.industry === 'MANHOLE') return '窨井'
+    if (component.kind === PIPE_COMPONENT.OTHER) return component.label || COMPONENT_TEXT[PIPE_COMPONENT.OTHER]
+    return COMPONENT_TEXT[component.kind] || component.label || ''
+}
+
+/**
+ * 构件 → 点位标记行。
+ * @param {Array} components `toPipeNetwork()` 的 `components`(已准入)
+ * @returns {Array} `{ key, kind:'pipe-component', code, name, type, symbol, industry, componentKind, status, toneHex, longitude, latitude }`
+ */
+export function pipeComponentMarkers(components) {
+    return (Array.isArray(components) ? components : [])
+        .filter((row) => row && Array.isArray(row.position)
+            && Number.isFinite(row.position[0]) && Number.isFinite(row.position[1]))
+        .map((row) => ({
+            key: `pipe-component|${row.code}`,
+            kind: 'pipe-component',
+            code: row.code,
+            name: pipeComponentLabel(row),
+            type: row.industry === 'MANHOLE' ? 'MANHOLE' : row.kind,
+            symbol: pipeComponentIcon(row),
+            industry: row.industry || '',
+            componentKind: row.kind || '',
+            status: row.status || '',
+            // 行业色直接给 hex(渲染层不再查第二张色表);管线图层与图例用的是同一份 PIPE_PALETTE。
+            toneHex: PIPE_PALETTE[pipeColorKey(row)].color,
+            longitude: row.position[0],
+            latitude: row.position[1],
+        }))
+}
+
 /**
  * 管径(mm) → 管线线宽(px)。
  *
@@ -186,7 +250,8 @@ export function toPipeNetwork(payload) {
         // 时按 properties.typeCode 归类——两端的 source(pipe_network_base / gas_pipe_point / manhole_base)
         // 各自只有一种形状,typeCode 是后端给的权威标识,不猜。
         const geometryType = feature && feature.geometry && feature.geometry.type
-        const isComponent = geometryType === 'Point' || (!geometryType && String(properties.typeCode || '').includes('POINT'))
+        const isComponent = geometryType === 'Point' || (!geometryType && (
+            String(properties.typeCode || '').includes('POINT') || properties.typeCode === 'MANHOLE'))
         const kind = isComponent ? 'component' : 'pipe'
         const id = String(properties.code || properties.sourceId || feature.id || '')
         const verdict = admission(properties)

+ 2 - 2
src/modules/cockpit/services/cesiumPipeLayer.js

@@ -143,7 +143,7 @@ function componentEntity(code, component) {
  *   added:number, updated:number, removed:number, unchanged:number}}
  *   unchanged>0 是 diff 真的在工作的可观察证据(验收闸门读它)。
  */
-export async function syncPipeLayer(viewer, network, { onModelChange = () => {} } = {}) {
+export async function syncPipeLayer(viewer, network, { onModelChange = () => {}, componentOverlayActive = () => false } = {}) {
     if (!viewer || !viewer.dataSources) return { pipes: 0, components: 0, rendered: false, added: 0, updated: 0, removed: 0, unchanged: 0 }
     const version = (layerVersions.get(viewer) || 0) + 1
     layerVersions.set(viewer, version)
@@ -220,7 +220,7 @@ export async function syncPipeLayer(viewer, network, { onModelChange = () => {}
         if (viewer.isDestroyed() || layerVersions.get(viewer) !== version) return { pipes: 0, components: 0, rendered: false }
         let layer = modelLayers.get(viewer)
         if (!layer) {
-            layer = createCesiumPipeModels(viewer, { source, onChange: onModelChange, viewMode:pipeViewModes.get(viewer)||'ground' })
+            layer = createCesiumPipeModels(viewer, { source, onChange: onModelChange, componentOverlayActive, viewMode:pipeViewModes.get(viewer)||'ground' })
             modelLayers.set(viewer, layer)
         }
         layer.sync({ pipes, components })

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

@@ -116,7 +116,7 @@ async function groupsFor(viewer, plan, heights, alive) {
     return { groups:[...groups.values()], missing }
 }
 
-export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=import.meta.env?.BASE_URL||'/', viewMode='ground' }={}) {
+export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=import.meta.env?.BASE_URL||'/', viewMode='ground', componentOverlayActive=()=>false }={}) {
     const heights=new Map()
     const guideStates=new WeakMap()
     let active=true,generation=0,fingerprint='',missing=new Set(),allIds=[],task=null,planning=false
@@ -140,7 +140,10 @@ export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=i
                     guideStates.set(entity.polyline,distantOnly)
                 }
             } else {
-                const show=!pipelineView||!modeled
+                // 票 101:地面视图的构件点位交给 DOM overlay 标记(icon+标签,与设备/设施同一套视觉),
+                // Cesium 兜底点隐藏,避免同一构件出现两套视觉;管线视图保留原语义——
+                // 未建模(无 3D 模型)的构件仍用 Cesium 点兜底,不让构件在地下视图里消失。
+                const show = pipelineView ? !modeled : !componentOverlayActive()
                 if(entity.show!==show) entity.show=show
             }
         }
@@ -185,13 +188,14 @@ export function createCesiumPipeModels(viewer, { source, onChange=()=>{}, base=i
         // 地面视图下这些模型本来就被 `show=false` 隐藏,等于白花 CPU/GPU/内存。
         // 这里显式清空批次并释放 GPU 资源,等切到管线视图再重建。
         if (!pipelineView) {
-            const had = lastGroups.length > 0 || planning
             lastGroups = []
             signatures = new Map()
             fingerprint = ''
             planning = false
             const cleared = batches.clear()
-            if (had) publish(batches.state())
+            // 票 101:地面视图也要 publish 一次 —— 构件点的 Cesium 兜底点必须在这里被隐藏
+            //(DOM overlay 标记接管),否则首次同步后地面视图会出现「DOM 标记 + Cesium 点」双视觉。
+            publish(batches.state())
             return cleared
         }
         const plan=planPipeModels(network)

+ 98 - 0
src/modules/cockpit/services/facilityPointService.js

@@ -0,0 +1,98 @@
+/**
+ * 设施点取数(票 68):设施台账枚举 + 实体详情补坐标。
+ *
+ * 为什么是两跳(而不是一条几何端点):
+ *   · cockpit 没有「设施列表」端点,四类设施的实体标识只出现在
+ *     `/infrastructure/operating-information` 的指标行上(`facilityId` + `facilityKind`);
+ *   · 坐标只出现在 `/entities/{id}/detail?category=…` 的 `coordinates` 上。
+ *   两条都是 `GET /api/cockpit/v1/**` 免鉴权只读端点,不碰业务 GIS 路径(ADR 0005)。
+ *   **契约缺口**(票 68 登记):实体详情不带几何图层那套 `coordinateValidation`,
+ *   基础设施也还没进 `/infrastructure/geometry` —— 后端补齐后可去掉这层两跳拼装。
+ *
+ * 失败纪律沿用全舱:台账取不到 → 设施点图层整体 `failed`(图例如实「未取到」;
+ * 不是「这里没有设施」);单条详情取不到 → 该条进「无地图位置」(原因是详情未取到),
+ * 其余照常上图 —— 一条坏数据不掀掉整层。
+ */
+import { requestCockpitJson } from './cockpitApi.js'
+import { entityDetailPath } from './entityDetailApi.js'
+import { SECTION_STATE, section, sectionStateFromReason } from '../domain/sectionState.js'
+import { REQUEST_REASON } from '../domain/requestReason.js'
+import {
+    facilityDetailRequests,
+    facilityInventory,
+    facilityPointsWithDetails,
+    facilityRowsFromOperating,
+} from '../domain/facilityPoints.js'
+
+export const FACILITY_OPERATING_PATH = '/infrastructure/operating-information'
+export const FACILITY_ARCHIVES_PATH = '/infrastructure/base-archives'
+
+/** 详情并发上限:现网四类设施合计个位数,4 路并发足够,不把一次启动打成请求风暴。 */
+const DETAIL_CONCURRENCY = 4
+
+/**
+ * 逐条拉实体详情(并发有界)。失败只记该条,不抛。
+ * @param {Array} requests `facilityDetailRequests()` 的输出
+ * @returns {Promise<object>} `key → { ok, detail }`
+ */
+async function loadFacilityDetails(requests, options) {
+    const results = {}
+    let cursor = 0
+    async function worker() {
+        while (cursor < requests.length) {
+            const request = requests[cursor]
+            cursor += 1
+            const path = entityDetailPath({ kind: 'facility', id: request.id, category: request.category })
+            if (!path) {
+                results[request.key] = { ok: false, reason: 'invalid-identity' }
+                continue
+            }
+            const result = await requestCockpitJson(path, options)
+            results[request.key] = result.ok && result.data && typeof result.data === 'object'
+                ? { ok: true, detail: result.data }
+                : { ok: false, reason: result.reason || REQUEST_REASON.BAD_PAYLOAD }
+        }
+    }
+    await Promise.all(Array.from({ length: Math.min(DETAIL_CONCURRENCY, requests.length) }, worker))
+    return results
+}
+
+/**
+ * 取设施点图层。
+ *
+ * @param {object} [options] `{ config, fetchImpl }`(单测/走查注入)
+ * @returns {Promise<object>} 分区(`section()`):
+ *   成功时 `data` 是 `{ points, inventory, archivesAvailable, detailFailures }`;
+ *   台账失败时 `data` 仍给出 `inventory`(档案若取到则计数可见)与 `points: null`。
+ */
+export async function fetchFacilityPoints(options = {}) {
+    const requestOptions = { config: options.config, fetchImpl: options.fetchImpl }
+    const [operating, archives] = await Promise.all([
+        requestCockpitJson(FACILITY_OPERATING_PATH, requestOptions),
+        requestCockpitJson(FACILITY_ARCHIVES_PATH, requestOptions),
+    ])
+    const archivesData = archives.ok && archives.data && typeof archives.data === 'object' ? archives.data : null
+
+    if (!operating.ok) {
+        return section(sectionStateFromReason(operating.reason), {
+            reason: operating.reason || '',
+            mode: operating.mode || '',
+            // `points: null` 是「图层没取到」的唯一形状(空数组=取到了、确实没有设施)。
+            data: { points: null, inventory: facilityInventory(archivesData, null), archivesAvailable: archivesData !== null, detailFailures: 0 },
+        })
+    }
+
+    const rows = facilityRowsFromOperating(operating.data)
+    const details = await loadFacilityDetails(facilityDetailRequests(rows), requestOptions)
+    const points = facilityPointsWithDetails(rows, details)
+    const detailFailures = Object.values(details).filter((result) => !result.ok).length
+    return section(SECTION_STATE.SUCCESS, {
+        mode: operating.mode || '',
+        data: {
+            points,
+            inventory: facilityInventory(archivesData, points),
+            archivesAvailable: archivesData !== null,
+            detailFailures,
+        },
+    })
+}

+ 241 - 23
src/modules/cockpit/services/mapService.js

@@ -43,6 +43,8 @@ import {
 import { planDeviceAsset } from '../domain/deviceAssets.js'
 // 批次 3:位置符号合成计划层(规格 10 的唯一新接缝)与母版合成渲染。
 import { planMarker, planToDataset } from '../domain/markerPlan.js'
+// 票 101:管线构件点位标记行(符号/标签/行业色)由领域层单源给出,服务层不拼视觉。
+import { pipeComponentMarkers } from '../domain/pipeNetwork.js'
 // 票 82:标记几何单源(本体宽 / pad / 绘制宽),服务层不再持有尺寸字面量。
 import { MARKER_BODY_WIDTH, markerDevKnobs, markerMetricsFor, resetMarkerDevKnobs, setMarkerDevKnobs } from '../domain/markerMetrics.js'
 // 票 76(C3):强调量由服务层直接产出给 DOM —— 判据复用引擎同一份 domain 单源,
@@ -224,6 +226,16 @@ let markerSkipped = 0
 // 票 42:关联设备临时图层的最近一次计划与结果(与摘要标记**分开**计数,互不污染)。
 let deviceRows = []
 
+// 票 68:设施点(水厂/泵站/水源地/用水户)的最近一次计划行。与摘要标记/关联设备**分开**持有:
+// 设施点不进摘要序列、不进设备图例,也不参与设备的动效/重叠判定。
+let facilityMarkerRows = []
+let selectedFacilityKey = ''
+let facilitySelectionIdentity = null
+
+// 票 101:管线构件点位(阀门/窨井/特殊管点等)的最近一次标记行。同样独立持有:
+// 构件点击走**管线拾取链**(局部增强),不进设施详情,也不进设备/设施图例。
+let pipeComponentRows = []
+
 /**
  * DOM overlay 每帧取数的复用槽(票 35 卡顿整改)。
  *
@@ -240,11 +252,43 @@ const overlayFrameCache = {
     // 票 85:跨图层合计门控与收敛系数(降级时环仍画,只是更小更淡)。
     combinedRef: null, converge: null,
     // 静态帧闸门:镜头位姿/宿主矩形/输入计划都没变时,66ms 内的重复帧直接跳过。
-    pose: '', rectKey: '', at: 0, lastPlan: null, lastRows: null, lastEffects: null,
+    pose: '', rectKey: '', at: 0, lastPlan: null, lastRows: null, lastFacilityRows: null, lastComponentRows: null, lastViewMode: '', lastEffects: null,
     lastDeviceEffects: null, lastCombined: null,
 }
 const overlayPlanMarkerCache = new Map()
 
+// 票 68:设施点合成计划的记忆化包装(离散组合只有 type|status|tone 三类,数量极小)。
+const overlayFacilityPlanCache = new Map()
+
+// 票 101:构件点合成计划(离散组合只有 componentKind|industry 两类)。
+const overlayComponentPlanCache = new Map()
+
+function overlayComponentPlanMarkerCached(marker) {
+    const key = `${marker.componentKind || ''}|${marker.industry || ''}`
+    let plan = overlayComponentPlanCache.get(key)
+    if (!plan) {
+        plan = planMarker({ kind: 'pipe-component', componentKind: marker.componentKind, industry: marker.industry })
+        if (overlayComponentPlanCache.size > 64) overlayComponentPlanCache.clear()
+        overlayComponentPlanCache.set(key, plan)
+    }
+    return plan
+}
+
+/**
+ * 设施点合成计划(票 68):走 `planMarker` 的新 kind,**不复用** `overlayPlanMarkerCached` ——
+ * 后者把「非监测点」强转成监测设备,设施点会丢设施类型图标并被降成设备档。
+ */
+function overlayFacilityPlanMarkerCached(marker) {
+    const key = `${marker.type || ''}|${marker.status || ''}|${marker.tone || ''}`
+    let plan = overlayFacilityPlanCache.get(key)
+    if (!plan) {
+        plan = planMarker({ kind: 'facility-point', status: marker.status, facilityType: marker.type, tone: marker.tone })
+        if (overlayFacilityPlanCache.size > 64) overlayFacilityPlanCache.clear()
+        overlayFacilityPlanCache.set(key, plan)
+    }
+    return plan
+}
+
 /** `planMarker` 的记忆化包装:输入是少量离散组合(kind|status|industry|tone|unassociated)。 */
 function overlayPlanMarkerCached(marker) {
     const kind = marker.kind === 'monitoring-point' ? 'monitoring-point' : 'monitoring-device'
@@ -299,15 +343,23 @@ export function getMapCanvasRect() {
     return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }
 }
 
-export function hasMapMarker(kind, code, pointCode = '') {
+export function hasMapMarker(kind, code, pointCode = '', key = '') {
     if (!instance || !instance.engine || !instance.viewer) return false
+    if (kind === 'facility-point') return Boolean(facilityMarkerFor(code, key))
     return kind === DEVICE_LAYER_KIND
         ? instance.engine.hasDeviceMarker(instance.viewer, code, pointCode)
         : instance.engine.hasPointMarker(instance.viewer, kind, code)
 }
 
-export function getMapMarkerAnchor(kind, code, pointCode = '') {
+export function getMapMarkerAnchor(kind, code, pointCode = '', key = '') {
     if (!instance || !instance.engine || !instance.viewer || !code) return null
+    if (kind === 'facility-point') {
+        const marker = facilityMarkerFor(code, key)
+        const at = marker && instance.projectMapPoint?.(marker.longitude, marker.latitude)
+        if (!at) return null
+        const plan = overlayFacilityPlanMarkerCached(marker)
+        return { ...at, iconSize: Math.max(plan.draw.width, plan.draw.height), panelGap: 12 }
+    }
     const anchor = kind === DEVICE_LAYER_KIND
         ? instance.engine.projectDeviceMarker(instance.viewer, code, pointCode)
         : instance.engine.projectPointMarker(instance.viewer, kind, code)
@@ -348,8 +400,11 @@ export function cancelPointLocate() {
  *   · `TERRAIN` / `CAMERA` —— 地形采样取不到可靠高程,或该视角构造不出「相机高于地表」,
  *     由 `planTerrainLocateTarget` 纯函数守卫裁决(不再用椭球 0 或旧高度伪造成功)。
  * 迟到采样/实例被换走时作废,不覆盖新目标(`isStaleLocate` 的竞态纪律)。
+ *
+ * 票 101:抽成 `runTerrainLocate(共享管线)`,准入判据由调用方给 ——
+ * 点位/设备只认后端 `renderable`(票 07),设施点按票 68 口径「有坐标即可上图」。
  */
-export async function locateMonitoringPoint(object, { tier = 'point', centerOnly = false, onFailure = null } = {}) {
+async function runTerrainLocate(object, { tier = 'point', centerOnly = false, onFailure = null, isCurrent = null } = {}, admitted) {
     const token = ++pointLocateToken
     const record = instance
     const fail = (reason) => {
@@ -357,14 +412,15 @@ export async function locateMonitoringPoint(object, { tier = 'point', centerOnly
         return false
     }
     if (!record || !record.cameraControl || !record.engine || !record.viewer) return fail(TERRAIN_LOCATE_REASON.MAP)
-    if (!isRenderable(object) || !Number.isFinite(object.longitude) || !Number.isFinite(object.latitude)) {
+    if (!admitted(object)) {
         return fail(TERRAIN_LOCATE_REASON.COORDINATE)
     }
 
     let height = await record.engine.sampleTerrainHeight(record.viewer, object.longitude, object.latitude)
-    if (!Number.isFinite(height)) height = record.engine.groundHeightAt(record.viewer, object.longitude, object.latitude)
-    // 被更新的相机指令顶掉 / 实例已销毁:作废这次定位,归「地图未就绪」口径(与旧行为同文案)。
+    // 在读取旧 Viewer 或提交相机副作用之前,确认原选择与实例仍然有效。
     if (isStaleLocate(token, pointLocateToken) || instance !== record) return fail(TERRAIN_LOCATE_REASON.MAP)
+    if (isCurrent && !isCurrent()) return false
+    if (!Number.isFinite(height)) height = record.engine.groundHeightAt(record.viewer, object.longitude, object.latitude)
     const view = planLocateView(getCockpitConfig().map, tier)
     // Terrain is required for point/device locating. Height 0 is never a success fallback.
     const targetPlan = planTerrainLocateTarget(object, height, view)
@@ -378,6 +434,26 @@ export async function locateMonitoringPoint(object, { tier = 'point', centerOnly
     return applied
 }
 
+/**
+ * 点位/设备定位:准入只认后端 `renderable`(票 07 的唯一判据,不由前端重算范围)。
+ */
+export async function locateMonitoringPoint(object, options = {}) {
+    return runTerrainLocate(object, options, (item) =>
+        isRenderable(item) && Number.isFinite(item.longitude) && Number.isFinite(item.latitude))
+}
+
+/**
+ * 票 101:设施点的定位跳转 —— 与点位/设备**同一条**地形感知飞行管线(同一份
+ * 地形采样 / 视角计划 / 相机指令 / 竞态令牌),唯一差别是准入判据:
+ * 设施坐标来自设施档案(票 68 的既定口径「有坐标即可上图」,档案坐标不经
+ * 几何图层那套 `coordinateValidation`),所以这里只要求经纬度是有限数字。
+ * @param {object} row 设施点标记行(`facilityPointMarkers()` 的输出,带 longitude/latitude)
+ */
+export async function locateFacilityPoint(row, options = {}) {
+    return runTerrainLocate(row, options, (item) =>
+        Number.isFinite(item && item.longitude) && Number.isFinite(item.latitude))
+}
+
 /** 只有真元素才做 DOM / 引擎操作;单测传普通对象时整条引擎链路都不启动。 */
 function isDomHost(host) {
     return !!host && typeof host === 'object' && host.nodeType === 1 && typeof host.appendChild === 'function'
@@ -919,6 +995,11 @@ async function bootEngine(record) {
 
         // 票 03:相机契约落在服务层 —— 引擎就绪后才挂,默认输入已在 createViewer 里全关。
         const cameraEngine = engine.createCameraEngine(viewer)
+        // 设施锚点和 DOM 标记同帧共用地形投影,不读上一帧节点位置。
+        record.projectMapPoint = (lon, lat) => {
+            const ground = engine.groundHeightAt(viewer, lon, lat)
+            return cameraEngine.projectPivot({ lon, lat, height: Number.isFinite(ground) ? ground : 0 }, { visibleOnly: true })
+        }
         record.cameraControl = createCameraControl({
             engine: cameraEngine,
             onStateChange: (state) => {
@@ -1037,11 +1118,11 @@ async function bootEngine(record) {
         // 也不再有「关掉 DOM 露出 billboard」的第二态(那种态下点选只能靠 GPU 命中盒)。
         if (mountMarkerOverlay(record.host, {
             // 票 76(C6):hover 身份由 overlay 按**节点身份**回传(DOM 是唯一命中盒)。
-            onHover: ({ kind, code }) => setDomHoverTarget(kind, code),
+            onHover: hit => setDomHoverTarget(hit?.kind || '', hit?.code || ''),
             // 票 76(C6):点选**唯一**入口 —— 节点身份 → `pickMarkerByIdentity` → `dispatchPickResult`。
             // GPU 拾取兜底已退役:billboard 不再渲染,命中盒与视觉同一份几何(DOM 节点),
             // 不存在「DOM 认不出、坐标能拾到」的第三种情况。
-            onPick: ({ kind, code }) => pickMarkerByIdentity(kind, code),
+            onPick: ({ kind, code, key }) => pickMarkerByIdentity(kind, code, key),
         })) {
             record.detachOverlay = attachOverlayRenderer(instance.viewer, () => {
                 if (!markerPlan || !instance || !instance.viewer) return null
@@ -1068,6 +1149,10 @@ async function bootEngine(record) {
                 const rectKey = overlayFrameCache.rectKey || `${hostRect.left},${hostRect.top},${hostRect.width},${hostRect.height}`
                 const inputsChanged = overlayFrameCache.lastPlan !== markerPlan
                     || overlayFrameCache.lastRows !== deviceRows
+                    || overlayFrameCache.lastFacilityRows !== facilityMarkerRows
+                    || overlayFrameCache.lastFacilitySelection !== selectedFacilityKey
+                    || overlayFrameCache.lastComponentRows !== pipeComponentRows
+                    || overlayFrameCache.lastViewMode !== viewMode
                     || overlayFrameCache.lastEffects !== markerEffectsPlan
                     || overlayFrameCache.lastDeviceEffects !== deviceEffectsPlan
                     || overlayFrameCache.lastCombined !== combinedEffectState
@@ -1083,6 +1168,10 @@ async function bootEngine(record) {
                 overlayFrameCache.at = nowMs
                 overlayFrameCache.lastPlan = markerPlan
                 overlayFrameCache.lastRows = deviceRows
+                overlayFrameCache.lastFacilityRows = facilityMarkerRows
+                overlayFrameCache.lastFacilitySelection = selectedFacilityKey
+                overlayFrameCache.lastComponentRows = pipeComponentRows
+                overlayFrameCache.lastViewMode = viewMode
                 overlayFrameCache.lastEffects = markerEffectsPlan
                 overlayFrameCache.lastDeviceEffects = deviceEffectsPlan
                 overlayFrameCache.lastCombined = combinedEffectState
@@ -1110,6 +1199,14 @@ async function bootEngine(record) {
                 }
                 return {
                     markers: markerPlan.markers,
+                    // 票 68:设施点计划行(DOM overlay 第四条循环消费;计划/引用一变就重建帧状态)。
+                    facilities: facilityMarkerRows,
+                    facilityPlanOf: overlayFacilityPlanMarkerCached,
+                    selectedFacilityKey,
+                    // 票 101:管线构件点位行 + 地面视图可见性(管线视图交给 3D 模型/Cesium 兜底点)。
+                    pipeComponents: pipeComponentRows,
+                    pipeComponentsVisible: viewMode === 'ground',
+                    componentPlanOf: overlayComponentPlanMarkerCached,
                     // 票 76(C1):重叠聚合符号每帧投影进 DOM(票 47 遗留:聚合此前只活在 billboard)。
                     // 只有 group 身份/计数/组内状态词,没有设备身份 —— 聚合符号不冒充设备。
                     aggregates: instance.engine.projectOverlapSymbols(instance.viewer),
@@ -1144,8 +1241,7 @@ async function bootEngine(record) {
                     // 首版按椭球 0 米投影,地形区里 DOM 标记恒落在 billboard 标记的**地面以下**。
                     // 高度取值与 `projectMarkerEntity` 一致:地形瓦片实测高程,取不到回 0(椭球面)。
                     projectToWindow: (lon, lat) => {
-                        const ground = engine.groundHeightAt(instance.viewer, lon, lat)
-                        const at = cameraEngine.projectPivot({ lon, lat, height: Number.isFinite(ground) ? ground : 0 })
+                        const at = record.projectMapPoint(lon, lat)
                         if (!at) return null
                         // projectPivot 回**窗口坐标**;overlay 宿主 inset:0 铺在 host 内,
                         // 节点坐标原点 = host 左上角 → 减 host 的窗口原点(每帧只读一次布局)。
@@ -1173,6 +1269,10 @@ async function bootEngine(record) {
         }
 
         setEngineState(record, 'ready')
+        // 管线在引擎启动前收到的计划必须重放;不能等待一次偶然的摘要变化。
+        if (pendingPipeLayerPlan) void syncPipeLayerForPlan(pendingPipeLayerPlan).catch(() => {
+            if (instance === record && record.host?.dataset) record.host.dataset.mapPipeSync = 'failed'
+        })
         publishDevHook()
     } catch (error) {
         if (instance !== record) return
@@ -1242,12 +1342,22 @@ export function resizeMap() {
 
 /** 只在离开 /cockpit 时销毁。 */
 export function unmountMap() {
+    // 数据可能早于延迟挂载到达:没有 Viewer 也必须清掉旧路由的计划和选择。
+    cancelPointLocate()
+    pendingPipeLayerPlan = null
+    lastPipeLayerRefs = { pipes: null, components: null, viewMode: null, viewer: null, overlayActive: null }
+    facilityMarkerRows = []
+    selectedFacilityKey = ''
+    facilitySelectionIdentity = null
+    pipeComponentRows = []
+    pipeSyncDeferred = false
+    pipeSyncDeferredPlan = null
     if (!instance) {
+        unmountMarkerOverlay()
         return
     }
 
     const record = instance
-    cancelPointLocate()
     instance = null
     destroyedCount += 1
 
@@ -2800,7 +2910,8 @@ export function invalidateToneColors() {
 // 合并语义:同一类落地任务只保留最后一次(连续翻页/多次摘要到达不会叠加执行)。
 
 /** 管线图层最近一次落地的计划引用(同引用短路用)与延迟重试状态。 */
-let lastPipeLayerRefs = { pipes: null, components: null, viewMode: null }
+let lastPipeLayerRefs = { pipes: null, components: null, viewMode: null, viewer: null, overlayActive: null }
+let pendingPipeLayerPlan = null
 let pipeSyncDeferred = false
 let pipeSyncDeferredAt = 0
 let pipeSyncDeferredPlan = null
@@ -3011,28 +3122,45 @@ export function syncPointMarkersForPlan(plan, legendSource) {
  * @returns {Promise<boolean>} 是否已画到地图上
  */
 export async function syncPipeLayerForPlan(plan) {
+    pendingPipeLayerPlan = plan
     const layer = {
         pipes: plan && Array.isArray(plan.pipelines) ? plan.pipelines : [],
         components: plan && Array.isArray(plan.facilities) ? plan.facilities : [],
     }
 
+    // 半启动 Viewer 也能先画兜底;DOM 挂载后的同数组计划必须重新发布接管状态。
+    const overlayActive = billboardVisualHidden()
+
+    // 移动期只保留一个最新计划。B 到达后不能继续排队 A,再由旧定时器回画 A。
+    if (pipeSyncDeferred) {
+        pipeComponentRows = pipeComponentMarkers(layer.components)
+        pipeSyncDeferredPlan = plan
+        return false
+    }
+
     // 票 35 第六轮(基础设施模式反而更卡):
     //   1) **同引用短路**:计划层对同一批数据是原样引用的(见 modeLayerPlan 注释),
     //      30s freshness tick 会让 watcher 再触发一次;引用没变、视图没变就不必重新指纹比较
     //      几百条管段(每条一次 JSON.stringify)。基础设施模式管子最多,这一步最值钱。
     //   2) **落地让路**:镜头在动时把这次同步排到落定后(最多 700ms),别和移动帧抢主线程。
     if (layer.pipes === lastPipeLayerRefs.pipes && layer.components === lastPipeLayerRefs.components
-        && lastPipeLayerRefs.viewMode === viewMode) {
+        && lastPipeLayerRefs.viewMode === viewMode && instance?.viewer
+        && lastPipeLayerRefs.viewer === instance.viewer
+        && lastPipeLayerRefs.overlayActive === overlayActive) {
         return true
     }
-    lastPipeLayerRefs = { pipes: layer.pipes, components: layer.components, viewMode }
+    // 票 101:构件在地面视图升级为「点位标记」(DOM overlay)。行由领域层给出
+    // (符号/标签/行业色),这里只随图层计划同步;管线视图的 3D 模型不受影响。
+    pipeComponentRows = pipeComponentMarkers(layer.components)
     if (cameraInMotion() && !pipeSyncDeferred) {
         // 排队到镜头落定:保留最新一次计划,重试期间不被短路挡住。
+        const owner = instance
         pipeSyncDeferred = true
         pipeSyncDeferredAt = engineNow()
         pipeSyncDeferredPlan = plan
         lastPipeLayerRefs = { pipes: null, components: null, viewMode: null }
         const retry = () => {
+            if (instance !== owner) return
             if (!instance || !instance.viewer) { pipeSyncDeferred = false; return }
             if (cameraInMotion() && engineNow() - pipeSyncDeferredAt < SCENE_APPLY_MAX_DEFER_MS) {
                 setTimeout(retry, 120)
@@ -3041,19 +3169,22 @@ export async function syncPipeLayerForPlan(plan) {
             pipeSyncDeferred = false
             const queued = pipeSyncDeferredPlan
             pipeSyncDeferredPlan = null
-            if (queued) void syncPipeLayerForPlan(queued)
+            if (queued && queued === pendingPipeLayerPlan) void syncPipeLayerForPlan(queued)
         }
         setTimeout(retry, 120)
-        return true
+        return false
     }
     if (!instance || !instance.viewer) return false
 
     const record = instance
+    const appliedViewMode = viewMode
     const engine = await import('./cesiumPipeLayer.js')
     if (instance !== record || !record.viewer) return false
     record.pipeLayer = engine
     engine.setPipeViewMode(record.viewer, viewMode)
-    await engine.syncPipeLayer(record.viewer, layer, { onModelChange: (state) => {
+    const result = await engine.syncPipeLayer(record.viewer, layer, {
+        componentOverlayActive: billboardVisualHidden,
+        onModelChange: (state) => {
         if (instance !== record || !record.host) return
         const host = record.host
         if (host.dataset) {
@@ -3067,7 +3198,8 @@ export async function syncPipeLayerForPlan(plan) {
         }
         host.dispatchEvent(new CustomEvent('cockpit-pipe-model-state', { detail: state }))
     } })
-    if (instance !== record) return false
+    if (instance !== record || pendingPipeLayerPlan !== plan || result.rendered !== true) return false
+    lastPipeLayerRefs = { pipes: layer.pipes, components: layer.components, viewMode: appliedViewMode, viewer: record.viewer, overlayActive }
     const host = record.host
     if (host && host.dataset) {
         host.dataset.mapPipes = String(layer.pipes.length)
@@ -3076,6 +3208,54 @@ export async function syncPipeLayerForPlan(plan) {
     return true
 }
 
+/**
+ * 同步**设施点图层**(票 68)。
+ *
+ * 与摘要标记/关联设备分开是结构性的:设施点不是监测点、也不是设备,不进摘要序列、
+ * 不进设备图例与动效/重叠判定。渲染由 DOM overlay 的设施点循环承担;本函数只持有本轮
+ * 计划行并写宿主读数(计数口径与图例的「可上图 N」同源,都是本轮可画的行数)。
+ *
+ * `rows` 已由 store 按设施类型 + 全局行业/片区筛好,计划层只按一级模式显隐,这里不再过滤。
+ *
+ * @param {Array} rows 设施点标记行(`domain/facilityPoints.js` 的 `facilityPointMarkers()` 输出)
+ * @returns {number} 本轮交给渲染层的设施点数
+ */
+export function syncFacilityMarkersForPlan(rows) {
+    facilityMarkerRows = Array.isArray(rows) ? rows : []
+    // watcher 可能早于合并后的 marker 同步;以新行重算可见选择,避免保留已筛掉的高亮。
+    setFacilitySelection(facilitySelectionIdentity)
+    const host = instance && isDomHost(instance.host) ? instance.host : null
+    if (host && host.dataset) {
+        host.dataset.mapFacilityPoints = String(facilityMarkerRows.length)
+        const byType = {}
+        for (const row of facilityMarkerRows) {
+            if (!row || !row.type) continue
+            byType[row.type] = (byType[row.type] || 0) + 1
+        }
+        host.dataset.mapFacilityTypes = Object.entries(byType).map(([type, count]) => `${type}:${count}`).join(',')
+    }
+    return facilityMarkerRows.length
+}
+
+/** 设施选中视觉只镜像实体详情 store 的身份;筛掉或关详情就清空,不自建第二套业务选择。 */
+export function setFacilitySelection(identity) {
+    facilitySelectionIdentity = identity || null
+    const row = identity?.kind === 'facility' ? facilityMarkerRows.find(item =>
+        item.entityId === identity.id && item.detailCategory === identity.category) : null
+    selectedFacilityKey = row?.key || ''
+    if (instance?.host?.dataset) instance.host.dataset.mapFacilityPicked = selectedFacilityKey
+}
+
+function facilityMarkerFor(code, key = '') {
+    const matches = facilityMarkerRows.filter(item => item.code === code && (!key || item.key === key))
+    return matches.length === 1 ? matches[0] : null
+}
+
+/** 本轮设施点计划行(走查/单测读;与宿主 `data-map-facility-*` 同源)。 */
+export function getFacilityMarkerRows() {
+    return facilityMarkerRows
+}
+
 /**
  * 同步**关联监测设备临时图层**(票 42)。
  *
@@ -3714,7 +3894,7 @@ export function pickDeviceOrPointAt(clientX, clientY) {
     // 保留本入口是因为 DEV 走查仍按「屏幕坐标」驱动同一条用户链路;实现里不再有 Cesium 拾取。
     const target = domMarkerAt(clientX, clientY)
     if (!target || !target.kind) return dispatchPickResult({ target: 'none', pick: null })
-    return pickMarkerByIdentity(target.kind, target.code)
+    return pickMarkerByIdentity(target.kind, target.code, target.key)
 }
 
 /**
@@ -3776,8 +3956,46 @@ function dispatchPickResult(resolved) {
  * @param {string} code 节点上的 `data-marker-overlay-code`
  * @returns {{target:string, pick:object|null}}
  */
-export function pickMarkerByIdentity(kind, code) {
+export function pickMarkerByIdentity(kind, code, key = '') {
     if (kind === 'overlap-aggregate') return toggleOverlap(code)
+    // 票 101:设施点点击 —— 与独立设备**同一条派发链**(target 'point' → paintPicked +
+    // pointPickHandlers),由订阅方(MapCanvas)打开设施详情并做镜头定位;
+    // 详情与「设施台账」卡片点击同源(entityDetailStore + entity-detail 契约)。
+    // 缺主键/类别的行不可点(领域层 `clickable` 已判),这里再兜一层不把脏身份发出去。
+    if (kind === 'facility-point') {
+        // 节点 key 包含类别+主键;旧的 code-only 入口仅接受唯一匹配,不能猜第一条。
+        const row = facilityMarkerFor(code, key)
+        if (!row || !row.clickable) return { target: 'none', pick: null }
+        return dispatchPickResult({ target: 'point', pick: {
+            kind: 'facility-point',
+            key: row.key,
+            code: row.code,
+            name: row.name,
+            status: row.status,
+            id: row.entityId,
+            category: row.detailCategory,
+            industry: row.industry,
+            facilityType: row.type,
+            longitude: row.longitude,
+            latitude: row.latitude,
+        } })
+    }
+    // 票 101:管线构件点位点击 —— 与点构件实体/3D 模型同一条**管线拾取链**(局部增强),
+    // 不进设施详情。pick 形状与引擎 `pickPipeObjectAt` 的构件命中一致(objectKind:'component')。
+    if (kind === 'pipe-component') {
+        const row = (pipeComponentRows || []).find((item) => item.code === code) || null
+        if (!row) return { target: 'none', pick: null }
+        const pick = {
+            kind: 'pipeline',
+            code: row.code,
+            objectKind: 'component',
+            name: row.name,
+            componentKind: row.componentKind,
+            industry: row.industry,
+        }
+        for (const handler of [...pipelinePickHandlers]) handler(pick)
+        return { target: 'pipeline', pick }
+    }
     if (!code) return { target: 'none', pick: null }
     if (kind === 'associated-device') {
         // 票 76(四态规格 2026-10-26 修)点击设备点:**始终走同一条派发链**。
@@ -3913,8 +4131,8 @@ function domMarkerAt(clientX, clientY) {
     if (!doc || typeof doc.elementFromPoint !== 'function') return null
     const element = doc.elementFromPoint(clientX, clientY)
     const node = element && element.closest ? element.closest('.marker-overlay-node') : null
-    if (!node || !node.dataset) return null
-    return { kind: node.dataset.markerOverlayKind || '', code: node.dataset.markerOverlayCode || '' }
+    if (!node || !node.dataset || !instance.host.contains(node)) return null
+    return { kind: node.dataset.markerOverlayKind || '', code: node.dataset.markerOverlayCode || '', key: node.dataset.markerOverlayKey || '' }
 }
 
 /** DOM hover 身份 → 与点选同形状的设备命中行(只认关联设备;点位有 DOM tooltip)。 */

+ 98 - 1
src/modules/cockpit/services/markerOverlay.js

@@ -559,6 +559,17 @@ function buildNode(key, marker, plan, toneHex, fx) {
     // currentColor 会落成白色(第一层原因)。
     root.style.setProperty('--marker-tone', toneHex)
 
+    // 票 68/101:设施点与点位/设备同一套指针语义(可点,宿主 click 派发节点身份)。
+    // 类型写在节点上,走查按 `data-facility-type` 断言四类符号而不是读渲染内部;
+    // 不可点身份(缺主键/类别)由 `marker.clickable === false` 关掉指针(不伪装可点)。
+    if (marker.kind === 'facility-point') {
+        if (marker.clickable === false) {
+            root.style.pointerEvents = 'none'
+            root.style.cursor = 'default'
+        }
+        root.dataset.facilityType = marker.type || ''
+    }
+
     // 特效层在标记本体之下(先挂 fx、SVG 后画盖住环心,环带在图标外扩散可见)。
     // 锚点是节点原点即尖端落地点(buildFxLayers 的 -box/2 偏移保证中心=原点)。
     const fxHost = fx ? buildFxLayers(fx, plan, toneHex, key) : null
@@ -695,6 +706,7 @@ export function mountMarkerOverlay(mapHost, { onPick, onHover } = {}) {
                 y: event.clientY,
                 kind: node.dataset.markerOverlayKind,
                 code: node.dataset.markerOverlayCode,
+                key: node.dataset.markerOverlayKey,
             })
         })
         // 标记节点上的按下/抬起不再冒泡到宿主:宿主的 attachPointPick 会对同一次点击**再拾一遍**
@@ -859,7 +871,7 @@ export function markerLabelState(hovered) {
     return { visible: true, expanded: hovered === true }
 }
 
-export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, tones, planOf, effectOf, deviceEffectOf, converge, selectedCode, projectToWindow }) {
+export function syncMarkerOverlay({ markers, devices, aggregates, facilities, selectedFacilityKey, pipeComponents, pipeComponentsVisible, pointScales, tones, planOf, facilityPlanOf, componentPlanOf, effectOf, deviceEffectOf, converge, selectedCode, projectToWindow }) {
     if (!hostNode || !hostNode.isConnected || typeof projectToWindow !== 'function') return
     // 每帧刷新门控查询(mapService 在计划变化时给新闭包;这里只搬运、不判定)。
     frameEffectOf = typeof effectOf === 'function' ? effectOf : () => ''
@@ -1084,6 +1096,91 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
         record.root.classList.toggle(`${NODE_CLASS}--replaced`, row.replaced === true)
     }
 
+    // 设施点与点位/设备共用节点池,点击派发完整的节点 key;不可点身份不挂指针交互。
+    // 计划行已由 store 按设施类型 + 全局行业/片区筛好;这里只按一级模式显隐(计划层收口)。
+    for (const facility of facilities || []) {
+        const key = facility.key || `facility-point|${facility.code}`
+        wanted.add(key)
+        if (!Number.isFinite(facility.longitude) || !Number.isFinite(facility.latitude)) { hidePooledNode(key); continue }
+        const windowPos = projectToWindow(facility.longitude, facility.latitude)
+        if (!windowPos || !Number.isFinite(windowPos.x) || !Number.isFinite(windowPos.y)) { hidePooledNode(key); continue }
+
+        const marker = { ...facility, kind: 'facility-point' }
+        const plan = facilityPlanOf(marker)
+        const toneHex = tones[plan.tint] || tones.neutral
+        // 指纹含设施类型/名称/状态与合成计划读数:任一变化就重建节点(与点位标记同一条纪律)。
+        const fp = [marker.type, marker.status, marker.name, marker.clickable,
+            plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio, toneHex].join(FP_SEP)
+
+        let record = pool.get(key)
+        if (!record || record.fp !== fp) {
+            destroyRecord(record)
+            record = { ...buildNode(key, marker, plan, toneHex, null), fp }
+            pool.set(key, record)
+            hostNode.appendChild(record.root)
+            if (plan.inner.enabled) {
+                const mine = record
+                renderInnerIconUrl(plan.inner.icon)
+                    .then((dataUrl) => {
+                        if (pool.get(key) === mine) mine.inner.setAttribute('href', dataUrl)
+                    })
+                    .catch(() => {
+                        // 内层素材失败:只剩母版水滴(降级可见),不阻塞其它设施点。
+                    })
+            }
+        }
+        const transform = `translate(${windowPos.x}px, ${windowPos.y}px)`
+        if (record.root.style.transform !== transform) record.root.style.transform = transform
+        if (record.root.style.display !== 'block') record.root.style.display = 'block'
+        placeLabel(record, windowPos.x, windowPos.y)
+        const facilityLabel = markerLabelState(hoveredLabelKey === key)
+        applyLabelState(record, facilityLabel.visible, facilityLabel.expanded)
+        record.root.classList.toggle(`${NODE_CLASS}--active`, selectedFacilityKey === key)
+    }
+
+    // 管线构件点位(票 101):阀门/窨井/特殊管点等。地面视图用 DOM 点位标记
+    // (与设备/设施同一套节点池与合成计划,行业色由领域层随行下发);管线视图的
+    // 3D 模型 / Cesium 兜底点由管线图层自己管(`pipeComponentsVisible` 关掉本循环)。
+    // 点击派发回**管线拾取链**(局部增强),不进设施详情 —— 构件不是设施实体。
+    for (const component of pipeComponentsVisible === false ? [] : (pipeComponents || [])) {
+        const key = component.key || `pipe-component|${component.code}`
+        wanted.add(key)
+        if (!Number.isFinite(component.longitude) || !Number.isFinite(component.latitude)) { hidePooledNode(key); continue }
+        const windowPos = projectToWindow(component.longitude, component.latitude)
+        if (!windowPos || !Number.isFinite(windowPos.x) || !Number.isFinite(windowPos.y)) { hidePooledNode(key); continue }
+
+        const marker = { ...component, kind: 'pipe-component' }
+        const plan = componentPlanOf(marker)
+        // 行业色随行下发(`toneHex`,与管线图层同一份 PIPE_PALETTE);缺行色回退中性。
+        const toneHex = marker.toneHex || tones[plan.tint] || tones.neutral
+        const fp = [marker.componentKind, marker.industry, marker.name,
+            plan.master, plan.inner.enabled, plan.inner.icon, toneHex].join(FP_SEP)
+
+        let record = pool.get(key)
+        if (!record || record.fp !== fp) {
+            destroyRecord(record)
+            record = { ...buildNode(key, marker, plan, toneHex, null), fp }
+            pool.set(key, record)
+            hostNode.appendChild(record.root)
+            if (plan.inner.enabled) {
+                const mine = record
+                renderInnerIconUrl(plan.inner.icon)
+                    .then((dataUrl) => {
+                        if (pool.get(key) === mine) mine.inner.setAttribute('href', dataUrl)
+                    })
+                    .catch(() => {
+                        // 内层素材失败:只剩母版水滴(降级可见),不阻塞其它构件点。
+                    })
+            }
+        }
+        const transform = `translate(${windowPos.x}px, ${windowPos.y}px)`
+        if (record.root.style.transform !== transform) record.root.style.transform = transform
+        if (record.root.style.display !== 'block') record.root.style.display = 'block'
+        placeLabel(record, windowPos.x, windowPos.y)
+        const componentLabel = markerLabelState(hoveredLabelKey === key)
+        applyLabelState(record, componentLabel.visible, componentLabel.expanded)
+    }
+
     // 本轮不在计划里的:隐藏(池复用,不销毁)。
     // ⚠️ 这一轮**必须**在所有计划遍历之后:放进某个循环里会漏掉「点位/设备被筛掉」的节点,
     // 它们会停在最后一次投影位置变成幽灵标记(2026-10-27 实测缺陷,勿再合并进循环)。

+ 14 - 3
src/modules/cockpit/stores/entityDetailStore.js

@@ -15,6 +15,7 @@ export const useEntityDetailStore = defineStore('entityDetailStore', () => {
     let token = 0
     let requestOptions = {}
     let listPrefill = null
+    let pendingLoad = null
 
     const hasTarget = computed(() => !!identity.value && identity.value.valid)
 
@@ -31,15 +32,24 @@ export const useEntityDetailStore = defineStore('entityDetailStore', () => {
         listPrefill = prefill && typeof prefill === 'object' ? { ...prefill } : null
         open.value = true
         error.value = ''
-        if (prefill) detail.value = prefilledEntityDetail(next, prefill)
+        // 同实体缓存命中时保留已取到的完整详情,不能先用摘要覆盖再跳过取数。
+        if (prefill && (!same || !loaded.value)) detail.value = prefilledEntityDetail(next, prefill)
         else if (!same) detail.value = null
-        if (same && loading.value) return true
+        if (same && loading.value) {
+            await pendingLoad
+            return true
+        }
         if (same && loaded.value && !options.force) return true
         await load(prefill, options)
         return true
     }
 
-    async function load(prefill = null, options = {}) {
+    function load(prefill = null, options = {}) {
+        pendingLoad = loadDetail(prefill, options)
+        return pendingLoad
+    }
+
+    async function loadDetail(prefill = null, options = {}) {
         const target = identity.value
         if (!target || !target.valid) return null
         token += 1
@@ -75,6 +85,7 @@ export const useEntityDetailStore = defineStore('entityDetailStore', () => {
         loaded.value = false
         error.value = ''
         listPrefill = null
+        pendingLoad = null
     }
 
     function retry() { return load(listPrefill, { force: true }) }

+ 141 - 0
src/modules/cockpit/stores/facilityPointStore.js

@@ -0,0 +1,141 @@
+/**
+ * 设施点图层状态(票 68)。
+ *
+ * 与管线/构件图层(`pipeNetworkStore`)分开持有:两者是不同业务对象、不同读取路径、
+ * 不同统计口径(管线走上图几何端点,设施点走设施台账 + 实体详情补坐标)。
+ *
+ * 失败纪律沿用全舱:几何/台账取不到只让**设施点图层**降级(图例说「未取到」),
+ * 不牵连监测点、设备、管线与面板;也绝不回退 mock。
+ */
+import { computed, ref } from 'vue'
+import { defineStore, acceptHMRUpdate } from 'pinia'
+import { SECTION_STATE, section } from '../domain/sectionState.js'
+import { facilityInventory, filterFacilityPoints } from '../domain/facilityPoints.js'
+import { fetchFacilityPoints } from '../services/facilityPointService.js'
+import { useFilterStore } from './filterStore.js'
+import { useEntityDetailStore } from './entityDetailStore.js'
+import { usePipeNetworkStore } from './pipeNetworkStore.js'
+import { useDetailStore } from './detailStore.js'
+import { sameEntity } from '../domain/entityDetail.js'
+import { facilityIdentity, resolveFacilityPoint, mergeFacilitySelectionPoints } from '../domain/facilitySelection.js'
+import { locateFailureText } from '../domain/mapLocate.js'
+import { TERRAIN_LOCATE_REASON } from '../domain/pointLocate.js'
+import { cancelPointLocate, locateFacilityPoint } from '../services/mapService.js'
+
+/** 未取数时的分区(与其它 store 同形,`loaded:false` 表示"还没请求过")。 */
+function pending() {
+    return {
+        ...section(SECTION_STATE.EMPTY, {
+            data: { points: null, inventory: facilityInventory(null, null), archivesAvailable: false, detailFailures: 0 },
+        }),
+        loaded: false,
+    }
+}
+
+export const useFacilityPointStore = defineStore('facilityPointStore', () => {
+    // 设施筛选是**全局条件**(filterStore 持有唯一一份),本 store 只读不写。
+    const filterStore = useFilterStore()
+    const layer = ref(pending())
+    let requestOptions = {}
+    const entityStore = useEntityDetailStore()
+    const pipeStore = usePipeNetworkStore()
+    const locate = ref(null)
+    let selectToken = 0
+    const selectedPoint = computed(() => entityStore.open ? resolveFacilityPoint({
+        identity: entityStore.identity, points: points.value, detail: entityStore.detail,
+        network: pipeStore.layer.data,
+    }) : null)
+    const renderPoints = computed(() => mergeFacilitySelectionPoints(filtered.value, selectedPoint.value, {
+        types: filterStore.facilityTypes, industry: filterStore.industry, scope: filterStore.scope,
+    }))
+
+    function clearLocate() { locate.value = null }
+
+    /** Shared map-click / ledger locate entry; entity detail remains the only business selection. */
+    async function select(target, prefill = null, options = {}) {
+        const identity = facilityIdentity(target)
+        if (!identity.valid) return false
+        const mine = ++selectToken
+        cancelPointLocate()
+        useDetailStore().closeDetail()
+        locate.value = { result: 'ok', pointCode: '', reasonText: '' }
+        await entityStore.show(identity, prefill, options)
+        if (mine !== selectToken || !entityStore.open || !sameEntity(entityStore.identity, identity)) return false
+        const point = selectedPoint.value
+        const finish = (result, reasonText = '') => {
+            locate.value = { result, pointCode: point?.code || identity.id, reasonText }
+            return locate.value
+        }
+        if (!point || point.placement === 'pending') return finish('unavailable', point?.reasonText || '设施位置尚未取到,请稍后重试')
+        if (point.placement !== 'drawn') return finish('blocked', point.reasonText || '设施没有可定位的地图位置')
+        if (!(renderPoints.value || []).some(row => row.key === point.key)) return finish('missing', '设施不在当前筛选范围,请调整筛选后定位')
+        let failure = TERRAIN_LOCATE_REASON.MAP
+        let applied = false
+        try {
+            applied = await (options.fly || locateFacilityPoint)(point, {
+                tier: 'device', onFailure: reason => { failure = reason },
+                isCurrent: () => mine === selectToken && entityStore.open && sameEntity(entityStore.identity, identity)
+                    && (renderPoints.value || []).some(row => row.key === point.key),
+            })
+        } catch { applied = false }
+        if (mine !== selectToken || !entityStore.open || !sameEntity(entityStore.identity, identity)) return false
+        return finish(applied === true ? 'ok' : 'unavailable', applied === true ? '' : locateFailureText(failure))
+    }
+
+    /** 请求注入(config / fetchImpl):取一次让后续取数共用。 */
+    function configure(options = {}) {
+        requestOptions = { ...requestOptions, ...options }
+    }
+
+    /** 只读写入分区(单测 / 走查注入用)。 */
+    function setLayer(value) {
+        layer.value = { ...value, loaded: true }
+    }
+
+    const loaded = computed(() => layer.value.loaded === true)
+    const state = computed(() => layer.value.state)
+    const reason = computed(() => layer.value.reason)
+
+    /** 设施点(**未筛**;图层没取到 = null,不是空数组)。 */
+    const points = computed(() => {
+        const data = layer.value.data
+        return data && Array.isArray(data.points) ? data.points : null
+    })
+
+    /** 图例口径(档案计数 + 可上图数);未取到时不补 0(null 上屏成「—」)。 */
+    const inventory = computed(() => {
+        const data = layer.value.data
+        return data && Array.isArray(data.inventory) ? data.inventory : facilityInventory(null, null)
+    })
+
+    /**
+     * 当前筛选下的设施点:设施类型(本票的独立维度)+ 全局行业/片区(与管线图层同一条纪律)。
+     * **只裁设施点**;点位/设备状态筛选是另一个维度,两类对象不重叠、互不改写。
+     */
+    const filtered = computed(() => (points.value
+        ? filterFacilityPoints(points.value, filterStore.facilityTypes, {
+            industry: filterStore.industry,
+            scope: filterStore.scope,
+        })
+        : null))
+
+    async function load(options = {}) {
+        if (Object.keys(options).length) configure(options)
+        let result
+        try {
+            result = await fetchFacilityPoints(requestOptions)
+        } catch {
+            // 适配层不抛;真抛了也要收成失败分区,不能让一次台账失败掀掉整个外壳。
+            result = section(SECTION_STATE.FAILED, {
+                data: { points: null, inventory: facilityInventory(null, null), archivesAvailable: false, detailFailures: 0 },
+            })
+        }
+        layer.value = { ...result, loaded: true }
+        return layer.value
+    }
+
+    return { layer, loaded, state, reason, points, inventory, filtered, selectedPoint, renderPoints, locate, select, clearLocate, load, configure, setLayer }
+})
+
+// 更新设施选择/定位 API 时必须同步已有 Pinia 实例,不能让新组件拿着旧 store 接口。
+if (import.meta.hot) import.meta.hot.accept(acceptHMRUpdate(useFacilityPointStore, import.meta.hot))

+ 45 - 1
src/modules/cockpit/stores/filterStore.js

@@ -12,6 +12,7 @@ import {
 } from '../domain/filterOptions.js'
 import { DEFAULT_VIEW_MODE, isKnownViewMode } from '../domain/viewModes.js'
 import { AREA_STATE, DEFAULT_AREA_STATE, isKnownAreaState } from '../domain/areaLimits.js'
+import { normalizeFacilityType, selectedFacilityTypes } from '../domain/facilityPoints.js'
 
 /**
  * 全局筛选(ADR 0004 的 filterStore)。
@@ -45,6 +46,17 @@ import { AREA_STATE, DEFAULT_AREA_STATE, isKnownAreaState } from '../domain/area
  * ⑥ **单值 + 可取消**:与行业/片区一样是单值条件,但入口是"点分布行",
  *    所以语义是 `toggle`(再点同一行取消)—— 与「把选中的状态桶都取消」同一种手感。
  *    类型码是数据值(无静态白名单,见 `domain/filterOptions.js` 的 `normalizeDeviceType`)。
+ *
+ * ── 「设施类型」的说明(票 68)──
+ *
+ * ⑦ **与点位状态是两个维度,互不改写**:设施点(水厂/泵站/水源地/用水户)不是监测点/设备,
+ *    所以设施筛选只裁设施点、状态筛选只裁点位与设备点;两类对象不重叠,同时生效不是
+ *    「取交集」,而是各自裁剪各自的集合。
+ *
+ * ⑧ **多选 OR + 空集合=全部**:与点位状态同一种手感(`toggleFacilityType`);
+ *    取值域是 `domain/facilityPoints.js` 的四类设施键,认不出的键不入集合。
+ *
+ * ⑨ **切模式不重置**:与其它全局条件一致(同 ③)。
  */
 export const useFilterStore = defineStore('filterStore', () => {
     /** 当前范围(区域)。 */
@@ -65,6 +77,12 @@ export const useFilterStore = defineStore('filterStore', () => {
      */
     const pointStatus = ref(DEFAULT_POINT_STATUS)
 
+    /**
+     * 当前设施类型(票 68 多值):**选中的设施类型集合**,空数组=全部类型。
+     * 取值是四类设施键(水厂/泵站/水源地/用水户),认不出的键不入集合。
+     */
+    const facilityTypes = ref([])
+
     /**
      * 区域状态(票 37):`locked`(在主场景区内、限位生效)/ `global`(区外自由视角)。
      *
@@ -144,6 +162,28 @@ export const useFilterStore = defineStore('filterStore', () => {
         }
     }
 
+    /**
+     * 设置设施类型集合(票 68 多值):空数组是合法值(回「全部类型」)。
+     * 逐项过四类白名单并去重,认不出的忽略。
+     */
+    function setFacilityTypes(next) {
+        facilityTypes.value = [...selectedFacilityTypes(next)]
+    }
+
+    /**
+     * 点一个设施类型行(票 68):已选中 → 取消;未选中 → 追加(多选 OR)。
+     * 与 `togglePointStatus` 同一种手感:把选中的都取消就回到全部。
+     */
+    function toggleFacilityType(next) {
+        const value = normalizeFacilityType(next)
+        if (!value) return
+        if (facilityTypes.value.includes(value)) {
+            facilityTypes.value = facilityTypes.value.filter((item) => item !== value)
+        } else {
+            facilityTypes.value = [...facilityTypes.value, value]
+        }
+    }
+
     /**
      * 切换区域状态(票 37)。未知键忽略。
      * **状态转移的判定不在这里** —— `mapService` 按「主场景区可用性」决定能不能进区
@@ -155,12 +195,13 @@ export const useFilterStore = defineStore('filterStore', () => {
         }
     }
 
-    /** 回到默认筛选(全域 / 全部行业 / 地面视图 / 全部状态 / 全部类型 / 区外自由视角)。 */
+    /** 回到默认筛选(全域 / 全部行业 / 地面视图 / 全部状态 / 全部类型 / 全部设施 / 区外自由视角)。 */
     function reset() {
         scope.value = DEFAULT_SCOPE
         industry.value = DEFAULT_INDUSTRY
         viewMode.value = DEFAULT_VIEW_MODE
         pointStatus.value = DEFAULT_POINT_STATUS.slice()
+        facilityTypes.value = []
         deviceType.value = DEFAULT_DEVICE_TYPE
         areaState.value = DEFAULT_AREA_STATE
     }
@@ -171,6 +212,7 @@ export const useFilterStore = defineStore('filterStore', () => {
         deviceType,
         viewMode,
         pointStatus,
+        facilityTypes,
         areaState,
         setScope,
         setIndustry,
@@ -179,6 +221,8 @@ export const useFilterStore = defineStore('filterStore', () => {
         setViewMode,
         setPointStatus,
         togglePointStatus,
+        setFacilityTypes,
+        toggleFacilityType,
         setAreaState,
         reset,
     }