ソースを参照

排水-监测设备 GIS 一张图

zengy 3 日 前
親
コミット
d70a991a35

+ 0 - 22
src/views/subSystem/waterSupply/components/WaterDeviceAbnormalPage.vue

@@ -92,27 +92,12 @@
                 <template #empty>
                     <el-empty :description="loadError ? '暂无法获取异常设备' : '当前筛选下没有异常设备'"/>
                 </template>
-                <el-table-column type="index" label="#" width="55"/>
                 <el-table-column prop="equipmentCode" label="设备编码" min-width="150" show-overflow-tooltip/>
                 <el-table-column prop="equipmentName" label="设备名称" min-width="150" show-overflow-tooltip/>
                 <el-table-column prop="equipmentTypeName" label="设备类型" min-width="130" show-overflow-tooltip/>
                 <el-table-column label="所属片区" width="100">
                     <template #default="{ row }">{{ districtLabel(row.district) }}</template>
                 </el-table-column>
-                <el-table-column label="设备图片" width="92">
-                    <template #default="{ row }">
-                        <el-image
-                                v-if="row.equipmentImageUrl"
-                                class="table-thumb"
-                                :src="row.equipmentImageUrl"
-                                :preview-src-list="[row.equipmentImageUrl]"
-                                preview-teleported
-                                fit="cover"
-                                @click.stop
-                        />
-                        <span v-else class="muted">-</span>
-                    </template>
-                </el-table-column>
                 <el-table-column label="异常原因" min-width="130">
                     <template #default="{ row }">
                         <el-tag v-for="type in row.abnormalTypes" :key="type" class="reason-tag"
@@ -673,13 +658,6 @@ onMounted(loadData)
         margin-top: 16px
     }
 
-    .table-thumb {
-        width: 46px;
-        height: 34px;
-        border: 1px solid #e2e8f0;
-        border-radius: 4px;
-    }
-
     .reason-tag {
         margin-right: 4px;
     }

+ 544 - 422
src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue

@@ -1,259 +1,188 @@
 <template>
   <div class="water-page gis-page">
-    <div class="page-heading">
-      <div class="page-title">
-        <span class="page-title-icon"><el-icon><MapLocation/></el-icon></span>
-        <div><h2>监测设备 GIS 一张图</h2><span class="muted">设备点位分布与状态查询</span></div>
-      </div>
-      <div class="heading-actions">
-        <el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
+    <div class="page-head">
+      <div class="page-head-titles">
+        <div class="page-title">
+          <el-icon class="page-title-icon"><MapLocation /></el-icon>
+          <span>监测设备 GIS 一张图</span>
+        </div>
       </div>
+      <el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
     </div>
 
-    <el-card shadow="never" class="water-panel filter-card">
-      <template #header>
-        <div class="card-title">
-          <span class="card-heading"><el-icon><Search/></el-icon><span>设备筛选</span></span>
+    <!-- 设备统计 -->
+    <div class="kpi-row">
+      <el-card shadow="never" class="kpi">
+        <div class="kpi-icon total"><el-icon><Monitor /></el-icon></div>
+        <div class="kpi-data">
+          <div class="kpi-label">设备总数(台)</div>
+          <div class="kpi-value">{{ statistics.total }}</div>
         </div>
