PipeEnhancement.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. <template>
  2. <aside v-if="open" class="pipe-enhancement" data-pipe-enhancement role="dialog" aria-label="地下管线详情视图" @keydown.esc.stop.prevent="$emit('close')">
  3. <header class="pipe-enhancement__header">
  4. <div>
  5. <span class="pipe-enhancement__eyebrow">地下管线详情视图</span>
  6. <h2 data-pipe-enhancement-title>{{ plan.object.title }}</h2>
  7. <p class="pipe-enhancement__controls">左键拖动平移 · 右键旋转 · 滚轮缩放 · Esc 关闭</p>
  8. </div>
  9. <button type="button" class="pipe-enhancement__close" aria-label="关闭地下管线详情视图" title="关闭地下管线详情视图" data-pipe-enhancement-close @click="$emit('close')">×</button>
  10. </header>
  11. <div class="pipe-enhancement__scope" v-if="plan.coverage">
  12. <label>附近范围
  13. <select aria-label="附近范围" :value="plan.radiusM" @change="$emit('radius-change', Number($event.target.value))">
  14. <option v-for="radius in [25, 50, 100]" :key="radius" :value="radius">{{ radius }} 米</option>
  15. </select>
  16. </label>
  17. <span>管道 {{ plan.coverage.pipeCount }} 条 · 构件 {{ plan.coverage.componentCount }} 个 · 邻近管道按范围裁切</span>
  18. </div>
  19. <div class="pipe-enhancement__workspace">
  20. <div ref="viewport" class="pipe-enhancement__viewport" data-pipe-enhancement-viewport role="region" aria-label="地下管线详情视图三维场景"></div>
  21. <aside class="pipe-enhancement__devices" aria-label="附近设备与详情">
  22. <section v-if="localPipeStates.length" aria-label="附近管道状态">
  23. <h3>附近管道状态</h3>
  24. <p v-for="pipe in localPipeStates" :key="pipe.code">
  25. {{ pipe.title }}({{ pipe.code }}):{{ pipe.stateText || '状态未提供' }}
  26. <small class="pipe-enhancement__dimensions">源管径 {{ pipe.diameterM ? pipe.diameter + ' mm' : '未提供(仅导引)' }} · 源材质 {{ pipe.material || '未提供' }}({{ pipe.surface?.label || '材质未识别' }})</small>
  27. <small class="pipe-enhancement__dimensions">{{ pipe.clipped ? '当前裁片' : '地理路径' }} {{ pipe.geographicLengthM?.toFixed(2) }} m · 后端管长 {{ pipe.lengthM ?? '未提供' }}{{ pipe.lengthM !== null ? '(源值)' : '' }}</small>
  28. </p>
  29. </section>
  30. <h3>监测设备</h3>
  31. <p v-if="plan.deviceCoverage?.loading">设备加载中</p>
  32. <p v-else-if="plan.deviceCoverage?.partial">设备覆盖不完整:快照未齐、设备待确认或部分点位读取失败。</p>
  33. <button v-if="plan.deviceCoverage?.partial && !plan.deviceCoverage?.loading" type="button" @click="$emit('devices-reload')">重新读取设备清单</button>
  34. <p v-if="plan.deviceCoverage?.stalePoints">部分设备为上次保留数据,非实时。</p>
  35. <p v-if="!plan.deviceList?.length && !plan.deviceCoverage?.loading">当前可用数据中没有附近设备。</p>
  36. <button v-for="device in plan.deviceList || []" :key="device.code" class="pipe-enhancement__device" type="button" @click="$emit('device-pick', device)">
  37. {{ device.name || device.code }}<small>{{ device.placementText }}{{ device.retained ? ' · 过期数据' : '' }}</small>
  38. </button>
  39. <section v-if="deviceState.selected" data-enhancement-device-detail>
  40. <header><strong>{{ deviceState.selected.name || deviceState.selected.code }}</strong><button type="button" @click="$emit('device-close')">关闭详情</button></header>
  41. <p>{{ deviceState.selected.placementText }}</p>
  42. <p v-if="deviceState.selected.pointCode">所属监测点:{{ deviceState.selected.pointCode }}</p>
  43. <p v-if="deviceState.loadingDetail">设备详情加载中</p>
  44. <template v-else-if="!deviceState.aggregate?.data"><p>设备详情暂不可用</p><button type="button" @click="$emit('device-retry')">重新加载</button></template>
  45. <EquipmentDetailSections v-else :aggregate="deviceState.aggregate" :device-code="deviceState.selected.code" />
  46. </section>
  47. </aside>
  48. </div>
  49. <dl class="pipe-enhancement__facts">
  50. <div><dt>对象</dt><dd>{{ plan.object.renderKind === 'manhole' ? '窨井剖切' : plan.object.sourceKind === 'pipe' ? '重点管段' : '重点构件' }}</dd></div>
  51. <div><dt>编码</dt><dd>{{ plan.object.code }}</dd></div>
  52. <div v-if="plan.object.diameter !== null"><dt>源管径</dt><dd>{{ plan.object.diameter }} mm</dd></div>
  53. <div v-if="plan.object.sourceKind === 'pipe'"><dt>地理路径长度</dt><dd>{{ plan.object.geographicLengthM?.toFixed(2) }} m</dd></div>
  54. <div v-if="plan.object.sourceKind === 'pipe'"><dt>后端管长(源值)</dt><dd>{{ plan.object.lengthM ?? '未提供' }}</dd></div>
  55. <div><dt>状态</dt><dd>{{ plan.object.stateText || '状态未提供' }}</dd></div>
  56. <div v-if="plan.object.material"><dt>材质</dt><dd>{{ plan.object.material }}</dd></div>
  57. </dl>
  58. <p class="pipe-enhancement__note" data-pipe-enhancement-note>{{ plan.disclaimer }} · {{ plan.connectionNote }} · 管体按源管径;长度保持地理路径,后端管长源值不用于拉伸。</p>
  59. </aside>
  60. </template>
  61. <script setup>
  62. import { computed, nextTick, onBeforeUnmount, watch, ref } from 'vue'
  63. import { mountPipeEnhancement } from '../services/threePipeEnhancement.js'
  64. import EquipmentDetailSections from './EquipmentDetailSections.vue'
  65. const props = defineProps({
  66. plan: { type: Object, required: true },
  67. deviceState: { type: Object, default: () => ({ selected: null, aggregate: null, loadingDetail: false }) },
  68. })
  69. const emit = defineEmits(['close', 'radius-change', 'device-pick', 'device-close', 'device-retry', 'devices-reload'])
  70. const viewport = ref(null)
  71. const localPipeStates = computed(() => [...new Map((props.plan.objects || [])
  72. .filter((object) => object.sourceKind === 'pipe').map((object) => [object.code, object])).values()])
  73. let viewer = null
  74. let mountGeneration = 0
  75. const open = ref(false)
  76. function disposeViewer() {
  77. if (viewer) viewer.destroy()
  78. viewer = null
  79. }
  80. async function mount() {
  81. const generation = ++mountGeneration
  82. disposeViewer()
  83. open.value = props.plan?.visible === true
  84. if (!open.value) return
  85. await nextTick()
  86. if (generation !== mountGeneration || !open.value || !viewport.value) return
  87. viewer = mountPipeEnhancement(viewport.value, props.plan, { onDevicePick: (device) => emit('device-pick', device) })
  88. }
  89. watch(
  90. () => props.plan?.visible === true ? JSON.stringify([props.plan.object, props.plan.radiusM, props.plan.objects, props.plan.devices]) : 'hidden',
  91. mount,
  92. { immediate: true },
  93. )
  94. onBeforeUnmount(() => {
  95. mountGeneration += 1
  96. disposeViewer()
  97. })
  98. </script>
  99. <style scoped>
  100. .pipe-enhancement {
  101. position: absolute;
  102. z-index: 5;
  103. left: 50%;
  104. top: 50%;
  105. transform: translate(-50%, -50%);
  106. width: min(1100px, calc(100% - 32px));
  107. height: min(760px, calc(100% - 32px));
  108. display: grid;
  109. grid-template-rows: auto auto minmax(0, 1fr) auto auto;
  110. gap: 12px;
  111. min-height: 0;
  112. overflow: hidden;
  113. box-sizing: border-box;
  114. padding: 16px;
  115. border: 1px solid rgba(1, 171, 234, .58);
  116. border-radius: 6px;
  117. background: rgba(2, 12, 32, .94);
  118. box-shadow: 0 14px 34px rgba(0, 0, 0, .5), 0 0 22px rgba(1, 171, 234, .12);
  119. color: var(--text-1, #e4f4ff);
  120. }
  121. .pipe-enhancement__header { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; }
  122. .pipe-enhancement__eyebrow { color: var(--cyan, #01abea); font-size: 10px; letter-spacing: .08em; }
  123. .pipe-enhancement h2 { margin: 3px 0 0; font-size: 20px; line-height: 1.35; overflow-wrap: anywhere; }
  124. .pipe-enhancement__controls { margin: 6px 0 0; color: var(--text-3, #7ba4c3); font-size: 12px; }
  125. .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; }
  126. .pipe-enhancement__close:hover, .pipe-enhancement__close:focus-visible { border-color: var(--cyan, #01abea); color: #fff; outline: none; }
  127. .pipe-enhancement__workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 32%); gap: 12px; min-height: 0; }
  128. .pipe-enhancement__devices { min-height: 0; overflow-y: auto; border-left: 1px solid #174969; padding-left: 12px; font-size: 12px; }
  129. .pipe-enhancement__dimensions { display: block; margin-top: 4px; color: #a7c4d9; }
  130. .pipe-enhancement__devices h3 { margin: 0 0 8px; }
  131. .pipe-enhancement__devices button { color: #e4f4ff; background: #08213a; border: 1px solid #21618a; padding: 7px; cursor: pointer; }
  132. .pipe-enhancement__device { display: block; width: 100%; margin-bottom: 6px; text-align: left; }
  133. .pipe-enhancement__device small { display: block; color: #a7c4d9; margin-top: 4px; }
  134. .pipe-enhancement__devices section { margin-top: 12px; }
  135. .pipe-enhancement__devices header { display: flex; justify-content: space-between; gap: 8px; align-items: center; }
  136. .pipe-enhancement__scope { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; color: #a7c4d9; font-size: 12px; }
  137. .pipe-enhancement__scope select { margin-left: 8px; padding: 5px 10px; border: 1px solid #168bff; border-radius: 4px; background: #031226; color: #e4f4ff; }
  138. .pipe-enhancement__viewport { min-height: 0; overflow: hidden; border: 1px solid rgba(1, 171, 234, .24); background: #031226; outline: none; touch-action: none; }
  139. .pipe-enhancement__viewport[data-scene-focused] { border-color: var(--cyan, #01abea); box-shadow: inset 0 0 0 1px var(--cyan, #01abea); }
  140. .pipe-enhancement__viewport :deep(canvas) { display: block; width: 100%; height: 100%; }
  141. .pipe-enhancement__facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px 16px; margin: 0; font-size: 14px; }
  142. .pipe-enhancement__facts div { min-width: 0; }
  143. .pipe-enhancement__facts dt { color: var(--text-3, #7ba4c3); }
  144. .pipe-enhancement__facts dd { margin: 2px 0 0; overflow-wrap: anywhere; }
  145. .pipe-enhancement__note { margin: 0; color: #a7c4d9; font-size: 12px; line-height: 1.45; }
  146. </style>