Selaa lähdekoodia

refactor(water): share GIS map canvas and status colors

Kazerin 15 tuntia sitten
vanhempi
commit
c7a4aa6cc5

+ 237 - 0
src/components/GisMapCanvas.vue

@@ -0,0 +1,237 @@
+<template>
+  <div ref="mapContainer" class="gis-map-canvas"></div>
+</template>
+
+<script setup>
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { convertCoord } from '@/utils/coordTransform'
+import { getMonitoringPointColor } from '@/utils/gisMapColors'
+
+const props = defineProps({
+  points: { type: Array, default: () => [] },
+  lines: { type: Array, default: () => [] },
+  center: { type: Object, default: () => ({ lng: 110.3965, lat: 28.4638 }) },
+  zoom: { type: Number, default: 15 },
+  minZoom: { type: Number, default: 13 },
+  maxZoom: { type: Number, default: 19 },
+  markerSize: { type: Number, default: 22 },
+  showMarkerName: { type: Boolean, default: true },
+  mapStyleId: { type: String, default: 'a0f4e6e6f9e7a6c5d8e7f7e7f7e7f7e5' }
+})
+
+const emit = defineEmits(['point-click', 'line-click', 'map-click', 'ready', 'error'])
+
+const mapContainer = ref(null)
+const mapError = ref('')
+let mapInstance = null
+let overlays = []
+
+const convertedCenter = computed(() => convertCoord(props.center.lng, props.center.lat))
+
+function waitForBMapGL() {
+  return new Promise((resolve, reject) => {
+    if (window.BMapGL) {
+      resolve(window.BMapGL)
+      return
+    }
+    let attempts = 0
+    const timer = setInterval(() => {
+      attempts += 1
+      if (window.BMapGL) {
+        clearInterval(timer)
+        resolve(window.BMapGL)
+        return
+      }
+      if (attempts >= 100) {
+        clearInterval(timer)
+        reject(new Error('百度地图加载超时'))
+      }
+    }, 200)
+  })
+}
+
+async function initMap() {
+  if (!mapContainer.value) return
+  try {
+    const BMapGL = await waitForBMapGL()
+    mapInstance = new BMapGL.Map(mapContainer.value, { enableMapClick: true })
+    const [lng, lat] = convertedCenter.value
+    mapInstance.centerAndZoom(new BMapGL.Point(lng, lat), props.zoom)
+    mapInstance.enableScrollWheelZoom(true)
+    mapInstance.setMinZoom(props.minZoom)
+    mapInstance.setMaxZoom(props.maxZoom)
+    mapInstance.setMapStyleV2({ styleId: props.mapStyleId })
+    mapInstance.addEventListener('click', () => emit('map-click'))
+    renderScene()
+    emit('ready', mapInstance)
+  } catch (error) {
+    mapError.value = '百度地图初始化失败,请检查网络或地图服务配置。'
+    emit('error', mapError.value)
+  }
+}
+
+function clearScene() {
+  if (!mapInstance) return
+  overlays.forEach((overlay) => {
+    try {
+      mapInstance.removeOverlay(overlay)
+    } catch (_) {
+      // ignore cleanup errors
+    }
+  })
+  overlays = []
+}
+
+function markerHtml(color, name) {
+  const size = props.markerSize
+  const nameHtml = props.showMarkerName && name ? `<div class="marker-text">${escapeHtml(name)}</div>` : ''
+  return `<div class="marker-wrap" style="cursor:pointer"><div style="width:${size}px;height:${size}px;background:${color};border-radius:50%;border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,0.25);margin:0 auto;"></div>${nameHtml}</div>`
+}
+
+function escapeHtml(value) {
+  return String(value ?? '')
+    .replaceAll('&', '&amp;')
+    .replaceAll('<', '&lt;')
+    .replaceAll('>', '&gt;')
+    .replaceAll('"', '&quot;')
+    .replaceAll("'", '&#39;')
+}
+
+function addPointMarker(BMapGL, point) {
+  if (!Number.isFinite(Number(point.lng)) || !Number.isFinite(Number(point.lat))) return
+  const [lng, lat] = convertCoord(point.lng, point.lat)
+  const color = point.color || getMonitoringPointColor(point)
+  const name = point.name || point.equipmentName || point.facilityName || point.label || ''
+  const label = new BMapGL.Label(markerHtml(color, name), {
+    position: new BMapGL.Point(lng, lat),
+    offset: new BMapGL.Size(-props.markerSize / 2 - 3, -props.markerSize - 8)
+  })
+  label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
+  label.addEventListener('click', (event) => {
+    event?.domEvent?.stopPropagation?.()
+    emit('point-click', point)
+  })
+  mapInstance.addOverlay(label)
+  overlays.push(label)
+}
+
+function addLine(BMapGL, line) {
+  const points = (line.points || []).filter((point) => Number.isFinite(Number(point.lng)) && Number.isFinite(Number(point.lat)))
+  if (points.length < 2) return
+  const bmapPoints = points.map((point) => {
+    const [lng, lat] = convertCoord(point.lng, point.lat)
+    return new BMapGL.Point(lng, lat)
+  })
+  const polyline = new BMapGL.Polyline(bmapPoints, {
+    strokeColor: line.color || '#3498db',
+    strokeWeight: line.strokeWeight || 6,
+    strokeOpacity: line.strokeOpacity ?? 0.92
+  })
+  polyline.addEventListener('click', (event) => {
+    event?.domEvent?.stopPropagation?.()
+    emit('line-click', line)
+  })
+  mapInstance.addOverlay(polyline)
+  overlays.push(polyline)
+
+  if (line.name) {
+    const first = points[0]
+    const last = points[points.length - 1]
+    const [lng, lat] = convertCoord((first.lng + last.lng) / 2, (first.lat + last.lat) / 2)
+    const label = new BMapGL.Label(`<div class="pipe-label">${escapeHtml(line.name)}</div>`, {
+      position: new BMapGL.Point(lng, lat),
+      offset: new BMapGL.Size(-28, -8)
+    })
+    label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
+    mapInstance.addOverlay(label)
+    overlays.push(label)
+  }
+}
+
+function renderScene() {
+  if (!mapInstance || !window.BMapGL) return
+  clearScene()
+  const BMapGL = window.BMapGL
+  props.lines.forEach((line) => addLine(BMapGL, line))
+  props.points.forEach((point) => addPointMarker(BMapGL, point))
+}
+
+function fitBounds() {
+  if (!mapInstance || !window.BMapGL) return
+  const points = [
+    ...props.points.map((point) => ({ lng: point.lng, lat: point.lat })),
+    ...props.lines.flatMap((line) => line.points || [])
+  ].filter((point) => Number.isFinite(Number(point.lng)) && Number.isFinite(Number(point.lat)))
+  if (!points.length) return
+  const bmapPoints = points.map((point) => {
+    const [lng, lat] = convertCoord(point.lng, point.lat)
+    return new window.BMapGL.Point(lng, lat)
+  })
+  mapInstance.setViewport(bmapPoints, { margins: [70, 70, 70, 70] })
+}
+
+function locatePoint(point) {
+  if (!mapInstance || !window.BMapGL || !Number.isFinite(Number(point?.lng)) || !Number.isFinite(Number(point?.lat))) return
+  const [lng, lat] = convertCoord(point.lng, point.lat)
+  mapInstance.centerAndZoom(new window.BMapGL.Point(lng, lat), 17)
+}
+
+function zoomIn() {
+  if (mapInstance) mapInstance.zoomIn()
+}
+
+function zoomOut() {
+  if (mapInstance) mapInstance.zoomOut()
+}
+
+watch(() => [props.points, props.lines], () => {
+  nextTick(renderScene)
+}, { deep: true })
+
+onMounted(async () => {
+  await nextTick()
+  await initMap()
+})
+
+onBeforeUnmount(() => {
+  clearScene()
+  try {
+    mapInstance?.destroy()
+  } catch (_) {
+    // ignore BMapGL cleanup errors
+  }
+  mapInstance = null
+})
+
+defineExpose({
+  fitBounds,
+  locatePoint,
+  zoomIn,
+  zoomOut,
+  mapError
+})
+</script>
+
+<style scoped>
+.gis-map-canvas {
+  width: 100%;
+  height: 100%;
+  min-height: 420px;
+  background: #e9f0e5;
+}
+
+.marker-text {
+  margin-top: 4px;
+  color: #22405c;
+  font-size: 12px;
+  font-weight: 600;
+  text-align: center;
+  white-space: nowrap;
+}
+
+.pipe-label {
+  color: #2563eb;
+  font-size: 12px;
+  font-weight: 600;
+}
+</style>

