index.vue 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894
  1. <!--窨井盖监测首页大屏-->
  2. <template>
  3. <div class="monitor-container">
  4. <!-- 左侧栏 -->
  5. <div class="left-col">
  6. <!-- 待办工单 -->
  7. <div class="panel">
  8. <h3 class="panel-title">待办工单 <span class="title-count">{{ workOrders.length }} 条</span></h3>
  9. <el-table :data="workOrders" class="panel-table" :header-cell-style="tableHeaderStyle" size="small" v-loading="loading.work">
  10. <el-table-column prop="orderNo" label="工单编号" min-width="120" show-overflow-tooltip />
  11. <el-table-column prop="deviceCode" label="设备" min-width="100" show-overflow-tooltip />
  12. <el-table-column label="优先级" width="70">
  13. <template #default="{ row }">
  14. <span :class="['priority-tag', priorityClass(row.orderLevel)]">{{ priorityText(row.orderLevel) }}</span>
  15. </template>
  16. </el-table-column>
  17. </el-table>
  18. </div>
  19. <!-- 告警类型统计(15天内) -->
  20. <div class="panel">
  21. <h3 class="panel-title">
  22. 告警类型统计(15天内)
  23. <span class="title-count">{{ alarmRangeText }}</span>
  24. </h3>
  25. <div ref="alarmChartRef" class="chart" v-loading="loading.alarm" />
  26. </div>
  27. <!-- 设备区域信息 -->
  28. <div class="panel">
  29. <h3 class="panel-title">设备区域信息</h3>
  30. <div ref="areaChartRef" class="chart" v-loading="loading.device" />
  31. </div>
  32. </div>
  33. <!-- 中间地图区域 -->
  34. <div class="center-col">
  35. <div class="panel map-panel">
  36. <div class="map-header">
  37. <span>窨井设备分布</span>
  38. <div class="map-legend">
  39. <span><i class="legend-dot online"></i> 在线 {{ stats.online }}</span>
  40. <span><i class="legend-dot alert"></i> 告警 {{ stats.alarm }}</span>
  41. <span><i class="legend-dot offline"></i> 离线 {{ stats.offline }}</span>
  42. </div>
  43. </div>
  44. <div id="homeMap" class="bmap-container"></div>
  45. </div>
  46. <!-- 设备分类 -->
  47. <div class="panel chart-panel">
  48. <h3 class="panel-title">设备分类</h3>
  49. <div ref="deviceTypeChartRef" class="chart" v-loading="loading.device" />
  50. </div>
  51. </div>
  52. <!-- 右侧栏 -->
  53. <div class="right-col">
  54. <!-- 设备列表 -->
  55. <div class="panel">
  56. <h3 class="panel-title">设备列表 <span class="title-count">共 {{ filteredDevices.length }} 台</span></h3>
  57. <div class="filter-bar">
  58. <el-select v-model="deviceTypeFilter" size="small" placeholder="设备类型" clearable style="width: 120px">
  59. <el-option v-for="item in deviceTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
  60. </el-select>
  61. </div>
  62. <el-table :data="filteredDevices" class="panel-table" :header-cell-style="tableHeaderStyle" size="small" v-loading="loading.device">
  63. <el-table-column prop="name" label="设备名称" min-width="100" show-overflow-tooltip />
  64. <el-table-column label="类型" width="70">
  65. <template #default="{ row }">{{ typeText(row.typeId, row.typeName) }}</template>
  66. </el-table-column>
  67. <el-table-column label="电量" width="60">
  68. <template #default="{ row }">
  69. <el-tag :type="batteryTag(row.battery)" size="small">{{ row.battery ?? '-' }}%</el-tag>
  70. </template>
  71. </el-table-column>
  72. <el-table-column label="状态" width="60">
  73. <template #default="{ row }">
  74. <span :class="['status-dot', row.status]"></span>
  75. <span class="status-text">{{ statusText(row.status) }}</span>
  76. </template>
  77. </el-table-column>
  78. </el-table>
  79. </div>
  80. <!-- 动态信息 -->
  81. <div class="panel">
  82. <h3 class="panel-title">动态信息</h3>
  83. <el-table :data="dynamicLogs" class="panel-table" :header-cell-style="tableHeaderStyle" size="small" v-loading="loading.alarm">
  84. <el-table-column prop="time" label="时间" width="140" />
  85. <el-table-column prop="event" label="事件" min-width="120" show-overflow-tooltip />
  86. <el-table-column label="状态" width="70">
  87. <template #default="{ row }">
  88. <span :class="['status-tag', row.alarmStatus === 1 ? 'done' : 'processing']">{{ row.alarmStatus === 1 ? '已处理' : '未处理' }}</span>
  89. </template>
  90. </el-table-column>
  91. </el-table>
  92. </div>
  93. </div>
  94. </div>
  95. </template>
  96. <script setup>
  97. import { ref, reactive, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
  98. import * as echarts from 'echarts'
  99. import { getManholeLayerData } from '@/api/pipeNetwork/basic'
  100. import { listWorkOrder } from '@/api/pipeNetwork/ops/workOrder.js'
  101. import { ORDER_STATUS_GROUP } from '@/views/subSystem/pipeNetwork/ops/workOrder/js/workOrderDict.js'
  102. import { getAlarmDataPage } from '@/api/drainage'
  103. import { ElMessage } from 'element-plus'
  104. import locationIcon from '@/assets/images/location.png'
  105. // 台账存 WGS84(设备表单选点经 bd09ToWgs84 回填),百度地图必须转 BD-09 再画
  106. import { convertCoord } from '@/utils/coordTransform'
  107. // ==================== 状态 ====================
  108. const loading = reactive({ work: false, alarm: false, device: false })
  109. const stats = reactive({ total: 0, online: 0, offline: 0, alarm: 0 })
  110. const workOrders = ref([])
  111. const allDevices = ref([])
  112. const dynamicLogs = ref([])
  113. const deviceTypeFilter = ref('')
  114. const alarmRangeText = ref(formatAlarmRange())
  115. /** 告警统计区间文案,空数据时也展示,避免面板看不出统计范围 */
  116. function formatAlarmRange() {
  117. const end = new Date()
  118. const start = new Date(end.getTime() - ALARM_RANGE_DAYS * 24 * 3600 * 1000)
  119. const label = date => `${date.getMonth() + 1}-${String(date.getDate()).padStart(2, '0')}`
  120. return `${label(start)} ~ ${label(end)}`
  121. }
  122. // 图表引用
  123. const alarmChartRef = ref(null)
  124. const areaChartRef = ref(null)
  125. const deviceTypeChartRef = ref(null)
  126. let alarmChart = null, areaChart = null, deviceTypeChart = null
  127. // ==================== 常量映射 ====================
  128. /** 历史遗留窨井类型编码,仅用于后端未返回 typeName 时兜底展示 */
  129. const LEGACY_TYPE_MAP = { '5': '供水窨井', '9': '排水窨井', '10': '燃气窨井' }
  130. // 所属片区:后端 DistrictEnum(0-城南 1-城北 2-太常)
  131. const DISTRICT_MAP = { 0: '城南', 1: '城北', 2: '太常' }
  132. function districtLabel(code) {
  133. return code == null ? '' : (DISTRICT_MAP[code] || String(code))
  134. }
  135. const tableHeaderStyle = { background: 'rgba(0,100,200,0.3)', color: '#fff', borderColor: '#2b4b8c' }
  136. const priorityText = (l) => ({ 0: '紧急', 1: '一般', 2: '低' }[l] || '-')
  137. const priorityClass = (l) => ({ 0: 'high', 1: 'medium', 2: 'low' }[l] || 'low')
  138. /** 待办工单口径:待派单/待接单/已接单/处理中/延期中(枚举派生,见 workOrderDict.js) */
  139. const TODO_ORDER_STATUS = ORDER_STATUS_GROUP.todo.join(',')
  140. /** 设备父类别ID(equipment_type.parent_id),与工单列表页保持同一口径 */
  141. const DEVICE_TYPE_PARENT_ID = '4'
  142. /** 窨井设备大类根ID(EquipmentTypeEnum.ROOT_MANHOLE_DEVICE),报警查询按大类隔离 */
  143. const MANHOLE_ROOT_TYPE_ID = 4
  144. /** 报警查询后端限制 pageSize 不超过 100 */
  145. const ALARM_PAGE_SIZE = 100
  146. const ALARM_MAX_PAGES = 3
  147. /** 告警统计窗口(天) */
  148. const ALARM_RANGE_DAYS = 15
  149. /** 无设备数据时仍展示的区域维度,取自 DistrictEnum(0-城南 1-城北 2-太常) */
  150. const DEFAULT_DISTRICTS = ['城南', '城北', '太常']
  151. const typeText = (id, name) => name || LEGACY_TYPE_MAP[id] || (id ? `类型${id}` : '-')
  152. const statusText = (s) => ({ normal: '在线', alert: '告警', offline: '离线' }[s] || s)
  153. const batteryTag = (b) => {
  154. if (b == null) return 'info'
  155. if (Number(b) <= 20) return 'danger'
  156. if (Number(b) <= 50) return 'warning'
  157. return 'success'
  158. }
  159. // ==================== 筛选后设备列表 ====================
  160. /** 设备类型下拉项:跟着台账实际类型走,不再写死 5/9/10 */
  161. const deviceTypeOptions = computed(() => {
  162. const map = new Map()
  163. allDevices.value.forEach(d => {
  164. const value = String(d.typeId ?? '')
  165. if (!value || map.has(value)) return
  166. map.set(value, { value, label: typeText(d.typeId, d.typeName) })
  167. })
  168. return Array.from(map.values())
  169. })
  170. const filteredDevices = computed(() => {
  171. if (!deviceTypeFilter.value) return allDevices.value
  172. return allDevices.value.filter(d => String(d.typeId) === deviceTypeFilter.value)
  173. })
  174. // ==================== 数据加载 ====================
  175. async function loadAll() {
  176. await Promise.allSettled([loadDevices(), loadWorkOrders(), loadAlarmData()])
  177. await nextTick()
  178. handleResize()
  179. }
  180. async function loadDevices() {
  181. loading.device = true
  182. try {
  183. const res = await getManholeLayerData({ isQueryManholeData: true })
  184. const devices = res.data?.devices || []
  185. allDevices.value = devices.map(d => {
  186. const onlineStatus = Number(d.equipmentStatus?.onlineStatus)
  187. const alarmStatus = Number(d.equipmentStatus?.alarmStatus)
  188. const manholeAlarm = Number(d.manholeData?.alarmStatus)
  189. const isAlert = alarmStatus === 1 || manholeAlarm === 1 ||
  190. Number(d.manholeData?.waterInfiltrationAlarmStatus) === 1 ||
  191. Number(d.manholeData?.waterLevelAlarmStatus) === 1
  192. let status = 'offline'
  193. if (isAlert) status = 'alert'
  194. else if (onlineStatus === 1) status = 'normal'
  195. return {
  196. id: d.equipmentId, code: d.equipmentCode, name: d.equipmentName,
  197. typeId: d.equipmentTypeId, typeName: d.equipmentTypeName,
  198. location: d.equipmentLocation, district: districtLabel(d.district),
  199. lng: d.longitude != null ? Number(d.longitude) : null,
  200. lat: d.latitude != null ? Number(d.latitude) : null,
  201. battery: d.manholeData?.batteryLevel ?? null,
  202. status
  203. }
  204. })
  205. const statistics = res.data?.statistics || {}
  206. stats.total = statistics.total || allDevices.value.length
  207. stats.online = statistics.online || 0
  208. stats.offline = statistics.offline || 0
  209. stats.alarm = statistics.alarm || 0
  210. renderAreaChart()
  211. renderDeviceTypeChart()
  212. refreshMarkers()
  213. } catch (e) {
  214. ElMessage.error('设备数据加载失败')
  215. // 加载失败也铺出空态骨架,面板不留白
  216. renderAreaChart()
  217. renderDeviceTypeChart()
  218. } finally {
  219. loading.device = false
  220. }
  221. }
  222. async function loadWorkOrders() {
  223. loading.work = true
  224. try {
  225. const res = await listWorkOrder({
  226. pageNum: 1,
  227. pageSize: 20,
  228. orderStatus: TODO_ORDER_STATUS,
  229. deviceTypeParentId: DEVICE_TYPE_PARENT_ID
  230. })
  231. workOrders.value = res.data?.records || []
  232. } catch (e) {
  233. ElMessage.error('工单数据加载失败')
  234. } finally {
  235. loading.work = false
  236. }
  237. }
  238. function formatDateTime(date) {
  239. const pad = value => String(value).padStart(2, '0')
  240. return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
  241. }
  242. /** 报警类型展示:优先报警内容,其次报警编码 */
  243. function alarmTypeText(record) {
  244. return record.alarmContent || record.warningCode || '其他告警'
  245. }
  246. /**
  247. * 拉取近 15 天的窨井报警
  248. *
  249. * rootTypeId 是报警查询的必填项(后端校验「设备大类不能为空」),
  250. * pageSize 上限 100,因此按页累加,最多取 3 页用于首页统计。
  251. */
  252. async function fetchRecentAlarms() {
  253. const params = {
  254. rootTypeId: MANHOLE_ROOT_TYPE_ID,
  255. startTime: formatDateTime(new Date(Date.now() - 15 * 24 * 3600 * 1000))
  256. }
  257. const records = []
  258. for (let page = 1; page <= ALARM_MAX_PAGES; page++) {
  259. const res = await getAlarmDataPage(page, ALARM_PAGE_SIZE, params)
  260. const pageRecords = res.rows || res.data?.records || res.data || []
  261. records.push(...pageRecords)
  262. if (pageRecords.length < ALARM_PAGE_SIZE) break
  263. }
  264. return records
  265. }
  266. async function loadAlarmData() {
  267. loading.alarm = true
  268. alarmRangeText.value = formatAlarmRange()
  269. try {
  270. const records = await fetchRecentAlarms()
  271. const fifteenDaysAgo = new Date(Date.now() - 15 * 24 * 3600 * 1000)
  272. const recentAlarms = records.filter(r => !r.createTime || new Date(String(r.createTime).replace(/-/g, '/')) >= fifteenDaysAgo)
  273. const typeMap = {}
  274. recentAlarms.forEach(r => {
  275. const type = alarmTypeText(r)
  276. typeMap[type] = (typeMap[type] || 0) + 1
  277. })
  278. renderAlarmChart(typeMap)
  279. // 动态信息(最新10条)
  280. dynamicLogs.value = records.slice(0, 10).map(r => ({
  281. time: r.createTime || '',
  282. event: `${alarmTypeText(r)} - ${r.deviceName || r.deviceCode || ''}`,
  283. alarmStatus: r.alarmStatus ?? 0
  284. }))
  285. } catch (e) {
  286. console.error(e)
  287. ElMessage.error('告警数据加载失败')
  288. // 加载失败也铺出空态骨架(含统计区间),面板不留白
  289. renderAlarmChart({})
  290. } finally {
  291. loading.alarm = false
  292. }
  293. }
  294. // ==================== ECharts ====================
  295. function renderAlarmChart(typeMap) {
  296. if (!alarmChartRef.value) return
  297. if (!alarmChart) alarmChart = echarts.init(alarmChartRef.value)
  298. const colors = ['#ff3333', '#ffb333', '#33cc33', '#3399ff', '#9966ff', '#ff6699', '#33cccc']
  299. const data = Object.entries(typeMap || {}).map(([name, value], i) => ({
  300. name, value, itemStyle: { color: colors[i % colors.length] }
  301. }))
  302. const hasData = data.length > 0
  303. alarmChart.setOption({
  304. backgroundColor: 'transparent',
  305. // 无告警时保留统计区间与说明,避免面板看起来是空的
  306. title: hasData ? { show: false } : {
  307. text: '近 15 天暂无告警',
  308. subtext: `统计区间 ${alarmRangeText.value}`,
  309. left: 'center',
  310. top: 'middle',
  311. textStyle: { color: '#9ba8c0', fontSize: 12, fontWeight: 'normal' },
  312. subtextStyle: { color: '#6f7d95', fontSize: 11 }
  313. },
  314. tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
  315. legend: { bottom: 0, textStyle: { color: '#ccc', fontSize: 10 }, show: hasData },
  316. // 空数据时不挂饼图系列,否则会残留一个灰色的空圆环
  317. series: hasData ? [{
  318. type: 'pie', radius: ['35%', '60%'], center: ['50%', '40%'],
  319. label: { color: '#ccc', fontSize: 10 },
  320. data
  321. }] : []
  322. }, true)
  323. }
  324. function renderAreaChart() {
  325. if (!areaChartRef.value) return
  326. if (!areaChart) areaChart = echarts.init(areaChartRef.value)
  327. const areaMap = {}
  328. allDevices.value.forEach(d => {
  329. const area = d.district || d.location || '未分配区域'
  330. // 取区域名(截取前4个字)
  331. const shortArea = area.length > 6 ? area.slice(0, 6) + '...' : area
  332. areaMap[shortArea] = (areaMap[shortArea] || 0) + 1
  333. })
  334. let entries = Object.entries(areaMap).sort((a, b) => b[1] - a[1]).slice(0, 8)
  335. const hasData = entries.length > 0
  336. // 无设备时仍按默认区域维度铺出骨架,面板不会空成一片
  337. if (!hasData) {
  338. entries = DEFAULT_DISTRICTS.map(name => [name, 0])
  339. }
  340. areaChart.setOption({
  341. backgroundColor: 'transparent',
  342. title: hasData ? { show: false } : {
  343. text: '暂无设备,按默认区域维度展示',
  344. left: 'center',
  345. top: 0,
  346. textStyle: { color: '#6f7d95', fontSize: 11, fontWeight: 'normal' }
  347. },
  348. tooltip: { trigger: 'axis' },
  349. grid: { left: '12%', right: '8%', bottom: '18%', top: '18%' },
  350. xAxis: {
  351. type: 'category', data: entries.map(e => e[0]),
  352. axisLabel: { color: '#ccc', fontSize: 10, interval: 0, rotate: 30 }
  353. },
  354. yAxis: {
  355. type: 'value', minInterval: 1,
  356. axisLabel: { color: '#ccc' },
  357. splitLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }
  358. },
  359. series: [{ type: 'bar', data: entries.map(e => e[1]), itemStyle: { color: '#3399ff' }, barWidth: '40%' }]
  360. }, true)
  361. }
  362. function renderDeviceTypeChart() {
  363. if (!deviceTypeChartRef.value) return
  364. if (!deviceTypeChart) deviceTypeChart = echarts.init(deviceTypeChartRef.value)
  365. // 按台账实际存在的设备类型聚合,避免把不存在的旧类型当成固定三分类
  366. const countMap = new Map()
  367. allDevices.value.forEach(d => {
  368. const label = typeText(d.typeId, d.typeName)
  369. countMap.set(label, (countMap.get(label) || 0) + 1)
  370. })
  371. // 有设备但类型名为空时,至少给出一个占位分类,面板不留白
  372. if (!countMap.size && allDevices.value.length) {
  373. countMap.set('未分类', allDevices.value.length)
  374. }
  375. const labels = Array.from(countMap.keys())
  376. const values = Array.from(countMap.values())
  377. const palette = ['#3399ff', '#67c23a', '#e6a23c', '#f56c6c', '#9b59b6']
  378. deviceTypeChart.setOption({
  379. backgroundColor: 'transparent',
  380. tooltip: { trigger: 'axis' },
  381. grid: { left: '12%', right: '8%', bottom: '15%', top: '8%' },
  382. title: labels.length ? undefined : {
  383. text: '暂无窨井设备',
  384. subtext: '设备台账中未查到窨井类设备',
  385. left: 'center',
  386. top: 'middle',
  387. textStyle: { color: '#ccc', fontSize: 12, fontWeight: 'normal' },
  388. subtextStyle: { color: '#888', fontSize: 11 }
  389. },
  390. xAxis: {
  391. type: 'category', data: labels,
  392. axisLabel: { color: '#ccc', fontSize: 11, interval: 0 }
  393. },
  394. yAxis: {
  395. type: 'value', minInterval: 1,
  396. axisLabel: { color: '#ccc' },
  397. splitLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }
  398. },
  399. series: labels.length ? [{
  400. type: 'bar',
  401. data: values.map((v, i) => ({ value: v, itemStyle: { color: palette[i % palette.length] } })),
  402. barWidth: '40%', label: { show: true, position: 'top', color: '#ccc' }
  403. }] : []
  404. }, true)
  405. }
  406. // ==================== 百度地图 ====================
  407. let mapInstance = null
  408. let mapFitted = false
  409. const markerMap = new Map()
  410. /** 兜底中心(沅陵县城),WGS84 口径,与设备台账一致 */
  411. const YUANLING_CENTER = { lng: 110.393, lat: 28.452 }
  412. function initMap() {
  413. const container = document.getElementById('homeMap')
  414. if (!container || typeof BMapGL === 'undefined') return
  415. try {
  416. mapInstance = new BMapGL.Map('homeMap')
  417. const [centerLng, centerLat] = convertCoord(YUANLING_CENTER.lng, YUANLING_CENTER.lat)
  418. mapInstance.centerAndZoom(new BMapGL.Point(centerLng, centerLat), 15)
  419. mapInstance.enableScrollWheelZoom(true)
  420. } catch (e) { console.error('地图初始化失败', e) }
  421. }
  422. /** 有设备时按设备范围取视野;点太近时限制放大层级,避免只剩一栋楼 */
  423. function fitMapToDevices(points) {
  424. if (!mapInstance || !points.length) return
  425. if (points.length === 1) {
  426. mapInstance.centerAndZoom(points[0], 16)
  427. return
  428. }
  429. const lngs = points.map(p => p.lng)
  430. const lats = points.map(p => p.lat)
  431. const span = Math.max(Math.max(...lngs) - Math.min(...lngs), Math.max(...lats) - Math.min(...lats))
  432. if (span < 0.002) {
  433. const mid = new BMapGL.Point((Math.max(...lngs) + Math.min(...lngs)) / 2, (Math.max(...lats) + Math.min(...lats)) / 2)
  434. mapInstance.centerAndZoom(mid, 16)
  435. } else {
  436. mapInstance.setViewport(points, { margins: [50, 50, 50, 50] })
  437. }
  438. }
  439. function getStatusColor(status) {
  440. if (status === 'alert') return '#F56C6C'
  441. if (status === 'offline') return '#909399'
  442. return '#67C23A'
  443. }
  444. function refreshMarkers() {
  445. if (!mapInstance) return
  446. markerMap.forEach(label => mapInstance.removeOverlay(label))
  447. markerMap.clear()
  448. const devicePoints = []
  449. allDevices.value.forEach(d => {
  450. if (d.lng == null || d.lat == null) return
  451. // 关键:台账是 WGS84,百度地图是 BD-09,不转换会偏约 1 公里
  452. const [bdLng, bdLat] = convertCoord(Number(d.lng), Number(d.lat))
  453. const point = new BMapGL.Point(bdLng, bdLat)
  454. devicePoints.push(point)
  455. const color = getStatusColor(d.status)
  456. const isAttention = d.status === 'alert' || d.status === 'offline'
  457. const attentionText = d.status === 'alert' ? '告警' : '离线'
  458. const html = isAttention
  459. ? `<div class="home-map-attention-marker home-map-${d.status}" title="${attentionText}设备:${d.name}">
  460. <div class="home-map-attention-badge">${attentionText} - ${d.name}</div>
  461. <div class="home-map-attention-pin">
  462. <i class="home-map-attention-ring"></i>
  463. <img src="${locationIcon}" alt="" />
  464. </div>
  465. </div>`
  466. : `<div style="text-align:center;cursor:pointer;">
  467. <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>
  468. <div><img src="${locationIcon}" alt="" style="width:22px;height:22px;display:block;margin:0 auto;"/></div>
  469. </div>`
  470. const label = new BMapGL.Label(html, {
  471. position: point,
  472. offset: new BMapGL.Size(isAttention ? -62 : -25, isAttention ? -68 : -40)
  473. })
  474. label.setStyle({ border: 'none', background: 'transparent', padding: '0', zIndex: isAttention ? '20' : '10' })
  475. mapInstance.addOverlay(label)
  476. markerMap.set(d.id, label)
  477. })
  478. // 只在首次拿到设备时校准视野,后续刷新不打断用户手动缩放
  479. if (!mapFitted && devicePoints.length) {
  480. fitMapToDevices(devicePoints)
  481. mapFitted = true
  482. }
  483. }
  484. // ==================== 生命周期 ====================
  485. const handleResize = () => {
  486. alarmChart && alarmChart.resize()
  487. areaChart && areaChart.resize()
  488. deviceTypeChart && deviceTypeChart.resize()
  489. mapInstance && mapInstance.resize && mapInstance.resize()
  490. }
  491. onMounted(async () => {
  492. await nextTick()
  493. initMap()
  494. window.addEventListener('resize', handleResize)
  495. loadAll()
  496. })
  497. onBeforeUnmount(() => {
  498. window.removeEventListener('resize', handleResize)
  499. alarmChart && alarmChart.dispose()
  500. areaChart && areaChart.dispose()
  501. deviceTypeChart && deviceTypeChart.dispose()
  502. if (mapInstance) {
  503. mapInstance.clearOverlays && mapInstance.clearOverlays()
  504. mapInstance = null
  505. }
  506. markerMap.clear()
  507. })
  508. </script>
  509. <!-- ==================== 全局样式(非 scoped):el-table 深色主题覆盖 ==================== -->
  510. <!-- 用 .monitor-container 限定范围,不影响其他页面 -->
  511. <style>
  512. .monitor-container .el-table {
  513. --el-table-bg-color: transparent;
  514. --el-table-tr-bg-color: transparent;
  515. --el-table-header-bg-color: transparent;
  516. --el-table-current-row-bg-color: rgba(0, 100, 200, 0.25);
  517. --el-table-row-hover-bg-color: rgba(0, 100, 200, 0.15);
  518. --el-table-text-color: #dce3ee;
  519. --el-table-header-text-color: #fff;
  520. --el-table-border-color: rgba(43, 75, 140, 0.35);
  521. --el-table-border: 1px solid rgba(43, 75, 140, 0.35);
  522. --el-fill-color-lighter: rgba(30, 40, 90, 0.25);
  523. --el-fill-color-light: rgba(30, 40, 90, 0.25);
  524. --el-fill-color: rgba(30, 40, 90, 0.25);
  525. --el-fill-color-blank: transparent;
  526. --el-text-color-regular: #dce3ee;
  527. --el-text-color-secondary: #9ba8c0;
  528. --el-border-color: rgba(43, 75, 140, 0.35);
  529. --el-border-color-lighter: rgba(43, 75, 140, 0.2);
  530. background: transparent !important;
  531. border: none !important;
  532. }
  533. /* 隐藏 EP 表格自带的底部/右侧边框伪元素 */
  534. .monitor-container .el-table::before,
  535. .monitor-container .el-table::after,
  536. .monitor-container .el-table .el-table__inner-wrapper::before,
  537. .monitor-container .el-table .el-table__inner-wrapper::after {
  538. display: none !important;
  539. }
  540. /* 内部容器全透明 */
  541. .monitor-container .el-table .el-table__inner-wrapper,
  542. .monitor-container .el-table .el-table__body-wrapper,
  543. .monitor-container .el-table .el-table__header-wrapper,
  544. .monitor-container .el-table .el-table__body,
  545. .monitor-container .el-table .el-table__header {
  546. background: transparent !important;
  547. }
  548. /* 表头单元格 — EP 2.x 使用 th.el-table__cell */
  549. .monitor-container .el-table th.el-table__cell {
  550. background: rgba(0, 100, 200, 0.35) !important;
  551. color: #fff !important;
  552. font-weight: 600;
  553. border-bottom: 1px solid #2b4b8c !important;
  554. }
  555. /* 内容单元格 */
  556. .monitor-container .el-table td.el-table__cell {
  557. background: transparent !important;
  558. border-bottom: 1px solid rgba(43, 75, 140, 0.25) !important;
  559. padding: 7px 0 !important;
  560. }
  561. /* 行 */
  562. .monitor-container .el-table .el-table__row {
  563. background: transparent !important;
  564. }
  565. .monitor-container .el-table .el-table__row:hover > td.el-table__cell {
  566. background: rgba(0, 100, 200, 0.15) !important;
  567. }
  568. /* 空数据 */
  569. .monitor-container .el-table .el-table__empty-block {
  570. background: transparent !important;
  571. }
  572. .monitor-container .el-table .el-table__empty-text {
  573. color: #8090a8;
  574. }
  575. /* 滚动条 */
  576. .monitor-container .el-table .el-scrollbar__bar {
  577. opacity: 0.4;
  578. }
  579. /* el-select 输入框深色适配 */
  580. .monitor-container .el-select .el-input__wrapper {
  581. background: rgba(30, 40, 90, 0.6);
  582. box-shadow: 0 0 0 1px rgba(43, 75, 140, 0.5) inset;
  583. }
  584. .monitor-container .el-select .el-input__inner {
  585. color: #dce3ee;
  586. }
  587. /* el-table 在 flex 容器中撑满高度(无 max-height 时) */
  588. .monitor-container .el-table {
  589. height: 100% !important;
  590. display: flex;
  591. flex-direction: column;
  592. }
  593. .monitor-container .el-table .el-table__inner-wrapper {
  594. flex: 1;
  595. min-height: 0;
  596. display: flex;
  597. flex-direction: column;
  598. }
  599. .monitor-container .el-table .el-table__body-wrapper {
  600. flex: 1;
  601. min-height: 0;
  602. }
  603. /* 告警和离线设备地图标记:通过扩散圈和上下位移动画强化定位提示 */
  604. .home-map-attention-marker {
  605. position: relative;
  606. width: 124px;
  607. height: 70px;
  608. cursor: pointer;
  609. text-align: center;
  610. }
  611. .home-map-attention-badge {
  612. position: relative;
  613. z-index: 2;
  614. display: inline-block;
  615. max-width: 118px;
  616. overflow: hidden;
  617. padding: 2px 7px;
  618. border: 1px solid rgba(255, 255, 255, 0.72);
  619. border-radius: 4px;
  620. color: #fff;
  621. box-shadow: 0 2px 8px rgba(152, 23, 23, 0.55);
  622. font-size: 10px;
  623. line-height: 16px;
  624. text-overflow: ellipsis;
  625. white-space: nowrap;
  626. }
  627. .home-map-attention-pin {
  628. position: absolute;
  629. left: 50%;
  630. top: 27px;
  631. width: 28px;
  632. height: 34px;
  633. transform: translateX(-50%);
  634. animation: homeMapAttentionBounce 1.1s ease-in-out infinite;
  635. }
  636. .home-map-attention-pin img {
  637. position: relative;
  638. z-index: 2;
  639. display: block;
  640. width: 28px;
  641. height: 28px;
  642. }
  643. .home-map-attention-ring {
  644. position: absolute;
  645. z-index: 1;
  646. left: 50%;
  647. top: 12px;
  648. width: 18px;
  649. height: 18px;
  650. border-radius: 50%;
  651. transform: translate(-50%, -50%);
  652. animation: homeMapAttentionPulse 1.5s ease-out infinite;
  653. }
  654. .home-map-alert .home-map-attention-badge { background: #e64545; }
  655. .home-map-alert .home-map-attention-ring { border: 2px solid rgba(255, 75, 75, 0.95); }
  656. .home-map-offline .home-map-attention-badge { background: #e67e22; }
  657. .home-map-offline .home-map-attention-ring { border: 2px solid rgba(255, 166, 77, 0.95); }
  658. @keyframes homeMapAttentionBounce {
  659. 0%, 100% { transform: translate(-50%, 0); }
  660. 50% { transform: translate(-50%, -8px); }
  661. }
  662. @keyframes homeMapAttentionPulse {
  663. 0% { opacity: 0.9; transform: translate(-50%, -50%) scale(0.7); }
  664. 70%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(2.2); }
  665. }
  666. </style>
  667. <!-- ==================== 页面布局样式(scoped) ==================== -->
  668. <style scoped>
  669. .monitor-container {
  670. display: flex;
  671. width: 100%;
  672. height: 100vh;
  673. min-height: 600px;
  674. background: #1a1a4a;
  675. color: #fff;
  676. gap: 16px;
  677. padding: 16px;
  678. box-sizing: border-box;
  679. overflow: hidden;
  680. }
  681. .left-col {
  682. flex: 0 0 25%;
  683. min-width: 280px;
  684. max-width: 420px;
  685. display: flex;
  686. flex-direction: column;
  687. gap: 16px;
  688. min-height: 0;
  689. }
  690. .right-col {
  691. flex: 0 0 25%;
  692. min-width: 280px;
  693. max-width: 420px;
  694. display: flex;
  695. flex-direction: column;
  696. gap: 16px;
  697. min-height: 0;
  698. }
  699. .center-col {
  700. flex: 1;
  701. min-width: 0;
  702. display: flex;
  703. flex-direction: column;
  704. gap: 16px;
  705. min-height: 0;
  706. }
  707. .panel {
  708. flex: 1;
  709. background: rgba(30, 40, 90, 0.8);
  710. border: 1px solid #2b4b8c;
  711. border-radius: 8px;
  712. padding: 14px;
  713. box-sizing: border-box;
  714. display: flex;
  715. flex-direction: column;
  716. min-height: 0;
  717. }
  718. /* 设备分类固定高度,不参与均分 */
  719. .chart-panel {
  720. flex: 0 0 190px;
  721. }
  722. .panel-title {
  723. margin: 0 0 10px 0;
  724. font-size: 14px;
  725. color: #66ccff;
  726. border-left: 3px solid #66ccff;
  727. padding-left: 8px;
  728. display: flex;
  729. justify-content: space-between;
  730. align-items: center;
  731. flex-shrink: 0;
  732. }
  733. .title-count {
  734. font-size: 12px;
  735. color: #aaa;
  736. font-weight: normal;
  737. }
  738. .panel-table {
  739. width: 100%;
  740. flex: 1;
  741. min-height: 0;
  742. overflow: hidden;
  743. }
  744. .chart {
  745. width: 100%;
  746. flex: 1;
  747. min-height: 100px;
  748. }
  749. .map-panel {
  750. flex: 1;
  751. display: flex;
  752. flex-direction: column;
  753. min-height: 0;
  754. }
  755. .map-header {
  756. display: flex;
  757. justify-content: space-between;
  758. align-items: center;
  759. margin-bottom: 8px;
  760. font-size: 14px;
  761. color: #66ccff;
  762. flex-shrink: 0;
  763. }
  764. .map-legend {
  765. display: flex;
  766. gap: 12px;
  767. font-size: 11px;
  768. }
  769. .legend-dot {
  770. display: inline-block;
  771. width: 8px;
  772. height: 8px;
  773. border-radius: 50%;
  774. margin-right: 3px;
  775. }
  776. .legend-dot.online { background: #67c23a; }
  777. .legend-dot.alert { background: #f56c6c; }
  778. .legend-dot.offline { background: #909399; }
  779. .bmap-container {
  780. width: 100%;
  781. flex: 1;
  782. min-height: 200px;
  783. border-radius: 4px;
  784. overflow: hidden;
  785. }
  786. .filter-bar {
  787. margin-bottom: 10px;
  788. flex-shrink: 0;
  789. }
  790. .priority-tag {
  791. padding: 1px 6px;
  792. border-radius: 3px;
  793. font-size: 11px;
  794. color: #fff;
  795. }
  796. .priority-tag.high { background: #ff3333; }
  797. .priority-tag.medium { background: #ffb333; color: #333; }
  798. .priority-tag.low { background: #33cc33; }
  799. .status-tag {
  800. padding: 1px 6px;
  801. border-radius: 3px;
  802. font-size: 11px;
  803. color: #fff;
  804. }
  805. .status-tag.processing { background: #3399ff; }
  806. .status-tag.done { background: #33cc33; }
  807. .status-dot {
  808. display: inline-block;
  809. width: 8px;
  810. height: 8px;
  811. border-radius: 50%;
  812. margin-right: 3px;
  813. }
  814. .status-dot.normal { background: #67c23a; }
  815. .status-dot.alert { background: #f56c6c; }
  816. .status-dot.offline { background: #909399; }
  817. .status-text {
  818. font-size: 11px;
  819. color: #dce3ee;
  820. }
  821. /* === 响应式 === */
  822. @media (max-width: 1600px) {
  823. .left-col, .right-col { flex: 0 0 24%; min-width: 260px; max-width: 380px; }
  824. .monitor-container { gap: 12px; padding: 12px; }
  825. .panel { padding: 12px; }
  826. .chart-panel { flex-basis: 170px; }
  827. }
  828. @media (max-width: 1366px) {
  829. .left-col, .right-col { flex: 0 0 27%; min-width: 240px; max-width: 340px; }
  830. .monitor-container { gap: 10px; padding: 10px; }
  831. .panel { padding: 10px; }
  832. .chart-panel { flex-basis: 150px; }
  833. }
  834. @media (max-width: 1024px) {
  835. .monitor-container {
  836. flex-direction: column;
  837. height: auto;
  838. min-height: 100vh;
  839. overflow: auto;
  840. }
  841. .left-col, .right-col {
  842. flex: 1 1 auto;
  843. min-width: 100%;
  844. max-width: 100%;
  845. }
  846. .center-col {
  847. flex: 1 1 auto;
  848. min-width: 100%;
  849. }
  850. }
  851. </style>