alarmList.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. <!--状态监测报警-->
  2. <!--监测报警一张图-->
  3. <template>
  4. <div class="alert-dashboard">
  5. <!-- 头部统计告警栏 -->
  6. <div class="alert-header">
  7. <div class="stats-cards">
  8. <div class="stat-item" :class="{ active: filterType === 'all' }" @click="filterAlerts('all')">
  9. <div class="stat-num">{{ totalAlerts }}</div>
  10. <div class="stat-label">全部报警</div>
  11. </div>
  12. <div class="stat-item critical" :class="{ active: filterType === 'critical' }" @click="filterAlerts('critical')">
  13. <div class="stat-num">{{ criticalCount }}</div>
  14. <div class="stat-label">严重报警</div>
  15. </div>
  16. <div class="stat-item warning" :class="{ active: filterType === 'warning' }" @click="filterAlerts('warning')">
  17. <div class="stat-num">{{ warningCount }}</div>
  18. <div class="stat-label">一般报警</div>
  19. </div>
  20. </div>
  21. </div>
  22. <!-- 主内容区域:左侧报警列表 + 右侧详情研判 -->
  23. <div class="main-content">
  24. <!-- 左侧报警列表 -->
  25. <div class="alert-list-panel" v-loading="listLoading">
  26. <div class="panel-header">
  27. <span>报警事件列表 <el-badge :value="unhandledCount" class="badge" /></span>
  28. <el-input v-model="searchText" placeholder="搜索位置/设备编号" prefix-icon="Search" clearable size="small" style="width: 180px" />
  29. </div>
  30. <div class="alert-list">
  31. <div
  32. v-for="alert in filteredAlerts"
  33. :key="alert.id"
  34. class="alert-item"
  35. :class="{
  36. selected: selectedAlert?.id === alert.id,
  37. critical: alert.level === 'critical',
  38. warning: alert.level === 'warning'
  39. }"
  40. @click="selectAlert(alert)"
  41. >
  42. <div class="alert-level-icon">
  43. <el-icon v-if="alert.level === 'critical'"><WarningFilled /></el-icon>
  44. <el-icon v-else-if="alert.level === 'warning'"><InfoFilled /></el-icon>
  45. <el-icon v-else><CircleCheck /></el-icon>
  46. </div>
  47. <div class="alert-content">
  48. <div class="alert-title">
  49. <span>{{ alert.deviceName }}</span>
  50. <span class="alert-time">{{ alert.time }}</span>
  51. </div>
  52. <div class="alert-location">
  53. <el-icon><Location /></el-icon> {{ alert.location }}
  54. </div>
  55. <div class="alert-type">{{ alert.alertType }}</div>
  56. <div class="alert-value" v-if="alert.exceedValue">实时值: <strong class="exceed">{{ alert.exceedValue }}</strong> (阈值: {{ alert.threshold }})</div>
  57. </div>
  58. <div class="alert-status" v-if="alert.status === 'resolved'">
  59. <el-tag type="success" size="small">已处置</el-tag>
  60. </div>
  61. <div class="alert-status" v-else-if="alert.status === 'processing'">
  62. <el-tag type="warning" size="small">处理中</el-tag>
  63. </div>
  64. <div class="alert-status" v-else>
  65. <el-tag type="danger" size="small">待处理</el-tag>
  66. </div>
  67. </div>
  68. <el-empty v-if="!filteredAlerts.length && !listLoading" description="暂无报警事件" :image-size="100" />
  69. </div>
  70. </div>
  71. <!-- 右侧报警详情与研判面板 -->
  72. <div class="detail-panel" v-if="selectedAlert" v-loading="detailLoading">
  73. <!-- 报警基本信息 -->
  74. <div class="detail-section">
  75. <div class="section-title">
  76. <span><el-icon><Warning /></el-icon> 报警详情 - 辅助研判</span>
  77. </div>
  78. <el-descriptions :column="2" border>
  79. <el-descriptions-item label="报警位置">
  80. <strong>{{ selectedAlert.location }}</strong> <el-button link type="primary" size="small" @click="viewOnMap">查看地图</el-button>
  81. </el-descriptions-item>
  82. <el-descriptions-item label="报警级别">
  83. <el-tag :type="selectedAlert.level === 'critical' ? 'danger' : 'warning'" size="default">
  84. {{ selectedAlert.level === 'critical' ? '严重报警' : '一般报警' }}
  85. </el-tag>
  86. </el-descriptions-item>
  87. <el-descriptions-item label="设备编号">{{ selectedAlert.deviceCode || '—' }}</el-descriptions-item>
  88. <el-descriptions-item label="设备类型">{{ selectedAlert.deviceType }}</el-descriptions-item>
  89. <el-descriptions-item label="报警类型">{{ selectedAlert.alertType }}</el-descriptions-item>
  90. <el-descriptions-item label="报警时间">{{ selectedAlert.time }}</el-descriptions-item>
  91. <el-descriptions-item label="实时数据" :span="2">
  92. <div class="real-time-data">
  93. <span v-if="selectedAlert.realData">
  94. 当前 {{ selectedAlert.alertParam }} = <strong class="warning-value">{{ selectedAlert.realData }}</strong>
  95. (阈值: {{ selectedAlert.threshold }})
  96. </span>
  97. <span v-else>{{ selectedAlert.exceedValue || '超出阈值' }}</span>
  98. </div>
  99. </el-descriptions-item>
  100. </el-descriptions>
  101. </div>
  102. </div>
  103. <!-- 未选中报警时的空状态 -->
  104. <div v-else class="empty-detail">
  105. <el-empty description="请从左侧选择一个报警事件进行研判" :image-size="140" />
  106. </div>
  107. </div>
  108. <!-- 地图弹窗 -->
  109. <el-dialog v-model="mapDialogVisible" title="报警位置地图" width="720px" @opened="handleMapDialogOpened">
  110. <div class="dialog-map-wrapper">
  111. <div ref="dialogMapRef" class="dialog-baidu-map"></div>
  112. <div v-if="dialogMapError" class="dialog-map-error">{{ dialogMapError }}</div>
  113. <div v-if="!hasAlertCoordinate" class="dialog-map-empty">
  114. 当前报警缺少真实坐标,已展示默认地图视野
  115. </div>
  116. <div class="dialog-map-footer">
  117. <div><strong>{{ selectedAlert?.location || '暂无位置' }}</strong></div>
  118. <div>经度: {{ selectedAlert?.lng || '暂无数据' }}, 纬度: {{ selectedAlert?.lat || '暂无数据' }}</div>
  119. </div>
  120. </div>
  121. </el-dialog>
  122. </div>
  123. </template>
  124. <script setup>
  125. import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue'
  126. import {CircleCheck, InfoFilled, Location, Warning, WarningFilled} from '@element-plus/icons-vue'
  127. import {ElMessage} from 'element-plus'
  128. import {getManholeAlarmDeviceDetail, getManholeAlarmDeviceList} from '@/api/pipeNetwork/basic'
  129. const alerts = ref([])
  130. const searchText = ref('')
  131. const filterType = ref('all')
  132. const selectedAlert = ref(null)
  133. const mapDialogVisible = ref(false)
  134. const listLoading = ref(false)
  135. const detailLoading = ref(false)
  136. const dialogMapRef = ref(null)
  137. const dialogMapError = ref('')
  138. const BAIDU_MAP_AK = import.meta.env.VITE_BAIDU_MAP_AK || ''
  139. const DEFAULT_MAP_CENTER = { lng: 110.393, lat: 28.452 }
  140. let dialogMapInstance = null
  141. let dialogMapOverlays = []
  142. let baiduMapScriptPromise = null
  143. function toNumber(value) {
  144. const num = Number(value)
  145. return Number.isFinite(num) ? num : null
  146. }
  147. function isValidCoordinate(lng, lat) {
  148. return lng !== null && lat !== null && lng >= 70 && lng <= 140 && lat >= 3 && lat <= 55
  149. }
  150. function formatDateTime(value) {
  151. if (!value) return '—'
  152. return String(value).replace('T', ' ')
  153. }
  154. function getThresholdTextByCode(raw, warningCode) {
  155. const list = Array.isArray(raw?.warningThresholdList) ? raw.warningThresholdList : []
  156. const target = list.find(item => item.warningCode === warningCode)
  157. if (!target) return '—'
  158. const { minValue, maxValue } = target
  159. if (minValue !== null && minValue !== undefined && maxValue !== null && maxValue !== undefined) {
  160. return `${minValue} ~ ${maxValue}`
  161. }
  162. if (minValue !== null && minValue !== undefined) return `<= ${minValue}`
  163. if (maxValue !== null && maxValue !== undefined) return `>= ${maxValue}`
  164. return '—'
  165. }
  166. function buildAlertInfo(raw) {
  167. const status = raw?.equipmentStatus || {}
  168. const manholeData = raw?.manholeData || {}
  169. const battery = toNumber(manholeData.batteryLevel)
  170. const signal = toNumber(manholeData.signalStrength)
  171. const tiltAngle = toNumber(manholeData.tiltAngle)
  172. if (`${manholeData.waterLevelAlarmStatus}` === '1') {
  173. return {
  174. hasAlert: true,
  175. level: 'critical',
  176. levelText: '严重报警',
  177. alertType: '水位报警',
  178. alertParam: '水位',
  179. realData: manholeData.waterLevel !== undefined && manholeData.waterLevel !== null ? `${manholeData.waterLevel}cm` : '报警',
  180. threshold: getThresholdTextByCode(raw, 'WARN-WATER-LEVEL')
  181. }
  182. }
  183. if (`${manholeData.waterInfiltrationAlarmStatus}` === '1') {
  184. return {
  185. hasAlert: true,
  186. level: 'critical',
  187. levelText: '严重报警',
  188. alertType: '水浸报警',
  189. alertParam: '水浸状态',
  190. realData: '报警',
  191. threshold: getThresholdTextByCode(raw, 'WARN-WATER-INFILTRATION')
  192. }
  193. }
  194. if (Number(status.alarmStatus) === 1) {
  195. return {
  196. hasAlert: true,
  197. level: 'critical',
  198. levelText: '严重报警',
  199. alertType: tiltAngle !== null ? '倾斜报警' : '设备报警',
  200. alertParam: tiltAngle !== null ? '倾斜角' : '设备状态',
  201. realData: tiltAngle !== null ? `${tiltAngle}°` : '报警',
  202. threshold: tiltAngle !== null ? getThresholdTextByCode(raw, 'WARN-TILT') : '—'
  203. }
  204. }
  205. if (battery !== null && battery <= 20) {
  206. return {
  207. hasAlert: true,
  208. level: 'warning',
  209. levelText: '一般报警',
  210. alertType: '低电量预警',
  211. alertParam: '电池电量',
  212. realData: `${battery}%`,
  213. threshold: '20%'
  214. }
  215. }
  216. if (signal !== null && signal <= 20) {
  217. return {
  218. hasAlert: true,
  219. level: 'warning',
  220. levelText: '一般报警',
  221. alertType: '弱信号预警',
  222. alertParam: '信号强度',
  223. realData: `${signal}%`,
  224. threshold: '20%'
  225. }
  226. }
  227. return {
  228. hasAlert: false,
  229. level: '',
  230. levelText: '',
  231. alertType: '',
  232. alertParam: '',
  233. realData: '',
  234. threshold: '—'
  235. }
  236. }
  237. function normalizeAlert(raw, baseAlert = null) {
  238. const alertInfo = buildAlertInfo(raw)
  239. if (!alertInfo.hasAlert) return null
  240. return {
  241. id: raw?.equipmentId || raw?.equipmentCode,
  242. equipmentId: raw?.equipmentId || '',
  243. deviceCode: raw?.equipmentCode || '',
  244. deviceName: raw?.equipmentName || raw?.equipmentCode || '未命名设备',
  245. deviceType: raw?.equipmentTypeName || '井盖监测设备',
  246. location: raw?.equipmentLocation || '暂无位置信息',
  247. lng: raw?.longitude || baseAlert?.lng || '',
  248. lat: raw?.latitude || baseAlert?.lat || '',
  249. alertType: alertInfo.alertType,
  250. alertParam: alertInfo.alertParam,
  251. realData: alertInfo.realData,
  252. threshold: alertInfo.threshold,
  253. exceedValue: alertInfo.realData || '报警',
  254. level: alertInfo.level,
  255. levelText: alertInfo.levelText,
  256. time: formatDateTime(raw?.equipmentStatus?.statusUpdateTime || raw?.updateTime || raw?.installTime),
  257. status: raw?.equipmentStatus?.alarmStatus === 1 ? 'pending' : 'resolved',
  258. statusText: raw?.equipmentStatus?.alarmStatus === 1 ? '待处理' : '已恢复',
  259. raw
  260. }
  261. }
  262. async function loadAlerts() {
  263. listLoading.value = true
  264. try {
  265. const res = await getManholeAlarmDeviceList({
  266. alarmStatus: 1,
  267. isQueryManholeData: true
  268. })
  269. const list = Array.isArray(res?.data) ? res.data : []
  270. alerts.value = list
  271. .map(item => normalizeAlert(item))
  272. .filter(Boolean)
  273. if (!alerts.value.length) {
  274. selectedAlert.value = null
  275. return
  276. }
  277. const currentId = selectedAlert.value?.id
  278. selectedAlert.value = alerts.value.find(item => item.id === currentId) || alerts.value[0]
  279. if (selectedAlert.value?.equipmentId) {
  280. await loadAlertDetail(selectedAlert.value)
  281. }
  282. } catch (error) {
  283. alerts.value = []
  284. selectedAlert.value = null
  285. ElMessage.error('报警设备加载失败')
  286. } finally {
  287. listLoading.value = false
  288. }
  289. }
  290. async function loadAlertDetail(alert) {
  291. if (!alert?.equipmentId) return
  292. detailLoading.value = true
  293. try {
  294. const res = await getManholeAlarmDeviceDetail(alert.equipmentId)
  295. if (res?.data) {
  296. const merged = normalizeAlert(res.data, alert)
  297. if (merged) {
  298. const idx = alerts.value.findIndex(item => item.id === merged.id)
  299. if (idx > -1) {
  300. alerts.value[idx] = merged
  301. }
  302. selectedAlert.value = merged
  303. }
  304. }
  305. } catch (error) {
  306. ElMessage.error('报警详情加载失败')
  307. } finally {
  308. detailLoading.value = false
  309. }
  310. }
  311. const filteredAlerts = computed(() => {
  312. let list = alerts.value
  313. if (filterType.value === 'critical') {
  314. list = list.filter(a => a.level === 'critical')
  315. } else if (filterType.value === 'warning') {
  316. list = list.filter(a => a.level === 'warning')
  317. }
  318. if (searchText.value) {
  319. const kw = searchText.value.toLowerCase()
  320. list = list.filter(a =>
  321. `${a.location || ''}`.toLowerCase().includes(kw) ||
  322. `${a.deviceCode || ''}`.toLowerCase().includes(kw) ||
  323. `${a.deviceName || ''}`.toLowerCase().includes(kw)
  324. )
  325. }
  326. return list
  327. })
  328. const totalAlerts = computed(() => alerts.value.length)
  329. const criticalCount = computed(() => alerts.value.filter(a => a.level === 'critical').length)
  330. const warningCount = computed(() => alerts.value.filter(a => a.level === 'warning').length)
  331. const unhandledCount = computed(() => alerts.value.length)
  332. const hasAlertCoordinate = computed(() => isValidCoordinate(toNumber(selectedAlert.value?.lng), toNumber(selectedAlert.value?.lat)))
  333. const filterAlerts = (type) => {
  334. filterType.value = type
  335. }
  336. const selectAlert = async (alert) => {
  337. selectedAlert.value = alert
  338. await loadAlertDetail(alert)
  339. }
  340. const viewOnMap = () => {
  341. mapDialogVisible.value = true
  342. }
  343. function clearDialogMapMarker() {
  344. if (!dialogMapInstance || !dialogMapOverlays.length) return
  345. dialogMapOverlays.forEach((overlay) => {
  346. try {
  347. dialogMapInstance.removeOverlay(overlay)
  348. } catch (_) {}
  349. })
  350. dialogMapOverlays = []
  351. }
  352. function getDialogInfoWindowHtml() {
  353. const levelTagColor = selectedAlert.value?.level === 'critical' ? '#f56c6c' : '#e6a23c'
  354. const levelText = selectedAlert.value?.levelText || '报警'
  355. return `
  356. <div style="min-width:220px;max-width:280px;padding:4px 2px;line-height:1.7;color:#303133;">
  357. <div style="font-size:14px;font-weight:700;color:#1f2937;margin-bottom:6px;">
  358. ${selectedAlert.value?.deviceName || '报警设备'}
  359. </div>
  360. <div style="margin-bottom:4px;">
  361. <span style="display:inline-block;padding:1px 8px;border-radius:999px;background:${levelTagColor};color:#fff;font-size:12px;">
  362. ${levelText}
  363. </span>
  364. </div>
  365. <div style="font-size:12px;"><span style="color:#909399;">报警类型:</span>${selectedAlert.value?.alertType || '—'}</div>
  366. <div style="font-size:12px;"><span style="color:#909399;">报警时间:</span>${selectedAlert.value?.time || '—'}</div>
  367. <div style="font-size:12px;"><span style="color:#909399;">实时数据:</span>${selectedAlert.value?.realData || selectedAlert.value?.exceedValue || '—'}</div>
  368. <div style="font-size:12px;"><span style="color:#909399;">阈值:</span>${selectedAlert.value?.threshold || '—'}</div>
  369. <div style="font-size:12px;"><span style="color:#909399;">报警位置:</span>${selectedAlert.value?.location || '—'}</div>
  370. </div>
  371. `
  372. }
  373. function ensureBaiduMap() {
  374. if (window.BMapGL) {
  375. return Promise.resolve(window.BMapGL)
  376. }
  377. if (baiduMapScriptPromise) {
  378. return baiduMapScriptPromise
  379. }
  380. if (!BAIDU_MAP_AK) {
  381. return Promise.reject(new Error('未配置百度地图 AK,请在 .env.development 中配置 VITE_BAIDU_MAP_AK'))
  382. }
  383. baiduMapScriptPromise = new Promise((resolve, reject) => {
  384. const existing = document.querySelector('script[data-baidu-map="webgl"]')
  385. if (existing) {
  386. let attempts = 0
  387. const timer = window.setInterval(() => {
  388. attempts++
  389. if (window.BMapGL) {
  390. window.clearInterval(timer)
  391. resolve(window.BMapGL)
  392. } else if (attempts >= 50) {
  393. window.clearInterval(timer)
  394. reject(new Error('百度地图脚本加载超时'))
  395. }
  396. }, 200)
  397. return
  398. }
  399. const script = document.createElement('script')
  400. script.src = `https://api.map.baidu.com/api?type=webgl&v=1.0&ak=${BAIDU_MAP_AK}`
  401. script.async = true
  402. script.defer = true
  403. script.setAttribute('data-baidu-map', 'webgl')
  404. script.onload = () => {
  405. let attempts = 0
  406. const timer = window.setInterval(() => {
  407. attempts++
  408. if (window.BMapGL) {
  409. window.clearInterval(timer)
  410. resolve(window.BMapGL)
  411. } else if (attempts >= 50) {
  412. window.clearInterval(timer)
  413. reject(new Error('百度地图脚本初始化超时'))
  414. }
  415. }, 200)
  416. }
  417. script.onerror = () => reject(new Error('百度地图脚本加载失败'))
  418. document.head.appendChild(script)
  419. })
  420. return baiduMapScriptPromise
  421. }
  422. function renderDialogMapMarker() {
  423. if (!dialogMapInstance || !window.BMapGL) return
  424. clearDialogMapMarker()
  425. const lng = toNumber(selectedAlert.value?.lng)
  426. const lat = toNumber(selectedAlert.value?.lat)
  427. if (!isValidCoordinate(lng, lat)) {
  428. dialogMapInstance.centerAndZoom(new window.BMapGL.Point(DEFAULT_MAP_CENTER.lng, DEFAULT_MAP_CENTER.lat), 13)
  429. return
  430. }
  431. const point = new window.BMapGL.Point(lng, lat)
  432. dialogMapInstance.centerAndZoom(point, 15)
  433. const marker = new window.BMapGL.Marker(point)
  434. const infoWindow = new window.BMapGL.InfoWindow(getDialogInfoWindowHtml(), {
  435. width: 260,
  436. title: '报警信息'
  437. })
  438. dialogMapInstance.addOverlay(marker)
  439. dialogMapOverlays.push(marker)
  440. marker.addEventListener('click', () => {
  441. dialogMapInstance.openInfoWindow(infoWindow, point)
  442. })
  443. const label = new window.BMapGL.Label(
  444. `<div style="padding:4px 8px;border-radius:999px;background:rgba(20,33,61,0.82);color:#fff;font-size:12px;line-height:1.2;white-space:nowrap;">${selectedAlert.value?.deviceName || '报警设备'}</div>`,
  445. {
  446. position: point,
  447. offset: new window.BMapGL.Size(-30, -40)
  448. }
  449. )
  450. label.setStyle({
  451. border: 'none',
  452. background: 'transparent',
  453. padding: '0'
  454. })
  455. label.addEventListener('click', () => {
  456. dialogMapInstance.openInfoWindow(infoWindow, point)
  457. })
  458. dialogMapInstance.addOverlay(label)
  459. dialogMapOverlays.push(label)
  460. dialogMapInstance.openInfoWindow(infoWindow, point)
  461. }
  462. async function initDialogMap() {
  463. if (!dialogMapRef.value) return
  464. try {
  465. await ensureBaiduMap()
  466. if (!dialogMapInstance) {
  467. dialogMapInstance = new window.BMapGL.Map(dialogMapRef.value, { enableMapClick: true })
  468. dialogMapInstance.enableScrollWheelZoom(true)
  469. dialogMapInstance.centerAndZoom(new window.BMapGL.Point(DEFAULT_MAP_CENTER.lng, DEFAULT_MAP_CENTER.lat), 13)
  470. }
  471. dialogMapError.value = ''
  472. renderDialogMapMarker()
  473. } catch (error) {
  474. dialogMapError.value = error?.message || '百度地图加载失败'
  475. }
  476. }
  477. async function handleMapDialogOpened() {
  478. await nextTick()
  479. await initDialogMap()
  480. }
  481. watch(
  482. () => selectedAlert.value?.id,
  483. async () => {
  484. if (!mapDialogVisible.value) return
  485. await nextTick()
  486. renderDialogMapMarker()
  487. }
  488. )
  489. onMounted(async () => {
  490. await loadAlerts()
  491. })
  492. onUnmounted(() => {
  493. clearDialogMapMarker()
  494. dialogMapInstance = null
  495. })
  496. </script>
  497. <style scoped>
  498. .alert-dashboard {
  499. background: #f0f2f6;
  500. min-height: 100vh;
  501. padding: 20px;
  502. }
  503. .alert-header {
  504. background: white;
  505. border-radius: 20px;
  506. padding: 16px 24px;
  507. margin-bottom: 20px;
  508. box-shadow: 0 2px 8px rgba(0,0,0,0.04);
  509. }
  510. .title-badge {
  511. display: flex;
  512. align-items: center;
  513. gap: 16px;
  514. margin-bottom: 16px;
  515. }
  516. .blink-icon {
  517. font-size: 28px;
  518. color: #f56c6c;
  519. animation: blink 1s infinite;
  520. }
  521. @keyframes blink { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }
  522. .title { font-size: 22px; font-weight: 700; }
  523. .stats-cards { display: flex; gap: 20px; margin-top: 6px; }
  524. .stat-item { background: #f8fafc; padding: 10px 24px; border-radius: 16px; cursor: pointer; text-align: center; transition: all 0.2s; min-width: 100px; }
  525. .stat-item.active { background: #ecf5ff; border: 1px solid #409eff; }
  526. .stat-num { font-size: 28px; font-weight: bold; }
  527. .stat-item.critical .stat-num { color: #f56c6c; }
  528. .stat-item.warning .stat-num { color: #e6a23c; }
  529. .main-content { display: flex; gap: 20px; }
  530. .alert-list-panel {
  531. width: 400px;
  532. background: white;
  533. border-radius: 20px;
  534. padding: 16px;
  535. display: flex;
  536. flex-direction: column;
  537. }
  538. .panel-header { display: flex; justify-content: space-between; margin-bottom: 16px; font-weight: 600; }
  539. .alert-list { max-height: calc(100vh - 200px); overflow-y: auto; }
  540. .alert-item {
  541. display: flex;
  542. align-items: flex-start;
  543. gap: 12px;
  544. padding: 14px;
  545. margin-bottom: 12px;
  546. border-radius: 14px;
  547. background: #fafbfc;
  548. cursor: pointer;
  549. border: 1px solid #e9ecef;
  550. transition: all 0.2s;
  551. }
  552. .alert-item.selected { background: #ecf5ff; border-color: #409eff; }
  553. .alert-item.critical { border-left: 4px solid #f56c6c; }
  554. .alert-item.warning { border-left: 4px solid #e6a23c; }
  555. .alert-level-icon { font-size: 22px; margin-top: 4px; }
  556. .alert-content { flex: 1; }
  557. .alert-title { display: flex; justify-content: space-between; font-weight: 600; margin-bottom: 4px; }
  558. .alert-time { font-size: 11px; color: #909399; }
  559. .alert-location { font-size: 12px; color: #606266; display: flex; align-items: center; gap: 4px; margin: 4px 0; }
  560. .alert-type { font-size: 12px; color: #409eff; }
  561. .alert-value { font-size: 12px; margin-top: 6px; }
  562. .exceed { color: #f56c6c; }
  563. .detail-panel {
  564. flex: 1;
  565. background: white;
  566. border-radius: 20px;
  567. padding: 20px;
  568. overflow-y: auto;
  569. max-height: calc(100vh - 150px);
  570. }
  571. .detail-section {
  572. margin-bottom: 24px;
  573. border-bottom: 1px solid #f0f0f0;
  574. padding-bottom: 20px;
  575. }
  576. .section-title {
  577. display: flex;
  578. justify-content: space-between;
  579. align-items: center;
  580. margin-bottom: 16px;
  581. font-weight: 600;
  582. font-size: 16px;
  583. }
  584. .real-time-data { background: #fff9f0; padding: 8px; border-radius: 8px; }
  585. .warning-value { color: #f56c6c; font-size: 18px; margin: 0 4px; }
  586. .action-buttons { display: flex; gap: 8px; }
  587. .empty-detail { flex: 1; display: flex; align-items: center; justify-content: center; background: white; border-radius: 20px; }
  588. .dialog-map-wrapper {
  589. position: relative;
  590. }
  591. .dialog-baidu-map {
  592. height: 420px;
  593. border-radius: 12px;
  594. overflow: hidden;
  595. background: #eef2f0;
  596. }
  597. .dialog-map-footer {
  598. padding-top: 12px;
  599. font-size: 13px;
  600. color: #606266;
  601. line-height: 1.8;
  602. }
  603. .dialog-map-empty {
  604. position: absolute;
  605. top: 16px;
  606. left: 16px;
  607. background: rgba(255, 255, 255, 0.94);
  608. color: #909399;
  609. font-size: 12px;
  610. padding: 8px 12px;
  611. border-radius: 10px;
  612. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  613. z-index: 2;
  614. }
  615. .dialog-map-error {
  616. position: absolute;
  617. top: 16px;
  618. right: 16px;
  619. max-width: 280px;
  620. background: rgba(255, 255, 255, 0.95);
  621. color: #f56c6c;
  622. border: 1px solid rgba(245, 108, 108, 0.25);
  623. border-radius: 10px;
  624. padding: 8px 12px;
  625. font-size: 12px;
  626. line-height: 1.6;
  627. z-index: 2;
  628. }
  629. </style>