+ 44 - 0
src/utils/gisMapColors.js

@@ -0,0 +1,44 @@
+export const gisPointStatusColors = {
+  normal: '#2ecc71',
+  alarm: '#e74c3c',
+  warning: '#f1c40f',
+  workOrder: '#3498db',
+  offline: '#95a5a6'
+}
+
+export const gisPointStatusLabels = {
+  normal: '正常',
+  alarm: '报警',
+  warning: '预警',
+  workOrder: '关联工单',
+  offline: '离线'
+}
+
+export const gisPointStatusTagTypes = {
+  normal: 'success',
+  alarm: 'danger',
+  warning: 'warning',
+  workOrder: 'primary',
+  offline: 'info'
+}
+
+export function getMonitoringPointStatus(point = {}) {
+  const relatedOrderNo = point.relatedOrderNo ?? point.relatedWorkOrderNo ?? point.orderNo
+  if (relatedOrderNo && relatedOrderNo !== '-') return 'workOrder'
+  if (Number(point.alarmStatus) === 1) return 'alarm'
+  if (Number(point.currentStatus) === 3) return 'warning'
+  if (Number(point.onlineStatus) === 0) return 'offline'
+  return 'normal'
+}
+
+export function getMonitoringPointColor(point = {}) {
+  return gisPointStatusColors[getMonitoringPointStatus(point)] || gisPointStatusColors.normal
+}
+
+export function getMonitoringPointLabel(point = {}) {
+  return gisPointStatusLabels[getMonitoringPointStatus(point)] || gisPointStatusLabels.normal
+}
+
+export function getMonitoringPointTagType(point = {}) {
+  return gisPointStatusTagTypes[getMonitoringPointStatus(point)] || gisPointStatusTagTypes.normal
+}

+ 40 - 21
src/views/subSystem/waterSupply/components/WaterAlarmPage.vue

@@ -117,22 +117,27 @@
                            layout="total, sizes, prev, pager, next" :page-sizes="[10,20,50]" @change="loadData"/>
         </el-card>
         <el-card v-else shadow="never" class="water-panel map-card">
-            <div class="card-title"><span><el-icon><MapLocation/></el-icon> 监测报警一张图</span><span class="muted">按报警等级着色</span>
+            <div class="card-title"><span><el-icon><MapLocation/></el-icon> 监测报警一张图</span><span class="muted">按监测点状态着色</span>
             </div>
             <div class="map-workspace">
