Parcourir la source

1.pc端供水子系统-流量监测开发
2.pc端供水子系统-压力监测开发
3.pc端供水子系统-漏失监测开发

zengy il y a 1 jour
Parent
commit
5e2d46779e

+ 15 - 9
src/api/pipeNetwork/waterSupply.js

@@ -2,17 +2,23 @@ import request from '@/utils/request'
 
 const get = (url, params) => request({ url, method: 'get', params })
 
-export const listFlowMonitoring = (params) => get('/waterSupply/monitoring/flow/page', params)
-export const getFlowTrend = (id, params) => get(`/waterSupply/monitoring/flow/trend/${id}`, params)
-export const exportFlowMonitoring = (params) => request({ url: '/waterSupply/monitoring/flow/export', method: 'get', params, responseType: 'blob' })
+// 供水监测原始采集记录列表(按 warning_threshold 判定告警)
+export const listFlowRecords = (params) => get('/waterSupply/monitoring/flow/records', params)
+export const listSupplyPressureRecords = (params) => get('/waterSupply/monitoring/pressure/supply/records', params)
+export const listHydrantPressureRecords = (params) => get('/waterSupply/monitoring/pressure/hydrant/records', params)
+export const listLeakageRecords = (params) => get('/waterSupply/monitoring/leakage/records', params)
 
-export const listPressureMonitoring = (params) => get('/waterSupply/monitoring/pressure/page', params)
-export const getPressureTrend = (id, params) => get(`/waterSupply/monitoring/pressure/trend/${id}`, params)
-export const exportPressureMonitoring = (params) => request({ url: '/waterSupply/monitoring/pressure/export', method: 'get', params, responseType: 'blob' })
+// 供水监测概览(顶部统计指标 + 7 天趋势)
+export const getFlowOverview = (params) => get('/waterSupply/monitoring/flow/overview', params)
+export const getSupplyPressureOverview = (params) => get('/waterSupply/monitoring/pressure/supply/overview', params)
+export const getHydrantPressureOverview = (params) => get('/waterSupply/monitoring/pressure/hydrant/overview', params)
+export const getLeakageOverview = (params) => get('/waterSupply/monitoring/leakage/overview', params)
 
-export const listLeakageMonitoring = (params) => get('/waterSupply/monitoring/leakage/page', params)
-export const getLeakageTrend = (id, params) => get(`/waterSupply/monitoring/leakage/trend/${id}`, params)
-export const exportLeakageMonitoring = (params) => request({ url: '/waterSupply/monitoring/leakage/export', method: 'get', params, responseType: 'blob' })
+// 供水监测原始采集记录导出
+export const exportFlowRecords = (params) => request({ url: '/waterSupply/monitoring/flow/export', method: 'get', params, responseType: 'blob' })
+export const exportSupplyPressureRecords = (params) => request({ url: '/waterSupply/monitoring/pressure/supply/export', method: 'get', params, responseType: 'blob' })
+export const exportHydrantPressureRecords = (params) => request({ url: '/waterSupply/monitoring/pressure/hydrant/export', method: 'get', params, responseType: 'blob' })
+export const exportLeakageRecords = (params) => request({ url: '/waterSupply/monitoring/leakage/export', method: 'get', params, responseType: 'blob' })
 
 export const getAlarmStats = () => get('/waterSupply/alarm/stats')
 export const listRealtimeAlarms = () => get('/waterSupply/alarm/realtime')

+ 24 - 0
src/utils/exportTable.js

@@ -0,0 +1,24 @@
+import * as XLSX from 'xlsx'
+import { saveAs } from 'file-saver'
+
+/**
+ * 按列定义将表格数据导出为 xlsx
+ * @param {Object} opts
+ * @param {Array} opts.rows 行数据
+ * @param {Array<{prop:string,label:string,format?:(v:any)=>any}>} opts.columns 列定义,顺序即导出顺序
+ * @param {string} opts.filename 文件名
+ * @param {string} [opts.sheetName] 工作表名
+ */
+export function exportTableToXlsx({ rows, columns, filename, sheetName = '导出' }) {
+  const header = columns.map((c) => c.label)
+  const body = (rows || []).map((row) =>
+    columns.map((c) => {
+      const raw = row ? row[c.prop] : ''
+      return c.format ? c.format(raw) : raw
+    })
+  )
+  const sheet = XLSX.utils.aoa_to_sheet([header, ...body])
+  const book = XLSX.utils.book_new()
+  XLSX.utils.book_append_sheet(book, sheet, sheetName)
+  saveAs(new Blob([XLSX.write(book, { bookType: 'xlsx', type: 'array' })]), filename)
+}

+ 0 - 48
src/utils/waterSupplyMonitoringQuery.js

