浏览代码

feat(water): add monitoring filters and exports (#8)

Kazerin 3 天之前
父节点
当前提交
9cfc527389

+ 29 - 0
memory/sessions/2026-09-01.md

@@ -41,3 +41,32 @@
 ### 风险与后续
 
 - 本轮仍未连接真实 KingbaseES 做人工验收,最终验收需以 KingbaseES 环境确认统计口径、筛选和 Excel 导出。
+
+## 2026-09-01:供水管网运行监测 T4
+
+### 目标
+
+完成 Gogs Issue #8「T4 供水运行监测」:流量、压力、漏失三类页面的时间与数值筛选、趋势范围查询、真实 Excel 导出、空态与错误态,以及 KingbaseES 测试数据。
+
+### 结论
+
+- 前后端仍分别提交;供水监测三个页面继续复用 `WaterMonitoringPage`,保持页面独立可用。
+- 查询构造统一清理空值、归一化数值,并把 Date 或字符串时间范围转换为 `yyyy-MM-dd HH:mm:ss`。
+- 页面增加采集时间范围、显式空态、加载失败重试、趋势加载/空态/失败重试和 Blob XLSX 保存。
+- 后端按设备取时间与数值范围内最新一条测量值,再手动分页,分页总数与筛选结果一致;流量兼容雷达与遥测数据。
+- 三类趋势接口支持显式时间范围;仅结束时间时不再隐式套用近 24 小时起点,双空才使用默认近 24 小时。
+- 导出接口输出真实 XLSX 文件,包含设备编码、名称、位置、数值、单位和采集时间,导出筛选与分页筛选一致。
+- `water_supply_monitoring_test_data.sql` 已在真实 KingbaseES `app_user` schema 校验表与列,并连续执行两次,最终测试行数量保持为类型 3、设备 5、三类测量各 3。
+
+### 验证
+
+- 后端 `mvn test` 通过。
+- 后端导出契约测试通过。
+- 前端 `node --test tests/waterSupplyMonitoring.test.mjs` 通过。
+- 前端全量 Node 测试 46 个中 45 个通过;唯一失败为 T4 外既有 `monitor/job` 只读详情仍使用居中弹窗。
+- 前端 `npm run build:prod` 通过,仅存在既有 Sass 弃用警告。
+
+### 风险与后续
+
+- Issue #8 尚未在 Gogs 关闭,等待用户人工验收。
+- KingbaseES 已完成测试数据脚本验证;页面级人工验收仍建议在真实服务与浏览器中确认导出文件和趋势交互。

+ 6 - 6
src/api/pipeNetwork/waterSupply.js

@@ -3,16 +3,16 @@ 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) => get(`/waterSupply/monitoring/flow/trend/${id}`)
-export const exportFlowMonitoring = (params) => get('/waterSupply/monitoring/flow/export', 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' })
 
 export const listPressureMonitoring = (params) => get('/waterSupply/monitoring/pressure/page', params)
-export const getPressureTrend = (id) => get(`/waterSupply/monitoring/pressure/trend/${id}`)
-export const exportPressureMonitoring = (params) => get('/waterSupply/monitoring/pressure/export', 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' })
 
 export const listLeakageMonitoring = (params) => get('/waterSupply/monitoring/leakage/page', params)
-export const getLeakageTrend = (id) => get(`/waterSupply/monitoring/leakage/trend/${id}`)
-export const exportLeakageMonitoring = (params) => get('/waterSupply/monitoring/leakage/export', 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 getAlarmStats = () => get('/waterSupply/alarm/stats')
 export const listRealtimeAlarms = () => get('/waterSupply/alarm/realtime')

+ 48 - 0
src/utils/waterSupplyMonitoringQuery.js

@@ -0,0 +1,48 @@
+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 }

+ 455 - 69
src/views/subSystem/waterSupply/components/WaterMonitoringPage.vue

@@ -1,80 +1,466 @@
 <template>
-  <div class="water-page">
-    <div class="page-heading">
-      <div class="page-title"><span class="page-title-icon"><el-icon><component :is="pageIcon" /></el-icon></span><div><h2>{{ title }}</h2><span class="muted">实时采集点位与近24小时趋势</span></div></div>
-      <el-button :icon="Refresh" @click="loadData">刷新</el-button>
-    </div>
-    <el-card shadow="never" class="filter-card">
-      <el-form :inline="true" :model="query" @submit.prevent="loadData">
-        <el-form-item label="设备名称"><el-input v-model="query.equipmentName" clearable placeholder="请输入设备名称" /></el-form-item>
-        <el-form-item label="设备编码"><el-input v-model="query.equipmentCode" clearable placeholder="请输入设备编码" /></el-form-item>
-        <el-form-item label="安装位置"><el-input v-model="query.equipmentLocation" clearable placeholder="请输入位置" /></el-form-item>
-        <el-form-item :label="rangeLabel"><el-input-number v-model="query[minKey]" :min="0" controls-position="right" /><span class="range-sep">至</span><el-input-number v-model="query[maxKey]" :min="0" controls-position="right" /></el-form-item>
-        <el-form-item><el-button type="primary" @click="loadData">查询</el-button><el-button @click="resetQuery">重置</el-button></el-form-item>
-      </el-form>
-    </el-card>
-    <div class="metric-grid">
-      <div class="metric"><el-icon class="metric-icon"><Monitor /></el-icon><span>监测点位</span><strong>{{ page.total }}</strong><small>个</small></div>
-      <div class="metric"><el-icon class="metric-icon"><DataAnalysis /></el-icon><span>当前均值</span><strong>{{ averageValue }}</strong><small>{{ unit }}</small></div>
-      <div class="metric"><el-icon class="metric-icon"><Warning /></el-icon><span>异常点位</span><strong class="danger">{{ abnormalCount }}</strong><small>个</small></div>
-      <div class="metric"><el-icon class="metric-icon"><Clock /></el-icon><span>数据更新时间</span><strong class="time">{{ latestTime }}</strong></div>
+    <div class="water-page">
+        <div class="page-heading">
+            <div class="page-title">
+                <span class="page-title-icon"><el-icon><component :is="pageIcon"/></el-icon></span>
+                <div>
+                    <h2>{{ title }}</h2>
+                    <span class="muted">按时间与数值范围查询实时点位,并查看趋势明细</span>
+                </div>
+            </div>
+            <el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
+        </div>
+
+        <el-card shadow="never" class="filter-card">
+            <el-form :inline="true" :model="query" @submit.prevent="loadData">
+                <el-form-item label="设备名称">
+                    <el-input v-model="query.equipmentName" clearable placeholder="请输入设备名称"/>
+                </el-form-item>
+                <el-form-item label="设备编码">
+                    <el-input v-model="query.equipmentCode" clearable placeholder="请输入设备编码"/>
+                </el-form-item>
+                <el-form-item label="安装位置">
+                    <el-input v-model="query.equipmentLocation" clearable placeholder="请输入位置"/>
+                </el-form-item>
+                <el-form-item label="采集时间">
+                    <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" :loading="loading" @click="loadData">查询</el-button>
+                    <el-button @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">
+                <el-icon class="metric-icon">
+                    <Monitor/>
+                </el-icon>
+                <span>监测点位</span><strong>{{ page.total }}</strong><small>个</small>
+            </div>
+            <div class="metric">
+                <el-icon class="metric-icon">
+                    <DataAnalysis/>
+                </el-icon>
+                <span>当前均值</span><strong>{{ averageValue }}</strong><small>{{ unit }}</small>
+            </div>
+            <div class="metric">
+                <el-icon class="metric-icon">
+                    <Warning/>
+                </el-icon>
+                <span>异常点位</span><strong class="danger">{{ abnormalCount }}</strong><small>个</small>
+            </div>
+            <div class="metric">
+                <el-icon class="metric-icon">
+                    <Clock/>
+                </el-icon>
+                <span>数据更新时间</span><strong class="time">{{ latestTime }}</strong>
+            </div>
+        </div>
+
+        <el-card shadow="never" class="table-card">
+            <div class="card-title">
+                <span>{{ title }}点位清单</span>
+                <el-button link type="primary" :loading="exportLoading" @click="exportData">批量导出</el-button>
+            </div>
+            <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 link type="primary" @click.stop="showTrend(row)">趋势</el-button>
+                    </template>
+                </el-table-column>
+            </el-table>
+            <el-pagination
+                    v-model:current-page="query.pageNum"
+                    v-model:page-size="query.pageSize"
+                    :total="page.total"
+                    layout="total, sizes, prev, pager, next"
+                    :page-sizes="[10, 20, 50]"
+                    @change="loadData"
+            />
+        </el-card>
+
+        <el-drawer
+                v-model="trendVisible"
+                class="detail-drawer"
+                direction="rtl"
+                :title="`${current?.equipmentName || ''} - ${title}趋势`"
+                size="520px"
+        >
+            <div 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>
-    <el-card shadow="never" class="table-card">
-      <div class="card-title"><span>{{ title }}点位清单</span><el-button link type="primary" @click="exportData">批量导出</el-button></div>
-      <el-table :data="page.records" stripe border v-loading="loading" @row-click="showTrend">
-        <el-table-column type="index" label="#" width="55" />
-        <el-table-column prop="equipmentCode" label="设备编码" min-width="130" />
-        <el-table-column prop="equipmentName" label="设备名称" min-width="150" show-overflow-tooltip />
-        <el-table-column prop="equipmentTypeName" label="设备子类型" min-width="145" show-overflow-tooltip />
-        <el-table-column prop="location" label="安装位置" min-width="180" show-overflow-tooltip />
-        <el-table-column :label="`${metricLabel}(${unit})`" min-width="130">
-          <template #default="{ row }"><span :class="{ danger: row.value == null || row.status === 1 || row.status === '1' }">{{ row.value ?? '-' }}</span></template>
-        </el-table-column>
-        <el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="statusType(row)">{{ statusText(row) }}</el-tag></template></el-table-column>
-        <el-table-column prop="updateTime" label="采集时间" min-width="165" />
-        <el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click.stop="showTrend(row)">趋势</el-button></template></el-table-column>
-      </el-table>
-      <el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="page.total" layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" @change="loadData" />
-    </el-card>
-    <el-drawer v-model="trendVisible" class="detail-drawer" direction="rtl" :title="`${current?.equipmentName || ''} - ${title}趋势`" size="520px">
-      <div ref="chartRef" class="trend-chart"></div>
-      <el-descriptions v-if="current" :column="1" border><el-descriptions-item label="设备编码">{{ current.equipmentCode }}</el-descriptions-item><el-descriptions-item label="安装位置">{{ current.location }}</el-descriptions-item><el-descriptions-item label="当前值">{{ current.value ?? '-' }} {{ current.unit }}</el-descriptions-item></el-descriptions>
-    </el-drawer>
-  </div>
 </template>
-
 <script setup>
-import { computed, nextTick, onMounted, ref } from 'vue'
-import { Clock, DataAnalysis, Monitor, Refresh, Warning } from '@element-plus/icons-vue'
-import { ElMessage } from 'element-plus'
-import * as echarts from 'echarts'
-import { normalizeMonitoringRecord, normalizePage, unwrapResponse } from '@/utils/waterSupplyModel'
-
-const props = defineProps({ title: { type: String, required: true }, metricLabel: { type: String, required: true }, unit: { type: String, default: 'm³/h' }, minKey: { type: String, default: 'flowMin' }, maxKey: { type: String, default: 'flowMax' }, rangeLabel: { type: String, default: '数值范围' }, load: { type: Function, required: true }, trend: { type: Function, required: true }, exportApi: { type: Function, required: true }, metric: { type: String, default: 'value' }, monitorType: { type: String, default: '' } })
-const pageIcon = computed(() => props.metric === 'pressure' ? Warning : props.metric === 'power' ? DataAnalysis : Monitor)
-const query = ref({ pageNum: 1, pageSize: 10, equipmentName: '', equipmentCode: '', equipmentLocation: '', [props.minKey]: undefined, [props.maxKey]: undefined })
-const page = ref({ records: [], total: 0 })
-const loading = ref(false)
-const current = ref(null)
-const trendVisible = ref(false)
-const chartRef = ref(null)
-let chart
-const loadData = async () => { loading.value = true; try { const response = await props.load({ ...query.value }); const normalized = normalizePage(response); normalized.records = normalized.records.map(row => normalizeMonitoringRecord(row, props.metric, props.monitorType)); page.value = normalized } catch (error) { page.value = { records: [], total: 0 }; console.warn(`[${props.title}] 数据加载失败`, error) } finally { loading.value = false } }
-const resetQuery = () => { query.value = { pageNum: 1, pageSize: 10, equipmentName: '', equipmentCode: '', equipmentLocation: '', [props.minKey]: undefined, [props.maxKey]: undefined }; loadData() }
-const averageValue = computed(() => { const values = page.value.records.map(item => item.value).filter(value => Number.isFinite(value)); return values.length ? (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2) : '-' })
-const abnormalCount = computed(() => page.value.records.filter(item => item.status === 1 || item.status === '1' || item.value == null).length)
-const latestTime = computed(() => page.value.records.map(item => item.updateTime).filter(Boolean).sort().at(-1) || '-')
-const statusText = row => row.status === 1 || row.status === '1' ? '异常' : row.status === 0 || row.status === '0' ? '正常' : row.status ? '在线' : '未知'
-const statusType = row => statusText(row) === '异常' ? 'danger' : statusText(row) === '正常' || statusText(row) === '在线' ? 'success' : 'info'
-const showTrend = async row => { current.value = row; trendVisible.value = true; await nextTick(); const response = await props.trend(row.id); const data = unwrapResponse(response); const records = Array.isArray(data) ? data : data?.records || []; const labels = records.map(item => item.time || item.monitorTime || item.createTime || ''); const values = records.map(item => normalizeMonitoringRecord(item, props.metric, props.monitorType).value ?? 0); chart?.dispose(); chart = echarts.init(chartRef.value); chart.setOption({ tooltip: { trigger: 'axis' }, grid: { left: 40, right: 18, top: 20, bottom: 35 }, xAxis: { type: 'category', data: labels }, yAxis: { type: 'value', name: props.unit }, series: [{ type: 'line', smooth: true, data: values, areaStyle: { opacity: 0.12 }, itemStyle: { color: '#2563eb' } }] }) }
-const exportData = async () => { try { await props.exportApi({ ...query.value }); ElMessage.success('导出请求已提交') } catch (error) { ElMessage.warning('当前接口暂不支持文件流导出') } }
-onMounted(loadData)
+    import {computed, nextTick, onBeforeUnmount, onMounted, ref} from 'vue'
+    import {DataAnalysis, Monitor, Warning} from '@element-plus/icons-vue'
+    import {ElMessage} from 'element-plus'
+    import {saveAs} from 'file-saver'
+    import * as echarts from 'echarts'
+    import {normalizeMonitoringRecord, normalizePage, 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},
+        metric: {type: String, default: 'value'},
+        monitorType: {type: String, default: ''}
+    })
+
+    const pageIcon = computed(() => props.metric === 'pressure' ? Warning : props.metric === 'power' ? DataAnalysis : Monitor)
+    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) {
+            page.value = {records: [], total: 0}
+            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()
+    }
+
+    async function loadTrend() {
+        if (!current.value?.id) return
+        trendLoading.value = true
+        trendError.value = false
+        trendRecords.value = []
+        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
+            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)
+        chart.setOption({
+            tooltip: {trigger: 'axis'},
+            grid: {left: 40, right: 18, top: 20, bottom: 35},
+            xAxis: {type: 'category', data: labels},
+            yAxis: {type: 'value', name: props.unit},
+            series: [{
+                type: 'line',
+                smooth: true,
+                data: values,
+                areaStyle: {opacity: 0.12},
+                itemStyle: {color: '#2563eb'}
+            }]
+        })
+    }
+
+    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'})
+            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';
+    @use './waterSupplyPageTheme.scss';
 </style>
 
 <style scoped>
