|
@@ -1,198 +1,540 @@
|
|
|
-<!--窨井盖监测首页大屏-->
|
|
|
|
|
|
|
+<!--窨井盖监测首页大屏:中间地图按 equipmentGIS.vue 的 GIS 一张图规范实现-->
|
|
|
<template>
|
|
<template>
|
|
|
<div class="monitor-container">
|
|
<div class="monitor-container">
|
|
|
- <!-- 左侧栏 -->
|
|
|
|
|
- <div class="left-col">
|
|
|
|
|
- <!-- 待办工单 -->
|
|
|
|
|
- <div class="panel">
|
|
|
|
|
- <h3 class="panel-title">待办工单 <span class="title-count">{{ workOrders.length }} 条</span></h3>
|
|
|
|
|
- <el-table :data="workOrders" class="panel-table" :header-cell-style="tableHeaderStyle" size="small" v-loading="loading.work">
|
|
|
|
|
- <el-table-column prop="orderNo" label="工单编号" min-width="120" show-overflow-tooltip />
|
|
|
|
|
- <el-table-column prop="deviceCode" label="设备" min-width="100" show-overflow-tooltip />
|
|
|
|
|
- <el-table-column label="优先级" width="70">
|
|
|
|
|
- <template #default="{ row }">
|
|
|
|
|
- <span :class="['priority-tag', priorityClass(row.orderLevel)]">{{ priorityText(row.orderLevel) }}</span>
|
|
|
|
|
- </template>
|
|
|
|
|
- </el-table-column>
|
|
|
|
|
- </el-table>
|
|
|
|
|
|
|
+ <!-- 顶部:标题 + 设备状态总览(全域健康度带) -->
|
|
|
|
|
+ <header class="top-bar">
|
|
|
|
|
+ <div class="brand">
|
|
|
|
|
+ <div class="brand-title">窨井盖监测中心</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <!-- 告警类型统计(15天内) -->
|
|
|
|
|
- <div class="panel">
|
|
|
|
|
- <h3 class="panel-title">
|
|
|
|
|
- 告警类型统计(15天内)
|
|
|
|
|
- <span class="title-count">{{ alarmRangeText }}</span>
|
|
|
|
|
- </h3>
|
|
|
|
|
- <div ref="alarmChartRef" class="chart" v-loading="loading.alarm" />
|
|
|
|
|
|
|
+ <div class="health-band">
|
|
|
|
|
+ <div class="hb-rate">
|
|
|
|
|
+ <b :class="{ low: onlineRate < 60 }">{{ onlineRate }}%</b>
|
|
|
|
|
+ <span>设备在线率</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="hb-bar">
|
|
|
|
|
+ <i class="on" :style="{ width: onlineRate + '%' }"></i>
|
|
|
|
|
+ <i class="off" :style="{ width: offlineRate + '%' }"></i>
|
|
|
|
|
+ <i class="al" :style="{ width: alarmRate + '%' }"></i>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="hb-items">
|
|
|
|
|
+ <div><b>{{ stats.total }}</b><span>设备总数</span></div>
|
|
|
|
|
+ <div><b class="text-green">{{ stats.online }}</b><span>在线</span></div>
|
|
|
|
|
+ <div><b class="text-gray">{{ stats.offline }}</b><span>离线</span></div>
|
|
|
|
|
+ <div><b class="text-red">{{ stats.alarm }}</b><span>报警</span></div>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
-
|
|
|
|
|
- <!-- 设备区域信息 -->
|
|
|
|
|
- <div class="panel">
|
|
|
|
|
- <h3 class="panel-title">设备区域信息</h3>
|
|
|
|
|
- <div ref="areaChartRef" class="chart" v-loading="loading.device" />
|
|
|
|
|
|
|
+ </header>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 主体三列 -->
|
|
|
|
|
+ <div class="main-grid">
|
|
|
|
|
+ <!-- 左:实时报警 + 报警类型分布 -->
|
|
|
|
|
+ <div class="col left-col">
|
|
|
|
|
+ <section class="panel alarm-panel">
|
|
|
|
|
+ <h3 class="panel-title">
|
|
|
|
|
+ 实时报警
|
|
|
|
|
+ <span class="badge" :class="{ zero: !activeAlarms.length }">{{ activeAlarms.length }}</span>
|
|
|
|
|
+ </h3>
|
|
|
|
|
+ <div class="alarm-list" v-loading="loading.alarm">
|
|
|
|
|
+ <div v-for="a in activeAlarms" :key="a.id || a.alarmNo" class="alarm-item">
|
|
|
|
|
+ <div class="alarm-top">
|
|
|
|
|
+ <span class="alarm-dev">
|
|
|
|
|
+ {{ a.deviceName || a.deviceCode || '未知设备' }}
|
|
|
|
|
+ <span v-if="a.deviceCode" class="alarm-code">{{ a.deviceCode }}</span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span class="alarm-level" :style="{ color: levelColor(a.alarmLevel) }">{{ levelName(a.alarmLevel) }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="alarm-time">
|
|
|
|
|
+ <span v-if="a.alarmNo" class="alarm-no">{{ a.alarmNo }}</span>
|
|
|
|
|
+ <span>{{ a.createTime || '—' }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="alarm-desc">
|
|
|
|
|
+ {{ a.alarmContent || a.warningCode || '—' }}
|
|
|
|
|
+ <span v-if="a.actualValue != null">实测 {{ a.actualValue }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="!activeAlarms.length" class="empty">当前无未处理报警</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <section class="panel dist-panel">
|
|
|
|
|
+ <h3 class="panel-title">
|
|
|
|
|
+ 报警类型分布
|
|
|
|
|
+ <span class="title-count">近 {{ ALARM_RANGE_DAYS }} 天 · {{ recentAlarms.length }} 条</span>
|
|
|
|
|
+ </h3>
|
|
|
|
|
+ <div class="type-dist" v-loading="loading.alarm">
|
|
|
|
|
+ <div v-for="t in alarmTypeDist" :key="t.name" class="td-row">
|
|
|
|
|
+ <span class="td-name" :title="t.name">{{ t.name }}</span>
|
|
|
|
|
+ <div class="td-track"><i :style="{ width: t.rate + '%' }"></i></div>
|
|
|
|
|
+ <span class="td-num">{{ t.count }} 条</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="!alarmTypeDist.length" class="empty">近 {{ ALARM_RANGE_DAYS }} 天暂无报警</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
|
|
|
- <!-- 中间地图区域 -->
|
|
|
|
|
- <div class="center-col">
|
|
|
|
|
- <div class="panel map-panel">
|
|
|
|
|
- <div class="map-header">
|
|
|
|
|
- <span>窨井设备分布</span>
|
|
|
|
|
- <div class="map-legend">
|
|
|
|
|
- <span><i class="legend-dot online"></i> 在线 {{ stats.online }}</span>
|
|
|
|
|
- <span><i class="legend-dot alert"></i> 告警 {{ stats.alarm }}</span>
|
|
|
|
|
- <span><i class="legend-dot offline"></i> 离线 {{ stats.offline }}</span>
|
|
|
|
|
|
|
+ <!-- 中:监测设备 GIS 一张图(与 equipmentGIS.vue 同规范) -->
|
|
|
|
|
+ <div class="col center-col">
|
|
|
|
|
+ <section class="panel map-panel">
|
|
|
|
|
+ <div class="map-header">
|
|
|
|
|
+ <span>窨井设备分布</span>
|
|
|
|
|
+ <el-button :icon="Refresh" size="small" :loading="gisLoading" @click="loadGisData">刷新</el-button>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
- <div id="homeMap" class="bmap-container"></div>
|
|
|
|
|
|
|
+ <div class="map-wrap">
|
|
|
|
|
+ <GisMapCanvas
|
|
|
|
|
+ ref="gisMapRef"
|
|
|
|
|
+ :points="mapPoints"
|
|
|
|
|
+ cluster-label="设备集合"
|
|
|
|
|
+ :center="mapCenter"
|
|
|
|
|
+ :zoom="15"
|
|
|
|
|
+ @point-click="openDetail($event.equipmentId)"
|
|
|
|
|
+ @error="mapError = $event"
|
|
|
|
|
+ />
|
|
|
|
|
+ <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="loadGisData">重试</el-button>
|
|
|
|
|
+ </el-alert>
|
|
|
|
|
+ <el-empty
|
|
|
|
|
+ v-if="!gisLoading && !mapError && !mapPoints.length"
|
|
|
|
|
+ class="map-empty"
|
|
|
|
|
+ description="当前没有可显示的监测设备"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <!-- 设备分类 -->
|
|
|
|
|
- <div class="panel chart-panel">
|
|
|
|
|
- <h3 class="panel-title">设备分类</h3>
|
|
|
|
|
- <div ref="deviceTypeChartRef" class="chart" v-loading="loading.device" />
|
|
|
|
|
|
|
+ <!-- 右:待办工单 + 设备状态总览(片区点阵) -->
|
|
|
|
|
+ <div class="col right-col">
|
|
|
|
|
+ <section class="panel work-panel">
|
|
|
|
|
+ <h3 class="panel-title">待办工单 <span class="title-count">{{ workOrders.length }} 条</span></h3>
|
|
|
|
|
+ <el-table :data="workOrders" class="panel-table" :header-cell-style="tableHeaderStyle" size="small" v-loading="loading.work">
|
|
|
|
|
+ <el-table-column prop="orderNo" label="工单编号" min-width="110" show-overflow-tooltip />
|
|
|
|
|
+ <el-table-column prop="orderDesc" label="工单描述" min-width="150" show-overflow-tooltip :formatter="(row) => row.orderDesc || '-'" />
|
|
|
|
|
+ <el-table-column prop="deviceName" label="设备" min-width="110" show-overflow-tooltip :formatter="(row) => row.deviceName || row.deviceCode || '-'" />
|
|
|
|
|
+ <el-table-column label="优先级" width="70">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <span :class="['priority-tag', priorityClass(row.orderLevel)]">{{ priorityText(row.orderLevel) }}</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <section class="panel area-panel">
|
|
|
|
|
+ <h3 class="panel-title">
|
|
|
|
|
+ 设备状态总览
|
|
|
|
|
+ <span class="title-count">按片区 · {{ areaRank.length }} 个片区</span>
|
|
|
|
|
+ </h3>
|
|
|
|
|
+ <div class="area-cards" v-loading="loading.device">
|
|
|
|
|
+ <div v-for="a in areaRank" :key="a.district" class="acard" :class="{ 'has-alarm': a.alarm > 0 }">
|
|
|
|
|
+ <div class="ac-head">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div class="ac-name">{{ a.district }}</div>
|
|
|
|
|
+ <div class="ac-sub">共 {{ a.total }} 台设备</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="ac-rate">
|
|
|
|
|
+ <b :class="{ 'text-red': a.alarm > 0 }">{{ areaOnlineRate(a) }}%</b>
|
|
|
|
|
+ <span>在线率</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="ac-bar">
|
|
|
|
|
+ <i class="on" :style="{ width: areaOnlineRate(a) + '%' }"></i>
|
|
|
|
|
+ <i class="off" :style="{ width: areaOfflineRate(a) + '%' }"></i>
|
|
|
|
|
+ <i class="al" :style="{ width: areaAlarmRate(a) + '%' }"></i>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="dot-matrix">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="d in devicesByDistrict(a.district)"
|
|
|
|
|
+ :key="d.id || d.code"
|
|
|
|
|
+ class="dot"
|
|
|
|
|
+ :class="dotClass(d)"
|
|
|
|
|
+ :title="`${d.name}(${d.code})· ${statusText(d.status)}`"
|
|
|
|
|
+ ></div>
|
|
|
|
|
+ <span v-if="!devicesByDistrict(a.district).length" class="ac-tip">暂无设备</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="ac-metrics">
|
|
|
|
|
+ <span>在线 <b>{{ a.online }}</b></span>
|
|
|
|
|
+ <span>离线 <b>{{ a.offline }}</b></span>
|
|
|
|
|
+ <span>报警 <b class="text-red">{{ a.alarm }}</b></span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <!-- 右侧栏 -->
|
|
|
|
|
- <div class="right-col">
|
|
|
|
|
- <!-- 设备列表 -->
|
|
|
|
|
- <div class="panel">
|
|
|
|
|
- <h3 class="panel-title">设备列表 <span class="title-count">共 {{ filteredDevices.length }} 台</span></h3>
|
|
|
|
|
- <div class="filter-bar">
|
|
|
|
|
- <el-select v-model="deviceTypeFilter" size="small" placeholder="设备类型" clearable style="width: 120px">
|
|
|
|
|
- <el-option v-for="item in deviceTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
|
|
|
|
- </el-select>
|
|
|
|
|
- </div>
|
|
|
|
|
- <el-table :data="filteredDevices" class="panel-table" :header-cell-style="tableHeaderStyle" size="small" v-loading="loading.device">
|
|
|
|
|
- <el-table-column prop="name" label="设备名称" min-width="100" show-overflow-tooltip />
|
|
|
|
|
- <el-table-column label="类型" width="70">
|
|
|
|
|
- <template #default="{ row }">{{ typeText(row.typeId, row.typeName) }}</template>
|
|
|
|
|
- </el-table-column>
|
|
|
|
|
- <el-table-column label="电量" width="60">
|
|
|
|
|
- <template #default="{ row }">
|
|
|
|
|
- <el-tag :type="batteryTag(row.battery)" size="small">{{ row.battery ?? '-' }}%</el-tag>
|
|
|
|
|
- </template>
|
|
|
|
|
- </el-table-column>
|
|
|
|
|
- <el-table-column label="状态" width="60">
|
|
|
|
|
- <template #default="{ row }">
|
|
|
|
|
- <span :class="['status-dot', row.status]"></span>
|
|
|
|
|
- <span class="status-text">{{ statusText(row.status) }}</span>
|
|
|
|
|
- </template>
|
|
|
|
|
- </el-table-column>
|
|
|
|
|
- </el-table>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <!-- 设备详情(与 equipmentGIS.vue 一致) -->
|
|
|
|
|
+ <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="设备详情加载失败,请关闭后重试" />
|
|
|
|
|
+ <div v-loading="detailLoading">
|
|
|
|
|
+ <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="st-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="st-badge alarm">
|
|
|
|
|
+ <el-icon class="badge-icon"><WarningFilled /></el-icon>
|
|
|
|
|
+ <span class="badge-text">报警</span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- <!-- 动态信息 -->
|
|
|
|
|
- <div class="panel">
|
|
|
|
|
- <h3 class="panel-title">动态信息</h3>
|
|
|
|
|
- <el-table :data="dynamicLogs" class="panel-table" :header-cell-style="tableHeaderStyle" size="small" v-loading="loading.alarm">
|
|
|
|
|
- <el-table-column prop="time" label="时间" width="140" />
|
|
|
|
|
- <el-table-column prop="event" label="事件" min-width="120" show-overflow-tooltip />
|
|
|
|
|
- <el-table-column label="状态" width="70">
|
|
|
|
|
- <template #default="{ row }">
|
|
|
|
|
- <span :class="['status-tag', row.alarmStatus === 1 ? 'done' : 'processing']">{{ row.alarmStatus === 1 ? '已处理' : '未处理' }}</span>
|
|
|
|
|
- </template>
|
|
|
|
|
- </el-table-column>
|
|
|
|
|
- </el-table>
|
|
|
|
|
|
|
+ <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-if="!detailLoading" description="未选中设备" />
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
+ <template #footer>
|
|
|
|
|
+ <div class="dlg-footer">
|
|
|
|
|
+ <el-button @click="detailVisible = false">关 闭</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-dialog>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup>
|
|
<script setup>
|
|
|
-import { ref, reactive, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
|
|
|
|
|
-import * as echarts from 'echarts'
|
|
|
|
|
-import { getManholeLayerData } from '@/api/pipeNetwork/basic'
|
|
|
|
|
|
|
+import { ref, reactive, onMounted, nextTick, computed } from 'vue'
|
|
|
|
|
+import { Refresh, WarningFilled } from '@element-plus/icons-vue'
|
|
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
|
|
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
|
|
|
|
|
+import { getManholeLayerData, getEquipmentGisPoints } from '@/api/pipeNetwork/basic'
|
|
|
|
|
+import { getEquipmentDetail } from '@/api/pipeNetwork/pdeviceInform'
|
|
|
import { listWorkOrder } from '@/api/pipeNetwork/ops/workOrder.js'
|
|
import { listWorkOrder } from '@/api/pipeNetwork/ops/workOrder.js'
|
|
|
import { ORDER_STATUS_GROUP } from '@/views/subSystem/pipeNetwork/ops/workOrder/js/workOrderDict.js'
|
|
import { ORDER_STATUS_GROUP } from '@/views/subSystem/pipeNetwork/ops/workOrder/js/workOrderDict.js'
|
|
|
import { getAlarmDataPage } from '@/api/drainage'
|
|
import { getAlarmDataPage } from '@/api/drainage'
|
|
|
-import { ElMessage } from 'element-plus'
|
|
|
|
|
-import locationIcon from '@/assets/images/location.png'
|
|
|
|
|
-// 台账存 WGS84(设备表单选点经 bd09ToWgs84 回填),百度地图必须转 BD-09 再画
|
|
|
|
|
-import { convertCoord } from '@/utils/coordTransform'
|
|
|
|
|
|
|
+import { gisPointStatusIcons } from '@/utils/gisMapColors'
|
|
|
|
|
+import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
|
|
|
|
|
|
|
|
// ==================== 状态 ====================
|
|
// ==================== 状态 ====================
|
|
|
const loading = reactive({ work: false, alarm: false, device: false })
|
|
const loading = reactive({ work: false, alarm: false, device: false })
|
|
|
-const stats = reactive({ total: 0, online: 0, offline: 0, alarm: 0 })
|
|
|
|
|
-
|
|
|
|
|
const workOrders = ref([])
|
|
const workOrders = ref([])
|
|
|
const allDevices = ref([])
|
|
const allDevices = ref([])
|
|
|
-const dynamicLogs = ref([])
|
|
|
|
|
-const deviceTypeFilter = ref('')
|
|
|
|
|
-const alarmRangeText = ref(formatAlarmRange())
|
|
|
|
|
-
|
|
|
|
|
-/** 告警统计区间文案,空数据时也展示,避免面板看不出统计范围 */
|
|
|
|
|
-function formatAlarmRange() {
|
|
|
|
|
- const end = new Date()
|
|
|
|
|
- const start = new Date(end.getTime() - ALARM_RANGE_DAYS * 24 * 3600 * 1000)
|
|
|
|
|
- const label = date => `${date.getMonth() + 1}-${String(date.getDate()).padStart(2, '0')}`
|
|
|
|
|
- return `${label(start)} ~ ${label(end)}`
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// 图表引用
|
|
|
|
|
-const alarmChartRef = ref(null)
|
|
|
|
|
-const areaChartRef = ref(null)
|
|
|
|
|
-const deviceTypeChartRef = ref(null)
|
|
|
|
|
-let alarmChart = null, areaChart = null, deviceTypeChart = null
|
|
|
|
|
|
|
+const recentAlarms = ref([])
|
|
|
|
|
|
|
|
// ==================== 常量映射 ====================
|
|
// ==================== 常量映射 ====================
|
|
|
-/** 历史遗留窨井类型编码,仅用于后端未返回 typeName 时兜底展示 */
|
|
|
|
|
-const LEGACY_TYPE_MAP = { '5': '供水窨井', '9': '排水窨井', '10': '燃气窨井' }
|
|
|
|
|
-
|
|
|
|
|
-// 所属片区:后端 DistrictEnum(0-城南 1-城北 2-太常)
|
|
|
|
|
|
|
+/** 所属片区:后端 DistrictEnum(0-城南 1-城北 2-太常) */
|
|
|
const DISTRICT_MAP = { 0: '城南', 1: '城北', 2: '太常' }
|
|
const DISTRICT_MAP = { 0: '城南', 1: '城北', 2: '太常' }
|
|
|
-function districtLabel(code) {
|
|
|
|
|
- return code == null ? '' : (DISTRICT_MAP[code] || String(code))
|
|
|
|
|
-}
|
|
|
|
|
-const tableHeaderStyle = { background: 'rgba(0,100,200,0.3)', color: '#fff', borderColor: '#2b4b8c' }
|
|
|
|
|
-
|
|
|
|
|
-const priorityText = (l) => ({ 0: '紧急', 1: '一般', 2: '低' }[l] || '-')
|
|
|
|
|
-const priorityClass = (l) => ({ 0: 'high', 1: 'medium', 2: 'low' }[l] || 'low')
|
|
|
|
|
-/** 待办工单口径:待派单/待接单/已接单/处理中/延期中(枚举派生,见 workOrderDict.js) */
|
|
|
|
|
|
|
+/** 无设备数据时仍展示的区域维度,取自 DistrictEnum */
|
|
|
|
|
+const DEFAULT_DISTRICTS = ['城南', '城北', '太常']
|
|
|
const TODO_ORDER_STATUS = ORDER_STATUS_GROUP.todo.join(',')
|
|
const TODO_ORDER_STATUS = ORDER_STATUS_GROUP.todo.join(',')
|
|
|
/** 设备父类别ID(equipment_type.parent_id),与工单列表页保持同一口径 */
|
|
/** 设备父类别ID(equipment_type.parent_id),与工单列表页保持同一口径 */
|
|
|
const DEVICE_TYPE_PARENT_ID = '4'
|
|
const DEVICE_TYPE_PARENT_ID = '4'
|
|
|
-/** 窨井设备大类根ID(EquipmentTypeEnum.ROOT_MANHOLE_DEVICE),报警查询按大类隔离 */
|
|
|
|
|
|
|
+/** 窨井设备大类根ID(EquipmentTypeEnum.ROOT_MANHOLE_DEVICE) */
|
|
|
const MANHOLE_ROOT_TYPE_ID = 4
|
|
const MANHOLE_ROOT_TYPE_ID = 4
|
|
|
/** 报警查询后端限制 pageSize 不超过 100 */
|
|
/** 报警查询后端限制 pageSize 不超过 100 */
|
|
|
const ALARM_PAGE_SIZE = 100
|
|
const ALARM_PAGE_SIZE = 100
|
|
|
const ALARM_MAX_PAGES = 3
|
|
const ALARM_MAX_PAGES = 3
|
|
|
-/** 告警统计窗口(天) */
|
|
|
|
|
|
|
+/** 报警统计窗口(天) */
|
|
|
const ALARM_RANGE_DAYS = 15
|
|
const ALARM_RANGE_DAYS = 15
|
|
|
-/** 无设备数据时仍展示的区域维度,取自 DistrictEnum(0-城南 1-城北 2-太常) */
|
|
|
|
|
-const DEFAULT_DISTRICTS = ['城南', '城北', '太常']
|
|
|
|
|
-const typeText = (id, name) => name || LEGACY_TYPE_MAP[id] || (id ? `类型${id}` : '-')
|
|
|
|
|
|
|
+
|
|
|
|
|
+const tableHeaderStyle = { background: '#f7f9fc', color: '#5e6d82', borderColor: '#e3e9f2' }
|
|
|
|
|
+const priorityText = (l) => ({ 0: '紧急', 1: '一般', 2: '低' }[l] || '-')
|
|
|
|
|
+const priorityClass = (l) => ({ 0: 'high', 1: 'medium', 2: 'low' }[l] || 'low')
|
|
|
const statusText = (s) => ({ normal: '在线', alert: '告警', offline: '离线' }[s] || s)
|
|
const statusText = (s) => ({ normal: '在线', alert: '告警', offline: '离线' }[s] || s)
|
|
|
-const batteryTag = (b) => {
|
|
|
|
|
- if (b == null) return 'info'
|
|
|
|
|
- if (Number(b) <= 20) return 'danger'
|
|
|
|
|
- if (Number(b) <= 50) return 'warning'
|
|
|
|
|
- return 'success'
|
|
|
|
|
|
|
+function districtLabel(code) {
|
|
|
|
|
+ return code == null ? '-' : (DISTRICT_MAP[code] ?? String(code))
|
|
|
|
|
+}
|
|
|
|
|
+/** 报警类型展示:优先报警内容,其次报警编码 */
|
|
|
|
|
+function alarmTypeText(record) {
|
|
|
|
|
+ return record.alarmContent || record.warningCode || '其他告警'
|
|
|
|
|
+}
|
|
|
|
|
+/** 报警等级配色/名称:与系统 alarm_level 字典一致(1红 2橙 3蓝 4绿) */
|
|
|
|
|
+function levelColor(level) {
|
|
|
|
|
+ return ({ 1: '#f56c6c', 2: '#e6a23c', 3: '#409eff', 4: '#67c23a' }[Number(level)] || '#9ba8c0')
|
|
|
|
|
+}
|
|
|
|
|
+function levelName(level) {
|
|
|
|
|
+ return ({ 1: '一级报警', 2: '二级报警', 3: '三级报警', 4: '四级报警' }[Number(level)] || (level != null ? level + '级' : '—'))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const pad2 = value => String(value).padStart(2, '0')
|
|
|
|
|
+function formatDateTime(date) {
|
|
|
|
|
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ==================== 筛选后设备列表 ====================
|
|
|
|
|
-/** 设备类型下拉项:跟着台账实际类型走,不再写死 5/9/10 */
|
|
|
|
|
-const deviceTypeOptions = computed(() => {
|
|
|
|
|
|
|
+// ==================== 顶部健康度带 ====================
|
|
|
|
|
+const stats = computed(() => {
|
|
|
|
|
+ const list = allDevices.value
|
|
|
|
|
+ return {
|
|
|
|
|
+ total: list.length,
|
|
|
|
|
+ online: list.filter(d => d.status === 'normal').length,
|
|
|
|
|
+ offline: list.filter(d => d.status === 'offline').length,
|
|
|
|
|
+ alarm: list.filter(d => d.status === 'alert').length
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+const onlineRate = computed(() => stats.value.total ? Math.round(stats.value.online / stats.value.total * 100) : 0)
|
|
|
|
|
+const offlineRate = computed(() => stats.value.total ? Math.round(stats.value.offline / stats.value.total * 100) : 0)
|
|
|
|
|
+const alarmRate = computed(() => stats.value.total ? Math.round(stats.value.alarm / stats.value.total * 100) : 0)
|
|
|
|
|
+
|
|
|
|
|
+// ==================== 片区设备总览(点阵) ====================
|
|
|
|
|
+function areaKey(d) {
|
|
|
|
|
+ return d.district || d.location || '未分配区域'
|
|
|
|
|
+}
|
|
|
|
|
+const areaRank = computed(() => {
|
|
|
const map = new Map()
|
|
const map = new Map()
|
|
|
allDevices.value.forEach(d => {
|
|
allDevices.value.forEach(d => {
|
|
|
- const value = String(d.typeId ?? '')
|
|
|
|
|
- if (!value || map.has(value)) return
|
|
|
|
|
- map.set(value, { value, label: typeText(d.typeId, d.typeName) })
|
|
|
|
|
|
|
+ const name = areaKey(d)
|
|
|
|
|
+ if (!map.has(name)) map.set(name, { district: name, total: 0, online: 0, offline: 0, alarm: 0 })
|
|
|
|
|
+ const a = map.get(name)
|
|
|
|
|
+ a.total += 1
|
|
|
|
|
+ if (d.status === 'normal') a.online += 1
|
|
|
|
|
+ else if (d.status === 'offline') a.offline += 1
|
|
|
|
|
+ else if (d.status === 'alert') a.alarm += 1
|
|
|
|
|
+ })
|
|
|
|
|
+ const list = Array.from(map.values()).sort((x, y) => y.total - x.total)
|
|
|
|
|
+ return list.length ? list : DEFAULT_DISTRICTS.map(name => ({ district: name, total: 0, online: 0, offline: 0, alarm: 0 }))
|
|
|
|
|
+})
|
|
|
|
|
+function devicesByDistrict(name) {
|
|
|
|
|
+ return allDevices.value.filter(d => areaKey(d) === name)
|
|
|
|
|
+}
|
|
|
|
|
+function dotClass(d) {
|
|
|
|
|
+ if (d.status === 'alert') return 'al'
|
|
|
|
|
+ return d.status === 'normal' ? 'on' : 'off'
|
|
|
|
|
+}
|
|
|
|
|
+function areaOnlineRate(a) { return a.total ? Math.round(a.online / a.total * 100) : 0 }
|
|
|
|
|
+function areaOfflineRate(a) { return a.total ? Math.round(a.offline / a.total * 100) : 0 }
|
|
|
|
|
+function areaAlarmRate(a) { return a.total ? Math.round(a.alarm / a.total * 100) : 0 }
|
|
|
|
|
+
|
|
|
|
|
+// ==================== 实时报警 / 报警类型分布 ====================
|
|
|
|
|
+/** 实时报警:近 N 天未处理(alarmStatus!=1)的报警,按时间倒序 */
|
|
|
|
|
+const activeAlarms = computed(() => recentAlarms.value
|
|
|
|
|
+ .filter(r => Number(r.alarmStatus) !== 1)
|
|
|
|
|
+ .slice()
|
|
|
|
|
+ .sort((a, b) => String(b.createTime || '').localeCompare(String(a.createTime || '')))
|
|
|
|
|
+ .slice(0, 20))
|
|
|
|
|
+/** 报警类型分布:按类型聚合条数,横条按最大值归一化 */
|
|
|
|
|
+const alarmTypeDist = computed(() => {
|
|
|
|
|
+ const map = new Map()
|
|
|
|
|
+ recentAlarms.value.forEach(r => {
|
|
|
|
|
+ const name = alarmTypeText(r)
|
|
|
|
|
+ map.set(name, (map.get(name) || 0) + 1)
|
|
|
})
|
|
})
|
|
|
- return Array.from(map.values())
|
|
|
|
|
|
|
+ const list = Array.from(map, ([name, count]) => ({ name, count }))
|
|
|
|
|
+ .sort((a, b) => b.count - a.count)
|
|
|
|
|
+ .slice(0, 8)
|
|
|
|
|
+ const max = Math.max(1, ...list.map(x => x.count))
|
|
|
|
|
+ return list.map(x => ({ ...x, rate: Math.round(x.count / max * 100) }))
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
-const filteredDevices = computed(() => {
|
|
|
|
|
- if (!deviceTypeFilter.value) return allDevices.value
|
|
|
|
|
- return allDevices.value.filter(d => String(d.typeId) === deviceTypeFilter.value)
|
|
|
|
|
|
|
+// ==================== GIS 一张图(与 equipmentGIS.vue 同规范) ====================
|
|
|
|
|
+const mapCenter = { lng: 110.3965, lat: 28.4638 }
|
|
|
|
|
+const gisDevices = ref([])
|
|
|
|
|
+const gisLoading = ref(false)
|
|
|
|
|
+const mapError = ref('')
|
|
|
|
|
+const gisMapRef = ref(null)
|
|
|
|
|
+
|
|
|
|
|
+/** 标记图预加载:在线=绿、离线=灰,报警时在其右上角叠加红色标记 */
|
|
|
|
|
+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
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 gisDevices.value
|
|
|
|
|
+ .filter(isDeviceVisible)
|
|
|
|
|
+ .map(device => ({
|
|
|
|
|
+ ...device,
|
|
|
|
|
+ lng: device._longitude,
|
|
|
|
|
+ lat: device._latitude,
|
|
|
|
|
+ name: device.equipmentName || device.equipmentCode,
|
|
|
|
|
+ iconUrl: getDeviceIcon(device)
|
|
|
|
|
+ }))
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+/** 将 EquipmentBaseVO(统一设备台账)映射为地图点位设备对象 */
|
|
|
|
|
+function normalizeEquipment(row) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ equipmentId: row.equipmentId,
|
|
|
|
|
+ equipmentCode: row.equipmentCode,
|
|
|
|
|
+ equipmentName: row.equipmentName,
|
|
|
|
|
+ equipmentTypeId: row.equipmentTypeId,
|
|
|
|
|
+ equipmentTypeName: row.equipmentTypeName,
|
|
|
|
|
+ equipmentImage: row.equipmentImage,
|
|
|
|
|
+ onlineStatus: row.onlineStatus,
|
|
|
|
|
+ alarmStatus: row.alarmStatus,
|
|
|
|
|
+ currentStatus: row.currentStatus,
|
|
|
|
|
+ _longitude: row.longitude != null ? Number(row.longitude) : null,
|
|
|
|
|
+ _latitude: row.latitude != null ? Number(row.latitude) : null
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function loadGisData() {
|
|
|
|
|
+ gisLoading.value = true
|
|
|
|
|
+ mapError.value = ''
|
|
|
|
|
+ try {
|
|
|
|
|
+ // rootTypeId=4 只取窨井设备,与 equipmentGIS 一致
|
|
|
|
|
+ const res = await getEquipmentGisPoints({ rootTypeId: MANHOLE_ROOT_TYPE_ID })
|
|
|
|
|
+ const list = res?.data ?? res
|
|
|
|
|
+ gisDevices.value = (Array.isArray(list) ? list : []).map(normalizeEquipment)
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ gisMapRef.value?.fitBounds()
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ mapError.value = '监测设备数据加载失败,请点击右上角“刷新”重试'
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ gisLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ==================== 设备详情 ====================
|
|
|
|
|
+const detailVisible = ref(false)
|
|
|
|
|
+const detailLoading = ref(false)
|
|
|
|
|
+const detailError = ref('')
|
|
|
|
|
+const selectedDetail = ref(null)
|
|
|
|
|
+
|
|
|
|
|
+/** 资产状态: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 resolveImageUrl(url) {
|
|
|
|
|
+ return buildMinioAttachmentUrl(url)
|
|
|
|
|
+}
|
|
|
|
|
+async function openDetail(equipmentId) {
|
|
|
|
|
+ if (!equipmentId) return
|
|
|
|
|
+ detailVisible.value = true
|
|
|
|
|
+ detailLoading.value = true
|
|
|
|
|
+ selectedDetail.value = null
|
|
|
|
|
+ detailError.value = ''
|
|
|
|
|
+ try {
|
|
|
|
|
+ const detail = await getEquipmentDetail(equipmentId)
|
|
|
|
|
+ selectedDetail.value = detail?.data || detail
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ detailError.value = '设备详情加载失败,请关闭后重试'
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ detailLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// ==================== 数据加载 ====================
|
|
// ==================== 数据加载 ====================
|
|
|
async function loadAll() {
|
|
async function loadAll() {
|
|
|
- await Promise.allSettled([loadDevices(), loadWorkOrders(), loadAlarmData()])
|
|
|
|
|
- await nextTick()
|
|
|
|
|
- handleResize()
|
|
|
|
|
|
|
+ await Promise.allSettled([loadDevices(), loadWorkOrders(), loadAlarmData(), loadGisData()])
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function loadDevices() {
|
|
async function loadDevices() {
|
|
@@ -212,27 +554,13 @@ async function loadDevices() {
|
|
|
else if (onlineStatus === 1) status = 'normal'
|
|
else if (onlineStatus === 1) status = 'normal'
|
|
|
return {
|
|
return {
|
|
|
id: d.equipmentId, code: d.equipmentCode, name: d.equipmentName,
|
|
id: d.equipmentId, code: d.equipmentCode, name: d.equipmentName,
|
|
|
- typeId: d.equipmentTypeId, typeName: d.equipmentTypeName,
|
|
|
|
|
- location: d.equipmentLocation, district: districtLabel(d.district),
|
|
|
|
|
- lng: d.longitude != null ? Number(d.longitude) : null,
|
|
|
|
|
- lat: d.latitude != null ? Number(d.latitude) : null,
|
|
|
|
|
- battery: d.manholeData?.batteryLevel ?? null,
|
|
|
|
|
|
|
+ location: d.equipmentLocation,
|
|
|
|
|
+ district: d.district != null ? districtLabel(d.district) : '',
|
|
|
status
|
|
status
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
- const statistics = res.data?.statistics || {}
|
|
|
|
|
- stats.total = statistics.total || allDevices.value.length
|
|
|
|
|
- stats.online = statistics.online || 0
|
|
|
|
|
- stats.offline = statistics.offline || 0
|
|
|
|
|
- stats.alarm = statistics.alarm || 0
|
|
|
|
|
- renderAreaChart()
|
|
|
|
|
- renderDeviceTypeChart()
|
|
|
|
|
- refreshMarkers()
|
|
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
ElMessage.error('设备数据加载失败')
|
|
ElMessage.error('设备数据加载失败')
|
|
|
- // 加载失败也铺出空态骨架,面板不留白
|
|
|
|
|
- renderAreaChart()
|
|
|
|
|
- renderDeviceTypeChart()
|
|
|
|
|
} finally {
|
|
} finally {
|
|
|
loading.device = false
|
|
loading.device = false
|
|
|
}
|
|
}
|
|
@@ -255,31 +583,20 @@ async function loadWorkOrders() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function formatDateTime(date) {
|
|
|
|
|
- const pad = value => String(value).padStart(2, '0')
|
|
|
|
|
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/** 报警类型展示:优先报警内容,其次报警编码 */
|
|
|
|
|
-function alarmTypeText(record) {
|
|
|
|
|
- return record.alarmContent || record.warningCode || '其他告警'
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 拉取近 15 天的窨井报警
|
|
|
|
|
- *
|
|
|
|
|
- * rootTypeId 是报警查询的必填项(后端校验「设备大类不能为空」),
|
|
|
|
|
- * pageSize 上限 100,因此按页累加,最多取 3 页用于首页统计。
|
|
|
|
|
|
|
+ * 拉取近 N 天的窨井报警。
|
|
|
|
|
+ * rootTypeId 是报警查询必填项(后端校验「设备大类不能为空」),pageSize 上限 100,按页累加。
|
|
|
*/
|
|
*/
|
|
|
async function fetchRecentAlarms() {
|
|
async function fetchRecentAlarms() {
|
|
|
const params = {
|
|
const params = {
|
|
|
rootTypeId: MANHOLE_ROOT_TYPE_ID,
|
|
rootTypeId: MANHOLE_ROOT_TYPE_ID,
|
|
|
- startTime: formatDateTime(new Date(Date.now() - 15 * 24 * 3600 * 1000))
|
|
|
|
|
|
|
+ startTime: formatDateTime(new Date(Date.now() - ALARM_RANGE_DAYS * 24 * 3600 * 1000))
|
|
|
}
|
|
}
|
|
|
const records = []
|
|
const records = []
|
|
|
for (let page = 1; page <= ALARM_MAX_PAGES; page++) {
|
|
for (let page = 1; page <= ALARM_MAX_PAGES; page++) {
|
|
|
const res = await getAlarmDataPage(page, ALARM_PAGE_SIZE, params)
|
|
const res = await getAlarmDataPage(page, ALARM_PAGE_SIZE, params)
|
|
|
- const pageRecords = res.rows || res.data?.records || res.data || []
|
|
|
|
|
|
|
+ const raw = res.rows || res.data?.records || res.data || []
|
|
|
|
|
+ const pageRecords = Array.isArray(raw) ? raw : []
|
|
|
records.push(...pageRecords)
|
|
records.push(...pageRecords)
|
|
|
if (pageRecords.length < ALARM_PAGE_SIZE) break
|
|
if (pageRecords.length < ALARM_PAGE_SIZE) break
|
|
|
}
|
|
}
|
|
@@ -288,607 +605,287 @@ async function fetchRecentAlarms() {
|
|
|
|
|
|
|
|
async function loadAlarmData() {
|
|
async function loadAlarmData() {
|
|
|
loading.alarm = true
|
|
loading.alarm = true
|
|
|
- alarmRangeText.value = formatAlarmRange()
|
|
|
|
|
try {
|
|
try {
|
|
|
const records = await fetchRecentAlarms()
|
|
const records = await fetchRecentAlarms()
|
|
|
- const fifteenDaysAgo = new Date(Date.now() - 15 * 24 * 3600 * 1000)
|
|
|
|
|
- const recentAlarms = records.filter(r => !r.createTime || new Date(String(r.createTime).replace(/-/g, '/')) >= fifteenDaysAgo)
|
|
|
|
|
-
|
|
|
|
|
- const typeMap = {}
|
|
|
|
|
- recentAlarms.forEach(r => {
|
|
|
|
|
- const type = alarmTypeText(r)
|
|
|
|
|
- typeMap[type] = (typeMap[type] || 0) + 1
|
|
|
|
|
- })
|
|
|
|
|
- renderAlarmChart(typeMap)
|
|
|
|
|
-
|
|
|
|
|
- // 动态信息(最新10条)
|
|
|
|
|
- dynamicLogs.value = records.slice(0, 10).map(r => ({
|
|
|
|
|
- time: r.createTime || '',
|
|
|
|
|
- event: `${alarmTypeText(r)} - ${r.deviceName || r.deviceCode || ''}`,
|
|
|
|
|
- alarmStatus: r.alarmStatus ?? 0
|
|
|
|
|
- }))
|
|
|
|
|
|
|
+ const rangeStart = new Date(Date.now() - ALARM_RANGE_DAYS * 24 * 3600 * 1000)
|
|
|
|
|
+ recentAlarms.value = records.filter(r => !r.createTime || new Date(String(r.createTime).replace(/-/g, '/')) >= rangeStart)
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
console.error(e)
|
|
console.error(e)
|
|
|
ElMessage.error('告警数据加载失败')
|
|
ElMessage.error('告警数据加载失败')
|
|
|
- // 加载失败也铺出空态骨架(含统计区间),面板不留白
|
|
|
|
|
- renderAlarmChart({})
|
|
|
|
|
|
|
+ recentAlarms.value = []
|
|
|
} finally {
|
|
} finally {
|
|
|
loading.alarm = false
|
|
loading.alarm = false
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ==================== ECharts ====================
|
|
|
|
|
-function renderAlarmChart(typeMap) {
|
|
|
|
|
- if (!alarmChartRef.value) return
|
|
|
|
|
- if (!alarmChart) alarmChart = echarts.init(alarmChartRef.value)
|
|
|
|
|
- const colors = ['#ff3333', '#ffb333', '#33cc33', '#3399ff', '#9966ff', '#ff6699', '#33cccc']
|
|
|
|
|
- const data = Object.entries(typeMap || {}).map(([name, value], i) => ({
|
|
|
|
|
- name, value, itemStyle: { color: colors[i % colors.length] }
|
|
|
|
|
- }))
|
|
|
|
|
- const hasData = data.length > 0
|
|
|
|
|
- alarmChart.setOption({
|
|
|
|
|
- backgroundColor: 'transparent',
|
|
|
|
|
- // 无告警时保留统计区间与说明,避免面板看起来是空的
|
|
|
|
|
- title: hasData ? { show: false } : {
|
|
|
|
|
- text: '近 15 天暂无告警',
|
|
|
|
|
- subtext: `统计区间 ${alarmRangeText.value}`,
|
|
|
|
|
- left: 'center',
|
|
|
|
|
- top: 'middle',
|
|
|
|
|
- textStyle: { color: '#9ba8c0', fontSize: 12, fontWeight: 'normal' },
|
|
|
|
|
- subtextStyle: { color: '#6f7d95', fontSize: 11 }
|
|
|
|
|
- },
|
|
|
|
|
- tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
|
|
|
|
- legend: { bottom: 0, textStyle: { color: '#ccc', fontSize: 10 }, show: hasData },
|
|
|
|
|
- // 空数据时不挂饼图系列,否则会残留一个灰色的空圆环
|
|
|
|
|
- series: hasData ? [{
|
|
|
|
|
- type: 'pie', radius: ['35%', '60%'], center: ['50%', '40%'],
|
|
|
|
|
- label: { color: '#ccc', fontSize: 10 },
|
|
|
|
|
- data
|
|
|
|
|
- }] : []
|
|
|
|
|
- }, true)
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function renderAreaChart() {
|
|
|
|
|
- if (!areaChartRef.value) return
|
|
|
|
|
- if (!areaChart) areaChart = echarts.init(areaChartRef.value)
|
|
|
|
|
- const areaMap = {}
|
|
|
|
|
- allDevices.value.forEach(d => {
|
|
|
|
|
- const area = d.district || d.location || '未分配区域'
|
|
|
|
|
- // 取区域名(截取前4个字)
|
|
|
|
|
- const shortArea = area.length > 6 ? area.slice(0, 6) + '...' : area
|
|
|
|
|
- areaMap[shortArea] = (areaMap[shortArea] || 0) + 1
|
|
|
|
|
- })
|
|
|
|
|
- let entries = Object.entries(areaMap).sort((a, b) => b[1] - a[1]).slice(0, 8)
|
|
|
|
|
- const hasData = entries.length > 0
|
|
|
|
|
- // 无设备时仍按默认区域维度铺出骨架,面板不会空成一片
|
|
|
|
|
- if (!hasData) {
|
|
|
|
|
- entries = DEFAULT_DISTRICTS.map(name => [name, 0])
|
|
|
|
|
- }
|
|
|
|
|
- areaChart.setOption({
|
|
|
|
|
- backgroundColor: 'transparent',
|
|
|
|
|
- title: hasData ? { show: false } : {
|
|
|
|
|
- text: '暂无设备,按默认区域维度展示',
|
|
|
|
|
- left: 'center',
|
|
|
|
|
- top: 0,
|
|
|
|
|
- textStyle: { color: '#6f7d95', fontSize: 11, fontWeight: 'normal' }
|
|
|
|
|
- },
|
|
|
|
|
- tooltip: { trigger: 'axis' },
|
|
|
|
|
- grid: { left: '12%', right: '8%', bottom: '18%', top: '18%' },
|
|
|
|
|
- xAxis: {
|
|
|
|
|
- type: 'category', data: entries.map(e => e[0]),
|
|
|
|
|
- axisLabel: { color: '#ccc', fontSize: 10, interval: 0, rotate: 30 }
|
|
|
|
|
- },
|
|
|
|
|
- yAxis: {
|
|
|
|
|
- type: 'value', minInterval: 1,
|
|
|
|
|
- axisLabel: { color: '#ccc' },
|
|
|
|
|
- splitLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }
|
|
|
|
|
- },
|
|
|
|
|
- series: [{ type: 'bar', data: entries.map(e => e[1]), itemStyle: { color: '#3399ff' }, barWidth: '40%' }]
|
|
|
|
|
- }, true)
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function renderDeviceTypeChart() {
|
|
|
|
|
- if (!deviceTypeChartRef.value) return
|
|
|
|
|
- if (!deviceTypeChart) deviceTypeChart = echarts.init(deviceTypeChartRef.value)
|
|
|
|
|
-
|
|
|
|
|
- // 按台账实际存在的设备类型聚合,避免把不存在的旧类型当成固定三分类
|
|
|
|
|
- const countMap = new Map()
|
|
|
|
|
- allDevices.value.forEach(d => {
|
|
|
|
|
- const label = typeText(d.typeId, d.typeName)
|
|
|
|
|
- countMap.set(label, (countMap.get(label) || 0) + 1)
|
|
|
|
|
- })
|
|
|
|
|
- // 有设备但类型名为空时,至少给出一个占位分类,面板不留白
|
|
|
|
|
- if (!countMap.size && allDevices.value.length) {
|
|
|
|
|
- countMap.set('未分类', allDevices.value.length)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const labels = Array.from(countMap.keys())
|
|
|
|
|
- const values = Array.from(countMap.values())
|
|
|
|
|
- const palette = ['#3399ff', '#67c23a', '#e6a23c', '#f56c6c', '#9b59b6']
|
|
|
|
|
-
|
|
|
|
|
- deviceTypeChart.setOption({
|
|
|
|
|
- backgroundColor: 'transparent',
|
|
|
|
|
- tooltip: { trigger: 'axis' },
|
|
|
|
|
- grid: { left: '12%', right: '8%', bottom: '15%', top: '8%' },
|
|
|
|
|
- title: labels.length ? undefined : {
|
|
|
|
|
- text: '暂无窨井设备',
|
|
|
|
|
- subtext: '设备台账中未查到窨井类设备',
|
|
|
|
|
- left: 'center',
|
|
|
|
|
- top: 'middle',
|
|
|
|
|
- textStyle: { color: '#ccc', fontSize: 12, fontWeight: 'normal' },
|
|
|
|
|
- subtextStyle: { color: '#888', fontSize: 11 }
|
|
|
|
|
- },
|
|
|
|
|
- xAxis: {
|
|
|
|
|
- type: 'category', data: labels,
|
|
|
|
|
- axisLabel: { color: '#ccc', fontSize: 11, interval: 0 }
|
|
|
|
|
- },
|
|
|
|
|
- yAxis: {
|
|
|
|
|
- type: 'value', minInterval: 1,
|
|
|
|
|
- axisLabel: { color: '#ccc' },
|
|
|
|
|
- splitLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }
|
|
|
|
|
- },
|
|
|
|
|
- series: labels.length ? [{
|
|
|
|
|
- type: 'bar',
|
|
|
|
|
- data: values.map((v, i) => ({ value: v, itemStyle: { color: palette[i % palette.length] } })),
|
|
|
|
|
- barWidth: '40%', label: { show: true, position: 'top', color: '#ccc' }
|
|
|
|
|
- }] : []
|
|
|
|
|
- }, true)
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// ==================== 百度地图 ====================
|
|
|
|
|
-let mapInstance = null
|
|
|
|
|
-let mapFitted = false
|
|
|
|
|
-const markerMap = new Map()
|
|
|
|
|
-/** 兜底中心(沅陵县城),WGS84 口径,与设备台账一致 */
|
|
|
|
|
-const YUANLING_CENTER = { lng: 110.393, lat: 28.452 }
|
|
|
|
|
-
|
|
|
|
|
-function initMap() {
|
|
|
|
|
- const container = document.getElementById('homeMap')
|
|
|
|
|
- if (!container || typeof BMapGL === 'undefined') return
|
|
|
|
|
- try {
|
|
|
|
|
- mapInstance = new BMapGL.Map('homeMap')
|
|
|
|
|
- const [centerLng, centerLat] = convertCoord(YUANLING_CENTER.lng, YUANLING_CENTER.lat)
|
|
|
|
|
- mapInstance.centerAndZoom(new BMapGL.Point(centerLng, centerLat), 15)
|
|
|
|
|
- mapInstance.enableScrollWheelZoom(true)
|
|
|
|
|
- } catch (e) { console.error('地图初始化失败', e) }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/** 有设备时按设备范围取视野;点太近时限制放大层级,避免只剩一栋楼 */
|
|
|
|
|
-function fitMapToDevices(points) {
|
|
|
|
|
- if (!mapInstance || !points.length) return
|
|
|
|
|
- if (points.length === 1) {
|
|
|
|
|
- mapInstance.centerAndZoom(points[0], 16)
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
- const lngs = points.map(p => p.lng)
|
|
|
|
|
- const lats = points.map(p => p.lat)
|
|
|
|
|
- const span = Math.max(Math.max(...lngs) - Math.min(...lngs), Math.max(...lats) - Math.min(...lats))
|
|
|
|
|
- if (span < 0.002) {
|
|
|
|
|
- const mid = new BMapGL.Point((Math.max(...lngs) + Math.min(...lngs)) / 2, (Math.max(...lats) + Math.min(...lats)) / 2)
|
|
|
|
|
- mapInstance.centerAndZoom(mid, 16)
|
|
|
|
|
- } else {
|
|
|
|
|
- mapInstance.setViewport(points, { margins: [50, 50, 50, 50] })
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function getStatusColor(status) {
|
|
|
|
|
- if (status === 'alert') return '#F56C6C'
|
|
|
|
|
- if (status === 'offline') return '#909399'
|
|
|
|
|
- return '#67C23A'
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function refreshMarkers() {
|
|
|
|
|
- if (!mapInstance) return
|
|
|
|
|
- markerMap.forEach(label => mapInstance.removeOverlay(label))
|
|
|
|
|
- markerMap.clear()
|
|
|
|
|
- const devicePoints = []
|
|
|
|
|
- allDevices.value.forEach(d => {
|
|
|
|
|
- if (d.lng == null || d.lat == null) return
|
|
|
|
|
- // 关键:台账是 WGS84,百度地图是 BD-09,不转换会偏约 1 公里
|
|
|
|
|
- const [bdLng, bdLat] = convertCoord(Number(d.lng), Number(d.lat))
|
|
|
|
|
- const point = new BMapGL.Point(bdLng, bdLat)
|
|
|
|
|
- devicePoints.push(point)
|
|
|
|
|
- const color = getStatusColor(d.status)
|
|
|
|
|
- const isAttention = d.status === 'alert' || d.status === 'offline'
|
|
|
|
|
- const attentionText = d.status === 'alert' ? '告警' : '离线'
|
|
|
|
|
- const html = isAttention
|
|
|
|
|
- ? `<div class="home-map-attention-marker home-map-${d.status}" title="${attentionText}设备:${d.name}">
|
|
|
|
|
- <div class="home-map-attention-badge">${attentionText} - ${d.name}</div>
|
|
|
|
|
- <div class="home-map-attention-pin">
|
|
|
|
|
- <i class="home-map-attention-ring"></i>
|
|
|
|
|
- <img src="${locationIcon}" alt="" />
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>`
|
|
|
|
|
- : `<div style="text-align:center;cursor:pointer;">
|
|
|
|
|
- <div style="color:#fff;background:${color};border-radius:3px;padding:1px 6px;font-size:10px;white-space:nowrap;display:inline-block;margin-bottom:2px;box-shadow:0 1px 3px rgba(0,0,0,0.3);">${d.name}</div>
|
|
|
|
|
- <div><img src="${locationIcon}" alt="" style="width:22px;height:22px;display:block;margin:0 auto;"/></div>
|
|
|
|
|
- </div>`
|
|
|
|
|
- const label = new BMapGL.Label(html, {
|
|
|
|
|
- position: point,
|
|
|
|
|
- offset: new BMapGL.Size(isAttention ? -62 : -25, isAttention ? -68 : -40)
|
|
|
|
|
- })
|
|
|
|
|
- label.setStyle({ border: 'none', background: 'transparent', padding: '0', zIndex: isAttention ? '20' : '10' })
|
|
|
|
|
- mapInstance.addOverlay(label)
|
|
|
|
|
- markerMap.set(d.id, label)
|
|
|
|
|
- })
|
|
|
|
|
- // 只在首次拿到设备时校准视野,后续刷新不打断用户手动缩放
|
|
|
|
|
- if (!mapFitted && devicePoints.length) {
|
|
|
|
|
- fitMapToDevices(devicePoints)
|
|
|
|
|
- mapFitted = true
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
// ==================== 生命周期 ====================
|
|
// ==================== 生命周期 ====================
|
|
|
-const handleResize = () => {
|
|
|
|
|
- alarmChart && alarmChart.resize()
|
|
|
|
|
- areaChart && areaChart.resize()
|
|
|
|
|
- deviceTypeChart && deviceTypeChart.resize()
|
|
|
|
|
- mapInstance && mapInstance.resize && mapInstance.resize()
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-onMounted(async () => {
|
|
|
|
|
- await nextTick()
|
|
|
|
|
- initMap()
|
|
|
|
|
- window.addEventListener('resize', handleResize)
|
|
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ loadMarkerImages()
|
|
|
loadAll()
|
|
loadAll()
|
|
|
})
|
|
})
|
|
|
-
|
|
|
|
|
-onBeforeUnmount(() => {
|
|
|
|
|
- window.removeEventListener('resize', handleResize)
|
|
|
|
|
- alarmChart && alarmChart.dispose()
|
|
|
|
|
- areaChart && areaChart.dispose()
|
|
|
|
|
- deviceTypeChart && deviceTypeChart.dispose()
|
|
|
|
|
- if (mapInstance) {
|
|
|
|
|
- mapInstance.clearOverlays && mapInstance.clearOverlays()
|
|
|
|
|
- mapInstance = null
|
|
|
|
|
- }
|
|
|
|
|
- markerMap.clear()
|
|
|
|
|
-})
|
|
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
-<!-- ==================== 全局样式(非 scoped):el-table 深色主题覆盖 ==================== -->
|
|
|
|
|
-<!-- 用 .monitor-container 限定范围,不影响其他页面 -->
|
|
|
|
|
|
|
+<!-- ==================== 全局样式(非 scoped):el-table 浅色主题(与 realTimeShow.vue 一致) ==================== -->
|
|
|
<style>
|
|
<style>
|
|
|
.monitor-container .el-table {
|
|
.monitor-container .el-table {
|
|
|
- --el-table-bg-color: transparent;
|
|
|
|
|
- --el-table-tr-bg-color: transparent;
|
|
|
|
|
- --el-table-header-bg-color: transparent;
|
|
|
|
|
- --el-table-current-row-bg-color: rgba(0, 100, 200, 0.25);
|
|
|
|
|
- --el-table-row-hover-bg-color: rgba(0, 100, 200, 0.15);
|
|
|
|
|
- --el-table-text-color: #dce3ee;
|
|
|
|
|
- --el-table-header-text-color: #fff;
|
|
|
|
|
- --el-table-border-color: rgba(43, 75, 140, 0.35);
|
|
|
|
|
- --el-table-border: 1px solid rgba(43, 75, 140, 0.35);
|
|
|
|
|
- --el-fill-color-lighter: rgba(30, 40, 90, 0.25);
|
|
|
|
|
- --el-fill-color-light: rgba(30, 40, 90, 0.25);
|
|
|
|
|
- --el-fill-color: rgba(30, 40, 90, 0.25);
|
|
|
|
|
- --el-fill-color-blank: transparent;
|
|
|
|
|
- --el-text-color-regular: #dce3ee;
|
|
|
|
|
- --el-text-color-secondary: #9ba8c0;
|
|
|
|
|
- --el-border-color: rgba(43, 75, 140, 0.35);
|
|
|
|
|
- --el-border-color-lighter: rgba(43, 75, 140, 0.2);
|
|
|
|
|
- background: transparent !important;
|
|
|
|
|
|
|
+ --el-table-bg-color: #fff;
|
|
|
|
|
+ --el-table-tr-bg-color: #fff;
|
|
|
|
|
+ --el-table-header-bg-color: #f7f9fc;
|
|
|
|
|
+ --el-table-current-row-bg-color: #f4f9ff;
|
|
|
|
|
+ --el-table-row-hover-bg-color: #f8fbff;
|
|
|
|
|
+ --el-table-text-color: #1f2937;
|
|
|
|
|
+ --el-table-header-text-color: #5e6d82;
|
|
|
|
|
+ --el-table-border-color: #eef2f7;
|
|
|
|
|
+ --el-table-border: 1px solid #eef2f7;
|
|
|
|
|
+ --el-fill-color-lighter: #fff;
|
|
|
|
|
+ --el-fill-color-light: #f7f9fc;
|
|
|
|
|
+ --el-fill-color: #f7f9fc;
|
|
|
|
|
+ --el-fill-color-blank: #fff;
|
|
|
|
|
+ --el-text-color-regular: #1f2937;
|
|
|
|
|
+ --el-text-color-secondary: #97a3b7;
|
|
|
|
|
+ --el-border-color: #e3e9f2;
|
|
|
|
|
+ --el-border-color-lighter: #eef2f7;
|
|
|
|
|
+ background: #fff !important;
|
|
|
border: none !important;
|
|
border: none !important;
|
|
|
}
|
|
}
|
|
|
-/* 隐藏 EP 表格自带的底部/右侧边框伪元素 */
|
|
|
|
|
.monitor-container .el-table::before,
|
|
.monitor-container .el-table::before,
|
|
|
.monitor-container .el-table::after,
|
|
.monitor-container .el-table::after,
|
|
|
.monitor-container .el-table .el-table__inner-wrapper::before,
|
|
.monitor-container .el-table .el-table__inner-wrapper::before,
|
|
|
-.monitor-container .el-table .el-table__inner-wrapper::after {
|
|
|
|
|
- display: none !important;
|
|
|
|
|
-}
|
|
|
|
|
-/* 内部容器全透明 */
|
|
|
|
|
|
|
+.monitor-container .el-table .el-table__inner-wrapper::after { display: none !important; }
|
|
|
.monitor-container .el-table .el-table__inner-wrapper,
|
|
.monitor-container .el-table .el-table__inner-wrapper,
|
|
|
.monitor-container .el-table .el-table__body-wrapper,
|
|
.monitor-container .el-table .el-table__body-wrapper,
|
|
|
.monitor-container .el-table .el-table__header-wrapper,
|
|
.monitor-container .el-table .el-table__header-wrapper,
|
|
|
.monitor-container .el-table .el-table__body,
|
|
.monitor-container .el-table .el-table__body,
|
|
|
-.monitor-container .el-table .el-table__header {
|
|
|
|
|
- background: transparent !important;
|
|
|
|
|
-}
|
|
|
|
|
-/* 表头单元格 — EP 2.x 使用 th.el-table__cell */
|
|
|
|
|
|
|
+.monitor-container .el-table .el-table__header { background: #fff !important; }
|
|
|
.monitor-container .el-table th.el-table__cell {
|
|
.monitor-container .el-table th.el-table__cell {
|
|
|
- background: rgba(0, 100, 200, 0.35) !important;
|
|
|
|
|
- color: #fff !important;
|
|
|
|
|
- font-weight: 600;
|
|
|
|
|
- border-bottom: 1px solid #2b4b8c !important;
|
|
|
|
|
|
|
+ background: #f7f9fc !important;
|
|
|
|
|
+ color: #5e6d82 !important; font-weight: 600;
|
|
|
|
|
+ border-bottom: 1px solid #eef2f7 !important;
|
|
|
}
|
|
}
|
|
|
-/* 内容单元格 */
|
|
|
|
|
.monitor-container .el-table td.el-table__cell {
|
|
.monitor-container .el-table td.el-table__cell {
|
|
|
- background: transparent !important;
|
|
|
|
|
- border-bottom: 1px solid rgba(43, 75, 140, 0.25) !important;
|
|
|
|
|
|
|
+ background: #fff !important;
|
|
|
|
|
+ border-bottom: 1px solid #f1f5f9 !important;
|
|
|
padding: 7px 0 !important;
|
|
padding: 7px 0 !important;
|
|
|
}
|
|
}
|
|
|
-/* 行 */
|
|
|
|
|
-.monitor-container .el-table .el-table__row {
|
|
|
|
|
- background: transparent !important;
|
|
|
|
|
-}
|
|
|
|
|
-.monitor-container .el-table .el-table__row:hover > td.el-table__cell {
|
|
|
|
|
- background: rgba(0, 100, 200, 0.15) !important;
|
|
|
|
|
-}
|
|
|
|
|
-/* 空数据 */
|
|
|
|
|
-.monitor-container .el-table .el-table__empty-block {
|
|
|
|
|
- background: transparent !important;
|
|
|
|
|
-}
|
|
|
|
|
-.monitor-container .el-table .el-table__empty-text {
|
|
|
|
|
- color: #8090a8;
|
|
|
|
|
-}
|
|
|
|
|
-/* 滚动条 */
|
|
|
|
|
-.monitor-container .el-table .el-scrollbar__bar {
|
|
|
|
|
- opacity: 0.4;
|
|
|
|
|
-}
|
|
|
|
|
-/* el-select 输入框深色适配 */
|
|
|
|
|
-.monitor-container .el-select .el-input__wrapper {
|
|
|
|
|
- background: rgba(30, 40, 90, 0.6);
|
|
|
|
|
- box-shadow: 0 0 0 1px rgba(43, 75, 140, 0.5) inset;
|
|
|
|
|
-}
|
|
|
|
|
-.monitor-container .el-select .el-input__inner {
|
|
|
|
|
- color: #dce3ee;
|
|
|
|
|
-}
|
|
|
|
|
-/* el-table 在 flex 容器中撑满高度(无 max-height 时) */
|
|
|
|
|
-.monitor-container .el-table {
|
|
|
|
|
- height: 100% !important;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- flex-direction: column;
|
|
|
|
|
-}
|
|
|
|
|
-.monitor-container .el-table .el-table__inner-wrapper {
|
|
|
|
|
- flex: 1;
|
|
|
|
|
- min-height: 0;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- flex-direction: column;
|
|
|
|
|
-}
|
|
|
|
|
-.monitor-container .el-table .el-table__body-wrapper {
|
|
|
|
|
- flex: 1;
|
|
|
|
|
- min-height: 0;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/* 告警和离线设备地图标记:通过扩散圈和上下位移动画强化定位提示 */
|
|
|
|
|
-.home-map-attention-marker {
|
|
|
|
|
- position: relative;
|
|
|
|
|
- width: 124px;
|
|
|
|
|
- height: 70px;
|
|
|
|
|
- cursor: pointer;
|
|
|
|
|
- text-align: center;
|
|
|
|
|
-}
|
|
|
|
|
-.home-map-attention-badge {
|
|
|
|
|
- position: relative;
|
|
|
|
|
- z-index: 2;
|
|
|
|
|
- display: inline-block;
|
|
|
|
|
- max-width: 118px;
|
|
|
|
|
- overflow: hidden;
|
|
|
|
|
- padding: 2px 7px;
|
|
|
|
|
- border: 1px solid rgba(255, 255, 255, 0.72);
|
|
|
|
|
- border-radius: 4px;
|
|
|
|
|
- color: #fff;
|
|
|
|
|
- box-shadow: 0 2px 8px rgba(152, 23, 23, 0.55);
|
|
|
|
|
- font-size: 10px;
|
|
|
|
|
- line-height: 16px;
|
|
|
|
|
- text-overflow: ellipsis;
|
|
|
|
|
- white-space: nowrap;
|
|
|
|
|
-}
|
|
|
|
|
-.home-map-attention-pin {
|
|
|
|
|
- position: absolute;
|
|
|
|
|
- left: 50%;
|
|
|
|
|
- top: 27px;
|
|
|
|
|
- width: 28px;
|
|
|
|
|
- height: 34px;
|
|
|
|
|
- transform: translateX(-50%);
|
|
|
|
|
- animation: homeMapAttentionBounce 1.1s ease-in-out infinite;
|
|
|
|
|
-}
|
|
|
|
|
-.home-map-attention-pin img {
|
|
|
|
|
- position: relative;
|
|
|
|
|
- z-index: 2;
|
|
|
|
|
- display: block;
|
|
|
|
|
- width: 28px;
|
|
|
|
|
- height: 28px;
|
|
|
|
|
-}
|
|
|
|
|
-.home-map-attention-ring {
|
|
|
|
|
- position: absolute;
|
|
|
|
|
- z-index: 1;
|
|
|
|
|
- left: 50%;
|
|
|
|
|
- top: 12px;
|
|
|
|
|
- width: 18px;
|
|
|
|
|
- height: 18px;
|
|
|
|
|
- border-radius: 50%;
|
|
|
|
|
- transform: translate(-50%, -50%);
|
|
|
|
|
- animation: homeMapAttentionPulse 1.5s ease-out infinite;
|
|
|
|
|
-}
|
|
|
|
|
-.home-map-alert .home-map-attention-badge { background: #e64545; }
|
|
|
|
|
-.home-map-alert .home-map-attention-ring { border: 2px solid rgba(255, 75, 75, 0.95); }
|
|
|
|
|
-.home-map-offline .home-map-attention-badge { background: #e67e22; }
|
|
|
|
|
-.home-map-offline .home-map-attention-ring { border: 2px solid rgba(255, 166, 77, 0.95); }
|
|
|
|
|
-@keyframes homeMapAttentionBounce {
|
|
|
|
|
- 0%, 100% { transform: translate(-50%, 0); }
|
|
|
|
|
- 50% { transform: translate(-50%, -8px); }
|
|
|
|
|
-}
|
|
|
|
|
-@keyframes homeMapAttentionPulse {
|
|
|
|
|
- 0% { opacity: 0.9; transform: translate(-50%, -50%) scale(0.7); }
|
|
|
|
|
- 70%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(2.2); }
|
|
|
|
|
-}
|
|
|
|
|
|
|
+.monitor-container .el-table .el-table__row { background: #fff !important; }
|
|
|
|
|
+.monitor-container .el-table .el-table__row:hover > td.el-table__cell { background: #f8fbff !important; }
|
|
|
|
|
+.monitor-container .el-table .el-table__empty-block { background: #fff !important; }
|
|
|
|
|
+.monitor-container .el-table .el-table__empty-text { color: #97a3b7; }
|
|
|
|
|
+.monitor-container .el-table .el-scrollbar__bar { opacity: 0.4; }
|
|
|
|
|
+.monitor-container .el-table { height: 100% !important; display: flex; flex-direction: column; }
|
|
|
|
|
+.monitor-container .el-table .el-table__inner-wrapper { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
|
|
|
+.monitor-container .el-table .el-table__body-wrapper { flex: 1; min-height: 0; }
|
|
|
</style>
|
|
</style>
|
|
|
|
|
|
|
|
-<!-- ==================== 页面布局样式(scoped) ==================== -->
|
|
|
|
|
|
|
+<!-- ==================== 页面布局(scoped) ==================== -->
|
|
|
<style scoped>
|
|
<style scoped>
|
|
|
.monitor-container {
|
|
.monitor-container {
|
|
|
- display: flex;
|
|
|
|
|
- width: 100%;
|
|
|
|
|
- height: 100vh;
|
|
|
|
|
- min-height: 600px;
|
|
|
|
|
- background: #1a1a4a;
|
|
|
|
|
- color: #fff;
|
|
|
|
|
- gap: 16px;
|
|
|
|
|
- padding: 16px;
|
|
|
|
|
- box-sizing: border-box;
|
|
|
|
|
- overflow: hidden;
|
|
|
|
|
-}
|
|
|
|
|
-.left-col {
|
|
|
|
|
- flex: 0 0 25%;
|
|
|
|
|
- min-width: 280px;
|
|
|
|
|
- max-width: 420px;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- flex-direction: column;
|
|
|
|
|
- gap: 16px;
|
|
|
|
|
- min-height: 0;
|
|
|
|
|
-}
|
|
|
|
|
-.right-col {
|
|
|
|
|
- flex: 0 0 25%;
|
|
|
|
|
- min-width: 280px;
|
|
|
|
|
- max-width: 420px;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- flex-direction: column;
|
|
|
|
|
- gap: 16px;
|
|
|
|
|
- min-height: 0;
|
|
|
|
|
-}
|
|
|
|
|
-.center-col {
|
|
|
|
|
- flex: 1;
|
|
|
|
|
- min-width: 0;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- flex-direction: column;
|
|
|
|
|
- gap: 16px;
|
|
|
|
|
- min-height: 0;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ display: flex; flex-direction: column;
|
|
|
|
|
+ width: 100%; height: calc(100vh - 84px); min-height: 620px;
|
|
|
|
|
+ background: #f0f3f8;
|
|
|
|
|
+ color: #1f2937; gap: 12px; padding: 14px 16px 16px;
|
|
|
|
|
+ box-sizing: border-box; overflow: hidden;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ===== 顶部健康度带 ===== */
|
|
|
|
|
+.top-bar {
|
|
|
|
|
+ display: flex; align-items: center; gap: 22px; flex: none;
|
|
|
|
|
+ padding: 12px 18px; border-radius: 10px;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border: 1px solid #e3e9f2;
|
|
|
|
|
+ box-shadow: 0 2px 8px rgba(15, 34, 67, 0.06);
|
|
|
|
|
+}
|
|
|
|
|
+.brand { flex: none; min-width: 210px; }
|
|
|
|
|
+.brand-title { font-size: 20px; font-weight: 700; letter-spacing: 2px; color: #0a1628; }
|
|
|
|
|
+.health-band { flex: 1; display: flex; align-items: center; gap: 24px; min-width: 0; }
|
|
|
|
|
+.hb-rate { flex: none; min-width: 108px; display: flex; flex-direction: column; gap: 3px; }
|
|
|
|
|
+.hb-rate b { font-size: 38px; line-height: 1; font-weight: 700; color: #16a34a; font-variant-numeric: tabular-nums; }
|
|
|
|
|
+.hb-rate b.low { color: #ef4444; }
|
|
|
|
|
+.hb-rate span { font-size: 11px; color: #97a3b7; }
|
|
|
|
|
+.hb-bar { flex: 1; min-width: 150px; height: 12px; border-radius: 6px; overflow: hidden; display: flex; background: #eef2f7; }
|
|
|
|
|
+.hb-bar i { display: block; height: 100%; transition: width 0.4s; }
|
|
|
|
|
+.hb-bar i.on { background: #16a34a; }
|
|
|
|
|
+.hb-bar i.off { background: #94a3b8; }
|
|
|
|
|
+.hb-bar i.al { background: #ef4444; }
|
|
|
|
|
+.hb-items { display: flex; gap: 22px; flex: none; }
|
|
|
|
|
+.hb-items > div { display: flex; flex-direction: column; gap: 3px; }
|
|
|
|
|
+.hb-items b { font-size: 22px; font-weight: 700; line-height: 1; color: #0a1628; font-variant-numeric: tabular-nums; }
|
|
|
|
|
+.hb-items span { font-size: 11px; color: #97a3b7; }
|
|
|
|
|
+.text-green { color: #16a34a !important; }
|
|
|
|
|
+.text-gray { color: #64748b !important; }
|
|
|
|
|
+.text-red { color: #ef4444 !important; }
|
|
|
|
|
+/* ===== 主体三列 ===== */
|
|
|
|
|
+.main-grid {
|
|
|
|
|
+ flex: 1; display: grid;
|
|
|
|
|
+ grid-template-columns: minmax(280px, 24%) minmax(0, 1fr) minmax(300px, 26%);
|
|
|
|
|
+ gap: 12px; min-height: 0;
|
|
|
|
|
+}
|
|
|
|
|
+.col { display: flex; flex-direction: column; gap: 12px; min-height: 0; }
|
|
|
.panel {
|
|
.panel {
|
|
|
- flex: 1;
|
|
|
|
|
- background: rgba(30, 40, 90, 0.8);
|
|
|
|
|
- border: 1px solid #2b4b8c;
|
|
|
|
|
- border-radius: 8px;
|
|
|
|
|
- padding: 14px;
|
|
|
|
|
- box-sizing: border-box;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- flex-direction: column;
|
|
|
|
|
- min-height: 0;
|
|
|
|
|
-}
|
|
|
|
|
-/* 设备分类固定高度,不参与均分 */
|
|
|
|
|
-.chart-panel {
|
|
|
|
|
- flex: 0 0 190px;
|
|
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border: 1px solid #e3e9f2; border-radius: 10px;
|
|
|
|
|
+ padding: 12px 14px; box-sizing: border-box;
|
|
|
|
|
+ display: flex; flex-direction: column; min-height: 0;
|
|
|
|
|
+ box-shadow: 0 2px 8px rgba(15, 34, 67, 0.06);
|
|
|
}
|
|
}
|
|
|
.panel-title {
|
|
.panel-title {
|
|
|
- margin: 0 0 10px 0;
|
|
|
|
|
- font-size: 14px;
|
|
|
|
|
- color: #66ccff;
|
|
|
|
|
- border-left: 3px solid #66ccff;
|
|
|
|
|
- padding-left: 8px;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- justify-content: space-between;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- flex-shrink: 0;
|
|
|
|
|
-}
|
|
|
|
|
-.title-count {
|
|
|
|
|
- font-size: 12px;
|
|
|
|
|
- color: #aaa;
|
|
|
|
|
- font-weight: normal;
|
|
|
|
|
-}
|
|
|
|
|
-.panel-table {
|
|
|
|
|
- width: 100%;
|
|
|
|
|
- flex: 1;
|
|
|
|
|
- min-height: 0;
|
|
|
|
|
- overflow: hidden;
|
|
|
|
|
-}
|
|
|
|
|
-.chart {
|
|
|
|
|
- width: 100%;
|
|
|
|
|
- flex: 1;
|
|
|
|
|
- min-height: 100px;
|
|
|
|
|
-}
|
|
|
|
|
-.map-panel {
|
|
|
|
|
- flex: 1;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- flex-direction: column;
|
|
|
|
|
- min-height: 0;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ margin: 0 0 10px; font-size: 14px; font-weight: 600; color: #1f2937;
|
|
|
|
|
+ border-left: 3px solid #1677ff; padding-left: 9px;
|
|
|
|
|
+ display: flex; justify-content: space-between; align-items: center; flex-shrink: 0;
|
|
|
|
|
+}
|
|
|
|
|
+.title-count { font-size: 11px; color: #97a3b7; font-weight: 400; }
|
|
|
|
|
+.empty { padding: 28px 0; text-align: center; color: #97a3b7; font-size: 12px; }
|
|
|
|
|
+
|
|
|
|
|
+/* 实时报警 */
|
|
|
|
|
+.alarm-panel { flex: 1.1; }
|
|
|
|
|
+.badge {
|
|
|
|
|
+ display: inline-flex; min-width: 20px; height: 20px; padding: 0 6px;
|
|
|
|
|
+ border-radius: 10px; background: #ef4444; color: #fff; font-size: 12px;
|
|
|
|
|
+ align-items: center; justify-content: center; font-weight: 600;
|
|
|
|
|
+}
|
|
|
|
|
+.badge.zero { background: #cbd5e1; }
|
|
|
|
|
+.alarm-list { flex: 1; overflow-y: auto; min-height: 0; padding-right: 2px; }
|
|
|
|
|
+.alarm-item {
|
|
|
|
|
+ padding: 9px 10px; margin-bottom: 8px; border-radius: 6px;
|
|
|
|
|
+ border-left: 3px solid #ef4444; background: #fffafa;
|
|
|
|
|
+ border-bottom: 1px solid #eef2f7;
|
|
|
|
|
+ transition: background 0.15s;
|
|
|
|
|
+}
|
|
|
|
|
+.alarm-item:hover { background: #f8fbff; }
|
|
|
|
|
+.alarm-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
|
|
|
|
+.alarm-dev { font-size: 13px; font-weight: 600; color: #1f2937; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
|
+.alarm-code {
|
|
|
|
|
+ font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 10px; color: #5e6d82;
|
|
|
|
|
+ background: #f1f5f9; border: 1px solid #e3e9f2;
|
|
|
|
|
+ border-radius: 4px; padding: 1px 5px; margin-left: 6px;
|
|
|
|
|
+}
|
|
|
|
|
+.alarm-level { font-size: 11px; font-weight: 600; flex: none; }
|
|
|
|
|
+.alarm-time { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; color: #97a3b7; margin: 5px 0 3px; font-variant-numeric: tabular-nums; }
|
|
|
|
|
+.alarm-no { font-family: ui-monospace, Menlo, Consolas, monospace; color: #8a96a8; }
|
|
|
|
|
+.alarm-desc { font-size: 12px; color: #5e6d82; line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
|
+.work-panel { flex: 1; }
|
|
|
|
|
+.panel-table { width: 100%; flex: 1; min-height: 0; overflow: hidden; }
|
|
|
|
|
+.priority-tag { padding: 1px 6px; border-radius: 3px; font-size: 11px; color: #fff; }
|
|
|
|
|
+.priority-tag.high { background: #ef4444; }
|
|
|
|
|
+.priority-tag.medium { background: #f59e0b; }
|
|
|
|
|
+.priority-tag.low { background: #16a34a; }
|
|
|
|
|
+
|
|
|
|
|
+/* ===== 中间 GIS 一张图 ===== */
|
|
|
|
|
+.map-panel { flex: 1; }
|
|
|
.map-header {
|
|
.map-header {
|
|
|
- display: flex;
|
|
|
|
|
- justify-content: space-between;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- margin-bottom: 8px;
|
|
|
|
|
- font-size: 14px;
|
|
|
|
|
- color: #66ccff;
|
|
|
|
|
- flex-shrink: 0;
|
|
|
|
|
|
|
+ display: flex; justify-content: space-between; align-items: center;
|
|
|
|
|
+ margin-bottom: 10px; font-size: 14px; color: #1f2937; flex-shrink: 0;
|
|
|
}
|
|
}
|
|
|
|
|
+.map-header :deep(.el-button) {
|
|
|
|
|
+ background: #f4f9ff; border-color: #d6e6ff; color: #1677ff;
|
|
|
|
|
+}
|
|
|
|
|
+.map-wrap { position: relative; flex: 1; min-height: 0; border-radius: 8px; overflow: hidden; border: 1px solid #e3e9f2; }
|
|
|
|
|
+/* GisMapCanvas 自带 min-height 420px,大屏内需由父容器高度决定 */
|
|
|
|
|
+.map-wrap :deep(.gis-map-canvas) { height: 100% !important; min-height: 0 !important; }
|
|
|
.map-legend {
|
|
.map-legend {
|
|
|
- display: flex;
|
|
|
|
|
- gap: 12px;
|
|
|
|
|
- font-size: 11px;
|
|
|
|
|
-}
|
|
|
|
|
-.legend-dot {
|
|
|
|
|
- display: inline-block;
|
|
|
|
|
- width: 8px;
|
|
|
|
|
- height: 8px;
|
|
|
|
|
- border-radius: 50%;
|
|
|
|
|
- margin-right: 3px;
|
|
|
|
|
-}
|
|
|
|
|
-.legend-dot.online { background: #67c23a; }
|
|
|
|
|
-.legend-dot.alert { background: #f56c6c; }
|
|
|
|
|
-.legend-dot.offline { background: #909399; }
|
|
|
|
|
-.bmap-container {
|
|
|
|
|
- width: 100%;
|
|
|
|
|
- flex: 1;
|
|
|
|
|
- min-height: 200px;
|
|
|
|
|
- border-radius: 4px;
|
|
|
|
|
- overflow: hidden;
|
|
|
|
|
-}
|
|
|
|
|
-.filter-bar {
|
|
|
|
|
- margin-bottom: 10px;
|
|
|
|
|
- flex-shrink: 0;
|
|
|
|
|
-}
|
|
|
|
|
-.priority-tag {
|
|
|
|
|
- padding: 1px 6px;
|
|
|
|
|
- border-radius: 3px;
|
|
|
|
|
- font-size: 11px;
|
|
|
|
|
- color: #fff;
|
|
|
|
|
-}
|
|
|
|
|
-.priority-tag.high { background: #ff3333; }
|
|
|
|
|
-.priority-tag.medium { background: #ffb333; color: #333; }
|
|
|
|
|
-.priority-tag.low { background: #33cc33; }
|
|
|
|
|
-.status-tag {
|
|
|
|
|
- padding: 1px 6px;
|
|
|
|
|
- border-radius: 3px;
|
|
|
|
|
- font-size: 11px;
|
|
|
|
|
- color: #fff;
|
|
|
|
|
-}
|
|
|
|
|
-.status-tag.processing { background: #3399ff; }
|
|
|
|
|
-.status-tag.done { background: #33cc33; }
|
|
|
|
|
-.status-dot {
|
|
|
|
|
- display: inline-block;
|
|
|
|
|
- width: 8px;
|
|
|
|
|
- height: 8px;
|
|
|
|
|
- border-radius: 50%;
|
|
|
|
|
- margin-right: 3px;
|
|
|
|
|
-}
|
|
|
|
|
-.status-dot.normal { background: #67c23a; }
|
|
|
|
|
-.status-dot.alert { background: #f56c6c; }
|
|
|
|
|
-.status-dot.offline { background: #909399; }
|
|
|
|
|
-.status-text {
|
|
|
|
|
- font-size: 11px;
|
|
|
|
|
- color: #dce3ee;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/* === 响应式 === */
|
|
|
|
|
-@media (max-width: 1600px) {
|
|
|
|
|
- .left-col, .right-col { flex: 0 0 24%; min-width: 260px; max-width: 380px; }
|
|
|
|
|
- .monitor-container { gap: 12px; padding: 12px; }
|
|
|
|
|
- .panel { padding: 12px; }
|
|
|
|
|
- .chart-panel { flex-basis: 170px; }
|
|
|
|
|
-}
|
|
|
|
|
-@media (max-width: 1366px) {
|
|
|
|
|
- .left-col, .right-col { flex: 0 0 27%; min-width: 240px; max-width: 340px; }
|
|
|
|
|
- .monitor-container { gap: 10px; padding: 10px; }
|
|
|
|
|
- .panel { padding: 10px; }
|
|
|
|
|
- .chart-panel { flex-basis: 150px; }
|
|
|
|
|
|
|
+ position: absolute; top: 12px; left: 12px; z-index: 9999;
|
|
|
|
|
+ padding: 10px 12px; border-radius: 8px;
|
|
|
|
|
+ background: rgba(255, 255, 255, 0.95);
|
|
|
|
|
+ border: 1px solid #e3e9f2;
|
|
|
|
|
+ box-shadow: 0 2px 8px rgba(15, 34, 67, 0.1);
|
|
|
|
|
+ pointer-events: auto;
|
|
|
|
|
+}
|
|
|
|
|
+.legend-title { margin-bottom: 8px; font-size: 12px; font-weight: 600; color: #97a3b7; }
|
|
|
|
|
+.legend-row {
|
|
|
|
|
+ display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
|
|
|
|
|
+ padding: 0; border: none; background: transparent; font: inherit;
|
|
|
|
|
+ font-size: 13px; color: #5e6d82; cursor: pointer;
|
|
|
|
|
+ transition: opacity 0.15s, color 0.15s;
|
|
|
|
|
+}
|
|
|
|
|
+.legend-row:last-child { margin-bottom: 0; }
|
|
|
|
|
+.legend-row:hover { color: #1677ff; }
|
|
|
|
|
+.legend-row.is-off { opacity: 0.4; text-decoration: line-through; }
|
|
|
|
|
+.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
|
|
|
|
|
+.legend-dot.online { background: #16a34a; }
|
|
|
|
|
+.legend-dot.offline { background: #94a3b8; }
|
|
|
|
|
+.legend-dot.alarm { background: #ef4444; }
|
|
|
|
|
+.map-error { position: absolute; left: 16px; bottom: 16px; width: 360px; z-index: 2; }
|
|
|
|
|
+.map-empty { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 2; }
|
|
|
|
|
+
|
|
|
|
|
+/* ===== 报警类型分布(横条) ===== */
|
|
|
|
|
+.dist-panel { flex: 0 0 36%; min-height: 0; }
|
|
|
|
|
+.type-dist { flex: 1; overflow-y: auto; min-height: 0; padding: 2px; }
|
|
|
|
|
+.td-row { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid #f1f5f9; font-size: 12px; }
|
|
|
|
|
+.td-row:last-child { border-bottom: none; }
|
|
|
|
|
+.td-name { width: 96px; flex: none; color: #5e6d82; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
|
+.td-track { flex: 1; height: 10px; border-radius: 5px; background: #f1f5f9; overflow: hidden; min-width: 50px; }
|
|
|
|
|
+.td-track i { display: block; height: 100%; border-radius: 5px; background: #1677ff; transition: width 0.4s; }
|
|
|
|
|
+.td-num { width: 44px; text-align: right; color: #97a3b7; flex: none; font-variant-numeric: tabular-nums; }
|
|
|
|
|
+
|
|
|
|
|
+/* ===== 设备状态总览(片区点阵卡) ===== */
|
|
|
|
|
+.area-panel { flex: 1; }
|
|
|
|
|
+.area-cards { flex: 1; overflow-y: auto; min-height: 0; display: flex; flex-direction: column; gap: 10px; padding: 2px; }
|
|
|
|
|
+.acard { flex: none; border: 1px solid #e3e9f2; border-radius: 8px; padding: 10px 12px; background: #fff; transition: 0.18s; }
|
|
|
|
|
+.acard:hover { border-color: #1677ff; background: #f8fbff; }
|
|
|
|
|
+.acard.has-alarm { border-color: #fca5a5; background: linear-gradient(180deg, #fffafa 0%, #fff 42%); }
|
|
|
|
|
+.ac-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
|
|
|
|
|
+.ac-name { font-size: 15px; font-weight: 700; color: #0a1628; }
|
|
|
|
|
+.ac-sub { font-size: 10px; color: #97a3b7; margin-top: 3px; }
|
|
|
|
|
+.ac-rate { text-align: right; flex: none; line-height: 1.1; }
|
|
|
|
|
+.ac-rate b { display: block; font-size: 20px; font-weight: 700; color: #16a34a; font-variant-numeric: tabular-nums; }
|
|
|
|
|
+.ac-rate span { font-size: 10px; color: #97a3b7; }
|
|
|
|
|
+.ac-bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; background: #eef2f7; margin: 9px 0; }
|
|
|
|
|
+.ac-bar i { display: block; height: 100%; transition: width 0.4s; }
|
|
|
|
|
+.ac-bar i.on { background: #16a34a; }
|
|
|
|
|
+.ac-bar i.off { background: #94a3b8; }
|
|
|
|
|
+.ac-bar i.al { background: #ef4444; }
|
|
|
|
|
+.dot-matrix { display: flex; flex-wrap: wrap; gap: 4px; align-content: flex-start; min-height: 9px; }
|
|
|
|
|
+.dot { width: 9px; height: 9px; border-radius: 2px; background: #16a34a; }
|
|
|
|
|
+.dot.off { background: #cbd5e1; }
|
|
|
|
|
+.dot.al { background: #ef4444; animation: dotFlash 1s steps(1, end) infinite; }
|
|
|
|
|
+.ac-tip { font-size: 11px; color: #1677ff; }
|
|
|
|
|
+.ac-metrics { display: flex; gap: 16px; font-size: 10px; color: #97a3b7; margin-top: 9px; }
|
|
|
|
|
+.ac-metrics b { color: #0a1628; font-size: 12px; }
|
|
|
|
|
+@keyframes dotFlash { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0.3; } }
|
|
|
|
|
+
|
|
|
|
|
+/* ===== 设备详情弹窗(与 equipmentGIS.vue 一致) ===== */
|
|
|
|
|
+.dlg-summary { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px; border-radius: 8px; background: #f5f9ff; }
|
|
|
|
|
+.summary-name { font-size: 15px; font-weight: 500; color: #014db2; }
|
|
|
|
|
+.summary-code { font-size: 12px; 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; }
|
|
|
|
|
+.status-group { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
|
|
|
|
+.st-badge { display: flex; align-items: center; gap: 5px; }
|
|
|
|
|
+.badge-dot { width: 6px; height: 6px; border-radius: 3px; flex-shrink: 0; }
|
|
|
|
|
+.badge-text { font-size: 12px; font-weight: 500; }
|
|
|
|
|
+.st-badge.online .badge-dot { background: #10b981; }
|
|
|
|
|
+.st-badge.online .badge-text { color: #10b981; }
|
|
|
|
|
+.st-badge.offline .badge-dot { background: #f59e0b; }
|
|
|
|
|
+.st-badge.offline .badge-text { color: #f59e0b; }
|
|
|
|
|
+.st-badge.alarm .badge-text { color: #ef4444; }
|
|
|
|
|
+.badge-icon { font-size: 13px; color: #ef4444; }
|
|
|
|
|
+.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-title { margin-top: 18px; }
|
|
|
|
|
+.dlg-grid { margin-top: 16px; }
|
|
|
|
|
+.detail-image { margin-top: 16px; }
|
|
|
|
|
+.field-label { margin-bottom: 6px; font-size: 12px; color: #6b7280; }
|
|
|
|
|
+.field-media { width: 320px; max-width: 100%; height: 180px; border-radius: 6px; border: 1px solid #e6e6eb; }
|
|
|
|
|
+.dlg-footer { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
|
|
|
|
|
+
|
|
|
|
|
+/* ===== 响应式 ===== */
|
|
|
|
|
+@media (max-width: 1500px) {
|
|
|
|
|
+ .main-grid { grid-template-columns: minmax(250px, 23%) minmax(0, 1fr) minmax(265px, 25%); }
|
|
|
|
|
+ .top-bar { gap: 14px; }
|
|
|
|
|
+ .hb-items { gap: 14px; }
|
|
|
|
|
+ .hb-rate b { font-size: 32px; }
|
|
|
|
|
+}
|
|
|
|
|
+@media (max-width: 1280px) {
|
|
|
|
|
+ .brand { min-width: 170px; }
|
|
|
|
|
+ .brand-title { font-size: 17px; }
|
|
|
|
|
+ .hb-items b { font-size: 18px; }
|
|
|
|
|
+ .hb-items { gap: 12px; }
|
|
|
}
|
|
}
|
|
|
@media (max-width: 1024px) {
|
|
@media (max-width: 1024px) {
|
|
|
- .monitor-container {
|
|
|
|
|
- flex-direction: column;
|
|
|
|
|
- height: auto;
|
|
|
|
|
- min-height: 100vh;
|
|
|
|
|
- overflow: auto;
|
|
|
|
|
- }
|
|
|
|
|
- .left-col, .right-col {
|
|
|
|
|
- flex: 1 1 auto;
|
|
|
|
|
- min-width: 100%;
|
|
|
|
|
- max-width: 100%;
|
|
|
|
|
- }
|
|
|
|
|
- .center-col {
|
|
|
|
|
- flex: 1 1 auto;
|
|
|
|
|
- min-width: 100%;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ .monitor-container { height: auto; min-height: 100vh; overflow: auto; }
|
|
|
|
|
+ .main-grid { display: flex; flex-direction: column; }
|
|
|
|
|
+ .col { min-height: auto; }
|
|
|
|
|
+ .panel { min-height: 240px; }
|
|
|
|
|
+ .map-panel { min-height: 480px; }
|
|
|
|
|
+ .health-band { flex-wrap: wrap; }
|
|
|
|
|
+ .dist-panel { flex: 0 0 auto; }
|
|
|
}
|
|
}
|
|
|
</style>
|
|
</style>
|