krqindbj.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. <!--场站可燃气体浓度报警-->
  2. <template>
  3. <div class="methane-alert-center">
  4. <!-- 页面头部 -->
  5. <div class="page-header">
  6. <div class="header-left">
  7. <el-icon><Warning /></el-icon>
  8. <span class="title">可燃气体中心</span>
  9. <div class="stats-badge">
  10. <span class="stat-item critical"><i class="dot"></i>报警设备 {{ alarmStats.realtimeCount }}</span>
  11. <span class="stat-item warning"><i class="dot"></i>设备总数 {{ equipments.length }}</span>
  12. <span class="stat-item"><i class="dot"></i>最高浓度 {{ alarmStats.maxConcentration }}ppm</span>
  13. </div>
  14. </div>
  15. <div class="header-right">
  16. <el-date-picker
  17. v-model="dateRange"
  18. type="daterange"
  19. range-separator="至"
  20. start-placeholder="开始日期"
  21. end-placeholder="结束日期"
  22. style="width: 260px"
  23. size="default"
  24. />
  25. <el-select v-model="companyFilter" placeholder="燃气公司" clearable style="width: 140px" size="default">
  26. <el-option label="沅陵燃气" value="yuanling" />
  27. </el-select>
  28. <el-button type="primary" :icon="Refresh" @click="refreshData">刷新</el-button>
  29. <el-button :icon="Download" @click="exportReport">导出报表</el-button>
  30. </div>
  31. </div>
  32. <!-- KPI指标卡片 -->
  33. <div class="kpi-cards">
  34. <div class="kpi-card critical">
  35. <div class="kpi-value">{{ alarmStats.realtimeCount }}</div>
  36. <div class="kpi-label">实时报警</div>
  37. <div class="kpi-trend">较昨日 ↑{{ realtimeTrend }}</div>
  38. </div>
  39. <div class="kpi-card warning">
  40. <div class="kpi-value">{{ todayHistoryCount }}</div>
  41. <div class="kpi-label">今日历史报警</div>
  42. <div class="kpi-trend">待处理 {{ pendingCount }}</div>
  43. </div>
  44. <div class="kpi-card">
  45. <div class="kpi-value">{{ maxConcentration }}<span class="unit">ppm</span></div>
  46. <div class="kpi-label">最高浓度</div>
  47. <div class="kpi-trend">点位: {{ maxConcentrationPoint }}</div>
  48. </div>
  49. <div class="kpi-card">
  50. <div class="kpi-value">{{ avgResponseTime }}</div>
  51. <div class="kpi-label">平均响应时间(分钟)</div>
  52. </div>
  53. </div>
  54. <!-- 主内容区:左侧实时报警 + 右侧历史报警 -->
  55. <div class="main-layout">
  56. <!-- 左侧设备列表 -->
  57. <div class="alert-panel">
  58. <div class="panel-header">
  59. <span><el-icon><BellFilled /></el-icon> 探测器设备</span>
  60. <el-tag type="danger" effect="dark">{{ alarmStats.realtimeCount }} 个报警</el-tag>
  61. </div>
  62. <div class="alert-list realtime">
  63. <div
  64. v-for="eq in equipments"
  65. :key="eq.equipmentId"
  66. class="alert-item"
  67. :class="{ critical: eq.level === 'critical', warning: eq.level === 'warning', selected: selectedEquipment?.equipmentId === eq.equipmentId }"
  68. @click="selectEquipment(eq)"
  69. >
  70. <div class="alert-level">
  71. <el-tag :type="eq.level === 'critical' ? 'danger' : eq.level === 'warning' ? 'warning' : 'info'" size="small">
  72. {{ eq.level === 'critical' ? '严重' : eq.level === 'warning' ? '预警' : '正常' }}
  73. </el-tag>
  74. </div>
  75. <div class="alert-content">
  76. <div class="alert-title">{{ eq.equipmentName }}</div>
  77. <div class="alert-company">
  78. <el-icon><OfficeBuilding /></el-icon> 沅陵燃气
  79. </div>
  80. <div class="alert-location">
  81. <el-icon><Location /></el-icon> {{ eq.location }}
  82. </div>
  83. <div class="alert-data">
  84. <span class="data-item" v-if="eq.latestConcentration != null">甲烷浓度: <strong :class="{ 'critical-value': eq.latestConcentration > 30 }">{{ eq.latestConcentration }} ppm</strong></span>
  85. <span class="data-item" v-if="eq.latestThreshold != null">阈值: {{ eq.latestThreshold }} ppm</span>
  86. <span class="data-item" v-if="eq.latestConcentration == null" style="color:#67c23a">无报警记录</span>
  87. </div>
  88. <div class="alert-time" v-if="eq.alertTime">{{ eq.alertTime }}</div>
  89. </div>
  90. </div>
  91. <div v-if="equipments.length === 0" class="empty-state">
  92. <el-empty description="暂无探测器设备" :image-size="80" />
  93. </div>
  94. </div>
  95. </div>
  96. <!-- 右侧历史报警列表 -->
  97. <div class="history-panel">
  98. <div class="panel-header">
  99. <span><el-icon><Document /></el-icon> 历史报警</span>
  100. <el-input
  101. v-model="historySearch"
  102. placeholder="搜索场站/地址"
  103. clearable
  104. :prefix-icon="Search"
  105. style="width: 180px"
  106. size="small"
  107. />
  108. </div>
  109. <div class="alert-list history">
  110. <el-table :data="paginatedHistory" stripe border style="width: 100%" @row-click="viewAlertDetail">
  111. <el-table-column prop="stationName" label="场站名称" min-width="140" show-overflow-tooltip />
  112. <el-table-column prop="companyName" label="燃气公司" width="100" />
  113. <el-table-column prop="address" label="位置" min-width="150" show-overflow-tooltip />
  114. <el-table-column prop="concentration" label="甲烷浓度(ppm)" width="120" sortable>
  115. <template #default="{ row }">
  116. <span :class="{ 'critical-value': row.concentration > 30 }">{{ row.concentration }}</span>
  117. </template>
  118. </el-table-column>
  119. <el-table-column prop="threshold" label="阈值(ppm)" width="90" />
  120. <el-table-column prop="level" label="级别" width="80" align="center">
  121. <template #default="{ row }">
  122. <el-tag :type="row.level === 'critical' ? 'danger' : 'warning'" size="small">
  123. {{ row.level === 'critical' ? '严重' : '预警' }}
  124. </el-tag>
  125. </template>
  126. </el-table-column>
  127. <el-table-column prop="alertTime" label="报警时间" width="150" sortable />
  128. <el-table-column prop="status" label="状态" width="80" align="center">
  129. <template #default="{ row }">
  130. <el-tag :type="row.status === 'resolved' ? 'success' : 'warning'" size="small">
  131. {{ row.status === 'resolved' ? '已处理' : '待处理' }}
  132. </el-tag>
  133. </template>
  134. </el-table-column>
  135. <el-table-column label="操作" width="80" fixed="right" align="center">
  136. <template #default="{ row }">
  137. <el-button link type="primary" size="small" @click.stop="viewAlertDetail(row)">详情</el-button>
  138. </template>
  139. </el-table-column>
  140. </el-table>
  141. </div>
  142. <div class="pagination-area">
  143. <el-pagination
  144. small
  145. layout="total, prev, pager, next"
  146. :total="historyTotal"
  147. :page-size="historyPageSize"
  148. v-model:current-page="historyPage"
  149. @current-change="loadHistory"
  150. />
  151. </div>
  152. </div>
  153. </div>
  154. <!-- 报警详情抽屉 -->
  155. <el-drawer v-model="detailDrawerVisible" :title="`报警详情 - ${currentAlert?.stationName}`" size="480px" direction="rtl" @opened="onDrawerOpened" class="detail-drawer">
  156. <div v-if="currentAlert" class="alert-detail">
  157. <el-descriptions :column="1" border>
  158. <el-descriptions-item label="场站名称">{{ currentAlert.stationName }}</el-descriptions-item>
  159. <el-descriptions-item label="场站编号">{{ currentAlert.stationCode }}</el-descriptions-item>
  160. <el-descriptions-item label="燃气公司">{{ currentAlert.companyName }}</el-descriptions-item>
  161. <el-descriptions-item label="详细地址">{{ currentAlert.address }}</el-descriptions-item>
  162. <el-descriptions-item label="报警级别">
  163. <el-tag :type="currentAlert.level === 'critical' ? 'danger' : 'warning'">
  164. {{ currentAlert.level === 'critical' ? '严重报警' : '预警' }}
  165. </el-tag>
  166. </el-descriptions-item>
  167. <el-descriptions-item label="甲烷浓度">
  168. <span :class="{ 'critical-value': currentAlert.concentration > 30 }">{{ currentAlert.concentration }} ppm</span>
  169. </el-descriptions-item>
  170. <el-descriptions-item label="报警阈值">{{ currentAlert.threshold }} ppm</el-descriptions-item>
  171. <el-descriptions-item label="报警时间">{{ currentAlert.alertTime }}</el-descriptions-item>
  172. <el-descriptions-item label="处理状态">
  173. <el-tag :type="currentAlert.status === 'resolved' ? 'success' : 'danger'">
  174. {{ currentAlert.status === 'resolved' ? '已处理' : '待处理' }}
  175. </el-tag>
  176. </el-descriptions-item>
  177. <el-descriptions-item v-if="currentAlert.resolveTime" label="处理时间">{{ currentAlert.resolveTime }}</el-descriptions-item>
  178. <el-descriptions-item label="处理人">{{ currentAlert.handler || '—' }}</el-descriptions-item>
  179. <el-descriptions-item label="处理说明">{{ currentAlert.remark || '—' }}</el-descriptions-item>
  180. </el-descriptions>
  181. <!-- 浓度趋势图 -->
  182. <div class="trend-section">
  183. <div class="section-title">甲烷浓度趋势(最近1小时)</div>
  184. <div ref="chartRef" class="trend-chart"></div>
  185. </div>
  186. <!-- 处理操作 -->
  187. <div class="action-section" v-if="currentAlert.status !== 'resolved'">
  188. <el-button type="primary" @click="resolveAlert">标记已处理</el-button>
  189. <el-button type="warning" @click="dispatchWorkOrder">派发现场工单</el-button>
  190. </div>
  191. </div>
  192. </el-drawer>
  193. </div>
  194. </template>
  195. <script setup>
  196. import { ref, computed, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
  197. import { Warning, Refresh, Download, BellFilled, Document, Location, Search, OfficeBuilding } from '@element-plus/icons-vue'
  198. import * as echarts from 'echarts'
  199. import { ElMessage } from 'element-plus'
  200. import { getGasAlarmEquipment, getGasAlarmHistory, getGasAlarmStats, resolveGasAlarm, dispatchGasAlarm } from '@/api/pipeNetwork/basic'
  201. // 左侧: 所有探测器设备(去重)
  202. const equipments = ref([])
  203. const selectedEquipment = ref(null)
  204. // 右侧: 历史报警
  205. const historyAlerts = ref([])
  206. const historyPage = ref(1)
  207. const historyPageSize = 8
  208. const historyTotal = ref(0)
  209. const alarmStats = ref({ realtimeCount: 0, pendingCount: 0, maxConcentration: 0, totalStations: 0 })
  210. async function loadEquipment() {
  211. try {
  212. const res = await getGasAlarmEquipment()
  213. equipments.value = (res.data || []).map(e => ({
  214. ...e,
  215. stationName: e.equipmentName,
  216. concentration: e.latestConcentration,
  217. threshold: e.latestThreshold || 20,
  218. alertTime: e.latestAlarmTime,
  219. level: e.hasAlarm ? (e.latestAlarmLevel === 1 ? 'critical' : 'warning') : 'normal',
  220. status: e.hasAlarm ? 'pending' : 'normal'
  221. }))
  222. } catch (e) { console.error(e) }
  223. }
  224. const currentDeviceCode = ref('')
  225. async function loadHistory() {
  226. try {
  227. const d = dateRange.value
  228. const res = await getGasAlarmHistory(historyPage.value, historyPageSize, d?.[0] || '', d?.[1] || '', currentDeviceCode.value || '')
  229. historyAlerts.value = (res.data?.records || []).map(mapAlarm)
  230. historyTotal.value = res.data?.total || 0
  231. } catch (e) { console.error(e) }
  232. }
  233. function selectEquipment(eq) {
  234. selectedEquipment.value = eq
  235. currentDeviceCode.value = eq.equipmentCode
  236. historyPage.value = 1
  237. loadHistory()
  238. }
  239. function mapAlarm(a) {
  240. const lv = (a.alarmLevel === 1 || a.alarmLevelText === '严重') ? 'critical' : 'warning'
  241. const status = (a.alarmStatus === 0 || a.statusText === '待处理') ? 'pending' : 'resolved'
  242. return {
  243. id: a.id, deviceCode: a.deviceCode,
  244. stationName: a.stationName || a.deviceCode,
  245. companyName: '沅陵燃气', companyId: 'yuanling',
  246. address: a.address || '',
  247. concentration: a.concentration || a.actualValue || 0,
  248. threshold: a.threshold || a.maxValue || 20,
  249. level: lv, alertTime: a.alarmTime || '', status,
  250. resolveTime: a.resolveTime || a.handleTime || null,
  251. handler: a.handler || a.handleUser || null,
  252. remark: a.remark || a.handleRemark || null
  253. }
  254. }
  255. const realtimeTrend = 1
  256. const todayHistoryCount = computed(() => equipments.value.filter(e => e.alertTime && e.alertTime.startsWith(new Date().toISOString().substring(0,10))).length)
  257. const pendingCount = computed(() => alarmStats.value.pendingCount)
  258. const maxConcentration = computed(() => alarmStats.value.maxConcentration)
  259. const maxConcentrationPoint = computed(() => alarmStats.value.maxConcentrationPoint || '—')
  260. const avgResponseTime = computed(() => '--')
  261. // 筛选
  262. const dateRange = ref([])
  263. const companyFilter = ref('')
  264. const historySearch = ref('')
  265. const paginatedHistory = computed(() => historyAlerts.value)
  266. // 当前选中报警
  267. const currentAlert = ref(null)
  268. const detailDrawerVisible = ref(false)
  269. const chartRef = ref(null)
  270. let chartInstance = null
  271. function renderChart() {
  272. if (!chartRef.value || !currentAlert.value?.trendData) return
  273. if (!chartInstance) {
  274. chartInstance = echarts.init(chartRef.value)
  275. }
  276. const times = Array.from({ length: 24 }, (_, i) => `${i}:00`)
  277. const threshold = currentAlert.value.threshold
  278. chartInstance.setOption({
  279. tooltip: { trigger: 'axis' },
  280. xAxis: { type: 'category', data: times, axisLabel: { rotate: 45, interval: 4 } },
  281. yAxis: { type: 'value', name: '甲烷浓度 (ppm)' },
  282. series: [
  283. { name: '甲烷浓度', type: 'line', data: currentAlert.value.trendData, smooth: true, lineStyle: { color: '#f56c6c', width: 2 }, areaStyle: { opacity: 0.1 } },
  284. { name: '报警阈值', type: 'line', data: Array(24).fill(threshold), lineStyle: { color: '#e6a23c', width: 2, type: 'dashed' }, symbol: 'none' }
  285. ]
  286. })
  287. }
  288. function onDrawerOpened() {
  289. nextTick(() => renderChart())
  290. }
  291. // 方法
  292. const viewAlertDetail = (alert) => {
  293. currentAlert.value = alert
  294. detailDrawerVisible.value = true
  295. }
  296. const handleAlert = (alert) => {
  297. currentAlert.value = alert
  298. detailDrawerVisible.value = true
  299. }
  300. const resolveAlert = async () => {
  301. if (currentAlert.value?.status === 'pending') {
  302. try {
  303. await resolveGasAlarm(currentAlert.value.id)
  304. currentAlert.value.status = 'resolved'
  305. ElMessage.success('已标记为已处理')
  306. detailDrawerVisible.value = false
  307. loadEquipment(); loadHistory(); loadStats()
  308. } catch (e) { ElMessage.error('处理失败') }
  309. }
  310. }
  311. const dispatchWorkOrder = async () => {
  312. if (currentAlert.value?.id) {
  313. try {
  314. await dispatchGasAlarm(currentAlert.value.id)
  315. ElMessage.success('已派发现场处置工单(伪)')
  316. } catch (e) { ElMessage.error('派发失败') }
  317. }
  318. }
  319. async function loadStats() {
  320. try {
  321. const res = await getGasAlarmStats()
  322. if (res.data) {
  323. alarmStats.value = res.data
  324. totalStations.value = res.data.totalStations || 0
  325. }
  326. } catch (e) { console.error(e) }
  327. }
  328. onMounted(async () => {
  329. await loadEquipment()
  330. await loadHistory()
  331. await loadStats()
  332. })
  333. const refreshData = () => {
  334. ElMessage.success('数据已刷新')
  335. }
  336. const exportReport = () => {
  337. ElMessage.success('导出报表(演示)')
  338. }
  339. // 日期范围变化重载历史
  340. watch(dateRange, () => { historyPage.value = 1; loadHistory() })
  341. onBeforeUnmount(() => {
  342. try { chartInstance?.dispose() } catch {}
  343. chartInstance = null
  344. })
  345. </script>
  346. <style scoped>
  347. .methane-alert-center {
  348. background: #f0f2f6;
  349. min-height: 100vh;
  350. padding: 20px;
  351. }
  352. .page-header {
  353. background: white;
  354. border-radius: 16px;
  355. padding: 16px 24px;
  356. margin-bottom: 20px;
  357. display: flex;
  358. justify-content: space-between;
  359. align-items: center;
  360. flex-wrap: wrap;
  361. gap: 12px;
  362. }
  363. .header-left { display: flex; align-items: center; gap: 20px; }
  364. .header-left .title { font-size: 1.3rem; font-weight: 600; }
  365. .stats-badge { display: flex; gap: 20px; }
  366. .stat-item { font-size: 13px; display: flex; align-items: center; gap: 6px; }
  367. .stat-item.critical { color: #f56c6c; }
  368. .stat-item.warning { color: #e6a23c; }
  369. .stat-item .dot { width: 8px; height: 8px; border-radius: 50%; background: #67c23a; }
  370. .stat-item.critical .dot { background: #f56c6c; }
  371. .stat-item.warning .dot { background: #e6a23c; }
  372. .header-right { display: flex; gap: 12px; flex-wrap: wrap; }
  373. .kpi-cards {
  374. display: grid;
  375. grid-template-columns: repeat(4, 1fr);
  376. gap: 16px;
  377. margin-bottom: 20px;
  378. }
  379. .kpi-card {
  380. background: white;
  381. border-radius: 12px;
  382. padding: 16px;
  383. text-align: center;
  384. }
  385. .kpi-card.critical .kpi-value { color: #f56c6c; }
  386. .kpi-card.warning .kpi-value { color: #e6a23c; }
  387. .kpi-value { font-size: 32px; font-weight: bold; }
  388. .kpi-value .unit { font-size: 14px; font-weight: normal; }
  389. .kpi-label { font-size: 13px; color: #909399; margin-top: 4px; }
  390. .kpi-trend { font-size: 11px; color: #67c23a; margin-top: 4px; }
  391. .main-layout {
  392. display: flex;
  393. gap: 20px;
  394. }
  395. .alert-panel {
  396. width: 420px;
  397. background: white;
  398. border-radius: 16px;
  399. display: flex;
  400. flex-direction: column;
  401. overflow: hidden;
  402. }
  403. .history-panel {
  404. flex: 1;
  405. background: white;
  406. border-radius: 16px;
  407. display: flex;
  408. flex-direction: column;
  409. overflow: hidden;
  410. }
  411. .panel-header {
  412. padding: 16px;
  413. border-bottom: 1px solid #e9ecef;
  414. display: flex;
  415. justify-content: space-between;
  416. align-items: center;
  417. font-weight: 600;
  418. }
  419. .alert-list {
  420. flex: 1;
  421. overflow-y: auto;
  422. padding: 12px;
  423. }
  424. .alert-item {
  425. display: flex;
  426. gap: 12px;
  427. padding: 14px;
  428. margin-bottom: 12px;
  429. border-radius: 12px;
  430. cursor: pointer;
  431. transition: all 0.2s;
  432. }
  433. .alert-item.critical { background: #fff8f8; border: 1px solid #ffd4d4; }
  434. .alert-item.warning { background: #fffef5; border: 1px solid #ffe4b5; }
  435. .alert-item.selected { background: #ecf5ff; border: 2px solid #409eff; }
  436. .alert-item:hover { transform: translateX(2px); box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
  437. .alert-level { flex-shrink: 0; }
  438. .alert-content { flex: 1; }
  439. .alert-title { font-weight: 600; margin-bottom: 6px; }
  440. .alert-company { font-size: 12px; color: #409eff; display: flex; align-items: center; gap: 4px; margin-bottom: 4px; }
  441. .alert-location { font-size: 12px; color: #6c757d; display: flex; align-items: center; gap: 4px; margin-bottom: 6px; }
  442. .alert-data { display: flex; gap: 16px; font-size: 12px; margin-bottom: 6px; }
  443. .alert-data .data-item { background: #f5f5f5; padding: 2px 8px; border-radius: 12px; }
  444. .critical-value { color: #f56c6c; font-weight: bold; }
  445. .alert-time { font-size: 11px; color: #aaa; }
  446. .alert-action { display: flex; align-items: center; }
  447. .empty-state { padding: 40px 0; text-align: center; }
  448. .pagination-area { padding: 12px; text-align: center; border-top: 1px solid #eee; }
  449. .alert-detail { padding: 8px; }
  450. .section-title { font-weight: 600; margin: 16px 0 12px 0; }
  451. .trend-chart { height: 200px; }
  452. .action-section { margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end; }
  453. </style>