-.water-page{padding:20px;background:#f5f7fa;min-height:calc(100vh - 84px)}.page-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.page-heading h2{margin:0 0 5px;font-size:20px;color:#1f2937}.muted{font-size:13px;color:#909399}.filter-card{margin-bottom:16px}.filter-card :deep(.el-form-item){margin-bottom:0}.range-sep{margin:0 8px;color:#909399}.metric-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.metric{background:#fff;border:1px solid #ebeef5;padding:16px 18px;min-height:70px}.metric span{display:block;color:#909399;font-size:13px}.metric strong{font-size:24px;line-height:35px;color:#1f2937}.metric small{margin-left:5px;color:#909399}.metric .time{font-size:16px}.danger{color:#e45656!important}.table-card{min-height:420px}.card-title{display:flex;align-items:center;justify-content:space-between;font-weight:600;margin-bottom:14px}.table-card :deep(.el-pagination){justify-content:flex-end;margin-top:16px}.trend-chart{height:300px;margin-bottom:20px}@media(max-width:900px){.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.filter-card :deep(.el-form-item){margin-bottom:12px}}
+    .water-page {
+        padding: 20px;
+        background: #f5f7fa;
+        min-height: calc(100vh - 84px)
+    }
+
+    .page-heading {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        margin-bottom: 16px
+    }
+
+    .page-heading h2 {
+        margin: 0 0 5px;
+        font-size: 20px;
+        color: #1f2937
+    }
+
+    .muted {
+        font-size: 13px;
+        color: #909399
+    }
+
+    .filter-card {
+        margin-bottom: 16px
+    }
+
+    .filter-card :deep(.el-form-item) {
+        margin-bottom: 0
+    }
+
+    .range-sep {
+        margin: 0 8px;
+        color: #909399
+    }
+
+    .page-error {
+        margin-bottom: 16px
+    }
+
+    .metric-grid {
+        display: grid;
+        grid-template-columns:repeat(4, minmax(0, 1fr));
+        gap: 12px;
+        margin-bottom: 16px
+    }
+
+    .metric {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        text-align: center;
+        background: #fff;
+        border: 1px solid #ebeef5;
+        padding: 16px 18px;
+        min-height: 70px;
+    }
+
+    /* 图标大小与下边距,确保与文字分离 */
+    .metric-icon {
+        font-size: 28px;
+        margin-bottom: 6px;
+    }
+
+    .metric span {
+        display: block;
+        color: #909399;
+        font-size: 13px
+    }
+
+    .metric strong {
+        font-size: 24px;
+        line-height: 1.2;
+        color: #1f2937
+    }
+
+    .metric small {
+        margin-left: 5px;
+        color: #909399
+    }
+
+    .metric .time {
+        font-size: 16px
+    }
+
+    .danger {
+        color: #e45656 !important
+    }
+
+    .table-card {
+        min-height: 420px
+    }
+
+    .card-title {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        font-weight: 600;
+        margin-bottom: 14px
+    }
+
+    .table-card :deep(.el-pagination) {
+        justify-content: flex-end;
+        margin-top: 16px
+    }
+
+    .trend-body {
+        min-height: 340px
+    }
+
+    .trend-chart {
+        height: 300px;
+        margin-bottom: 20px
+    }
+
+    .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>

+ 45 - 0
tests/waterSupplyMonitoring.test.mjs

@@ -0,0 +1,45 @@
+import assert from 'node:assert/strict'
+import {
+  buildMonitoringQuery,
+  buildTrendQuery,
+  formatMonitoringDateTime
+} from '../src/utils/waterSupplyMonitoringQuery.js'
+
+const query = {
+  pageNum: 2,
+  pageSize: 20,
+  equipmentName: '测试流量点',
+  equipmentCode: '',
+  equipmentLocation: '测试大道',
+  timeRange: [new Date(2026, 8, 1, 8, 30, 5), '2026-09-01 12:00:00'],
+  flowMin: '20',
+  flowMax: 40
+}
+
+assert.deepEqual(buildMonitoringQuery(query, 'flowMin', 'flowMax'), {
+  pageNum: 2,
+  pageSize: 20,
+  equipmentName: '测试流量点',
+  equipmentLocation: '测试大道',
+  startTime: '2026-09-01 08:30:05',
+  endTime: '2026-09-01 12:00:00',
+  flowMin: 20,
+  flowMax: 40
+})
+
+assert.deepEqual(buildMonitoringQuery({}, 'pressureMin', 'pressureMax'), {
+  pageNum: 1,
+  pageSize: 10
+})
+assert.deepEqual(buildMonitoringQuery({ powerMin: 'not-number' }, 'powerMin', 'powerMax'), {
+  pageNum: 1,
+  pageSize: 10
+})
+assert.deepEqual(buildTrendQuery(['2026-09-01 08:00:00']), {
+  startTime: '2026-09-01 08:00:00'
+})
+assert.deepEqual(buildTrendQuery(), {})
+assert.equal(formatMonitoringDateTime(new Date(2026, 8, 1, 8, 30, 5)), '2026-09-01 08:30:05')
+assert.equal(formatMonitoringDateTime('  '), undefined)
+
+console.log('waterSupplyMonitoring tests passed')