Переглянути джерело

fix(water): refine threshold management UI (#9)

Kazerin 3 днів тому
батько
коміт
2f97089efb

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

@@ -7,3 +7,10 @@
 - 契约:批量保存发送平铺 `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` 既有右侧抽屉规则失败。

+ 1 - 0
src/api/pipeNetwork/waterSupply.js

@@ -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 })

Різницю між файлами не показано, бо вона завелика
+ 0 - 1
src/views/subSystem/waterSupply/components/WaterAlarmPage.vue


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

+ 22 - 0
tests/waterSupplyThreshold.test.mjs

@@ -1,11 +1,33 @@
 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',

Деякі файли не було показано, через те що забагато файлів було змінено