-                <div class="water-map alarm-map">
-                    <div v-for="point in mapPoints" :key="point.alarmId || point.id" class="alarm-point"
-                         :class="`level-${point.alarmLevel || 1}`" :style="pointStyle(point)"
-                         @click="openDetail(point)"><span></span><b>{{ point.deviceCode || point.name || '报警点' }}</b>
-                    </div>
-                    <el-empty v-if="!mapPoints.length" description="暂无报警点位"/>
-                </div>
+                <GisMapCanvas
+                    ref="gisMapRef"
+                    :points="gisMapPoints"
+                    :center="{ lng: 110.3965, lat: 28.4638 }"
+                    :zoom="15"
+                    @point-click="openDetail"
+                    @map-click="detailVisible = false"
+                    @error="mapError = $event"
+                />
+                <el-alert v-if="mapError" type="warning" show-icon :closable="false" :title="mapError" />
+                <el-empty v-if="!mapPoints.length" description="暂无报警点位"/>
                 <aside class="map-side-panel"><h3>报警图层与图例</h3>
                     <div class="map-legend">
-                        <div class="map-legend-item"><i class="map-legend-dot level-dot-1"/>一级报警</div>
-                        <div class="map-legend-item"><i class="map-legend-dot level-dot-2"/>二级报警</div>
-                        <div class="map-legend-item"><i class="map-legend-dot level-dot-3"/>三级报警</div>
-                        <div class="map-legend-item"><i class="map-legend-dot level-dot-4"/>四级报警</div>
+                        <div class="map-legend-item"><i class="map-legend-dot status-dot-normal"/>正常</div>
+                        <div class="map-legend-item"><i class="map-legend-dot status-dot-alarm"/>报警</div>
+                        <div class="map-legend-item"><i class="map-legend-dot status-dot-warning"/>预警</div>
+                        <div class="map-legend-item"><i class="map-legend-dot status-dot-work-order"/>关联工单</div>
+                        <div class="map-legend-item"><i class="map-legend-dot status-dot-offline"/>离线</div>
                     </div>
                     <div class="alarm-detail"><h3>选中报警</h3>
                         <template v-if="selectedPoint.id || selectedPoint.alarmId">
@@ -268,6 +273,8 @@
         listRealtimeAlarms,
         resolveAlarm
     } from '@/api/pipeNetwork/waterSupply'
+    import GisMapCanvas from '@/components/GisMapCanvas.vue'
+    import { getMonitoringPointColor } from '@/utils/gisMapColors'
     import {normalizeMapPoints, normalizePage, unwrapResponse} from '@/utils/waterSupplyModel'
     import {getRequestErrorMessage} from '@/utils/waterFacilityQuery'
 
@@ -281,6 +288,8 @@
     const stats = ref({});
     const realtime = ref([]);
     const mapPoints = ref([]);
+    const mapError = ref('')
+    const gisMapRef = ref(null)
     const loading = ref(false);
     const detailVisible = ref(false);
     const detail = ref({});
@@ -295,6 +304,14 @@
         label: '四级',
         value: 4
     }]
+    const gisMapPoints = computed(() => mapPoints.value.map(point => ({
+        ...point,
+        lng: point._longitude ?? point.longitude,
+        lat: point._latitude ?? point.latitude,
+        name: point.deviceCode || point.name || '报警点',
+        color: getMonitoringPointColor(point)
+    })))
+
     const mapAlarmCount = computed(() => mapPoints.value.length);
     const mapCriticalCount = computed(() => mapPoints.value.filter(point => Number(point.alarmLevel) <= 2).length);
     const mapResolvedCount = computed(() => mapPoints.value.filter(point => Number(point.alarmStatus) === 1).length);
@@ -396,7 +413,6 @@
         }
     }
 