@@ -1,48 +0,0 @@
-const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
-
-function pad(value) {
-  return String(value).padStart(2, '0')
-}
-
-export function formatMonitoringDateTime(value) {
-  if (value == null || value === '') return undefined
-  if (value instanceof Date) {
-    return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`
-  }
-  const text = String(value).trim()
-  return text || undefined
-}
-
-function hasValue(value) {
-  return value !== undefined && value !== null && value !== ''
-}
-
-function numberOrNull(value) {
-  return hasValue(value) && Number.isFinite(Number(value)) ? Number(value) : undefined
-}
-
-export function buildMonitoringQuery(query = {}, minKey = 'flowMin', maxKey = 'flowMax') {
-  const timeRange = Array.isArray(query.timeRange) ? query.timeRange : []
-  const result = {
-    pageNum: query.pageNum ?? 1,
-    pageSize: query.pageSize ?? 10,
-    equipmentName: hasValue(query.equipmentName) ? query.equipmentName : undefined,
-    equipmentCode: hasValue(query.equipmentCode) ? query.equipmentCode : undefined,
-    equipmentLocation: hasValue(query.equipmentLocation) ? query.equipmentLocation : undefined,
-    startTime: formatMonitoringDateTime(timeRange[0]),
-    endTime: formatMonitoringDateTime(timeRange[1]),
-    [minKey]: numberOrNull(query[minKey]),
-    [maxKey]: numberOrNull(query[maxKey])
-  }
-  return Object.fromEntries(Object.entries(result).filter(([, value]) => hasValue(value)))
-}
-
-export function buildTrendQuery(timeRange = []) {
-  const range = Array.isArray(timeRange) ? timeRange : []
-  return Object.fromEntries(Object.entries({
-    startTime: formatMonitoringDateTime(range[0]),
-    endTime: formatMonitoringDateTime(range[1])
-  }).filter(([, value]) => hasValue(value)))
-}
-
-export { DATE_TIME_FORMAT }

+ 38 - 0
src/views/subSystem/waterSupply/components/DeviceTrendDialog.vue

@@ -0,0 +1,38 @@
+<template>
+  <el-dialog
+    :model-value="modelValue"
+    :title="title"
+    width="920px"
+    destroy-on-close
+    @update:model-value="(v) => emit('update:modelValue', v)"
+  >
+    <MonitorTrendChart
+      :trend="trend"
+      :unit="unit"
+      :series="series"
+      :threshold-min="thresholdMin"
+      :threshold-max="thresholdMax"
+      threshold-min-label="最低阈值"
+      threshold-max-label="最高阈值"
+      :height="380"
+    />
+    <template #footer>
+      <el-button @click="emit('update:modelValue', false)">关闭</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+import MonitorTrendChart from './MonitorTrendChart.vue'
+
+const props = defineProps({
+  modelValue: { type: Boolean, default: false },
+  title: { type: String, default: '设备 7 日趋势' },
+  trend: { type: Array, default: () => [] },
+  unit: { type: String, default: '' },
+  series: { type: Array, default: () => [] },
+  thresholdMin: { default: null },
+  thresholdMax: { default: null }
+})
+const emit = defineEmits(['update:modelValue'])
+</script>

+ 207 - 0
src/views/subSystem/waterSupply/components/HistoryDialog.vue

@@ -0,0 +1,207 @@
+<template>
+  <el-dialog
+    :model-value="modelValue"
+    :title="title"
+    width="900px"
+    destroy-on-close
+    @update:model-value="(v) => emit('update:modelValue', v)"
+  >
+    <div class="history-bar">
+      <div class="device-title" v-if="deviceTitle">
+        <el-icon><DataLine /></el-icon>
+        <span>{{ deviceTitle }}</span>
+      </div>
+      <el-button
+        type="primary"
+        plain
+        :icon="Download"
+        :loading="exporting"
+        size="small"
+        @click="handleExport"
+      >导出 Excel</el-button>
+    </div>
+
+    <div class="history-filter">
+      <span class="filter-label">{{ dateLabel || '采集时间' }}</span>
+      <el-date-picker
+        v-model="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
+        style="width: 300px"
+      />
+      <el-button type="primary" :loading="loading" @click="search">查询</el-button>
+    </div>
+
+    <el-table :data="rows" v-loading="loading" border stripe :max-height="420">
+      <el-table-column
+        v-for="col in columns"
+        :key="col.prop"
+        :prop="col.prop"
+        :label="col.label"
+        :min-width="col.width"
+      >
+        <template #default="{ row }">
+          {{ col.format ? col.format(row[col.prop]) : row[col.prop] }}
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <div class="history-pager">
+      <el-pagination
+        v-model:current-page="pageNum"
+        v-model:page-size="pageSize"
+        :total="total"
+        layout="total, sizes, prev, pager, next"
+        :page-sizes="[10, 20, 50]"
+        @change="load"
+      />
+    </div>
+  </el-dialog>
+</template>
+
+<script setup>
+import { ref, watch } from 'vue'
+import { DataLine, Download } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import { exportTableToXlsx } from '@/utils/exportTable'
+
+const props = defineProps({
+  modelValue: { type: Boolean, default: false },
+  title: { type: String, default: '历史采集记录' },
+  deviceKey: { type: String, default: '' },
+  deviceKeyField: { type: String, required: true },
+  apiFn: { type: Function, required: true },
+  columns: { type: Array, default: () => [] },
+  dateLabel: { type: String, default: '采集时间' },
+  deviceTitle: { type: String, default: '' }
+})
+const emit = defineEmits(['update:modelValue'])
+
+const rows = ref([])
+const total = ref(0)
+const loading = ref(false)
+const timeRange = ref([])
+const pageNum = ref(1)
+const pageSize = ref(10)
+
+function load() {
+  loading.value = true
+  const [startTime, endTime] = timeRange.value || []
+  props.apiFn({
+    [props.deviceKeyField]: props.deviceKey,
+    startTime: startTime || undefined,
+    endTime: endTime || undefined,
+    latestOnly: false,
+    pageNum: pageNum.value,
+    pageSize: pageSize.value
+  }).then((res) => {
+    const p = (res && res.data) || res || {}
+    rows.value = p.records || []
+    total.value = p.total || 0
+  }).catch(() => {
+    rows.value = []
+    total.value = 0
+  }).finally(() => {
+    loading.value = false
+  })
+}
+
+function search() {
+  pageNum.value = 1
+  load()
+}
+
+watch(() => props.modelValue, (v) => {
+  if (v) {
+    pageNum.value = 1
+    timeRange.value = []
+    load()
+  }
+})
+
+watch(() => [props.deviceKey, props.deviceKeyField], () => {
+  if (props.modelValue) {
+    pageNum.value = 1
+    load()
+  }
+})
+
+const exporting = ref(false)
+
+async function handleExport() {
+  if (!props.apiFn) return
+  exporting.value = true
+  try {
+    const [startTime, endTime] = timeRange.value || []
+    const res = await props.apiFn({
+      [props.deviceKeyField]: props.deviceKey,
+      startTime: startTime || undefined,
+      endTime: endTime || undefined,
+      latestOnly: false,
+      pageNum: 1,
+      pageSize: 10000
+    })
+    const list = (res && res.data && res.data.records) || (res && res.data) || []
+    if (!Array.isArray(list) || !list.length) {
+      ElMessage.warning('当前条件下没有可导出的记录')
+      return
+    }
+    exportTableToXlsx({
+      rows: list,
+      columns: props.columns,
+      filename: `${props.deviceKey || 'history'}_${props.title || '历史记录'}.xlsx`,
+      sheetName: '历史记录'
+    })
+    ElMessage.success(`已导出 ${list.length} 条记录`)
+  } catch (e) {
+    console.warn('[history] 导出失败', e)
+    ElMessage.error('导出失败,请稍后重试')
+  } finally {
+    exporting.value = false
+  }
+}
+</script>
+
+<style scoped>
+.history-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.device-title {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #1e293b;
+}
+
+.history-filter {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.filter-label {
+  font-size: 14px;
+  color: #475569;
+  white-space: nowrap;
+}
+
+.history-pager {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 12px;
+}
+</style>

+ 138 - 0
src/views/subSystem/waterSupply/components/MonitorTrendChart.vue

@@ -0,0 +1,138 @@
+<template>
+  <div ref="chartRef" class="trend-chart" :style="{ height: typeof height === 'number' ? height + 'px' : height }"></div>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
+import * as echarts from 'echarts'
+
+const props = defineProps({
+  // [{ time: 'YYYY-MM-DD', avg, peak, min }]
+  trend: { type: Array, default: () => [] },
+  unit: { type: String, default: '' },
+  // 折线定义:{ name, key: 'avg'|'peak'|'min', color, area?, dashed? }
+  series: {
+    type: Array,
+    default: () => [{ name: '平均值', key: 'avg', color: '#1b6ef3', area: true }]
+  },
+  thresholdMin: { type: Number, default: null },
+  thresholdMax: { type: Number, default: null },
+  thresholdMinLabel: { type: String, default: '最低阈值' },
+  thresholdMaxLabel: { type: String, default: '最高阈值' },
+  height: { type: [Number, String], default: 360 }
+})
+
+const chartRef = ref(null)
+let chart = null
+let ro = null
+
+function hasData() {
+  return props.trend.some(p => props.series.some(s => p[s.key] != null))
+}
+
+function buildOption() {
+  if (!props.trend || !props.trend.length || !hasData()) {
+    return {
+      title: { text: '暂无数据', left: 'center', top: 'center', textStyle: { color: '#94a3b8', fontSize: 14 } },
+      xAxis: { type: 'category', show: false, data: [] },
+      yAxis: { type: 'value', show: false },
+      series: []
+    }
+  }
+  const xData = props.trend.map(p => p.time)
+  const series = props.series.map(s => {
+    const item = {
+      name: s.name,
+      type: 'line',
+      smooth: true,
+      showSymbol: true,
+      symbol: 'circle',
+      symbolSize: 6,
+      connectNulls: true,
+      data: props.trend.map(p => p[s.key]),
+      itemStyle: { color: s.color },
+      lineStyle: { color: s.color, width: 2, type: s.dashed ? 'dashed' : 'solid' }
+    }
+    if (s.area) {
+      item.areaStyle = { color: s.color, opacity: 0.12 }
+    }
+    return item
+  })
+  const markLineData = []
+  if (props.thresholdMin != null) {
+    markLineData.push({
+      yAxis: props.thresholdMin,
+      lineStyle: { color: '#e5484d', type: 'dashed' },
+      label: { formatter: `${props.thresholdMinLabel} ${props.thresholdMin}` }
+    })
+  }
+  if (props.thresholdMax != null) {
+    markLineData.push({
+      yAxis: props.thresholdMax,
+      lineStyle: { color: '#f0a020', type: 'dashed' },
+      label: { formatter: `${props.thresholdMaxLabel} ${props.thresholdMax}` }
+    })
+  }
+  if (markLineData.length && series.length) {
+    series[0].markLine = { silent: true, symbol: 'none', data: markLineData }
+  }
+  return {
+    tooltip: { trigger: 'axis' },
+    legend: { data: series.map(s => s.name), bottom: 0, icon: 'roundRect', textStyle: { color: '#475569' } },
+    grid: { left: 64, right: 28, top: 36, bottom: 48 },
+    xAxis: {
+      type: 'category',
+      data: xData,
+      boundaryGap: false,
+      axisLine: { lineStyle: { color: '#cbd5e1' } },
+      axisTick: { show: false },
+      axisLabel: { color: '#64748b', fontSize: 12, formatter: v => (v ? v.slice(5) : v) }
+    },
+    yAxis: {
+      type: 'value',
+      name: props.unit,
+      nameLocation: 'end',
+      nameGap: 10,
+      nameTextStyle: { color: '#64748b', fontSize: 12 },
+      scale: true,
+      axisLabel: { color: '#64748b', fontSize: 12 },
+      splitLine: { lineStyle: { color: '#eef2f7' } }
+    },
+    series
+  }
+}
+
+function render() {
+  if (chart) chart.setOption(buildOption(), true)
+}
+
+function resize() {
+  if (chart) chart.resize()
+}
+
+onMounted(() => {
+  chart = echarts.init(chartRef.value)
+  render()
+  nextTick(() => resize())
+  window.addEventListener('resize', resize)
+  if (typeof ResizeObserver !== 'undefined' && chartRef.value) {
+    ro = new ResizeObserver(() => resize())
+    ro.observe(chartRef.value)
+  }
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', resize)
+  if (ro) ro.disconnect()
+  if (chart) chart.dispose()
+})
+
+watch(() => props.trend, () => nextTick(render), { deep: true })
+watch(() => [props.thresholdMin, props.thresholdMax], render)
+</script>
+
+<style scoped>
+.trend-chart {
+  width: 100%;
+}
+</style>

+ 0 - 656
src/views/subSystem/waterSupply/components/WaterMonitoringPage.vue

@@ -1,656 +0,0 @@
-<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.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'
-    ).length)
-    const latestTime = computed(() =>
-        page.value.records.map(item => item.updateTime).filter(Boolean).sort().at(-1) || '-'
-    )
-
-    function statusText(row) {
-        if (row.status === 'no-data') return '暂无数据'
-        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>

+ 542 - 1
src/views/subSystem/waterSupply/monitoring/flow.vue

@@ -1 +1,542 @@
-<script setup>import Page from '../wyxjc/lljc.vue'</script><template><Page /></template>
+<template>
+  <div class="monitor-page">
+    <div class="page-heading">
+      <div class="page-title">
+        <span class="page-title-icon"><el-icon><Odometer /></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) }}<em>m³/h</em></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) }}<em>m³/h</em></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:#e6f0ff;color:#2563eb"><el-icon><Bottom /></el-icon></span>
+        </div>
+        <div class="k-val">{{ fmt(overview.minValue) }}<em>m³/h</em></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.deviceCode" 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="deviceCode" 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="最新瞬时流量(m³/h)" min-width="160">
+          <template #default="{ row }">
+            <span class="val-cell">{{ fmtNum(row.meter1InstantFlow, 1) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="最新流速(m/s)" min-width="130">
+          <template #default="{ row }">
+            <span class="val-cell">{{ fmtNum(row.flowSpeed, 2) }}</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="deviceCode"
+      :api-fn="listFlowRecords"
+      :columns="historyColumns"
+      :device-title="historyDeviceTitle"
+    />
+
+    <DeviceTrendDialog
+      v-model="trendVisible"
+      :title="trendTitle"
+      :trend="trendData"
+      unit="m³/h"
+      :series="trendSeries"
+      :threshold-min="trendThresholdMin"
+      :threshold-max="trendThresholdMax"
+    />
+
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { Bottom, DataLine, Document, Download, List, Odometer, Refresh, RefreshLeft, Search, Top } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import { getFlowOverview, listFlowRecords } from '@/api/pipeNetwork/waterSupply'
+import { exportTableToXlsx } from '@/utils/exportTable'
+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, minValue: null, thresholdMin: null, thresholdMax: null, trend: [], lastUpdate: '' })
+const query = ref(createQuery())
+
+function createQuery() {
+  return { pageNum: 1, pageSize: 10, deviceCode: '' }
+}
+
+function fmt(value) {
+  return value == null ? '--' : Number(value).toFixed(1)
+}
+
+function buildParams() {
+  return {
+    pageNum: query.value.pageNum,
+    pageSize: query.value.pageSize,
+    deviceCode: query.value.deviceCode || undefined,
+    latestOnly: true
+  }
+}
+
+async function loadData() {
+  loading.value = true
+  loadError.value = false
+  try {
+    const res = await listFlowRecords(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 getFlowOverview({})
+    overview.value = Object.assign({ avgValue: null, peakValue: null, minValue: null, thresholdMin: null, thresholdMax: null, trend: [], lastUpdate: '' }, (res && res.data) || res || {})
+  } catch (e) {
+    console.warn('[flow] 概览加载失败', e)
+  }
+}
+
+function loadAll() {
+  loadData()
+  loadOverview()
+}
+
+const exportColumns = [
+  { prop: 'deviceCode', label: '设备编码' },
+  { prop: 'deviceName', label: '设备名称' },
+  { prop: 'deviceType', label: '设备类型' },
+  { prop: 'meter1InstantFlow', label: '最新瞬时流量(m³/h)', format: (v) => fmtNum(v, 1) },
+  { prop: 'flowSpeed', label: '最新流速(m/s)', format: (v) => fmtNum(v, 2) },
+  { prop: 'createTime', label: '最新采集时间' }
+]
+
+async function handleExport() {
+  exporting.value = true
+  try {
+    const res = await listFlowRecords({
+      deviceCode: query.value.deviceCode || 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('[flow] 导出失败', e)
+    ElMessage.error('导出失败,请稍后重试')
+  } finally {
+    exporting.value = false
+  }
+}
+
+const historyVisible = ref(false)
+const historyDeviceKey = ref('')
+const historyDeviceTitle = ref('')
+const historyColumns = [
+  { prop: 'meter1InstantFlow', label: '瞬时流量(m³/h)', width: 160, format: (v) => fmtNum(v, 1) },
+  { prop: 'flowSpeed', label: '流速(m/s)', width: 130, format: (v) => fmtNum(v, 2) },
+  { prop: 'createTime', label: '采集时间', width: 170 }
+]
+const trendSeries = [
+  { name: '日均流量', key: 'avg', color: '#1b6ef3', area: true },
+  { name: '日内最高', key: 'peak', color: '#a9c6fb', dashed: true },
+  { name: '日内最低', key: 'min', color: '#f59e0b', dashed: true }
+]
+
+function openHistory(row) {
+  historyDeviceKey.value = row.deviceCode
+  historyDeviceTitle.value = `${row.deviceCode}.${row.deviceName || row.deviceCode}`
+  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 getFlowOverview({ deviceCode: row.deviceCode, 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.deviceCode} · 近 7 日趋势(截至 ${row.createTime})`
+    trendVisible.value = true
+  } catch (e) {
+    console.warn('[flow] 趋势加载失败', e)
+  }
+}
+
+function resetQuery() {
+  query.value = createQuery()
+  loadData()
+}
+
+function fmtNum(value, digits = 1) {
+  if (value === null || value === undefined || String(value).trim() === '') return '-'
+  const n = Number(value)
+  return Number.isFinite(n) ? n.toFixed(digits) : String(value)
+}
+
+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(3, 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-val em {
+  margin-left: 6px;
+  font-size: 13px;
+  font-weight: 500;
+  font-style: normal;
+  color: #94a3b8;
+}
+
+.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;
+}
+
+.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;
+}
+
+.val-cell .unit {
+  font-size: 12px;
+  font-style: normal;
+  color: #94a3b8;
+}
+
+.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>

