Explorar el Código

fix(water): stabilize facility gis markers and panel

Kazerin hace 1 mes
padre
commit
98afe27a10

+ 25 - 30
src/components/GisMapCanvas.vue

@@ -5,15 +5,12 @@
 <script setup>
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { convertCoord } from '@/utils/coordTransform'
-import { getMonitoringPointColor, getMonitoringPointIcon } 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' }
@@ -58,8 +55,6 @@ async function initMap() {
     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()
@@ -82,36 +77,23 @@ function clearScene() {
   overlays = []
 }
 
-function createMarkerIcon(BMapGL, iconUrl, size) {
-  const icon = new BMapGL.Icon(iconUrl, new BMapGL.Size(size, size), {
-    anchor: new BMapGL.Size(size / 2, size),
-    imageSize: new BMapGL.Size(size, size)
-  })
-  return icon
-}
 
 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 iconUrl = point.iconUrl || getMonitoringPointIcon(point)
   const name = point.name || point.equipmentName || point.facilityName || point.label || ''
   const position = new BMapGL.Point(lng, lat)
-  const marker = new BMapGL.Marker(position, {
-    icon: createMarkerIcon(BMapGL, iconUrl, props.markerSize),
-    enableDragging: false
-  })
+  const marker = new BMapGL.Marker(position)
   marker.addEventListener('click', (event) => {
     event?.domEvent?.stopPropagation?.()
     emit('point-click', point)
   })
   mapInstance.addOverlay(marker)
   overlays.push(marker)
-
   if (props.showMarkerName && name) {
     const label = new BMapGL.Label(`<div class="marker-text">${escapeHtml(name)}</div>`, {
       position,
-      offset: new BMapGL.Size(-90, -props.markerSize - 28)
+      offset: new BMapGL.Size(-90, -props.markerSize - 8)
     })
     label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
     label.addEventListener('click', (event) => {
@@ -133,7 +115,8 @@ function addLine(BMapGL, line) {
   const polyline = new BMapGL.Polyline(bmapPoints, {
     strokeColor: line.color || '#3498db',
     strokeWeight: line.strokeWeight || 6,
-    strokeOpacity: line.strokeOpacity ?? 0.92
+    strokeOpacity: line.strokeOpacity ?? 0.92,
+    strokeStyle: 'solid'
   })
   polyline.addEventListener('click', (event) => {
     event?.domEvent?.stopPropagation?.()
@@ -142,20 +125,32 @@ function addLine(BMapGL, 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)
+  ;[points[0], points[points.length - 1]].forEach((point, index) => {
+    const [lng, lat] = convertCoord(point.lng, point.lat)
+    const position = new BMapGL.Point(lng, lat)
+    const marker = new BMapGL.Marker(position)
+    marker.addEventListener('click', (event) => {
+      event?.domEvent?.stopPropagation?.()
+      emit('line-click', line)
+    })
+    mapInstance.addOverlay(marker)
+    overlays.push(marker)
+
+    if (!props.showMarkerName) return
+    const name = `${line.name || '供水管网'} ${index === 0 ? '起点' : '终点'}`
+    const label = new BMapGL.Label(`<div class="marker-text">${escapeHtml(name)}</div>`, {
+      position,
+      offset: new BMapGL.Size(-90, -props.markerSize - 8)
     })
     label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
+    label.addEventListener('click', (event) => {
+      event?.domEvent?.stopPropagation?.()
+      emit('line-click', line)
+    })
     mapInstance.addOverlay(label)
     overlays.push(label)
-  }
+  })
 }
-
 function renderScene() {
   if (!mapInstance || !window.BMapGL) return
   clearScene()

+ 307 - 415
src/views/subSystem/waterSupply/facility/gis.vue

@@ -1,17 +1,9 @@
 <template>
-  <div class="gis-dashboard">
-    <div class="dashboard-header">
-      <div class="header-title">
-        <div class="title-main">
-          <el-icon><MapLocation /></el-icon>
-          <span>供水设施GIS一张图</span>
-        </div>
-        <div class="title-metrics">
-          <span class="metric metric-source">水源地 {{ summaryStats.sourceCount }}处</span>
-          <span class="metric metric-plant">水厂 {{ summaryStats.plantCount }}座</span>
-          <span class="metric metric-pumpStation">泵站 {{ summaryStats.pumpStationCount }}座</span>
-          <span class="metric metric-pipe">供水管网 {{ summaryStats.pipeCount }}条</span>
-        </div>
+  <div class="water-device-gis facility-gis">
+    <div class="gis-header">
+      <div>
+        <p class="eyebrow">WATER SUPPLY FACILITY GIS</p>
+        <h2>供水设施 GIS 一张图</h2>
       </div>
       <div class="header-actions">
         <el-input
@@ -19,81 +11,73 @@
           class="search-input"
           placeholder="搜索设施名称/编号/地址"
           clearable
+          :prefix-icon="Search"
           @keyup.enter="handleSearch"
-        >
-          <template #prefix>
-            <el-icon><Search /></el-icon>
-          </template>
-        </el-input>
-        <el-button type="primary" @click="handleSearch">
-          <el-icon><Search /></el-icon>
-          搜索
-        </el-button>
-        <el-button @click="refreshScene">
-          <el-icon><RefreshRight /></el-icon>
-          刷新
-        </el-button>
-        <el-button @click="toggleFullscreen">
-          <el-icon><FullScreen /></el-icon>
-          全图
-        </el-button>
+        />
+        <el-select v-model="selectedFacilityType" class="type-select" placeholder="全部设施类型" clearable>
+          <el-option v-for="item in facilityTypes" :key="item.value" :label="item.label" :value="item.value" />
+        </el-select>
+        <el-button type="primary" :icon="Refresh" @click="refreshScene">刷新</el-button>
+        <el-button :icon="Aim" @click="fitBounds">全图</el-button>
       </div>
     </div>
 
-    <div ref="screenRef" class="dashboard-content">
-      <aside class="dashboard-sidebar">
-        <el-card shadow="never" class="panel-card">
-          <template #header>
-            <span>图层控制</span>
-          </template>
-          <div class="layer-group">
-            <div class="layer-title">供水设施图层</div>
-            <el-checkbox v-for="item in facilityTypes" :key="item.value" v-model="layerState[item.value]">{{ item.label }}</el-checkbox>
+    <div class="gis-layout">
+      <aside class="side-panel">
+        <div class="metric-grid">
+          <div class="metric">
+            <span>设施总数</span>
+            <strong>{{ summaryStats.total }}</strong>
           </div>
-        </el-card>
-
-        <el-card shadow="never" class="panel-card">
-          <template #header>
-            <span>图例说明</span>
-          </template>
-          <div class="legend-list">
-            <div class="legend-item" v-for="item in facilityTypes" :key="item.value"><span class="legend-dot" :style="{ background: item.color }"></span><span>{{ item.label }}</span></div>
+          <div class="metric">
+            <span>供水管网</span>
+            <strong>{{ summaryStats.pipeCount }}</strong>
           </div>
-        </el-card>
-
-        <el-card shadow="never" class="panel-card stats-card">
-          <template #header>
-<span>供水设施统计</span>
-          </template>
-          <div class="stats-list">
-            <div class="stats-row"><span>水源地</span><strong>{{ summaryStats.sourceCount }}</strong></div>
-            <div class="stats-row"><span>水厂</span><strong>{{ summaryStats.plantCount }}</strong></div>
-            <div class="stats-row"><span>泵站</span><strong>{{ summaryStats.pumpStationCount }}</strong></div>
-            <div class="stats-row"><span>供水管网</span><strong>{{ summaryStats.pipeCount }}</strong></div>
-            <div class="stats-row"><span>用水户</span><strong>{{ summaryStats.userCount }}</strong></div>
+          <div class="metric">
+            <span>水厂</span>
+            <strong>{{ summaryStats.plantCount }}</strong>
           </div>
-        </el-card>
+          <div class="metric">
+            <span>泵站</span>
+            <strong>{{ summaryStats.pumpStationCount }}</strong>
+          </div>
+        </div>
 
-      </aside>
+        <div class="panel-section">
+          <h3>图层控制</h3>
+          <div class="layer-list">
+            <label v-for="item in facilityTypes" :key="item.value" class="layer-row">
+              <el-checkbox v-model="layerState[item.value]">{{ item.label }}</el-checkbox>
+            </label>
+          </div>
+        </div>
+
+        <div class="panel-section legend-panel">
+          <h3>图例</h3>
+          <div class="legend-row" v-for="item in facilityTypes" :key="item.value">
+            <i class="legend-dot" :style="{ background: item.color }"></i>{{ item.label }}
+          </div>
+        </div>
 
-      <section class="dashboard-map-panel">
-        <div class="map-toolbar">
-          <div class="toolbar-left">
-            <el-button @click="zoomIn">
-              <el-icon><Plus /></el-icon>
-              放大
-            </el-button>
-            <el-button @click="zoomOut">
-              <el-icon><Minus /></el-icon>
-              缩小
-            </el-button>
-            <el-button @click="locateCenter">
-              <el-icon><Location /></el-icon>
-              定位
-            </el-button>
+        <div class="panel-section result-section">
+          <h3>搜索设施</h3>
+          <el-alert v-if="loadError" type="error" :closable="false" show-icon :title="loadError" />
+          <el-empty
+            v-else-if="!filteredFeatures.length"
+            description="暂无可显示的供水设施"
+            :image-size="80"
+          />
+          <div v-else class="device-result-list">
+            <button v-for="feature in filteredFeatures" :key="feature.id || feature.name" type="button" @click="locateFeature(feature)">
+              <span class="device-name">{{ feature.name }}</span>
+              <span class="device-meta">{{ labelMap[feature.type] || '供水设施' }} · {{ feature.code || '-' }}</span>
+              <el-tag size="small" type="primary">{{ labelMap[feature.type] || '供水设施' }}</el-tag>
+            </button>
           </div>
-          <div class="toolbar-right">当前坐标: {{ currentCoordinate }}</div>
         </div>
+      </aside>
+
+      <main class="map-panel" v-loading="loading">
         <GisMapCanvas
           ref="gisMapRef"
           :points="mapPoints"
@@ -103,44 +87,49 @@
           @point-click="openFacilityDetail"
           @line-click="openFacilityDetail"
           @map-click="detailOpen = false"
+          @ready="handleMapReady"
           @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>
-          </el-result>
-        </div>
-        <div v-else-if="mapEmpty" class="map-error">
-          <el-empty description="当前筛选下没有可显示的供水设施" />
-        </div>
-      </section>
+        <el-alert v-if="mapError" class="map-error" type="warning" show-icon :closable="false" :title="mapError" />
+        <el-alert v-else-if="loadError" class="map-error" type="error" show-icon :closable="false" :title="loadError" />
+        <el-empty
+          v-if="dataLoaded && !mapError && !loadError && !visibleFeatures.length"
+          class="map-empty"
+          description="当前筛选下没有可显示的供水设施"
+        />
+      </main>
     </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 v-model="detailOpen" title="设施详情" size="420px" direction="rtl" class="detail-drawer" append-to-body>
+      <div v-loading="detailLoading" class="device-detail">
+        <el-alert v-if="detailError" type="error" show-icon :closable="false" title="设施详情加载失败,请重试" />
+        <template v-if="detailRecord">
+          <section class="detail-hero">
+            <h3>{{ detailTarget?.name || '供水设施' }}</h3>
+            <p>{{ labelMap[detailTarget?.type] || '供水设施' }} · {{ detailTarget?.code || '-' }}</p>
+            <div class="status-tags">
+              <el-tag type="primary">{{ labelMap[detailTarget?.type] || '供水设施' }}</el-tag>
+            </div>
+          </section>
+          <section class="detail-section">
+            <h4>基础信息</h4>
+            <dl>
+              <template v-for="item in detailEntries" :key="item.label">
+                <dt>{{ item.label }}</dt>
+                <dd>{{ item.value }}</dd>
+              </template>
+            </dl>
+          </section>
+        </template>
+        <el-empty v-else-if="!detailLoading && !detailError" description="暂无设施详情" />
+      </div>
     </el-drawer>
   </div>
 </template>
 
 <script setup>
-import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
-import { FullScreen, Location, MapLocation, RefreshRight, Search } from '@element-plus/icons-vue'
+import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
+import { Aim, Refresh, Search } from '@element-plus/icons-vue'
 import { ElMessage } from 'element-plus'
 import GisMapCanvas from '@/components/GisMapCanvas.vue'
 import { getWaterFacilityOverview, getWaterFacilityFeatures, getWaterFacilityDetail } from '@/api/pipeNetwork/waterSupply'
@@ -148,7 +137,7 @@ 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 }
+const centerPoint = { lng: 110.3965, lat: 28.4638 }
 
 const defaultLayerState = () => ({
   source: true,
@@ -182,12 +171,14 @@ const labelMap = {
   user: '用水户'
 }
 
-const screenRef = ref(null)
 const gisMapRef = ref(null)
 const searchKeyword = ref('')
-const currentCoordinate = ref(`${centerPoint.lng}, ${centerPoint.lat}`)
+const selectedFacilityType = ref('')
 const mapError = ref('')
+const loadError = ref('')
 const dataLoaded = ref(false)
+const loading = ref(false)
+const mapReady = ref(false)
 const layerState = reactive(defaultLayerState())
 const facilityOverview = ref([])
 const pipelineData = ref([])
@@ -204,6 +195,7 @@ const typeCount = type => {
 }
 
 const summaryStats = computed(() => ({
+  total: facilityTypes.reduce((sum, item) => sum + typeCount(item.value), 0),
   sourceCount: typeCount('source'),
   plantCount: typeCount('plant'),
   pumpStationCount: typeCount('pumpStation'),
@@ -213,11 +205,20 @@ const summaryStats = computed(() => ({
 
 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 })))
+const visibleFeatures = computed(() => [
+  ...visibleMapModel.value.points.map(item => ({ ...item, kind: 'point' })),
+  ...visibleMapModel.value.lines.map(item => ({ ...item, kind: 'pipeline' }))
+])
+
+const filteredFeatures = computed(() => {
+  const keyword = searchKeyword.value.trim().toLocaleLowerCase()
+  return visibleFeatures.value.filter((feature) => {
+    const matchedType = !selectedFacilityType.value || feature.type === selectedFacilityType.value
+    const matchedKeyword = !keyword || [feature.name, feature.code, feature.road]
+      .some(value => String(value || '').toLocaleLowerCase().includes(keyword))
+    return matchedType && matchedKeyword
+  })
+})
 
 const mapPoints = computed(() => visibleMapModel.value.points.map(point => ({
   ...point,
@@ -231,22 +232,28 @@ const mapLines = computed(() => visibleMapModel.value.lines.map(line => ({
   name: line.name || '供水管网'
 })))
 
+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, () => {
-  gisMapRef.value?.fitBounds()
+  if (mapReady.value) nextTick(() => gisMapRef.value?.fitBounds())
 }, { deep: true })
 
 onMounted(async () => {
   await nextTick()
   await loadGisData()
-  document.addEventListener('fullscreenchange', handleFullscreenChange)
 })
 
-onBeforeUnmount(() => {
-  document.removeEventListener('fullscreenchange', handleFullscreenChange)
-})
+function handleMapReady() {
+  mapReady.value = true
+  if (dataLoaded.value) nextTick(() => gisMapRef.value?.fitBounds())
+}
 
 async function loadGisData() {
+  loading.value = true
   mapError.value = ''
+  loadError.value = ''
   dataLoaded.value = false
   try {
     const [overviewRes, featureRes] = await Promise.all([
@@ -261,17 +268,23 @@ async function loadGisData() {
     pointData.value = model.points
     dataLoaded.value = true
     await nextTick()
-    gisMapRef.value?.fitBounds()
+    if (mapReady.value) gisMapRef.value?.fitBounds()
   } catch (e) {
     console.error('加载供水GIS数据失败', e)
     facilityOverview.value = []
     pipelineData.value = []
     pointData.value = []
     dataLoaded.value = true
-    mapError.value = getRequestErrorMessage(e, '供水GIS数据加载失败,请重试。')
+    loadError.value = getRequestErrorMessage(e, '供水GIS数据加载失败,请重试。')
+  } finally {
+    loading.value = false
   }
 }
 
+function fitBounds() {
+  gisMapRef.value?.fitBounds()
+}
+
 function handleSearch() {
   const keyword = searchKeyword.value.trim()
   if (!keyword) {
@@ -291,6 +304,11 @@ function handleSearch() {
   ElMessage.success(`已定位到 ${matched.name}`)
 }
 
+function locateFeature(feature) {
+  gisMapRef.value?.locatePoint(feature)
+  openFacilityDetail(feature)
+}
+
 async function openFacilityDetail(feature) {
   detailTarget.value = feature
   detailOpen.value = true
@@ -321,357 +339,231 @@ async function refreshScene() {
   layerState.pipe = true
   layerState.user = true
   searchKeyword.value = ''
+  selectedFacilityType.value = ''
   detailOpen.value = false
   await loadGisData()
-  locateCenter()
-}
-
-function locateCenter() {
-  gisMapRef.value?.locatePoint(centerPoint)
-}
-
-function zoomIn() {
-  gisMapRef.value?.zoomIn()
-}
-
-function zoomOut() {
-  gisMapRef.value?.zoomOut()
-}
-
-function toggleFullscreen() {
-  const target = screenRef.value
-  if (!target) return
-  if (!document.fullscreenElement) {
-    target.requestFullscreen?.()
-    return
-  }
-  document.exitFullscreen()
-}
-
-function handleFullscreenChange() {
-  gisMapRef.value?.locatePoint(centerPoint)
+  fitBounds()
 }
 </script>
 
-
 <style scoped>
-.gis-dashboard {
-  height: calc(100vh - 84px);
-  padding: 16px;
-  background: #f2f5f9;
-  display: flex;
-  flex-direction: column;
-  gap: 0;
+.water-device-gis {
+  min-height: 100%;
+  padding: 18px;
+  background: linear-gradient(180deg, #edf6ff 0%, #f5f7fb 100%);
+  color: #1f3b57;
 }
-
-.dashboard-header {
+.gis-header {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  gap: 16px;
-  padding: 10px 16px;
-  margin-bottom: 14px;
-  background: #fff;
-  border-radius: 10px;
-  box-shadow: 0 4px 16px rgba(15, 35, 95, 0.05);
-  flex-shrink: 0;
-}
-
-.header-title {
-  display: flex;
-  align-items: center;
-  gap: 20px;
-  flex-wrap: wrap;
-}
-
-.title-main {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  color: #1f2d3d;
-  font-size: 17px;
-  font-weight: 700;
-}
-
-.title-metrics {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 16px;
-  margin: 8px 0;
-  align-items: center;
-  margin-bottom: 8px;
-}
-
-.metric {
-  display: inline-flex;
-  align-items: center;      /* 垂直居中 */
-  padding: 12px 16px;          /* 左右内边距 */
-  height: auto;
-  min-height: 48px;
-  font-size: 16px;
-  line-height: 1.5;
-  border-radius: 8px;
-  min-width: 100px;
-  white-space: nowrap;
-  background: rgba(255, 255, 255, 0.06);
-  border: 1px solid rgba(255, 255, 255, 0.12);
-  color: #ecf6ff;
-  box-shadow: 0 1px 8px rgba(0, 0, 0, 0.25);
-  transition: all 0.25s ease;
-  cursor: default;          /* 如果希望可点击,可改 pointer */
-  backdrop-filter: blur(4px); /* 毛玻璃效果(可选,需浏览器支持) */
-}
-
-.metric::before {
-  content: '';
-  position: absolute;
-  left: 0;
-  top: 50%;
-  width: 9px;
-  height: 9px;
-  border-radius: 50%;
-  transform: translateY(-50%);
-}
-
-/* 语义颜色强化 */
-.metric-source {
-  background: rgba(69, 123, 157, 0.8);
-  border-color: rgba(69, 123, 157, 0.6);
-  color: #F0F4F6;
-}
-.metric-plant {
-  background: rgba(42, 157, 143, 0.8);
-  border-color: rgba(42, 157, 143, 0.6);
-  color: #F0F4F6;
-}
-.metric-pumpStation {
-  background: rgba(231, 111, 81, 0.8);
-  border-color: rgba(231, 111, 81, 0.6);
-  color: #F0F4F6;
+  gap: 18px;
+  margin-bottom: 16px;
 }
-.metric-pipe {
-  background: rgba(252, 191, 73, 0.8);
-  border-color: rgba(252, 191, 73, 0.6);
-  color: #F0F4F6;
+.eyebrow {
+  margin: 0 0 3px;
+  font-size: 11px;
+  letter-spacing: .12em;
+  color: #5c93c7;
 }
-
-.metric:hover {
-  transform: translateY(-2px);
-  background: rgba(0, 149, 253, 0.5);
-  border-color: rgba(0, 149, 253, 0.5);
-  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
+.gis-header h2 {
+  margin: 0;
+  font-size: 22px;
+  color: #173a5e;
 }
-
 .header-actions {
   display: flex;
   align-items: center;
   gap: 10px;
 }
-
-.search-input {
-  width: 300px;
+.search-input,
+.type-select {
+  width: 240px;
 }
-
-.dashboard-content {
-  flex: 1;
-  display: flex;
-  gap: 14px;
-  min-height: 0;
+.gis-layout {
+  display: grid;
+  grid-template-columns: 310px minmax(0, 1fr);
+  gap: 16px;
+  align-items: start;
 }
-
-.dashboard-sidebar {
-  width: 320px;
+.side-panel,
+.map-panel {
+  position: relative;
+  height: calc(100vh - 140px);
+  min-height: 620px;
+  overflow: hidden;
+}
+.side-panel {
   display: flex;
   flex-direction: column;
   gap: 14px;
-  flex-shrink: 0;
-  overflow-y: auto;
+  padding: 16px;
+  overflow: visible;
 }
-
-.panel-card {
-  border: none;
-  border-radius: 14px;
-  box-shadow: 0 4px 18px rgba(15, 35, 95, 0.05);
-  flex-shrink: 0;
+.metric-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 8px;
 }
-
-.layer-group {
-  display: flex;
-  flex-direction: column;
-  gap: 9px;
-  padding-bottom: 16px;
-  margin-bottom: 16px;
-  border-bottom: 1px solid #eef2f8;
+.metric {
+  padding: 10px;
+  border: 1px solid #dceaf7;
+  border-radius: 8px;
+  background: #f7fbff;
 }
-
-.layer-group-last {
-  padding-bottom: 0;
-  margin-bottom: 0;
-  border-bottom: none;
+.metric span {
+  display: block;
+  font-size: 12px;
+  color: #668099;
 }
-
-.layer-title {
-  color: #303133;
+.metric strong {
+  display: block;
+  margin-top: 3px;
+  font-size: 20px;
+  color: #1b5c98;
+}
+.panel-section h3 {
+  margin: 0 0 8px;
   font-size: 14px;
-  font-weight: 600;
+  color: #29567e;
 }
-
-.legend-list {
+.layer-list {
   display: flex;
   flex-direction: column;
-  gap: 10px;
+  gap: 6px;
 }
-
-.legend-item {
+.layer-row {
   display: flex;
   align-items: center;
-  gap: 8px;
-  color: #4e5969;
-  font-size: 14px;
 }
-
-.legend-line {
-  width: 34px;
-  height: 0;
-  border-top-width: 3px;
-  border-top-style: solid;
-  border-radius: 999px;
-  flex-shrink: 0;
+.legend-panel {
+  border-top: 1px solid #e4eef8;
+  padding-top: 12px;
 }
-
-.legend-high { border-top-color: #e63946; }
-.legend-sub-high { border-top-color: #f77f00; }
-.legend-medium { border-top-color: #fcbf49; }
-.legend-low { border-top-color: #2a9d8f; }
-
-.legend-dot,
-.legend-square {
-  width: 12px;
-  height: 12px;
-  flex-shrink: 0;
+.legend-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 6px;
+  font-size: 13px;
+  color: #4d667d;
 }
-
-.legend-dot { border-radius: 50%; }
-.legend-square { border-radius: 2px; }
-.legend-valve { background: #457b9d; }
-.legend-regulator { background: #e76f51; }
-.legend-flowmeter { background: #2d6a4f; }
-.legend-terminal { background: #6c757d; }
-.legend-cover { background: #7f4f24; }
-.legend-inspection { background: #b08968; }
-
-.stats-card {
-  background: linear-gradient(180deg, #f8fbff 0%, #f3f7fb 100%);
+.legend-dot {
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  flex-shrink: 0;
 }
-
-.stats-list {
+.result-section {
+  flex: none;
+  min-height: 220px;
   display: flex;
   flex-direction: column;
-  gap: 11px;
 }
-
-.stats-row {
+.device-result-list {
+  flex: none;
   display: flex;
+  flex-direction: column;
+  gap: 6px;
+  overflow-y: visible;
+}
+.device-result-list button {
+  width: 100%;
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 3px 8px;
   align-items: center;
-  justify-content: space-between;
-  color: #4e5969;
-  font-size: 13px;
+  border: 1px solid #dbe8f5;
+  background: #fff;
+  border-radius: 8px;
+  padding: 8px 10px;
+  text-align: left;
+  cursor: pointer;
 }
-
-.stats-row strong {
-  color: #1f2d3d;
-  font-size: 14px;
+.device-name {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-weight: 600;
+  color: #274b6d;
 }
-
-.detail-card {
-  flex: 1;
-  min-height: 280px;
+.device-meta {
+  grid-column: 1 / -1;
+  font-size: 12px;
+  color: #7793aa;
 }
-
-.dashboard-map-panel {
+.map-panel {
   position: relative;
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  background: #fff;
-  border-radius: 14px;
   overflow: hidden;
-  box-shadow: 0 4px 18px rgba(15, 35, 95, 0.05);
-  min-width: 0;
 }
-
-.map-toolbar {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-  padding: 10px 16px;
-  border-bottom: 1px solid #edf2f7;
-  flex-shrink: 0;
+.map-error {
+  position: absolute;
+  left: 16px;
+  bottom: 16px;
+  width: 360px;
+  z-index: 1;
 }
-
-.toolbar-left {
-  display: flex;
-  align-items: center;
-  gap: 8px;
+.map-empty {
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 1;
 }
-
-.toolbar-right {
-  color: #7a8699;
-  font-size: 13px;
+.device-detail {
+  min-height: 240px;
 }
-
-.gis-map {
-  flex: 1;
-  min-height: 0;
-  background: #e9f0e5;
+.detail-hero h3,
+.detail-section h4 {
+  margin: 0 0 8px;
+  color: #1c4b78;
 }
-
-.map-error {
-  position: absolute;
-  inset: 52px 0 0;
+.detail-hero p {
+  margin: 0 0 10px;
+  color: #678197;
+  font-size: 13px;
+}
+.status-tags {
   display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #909399;
-  background: rgba(255, 255, 255, 0.85);
-  font-size: 15px;
-  z-index: 10;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+.detail-section {
+  margin-top: 18px;
+  padding-top: 12px;
+  border-top: 1px solid #e5eef7;
+}
+.detail-section dl {
+  display: grid;
+  grid-template-columns: 88px minmax(0, 1fr);
+  gap: 7px 10px;
+  margin: 0;
+  font-size: 13px;
 }
-</style>
-
-<style>
-.pipe-label {
-  padding: 2px 8px;
-  font-size: 12px;
-  font-weight: 600;
-  line-height: 1.3;
-  white-space: nowrap;
-  text-shadow: 0 0 4px #fff, 0 0 4px #fff;
-  background: rgba(255, 255, 255, 0.7);
-  border-radius: 10px;
+.detail-section dt {
+  color: #71889c;
 }
-
-.marker-wrap {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
+.detail-section dd {
+  margin: 0;
+  color: #2b4c67;
+  overflow-wrap: anywhere;
 }
-
-.marker-text {
-  margin-top: 3px;
-  padding: 1px 7px;
-  color: #2f3a4f;
-  font-size: 11px;
-  line-height: 1.3;
-  white-space: nowrap;
-  background: rgba(255, 255, 255, 0.78);
-  border-radius: 10px;
-  text-shadow: 0 0 3px #fff;
+.muted {
+  margin: 0;
+  color: #7d93a6;
+  font-size: 13px;
 }
-
-.BMap_cpyCtrl,
-.anchorBL {
-  display: none;
+@media (max-width: 1200px) {
+  .gis-layout {
+    grid-template-columns: 1fr;
+    height: auto;
+  }
+  .map-panel {
+    height: 560px;
+  }
+  .search-input,
+  .type-select {
+    width: 180px;
+  }
 }
 </style>

+ 6 - 5
tests/gisMapCanvas.test.mjs

@@ -13,15 +13,16 @@ test('shared gis map canvas converts WGS84 coordinates before BMapGL rendering',
   assert.doesNotMatch(source, /new BMapGL\.Point\(point\.lng, point\.lat\)/u)
 })
 
-test('shared gis map canvas uses the common point marker contract', async () => {
+test('shared gis map canvas uses the official default marker with labels above points', async () => {
   const source = await readFile('src/components/GisMapCanvas.vue', 'utf8')
 
   assert.match(source, /markerSize: \{ type: Number, default: 22 \}/u)
-  assert.match(source, /new BMapGL\.Icon\(iconUrl, new BMapGL\.Size\(size, size\),/u)
-  assert.match(source, /anchor: new BMapGL\.Size\(size \/ 2, size\)/u)
-  assert.match(source, /new BMapGL\.Marker\(position, \{/u)
+  assert.match(source, /const marker = new BMapGL\.Marker\(position\)/u)
+  assert.doesNotMatch(source, /new BMapGL\.Icon\(/u)
   assert.match(source, /<div class="marker-text">/u)
-  assert.match(source, /offset: new BMapGL\.Size\(-90, -props\.markerSize - 28\)/u)
+  assert.match(source, /offset: new BMapGL\.Size\(-90, -props\.markerSize - 8\)/u)
+  assert.match(source, /供水管网.*起点/u)
+  assert.match(source, /终点/u)
   assert.ok(source.includes("emit('point-click', point)"))
 })
 

+ 2 - 0
tests/waterFacilityGis.test.mjs

@@ -69,4 +69,6 @@ test('GIS page exposes REST-backed right detail drawer and empty layer state', (
   assert.match(source, /detailLoading/u)
   assert.match(source, /detailError/u)
   assert.match(source, /当前筛选下没有可显示的供水设施/u)
+  assert.match(source, /const centerPoint = \{ lng: 110\.3965, lat: 28\.4638 \}/u)
+  assert.match(source, /overflow: visible/u)
 })