-    const pointStyle = point => ({left: `${point.mapX ?? 50}%`, top: `${point.mapY ?? 50}%`})
 
     onMounted(() => {
         loadData()
@@ -662,22 +678,25 @@
         background-size: 40px 40px;
     }
 
-    .alarm-page .level-dot-1 {
-        background: #f56c6c;
+    .alarm-page .status-dot-normal {
+        background: #2ecc71;
     }
 
-    .alarm-page .level-dot-2 {
-        background: #e6a23c;
+    .alarm-page .status-dot-alarm {
+        background: #e74c3c;
     }
 
-    .alarm-page .level-dot-3 {
-        background: #409eff;
+    .alarm-page .status-dot-warning {
+        background: #f1c40f;
     }
 
-    .alarm-page .level-dot-4 {
-        background: #67c23a;
+    .alarm-page .status-dot-work-order {
+        background: #3498db;
     }
 
+    .alarm-page .status-dot-offline {
+        background: #95a5a6;
+    }
     .alarm-page .alarm-detail {
         margin-top: 24px;
         padding-top: 16px;

+ 36 - 120
src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue

@@ -47,7 +47,8 @@
           <el-checkbox-group v-model="statusFilters" class="status-filter">
             <el-checkbox value="normal">正常</el-checkbox>
             <el-checkbox value="alarm">报警</el-checkbox>
-            <el-checkbox value="maintenance">维修</el-checkbox>
+            <el-checkbox value="warning">预警</el-checkbox>
+            <el-checkbox value="workOrder">关联工单</el-checkbox>
             <el-checkbox value="offline">离线</el-checkbox>
           </el-checkbox-group>
         </div>
@@ -73,7 +74,8 @@
           <h3>图例</h3>
           <div class="legend-row"><i class="legend-dot normal"></i>正常设备</div>
           <div class="legend-row"><i class="legend-dot alarm"></i>报警设备</div>
-          <div class="legend-row"><i class="legend-dot maintenance"></i>维修设备</div>
+          <div class="legend-row"><i class="legend-dot warning"></i>预警设备</div>
+          <div class="legend-row"><i class="legend-dot workOrder"></i>关联工单设备</div>
           <div class="legend-row"><i class="legend-dot offline"></i>离线设备</div>
         </div>
 
@@ -96,7 +98,14 @@
       </aside>
 
       <main class="map-panel" v-loading="loading">
-        <div ref="mapContainer" class="map-container"></div>
+        <GisMapCanvas
+          ref="gisMapRef"
+          :points="mapPoints"
+          :center="{ lng: 110.3965, lat: 28.4638 }"
+          :zoom="15"
+          @point-click="openDetail($event.equipmentId)"
+          @error="mapError = $event"
+        />
         <el-alert v-if="mapError" class="map-error" type="warning" show-icon :closable="false" :title="mapError" />
       </main>
     </div>
@@ -157,8 +166,10 @@
 </template>
 
 <script setup>
-import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { computed, nextTick, onMounted, ref } from 'vue'
 import { Aim, Refresh, Search } from '@element-plus/icons-vue'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import { getMonitoringPointColor, getMonitoringPointLabel, getMonitoringPointStatus, getMonitoringPointTagType } from '@/utils/gisMapColors'
 import {
   getDeviceGisAbnormalPoints,
   getDeviceGisAllPoints,
@@ -170,22 +181,19 @@ import { unwrapResponse } from '@/utils/waterSupplyModel'
 
 const searchKeyword = ref('')
 const selectedTypeId = ref('')
-const statusFilters = ref(['normal', 'alarm', 'maintenance', 'offline'])
+const statusFilters = ref(['normal', 'alarm', 'warning', 'workOrder', 'offline'])
 const allDevices = ref([])
 const abnormalDevices = ref([])
 const gisStatistics = ref({})
 const loading = ref(false)
 const loadError = ref('')
 const mapError = ref('')
-const mapContainer = ref(null)
+const gisMapRef = ref(null)
 const detailVisible = ref(false)
 const detailLoading = ref(false)
 const detailError = ref('')
 const selectedDetail = ref(null)
 
-let mapInstance = null
-let overlays = []
-
 const typeOptions = computed(() => {
   const typeMap = new Map()
   allDevices.value.forEach((device) => {
@@ -219,6 +227,13 @@ const filteredDevices = computed(() => {
   })
 })
 
+const mapPoints = computed(() => filteredDevices.value.map((device) => ({
+  ...device,
+  lng: device._longitude,
+  lat: device._latitude,
+  name: device.equipmentName || device.equipmentCode
+})))
+
 const latestDataEntries = computed(() => {
   const data = selectedDetail.value?.latestData || {}
   return Object.entries(data).map(([key, value]) => ({
@@ -243,27 +258,12 @@ const maintainLabels = {
   maintainPerson: '维修人员'
 }
 
-
-function escapeHtml(value) {
-  return String(value ?? '')
-    .replaceAll('&', '&amp;')
-    .replaceAll('<', '&lt;')
-    .replaceAll('>', '&gt;')
-    .replaceAll('"', '&quot;')
-    .replaceAll("'", '&#39;')
-}
 function getStatusKey(device) {
-  if (Number(device.alarmStatus) === 1) return 'alarm'
-  if (Number(device.currentStatus) === 3) return 'maintenance'
-  return Number(device.onlineStatus) === 1 ? 'normal' : 'offline'
+  return getMonitoringPointStatus(device)
 }
 
 function getStatusStyle(device) {
-  const key = getStatusKey(device)
-  if (key === 'alarm') return { color: '#f56c6c', text: '报警' }
-  if (key === 'maintenance') return { color: '#e6a23c', text: '维修' }
-  if (key === 'offline') return { color: '#909399', text: '离线' }
-  return { color: '#67c23a', text: '正常' }
+  return { color: getMonitoringPointColor(device), text: getMonitoringPointLabel(device) }
 }
 
 function statusText(device) {
@@ -271,11 +271,7 @@ function statusText(device) {
 }
 
 function statusTagType(device) {
-  const key = getStatusKey(device)
-  if (key === 'alarm') return 'danger'
-  if (key === 'maintenance') return 'warning'
-  if (key === 'offline') return 'info'
-  return 'success'
+  return getMonitoringPointTagType(device)
 }
 
 function deviceStatusTagType(status) {
@@ -299,8 +295,7 @@ async function loadData() {
     abnormalDevices.value = normalizeDeviceGisPoints(abnormalResponse).map(normalizeDeviceRow)
     gisStatistics.value = unwrapResponse(statisticsResponse) || {}
     await nextTick()
-    renderMarkers()
-    fitBounds()
+    gisMapRef.value?.fitBounds()
   } catch (error) {
     loadError.value = '监测设备数据加载失败,请点击右上角“刷新”重试'
   } finally {
@@ -308,69 +303,12 @@ async function loadData() {
   }
 }
 
-function waitForBMapGL() {
-  return new Promise((resolve, reject) => {
-    if (window.BMapGL) {
-      resolve(window.BMapGL)
-      return
-    }
-    let attempts = 0
-    const timer = setInterval(() => {
-      attempts += 1
-      if (window.BMapGL) {
-        clearInterval(timer)
-        resolve(window.BMapGL)
-        return
-      }
-      if (attempts >= 100) {
-        clearInterval(timer)
-        reject(new Error('百度地图加载超时'))
-      }
-    }, 200)
-  })
-}
-
-async function initMap() {
-  if (!mapContainer.value) return
-  try {
-    const BMapGL = await waitForBMapGL()
-    mapInstance = new BMapGL.Map(mapContainer.value, { enableMapClick: true })
-    mapInstance.centerAndZoom(new BMapGL.Point(110.3965, 28.4638), 15)
-    mapInstance.enableScrollWheelZoom(true)
-    mapInstance.setMapStyleV2({ styleId: 'a0f4e6e6f9e7a6c5d8e7f7e7f7e7f7e5' })
-  } catch (error) {
-    mapError.value = '百度地图加载失败,请检查地图脚本或网络连接。'
-  }
-}
-
-function renderMarkers() {
-  if (!mapInstance || !window.BMapGL) return
-  overlays.forEach((overlay) => mapInstance.removeOverlay(overlay))
-  overlays = []
-
-  filteredDevices.value.forEach((device) => {
-    const BMapGL = window.BMapGL
-    const point = new BMapGL.Point(device._longitude, device._latitude)
-    const style = getStatusStyle(device)
-    const label = new BMapGL.Label(
-      `<div style="min-width:64px;padding:6px 9px;border:1px solid ${style.color};background:rgba(255,255,255,.94);border-radius:8px;box-shadow:0 4px 14px rgba(15,72,133,.16);font:12px/1.3 sans-serif;color:#22405c;text-align:center;cursor:pointer"><b style="display:block;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(device.equipmentName || device.equipmentCode)}</b><span style="color:${style.color};font-weight:700">${style.text}</span></div>`,
-      { position: point, offset: new BMapGL.Size(-46, -40) }
-    )
-    label.addEventListener('click', () => openDetail(device.equipmentId))
-    mapInstance.addOverlay(label)
-    overlays.push(label)
-  })
-}
-
 function fitBounds() {
-  if (!mapInstance || !window.BMapGL || !filteredDevices.value.length) return
-  const points = filteredDevices.value.map((device) => new window.BMapGL.Point(device._longitude, device._latitude))
-  mapInstance.setViewport(points, { margins: [70, 70, 70, 70] })
+  gisMapRef.value?.fitBounds()
 }
 
 function locateDevice(device) {
-  if (!mapInstance || !window.BMapGL) return
-  mapInstance.centerAndZoom(new window.BMapGL.Point(device._longitude, device._latitude), 17)
+  gisMapRef.value?.locatePoint({ lng: device._longitude, lat: device._latitude })
   openDetail(device.equipmentId)
 }
 
@@ -391,25 +329,7 @@ async function openDetail(equipmentId) {
   }
 }
 
-watch([searchKeyword, selectedTypeId, statusFilters], () => {
-  renderMarkers()
-})
-
-onMounted(async () => {
-  await initMap()
-  await loadData()
-})
-
-onBeforeUnmount(() => {
-  overlays.forEach((overlay) => mapInstance?.removeOverlay(overlay))
-  overlays = []
-  try {
-    mapInstance?.destroy()
-  } catch (error) {
-    // Ignore BMapGL cleanup errors during route leave.
-  }
-  mapInstance = null
-})
+onMounted(loadData)
 </script>
 
 <style scoped>
@@ -547,15 +467,11 @@ onBeforeUnmount(() => {
   height: 10px;
   border-radius: 50%;
 }
-.legend-dot.normal,
-.legend-dot.alarm,
-.legend-dot.maintenance,
-.legend-dot.offline {
-  background: #67c23a;
-}
-.legend-dot.alarm { background: #f56c6c; }
-.legend-dot.maintenance { background: #e6a23c; }
-.legend-dot.offline { background: #909399; }
+.legend-dot.normal { background: #2ecc71; }
+.legend-dot.alarm { background: #e74c3c; }
+.legend-dot.warning { background: #f1c40f; }
+.legend-dot.workOrder { background: #3498db; }
+.legend-dot.offline { background: #95a5a6; }
 .result-section {
   flex: 1;
   min-height: 0;

+ 35 - 236
src/views/subSystem/waterSupply/facility/gis.vue

@@ -94,7 +94,17 @@
           </div>
           <div class="toolbar-right">当前坐标: {{ currentCoordinate }}</div>
         </div>
-        <div id="gisMap" ref="mapRef" class="gis-map"></div>
+        <GisMapCanvas
+          ref="gisMapRef"
+          :points="mapPoints"
+          :lines="mapLines"
+          :center="centerPoint"
+          :zoom="15"
+          @point-click="openFacilityDetail"
+          @line-click="openFacilityDetail"
+          @map-click="detailOpen = false"
+          @error="mapError = $event"
+        />
         <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>
@@ -130,9 +140,9 @@
 
 <script setup>
 import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
-import { FullScreen, Location, MapLocation, Minus, Plus, RefreshRight, Search } from '@element-plus/icons-vue'
+import { FullScreen, Location, MapLocation, RefreshRight, Search } from '@element-plus/icons-vue'
 import { ElMessage } from 'element-plus'
-import { convertCoord } from '@/utils/coordTransform'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
 import { getWaterFacilityOverview, getWaterFacilityFeatures, getWaterFacilityDetail } from '@/api/pipeNetwork/waterSupply'
 import { normalizeGeoJson, unwrapResponse } from '@/utils/waterSupplyModel'
 import { getRequestErrorMessage } from '@/utils/waterFacilityQuery'
@@ -140,14 +150,6 @@ import { buildFacilityMapModel, filterFacilityMapModel, findFacilityFeature } fr
 
 const centerPoint = { lng: 110.386, lat: 28.445 }
 
-const MAP_BOUNDS = {
-  southWest: { lng: 110.355, lat: 28.428 },
-  northEast: { lng: 110.410, lat: 28.460 }
-}
-const MAP_MIN_ZOOM = 13
-const MAP_MAX_ZOOM = 19
-const MAP_DEFAULT_ZOOM = 15
-
 const defaultLayerState = () => ({
   source: true,
   plant: true,
@@ -180,8 +182,8 @@ const labelMap = {
   user: '用水户'
 }
 
-const mapRef = ref(null)
 const screenRef = ref(null)
+const gisMapRef = ref(null)
 const searchKeyword = ref('')
 const currentCoordinate = ref(`${centerPoint.lng}, ${centerPoint.lat}`)
 const mapError = ref('')
@@ -196,9 +198,6 @@ const detailError = ref('')
 const detailRecord = ref(null)
 const detailTarget = ref(null)
 
-let mapInstance = null
-let overlayClicked = false
-
 const typeCount = type => {
   const row = facilityOverview.value.find(item => item.facilityType === type)
   return Number(row?.totalCount ?? row?.count ?? 0)
@@ -220,216 +219,32 @@ const detailEntries = computed(() => Object.entries(detailRecord.value || {})
   .filter(([, value]) => value !== null && value !== undefined && typeof value !== 'object')
   .map(([label, value]) => ({ label, value: value === '' ? '-' : value })))
 
+const mapPoints = computed(() => visibleMapModel.value.points.map(point => ({
+  ...point,
+  color: colorMap[point.type] || '#457b9d',
+  name: point.name || point.code || '供水设施'
+})))
+
+const mapLines = computed(() => visibleMapModel.value.lines.map(line => ({
+  ...line,
+  color: colorMap[line.type] || '#fcbf49',
+  name: line.name || '供水管网'
+})))
+
 watch(layerState, () => {
-  renderMapScene()
+  gisMapRef.value?.fitBounds()
 }, { deep: true })
 
 onMounted(async () => {
   await nextTick()
   await loadGisData()
-  waitForBMapGL()
   document.addEventListener('fullscreenchange', handleFullscreenChange)
 })
 
 onBeforeUnmount(() => {
   document.removeEventListener('fullscreenchange', handleFullscreenChange)
-  if (mapInstance) {
-    mapInstance.clearOverlays()
-    mapInstance = null
-  }
 })
 
-function waitForBMapGL() {
-  if (typeof BMapGL !== 'undefined') {
-    initMap()
-    return
-  }
-
-  let attempts = 0
-  const maxAttempts = 50
-  const timer = setInterval(() => {
-    attempts++
-    if (typeof BMapGL !== 'undefined') {
-      clearInterval(timer)
-      initMap()
-      return
-    }
-    if (attempts >= maxAttempts) {
-      clearInterval(timer)
-      mapError.value = '百度地图脚本加载超时,请刷新页面重试。'
-    }
-  }, 200)
-}
-
-function initMap() {
-  const el = document.getElementById('gisMap')
-  if (!el) {
-    mapError.value = '地图容器未找到。'
-    return
-  }
-
-  try {
-    mapInstance = new BMapGL.Map('gisMap', { enableMapClick: true })
-  } catch (e) {
-    console.error('创建地图实例失败:', e)
-    mapError.value = '百度地图初始化失败,请检查AK和网络连接。'
-    return
-  }
-
-  try {
-    const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
-    const centerBMapPoint = new BMapGL.Point(clng, clat)
-    mapInstance.centerAndZoom(centerBMapPoint, MAP_DEFAULT_ZOOM)
-    mapInstance.enableScrollWheelZoom(true)
-  } catch (e) {
-    console.error('设置地图中心失败:', e)
-  }
-
-  try {
-    if (typeof mapInstance.setMinZoom === 'function') {
-      mapInstance.setMinZoom(MAP_MIN_ZOOM)
-    }
-    if (typeof mapInstance.setMaxZoom === 'function') {
-      mapInstance.setMaxZoom(MAP_MAX_ZOOM)
-    }
-  } catch (e) {
-    console.warn('设置缩放限制失败:', e)
-  }
-
-  try {
-    if (typeof mapInstance.setMaxBounds === 'function') {
-      const sw = new BMapGL.Point(MAP_BOUNDS.southWest.lng, MAP_BOUNDS.southWest.lat)
-      const ne = new BMapGL.Point(MAP_BOUNDS.northEast.lng, MAP_BOUNDS.northEast.lat)
-      mapInstance.setMaxBounds(new BMapGL.Bounds(sw, ne))
-    }
-  } catch (e) {
-    console.warn('设置地图范围限制失败:', e)
-  }
-
-  mapInstance.addEventListener('click', () => {
-    if (overlayClicked) {
-      overlayClicked = false
-      return
-    }
-    detailOpen.value = false
-  })
-
-  mapInstance.addEventListener('mousemove', (event) => {
-    try {
-      if (event && event.latlng) {
-        currentCoordinate.value = `${event.latlng.lng.toFixed(4)}, ${event.latlng.lat.toFixed(4)}`
-      }
-    } catch (_) { /* ignore mousemove errors */ }
-  })
-
-  try {
-    renderMapScene()
-  } catch (e) {
-    console.error('渲染地图要素失败:', e)
-    mapError.value = '地图要素渲染失败。'
-  }
-}
-
-function renderMapScene() {
-  if (!mapInstance || typeof BMapGL === 'undefined') return
-
-  try { mapInstance.clearOverlays() } catch (_) { /* ignore */ }
-  try { drawPipelines() } catch (e) { console.warn('绘制供水管网失败:', e) }
-  try { drawPoints() } catch (e) { console.warn('绘制供水设施失败:', e) }
-}
-
-function drawPipelines() {
-  pipelineData.value.forEach(item => {
-    try {
-      if (!layerState[item.type]) return
-      const pts = item.points.map(p => { const [lng, lat] = convertCoord(p.lng, p.lat); return new BMapGL.Point(lng, lat) })
-      const polyline = new BMapGL.Polyline(pts, {
-        strokeColor: colorMap[item.type],
-        strokeWeight: 6,
-        strokeOpacity: 0.92
-      })
-      try {
-        polyline.addEventListener('click', (e) => {
-          overlayClicked = true
-          e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
-          openFacilityDetail(item)
-        })
-      } catch (_) { /* polyline click listener failed */ }
-      mapInstance.addOverlay(polyline)
-
-      const rawMid = getMidPoint(item.points)
-      const mid = { lng: convertCoord(rawMid.lng, rawMid.lat)[0], lat: convertCoord(rawMid.lng, rawMid.lat)[1] }
-      addPipeLabel(mid.lng, mid.lat, item.name, colorMap[item.type], item)
-    } catch (e) {
-      console.warn(`绘制供水管网 ${item.name} 失败:`, e)
-    }
-  })
-}
-
-function drawPoints() {
-  pointData.value.forEach(item => {
-    try {
-      if (!layerState[item.type]) return
-      const [plng, plat] = convertCoord(item.lng, item.lat)
-      addPointMarker(plng, plat, colorMap[item.type], item.name, 'dot', () => {
-        openFacilityDetail(item)
-      })
-    } catch (e) {
-      console.warn(`绘制供水设施 ${item.name} 失败:`, e)
-    }
-  })
-}
-
-function addPipeLabel(lng, lat, text, color, rawItem) {
-  const point = new BMapGL.Point(lng, lat)
-  const html = `<div class="pipe-label" style="color:${color}">${text}</div>`
-  const label = new BMapGL.Label(html, {
-    position: point,
-    offset: new BMapGL.Size(-28, -8)
-  })
-  label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
-  try {
-    label.addEventListener('click', (e) => {
-      overlayClicked = true
-      e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
-      openFacilityDetail(rawItem)
-    })
-  } catch (_) { /* label click listener failed */ }
-  mapInstance.addOverlay(label)
-}
-
-function addPointMarker(lng, lat, color, name, shape, onClick) {
-  const point = new BMapGL.Point(lng, lat)
-  const size = 22
-  const borderRadius = shape === 'square' ? '3px' : '50%'
-  const html = `<div class="marker-wrap" style="cursor:pointer">
-    <div style="width:${size}px;height:${size}px;background:${color};border-radius:${borderRadius};border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,0.25);margin:0 auto;"></div>
-    <div class="marker-text">${name}</div>
-  </div>`
-  const label = new BMapGL.Label(html, {
-    position: point,
-    offset: new BMapGL.Size(-size / 2 - 3, -size - 8)
-  })
-  label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
-  try {
-    label.addEventListener('click', (e) => {
-      overlayClicked = true
-      e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
-      onClick && onClick()
-    })
-  } catch (_) { /* marker click listener failed */ }
-  mapInstance.addOverlay(label)
-}
-
-function getMidPoint(points) {
-  const first = points[0]
-  const last = points[points.length - 1]
-  return {
-    lng: (first.lng + last.lng) / 2,
-    lat: (first.lat + last.lat) / 2
-  }
-}
-
 async function loadGisData() {
   mapError.value = ''
   dataLoaded.value = false
@@ -445,7 +260,8 @@ async function loadGisData() {
     pipelineData.value = model.lines
     pointData.value = model.points
     dataLoaded.value = true
-    if (mapInstance && typeof BMapGL !== 'undefined') renderMapScene()
+    await nextTick()
+    gisMapRef.value?.fitBounds()
   } catch (e) {
     console.error('加载供水GIS数据失败', e)
     facilityOverview.value = []
@@ -470,13 +286,8 @@ function handleSearch() {
     return
   }
 
-  const [mlng, mlat] = convertCoord(matched.lng, matched.lat)
-  if (mapInstance && typeof BMapGL !== 'undefined') {
-    mapInstance.centerAndZoom(new BMapGL.Point(mlng, mlat), 18)
-  }
-
+  gisMapRef.value?.locatePoint(matched)
   openFacilityDetail(matched)
-
   ElMessage.success(`已定位到 ${matched.name}`)
 }
 
@@ -516,23 +327,15 @@ async function refreshScene() {
 }
 
 function locateCenter() {
-  if (!mapInstance) return
-  const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
-  mapInstance.centerAndZoom(new BMapGL.Point(clng, clat), MAP_DEFAULT_ZOOM)
+  gisMapRef.value?.locatePoint(centerPoint)
 }
 
 function zoomIn() {
-  if (!mapInstance) return
-  const z = mapInstance.getZoom()
-  if (z < MAP_MAX_ZOOM) mapInstance.zoomIn()
-  else ElMessage.info('已放大至最大级别')
+  gisMapRef.value?.zoomIn()
 }
 
 function zoomOut() {
-  if (!mapInstance) return
-  const z = mapInstance.getZoom()
-  if (z > MAP_MIN_ZOOM) mapInstance.zoomOut()
-  else ElMessage.info('已缩小至最小级别')
+  gisMapRef.value?.zoomOut()
 }
 
 function toggleFullscreen() {
@@ -542,15 +345,11 @@ function toggleFullscreen() {
     target.requestFullscreen?.()
     return
   }
-  document.exitFullscreen?.()
+  document.exitFullscreen()
 }
 
 function handleFullscreenChange() {
-  if (!mapInstance) return
-  const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
-  setTimeout(() => {
-    mapInstance.centerAndZoom(new BMapGL.Point(clng, clat), mapInstance.getZoom())
-  }, 200)
+  gisMapRef.value?.locatePoint(centerPoint)
 }
 </script>
 

+ 69 - 0
tests/gisMapCanvas.test.mjs

@@ -0,0 +1,69 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { getMonitoringPointColor, getMonitoringPointLabel, getMonitoringPointStatus, getMonitoringPointTagType } from '../src/utils/gisMapColors.js'
+
+test('shared gis map canvas converts WGS84 coordinates before BMapGL rendering', async () => {
+  const source = await readFile('src/components/GisMapCanvas.vue', 'utf8')
+
+  assert.match(source, /import \{ convertCoord \} from '@\/utils\/coordTransform'/u)
+  assert.match(source, /const \[lng, lat\] = convertCoord\(point\.lng, point\.lat\)/u)
+  assert.match(source, /new BMapGL\.Point\(lng, lat\)/u)
+  assert.match(source, /mapInstance\.setViewport\(bmapPoints/u)
+  assert.doesNotMatch(source, /new BMapGL\.Point\(point\.lng, point\.lat\)/u)
+})
+
+test('shared gis map canvas uses the common point marker contract', async () => {
+  const source = await readFile('src/components/GisMapCanvas.vue', 'utf8')
+
+  assert.match(source, /markerSize: \{ type: Number, default: 22 \}/u)
+  assert.match(source, /border-radius:50%/u)
+  assert.match(source, /border:3px solid #fff/u)
+  assert.match(source, /class="marker-text"/u)
+  assert.match(source, /emit\('point-click', point\)/u)
+})
+
+test('monitoring point colors follow the documented status semantics', () => {
+  assert.equal(getMonitoringPointStatus({ relatedOrderNo: 'WO-1' }), 'workOrder')
+  assert.equal(getMonitoringPointStatus({ relatedWorkOrderNo: 'WO-2' }), 'workOrder')
+  assert.equal(getMonitoringPointStatus({ alarmStatus: 1 }), 'alarm')
+  assert.equal(getMonitoringPointStatus({ currentStatus: 3 }), 'warning')
+  assert.equal(getMonitoringPointStatus({ onlineStatus: 0 }), 'offline')
+  assert.equal(getMonitoringPointStatus({}), 'normal')
+
+  assert.equal(getMonitoringPointColor({ relatedOrderNo: 'WO-1' }), '#3498db')
+  assert.equal(getMonitoringPointColor({ alarmStatus: 1 }), '#e74c3c')
+  assert.equal(getMonitoringPointColor({ currentStatus: 3 }), '#f1c40f')
+  assert.equal(getMonitoringPointColor({ onlineStatus: 0 }), '#95a5a6')
+  assert.equal(getMonitoringPointColor({}), '#2ecc71')
+
+  assert.equal(getMonitoringPointLabel({ currentStatus: 3 }), '预警')
+  assert.equal(getMonitoringPointLabel({ relatedWorkOrderNo: 'WO-2' }), '关联工单')
+  assert.equal(getMonitoringPointTagType({ relatedWorkOrderNo: 'WO-2' }), 'primary')
+  assert.equal(getMonitoringPointTagType({ onlineStatus: 0 }), 'info')
+})
+
+test('water supply GIS pages share the common canvas component', async () => {
+  const deviceSource = await readFile('src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue', 'utf8')
+  const facilitySource = await readFile('src/views/subSystem/waterSupply/facility/gis.vue', 'utf8')
+
+  assert.match(deviceSource, /<GisMapCanvas/u)
+  assert.match(deviceSource, /value="workOrder">关联工单/u)
+  assert.match(deviceSource, /legend-dot warning/u)
+  assert.match(deviceSource, /getMonitoringPointTagType/u)
+  assert.match(deviceSource, /:points="mapPoints"/u)
+  assert.match(deviceSource, /@point-click="openDetail\(\$event\.equipmentId\)"/u)
+
+  assert.match(facilitySource, /<GisMapCanvas/u)
+  assert.match(facilitySource, /:points="mapPoints"/u)
+  assert.match(facilitySource, /:lines="mapLines"/u)
+  assert.match(facilitySource, /@point-click="openFacilityDetail"/u)
+  const alarmSource = await readFile('src/views/subSystem/waterSupply/components/WaterAlarmPage.vue', 'utf8')
+
+  assert.match(alarmSource, /<GisMapCanvas/u)
+  assert.match(alarmSource, /getMonitoringPointColor\(point\)/u)
+  assert.match(alarmSource, /一级报警/u)
+  assert.doesNotMatch(alarmSource, /color: point\.relatedOrderNo \?/u)
+  assert.match(alarmSource, /:points="gisMapPoints"/u)
+  assert.match(alarmSource, /@point-click="openDetail"/u)
+})

+ 5 - 2
tests/waterDeviceGisMap.test.mjs

@@ -33,7 +33,8 @@ test('device gis map exposes water filtering, exception highlight and detail beh
   assert.match(source, /维修/u)
   assert.match(source, /离线/u)
   assert.match(source, /正常/u)
-  assert.match(source, /BMapGL/u)
+  assert.match(source, /<GisMapCanvas/u)
+  assert.match(source, /:points="mapPoints"/u)
   assert.match(source, /el-drawer/u)
   assert.match(source, /设备详情/u)
   assert.match(source, /v-loading/u)
@@ -55,4 +56,6 @@ test('device gis map separates status sources in detail text', async () => {
   assert.match(source, /异常原因/u)
   assert.match(source, /最新数据/u)
   assert.match(source, /维修记录/u)
-})
+  assert.match(source, /selectedDetail\.longitude/u)
+  assert.match(source, /selectedDetail\.latitude/u)
+})