|
@@ -1,80 +1,653 @@
|
|
|
<template>
|
|
<template>
|
|
|
- <div class="water-page">
|
|
|
|
|
- <div class="page-heading">
|
|
|
|
|
- <div class="page-title"><span class="page-title-icon"><el-icon><component :is="pageIcon" /></el-icon></span><div><h2>{{ title }}</h2><span class="muted">实时采集点位与近24小时趋势</span></div></div>
|
|
|
|
|
- <el-button :icon="Refresh" @click="loadData">刷新</el-button>
|
|
|
|
|
- </div>
|
|
|
|
|
- <el-card shadow="never" class="filter-card">
|
|
|
|
|
- <el-form :inline="true" :model="query" @submit.prevent="loadData">
|
|
|
|
|
- <el-form-item label="设备名称"><el-input v-model="query.equipmentName" clearable placeholder="请输入设备名称" /></el-form-item>
|
|
|
|
|
- <el-form-item label="设备编码"><el-input v-model="query.equipmentCode" clearable placeholder="请输入设备编码" /></el-form-item>
|
|
|
|
|
- <el-form-item label="安装位置"><el-input v-model="query.equipmentLocation" clearable placeholder="请输入位置" /></el-form-item>
|
|
|
|
|
- <el-form-item :label="rangeLabel"><el-input-number v-model="query[minKey]" :min="0" controls-position="right" /><span class="range-sep">至</span><el-input-number v-model="query[maxKey]" :min="0" controls-position="right" /></el-form-item>
|
|
|
|
|
- <el-form-item><el-button type="primary" @click="loadData">查询</el-button><el-button @click="resetQuery">重置</el-button></el-form-item>
|
|
|
|
|
- </el-form>
|
|
|
|
|
- </el-card>
|
|
|
|
|
- <div class="metric-grid">
|
|
|
|
|
- <div class="metric"><el-icon class="metric-icon"><Monitor /></el-icon><span>监测点位</span><strong>{{ page.total }}</strong><small>个</small></div>
|
|
|
|
|
- <div class="metric"><el-icon class="metric-icon"><DataAnalysis /></el-icon><span>当前均值</span><strong>{{ averageValue }}</strong><small>{{ unit }}</small></div>
|
|
|
|
|
- <div class="metric"><el-icon class="metric-icon"><Warning /></el-icon><span>异常点位</span><strong class="danger">{{ abnormalCount }}</strong><small>个</small></div>
|
|
|
|
|
- <div class="metric"><el-icon class="metric-icon"><Clock /></el-icon><span>数据更新时间</span><strong class="time">{{ latestTime }}</strong></div>
|
|
|
|
|
|
|
+ <div class="water-page">
|
|
|
|
|
+ <div class="page-heading">
|
|
|
|
|
+ <div class="page-title">
|
|
|
|
|
+ <span class="page-title-icon"><el-icon><component :is="pageIcon"/></el-icon></span>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h2>{{ title }}</h2>
|
|
|
|
|
+ <span class="muted">按时间与数值范围查询实时点位,并查看趋势明细</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-button class="heading-refresh" :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
|
|
|
|
|
+ </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.equipmentName" clearable placeholder="请输入设备名称"/>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="设备编码">
|
|
|
|
|
+ <el-input v-model="query.equipmentCode" clearable placeholder="请输入设备编码"/>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="安装位置">
|
|
|
|
|
+ <el-input v-model="query.equipmentLocation" clearable placeholder="请输入位置"/>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="采集时间">
|
|
|
|
|
+ <el-date-picker
|
|
|
|
|
+ v-model="query.timeRange"
|
|
|
|
|
+ type="datetimerange"
|
|
|
|
|
+ range-separator="至"
|
|
|
|
|
+ start-placeholder="开始时间"
|
|
|
|
|
+ end-placeholder="结束时间"
|
|
|
|
|
+ value-format="YYYY-MM-DD HH:mm:ss"
|
|
|
|
|
+ format="YYYY-MM-DD HH:mm:ss"
|
|
|
|
|
+ clearable
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item :label="rangeLabel">
|
|
|
|
|
+ <el-input-number v-model="query[minKey]" :min="0" controls-position="right"/>
|
|
|
|
|
+ <span class="range-sep">至</span>
|
|
|
|
|
+ <el-input-number v-model="query[maxKey]" :min="0" controls-position="right"/>
|
|
|
|
|
+ </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>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="metric-grid">
|
|
|
|
|
+ <div class="metric">
|
|
|
|
|
+ <div class="metric-icon metric-icon--blue"><el-icon><Monitor/></el-icon></div>
|
|
|
|
|
+ <div class="metric-body">
|
|
|
|
|
+ <span>监测点位</span>
|
|
|
|
|
+ <div class="metric-value"><strong>{{ page.total }}</strong><small>个</small></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="metric">
|
|
|
|
|
+ <div class="metric-icon metric-icon--cyan"><el-icon><DataAnalysis/></el-icon></div>
|
|
|
|
|
+ <div class="metric-body">
|
|
|
|
|
+ <span>当前均值</span>
|
|
|
|
|
+ <div class="metric-value"><strong>{{ averageValue }}</strong><small>{{ unit }}</small></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="metric">
|
|
|
|
|
+ <div class="metric-icon metric-icon--orange"><el-icon><Warning/></el-icon></div>
|
|
|
|
|
+ <div class="metric-body">
|
|
|
|
|
+ <span>异常点位</span>
|
|
|
|
|
+ <div class="metric-value"><strong class="danger">{{ abnormalCount }}</strong><small>个</small></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="metric">
|
|
|
|
|
+ <div class="metric-icon metric-icon--slate"><el-icon><Clock/></el-icon></div>
|
|
|
|
|
+ <div class="metric-body">
|
|
|
|
|
+ <span>数据更新时间</span>
|
|
|
|
|
+ <div class="metric-value"><strong class="time">{{ latestTime }}</strong></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <el-card shadow="never" class="table-card">
|
|
|
|
|
+ <template #header>
|
|
|
|
|
+ <div class="table-toolbar">
|
|
|
|
|
+ <div class="card-title">
|
|
|
|
|
+ <el-icon><DataLine/></el-icon>
|
|
|
|
|
+ <span>{{ title }}点位清单</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-button
|
|
|
|
|
+ v-hasPermi="[exportPermission]"
|
|
|
|
|
+ type="success"
|
|
|
|
|
+ plain
|
|
|
|
|
+ :icon="Download"
|
|
|
|
|
+ :loading="exportLoading"
|
|
|
|
|
+ @click="exportData"
|
|
|
|
|
+ >批量导出</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <el-table :data="page.records" stripe border v-loading="loading" @row-click="showTrend">
|
|
|
|
|
+ <template #empty>
|
|
|
|
|
+ <el-empty :description="loadError ? '暂无法获取监测数据' : '暂无符合条件的监测数据'"/>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <el-table-column type="index" label="#" width="55"/>
|
|
|
|
|
+ <el-table-column prop="equipmentCode" label="设备编码" min-width="130"/>
|
|
|
|
|
+ <el-table-column prop="equipmentName" label="设备名称" min-width="150" show-overflow-tooltip/>
|
|
|
|
|
+ <el-table-column prop="equipmentTypeName" label="设备子类型" min-width="145" show-overflow-tooltip/>
|
|
|
|
|
+ <el-table-column prop="location" label="安装位置" min-width="180" show-overflow-tooltip/>
|
|
|
|
|
+ <el-table-column :label="`${metricLabel}(${unit})`" min-width="130">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <span :class="{ danger: row.value == null || row.status === 1 || row.status === '1' }">
|
|
|
|
|
+ {{ row.value ?? '-' }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="状态" width="90">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <el-tag :type="statusType(row)">{{ statusText(row) }}</el-tag>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column prop="updateTime" label="采集时间" min-width="165"/>
|
|
|
|
|
+ <el-table-column label="操作" width="90" fixed="right">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <el-button class="trend-button" link type="primary" @click.stop="showTrend(row)">
|
|
|
|
|
+ <el-icon><TrendCharts/></el-icon>
|
|
|
|
|
+ <span>趋势</span>
|
|
|
|
|
+ </el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ <el-pagination
|
|
|
|
|
+ v-model:current-page="query.pageNum"
|
|
|
|
|
+ v-model:page-size="query.pageSize"
|
|
|
|
|
+ :total="page.total"
|
|
|
|
|
+ layout="total, sizes, prev, pager, next"
|
|
|
|
|
+ :page-sizes="[10, 20, 50]"
|
|
|
|
|
+ @change="loadData"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+
|
|
|
|
|
+ <el-drawer
|
|
|
|
|
+ v-model="trendVisible"
|
|
|
|
|
+ class="detail-drawer"
|
|
|
|
|
+ direction="rtl"
|
|
|
|
|
+ :title="`${current?.equipmentName || ''} - ${title}趋势`"
|
|
|
|
|
+ size="520px"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div v-loading="trendLoading" class="trend-body">
|
|
|
|
|
+ <div v-show="trendRecords.length" ref="chartRef" class="trend-chart"/>
|
|
|
|
|
+ <el-empty v-if="!trendLoading && !trendError && !trendRecords.length" description="暂无趋势数据"/>
|
|
|
|
|
+ <el-alert
|
|
|
|
|
+ v-if="trendError"
|
|
|
|
|
+ type="error"
|
|
|
|
|
+ show-icon
|
|
|
|
|
+ :closable="false"
|
|
|
|
|
+ title="趋势数据加载失败"
|
|
|
|
|
+ description="请检查后端服务或网络连接后重试。"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-button size="small" type="primary" @click="loadTrend">重试</el-button>
|
|
|
|
|
+ </el-alert>
|
|
|
|
|
+ <el-descriptions v-if="current" :column="1" border>
|
|
|
|
|
+ <el-descriptions-item label="设备编码">{{ current.equipmentCode }}</el-descriptions-item>
|
|
|
|
|
+ <el-descriptions-item label="安装位置">{{ current.location }}</el-descriptions-item>
|
|
|
|
|
+ <el-descriptions-item label="当前值">{{ current.value ?? '-' }} {{ current.unit }}
|
|
|
|
|
+ </el-descriptions-item>
|
|
|
|
|
+ <el-descriptions-item label="采集时间">{{ current.updateTime || '-' }}</el-descriptions-item>
|
|
|
|
|
+ </el-descriptions>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-drawer>
|
|
|
</div>
|
|
</div>
|
|
|
- <el-card shadow="never" class="table-card">
|
|
|
|
|
- <div class="card-title"><span>{{ title }}点位清单</span><el-button link type="primary" @click="exportData">批量导出</el-button></div>
|
|
|
|
|
- <el-table :data="page.records" stripe border v-loading="loading" @row-click="showTrend">
|
|
|
|
|
- <el-table-column type="index" label="#" width="55" />
|
|
|
|
|
- <el-table-column prop="equipmentCode" label="设备编码" min-width="130" />
|
|
|
|
|
- <el-table-column prop="equipmentName" label="设备名称" min-width="150" show-overflow-tooltip />
|
|
|
|
|
- <el-table-column prop="equipmentTypeName" label="设备子类型" min-width="145" show-overflow-tooltip />
|
|
|
|
|
- <el-table-column prop="location" label="安装位置" min-width="180" show-overflow-tooltip />
|
|
|
|
|
- <el-table-column :label="`${metricLabel}(${unit})`" min-width="130">
|
|
|
|
|
- <template #default="{ row }"><span :class="{ danger: row.value == null || row.status === 1 || row.status === '1' }">{{ row.value ?? '-' }}</span></template>
|
|
|
|
|
- </el-table-column>
|
|
|
|
|
- <el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="statusType(row)">{{ statusText(row) }}</el-tag></template></el-table-column>
|
|
|
|
|
- <el-table-column prop="updateTime" label="采集时间" min-width="165" />
|
|
|
|
|
- <el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click.stop="showTrend(row)">趋势</el-button></template></el-table-column>
|
|
|
|
|
- </el-table>
|
|
|
|
|
- <el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="page.total" layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" @change="loadData" />
|
|
|
|
|
- </el-card>
|
|
|
|
|
- <el-drawer v-model="trendVisible" class="detail-drawer" direction="rtl" :title="`${current?.equipmentName || ''} - ${title}趋势`" size="520px">
|
|
|
|
|
- <div ref="chartRef" class="trend-chart"></div>
|
|
|
|
|
- <el-descriptions v-if="current" :column="1" border><el-descriptions-item label="设备编码">{{ current.equipmentCode }}</el-descriptions-item><el-descriptions-item label="安装位置">{{ current.location }}</el-descriptions-item><el-descriptions-item label="当前值">{{ current.value ?? '-' }} {{ current.unit }}</el-descriptions-item></el-descriptions>
|
|
|
|
|
- </el-drawer>
|
|
|
|
|
- </div>
|
|
|
|
|
</template>
|
|
</template>
|
|
|
-
|
|
|
|
|
<script setup>
|
|
<script setup>
|
|
|
-import { computed, nextTick, onMounted, ref } from 'vue'
|
|
|
|
|
-import { Clock, DataAnalysis, Monitor, Refresh, Warning } from '@element-plus/icons-vue'
|
|
|
|
|
-import { ElMessage } from 'element-plus'
|
|
|
|
|
-import * as echarts from 'echarts'
|
|
|
|
|
-import { normalizeMonitoringRecord, normalizePage, unwrapResponse } from '@/utils/waterSupplyModel'
|
|
|
|
|
-
|
|
|
|
|
-const props = defineProps({ title: { type: String, required: true }, metricLabel: { type: String, required: true }, unit: { type: String, default: 'm³/h' }, minKey: { type: String, default: 'flowMin' }, maxKey: { type: String, default: 'flowMax' }, rangeLabel: { type: String, default: '数值范围' }, load: { type: Function, required: true }, trend: { type: Function, required: true }, exportApi: { type: Function, required: true }, metric: { type: String, default: 'value' }, monitorType: { type: String, default: '' } })
|
|
|
|
|
-const pageIcon = computed(() => props.metric === 'pressure' ? Warning : props.metric === 'power' ? DataAnalysis : Monitor)
|
|
|
|
|
-const query = ref({ pageNum: 1, pageSize: 10, equipmentName: '', equipmentCode: '', equipmentLocation: '', [props.minKey]: undefined, [props.maxKey]: undefined })
|
|
|
|
|
-const page = ref({ records: [], total: 0 })
|
|
|
|
|
-const loading = ref(false)
|
|
|
|
|
-const current = ref(null)
|
|
|
|
|
-const trendVisible = ref(false)
|
|
|
|
|
-const chartRef = ref(null)
|
|
|
|
|
-let chart
|
|
|
|
|
-const loadData = async () => { loading.value = true; try { const response = await props.load({ ...query.value }); const normalized = normalizePage(response); normalized.records = normalized.records.map(row => normalizeMonitoringRecord(row, props.metric, props.monitorType)); page.value = normalized } catch (error) { page.value = { records: [], total: 0 }; console.warn(`[${props.title}] 数据加载失败`, error) } finally { loading.value = false } }
|
|
|
|
|
-const resetQuery = () => { query.value = { pageNum: 1, pageSize: 10, equipmentName: '', equipmentCode: '', equipmentLocation: '', [props.minKey]: undefined, [props.maxKey]: undefined }; loadData() }
|
|
|
|
|
-const averageValue = computed(() => { const values = page.value.records.map(item => item.value).filter(value => Number.isFinite(value)); return values.length ? (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2) : '-' })
|
|
|
|
|
-const abnormalCount = computed(() => page.value.records.filter(item => item.status === 1 || item.status === '1' || item.value == null).length)
|
|
|
|
|
-const latestTime = computed(() => page.value.records.map(item => item.updateTime).filter(Boolean).sort().at(-1) || '-')
|
|
|
|
|
-const statusText = row => row.status === 1 || row.status === '1' ? '异常' : row.status === 0 || row.status === '0' ? '正常' : row.status ? '在线' : '未知'
|
|
|
|
|
-const statusType = row => statusText(row) === '异常' ? 'danger' : statusText(row) === '正常' || statusText(row) === '在线' ? 'success' : 'info'
|
|
|
|
|
-const showTrend = async row => { current.value = row; trendVisible.value = true; await nextTick(); const response = await props.trend(row.id); const data = unwrapResponse(response); const records = Array.isArray(data) ? data : data?.records || []; const labels = records.map(item => item.time || item.monitorTime || item.createTime || ''); const values = records.map(item => normalizeMonitoringRecord(item, props.metric, props.monitorType).value ?? 0); chart?.dispose(); chart = echarts.init(chartRef.value); chart.setOption({ tooltip: { trigger: 'axis' }, grid: { left: 40, right: 18, top: 20, bottom: 35 }, xAxis: { type: 'category', data: labels }, yAxis: { type: 'value', name: props.unit }, series: [{ type: 'line', smooth: true, data: values, areaStyle: { opacity: 0.12 }, itemStyle: { color: '#2563eb' } }] }) }
|
|
|
|
|
-const exportData = async () => { try { await props.exportApi({ ...query.value }); ElMessage.success('导出请求已提交') } catch (error) { ElMessage.warning('当前接口暂不支持文件流导出') } }
|
|
|
|
|
-onMounted(loadData)
|
|
|
|
|
|
|
+ import {computed, nextTick, onBeforeUnmount, onMounted, ref} from 'vue'
|
|
|
|
|
+ import {
|
|
|
|
|
+ Clock,
|
|
|
|
|
+ DataAnalysis,
|
|
|
|
|
+ DataLine,
|
|
|
|
|
+ Download,
|
|
|
|
|
+ Monitor,
|
|
|
|
|
+ Refresh,
|
|
|
|
|
+ RefreshLeft,
|
|
|
|
|
+ Search,
|
|
|
|
|
+ TrendCharts,
|
|
|
|
|
+ Warning
|
|
|
|
|
+ } from '@element-plus/icons-vue'
|
|
|
|
|
+ import {ElMessage} from 'element-plus'
|
|
|
|
|
+ import {saveAs} from 'file-saver'
|
|
|
|
|
+ import * as echarts from 'echarts'
|
|
|
|
|
+ import {normalizeMonitoringRecord, normalizePage, readMonitoringExportError, unwrapResponse} from '@/utils/waterSupplyModel'
|
|
|
|
|
+ import {buildMonitoringQuery, buildTrendQuery} from '@/utils/waterSupplyMonitoringQuery'
|
|
|
|
|
+
|
|
|
|
|
+ const props = defineProps({
|
|
|
|
|
+ title: {type: String, required: true},
|
|
|
|
|
+ metricLabel: {type: String, required: true},
|
|
|
|
|
+ unit: {type: String, default: 'm³/h'},
|
|
|
|
|
+ minKey: {type: String, default: 'flowMin'},
|
|
|
|
|
+ maxKey: {type: String, default: 'flowMax'},
|
|
|
|
|
+ rangeLabel: {type: String, default: '数值范围'},
|
|
|
|
|
+ load: {type: Function, required: true},
|
|
|
|
|
+ trend: {type: Function, required: true},
|
|
|
|
|
+ exportApi: {type: Function, required: true},
|
|
|
|
|
+ exportPermission: {type: String, required: true},
|
|
|
|
|
+ metric: {type: String, default: 'value'},
|
|
|
|
|
+ monitorType: {type: String, default: ''}
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const pageIcon = computed(() => props.metric === 'pressure' ? Warning : props.metric === 'power' ? DataAnalysis : Monitor)
|
|
|
|
|
+ const metricColor = computed(() => props.metric === 'pressure' ? '#f97316' : props.metric === 'power' ? '#06b6d4' : '#2563eb')
|
|
|
|
|
+ const query = ref(createQuery())
|
|
|
|
|
+ const page = ref({records: [], total: 0})
|
|
|
|
|
+ const loading = ref(false)
|
|
|
|
|
+ const loadError = ref(false)
|
|
|
|
|
+ const exportLoading = ref(false)
|
|
|
|
|
+ const current = ref(null)
|
|
|
|
|
+ const trendVisible = ref(false)
|
|
|
|
|
+ const trendLoading = ref(false)
|
|
|
|
|
+ const trendError = ref(false)
|
|
|
|
|
+ const trendRecords = ref([])
|
|
|
|
|
+ const chartRef = ref(null)
|
|
|
|
|
+ let chart
|
|
|
|
|
+
|
|
|
|
|
+ function createQuery() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ pageNum: 1,
|
|
|
|
|
+ pageSize: 10,
|
|
|
|
|
+ equipmentName: '',
|
|
|
|
|
+ equipmentCode: '',
|
|
|
|
|
+ equipmentLocation: '',
|
|
|
|
|
+ timeRange: [],
|
|
|
|
|
+ [props.minKey]: undefined,
|
|
|
|
|
+ [props.maxKey]: undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function loadData() {
|
|
|
|
|
+ loading.value = true
|
|
|
|
|
+ loadError.value = false
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await props.load(buildMonitoringQuery(query.value, props.minKey, props.maxKey))
|
|
|
|
|
+ const normalized = normalizePage(response)
|
|
|
|
|
+ normalized.records = normalized.records.map(row => normalizeMonitoringRecord(row, props.metric, props.monitorType))
|
|
|
|
|
+ page.value = normalized
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ loadError.value = true
|
|
|
|
|
+ console.warn(`[${props.title}] 数据加载失败`, error)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ loading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function resetQuery() {
|
|
|
|
|
+ query.value = createQuery()
|
|
|
|
|
+ loadData()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const averageValue = computed(() => {
|
|
|
|
|
+ const values = page.value.records.map(item => item.value).filter(value => Number.isFinite(value))
|
|
|
|
|
+ return values.length ? (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2) : '-'
|
|
|
|
|
+ })
|
|
|
|
|
+ const abnormalCount = computed(() => page.value.records.filter(item =>
|
|
|
|
|
+ item.status === 1 || item.status === '1' || item.value == null
|
|
|
|
|
+ ).length)
|
|
|
|
|
+ const latestTime = computed(() =>
|
|
|
|
|
+ page.value.records.map(item => item.updateTime).filter(Boolean).sort().at(-1) || '-'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ function statusText(row) {
|
|
|
|
|
+ if (row.status === 1 || row.status === '1') return '异常'
|
|
|
|
|
+ if (row.status === 0 || row.status === '0') return '正常'
|
|
|
|
|
+ return row.status ? '在线' : '未知'
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function statusType(row) {
|
|
|
|
|
+ const text = statusText(row)
|
|
|
|
|
+ return text === '异常' ? 'danger' : text === '正常' || text === '在线' ? 'success' : 'info'
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function showTrend(row) {
|
|
|
|
|
+ current.value = row
|
|
|
|
|
+ trendVisible.value = true
|
|
|
|
|
+ await loadTrend(true)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function loadTrend(resetTrend = false) {
|
|
|
|
|
+ if (!current.value?.id) return
|
|
|
|
|
+ trendLoading.value = true
|
|
|
|
|
+ trendError.value = false
|
|
|
|
|
+ if (resetTrend) {
|
|
|
|
|
+ trendRecords.value = []
|
|
|
|
|
+ chart?.dispose()
|
|
|
|
|
+ chart = undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await props.trend(current.value.id, buildTrendQuery(query.value.timeRange))
|
|
|
|
|
+ const data = unwrapResponse(response)
|
|
|
|
|
+ const records = Array.isArray(data) ? data : data?.records || []
|
|
|
|
|
+ trendRecords.value = records.map(item => normalizeMonitoringRecord(item, props.metric, props.monitorType))
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ renderTrend()
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ trendError.value = true
|
|
|
|
|
+ if (resetTrend) {
|
|
|
|
|
+ chart?.dispose()
|
|
|
|
|
+ chart = undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ console.warn(`[${props.title}] 趋势数据加载失败`, error)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ trendLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function renderTrend() {
|
|
|
|
|
+ chart?.dispose()
|
|
|
|
|
+ chart = undefined
|
|
|
|
|
+ if (!trendRecords.value.length) return
|
|
|
|
|
+ chart = echarts.init(chartRef.value)
|
|
|
|
|
+ const labels = trendRecords.value.map(item => item.updateTime)
|
|
|
|
|
+ const values = trendRecords.value.map(item => item.value ?? 0)
|
|
|
|
|
+ const color = metricColor.value
|
|
|
|
|
+ chart.setOption({
|
|
|
|
|
+ tooltip: {
|
|
|
|
|
+ trigger: 'axis',
|
|
|
|
|
+ axisPointer: {type: 'line', lineStyle: {color: 'rgba(37, 99, 235, 0.25)'}}
|
|
|
|
|
+ },
|
|
|
|
|
+ grid: {left: 48, right: 20, top: 34, bottom: 38},
|
|
|
|
|
+ xAxis: {
|
|
|
|
|
+ type: 'category',
|
|
|
|
|
+ data: labels,
|
|
|
|
|
+ axisLine: {lineStyle: {color: '#cbd5e1'}},
|
|
|
|
|
+ axisLabel: {color: '#64748b'},
|
|
|
|
|
+ axisTick: {show: false}
|
|
|
|
|
+ },
|
|
|
|
|
+ yAxis: {
|
|
|
|
|
+ type: 'value',
|
|
|
|
|
+ name: props.unit,
|
|
|
|
|
+ nameTextStyle: {color: '#64748b'},
|
|
|
|
|
+ axisLine: {show: false},
|
|
|
|
|
+ axisLabel: {color: '#64748b'},
|
|
|
|
|
+ splitLine: {lineStyle: {color: '#e2e8f0', type: 'dashed'}}
|
|
|
|
|
+ },
|
|
|
|
|
+ series: [{
|
|
|
|
|
+ type: 'line',
|
|
|
|
|
+ smooth: true,
|
|
|
|
|
+ data: values,
|
|
|
|
|
+ symbol: 'circle',
|
|
|
|
|
+ symbolSize: 7,
|
|
|
|
|
+ lineStyle: {width: 3, color},
|
|
|
|
|
+ itemStyle: {color, borderColor: '#fff', borderWidth: 2},
|
|
|
|
|
+ areaStyle: {
|
|
|
|
|
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
|
|
+ {offset: 0, color: `${color}33`},
|
|
|
|
|
+ {offset: 1, color: `${color}05`}
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+ }]
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function exportData() {
|
|
|
|
|
+ exportLoading.value = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await props.exportApi(buildMonitoringQuery(query.value, props.minKey, props.maxKey))
|
|
|
|
|
+ const blob = response instanceof Blob
|
|
|
|
|
+ ? response
|
|
|
|
|
+ : new Blob([response], {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'})
|
|
|
|
|
+ const exportError = await readMonitoringExportError(blob)
|
|
|
|
|
+ if (exportError) {
|
|
|
|
|
+ ElMessage.error(exportError)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!blob.size) throw new Error('导出文件为空')
|
|
|
|
|
+ saveAs(blob, `${props.title}数据_${new Date().toISOString().slice(0, 10)}.xlsx`)
|
|
|
|
|
+ ElMessage.success('导出完成')
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.warn(`[${props.title}] 导出失败`, error)
|
|
|
|
|
+ ElMessage.error('导出失败,请稍后重试')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ exportLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ onMounted(loadData)
|
|
|
|
|
+ onBeforeUnmount(() => {
|
|
|
|
|
+ chart?.dispose()
|
|
|
|
|
+ chart = undefined
|
|
|
|
|
+ })
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<style lang="scss">
|
|
<style lang="scss">
|
|
|
-@use './waterSupplyPageTheme.scss';
|
|
|
|
|
|
|
+ @use './waterSupplyPageTheme.scss';
|
|
|
</style>
|
|
</style>
|
|
|
|
|
|
|
|
<style scoped>
|
|
<style scoped>
|
|
|
-.water-page{padding:20px;background:#f5f7fa;min-height:calc(100vh - 84px)}.page-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.page-heading h2{margin:0 0 5px;font-size:20px;color:#1f2937}.muted{font-size:13px;color:#909399}.filter-card{margin-bottom:16px}.filter-card :deep(.el-form-item){margin-bottom:0}.range-sep{margin:0 8px;color:#909399}.metric-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.metric{background:#fff;border:1px solid #ebeef5;padding:16px 18px;min-height:70px}.metric span{display:block;color:#909399;font-size:13px}.metric strong{font-size:24px;line-height:35px;color:#1f2937}.metric small{margin-left:5px;color:#909399}.metric .time{font-size:16px}.danger{color:#e45656!important}.table-card{min-height:420px}.card-title{display:flex;align-items:center;justify-content:space-between;font-weight:600;margin-bottom:14px}.table-card :deep(.el-pagination){justify-content:flex-end;margin-top:16px}.trend-chart{height:300px;margin-bottom:20px}@media(max-width:900px){.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.filter-card :deep(.el-form-item){margin-bottom:12px}}
|
|
|
|
|
|
|
+ .water-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
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .heading-refresh {
|
|
|
|
|
+ border-color: #bfdbfe;
|
|
|
|
|
+ color: #2563eb;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .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-bottom: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .filter-card :deep(.el-form-item__label) {
|
|
|
|
|
+ color: #475569
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .range-sep {
|
|
|
|
|
+ margin: 0 8px;
|
|
|
|
|
+ color: #94a3b8
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .page-error {
|
|
|
|
|
+ margin-bottom: 16px
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .metric-grid {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
|
|
|
+ gap: 14px;
|
|
|
|
|
+ margin-bottom: 16px
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .metric {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 14px;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ padding: 16px 18px;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .metric-icon {
|
|
|
|
|
+ position: static;
|
|
|
|
|
+ flex: 0 0 42px;
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ place-items: center;
|
|
|
|
|
+ width: 42px;
|
|
|
|
|
+ height: 42px;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .metric-icon .el-icon {
|
|
|
|
|
+ font-size: 22px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .metric-icon--blue {
|
|
|
|
|
+ color: #2563eb;
|
|
|
|
|
+ background: linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .metric-icon--cyan {
|
|
|
|
|
+ color: #0891b2;
|
|
|
|
|
+ background: linear-gradient(135deg, #cffafe 0%, #ecfeff 100%);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .metric-icon--orange {
|
|
|
|
|
+ color: #ea580c;
|
|
|
|
|
+ background: linear-gradient(135deg, #ffedd5 0%, #fff7ed 100%);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .metric-icon--slate {
|
|
|
|
|
+ color: #475569;
|
|
|
|
|
+ background: linear-gradient(135deg, #e2e8f0 0%, #f8fafc 100%);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .metric-body {
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ text-align: left;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .metric-body span {
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ margin-bottom: 3px;
|
|
|
|
|
+ color: #64748b;
|
|
|
|
|
+ font-size: 13px
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .metric-value {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: baseline;
|
|
|
|
|
+ gap: 5px;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .metric-value strong {
|
|
|
|
|
+ font-size: 24px;
|
|
|
|
|
+ line-height: 1.2;
|
|
|
|
|
+ color: #1e293b;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .metric-value small {
|
|
|
|
|
+ color: #94a3b8
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .metric .time {
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ font-size: 16px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .danger {
|
|
|
|
|
+ color: #ef4444 !important
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .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 {
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .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;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .table-card :deep(.el-table__row) {
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .trend-button {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 4px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .table-card :deep(.el-pagination) {
|
|
|
|
|
+ justify-content: flex-end;
|
|
|
|
|
+ margin-top: 16px
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .trend-body {
|
|
|
|
|
+ min-height: 340px;
|
|
|
|
|
+ padding-right: 4px
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .trend-chart {
|
|
|
|
|
+ height: 300px;
|
|
|
|
|
+ margin-bottom: 20px;
|
|
|
|
|
+ padding: 12px;
|
|
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ background: #fff
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .trend-body :deep(.el-empty) {
|
|
|
|
|
+ margin-bottom: 20px
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @media (max-width: 900px) {
|
|
|
|
|
+ .metric-grid {
|
|
|
|
|
+ grid-template-columns:repeat(2, minmax(0, 1fr))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .filter-card :deep(.el-form-item) {
|
|
|
|
|
+ margin-bottom: 12px
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
</style>
|
|
</style>
|