1
0

14 Коммиты 71e8e17a2d ... 111b85812d

Автор SHA1 Сообщение Дата
  Kazerin 111b85812d Merge remote-tracking branch 'origin/master' 1 день назад
  Kazerin 539095052d - 供水页面修改 1 день назад
  Kazerin e8aa40297c feat(water): complete device T6 UI contract 1 день назад
  Kazerin 3f017a29a5 Merge remote-tracking branch 'origin/master' 1 день назад
  Kazerin 4a1c923d5d Merge remote-tracking branch 'origin/master' 2 дней назад
  Kazerin 2f97089efb fix(water): refine threshold management UI (#9) 2 дней назад
  Kazerin eba9ab1cbf feat(water): deliver threshold management (#9) 2 дней назад
  Kazerin 03702f47eb fix(water): prevent monitoring metric icon overlap 2 дней назад
  Kazerin e8bf55045e style(water): align monitoring pages with statistics design 2 дней назад
  Kazerin 78907b1cfd Merge remote-tracking branch 'origin/master' 2 дней назад
  Kazerin 679324347b docs(memory): record water monitoring T4 acceptance 3 дней назад
  Kazerin b9922597ba fix(water): preserve monitoring data on request failures (#8) 3 дней назад
  Kazerin 11b2858047 fix(water): handle monitoring trend times and export errors (#8) 3 дней назад
  Kazerin 9cfc527389 feat(water): add monitoring filters and exports (#8) 3 дней назад

+ 2 - 3
memory/index.md

@@ -12,7 +12,7 @@
 - 应用入口:`src/main.js`
 - 根组件:`src/App.vue`
 - 路由守卫:`src/permission.js`
-- 路由定义:`src/router/index.js`(总后台默认落地页常量 `DEFAULT_BACKEND_HOME = '/system/user'`,写死的首页菜单已移除)
+- 路由定义:`src/router/index.js`
 - 动态权限路由:`src/store/modules/permission.js`
 - Axios 封装:`src/utils/request.js`
 - Vite 配置:`vite.config.js`
@@ -35,7 +35,7 @@ npm run dev       # 默认 http://localhost:80
 ## 当前已知风险
 
 - 业务菜单主要由后端 `/getRouters` 动态下发,前端常量路由不包含完整业务树。
-- 门户页退出曾误调 `userStore.logout()`(Store 实际方法名为 `logOut()`),已修正为与 `Navbar` 一致的 `logOut()` + `location.href = '/login'` 整页跳转
+- 门户页调用 `userStore.logout()`,用户 Store 当前定义的方法名为 `logOut()`,需要后续确认是否修复
 - 供水 19 个页面已按 5.3.2 文档命名补齐并接入后端接口,包含设施 CRUD、设备、监测、报警、统计和轻量 GIS;部署菜单仍依赖后端 `sys_menu` 与角色授权。
 - 排水报警详情 API 请求 `/api/alarm/{id}`,后端实际路径为 `/api/alarm/getById/{id}`。
 - 若干排水请求设置 `X-Silent-Error`,但 Axios 拦截器未处理该标志。
@@ -54,4 +54,3 @@ npm run dev       # 默认 http://localhost:80
 - [2026-08-21:工程技能仓库配置](sessions/2026-08-21.md)
 - [2026-08-22:供水报警与工单前端适配设计](sessions/2026-08-22.md)
 - [2026-09-01:供水管网数据统计分析 T3](sessions/2026-09-01.md)
-- [2026-09-03:移除写死的首页菜单,后台默认落地到用户管理](sessions/2026-09-03.md)

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

@@ -41,3 +41,87 @@
 ### 风险与后续
 
 - 本轮仍未连接真实 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 已完成测试数据脚本验证;页面级人工验收仍建议在真实服务与浏览器中确认导出文件和趋势交互。
+
+## 2026-09-01:供水运行监测 T4 第四轮修正
+
+### 结论
+
+- 流量分页在雷达与遥测数据同时存在时,改为按采集时间选择真正最新的一条测量值,不再固定优先雷达数据。
+- 四类趋势查询在只传结束时间时不再绑定 `NULL` 起点条件,避免 KingbaseES 中 `create_time >= NULL` 导致查询为空。
+- 前端恢复 `Refresh` 图标导入,保证供水监测页刷新按钮正常渲染。
+- 新增后端回归测试覆盖跨数据源最新流量值和仅结束时间的趋势查询参数绑定。
+
+### 验证
+
+- 后端 `mvn -q -pl zksy-system -Dtest=MonitoringServiceImplTest test` 通过。
+- 后端 `mvn -q test` 通过。
+- 前端 `node --test tests/waterSupplyMonitoring.test.mjs` 1/1 通过。
+- 前端 `npm run build:prod` 通过,仅存在既有 Sass 弃用警告。
+
+### 风险与后续
+
+- Issue #8 继续保留给用户人工验收;本轮未连接真实 KingbaseES 页面环境复测浏览器交互。
+
+## 2026-09-01:供水运行监测 T4 标准审查
+
+### 结论
+
+- 按 `origin/master...HEAD` 审查前端 2 个提交、后端 3 个提交;确认供水设备树过滤、共享监测页、右侧详情抽屉、显式状态、权限导出和测试覆盖基本符合项目标准。
+- 发现 1 个硬性标准问题:监测列表与趋势请求失败时先清空既有数据,违反供水工单 UI 设计中“接口失败在原位置显示错误与重试入口,保留已有数据和表单内容”的要求。
+- 另记录趋势接口 Swagger 文案仍写“近24小时”、全量内存分页与导出、控制器/基础字段填充重复、运行时类型选择器等 judgement-call 风险。
+
+### 验证
+
+- 本轮为只读标准审查,未修改业务代码,未重新运行测试;沿用本轮审查证据和既有会话记录中的验证结果。
+
+### 后续行动
+
+- 修复失败请求时的既有数据保留逻辑;建议同步修正趋势接口文案,并在数据量扩大前评估数据库分页与流式导出。
+
+## 2026-09-01:供水运行监测 T4 验收收尾
+
+### 结论
+
+- 修复监测列表请求失败时清空旧数据的问题;失败时保留原列表、分页和筛选上下文,仅显示错误与重试入口。
+- 趋势请求重试失败时保留当前设备旧图表;切换到新设备时才重置趋势上下文,避免旧设备数据误导当前详情。
+- 双轴审查结论:Spec 轴未见阻塞性缺口;Standards 轴原硬性问题已修复,剩余 Swagger 文案、内存分页/导出与重复代码为非阻塞改进项。
+
+### 验证
+
+- 前端 `node --test tests/waterSupplyMonitoring.test.mjs` 通过。
+- 前端 `npm run build:prod` 通过,仅存在既有 Sass 弃用警告。
+- 后端本轮未再改动,沿用本轮 `mvn -q test` 通过结果。
+
+### 风险与后续
+
+- Issue #8 仍保持开启,等待用户在 KingbaseES 与真实浏览器中完成人工验收。
+- 建议后续单独处理趋势接口 Swagger 文案、数据库侧分页/流式导出和三类监测控制器重复逻辑。

+ 16 - 0
memory/sessions/2026-09-02.md

@@ -0,0 +1,16 @@
+# 2026-09-02 会话记录
+
+## T5 阈值管理
+
+- 目标:完成 Gogs Issue #9“[供水] T5 阈值管理”的前端部分,覆盖设备/分类筛选、批量设备选择、分时表单、编辑、删除、加载与错误反馈。
+- 实现:`WaterAlarmPage.vue` 的阈值模式改为独立筛选与批量配置表单;新增阈值保存、更新和查询契约构建函数,修复原先向后端发送 `{deviceCodes, thresholds}` 嵌套结构导致的保存失败。
+- 契约:批量保存发送平铺 `deviceCodes/warningType/warningCode/minValue/maxValue/periodStart/periodEnd/remark`;更新保留 `id` 并发送平铺字段;查询只发送阈值接口支持的参数。
+- 验证:`node --test tests/waterSupplyThreshold.test.mjs tests/waterSupplyUiModel.test.mjs tests/waterSupplyModel.test.mjs` 7/7 通过;`npm run build:prod` 通过,仅存在既有 Sass 弃用警告。
+- 边界:未修改其他页面;全量 Node 测试中 `src/views/monitor/job/index.vue` 的既有详情弹窗规则失败,属于工作区既有非 T5 改动。
+
+## T5 阈值管理验收修正
+
+- 目标:修复人工验收发现的非供水设备选项泄漏、预警分类选择框过窄和页面视觉风格不统一。
+- 实现:阈值设备选项改用后端新增的 `/waterSupply/alarm/threshold/devices` 供水专用契约;阈值页面拆分为独立 `WaterThresholdPage.vue`,沿用蓝白科技感卡片、指标、筛选和右侧抽屉布局,预警分类筛选固定宽度。
+- 复审修正:阈值抽屉补齐 `direction="rtl"`、`detail-drawer` 类和 768px 全宽;刷新失败保留既有数据并提供就地重试;删除按钮增加行级 loading;移除 `WaterAlarmPage.vue` 中不再使用的阈值模式死代码。
+- 验证:T5 前端测试 9/9 通过;生产构建通过;后端全量 Maven 测试通过;全量 Node 测试仍仅存在 `monitor/job` 既有右侧抽屉规则失败。

+ 9 - 8
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')
@@ -26,6 +26,7 @@ export const getAlarmFlow = (id) => get(`/waterSupply/alarm/flow/${id}`)
 export const listAlarmMapPoints = () => get('/waterSupply/alarm/map/points')
 
 export const listThresholds = (params) => get('/waterSupply/alarm/threshold/page', params)
+export const listThresholdDevices = () => get('/waterSupply/alarm/threshold/devices')
 export const getThreshold = (id) => get(`/waterSupply/alarm/threshold/detail/${id}`)
 export const saveThresholds = (data) => request({ url: '/waterSupply/alarm/threshold/save', method: 'post', data })
 export const updateThreshold = (data) => request({ url: '/waterSupply/alarm/threshold/update', method: 'put', data })
@@ -34,10 +35,10 @@ export const deleteThresholds = (ids) => request({ url: '/waterSupply/alarm/thre
 export const listDeviceLedger = (params) => get('/waterSupply/device/ledger/page', params)
 export const getDeviceTypeStatistics = () => get('/waterSupply/device/ledger/typeStatistics')
 export const listDeviceGisPoints = (params) => get('/waterSupply/device/ledger/gisPoints', params)
-export const exportDeviceLedger = (params) => request({ url: '/waterSupply/device/ledger/export', method: 'post', params })
+export const exportDeviceLedger = (params) => request({ url: '/waterSupply/device/ledger/export', method: 'post', params, responseType: 'blob' })
 export const listAbnormalDevices = (params) => get('/waterSupply/device/abnormal/page', params)
 export const listAbnormalGisPoints = (params) => get('/waterSupply/device/abnormal/gisPoints', params)
-export const exportAbnormalDevices = (params) => request({ url: '/waterSupply/device/abnormal/export', method: 'post', params })
+export const exportAbnormalDevices = (params) => request({ url: '/waterSupply/device/abnormal/export', method: 'post', params, responseType: 'blob' })
 export const createMaintainOrder = (data) => request({ url: '/waterSupply/device/maintain/order', method: 'post', data })
 export const createAbnormalOrder = (data) => request({ url: '/waterSupply/device/abnormal/order', method: 'post', data })
 export const listMaintainRecords = (params) => get('/waterSupply/device/maintain/record/page', params)

+ 11 - 1
src/utils/waterSupplyModel.js

@@ -35,7 +35,17 @@ export function normalizeMonitoringRecord(record = {}, metric = 'value', monitor
     value: value == null || value === '' ? null : Number(value),
     unit: record.unit ?? (metric === 'pressure' ? 'MPa' : metric === 'power' ? 'kW' : 'm³/h'),
     status: record.status ?? record.onlineStatus ?? record.alarmStatus ?? 0,
-    updateTime: record.updateTime ?? record.monitorTime ?? record.createTime ?? '-'
+    updateTime: record.updateTime ?? record.monitorTime ?? record.createTime ?? record.time ?? '-'
+  }
+}
+
+export async function readMonitoringExportError(blob) {
+  if (!blob || blob.type !== 'application/json') return null
+  try {
+    const payload = JSON.parse(await blob.text())
+    return payload?.msg || payload?.message || '导出失败,请稍后重试'
+  } catch (error) {
+    return '导出失败:服务端返回了无法解析的错误信息'
   }
 }
 

+ 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 }

+ 146 - 8
src/utils/waterSupplyUiModel.js

@@ -16,21 +16,93 @@ const workOrderStatusLabels = {
   8: '已取消'
 }
 
+const deviceCurrentStatusLabels = {
+  1: '在用',
+  2: '闲置',
+  3: '维修',
+  4: '报废',
+  5: '待入库'
+}
+
+const onlineStatusLabels = {
+  1: '在线',
+  0: '离线'
+}
+
 export function normalizeDeviceRow(row = {}) {
-  const alarmStatus = Number(row.alarmStatus ?? row.status ?? 0)
+  const alarmStatus = Number(row.alarmStatus)
+  const currentStatus = Number(row.currentStatus)
+  const onlineStatus = Number(row.onlineStatus)
   const alarmLevel = Number(row.alertLevel ?? row.alarmLevel ?? 0)
-  const relatedOrderNo = row.latestOrderNo ?? row.orderNo ?? row.latestMaintainId ?? '-'
-  const rawOrderStatus = row.latestMaintainStatus ?? row.orderStatus ?? row.statusText
+  const relatedOrderNo = row.relatedWorkOrderNo
+    ?? row.latestOrderNo
+    ?? row.orderNo
+    ?? row.latestMaintainId
+    ?? '-'
+  const rawOrderStatus = row.relatedWorkOrderStatus
+    ?? row.latestMaintainStatus
+    ?? row.orderStatus
+    ?? row.statusText
+  const currentStatusText = deviceCurrentStatusLabels[currentStatus]
+    ?? (currentStatus ? `状态${currentStatus}` : '未知')
+  const onlineStatusText = onlineStatusLabels[onlineStatus]
+    ?? (Number.isFinite(onlineStatus) && onlineStatus !== 0 ? '在线' : '离线')
+  const alarmStatusText = alarmStatus === 1 ? '报警' : '正常'
 
+  const workOrderStatus = normalizeWorkOrderStatus(rawOrderStatus)
   return {
     ...row,
-    deviceStatusText: alarmStatus === 1 ? '报警' : '正常',
+    deviceStatusText: currentStatusText,
+    currentStatusText,
+    onlineStatusText,
+    alarmStatusText,
+    abnormalReasonText: row.abnormalReason
+      || (alarmStatus === 1 ? '设备报警' : currentStatus === 3 ? '设备维修中' : '-'),
     alarmLevelText: alarmLevelLabels[alarmLevel] ?? (alarmLevel ? `${alarmLevel}级报警` : '未分级'),
     relatedOrderNo,
-    workOrderStatusText: typeof rawOrderStatus === 'number'
-      ? (workOrderStatusLabels[rawOrderStatus] ?? '未知')
-      : (rawOrderStatus || '暂无工单')
+    relatedWorkOrderPerson: row.relatedWorkOrderPerson ?? '-',
+    workOrderStatusCode: workOrderStatus.code,
+    workOrderTerminal: workOrderStatus.terminal,
+    workOrderStatusText: workOrderStatus.text
+  }
+}
+
+export function normalizeWorkOrderStatus(value) {
+  if (value === null || value === undefined || value === '') {
+    return { code: null, text: '暂无工单', terminal: false }
+  }
+  const numeric = typeof value === 'number' || /^\s*\d+\s*$/.test(String(value))
+  if (numeric) {
+    const code = Number(value)
+    return {
+      code: Number.isFinite(code) ? code : null,
+      text: workOrderStatusLabels[code] ?? '未知',
+      terminal: code >= 6
+    }
   }
+  const text = String(value)
+  const code = Object.entries(workOrderStatusLabels).find(([, label]) => label === text)?.[0]
+  return { code: code ? Number(code) : null, text: text || '暂无工单', terminal: code ? Number(code) >= 6 : false }
+}
+
+export function canDispatchDevice(row = {}) {
+  const orderNo = row.relatedOrderNo ?? row.latestOrderNo ?? row.orderNo
+  if (!orderNo || orderNo === '-') return true
+  return normalizeWorkOrderStatus(row.relatedWorkOrderStatus ?? row.latestMaintainStatus ?? row.orderStatus).terminal
+}
+
+export function preserveDevicePageOnError(page) {
+  return { page, error: '设备数据加载失败,请点击重试' }
+}
+
+export function normalizeDeviceGisPoints(response) {
+  const data = response?.data ?? response
+  const points = Array.isArray(data) ? data : (data?.records || data?.rows || data?.points || [])
+  return points.map(point => ({
+    ...point,
+    _longitude: Number(point.longitude ?? point.lng),
+    _latitude: Number(point.latitude ?? point.lat)
+  })).filter(point => Number.isFinite(point._longitude) && Number.isFinite(point._latitude))
 }
 
 export function buildMaintainOrderPayload(device = {}, form = {}) {
@@ -49,5 +121,71 @@ export function buildMaintainOrderPayload(device = {}, form = {}) {
 }
 
 export function getDeviceOrderEndpoint(view) {
-  return view === 'dispatch' ? '/waterSupply/device/abnormal/order' : null
+  return view === 'dispatch' || view === 'abnormal'
+    ? '/waterSupply/device/abnormal/order'
+    : null
+}
+
+function normalizeThresholdValue(value) {
+  if (value === '' || value === null || value === undefined) {
+    return undefined
+  }
+  const parsed = Number(value)
+  return Number.isFinite(parsed) ? parsed : value
+}
+
+function normalizeThresholdPeriod(form = {}) {
+  const periodStart = form.periodStart?.trim() || undefined
+  const periodEnd = form.periodEnd?.trim() || undefined
+  return { periodStart, periodEnd }
+}
+
+function removeUndefined(payload) {
+  return Object.fromEntries(
+    Object.entries(payload).filter(([, value]) => value !== undefined)
+  )
+}
+
+export function buildThresholdSavePayload(form = {}) {
+  const deviceCodes = Array.from(new Set((form.deviceCodes || [])
+    .map(code => code?.trim())
+    .filter(Boolean)))
+  const { periodStart, periodEnd } = normalizeThresholdPeriod(form)
+
+  return removeUndefined({
+    deviceCodes,
+    warningType: form.warningType?.trim() || undefined,
+    warningCode: form.warningCode?.trim() || undefined,
+    minValue: normalizeThresholdValue(form.minValue),
+    maxValue: normalizeThresholdValue(form.maxValue),
+    periodStart,
+    periodEnd,
+    remark: form.remark?.trim() || undefined
+  })
+}
+
+export function buildThresholdUpdatePayload(form = {}) {
+  const { periodStart, periodEnd } = normalizeThresholdPeriod(form)
+
+  return removeUndefined({
+    id: form.id,
+    deviceCode: form.deviceCode?.trim() || undefined,
+    warningType: form.warningType?.trim() || undefined,
+    warningCode: form.warningCode?.trim() || undefined,
+    minValue: normalizeThresholdValue(form.minValue),
+    maxValue: normalizeThresholdValue(form.maxValue),
+    periodStart,
+    periodEnd,
+    remark: form.remark?.trim() || undefined
+  })
+}
+
+export function buildThresholdQuery(query = {}) {
+  return removeUndefined({
+    pageNum: query.pageNum || 1,
+    pageSize: query.pageSize || 10,
+    deviceCode: query.deviceCode?.trim() || undefined,
+    warningType: query.warningType?.trim() || undefined,
+    keyword: query.keyword?.trim() || undefined
+  })
 }

+ 1 - 1
src/views/subSystem/basic/waterSource/index.vue

@@ -5,7 +5,7 @@
       <el-button type="primary" link size="small" @click="retryRequest">重试</el-button>
     </div>
     <!-- 搜索区域 -->
-    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="80px">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="88px">
       <el-form-item label="水源地ID" prop="sourceId">
         <el-input v-model="queryParams.sourceId" placeholder="请输入水源地ID" clearable size="small" @keyup.enter="handleQuery" />
       </el-form-item>

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
src/views/subSystem/waterSupply/components/WaterAlarmPage.vue


+ 37 - 14
src/views/subSystem/waterSupply/components/WaterDevicePage.vue

@@ -20,19 +20,25 @@
 
     <el-card shadow="never" class="water-panel">
       <div class="card-title"><span><el-icon><Monitor /></el-icon> {{ title }}清单</span><el-button link type="primary" @click="exportData">导出</el-button></div>
+      <el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="load-error"><el-button size="small" type="danger" plain @click="loadData">重试</el-button></el-alert>
+      <el-empty v-if="view === 'gis' && !loading && !loadError && !page.records.length" description="暂无设备 GIS 点位数据" />
       <el-table :data="page.records" border stripe v-loading="loading" row-key="equipmentId">
         <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" /><el-table-column prop="equipmentTypeName" label="设备类型" min-width="120" /><el-table-column prop="equipmentLocation" label="安装位置" min-width="180" show-overflow-tooltip />
-        <el-table-column v-if="view !== 'maintain'" label="设备状态" width="100"><template #default="{ row }"><el-tag :type="Number(row.alarmStatus) === 1 ? 'danger' : 'success'">{{ row.deviceStatusText }}</el-tag></template></el-table-column>
+        <el-table-column v-if="view !== 'maintain'" label="设备状态" width="100"><template #default="{ row }"><el-tag :type="Number(row.currentStatus) === 3 ? 'danger' : 'success'">{{ row.currentStatusText }}</el-tag></template></el-table-column>
+        <el-table-column v-if="view !== 'maintain'" label="在线状态" width="100"><template #default="{ row }"><el-tag :type="Number(row.onlineStatus) === 1 ? 'success' : 'info'">{{ row.onlineStatusText }}</el-tag></template></el-table-column>
+        <el-table-column v-if="view !== 'maintain'" label="报警状态" width="100"><template #default="{ row }"><el-tag :type="Number(row.alarmStatus) === 1 ? 'danger' : 'success'">{{ row.alarmStatusText }}</el-tag></template></el-table-column>
         <el-table-column v-if="view !== 'maintain'" label="报警等级" width="110"><template #default="{ row }"><el-tag :type="alarmLevelType(row.alertLevel)">{{ row.alarmLevelText }}</el-tag></template></el-table-column>
-        <el-table-column label="关联维修单" min-width="190" show-overflow-tooltip><template #default="{ row }">{{ view === 'maintain' ? (row.orderNo || '-') : row.relatedOrderNo }}</template></el-table-column>
-        <el-table-column label="维修状态" min-width="110"><template #default="{ row }">{{ view === 'maintain' ? (row.status || '暂无') : row.workOrderStatusText }}</template></el-table-column>
-        <el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="selectPoint(row)">详情</el-button><el-button v-if="view === 'dispatch'" link type="warning" @click="openOrder(row)">维修派单</el-button><el-button v-if="view === 'maintain'" link type="danger" @click="removeRecord(row)">删除</el-button></template></el-table-column>
+        <el-table-column v-if="view === 'abnormal' || view === 'dispatch'" label="异常原因" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.abnormalReasonText || '-' }}</template></el-table-column>
+        <el-table-column label="关联工单" min-width="190" show-overflow-tooltip><template #default="{ row }">{{ view === 'maintain' ? (row.orderNo || '-') : row.relatedOrderNo }}</template></el-table-column>
+        <el-table-column label="工单状态" min-width="110"><template #default="{ row }"><el-tag v-if="view !== 'maintain' && row.workOrderStatusText !== '暂无工单'" :type="workOrderStatusType(row.workOrderStatusCode)">{{ row.workOrderStatusText }}</el-tag><span v-else>{{ view === 'maintain' ? (row.status || '暂无') : row.workOrderStatusText }}</span></template></el-table-column>
+        <el-table-column v-if="view === 'abnormal' || view === 'dispatch'" label="接单责任人" min-width="110"><template #default="{ row }">{{ view === 'maintain' ? (row.maintainPerson || '-') : (row.relatedWorkOrderPerson || '-') }}</template></el-table-column>
+        <el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="selectPoint(row)">详情</el-button><el-button v-if="canDispatch(row)" link type="warning" @click="openOrder(row)">维修派单</el-button><el-button v-if="view === 'maintain'" link type="danger" @click="removeRecord(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="detailVisible" class="detail-drawer device-detail-drawer" title="设备详情" direction="rtl" size="680px" append-to-body>
-      <div class="detail-summary"><span class="detail-name">{{ detail.equipmentName || '-' }}</span><el-tag :type="Number(detail.alarmStatus) === 1 ? 'danger' : 'success'">{{ detail.deviceStatusText || '-' }}</el-tag></div>
+      <div class="detail-summary"><span class="detail-name">{{ detail.equipmentName || '-' }}</span><el-tag :type="Number(detail.currentStatus) === 3 ? 'danger' : 'success'">{{ detail.currentStatusText || '-' }}</el-tag><el-tag :type="Number(detail.alarmStatus) === 1 ? 'danger' : 'success'">{{ detail.alarmStatusText || '-' }}</el-tag><el-tag v-if="detail.workOrderStatusText && detail.workOrderStatusText !== '暂无工单'" :type="workOrderStatusType(detail.workOrderStatusCode)">{{ detail.workOrderStatusText }}</el-tag></div>
       <el-descriptions :column="2" border>
         <el-descriptions-item v-for="item in detailFields" :key="item.label" :label="item.label">{{ item.value }}</el-descriptions-item>
       </el-descriptions>
@@ -47,25 +53,42 @@ import { computed, onMounted, ref } from 'vue'
 import { ElMessage } from 'element-plus'
 import { Monitor, Refresh } from '@element-plus/icons-vue'
 import { listUser } from '@/api/system/user'
-import { createAbnormalOrder, exportAbnormalDevices, exportDeviceLedger, listAbnormalDevices, listDeviceLedger, listMaintainRecords, deleteMaintainRecord } from '@/api/pipeNetwork/waterSupply'
-import { normalizePage } from '@/utils/waterSupplyModel'
-import { buildMaintainOrderPayload, normalizeDeviceRow } from '@/utils/waterSupplyUiModel'
+import { createAbnormalOrder, exportAbnormalDevices, exportDeviceLedger, listAbnormalDevices, listDeviceLedger, listDeviceGisPoints, listMaintainRecords, deleteMaintainRecord } from '@/api/pipeNetwork/waterSupply'
+import { normalizePage, readMonitoringExportError } from '@/utils/waterSupplyModel'
+import { buildMaintainOrderPayload, normalizeDeviceRow, canDispatchDevice, normalizeDeviceGisPoints, preserveDevicePageOnError } from '@/utils/waterSupplyUiModel'
+import { saveAs } from 'file-saver'
 
 const props = defineProps({ title: { type: String, required: true }, subtitle: { type: String, default: '设备信息、运行状态与维护过程管理' }, view: { type: String, default: 'ledger' } })
 const { title, subtitle, view } = props
 const query = ref({ pageNum: 1, pageSize: 10, equipmentName: '', equipmentCode: '', alarmStatus: undefined, alertLevel: undefined })
-const page = ref({ records: [], total: 0 }); const loading = ref(false); const detailVisible = ref(false); const detail = ref({}); const dispatchVisible = ref(false); const dispatching = ref(false); const dispatchDevice = ref({}); const dispatchForm = ref({ description: '', receiveUserId: undefined }); const users = ref([]); const userLoading = ref(false)
+const page = ref({ records: [], total: 0 }); const loading = ref(false); const loadError = ref(''); const detailVisible = ref(false); const detail = ref({}); const dispatchVisible = ref(false); const dispatching = ref(false); const dispatchDevice = ref({}); const dispatchForm = ref({ description: '', receiveUserId: undefined }); const users = ref([]); const userLoading = ref(false)
 const levels = [{ label: '一级', value: 1 }, { label: '二级', value: 2 }, { label: '三级', value: 3 }, { label: '四级', value: 4 }]; const pageIcon = Monitor; const alarmLevelType = level => ({ 1: 'danger', 2: 'warning', 3: 'primary', 4: 'success' }[Number(level)] || 'info')
-const detailFields = computed(() => [['设备编码', detail.value.equipmentCode], ['设备类型', detail.value.equipmentTypeName || detail.value.typeName], ['安装位置', detail.value.equipmentLocation], ['设备状态', detail.value.deviceStatusText], ['报警等级', detail.value.alarmLevelText], ['在线状态', Number(detail.value.onlineStatus) === 1 ? '在线' : '离线'], ['关联维修单', detail.value.relatedOrderNo || detail.value.orderNo], ['维修状态', detail.value.workOrderStatusText || detail.value.status], ['状态更新时间', detail.value.statusUpdateTime]].map(([label, value]) => ({ label, value: value ?? '-' })))
+function canDispatch(row) {
+  if (view !== 'dispatch' && view !== 'abnormal') return false
+  return canDispatchDevice(row)
+}
+function workOrderStatusType(code) { return ({ 1: 'info', 2: 'warning', 3: 'primary', 4: 'warning', 5: 'danger', 6: 'success', 7: 'info', 8: 'info' })[Number(code)] || 'info' }
+const detailFields = computed(() => [['设备编码', detail.value.equipmentCode], ['设备类型', detail.value.equipmentTypeName || detail.value.typeName], ['安装位置', detail.value.equipmentLocation], ['设备状态', detail.value.currentStatusText], ['在线状态', detail.value.onlineStatusText], ['报警状态', detail.value.alarmStatusText], ['报警等级', detail.value.alarmLevelText], ['异常原因', detail.value.abnormalReasonText], ['关联工单', detail.value.relatedOrderNo || detail.value.orderNo], ['工单状态', detail.value.workOrderStatusText || detail.value.status], ['接单责任人', detail.value.relatedWorkOrderPerson || detail.value.maintainPerson], ['状态更新时间', detail.value.statusUpdateTime]].map(([label, value]) => ({ label, value: value ?? '-' })))
 
-async function loadData() { loading.value = true; try { let response; if (view === 'maintain') response = await listMaintainRecords({ ...query.value }); else if (view === 'dispatch' || view === 'abnormal') response = await listAbnormalDevices({ ...query.value }); else response = await listDeviceLedger({ ...query.value }); const normalized = normalizePage(response); page.value = { ...normalized, records: normalized.records.map(normalizeDeviceRow) } } catch (error) { page.value = { records: [], total: 0 }; console.warn(`[${title}] 数据加载失败`, error) } finally { loading.value = false } }
+async function loadData() { loading.value = true; loadError.value = ''; try { if (view === 'gis') { const response = await listDeviceGisPoints({ ...query.value }); const records = normalizeDeviceGisPoints(response).map(normalizeDeviceRow); page.value = { records, total: records.length, current: 1, size: records.length || 10 }; return } let response; if (view === 'maintain') response = await listMaintainRecords({ ...query.value }); else if (view === 'dispatch' || view === 'abnormal') response = await listAbnormalDevices({ ...query.value }); else response = await listDeviceLedger({ ...query.value }); const normalized = normalizePage(response); page.value = { ...normalized, records: normalized.records.map(normalizeDeviceRow) } } catch (error) { const retained = preserveDevicePageOnError(page.value); page.value = retained.page; loadError.value = retained.error; console.warn(`[${title}] 数据加载失败`, error) } finally { loading.value = false } }
 function reset() { query.value = { pageNum: 1, pageSize: 10, equipmentName: '', equipmentCode: '', alarmStatus: undefined, alertLevel: undefined }; loadData() }
 function selectPoint(row) { detail.value = normalizeDeviceRow(row); detailVisible.value = true }
 async function loadUsers() { if (users.value.length) return; userLoading.value = true; try { const response = await listUser({ pageNum: 1, pageSize: 200 }); users.value = response.rows || response.data?.rows || [] } catch (error) { ElMessage.error('责任人列表加载失败') } finally { userLoading.value = false } }
-async function openOrder(row) { dispatchDevice.value = row; dispatchForm.value = { description: row.abnormalDescription || row.description || '', receiveUserId: undefined }; await loadUsers(); dispatchVisible.value = true }
-async function submitDispatch() { if (!dispatchForm.value.description?.trim()) return ElMessage.warning('请填写问题描述'); if (!dispatchForm.value.receiveUserId) return ElMessage.warning('请选择接单责任人'); dispatching.value = true; try { await createAbnormalOrder(buildMaintainOrderPayload(dispatchDevice.value, dispatchForm.value)); ElMessage.success('维修派单已提交'); dispatchVisible.value = false; await loadData() } catch (error) { ElMessage.error('派单失败,请检查权限或服务状态') } finally { dispatching.value = false } }
+async function openOrder(row) { dispatchDevice.value = row; dispatchForm.value = { description: row.abnormalReason || row.abnormalReasonText || row.description || '', receiveUserId: undefined }; await loadUsers(); dispatchVisible.value = true }
+async function submitDispatch() { if (!dispatchForm.value.description?.trim()) return ElMessage.warning('请填写问题描述'); if (!dispatchForm.value.receiveUserId) return ElMessage.warning('请选择接单责任人'); dispatching.value = true; try { const response = await createAbnormalOrder(buildMaintainOrderPayload(dispatchDevice.value, dispatchForm.value)); const summary = response?.data?.data || response?.data || response || {}; const orderNo = summary.orderNo || summary.orderId; ElMessage.success(orderNo ? `已派单,等待接单(${orderNo})` : '已派单,等待接单'); dispatchVisible.value = false; await loadData() } catch (error) { ElMessage.error(error?.response?.data?.msg || '派单失败,请检查权限或服务状态') } finally { dispatching.value = false } }
 async function removeRecord(row) { try { await deleteMaintainRecord(row.maintainId || row.id); ElMessage.success('记录已删除'); await loadData() } catch (error) { ElMessage.error('删除失败') } }
-async function exportData() { try { await (view === 'dispatch' || view === 'abnormal' ? exportAbnormalDevices : exportDeviceLedger)({ ...query.value }); ElMessage.success('导出请求已提交') } catch (error) { ElMessage.error('导出失败') } }
+async function exportData() {
+  try {
+    const response = await (view === 'dispatch' || view === 'abnormal' ? exportAbnormalDevices : exportDeviceLedger)({ ...query.value })
+    const errorMessage = await readMonitoringExportError(response)
+    if (errorMessage) throw new Error(errorMessage)
+    const blob = response instanceof Blob ? response : new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
+    saveAs(blob, `${title}_${new Date().toISOString().slice(0, 10)}.xlsx`)
+    ElMessage.success('导出成功')
+  } catch (error) {
+    ElMessage.error(error?.message || '导出失败,请稍后重试')
+  }
+}
 onMounted(loadData)
 </script>
 

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

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

+ 520 - 0
src/views/subSystem/waterSupply/components/WaterThresholdPage.vue

@@ -0,0 +1,520 @@
+<template>
+  <div class="water-page threshold-page">
+    <div class="page-heading">
+      <div class="page-title">
+        <span class="page-title-icon"><el-icon><Warning /></el-icon></span>
+        <div>
+          <h2>阈值管理</h2>
+          <span class="muted">供水监测设备阈值、分时规则与生效范围统一配置</span>
+        </div>
+      </div>
+      <div class="heading-actions">
+        <el-button type="primary" :icon="Plus" @click="openCreate">新增阈值</el-button>
+        <el-button :icon="Refresh" :loading="loading" @click="loadRules">刷新</el-button>
+      </div>
+    </div>
+
+    <div class="metric-grid">
+      <div class="metric">
+        <div class="metric-icon"><el-icon><Connection /></el-icon></div>
+        <div class="metric-body">
+          <span>供水监测设备</span>
+          <div class="metric-value"><strong>{{ devices.length }}</strong><small>台</small></div>
+        </div>
+      </div>
+      <div class="metric">
+        <div class="metric-icon"><el-icon><Bell /></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"><el-icon><CircleCheck /></el-icon></div>
+        <div class="metric-body">
+          <span>当前页全天规则</span>
+          <div class="metric-value"><strong>{{ allDayCount }}</strong><small>条</small></div>
+        </div>
+      </div>
+      <div class="metric">
+        <div class="metric-icon"><el-icon><Clock /></el-icon></div>
+        <div class="metric-body">
+          <span>当前页分时规则</span>
+          <div class="metric-value"><strong>{{ timedCount }}</strong><small>条</small></div>
+        </div>
+      </div>
+    </div>
+
+    <el-card shadow="never" class="water-panel filter-card">
+      <template #header>
+        <div class="card-title">
+          <el-icon><Search /></el-icon>
+          <span>阈值筛选</span>
+        </div>
+      </template>
+      <el-form :inline="true" :model="query" class="filter-form">
+        <el-form-item label="设备编码">
+          <el-input v-model="query.deviceCode" clearable placeholder="请输入供水设备编码" />
+        </el-form-item>
+        <el-form-item label="预警分类">
+          <el-select v-model="query.warningType" clearable filterable placeholder="展示全部" class="warning-select">
+            <el-option v-for="item in warningTypes" :key="item" :label="item" :value="item" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
+          <el-button @click="resetQuery">展示全部</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-alert
+      v-if="loadError"
+      class="load-error"
+      type="error"
+      show-icon
+      :closable="false"
+      title="阈值加载失败"
+      :description="loadError"
+    >
+      <el-button size="small" type="primary" :loading="loading" @click="loadRules">重试</el-button>
+    </el-alert>
+
+    <el-card shadow="never" class="water-panel table-card">
+      <template #header>
+        <div class="card-title">
+          <el-icon><DataLine /></el-icon>
+          <span>阈值配置清单</span>
+        </div>
+      </template>
+      <el-table :data="page.records" stripe border v-loading="loading">
+        <el-table-column type="index" label="#" width="55" />
+        <el-table-column prop="deviceCode" label="供水设备编码" min-width="190" show-overflow-tooltip />
+        <el-table-column prop="warningType" label="预警分类" min-width="120">
+          <template #default="{ row }">
+            <el-tag effect="light">{{ row.warningType }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="warningCode" label="预警编码" min-width="150" show-overflow-tooltip />
+        <el-table-column label="阈值范围" min-width="160">
+          <template #default="{ row }">
+            <span class="threshold-range">{{ thresholdText(row) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="生效时段" min-width="140">
+          <template #default="{ row }">
+            <el-tag :type="isTimed(row) ? 'warning' : 'success'" effect="plain">
+              {{ periodText(row) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="updateTime" label="更新时间" min-width="170" show-overflow-tooltip />
+        <el-table-column label="操作" width="150" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button>
+            <el-button
+              link
+              type="danger"
+              :icon="Delete"
+              :loading="deletingId === row.id"
+              :disabled="deletingId !== null && deletingId !== row.id"
+              @click="handleDelete(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="loadRules"
+      />
+    </el-card>
+
+    <el-drawer
+      v-model="drawerVisible"
+      size="520px"
+      direction="rtl"
+      class="detail-drawer threshold-drawer"
+      :title="form.id ? '编辑阈值' : '新增阈值'"
+    >
+      <el-alert
+        v-if="deviceLoadError"
+        class="load-error"
+        type="error"
+        show-icon
+        :closable="false"
+        title="供水设备加载失败"
+        :description="deviceLoadError"
+      >
+        <el-button size="small" type="primary" @click="loadDevices">重试</el-button>
+      </el-alert>
+      <el-form :model="form" label-width="100px">
+        <el-form-item label="监测设备">
+          <el-select
+            v-if="!form.id"
+            v-model="form.deviceCodes"
+            multiple
+            filterable
+            placeholder="请选择供水监测设备"
+          >
+            <el-option
+              v-for="device in devices"
+              :key="device.equipmentId || device.id"
+              :label="device.equipmentName ? `${device.equipmentName}(${device.equipmentCode})` : device.equipmentCode"
+              :value="device.equipmentCode"
+            />
+          </el-select>
+          <el-select v-else v-model="form.deviceCode" filterable placeholder="请选择供水监测设备">
+            <el-option
+              v-for="device in devices"
+              :key="device.equipmentId || device.id"
+              :label="device.equipmentName ? `${device.equipmentName}(${device.equipmentCode})` : device.equipmentCode"
+              :value="device.equipmentCode"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="预警分类">
+          <el-select v-model="form.warningType" filterable allow-create placeholder="请选择预警分类">
+            <el-option v-for="item in warningTypes" :key="item" :label="item" :value="item" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="预警编码">
+          <el-input v-model="form.warningCode" placeholder="例如 WARN-T5-FLOW" />
+        </el-form-item>
+        <el-form-item label="阈值下限">
+          <el-input-number v-model="form.minValue" :precision="3" placeholder="可留空" />
+        </el-form-item>
+        <el-form-item label="阈值上限">
+          <el-input-number v-model="form.maxValue" :precision="3" placeholder="可留空" />
+        </el-form-item>
+        <el-form-item label="分时生效">
+          <div class="period-range">
+            <el-time-picker v-model="form.periodStart" format="HH:mm" value-format="HH:mm" placeholder="全天开始" />
+            <span>至</span>
+            <el-time-picker v-model="form.periodEnd" format="HH:mm" value-format="HH:mm" placeholder="全天结束" />
+          </div>
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="form.remark" type="textarea" :rows="3" maxlength="200" show-word-limit />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="drawerVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="saveThreshold">保存</el-button>
+      </template>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Bell, CircleCheck, Clock, Connection, DataLine, Delete, Edit, Plus, Refresh, Search, Warning } from '@element-plus/icons-vue'
+import { deleteThresholds, listThresholdDevices, listThresholds, saveThresholds, updateThreshold } from '@/api/pipeNetwork/waterSupply'
+import { normalizePage, unwrapResponse } from '@/utils/waterSupplyModel'
+import { buildThresholdQuery, buildThresholdSavePayload, buildThresholdUpdatePayload } from '@/utils/waterSupplyUiModel'
+import { getRequestErrorMessage } from '@/utils/waterFacilityQuery'
+
+const warningTypes = ['压力预警', '流量预警', '漏失预警', '设备离线', '水质异常']
+const query = ref({ pageNum: 1, pageSize: 10, deviceCode: '', warningType: undefined })
+const page = ref({ records: [], total: 0 })
+const devices = ref([])
+const loading = ref(false)
+const saving = ref(false)
+const deletingId = ref(null)
+const loadError = ref('')
+const deviceLoadError = ref('')
+const drawerVisible = ref(false)
+const form = ref({})
+
+const allDayCount = computed(() => page.value.records.filter(item => !isTimed(item)).length)
+const timedCount = computed(() => page.value.records.filter(isTimed).length)
+
+const thresholdText = row => row.minValue != null || row.maxValue != null
+  ? `${row.minValue ?? '-'} ~ ${row.maxValue ?? '-'}`
+  : '-'
+const isTimed = row => Boolean(row.periodStart || row.periodEnd)
+const periodText = row => isTimed(row)
+  ? `${row.periodStart || '--:--'} - ${row.periodEnd || '--:--'}`
+  : '全天'
+
+async function loadRules() {
+  loading.value = true
+  try {
+    page.value = normalizePage(await listThresholds(buildThresholdQuery(query.value)))
+    loadError.value = ''
+  } catch (error) {
+    loadError.value = getRequestErrorMessage(error, '请检查服务状态')
+  } finally {
+    loading.value = false
+  }
+}
+
+async function loadDevices() {
+  try {
+    const response = unwrapResponse(await listThresholdDevices())
+    devices.value = Array.isArray(response) ? response : []
+    deviceLoadError.value = ''
+  } catch (error) {
+    deviceLoadError.value = getRequestErrorMessage(error, '请检查服务状态')
+  }
+}
+
+function handleSearch() {
+  query.value.pageNum = 1
+  loadRules()
+}
+
+function resetQuery() {
+  query.value = { pageNum: 1, pageSize: 10, deviceCode: '', warningType: undefined }
+  loadRules()
+}
+
+function openCreate() {
+  form.value = {
+    deviceCodes: [],
+    warningType: '压力预警',
+    warningCode: '',
+    minValue: undefined,
+    maxValue: undefined,
+    periodStart: '',
+    periodEnd: '',
+    remark: ''
+  }
+  drawerVisible.value = true
+}
+
+function openEdit(row) {
+  form.value = { ...row }
+  drawerVisible.value = true
+}
+
+function validateForm() {
+  const current = form.value
+  const selectedDevices = current.id ? [current.deviceCode] : current.deviceCodes
+  if (!selectedDevices?.filter(Boolean).length) return '请选择至少一个供水监测设备'
+  if (!current.warningType?.trim()) return '请选择预警分类'
+  if (!current.warningCode?.trim()) return '请填写预警编码'
+  if (current.minValue == null && current.maxValue == null) return '阈值下限和上限至少填写一项'
+  if (current.minValue != null && current.maxValue != null && Number(current.minValue) > Number(current.maxValue)) return '阈值下限不能大于上限'
+  if (Boolean(current.periodStart) !== Boolean(current.periodEnd)) return '分时开始和结束时间必须同时填写'
+  return ''
+}
+
+async function saveThreshold() {
+  const message = validateForm()
+  if (message) {
+    ElMessage.warning(message)
+    return
+  }
+  saving.value = true
+  try {
+    if (form.value.id) {
+      await updateThreshold(buildThresholdUpdatePayload(form.value))
+    } else {
+      await saveThresholds(buildThresholdSavePayload(form.value))
+    }
+    ElMessage.success('阈值已保存')
+    drawerVisible.value = false
+    await loadRules()
+  } catch (error) {
+    ElMessage.error(getRequestErrorMessage(error, '阈值保存失败,请检查权限或服务状态'))
+  } finally {
+    saving.value = false
+  }
+}
+
+async function handleDelete(row) {
+  try {
+    await ElMessageBox.confirm(`确认删除设备 ${row.deviceCode} 的阈值规则吗?`, '删除确认', { type: 'warning' })
+    deletingId.value = row.id
+    await deleteThresholds([row.id])
+    ElMessage.success('阈值已删除')
+    await loadRules()
+  } catch (error) {
+    if (error !== 'cancel') {
+      ElMessage.error(getRequestErrorMessage(error, '阈值删除失败,请检查权限或服务状态'))
+    }
+  } finally {
+    deletingId.value = null
+  }
+}
+
+onMounted(() => {
+  loadRules()
+  loadDevices()
+})
+</script>
+
+<style lang="scss">
+@use './waterSupplyPageTheme.scss';
+</style>
+
+<style scoped>
+.threshold-page {
+  background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 45%, #eef2f7 100%);
+}
+
+.heading-actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.page-title-icon {
+  display: grid;
+  place-items: center;
+  width: 42px;
+  height: 42px;
+  color: #fff;
+  background: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%);
+  border-radius: 8px;
+  box-shadow: 0 8px 16px rgba(37, 99, 235, 0.2);
+}
+
+.metric-grid {
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.metric {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  min-height: 78px;
+  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;
+  color: #2563eb;
+  background: linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%);
+  border-radius: 8px;
+}
+
+.metric-icon :deep(.el-icon) {
+  font-size: 22px;
+}
+
+.metric-body span {
+  display: block;
+  margin-bottom: 3px;
+  overflow: hidden;
+  color: #64748b;
+  font-size: 13px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.metric-value {
+  display: flex;
+  align-items: baseline;
+  gap: 5px;
+}
+
+.metric-value strong {
+  color: #1e293b;
+  font-size: 24px;
+  line-height: 1.2;
+}
+
+.filter-card,
+.table-card {
+  margin-bottom: 16px;
+  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 :deep(.el-icon) {
+  color: #2563eb;
+  font-size: 18px;
+}
+
+.filter-form :deep(.el-form-item) {
+  margin-bottom: 0;
+}
+
+.warning-select {
+  width: 220px;
+}
+
+.load-error {
+  margin-bottom: 16px;
+}
+
+.threshold-range {
+  color: #17324d;
+  font-weight: 600;
+}
+
+.table-card :deep(.el-card__body) {
+  padding-top: 0;
+}
+
+.table-card :deep(.el-table__header th) {
+  color: #475569;
+  background: #f8fafc;
+}
+
+.table-card :deep(.el-pagination) {
+  justify-content: flex-end;
+  margin-top: 16px;
+}
+
+.threshold-drawer :deep(.el-select),
+.threshold-drawer :deep(.el-input-number) {
+  width: 100%;
+}
+
+.period-range {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+}
+
+.period-range :deep(.el-date-editor) {
+  flex: 1;
+}
+
+@media (max-width: 1000px) {
+  .metric-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 650px) {
+  .page-heading,
+  .heading-actions {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .metric-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .threshold-drawer {
+    width: 100% !important;
+  }
+}
+</style>

+ 1 - 1
src/views/subSystem/waterSupply/device/abnormal.vue

@@ -1 +1 @@
-<script setup>import Page from '../components/WaterDevicePage.vue'</script><template><Page title="设备异常管理" view="abnormal" /></template>
+<script setup>import Page from '../components/WaterDevicePage.vue'</script><template><Page title="设备异常管理" view="abnormal" subtitle="异常态势、处置跟踪与关联工单管理" /></template>

+ 1 - 1
src/views/subSystem/waterSupply/device/maintain.vue

@@ -1 +1 @@
-<script setup>import Page from '../components/WaterDevicePage.vue'</script><template><Page title="设备维修派单" view="dispatch" subtitle="异常设备维修派单与运维同步" /></template>
+<script setup>import Page from '../components/WaterDevicePage.vue'</script><template><Page title="设备维修派单" view="dispatch" subtitle="待派单设备队列与责任人派发" /></template>

+ 55 - 15
src/views/subSystem/waterSupply/facility/gis.vue

@@ -7,10 +7,10 @@
           <span>供水设施GIS一张图</span>
         </div>
         <div class="title-metrics">
-          <span class="metric metric-danger">水源地 {{ summaryStats.sourceCount }}处</span>
-          <span class="metric metric-primary">水厂 {{ summaryStats.plantCount }}座</span>
-          <span class="metric metric-warning">泵站 {{ summaryStats.pumpStationCount }}座</span>
-          <span class="metric">供水管网 {{ summaryStats.pipeCount }}条</span>
+          <span class="metric metric-source">水源地 {{ summaryStats.sourceCount }}处</span>
+          <span class="metric metric-plant">水厂 {{ summaryStats.plantCount }}座</span>
+          <span class="metric metric-pumpStation">泵站 {{ summaryStats.pumpStationCount }}座</span>
+          <span class="metric metric-pipe">供水管网 {{ summaryStats.pipeCount }}条</span>
         </div>
       </div>
       <div class="header-actions">
@@ -596,16 +596,31 @@ function handleFullscreenChange() {
 
 .title-metrics {
   display: flex;
-  align-items: center;
-  gap: 16px;
   flex-wrap: wrap;
+  gap: 16px;
+  margin: 8px 0;
+  align-items: center;
+  margin-bottom: 8px;
 }
 
 .metric {
-  position: relative;
-  padding-left: 14px;
-  color: #44546a;
-  font-size: 13px;
+  display: inline-flex;
+  align-items: center;      /* 垂直居中 */
+  padding: 12px 16px;          /* 左右内边距 */
+  height: auto;
+  min-height: 48px;
+  font-size: 16px;
+  line-height: 1.5;
+  border-radius: 8px;
+  min-width: 100px;
+  white-space: nowrap;
+  background: rgba(255, 255, 255, 0.06);
+  border: 1px solid rgba(255, 255, 255, 0.12);
+  color: #ecf6ff;
+  box-shadow: 0 1px 8px rgba(0, 0, 0, 0.25);
+  transition: all 0.25s ease;
+  cursor: default;          /* 如果希望可点击,可改 pointer */
+  backdrop-filter: blur(4px); /* 毛玻璃效果(可选,需浏览器支持) */
 }
 
 .metric::before {
@@ -619,9 +634,34 @@ function handleFullscreenChange() {
   transform: translateY(-50%);
 }
 
-.metric-danger::before { background: #e63946; }
-.metric-primary::before { background: #457b9d; }
-.metric-warning::before { background: #7f4f24; }
+/* 语义颜色强化 */
+.metric-source {
+  background: rgba(69, 123, 157, 0.8);
+  border-color: rgba(69, 123, 157, 0.6);
+  color: #F0F4F6;
+}
+.metric-plant {
+  background: rgba(42, 157, 143, 0.8);
+  border-color: rgba(42, 157, 143, 0.6);
+  color: #F0F4F6;
+}
+.metric-pumpStation {
+  background: rgba(231, 111, 81, 0.8);
+  border-color: rgba(231, 111, 81, 0.6);
+  color: #F0F4F6;
+}
+.metric-pipe {
+  background: rgba(252, 191, 73, 0.8);
+  border-color: rgba(252, 191, 73, 0.6);
+  color: #F0F4F6;
+}
+
+.metric:hover {
+  transform: translateY(-2px);
+  background: rgba(0, 149, 253, 0.5);
+  border-color: rgba(0, 149, 253, 0.5);
+  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
+}
 
 .header-actions {
   display: flex;
@@ -686,9 +726,9 @@ function handleFullscreenChange() {
 .legend-item {
   display: flex;
   align-items: center;
-  gap: 10px;
+  gap: 8px;
   color: #4e5969;
-  font-size: 13px;
+  font-size: 14px;
 }
 
 .legend-line {

+ 1 - 1
src/views/subSystem/waterSupply/wjcbj/yzgl.vue

@@ -1 +1 @@
-<script setup>import WaterAlarmPage from '../components/WaterAlarmPage.vue'</script><template><WaterAlarmPage title="阈值管理" subtitle="分类设置监测阈值与分时规则" mode="threshold" /></template>
+<script setup>import WaterThresholdPage from '../components/WaterThresholdPage.vue'</script><template><WaterThresholdPage /></template>

+ 1 - 1
src/views/subSystem/waterSupply/wyxjc/lljc.vue

@@ -2,4 +2,4 @@
 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" min-key="flowMin" max-key="flowMax" /></template>
+<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>

+ 1 - 1
src/views/subSystem/waterSupply/wyxjc/lousjc.vue

@@ -2,4 +2,4 @@
 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" /></template>
+<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>

+ 1 - 1
src/views/subSystem/waterSupply/wyxjc/lsjc.vue

@@ -2,4 +2,4 @@
 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" /></template>
+<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>

+ 69 - 0
tests/waterSupplyMonitoring.test.mjs

@@ -0,0 +1,69 @@
+import assert from 'node:assert/strict'
+import {
+  buildMonitoringQuery,
+  buildTrendQuery,
+  formatMonitoringDateTime
+} from '../src/utils/waterSupplyMonitoringQuery.js'
+import {
+  normalizeMonitoringRecord,
+  readMonitoringExportError
+} from '../src/utils/waterSupplyModel.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)
+
+const trendPoint = normalizeMonitoringRecord({
+  equipmentId: 'water-flow-device',
+  equipmentName: '供水流量监测点',
+  time: '2026-09-01 12:30:00',
+  value: '25.5'
+}, 'flow', '流量监测')
+
+assert.equal(trendPoint.updateTime, '2026-09-01 12:30:00')
+assert.equal(trendPoint.value, 25.5)
+
+const exportError = await readMonitoringExportError(
+  new Blob(['{"code":500,"msg":"导出数据过多"}'], {type: 'application/json'})
+)
+const validExport = await readMonitoringExportError(
+  new Blob(['xlsx'], {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'})
+)
+
+assert.equal(exportError, '导出数据过多')
+assert.equal(validExport, null)
+
+console.log('waterSupplyMonitoring tests passed')

+ 98 - 0
tests/waterSupplyThreshold.test.mjs

@@ -0,0 +1,98 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import {
+  buildThresholdQuery,
+  buildThresholdSavePayload,
+  buildThresholdUpdatePayload
+} from '../src/utils/waterSupplyUiModel.js'
+
+test('threshold page uses the water-only threshold device endpoint', async () => {
+  const source = await readFile(
+    'src/views/subSystem/waterSupply/components/WaterThresholdPage.vue',
+    'utf8'
+  )
+
+  assert.match(source, /listThresholdDevices/u)
+  assert.doesNotMatch(source, /listDeviceLedger/u)
+})
+
+test('threshold drawer follows the shared right-side detail drawer contract', async () => {
+  const source = await readFile(
+    'src/views/subSystem/waterSupply/components/WaterThresholdPage.vue',
+    'utf8'
+  )
+
+  assert.match(source, /<el-drawer\b[^>]*direction="rtl"[^>]*>/u)
+  assert.match(source, /<el-drawer\b[^>]*class="detail-drawer threshold-drawer"[^>]*>/u)
+  assert.match(source, /\.threshold-drawer\s*\{\s*width:\s*100% !important/u)
+})
+
+test('builds the flat batch-save contract expected by the threshold API', () => {
+  const payload = buildThresholdSavePayload({
+    id: 'draft-id',
+    deviceCodes: [' WS-FLOW-1 ', '', 'WS-FLOW-2', 'WS-FLOW-1'],
+    warningType: ' 流量预警 ',
+    warningCode: ' WARN-FLOW ',
+    minValue: '20',
+    maxValue: '40',
+    periodStart: '08:00',
+    periodEnd: '20:00',
+    remark: ' 高峰期阈值 '
+  })
+
+  assert.deepEqual(payload, {
+    deviceCodes: ['WS-FLOW-1', 'WS-FLOW-2'],
+    warningType: '流量预警',
+    warningCode: 'WARN-FLOW',
+    minValue: 20,
+    maxValue: 40,
+    periodStart: '08:00',
+    periodEnd: '20:00',
+    remark: '高峰期阈值'
+  })
+})
+
+test('builds an update payload that preserves the threshold id', () => {
+  const payload = buildThresholdUpdatePayload({
+    id: 'threshold-id',
+    deviceCode: 'WS-FLOW-1',
+    warningType: '流量预警',
+    warningCode: 'WARN-FLOW',
+    minValue: 20,
+    maxValue: 40,
+    periodStart: '08:00',
+    periodEnd: '20:00',
+    createTime: '2026-09-02 10:00:00'
+  })
+
+  assert.deepEqual(payload, {
+    id: 'threshold-id',
+    deviceCode: 'WS-FLOW-1',
+    warningType: '流量预警',
+    warningCode: 'WARN-FLOW',
+    minValue: 20,
+    maxValue: 40,
+    periodStart: '08:00',
+    periodEnd: '20:00'
+  })
+})
+
+test('builds threshold list filters without alarm-only fields', () => {
+  const query = buildThresholdQuery({
+    pageNum: 2,
+    pageSize: 20,
+    deviceCode: 'WS-FLOW-1',
+    warningType: '流量预警',
+    keyword: ' ',
+    alarmLevel: 2,
+    alarmStatus: 0
+  })
+
+  assert.deepEqual(query, {
+    pageNum: 2,
+    pageSize: 20,
+    deviceCode: 'WS-FLOW-1',
+    warningType: '流量预警'
+  })
+})

+ 60 - 3
tests/waterSupplyUiModel.test.mjs

@@ -3,7 +3,11 @@ import assert from 'node:assert/strict'
 import {
   normalizeDeviceRow,
   buildMaintainOrderPayload,
-  getDeviceOrderEndpoint
+  getDeviceOrderEndpoint,
+  normalizeWorkOrderStatus,
+  canDispatchDevice,
+  preserveDevicePageOnError,
+  normalizeDeviceGisPoints
 } from '../src/utils/waterSupplyUiModel.js'
 
 test('normalizes alarm and related work-order fields for device pages', () => {
@@ -17,12 +21,36 @@ test('normalizes alarm and related work-order fields for device pages', () => {
     latestMaintainStatus: '处理中'
   })
 
-  assert.equal(row.deviceStatusText, '报警')
+  assert.equal(row.alarmStatusText, '报警')
   assert.equal(row.alarmLevelText, '二级报警')
   assert.equal(row.relatedOrderNo, 'WO202606101703482640')
   assert.equal(row.workOrderStatusText, '处理中')
 })
 
+test('keeps device operation, online, alarm, and work-order states distinct', () => {
+  const row = normalizeDeviceRow({
+    equipmentId: 'EQ-T6',
+    equipmentCode: 'CN-GS-202609020001',
+    equipmentName: 'T6压力监测点',
+    currentStatus: 3,
+    onlineStatus: 0,
+    alarmStatus: 1,
+    alertLevel: 1,
+    abnormalReason: '供水压力超限',
+    relatedWorkOrderNo: 'WO202609020001',
+    relatedWorkOrderStatus: 4,
+    relatedWorkOrderPerson: '张三'
+  })
+
+  assert.equal(row.currentStatusText, '维修')
+  assert.equal(row.onlineStatusText, '离线')
+  assert.equal(row.alarmStatusText, '报警')
+  assert.equal(row.abnormalReasonText, '供水压力超限')
+  assert.equal(row.relatedOrderNo, 'WO202609020001')
+  assert.equal(row.workOrderStatusText, '处理中')
+  assert.equal(row.relatedWorkOrderPerson, '张三')
+})
+
 test('builds a repair dispatch payload with the selected responsible user', () => {
   const payload = buildMaintainOrderPayload(
     { equipmentId: 'EQ-1', equipmentCode: 'P-001', equipmentName: '一号压力计' },
@@ -41,5 +69,34 @@ test('builds a repair dispatch payload with the selected responsible user', () =
 
 test('uses the abnormal-device order endpoint for device repair dispatch', () => {
   assert.equal(getDeviceOrderEndpoint('dispatch'), '/waterSupply/device/abnormal/order')
-  assert.equal(getDeviceOrderEndpoint('abnormal'), null)
+  assert.equal(getDeviceOrderEndpoint('abnormal'), '/waterSupply/device/abnormal/order')
+})
+
+test('maps numeric and numeric-string work-order statuses and marks terminal states', () => {
+  assert.deepEqual(normalizeWorkOrderStatus(4), { code: 4, text: '处理中', terminal: false })
+  assert.deepEqual(normalizeWorkOrderStatus('6'), { code: 6, text: '已结案', terminal: true })
+  assert.deepEqual(normalizeWorkOrderStatus('7'), { code: 7, text: '已驳回', terminal: true })
+  assert.deepEqual(normalizeWorkOrderStatus('暂无工单'), { code: null, text: '暂无工单', terminal: false })
+})
+
+test('allows dispatch when the latest order is terminal, including numeric-string status', () => {
+  assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-6', relatedWorkOrderStatus: '6' }), true)
+  assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-7', relatedWorkOrderStatus: 7 }), true)
+  assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-4', relatedWorkOrderStatus: '4' }), false)
+  assert.equal(canDispatchDevice({ relatedOrderNo: '-' }), true)
+})
+
+test('retains existing page rows and total when a reload fails', () => {
+  const previous = { records: [{ equipmentId: 'EQ-1' }], total: 1 }
+  assert.deepEqual(preserveDevicePageOnError(previous), {
+    page: previous,
+    error: '设备数据加载失败,请点击重试'
+  })
+})
+
+test('normalizes GIS responses to point records without inventing coordinates', () => {
+  assert.deepEqual(normalizeDeviceGisPoints({ data: [{ equipmentId: 'EQ-1', longitude: '110.1', latitude: '28.2' }] }), [
+    { equipmentId: 'EQ-1', longitude: '110.1', latitude: '28.2', _longitude: 110.1, _latitude: 28.2 }
+  ])
+  assert.deepEqual(normalizeDeviceGisPoints({ data: [{ equipmentId: 'EQ-2' }] }), [])
 })

Некоторые файлы не были показаны из-за большого количества измененных файлов