Ver código fonte

还原供水设备异常管理页面并对齐统一设备模块 (#65)

取数改走 /equipment-base 与 /equipment-base/statistics?rootTypeId=1,供水范围由根类型限定;页面为只读看板(异常指标带、异常类型/编码/名称筛选、只读清单、右侧详情抽屉、当前筛选结果导出),不含派单、建单或改状态入口。新增 waterDeviceAbnormalModel 统一异常来源判定与筛选口径,并补充 CONTEXT 术语与记忆库记录。
Kazerin 3 semanas atrás
pai
commit
08b73e7126

+ 6 - 2
CONTEXT.md

@@ -5,8 +5,8 @@
 ## 供水监测与工单
 
 **设备异常**:
-供水监测设备处于需要人工关注或处置的异常状态;设备自动报警、离线或维修过程都是设备异常的来源。
-_Avoid_: 维修任务、异常工单
+供水监测设备处于需要人工关注或处置的异常状态;设备自动报警、离线或维修过程都是设备异常的来源。设备异常管理看板按设备状态表口径并列展示三种来源:报警中(`alarm_status=1`)、离线(`online_status=0`)、维修中(`current_status=1`),三者相互独立、可同时命中,不得互相替代,也不在页面提供派单或建单入口。
+_Avoid_: 维修任务、异常工单、只把报警当作异常、把维修中并入报警
 
 **报警转工单**:
 用户根据设备报警创建尚未指定接单责任人的故障维修工单,将报警纳入人工处置流程。报警记录与工单分别表达监测事实和处置过程。
@@ -42,6 +42,10 @@ _Avoid_: 设备报警状态、工单状态
 表示监测设备当前是否处于报警中的设备级状态,由运行系统根据设备仍然有效的报警原因维护。
 _Avoid_: 报警记录状态、工单状态
 
+**设备当前状态**:
+设备状态表 `equipment_status.current_status` 表达的资产状态,编码以后端 `CurrentStatusEnum` 为准:0-在用、1-维修、2-报废。设备异常管理看板与统一设备模块按此口径解释;旧 GIS 代码里的 3-维修 不再是权威。
+_Avoid_: 在线状态、报警状态、3-维修
+
 **工单处置状态**:
 表示统一工单当前所处的待派单、待接单、已接单、处理中、延期中、待验收、已结案或已取消阶段;延期中是处理中的旁路阶段,终态仅已结案与已取消。
 _Avoid_: 设备报警状态、报警记录状态、已驳回状态、延期审核状态、把延期中视作终态

+ 1 - 0
memory/index.md

@@ -51,6 +51,7 @@ npm run dev       # 默认 http://localhost:80
 - `package.json` 没有测试、Lint 或类型检查脚本。
 - 供水管网信息查询页已重构接入统一表 `pipe_network_base`(`/PipeNetworkBase/*`,network_type=2);旧表 `water_pipe_info` 的接口、实体、mapper 与前端死代码已全部删除,供水 GIS 图与统计分析 mapper 已迁移至新表(均带 `network_type='2'`),压力分布图改为等级分布(`pipe_network_level` 字典);数据迁移与删表由用户侧执行(2026-09-09)。
 - 管理端工单状态契约已与后端重构后的 `0–7` 状态机对齐;`6-延期中` 是处理旁路,终态为 `5-已结案` 与 `7-已取消`。
+- 供水「设备异常管理」页面已按统一设备模块还原(Gogs #65):取数走 `/equipment-base` + `/equipment-base/statistics?rootTypeId=1`,只读看板 + 右侧详情抽屉,导出为当前筛选结果的 CSV。资产状态编码仍有分歧:后端 `CurrentStatusEnum`(0-在用/1-维修/2-报废)与 `waterSupplyUiModel`/旧 GIS 的 3-维修 未统一,收敛前维修中设备在异常看板与 GIS 可能判定不同(2026-09-24)。
 
 ## 长期记忆
 

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

@@ -39,3 +39,31 @@
 - 不删首页图表、不改布局及其他取数链路,不使用静态模拟数。测试增加首页所有 API 命名导入与真实导出的解析校验,防止重构删除接口后遗漏消费方。
 - 验证:相关前端 Node 测试 50 项、生产构建、后端运营 service/mapper/controller/范围测试 26 项、实际 MyBatis SQL 的内存 PGlite 兼容测试 4 项均通过。两仓 diff 检查通过;构建输出到临时目录,未覆盖原发布目录。
 - 前端和管理后端需同步更新,重启后端再刷新页面;无需数据库脚本。未验证真实登录或实际 KingbaseES、未重启服务、未部署/提交/推送。过程中发现其他工单代码与测试文件的并行改动,均保留未覆盖。
+
+## 设备异常管理页面还原并对齐统一设备模块(Gogs #65 / T01)
+
+### 目标与结论
+
+- 服务器提交 `79bb7e5 设备管理统一管理` 删除了 `subSystem/waterSupply/device/abnormal.vue` 及其共享实现,但菜单 2215 仍指向该组件路径,页面因此空白。本次只还原「监测设备管理-设备异常管理」这一页,不改数据库菜单/数据、不新建表、不动设备台账与其它设备页面。
+- 取数改走统一设备模块:`/equipment-base`(分页列表)与 `/equipment-base/statistics?rootTypeId=1`,供水范围由 `WATER_DEVICE_ROOT_TYPE_ID=1` 限定,不再依赖已下线的 `/waterSupply/device/ledger/*`、`/waterSupply/device/abnormal/*`。
+- 统一设备接口不支持「报警或离线或维修」的或条件,页面按供水根类型一次取全量,再在本地做异常筛选与分页;导出为当前筛选结果的 CSV(带 BOM),避免依赖已下线的后端导出接口。
+- 页面为只读看板:指标带、筛选(异常类型/设备编码/设备名称)、只读清单、右侧详情抽屉、批量导出;没有派单、建单或改状态入口。
+
+### 文件变更
+
+- 新增 `src/utils/waterDeviceAbnormalModel.js`:异常来源判定(报警中/离线/维修中)、类型与关键字筛选、聚合统计、`/equipment-base` 取数参数与 CSV 导出内容。
+- 新增 `src/views/subSystem/waterSupply/components/WaterDeviceAbnormalPage.vue`:蓝白指挥台风格只读看板 + 右侧详情抽屉。
+- 新增 `src/views/subSystem/waterSupply/device/abnormal.vue`:菜单入口,渲染上述看板。
+- 重写 `tests/waterDeviceAbnormalBoard.test.mjs`(模型行为 + 页面契约),`tests/waterSupplyWorkOrderStatusDict.test.mjs` 改为指向新页面。
+- `CONTEXT.md` 补充「设备当前状态」等术语,明确异常看板三类来源与设备状态表的对应关系。
+
+### 验证结果
+
+- `node --test tests/*.test.mjs`:163 项,159 通过、4 失败;失败均为既存(引用已删除的 `WaterDevicePage.vue` 的台账导入导出/派单责任人用例,以及报警 GIS 标题用例),较基线 9 项失败减少 5 项,无新增失败。
+- `vite build` 通过(构建输出到已被忽略的 `dist/`)。
+
+### 风险与后续
+
+- 资产状态编码存在两套口径:后端 `CurrentStatusEnum`(0-在用/1-维修/2-报废,统一设备模块与本次看板采用)与 `waterSupplyUiModel`/GIS 仍在用的 3-维修。两处不一致,后续需要收敛,否则维修中设备在 GIS 与异常看板可能判定不同。
+- 详情抽屉的「关联未办结工单」取自 `/waterSupply/device/gis/detail/{id}`(`waterSupply:device:gis` 权限);无该权限时抽屉降级为列表数据并给出重试提示。
+- 未验证真实登录、KingbaseES 实时数据与人工验收;异常看板在真实数据下的分页与导出仍待人工检查。

+ 192 - 0
src/utils/waterDeviceAbnormalModel.js

@@ -0,0 +1,192 @@
+import { normalizeDeviceRow, normalizeWorkOrderStatus } from './waterSupplyUiModel.js'
+import { WATER_DEVICE_ROOT_TYPE_ID } from './waterHomeModel.js'
+
+/**
+ * 供水「监测设备管理-设备异常管理」只读看板数据模型(Ticket #65)。
+ *
+ * 本模块只做纯计算:异常来源判定、筛选、统计、导出内容和取数参数。
+ * 页面不得再自带一份状态表,设备/报警/工单口径统一从这里取。
+ */
+
+/**
+ * 异常来源,与设备状态表 equipment_status 的三个字段一一对应:
+ * alarm_status=1 报警、online_status=0 离线、current_status=1 维修。
+ * 资产状态编码以后端 CurrentStatusEnum 为准:0-在用、1-维修、2-报废。
+ */
+export const ABNORMAL_SOURCE = {
+  ALARM: 'alarm',
+  OFFLINE: 'offline',
+  MAINTENANCE: 'maintenance'
+}
+
+export const ABNORMAL_TYPE_OPTIONS = [
+  { value: 'all', label: '全部异常' },
+  { value: ABNORMAL_SOURCE.ALARM, label: '报警中' },
+  { value: ABNORMAL_SOURCE.OFFLINE, label: '离线' },
+  { value: 'offline_alarm', label: '离线且报警' },
+  { value: ABNORMAL_SOURCE.MAINTENANCE, label: '维修中' }
+]
+
+const ABNORMAL_SOURCE_LABELS = {
+  [ABNORMAL_SOURCE.ALARM]: '报警中',
+  [ABNORMAL_SOURCE.OFFLINE]: '离线',
+  [ABNORMAL_SOURCE.MAINTENANCE]: '维修中'
+}
+
+const ABNORMAL_SOURCE_TAGS = {
+  [ABNORMAL_SOURCE.ALARM]: 'danger',
+  [ABNORMAL_SOURCE.OFFLINE]: 'info',
+  [ABNORMAL_SOURCE.MAINTENANCE]: 'warning'
+}
+
+// 资产状态以后端 CurrentStatusEnum 为准,不再沿用旧 GIS 的 3-维修 编码
+const ASSET_STATUS_LABELS = {
+  0: '在用',
+  1: '维修',
+  2: '报废'
+}
+
+const ABNORMAL_REASON_TEXT = {
+  [ABNORMAL_SOURCE.ALARM]: '设备报警',
+  [ABNORMAL_SOURCE.OFFLINE]: '设备离线',
+  [ABNORMAL_SOURCE.MAINTENANCE]: '设备维修中'
+}
+
+export { WATER_DEVICE_ROOT_TYPE_ID }
+
+function isTrue(value) {
+  return Number(value) === 1
+}
+
+function isFalse(value) {
+  return Number(value) === 0
+}
+
+/** 设备命中哪些异常来源,顺序固定为 报警 → 离线 → 维修。 */
+export function resolveAbnormalTypes(row = {}) {
+  const types = []
+  if (isTrue(row.alarmStatus)) types.push(ABNORMAL_SOURCE.ALARM)
+  if (isFalse(row.onlineStatus)) types.push(ABNORMAL_SOURCE.OFFLINE)
+  if (isTrue(row.currentStatus)) types.push(ABNORMAL_SOURCE.MAINTENANCE)
+  return types
+}
+
+export function isAbnormalDevice(row = {}) {
+  return resolveAbnormalTypes(row).length > 0
+}
+
+export function abnormalTypeLabel(type) {
+  return ABNORMAL_SOURCE_LABELS[type] || '异常'
+}
+
+export function abnormalTypeTagType(type) {
+  return ABNORMAL_SOURCE_TAGS[type] || 'info'
+}
+
+/** 异常类型筛选:全部异常要求至少命中一个来源,离线且报警要求两个条件同时成立。 */
+export function matchesAbnormalType(row = {}, type = 'all') {
+  const types = resolveAbnormalTypes(row)
+  if (type === 'all' || !type) return types.length > 0
+  if (type === 'offline_alarm') {
+    return types.includes(ABNORMAL_SOURCE.ALARM) && types.includes(ABNORMAL_SOURCE.OFFLINE)
+  }
+  return types.includes(type)
+}
+
+/** 关键字同时匹配设备编码与设备名称,忽略大小写。 */
+export function matchesAbnormalKeyword(row = {}, keyword = '') {
+  const normalized = String(keyword ?? '').trim().toLowerCase()
+  if (!normalized) return true
+  return [row.equipmentCode, row.equipmentName]
+    .some(value => String(value ?? '').toLowerCase().includes(normalized))
+}
+
+export function filterAbnormalDevices(rows = [], { type = 'all', keyword = '' } = {}) {
+  return (Array.isArray(rows) ? rows : []).filter(
+    row => matchesAbnormalType(row, type) && matchesAbnormalKeyword(row, keyword)
+  )
+}
+
+/** 只读看板的展示行:补中文状态、异常原因、图片地址与关联工单状态。 */
+export function normalizeAbnormalRow(row = {}) {
+  const base = normalizeDeviceRow(row)
+  const types = resolveAbnormalTypes(row)
+  const labels = types.map(abnormalTypeLabel)
+  const orderStatus = normalizeWorkOrderStatus(
+    row.relatedWorkOrderStatus ?? row.orderStatus ?? row.latestMaintainStatus
+  )
+  return {
+    ...base,
+    abnormalTypes: types,
+    abnormalTypeLabels: labels,
+    abnormalTypeText: labels.join(' / ') || '-',
+    abnormalReasonText: row.abnormalReason || types.map(type => ABNORMAL_REASON_TEXT[type]).join(' / ') || '-',
+    currentStatusText: ASSET_STATUS_LABELS[Number(row.currentStatus)] ?? '未知',
+    deviceStatusText: ASSET_STATUS_LABELS[Number(row.currentStatus)] ?? '未知',
+    relatedOrderNo: base.relatedOrderNo && base.relatedOrderNo !== '-' ? base.relatedOrderNo : '-',
+    relatedWorkOrderPerson: row.relatedWorkOrderPerson || '-',
+    workOrderStatusCode: orderStatus.code,
+    workOrderStatusText: orderStatus.text,
+    workOrderTerminal: orderStatus.terminal
+  }
+}
+
+/** 异常指标带中的本地聚合:指标带的总量仍以后端统计为准,这里只补后端没给的维修中。 */
+export function computeAbnormalSummary(rows = []) {
+  const list = Array.isArray(rows) ? rows : []
+  return list.reduce((acc, row) => {
+    const types = resolveAbnormalTypes(row)
+    if (!types.length) return acc
+    acc.total += 1
+    if (types.includes(ABNORMAL_SOURCE.ALARM)) acc.alarm += 1
+    if (types.includes(ABNORMAL_SOURCE.OFFLINE)) acc.offline += 1
+    if (types.includes(ABNORMAL_SOURCE.MAINTENANCE)) acc.maintenance += 1
+    if (types.includes(ABNORMAL_SOURCE.ALARM) && types.includes(ABNORMAL_SOURCE.OFFLINE)) acc.offlineAlarm += 1
+    return acc
+  }, { total: 0, alarm: 0, offline: 0, maintenance: 0, offlineAlarm: 0 })
+}
+
+/**
+ * 统一设备接口 /equipment-base 不支持"报警或离线或维修"的或条件,
+ * 因此按供水根类型一次取全量,再在本地做异常筛选与分页。
+ */
+export function buildAbnormalListQuery({ total = 1, equipmentCode = '', equipmentName = '' } = {}) {
+  const size = Number(total) > 0 ? Number(total) : 1
+  return {
+    rootTypeId: WATER_DEVICE_ROOT_TYPE_ID,
+    pageNum: 1,
+    pageSize: size,
+    ...(String(equipmentCode ?? '').trim() ? { equipmentCode: String(equipmentCode).trim() } : {}),
+    ...(String(equipmentName ?? '').trim() ? { equipmentName: String(equipmentName).trim() } : {})
+  }
+}
+
+/** 导出沿用同一筛选口径;后端未限制 pageSize,用一个大页码一次取全。 */
+export function buildAbnormalExportQuery({ equipmentCode = '', equipmentName = '' } = {}) {
+  return buildAbnormalListQuery({ total: 1000, equipmentCode, equipmentName })
+}
+
+const EXPORT_COLUMNS = [
+  ['设备编码', 'equipmentCode'],
+  ['设备名称', 'equipmentName'],
+  ['设备类型', 'equipmentTypeName'],
+  ['异常原因', 'abnormalReasonText'],
+  ['报警状态', 'alarmStatusText'],
+  ['在线状态', 'onlineStatusText'],
+  ['设备当前状态', 'currentStatusText'],
+  ['关联未办结工单', 'relatedOrderNo'],
+  ['工单状态', 'workOrderStatusText']
+]
+
+function csvCell(value) {
+  const text = value === null || value === undefined ? '' : String(value)
+  return /[",\r\n]/u.test(text) ? `"${text.replace(/"/gu, '""')}"` : text
+}
+
+/** 导出当前筛选结果;入参应是已筛选并归一化后的行,本函数只负责序列化。 */
+export function buildAbnormalCsv(rows = []) {
+  const data = Array.isArray(rows) ? rows : []
+  const header = EXPORT_COLUMNS.map(([label]) => csvCell(label))
+  const lines = data.map(row => EXPORT_COLUMNS.map(([, key]) => csvCell(row?.[key])).join(','))
+  return `\ufeff${[header.join(','), ...lines].join('\r\n')}\r\n`
+}

+ 749 - 0
src/views/subSystem/waterSupply/components/WaterDeviceAbnormalPage.vue

@@ -0,0 +1,749 @@
+<template>
+    <div class="water-page">
+        <div class="page-heading">
+            <div class="page-title">
+                <span class="page-title-icon"><el-icon><WarningFilled/></el-icon></span>
+                <div>
+                    <h2>设备异常管理</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="handleQuery">
+                <el-form-item label="异常类型">
+                    <el-select v-model="query.type" class="select--type" @change="handleQuery">
+                        <el-option
+                                v-for="option in abnormalTypeOptions"
+                                :key="option.value"
+                                :label="option.label"
+                                :value="option.value"
+                        />
+                    </el-select>
+                </el-form-item>
+                <el-form-item label="设备编码">
+                    <el-input v-model="query.equipmentCode" clearable placeholder="请输入设备编码"
+                              @keyup.enter="handleQuery"/>
+                </el-form-item>
+                <el-form-item label="设备名称">
+                    <el-input v-model="query.equipmentName" clearable placeholder="请输入设备名称"
+                              @keyup.enter="handleQuery"/>
+                </el-form-item>
+                <el-form-item>
+                    <el-button type="primary" :icon="Search" :loading="loading" @click="handleQuery">查询</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 v-for="item in abnormalMetrics" :key="item.key" class="metric">
+                <div class="metric-icon" :class="item.iconClass">
+                    <el-icon><component :is="item.icon"/></el-icon>
+                </div>
+                <div class="metric-body">
+                    <span>{{ item.label }}</span>
+                    <div class="metric-value">
+                        <strong :class="{ danger: item.danger }">{{ item.value }}</strong>
+                        <small>{{ item.unit }}</small>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <el-card shadow="never" class="table-card">
+            <template #header>
+                <div class="table-toolbar">
+                    <div class="card-title">
+                        <el-icon><List/></el-icon>
+                        <span>异常设备清单</span>
+                        <span class="muted">共 {{ filteredRows.length }} 台</span>
+                    </div>
+                    <el-button
+                            type="success"
+                            plain
+                            :icon="Download"
+                            :disabled="!filteredRows.length"
+                            @click="exportData"
+                    >批量导出</el-button>
+                </div>
+            </template>
+            <el-table :data="pagedRows" stripe border v-loading="loading" @row-click="openDetail">
+                <template #empty>
+                    <el-empty :description="loadError ? '暂无法获取异常设备' : '当前筛选下没有异常设备'"/>
+                </template>
+                <el-table-column type="index" label="#" width="55"/>
+                <el-table-column prop="equipmentCode" label="设备编码" min-width="150" show-overflow-tooltip/>
+                <el-table-column prop="equipmentName" label="设备名称" min-width="150" show-overflow-tooltip/>
+                <el-table-column prop="equipmentTypeName" label="设备类型" min-width="130" show-overflow-tooltip/>
+                <el-table-column label="所属片区" width="100">
+                    <template #default="{ row }">{{ districtLabel(row.district) }}</template>
+                </el-table-column>
+                <el-table-column label="设备图片" width="92">
+                    <template #default="{ row }">
+                        <el-image
+                                v-if="row.equipmentImageUrl"
+                                class="table-thumb"
+                                :src="row.equipmentImageUrl"
+                                :preview-src-list="[row.equipmentImageUrl]"
+                                preview-teleported
+                                fit="cover"
+                                @click.stop
+                        />
+                        <span v-else class="muted">-</span>
+                    </template>
+                </el-table-column>
+                <el-table-column label="异常原因" min-width="130">
+                    <template #default="{ row }">
+                        <el-tag v-for="type in row.abnormalTypes" :key="type" class="reason-tag"
+                                size="small" :type="abnormalTypeTagType(type)">
+                            {{ abnormalTypeLabel(type) }}
+                        </el-tag>
+                    </template>
+                </el-table-column>
+                <el-table-column label="设备当前状态" width="115">
+                    <template #default="{ row }">
+                        <el-tag size="small" :type="row.currentStatus === 1 ? 'warning' : 'info'">
+                            {{ row.currentStatusText }}
+                        </el-tag>
+                    </template>
+                </el-table-column>
+                <el-table-column label="设备报警状态" width="115">
+                    <template #default="{ row }">
+                        <el-tag size="small" :type="row.alarmStatus === 1 ? 'danger' : 'success'">
+                            {{ row.alarmStatusText }}
+                        </el-tag>
+                    </template>
+                </el-table-column>
+                <el-table-column label="在线状态" width="95">
+                    <template #default="{ row }">
+                        <el-tag size="small" :type="row.onlineStatus === 1 ? 'success' : 'info'">
+                            {{ row.onlineStatusText }}
+                        </el-tag>
+                    </template>
+                </el-table-column>
+                <el-table-column label="关联未办结工单" min-width="160" show-overflow-tooltip>
+                    <template #default="{ row }">
+                        <span>{{ row.relatedOrderNo }}</span>
+                        <span v-if="row.workOrderStatusCode !== null" class="muted">
+                            · {{ row.workOrderStatusText }}
+                        </span>
+                    </template>
+                </el-table-column>
+                <el-table-column label="操作" width="90" fixed="right">
+                    <template #default="{ row }">
+                        <el-button link type="primary" :icon="View" @click.stop="openDetail(row)">详情</el-button>
+                    </template>
+                </el-table-column>
+            </el-table>
+            <el-pagination
+                    v-model:current-page="query.pageNum"
+                    v-model:page-size="query.pageSize"
+                    :total="filteredRows.length"
+                    layout="total, sizes, prev, pager, next"
+                    :page-sizes="[10, 20, 50]"
+            />
+        </el-card>
+
+        <el-drawer
+                v-model="detailVisible"
+                class="detail-drawer"
+                direction="rtl"
+                size="480px"
+                :title="detailRow?.equipmentName || '设备详情'"
+        >
+            <div v-loading="detailLoading" class="detail-body">
+                <div v-if="detailRow" class="detail-header">
+                    <el-image
+                            v-if="detailImageUrl"
+                            class="detail-media"
+                            :src="detailImageUrl"
+                            :preview-src-list="[detailImageUrl]"
+                            preview-teleported
+                            fit="cover"
+                    />
+                    <div v-else class="detail-media detail-media--empty">暂无设备图片</div>
+                    <div class="detail-header-text">
+                        <div class="detail-name">{{ detailRow.equipmentName || '未命名设备' }}</div>
+                        <div class="muted">{{ detailRow.equipmentCode }} · {{ detailRow.equipmentTypeName || '未分类' }}</div>
+                        <div class="reason-tags">
+                            <el-tag v-for="type in detailRow.abnormalTypes" :key="type" size="small"
+                                    :type="abnormalTypeTagType(type)">
+                                {{ abnormalTypeLabel(type) }}
+                            </el-tag>
+                        </div>
+                    </div>
+                </div>
+
+                <el-alert
+                        v-if="detailWarning"
+                        type="warning"
+                        show-icon
+                        :closable="false"
+                        :title="detailWarning"
+                >
+                    <el-button size="small" link type="primary" @click="loadDetailEnrichment">重试</el-button>
+                </el-alert>
+
+                <div class="detail-section-title">设备状态</div>
+                <el-descriptions :column="1" border size="small">
+                    <el-descriptions-item label="设备当前状态">{{ detailRow.currentStatusText }}</el-descriptions-item>
+                    <el-descriptions-item label="设备报警状态">{{ detailRow.alarmStatusText }}</el-descriptions-item>
+                    <el-descriptions-item label="在线状态">{{ detailRow.onlineStatusText }}</el-descriptions-item>
+                    <el-descriptions-item label="异常原因">{{ detailRow.abnormalReasonText }}</el-descriptions-item>
+                    <el-descriptions-item v-if="detailRow.alarmLevelText !== '未分级'" label="报警等级">
+                        {{ detailRow.alarmLevelText }}
+                    </el-descriptions-item>
+                </el-descriptions>
+
+                <div class="detail-section-title">关联未办结工单</div>
+                <el-descriptions :column="1" border size="small">
+                    <el-descriptions-item label="工单编号">{{ detailRow.relatedOrderNo }}</el-descriptions-item>
+                    <el-descriptions-item label="工单状态">
+                        {{ orderStatusText(detailRow.workOrderStatusCode) }}
+                    </el-descriptions-item>
+                    <el-descriptions-item label="接单责任人">{{ detailRow.relatedWorkOrderPerson }}</el-descriptions-item>
+                </el-descriptions>
+
+                <div class="detail-section-title">设备信息</div>
+                <el-descriptions :column="1" border size="small">
+                    <el-descriptions-item label="所属片区">{{ districtLabel(detailRow.district) }}</el-descriptions-item>
+                    <el-descriptions-item label="安装位置">{{ detailRow.equipmentLocation || '-' }}</el-descriptions-item>
+                    <el-descriptions-item label="最近上报">{{ detailRow.lastDataTime || '-' }}</el-descriptions-item>
+                    <el-descriptions-item label="设备负责人">{{ detailRow.maintainer || '-' }}</el-descriptions-item>
+                    <el-descriptions-item label="联系电话">{{ detailRow.maintainerPhone || '-' }}</el-descriptions-item>
+                </el-descriptions>
+            </div>
+        </el-drawer>
+    </div>
+</template>
+
+<script setup>
+import {computed, onMounted, ref} from 'vue'
+import {
+    BellFilled,
+    CircleClose,
+    Download,
+    List,
+    Monitor,
+    Refresh,
+    RefreshLeft,
+    Search,
+    Tools,
+    View,
+    WarningFilled
+} from '@element-plus/icons-vue'
+import {ElMessage} from 'element-plus'
+import {saveAs} from 'file-saver'
+import {
+    getEquipmentDetail,
+    getEquipmentStatusStatistics,
+    listEquipment
+} from '@/api/pipeNetwork/pdeviceInform'
+import {getDeviceGisDetail} from '@/api/pipeNetwork/waterSupply'
+import {normalizeDeviceStatistics} from '@/utils/waterHomeModel'
+import {normalizePage, unwrapResponse} from '@/utils/waterSupplyModel'
+import {getRequestErrorMessage} from '@/utils/waterFacilityQuery'
+import {
+    ABNORMAL_TYPE_OPTIONS,
+    WATER_DEVICE_ROOT_TYPE_ID,
+    abnormalTypeLabel,
+    abnormalTypeTagType,
+    buildAbnormalCsv,
+    buildAbnormalListQuery,
+    computeAbnormalSummary,
+    filterAbnormalDevices,
+    normalizeAbnormalRow
+} from '@/utils/waterDeviceAbnormalModel'
+import {orderStatusText} from '@/views/subSystem/pipeNetwork/ops/workOrder/workOrderDict.js'
+
+const ABNORMAL_TYPE_FALLBACK = 'all'
+
+const districtOptions = [
+    {label: '城南', value: 0},
+    {label: '城北', value: 1},
+    {label: '太常', value: 2}
+]
+
+const abnormalTypeOptions = ABNORMAL_TYPE_OPTIONS
+
+const query = ref(createQuery())
+const rows = ref([])
+const statistics = ref({total: 0, online: 0, offline: 0, alarm: 0, onlineRate: 0})
+const loading = ref(false)
+const loadError = ref('')
+
+const detailVisible = ref(false)
+const detailLoading = ref(false)
+const detailWarning = ref('')
+const detailRow = ref(null)
+const detailImageUrl = ref('')
+
+function createQuery() {
+    return {
+        type: ABNORMAL_TYPE_FALLBACK,
+        equipmentCode: '',
+        equipmentName: '',
+        pageNum: 1,
+        pageSize: 10
+    }
+}
+
+function districtLabel(code) {
+    const hit = districtOptions.find(item => item.value === Number(code))
+    return hit ? hit.label : '-'
+}
+
+// 异常指标带取后端聚合的设备总量/在线/离线/报警,维修中由本地全量结果补齐
+const summary = computed(() => computeAbnormalSummary(rows.value))
+const abnormalMetrics = computed(() => [
+    {
+        key: 'total',
+        label: '供水设备总数',
+        value: statistics.value.total,
+        unit: '台',
+        icon: Monitor,
+        iconClass: 'metric-icon--blue'
+    },
+    {
+        key: 'alarm',
+        label: '报警设备',
+        value: statistics.value.alarm,
+        unit: '台',
+        icon: BellFilled,
+        iconClass: 'metric-icon--red',
+        danger: true
+    },
+    {
+        key: 'offline',
+        label: '离线设备',
+        value: statistics.value.offline,
+        unit: '台',
+        icon: CircleClose,
+        iconClass: 'metric-icon--slate'
+    },
+    {
+        key: 'maintenance',
+        label: '维修中设备',
+        value: summary.value.maintenance,
+        unit: '台',
+        icon: Tools,
+        iconClass: 'metric-icon--orange'
+    }
+])
+const filteredRows = computed(() => filterAbnormalDevices(rows.value, {type: query.value.type}))
+const pagedRows = computed(() => {
+    const start = (query.value.pageNum - 1) * query.value.pageSize
+    return filteredRows.value.slice(start, start + query.value.pageSize)
+})
+
+async function loadData() {
+    loading.value = true
+    loadError.value = ''
+    try {
+        const statsResponse = await getEquipmentStatusStatistics(WATER_DEVICE_ROOT_TYPE_ID)
+        statistics.value = normalizeDeviceStatistics(unwrapResponse(statsResponse) || {})
+
+        const listResponse = await listEquipment(buildAbnormalListQuery({
+            total: statistics.value.total || 1,
+            equipmentCode: query.value.equipmentCode,
+            equipmentName: query.value.equipmentName
+        }))
+        const page = normalizePage(listResponse)
+        rows.value = page.records
+            .map(normalizeAbnormalRow)
+            .filter(row => row.abnormalTypes.length > 0)
+        if (query.value.pageNum > Math.max(Math.ceil(filteredRows.value.length / query.value.pageSize), 1)) {
+            query.value.pageNum = 1
+        }
+    } catch (error) {
+        loadError.value = getRequestErrorMessage(error, '异常设备加载失败,请点击重试')
+        console.warn('[设备异常管理] 数据加载失败', error)
+    } finally {
+        loading.value = false
+    }
+}
+
+function handleQuery() {
+    query.value.pageNum = 1
+    loadData()
+}
+
+function resetQuery() {
+    query.value = createQuery()
+    loadData()
+}
+
+async function openDetail(row) {
+    detailRow.value = { ...row }
+    detailImageUrl.value = row.equipmentImageUrl || ''
+    detailWarning.value = ''
+    detailVisible.value = true
+    await loadDetailEnrichment()
+}
+
+// 统一设备详情接口给出权威台账,供水 GIS 详情补关联未办结工单与最近上报
+async function loadDetailEnrichment() {
+    const equipmentId = detailRow.value?.equipmentId
+    if (!equipmentId) return
+    detailLoading.value = true
+    detailWarning.value = ''
+    try {
+        const detail = unwrapResponse(await getEquipmentDetail(equipmentId)) || {}
+        const info = detail.equipmentInfo || {}
+        const status = detail.statusInfo || {}
+        detailRow.value = normalizeAbnormalRow({
+            ...detailRow.value,
+            ...info,
+            equipmentImage: info.equipmentImage || detailRow.value?.equipmentImage,
+            currentStatus: status.currentStatus ?? detailRow.value?.currentStatus,
+            alarmStatus: status.alarmStatus ?? detailRow.value?.alarmStatus,
+            onlineStatus: status.onlineStatus ?? detailRow.value?.onlineStatus
+        })
+        detailImageUrl.value = detailRow.value.equipmentImageUrl || ''
+    } catch (error) {
+        detailWarning.value = '设备详情加载失败,已显示列表数据'
+        console.warn('[设备异常管理] 设备详情加载失败', error)
+        return
+    } finally {
+        detailLoading.value = false
+    }
+    await loadDetailWorkOrder(equipmentId)
+}
+
+async function loadDetailWorkOrder(equipmentId) {
+    try {
+        const enrichment = unwrapResponse(await getDeviceGisDetail(equipmentId)) || {}
+        detailRow.value = normalizeAbnormalRow({
+            ...detailRow.value,
+            abnormalReason: enrichment.abnormalReason || detailRow.value?.abnormalReason,
+            alertLevel: enrichment.alertLevel ?? detailRow.value?.alertLevel,
+            lastDataTime: enrichment.lastDataTime || detailRow.value?.lastDataTime,
+            relatedWorkOrderNo: enrichment.relatedWorkOrderNo,
+            relatedWorkOrderStatus: enrichment.relatedWorkOrderStatus,
+            relatedWorkOrderPerson: enrichment.relatedWorkOrderPerson
+        })
+    } catch (error) {
+        detailWarning.value = '关联工单加载失败,请点击重试'
+        console.warn('[设备异常管理] 关联工单加载失败', error)
+    }
+}
+
+function exportData() {
+    try {
+        const csv = buildAbnormalCsv(filteredRows.value)
+        const blob = new Blob([csv], {type: 'text/csv;charset=utf-8'})
+        saveAs(blob, `设备异常清单_${new Date().toISOString().slice(0, 10)}.csv`)
+        ElMessage.success('导出完成')
+    } catch (error) {
+        console.warn('[设备异常管理] 导出失败', error)
+        ElMessage.error('导出失败,请稍后重试')
+    }
+}
+
+onMounted(loadData)
+</script>
+
+<style lang="scss">
+    @use './waterSupplyPageTheme.scss';
+</style>
+
+<style scoped>
+    .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-top: 8px;
+        margin-right: 16px;
+        margin-bottom: 8px
+    }
+
+    .filter-card :deep(.el-form-item__label) {
+        color: #475569
+    }
+
+    .select--type {
+        width: 150px;
+    }
+
+    .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--red {
+        color: #dc2626;
+        background: linear-gradient(135deg, #fee2e2 0%, #fef2f2 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
+    }
+
+    .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 {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+    }
+
+    .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;
+    }
+
+    .table-card :deep(.el-pagination) {
+        justify-content: flex-end;
+        margin-top: 16px
+    }
+
+    .table-thumb {
+        width: 46px;
+        height: 34px;
+        border: 1px solid #e2e8f0;
+        border-radius: 4px;
+    }
+
+    .reason-tag {
+        margin-right: 4px;
+    }
+
+    .reason-tags {
+        display: flex;
+        gap: 6px;
+        margin-top: 8px;
+    }
+
+    .detail-body {
+        min-height: 320px;
+    }
+
+    .detail-header {
+        display: flex;
+        gap: 14px;
+        margin-bottom: 16px;
+    }
+
+    .detail-media {
+        width: 104px;
+        height: 78px;
+        flex: 0 0 104px;
+        border: 1px solid #e2e8f0;
+        border-radius: 6px;
+    }
+
+    .detail-media--empty {
+        display: grid;
+        place-items: center;
+        color: #94a3b8;
+        font-size: 12px;
+        background: #f8fafc;
+    }
+
+    .detail-header-text {
+        min-width: 0;
+    }
+
+    .detail-name {
+        margin-bottom: 4px;
+        font-size: 16px;
+        font-weight: 600;
+        color: #1e293b;
+    }
+
+    .detail-section-title {
+        margin: 18px 0 10px;
+        padding-left: 8px;
+        border-left: 3px solid #2563eb;
+        font-size: 14px;
+        font-weight: 600;
+        color: #1e293b;
+    }
+
+    .detail-body :deep(.el-alert) {
+        margin-bottom: 12px;
+    }
+
+    @media (max-width: 900px) {
+        .metric-grid {
+            grid-template-columns: repeat(2, minmax(0, 1fr))
+        }
+    }
+</style>

+ 7 - 0
src/views/subSystem/waterSupply/device/abnormal.vue

@@ -0,0 +1,7 @@
+<script setup>
+import WaterDeviceAbnormalPage from '../components/WaterDeviceAbnormalPage.vue'
+</script>
+
+<template>
+  <WaterDeviceAbnormalPage />
+</template>

+ 211 - 42
tests/waterDeviceAbnormalBoard.test.mjs

@@ -2,64 +2,233 @@ import assert from 'node:assert/strict'
 import { readFile } from 'node:fs/promises'
 import test from 'node:test'
 
-const pagePath = 'src/views/subSystem/waterSupply/components/WaterDevicePage.vue'
-
-/** 找到 marker 所在标签上离它最近的 v-if 条件,用于校验列/区块的视图可见性 */
-function conditionOf(source, marker) {
-  const at = source.indexOf(marker)
-  if (at < 0) return null
-  const before = source.slice(0, at)
-  const start = before.lastIndexOf('v-if="')
-  if (start < 0) return null
-  const end = source.indexOf('"', start + 'v-if="'.length)
-  return source.slice(start + 'v-if="'.length, end)
+import {
+  ABNORMAL_TYPE_OPTIONS,
+  buildAbnormalCsv,
+  buildAbnormalExportQuery,
+  buildAbnormalListQuery,
+  computeAbnormalSummary,
+  filterAbnormalDevices,
+  normalizeAbnormalRow,
+  resolveAbnormalTypes
+} from '../src/utils/waterDeviceAbnormalModel.js'
+
+// 独立构造的样例设备:三台异常来源各不相同,一台完全正常用于排除断言。
+const alarmDevice = {
+  equipmentId: 'eq-alarm',
+  equipmentCode: 'CN-GS-202609020001',
+  equipmentName: '城南压力计',
+  equipmentTypeName: '压力计',
+  district: 0,
+  alarmStatus: 1,
+  onlineStatus: 1,
+  currentStatus: 0,
+  equipmentImage: '/profile/upload/alarm.png'
+}
+const offlineDevice = {
+  equipmentId: 'eq-offline',
+  equipmentCode: 'CB-GS-202609020002',
+  equipmentName: '城北漏失监测器',
+  equipmentTypeName: '漏失监测仪',
+  district: 1,
+  alarmStatus: 0,
+  onlineStatus: 0,
+  currentStatus: 0
+}
+const maintenanceDevice = {
+  equipmentId: 'eq-maintenance',
+  equipmentCode: 'TC-GS-202609020003',
+  equipmentName: '太常流量计',
+  equipmentTypeName: '超声波外夹流量计',
+  district: 2,
+  alarmStatus: 0,
+  onlineStatus: 1,
+  currentStatus: 1
+}
+const healthyDevice = {
+  equipmentId: 'eq-healthy',
+  equipmentCode: 'CN-GS-202609020004',
+  equipmentName: '城南消火栓压力终端',
+  equipmentTypeName: '消火栓压力监测终端',
+  district: 0,
+  alarmStatus: 0,
+  onlineStatus: 1,
+  currentStatus: 0
 }
 
-test('abnormal board shows the district column so the region filter is verifiable', async () => {
-  const source = await readFile(pagePath, 'utf8')
-  assert.match(
-    source,
-    /label="所属区域"[^>]*>\s*<template #default="\{ row \}">\{\{ row\.district \|\| '-' \}\}/u,
-    '异常看板应展示所属区域列'
+const allRows = [alarmDevice, offlineDevice, maintenanceDevice, healthyDevice]
+
+test('abnormal source covers alarm, offline and maintenance from the device status table', () => {
+  assert.deepEqual(resolveAbnormalTypes(alarmDevice), ['alarm'])
+  assert.deepEqual(resolveAbnormalTypes(offlineDevice), ['offline'])
+  assert.deepEqual(resolveAbnormalTypes(maintenanceDevice), ['maintenance'])
+  assert.deepEqual(resolveAbnormalTypes(healthyDevice), [])
+
+  const bothSources = { ...offlineDevice, alarmStatus: 1, currentStatus: 1 }
+  assert.deepEqual(resolveAbnormalTypes(bothSources), ['alarm', 'offline', 'maintenance'])
+})
+
+test('abnormal type filters keep only the matching abnormal sources', () => {
+  assert.deepEqual(
+    filterAbnormalDevices(allRows, { type: 'all' }).map(row => row.equipmentId),
+    ['eq-alarm', 'eq-offline', 'eq-maintenance']
   )
-  assert.match(source, /v-if="view === 'abnormal' \|\| view === 'dispatch'"/u)
+  assert.deepEqual(
+    filterAbnormalDevices(allRows, { type: 'alarm' }).map(row => row.equipmentId),
+    ['eq-alarm']
+  )
+  assert.deepEqual(
+    filterAbnormalDevices(allRows, { type: 'offline' }).map(row => row.equipmentId),
+    ['eq-offline']
+  )
+  assert.deepEqual(
+    filterAbnormalDevices(allRows, { type: 'maintenance' }).map(row => row.equipmentId),
+    ['eq-maintenance']
+  )
+  // 离线且报警要求两个条件同时成立,单独离线不算命中
+  assert.deepEqual(filterAbnormalDevices(allRows, { type: 'offline_alarm' }), [])
+  assert.deepEqual(
+    filterAbnormalDevices([{ ...alarmDevice, onlineStatus: 0 }, ...allRows], { type: 'offline_alarm' })
+      .map(row => row.equipmentId),
+    ['eq-alarm']
+  )
+  assert.ok(ABNORMAL_TYPE_OPTIONS.some(option => option.value === 'offline_alarm'))
 })
 
-test('abnormal board detail includes the district field', async () => {
-  const source = await readFile(pagePath, 'utf8')
-  assert.match(source, /\['所属区域', detail\.value\.district\]/u)
+test('keyword filter matches either the device code or the device name', () => {
+  assert.deepEqual(
+    filterAbnormalDevices(allRows, { type: 'all', keyword: 'TC-GS' }).map(row => row.equipmentId),
+    ['eq-maintenance']
+  )
+  assert.deepEqual(
+    filterAbnormalDevices(allRows, { type: 'all', keyword: '漏失' }).map(row => row.equipmentId),
+    ['eq-offline']
+  )
+  assert.deepEqual(filterAbnormalDevices(allRows, { type: 'all', keyword: '不存在' }), [])
+})
+
+test('abnormal rows expose Chinese status text, reason and resolvable image url', () => {
+  const row = normalizeAbnormalRow(alarmDevice)
+  assert.equal(row.abnormalReasonText, '设备报警')
+  assert.equal(row.alarmStatusText, '报警')
+  assert.equal(row.onlineStatusText, '在线')
+  assert.equal(row.currentStatusText, '在用')
+  assert.equal(row.equipmentTypeName, '压力计')
+  assert.ok(row.abnormalTypeLabels.includes('报警中'))
+  assert.match(row.equipmentImageUrl, /\/profile\/upload\/alarm\.png$/u)
+
+  const offlineRow = normalizeAbnormalRow(offlineDevice)
+  assert.equal(offlineRow.abnormalReasonText, '设备离线')
+  const maintenanceRow = normalizeAbnormalRow(maintenanceDevice)
+  assert.equal(maintenanceRow.abnormalReasonText, '设备维修中')
 })
 
-test('abnormal board lists and previews the device image', async () => {
+test('abnormal summary counts each abnormal source independently', () => {
+  const summary = computeAbnormalSummary([
+    { ...alarmDevice, onlineStatus: 0 },
+    offlineDevice,
+    maintenanceDevice,
+    healthyDevice
+  ])
+  assert.deepEqual(summary, { total: 3, alarm: 1, offline: 2, maintenance: 1, offlineAlarm: 1 })
+})
+
+test('list query is scoped to the water root type and fetches the whole filtered set', () => {
+  assert.deepEqual(buildAbnormalListQuery({ total: 4 }), {
+    rootTypeId: 1,
+    pageNum: 1,
+    pageSize: 4
+  })
+  assert.deepEqual(buildAbnormalListQuery({ total: 0, equipmentCode: 'TC-GS', equipmentName: '流量' }), {
+    rootTypeId: 1,
+    pageNum: 1,
+    pageSize: 1,
+    equipmentCode: 'TC-GS',
+    equipmentName: '流量'
+  })
+  // 导出沿用同一筛选口径,但只按条件取数,不再带分页
+  assert.deepEqual(buildAbnormalExportQuery({ equipmentCode: 'TC-GS' }), {
+    rootTypeId: 1,
+    pageNum: 1,
+    pageSize: 1000,
+    equipmentCode: 'TC-GS'
+  })
+})
+
+test('csv export carries a BOM, Chinese headers and the filtered rows', () => {
+  const csv = buildAbnormalCsv([normalizeAbnormalRow(alarmDevice)])
+  assert.ok(csv.startsWith('\ufeff'), '导出内容需带 BOM 以便 Excel 正确识别中文')
+  assert.match(csv, /设备编码/u)
+  assert.match(csv, /异常原因/u)
+  assert.match(csv, /CN-GS-202609020001/u)
+  assert.match(csv, /设备报警/u)
+  assert.equal(csv.split(/\r?\n/u).filter(Boolean).length, 2)
+})
+
+const routePath = 'src/views/subSystem/waterSupply/device/abnormal.vue'
+const pagePath = 'src/views/subSystem/waterSupply/components/WaterDeviceAbnormalPage.vue'
+
+test('abnormal management menu entry renders the dedicated abnormal board', async () => {
+  const route = await readFile(routePath, 'utf8')
+  assert.match(route, /WaterDeviceAbnormalPage/u)
+})
+
+test('abnormal board reads the unified equipment module within the water root type', async () => {
   const source = await readFile(pagePath, 'utf8')
 
-  const columnCond = conditionOf(source, 'label="设备图片"')
-  assert.ok(columnCond, '缺少设备图片列')
-  assert.match(columnCond, /'abnormal'/u, '设备图片列需覆盖异常视图')
-  assert.match(columnCond, /'ledger'/u, '设备图片列不应丢掉台账视图')
+  assert.match(source, /listEquipment\(/u)
+  assert.match(source, /getEquipmentStatusStatistics\(/u)
+  assert.match(source, /buildAbnormalListQuery\(/u)
+  assert.match(source, /WATER_DEVICE_ROOT_TYPE_ID/u)
+  // 取数范围由模型统一带上 rootTypeId,页面只传供水根类型常量
+  assert.match(source, /getEquipmentStatusStatistics\(WATER_DEVICE_ROOT_TYPE_ID\)/u)
+  // 供水专属的旧设备接口已下线,异常看板不得再引用
+  assert.doesNotMatch(source, /waterSupply\/device\/(ledger|abnormal)/u)
+  assert.doesNotMatch(source, /getDeviceLedgerStatistics/u)
+  assert.doesNotMatch(source, /listAbnormalDevices/u)
+})
+
+test('abnormal board is a read-only board without dispatch or work-order creation', async () => {
+  const source = await readFile(pagePath, 'utf8')
 
-  const mediaCond = conditionOf(source, 'class="detail-media"')
-  assert.ok(mediaCond, '详情抽屉缺少设备图片区块')
-  assert.match(mediaCond, /'abnormal'/u, '详情图片区块需覆盖异常视图')
+  assert.match(source, /异常类型/u)
+  assert.match(source, /设备编码/u)
+  assert.match(source, /设备名称/u)
+  assert.match(source, /批量导出/u)
+  assert.match(source, /<el-drawer/u)
+  assert.match(source, /direction="rtl"/u)
+  assert.match(source, /设备图片/u)
+  assert.doesNotMatch(source, /派单/u)
+  assert.doesNotMatch(source, /addWorkOrder|dispatchWorkOrder|createWorkOrder/u)
+  assert.doesNotMatch(source, /updateEquipmentStatus/u)
 })
 
-test('abnormal board is read-only and stats come from the backend aggregate', async () => {
+test('abnormal detail drawer shows the three independent state sources and related order', async () => {
   const source = await readFile(pagePath, 'utf8')
+
+  assert.match(source, /设备当前状态/u)
+  assert.match(source, /设备报警状态/u)
+  assert.match(source, /在线状态/u)
+  assert.match(source, /关联未办结工单/u)
+  assert.match(source, /orderStatusText/u)
   assert.match(
     source,
-    /function canDispatch\(row\) \{\s*if \(view !== 'dispatch'\) return false/u,
-    '异常视图不应保留派单入口'
-  )
-  assert.match(
-    source,
-    /if \(view === 'abnormal'\) await loadAbnormalStats\(\)/u,
-    '异常视图应加载后端聚合统计'
+    /from '@\/views\/subSystem\/pipeNetwork\/ops\/workOrder\/workOrderDict\.js'/u,
+    '异常看板应复用统一工单字典'
   )
+  assert.doesNotMatch(source, /function\s+workOrderStatusType/u)
+})
+
+test('abnormal board keeps the backend aggregate for the device indicator band', async () => {
+  const source = await readFile(pagePath, 'utf8')
+
   const statsBlock = source.slice(
-    source.indexOf('const abnormalStats = computed'),
-    source.indexOf('const ledgerStatsCards')
+    source.indexOf('const abnormalMetrics = computed'),
+    source.indexOf('const filteredRows = computed')
   )
-  assert.doesNotMatch(statsBlock, /page\.value\.records/u, '统计卡片不应基于当前分页行计数')
-  assert.match(statsBlock, /abnormalStatsData\.value\.total/u)
-  assert.match(statsBlock, /abnormalStatsData\.value\.offline/u)
+  assert.ok(statsBlock, '缺少异常指标带计算块')
+  assert.doesNotMatch(statsBlock, /page\.value\.records/u, '指标带不应基于当前分页行计数')
+  assert.match(statsBlock, /statistics\.value\.total/u)
+  assert.match(statsBlock, /statistics\.value\.offline/u)
+  assert.match(statsBlock, /summary\.value\.maintenance/u)
 })

+ 2 - 2
tests/waterSupplyWorkOrderStatusDict.test.mjs

@@ -16,11 +16,11 @@ test('shared work-order dictionary covers the authoritative 0-7 status codes', (
 })
 
 test('water supply device pages reuse the shared dictionary instead of a private status table', async () => {
-  const source = await readFile('src/views/subSystem/waterSupply/components/WaterDevicePage.vue', 'utf8')
+  const source = await readFile('src/views/subSystem/waterSupply/components/WaterDeviceAbnormalPage.vue', 'utf8')
   assert.doesNotMatch(source, /function\s+workOrderStatusType/, '页面不应自带工单状态色表')
   assert.match(
     source,
     /from '@\/views\/subSystem\/pipeNetwork\/ops\/workOrder\/workOrderDict\.js'/u,
     '页面应复用统一工单字典'
   )
-})
+})