| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152 |
- <template>
- <aside v-if="open" class="pipe-enhancement" data-pipe-enhancement role="dialog" aria-label="地下管线详情视图" @keydown.esc.stop.prevent="$emit('close')">
- <header class="pipe-enhancement__header">
- <div>
- <span class="pipe-enhancement__eyebrow">地下管线详情视图</span>
- <h2 data-pipe-enhancement-title>{{ plan.object.title }}</h2>
- <p class="pipe-enhancement__controls">左键拖动平移 · 右键旋转 · 滚轮缩放 · Esc 关闭</p>
- </div>
- <button type="button" class="pipe-enhancement__close" aria-label="关闭地下管线详情视图" title="关闭地下管线详情视图" data-pipe-enhancement-close @click="$emit('close')">×</button>
- </header>
- <div class="pipe-enhancement__scope" v-if="plan.coverage">
- <label>附近范围
- <select aria-label="附近范围" :value="plan.radiusM" @change="$emit('radius-change', Number($event.target.value))">
- <option v-for="radius in [25, 50, 100]" :key="radius" :value="radius">{{ radius }} 米</option>
- </select>
- </label>
- <span>管道 {{ plan.coverage.pipeCount }} 条 · 构件 {{ plan.coverage.componentCount }} 个 · 邻近管道按范围裁切</span>
- </div>
- <div class="pipe-enhancement__workspace">
- <div ref="viewport" class="pipe-enhancement__viewport" data-pipe-enhancement-viewport role="region" aria-label="地下管线详情视图三维场景"></div>
- <aside class="pipe-enhancement__devices" aria-label="附近设备与详情">
- <section v-if="localPipeStates.length" aria-label="附近管道状态">
- <h3>附近管道状态</h3>
- <p v-for="pipe in localPipeStates" :key="pipe.code">
- {{ pipe.title }}({{ pipe.code }}):{{ 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>
- </section>
- <h3>监测设备</h3>
- <p v-if="plan.deviceCoverage?.loading">设备加载中</p>
- <p v-else-if="plan.deviceCoverage?.partial">设备覆盖不完整:快照未齐、设备待确认或部分点位读取失败。</p>
- <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" class="pipe-enhancement__device" type="button" @click="$emit('device-pick', device)">
- {{ device.name || device.code }}<small>{{ device.placementText }}{{ device.retained ? ' · 过期数据' : '' }}</small>
- </button>
- <section v-if="deviceState.selected" data-enhancement-device-detail>
- <header><strong>{{ deviceState.selected.name || deviceState.selected.code }}</strong><button type="button" @click="$emit('device-close')">关闭详情</button></header>
- <p>{{ deviceState.selected.placementText }}</p>
- <p v-if="deviceState.selected.pointCode">所属监测点:{{ deviceState.selected.pointCode }}</p>
- <p v-if="deviceState.loadingDetail">设备详情加载中</p>
- <template v-else-if="!deviceState.aggregate?.data"><p>设备详情暂不可用</p><button type="button" @click="$emit('device-retry')">重新加载</button></template>
- <EquipmentDetailSections v-else :aggregate="deviceState.aggregate" :device-code="deviceState.selected.code" />
- </section>
- </aside>
- </div>
- <dl class="pipe-enhancement__facts">
- <div><dt>对象</dt><dd>{{ plan.object.renderKind === 'manhole' ? '窨井剖切' : plan.object.sourceKind === 'pipe' ? '重点管段' : '重点构件' }}</dd></div>
- <div><dt>编码</dt><dd>{{ plan.object.code }}</dd></div>
- <div v-if="plan.object.diameter !== null"><dt>源管径</dt><dd>{{ plan.object.diameter }} mm</dd></div>
- <div v-if="plan.object.sourceKind === 'pipe'"><dt>地理路径长度</dt><dd>{{ plan.object.geographicLengthM?.toFixed(2) }} m</dd></div>
- <div v-if="plan.object.sourceKind === 'pipe'"><dt>后端管长(源值)</dt><dd>{{ plan.object.lengthM ?? '未提供' }}</dd></div>
- <div><dt>状态</dt><dd>{{ plan.object.stateText || '状态未提供' }}</dd></div>
- <div v-if="plan.object.material"><dt>材质</dt><dd>{{ plan.object.material }}</dd></div>
- </dl>
- <p class="pipe-enhancement__note" data-pipe-enhancement-note>{{ plan.disclaimer }} · {{ plan.connectionNote }} · 管体按源管径;长度保持地理路径,后端管长源值不用于拉伸。</p>
- </aside>
- </template>
- <script setup>
- import { computed, nextTick, onBeforeUnmount, watch, ref } from 'vue'
- import { mountPipeEnhancement } from '../services/threePipeEnhancement.js'
- import EquipmentDetailSections from './EquipmentDetailSections.vue'
- const props = defineProps({
- plan: { type: Object, required: true },
- deviceState: { type: Object, default: () => ({ selected: null, aggregate: null, loadingDetail: false }) },
- })
- const emit = defineEmits(['close', 'radius-change', 'device-pick', 'device-close', 'device-retry', 'devices-reload'])
- const viewport = ref(null)
- const localPipeStates = computed(() => [...new Map((props.plan.objects || [])
- .filter((object) => object.sourceKind === 'pipe').map((object) => [object.code, object])).values()])
- let viewer = null
- let mountGeneration = 0
- const open = ref(false)
- function disposeViewer() {
- if (viewer) viewer.destroy()
- viewer = null
- }
- async function mount() {
- const generation = ++mountGeneration
- disposeViewer()
- open.value = props.plan?.visible === true
- if (!open.value) return
- await nextTick()
- if (generation !== mountGeneration || !open.value || !viewport.value) return
- viewer = mountPipeEnhancement(viewport.value, props.plan, { onDevicePick: (device) => emit('device-pick', device) })
- }
- watch(
- () => props.plan?.visible === true ? JSON.stringify([props.plan.object, props.plan.radiusM, props.plan.objects, props.plan.devices]) : 'hidden',
- mount,
- { immediate: true },
- )
- onBeforeUnmount(() => {
- mountGeneration += 1
- disposeViewer()
- })
- </script>
- <style scoped>
- .pipe-enhancement {
- position: absolute;
- z-index: 5;
- left: 50%;
- top: 50%;
- transform: translate(-50%, -50%);
- width: min(1100px, calc(100% - 32px));
- height: min(760px, calc(100% - 32px));
- display: grid;
- grid-template-rows: auto auto minmax(0, 1fr) auto auto;
- gap: 12px;
- min-height: 0;
- overflow: hidden;
- box-sizing: border-box;
- padding: 16px;
- border: 1px solid rgba(1, 171, 234, .58);
- border-radius: 6px;
- background: rgba(2, 12, 32, .94);
- box-shadow: 0 14px 34px rgba(0, 0, 0, .5), 0 0 22px rgba(1, 171, 234, .12);
- color: var(--text-1, #e4f4ff);
- }
- .pipe-enhancement__header { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; }
- .pipe-enhancement__eyebrow { color: var(--cyan, #01abea); font-size: 10px; letter-spacing: .08em; }
- .pipe-enhancement h2 { margin: 3px 0 0; font-size: 20px; line-height: 1.35; overflow-wrap: anywhere; }
- .pipe-enhancement__controls { margin: 6px 0 0; color: var(--text-3, #7ba4c3); font-size: 12px; }
- .pipe-enhancement__close { width: 28px; height: 28px; border: 1px solid rgba(1, 171, 234, .46); border-radius: 4px; background: transparent; color: inherit; font-size: 20px; line-height: 1; cursor: pointer; }
- .pipe-enhancement__close:hover, .pipe-enhancement__close:focus-visible { border-color: var(--cyan, #01abea); color: #fff; outline: none; }
- .pipe-enhancement__workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 32%); gap: 12px; min-height: 0; }
- .pipe-enhancement__devices { min-height: 0; overflow-y: auto; border-left: 1px solid #174969; padding-left: 12px; font-size: 12px; }
- .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; }
- .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; }
- .pipe-enhancement__viewport[data-scene-focused] { border-color: var(--cyan, #01abea); box-shadow: inset 0 0 0 1px var(--cyan, #01abea); }
- .pipe-enhancement__viewport :deep(canvas) { display: block; width: 100%; height: 100%; }
- .pipe-enhancement__facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px 16px; margin: 0; font-size: 14px; }
- .pipe-enhancement__facts div { min-width: 0; }
- .pipe-enhancement__facts dt { color: var(--text-3, #7ba4c3); }
- .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>
|