+ 537 - 1
src/views/subSystem/waterSupply/monitoring/leakage.vue

@@ -1 +1,537 @@
-<script setup>import Page from '../wyxjc/lousjc.vue'</script><template><Page /></template>
+<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>

+ 629 - 1
src/views/subSystem/waterSupply/monitoring/pressure.vue

@@ -1 +1,629 @@
-<script setup>import Page from '../wyxjc/lsjc.vue'</script><template><Page /></template>
+<template>
+  <div class="monitor-page">
+    <div class="page-heading">
+      <div class="page-title">
+        <span class="page-title-icon"><el-icon><Compass /></el-icon></span>
+        <div>
+          <h2>压力监测</h2>
+          <span class="muted">供水管压力与消火栓压力原始采集记录</span>
+        </div>
+      </div>
+      <div class="head-actions">
+        <el-radio-group v-model="activeTab" @change="onTabChange">
+          <el-radio-button label="supply">供水管压力</el-radio-button>
+          <el-radio-button label="hydrant">消火栓压力</el-radio-button>
+        </el-radio-group>
+        <el-button class="heading-refresh" :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
+      </div>
+    </div>
+
+    <!-- 供水管压力 -->
+    <template v-if="activeTab === 'supply'">
+      <div class="kpis kpis-3">
+        <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(supply.avgValue) }}<em>MPa</em></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(supply.peakValue) }}<em>MPa</em></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:#e6f0ff;color:#2563eb"><el-icon><Bottom /></el-icon></span>
+          </div>
+          <div class="k-val">{{ fmt(supply.minValue) }}<em>MPa</em></div>
+          <div class="k-foot">最近 7 天单小时最低</div>
+        </div>
+      </div>
+
+    </template>
+
+    <!-- 消火栓压力 -->
+    <template v-else>
+      <div class="kpis kpis-3">
+        <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(hydrant.avgValue) }}<em>MPa</em></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(hydrant.peakValue) }}<em>MPa</em></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:#e6f0ff;color:#2563eb"><el-icon><Bottom /></el-icon></span>
+          </div>
+          <div class="k-val">{{ fmt(hydrant.minValue) }}<em>MPa</em></div>
+          <div class="k-foot">最近 7 天单小时最低</div>
+        </div>
+      </div>
+
+    </template>
+
+    <el-card shadow="never" class="filter-card">
+      <template #header>
+        <div class="card-title">
+          <el-icon><Search /></el-icon>
+          <span>查询条件({{ activeTab === 'supply' ? '供水管压力' : '消火栓压力' }})</span>
+        </div>
+      </template>
+      <el-form :inline="true" :model="query" @submit.prevent="loadData">
+        <el-form-item label="设备信息">
+          <el-input v-model="query.deviceCode" 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>{{ activeTab === 'supply' ? '供水管压力' : '消火栓压力' }}数据列表</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 label="设备编码" min-width="150" show-overflow-tooltip>
+          <template #default="{ row }">{{ activeTab === 'supply' ? row.deviceCode : row.telemeteringStation }}</template>
+        </el-table-column>
+        <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="最新压力(MPa)" min-width="160">
+          <template #default="{ row }">
+            <span class="val-cell">{{ activeTab === 'supply' ? fmtNum(row.pressure, 3) : fmtNum(row.pressureValue, 3) }}</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="historyTitle"
+      :device-key="historyDeviceKey"
+      :device-key-field="historyDeviceField"
+      :api-fn="historyApiFn"
+      :columns="historyColumns"
+      :device-title="historyDeviceTitle"
+    />
+
+    <DeviceTrendDialog
+      v-model="trendVisible"
+      :title="trendTitle"
+      :trend="trendData"
+      unit="MPa"
+      :series="trendSeries"
+      :threshold-min="trendThresholdMin"
+      :threshold-max="trendThresholdMax"
+    />
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { Bottom, Compass, DataLine, Document, Download, List, Refresh, RefreshLeft, Search, Top, Warning } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import {
+  getSupplyPressureOverview,
+  getHydrantPressureOverview,
+  listSupplyPressureRecords,
+  listHydrantPressureRecords
+} from '@/api/pipeNetwork/waterSupply'
+import { exportTableToXlsx } from '@/utils/exportTable'
+import HistoryDialog from '../components/HistoryDialog.vue'
+import DeviceTrendDialog from '../components/DeviceTrendDialog.vue'
+
+const activeTab = ref('supply')
+const records = ref([])
+const total = ref(0)
+const loading = ref(false)
+const exporting = ref(false)
+const loadError = ref(false)
+const query = ref(createQuery())
+const supply = ref(emptyOverview())
+const hydrant = ref(emptyOverview())
+
+function emptyOverview() {
+  return { avgValue: null, peakValue: null, minValue: null, anomalyCount: null, thresholdMin: null, thresholdMax: null, trend: [], lastUpdate: '' }
+}
+
+function createQuery() {
+  return { pageNum: 1, pageSize: 10, deviceCode: '' }
+}
+
+function fmt(value) {
+  return value == null ? '--' : Number(value).toFixed(3)
+}
+
+function buildParams() {
+  const base = {
+    pageNum: query.value.pageNum,
+    pageSize: query.value.pageSize
+  }
+  if (activeTab.value === 'supply') {
+    base.deviceCode = query.value.deviceCode || undefined
+  } else {
+    base.telemeteringStation = query.value.deviceCode || undefined
+  }
+  base.latestOnly = true
+  return base
+}
+
+async function loadData() {
+  loading.value = true
+  loadError.value = false
+  try {
+    const api = activeTab.value === 'supply' ? listSupplyPressureRecords : listHydrantPressureRecords
+    const res = await api(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 s = await getSupplyPressureOverview({})
+    supply.value = Object.assign(emptyOverview(), (s && s.data) || s || {})
+    const h = await getHydrantPressureOverview({})
+    hydrant.value = Object.assign(emptyOverview(), (h && h.data) || h || {})
+  } catch (e) {
+    console.warn('[pressure] 概览加载失败', e)
+  }
+}
+
+function loadAll() {
+  loadData()
+  loadOverview()
+}
+
+function onTabChange() {
+  query.value.pageNum = 1
+  loadData()
+}
+
+async function handleExport() {
+  exporting.value = true
+  try {
+    const isSupply = activeTab.value === 'supply'
+    const params = { pageNum: 1, pageSize: 10000, latestOnly: true }
+    if (isSupply) params.deviceCode = query.value.deviceCode || undefined
+    else params.telemeteringStation = query.value.deviceCode || undefined
+    const api = isSupply ? listSupplyPressureRecords : listHydrantPressureRecords
+    const res = await api(params)
+    const list = (res && res.data && res.data.records) || []
+    if (!list.length) {
+      ElMessage.warning('当前条件下没有可导出的记录')
+      return
+    }
+    const columns = [
+      { prop: isSupply ? 'deviceCode' : 'telemeteringStation', label: '设备编码' },
+      { prop: 'deviceName', label: '设备名称' },
+      { prop: 'deviceType', label: '设备类型' },
+      { prop: isSupply ? 'pressure' : 'pressureValue', label: '最新压力(MPa)', format: (v) => fmtNum(v, 3) },
+      { prop: 'createTime', label: '最新采集时间' }
+    ]
+    const filename = isSupply ? '供水管压力记录.xlsx' : '消火栓压力记录.xlsx'
+    exportTableToXlsx({ rows: list, columns, filename, sheetName: isSupply ? '供水管压力' : '消火栓压力' })
+    ElMessage.success(`已导出 ${list.length} 条记录`)
+  } catch (e) {
+    console.warn('[pressure] 导出失败', e)
+    ElMessage.error('导出失败,请稍后重试')
+  } finally {
+    exporting.value = false
+  }
+}
+
+const supplyTrendSeries = [
+  { name: '日平均压力', key: 'avg', color: '#1b6ef3', area: true },
+  { name: '日内最高', key: 'peak', color: '#a9c6fb', dashed: true },
+  { name: '日内最低', key: 'min', color: '#f59e0b', dashed: true }
+]
+const hydrantTrendSeries = [
+  { name: '日平均压力', key: 'avg', color: '#e5484d', area: true },
+  { name: '日内最高', key: 'peak', color: '#f5a3a3', dashed: true },
+  { name: '日内最低', key: 'min', color: '#f59e0b', dashed: true }
+]
+
+const historyVisible = ref(false)
+const historyDeviceKey = ref('')
+const historyDeviceField = ref('deviceCode')
+const historyApiFn = ref(listSupplyPressureRecords)
+const historyTitle = ref('供水管压力历史采集记录')
+const historyDeviceTitle = ref('')
+const supplyHistoryColumns = [
+  { prop: 'pressure', label: '压力(MPa)', width: 160, format: (v) => fmtNum(v, 3) },
+  { prop: 'createTime', label: '采集时间', width: 170 }
+]
+const hydrantHistoryColumns = [
+  { prop: 'pressureValue', label: '压力(MPa)', width: 160, format: (v) => fmtNum(v, 3) },
+  { prop: 'createTime', label: '采集时间', width: 170 }
+]
+const historyColumns = ref(supplyHistoryColumns)
+
+function openHistory(row) {
+  const isSupply = activeTab.value === 'supply'
+  const key = isSupply ? row.deviceCode : row.telemeteringStation
+  historyDeviceKey.value = key
+  historyDeviceField.value = isSupply ? 'deviceCode' : 'telemeteringStation'
+  historyApiFn.value = isSupply ? listSupplyPressureRecords : listHydrantPressureRecords
+  historyTitle.value = isSupply ? '供水管压力历史采集记录' : '消火栓压力历史采集记录'
+  historyDeviceTitle.value = `${key}.${row.deviceName || key}`
+  historyColumns.value = isSupply ? supplyHistoryColumns : hydrantHistoryColumns
+  historyVisible.value = true
+}
+
+const trendVisible = ref(false)
+const trendTitle = ref('')
+const trendData = ref([])
+const trendThresholdMin = ref(null)
+const trendThresholdMax = ref(null)
+const trendSeries = ref(supplyTrendSeries)
+
+async function openTrend(row) {
+  const isSupply = activeTab.value === 'supply'
+  const key = isSupply ? row.deviceCode : row.telemeteringStation
+  const field = isSupply ? 'deviceCode' : 'telemeteringStation'
+  const api = isSupply ? getSupplyPressureOverview : getHydrantPressureOverview
+  try {
+    const res = await api({ [field]: key, endTime: row.createTime })
+    const data = (res && res.data) || res || {}
+    trendData.value = data.trend || []
+    trendThresholdMin.value = data.thresholdMin ?? null
+    trendThresholdMax.value = data.thresholdMax ?? null
+    trendSeries.value = isSupply ? supplyTrendSeries : hydrantTrendSeries
+    trendTitle.value = `${row.deviceName || key} · 近 7 日趋势(截至 ${row.createTime})`
+    trendVisible.value = true
+  } catch (e) {
+    console.warn('[pressure] 趋势加载失败', e)
+  }
+}
+
+function resetQuery() {
+  query.value = createQuery()
+  loadData()
+}
+
+function fmtNum(value, digits = 1) {
+  if (value === null || value === undefined || String(value).trim() === '') return '-'
+  const n = Number(value)
+  return Number.isFinite(n) ? n.toFixed(digits) : String(value)
+}
+
+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;
+}
+
+.chart-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 12px;
+}
+
+.chart-head-tools {
+  flex: 0 0 auto;
+}
+
+.kpis {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 16px;
+  margin-bottom: 16px;
+}
+
+.kpis-3 {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.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-val em {
+  margin-left: 6px;
+  font-size: 13px;
+  font-weight: 500;
+  font-style: normal;
+  color: #94a3b8;
+}
+
+.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);
+}
+
+.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;
+}
+
+.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;
+}
+
+.val-cell .unit {
+  font-size: 12px;
+  font-style: normal;
+  color: #94a3b8;
+}
+
+.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>

