소스 검색

feat(cockpit): 管线详情设备详情改为右侧资产详情页签

维护者 2026-11-12:设备详情浮在三维场景上要么遮挡 3D 区域、要么太窄读不清数据,
改为右侧页签承载,三维视口不再被覆盖。

- 右侧拆两个页签:管道临近资产(原管道 + 设备双列列表)|资产详情。
  选中设备或设施自动切到资产详情,取消选中退回列表。
- 管道卡片去掉状态文字与两行尺寸描述,只保留卡片信息;设备卡片同款样式。
- 新增公用 AssetDetailPanel 与 domain/assetDetail.js 适配器注册表:
  设备复用 EquipmentDetailSections、设施复用 EntityDetailSections,
  新增资产类型只需在注册表登记一条,视图与容器无需改动。
- 外栏右列最小宽度 300u、占比 46%,既保证表格可读又不压掉三维区域。
- 设施详情只在身份确属当前管线对象时才接入,避免跨上下文串数据。

回归:新增 t118(适配器/页签结构/无覆盖浮层/组件解耦);
t115/t116/t117/t11/t42 与视频套件全部通过。
ChenJZ 9 시간 전
부모
커밋
0f3db5017c

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

@@ -376,7 +376,9 @@
                 class="cockpit-shell__enhancement"
                 :plan="pipeEnhancementPlan"
                 :device-state="enhancementDevicesStore"
+                :facility-state="enhancementFacilityState"
                 @object-pick="selectPipeEnhancement"
+                @facility-retry="retryEnhancementFacility"
                 @device-pick="enhancementDevicesStore.open"
                 @devices-reload="enhancementDevicesStore.load(mapStore.summary, { force: true })"
                 @device-close="enhancementDevicesStore.closeDetail"
@@ -658,6 +660,25 @@ const overviewStore = useOverviewStore()
 const eventStore = useEventStore()
 const entityDetailStore = useEntityDetailStore()
 const facilityDetailOpen = computed(() => entityDetailStore.open && entityDetailStore.identity?.kind === 'facility')
+
+/**
+ * 管线详情视图的「资产详情」页签:设施详情与设备详情并列可选。
+ *
+ * 只有设施身份确实属于当前管线选中对象时才喂给面板——否则会把「设施台账里刚点过、
+ * 但和这条管段无关」的设施串进管线详情(跨上下文串数据比空着更糟)。
+ */
+const enhancementFacilityState = computed(() => {
+    const identity = entityDetailStore.identity
+    const object = pipeEnhancementPlan.value?.object
+    if (!identity || identity.kind !== 'facility' || !object) return { detail: null, loading: false, error: false }
+    const belongs = String(identity.id) === String(object.code || object.sourceId || '')
+    if (!belongs) return { detail: null, loading: false, error: false }
+    return {
+        detail: entityDetailStore.detail,
+        loading: entityDetailStore.loading === true,
+        error: Boolean(entityDetailStore.error),
+    }
+})
 const runtimeStore = useRuntimeStore()
 const statusPanelExpanded = ref(false)
 const { mode, expandedPanelIds } = storeToRefs(uiStore)
@@ -1276,6 +1297,11 @@ function selectPipeEnhancement(hit) {
     pipeEnhancementStore.select(kind, hit.code)
 }
 
