|
|
@@ -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('&', '&')
|
|
|
+ .replaceAll('<', '<')
|
|
|
+ .replaceAll('>', '>')
|
|
|
+ .replaceAll('"', '"')
|
|
|
+ .replaceAll("'", ''')
|
|
|
+}
|
|
|
+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>
|