leakage.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. <template>
  2. <div class="monitor-page">
  3. <div class="page-heading">
  4. <div class="page-title">
  5. <span class="page-title-icon"><el-icon><Microphone /></el-icon></span>
  6. <div>
  7. <h2>漏失监测</h2>
  8. <span class="muted">噪声监测原始采集记录</span>
  9. </div>
  10. </div>
  11. <div class="head-actions">
  12. <el-button class="heading-refresh" :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
  13. </div>
  14. </div>
  15. <div class="kpis">
  16. <div class="card kpi">
  17. <div class="k-top">
  18. <span class="k-label">7 日平均特征值</span>
  19. <span class="k-icon" style="background:#e6f7f4;color:#0fb5a6"><el-icon><DataLine /></el-icon></span>
  20. </div>
  21. <div class="k-val">{{ fmt(overview.avgValue) }}</div>
  22. <div class="k-foot">最近 7 天全网特征值均值</div>
  23. </div>
  24. <div class="card kpi">
  25. <div class="k-top">
  26. <span class="k-label">7 日峰值特征值</span>
  27. <span class="k-icon" style="background:#fff3e6;color:#f59e0b"><el-icon><Top /></el-icon></span>
  28. </div>
  29. <div class="k-val">{{ fmt(overview.peakValue) }}</div>
  30. <div class="k-foot">最近 7 天单小时峰值</div>
  31. </div>
  32. </div>
  33. <el-card shadow="never" class="filter-card">
  34. <template #header>
  35. <div class="card-title">
  36. <el-icon><Search /></el-icon>
  37. <span>查询条件</span>
  38. </div>
  39. </template>
  40. <el-form :inline="true" :model="query" @submit.prevent="loadData">
  41. <el-form-item label="设备信息">
  42. <el-input v-model="query.encode" clearable placeholder="请输入设备编码/名称" style="width: 200px" />
  43. </el-form-item>
  44. <el-form-item>
  45. <el-button type="primary" :icon="Search" :loading="loading" @click="loadData">查询</el-button>
  46. <el-button :icon="RefreshLeft" @click="resetQuery">重置</el-button>
  47. </el-form-item>
  48. </el-form>
  49. </el-card>
  50. <el-alert
  51. v-if="loadError"
  52. class="page-error"
  53. type="error"
  54. show-icon
  55. :closable="false"
  56. title="数据加载失败"
  57. description="请检查后端服务或网络连接后重试。"
  58. >
  59. <el-button size="small" type="primary" @click="loadData">重试</el-button>
  60. </el-alert>
  61. <el-card shadow="never" class="table-card">
  62. <template #header>
  63. <div class="table-toolbar">
  64. <div class="card-title">
  65. <el-icon><List /></el-icon>
  66. <span>漏失监测采集记录</span>
  67. </div>
  68. <el-button type="primary" plain :icon="Download" :loading="exporting" size="small" @click="handleExport">导出 Excel</el-button>
  69. </div>
  70. </template>
  71. <el-table :data="records" stripe border v-loading="loading" empty-text="暂无符合条件的采集记录">
  72. <el-table-column prop="encode" label="设备编码" min-width="150" show-overflow-tooltip />
  73. <el-table-column prop="deviceName" label="设备名称" min-width="180" show-overflow-tooltip />
  74. <el-table-column prop="deviceType" label="设备类型" min-width="140" show-overflow-tooltip />
  75. <el-table-column label="最新特征值" min-width="160">
  76. <template #default="{ row }">
  77. <span class="val-cell">{{ blank(row.rms) }}</span>
  78. </template>
  79. </el-table-column>
  80. <el-table-column label="音频" min-width="200">
  81. <template #default="{ row }">
  82. <span v-if="row.filepath" class="rec-audio-cell">
  83. <audio class="rec-audio" controls preload="none" :src="buildMinioAttachmentUrl(row.filepath)" @play="pauseOtherAudios" />
  84. </span>
  85. <span v-else>—</span>
  86. </template>
  87. </el-table-column>
  88. <el-table-column prop="createTime" label="最新采集时间" min-width="170" />
  89. <el-table-column label="操作" min-width="120" fixed="right">
  90. <template #default="{ row }">
  91. <div style="display: flex; justify-content: center; align-items: center; gap: 8px;">
  92. <el-tooltip content="查看历史" placement="top">
  93. <el-button link type="primary" :icon="Document" @click="openHistory(row)" />
  94. </el-tooltip>
  95. <el-tooltip content="7日趋势" placement="top">
  96. <el-button link type="primary" :icon="DataLine" @click="openTrend(row)" />
  97. </el-tooltip>
  98. </div>
  99. </template>
  100. </el-table-column>
  101. </el-table>
  102. <el-pagination
  103. v-model:current-page="query.pageNum"
  104. v-model:page-size="query.pageSize"
  105. :total="total"
  106. layout="total, sizes, prev, pager, next"
  107. :page-sizes="[10, 20, 50]"
  108. @change="loadData"
  109. />
  110. </el-card>
  111. <HistoryDialog
  112. v-model="historyVisible"
  113. title="漏失历史采集记录"
  114. :device-key="historyDeviceKey"
  115. device-key-field="encode"
  116. :api-fn="listLeakageRecords"
  117. :columns="historyColumns"
  118. :device-title="historyDeviceTitle"
  119. />
  120. <DeviceTrendDialog
  121. v-model="trendVisible"
  122. :title="trendTitle"
  123. :trend="trendData"
  124. unit="特征值"
  125. :series="trendSeries"
  126. :threshold-min="trendThresholdMin"
  127. :threshold-max="trendThresholdMax"
  128. />
  129. </div>
  130. </template>
  131. <script setup>
  132. import { ref } from 'vue'
  133. import { DataLine, Document, Download, List, Microphone, Refresh, RefreshLeft, Search, Top } from '@element-plus/icons-vue'
  134. import { ElMessage } from 'element-plus'
  135. import { getLeakageOverview, listLeakageRecords } from '@/api/pipeNetwork/waterSupply'
  136. import { exportTableToXlsx } from '@/utils/exportTable'
  137. import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
  138. import HistoryDialog from '../components/HistoryDialog.vue'
  139. import DeviceTrendDialog from '../components/DeviceTrendDialog.vue'
  140. const records = ref([])
  141. const total = ref(0)
  142. const loading = ref(false)
  143. const exporting = ref(false)
  144. const loadError = ref(false)
  145. const overview = ref({ avgValue: null, peakValue: null, thresholdMin: null, thresholdMax: null, trend: [], lastUpdate: '' })
  146. const query = ref(createQuery())
  147. function createQuery() {
  148. return { pageNum: 1, pageSize: 10, encode: '' }
  149. }
  150. function fmt(value) {
  151. return value == null ? '--' : Number(value).toFixed(3)
  152. }
  153. function buildParams() {
  154. return {
  155. pageNum: query.value.pageNum,
  156. pageSize: query.value.pageSize,
  157. encode: query.value.encode || undefined,
  158. latestOnly: true
  159. }
  160. }
  161. async function loadData() {
  162. loading.value = true
  163. loadError.value = false
  164. try {
  165. const res = await listLeakageRecords(buildParams())
  166. const page = (res && res.data) || res || {}
  167. records.value = Array.isArray(page.records) ? page.records : []
  168. total.value = Number(page.total) || records.value.length
  169. } catch (e) {
  170. loadError.value = true
  171. records.value = []
  172. total.value = 0
  173. } finally {
  174. loading.value = false
  175. }
  176. }
  177. async function loadOverview() {
  178. try {
  179. const res = await getLeakageOverview({})
  180. overview.value = Object.assign({ avgValue: null, peakValue: null, thresholdMin: null, thresholdMax: null, trend: [], lastUpdate: '' }, (res && res.data) || res || {})
  181. } catch (e) {
  182. console.warn('[leakage] 概览加载失败', e)
  183. }
  184. }
  185. function loadAll() {
  186. loadData()
  187. loadOverview()
  188. }
  189. const exportColumns = [
  190. { prop: 'encode', label: '设备编码' },
  191. { prop: 'deviceName', label: '设备名称' },
  192. { prop: 'deviceType', label: '设备类型' },
  193. { prop: 'rms', label: '最新特征值', format: (v) => blank(v) },
  194. { prop: 'createTime', label: '最新采集时间' }
  195. ]
  196. async function handleExport() {
  197. exporting.value = true
  198. try {
  199. const res = await listLeakageRecords({
  200. encode: query.value.encode || undefined,
  201. pageNum: 1,
  202. pageSize: 10000,
  203. latestOnly: true
  204. })
  205. const list = (res && res.data && res.data.records) || []
  206. if (!list.length) {
  207. ElMessage.warning('当前条件下没有可导出的记录')
  208. return
  209. }
  210. exportTableToXlsx({ rows: list, columns: exportColumns, filename: '漏失监测记录.xlsx', sheetName: '漏失监测' })
  211. ElMessage.success(`已导出 ${list.length} 条记录`)
  212. } catch (e) {
  213. console.warn('[leakage] 导出失败', e)
  214. ElMessage.error('导出失败,请稍后重试')
  215. } finally {
  216. exporting.value = false
  217. }
  218. }
  219. const historyVisible = ref(false)
  220. const historyDeviceKey = ref('')
  221. const historyDeviceTitle = ref('')
  222. const historyColumns = [
  223. { prop: 'rms', label: '特征值', width: 160, format: (v) => blank(v) },
  224. { prop: 'createTime', label: '采集时间', width: 170 }
  225. ]
  226. const trendSeries = [
  227. { name: '日平均特征值', key: 'avg', color: '#1b6ef3', area: true },
  228. { name: '日内峰值', key: 'peak', color: '#a9c6fb', dashed: true }
  229. ]
  230. function openHistory(row) {
  231. historyDeviceKey.value = row.encode
  232. historyDeviceTitle.value = `${row.encode}.${row.deviceName || row.encode}`
  233. historyVisible.value = true
  234. }
  235. const trendVisible = ref(false)
  236. const trendTitle = ref('')
  237. const trendData = ref([])
  238. const trendThresholdMin = ref(null)
  239. const trendThresholdMax = ref(null)
  240. async function openTrend(row) {
  241. try {
  242. const res = await getLeakageOverview({ encode: row.encode, endTime: row.createTime })
  243. const data = (res && res.data) || res || {}
  244. trendData.value = data.trend || []
  245. trendThresholdMin.value = data.thresholdMin ?? null
  246. trendThresholdMax.value = data.thresholdMax ?? null
  247. trendTitle.value = `${row.deviceName || row.encode} · 近 7 日趋势(截至 ${row.createTime})`
  248. trendVisible.value = true
  249. } catch (e) {
  250. console.warn('[leakage] 趋势加载失败', e)
  251. }
  252. }
  253. function resetQuery() {
  254. query.value = createQuery()
  255. loadData()
  256. }
  257. function blank(value) {
  258. return value === null || value === undefined || String(value).trim() === '' ? '-' : value
  259. }
  260. function pauseOtherAudios(e) {
  261. document.querySelectorAll('audio.rec-audio').forEach((el) => {
  262. if (el !== e.target) el.pause()
  263. })
  264. }
  265. loadAll()
  266. </script>
  267. <style lang="scss" scoped>
  268. .monitor-page {
  269. padding: 20px;
  270. background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 45%, #eef2f7 100%);
  271. min-height: calc(100vh - 84px);
  272. }
  273. .page-heading {
  274. display: flex;
  275. justify-content: space-between;
  276. align-items: center;
  277. gap: 16px;
  278. margin-bottom: 16px;
  279. }
  280. .page-title {
  281. display: flex;
  282. align-items: center;
  283. gap: 12px;
  284. min-width: 0;
  285. }
  286. .page-title-icon {
  287. display: grid;
  288. place-items: center;
  289. width: 42px;
  290. height: 42px;
  291. border-radius: 8px;
  292. color: #fff;
  293. background: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%);
  294. box-shadow: 0 8px 16px rgba(37, 99, 235, 0.2);
  295. }
  296. .page-heading h2 {
  297. margin: 0 0 4px;
  298. font-size: 20px;
  299. color: #1e293b;
  300. }
  301. .muted {
  302. font-size: 13px;
  303. color: #64748b;
  304. }
  305. .head-actions {
  306. display: flex;
  307. gap: 10px;
  308. }
  309. .heading-refresh {
  310. border-color: #bfdbfe;
  311. color: #2563eb;
  312. background: #fff;
  313. }
  314. .kpis {
  315. display: grid;
  316. grid-template-columns: repeat(2, minmax(0, 1fr));
  317. gap: 16px;
  318. margin-bottom: 16px;
  319. }
  320. .kpi {
  321. border: 1px solid #e2e8f0;
  322. border-radius: 8px;
  323. padding: 16px 18px;
  324. box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
  325. }
  326. .k-top {
  327. display: flex;
  328. justify-content: space-between;
  329. align-items: center;
  330. }
  331. .k-label {
  332. font-size: 14px;
  333. color: #475569;
  334. }
  335. .k-icon {
  336. display: grid;
  337. place-items: center;
  338. width: 32px;
  339. height: 32px;
  340. border-radius: 8px;
  341. font-size: 18px;
  342. }
  343. .k-val {
  344. margin-top: 10px;
  345. font-size: 26px;
  346. font-weight: 700;
  347. color: #1e293b;
  348. }
  349. .k-foot {
  350. margin-top: 6px;
  351. font-size: 12px;
  352. color: #94a3b8;
  353. }
  354. .chart-card {
  355. margin-bottom: 16px;
  356. border: 1px solid #e2e8f0;
  357. border-radius: 8px;
  358. box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
  359. }
  360. .chart-head {
  361. display: flex;
  362. justify-content: space-between;
  363. align-items: center;
  364. gap: 12px;
  365. }
  366. .chart-head-tools {
  367. flex: 0 0 auto;
  368. }
  369. .filter-card {
  370. margin-bottom: 16px;
  371. border: 1px solid #e2e8f0;
  372. border-radius: 8px;
  373. box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
  374. }
  375. .filter-card :deep(.el-form-item) {
  376. margin-top: 8px;
  377. margin-right: 16px;
  378. margin-bottom: 8px;
  379. }
  380. .filter-card :deep(.el-form-item__label) {
  381. color: #475569;
  382. }
  383. .page-error {
  384. margin-bottom: 16px;
  385. }
  386. .table-card {
  387. min-height: 420px;
  388. border: 1px solid #e2e8f0;
  389. border-radius: 8px;
  390. box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
  391. }
  392. .card-title {
  393. display: flex;
  394. align-items: center;
  395. gap: 8px;
  396. color: #1e293b;
  397. font-weight: 600;
  398. }
  399. .card-title .el-icon {
  400. color: #2563eb;
  401. font-size: 18px;
  402. }
  403. .table-toolbar {
  404. display: flex;
  405. justify-content: space-between;
  406. align-items: center;
  407. }
  408. .total-tip {
  409. font-size: 13px;
  410. color: #64748b;
  411. }
  412. .rec-audio-cell {
  413. display: flex;
  414. align-items: center;
  415. overflow: hidden;
  416. }
  417. .rec-audio {
  418. width: 180px;
  419. max-width: 100%;
  420. height: 30px;
  421. vertical-align: middle;
  422. }
  423. .table-card :deep(.el-table) {
  424. --el-table-border-color: #e2e8f0;
  425. --el-table-header-bg-color: #f8fafc;
  426. --el-table-header-text-color: #475569;
  427. --el-table-row-hover-bg-color: #eff6ff;
  428. }
  429. .val-cell {
  430. display: inline-flex;
  431. align-items: baseline;
  432. gap: 8px;
  433. }
  434. .table-card :deep(.el-pagination) {
  435. justify-content: flex-end;
  436. margin-top: 16px;
  437. }
  438. .latest-card {
  439. margin-bottom: 16px;
  440. border: 1px solid #e2e8f0;
  441. border-radius: 8px;
  442. box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
  443. }
  444. .latest-row {
  445. display: flex;
  446. align-items: center;
  447. flex-wrap: wrap;
  448. gap: 28px;
  449. }
  450. .latest-item {
  451. display: flex;
  452. flex-direction: column;
  453. gap: 4px;
  454. }
  455. .latest-label {
  456. font-size: 13px;
  457. color: #64748b;
  458. }
  459. .latest-val {
  460. font-size: 20px;
  461. font-weight: 700;
  462. color: #1e293b;
  463. }
  464. .latest-val em {
  465. margin-left: 6px;
  466. font-size: 12px;
  467. font-weight: 500;
  468. font-style: normal;
  469. color: #94a3b8;
  470. }
  471. .latest-chart-btn {
  472. margin-left: auto;
  473. }
  474. </style>