Ver código fonte

feat(water): deliver T2 facility GIS map (#6)

Kazerin 4 dias atrás
pai
commit
f888a16f05

+ 87 - 0
src/utils/waterFacilityGisModel.js

@@ -0,0 +1,87 @@
+function finiteCoordinate(value) {
+  if (value === null || value === undefined || value === '') return null
+  const coordinate = Number(value)
+  return Number.isFinite(coordinate) ? coordinate : null
+}
+
+function featureIdentity(feature, properties) {
+  return {
+    id: properties.id ?? feature.id,
+    featureId: feature.id,
+    type: properties.facilityType || properties.type || '',
+    name: properties.facilityName || properties.name || properties.pipeName || properties.facilityTypeName || '供水设施',
+    code: properties.facilityCode || properties.code || properties.pipeCode || '-',
+    status: properties.status,
+    road: properties.road || properties.address || properties.location || '',
+    raw: feature
+  }
+}
+
+export function buildFacilityMapModel(collection = {}) {
+  const points = []
+  const lines = []
+
+  for (const feature of Array.isArray(collection.features) ? collection.features : []) {
+    const properties = feature.properties || {}
+    const identity = featureIdentity(feature, properties)
+    const coordinates = feature.geometry?.coordinates
+
+    if (feature.geometry?.type === 'Point' && Array.isArray(coordinates)) {
+      const lng = finiteCoordinate(coordinates[0])
+      const lat = finiteCoordinate(coordinates[1])
+      if (lng === null || lat === null) continue
+      points.push({
+        ...identity,
+        address: properties.address || properties.location || '',
+        lng,
+        lat
+      })
+    }
+
+    if (feature.geometry?.type === 'LineString' && Array.isArray(coordinates) && coordinates.length >= 2) {
+      const linePoints = coordinates.map(pair => ({
+        lng: finiteCoordinate(pair?.[0]),
+        lat: finiteCoordinate(pair?.[1])
+      }))
+      if (linePoints.some(point => point.lng === null || point.lat === null)) continue
+      lines.push({
+        ...identity,
+        type: identity.type || 'pipe',
+        material: properties.pipeMaterial,
+        diameter: properties.pipeDiameter,
+        startPoint: properties.startPoint,
+        endPoint: properties.endPoint,
+        layingYear: properties.layingYear,
+        length: properties.pipeLength,
+        points: linePoints
+      })
+    }
+  }
+
+  return { points, lines }
+}
+
+export function filterFacilityMapModel(model = {}, enabledTypes = {}) {
+  const points = (model.points || []).filter(item => enabledTypes[item.type] !== false)
+  const lines = (model.lines || []).filter(item => enabledTypes[item.type] !== false)
+  return { points, lines, isEmpty: points.length === 0 && lines.length === 0 }
+}
+
+export function findFacilityFeature(model = {}, keyword = '') {
+  const normalized = String(keyword).trim().toLocaleLowerCase()
+  if (!normalized) return null
+  const items = [
+    ...(model.points || []).map(item => ({ ...item, kind: 'point' })),
+    ...(model.lines || []).map(item => {
+      const first = item.points[0]
+      const last = item.points[item.points.length - 1]
+      const midpoint = {
+        lng: Number(((first.lng + last.lng) / 2).toFixed(8)),
+        lat: Number(((first.lat + last.lat) / 2).toFixed(8))
+      }
+      return { ...item, kind: 'pipeline', lng: midpoint.lng, lat: midpoint.lat }
+    })
+  ]
+  return items.find(item => [item.name, item.code, item.road]
+    .some(value => String(value || '').toLocaleLowerCase().includes(normalized))) || null
+}

+ 92 - 261
src/views/subSystem/waterSupply/facility/gis.vue

@@ -74,25 +74,6 @@
           </div>
         </el-card>
 
-        <el-card shadow="never" class="panel-card detail-card">
-          <template #header>
-            <span>要素详情</span>
-          </template>
-          <div v-if="selectedFeature" class="detail-body">
-            <div class="detail-name">{{ selectedFeature.name }}</div>
-            <div class="detail-tag">{{ selectedFeature.category }}</div>
-            <el-descriptions :column="1" border size="small">
-              <el-descriptions-item
-                v-for="item in selectedFeatureDetails"
-                :key="item.label"
-                :label="item.label"
-              >
-                {{ item.value }}
-              </el-descriptions-item>
-            </el-descriptions>
-          </div>
-          <el-empty v-else description="点击地图要素查看详情" :image-size="72" />
-        </el-card>
       </aside>
 
       <section class="dashboard-map-panel">
@@ -114,9 +95,36 @@
           <div class="toolbar-right">当前坐标: {{ currentCoordinate }}</div>
         </div>
         <div id="gisMap" ref="mapRef" class="gis-map"></div>
-        <div v-if="mapError" class="map-error">{{ mapError }}</div>
+        <div v-if="mapError" class="map-error">
+          <el-result icon="warning" title="供水设施图层加载失败" :sub-title="mapError">
+            <template #extra><el-button type="primary" @click="loadGisData">重试</el-button></template>
+          </el-result>
+        </div>
+        <div v-else-if="mapEmpty" class="map-error">
+          <el-empty description="当前筛选下没有可显示的供水设施" />
+        </div>
       </section>
     </div>
+
+    <el-drawer
+      v-model="detailOpen"
+      :title="detailTitle"
+      direction="rtl"
+      class="detail-drawer"
+      size="560px"
+      append-to-body
+    >
+      <el-skeleton v-if="detailLoading" :rows="8" animated />
+      <el-result v-else-if="detailError" icon="warning" title="设施详情加载失败" :sub-title="detailError">
+        <template #extra><el-button type="primary" @click="loadFacilityDetail">重试</el-button></template>
+      </el-result>
+      <el-descriptions v-else-if="detailRecord" :column="1" border>
+        <el-descriptions-item v-for="item in detailEntries" :key="item.label" :label="item.label">
+          {{ item.value }}
+        </el-descriptions-item>
+      </el-descriptions>
+      <el-empty v-else description="暂无设施详情" />
+    </el-drawer>
   </div>
 </template>
 
@@ -127,6 +135,8 @@ import { ElMessage } from 'element-plus'
 import { convertCoord } from '@/utils/coordTransform'
 import { getWaterFacilityOverview, getWaterFacilityFeatures, getWaterFacilityDetail } from '@/api/pipeNetwork/waterSupply'
 import { normalizeGeoJson, unwrapResponse } from '@/utils/waterSupplyModel'
+import { getRequestErrorMessage } from '@/utils/waterFacilityQuery'
+import { buildFacilityMapModel, filterFacilityMapModel, findFacilityFeature } from '@/utils/waterFacilityGisModel'
 
 const centerPoint = { lng: 110.386, lat: 28.445 }
 
@@ -174,15 +184,19 @@ const mapRef = ref(null)
 const screenRef = ref(null)
 const searchKeyword = ref('')
 const currentCoordinate = ref(`${centerPoint.lng}, ${centerPoint.lat}`)
-const selectedFeature = ref(null)
 const mapError = ref('')
+const dataLoaded = ref(false)
 const layerState = reactive(defaultLayerState())
 const facilityOverview = ref([])
 const pipelineData = ref([])
 const pointData = ref([])
+const detailOpen = ref(false)
+const detailLoading = ref(false)
+const detailError = ref('')
+const detailRecord = ref(null)
+const detailTarget = ref(null)
 
 let mapInstance = null
-let activeInfoWindow = null
 let overlayClicked = false
 
 const typeCount = type => {
@@ -198,34 +212,13 @@ const summaryStats = computed(() => ({
   userCount: typeCount('user')
 }))
 
-const selectedFeatureDetails = computed(() => {
-  if (!selectedFeature.value) return []
-  return Object.entries(selectedFeature.value.details).map(([label, value]) => ({ label, value }))
-})
-
-const searchableFeatures = computed(() => {
-  const pipelines = pipelineData.value.map(item => ({
-    kind: 'pipeline',
-    type: item.type,
-    name: item.name,
-    code: item.code,
-    road: item.road,
-    lng: getMidPoint(item.points).lng,
-    lat: getMidPoint(item.points).lat,
-    raw: item
-  }))
-  const points = pointData.value.map(item => ({
-    kind: 'point',
-    type: item.type,
-    name: item.name,
-    code: item.code,
-    road: item.road,
-    lng: item.lng,
-    lat: item.lat,
-    raw: item
-  }))
-  return [...pipelines, ...points]
-})
+const fullMapModel = computed(() => ({ points: pointData.value, lines: pipelineData.value }))
+const visibleMapModel = computed(() => filterFacilityMapModel(fullMapModel.value, layerState))
+const mapEmpty = computed(() => dataLoaded.value && !mapError.value && visibleMapModel.value.isEmpty)
+const detailTitle = computed(() => `${labelMap[detailTarget.value?.type] || '供水设施'}详情`)
+const detailEntries = computed(() => Object.entries(detailRecord.value || {})
+  .filter(([, value]) => value !== null && value !== undefined && typeof value !== 'object')
+  .map(([label, value]) => ({ label, value: value === '' ? '-' : value })))
 
 watch(layerState, () => {
   renderMapScene()
@@ -318,8 +311,7 @@ function initMap() {
       overlayClicked = false
       return
     }
-    closeInfoWindow()
-    selectedFeature.value = null
+    detailOpen.value = false
   })
 
   mapInstance.addEventListener('mousemove', (event) => {
@@ -341,7 +333,6 @@ function initMap() {
 function renderMapScene() {
   if (!mapInstance || typeof BMapGL === 'undefined') return
 
-  closeInfoWindow()
   try { mapInstance.clearOverlays() } catch (_) { /* ignore */ }
   try { drawPipelines() } catch (e) { console.warn('绘制供水管网失败:', e) }
   try { drawPoints() } catch (e) { console.warn('绘制供水设施失败:', e) }
@@ -357,13 +348,11 @@ function drawPipelines() {
         strokeWeight: 6,
         strokeOpacity: 0.92
       })
-      const firstPt = { lng: convertCoord(item.points[0].lng, item.points[0].lat)[0], lat: convertCoord(item.points[0].lng, item.points[0].lat)[1] }
       try {
         polyline.addEventListener('click', (e) => {
           overlayClicked = true
           e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
-          showPopup(firstPt.lng, firstPt.lat, buildPopupHTML(buildPipelineDetail(item)))
-          selectedFeature.value = buildPipelineDetail(item)
+          openFacilityDetail(item)
         })
       } catch (_) { /* polyline click listener failed */ }
       mapInstance.addOverlay(polyline)
@@ -383,8 +372,7 @@ function drawPoints() {
       if (!layerState[item.type]) return
       const [plng, plat] = convertCoord(item.lng, item.lat)
       addPointMarker(plng, plat, colorMap[item.type], item.name, 'dot', () => {
-        showPopup(plng, plat, buildPopupHTML(buildPointDetail(item)))
-        selectedFeature.value = buildPointDetail(item)
+        openFacilityDetail(item)
       })
     } catch (e) {
       console.warn(`绘制供水设施 ${item.name} 失败:`, e)
@@ -404,8 +392,7 @@ function addPipeLabel(lng, lat, text, color, rawItem) {
     label.addEventListener('click', (e) => {
       overlayClicked = true
       e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
-      showPopup(lng, lat, buildPopupHTML(buildPipelineDetail(rawItem)))
-      selectedFeature.value = buildPipelineDetail(rawItem)
+      openFacilityDetail(rawItem)
     })
   } catch (_) { /* label click listener failed */ }
   mapInstance.addOverlay(label)
@@ -434,80 +421,18 @@ function addPointMarker(lng, lat, color, name, shape, onClick) {
   mapInstance.addOverlay(label)
 }
 
-function showPopup(lng, lat, html) {
-  try {
-    closeInfoWindow()
-    const point = new BMapGL.Point(lng, lat)
-    const infoWindow = new BMapGL.InfoWindow(html, {
-      width: 310,
-      title: '',
-      enableMessage: false
-    })
-    activeInfoWindow = infoWindow
-    mapInstance.openInfoWindow(infoWindow, point)
-  } catch (e) {
-    console.warn('弹出信息窗口失败:', e)
-  }
-}
-
-function closeInfoWindow() {
-  if (activeInfoWindow && mapInstance) {
-    mapInstance.closeInfoWindow()
-    activeInfoWindow = null
-  }
-}
-
-function buildPopupHTML(detail) {
-  const rows = Object.entries(detail.details)
-    .map(([k, v]) => `<div class="popup-row"><span class="popup-label">${k}</span><span class="popup-value">${v}</span></div>`)
-    .join('')
-  return `<div class="info-popup">
-    <div class="popup-header">${detail.name}</div>
-    <div class="popup-tag">${detail.category}</div>
-    <div class="popup-divider"></div>
-    ${rows}
-  </div>`
-}
-
-function buildPipelineDetail(item) {
+function getMidPoint(points) {
+  const first = points[0]
+  const last = points[points.length - 1]
   return {
-    name: item.name,
-    category: labelMap[item.type] || '供水管网',
-    details: {
-      '管网编号': item.code || '-',
-      '管网名称': item.name,
-      '管径': item.diameter || '-',
-      '材质': item.material || '-',
-      '起点位置': item.startPoint || '-',
-      '终点位置': item.endPoint || '-',
-      '铺设年份': item.layingYear || '-',
-      '长度': item.length || '-',
-      '运行状态': item.status || '-'
-    }
+    lng: (first.lng + last.lng) / 2,
+    lat: (first.lat + last.lat) / 2
   }
 }
 
-function buildPointDetail(item) {
-  return {
-    name: item.name,
-    category: labelMap[item.type] || '供水设施',
-    details: {
-      '设施编号': item.code || '-',
-      '设施名称': item.name,
-      '所属类型': labelMap[item.type] || '-',
-      '地址': item.address || item.location || '-',
-      '状态': item.status || '-',
-      '经度': item.lng.toFixed(5),
-      '纬度': item.lat.toFixed(5)
-    }
-  }
-}
-
-function getMidPoint(points) {
-  return points[Math.floor(points.length / 2)]
-}
-
 async function loadGisData() {
+  mapError.value = ''
+  dataLoaded.value = false
   try {
     const [overviewRes, featureRes] = await Promise.all([
       getWaterFacilityOverview(),
@@ -516,49 +441,18 @@ async function loadGisData() {
     const overviewData = unwrapResponse(overviewRes)
     facilityOverview.value = Array.isArray(overviewData) ? overviewData : (overviewData?.facilityOverview || [])
     const geoJson = normalizeGeoJson(featureRes)
-    const lines = []
-    const points = []
-    ;(geoJson.features || []).forEach(feature => {
-      const coordinates = feature.geometry?.coordinates || []
-      const properties = feature.properties || {}
-      const type = properties.facilityType || properties.type || ''
-      if (feature.geometry?.type === 'LineString' && coordinates.length > 1) {
-        lines.push({
-          id: feature.id || properties.id,
-          type: type || 'pipe',
-          name: properties.pipeName || properties.name || properties.facilityTypeName || '供水管网',
-          code: properties.pipeCode || properties.code || '-',
-          material: properties.pipeMaterial,
-          diameter: properties.pipeDiameter,
-          startPoint: properties.startPoint,
-          endPoint: properties.endPoint,
-          layingYear: properties.layingYear,
-          length: properties.pipeLength,
-          status: properties.status,
-          road: properties.road || properties.address || '',
-          points: coordinates.map(([lng, lat]) => ({ lng, lat }))
-        })
-      }
-      if (feature.geometry?.type === 'Point' && coordinates.length >= 2) {
-        points.push({
-          id: feature.id || properties.id,
-          type: type,
-          name: properties.facilityName || properties.name || properties.pipeName || properties.facilityTypeName || '供水设施',
-          code: properties.facilityCode || properties.code || '-',
-          address: properties.address || properties.location || '',
-          status: properties.status,
-          road: properties.address || properties.location || '',
-          lng: coordinates[0],
-          lat: coordinates[1]
-        })
-      }
-    })
-    pipelineData.value = lines
-    pointData.value = points
+    const model = buildFacilityMapModel(geoJson)
+    pipelineData.value = model.lines
+    pointData.value = model.points
+    dataLoaded.value = true
     if (mapInstance && typeof BMapGL !== 'undefined') renderMapScene()
   } catch (e) {
     console.error('加载供水GIS数据失败', e)
-    ElMessage.warning('供水GIS数据加载失败')
+    facilityOverview.value = []
+    pipelineData.value = []
+    pointData.value = []
+    dataLoaded.value = true
+    mapError.value = getRequestErrorMessage(e, '供水GIS数据加载失败,请重试。')
   }
 }
 
@@ -569,9 +463,7 @@ function handleSearch() {
     return
   }
 
-  const matched = searchableFeatures.value.find(item =>
-    [item.name, item.code, item.road].some(text => text?.includes(keyword))
-  )
+  const matched = findFacilityFeature(visibleMapModel.value, keyword)
 
   if (!matched) {
     ElMessage.info('未找到匹配要素')
@@ -579,19 +471,38 @@ function handleSearch() {
   }
 
   const [mlng, mlat] = convertCoord(matched.lng, matched.lat)
-  mapInstance.centerAndZoom(new BMapGL.Point(mlng, mlat), 18)
-
-  if (matched.kind === 'pipeline') {
-    selectedFeature.value = buildPipelineDetail(matched.raw)
-    showPopup(mlng, mlat, buildPopupHTML(selectedFeature.value))
-  } else {
-    selectedFeature.value = buildPointDetail(matched.raw)
-    showPopup(mlng, mlat, buildPopupHTML(selectedFeature.value))
+  if (mapInstance && typeof BMapGL !== 'undefined') {
+    mapInstance.centerAndZoom(new BMapGL.Point(mlng, mlat), 18)
   }
 
+  openFacilityDetail(matched)
+
   ElMessage.success(`已定位到 ${matched.name}`)
 }
 
+async function openFacilityDetail(feature) {
+  detailTarget.value = feature
+  detailOpen.value = true
+  detailRecord.value = null
+  detailError.value = ''
+  await loadFacilityDetail()
+}
+
+async function loadFacilityDetail() {
+  const feature = detailTarget.value
+  if (!feature?.type || feature.id === null || feature.id === undefined) return
+  detailLoading.value = true
+  detailError.value = ''
+  try {
+    detailRecord.value = unwrapResponse(await getWaterFacilityDetail(feature.type, feature.id))
+  } catch (error) {
+    detailRecord.value = null
+    detailError.value = getRequestErrorMessage(error, '设施详情查询失败,请重试。')
+  } finally {
+    detailLoading.value = false
+  }
+}
+
 async function refreshScene() {
   layerState.source = true
   layerState.plant = true
@@ -599,8 +510,7 @@ async function refreshScene() {
   layerState.pipe = true
   layerState.user = true
   searchKeyword.value = ''
-  selectedFeature.value = null
-  closeInfoWindow()
+  detailOpen.value = false
   await loadGisData()
   locateCenter()
 }
@@ -839,29 +749,6 @@ function handleFullscreenChange() {
   min-height: 280px;
 }
 
-.detail-body {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-}
-
-.detail-name {
-  color: #1f2d3d;
-  font-size: 16px;
-  font-weight: 700;
-}
-
-.detail-tag {
-  display: inline-flex;
-  align-items: center;
-  width: fit-content;
-  padding: 3px 10px;
-  color: #2d6cdf;
-  background: #edf4ff;
-  border-radius: 999px;
-  font-size: 12px;
-}
-
 .dashboard-map-panel {
   position: relative;
   flex: 1;
@@ -944,62 +831,6 @@ function handleFullscreenChange() {
   text-shadow: 0 0 3px #fff;
 }
 
-.info-popup {
-  padding: 4px 2px;
-  font-size: 13px;
-  color: #333;
-}
-
-.popup-header {
-  font-size: 15px;
-  font-weight: 700;
-  color: #1a1a2e;
-  margin-bottom: 6px;
-}
-
-.popup-tag {
-  display: inline-block;
-  padding: 2px 10px;
-  font-size: 11px;
-  color: #1d4ed8;
-  background: #eff6ff;
-  border-radius: 999px;
-  margin-bottom: 10px;
-}
-
-.popup-divider {
-  height: 1px;
-  background: #e5e7eb;
-  margin-bottom: 10px;
-}
-
-.popup-row {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  padding: 5px 0;
-  border-bottom: 1px dashed #f0f0f0;
-  font-size: 12px;
-  line-height: 1.5;
-}
-
-.popup-row:last-child {
-  border-bottom: none;
-}
-
-.popup-label {
-  color: #6b7280;
-  flex-shrink: 0;
-  margin-right: 12px;
-}
-
-.popup-value {
-  color: #1f2937;
-  font-weight: 500;
-  text-align: right;
-  word-break: break-all;
-}
-
 .BMap_cpyCtrl,
 .anchorBL {
   display: none;

+ 72 - 0
tests/waterFacilityGis.test.mjs

@@ -0,0 +1,72 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+
+import {
+  buildFacilityMapModel,
+  filterFacilityMapModel,
+  findFacilityFeature
+} from '../src/utils/waterFacilityGisModel.js'
+
+const collection = {
+  type: 'FeatureCollection',
+  features: [
+    {
+      type: 'Feature',
+      id: 'plant:1',
+      geometry: { type: 'Point', coordinates: [113.1, 23.1] },
+      properties: { facilityType: 'plant', id: 1, code: 'WP-1', name: '城北水厂', location: '城北路' }
+    },
+    {
+      type: 'Feature',
+      id: 'pipe:2',
+      geometry: { type: 'LineString', coordinates: [[113.2, 23.2], [113.4, 23.4]] },
+      properties: { facilityType: 'pipe', id: 2, code: 'PIPE-2', name: '输水干线', location: '滨江路' }
+    },
+    {
+      type: 'Feature',
+      id: 'user:3',
+      geometry: { type: 'Point', coordinates: [113.5, null] },
+      properties: { facilityType: 'user', id: 3, code: 'USER-3', name: '缺坐标用水户' }
+    }
+  ]
+}
+
+test('facility map model keeps WGS84 coordinate order and omits incomplete features', () => {
+  const model = buildFacilityMapModel(collection)
+
+  assert.deepEqual(model.points.map(item => [item.lng, item.lat]), [[113.1, 23.1]])
+  assert.deepEqual(model.lines[0].points.map(item => [item.lng, item.lat]), [[113.2, 23.2], [113.4, 23.4]])
+})
+
+test('facility type filters produce an observable empty layer state', () => {
+  const model = buildFacilityMapModel(collection)
+  const visible = filterFacilityMapModel(model, { plant: false, pipe: false, user: true })
+
+  assert.equal(visible.points.length, 0)
+  assert.equal(visible.lines.length, 0)
+  assert.equal(visible.isEmpty, true)
+})
+
+test('facility search locates points and line midpoints by name, code, or address', () => {
+  const model = buildFacilityMapModel(collection)
+
+  assert.deepEqual(findFacilityFeature(model, 'WP-1'), {
+    ...model.points[0],
+    kind: 'point'
+  })
+  assert.deepEqual(
+    [findFacilityFeature(model, '滨江路').lng, findFacilityFeature(model, '滨江路').lat],
+    [113.3, 23.3]
+  )
+})
+
+test('GIS page exposes REST-backed right detail drawer and empty layer state', () => {
+  const source = fs.readFileSync('src/views/subSystem/waterSupply/facility/gis.vue', 'utf8')
+
+  assert.match(source, /<el-drawer[\s\S]*?direction="rtl"[\s\S]*?class="detail-drawer"/u)
+  assert.match(source, /getWaterFacilityDetail\(feature\.type, feature\.id\)/u)
+  assert.match(source, /detailLoading/u)
+  assert.match(source, /detailError/u)
+  assert.match(source, /当前筛选下没有可显示的供水设施/u)
+})