| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655 |
- <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">按时间与数值范围查询实时点位,并查看趋势明细</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>
- </template>
- <script setup>
- 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>
- <style lang="scss">
- @use './waterSupplyPageTheme.scss';
- </style>
- <style scoped>
- .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-top: 8px;
- margin-right: 16px;
- margin-bottom: 8px
- }
- .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>
|