Forráskód Böngészése

feat(water): add device GIS map

Kazerin 14 órája
szülő
commit
5f11b22723

+ 10 - 0
memory/sessions/2026-09-07.md

@@ -0,0 +1,10 @@
+# 2026-09-07
+
+## T7 监测设备 GIS 一张图
+
+- 目标:交付供水监测设备专用 GIS 页面,支持供水设备点位展示、搜索、定位、状态筛选、异常高亮和右侧详情抽屉。
+- 结论:新增 `WaterDeviceGisMap.vue`,页面通过 `/waterSupply/device/gis/*` REST 契约读取全部点位、异常点位、统计和详情;共享 GIS 解析工具支持 FeatureCollection;详情区分设备状态、在线状态、报警状态、报警等级、异常原因、最新数据和维修记录。
+- 文件变更:`src/api/pipeNetwork/waterSupply.js`、`src/utils/waterSupplyUiModel.js`、`src/views/subSystem/waterSupply/device/gis.vue`、`src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue`、`tests/waterDeviceGisMap.test.mjs`、`tests/waterSupplyUiModel.test.mjs`;后端补充 `WaterSupplyDeviceServiceImplTest` 的供水设备 GIS 点位行为测试。
+- 验证:前端 `node --test tests` 72/72 通过;`npm run build:prod` 通过;后端 `mvn -q -f pipe-network-service/pom.xml test` 通过;两仓库 `git diff --check` 通过。
+- 风险:浏览器地图效果仍需真实登录态与 BMapGL 环境人工验收;本轮未修改后端 GIS 服务实现。
+- 后续:人工验收页面搜索、定位、状态筛选、异常高亮和详情抽屉;如通过,再关闭对应 Gogs Issue。

+ 1 - 0
src/api/pipeNetwork/waterSupply.js