+/** 资产详情页签的设施重试:沿用实体详情的既有重试入口,不另造取数路径。 */
+function retryEnhancementFacility() {
+    if (entityDetailStore.identity?.kind === 'facility') entityDetailStore.retry()
+}
+
 function closePipeEnhancement() {
     pipeEnhancementStore.clear()
     enhancementDevicesStore.clear()

+ 70 - 0
src/modules/cockpit/components/AssetDetailPanel.vue

@@ -0,0 +1,70 @@
+<!--
+    资产详情(公用组件):地下管线详情视图右侧「资产详情」页签的正文。
+
+    维护者 2026-11-12:设备详情从三维场景浮层改为右侧页签,避免遮挡 3D 区域;
+    同时设施详情与设备详情共用这一个容器,新增资产类型只需在
+    `domain/assetDetail.js` 的适配器注册表登记一条,本组件无需改动。
+-->
+<template>
+    <div class="asset-detail" data-asset-detail :data-asset-kind="view.kind">
+        <template v-if="view.available">
+            <header class="asset-detail__head">
+                <strong class="asset-detail__title" data-asset-title>{{ view.title }}</strong>
+                <slot name="actions" />
+            </header>
+            <p v-if="view.subtitle" class="asset-detail__subtitle">{{ view.subtitle }}</p>
+            <slot />
+        </template>
+        <p v-else class="asset-detail__empty" data-asset-empty>
+            在左侧列表或三维场景中选择设施或设备,这里显示它的详情。
+        </p>
+    </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { assetDetailView } from '../domain/assetDetail.js'
+
+const props = defineProps({
+    /** 资产上下文:{ device?, facility? },由视图按当前选中对象组装。 */
+    context: { type: Object, default: () => ({}) },
+})
+const view = computed(() => assetDetailView(props.context))
+</script>
+
+<style scoped>
+.asset-detail {
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+    gap: calc(6 * var(--u, 1px));
+}
+.asset-detail__head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: calc(8 * var(--u, 1px));
+    min-width: 0;
+}
+.asset-detail__title {
+    min-width: 0;
+    overflow: hidden;
+    color: var(--text-strong, #fff);
+    font-size: var(--fs-body, 14px);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.asset-detail__subtitle {
+    margin: 0;
+    color: var(--muted, #5680b0);
+    font-size: var(--fs-caption, 11.5px);
+    line-height: 1.5;
+}
+.asset-detail__empty {
+    margin: 0;
+    padding: calc(12 * var(--u, 1px));
+    color: var(--muted, #5680b0);
+    font-size: var(--fs-caption, 11.5px);
+    line-height: 1.6;
+}
+</style>

+ 104 - 69
src/modules/cockpit/components/PipeEnhancement.vue

@@ -19,34 +19,28 @@
         <div class="pipe-enhancement__workspace">
             <div ref="viewport" class="pipe-enhancement__viewport" data-pipe-enhancement-viewport role="region" aria-label="地下管线详情视图三维场景"></div>
             <!--
-                设备详情浮层:维护者 2026-10-22 要求「点击后在三维场景中展现浮动 UI 面板」,
-                所以它脱离右侧列表、浮在场景之上(保持场景/相机不动),可单独关闭。
+                维护者 2026-11-12:设备/设施详情不再浮在三维场景上(遮挡 3D 区域),
+                改为右侧「资产详情」页签;「管道临近资产」保留原有的管道 + 设备双列列表。
             -->
-            <section v-if="deviceState.selected" class="pipe-enhancement__device-detail" data-enhancement-device-detail>
-                <header>
-                    <strong>{{ deviceState.selected.name || deviceState.selected.code }}</strong>
-                    <button type="button" data-enhancement-device-close @click="$emit('device-close')">关闭详情</button>
-                </header>
-                <div class="pipe-enhancement__device-status-card" data-enhancement-device-status-card>
-                    <DeviceStateTags data-enhancement-device-status :device-code="deviceState.selected.code"
-                        :fallback-status="deviceState.selected.status || ''" :aggregate="deviceState.aggregate" />
-                    <span class="pipe-enhancement__device-status-label">设备状态</span>
-                </div>
-                <p>{{ deviceState.selected.placementText }}</p>
-                <p v-if="deviceState.selected.pointCode">所属监测点:{{ deviceState.selected.pointCode }}</p>
-                <p v-if="deviceState.loadingDetail && !deviceState.aggregate?.data">设备详情加载中</p>
-                <template v-else-if="!deviceState.aggregate?.data"><p>设备详情暂不可用</p><button type="button" @click="$emit('device-retry')">重新加载</button></template>
-                <EquipmentDetailSections v-else :aggregate="deviceState.aggregate" :device-code="deviceState.selected.code" monitor-source="enhancement" />
-            </section>
-            <aside class="pipe-enhancement__devices" aria-label="附近设备与详情">
+            <aside class="pipe-enhancement__side" aria-label="管道临近资产与资产详情">
+                <nav class="pipe-enhancement__side-tabs" role="tablist" aria-label="管线详情分区">
+                    <button v-for="tab in sideTabs" :key="tab.key" type="button" role="tab"
+                        :aria-selected="sideTab === tab.key" :class="{ 'is-active': sideTab === tab.key }"
+                        :data-side-tab="tab.key" @click="sideTab = tab.key">{{ tab.label }}</button>
+                </nav>
+                <div v-show="sideTab === 'nearby'" class="pipe-enhancement__nearby" data-side-panel="nearby">
+                    <aside class="pipe-enhancement__devices" aria-label="附近资产列表">
                 <section v-if="localPipeStates.length" class="pipe-enhancement__pipe-list" data-pipe-status-list aria-label="附近管道状态">
                     <h3>附近管道状态({{ localPipeStates.length }})</h3>
                     <div class="pipe-enhancement__pipe-scroll">
-                        <p v-for="pipe in pagedPipeStates" :key="pipe.code">
-                            <button type="button" class="pipe-enhancement__pipe" :aria-pressed="plan.object.sourceKind === 'pipe' && plan.object.code === pipe.code" @click="$emit('object-pick', { objectKind: 'pipe', code: pipe.code })">{{ pipe.title }}({{ pipe.code }})</button>:{{ pipe.stateText || '状态未提供' }}
-                            <small class="pipe-enhancement__dimensions">源管径 {{ pipe.diameterM ? pipe.diameter + ' mm' : '未提供(仅导引)' }} · 源材质 {{ pipe.material || '未提供' }}({{ pipe.surface?.label || '材质未识别' }})</small>
-                            <small class="pipe-enhancement__dimensions">{{ pipe.clipped ? '当前裁片' : '地理路径' }} {{ pipe.geographicLengthM?.toFixed(2) }} m · 后端管长 {{ pipe.lengthM ?? '未提供' }}{{ pipe.lengthM !== null ? '(源值)' : '' }}</small>
-                        </p>
+                        <button v-for="pipe in pagedPipeStates" :key="pipe.code" type="button"
+                            class="pipe-enhancement__asset-card" data-pipe-card
+                            :aria-pressed="plan.object.sourceKind === 'pipe' && plan.object.code === pipe.code"
+                            @click="$emit('object-pick', { objectKind: 'pipe', code: pipe.code })">
+                            <b>{{ pipe.title }}</b>
+                            <small>{{ pipe.code }} · {{ pipe.stateText || '状态未提供' }}</small>
+                            <small>{{ pipe.diameterM ? pipe.diameter + ' mm' : '管径未提供' }} · {{ pipe.material || '材质未提供' }}</small>
+                        </button>
                     </div>
                     <nav v-if="pipePageCount > 1" class="pipe-enhancement__pager" data-pipe-pager aria-label="附近管道状态翻页">
                         <button type="button" :disabled="pipePage <= 1" @click="pipePage -= 1">上一页</button>
@@ -63,11 +57,41 @@
                         <button v-if="plan.deviceCoverage?.partial && !plan.deviceCoverage?.loading" type="button" @click="$emit('devices-reload')">重新读取设备清单</button>
                         <p v-if="plan.deviceCoverage?.stalePoints">部分设备为上次保留数据,非实时。</p>
                         <p v-if="!plan.deviceList?.length && !plan.deviceCoverage?.loading">当前可用数据中没有附近设备。</p>
-                        <button v-for="device in plan.deviceList || []" :key="device.code" :data-device-code="device.code" class="pipe-enhancement__device" type="button" @click="selectDevice(device)">
-                            {{ device.name || device.code }}<small>{{ device.placementText }}{{ device.retained ? ' · 过期数据' : '' }} · 点击场景图标或此处查看详情</small>
+                        <button v-for="device in plan.deviceList || []" :key="device.code" :data-device-code="device.code"
+                            class="pipe-enhancement__asset-card" data-device-card type="button"
+                            :aria-pressed="deviceState.selected?.code === device.code" @click="selectDevice(device)">
+                            <b>{{ device.name || device.code }}</b>
+                            <small>{{ device.code }} · {{ device.placementText }}{{ device.retained ? ' · 过期数据' : '' }}</small>
                         </button>
                     </div>
-                </section>
+                    </section>
+                </aside>
+                </div>
+                <div v-show="sideTab === 'detail'" class="pipe-enhancement__asset-pane" data-side-panel="detail">
+                    <AssetDetailPanel :context="assetContext">
+                        <template v-if="assetKind === 'device'">
+                            <div class="pipe-enhancement__device-status-card" data-enhancement-device-status-card>
+                                <DeviceStateTags data-enhancement-device-status :device-code="deviceState.selected.code"
+                                    :fallback-status="deviceState.selected.status || ''" :aggregate="deviceState.aggregate" />
+                                <span class="pipe-enhancement__device-status-label">设备状态</span>
+                            </div>
+                            <p v-if="deviceState.loadingDetail && !deviceState.aggregate?.data" class="pipe-enhancement__hint">设备详情加载中</p>
+                            <template v-else-if="!deviceState.aggregate?.data">
+                                <p class="pipe-enhancement__hint">设备详情暂不可用</p>
+                                <button type="button" class="pipe-enhancement__action" @click="$emit('device-retry')">重新加载</button>
+                            </template>
+                            <EquipmentDetailSections v-else :aggregate="deviceState.aggregate" :device-code="deviceState.selected.code" monitor-source="enhancement" />
+                        </template>
+                        <template v-else-if="assetKind === 'facility'">
+                            <p v-if="assetFacility?.loading" class="pipe-enhancement__hint" data-asset-facility-loading>设施详情加载中…</p>
+                            <p v-else-if="assetFacility?.error" class="pipe-enhancement__hint is-error" data-asset-facility-error>
+                                设施详情未取到,已保留现有信息。
+                                <button type="button" class="pipe-enhancement__action" @click="$emit('facility-retry')">重试</button>
+                            </p>
+                            <EntityDetailSections v-if="assetFacility?.detail" :detail="assetFacility.detail" presentation="device" />
+                        </template>
+                    </AssetDetailPanel>
+                </div>
             </aside>
         </div>
         <dl class="pipe-enhancement__facts">
@@ -87,13 +111,47 @@
 import { computed, nextTick, onBeforeUnmount, watch, ref } from 'vue'
 import { mountPipeEnhancement } from '../services/threePipeEnhancement.js'
 import EquipmentDetailSections from './EquipmentDetailSections.vue'
+import EntityDetailSections from './EntityDetailSections.vue'
 import DeviceStateTags from './DeviceStateTags.vue'
+import AssetDetailPanel from './AssetDetailPanel.vue'
+import { ASSET_KIND } from '../domain/assetDetail.js'
 
 const props = defineProps({
     plan: { type: Object, required: true },
     deviceState: { type: Object, default: () => ({ selected: null, aggregate: null, loadingDetail: false }) },
+    /** 设施详情状态:{ detail, loading, error };由外壳把选中设施接进来,与设备详情并列可选。 */
+    facilityState: { type: Object, default: () => ({ detail: null, loading: false, error: false }) },
+})
+const emit = defineEmits(['close', 'radius-change', 'device-pick', 'device-close', 'device-retry', 'devices-reload', 'object-pick', 'facility-retry'])
+
+/**
+ * 右侧两个页签:管道临近资产(原双列列表)|资产详情(设备或设施)。
+ * 选中的资产一变就自动切到详情页,避免用户还要手动点一下才知道有详情。
+ */
+const SIDE_TABS = [
+    { key: 'nearby', label: '管道临近资产' },
+    { key: 'detail', label: '资产详情' },
+]
+const sideTabs = SIDE_TABS
+const sideTab = ref('nearby')
+const assetFacility = computed(() => props.facilityState || { detail: null, loading: false, error: false })
+const assetContext = computed(() => ({
+    device: props.deviceState?.selected || null,
+    facility: assetFacility.value?.detail ? {
+        id: assetFacility.value.detail.id || '',
+        category: assetFacility.value.detail.kind === 'facility' ? (assetFacility.value.detail.category || '') : '',
+        title: assetFacility.value.detail.name || assetFacility.value.detail.code || '',
+        subtitle: assetFacility.value.detail.industry || '',
+    } : null,
+}))
+const assetKind = computed(() => {
+    // 设备优先级高于设施:两者同时存在时以最近一次选择的设备为准(device 选择会清掉设施详情)。
+    if (props.deviceState?.selected?.code) return ASSET_KIND.DEVICE
+    if (assetFacility.value?.detail) return ASSET_KIND.FACILITY
+    return ''
 })
-const emit = defineEmits(['close', 'radius-change', 'device-pick', 'device-close', 'device-retry', 'devices-reload', 'object-pick'])
+// 有资产被选中就切到详情页;全部取消则退回列表页,避免停在空白详情。
+watch(assetKind, (kind) => { sideTab.value = kind ? 'detail' : 'nearby' }, { immediate: true })
 const viewport = ref(null)
 const localPipeStates = computed(() => [...new Map((props.plan.objects || [])
     .filter((object) => object.sourceKind === 'pipe').map((object) => [object.code, object])).values()])
@@ -208,33 +266,29 @@ onBeforeUnmount(() => {
 .pipe-enhancement__close:hover, .pipe-enhancement__close:focus-visible { border-color: var(--cyan, #01abea); color: #fff; outline: none; }
 /* 右侧块(附近管道状态 + 监测设备)从 32% 放宽到 42%:两块改成左右并排后,
    每块仍需要能读下「源管径 / 源材质 / 地理路径」那几行小字,否则并排只是把两列都挤扁。 */
-.pipe-enhancement__workspace { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(calc(270 * var(--u, 1px)), 42%); gap: 12px; min-height: 0; }
+.pipe-enhancement__workspace { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(calc(300 * var(--u, 1px)), 46%); gap: 12px; min-height: 0; }
 /* 票 90 第二条(维护者 2026-10-27):右侧的「附近管道状态」与「监测设备」改为**左右并排**,
    不再上下堆叠 —— 两块本就各自独立滚动,竖排等于把同一块纵向空间切成两半,谁都读不全。
    分隔线跟着从「上边框」改成两列之间的竖线。 */
-.pipe-enhancement__devices { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px; min-height: 0; overflow: hidden; border-left: 1px solid #174969; padding-left: 12px; font-size: 12px; }
+.pipe-enhancement__side { display: flex; flex-direction: column; min-height: 0; overflow: hidden; border-left: 1px solid #174969; padding-left: 12px; font-size: 12px; }
+.pipe-enhancement__side-tabs { display: flex; flex: none; gap: 6px; margin-bottom: 8px; }
+.pipe-enhancement__side-tabs button { flex: 1; padding: 6px 8px; border: 1px solid #226286; border-radius: calc(8 * var(--u, 1px)); background: #072039; color: #a7c4d9; font: inherit; cursor: pointer; }
+.pipe-enhancement__side-tabs button.is-active { border-color: var(--cyan, #01abea); background: linear-gradient(180deg, rgba(1, 171, 234, .85), rgba(0, 82, 168, .85)); color: #fff; }
+.pipe-enhancement__nearby, .pipe-enhancement__asset-pane { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
+.pipe-enhancement__asset-pane { overflow: auto; }
+.pipe-enhancement__nearby { overflow: hidden; }
+.pipe-enhancement__devices { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px; min-height: 0; overflow: hidden; }
 .pipe-enhancement__pipe-list { display: flex; flex-direction: column; min-height: 0; overflow: hidden; border-right: 1px solid #174969; padding-right: 10px; }
 .pipe-enhancement__device-list { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
 .pipe-enhancement__pipe-scroll, .pipe-enhancement__device-scroll { min-height: 0; overflow: auto; overscroll-behavior: contain; }
-/* 设备详情浮层:浮在三维场景之上(维护者 2026-10-22),不占用右侧列表空间。 */
-.pipe-enhancement__device-detail {
-    position: absolute;
-    z-index: 6;
-    left: 12px;
-    top: 12px;
-    /* 管线详情用紧凑宽度,不再套主地图详情的460px下限。 */
-    width: min(calc(340 * var(--u, 1px)), 380px, calc(100% - 24px));
-    max-height: calc(100% - 24px);
-    overflow: auto;
-    box-sizing: border-box;
-    padding: 12px;
-    border: 1px solid #36b8d4;
-    border-radius: 6px;
-    background: rgba(2, 12, 32, .97);
-    box-shadow: 0 10px 30px rgba(0, 0, 0, .7);
-    font-size: 12px;
-}
-.pipe-enhancement__device-detail header { display: flex; justify-content: space-between; gap: 8px; align-items: center; margin-bottom: 8px; }
+/* 卡片:只留卡片信息(标题 + 编码/状态 + 管径/材质),不再铺长串描述。 */
+.pipe-enhancement__asset-card { display: block; width: 100%; margin-bottom: 6px; padding: 7px 8px; text-align: left; border: 1px solid #21618a; border-radius: calc(8 * var(--u, 1px)); background: #08213a; color: #e4f4ff; font: inherit; cursor: pointer; }
+.pipe-enhancement__asset-card b { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
+.pipe-enhancement__asset-card small { display: block; margin-top: 3px; color: #a7c4d9; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
+.pipe-enhancement__asset-card[aria-pressed="true"] { border-color: #d8fbff; box-shadow: inset 0 0 4px #38bac9; }
+.pipe-enhancement__hint { margin: 0 0 8px; color: #a7c4d9; line-height: 1.5; }
+.pipe-enhancement__hint.is-error { color: var(--lv1, #ff3b52); }
+.pipe-enhancement__action { align-self: flex-start; padding: 5px 10px; border: 1px solid #21618a; border-radius: calc(8 * var(--u, 1px)); background: #08213a; color: #e4f4ff; cursor: pointer; }
 .pipe-enhancement__device-status-card {
     display: flex;
     flex-wrap: wrap;
@@ -247,29 +301,9 @@ onBeforeUnmount(() => {
     background: linear-gradient(135deg, rgba(4, 42, 76, .72), rgba(5, 23, 47, .88));
 }
 .pipe-enhancement__device-status-label { color: #7ba4c3; }
-.pipe-enhancement__device-detail button { color: #e4f4ff; background: #08213a; border: 1px solid #21618a; padding: 5px 8px; cursor: pointer;
-    border-radius: calc(8 * var(--u, 1px));}
-.pipe-enhancement__device-detail :deep(.equipment-sections) { max-height: 60vh; overflow: auto; }
-/* 紧凑详情下缩短时间/播放列,保留横向读数空间;字段名仍禁止竖排。 */
-.pipe-enhancement__device-detail :deep(.collection-table__time) { width: calc(76 * var(--u, 1px)); }
-.pipe-enhancement__device-detail :deep(.collection-table__audio-col) { width: calc(52 * var(--u, 1px)); }
-.pipe-enhancement__device-detail :deep(.collection-table__value) { padding-left: calc(2 * var(--u, 1px)); }
-.pipe-enhancement__device-detail :deep(.collection-table__reading) { column-gap: calc(8 * var(--u, 1px)); }
-.pipe-enhancement__device-detail :deep(.collection-table__heading) { font-size: calc(9 * var(--u, 1px)); }
 .pipe-enhancement__pager { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-top: 6px; font-size: 11px; color: #a7c4d9; }
 .pipe-enhancement__pager button { padding: 3px 8px; }
 .pipe-enhancement__pager button:disabled { opacity: .4; cursor: default; }
-.pipe-enhancement__pipe { padding: 3px 5px; border: 1px solid #226286; background: #072039; color: #d9f7ff; cursor: pointer; text-align: left;
-    border-radius: calc(8 * var(--u, 1px));}
-.pipe-enhancement__pipe[aria-pressed="true"] { border-color: #d8fbff; box-shadow: inset 0 0 4px #38bac9; }
-.pipe-enhancement__dimensions { display: block; margin-top: 4px; color: #a7c4d9; }
-.pipe-enhancement__devices h3 { margin: 0 0 8px; }
-.pipe-enhancement__devices button { color: #e4f4ff; background: #08213a; border: 1px solid #21618a; padding: 7px; cursor: pointer;
-    border-radius: calc(8 * var(--u, 1px));}
-.pipe-enhancement__device { display: block; width: 100%; margin-bottom: 6px; text-align: left; }
-.pipe-enhancement__device small { display: block; color: #a7c4d9; margin-top: 4px; }
-.pipe-enhancement__devices section { margin-top: 12px; }
-.pipe-enhancement__devices header { display: flex; justify-content: space-between; gap: 8px; align-items: center; }
 .pipe-enhancement__scope { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; color: #a7c4d9; font-size: 12px; }
 .pipe-enhancement__scope select { margin-left: 8px; padding: 5px 10px; border: 1px solid #168bff; border-radius: 4px; background: #031226; color: #e4f4ff; }
 .pipe-enhancement__viewport { min-height: 0; overflow: hidden; border: 1px solid rgba(1, 171, 234, .24); background: #031226; outline: none; touch-action: none;
@@ -282,3 +316,4 @@ onBeforeUnmount(() => {
 .pipe-enhancement__facts dd { margin: 2px 0 0; overflow-wrap: anywhere; }
 .pipe-enhancement__note { margin: 0; color: #a7c4d9; font-size: 12px; line-height: 1.45; }
 </style>
+

+ 66 - 0
src/modules/cockpit/domain/assetDetail.js

@@ -0,0 +1,66 @@
+/**
+ * 资产详情适配器注册表。
+ *
+ * 地下管线详情视图右侧「资产详情」页签要能展示不同类型的资产:
+ *   · device    —— 监测设备,复用设备详情分区(EquipmentDetailSections);
+ *   · facility  —— 水厂/泵站/管网/工程设施,复用设施详情分区(EntityDetailSections);
+ *   · 未来新增类型(如阀门、消火栓)只需在此登记一条,视图无需改动。
+ *
+ * 每类适配器只回答三件事:有没有可展示的资产、标题是什么、正文用哪个分区组件。
+ * 判定全部是纯函数,便于脱浏览器断言;组件层不判断「这是什么类型」。
+ */
+
+/** 资产类型词表。新增类型在此追加,并按需在下方登记适配器。 */
+export const ASSET_KIND = Object.freeze({
+    DEVICE: 'device',
+    FACILITY: 'facility',
+})
+
+/**
+ * 适配器契约:
+ *   key        资产类型键(ASSET_KIND 之一)
+ *   available  (ctx) => boolean  —— 当前上下文是否有该类资产可展示
+ *   title      (ctx) => string   —— 面板标题,缺省回落到 code
+ *   subtitle   (ctx) => string   —— 次要说明(可为空)
+ */
+const ADAPTERS = [
+    {
+        key: ASSET_KIND.DEVICE,
+        // 设备以「已选中 + 有可展示编码」为准;聚合尚未到达时仍可展示标题与加载态。
+        available: (ctx) => Boolean(ctx && ctx.device && ctx.device.code),
+        title: (ctx) => ctx.device.name || ctx.device.code,
+        subtitle: (ctx) => ctx.device.placementText || (ctx.device.pointCode ? `所属监测点:${ctx.device.pointCode}` : ''),
+    },
+    {
+        key: ASSET_KIND.FACILITY,
+        // 设施只在身份有效时可用:缺主键/类别的行不可点,也不伪装成设施。
+        available: (ctx) => Boolean(ctx && ctx.facility && ctx.facility.id && ctx.facility.category),
+        title: (ctx) => ctx.facility.title || ctx.facility.id,
+        subtitle: (ctx) => ctx.facility.subtitle || '',
+    },
+]
+
+/** 按优先顺序挑选第一个可用适配器;都不可用返回 null(视图据此显示空态)。 */
+export function resolveAssetAdapter(ctx) {
+    return ADAPTERS.find((adapter) => {
+        try { return adapter.available(ctx) === true } catch { return false }
+    }) || null
+}
+
+/** 资产详情的展示视图(标题/副标题/类型),供组件直接渲染。 */
+export function assetDetailView(ctx) {
+    const adapter = resolveAssetAdapter(ctx)
+    if (!adapter) return { kind: '', available: false, title: '', subtitle: '' }
+    return {
+        kind: adapter.key,
+        available: true,
+        title: adapter.title(ctx) || '',
+        subtitle: adapter.subtitle(ctx) || '',
+    }
+}
+
+/** 资产类型 → 中文标签(页签文案与后续扩展共用)。 */
+export const ASSET_KIND_LABEL = Object.freeze({
+    [ASSET_KIND.DEVICE]: '设备详情',
+    [ASSET_KIND.FACILITY]: '设施详情',
+})