Browse Source

预警页面结构调整,供水首页bug修复

zengy 3 tuần trước cách đây
mục cha
commit
9026e8d1ae

+ 1 - 0
memory/index.md

@@ -39,6 +39,7 @@ npm run dev       # 默认 http://localhost:80
 - 供水 19 个页面已按 5.3.2 文档命名补齐并接入后端接口,包含设施 CRUD、设备、监测、报警、统计和轻量 GIS;部署菜单仍依赖后端 `sys_menu` 与角色授权。
 - 供水 T3 统计(Issue #7)与 T4 运行监测(Issue #8)已人工验收通过并在 Gogs 关闭(2026-09-05)。
 - 报警提醒/清单/研判/GIS 已统一至 `src/views/subSystem/alarm`;新详情为 `GET /api/alarm/{id}` 并要求 `rootTypeId`,旧未使用的排水详情 API 包装已删除。部署需执行后端 `migration_alarm_operations.sql`,GIS 最新名称为 `alarmGIS.vue` / `drainageAlarmGIS`。
+- 供水首页恢复后遗留的 `getAlarmLedgerDashboard` 导入已修复,态势图改用统一 `GET /api/alarm/dashboard?rootTypeId=1&days=7`;本次需同步更新并重启管理后端,无新增结构脚本,原统计图表保留(2026-09-24)。
 - 报警单号 `alarm_no` 最新要求为后端程序生成(`BJyyyyMMddHHmmss` + 4 位随机数,北京时间),原数据库生成触发器/序列由最新版脚本移除;管理端和采集 API 需同步部署。
 - 报警确认只记一条审核日志,包含实际工单号;工单操作不复制到 `alarm_log`。历史共用 `AlarmHistory.vue`,昵称、时间倒序、日期分组及图片预览;后端两工程各自定义 `AlarmLogOperTypeEnum`,值保持一致(0~3),不新增共享模块,已有环境执行 `migration_alarm_review_log.sql` 后同步部署两端及前端,历史旧日志保留(2026-09-24)。
 - 若干排水请求设置 `X-Silent-Error`,但 Axios 拦截器未处理该标志。

+ 1 - 0
memory/long-term.md

@@ -65,6 +65,7 @@
 - 报警确认只写一条审核日志,描述包含选填意见及“已转工单(实际工单号)”;关联工单和工单操作不再单独写 `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;处置记录以缩略图展示,点击查看大图,可切换同条记录的多张图片。
 - 报警详情的监测状态、审核结果、处置状态采用紧凑横排“普通字重标签 + 小号浅色状态标记”,不使用灰底大卡片或加粗标签;状态文本仍来自接口,处置颜色复用工单字典(2026-09-24 截图样式要求)。
+- 供水首页保留报警态势/趋势/占比图,使用统一 `/api/alarm/dashboard`(rootTypeId=1、days默认7,后端范围校验),不引用已删除的 `getAlarmLedgerDashboard` 或旧 `/waterSupply/alarm/ledger/dashboard`。后端按全量授权数据聚合,仅趋势限制日期,不用分页明细推算总量(2026-09-24 修复)。
 - 报警提醒服务固定只查 `alarm_status=0`,页面不提供“报警清单”操作。报警清单时间条件叫“报警时间”,设备提示“请输入设备编码/名称”,移除审核结果筛选但保留表格结果列。GIS 曲线选项和研判下拉仅展示 `actual_value` 非空事件(0 有效);地图点位、报警历史不受此限制,研判通过 `actualValueOnly=true` 后端先过滤再分页,无实际值深链接不能重新加入下拉(2026-09-24)。
 - 排水 GIS 样式参考 `prd/outputs/alarm-gis.html`:顶部筛选/等级统计、左侧设备列表、大地图及覆盖式详情抽屉,打开详情不挤占地图。统计展示当前结果的设备数,不是报警事件总数;沿用红橙蓝绿等级色、真实 GisMapCanvas/API 和框架分页。详情仅监测曲线、报警记录、设备信息,数值快照明确标注为触发值,不使用原型合成数据或周边环境内容(2026-09-24)。
 - 报警处置历史日期标题:当天只显示“今天”,前一天只显示“昨天”,不附加日期;同年其他日期显示“M月D日”,跨年保留年份。按本地日历日判断,悬停保留完整日期,每条操作仍显示时分秒,不改变倒序规则(2026-09-24 最新要求)。

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

@@ -30,3 +30,12 @@
 - 用户仅询问 `E:/work/city-life-line/dist-verify` 能否删除,本轮只检查,未执行删除。
 - 目录不是链接,内容为编译后的 HTML、assets 及静态资源,Git 未跟踪;项目源码、配置和脚本未发现依赖该路径。Vite 默认构建输出为 `dist`,开发服务不依赖 `dist-verify`。
 - 结论:该验证构建产物可清理,不影响源码和正常开发/构建;如有项目外静态服务另行指向该目录,需先停用或切换。需要时可通过 `npm run build:prod -- --outDir dist-verify` 重新生成当前源码的验证产物。
+
+## 修复供水首页引用已删除的报警态势接口
+
+- 截图报错为 `waterSupply.js` 未导出 `getAlarmLedgerDashboard`。合并后恢复的 `WaterSupplyHome.vue` 仍调用旧函数,旧后台 `/waterSupply/alarm/ledger/dashboard` 也已删除;单补导出会继续报 404。
+- 首页改用 `src/api/alarm.js` 的 `getAlarmDashboard({ rootTypeId: WATER_DEVICE_ROOT_TYPE_ID, days: 7 })`。统一 `AlarmController` 新增 `GET /api/alarm/dashboard`,沿用 `alarm:operations:list` 权限及获授权菜单/类型树校验,不恢复旧接口或旧页面。
+- 后端在数据库聚合总报警数、当前报警设备去重数、分级自然日趋势、监测指标及设备类型占比;保持历史事件也参与统计、设备状态不由报警状态替代。近 N 天使用北京时间,允许 1~31 天,无事件日期四级计数补 0;空范围不查询全表。指标名称优先配置表并防止重复关联放大计数,设备类型从台账关联。
+- 不删首页图表、不改布局及其他取数链路,不使用静态模拟数。测试增加首页所有 API 命名导入与真实导出的解析校验,防止重构删除接口后遗漏消费方。
+- 验证:相关前端 Node 测试 50 项、生产构建、后端运营 service/mapper/controller/范围测试 26 项、实际 MyBatis SQL 的内存 PGlite 兼容测试 4 项均通过。两仓 diff 检查通过;构建输出到临时目录,未覆盖原发布目录。
+- 前端和管理后端需同步更新,重启后端再刷新页面;无需数据库脚本。未验证真实登录或实际 KingbaseES、未重启服务、未部署/提交/推送。过程中发现其他工单代码与测试文件的并行改动,均保留未覆盖。

+ 1 - 0
src/api/alarm.js

@@ -5,6 +5,7 @@ export const getAlarms = params => get('/api/alarm', params)
 export const getAlarmReminders = params => get('/api/alarm/reminders', params)
 export const getAlarmOptions = params => get('/api/alarm/options', params)
 export const getAlarmStatistics = params => get('/api/alarm/statistics', params)
+export const getAlarmDashboard = params => get('/api/alarm/dashboard', params)
 export const getAlarm = (id, params) => get(`/api/alarm/${encodeURIComponent(id)}`, params)
 export const getAlarmTimeline = (id, params) => get(`/api/alarm/${encodeURIComponent(id)}/timeline`, params)
 export const getAlarmAnalysis = (id, params) => get(`/api/alarm/${encodeURIComponent(id)}/analysis`, params)

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

@@ -23,7 +23,7 @@
 
 <script setup>
 import { computed } from 'vue'
-import { imageAttachments } from './alarmReviewModel'
+import { imageAttachments } from './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 './alarmOperationsModel'
+import { seriesPoints, sampleTime } from './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 './alarmOperationsModel'
+import { auditStatusTone, levelTone, monitorStatusTone } from './utils/alarmOperationsModel'
 import { orderStatusType } from '../pipeNetwork/ops/workOrder/workOrderDict'
-import { hasActualValue } from './alarmReviewModel'
-import { useAlarmScope } from './useAlarmScope'
+import { hasActualValue } from './utils/alarmReviewModel'
+import { useAlarmScope } from './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 './alarmHistoryModel'
+import { groupAlarmHistory, historyDayLabel, historyTime } from './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 './alarmReviewModel'
+import { alarmDescription, thresholdText, valueText, hasActualValue } from './utils/alarmReviewModel'
 defineProps({ alarm: { type: Object, required: true }, levelTone: { type: String, default: 'info' } })
 </script>
 <style scoped>

+ 0 - 41
src/views/subSystem/alarm/alarmReviewModel.test.mjs

@@ -1,41 +0,0 @@
-import { test } from 'node:test'
-import assert from 'node:assert/strict'
-import { valueText, thresholdText, alarmDescription, imageAttachments, hasActualValue } from './alarmReviewModel.js'
-
-test('zero is a value, not an empty threshold', () => {
-  assert.equal(valueText(0,'MPa'),'0 MPa')
-  assert.equal(thresholdText({minValue:0,maxValue:10,warningUnit:'MPa'}),'0 ~ 10 MPa')
-  assert.equal(thresholdText({maxValue:0}),'上限 0')
-  assert.equal(valueText(null),'—')
-})
-test('snapshot visibility hides empty actual values but preserves numeric zero', () => {
-  for (const actualValue of [null, undefined, '']) assert.equal(hasActualValue({ actualValue, maxValue: 0 }), false)
-  assert.equal(hasActualValue({}), false)
-  assert.equal(hasActualValue({ actualValue: 0 }), true)
-  assert.equal(hasActualValue({ actualValue: '0' }), true)
-  assert.equal(hasActualValue({ actualValue: 336 }), true)
-})
-test('single-sided limits and equality follow collection semantics', () => {
-  assert.equal(thresholdText({minValue:1,warningUnit:'m'}),'下限 1 m')
-  assert.equal(alarmDescription({warningType:'压力',actualValue:1,minValue:1}),'压力达到或低于下限')
-  assert.equal(alarmDescription({warningType:'压力',actualValue:10,maxValue:10}),'压力达到或超过上限')
-  assert.equal(alarmDescription({warningCode:'WARN',actualValue:null}),'WARN报警')
-})
-test('attachments reject executable or protocol-relative links', () => {
-  assert.deepEqual(imageAttachments('javascript:alert(1),//evil.test/a,data:text/html;base64,eA=='),[])
-  assert.deepEqual(imageAttachments('/profile/a.png','/dev-api'),[{url:'/dev-api/profile/a.png',name:'a.png'}])
-  assert.equal(imageAttachments('https://files.example/a.png')[0].url,'https://files.example/a.png')
-})
-
-test('image preview groups preserve image order and exclude PDF documents', () => {
-  const images = imageAttachments('/profile/first.jpeg,/profile/report.pdf,/profile/second.PNG?version=1', '/dev-api')
-  assert.deepEqual(images.map(file => file.url), ['/dev-api/profile/first.jpeg', '/dev-api/profile/second.PNG?version=1'])
-})
-test('image grouping handles absolute URLs, fragments and invalid attachments', () => {
-  const images = imageAttachments('https://files.example/a.JPG?version=2#view,/profile/b.png#image,javascript:bad,//evil.test/a.jpg', '/dev-api')
-  assert.equal(images.length, 2)
-  assert.equal(images[0].url, 'https://files.example/a.JPG?version=2#view')
-  assert.equal(images[1].name, 'b.png')
-  assert.deepEqual(imageAttachments(null), [])
-  assert.deepEqual(imageAttachments('/profile/report.pdf'), [])
-})

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

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

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

@@ -15,10 +15,10 @@ import { getAlarms, getAlarm } from '@/api/alarm'
 import AlarmSummary from '../AlarmSummary.vue'
 import AlarmChart from '../AlarmChart.vue'
 import AlarmDetail from '../AlarmDetail.vue'
-import { alarmCurveChoices, levelTone } from '../alarmOperationsModel'
-import { hasActualValue } from '../alarmReviewModel'
-import { useAlarmScope } from '../useAlarmScope'
-import '../alarmOperations.scss'
+import { alarmCurveChoices, levelTone } from '../utils/alarmOperationsModel'
+import { hasActualValue } from '../utils/alarmReviewModel'
+import { useAlarmScope } from '../utils/useAlarmScope'
+import '../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

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

@@ -63,7 +63,7 @@ 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 { alarmDescription, thresholdText, valueText, hasActualValue } from './alarmReviewModel'
+import { alarmDescription, thresholdText, valueText, hasActualValue } from './utils/alarmReviewModel'
 
 const route = useRoute()
 const baseUrl = import.meta.env.VITE_APP_BASE_API

+ 4 - 4
src/views/subSystem/alarm/list.vue

@@ -33,11 +33,11 @@
 import { ref, reactive, watch, onBeforeUnmount } from 'vue'
 import { Search, Refresh } from '@element-plus/icons-vue'
 import { getAlarms, getAlarmOptions } from '@/api/alarm'
-import { alarmDescription, hasActualValue, valueText, thresholdText } from './alarmReviewModel'
-import { levelTone, rangeParams } from './alarmOperationsModel'
-import { useAlarmScope } from './useAlarmScope'
+import { alarmDescription, hasActualValue, valueText, thresholdText } from './utils/alarmReviewModel'
+import { levelTone, rangeParams } from './utils/alarmOperationsModel'
+import { useAlarmScope } from './utils/useAlarmScope'
 import AlarmDetail from './AlarmDetail.vue'
-import './alarmOperations.scss'
+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)

+ 4 - 4
src/views/subSystem/alarm/remind.vue

@@ -22,11 +22,11 @@
 import { computed, ref, watch, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
 import { VideoPlay, VideoPause } from '@element-plus/icons-vue'
 import { getAlarmReminders, getAlarmOptions, getAlarmStatistics } from '@/api/alarm'
-import { levelTone, levelColor } from './alarmOperationsModel'
-import { alarmDescription } from './alarmReviewModel'
-import { useAlarmScope } from './useAlarmScope'
+import { levelTone, levelColor } from './utils/alarmOperationsModel'
+import { alarmDescription } from './utils/alarmReviewModel'
+import { useAlarmScope } from './utils/useAlarmScope'
 import AlarmDetail from './AlarmDetail.vue'
-import './alarmOperations.scss'
+import './styles/alarmOperations.scss'
 const { scope, scopeKey } = useAlarmScope()
 const options = ref({}), stats = ref([]), level = ref(), visibleRows = ref([]), received = ref([])
 const manualPause = ref(false), hoverPause = ref(false), focusPause = ref(false), feed = ref(), detailRef = ref()

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


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

@@ -28,7 +28,7 @@ export function historyDayLabel(value, now = new Date()) {
 // 保留接口的微秒时间精度;同一时间按字符串主键比较,避免 int8 转 Number 丢失精度。
 export function groupAlarmHistory(records = []) {
   const sorted = [...records].sort((a, b) => {
-    const dateOrder = historyDate(b.operTime).localeCompare(historyDate(a.operTime))
+    const dateOrder = historyDate(b.operTime).localeCompre(historyDate(a.operTime))
     return dateOrder || String(b.logId ?? '').localeCompare(String(a.logId ?? ''), 'en', { numeric: true })
   })
   const groups = new Map()

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


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


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


+ 12 - 2
src/views/subSystem/pipeNetwork/ops/workOrder/dispose.vue

@@ -304,6 +304,15 @@
         <el-form-item label="派单备注">
           <el-input v-model="dispatchForm.remarks" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="可填写故障要点、现场注意事项等派单说明" />
         </el-form-item>
+        <el-form-item label="计划完成时间">
+          <el-date-picker
+            v-model="dispatchForm.planFinishTime"
+            type="datetime"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            placeholder="可设定/修改计划完成时间"
+            style="width: 100%"
+          />
+        </el-form-item>
       </el-form>
       <template #footer>
         <el-button @click="dispatchVisible = false">取消</el-button>
@@ -772,13 +781,14 @@ async function refresh(opOrderId) {
 
 /* ============ 派单 ============ */
 const dispatchVisible = ref(false)
-const dispatchForm = reactive({ orderId: null, handlerId: null, handlerName: '', remarks: '' })
+const dispatchForm = reactive({ orderId: null, handlerId: null, handlerName: '', remarks: '', planFinishTime: '' })
 
 function openDispatch(o) {
   dispatchForm.orderId = o.orderId
   dispatchForm.handlerId = o.receiveUser || null
   dispatchForm.handlerName = o.receiveUserName || ''
   dispatchForm.remarks = ''
+  dispatchForm.planFinishTime = o.planFinishTime || ''
   dispatchVisible.value = true
 }
 
@@ -789,7 +799,7 @@ async function submitDispatch() {
   }
   submitting.value = true
   try {
-    await dispatchWorkOrder({ orderId: dispatchForm.orderId, handlerId: dispatchForm.handlerId, remarks: dispatchForm.remarks })
+    await dispatchWorkOrder({ orderId: dispatchForm.orderId, handlerId: dispatchForm.handlerId, remarks: dispatchForm.remarks, planFinishTime: dispatchForm.planFinishTime || null })
     proxy.$modal.msgSuccess('派单成功,工单状态变更为待接单')
     dispatchVisible.value = false
     await refresh(dispatchForm.orderId)

+ 3 - 2
src/views/subSystem/waterSupply/components/WaterSupplyHome.vue

@@ -283,9 +283,10 @@ import {
   MapLocation, OfficeBuilding, PieChart, Refresh, Tickets, TrendCharts,
   Warning, WarningFilled
 } from '@element-plus/icons-vue'
-import { getAlarmLedgerDashboard, getAlarmStats, getDeviceGisAllPoints,
+import { getAlarmStats, getDeviceGisAllPoints,
   getWaterFacilityFeatures, getWaterFacilityOverview, listAlarmMapPoints,
   listRealtimeAlarms } from '@/api/pipeNetwork/waterSupply'
+import { getAlarmDashboard } from '@/api/alarm'
 import { getEquipmentStatusStatistics, listEquipment } from '@/api/pipeNetwork/pdeviceInform'
 import { listWorkOrder, workOrderStatusCount } from '@/api/pipeNetwork/ops/workOrder'
 import { normalizeGeoJson, normalizePage, unwrapResponse } from '@/utils/waterSupplyModel'
@@ -413,7 +414,7 @@ async function loadAll() {
     settle(() => getWaterFacilityOverview(), [], recordProblem('设施概览')),
     settle(() => getEquipmentStatusStatistics(WATER_DEVICE_ROOT_TYPE_ID), {}, recordProblem('设备统计')),
     settle(() => getAlarmStats(), {}, recordProblem('报警统计')),
-    settle(() => getAlarmLedgerDashboard(trendDays), {}, recordProblem('报警态势')),
+    settle(() => getAlarmDashboard({ rootTypeId: WATER_DEVICE_ROOT_TYPE_ID, days: trendDays }), {}, recordProblem('报警态势')),
     settle(() => listRealtimeAlarms(), [], recordProblem('实时报警')),
     // 异常待办 = 当前处于报警中的供水设备;设备状态由 equipment_status 维护
     settle(() => listEquipment({ pageNum: 1, pageSize: 50, rootTypeId: WATER_DEVICE_ROOT_TYPE_ID, alarmStatus: 1 }), { records: [], total: 0 }, recordProblem('异常待办')),

+ 0 - 124
tests/alarmGIS.visual.mjs

@@ -1,124 +0,0 @@
-// 独立视觉/交互测试:只在此测试服务器替换接口与地图,不改变生产页面的数据源。
-// PLAYWRIGHT_MODULE 指向已安装 playwright 的 index.mjs,BROWSER_EXECUTABLE 可指定浏览器。
-import { mkdtemp } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import path from 'node:path'
-import { pathToFileURL } from 'node:url'
-import assert from 'node:assert/strict'
-import { createServer } from 'vite'
-import vue from '@vitejs/plugin-vue'
-import AutoImport from 'unplugin-auto-import/vite'
-
-const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ? pathToFileURL(process.env.PLAYWRIGHT_MODULE).href : 'playwright')
-const output = await mkdtemp(path.join(tmpdir(), 'alarm-gis-visual-'))
-const samples = [
-  { id: 'a1', equipmentId: 'e1', deviceCode: 'PS-DEMO-001', deviceName: '城北路液位监测点', equipmentLocation: '城北路与人民路交叉口东侧', alarmLevel: 1, actualValue: null, longitude: '110.40', latitude: '28.46' },
-  { id: 'a2', equipmentId: 'e1', deviceCode: 'PS-DEMO-001', deviceName: '城北路液位监测点', equipmentLocation: '城北路与人民路交叉口东侧', alarmLevel: 2, actualValue: 0, longitude: '110.40', latitude: '28.46' },
-  { id: 'b1', equipmentId: 'e2', deviceCode: 'PS-DEMO-002', deviceName: '沿江路流量监测点', equipmentLocation: '沿江路排水管网检查井', alarmLevel: 2, actualValue: 2.36, longitude: '110.41', latitude: '28.47' },
-  { id: 'c1', equipmentId: 'e3', deviceCode: 'PS-DEMO-003', deviceName: '人民路井盖监测点', equipmentLocation: '人民路北侧步行道', alarmLevel: 3, actualValue: null, longitude: null, latitude: null },
-  { id: 'd1', equipmentId: 'e4', deviceCode: 'PS-DEMO-004', deviceName: '建设路液位监测点', equipmentLocation: '建设路与文化路交叉口', alarmLevel: 4, actualValue: 1.26, longitude: '110.42', latitude: '28.48' }
-].map((row, index) => ({ ...row, alarmNo: 'BJ20260924102000000' + index, createTime: '2026-09-24 10:20:00', alarmStatus: 0, monitorStatusName: '异常', auditStatus: 0, auditStatusName: '未审核', orderStatusName: '未转工单', warningCode: 'WARN_INSTANT_FLOW', warningType: '液位', warningUnit: 'm', maxValue: 1.8, alarmLevelName: ['一级报警', '二级报警', '三级报警', '四级报警'][row.alarmLevel - 1] }))
-const apiModule = `
-const samples = ${JSON.stringify(samples)};
-const response = data => Promise.resolve({ data });
-export const getAlarmOptions = () => response({ alarmLevels: ['一级报警','二级报警','三级报警','四级报警'].map((label,i)=>({value:i+1,label})), monitorStatuses:[{value:0,label:'异常'},{value:1,label:'已恢复'}] });
-export const getAlarmMap = params => { window.__gisRequests.push(params); return window.__gisDataError ? Promise.reject(new Error('测试接口异常,请重试')) : response(samples.filter(row=>(params.alarmLevel == null || row.alarmLevel===params.alarmLevel) && (params.alarmStatus == null || row.alarmStatus===params.alarmStatus) && (!params.keyword || row.deviceCode.includes(params.keyword) || row.deviceName.includes(params.keyword)))); };
-export const getAlarms = params => { window.__gisHistoryRequests.push(params); const rows=samples.filter(row=>row.deviceCode===params.deviceCode); return response({records:rows,total:rows.length}); };
-export const getAlarmAnalysis = id => { window.__gisAnalysisRequests.push(id); return response({ alarm:samples.find(row=>row.id===id), supported:true, samples:[], message:'测试数据:暂无区间采样' }); };
-export const getAlarm = id => response(samples.find(row=>row.id===id));
-export const getAlarmTimeline = () => response([]);
-`
-const scopeModule = `import { ref, computed } from 'vue'; const scope=ref({rootTypeId:2}); export function useAlarmScope(){return {scope,scopeKey:computed(()=>JSON.stringify(scope.value)),route:{query:{}},menu:()=>null,navigate:()=>{}}}`
-const mapModule = `import { onMounted, h } from 'vue'; export default {props:['points'],emits:['ready','error','point-click','cluster-click'],setup(props,{emit,expose}){expose({fitBounds(){},locatePoint(point){window.__gisLocated=point.deviceCode},zoomIn(){},zoomOut(){}});onMounted(()=>window.__gisMapFails?emit('error','测试环境:底图不可用,列表及详情不受影响。'):emit('ready'));return()=>h('div',{class:'gis-map-canvas','data-testid':'map-fixture',style:'height:100%;background:#f4f7fb'},window.__gisMapFails?[]:props.points.map(point=>h('button',{'data-testid':'map-marker',onClick:()=>emit('point-click',point)},point.name)));}}`
-const entry = `
-import {createApp} from 'vue'; import ElementPlus from 'element-plus'; import 'element-plus/dist/index.css'; import zhCn from 'element-plus/es/locale/lang/zh-cn';
-import Page from '/src/views/subSystem/alarm/drainage/alarmGIS.vue'; import Pagination from '/src/components/Pagination/index.vue';
-window.__gisRequests=[];window.__gisHistoryRequests=[];window.__gisAnalysisRequests=[];window.__gisMapFails=true;
-const app=createApp(Page);app.use(ElementPlus,{locale:zhCn});app.component('pagination',Pagination);app.mount('#app');
-`
-const fixture = {
-  name: 'alarm-gis-visual-fixture', enforce: 'pre',
-  resolveId(id) {
-    if (id === 'virtual:gis-entry') return '\0gis-entry'
-    if (id === 'virtual:gis-api') return '\0gis-api'
-    if (id.endsWith('/useAlarmScope') || id.endsWith('/useAlarmScope.js')) return '\0gis-scope'
-    if (id === 'virtual:gis-map') return '\0gis-map'
-  },
-  load(id) { return ({ '\0gis-entry': entry, '\0gis-api': apiModule, '\0gis-scope': scopeModule, '\0gis-map': mapModule })[id] },
-  configureServer(server) {
-    server.middlewares.use((req, res, next) => {
-      if (req.url !== '/__alarm-gis-fixture') return next()
-      res.setHeader('Content-Type', 'text/html; charset=utf-8')
-      res.end('<!doctype html><html lang="zh-CN"><meta charset="utf-8"><style>body{margin:0;font-family:"Microsoft YaHei",sans-serif;background:#f5f7fa}*{box-sizing:border-box}button{font-family:inherit}</style><div id="app"></div><script type="module" src="/@id/virtual:gis-entry"></script></html>')
-    })
-  }
-}
-const server = await createServer({
-  configFile: false, root: process.cwd(), plugins: [fixture, vue(), AutoImport({ imports: ['vue'], dts: false })],
-  cacheDir: path.join(output, 'vite-cache'),
-  optimizeDeps: { entries: [], include: ['vue', 'element-plus', '@element-plus/icons-vue', 'echarts'] },
-  resolve: { alias: [{find:'@/api/alarm',replacement:'virtual:gis-api'},{find:'@/components/GisMapCanvas.vue',replacement:'virtual:gis-map'},{find:'@',replacement:path.resolve('src')}] },
-  server: { host: '127.0.0.1', port: 0 }, logLevel: 'error'
-})
-let browser
-try {
-  await server.listen()
-  browser = await chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE || undefined })
-  const page = await browser.newPage({ viewport: { width: 1366, height: 1000 }, deviceScaleFactor: 1 })
-  const pageErrors = []
-  page.on('pageerror', error => pageErrors.push(error.message))
-  await page.route('**/*', route => route.request().url().startsWith('http://127.0.0.1:') ? route.continue() : route.abort())
-  await page.goto('http://127.0.0.1:' + server.httpServer.address().port + '/__alarm-gis-fixture')
-  await page.getByRole('heading', {name:'当前报警 GIS 一张图'}).waitFor()
-  await page.locator('.gis-device-item').nth(3).waitFor()
-  assert.equal(await page.locator('.gis-level-metric').count(),4)
-  assert.deepEqual(await page.locator('.gis-level-metric > strong').allTextContents(),['1台','1台','1台','1台'])
-  assert.equal(await page.getByRole('button',{name:'放大地图'}).isDisabled(),true)
-  await page.screenshot({path:path.join(output,'desktop.png'),fullPage:true})
-  const mapBefore = await page.locator('.gis-map-stage').boundingBox()
-  await page.getByLabel('报警设备列表').getByRole('button',{name:'城北路液位监测点',exact:true}).click()
-  await page.locator('.alarm-gis-drawer').waitFor()
-  await page.getByRole('tab',{name:'监测曲线',exact:true}).waitFor()
-  await page.waitForFunction(()=>window.__gisAnalysisRequests.includes('a2'))
-  assert.ok(!(await page.evaluate(()=>window.__gisAnalysisRequests)).includes('a1'))
-  assert.equal((await page.locator('.gis-map-stage').boundingBox()).width,mapBefore.width)
-  await page.getByRole('tab',{name:'报警记录',exact:true}).click()
-  assert.equal(await page.locator('.gis-history-item').count(),2)
-  assert.equal(await page.locator('.gis-history-actions').getByRole('button',{name:'查看曲线'}).count(),1)
-  assert.equal(await page.locator('.pagination-container').isVisible(),true)
-  await page.screenshot({path:path.join(output,'drawer-history.png')})
-  await page.getByRole('tab',{name:'设备信息',exact:true}).click()
-  assert.equal(await page.locator('.gis-detail-grid').last().getByText('110.4',{exact:true}).count(),1)
-  await page.keyboard.press('Escape')
-  await page.locator('.alarm-gis-drawer').waitFor({state:'hidden'})
-  await page.getByRole('button',{name:'人民路井盖监测点',exact:true}).click()
-  assert.equal(await page.getByRole('tab',{name:'报警记录',exact:true}).getAttribute('aria-selected'),'true')
-  await page.getByRole('tab',{name:'监测曲线',exact:true}).click()
-  await page.getByText('暂无有实际值的报警指标',{exact:true}).waitFor()
-  await page.keyboard.press('Escape')
-  await page.locator('.alarm-gis-drawer').waitFor({state:'hidden'})
-  await page.evaluate(()=>window.__gisMapFails=false)
-  await page.getByRole('button',{name:'重试底图',exact:true}).click()
-  await page.getByText('地图已就绪',{exact:true}).waitFor()
-  assert.equal(await page.getByRole('button',{name:'放大地图'}).isDisabled(),false)
-  assert.equal(await page.getByTestId('map-marker').count(),3)
-  await page.locator('.gis-device-item').first().getByRole('button',{name:'定位',exact:true}).click()
-  assert.equal(await page.evaluate(()=>window.__gisLocated),'PS-DEMO-001')
-  await page.getByPlaceholder('搜索设备编码、名称或安装位置').fill('无匹配设备')
-  await page.getByRole('button',{name:'查询',exact:true}).click()
-  await page.getByText('暂无匹配的报警设备',{exact:true}).waitFor()
-  await page.getByRole('button',{name:'重置',exact:true}).click()
-  await page.locator('.gis-device-item').nth(3).waitFor()
-  await page.setViewportSize({width:390,height:844})
-  assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth <= window.innerWidth),true)
-  await page.screenshot({path:path.join(output,'mobile.png'),fullPage:true})
-  await page.getByLabel('报警设备列表').getByRole('button',{name:'城北路液位监测点',exact:true}).click()
-  await page.getByRole('tab',{name:'报警记录',exact:true}).click()
-  await page.screenshot({path:path.join(output,'mobile-drawer.png')})
-  assert.equal(await page.locator('.alarm-gis-drawer').evaluate(el=>el.scrollWidth <= el.clientWidth),true)
-  assert.deepEqual(pageErrors,[])
-  console.log('GIS visual/interaction checks passed. Screenshots: ' + output)
-} finally {
-  await browser?.close()
-  await server.close()
-}

+ 0 - 55
tests/alarmHistory.test.mjs

@@ -1,55 +0,0 @@
-import test from 'node:test'
-import assert from 'node:assert/strict'
-import { readFile } from 'node:fs/promises'
-import { groupAlarmHistory, historyDayLabel, historyTime } from '../src/views/subSystem/alarm/alarmHistoryModel.js'
-
-test('history day labels show only today or yesterday, retaining dates for older records', () => {
-  const now = new Date(2026, 8, 24, 0, 10)
-  assert.equal(historyDayLabel('2026-09-24', now), '今天')
-  assert.equal(historyDayLabel('2026-09-23', now), '昨天')
-  assert.equal(historyDayLabel('2026-09-22', now), '9月22日')
-  assert.equal(historyDayLabel('2025-09-24', now), '2025年9月24日')
-  assert.equal(historyDayLabel('2026-09-25', now), '9月25日')
-})
-
-test('yesterday labels handle calendar month, year and leap day boundaries', () => {
-  assert.equal(historyDayLabel('2026-08-31', new Date(2026, 8, 1, 0, 1)), '昨天')
-  assert.equal(historyDayLabel('2025-12-31', new Date(2026, 0, 1, 0, 1)), '昨天')
-  assert.equal(historyDayLabel('2024-02-29', new Date(2024, 2, 1, 0, 1)), '昨天')
-})
-
-test('missing or invalid dates have a clear fallback', () => {
-  const now = new Date(2026, 8, 24)
-  for (const value of [null, undefined, '', '时间未记录', 'invalid', '2026-02-30', '2026-13-01']) {
-    assert.equal(historyDayLabel(value, now), '时间未记录')
-  }
-})
-
-test('history is grouped by date descending then time and log id descending without mutating input', () => {
-  const logs = [
-    { logId: '8', operTime: '2026-09-23 18:00:00' },
-    { logId: '9007199254740992', operTime: '2026-09-24 09:00:00' },
-    { logId: '9007199254740993', operTime: '2026-09-24 09:00:00' },
-    { logId: '10', operTime: '2026-09-24T10:00:00' },
-    { logId: '11', operTime: null }
-  ]
-  const groups = groupAlarmHistory(logs)
-  assert.deepEqual(groups.map(g => g.day), ['2026-09-24', '2026-09-23', '时间未记录'])
-  assert.deepEqual(groups[0].items.map(item => item.logId), ['10', '9007199254740993', '9007199254740992'])
-  assert.equal(logs[0].logId, '8')
-  assert.equal(historyTime('2026-09-24T10:00:01'), '10:00:01')
-  assert.equal(historyTime(null), '—')
-})
-
-test('audit and operations use the same dispose-style history with safe description text and image previews', async () => {
-  for (const name of ['index.vue', 'AlarmDetail.vue']) {
-    const source = await readFile('src/views/subSystem/alarm/' + name, 'utf8')
-    assert.ok(source.includes('<AlarmHistory'))
-    assert.ok(!source.includes('<el-timeline'))
-  }
-  const source = await readFile('src/views/subSystem/alarm/AlarmHistory.vue', 'utf8')
-  assert.ok(source.includes('historyDayLabel(group.day)'))
-  assert.ok(source.includes(':title="group.day"'))
-  for (const text of ['按时间倒序分组', 'history-day', 'history-time', 'item.operUserName', '{{ item.operDesc }}', 'AlarmAttachments']) assert.ok(source.includes(text))
-  assert.ok(!source.includes('v-html'))
-})

+ 0 - 139
tests/alarmOperations.test.mjs

@@ -1,139 +0,0 @@
-import test from 'node:test'
-import assert from 'node:assert/strict'
-import { readFile, access } from 'node:fs/promises'
-import { alarmCurveChoices, auditStatusTone, monitorStatusTone, groupAlarmDevices, findAlarmMenu, seriesPoints, rangeParams } from '../src/views/subSystem/alarm/alarmOperationsModel.js'
-
-test('detail status colors distinguish monitoring and audit states without treating missing values as zero',()=>{
-  assert.equal(monitorStatusTone(0),'danger')
-  assert.equal(monitorStatusTone('1'),'success')
-  assert.equal(auditStatusTone(0),'warning')
-  assert.equal(auditStatusTone('1'),'primary')
-  assert.equal(auditStatusTone(2),'info')
-  for(const status of [null,undefined,'',9]){
-    assert.equal(monitorStatusTone(status),'info')
-    assert.equal(auditStatusTone(status),'info')
-  }
-})
-
-test('detail uses lightweight inline state labels and small tags, without old gray status cards',async()=>{
-  const source=await readFile('src/views/subSystem/alarm/AlarmDetail.vue','utf8')
-  for(const label of ['监测状态','审核结果','处置状态']) assert.ok(source.includes('<dt>'+label+'</dt>'))
-  assert.equal((source.match(/size="small" effect="light"/g)||[]).length,3)
-  assert.ok(source.includes('font-weight: 400'))
-  assert.ok(source.includes('orderStatusType(alarm.orderStatus)'))
-  assert.ok(!source.includes('<label>'))
-  const styles=await readFile('src/views/subSystem/alarm/alarmOperations.scss','utf8')
-  assert.ok(!styles.includes('.state-grid'))
-})
-
-test('curve choices exclude absent values, retain zero and deduplicate events without changing source rows',()=>{
-  const rows=[{id:'null',actualValue:null},{id:'missing'},{id:'empty',actualValue:''},
-    {id:'zero',actualValue:0},{id:'negative',actualValue:-1},{id:'text-zero',actualValue:'0'},
-    {id:'zero',actualValue:0,warningType:'流量'}]
-  const choices=alarmCurveChoices(rows)
-  assert.deepEqual(choices.map(row=>row.id),['zero','negative','text-zero'])
-  assert.equal(choices[0].warningType,'流量')
-  assert.equal(rows.length,7)
-  assert.deepEqual(alarmCurveChoices([]),[])
-})
-
-test('reminders request only unhandled alarms and no longer link to ledger',async()=>{
-  const source=await readFile('src/views/subSystem/alarm/remind.vue','utf8')
-  assert.match(source,/getAlarmReminders\(\{ \.\.\.scope.value, alarmStatus: 0/)
-  assert.ok(!source.includes('报警清单'))
-  assert.ok(!source.includes("navigate('alarmList'"))
-  assert.ok(source.includes('事件详情'))
-})
-
-test('ledger labels are updated and the audit filter is removed without removing its table column',async()=>{
-  const source=await readFile('src/views/subSystem/alarm/list.vue','utf8')
-  assert.ok(source.includes('placeholder="请输入设备编码/名称"'))
-  assert.ok(source.includes('<el-form-item label="报警时间">'))
-  assert.ok(!source.includes('<el-form-item label="审核结果">'))
-  assert.ok(!source.includes('filters.auditStatus'))
-  assert.ok(!source.includes('auditStatus:'))
-  assert.ok(source.includes('prop="auditStatusName" label="审核结果"'))
-})
-
-test('GIS filters curve options and entries without filtering map or alarm history',async()=>{
-  const source=await readFile('src/views/subSystem/alarm/drainage/alarmGIS.vue','utf8')
-  assert.ok(source.includes('alarmCurveChoices([...(selected.value?.alarms || []), ...history.value])'))
-  assert.ok(source.includes('device.alarms.find(hasActualValue)'))
-  assert.ok(source.includes('v-if="hasActualValue(row)"'))
-  assert.ok(source.includes('watch(curveChoices'))
-  assert.ok(source.includes("tab === 'curve' && currentCurve"))
-  assert.ok(source.includes('暂无有实际值的报警指标'))
-  assert.ok(!source.includes('actualValueOnly: true'))
-})
-
-test('analysis filters on server before pagination and rejects empty-value deep links',async()=>{
-  const source=await readFile('src/views/subSystem/alarm/drainage/analysis.vue','utf8')
-  assert.ok(source.includes('actualValueOnly: true'))
-  assert.ok(source.includes('alarmCurveChoices(result.data.records || [])'))
-  assert.ok(source.includes('if (!hasActualValue(result.data))'))
-  assert.ok(source.indexOf('if (!hasActualValue(result.data))') < source.indexOf('choices.value.unshift(result.data)'))
-  const detail=await readFile('src/views/subSystem/alarm/AlarmDetail.vue','utf8')
-  assert.ok(detail.includes("alarm && hasActualValue(alarm) && menu('drainageAlarmAnalysis')"))
-})
-
-test('GIS groups events by device and uses highest current grade, ignoring recovered severity',()=>{
-  const [device] = groupAlarmDevices([
-    {id:'a',equipmentId:'E1',deviceCode:'D1',createTime:'2026-01-01',alarmLevel:1,alarmStatus:1,longitude:'110',latitude:'28'},
-    {id:'b',equipmentId:'E1',deviceCode:'D1',createTime:'2026-01-02',alarmLevel:3,alarmStatus:0,longitude:'110',latitude:'28'},
-    {id:'c',equipmentId:'E1',deviceCode:'D1',createTime:'2026-01-03',alarmLevel:2,alarmStatus:0,longitude:'110',latitude:'28'}
-  ])
-  assert.equal(device.alarms.length,3); assert.equal(device.alarmLevel,2); assert.equal(device.alarms[0].id,'c'); assert.equal(device.hasCoordinates,true)
-})
-test('GIS keeps missing or invalid coordinates in device list but does not map them',()=>{
-  for(const [longitude,latitude] of [[null,28],['',28],[undefined,28],['nan',28],[181,28],[110,91],[0,0]]){
-    const [device]=groupAlarmDevices([{deviceCode:'D1',longitude,latitude}]); assert.equal(device.hasCoordinates,false)
-  }
-  assert.equal(groupAlarmDevices([{deviceCode:'D1',longitude:0,latitude:28}])[0].hasCoordinates,true)
-})
-test('same leaf route selects only the matching authorized subsystem',()=>{
-  const routes=[{path:'/water',children:[{path:'alarmList',query:'{"rootTypeId":1}'}]},{path:'/drainage',children:[{path:'alarmList',query:'{"rootTypeId":2}'}]}]
-  assert.equal(findAlarmMenu(routes,'alarmList',2).path,'/drainage/alarmList')
-  assert.equal(findAlarmMenu(routes,'alarmList',3),null)
-  assert.equal(findAlarmMenu([{path:'alarmList',query:'invalid'}],'alarmList',2),null)
-})
-test('menu navigation reads the real permission store sidebar property',async()=>{
-  const scope=await readFile('src/views/subSystem/alarm/useAlarmScope.js','utf8')
-  const store=await readFile('src/store/modules/permission.js','utf8')
-  const property=scope.match(/findAlarmMenu\(permissions\.(\w+)/)[1]
-  assert.match(store,new RegExp(property+': \\[\\]'))
-})
-test('raw series preserve zero and missing values and break large sampling gaps',()=>{
-  const points=seriesPoints([{time:'2026-01-01 00:00:00',value:0},{time:'2026-01-01 00:05:00',value:2},{time:'2026-01-01 00:10:00',value:null},{time:'2026-01-01 01:00:00',value:4}],true)
-  assert.deepEqual(points.map(p=>p[1]),[0,2,null,null,4])
-  assert.equal(seriesPoints([{time:'2026-01-01 00:00:00',value:0}],true)[0][1],0)
-})
-test('new GIS entry has the correct name, three tabs and no surrounding environment tab',async()=>{
-  const source=await readFile('src/views/subSystem/alarm/drainage/alarmGIS.vue','utf8')
-  assert.ok(source.includes('DrainageAlarmGIS')); assert.ok(!source.includes('周边环境'))
-  for(const label of ['设备信息','报警记录','监测曲线'])assert.ok(source.includes('label="'+label+'"'))
-  await assert.rejects(access('src/views/subSystem/alarm/drainage/alarmMap.vue'))
-})
-test('GIS prototype layout keeps the map width stable with a separate responsive detail drawer',async()=>{
-  const source=await readFile('src/views/subSystem/alarm/drainage/alarmGIS.vue','utf8')
-  const styles=await readFile('src/views/subSystem/alarm/drainage/alarmGIS.scss','utf8')
-  for(const text of ['gis-page-heading','gis-level-metrics','gis-device-pane','gis-map-status','gis-map-legend','gis-workspace-footer'])assert.ok(source.includes(text))
-  assert.ok(source.includes('<el-drawer v-model="detailVisible"'))
-  assert.ok(source.includes('append-to-body destroy-on-close'))
-  assert.ok(!source.includes('selection-panel'))
-  assert.ok(source.includes('device.alarmLevel === Number(item.value)'))
-  assert.ok(source.includes('getAlarmMap({ ...scope.value'))
-  assert.ok(source.includes('pageNum: historyPage.value, pageSize: historySize.value'))
-  assert.ok(source.includes('aria-label="查看全部点位"'))
-  assert.ok(styles.includes('grid-template-columns: 300px minmax(0, 1fr)'))
-  assert.ok(styles.includes('@media (max-width: 680px)'))
-})
-test('ledger uses first-column business number, server pagination and complete-day dates',async()=>{
-  const source=await readFile('src/views/subSystem/alarm/list.vue','utf8')
-  assert.match(source,/<el-table-column prop="alarmNo" label="报警单号"/)
-  assert.ok(source.indexOf('prop="alarmNo"')<source.indexOf('prop="deviceCode"'))
-  assert.ok(source.includes('pageNum: pageNum.value, pageSize: pageSize.value'))
-  assert.deepEqual(rangeParams(['2026-01-01','2026-01-02']),{startTime:'2026-01-01 00:00:00',endTime:'2026-01-02 23:59:59.999999'})
-})
-test('replaced subsystem entries are removed',async()=>{
-  for(const name of ['drainage/jcbj/bjtx.vue','drainage/jcbj/bjyp.vue','drainage/jcbj/bjqd.vue','drainage/jcbj/dqbj.vue','waterSupply/monitorAlarm/index.vue','waterSupply/wjcbj/bjgl.vue']) await assert.rejects(access('src/views/subSystem/'+name))
-})

+ 31 - 0
tests/waterSupplyHomeContract.test.mjs

@@ -1,9 +1,40 @@
 import test from 'node:test'
 import assert from 'node:assert/strict'
 import { readFile } from 'node:fs/promises'
+import { parse as parseSfc } from '@vue/compiler-sfc'
+import { parse as parseJs } from '@babel/parser'
 
 const homePath = 'src/views/subSystem/waterSupply/components/WaterSupplyHome.vue'
 
+test('home API imports all resolve to real named exports', async () => {
+  const { descriptor } = parseSfc(await readFile(homePath, 'utf8'))
+  const imports = parseJs(descriptor.scriptSetup.content, { sourceType: 'module' }).program.body
+    .filter(node => node.type === 'ImportDeclaration' && node.source.value.startsWith('@/api/'))
+  for (const declaration of imports) {
+    const apiPath = declaration.source.value.replace('@/', 'src/') + '.js'
+    const nodes = parseJs(await readFile(apiPath, 'utf8'), { sourceType: 'module' }).program.body
+    const names = nodes.filter(node => node.type === 'ExportNamedDeclaration').flatMap(node => {
+      if (node.declaration?.type === 'VariableDeclaration') return node.declaration.declarations.map(item => item.id.name)
+      return node.declaration?.id ? [node.declaration.id.name] : node.specifiers.map(item => item.exported.name)
+    })
+    for (const item of declaration.specifiers.filter(item => item.type === 'ImportSpecifier')) {
+      assert.ok(names.includes(item.imported.name), `${apiPath} does not export ${item.imported.name}`)
+    }
+  }
+})
+
+test('home dashboard uses the unified scoped endpoint instead of the removed ledger dashboard', async () => {
+  const source = await readFile(homePath, 'utf8')
+  assert.doesNotMatch(source, /getAlarmLedgerDashboard/u)
+  assert.match(source, /getAlarmDashboard\(\{ rootTypeId: WATER_DEVICE_ROOT_TYPE_ID, days: trendDays \}\)/u)
+  const api = (await readFile('src/api/alarm.js', 'utf8'))
+    .replace("import request from '@/utils/request'", 'const request = config => Promise.resolve(config)')
+  const { getAlarmDashboard } = await import('data:text/javascript;base64,' + Buffer.from(api).toString('base64'))
+  assert.deepEqual(await getAlarmDashboard({ rootTypeId: 1, days: 7 }), {
+    url: '/api/alarm/dashboard', method: 'get', params: { rootTypeId: 1, days: 7 }
+  })
+})
+
 test('home removes the A/B/C placeholder headings', async () => {
   const source = await readFile(homePath, 'utf8')