@@ -65,6 +65,7 @@ export const getWaterFacilityDetail = (type, id) => get(`/waterSupply/gis/map/de
 export const getDeviceGisAllPoints = () => get('/waterSupply/device/gis/allPoints')
 export const getDeviceGisAbnormalPoints = () => get('/waterSupply/device/gis/abnormalPoints')
 export const getDeviceGisStatistics = () => get('/waterSupply/device/gis/statistics')
+export const getDeviceGisDetail = (id) => get(`/waterSupply/device/gis/detail/${id}`)
 
 // 供水设施基础信息批量导出(后端 WaterFacilityExportController)
 export const exportWaterSource = (params) => request({ url: '/api/water/sourceInfo/export', method: 'get', params, responseType: 'blob' })

+ 1 - 1
src/utils/waterSupplyUiModel.js

@@ -108,7 +108,7 @@ export function preserveDevicePageOnError(page) {
 
 export function normalizeDeviceGisPoints(response) {
   const data = response?.data ?? response
-  const points = Array.isArray(data) ? data : (data?.records || data?.rows || data?.points || [])
+  const points = Array.isArray(data) ? data : (data?.features || data?.records || data?.rows || data?.points || [])
   return points.map(point => ({
     ...point,
     _longitude: Number(point.longitude ?? point.lng),

+ 656 - 0
src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue

@@ -0,0 +1,656 @@
+<template>
+  <div class="water-device-gis">
+    <div class="gis-header">
+      <div>
+        <p class="eyebrow">WATER SUPPLY DEVICE GIS</p>
+        <h2>监测设备 GIS 一张图</h2>
+      </div>
+      <div class="header-actions">
+        <el-input
+          v-model="searchKeyword"
+          class="search-input"
+          placeholder="搜索设备编号/名称/位置"
+          clearable
+          :prefix-icon="Search"
+        />
+        <el-select v-model="selectedTypeId" class="type-select" placeholder="全部设备类型" clearable>
+          <el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
+        </el-select>
+        <el-button type="primary" :icon="Refresh" @click="loadData">刷新</el-button>
+        <el-button :icon="Aim" @click="fitBounds">全图</el-button>
+      </div>
+    </div>
+
+    <div class="gis-layout">
+      <aside class="side-panel">
+        <div class="metric-grid">
+          <div class="metric">
+            <span>设备总数</span>
+            <strong>{{ summary.total }}</strong>
+          </div>
+          <div class="metric">
+            <span>在线</span>
+            <strong>{{ summary.online }}</strong>
+          </div>
+          <div class="metric">
+            <span>离线</span>
+            <strong>{{ summary.offline }}</strong>
+          </div>
+          <div class="metric">
+            <span>异常</span>
+            <strong>{{ summary.abnormal }}</strong>
+          </div>
+        </div>
+
+        <div class="panel-section">
+          <h3>状态筛选</h3>
+          <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="offline">离线</el-checkbox>
+          </el-checkbox-group>
+        </div>
+
+        <div class="panel-section">
+          <h3>设备类型</h3>
+          <div v-if="typeOptions.length" class="type-list">
+            <button
+              v-for="item in typeOptions"
+              :key="item.value"
+              type="button"
+              :class="{ active: selectedTypeId === item.value }"
+              @click="selectedTypeId = selectedTypeId === item.value ? '' : item.value"
+            >
+              <span>{{ item.label }}</span>
+              <strong>{{ item.count }}</strong>
+            </button>
+          </div>
+          <el-empty v-else description="暂无供水设备类型" :image-size="72" />
+        </div>
+
+        <div class="panel-section legend-panel">
+          <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 offline"></i>离线设备</div>
+        </div>
+
+        <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="!filteredDevices.length"
+            description="暂无可显示的监测设备"
+            :image-size="80"
+          />
+          <div v-else class="device-result-list">
+            <button v-for="device in filteredDevices" :key="device.equipmentId" type="button" @click="locateDevice(device)">
+              <span class="device-name">{{ device.equipmentName || device.equipmentCode }}</span>
+              <span class="device-meta">{{ device.equipmentTypeName }} · {{ device.onlineStatusText }}</span>
+              <el-tag size="small" :type="statusTagType(device)">{{ statusText(device) }}</el-tag>
+            </button>
+          </div>
+        </div>
+      </aside>
+
+      <main class="map-panel" v-loading="loading">
+        <div ref="mapContainer" class="map-container"></div>
+        <el-alert v-if="mapError" class="map-error" type="warning" show-icon :closable="false" :title="mapError" />
+      </main>
+    </div>
+
+    <el-drawer v-model="detailVisible" title="设备详情" size="420px" class="detail-drawer" direction="rtl" 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="selectedDetail">
+          <section class="detail-hero">
+            <h3>{{ selectedDetail.equipmentName || selectedDetail.equipmentCode }}</h3>
+            <p>{{ selectedDetail.equipmentCode }} · {{ selectedDetail.equipmentTypeName || '未知类型' }}</p>
+            <div class="status-tags">
+              <el-tag :type="deviceStatusTagType(selectedDetail.currentStatus)">设备状态:{{ selectedDetail.currentStatusText }}</el-tag>
+              <el-tag :type="selectedDetail.onlineStatus === 1 ? 'success' : 'info'">在线状态:{{ selectedDetail.onlineStatusText }}</el-tag>
+              <el-tag :type="selectedDetail.alarmStatus === 1 ? 'danger' : 'success'">报警状态:{{ selectedDetail.alarmStatusText }}</el-tag>
+              <el-tag :type="selectedDetail.alertLevel ? 'warning' : 'info'">报警等级:{{ selectedDetail.alarmLevelText }}</el-tag>
+            </div>
+          </section>
+
+          <section class="detail-section">
+            <h4>基础信息</h4>
+            <dl>
+              <dt>设备位置</dt><dd>{{ selectedDetail.equipmentLocation || '-' }}</dd>
+              <dt>经纬度</dt><dd>{{ selectedDetail.longitude }}, {{ selectedDetail.latitude }}</dd>
+              <dt>异常原因</dt><dd>{{ selectedDetail.abnormalReasonText || '-' }}</dd>
+              <dt>关联工单</dt><dd>{{ selectedDetail.relatedOrderNo || '-' }}</dd>
+              <dt>工单状态</dt><dd>{{ selectedDetail.workOrderStatusText }}</dd>
+              <dt>责任人</dt><dd>{{ selectedDetail.relatedWorkOrderPerson || '-' }}</dd>
+            </dl>
+          </section>
+
+          <section class="detail-section">
+            <h4>最新数据</h4>
+            <template v-if="latestDataEntries.length">
+              <dl>
+                <dt v-for="item in latestDataEntries" :key="item.label">{{ item.label }}</dt>
+                <dd v-for="item in latestDataEntries" :key="`value-${item.label}`">{{ item.value }}</dd>
+              </dl>
+              <p v-if="selectedDetail.lastDataTime" class="muted">更新时间:{{ selectedDetail.lastDataTime }}</p>
+            </template>
+            <p v-else class="muted">暂无最新监测数据</p>
+          </section>
+
+          <section class="detail-section">
+            <h4>维修记录</h4>
+            <template v-if="latestMaintainEntries.length">
+              <dl>
+                <dt v-for="item in latestMaintainEntries" :key="item.label">{{ item.label }}</dt>
+                <dd v-for="item in latestMaintainEntries" :key="`value-${item.label}`">{{ item.value }}</dd>
+              </dl>
+            </template>
+            <p v-else class="muted">暂无维修记录</p>
+          </section>
+        </template>
+      </div>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup>
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { Aim, Refresh, Search } from '@element-plus/icons-vue'
+import {
+  getDeviceGisAbnormalPoints,
+  getDeviceGisAllPoints,
+  getDeviceGisDetail,
+  getDeviceGisStatistics
+} from '@/api/pipeNetwork/waterSupply'
+import { normalizeDeviceGisPoints, normalizeDeviceRow } from '@/utils/waterSupplyUiModel'
+import { unwrapResponse } from '@/utils/waterSupplyModel'
+
+const searchKeyword = ref('')
+const selectedTypeId = ref('')
+const statusFilters = ref(['normal', 'alarm', 'maintenance', '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 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) => {
+    const id = device.equipmentTypeId || 'unknown'
+    const item = typeMap.get(id) || {
+      value: id,
+      label: device.equipmentTypeName || '未知类型',
+      count: 0
+    }
+    item.count += 1
+    typeMap.set(id, item)
+  })
+  return [...typeMap.values()].sort((a, b) => a.label.localeCompare(b.label, 'zh-CN'))
+})
+
+const summary = computed(() => ({
+  total: allDevices.value.length,
+  online: allDevices.value.filter((device) => Number(device.onlineStatus) === 1).length,
+  offline: allDevices.value.filter((device) => Number(device.onlineStatus) !== 1).length,
+  abnormal: abnormalDevices.value.length
+}))
+
+const filteredDevices = computed(() => {
+  const keyword = searchKeyword.value.trim().toLowerCase()
+  return allDevices.value.filter((device) => {
+    const matchedType = !selectedTypeId.value || device.equipmentTypeId === selectedTypeId.value
+    const matchedKeyword = !keyword || [device.equipmentName, device.equipmentCode, device.equipmentLocation]
+      .some((value) => String(value || '').toLowerCase().includes(keyword))
+    const statusKey = getStatusKey(device)
+    return matchedType && matchedKeyword && statusFilters.value.includes(statusKey)
+  })
+})
+
+const latestDataEntries = computed(() => {
+  const data = selectedDetail.value?.latestData || {}
+  return Object.entries(data).map(([key, value]) => ({
+    label: key,
+    value: value ?? '-'
+  }))
+})
+
+const latestMaintainEntries = computed(() => {
+  const record = selectedDetail.value?.latestMaintain
+  if (!record) return []
+  const preferredKeys = ['maintainDate', 'maintainType', 'maintainContent', 'maintainPerson']
+  return preferredKeys
+    .filter((key) => record[key] !== undefined && record[key] !== null && record[key] !== '')
+    .map((key) => ({ label: maintainLabels[key] || key, value: record[key] }))
+})
+
+const maintainLabels = {
+  maintainDate: '维修日期',
+  maintainType: '维修类型',
+  maintainContent: '维修内容',
+  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'
+}
+
+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: '正常' }
+}
+
+function statusText(device) {
+  return getStatusStyle(device).text
+}
+
+function statusTagType(device) {
+  const key = getStatusKey(device)
+  if (key === 'alarm') return 'danger'
+  if (key === 'maintenance') return 'warning'
+  if (key === 'offline') return 'info'
+  return 'success'
+}
+
+function deviceStatusTagType(status) {
+  const value = Number(status)
+  if (value === 3) return 'warning'
+  if (value === 4) return 'danger'
+  if (value === 5) return 'info'
+  return 'success'
+}
+
+async function loadData() {
+  loading.value = true
+  loadError.value = ''
+  try {
+    const [allResponse, abnormalResponse, statisticsResponse] = await Promise.all([
+      getDeviceGisAllPoints(),
+      getDeviceGisAbnormalPoints(),
+      getDeviceGisStatistics()
+    ])
+    allDevices.value = normalizeDeviceGisPoints(allResponse).map(normalizeDeviceRow)
+    abnormalDevices.value = normalizeDeviceGisPoints(abnormalResponse).map(normalizeDeviceRow)
+    gisStatistics.value = unwrapResponse(statisticsResponse) || {}
+    await nextTick()
+    renderMarkers()
+    fitBounds()
+  } catch (error) {
+    loadError.value = '监测设备数据加载失败,请点击右上角“刷新”重试'
+  } finally {
+    loading.value = false
+  }
+}
+
+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] })
+}
+
+function locateDevice(device) {
+  if (!mapInstance || !window.BMapGL) return
+  mapInstance.centerAndZoom(new window.BMapGL.Point(device._longitude, device._latitude), 17)
+  openDetail(device.equipmentId)
+}
+
+async function openDetail(equipmentId) {
+  if (!equipmentId) return
+  detailVisible.value = true
+  detailLoading.value = true
+  selectedDetail.value = null
+  detailError.value = ''
+  try {
+    const detail = unwrapResponse(await getDeviceGisDetail(equipmentId))
+    selectedDetail.value = normalizeDeviceRow(detail)
+  } catch (error) {
+    selectedDetail.value = null
+    detailError.value = '设备详情加载失败,请关闭后重试'
+  } finally {
+    detailLoading.value = false
+  }
+}
+
+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
+})
+</script>
+
+<style scoped>
+.water-device-gis {
+  min-height: 100%;
+  padding: 18px;
+  background: linear-gradient(180deg, #edf6ff 0%, #f5f7fb 100%);
+  color: #1f3b57;
+}
+.gis-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 18px;
+  margin-bottom: 16px;
+}
+.eyebrow {
+  margin: 0 0 3px;
+  font-size: 11px;
+  letter-spacing: .12em;
+  color: #5c93c7;
+}
+.gis-header h2 {
+  margin: 0;
+  font-size: 22px;
+  color: #173a5e;
+}
+.header-actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+.search-input,
+.type-select {
+  width: 240px;
+}
+.gis-layout {
+  display: grid;
+  grid-template-columns: 310px minmax(0, 1fr);
+  gap: 16px;
+  height: calc(100vh - 140px);
+  min-height: 620px;
+}
+.side-panel,
+.map-panel {
+  background: rgba(255,255,255,.94);
+  border: 1px solid #dbe8f5;
+  border-radius: 10px;
+  box-shadow: 0 10px 28px rgba(31,74,121,.08);
+}
+.side-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  padding: 16px;
+  overflow: hidden;
+}
+.metric-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 8px;
+}
+.metric {
+  padding: 10px;
+  border: 1px solid #dceaf7;
+  border-radius: 8px;
+  background: #f7fbff;
+}
+.metric span {
+  display: block;
+  font-size: 12px;
+  color: #668099;
+}
+.metric strong {
+  display: block;
+  margin-top: 3px;
+  font-size: 20px;
+  color: #1b5c98;
+}
+.panel-section h3 {
+  margin: 0 0 8px;
+  font-size: 14px;
+  color: #29567e;
+}
+.status-filter {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+}
+.type-list,
+.device-result-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  overflow-y: auto;
+}
+.type-list {
+  max-height: 160px;
+}
+.type-list button,
+.device-result-list button {
+  width: 100%;
+  border: 1px solid #dbe8f5;
+  background: #fff;
+  border-radius: 8px;
+  padding: 8px 10px;
+  text-align: left;
+  cursor: pointer;
+}
+.type-list button {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 13px;
+  color: #33556f;
+}
+.type-list button.active {
+  border-color: #4b9ce7;
+  background: #edf7ff;
+  color: #1662a8;
+}
+.legend-panel {
+  border-top: 1px solid #e4eef8;
+  padding-top: 12px;
+}
+.legend-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 6px;
+  font-size: 13px;
+  color: #4d667d;
+}
+.legend-dot {
+  width: 10px;
+  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; }
+.result-section {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+.device-result-list {
+  flex: 1;
+}
+.device-result-list button {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 3px 8px;
+  align-items: center;
+}
+.device-name {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-weight: 600;
+  color: #274b6d;
+}
+.device-meta {
+  grid-column: 1 / -1;
+  font-size: 12px;
+  color: #7793aa;
+}
+.map-panel {
+  position: relative;
+  overflow: hidden;
+}
+.map-container {
+  width: 100%;
+  height: 100%;
+}
+.map-error {
+  position: absolute;
+  left: 16px;
+  bottom: 16px;
+  width: 360px;
+}
+.device-detail {
+  min-height: 240px;
+}
+.detail-hero h3,
+.detail-section h4 {
+  margin: 0 0 8px;
+  color: #1c4b78;
+}
+.detail-hero p {
+  margin: 0 0 10px;
+  color: #678197;
+  font-size: 13px;
+}
+.status-tags {
+  display: flex;
+  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;
+}
+.detail-section dt {
+  color: #71889c;
+}
+.detail-section dd {
+  margin: 0;
+  color: #2b4c67;
+  overflow-wrap: anywhere;
+}
+.muted {
+  margin: 0;
+  color: #7d93a6;
+  font-size: 13px;
+}
+@media (max-width: 1200px) {
+  .gis-layout {
+    grid-template-columns: 1fr;
+    height: auto;
+  }
+  .map-panel {
+    height: 560px;
+  }
+  .search-input,
+  .type-select {
+    width: 180px;
+  }
+}
+</style>

+ 7 - 1
src/views/subSystem/waterSupply/device/gis.vue

@@ -1 +1,7 @@
-<script setup>import Page from '../components/WaterDevicePage.vue'</script><template><Page title="监测设备 GIS 一张图" view="gis" /></template>
+<script setup>
+import WaterDeviceGisMap from '../components/WaterDeviceGisMap.vue'
+</script>
+
+<template>
+  <WaterDeviceGisMap />
+</template>

+ 58 - 0
tests/waterDeviceGisMap.test.mjs

@@ -0,0 +1,58 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+test('device gis page is a dedicated map at the public route seam', async () => {
+  const apiSource = await readFile('src/api/pipeNetwork/waterSupply.js', 'utf8')
+  const entrySource = await readFile('src/views/subSystem/waterSupply/device/gis.vue', 'utf8')
+
+  assert.match(apiSource, /export const getDeviceGisAllPoints = \(\) => get\('\/waterSupply\/device\/gis\/allPoints'\)/u)
+  assert.match(apiSource, /export const getDeviceGisAbnormalPoints = \(\) => get\('\/waterSupply\/device\/gis\/abnormalPoints'\)/u)
+  assert.match(apiSource, /export const getDeviceGisStatistics = \(\) => get\('\/waterSupply\/device\/gis\/statistics'\)/u)
+  assert.match(apiSource, /export const getDeviceGisDetail = \(id\) => get\(`\/waterSupply\/device\/gis\/detail\/\$\{id\}`\)/u)
+  assert.match(entrySource, /import WaterDeviceGisMap from '\.\.\/components\/WaterDeviceGisMap\.vue'/u)
+  assert.doesNotMatch(entrySource, /WaterDevicePage/)
+})
+
+test('device gis map exposes water filtering, exception highlight and detail behavior', async () => {
+  const source = await readFile(
+    'src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue',
+    'utf8'
+  )
+
+  assert.match(source, /getDeviceGisAllPoints/u)
+  assert.match(source, /getDeviceGisAbnormalPoints/u)
+  assert.match(source, /getDeviceGisStatistics/u)
+  assert.match(source, /getDeviceGisDetail/u)
+  assert.match(source, /equipmentTypeId/u)
+  assert.match(source, /设备类型/u)
+  assert.match(source, /searchKeyword/u)
+  assert.match(source, /搜索设备/u)
+  assert.match(source, /图例/u)
+  assert.match(source, /报警/u)
+  assert.match(source, /维修/u)
+  assert.match(source, /离线/u)
+  assert.match(source, /正常/u)
+  assert.match(source, /BMapGL/u)
+  assert.match(source, /el-drawer/u)
+  assert.match(source, /设备详情/u)
+  assert.match(source, /v-loading/u)
+  assert.match(source, /暂无可显示的监测设备/u)
+  assert.match(source, /重试/u)
+  assert.match(source, /刷新/u)
+})
+
+test('device gis map separates status sources in detail text', async () => {
+  const source = await readFile(
+    'src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue',
+    'utf8'
+  )
+
+  assert.match(source, /设备状态/u)
+  assert.match(source, /在线状态/u)
+  assert.match(source, /报警状态/u)
+  assert.match(source, /报警等级/u)
+  assert.match(source, /异常原因/u)
+  assert.match(source, /最新数据/u)
+  assert.match(source, /维修记录/u)
+})

+ 8 - 0
tests/waterSupplyUiModel.test.mjs

@@ -120,6 +120,14 @@ test('normalizes GIS responses to point records without inventing coordinates',
     { equipmentId: 'EQ-1', longitude: '110.1', latitude: '28.2', _longitude: 110.1, _latitude: 28.2 }
   ])
   assert.deepEqual(normalizeDeviceGisPoints({ data: [{ equipmentId: 'EQ-2' }] }), [])
+  assert.deepEqual(normalizeDeviceGisPoints({
+    data: {
+      type: 'FeatureCollection',
+      features: [{ equipmentId: 'EQ-GIS', longitude: '110.3', latitude: '28.4' }]
+    }
+  }), [
+    { equipmentId: 'EQ-GIS', longitude: '110.3', latitude: '28.4', _longitude: 110.3, _latitude: 28.4 }
+  ])
 })
 
 test('normalizes eligible assignee display fields with safe fallbacks', () => {