-      </template>
-      <el-form :inline="true" class="filter-form">
-        <el-form-item label="关键字">
-          <el-input v-model="searchKeyword" class="keyword-input" clearable placeholder="设备编号 / 名称 / 位置"
-                    :prefix-icon="Search" @keyup.enter="loadData"/>
-        </el-form-item>
-        <el-form-item label="设备类型">
-          <el-select v-model="selectedTypeId" class="status-select" clearable placeholder="全部设备类型">
-            <el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value"/>
-          </el-select>
-        </el-form-item>
-        <el-form-item>
-          <el-button type="primary" @click="loadData">查询</el-button>
-          <el-button @click="resetFilters">重置</el-button>
-        </el-form-item>
-      </el-form>
-    </el-card>
-
-  <el-card shadow="never" class="water-panel map-card">
-  <WaterGisPageShell
-    embedded
-    eyebrow="WATER SUPPLY DEVICE GIS"
-    title="监测设备 GIS 一张图"
-    :loading="loading"
-  >
-    <template #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>
-    </template>
-
-    <template #metrics>
-      <div class="metric">
-        <div class="metric-icon"><el-icon><Odometer/></el-icon></div>
-        <div class="metric-body">
-          <span>设备总数</span>
-          <div class="metric-value"><strong>{{ summary.total }}</strong><small>台</small></div>
+      </el-card>
+      <el-card shadow="never" class="kpi">
+        <div class="kpi-icon online"><el-icon><CircleCheck /></el-icon></div>
+        <div class="kpi-data">
+          <div class="kpi-label">在线设备(台)</div>
+          <div class="kpi-value">{{ statistics.online }}</div>
         </div>
-      </div>
-      <div class="metric">
-        <div class="metric-icon"><el-icon><CircleCheck/></el-icon></div>
-        <div class="metric-body">
-          <span>在线</span>
-          <div class="metric-value"><strong>{{ summary.online }}</strong><small>台</small></div>
-        </div>
-      </div>
-      <div class="metric">
-        <div class="metric-icon"><el-icon><CircleClose/></el-icon></div>
-        <div class="metric-body">
-          <span>离线</span>
-          <div class="metric-value"><strong>{{ summary.offline }}</strong><small>台</small></div>
-        </div>
-      </div>
-      <div class="metric">
-        <div class="metric-icon"><el-icon><Warning/></el-icon></div>
-        <div class="metric-body">
-          <span>异常</span>
-          <div class="metric-value"><strong>{{ summary.abnormal }}</strong><small>台</small></div>
+      </el-card>
+      <el-card shadow="never" class="kpi">
+        <div class="kpi-icon offline"><el-icon><CircleClose /></el-icon></div>
+        <div class="kpi-data">
+          <div class="kpi-label">离线设备(台)</div>
+          <div class="kpi-value">{{ statistics.offline }}</div>
         </div>
-      </div>
-    </template>
-
-    <template #controls>
-      <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="warning">预警</el-checkbox>
-            <el-checkbox value="workOrder">关联工单</el-checkbox>
-            <el-checkbox value="offline">离线</el-checkbox>
-          </el-checkbox-group>
+      </el-card>
+      <el-card shadow="never" class="kpi">
+        <div class="kpi-icon alarm"><el-icon><Warning /></el-icon></div>
+        <div class="kpi-data">
+          <div class="kpi-label">报警设备(台)</div>
+          <div class="kpi-value">{{ statistics.alarm }}</div>
         </div>
+      </el-card>
+    </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>
-    </template>
-
-    <template #legend>
-      <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 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>
-    </template>
-
-    <template #results>
-      <div class="panel-section result-section">
-          <h3>搜索设备</h3>
-          <el-alert v-if="loadError" type="error" :closable="false" show-icon :title="loadError">
-            <el-button link type="primary" @click="loadData">重试</el-button>
-          </el-alert>
-          <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>
-    </template>
-
-    <template #map>
+    <!-- 地图 -->
+    <el-card shadow="never" class="map-card">
+      <div class="map-wrap">
         <GisMapCanvas
           ref="gisMapRef"
           :points="mapPoints"
           cluster-label="设备集合"
-          :center="{ lng: 110.3965, lat: 28.4638 }"
+          :center="mapCenter"
           :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">
