Bladeren bron

预警清单添加统计数据

zengy 3 weken geleden
bovenliggende
commit
804ff09627

+ 3 - 1
memory/index.md

@@ -34,11 +34,13 @@ npm run dev       # 默认 http://localhost:80
 
 ## 当前已知风险
 
+- 报警清单 `alarm/common/list.vue` 顶部新增报警总数、监测异常、监测已恢复三张统计卡片,按当前全部筛选结果计数、不受分页影响;既有查询区和表格保持原样。复用 `/api/alarm/statistics`,后端分级统计新增 abnormal/recovered,需要同步更新管理后端,无数据库结构变更(2026-09-24)。
+
 - 业务菜单主要由后端 `/getRouters` 动态下发,前端常量路由不包含完整业务树。
 - 门户页调用 `userStore.logout()`,用户 Store 当前定义的方法名为 `logOut()`,需要后续确认是否修复。
 - 供水 19 个页面已按 5.3.2 文档命名补齐并接入后端接口,包含设施 CRUD、设备、监测、报警、统计和轻量 GIS;部署菜单仍依赖后端 `sys_menu` 与角色授权。
 - 供水 T3 统计(Issue #7)与 T4 运行监测(Issue #8)已人工验收通过并在 Gogs 关闭(2026-09-05)。
-- 报警提醒/清单/审核位于 `src/views/subSystem/alarm`(审核入口 `audit.vue`);排水研判及 GIS 已移动至 `src/views/subSystem/drainage/alarm`。详情为 `GET /api/alarm/{id}` 并要求 `rootTypeId`。已有环境执行 `migration_alarm_menu_components.sql` 更新四个审核菜单及排水 2141/2143 的 component,path/query/权限保持不变;初装脚本 `migration_alarm_review.sql`、`migration_alarm_operations.sql` 也已同步新路径(2026-09-24)。
+- 报警提醒/清单/审核及配套 `utils`、`styles` 位于 `src/views/subSystem/alarm/common`;排水研判及 GIS 位于 `src/views/subSystem/drainage/alarm`。详情为 `GET /api/alarm/{id}` 并要求 `rootTypeId`。本次已有环境执行 `migration_alarm_common_components.sql` 精准更新七个公共页面菜单的 component,同步部署前后端并重新登录;path/query/权限保持不变,初装脚本已同步(2026-09-24)。
 - 供水 GIS 已按排水版复制到 `src/views/subSystem/waterSupply/alarm/alarmGIS.vue`;旧地图及其专用接口删除,首页同步使用统一地图接口。菜单 2120 由 `migration_water_supply_alarm_gis.sql` 更新为 `waterSupplyAlarmGIS`,范围 JSON 写在 query(rootTypeId=1),需同步部署前后端并执行脚本、重新登录(2026-09-24)。
 - 供水首页恢复后遗留的 `getAlarmLedgerDashboard` 导入已修复,态势图改用统一 `GET /api/alarm/dashboard?rootTypeId=1&days=7`;本次需同步更新并重启管理后端,无新增结构脚本,原统计图表保留(2026-09-24)。
 - 报警单号 `alarm_no` 最新要求为后端程序生成(`BJyyyyMMddHHmmss` + 4 位随机数,北京时间),原数据库生成触发器/序列由最新版脚本移除;管理端和采集 API 需同步部署。

+ 6 - 2
memory/long-term.md

@@ -40,6 +40,10 @@
 
 ## 用户已确认决策
 
+- 报警清单顶部展示蓝色“报警总数”、红色“监测异常”、绿色“监测已恢复”三张卡片;仅增加统计区,保留原查询、表格和分页。统计按当前查询范围的全部事件计数,监测恢复不等同于工单处置完成(2026-09-24)。
+
+- 报警审核、清单、提醒三个页面及其 `utils`、`styles` 统一放在 `src/views/subSystem/alarm/common`;详情、历史等共享组件继续保留在 `alarm` 根目录。后端菜单组件白名单、初装及增量 SQL 同步新路径,路由 path 与 query 不变(2026-09-24)。
+
 - 告警表 `alarm_data` 不再存储设备名称、设备类型、处理时间、处理人和处理备注(2026-09-24)。名称和类型从设备台账查询,处理状态仍使用 `alarm_status`;操作日志独立保留。
 
 - 采用仓库原生 `AGENTS.md + memory/` 方案。
@@ -58,9 +62,9 @@
 - 供水报警与工单前端统一使用唯一接单责任人;无手机号不阻断派单。
 - 供水、排水、燃气、窨井共用统一工单业务流程,系统展示范围由关联设备类型过滤;前端优先复用既有 REST 接口,不因页面入口创建平行工单生命周期。
 - 工单系统归属以关联设备类型树稳定业务编码判定;`/api/work-order` 是跨系统规范 API,旧入口只做兼容适配;统一工单继续采用数据库状态机和统一日志语义。
-- 报警管理侧重只读态势与台账;四子系统报警审核共用 `src/views/subSystem/alarm/audit.vue`,`sys_menu.path` 均为 `alarmAudit`,菜单 `query.rootTypeId` 按设备类型树隔离,燃气再限定 `warning_code`。确认报警创建待派单工单但不改设备状态;工单后续操作仍在统一工单页。此约定取代早期“审核页承载工单全流程/误报解除”的设计。
+- 报警管理侧重只读态势与台账;四子系统报警审核共用 `src/views/subSystem/alarm/common/audit.vue`,`sys_menu.path` 均为 `alarmAudit`,菜单 `query.rootTypeId` 按设备类型树隔离,燃气再限定 `warning_code`。确认报警创建待派单工单但不改设备状态;工单后续操作仍在统一工单页。此约定取代早期“审核页承载工单全流程/误报解除”的设计。
 - 报警继续使用 `alarm_level`:1~4 为一级~四级报警,一级最严重;`audit_status` 独立于 `alarm_status`。本次数据库脚本不迁移历史数据,不引入 `source_key`;`alarm_log.log_id` 使用 Kingbase `int8` 和独立序列 `alarm_log_id_seq`。
-- 报警运营共用 `alarm/remind.vue`、`alarm/list.vue`,排水研判与 GIS 为 `drainage/alarm/analysis.vue`、`drainage/alarm/alarmGIS.vue`;GIS 菜单 path 为 `drainageAlarmGIS`,详情无周边环境页签。现有菜单 2139/2140/2141/2143/2117 精确更新,供水 root=1、排水 root=2;后端校验授权菜单后查根及后代,运营读取与审核授权分开。供水 GIS 最新改为复制排水页面至 `waterSupply/alarm/alarmGIS.vue`(含配套 SCSS),菜单 2120 的 path=`waterSupplyAlarmGIS`、query=`{"rootTypeId":1}`、perms=`alarm:operations:list`;旧地图页面、模型及专用地图/台账补全/详情/历史接口删除,首页地图取数也使用统一 `/api/alarm/map`。地图与曲线允许供水/排水,仍校验授权菜单及设备类型树;执行 `migration_water_supply_alarm_gis.sql` 后重新登录(2026-09-24 最新要求,取代保留旧供水 GIS 的决定)。
+- 报警运营共用 `alarm/common/remind.vue`、`alarm/common/list.vue`,排水研判与 GIS 为 `drainage/alarm/analysis.vue`、`drainage/alarm/alarmGIS.vue`;GIS 菜单 path 为 `drainageAlarmGIS`,详情无周边环境页签。现有菜单 2139/2140/2141/2143/2117 精确更新,供水 root=1、排水 root=2;后端校验授权菜单后查根及后代,运营读取与审核授权分开。供水 GIS 最新改为复制排水页面至 `waterSupply/alarm/alarmGIS.vue`(含配套 SCSS),菜单 2120 的 path=`waterSupplyAlarmGIS`、query=`{"rootTypeId":1}`、perms=`alarm:operations:list`;旧地图页面、模型及专用地图/台账补全/详情/历史接口删除,首页地图取数也使用统一 `/api/alarm/map`。地图与曲线允许供水/排水,仍校验授权菜单及设备类型树;执行 `migration_water_supply_alarm_gis.sql` 后重新登录(2026-09-24 最新要求,取代保留旧供水 GIS 的决定)。
 - `alarm_data.alarm_no` 改由后端程序新增时生成 `BJyyyyMMddHHmmss` 加 4 位随机数(北京时间),覆盖请求带入的号码,普通更新不改号;不使用数据库赋号触发器/默认值/序列,保留唯一索引、不回填旧数据。管理端新增/演练及采集新增都赋号,采集复用与恢复不改号。相关列表首列展示报警单号,转工单创建日志使用该号;报警触发和采集自动恢复由 Java 在同事务记报警日志,不依赖自定义函数/触发器。研判仅使用真实采样,日/月均聚合原始有效值,不补零;没有数值数据源的指标明确提示无曲线(2026-09-24 用户最新调整,替代数据库生成及雪花 ID 约定)。
 - 报警确认只写一条审核日志,描述包含选填意见及“已转工单(实际工单号)”;关联工单和工单操作不再单独写 `alarm_log`,仍保留正常 `work_order_log`。人工日志使用 `sys_user.nick_name`;审核页与详情页共用 `AlarmHistory.vue`,按时间、同一时间按 ID 倒序,日期分组样式参考 `dispose.vue`,保留图片预览。按最新要求,`zksy-system` 与 `zk-api-service` 各自定义 `AlarmLogOperTypeEnum`(0 触发/1 确认/2 误报/3 处理),两份值保持一致,不新增共享模块,移除工单相关类型,不删除旧类型历史行(2026-09-24)。
 - 报警审核附件只支持 JPG/JPEG/PNG 图片(最多 5 张、单张 10 MB),前后端禁止 PDF;处置记录以缩略图展示,点击查看大图,可切换同条记录的多张图片。

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

@@ -39,3 +39,19 @@
 - 不删首页图表、不改布局及其他取数链路,不使用静态模拟数。测试增加首页所有 API 命名导入与真实导出的解析校验,防止重构删除接口后遗漏消费方。
 - 验证:相关前端 Node 测试 50 项、生产构建、后端运营 service/mapper/controller/范围测试 26 项、实际 MyBatis SQL 的内存 PGlite 兼容测试 4 项均通过。两仓 diff 检查通过;构建输出到临时目录,未覆盖原发布目录。
 - 前端和管理后端需同步更新,重启后端再刷新页面;无需数据库脚本。未验证真实登录或实际 KingbaseES、未重启服务、未部署/提交/推送。过程中发现其他工单代码与测试文件的并行改动,均保留未覆盖。
+
+## 报警公共页面增加 common 目录
+
+- 按要求将 `alarm/audit.vue`、`list.vue`、`remind.vue` 和完整 `utils`、`styles` 内容移动至 `alarm/common`;详情、历史、曲线等共享组件继续保留在 `alarm` 根目录。同步修改相对组件引用、工单字典引用、供水/排水工具与样式引用和既有测试路径,页面业务逻辑不变。
+- 后端 `AlarmReviewScopeService` 的审核/运营菜单组件匹配同步新路径,继续校验各子系统设备类型范围且区分读取与审核权限;初装审核/运营 SQL、SQL 回归期望及交付文档同步更新。
+- 新增 `pipe-network-service/sql/migration_alarm_common_components.sql`,仅按七个 menu_id(2118/2142/2097/2024/2117/2140/2139)更新 component 和更新时间,保留 path/query/权限及角色关联,可重复执行。
+- 验证:前端既有测试 25 项、后端范围测试 8 项通过;11 个相关 Vue SFC、共享 SCSS 编译通过,4 个工具模块及 60 处本地导入校验通过;两仓 diff 检查通过。
+- 风险与后续:未执行真实数据库 SQL,未验证真实登录页面,未部署或提交;上线需同步更新前后端、执行增量菜单脚本并重新登录。原有后端 IDE 配置和 `.feisuan/` 修改保留。
+
+## 报警清单顶部增加三项统计
+
+- 依截图在 `alarm/common/list.vue` 查询区上方新增三张白底卡片:报警总数(蓝)、监测异常(红)、监测已恢复(绿),配线性图标及说明,窄屏自动纵向排列。查询区、表格与分页模板逐字比较保持不变,样式限定在本页面新增区域。
+- 复用 `GET /api/alarm/statistics`,后端 `countByLevel` 聚合保留 level/total 并增加 abnormal/recovered,状态值通过 `AlarmRecordStatusEnum` 传入;统计沿用同一查询过滤和已授权设备类型范围,不接收分页参数。未知状态仅计入总数,不误算恢复。
+- 统计与列表独立加载;切换查询/子系统时丢弃旧响应,异常显示占位和重试,空结果显示 0,不等待统计才显示列表。
+- 验证:前端统计异步与提醒回归共 15 项、后端运营 service/mapper 共 19 项通过;Vue 脚本/模板/SCSS 编译和图标导出检查通过,两仓 diff 检查通过。未进行真实浏览器登录和 Kingbase 数据库联调。
+- 后续:前端与管理后端同步部署后生效;本次无新增 SQL、无表结构或菜单调整,未提交/推送/部署,保留既有目录迁移及其他工作区改动。

+ 1 - 1
src/views/subSystem/alarm/AlarmAttachments.vue

@@ -23,7 +23,7 @@
 
 <script setup>
 import { computed } from 'vue'
-import { imageAttachments } from './utils/alarmReviewModel'
+import { imageAttachments } from './common/utils/alarmReviewModel'
 
 const props = defineProps({
   value: { type: String, default: '' },

+ 1 - 1
src/views/subSystem/alarm/AlarmChart.vue

@@ -12,7 +12,7 @@
 import { ref, computed, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
 import * as echarts from 'echarts'
 import { getAlarmAnalysis } from '@/api/alarm'
-import { seriesPoints, sampleTime } from './utils/alarmOperationsModel'
+import { seriesPoints, sampleTime } from './common/utils/alarmOperationsModel'
 const props = defineProps({ alarmId: String, scope: { type: Object, required: true } })
 const period = ref('CURVE'), host = ref(), loading = ref(false), error = ref(''), result = ref({})
 const validCount = computed(() => (result.value.samples || []).reduce((sum, p) => sum + Number(p.validCount), 0))

+ 3 - 3
src/views/subSystem/alarm/AlarmDetail.vue

@@ -21,10 +21,10 @@ import { ref, watch, onBeforeUnmount } from 'vue'
 import { getAlarm, getAlarmTimeline } from '@/api/alarm'
 import AlarmSummary from './AlarmSummary.vue'
 import AlarmHistory from './AlarmHistory.vue'
-import { auditStatusTone, levelTone, monitorStatusTone } from './utils/alarmOperationsModel'
+import { auditStatusTone, levelTone, monitorStatusTone } from './common/utils/alarmOperationsModel'
 import { orderStatusType } from '../pipeNetwork/ops/workOrder/workOrderDict'
-import { hasActualValue } from './utils/alarmReviewModel'
-import { useAlarmScope } from './utils/useAlarmScope'
+import { hasActualValue } from './common/utils/alarmReviewModel'
+import { useAlarmScope } from './common/utils/useAlarmScope'
 const { scope, scopeKey, menu, navigate } = useAlarmScope()
 const emit = defineEmits(['visible-change'])
 const visible = ref(false), loading = ref(false), alarm = ref(null), timeline = ref([]), error = ref('')

+ 1 - 1
src/views/subSystem/alarm/AlarmHistory.vue

@@ -20,7 +20,7 @@
 <script setup>
 import { computed } from 'vue'
 import AlarmAttachments from './AlarmAttachments.vue'
-import { groupAlarmHistory, historyDayLabel, historyTime } from './utils/alarmHistoryModel'
+import { groupAlarmHistory, historyDayLabel, historyTime } from './common/utils/alarmHistoryModel'
 
 const props = defineProps({
   records: { type: Array, default: () => [] },

+ 1 - 1
src/views/subSystem/alarm/AlarmSummary.vue

@@ -11,7 +11,7 @@
   </section>
 </template>
 <script setup>
-import { alarmDescription, thresholdText, valueText, hasActualValue } from './utils/alarmReviewModel'
+import { alarmDescription, thresholdText, valueText, hasActualValue } from './common/utils/alarmReviewModel'
 defineProps({ alarm: { type: Object, required: true }, levelTone: { type: String, default: 'info' } })
 </script>
 <style scoped>

+ 2 - 2
src/views/subSystem/alarm/audit.vue → src/views/subSystem/alarm/common/audit.vue

@@ -61,8 +61,8 @@ import { useRoute, onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
 import { ElMessage } from 'element-plus'
 import { Refresh, Search, Upload } from '@element-plus/icons-vue'
 import { getAlarmReviewOptions, getAlarmReviewPage, getAlarmReviewStatistics, getAlarmReviewDetail, getAlarmLogs, submitAlarmReview, uploadAlarmReviewAttachment } from '@/api/alarmReview'
-import AlarmSummary from './AlarmSummary.vue'
-import AlarmHistory from './AlarmHistory.vue'
+import AlarmSummary from '../AlarmSummary.vue'
+import AlarmHistory from '../AlarmHistory.vue'
 import { alarmDescription, thresholdText, valueText, hasActualValue } from './utils/alarmReviewModel'
 
 const route = useRoute()

+ 71 - 5
src/views/subSystem/alarm/list.vue → src/views/subSystem/alarm/common/list.vue

@@ -1,5 +1,16 @@
 <template>
   <div class="alarm-page">
+    <section v-loading="statisticsLoading" class="alarm-list-statistics" aria-label="报警统计" :aria-busy="statisticsLoading">
+      <div v-for="card in statisticsCards" :key="card.key" class="alarm-list-stat-card" :class="`is-${card.tone}`">
+        <div class="stat-content">
+          <div class="stat-label">{{ card.label }}</div>
+          <div class="stat-value">{{ statistics?.[card.key] ?? '—' }}</div>
+          <div class="stat-caption">{{ card.caption }}</div>
+        </div>
+        <el-icon class="stat-icon" aria-hidden="true"><component :is="card.icon" /></el-icon>
+      </div>
+      <el-alert v-if="statisticsError" class="statistics-error" :title="statisticsError" type="error" show-icon :closable="false"><el-button link type="primary" @click="load">重新加载</el-button></el-alert>
+    </section>
     <el-card shadow="never" class="alarm-filter-card"><el-form :model="filters" inline class="alarm-filters" @submit.prevent="search">
       <el-form-item label="报警单号"><el-input v-model="filters.alarmNo" clearable placeholder="请输入报警单号" @keyup.enter="search" /></el-form-item>
       <el-form-item label="设备搜索"><el-input v-model="filters.keyword" clearable placeholder="请输入设备编码/名称" @keyup.enter="search" /></el-form-item>
@@ -35,23 +46,45 @@
 </template>
 <script setup name="AlarmEventList">
 import { ref, reactive, watch, onBeforeUnmount } from 'vue'
-import { Search, Refresh } from '@element-plus/icons-vue'
-import { getAlarms, getAlarmOptions } from '@/api/alarm'
+import { Search, Refresh, Document, Warning, CircleCheck } from '@element-plus/icons-vue'
+import { getAlarms, getAlarmOptions, getAlarmStatistics } from '@/api/alarm'
 import { alarmDescription, hasActualValue, valueText, thresholdText } from './utils/alarmReviewModel'
 import { auditStatusTone, levelTone, rangeParams } from './utils/alarmOperationsModel'
-import { orderStatusType } from '../pipeNetwork/ops/workOrder/workOrderDict'
+import { orderStatusType } from '../../pipeNetwork/ops/workOrder/workOrderDict'
 import { useAlarmScope } from './utils/useAlarmScope'
-import AlarmDetail from './AlarmDetail.vue'
+import AlarmDetail from '../AlarmDetail.vue'
 import './styles/alarmOperations.scss'
 const { scope, scopeKey, route } = useAlarmScope()
 const empty = () => ({ alarmNo: '', keyword: '', alarmLevel: undefined, alarmStatus: undefined })
 const filters = reactive(empty()), dates = ref([]), options = ref({}), rows = ref([]), total = ref(0)
 const pageNum = ref(1), pageSize = ref(10), ascending = ref(false), loading = ref(false), error = ref(''), detailRef = ref()
+const statistics = ref(null), statisticsLoading = ref(false), statisticsError = ref('')
+const statisticsCards = [
+  { key: 'total', label: '报警总数', caption: '当前查询范围 · 按事件计数', tone: 'primary', icon: Document },
+  { key: 'abnormal', label: '监测异常', caption: '当前查询范围 · 按事件计数', tone: 'danger', icon: Warning },
+  { key: 'recovered', label: '监测已恢复', caption: '监测恢复不代表处置完成', tone: 'success', icon: CircleCheck }
+]
 let generation = 0
+async function loadStatistics(params, token) {
+  statisticsLoading.value = true; statisticsError.value = ''; statistics.value = null
+  try {
+    const result = await getAlarmStatistics(params)
+    if (token !== generation) return
+    statistics.value = (result.data || []).reduce((counts, row) => ({
+      total: counts.total + Number(row.total || 0),
+      abnormal: counts.abnormal + Number(row.abnormal || 0),
+      recovered: counts.recovered + Number(row.recovered || 0)
+    }), { total: 0, abnormal: 0, recovered: 0 })
+  } catch (e) { if (token === generation) statisticsError.value = e.message || '统计加载失败,请重试' }
+  finally { if (token === generation) statisticsLoading.value = false }
+}
 async function load() {
   const token = ++generation; loading.value = true; error.value = ''
+  const params = { ...filters, ...rangeParams(dates.value), ...scope.value }
+  // 统计覆盖全部筛选结果,独立加载,不阻塞列表显示。
+  loadStatistics(params, token)
   try {
-    const result = await getAlarms({ ...filters, ...rangeParams(dates.value), ...scope.value, pageNum: pageNum.value, pageSize: pageSize.value, ascending: ascending.value })
+    const result = await getAlarms({ ...params, pageNum: pageNum.value, pageSize: pageSize.value, ascending: ascending.value })
     if (token === generation) { rows.value = result.data.records || []; total.value = result.data.total || 0 }
   } catch (e) { if (token === generation) { rows.value = []; total.value = 0; error.value = e.message || '加载失败' } }
   finally { if (token === generation) loading.value = false }
@@ -61,6 +94,7 @@ function reset() { Object.assign(filters, empty()); dates.value = []; search() }
 function sort({ order }) { ascending.value = order === 'ascending'; search() }
 watch(scopeKey, async () => {
   ++generation; rows.value = []; total.value = 0; loading.value = false
+  statistics.value = null; statisticsLoading.value = false; statisticsError.value = ''
   Object.assign(filters, empty()); dates.value = []; options.value = {}
   const key = scopeKey.value
   try { const result = await getAlarmOptions({ ...scope.value }); if (key === scopeKey.value) options.value = result.data || {} } catch (e) { error.value = e.message }
@@ -71,3 +105,35 @@ watch(scopeKey, async () => {
 onBeforeUnmount(() => { ++generation })
 watch(() => route.query.alarmId, id => { if (id) detailRef.value?.open(id) })
 </script>
+
+<style scoped lang="scss">
+.alarm-list-statistics {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 16px;
+  margin-bottom: 20px;
+}
+.alarm-list-stat-card {
+  --stat-color: var(--el-color-primary);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 22px 24px;
+  background: #fff;
+  border: 1px solid var(--el-border-color-light);
+  border-radius: 8px;
+
+  &.is-danger { --stat-color: var(--el-color-danger); }
+  &.is-success { --stat-color: var(--el-color-success); }
+}
+.stat-label { color: #303133; font-size: 14px; line-height: 22px; }
+.stat-value { margin: 4px 0; color: var(--stat-color); font-size: 30px; font-weight: 600; line-height: 36px; font-variant-numeric: tabular-nums; }
+.stat-caption { color: #909399; font-size: 12px; line-height: 20px; }
+.stat-icon { flex-shrink: 0; color: var(--stat-color); font-size: 26px; }
+.statistics-error { grid-column: 1 / -1; }
+@media (max-width: 768px) {
+  .alarm-list-statistics { grid-template-columns: 1fr; gap: 12px; }
+  .alarm-list-stat-card { padding: 16px 20px; }
+}
+</style>

+ 1 - 1
src/views/subSystem/alarm/remind.vue → src/views/subSystem/alarm/common/remind.vue

@@ -25,7 +25,7 @@ import { getAlarmReminders, getAlarmOptions, getAlarmStatistics } from '@/api/al
 import { levelTone, levelColor } from './utils/alarmOperationsModel'
 import { alarmDescription } from './utils/alarmReviewModel'
 import { useAlarmScope } from './utils/useAlarmScope'
-import AlarmDetail from './AlarmDetail.vue'
+import AlarmDetail from '../AlarmDetail.vue'
 import './styles/alarmOperations.scss'
 const { scope, scopeKey } = useAlarmScope()
 const options = ref({}), stats = ref([]), level = ref(), visibleRows = ref([]), received = ref([])

+ 0 - 0
src/views/subSystem/alarm/styles/alarmOperations.scss → src/views/subSystem/alarm/common/styles/alarmOperations.scss


+ 0 - 0
src/views/subSystem/alarm/utils/alarmHistoryModel.js → src/views/subSystem/alarm/common/utils/alarmHistoryModel.js


+ 0 - 0
src/views/subSystem/alarm/utils/alarmOperationsModel.js → src/views/subSystem/alarm/common/utils/alarmOperationsModel.js


+ 0 - 0
src/views/subSystem/alarm/utils/alarmReviewModel.js → src/views/subSystem/alarm/common/utils/alarmReviewModel.js


+ 0 - 0
src/views/subSystem/alarm/utils/useAlarmScope.js → src/views/subSystem/alarm/common/utils/useAlarmScope.js


+ 4 - 4
src/views/subSystem/drainage/alarm/alarmGIS.vue

@@ -110,10 +110,10 @@ import { getAlarmMap, getAlarmOptions, getAlarms } from '@/api/alarm'
 import AlarmDetail from '../../alarm/AlarmDetail.vue'
 import AlarmChart from '../../alarm/AlarmChart.vue'
 import AlarmAttachments from '../../alarm/AlarmAttachments.vue'
-import { alarmCurveChoices, groupAlarmDevices, levelColor, levelTone, monitorStatusTone } from '../../alarm/utils/alarmOperationsModel'
-import { alarmDescription, hasActualValue, thresholdText, valueText } from '../../alarm/utils/alarmReviewModel'
-import { useAlarmScope } from '../../alarm/utils/useAlarmScope'
-import '../../alarm/styles/alarmOperations.scss'
+import { alarmCurveChoices, groupAlarmDevices, levelColor, levelTone, monitorStatusTone } from '../../alarm/common/utils/alarmOperationsModel'
+import { alarmDescription, hasActualValue, thresholdText, valueText } from '../../alarm/common/utils/alarmReviewModel'
+import { useAlarmScope } from '../../alarm/common/utils/useAlarmScope'
+import '../../alarm/common/styles/alarmOperations.scss'
 
 const { scope, scopeKey } = useAlarmScope()
 const baseUrl = import.meta.env.VITE_APP_BASE_API

+ 4 - 4
src/views/subSystem/drainage/alarm/analysis.vue

@@ -15,10 +15,10 @@ import { getAlarms, getAlarm } from '@/api/alarm'
 import AlarmSummary from '../../alarm/AlarmSummary.vue'
 import AlarmChart from '../../alarm/AlarmChart.vue'
 import AlarmDetail from '../../alarm/AlarmDetail.vue'
-import { alarmCurveChoices, levelTone } from '../../alarm/utils/alarmOperationsModel'
-import { hasActualValue } from '../../alarm/utils/alarmReviewModel'
-import { useAlarmScope } from '../../alarm/utils/useAlarmScope'
-import '../../alarm/styles/alarmOperations.scss'
+import { alarmCurveChoices, levelTone } from '../../alarm/common/utils/alarmOperationsModel'
+import { hasActualValue } from '../../alarm/common/utils/alarmReviewModel'
+import { useAlarmScope } from '../../alarm/common/utils/useAlarmScope'
+import '../../alarm/common/styles/alarmOperations.scss'
 const { scope, scopeKey, route } = useAlarmScope()
 const choices = ref([]), selectedId = ref(''), alarm = ref(null), loading = ref(false), searching = ref(false), error = ref(''), detailRef = ref()
 let generation = 0, searchGeneration = 0

+ 4 - 4
src/views/subSystem/waterSupply/alarm/alarmGIS.vue

@@ -111,10 +111,10 @@ import { getAlarmMap, getAlarmOptions, getAlarms } from '@/api/alarm'
 import AlarmDetail from '../../alarm/AlarmDetail.vue'
 import AlarmChart from '../../alarm/AlarmChart.vue'
 import AlarmAttachments from '../../alarm/AlarmAttachments.vue'
-import { alarmCurveChoices, groupAlarmDevices, levelColor, levelTone, monitorStatusTone } from '../../alarm/utils/alarmOperationsModel'
-import { alarmDescription, hasActualValue, thresholdText, valueText } from '../../alarm/utils/alarmReviewModel'
-import { useAlarmScope } from '../../alarm/utils/useAlarmScope'
-import '../../alarm/styles/alarmOperations.scss'
+import { alarmCurveChoices, groupAlarmDevices, levelColor, levelTone, monitorStatusTone } from '../../alarm/common/utils/alarmOperationsModel'
+import { alarmDescription, hasActualValue, thresholdText, valueText } from '../../alarm/common/utils/alarmReviewModel'
+import { useAlarmScope } from '../../alarm/common/utils/useAlarmScope'
+import '../../alarm/common/styles/alarmOperations.scss'
 
 const { scope, scopeKey } = useAlarmScope()
 const baseUrl = import.meta.env.VITE_APP_BASE_API

+ 103 - 0
tests/alarmListStatistics.test.mjs

@@ -0,0 +1,103 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import vm from 'node:vm'
+import { computed, effectScope, nextTick, reactive, ref, watch } from 'vue'
+import { parse } from '@vue/compiler-sfc'
+import { rangeParams } from '../src/views/subSystem/alarm/common/utils/alarmOperationsModel.js'
+
+const { descriptor } = parse(await readFile(new URL('../src/views/subSystem/alarm/common/list.vue', import.meta.url), 'utf8'))
+const script = descriptor.scriptSetup.content.replace(/^import .+$/gm, '')
+async function flush() {
+  for (let i = 0; i < 8; i++) await Promise.resolve()
+  await nextTick()
+}
+function mount(t) {
+  const requests = { list: [], statistics: [] }, scope = ref({ rootTypeId: 2 }), lifecycle = {}
+  const request = key => params => new Promise((resolve, reject) => requests[key].push({ params, resolve, reject }))
+  const context = {
+    ref, reactive, watch, rangeParams, Document: {}, Warning: {}, CircleCheck: {},
+    useAlarmScope: () => ({ scope, scopeKey: computed(() => JSON.stringify(scope.value)), route: { query: {} } }),
+    getAlarms: request('list'), getAlarmStatistics: request('statistics'),
+    getAlarmOptions: async () => ({ data: {} }),
+    onBeforeUnmount: callback => { lifecycle.unmount = callback }
+  }
+  const effects = effectScope()
+  effects.run(() => vm.runInNewContext(`${script}
+    globalThis.page = { statistics, statisticsLoading, statisticsError, rows, total, loading, error, filters, dates, pageNum, pageSize, search, reset, load }
+  `, context))
+  t.after(() => { lifecycle.unmount(); effects.stop() })
+  return { page: context.page, requests, scope }
+}
+const stats = (total, abnormal, recovered) => ({ data: [{ level: 1, total, abnormal, recovered }] })
+const snapshot = value => JSON.parse(JSON.stringify(value))
+
+test('statistics use the full filtered result and do not delay table rendering', async t => {
+  const { page, requests } = mount(t)
+  await flush()
+  requests.list[0].resolve({ data: { records: [{ id: 'one' }], total: 120 } })
+  await flush()
+  assert.equal(page.rows.value.length, 1)
+  assert.equal(page.loading.value, false)
+  assert.equal(page.statisticsLoading.value, true)
+  requests.statistics[0].resolve({ data: [
+    { level: 1, total: '30', abnormal: '10', recovered: '20' },
+    { level: null, total: '90', abnormal: '40', recovered: '48' }
+  ] })
+  await flush()
+  assert.deepEqual(snapshot(page.statistics.value), { total: 120, abnormal: 50, recovered: 68 })
+  Object.assign(page.filters, { alarmNo: 'BJ', keyword: '设备', alarmLevel: 2, alarmStatus: 1 })
+  page.dates.value = ['2026-09-01', '2026-09-24']
+  page.pageNum.value = 3
+  page.load()
+  const params = requests.statistics[1].params
+  assert.equal(params.rootTypeId, 2)
+  assert.equal(params.keyword, '设备')
+  assert.equal(params.alarmNo, 'BJ')
+  assert.equal(params.alarmLevel, 2)
+  assert.equal(params.alarmStatus, 1)
+  assert.equal(params.startTime, '2026-09-01 00:00:00')
+  assert.equal(params.endTime, '2026-09-24 23:59:59.999999')
+  assert.equal(params.pageNum, undefined)
+  assert.equal(params.pageSize, undefined)
+  assert.equal(requests.list[1].params.pageNum, 3)
+})
+
+test('late statistics from an old search or subsystem cannot overwrite current cards', async t => {
+  const { page, requests, scope } = mount(t)
+  await flush()
+  page.filters.keyword = '新查询'
+  page.search()
+  requests.statistics[1].resolve(stats(4, 3, 1))
+  await flush()
+  requests.statistics[0].resolve(stats(120, 50, 70))
+  await flush()
+  assert.equal(page.statistics.value.total, 4)
+  page.search()
+  scope.value = { rootTypeId: 1 }
+  await flush()
+  assert.equal(page.statistics.value, null)
+  assert.equal(requests.statistics[3].params.rootTypeId, 1)
+  requests.statistics[3].resolve(stats(2, 1, 1))
+  requests.statistics[2].resolve(stats(10, 8, 2))
+  await flush()
+  assert.equal(page.statistics.value.total, 2)
+})
+
+test('statistics failure remains distinct from zero and leaves the list usable', async t => {
+  const { page, requests } = mount(t)
+  await flush()
+  requests.statistics[0].reject(new Error('统计请求失败'))
+  requests.list[0].resolve({ data: { records: [{ id: 'one' }], total: 1 } })
+  await flush()
+  assert.equal(page.statistics.value, null)
+  assert.equal(page.statisticsError.value, '统计请求失败')
+  assert.equal(page.statisticsLoading.value, false)
+  assert.equal(page.rows.value.length, 1)
+  assert.equal(page.error.value, '')
+  page.reset()
+  requests.statistics[1].resolve({ data: [] })
+  await flush()
+  assert.deepEqual(snapshot(page.statistics.value), { total: 0, abnormal: 0, recovered: 0 })
+  assert.equal(page.statisticsError.value, '')
+})

+ 1 - 1
tests/alarmRemindLoading.test.mjs

@@ -5,7 +5,7 @@ import vm from 'node:vm'
 import { computed, effectScope, nextTick, ref, watch } from 'vue'
 import { parse } from '@vue/compiler-sfc'
 
-const filename = new URL('../src/views/subSystem/alarm/remind.vue', import.meta.url)
+const filename = new URL('../src/views/subSystem/alarm/common/remind.vue', import.meta.url)
 const { descriptor } = parse(await readFile(filename, 'utf8'))
 // 执行实际页面脚本,用可控请求和生命周期验证异步显示,不依赖真实接口或等待轮询。
 const script = descriptor.scriptSetup.content.replace(/^import .+$/gm, '')

+ 1 - 1
tests/waterAlarmAuditWorkbench.test.mjs

@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'
 import test from 'node:test'
 import { ensureUniqueSubsystemRouteNames } from '../src/router/routeNameUtils.js'
 
-const page = () => readFile('src/views/subSystem/alarm/audit.vue', 'utf8')
+const page = () => readFile('src/views/subSystem/alarm/common/audit.vue', 'utf8')
 
 test('shared audit page requires a decision but leaves the opinion optional with a length limit', async () => {
   const source = await page()

+ 2 - 2
tests/waterSupplyAlarmGIS.test.mjs

@@ -4,8 +4,8 @@ import { readFile } from 'node:fs/promises'
 import vm from 'node:vm'
 import { parse } from '@vue/compiler-sfc'
 import { computed, effectScope, nextTick, ref, watch } from 'vue'
-import * as operations from '../src/views/subSystem/alarm/utils/alarmOperationsModel.js'
-import * as review from '../src/views/subSystem/alarm/utils/alarmReviewModel.js'
+import * as operations from '../src/views/subSystem/alarm/common/utils/alarmOperationsModel.js'
+import * as review from '../src/views/subSystem/alarm/common/utils/alarmReviewModel.js'
 import { HOME_QUICK_ENTRIES, WATER_DEVICE_ROOT_TYPE_ID, buildHomeGisOverview } from '../src/utils/waterHomeModel.js'
 
 const { descriptor } = parse(await readFile(new URL('../src/views/subSystem/waterSupply/alarm/alarmGIS.vue', import.meta.url), 'utf8'))