+ 0 - 5
src/views/subSystem/waterSupply/wyxjc/lljc.vue

@@ -1,5 +0,0 @@
-<script setup>
-import WaterMonitoringPage from '../components/WaterMonitoringPage.vue'
-import { listFlowMonitoring, getFlowTrend, exportFlowMonitoring } from '@/api/pipeNetwork/waterSupply'
-</script>
-<template><WaterMonitoringPage title="流量监测" metric-label="流量" unit="m³/h" metric="flow" monitor-type="流量监测" :load="listFlowMonitoring" :trend="getFlowTrend" :export-api="exportFlowMonitoring" export-permission="waterSupply:monitoring:flow:export" min-key="flowMin" max-key="flowMax" /></template>

+ 0 - 5
src/views/subSystem/waterSupply/wyxjc/lousjc.vue

@@ -1,5 +0,0 @@
-<script setup>
-import WaterMonitoringPage from '../components/WaterMonitoringPage.vue'
-import { listLeakageMonitoring, getLeakageTrend, exportLeakageMonitoring } from '@/api/pipeNetwork/waterSupply'
-</script>
-<template><WaterMonitoringPage title="漏失监测" metric-label="漏失" unit="kW" metric="power" monitor-type="漏失监测" range-label="功率范围" min-key="powerMin" max-key="powerMax" :load="listLeakageMonitoring" :trend="getLeakageTrend" :export-api="exportLeakageMonitoring" export-permission="waterSupply:monitoring:leakage:export" /></template>

+ 0 - 5
src/views/subSystem/waterSupply/wyxjc/lsjc.vue

@@ -1,5 +0,0 @@
-<script setup>
-import WaterMonitoringPage from '../components/WaterMonitoringPage.vue'
-import { listPressureMonitoring, getPressureTrend, exportPressureMonitoring } from '@/api/pipeNetwork/waterSupply'
-</script>
-<template><WaterMonitoringPage title="压力监测" metric-label="压力" unit="MPa" metric="pressure" monitor-type="压力监测" range-label="压力范围" min-key="pressureMin" max-key="pressureMax" :load="listPressureMonitoring" :trend="getPressureTrend" :export-api="exportPressureMonitoring" export-permission="waterSupply:monitoring:pressure:export" /></template>