+        <div class="map-legend" @mousedown.stop @click.stop @dblclick.stop>
+          <div class="legend-title">点击切换显示</div>
+          <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.online }" @click="toggleLegend('online')">
+            <i class="legend-dot online"></i>在线
+          </button>
+          <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.offline }" @click="toggleLegend('offline')">
+            <i class="legend-dot offline"></i>离线
+          </button>
+          <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.alarm }" @click="toggleLegend('alarm')">
+            <i class="legend-dot alarm"></i>报警(在线/离线时叠加)
+          </button>
+        </div>
+        <el-alert
+          v-if="mapError"
+          class="map-error"
+          type="warning"
+          show-icon
+          :closable="false"
+          :title="mapError"
+        >
           <el-button link type="primary" @click="loadData">重试</el-button>
         </el-alert>
         <el-empty
           v-if="!loading && !mapError && !mapPoints.length"
           class="map-empty"
-          description="当前筛选下没有可显示的监测设备"
+          description="当前没有可显示的监测设备"
         />
-    </template>
-
-    <template #drawer>
-      <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'">报警等级:{{ 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>
-    </template>
-  </WaterGisPageShell>
-  </el-card>
+    </el-card>
+
+    <!-- 设备详情 -->
+    <el-dialog
+      title="设备详情"
+      v-model="detailVisible"
+      width="800px"
+      append-to-body
+      class="detail-dialog"
+    >
+      <el-alert
+        v-if="detailError"
+        type="error"
+        show-icon
+        :closable="false"
+        title="设备详情加载失败,请关闭后重试"
+      />
+      <template v-if="selectedDetail?.equipmentInfo">
+        <div class="dlg-summary">
+          <div class="summary-name">{{ selectedDetail.equipmentInfo.equipmentName || "未命名设备" }}</div>
+          <div class="summary-code">{{ selectedDetail.equipmentInfo.equipmentCode }}</div>
+          <span class="summary-tag">
+            {{ selectedDetail.typeInfo?.typeName || "未分类" }}
+          </span>
+          <div class="status-group">
+            <span class="badge" :class="selectedDetail.statusInfo?.onlineStatus === 1 ? 'online' : 'offline'">
+              <span class="badge-dot"></span>
+              <span class="badge-text">{{ selectedDetail.statusInfo?.onlineStatus === 1 ? "在线" : "离线" }}</span>
+            </span>
+            <span v-if="selectedDetail.statusInfo?.alarmStatus === 1" class="badge alarm">
+              <el-icon class="badge-icon"><WarningFilled /></el-icon>
+              <span class="badge-text">报警</span>
+            </span>
+          </div>
+        </div>
+
+        <div class="form-section-title detail-section-title">基础信息</div>
+        <el-descriptions :column="2" border size="small" class="dlg-grid">
+          <el-descriptions-item label="设备编码">{{ selectedDetail.equipmentInfo.equipmentCode || "-" }}</el-descriptions-item>
+          <el-descriptions-item label="设备名称">{{ selectedDetail.equipmentInfo.equipmentName || "-" }}</el-descriptions-item>
+          <el-descriptions-item label="设备类型">{{ selectedDetail.typeInfo?.typeName || "-" }}</el-descriptions-item>
+          <el-descriptions-item label="所属片区">{{ districtLabel(selectedDetail.equipmentInfo.district) }}</el-descriptions-item>
+          <el-descriptions-item label="设备型号">{{ selectedDetail.equipmentInfo.equipmentModel || "-" }}</el-descriptions-item>
+          <el-descriptions-item label="制造商">{{ selectedDetail.equipmentInfo.manufacturer || "-" }}</el-descriptions-item>
+          <el-descriptions-item label="生产日期">{{ selectedDetail.equipmentInfo.productionDate || "-" }}</el-descriptions-item>
+          <el-descriptions-item label="规格参数">{{ selectedDetail.equipmentInfo.equipmentSpec || "-" }}</el-descriptions-item>
+        </el-descriptions>
+
+        <div class="form-section-title detail-section-title">资产信息</div>
+        <el-descriptions :column="2" border size="small" class="dlg-grid">
+          <el-descriptions-item label="资产状态">{{ currentStatusMeta(selectedDetail.statusInfo?.currentStatus).text }}</el-descriptions-item>
+          <el-descriptions-item label="资产价值(元)">{{ selectedDetail.equipmentInfo.assetValue ?? "-" }}</el-descriptions-item>
+          <el-descriptions-item label="使用年限(年)">{{ selectedDetail.equipmentInfo.useLife ?? "-" }}</el-descriptions-item>
+        </el-descriptions>
+
+        <div class="form-section-title detail-section-title">部署运维</div>
+        <el-descriptions :column="2" border size="small" class="dlg-grid">
+          <el-descriptions-item label="安装位置" :span="2">{{ selectedDetail.equipmentInfo.equipmentLocation || "-" }}</el-descriptions-item>
+          <el-descriptions-item label="经度">{{ selectedDetail.equipmentInfo.longitude ?? "-" }}</el-descriptions-item>
+          <el-descriptions-item label="纬度">{{ selectedDetail.equipmentInfo.latitude ?? "-" }}</el-descriptions-item>
+          <el-descriptions-item label="安装时间">{{ selectedDetail.equipmentInfo.installTime || "-" }}</el-descriptions-item>
+          <el-descriptions-item label="维护人员">{{ selectedDetail.equipmentInfo.maintainer || "-" }}</el-descriptions-item>
+          <el-descriptions-item label="联系电话" :span="2">{{ selectedDetail.equipmentInfo.maintainerPhone || "-" }}</el-descriptions-item>
+          <el-descriptions-item label="备注" :span="2">{{ selectedDetail.equipmentInfo.remark || "-" }}</el-descriptions-item>
+        </el-descriptions>
+
+        <div v-if="selectedDetail.equipmentInfo.equipmentImage" class="detail-image">
+          <div class="field-label">设备图片</div>
+          <el-image
+            :src="resolveImageUrl(selectedDetail.equipmentInfo.equipmentImage)"
+            :preview-src-list="[resolveImageUrl(selectedDetail.equipmentInfo.equipmentImage)]"
+            preview-teleported
+            fit="cover"
+            class="field-media"
+          />
+        </div>
+      </template>
+      <el-empty v-else description="未选中设备" />
+      <template #footer>
+        <div class="dlg-footer">
+          <el-button class="btn-ghost" @click="detailVisible = false">关 闭</el-button>
+        </div>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup>
-import { computed, nextTick, onMounted, ref } from 'vue'
-import { Aim, CircleCheck, CircleClose, MapLocation, Odometer, Refresh, Search, Warning } from '@element-plus/icons-vue'
+import { computed, nextTick, onMounted, reactive, ref } from 'vue'
+import { CircleCheck, CircleClose, MapLocation, Monitor, Refresh, Warning, WarningFilled } from '@element-plus/icons-vue'
 import GisMapCanvas from '@/components/GisMapCanvas.vue'
