| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537 |
- <template>
- <div class="monitor-page">
- <div class="page-heading">
- <div class="page-title">
- <span class="page-title-icon"><el-icon><Microphone /></el-icon></span>
- <div>
- <h2>漏失监测</h2>
- <span class="muted">噪声监测原始采集记录</span>
- </div>
- </div>
- <div class="head-actions">
- <el-button class="heading-refresh" :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
- </div>
- </div>
- <div class="kpis">
- <div class="card kpi">
- <div class="k-top">
- <span class="k-label">7 日平均特征值</span>
- <span class="k-icon" style="background:#e6f7f4;color:#0fb5a6"><el-icon><DataLine /></el-icon></span>
- </div>
- <div class="k-val">{{ fmt(overview.avgValue) }}</div>
- <div class="k-foot">最近 7 天全网特征值均值</div>
- </div>
- <div class="card kpi">
- <div class="k-top">
- <span class="k-label">7 日峰值特征值</span>
- <span class="k-icon" style="background:#fff3e6;color:#f59e0b"><el-icon><Top /></el-icon></span>
- </div>
- <div class="k-val">{{ fmt(overview.peakValue) }}</div>
- <div class="k-foot">最近 7 天单小时峰值</div>
- </div>
- </div>
- <el-card shadow="never" class="filter-card">
- <template #header>
- <div class="card-title">
- <el-icon><Search /></el-icon>
- <span>查询条件</span>
- </div>
- </template>
- <el-form :inline="true" :model="query" @submit.prevent="loadData">
- <el-form-item label="设备信息">
- <el-input v-model="query.encode" clearable placeholder="请输入设备编码/名称" style="width: 200px" />
- </el-form-item>
- <el-form-item>
- <el-button type="primary" :icon="Search" :loading="loading" @click="loadData">查询</el-button>
- <el-button :icon="RefreshLeft" @click="resetQuery">重置</el-button>
- </el-form-item>
- </el-form>
- </el-card>
- <el-alert
- v-if="loadError"
- class="page-error"
- type="error"
- show-icon
- :closable="false"
- title="数据加载失败"
- description="请检查后端服务或网络连接后重试。"
- >
- <el-button size="small" type="primary" @click="loadData">重试</el-button>
- </el-alert>
- <el-card shadow="never" class="table-card">
- <template #header>
- <div class="table-toolbar">
- <div class="card-title">
- <el-icon><List /></el-icon>
- <span>漏失监测采集记录</span>
- </div>
- <el-button type="primary" plain :icon="Download" :loading="exporting" size="small" @click="handleExport">导出 Excel</el-button>
- </div>
- </template>
- <el-table :data="records" stripe border v-loading="loading" empty-text="暂无符合条件的采集记录">
- <el-table-column prop="encode" label="设备编码" min-width="150" show-overflow-tooltip />
- <el-table-column prop="deviceName" label="设备名称" min-width="180" show-overflow-tooltip />
- <el-table-column prop="deviceType" label="设备类型" min-width="140" show-overflow-tooltip />
- <el-table-column label="最新特征值" min-width="160">
- <template #default="{ row }">
- <span class="val-cell">{{ blank(row.rms) }}</span>
- </template>
- </el-table-column>
- <el-table-column label="音频" min-width="200">
- <template #default="{ row }">
- <span v-if="row.filepath" class="rec-audio-cell">
- <audio class="rec-audio" controls preload="none" :src="buildMinioAttachmentUrl(row.filepath)" @play="pauseOtherAudios" />
- </span>
- <span v-else>—</span>
- </template>
- </el-table-column>
- <el-table-column prop="createTime" label="最新采集时间" min-width="170" />
- <el-table-column label="操作" min-width="120" fixed="right">
- <template #default="{ row }">
- <div style="display: flex; justify-content: center; align-items: center; gap: 8px;">
- <el-tooltip content="查看历史" placement="top">
- <el-button link type="primary" :icon="Document" @click="openHistory(row)" />
- </el-tooltip>
- <el-tooltip content="7日趋势" placement="top">
- <el-button link type="primary" :icon="DataLine" @click="openTrend(row)" />
- </el-tooltip>
- </div>
- </template>
- </el-table-column>
- </el-table>
- <el-pagination
- v-model:current-page="query.pageNum"
- v-model:page-size="query.pageSize"
- :total="total"
- layout="total, sizes, prev, pager, next"
- :page-sizes="[10, 20, 50]"
- @change="loadData"
- />
- </el-card>
- <HistoryDialog
- v-model="historyVisible"
- title="漏失历史采集记录"
- :device-key="historyDeviceKey"
- device-key-field="encode"
- :api-fn="listLeakageRecords"
- :columns="historyColumns"
- :device-title="historyDeviceTitle"
- />
- <DeviceTrendDialog
- v-model="trendVisible"
- :title="trendTitle"
- :trend="trendData"
- unit="特征值"
- :series="trendSeries"
- :threshold-min="trendThresholdMin"
- :threshold-max="trendThresholdMax"
- />
- </div>
- </template>
- <script setup>
- import { ref } from 'vue'
- import { DataLine, Document, Download, List, Microphone, Refresh, RefreshLeft, Search, Top } from '@element-plus/icons-vue'
- import { ElMessage } from 'element-plus'
- import { getLeakageOverview, listLeakageRecords } from '@/api/pipeNetwork/waterSupply'
- import { exportTableToXlsx } from '@/utils/exportTable'
- import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
- import HistoryDialog from '../components/HistoryDialog.vue'
- import DeviceTrendDialog from '../components/DeviceTrendDialog.vue'
- const records = ref([])
- const total = ref(0)
- const loading = ref(false)
- const exporting = ref(false)
- const loadError = ref(false)
- const overview = ref({ avgValue: null, peakValue: null, thresholdMin: null, thresholdMax: null, trend: [], lastUpdate: '' })
- const query = ref(createQuery())
- function createQuery() {
- return { pageNum: 1, pageSize: 10, encode: '' }
- }
- function fmt(value) {
- return value == null ? '--' : Number(value).toFixed(3)
- }
- function buildParams() {
- return {
- pageNum: query.value.pageNum,
- pageSize: query.value.pageSize,
- encode: query.value.encode || undefined,
- latestOnly: true
- }
- }
- async function loadData() {
- loading.value = true
- loadError.value = false
- try {
- const res = await listLeakageRecords(buildParams())
- const page = (res && res.data) || res || {}
- records.value = Array.isArray(page.records) ? page.records : []
- total.value = Number(page.total) || records.value.length
- } catch (e) {
- loadError.value = true
- records.value = []
- total.value = 0
- } finally {
- loading.value = false
- }
- }
- async function loadOverview() {
- try {
- const res = await getLeakageOverview({})
- overview.value = Object.assign({ avgValue: null, peakValue: null, thresholdMin: null, thresholdMax: null, trend: [], lastUpdate: '' }, (res && res.data) || res || {})
- } catch (e) {
- console.warn('[leakage] 概览加载失败', e)
- }
- }
- function loadAll() {
- loadData()
- loadOverview()
- }
- const exportColumns = [
- { prop: 'encode', label: '设备编码' },
- { prop: 'deviceName', label: '设备名称' },
- { prop: 'deviceType', label: '设备类型' },
- { prop: 'rms', label: '最新特征值', format: (v) => blank(v) },
- { prop: 'createTime', label: '最新采集时间' }
- ]
- async function handleExport() {
- exporting.value = true
- try {
- const res = await listLeakageRecords({
- encode: query.value.encode || undefined,
- pageNum: 1,
- pageSize: 10000,
- latestOnly: true
- })
- const list = (res && res.data && res.data.records) || []
- if (!list.length) {
- ElMessage.warning('当前条件下没有可导出的记录')
- return
- }
- exportTableToXlsx({ rows: list, columns: exportColumns, filename: '漏失监测记录.xlsx', sheetName: '漏失监测' })
- ElMessage.success(`已导出 ${list.length} 条记录`)
- } catch (e) {
- console.warn('[leakage] 导出失败', e)
- ElMessage.error('导出失败,请稍后重试')
- } finally {
- exporting.value = false
- }
- }
- const historyVisible = ref(false)
- const historyDeviceKey = ref('')
- const historyDeviceTitle = ref('')
- const historyColumns = [
- { prop: 'rms', label: '特征值', width: 160, format: (v) => blank(v) },
- { prop: 'createTime', label: '采集时间', width: 170 }
- ]
- const trendSeries = [
- { name: '日平均特征值', key: 'avg', color: '#1b6ef3', area: true },
- { name: '日内峰值', key: 'peak', color: '#a9c6fb', dashed: true }
- ]
- function openHistory(row) {
- historyDeviceKey.value = row.encode
- historyDeviceTitle.value = `${row.encode}.${row.deviceName || row.encode}`
- historyVisible.value = true
- }
- const trendVisible = ref(false)
- const trendTitle = ref('')
- const trendData = ref([])
- const trendThresholdMin = ref(null)
- const trendThresholdMax = ref(null)
- async function openTrend(row) {
- try {
- const res = await getLeakageOverview({ encode: row.encode, endTime: row.createTime })
- const data = (res && res.data) || res || {}
- trendData.value = data.trend || []
- trendThresholdMin.value = data.thresholdMin ?? null
- trendThresholdMax.value = data.thresholdMax ?? null
- trendTitle.value = `${row.deviceName || row.encode} · 近 7 日趋势(截至 ${row.createTime})`
- trendVisible.value = true
- } catch (e) {
- console.warn('[leakage] 趋势加载失败', e)
- }
- }
- function resetQuery() {
- query.value = createQuery()
- loadData()
- }
- function blank(value) {
- return value === null || value === undefined || String(value).trim() === '' ? '-' : value
- }
- function pauseOtherAudios(e) {
- document.querySelectorAll('audio.rec-audio').forEach((el) => {
- if (el !== e.target) el.pause()
- })
- }
- loadAll()
- </script>
- <style lang="scss" scoped>
- .monitor-page {
- padding: 20px;
- background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 45%, #eef2f7 100%);
- min-height: calc(100vh - 84px);
- }
- .page-heading {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 16px;
- margin-bottom: 16px;
- }
- .page-title {
- display: flex;
- align-items: center;
- gap: 12px;
- min-width: 0;
- }
- .page-title-icon {
- display: grid;
- place-items: center;
- width: 42px;
- height: 42px;
- border-radius: 8px;
- color: #fff;
- background: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%);
- box-shadow: 0 8px 16px rgba(37, 99, 235, 0.2);
- }
- .page-heading h2 {
- margin: 0 0 4px;
- font-size: 20px;
- color: #1e293b;
- }
- .muted {
- font-size: 13px;
- color: #64748b;
- }
- .head-actions {
- display: flex;
- gap: 10px;
- }
- .heading-refresh {
- border-color: #bfdbfe;
- color: #2563eb;
- background: #fff;
- }
- .kpis {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 16px;
- margin-bottom: 16px;
- }
- .kpi {
- border: 1px solid #e2e8f0;
- border-radius: 8px;
- padding: 16px 18px;
- box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
- }
- .k-top {
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .k-label {
- font-size: 14px;
- color: #475569;
- }
- .k-icon {
- display: grid;
- place-items: center;
- width: 32px;
- height: 32px;
- border-radius: 8px;
- font-size: 18px;
- }
- .k-val {
- margin-top: 10px;
- font-size: 26px;
- font-weight: 700;
- color: #1e293b;
- }
- .k-foot {
- margin-top: 6px;
- font-size: 12px;
- color: #94a3b8;
- }
- .chart-card {
- margin-bottom: 16px;
- border: 1px solid #e2e8f0;
- border-radius: 8px;
- box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
- }
- .chart-head {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 12px;
- }
- .chart-head-tools {
- flex: 0 0 auto;
- }
- .filter-card {
- margin-bottom: 16px;
- border: 1px solid #e2e8f0;
- border-radius: 8px;
- box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
- }
- .filter-card :deep(.el-form-item) {
- margin-top: 8px;
- margin-right: 16px;
- margin-bottom: 8px;
- }
- .filter-card :deep(.el-form-item__label) {
- color: #475569;
- }
- .page-error {
- margin-bottom: 16px;
- }
- .table-card {
- min-height: 420px;
- border: 1px solid #e2e8f0;
- border-radius: 8px;
- box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
- }
- .card-title {
- display: flex;
- align-items: center;
- gap: 8px;
- color: #1e293b;
- font-weight: 600;
- }
- .card-title .el-icon {
- color: #2563eb;
- font-size: 18px;
- }
- .table-toolbar {
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .total-tip {
- font-size: 13px;
- color: #64748b;
- }
- .rec-audio-cell {
- display: flex;
- align-items: center;
- overflow: hidden;
- }
- .rec-audio {
- width: 180px;
- max-width: 100%;
- height: 30px;
- vertical-align: middle;
- }
- .table-card :deep(.el-table) {
- --el-table-border-color: #e2e8f0;
- --el-table-header-bg-color: #f8fafc;
- --el-table-header-text-color: #475569;
- --el-table-row-hover-bg-color: #eff6ff;
- }
- .val-cell {
- display: inline-flex;
- align-items: baseline;
- gap: 8px;
- }
- .table-card :deep(.el-pagination) {
- justify-content: flex-end;
- margin-top: 16px;
- }
- .latest-card {
- margin-bottom: 16px;
- border: 1px solid #e2e8f0;
- border-radius: 8px;
- box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
- }
- .latest-row {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 28px;
- }
- .latest-item {
- display: flex;
- flex-direction: column;
- gap: 4px;
- }
- .latest-label {
- font-size: 13px;
- color: #64748b;
- }
- .latest-val {
- font-size: 20px;
- font-weight: 700;
- color: #1e293b;
- }
- .latest-val em {
- margin-left: 6px;
- font-size: 12px;
- font-weight: 500;
- font-style: normal;
- color: #94a3b8;
- }
- .latest-chart-btn {
- margin-left: auto;
- }
- </style>
|