WaterMonitoringPage.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  1. <template>
  2. <div class="water-page">
  3. <div class="page-heading">
  4. <div class="page-title">
  5. <span class="page-title-icon"><el-icon><component :is="pageIcon"/></el-icon></span>
  6. <div>
  7. <h2>{{ title }}</h2>
  8. <span class="muted">按时间与数值范围查询实时点位,并查看趋势明细</span>
  9. </div>
  10. </div>
  11. <el-button class="heading-refresh" :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
  12. </div>
  13. <el-card shadow="never" class="filter-card">
  14. <template #header>
  15. <div class="card-title">
  16. <el-icon><Search/></el-icon>
  17. <span>监测数据筛选</span>
  18. </div>
  19. </template>
  20. <el-form :inline="true" :model="query" @submit.prevent="loadData">
  21. <el-form-item label="设备名称">
  22. <el-input v-model="query.equipmentName" clearable placeholder="请输入设备名称"/>
  23. </el-form-item>
  24. <el-form-item label="设备编码">
  25. <el-input v-model="query.equipmentCode" clearable placeholder="请输入设备编码"/>
  26. </el-form-item>
  27. <el-form-item label="安装位置">
  28. <el-input v-model="query.equipmentLocation" clearable placeholder="请输入位置"/>
  29. </el-form-item>
  30. <el-form-item label="采集时间">
  31. <el-date-picker
  32. v-model="query.timeRange"
  33. type="datetimerange"
  34. range-separator="至"
  35. start-placeholder="开始时间"
  36. end-placeholder="结束时间"
  37. value-format="YYYY-MM-DD HH:mm:ss"
  38. format="YYYY-MM-DD HH:mm:ss"
  39. clearable
  40. />
  41. </el-form-item>
  42. <el-form-item :label="rangeLabel">
  43. <el-input-number v-model="query[minKey]" :min="0" controls-position="right"/>
  44. <span class="range-sep">至</span>
  45. <el-input-number v-model="query[maxKey]" :min="0" controls-position="right"/>
  46. </el-form-item>
  47. <el-form-item>
  48. <el-button type="primary" :icon="Search" :loading="loading" @click="loadData">查询</el-button>
  49. <el-button :icon="RefreshLeft" @click="resetQuery">重置</el-button>
  50. </el-form-item>
  51. </el-form>
  52. </el-card>
  53. <el-alert
  54. v-if="loadError"
  55. class="page-error"
  56. type="error"
  57. show-icon
  58. :closable="false"
  59. title="监测数据加载失败"
  60. description="请检查后端服务或网络连接后重试。"
  61. >
  62. <el-button size="small" type="primary" @click="loadData">重试</el-button>
  63. </el-alert>
  64. <div class="metric-grid">
  65. <div class="metric">
  66. <div class="metric-icon metric-icon--blue"><el-icon><Monitor/></el-icon></div>
  67. <div class="metric-body">
  68. <span>监测点位</span>
  69. <div class="metric-value"><strong>{{ page.total }}</strong><small>个</small></div>
  70. </div>
  71. </div>
  72. <div class="metric">
  73. <div class="metric-icon metric-icon--cyan"><el-icon><DataAnalysis/></el-icon></div>
  74. <div class="metric-body">
  75. <span>当前均值</span>
  76. <div class="metric-value"><strong>{{ averageValue }}</strong><small>{{ unit }}</small></div>
  77. </div>
  78. </div>
  79. <div class="metric">
  80. <div class="metric-icon metric-icon--orange"><el-icon><Warning/></el-icon></div>
  81. <div class="metric-body">
  82. <span>异常点位</span>
  83. <div class="metric-value"><strong class="danger">{{ abnormalCount }}</strong><small>个</small></div>
  84. </div>
  85. </div>
  86. <div class="metric">
  87. <div class="metric-icon metric-icon--slate"><el-icon><Clock/></el-icon></div>
  88. <div class="metric-body">
  89. <span>数据更新时间</span>
  90. <div class="metric-value"><strong class="time">{{ latestTime }}</strong></div>
  91. </div>
  92. </div>
  93. </div>
  94. <el-card shadow="never" class="table-card">
  95. <template #header>
  96. <div class="table-toolbar">
  97. <div class="card-title">
  98. <el-icon><DataLine/></el-icon>
  99. <span>{{ title }}点位清单</span>
  100. </div>
  101. <el-button
  102. v-hasPermi="[exportPermission]"
  103. type="success"
  104. plain
  105. :icon="Download"
  106. :loading="exportLoading"
  107. @click="exportData"
  108. >批量导出</el-button>
  109. </div>
  110. </template>
  111. <el-table :data="page.records" stripe border v-loading="loading" @row-click="showTrend">
  112. <template #empty>
  113. <el-empty :description="loadError ? '暂无法获取监测数据' : '暂无符合条件的监测数据'"/>
  114. </template>
  115. <el-table-column type="index" label="#" width="55"/>
  116. <el-table-column prop="equipmentCode" label="设备编码" min-width="130"/>
  117. <el-table-column prop="equipmentName" label="设备名称" min-width="150" show-overflow-tooltip/>
  118. <el-table-column prop="equipmentTypeName" label="设备子类型" min-width="145" show-overflow-tooltip/>
  119. <el-table-column prop="location" label="安装位置" min-width="180" show-overflow-tooltip/>
  120. <el-table-column :label="`${metricLabel}(${unit})`" min-width="130">
  121. <template #default="{ row }">
  122. <span :class="{ danger: row.value == null || row.status === 1 || row.status === '1' }">
  123. {{ row.value ?? '-' }}
  124. </span>
  125. </template>
  126. </el-table-column>
  127. <el-table-column label="状态" width="90">
  128. <template #default="{ row }">
  129. <el-tag :type="statusType(row)">{{ statusText(row) }}</el-tag>
  130. </template>
  131. </el-table-column>
  132. <el-table-column prop="updateTime" label="采集时间" min-width="165"/>
  133. <el-table-column label="操作" width="90" fixed="right">
  134. <template #default="{ row }">
  135. <el-button class="trend-button" link type="primary" @click.stop="showTrend(row)">
  136. <el-icon><TrendCharts/></el-icon>
  137. <span>趋势</span>
  138. </el-button>
  139. </template>
  140. </el-table-column>
  141. </el-table>
  142. <el-pagination
  143. v-model:current-page="query.pageNum"
  144. v-model:page-size="query.pageSize"
  145. :total="page.total"
  146. layout="total, sizes, prev, pager, next"
  147. :page-sizes="[10, 20, 50]"
  148. @change="loadData"
  149. />
  150. </el-card>
  151. <el-drawer
  152. v-model="trendVisible"
  153. class="detail-drawer"
  154. direction="rtl"
  155. :title="`${current?.equipmentName || ''} - ${title}趋势`"
  156. size="520px"
  157. >
  158. <div v-loading="trendLoading" class="trend-body">
  159. <div v-show="trendRecords.length" ref="chartRef" class="trend-chart"/>
  160. <el-empty v-if="!trendLoading && !trendError && !trendRecords.length" description="暂无趋势数据"/>
  161. <el-alert
  162. v-if="trendError"
  163. type="error"
  164. show-icon
  165. :closable="false"
  166. title="趋势数据加载失败"
  167. description="请检查后端服务或网络连接后重试。"
  168. >
  169. <el-button size="small" type="primary" @click="loadTrend">重试</el-button>
  170. </el-alert>
  171. <el-descriptions v-if="current" :column="1" border>
  172. <el-descriptions-item label="设备编码">{{ current.equipmentCode }}</el-descriptions-item>
  173. <el-descriptions-item label="安装位置">{{ current.location }}</el-descriptions-item>
  174. <el-descriptions-item label="当前值">{{ current.value ?? '-' }} {{ current.unit }}
  175. </el-descriptions-item>
  176. <el-descriptions-item label="采集时间">{{ current.updateTime || '-' }}</el-descriptions-item>
  177. </el-descriptions>
  178. </div>
  179. </el-drawer>
  180. </div>
  181. </template>
  182. <script setup>
  183. import {computed, nextTick, onBeforeUnmount, onMounted, ref} from 'vue'
  184. import {
  185. Clock,
  186. DataAnalysis,
  187. DataLine,
  188. Download,
  189. Monitor,
  190. Refresh,
  191. RefreshLeft,
  192. Search,
  193. TrendCharts,
  194. Warning
  195. } from '@element-plus/icons-vue'
  196. import {ElMessage} from 'element-plus'
  197. import {saveAs} from 'file-saver'
  198. import * as echarts from 'echarts'
  199. import {normalizeMonitoringRecord, normalizePage, readMonitoringExportError, unwrapResponse} from '@/utils/waterSupplyModel'
  200. import {buildMonitoringQuery, buildTrendQuery} from '@/utils/waterSupplyMonitoringQuery'
  201. const props = defineProps({
  202. title: {type: String, required: true},
  203. metricLabel: {type: String, required: true},
  204. unit: {type: String, default: 'm³/h'},
  205. minKey: {type: String, default: 'flowMin'},
  206. maxKey: {type: String, default: 'flowMax'},
  207. rangeLabel: {type: String, default: '数值范围'},
  208. load: {type: Function, required: true},
  209. trend: {type: Function, required: true},
  210. exportApi: {type: Function, required: true},
  211. exportPermission: {type: String, required: true},
  212. metric: {type: String, default: 'value'},
  213. monitorType: {type: String, default: ''}
  214. })
  215. const pageIcon = computed(() => props.metric === 'pressure' ? Warning : props.metric === 'power' ? DataAnalysis : Monitor)
  216. const metricColor = computed(() => props.metric === 'pressure' ? '#f97316' : props.metric === 'power' ? '#06b6d4' : '#2563eb')
  217. const query = ref(createQuery())
  218. const page = ref({records: [], total: 0})
  219. const loading = ref(false)
  220. const loadError = ref(false)
  221. const exportLoading = ref(false)
  222. const current = ref(null)
  223. const trendVisible = ref(false)
  224. const trendLoading = ref(false)
  225. const trendError = ref(false)
  226. const trendRecords = ref([])
  227. const chartRef = ref(null)
  228. let chart
  229. function createQuery() {
  230. return {
  231. pageNum: 1,
  232. pageSize: 10,
  233. equipmentName: '',
  234. equipmentCode: '',
  235. equipmentLocation: '',
  236. timeRange: [],
  237. [props.minKey]: undefined,
  238. [props.maxKey]: undefined
  239. }
  240. }
  241. async function loadData() {
  242. loading.value = true
  243. loadError.value = false
  244. try {
  245. const response = await props.load(buildMonitoringQuery(query.value, props.minKey, props.maxKey))
  246. const normalized = normalizePage(response)
  247. normalized.records = normalized.records.map(row => normalizeMonitoringRecord(row, props.metric, props.monitorType))
  248. page.value = normalized
  249. } catch (error) {
  250. loadError.value = true
  251. console.warn(`[${props.title}] 数据加载失败`, error)
  252. } finally {
  253. loading.value = false
  254. }
  255. }
  256. function resetQuery() {
  257. query.value = createQuery()
  258. loadData()
  259. }
  260. const averageValue = computed(() => {
  261. const values = page.value.records.map(item => item.value).filter(value => Number.isFinite(value))
  262. return values.length ? (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2) : '-'
  263. })
  264. const abnormalCount = computed(() => page.value.records.filter(item =>
  265. item.status === 1 || item.status === '1' || item.value == null
  266. ).length)
  267. const latestTime = computed(() =>
  268. page.value.records.map(item => item.updateTime).filter(Boolean).sort().at(-1) || '-'
  269. )
  270. function statusText(row) {
  271. if (row.status === 1 || row.status === '1') return '异常'
  272. if (row.status === 0 || row.status === '0') return '正常'
  273. return row.status ? '在线' : '未知'
  274. }
  275. function statusType(row) {
  276. const text = statusText(row)
  277. return text === '异常' ? 'danger' : text === '正常' || text === '在线' ? 'success' : 'info'
  278. }
  279. async function showTrend(row) {
  280. current.value = row
  281. trendVisible.value = true
  282. await loadTrend(true)
  283. }
  284. async function loadTrend(resetTrend = false) {
  285. if (!current.value?.id) return
  286. trendLoading.value = true
  287. trendError.value = false
  288. if (resetTrend) {
  289. trendRecords.value = []
  290. chart?.dispose()
  291. chart = undefined
  292. }
  293. try {
  294. const response = await props.trend(current.value.id, buildTrendQuery(query.value.timeRange))
  295. const data = unwrapResponse(response)
  296. const records = Array.isArray(data) ? data : data?.records || []
  297. trendRecords.value = records.map(item => normalizeMonitoringRecord(item, props.metric, props.monitorType))
  298. await nextTick()
  299. renderTrend()
  300. } catch (error) {
  301. trendError.value = true
  302. if (resetTrend) {
  303. chart?.dispose()
  304. chart = undefined
  305. }
  306. console.warn(`[${props.title}] 趋势数据加载失败`, error)
  307. } finally {
  308. trendLoading.value = false
  309. }
  310. }
  311. function renderTrend() {
  312. chart?.dispose()
  313. chart = undefined
  314. if (!trendRecords.value.length) return
  315. chart = echarts.init(chartRef.value)
  316. const labels = trendRecords.value.map(item => item.updateTime)
  317. const values = trendRecords.value.map(item => item.value ?? 0)
  318. const color = metricColor.value
  319. chart.setOption({
  320. tooltip: {
  321. trigger: 'axis',
  322. axisPointer: {type: 'line', lineStyle: {color: 'rgba(37, 99, 235, 0.25)'}}
  323. },
  324. grid: {left: 48, right: 20, top: 34, bottom: 38},
  325. xAxis: {
  326. type: 'category',
  327. data: labels,
  328. axisLine: {lineStyle: {color: '#cbd5e1'}},
  329. axisLabel: {color: '#64748b'},
  330. axisTick: {show: false}
  331. },
  332. yAxis: {
  333. type: 'value',
  334. name: props.unit,
  335. nameTextStyle: {color: '#64748b'},
  336. axisLine: {show: false},
  337. axisLabel: {color: '#64748b'},
  338. splitLine: {lineStyle: {color: '#e2e8f0', type: 'dashed'}}
  339. },
  340. series: [{
  341. type: 'line',
  342. smooth: true,
  343. data: values,
  344. symbol: 'circle',
  345. symbolSize: 7,
  346. lineStyle: {width: 3, color},
  347. itemStyle: {color, borderColor: '#fff', borderWidth: 2},
  348. areaStyle: {
  349. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  350. {offset: 0, color: `${color}33`},
  351. {offset: 1, color: `${color}05`}
  352. ])
  353. }
  354. }]
  355. })
  356. }
  357. async function exportData() {
  358. exportLoading.value = true
  359. try {
  360. const response = await props.exportApi(buildMonitoringQuery(query.value, props.minKey, props.maxKey))
  361. const blob = response instanceof Blob
  362. ? response
  363. : new Blob([response], {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'})
  364. const exportError = await readMonitoringExportError(blob)
  365. if (exportError) {
  366. ElMessage.error(exportError)
  367. return
  368. }
  369. if (!blob.size) throw new Error('导出文件为空')
  370. saveAs(blob, `${props.title}数据_${new Date().toISOString().slice(0, 10)}.xlsx`)
  371. ElMessage.success('导出完成')
  372. } catch (error) {
  373. console.warn(`[${props.title}] 导出失败`, error)
  374. ElMessage.error('导出失败,请稍后重试')
  375. } finally {
  376. exportLoading.value = false
  377. }
  378. }
  379. onMounted(loadData)
  380. onBeforeUnmount(() => {
  381. chart?.dispose()
  382. chart = undefined
  383. })
  384. </script>
  385. <style lang="scss">
  386. @use './waterSupplyPageTheme.scss';
  387. </style>
  388. <style scoped>
  389. .water-page {
  390. padding: 20px;
  391. background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 45%, #eef2f7 100%);
  392. min-height: calc(100vh - 84px)
  393. }
  394. .page-heading {
  395. display: flex;
  396. justify-content: space-between;
  397. align-items: center;
  398. gap: 16px;
  399. margin-bottom: 16px
  400. }
  401. .page-title {
  402. display: flex;
  403. align-items: center;
  404. gap: 12px;
  405. min-width: 0;
  406. }
  407. .page-title-icon {
  408. display: grid;
  409. place-items: center;
  410. width: 42px;
  411. height: 42px;
  412. border-radius: 8px;
  413. color: #fff;
  414. background: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%);
  415. box-shadow: 0 8px 16px rgba(37, 99, 235, 0.2);
  416. }
  417. .page-heading h2 {
  418. margin: 0 0 4px;
  419. font-size: 20px;
  420. color: #1e293b
  421. }
  422. .muted {
  423. font-size: 13px;
  424. color: #64748b
  425. }
  426. .heading-refresh {
  427. border-color: #bfdbfe;
  428. color: #2563eb;
  429. background: #fff;
  430. }
  431. .filter-card {
  432. margin-bottom: 16px;
  433. border: 1px solid #e2e8f0;
  434. border-radius: 8px;
  435. box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04)
  436. }
  437. .filter-card :deep(.el-form-item) {
  438. margin-top: 8px;
  439. margin-right: 16px;
  440. margin-bottom: 8px
  441. }
  442. .filter-card :deep(.el-form-item__label) {
  443. color: #475569
  444. }
  445. .range-sep {
  446. margin: 0 8px;
  447. color: #94a3b8
  448. }
  449. .page-error {
  450. margin-bottom: 16px
  451. }
  452. .metric-grid {
  453. display: grid;
  454. grid-template-columns: repeat(4, minmax(0, 1fr));
  455. gap: 14px;
  456. margin-bottom: 16px
  457. }
  458. .metric {
  459. display: flex;
  460. align-items: center;
  461. gap: 14px;
  462. min-width: 0;
  463. padding: 16px 18px;
  464. background: #fff;
  465. border: 1px solid #e2e8f0;
  466. border-radius: 8px;
  467. box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04)
  468. }
  469. .metric-icon {
  470. position: static;
  471. flex: 0 0 42px;
  472. display: grid;
  473. place-items: center;
  474. width: 42px;
  475. height: 42px;
  476. border-radius: 8px;
  477. }
  478. .metric-icon .el-icon {
  479. font-size: 22px;
  480. }
  481. .metric-icon--blue {
  482. color: #2563eb;
  483. background: linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%);
  484. }
  485. .metric-icon--cyan {
  486. color: #0891b2;
  487. background: linear-gradient(135deg, #cffafe 0%, #ecfeff 100%);
  488. }
  489. .metric-icon--orange {
  490. color: #ea580c;
  491. background: linear-gradient(135deg, #ffedd5 0%, #fff7ed 100%);
  492. }
  493. .metric-icon--slate {
  494. color: #475569;
  495. background: linear-gradient(135deg, #e2e8f0 0%, #f8fafc 100%);
  496. }
  497. .metric-body {
  498. min-width: 0;
  499. text-align: left;
  500. }
  501. .metric-body span {
  502. display: block;
  503. overflow: hidden;
  504. text-overflow: ellipsis;
  505. white-space: nowrap;
  506. margin-bottom: 3px;
  507. color: #64748b;
  508. font-size: 13px
  509. }
  510. .metric-value {
  511. display: flex;
  512. align-items: baseline;
  513. gap: 5px;
  514. min-width: 0;
  515. }
  516. .metric-value strong {
  517. font-size: 24px;
  518. line-height: 1.2;
  519. color: #1e293b;
  520. }
  521. .metric-value small {
  522. color: #94a3b8
  523. }
  524. .metric .time {
  525. overflow: hidden;
  526. text-overflow: ellipsis;
  527. white-space: nowrap;
  528. font-size: 16px;
  529. }
  530. .danger {
  531. color: #ef4444 !important
  532. }
  533. .table-card {
  534. min-height: 420px;
  535. border: 1px solid #e2e8f0;
  536. border-radius: 8px;
  537. box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04)
  538. }
  539. .card-title {
  540. display: flex;
  541. align-items: center;
  542. gap: 8px;
  543. color: #1e293b;
  544. font-weight: 600;
  545. }
  546. .card-title .el-icon {
  547. color: #2563eb;
  548. font-size: 18px;
  549. }
  550. .table-toolbar {
  551. justify-content: space-between;
  552. display: flex;
  553. align-items: center;
  554. }
  555. .table-card :deep(.el-table) {
  556. --el-table-border-color: #e2e8f0;
  557. --el-table-header-bg-color: #f8fafc;
  558. --el-table-header-text-color: #475569;
  559. --el-table-row-hover-bg-color: #eff6ff;
  560. }
  561. .table-card :deep(.el-table__row) {
  562. cursor: pointer;
  563. }
  564. .trend-button {
  565. display: inline-flex;
  566. align-items: center;
  567. gap: 4px;
  568. }
  569. .table-card :deep(.el-pagination) {
  570. justify-content: flex-end;
  571. margin-top: 16px
  572. }
  573. .trend-body {
  574. min-height: 340px;
  575. padding-right: 4px
  576. }
  577. .trend-chart {
  578. height: 300px;
  579. margin-bottom: 20px;
  580. padding: 12px;
  581. border: 1px solid #e2e8f0;
  582. border-radius: 8px;
  583. background: #fff
  584. }
  585. .trend-body :deep(.el-empty) {
  586. margin-bottom: 20px
  587. }
  588. @media (max-width: 900px) {
  589. .metric-grid {
  590. grid-template-columns:repeat(2, minmax(0, 1fr))
  591. }
  592. .filter-card :deep(.el-form-item) {
  593. margin-bottom: 12px
  594. }
  595. }
  596. </style>