-import WaterGisPageShell from '@/components/WaterGisPageShell.vue'
-import { getMonitoringPointColor, getMonitoringPointIcon, getMonitoringPointLabel, getMonitoringPointStatus, getMonitoringPointTagType } from '@/utils/gisMapColors'
-import {
-  getDeviceGisAbnormalPoints,
-  getDeviceGisAllPoints,
-  getDeviceGisDetail,
-  getDeviceGisStatistics
-} from '@/api/pipeNetwork/waterSupply'
+import { getDeviceGisAllPoints } from '@/api/pipeNetwork/waterSupply'
+import { getEquipmentDetail } from '@/api/pipeNetwork/pdeviceInform'
 import { normalizeDeviceGisPoints, normalizeDeviceRow } from '@/utils/waterSupplyUiModel'
-import { unwrapResponse } from '@/utils/waterSupplyModel'
-import { useDict } from '@/utils/dict'
+import { gisPointStatusIcons } from '@/utils/gisMapColors'
+import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
 
-const { alarm_level: alarmLevels } = useDict('alarm_level')
-const alarmLevelText = computed(() => alarmLevels.value.find(item => String(item.value) === String(selectedDetail.value?.alarmLevel))?.label || selectedDetail.value?.alarmLevelText || '—')
+const mapCenter = { lng: 110.3965, lat: 28.4638 }
 
-const searchKeyword = ref('')
-const selectedTypeId = ref('')
-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 gisMapRef = ref(null)
 const detailVisible = ref(false)
@@ -261,131 +190,147 @@ const detailLoading = ref(false)
 const detailError = ref('')
 const selectedDetail = ref(null)
 
-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(() => ({
+const statistics = 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
+  alarm: allDevices.value.filter((device) => Number(device.alarmStatus) === 1).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 iconsReady = ref(false)
+const markerImages = {
+  green: typeof Image !== 'undefined' ? new Image() : null,
+  grey: typeof Image !== 'undefined' ? new Image() : null,
+  red: typeof Image !== 'undefined' ? new Image() : null
+}
+const iconCache = new Map()
+
+function loadMarkerImages() {
+  const entries = [
+    ['green', gisPointStatusIcons.normal],
+    ['grey', gisPointStatusIcons.offline],
+    ['red', gisPointStatusIcons.alarm]
+  ]
+  let pending = entries.length
+  if (!pending) {
+    iconsReady.value = true
+    return
+  }
+  const done = () => {
+    pending -= 1
+    if (pending <= 0) iconsReady.value = true
+  }
+  entries.forEach(([key, url]) => {
+    const img = markerImages[key]
+    if (!img) return done()
+    img.onload = done
+    img.onerror = done
+    img.src = url
   })
-})
-
-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]) => ({
-    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 getStatusKey(device) {
-  return getMonitoringPointStatus(device)
-}
+function composeAlarmIcon(online) {
+  const base = online ? markerImages.green : markerImages.grey
+  const red = markerImages.red
+  if (!base || !red || !base.complete || !red.complete) {
+    return online ? gisPointStatusIcons.normal : gisPointStatusIcons.offline
+  }
+  const size = 32
+  const canvas = document.createElement('canvas')
+  canvas.width = size
+  canvas.height = size
+  const ctx = canvas.getContext('2d')
+  ctx.drawImage(base, 0, 0, size, size)
+  const badge = Math.round(size * 0.5)
+  ctx.drawImage(red, size - badge + 2, 1, badge, badge)
+  return canvas.toDataURL()
+}
+
+function getDeviceIcon(device) {
+  const online = Number(device.onlineStatus) === 1
+  const alarm = Number(device.alarmStatus) === 1
+  if (!alarm) return online ? gisPointStatusIcons.normal : gisPointStatusIcons.offline
+  if (!iconsReady.value) return online ? gisPointStatusIcons.normal : gisPointStatusIcons.offline
+  const key = online ? 'online' : 'offline'
+  if (!iconCache.has(key)) iconCache.set(key, composeAlarmIcon(online))
+  return iconCache.get(key)
+}
+
+// 图例状态筛选:隐藏后对应状态的设备在地图上不显示
+const legendVisible = reactive({ online: true, offline: true, alarm: true })
+
+function toggleLegend(key) {
+  legendVisible[key] = !legendVisible[key]
+}
+
+function isDeviceVisible(device) {
+  const online = Number(device.onlineStatus) === 1
+  const alarm = Number(device.alarmStatus) === 1
+  if (online && !legendVisible.online) return false
+  if (!online && !legendVisible.offline) return false
+  if (alarm && !legendVisible.alarm) return false
+  return true
+}
+
+const mapPoints = computed(() => {
+  // 依赖 iconsReady 与图例筛选状态,图片就绪或切换筛选后重算
+  void iconsReady.value
+  void legendVisible.online
+  void legendVisible.offline
+  void legendVisible.alarm
+  return allDevices.value
+    .filter(isDeviceVisible)
+    .map((device) => ({
+      ...device,
+      lng: device._longitude,
+      lat: device._latitude,
+      name: device.equipmentName || device.equipmentCode,
+      iconUrl: getDeviceIcon(device)
+    }))
+})
 
-function getStatusStyle(device) {
-  return { color: getMonitoringPointColor(device), text: getMonitoringPointLabel(device) }
-}
+// 所属片区:与后端 DistrictEnum 对齐(0-城南 1-城北 2-太常)
+const districtOptions = [
+  { label: '城南', value: 0 },
+  { label: '城北', value: 1 },
+  { label: '太常', value: 2 }
+]
 
-function statusText(device) {
-  return getStatusStyle(device).text
+function districtLabel(code) {
+  const hit = districtOptions.find((item) => item.value === code)
+  return hit ? hit.label : '-'
 }
 
-function statusTagType(device) {
-  return getMonitoringPointTagType(device)
+// 资产状态:0-在用,1-维修,2-报废
+function currentStatusMeta(value) {
+  const map = {
+    0: { text: '在用', type: 'success' },
+    1: { text: '维修', type: 'warning' },
+    2: { text: '报废', type: 'danger' }
+  }
+  return map[value] || { text: '未知', type: 'info' }
 }
 
-function deviceStatusTagType(status) {
-  const value = Number(status)
-  if (value === 3) return 'warning'
-  if (value === 4) return 'danger'
-  if (value === 5) return 'info'
-  return 'success'
+function resolveImageUrl(url) {
+  return buildMinioAttachmentUrl(url)
 }
 
 async function loadData() {
   loading.value = true
-  loadError.value = ''
+  mapError.value = ''
   try {
-    const [allResponse, abnormalResponse, statisticsResponse] = await Promise.all([
-      getDeviceGisAllPoints(),
-      getDeviceGisAbnormalPoints(),
-      getDeviceGisStatistics()
-    ])
+    const allResponse = await getDeviceGisAllPoints()
     allDevices.value = normalizeDeviceGisPoints(allResponse).map(normalizeDeviceRow)
-    abnormalDevices.value = normalizeDeviceGisPoints(abnormalResponse).map(normalizeDeviceRow)
-    gisStatistics.value = unwrapResponse(statisticsResponse) || {}
     await nextTick()
     gisMapRef.value?.fitBounds()
   } catch (error) {
-    loadError.value = '监测设备数据加载失败,请点击右上角“刷新”重试'
+    mapError.value = '监测设备数据加载失败,请点击右上角“刷新”重试'
   } finally {
     loading.value = false
   }
 }
 
-function resetFilters() {
-  searchKeyword.value = ''
-  selectedTypeId.value = ''
-  statusFilters.value = ['normal', 'alarm', 'warning', 'workOrder', 'offline']
-  loadData()
-}
-
-function fitBounds() {
-  gisMapRef.value?.fitBounds()
-}
-
-function locateDevice(device) {
-  gisMapRef.value?.locatePoint({ lng: device._longitude, lat: device._latitude })
-  openDetail(device.equipmentId)
-}
-
 async function openDetail(equipmentId) {
   if (!equipmentId) return
   detailVisible.value = true
@@ -393,172 +338,349 @@ async function openDetail(equipmentId) {
   selectedDetail.value = null
   detailError.value = ''
   try {
-    const detail = unwrapResponse(await getDeviceGisDetail(equipmentId))
-    selectedDetail.value = normalizeDeviceRow(detail)
+    const detail = await getEquipmentDetail(equipmentId)
+    selectedDetail.value = detail?.data || detail
   } catch (error) {
-    selectedDetail.value = null
     detailError.value = '设备详情加载失败,请关闭后重试'
   } finally {
     detailLoading.value = false
   }
 }
 
-onMounted(loadData)
+onMounted(() => {
+  loadMarkerImages()
+  loadData()
+})
 </script>
 
 <style scoped>
-.header-actions {
+.gis-page {
+  padding: 20px;
+  background: #f9f9f9;
+  min-height: calc(100vh - 84px);
+}
+
+/* 页头 */
+.page-head {
   display: flex;
   align-items: center;
-  gap: 10px;
+  justify-content: space-between;
+  margin-bottom: 20px;
 }
-.search-input,
-.type-select {
-  width: 240px;
+
+.page-head-titles {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  min-width: 0;
 }
 
-.panel-section h3 {
-  margin: 0 0 8px;
-  font-size: 14px;
-  color: #29567e;
+.page-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 20px;
+  font-weight: 600;
+  color: #0a1628;
 }
-.status-filter {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
+
+.page-title-icon {
+  color: #014db2;
+  font-size: 22px;
 }
-.type-list,
-.device-result-list {
+
+/* KPI */
+.kpi-row {
   display: flex;
-  flex-direction: column;
-  gap: 6px;
+  gap: 16px;
+  margin-bottom: 20px;
 }
-.type-list {
-  max-height: 160px;
+
+.kpi {
+  flex: 1 1 0;
+  min-width: 0;
+  border-radius: 12px;
 }
-.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;
+
+.kpi :deep(.el-card__body) {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 20px;
 }
-.type-list button {
+
+.kpi-icon {
   display: flex;
-  justify-content: space-between;
   align-items: center;
+  justify-content: center;
+  width: 44px;
+  height: 44px;
+  flex: 0 0 44px;
+  border-radius: 10px;
+  font-size: 22px;
+}
+
+.kpi-icon.total {
+  background: #edf3fc;
+  color: #014db2;
+}
+
+.kpi-icon.online {
+  background: #e7f8f1;
+  color: #10b981;
+}
+
+.kpi-icon.offline {
+  background: #fef4e2;
+  color: #f59e0b;
+}
+
+.kpi-icon.alarm {
+  background: #fdecec;
+  color: #ef4444;
+}
+
+.kpi-label {
   font-size: 13px;
-  color: #33556f;
+  font-weight: 400;
+  color: #6b7280;
+}
+
+.kpi-value {
+  margin-top: 6px;
+  font-size: 28px;
+  font-weight: 700;
+  line-height: 1;
+  color: #0a1628;
+}
+
+/* 地图 */
+.map-card {
+  border-radius: 12px;
+}
+
+.map-card :deep(.el-card__body) {
+  padding: 12px;
+}
+
+.map-wrap {
+  position: relative;
+  width: 100%;
+  height: calc(100vh - 340px);
+  min-height: 480px;
+  border-radius: 8px;
+  overflow: hidden;
 }
-.type-list button.active {
-  border-color: #4b9ce7;
-  background: #edf7ff;
-  color: #1662a8;
+
+.map-legend {
+  position: absolute;
+  top: 12px;
+  left: 12px;
+  z-index: 9999;
+  padding: 10px 12px;
+  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.92);
+  box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
+  pointer-events: auto;
 }
-.legend-panel {
-  border-top: 1px solid #e4eef8;
-  padding-top: 12px;
+
+.legend-title {
+  margin-bottom: 8px;
+  font-size: 12px;
+  font-weight: 600;
+  color: #94a3b8;
 }
+
 .legend-row {
   display: flex;
   align-items: center;
   gap: 8px;
   margin-bottom: 6px;
+  padding: 0;
+  border: none;
+  background: transparent;
+  font: inherit;
   font-size: 13px;
   color: #4d667d;
+  cursor: pointer;
+  transition: opacity 0.15s, color 0.15s;
+}
+
+.legend-row:last-child {
+  margin-bottom: 0;
 }
+
+.legend-row:hover {
+  color: #1f3b57;
+}
+
+.legend-row.is-off {
+  opacity: 0.4;
+  text-decoration: line-through;
+}
+
 .legend-dot {
   width: 10px;
   height: 10px;
   border-radius: 50%;
 }
-.legend-dot.normal { background: #2ecc71; }
-.legend-dot.alarm { background: #e74c3c; }
-.legend-dot.warning { background: #f1c40f; }
-.legend-dot.workOrder { background: #3498db; }
+
+.legend-dot.online { background: #2ecc71; }
 .legend-dot.offline { background: #95a5a6; }
-.result-section {
-  flex: 1;
-  min-height: 0;
-  display: flex;
-  flex-direction: column;
+.legend-dot.alarm { background: #e74c3c; }
+
+.map-error {
+  position: absolute;
+  left: 16px;
+  bottom: 16px;
+  width: 360px;
+  z-index: 2;
 }
-.device-result-list {
-  flex: none;
+
+.map-empty {
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 2;
 }
-.device-result-list button {
-  display: grid;
-  grid-template-columns: minmax(0, 1fr) auto;
-  gap: 3px 8px;
+
+/* 详情弹窗 */
+.dlg-summary {
+  display: flex;
   align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+  padding: 14px;
+  border-radius: 8px;
+  background: #f5f9ff;
 }
-.device-name {
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-  font-weight: 600;
-  color: #274b6d;
+
+.summary-name {
+  font-size: 15px;
+  font-weight: 500;
+  color: #014db2;
 }
-.device-meta {
-  grid-column: 1 / -1;
+
+.summary-code {
   font-size: 12px;
-  color: #7793aa;
+  font-weight: 400;
+  color: #6b7280;
 }
 
+.summary-tag {
+  display: inline-flex;
+  align-items: center;
+  padding: 3px 8px;
+  border-radius: 4px;
+  background: #fff;
+  font-size: 11px;
+  font-weight: 500;
+  color: #014db2;
+}
 
-.device-detail {
-  min-height: 240px;
+.dlg-grid {
+  margin-top: 16px;
 }
-.detail-hero h3,
-.detail-section h4 {
-  margin: 0 0 8px;
-  color: #1c4b78;
+
+.detail-image {
+  margin-top: 16px;
 }
-.detail-hero p {
-  margin: 0 0 10px;
-  color: #678197;
-  font-size: 13px;
+
+.field-label {
+  margin-bottom: 6px;
+  font-size: 12px;
+  font-weight: 400;
+  color: #6b7280;
 }
-.status-tags {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
+
+.field-media {
+  width: 320px;
+  max-width: 100%;
+  height: 180px;
+  border-radius: 6px;
+  border: 1px solid #e6e6eb;
+}
+
+.form-section-title {
+  margin: 8px 0 12px;
+  padding-left: 10px;
+  border-left: 3px solid #014db2;
+  color: #1f2937;
+  font-size: 14px;
+  font-weight: 600;
 }
-.detail-section {
+
+.detail-section-title {
   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;
+
+.status-group {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-shrink: 0;
 }
-.detail-section dd {
-  margin: 0;
-  color: #2b4c67;
-  overflow-wrap: anywhere;
+
+.badge {
+  display: flex;
+  align-items: center;
+  gap: 5px;
 }
-.muted {
-  margin: 0;
-  color: #7d93a6;
+
+.badge-dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 3px;
+  flex-shrink: 0;
+}
+
+.badge-text {
+  font-size: 12px;
+  font-weight: 500;
+}
+
+.badge.online .badge-dot { background: #10b981; }
+.badge.online .badge-text { color: #10b981; }
+.badge.offline .badge-dot { background: #f59e0b; }
+.badge.offline .badge-text { color: #f59e0b; }
+.badge.alarm .badge-text { color: #ef4444; }
+
+.badge-icon {
   font-size: 13px;
+  color: #ef4444;
+}
+
+.dlg-footer {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 10px;
 }
+
 @media (max-width: 1200px) {
-  .search-input,
-  .type-select {
-    width: 180px;
+  .kpi-row {
+    flex-wrap: wrap;
+  }
+
+  .kpi {
+    flex: 1 1 45%;
+  }
+
+  .map-wrap {
+    height: 520px;
   }
 }
-</style>
 
-<style lang="scss">
-@use './waterGisPageTheme.scss';
+@media (max-width: 768px) {
+  .kpi {
+    flex: 1 1 100%;
+  }
+
+  .page-head {
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 12px;
+  }
+}
 </style>