Kaynağa Gözat

Merge remote-tracking branch 'origin/master'

zengy 1 ay önce
ebeveyn
işleme
414f5bf23e

+ 4 - 0
CONTEXT.md

@@ -79,3 +79,7 @@ _Avoid_: 保养任务、维修任务
 **统一工单**:
 承载故障维修、日常巡检和设备保养全过程的业务记录。
 _Avoid_: 维修任务、报警工单
+
+**工单状态编码口径**:
+工单状态名是跨系统交流的业务权威;迁移期间不同技术契约可能使用不同编码,调用方必须依据所属契约解释并以状态名展示,不得脱离契约复用裸编码。
+_Avoid_: 把相同数字直接认定为相同工单状态

+ 8 - 2
docs/design/2026-09-09-water-supply-gis-marker-style.md

@@ -8,13 +8,19 @@ accepted
 供水 GIS 一张图包含供水设施 GIS 一张图、监测设备 GIS 一张图和监测报警一张图。三张图共用统一地图页壳与交互框架,但数据边界、图层语义和详情内容不同。
 
 统一地图使用 `GisMapCanvas` 渲染点位。输入坐标保持 WGS84;组件在渲染到百度地图前统一转换为 BD09。
+统一页壳使用 `WaterGisPageShell` 承载页头、左侧信息面板和地图区;设施图、设备图和报警图只替换面板数据与地图内容。
 
 ### 坐标点
 
 - 使用百度地图官方 `T_MarkPoint_*` 图标素材和 `BMapGL.Marker + BMapGL.Icon` 渲染坐标点。
 - 点位名称使用独立 `BMapGL.Label`,显示在坐标点上方;名称框宽度固定为坐标点图标宽度的 2 倍(56px),单行最多 6 个中文字符,超长自动换行。
 - 点击点位或搜索结果时定位并放大到对应要素。
-- 管网 `LineString` 绘制管线,并在起点、终点显示坐标点。
+- 管网 `LineString` 只绘制管线,不绘制起点、终点坐标点;点击管线时由页面查询并展示详情。
+
+### 重叠点位合并
+
+- 同屏相邻点位按像素半径(默认 48px)合并为一个簇,簇复用点位图标并显示数量;合并只发生在渲染层,不修改业务数据。
+- 点击簇时,画布优先缩放展开簇内点位;当簇内坐标完全重合、无法通过缩放展开时,画布发出 `cluster-click` 事件,由页面层展示簇内点位列表。
 
 ### 设施类型颜色
 
@@ -49,4 +55,4 @@ accepted
 - 默认地图中心为沅陵县。
 - 地图同时显示当前查询范围内全部有效点位和管线,不得只显示第一条记录。
 - 点击地图点位或左侧搜索结果时,定位并放大到对应要素,并打开右侧详情抽屉。
-- 左侧面板采用有界滚动,统计、图层、图例和搜索结果完整可用。
+- 左侧面板由 `side-panel-body` 统一采用有界滚动;统计、图层、图例和搜索结果完整可用,搜索结果不得再嵌套独立滚动。

+ 13 - 15
docs/domain/work-order-lifecycle.md

@@ -64,24 +64,22 @@
 - 处理前证据在“开始处置(3→4)”上传;处理流水在“处理中(4)”多次追加;处理后证据在“提交审核(4→5)”上传;验收意见/驳回原因/取消原因在对应动作记录。
 - 附件走后端 MinIO + KingbaseES 阶段化元数据方案,旧图片 URL 字段兼容读取,前端不得丢失历史图片。
 
-## 7. 目标编码与现网编码对照(迁移期)
+## 7. 目标编码与现网接口编码(迁移期)
 
-目标编码从 1 开始后,前六态与现网旧编码 1–6 同值,仅第 7 态语义不同。
+目标七态仍以 1–7 为权威。当前后端已出现两类并存契约:驾驶舱只读接口输出目标 1–7;管理端业务工单与供水设备详情仍读取数据库现网 0–7 编码,并保留 `6-延期中` 兼容分支。
 
-| 状态名 | 目标编码(权威) | 现网旧编码(迁移前) |
+| 状态名 | 目标编码(权威/驾驶舱只读) | 管理端现网编码 |
 | --- | --- | --- |
-| 待派单 | 1 | 1(同值) |
-| 待接单 | 2 | 2(同值) |
-| 已接单 | 3 | 3(同值) |
-| 处理中 | 4 | 4(同值) |
-| 待验收 | 5 | 5(同值) |
-| 已结案 | 6 | 6(同值) |
-| 已取消 | 7 | 旧实现无独立值(取消/驳回都落旧 7) |
-| ~~已驳回(废止)~~ | — | 7 |
-| ~~延期审核(废止)~~ | — | 8 |
-| ~~已延期(废止)~~ | — | 9 |
-
-前端状态字典应同时能解析新旧编码,但新增功能只面向七态;后端迁移完成后删除旧分支。
+| 待派单 | 1 | 0 |
+| 待接单 | 2 | 1 |
+| 已接单 | 3 | 2 |
+| 处理中 | 4 | 3 |
+| 待验收 | 5 | 4 |
+| 已结案 | 6 | 5 |
+| 已取消 | 7 | 7 |
+| ~~延期中(废止目标状态)~~ | — | 6(迁移期兼容) |
+
+前端必须按接口契约解析编码并统一展示状态名,禁止把管理端 `relatedWorkOrderStatus` 裸值直接套用目标 1–7 字典。监测设备 GIS 只消费管理端关联工单摘要,不承担数据库状态迁移;后端迁移完成后再删除管理端旧编码和延期兼容分支。
 
 ## 8. 与设备报警状态的关系
 

+ 1 - 1
memory/long-term.md

@@ -58,7 +58,7 @@
 - 报警管理侧重报警态势和派单,报警审核保留原菜单名并承载供水故障维修工单全流程,供水工单总览集中展示全部供水类型工单。
 - 日常巡检和设备保养工单由其他业务系统创建及首次派发;派给当前用户后可以在供水工单总览继续处置。
 - 设备报警状态、报警记录状态和工单处置状态必须分别展示,不得互相替代。
-- 统一工单生命周期以**七态**为唯一权威(1 待派单/2 待接单/3 已接单/4 处理中/5 待验收/6 已结案/7 已取消,编码从 1 开始):验收驳回是“待验收(5)回退到处理中(4)”的动作而非状态、已取消(7)是任意中间态可进入的终态;不设“已驳回”终态与“延期审核/已延期”。前六态编码与现网旧值 1–6 同值,仅第 7 态由旧“已驳回/取消混用(7)”改为“已取消”;后端代码与存量数据迁移另开任务。三仓共识见本仓 docs/domain/work-order-lifecycle.md、后端 ADR-0008 与 UE 同名文档。
+- 统一工单生命周期以**七态**为唯一权威(1 待派单/2 待接单/3 已接单/4 处理中/5 待验收/6 已结案/7 已取消,编码从 1 开始):验收驳回是“待验收(5)回退到处理中(4)”的动作而非状态、已取消(7)是任意中间态可进入的终态;不设“已驳回”终态与延期目标状态。迁移期驾驶舱只读契约已使用目标 1–7,而管理端业务工单和设备详情仍使用数据库现网 0–7(含 6-延期中兼容);调用方必须按接口契约解析并统一展示状态名。三仓共识见本仓 docs/domain/work-order-lifecycle.md、后端 ADR-0008 与 UE 同名文档。
 - 相关页面采用蓝白运行指挥台风格;一级至四级报警依次使用红、橙、蓝、绿,供水菜单全部配置图标。
 - 详细设计见 `docs/design/2026-08-22-water-supply-work-order-ui.md`。
 

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

@@ -46,3 +46,10 @@
 - 旧 `/api/water/pipe/*` 全部 404 属预期(前端已无调用方)。
 - 两仓改动均未提交、未推送(约定:不 push)。后端用户并行改动(cockpit 相关文件、spring.log)不得混入本任务提交;提交时只暂存本任务文件。
 - `length`/`diameter`/`material`/`build_date` 列名与 `PipeNetworkBase` 实体一致,统计 SQL 与分页共用字段,无额外映射。
+
+## 补充:供水 GIS Ticket #1/#2
+
+- Ticket #1 已由用户人工验收并在 Gogs 关闭;测试断言同步为人工确认的标签高度偏移(`markerSize + 18`),随后连同管网迁移前端改动、会话记忆和无关临时文件清理一起推送到 `origin/master`。
+- Ticket #2 新增 `WaterGisPageShell.vue`,把设施 GIS、监测设备 GIS 和报警 GIS 的页头、统计、筛选、图例、搜索结果、地图区和抽屉布局收敛到统一页壳;左侧面板改用单个有界滚动容器,报警图增加点位搜索结果列表。
+- 清理了执行代理误引入的驾驶舱 #31 本地提交及聚类/管线端点越界测试;相关补丁仅保存在系统临时目录,未混入本次提交。
+- 验证:`node --test "tests/*.test.mjs"` 79/79 通过;`node node_modules\vite\bin\vite.js build` 通过;`git diff --check` 通过。Spec 审查发现的坐标转换回归已恢复,Standards 审查余留插槽样式重复属 P3 非阻塞项。

+ 30 - 0
memory/sessions/2026-09-10.md

@@ -0,0 +1,30 @@
+# 2026-09-10:WGS84→BD09 转换诊断
+
+## 结论
+
+- 供水统一 GIS 画布 `GisMapCanvas.vue` 对数值型 WGS84 坐标执行 `convertCoord` 后再创建 `BMapGL.Point`;点位、管线、视野适配和定位路径均保持转换,未发现算法偏移异常。
+- 沅陵县样例 `110.3965,28.4638` 转换为约 `110.4079336973,28.4669691113`,经度约 1.12 km、纬度约 0.35 km 的偏移属于 WGS84→GCJ02→BD09 的正常量级。
+- 按用户确认,后端数据库坐标为数字格式,因此未对字符串坐标增加额外设计;数值探针显示字符串输入会产生 `NaN`,仅作为边界风险记录。
+- 供水首页 `WaterSupplyHome.vue` 仍使用独立地图实现,默认中心与图层代码暂不修改,等待后续统一复用公共 GIS 组件。
+- 当前分支已有本地未推送提交 `93fc1c3`(点位聚合/管线显示策略),属于用户已确认的需求,不在本轮调整。
+
+## 验证
+
+- `node --test "tests/*.test.mjs"`:86/86 通过。
+- 数值转换探针通过;未执行真实浏览器地图人工验收。
+- 本轮无代码修改。
+
+## 补充:GIS Ticket #2 收尾
+
+- 用户确认 #2 人工验收完成并关闭 Gogs Issue #2。
+- 修复统一 GIS 页壳地图区未铺满的问题:设施页和监测设备页的 `#map` 插槽移除重复 `main.map-panel`,由共享页壳统一承载地图面板;左侧统计区调整为更宽的固定列并禁止统计标题换行。
+- 报警 GIS 继续复用共享页壳,并保留报警点搜索、图例和右侧详情抽屉。
+- 删除本轮临时 `tests/waterGisShell.test.mjs`;既有 GIS 画布和聚合测试保留。
+- 针对 #2 的 GIS 相关测试和生产构建通过。全量测试受远端工单状态文案变更影响,存在若干既有 `waterSupplyUiModel`/派单契约断言失败,与本轮页壳改动无关。
+
+## 补充:Ticket #3 开工前工单领域核对
+
+- Gogs Issue #3 已确认:监测设备 GIS 接入共享画布,展示全部有效供水设备点位,保留设备类型/状态/关键字筛选、状态统计、点位和搜索结果定位及右侧详情。
+- 领域核对发现迁移期双轨:驾驶舱只读工单字典使用目标编码 1–7;管理端业务工单和供水设备详情仍使用数据库现网 0–7,其中 6 为延期兼容状态。状态名仍是跨系统业务权威,GIS 只消费设备状态和关联工单摘要,不在 Ticket #3 迁移工单状态机。
+- 已更新前端 `CONTEXT.md`、`docs/domain/work-order-lifecycle.md`、`memory/long-term.md` 以及后端 `pipe-network-service/docs/domain/work-order-lifecycle.md`,记录接口编码边界和迁移约束。
+- 同步修正前端契约测试到管理端现网编码,单元与全量测试恢复通过;Ticket #3 尚未开始实现。

+ 82 - 35
src/components/GisMapCanvas.vue

@@ -5,7 +5,8 @@
 <script setup>
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { convertCoord } from '@/utils/coordTransform'
-import { getMonitoringPointIcon } from '@/utils/gisMapColors'
+import { getMapClusterIcon, getMonitoringPointIcon } from '@/utils/gisMapColors'
+import { mergeMapPoints } from '@/utils/gisMapClustering'
 
 const props = defineProps({
   points: { type: Array, default: () => [] },
@@ -13,11 +14,13 @@ const props = defineProps({
   center: { type: Object, default: () => ({ lng: 110.3965, lat: 28.4638 }) },
   zoom: { type: Number, default: 15 },
   markerSize: { type: Number, default: 28 },
+  clusterRadius: { type: Number, default: 48 },
+  enableClustering: { type: Boolean, default: true },
   showMarkerName: { type: Boolean, default: true },
   mapStyleId: { type: String, default: 'a0f4e6e6f9e7a6c5d8e7f7e7f7e7f7e5' }
 })
 
-const emit = defineEmits(['point-click', 'line-click', 'map-click', 'ready', 'error'])
+const emit = defineEmits(['point-click', 'line-click', 'cluster-click', 'map-click', 'ready', 'error'])
 
 const mapContainer = ref(null)
 const mapError = ref('')
@@ -79,6 +82,8 @@ async function initMap() {
     mapInstance.enableScrollWheelZoom(true)
     mapInstance.setMapStyleV2({ styleId: props.mapStyleId })
     mapInstance.addEventListener('click', () => emit('map-click'))
+    mapInstance.addEventListener('moveend', renderScene)
+    mapInstance.addEventListener('zoomend', renderScene)
     renderScene()
     emit('ready', mapInstance)
   } catch (error) {
@@ -139,6 +144,60 @@ function addPointMarker(BMapGL, point) {
   }
 }
 
+function addClusterMarker(BMapGL, cluster) {
+  const [positionLng, positionLat] = convertCoord(cluster.position.lng, cluster.position.lat)
+  const position = new BMapGL.Point(positionLng, positionLat)
+  const marker = new BMapGL.Marker(position, {
+    icon: createMarkerIcon(BMapGL, getMapClusterIcon(cluster.points), props.markerSize),
+    enableDragging: false
+  })
+  marker.addEventListener('click', (event) => {
+    event?.domEvent?.stopPropagation?.()
+    focusCluster(cluster)
+    if (isClusterUnexpandable(cluster)) emit('cluster-click', cluster)
+  })
+  mapInstance.addOverlay(marker)
+  overlays.push(marker)
+
+  const label = new BMapGL.Label(`<div class="gis-cluster-label-text">${cluster.count}</div>`, {
+    position,
+    offset: new BMapGL.Size(-28, -props.markerSize - 18)
+  })
+  label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
+  label.addEventListener('click', (event) => {
+    event?.domEvent?.stopPropagation?.()
+    focusCluster(cluster)
+    if (isClusterUnexpandable(cluster)) emit('cluster-click', cluster)
+  })
+  mapInstance.addOverlay(label)
+  overlays.push(label)
+}
+
+function isClusterUnexpandable(cluster) {
+  const bounds = cluster?.bounds
+  if (!bounds) return false
+  return bounds.minLng === bounds.maxLng && bounds.minLat === bounds.maxLat
+}
+
+function projectMapPoint({ lng, lat }) {
+  const [bmapLng, bmapLat] = convertCoord(lng, lat)
+  const pixel = mapInstance.pointToPixel(new BMapGL.Point(bmapLng, bmapLat))
+  return { x: Number(pixel?.x), y: Number(pixel?.y) }
+}
+
+function focusCluster(cluster) {
+  if (!mapInstance || !window.BMapGL || !cluster?.points?.length) return
+  const bmapPoints = cluster.points.map(point => {
+    const [lng, lat] = convertCoord(point.lng, point.lat)
+    return new BMapGL.Point(lng, lat)
+  })
+  if (bmapPoints.length === 1) {
+    locatePoint(cluster.points[0])
+    return
+  }
+  mapInstance.setViewport(bmapPoints, { margins: [110, 110, 110, 110] })
+}
+
 function addLine(BMapGL, line) {
   const points = (line.points || []).filter((point) => Number.isFinite(Number(point.lng)) && Number.isFinite(Number(point.lat)))
   if (points.length < 2) return
@@ -158,43 +217,20 @@ function addLine(BMapGL, line) {
   })
   mapInstance.addOverlay(polyline)
   overlays.push(polyline)
-
-  ;[points[0], points[points.length - 1]].forEach((point, index) => {
-    const [lng, lat] = convertCoord(point.lng, point.lat)
-    const position = new BMapGL.Point(lng, lat)
-    const iconUrl = line.iconUrl || getMonitoringPointIcon(point)
-    const name = `${line.name || '供水管网'} ${index === 0 ? '起点' : '终点'}`
-    const marker = new BMapGL.Marker(position, {
-      icon: createMarkerIcon(BMapGL, iconUrl, props.markerSize),
-      enableDragging: false
-    })
-    marker.addEventListener('click', (event) => {
-      event?.domEvent?.stopPropagation?.()
-      emit('line-click', line)
-    })
-    mapInstance.addOverlay(marker)
-    overlays.push(marker)
-
-    if (!props.showMarkerName) return
-    const label = new BMapGL.Label(`<div class="gis-marker-label-text">${escapeHtml(name)}</div>`, {
-      position,
-      offset: new BMapGL.Size(-28, -props.markerSize - 18)
-    })
-    label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
-    label.addEventListener('click', (event) => {
-      event?.domEvent?.stopPropagation?.()
-      emit('line-click', line)
-    })
-    mapInstance.addOverlay(label)
-    overlays.push(label)
-  })
 }
 function renderScene() {
   if (!mapInstance || !window.BMapGL) return
   clearScene()
   const BMapGL = window.BMapGL
+  const renderPoints = props.points
+  const renderedPoints = props.enableClustering
+    ? mergeMapPoints(renderPoints, projectMapPoint, props.clusterRadius)
+    : renderPoints.map(point => ({ type: 'point', point, position: { lng: point.lng, lat: point.lat } }))
   props.lines.forEach((line) => addLine(BMapGL, line))
-  props.points.forEach((point) => addPointMarker(BMapGL, point))
+  renderedPoints.forEach((item) => {
+    if (item.type === 'cluster') addClusterMarker(BMapGL, item)
+    else addPointMarker(BMapGL, item.point)
+  })
 }
 
 function fitBounds() {
@@ -252,6 +288,7 @@ onBeforeUnmount(() => {
 defineExpose({
   fitBounds,
   locatePoint,
+  focusCluster,
   zoomIn,
   zoomOut,
   mapError
@@ -275,7 +312,8 @@ defineExpose({
   padding: 0 !important;
 }
 
-.gis-marker-label-text {
+.gis-marker-label-text,
+.gis-cluster-label-text {
   box-sizing: border-box;
   width: 56px;
   margin-bottom: 4px;
@@ -286,10 +324,19 @@ defineExpose({
   border: 1px solid rgba(118, 151, 181, 0.32);
   border-radius: 4px;
   box-shadow: 0 2px 6px rgba(31, 74, 121, 0.12);
+  text-align: center;
+}
+
+.gis-marker-label-text {
   font-size: 9px;
   line-height: 12px;
   font-weight: 600;
-  text-align: center;
   overflow-wrap: anywhere;
 }
+
+.gis-cluster-label-text {
+  font-size: 11px;
+  line-height: 16px;
+  font-weight: 700;
+}
 </style>

+ 192 - 0
src/components/WaterGisPageShell.vue

@@ -0,0 +1,192 @@
+<template>
+  <section class="water-gis-shell" :class="{ 'is-embedded': embedded }">
+    <header class="gis-header">
+      <div class="gis-heading">
+        <p class="eyebrow">{{ eyebrow }}</p>
+        <h2>{{ title }}</h2>
+        <p v-if="subtitle" class="shell-subtitle">{{ subtitle }}</p>
+      </div>
+      <div class="header-actions">
+        <slot name="actions" />
+      </div>
+    </header>
+
+    <div class="gis-layout">
+      <aside class="side-panel">
+        <div class="side-panel-body">
+          <div class="metric-grid">
+            <slot name="metrics" />
+          </div>
+          <div class="panel-section controls-section">
+            <slot name="controls" />
+          </div>
+          <div class="panel-section legend-panel">
+            <slot name="legend" />
+          </div>
+          <div class="panel-section result-section">
+            <slot name="results" />
+          </div>
+        </div>
+      </aside>
+
+      <main class="map-panel" v-loading="loading">
+        <slot name="map" />
+      </main>
+    </div>
+
+    <slot name="drawer" />
+  </section>
+</template>
+
+<script setup>
+defineProps({
+  eyebrow: { type: String, default: '' },
+  title: { type: String, required: true },
+  subtitle: { type: String, default: '' },
+  loading: { type: Boolean, default: false },
+  embedded: { type: Boolean, default: false }
+})
+</script>
+
+<style scoped>
+.water-gis-shell {
+  min-height: 100%;
+  padding: 18px;
+  background: linear-gradient(180deg, #edf6ff 0%, #f5f7fb 100%);
+  color: #1f3b57;
+}
+
+.water-gis-shell.is-embedded {
+  min-height: auto;
+  padding: 0;
+  background: transparent;
+}
+
+.gis-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 18px;
+  margin-bottom: 16px;
+}
+
+.eyebrow {
+  margin: 0 0 3px;
+  font-size: 11px;
+  letter-spacing: .12em;
+  color: #5c93c7;
+}
+
+.gis-header h2 {
+  margin: 0;
+  font-size: 22px;
+  color: #173a5e;
+}
+
+.shell-subtitle {
+  margin: 4px 0 0;
+  font-size: 13px;
+  color: #678197;
+}
+
+.header-actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.gis-layout {
+  display: grid;
+  grid-template-columns: minmax(340px, 380px) minmax(0, 1fr);
+  gap: 12px;
+  height: calc(100vh - 132px);
+  min-height: 620px;
+}
+
+.is-embedded .gis-layout {
+  height: clamp(560px, calc(100vh - 300px), 760px);
+}
+
+.side-panel,
+.map-panel {
+  background: rgba(255, 255, 255, .94);
+  border: 1px solid #dbe8f5;
+  border-radius: 10px;
+  box-shadow: 0 10px 28px rgba(31, 74, 121, .08);
+}
+
+.side-panel {
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.side-panel-body {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  padding: 12px;
+  overflow-y: auto;
+}
+
+.metric-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 8px;
+}
+
+.metric-grid :deep(.metric span) {
+  white-space: nowrap;
+}
+
+.panel-section :deep(h3) {
+  margin: 0 0 8px;
+  font-size: 14px;
+  color: #29567e;
+}
+
+.legend-panel {
+  border-top: 1px solid #e4eef8;
+  padding-top: 12px;
+}
+
+.result-section {
+  flex: 1;
+  min-height: 0;
+}
+
+.map-panel {
+  position: relative;
+  overflow: hidden;
+}
+
+@media (max-width: 1200px) {
+  .gis-header {
+    align-items: flex-start;
+    flex-direction: column;
+  }
+
+  .gis-layout {
+    grid-template-columns: 1fr;
+    height: auto;
+  }
+
+  .is-embedded .gis-layout {
+    height: auto;
+  }
+
+  .side-panel {
+    min-height: 480px;
+  }
+
+  .map-panel {
+    height: 560px;
+  }
+
+  .header-actions :deep(.search-input),
+  .header-actions :deep(.type-select) {
+    width: 180px;
+  }
+}
+</style>

+ 103 - 0
src/utils/gisMapClustering.js

@@ -0,0 +1,103 @@
+export function mergeMapPoints(points, projectPoint, clusterRadius = 48) {
+  const radius = Number(clusterRadius) || 48
+  const validPoints = (Array.isArray(points) ? points : []).filter(point => {
+    return Number.isFinite(Number(point?.lng)) && Number.isFinite(Number(point?.lat))
+  })
+  const projected = validPoints.map(point => ({
+    ...point,
+    pixel: toPixel(projectPoint, point)
+  }))
+  const grid = new Map()
+  projected.forEach((point, index) => {
+    if (!point.pixel) return
+    const key = gridKey(point.pixel, radius)
+    if (!grid.has(key)) grid.set(key, [])
+    grid.get(key).push(index)
+  })
+
+  const assigned = new Set()
+  const merged = []
+  projected.forEach((point, index) => {
+    if (!point.pixel) {
+      merged.push(makeSingle(point))
+      return
+    }
+    if (assigned.has(index)) return
+
+    const members = [point]
+    assigned.add(index)
+    const queue = [point]
+    while (queue.length) {
+      const current = queue.shift()
+      const cellX = Math.floor(current.pixel.x / radius)
+      const cellY = Math.floor(current.pixel.y / radius)
+      for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
+        for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
+          const key = `${cellX + offsetX}:${cellY + offsetY}`
+          for (const candidateIndex of grid.get(key) || []) {
+            if (assigned.has(candidateIndex)) continue
+            const candidate = projected[candidateIndex]
+            if (!candidate.pixel) continue
+            if (pixelDistance(current.pixel, candidate.pixel) > radius) continue
+            assigned.add(candidateIndex)
+            members.push(candidate)
+            queue.push(candidate)
+          }
+        }
+      }
+    }
+
+    merged.push(members.length === 1 ? makeSingle(members[0]) : makeCluster(members))
+  })
+
+  return merged
+}
+
+function toPixel(projectPoint, point) {
+  if (typeof projectPoint !== 'function') return null
+  const pixel = projectPoint(point)
+  const x = Number(pixel?.x)
+  const y = Number(pixel?.y)
+  return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
+}
+
+function gridKey(pixel, radius) {
+  return `${Math.floor(pixel.x / radius)}:${Math.floor(pixel.y / radius)}`
+}
+
+function pixelDistance(left, right) {
+  return Math.hypot(left.x - right.x, left.y - right.y)
+}
+
+function makeSingle(point) {
+  return {
+    type: 'point',
+    point,
+    position: { lng: point.lng, lat: point.lat },
+    pixel: point.pixel
+  }
+}
+
+function makeCluster(points) {
+  const lng = points.reduce((sum, point) => sum + point.lng, 0) / points.length
+  const lat = points.reduce((sum, point) => sum + point.lat, 0) / points.length
+  return {
+    type: 'cluster',
+    count: points.length,
+    points,
+    position: {
+      lng: Number(lng.toFixed(8)),
+      lat: Number(lat.toFixed(8))
+    },
+    pixel: {
+      x: points.reduce((sum, point) => sum + point.pixel.x, 0) / points.length,
+      y: points.reduce((sum, point) => sum + point.pixel.y, 0) / points.length
+    },
+    bounds: {
+      minLng: Math.min(...points.map(point => point.lng)),
+      maxLng: Math.max(...points.map(point => point.lng)),
+      minLat: Math.min(...points.map(point => point.lat)),
+      maxLat: Math.max(...points.map(point => point.lat))
+    }
+  }
+}

+ 9 - 0
src/utils/gisMapColors.js

@@ -26,6 +26,15 @@ export function getFacilityPointIcon(type = '') {
   return facilityPointIcons[type] || facilityPointIcons.plant
 }
 
+const clusterPointIcon = new URL('../assets/gis/T_MarkPoint_WhiteBackground.png', import.meta.url).href
+
+export function getMapClusterIcon(points = []) {
+  const icons = [...new Set((Array.isArray(points) ? points : [])
+    .map(point => point?.iconUrl || getMonitoringPointIcon(point))
+    .filter(Boolean))]
+  return icons.length === 1 ? icons[0] : clusterPointIcon
+}
+
 export const gisPointStatusLabels = {
   normal: '正常',
   alarm: '报警',

+ 8 - 4
src/views/subSystem/drainage/jcbj/bjsh.vue

@@ -253,8 +253,9 @@ const loading = ref(false)
 async function loadData() {
   loading.value = true
   try {
-    // 调用 alarm_data 真实接口,只取未处理报警(alarmStatus=0)
-    const res = await getAlarmDataPage(1, 200, { equipmentType: 'drainage', alarmStatus: 0 })
+    // 查询排水全部报警,由综合审核状态筛选待审核/已审核。
+    // 不能固定传 alarmStatus=0,否则已审核记录无法在页面显示。
+    const res = await getAlarmDataPage(1, 200, { equipmentType: 'drainage' })
     const pageData = res.code !== undefined ? res.data : res
     const records = pageData.records || pageData.rows || []
     let tableData_local = records.map(row => ({
@@ -270,7 +271,10 @@ async function loadData() {
       currentValue: row.actualValue,
       thresholdValue: row.maxValue,
       warningType: row.warningType,
-      status: 1,  // 未处理(alarmStatus=0)在审核页面显示为"待审核"
+      // 以接口返回的综合审核状态为准;已有工单的历史报警不能继续显示为待审核。
+      status: row.auditStatus != null
+        ? Number(row.auditStatus)
+        : (row.hasWorkOrder || row.alarmStatus === 1 ? 2 : 1),
       source: 'alarm_data'
     }))
 
@@ -373,7 +377,7 @@ async function submitAudit() {
         })
       : await handleAlarm(currentAlarm.value.id, userStore.name || 'admin', auditForm.value.opinion || auditForm.value.result)
     if (res.code === 200 || res.code === 0) {
-      ElMessage.success(isApproved ? '审核通过,已自动上传至工单' : '审核提交成功')
+      ElMessage.success(res.msg || (isApproved ? '审核通过,已自动上传至工单' : '审核提交成功'))
       auditVisible.value = false
       loadData()
     } else {

+ 11 - 5
src/views/subSystem/drainage/jcbj/bjtx.vue

@@ -8,7 +8,7 @@
       </div>
       <div class="top-right">
         <div class="alarm-stats">
-          <span class="stat-item">今日报警 <em class="stat-num">{{ alarmList.length }}</em> 条</span>
+          <span class="stat-item">待处理报警 <em class="stat-num">{{ alarmList.length }}</em> 条</span>
           <span class="stat-divider">/</span>
           <span class="stat-item">未处理 <em class="stat-num warn">{{ unHandledCount }}</em> 条</span>
         </div>
@@ -91,20 +91,26 @@ const alarmList = ref([])
 
 async function loadAlarms() {
   try {
-    const res = await getAlarmDataPage(1, 200, { equipmentType: 'drainage' })
+    // 报警提醒只展示未处理报警,已审核/已建工单的报警不再提醒。
+    const res = await getAlarmDataPage(1, 200, {
+      equipmentType: 'drainage',
+      alarmStatus: 0
+    })
     const pageData = res.code !== undefined ? res.data : res
     const records = pageData.records || pageData.rows || []
 
-    alarmList.value = records.map(row => ({
+    alarmList.value = records
+      .filter(row => Number(row.alarmStatus) === 0)
+      .map(row => ({
       ...row,
       alarmLevelText: alarmLevelMap[row.alarmLevel] || '-'
-    }))
+      }))
   } catch (error) {
     console.error('加载报警数据失败', error)
   }
 }
 
-const unHandledCount = computed(() => alarmList.value.filter(a => a.alarmStatus === 0).length)
+const unHandledCount = computed(() => alarmList.value.filter(a => Number(a.alarmStatus) === 0).length)
 
 // ==================== 自动滚动 ====================
 const scrollWrapper = ref(null)

+ 1 - 1
src/views/subSystem/drainage/jcyj/dqyjGIS.vue

@@ -154,7 +154,6 @@ async function loadData() {
   try {
     const params = {
       equipmentType: 'drainage',
-      warningType: '2',
       currentOnly: true,
       userId: userStore.id,
       isAdmin: isAdmin.value
@@ -178,6 +177,7 @@ async function loadData() {
       id: w.id,
       name: w.equipmentName || '-',
       warningNo: w.warningNo || '-',
+      deviceCode: w.deviceCode || w.warningType || '',
       level: levelMap[w.warningLevel] || '一般',
       createTime: w.createTime || '-',
       location: w.location || '-',

+ 10 - 11
src/views/subSystem/drainage/jcyj/dqyjgl.vue

@@ -65,9 +65,9 @@
             <el-tag :type="levelTagType(row.warningLevel)" size="small">{{ levelText(row.warningLevel) }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="warningType" label="预警类型" width="110" align="center">
+        <el-table-column prop="deviceCode" label="预警类型" width="110" align="center">
           <template #default="{ row }">
-            {{ equipmentTypeMap.get(String(row.warningType)) || row.warningType || '-' }}
+            {{ equipmentTypeMap.get(String(row.deviceCode)) || row.deviceCode || row.warningType || '-' }}
           </template>
         </el-table-column>
         <el-table-column prop="warningSpecial" label="预警专项" width="110" align="center" show-overflow-tooltip />
@@ -115,8 +115,8 @@
                 </el-form-item>
               </el-col>
               <el-col :span="12">
-                <el-form-item label="预警类型">{{ equipmentTypeMap.get(String(currentOrder.warningType)) ||
-                  currentOrder.warningType }}</el-form-item>
+                <el-form-item label="预警类型">{{ equipmentTypeMap.get(String(currentOrder.deviceCode)) ||
+                  currentOrder.deviceCode || currentOrder.warningType }}</el-form-item>
               </el-col>
             </el-row>
             <el-row :gutter="20">
@@ -237,7 +237,7 @@ async function loadEquipmentTypes() {
     const map = new Map()
     if (Array.isArray(list)) {
       list.forEach(item => {
-        map.set(String(item.id), item.typeName)
+        if (item.typeId) map.set(String(item.typeId), item.typeName)
       })
     }
     equipmentTypeMap.value = map
@@ -270,11 +270,11 @@ const levelLegends = computed(() => [
   { name: '一般', value: tableData.value.filter(o => o.warningLevel === 3).length, color: '#409EFF' }
 ])
 const typeLegends = computed(() => {
-  const types = [...new Set(tableData.value.map(o => o.warningType).filter(Boolean))]
+  const types = [...new Set(tableData.value.map(o => o.deviceCode || o.warningType).filter(Boolean))]
   const colors = ['#409EFF', '#E6A23C', '#67C23A', '#F56C6C']
   return types.map((t, i) => ({
     name: equipmentTypeMap.value.get(String(t)) || t,
-    value: tableData.value.filter(o => o.warningType === t).length,
+    value: tableData.value.filter(o => (o.deviceCode || o.warningType) === t).length,
     color: colors[i % colors.length]
   }))
 })
@@ -298,14 +298,13 @@ async function loadData() {
   loading.value = true
   try {
     const params = {
-      equipmentType: 'drainage',
-      warningType: '2'
+      equipmentType: 'drainage'
     }
     if (filters.value.equipmentName) params.equipmentName = filters.value.equipmentName
     if (filters.value.warningLevel) params.warningLevel = filters.value.warningLevel
     if (filters.value.dateRange && filters.value.dateRange.length === 2) {
-      params.startTime = filters.value.dateRange[0]
-      params.endTime = filters.value.dateRange[1]
+      params.startTime = `${filters.value.dateRange[0]} 00:00:00`
+      params.endTime = `${filters.value.dateRange[1]} 23:59:59`
     }
     // 告诉后端只返回当前预警(未处置的)
     params.currentOnly = true

+ 3 - 1
src/views/subSystem/drainage/jcyj/lsyjGIS.vue

@@ -146,7 +146,8 @@ const statusMap = { HANDLED: '已处置', CLOSED: '已解除', DRAFT: '草稿',
 async function loadData() {
   try {
     const res = await getWarningHistoryPage(1, 1000, {
-      historyOnly: true
+      historyOnly: true,
+      equipmentType: 'drainage'
     })
     const pageData = res.code !== undefined ? res.data : res
     const records = pageData.records || []
@@ -163,6 +164,7 @@ async function loadData() {
       id: w.id,
       name: w.equipmentName || '-',
       warningNo: w.warningNo || '-',
+      deviceCode: w.deviceCode || w.warningType || '',
       level: levelMap[w.warningLevel] || '一般',
       createTime: w.createTime || '-',
       location: w.location || '-',

+ 6 - 5
src/views/subSystem/drainage/jcyj/lsyjgl.vue

@@ -38,9 +38,9 @@
             <el-tag :type="levelTagType(row.warningLevel)" size="small">{{ levelText(row.warningLevel) }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="warningType" label="预警类型" width="110" align="center">
+        <el-table-column prop="deviceCode" label="预警类型" width="110" align="center">
           <template #default="{ row }">
-            {{ equipmentTypeMap.get(String(row.warningType)) || row.warningType || '-' }}
+            {{ equipmentTypeMap.get(String(row.deviceCode)) || row.deviceCode || row.warningType || '-' }}
           </template>
         </el-table-column>
         <el-table-column prop="warningSpecial" label="预警专项" width="110" align="center" show-overflow-tooltip />
@@ -97,7 +97,7 @@
             <div class="info-row">
               <div class="info-item">
                 <span class="info-label">预警类型:</span>
-                <span class="info-value">{{ equipmentTypeMap.get(String(currentDetail.warningType)) || currentDetail.warningType || '-' }}</span>
+                <span class="info-value">{{ equipmentTypeMap.get(String(currentDetail.deviceCode)) || currentDetail.deviceCode || currentDetail.warningType || '-' }}</span>
               </div>
               <div class="info-item">
                 <span class="info-label">预警专项:</span>
@@ -158,7 +158,7 @@ async function loadEquipmentTypes() {
     const map = new Map()
     if (Array.isArray(list)) {
       list.forEach(item => {
-        map.set(String(item.id), item.typeName)
+        if (item.typeId) map.set(String(item.typeId), item.typeName)
       })
     }
     equipmentTypeMap.value = map
@@ -206,7 +206,8 @@ async function loadData() {
     // 历史页由后端按历史状态(HANDLED/CLOSED)限定;不再写死
     // 旧的数字类型和 HANDLED 状态,避免过滤掉实际历史记录。
     const params = {
-      historyOnly: true
+      historyOnly: true,
+      equipmentType: 'drainage'
     }
     if (filters.equipmentName) params.equipmentName = filters.equipmentName
     if (filters.warningLevel) params.warningLevel = filters.warningLevel

+ 174 - 67
src/views/subSystem/waterSupply/components/WaterAlarmPage.vue

@@ -16,28 +16,6 @@
             <div class="metric"><span>平均响应时间</span><strong>{{ stats.avgResponseMinutes ??
                 stats.averageResponseMinutes ?? '-' }}</strong><small>分钟</small></div>
         </div>
-        <div v-if="mode === 'map'" class="metric-grid">
-            <div class="metric">
-                <el-icon class="metric-icon">
-                    <Bell/>
-                </el-icon>
-                <span>报警总数</span><strong>{{ mapAlarmCount }}</strong><small>条</small></div>
-            <div class="metric">
-                <el-icon class="metric-icon">
-                    <Warning/>
-                </el-icon>
-                <span>高等级报警</span><strong class="danger">{{ mapCriticalCount }}</strong><small>条</small></div>
-            <div class="metric">
-                <el-icon class="metric-icon">
-                    <CircleCheck/>
-                </el-icon>
-                <span>已处理报警</span><strong>{{ mapResolvedCount }}</strong><small>条</small></div>
-            <div class="metric">
-                <el-icon class="metric-icon">
-                    <Location/>
-                </el-icon>
-                <span>地图点位</span><strong>{{ mappedCount }}</strong><small>个</small></div>
-        </div>
         <el-card v-if="mode === 'manage'" shadow="never" class="realtime-card">
             <div class="card-title"><span>实时异常卡片</span><span class="muted">设备、位置与阈值</span></div>
             <div v-if="!realtime.length" class="empty-inline">当前无未处理报警</div>
@@ -117,61 +95,102 @@
                            layout="total, sizes, prev, pager, next" :page-sizes="[10,20,50]" @change="loadData"/>
         </el-card>
         <el-card v-else shadow="never" class="water-panel map-card">
-            <div class="card-title"><span><el-icon><MapLocation/></el-icon> 监测报警一张图</span><span class="muted">按监测点状态着色</span>
-            </div>
-            <div class="map-workspace">
-                <GisMapCanvas
-                    ref="gisMapRef"
-                    :points="gisMapPoints"
-                    :center="{ lng: 110.3965, lat: 28.4638 }"
-                    :zoom="15"
-                    @point-click="openDetail"
-                    @map-click="detailVisible = false"
-                    @error="mapError = $event"
-                />
-                <el-alert v-if="mapError" type="warning" show-icon :closable="false" :title="mapError" />
-                <el-empty v-if="!mapPoints.length" description="暂无报警点位"/>
-                <aside class="map-side-panel"><h3>报警图层与图例</h3>
-                    <div class="map-legend">
-                        <div class="map-legend-item"><i class="map-legend-dot status-dot-normal"/>正常</div>
-                        <div class="map-legend-item"><i class="map-legend-dot status-dot-alarm"/>报警</div>
-                        <div class="map-legend-item"><i class="map-legend-dot status-dot-warning"/>预警</div>
-                        <div class="map-legend-item"><i class="map-legend-dot status-dot-work-order"/>关联工单</div>
-                        <div class="map-legend-item"><i class="map-legend-dot status-dot-offline"/>离线</div>
+            <WaterGisPageShell
+                embedded
+                eyebrow="WATER SUPPLY ALARM GIS"
+                title="监测报警一张图"
+                subtitle="按监测点状态着色"
+                :loading="loading"
+            >
+                <template #actions>
+                    <el-input v-model="alarmSearch" class="search-input" placeholder="搜索设备/报警类型" clearable
+                              :prefix-icon="Search"/>
+                    <el-button :icon="Refresh" @click="loadData">刷新</el-button>
+                </template>
+                <template #metrics>
+                    <div class="metric">
+                        <el-icon class="metric-icon"><Bell/></el-icon>
+                        <span>报警总数</span><strong>{{ mapAlarmCount }}</strong><small>条</small>
+                    </div>
+                    <div class="metric">
+                        <el-icon class="metric-icon"><Warning/></el-icon>
+                        <span>高等级报警</span><strong class="danger">{{ mapCriticalCount }}</strong><small>条</small>
+                    </div>
+                    <div class="metric">
+                        <el-icon class="metric-icon"><CircleCheck/></el-icon>
+                        <span>已处理报警</span><strong>{{ mapResolvedCount }}</strong><small>条</small>
                     </div>
-                    <div class="alarm-detail"><h3>选中报警</h3>
+                    <div class="metric">
+                        <el-icon class="metric-icon"><Location/></el-icon>
+                        <span>地图点位</span><strong>{{ mappedCount }}</strong><small>个</small>
+                    </div>
+                </template>
+                <template #controls>
+                    <div class="panel-section alarm-detail">
+                        <h3>选中报警</h3>
                         <template v-if="selectedPoint.id || selectedPoint.alarmId">
-                            <div class="detail-name">{{ selectedPoint.deviceCode || selectedPoint.name || '报警点' }}
-                            </div>
+                            <div class="detail-name">{{ selectedPoint.deviceCode || selectedPoint.name || '报警点' }}</div>
                             <el-descriptions :column="1" border size="small">
                                 <el-descriptions-item label="报警等级">
-                                    <el-tag :type="levelType(selectedPoint.alarmLevel)">{{ selectedPoint.alarmLevelText
-                                        || levelText(selectedPoint.alarmLevel) }}
-                                    </el-tag>
-                                </el-descriptions-item>
-                                <el-descriptions-item label="报警类型">{{ selectedPoint.warningType ||
-                                    selectedPoint.alarmType || '-' }}
-                                </el-descriptions-item>
-                                <el-descriptions-item label="触发时间">{{ selectedPoint.alarmTime ||
-                                    selectedPoint.createTime || '-' }}
+                                    <el-tag :type="levelType(selectedPoint.alarmLevel)">{{ selectedPoint.alarmLevelText || levelText(selectedPoint.alarmLevel) }}</el-tag>
                                 </el-descriptions-item>
+                                <el-descriptions-item label="报警类型">{{ selectedPoint.warningType || selectedPoint.alarmType || '-' }}</el-descriptions-item>
+                                <el-descriptions-item label="触发时间">{{ selectedPoint.alarmTime || selectedPoint.createTime || '-' }}</el-descriptions-item>
                                 <el-descriptions-item label="状态">
-                                    <el-tag :type="Number(selectedPoint.alarmStatus) === 1 ? 'success' : 'danger'">{{
-                                        Number(selectedPoint.alarmStatus) === 1 ? '已处理' : '未处理' }}
+                                    <el-tag :type="Number(selectedPoint.alarmStatus) === 1 ? 'success' : 'danger'">
+                                        {{ Number(selectedPoint.alarmStatus) === 1 ? '已处理' : '未处理' }}
                                     </el-tag>
                                 </el-descriptions-item>
-                                <el-descriptions-item label="位置">{{ selectedPoint.equipmentLocation ||
-                                    selectedPoint.location || '-' }}
-                                </el-descriptions-item>
+                                <el-descriptions-item label="位置">{{ selectedPoint.equipmentLocation || selectedPoint.location || '-' }}</el-descriptions-item>
                             </el-descriptions>
-                            <el-button class="detail-action" type="primary" link @click="openDetail(selectedPoint)">
-                                查看完整详情
-                            </el-button>
+                            <el-button class="detail-action" type="primary" link @click="openDetail(selectedPoint)">查看完整详情</el-button>
                         </template>
                         <el-empty v-else description="点击点位查看详情" :image-size="65"/>
                     </div>
-                </aside>
-            </div>
+                </template>
+                <template #legend>
+                    <div class="panel-section">
+                        <h3>报警图层与图例</h3>
+                        <div class="map-legend">
+                            <div class="map-legend-item"><i class="map-legend-dot status-dot-normal"/>正常</div>
+                            <div class="map-legend-item"><i class="map-legend-dot status-dot-alarm"/>报警</div>
+                            <div class="map-legend-item"><i class="map-legend-dot status-dot-warning"/>预警</div>
+                            <div class="map-legend-item"><i class="map-legend-dot status-dot-work-order"/>关联工单</div>
+                            <div class="map-legend-item"><i class="map-legend-dot status-dot-offline"/>离线</div>
+                        </div>
+                    </div>
+                </template>
+                <template #results>
+                    <div class="panel-section result-section">
+                        <h3>搜索报警</h3>
+                        <el-alert v-if="mapError" type="warning" show-icon :closable="false" :title="mapError"/>
+                        <el-empty v-else-if="!filteredAlarmPoints.length" description="暂无报警点位" :image-size="72"/>
+                        <div v-else class="device-result-list">
+                            <button v-for="point in filteredAlarmPoints" :key="point.id || point.alarmId || point.deviceCode"
+                                    type="button" @click="locateAlarmPoint(point)">
+                                <span class="device-name">{{ point.deviceName || point.deviceCode || point.name || '报警点' }}</span>
+                                <span class="device-meta">{{ point.warningType || point.alarmType || '监测报警' }} · {{ point.alarmTime || point.createTime || '-' }}</span>
+                                <el-tag size="small" :type="levelType(point.alarmLevel)">
+                                    {{ point.alarmLevelText || levelText(point.alarmLevel) }}
+                                </el-tag>
+                            </button>
+                        </div>
+                    </div>
+                </template>
+                <template #map>
+                    <GisMapCanvas
+                        ref="gisMapRef"
+                        :points="gisMapPoints"
+                        :center="{ lng: 110.3965, lat: 28.4638 }"
+                        :zoom="15"
+                        @point-click="openDetail"
+                        @map-click="detailVisible = false"
+                        @error="mapError = $event"
+                    />
+                    <el-alert v-if="mapError" class="map-error" type="warning" show-icon :closable="false" :title="mapError"/>
+                    <el-empty v-if="!gisMapPoints.length && !mapError" class="map-empty" description="当前筛选下没有可显示的报警点位"/>
+                </template>
+            </WaterGisPageShell>
         </el-card>
 
         <el-drawer v-model="detailVisible" class="detail-drawer alarm-detail-drawer"
@@ -254,7 +273,7 @@
 <script setup>
     import {computed, onMounted, ref} from 'vue'
     import {ElMessage} from 'element-plus'
-    import {Bell, MapLocation} from '@element-plus/icons-vue'
+    import {Bell, MapLocation, Refresh, Search} from '@element-plus/icons-vue'
     import {
         auditAlarm,
         dispatchAlarm,
@@ -268,6 +287,7 @@
         resolveAlarm
     } from '@/api/pipeNetwork/waterSupply'
     import GisMapCanvas from '@/components/GisMapCanvas.vue'
+    import WaterGisPageShell from '@/components/WaterGisPageShell.vue'
     import { getMonitoringPointColor, getMonitoringPointIcon } from '@/utils/gisMapColors'
     import {normalizeMapPoints, normalizePage, unwrapResponse} from '@/utils/waterSupplyModel'
     import {getRequestErrorMessage} from '@/utils/waterFacilityQuery'
@@ -283,6 +303,7 @@
     const realtime = ref([]);
     const mapPoints = ref([]);
     const mapError = ref('')
+    const alarmSearch = ref('')
     const gisMapRef = ref(null)
     const loading = ref(false);
     const detailVisible = ref(false);
@@ -300,7 +321,15 @@
         label: '四级',
         value: 4
     }]
-    const gisMapPoints = computed(() => mapPoints.value.map(point => ({
+    const filteredAlarmPoints = computed(() => {
+        const keyword = alarmSearch.value.trim().toLocaleLowerCase();
+        if (!keyword) return mapPoints.value;
+        return mapPoints.value.filter(point => [
+            point.deviceName, point.deviceCode, point.name, point.warningType, point.alarmType
+        ].some(value => String(value || '').toLocaleLowerCase().includes(keyword)));
+    });
+
+    const gisMapPoints = computed(() => filteredAlarmPoints.value.map(point => ({
         ...point,
         lng: point._longitude ?? point.longitude,
         lat: point._latitude ?? point.latitude,
@@ -367,6 +396,18 @@
         }
     }
 
+    function alarmPointFocus(point) {
+        return {
+            lng: point._longitude ?? point.longitude,
+            lat: point._latitude ?? point.latitude
+        };
+    }
+
+    function locateAlarmPoint(point) {
+        gisMapRef.value?.locatePoint(alarmPointFocus(point));
+        openDetail(point);
+    }
+
     async function handleResolve(row) {
         try {
             await resolveAlarm(row.id || row.alarmId, '前端确认处理')
@@ -569,6 +610,72 @@
         min-height: 560px
     }
 
+    .map-card .search-input {
+        width: 220px
+    }
+
+    .map-card .panel-section h3 {
+        margin: 0 0 8px;
+        font-size: 14px;
+        color: #29567e
+    }
+
+    .map-card .alarm-detail {
+        margin-top: 0;
+        padding-top: 0;
+        border-top: 0
+    }
+
+    .map-card .device-result-list {
+        display: flex;
+        flex-direction: column;
+        gap: 6px
+    }
+
+    .map-card .device-result-list button {
+        width: 100%;
+        display: grid;
+        grid-template-columns: minmax(0, 1fr) auto;
+        gap: 3px 8px;
+        align-items: center;
+        border: 1px solid #dbe8f5;
+        background: #fff;
+        border-radius: 8px;
+        padding: 8px 10px;
+        text-align: left;
+        cursor: pointer
+    }
+
+    .map-card .device-name {
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+        font-weight: 600;
+        color: #274b6d
+    }
+
+    .map-card .device-meta {
+        grid-column: 1 / -1;
+        font-size: 12px;
+        color: #7793aa
+    }
+
+    .map-card .map-error {
+        position: absolute;
+        left: 16px;
+        bottom: 16px;
+        width: 360px;
+        z-index: 1
+    }
+
+    .map-card .map-empty {
+        position: absolute;
+        left: 50%;
+        top: 50%;
+        transform: translate(-50%, -50%);
+        z-index: 1
+    }
+
     .alarm-map {
         position: relative;
         height: 500px;

+ 32 - 84
src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue

@@ -1,11 +1,10 @@
 <template>
-  <div class="water-device-gis">
-    <div class="gis-header">
-      <div>
-        <p class="eyebrow">WATER SUPPLY DEVICE GIS</p>
-        <h2>监测设备 GIS 一张图</h2>
-      </div>
-      <div class="header-actions">
+  <WaterGisPageShell
+    eyebrow="WATER SUPPLY DEVICE GIS"
+    title="监测设备 GIS 一张图"
+    :loading="loading"
+  >
+    <template #actions>
         <el-input
           v-model="searchKeyword"
           class="search-input"
@@ -18,12 +17,10 @@
         </el-select>
         <el-button type="primary" :icon="Refresh" @click="loadData">刷新</el-button>
         <el-button :icon="Aim" @click="fitBounds">全图</el-button>
-      </div>
-    </div>
+    </template>
 
-    <div class="gis-layout">
-      <aside class="side-panel">
-        <div class="metric-grid">
+    <template #metrics>
+      <div class="metric-grid">
           <div class="metric">
             <span>设备总数</span>
             <strong>{{ summary.total }}</strong>
@@ -40,9 +37,11 @@
             <span>异常</span>
             <strong>{{ summary.abnormal }}</strong>
           </div>
-        </div>
+      </div>
+    </template>
 
-        <div class="panel-section">
+    <template #controls>
+      <div class="panel-section">
           <h3>状态筛选</h3>
           <el-checkbox-group v-model="statusFilters" class="status-filter">
             <el-checkbox value="normal">正常</el-checkbox>
@@ -68,18 +67,22 @@
             </button>
           </div>
           <el-empty v-else description="暂无供水设备类型" :image-size="72" />
-        </div>
+      </div>
+    </template>
 
-        <div class="panel-section legend-panel">
+    <template #legend>
+      <div class="panel-section legend-panel">
           <h3>图例</h3>
           <div class="legend-row"><i class="legend-dot normal"></i>正常设备</div>
           <div class="legend-row"><i class="legend-dot alarm"></i>报警设备</div>
           <div class="legend-row"><i class="legend-dot warning"></i>预警设备</div>
           <div class="legend-row"><i class="legend-dot workOrder"></i>关联工单设备</div>
           <div class="legend-row"><i class="legend-dot offline"></i>离线设备</div>
-        </div>
+      </div>
+    </template>
 
-        <div class="panel-section result-section">
+    <template #results>
+      <div class="panel-section result-section">
           <h3>搜索设备</h3>
           <el-alert v-if="loadError" type="error" :closable="false" show-icon :title="loadError" />
           <el-empty
@@ -94,10 +97,10 @@
               <el-tag size="small" :type="statusTagType(device)">{{ statusText(device) }}</el-tag>
             </button>
           </div>
-        </div>
-      </aside>
+      </div>
+    </template>
 
-      <main class="map-panel" v-loading="loading">
+    <template #map>
         <GisMapCanvas
           ref="gisMapRef"
           :points="mapPoints"
@@ -107,10 +110,10 @@
           @error="mapError = $event"
         />
         <el-alert v-if="mapError" class="map-error" type="warning" show-icon :closable="false" :title="mapError" />
-      </main>
-    </div>
+    </template>
 
-    <el-drawer v-model="detailVisible" title="设备详情" size="420px" class="detail-drawer" direction="rtl" append-to-body>
+    <template #drawer>
+      <el-drawer v-model="detailVisible" title="设备详情" size="420px" class="detail-drawer" direction="rtl" append-to-body>
       <div v-loading="detailLoading" class="device-detail">
         <el-alert v-if="detailError" type="error" show-icon :closable="false" title="设备详情加载失败,请关闭后重试" />
         <template v-if="selectedDetail">
@@ -161,14 +164,16 @@
           </section>
         </template>
       </div>
-    </el-drawer>
-  </div>
+      </el-drawer>
+    </template>
+  </WaterGisPageShell>
 </template>
 
 <script setup>
 import { computed, nextTick, onMounted, ref } from 'vue'
 import { Aim, Refresh, Search } from '@element-plus/icons-vue'
 import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import WaterGisPageShell from '@/components/WaterGisPageShell.vue'
 import { getMonitoringPointColor, getMonitoringPointIcon, getMonitoringPointLabel, getMonitoringPointStatus, getMonitoringPointTagType } from '@/utils/gisMapColors'
 import {
   getDeviceGisAbnormalPoints,
@@ -333,30 +338,6 @@ onMounted(loadData)
 </script>
 
 <style scoped>
-.water-device-gis {
-  min-height: 100%;
-  padding: 18px;
-  background: linear-gradient(180deg, #edf6ff 0%, #f5f7fb 100%);
-  color: #1f3b57;
-}
-.gis-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 18px;
-  margin-bottom: 16px;
-}
-.eyebrow {
-  margin: 0 0 3px;
-  font-size: 11px;
-  letter-spacing: .12em;
-  color: #5c93c7;
-}
-.gis-header h2 {
-  margin: 0;
-  font-size: 22px;
-  color: #173a5e;
-}
 .header-actions {
   display: flex;
   align-items: center;
@@ -366,27 +347,6 @@ onMounted(loadData)
 .type-select {
   width: 240px;
 }
-.gis-layout {
-  display: grid;
-  grid-template-columns: 310px minmax(0, 1fr);
-  gap: 16px;
-  height: calc(100vh - 140px);
-  min-height: 620px;
-}
-.side-panel,
-.map-panel {
-  background: rgba(255,255,255,.94);
-  border: 1px solid #dbe8f5;
-  border-radius: 10px;
-  box-shadow: 0 10px 28px rgba(31,74,121,.08);
-}
-.side-panel {
-  display: flex;
-  flex-direction: column;
-  gap: 14px;
-  padding: 16px;
-  overflow: hidden;
-}
 .metric-grid {
   display: grid;
   grid-template-columns: repeat(2, 1fr);
@@ -423,7 +383,6 @@ onMounted(loadData)
   display: flex;
   flex-direction: column;
   gap: 6px;
-  overflow-y: auto;
 }
 .type-list {
   max-height: 160px;
@@ -479,7 +438,7 @@ onMounted(loadData)
   flex-direction: column;
 }
 .device-result-list {
-  flex: 1;
+  flex: none;
 }
 .device-result-list button {
   display: grid;
@@ -499,10 +458,6 @@ onMounted(loadData)
   font-size: 12px;
   color: #7793aa;
 }
-.map-panel {
-  position: relative;
-  overflow: hidden;
-}
 .map-container {
   width: 100%;
   height: 100%;
@@ -557,13 +512,6 @@ onMounted(loadData)
   font-size: 13px;
 }
 @media (max-width: 1200px) {
-  .gis-layout {
-    grid-template-columns: 1fr;
-    height: auto;
-  }
-  .map-panel {
-    height: 560px;
-  }
   .search-input,
   .type-select {
     width: 180px;

+ 31 - 89
src/views/subSystem/waterSupply/facility/gis.vue

@@ -1,11 +1,10 @@
 <template>
-  <div class="water-device-gis facility-gis">
-    <div class="gis-header">
-      <div>
-        <p class="eyebrow">WATER SUPPLY FACILITY GIS</p>
-        <h2>供水设施 GIS 一张图</h2>
-      </div>
-      <div class="header-actions">
+  <WaterGisPageShell
+    eyebrow="WATER SUPPLY FACILITY GIS"
+    title="供水设施 GIS 一张图"
+    :loading="loading"
+  >
+    <template #actions>
         <el-input
           v-model="searchKeyword"
           class="search-input"
@@ -19,12 +18,10 @@
         </el-select>
         <el-button type="primary" :icon="Refresh" @click="refreshScene">刷新</el-button>
         <el-button :icon="Aim" @click="fitBounds">全图</el-button>
-      </div>
-    </div>
+    </template>
 
-    <div class="gis-layout">
-      <aside class="side-panel">
-        <div class="metric-grid">
+    <template #metrics>
+      <div class="metric-grid">
           <div class="metric">
             <span>设施总数</span>
             <strong>{{ summaryStats.total }}</strong>
@@ -41,25 +38,31 @@
             <span>泵站</span>
             <strong>{{ summaryStats.pumpStationCount }}</strong>
           </div>
-        </div>
+      </div>
+    </template>
 
-        <div class="panel-section">
+    <template #controls>
+      <div class="panel-section">
           <h3>图层控制</h3>
           <div class="layer-list">
             <label v-for="item in facilityTypes" :key="item.value" class="layer-row">
               <el-checkbox v-model="layerState[item.value]">{{ item.label }}</el-checkbox>
             </label>
           </div>
-        </div>
+      </div>
+    </template>
 
-        <div class="panel-section legend-panel">
+    <template #legend>
+      <div class="panel-section legend-panel">
           <h3>图例</h3>
           <div class="legend-row" v-for="item in facilityTypes" :key="item.value">
             <i class="legend-dot" :style="{ background: item.color }"></i>{{ item.label }}
           </div>
-        </div>
+      </div>
+    </template>
 
-        <div class="panel-section result-section">
+    <template #results>
+      <div class="panel-section result-section">
           <h3>搜索设施</h3>
           <el-alert v-if="loadError" type="error" :closable="false" show-icon :title="loadError" />
           <el-empty
@@ -74,10 +77,10 @@
               <el-tag size="small" type="primary">{{ labelMap[feature.type] || '供水设施' }}</el-tag>
             </button>
           </div>
-        </div>
-      </aside>
+      </div>
+    </template>
 
-      <main class="map-panel" v-loading="loading">
+    <template #map>
         <GisMapCanvas
           ref="gisMapRef"
           :points="mapPoints"
@@ -97,10 +100,10 @@
           class="map-empty"
           description="当前筛选下没有可显示的供水设施"
         />
-      </main>
-    </div>
+    </template>
 
-    <el-drawer v-model="detailOpen" title="设施详情" size="420px" direction="rtl" class="detail-drawer" append-to-body>
+    <template #drawer>
+      <el-drawer v-model="detailOpen" title="设施详情" size="420px" direction="rtl" class="detail-drawer" append-to-body>
       <div v-loading="detailLoading" class="device-detail">
         <el-alert v-if="detailError" type="error" show-icon :closable="false" title="设施详情加载失败,请重试" />
         <template v-if="detailRecord">
@@ -123,8 +126,9 @@
         </template>
         <el-empty v-else-if="!detailLoading && !detailError" description="暂无设施详情" />
       </div>
-    </el-drawer>
-  </div>
+      </el-drawer>
+    </template>
+  </WaterGisPageShell>
 </template>
 
 <script setup>
@@ -132,6 +136,7 @@ import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
 import { Aim, Refresh, Search } from '@element-plus/icons-vue'
 import { ElMessage } from 'element-plus'
 import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import WaterGisPageShell from '@/components/WaterGisPageShell.vue'
 import { getWaterFacilityOverview, getWaterFacilityFeatures, getWaterFacilityDetail } from '@/api/pipeNetwork/waterSupply'
 import { normalizeGeoJson, unwrapResponse } from '@/utils/waterSupplyModel'
 import { getRequestErrorMessage } from '@/utils/waterFacilityQuery'
@@ -362,30 +367,6 @@ async function refreshScene() {
 </script>
 
 <style scoped>
-.water-device-gis {
-  min-height: 100%;
-  padding: 18px;
-  background: linear-gradient(180deg, #edf6ff 0%, #f5f7fb 100%);
-  color: #1f3b57;
-}
-.gis-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 18px;
-  margin-bottom: 16px;
-}
-.eyebrow {
-  margin: 0 0 3px;
-  font-size: 11px;
-  letter-spacing: .12em;
-  color: #5c93c7;
-}
-.gis-header h2 {
-  margin: 0;
-  font-size: 22px;
-  color: #173a5e;
-}
 .header-actions {
   display: flex;
   align-items: center;
@@ -395,31 +376,6 @@ async function refreshScene() {
 .type-select {
   width: 240px;
 }
-.gis-layout {
-  display: grid;
-  grid-template-columns: 310px minmax(0, 1fr);
-  gap: 16px;
-  height: calc(100vh - 140px);
-  min-height: 620px;
-}
-.side-panel,
-.map-panel {
-  background: rgba(255,255,255,.94);
-  border: 1px solid #dbe8f5;
-  border-radius: 10px;
-  box-shadow: 0 10px 28px rgba(31,74,121,.08);
-}
-.side-panel {
-  display: flex;
-  flex-direction: column;
-  gap: 14px;
-  padding: 16px;
-  overflow-y: auto;
-}
-.map-panel {
-  position: relative;
-  overflow: hidden;
-}
 .metric-grid {
   display: grid;
   grid-template-columns: repeat(2, 1fr);
@@ -481,12 +437,9 @@ async function refreshScene() {
   flex-direction: column;
 }
 .device-result-list {
-  flex: 1;
-  min-height: 0;
   display: flex;
   flex-direction: column;
   gap: 6px;
-  overflow-y: auto;
 }
 .device-result-list button {
   width: 100%;
@@ -513,10 +466,6 @@ async function refreshScene() {
   font-size: 12px;
   color: #7793aa;
 }
-.map-panel {
-  position: relative;
-  overflow: hidden;
-}
 .map-error {
   position: absolute;
   left: 16px;
@@ -575,13 +524,6 @@ async function refreshScene() {
   font-size: 13px;
 }
 @media (max-width: 1200px) {
-  .gis-layout {
-    grid-template-columns: 1fr;
-    height: auto;
-  }
-  .map-panel {
-    height: 560px;
-  }
   .search-input,
   .type-select {
     width: 180px;

+ 36 - 3
tests/gisMapCanvas.test.mjs

@@ -1,7 +1,7 @@
 import test from 'node:test'
 import assert from 'node:assert/strict'
 import { readFile } from 'node:fs/promises'
-import { getFacilityPointIcon, getMonitoringPointColor, getMonitoringPointIcon, getMonitoringPointLabel, getMonitoringPointStatus, getMonitoringPointTagType } from '../src/utils/gisMapColors.js'
+import { getFacilityPointIcon, getMapClusterIcon, getMonitoringPointColor, getMonitoringPointIcon, getMonitoringPointLabel, getMonitoringPointStatus, getMonitoringPointTagType } from '../src/utils/gisMapColors.js'
 
 test('shared gis map canvas converts WGS84 coordinates before BMapGL rendering', async () => {
   const source = await readFile('src/components/GisMapCanvas.vue', 'utf8')
@@ -13,6 +13,35 @@ test('shared gis map canvas converts WGS84 coordinates before BMapGL rendering',
   assert.doesNotMatch(source, /new BMapGL\.Point\(point\.lng, point\.lat\)/u)
 })
 
+test('shared gis map canvas renders pipelines without endpoint markers', async () => {
+  const source = await readFile('src/components/GisMapCanvas.vue', 'utf8')
+  const addLine = source.match(/function addLine\(BMapGL, line\) \{[\s\S]*?\n\}/u)?.[0] || ''
+
+  assert.match(addLine, /new BMapGL\.Polyline\(bmapPoints/u)
+  assert.match(addLine, /emit\('line-click', line\)/u)
+  assert.doesNotMatch(addLine, /new BMapGL\.Marker/u)
+  assert.doesNotMatch(addLine, /new BMapGL\.Label/u)
+  assert.doesNotMatch(addLine, /起点/u)
+  assert.doesNotMatch(addLine, /终点/u)
+})
+
+test('shared gis map canvas merges overlapping markers and emits cluster clicks', async () => {
+  const source = await readFile('src/components/GisMapCanvas.vue', 'utf8')
+  const addClusterMarker = source.match(/function addClusterMarker\(BMapGL, cluster\) \{[\s\S]*?\n\}/u)?.[0] || ''
+
+  assert.match(source, /import \{ mergeMapPoints \} from '@\/utils\/gisMapClustering'/u)
+  assert.match(source, /clusterRadius: \{ type: Number, default: 48 \}/u)
+  assert.match(source, /mergeMapPoints\(renderPoints, projectMapPoint, props\.clusterRadius\)/u)
+  assert.match(source, /getMapClusterIcon\(cluster\.points\)/u)
+  assert.match(source, /focusCluster\(cluster\)/u)
+  assert.match(source, /emit\('cluster-click', cluster\)/u)
+  assert.match(source, /function isClusterUnexpandable\(cluster\)/u)
+  assert.match(addClusterMarker, /if \(isClusterUnexpandable\(cluster\)\) emit\('cluster-click', cluster\)/u)
+  assert.doesNotMatch(addClusterMarker, /props\.showMarkerName/u)
+  assert.match(source, /mapInstance\.addEventListener\('moveend', renderScene\)/u)
+  assert.match(source, /mapInstance\.addEventListener\('zoomend', renderScene\)/u)
+})
+
 test('shared gis map canvas renders every point as an official icon with a label above it', async () => {
   const source = await readFile('src/components/GisMapCanvas.vue', 'utf8')
 
@@ -35,8 +64,6 @@ test('shared gis map canvas renders every point as an official icon with a label
   assert.doesNotMatch(source, /white-space: nowrap/u)
   assert.doesNotMatch(source, /<img class="marker-icon"/u)
   assert.doesNotMatch(source, /class="marker-wrap"/u)
-  assert.match(source, /供水管网.*起点/u)
-  assert.match(source, /终点/u)
   assert.ok(source.includes("emit('point-click', point)"))
 })
 
@@ -49,6 +76,12 @@ test('facility point icons follow the five-facility-type contract', () => {
   assert.match(getFacilityPointIcon('unknown'), /T_MarkPoint_Green\.png/u)
 })
 
+test('cluster icons reuse the single member icon or fall back to the white marker', () => {
+  assert.match(getMapClusterIcon([{ alarmStatus: 1 }, { alarmStatus: 1 }]), /T_MarkPoint_Red\.png/u)
+  assert.match(getMapClusterIcon([{ onlineStatus: 0 }, { onlineStatus: 0 }]), /T_MarkPoint_Grey\.png/u)
+  assert.match(getMapClusterIcon([{ alarmStatus: 1 }, { onlineStatus: 0 }]), /T_MarkPoint_WhiteBackground\.png/u)
+})
+
 test('monitoring point colors follow the documented status semantics', () => {
   assert.equal(getMonitoringPointStatus({ relatedOrderNo: 'WO-1' }), 'workOrder')
   assert.equal(getMonitoringPointStatus({ relatedWorkOrderNo: 'WO-2' }), 'workOrder')

+ 57 - 0
tests/gisMapClustering.test.mjs

@@ -0,0 +1,57 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { mergeMapPoints } from '../src/utils/gisMapClustering.js'
+
+function projectPoint({ lng, lat }) {
+  return { x: lng * 100, y: lat * 100 }
+}
+
+test('mergeMapPoints keeps distant points as individual markers', () => {
+  const points = [
+    { id: 'a', lng: 110.3, lat: 28.4 },
+    { id: 'b', lng: 111, lat: 29 }
+  ]
+
+  const merged = mergeMapPoints(points, projectPoint, 48)
+
+  assert.equal(merged.length, 2)
+  assert.deepEqual(merged.map(item => item.type), ['point', 'point'])
+  assert.deepEqual(merged.map(item => item.point.id), ['a', 'b'])
+})
+
+test('mergeMapPoints combines nearby points into one countable cluster', () => {
+  const points = [
+    { id: 'a', lng: 110.3, lat: 28.4, iconUrl: 'green.png' },
+    { id: 'b', lng: 110.3005, lat: 28.4004, iconUrl: 'red.png' }
+  ]
+
+  const merged = mergeMapPoints(points, projectPoint, 48)
+
+  assert.equal(merged.length, 1)
+  assert.equal(merged[0].type, 'cluster')
+  assert.equal(merged[0].count, 2)
+  assert.deepEqual(merged[0].points.map(point => point.id), ['a', 'b'])
+})
+
+test('mergeMapPoints keeps the average render position for a cluster', () => {
+  const merged = mergeMapPoints([
+    { id: 'a', lng: 110.3, lat: 28.4 },
+    { id: 'b', lng: 110.301, lat: 28.402 }
+  ], projectPoint, 48)
+
+  assert.equal(merged[0].position.lng, 110.3005)
+  assert.equal(merged[0].position.lat, 28.401)
+})
+
+test('mergeMapPoints ignores invalid coordinates', () => {
+  const merged = mergeMapPoints([
+    { id: 'valid', lng: 110.3, lat: 28.4 },
+    { id: 'bad-lng', lng: Number.NaN, lat: 28.4 },
+    { id: 'bad-lat', lng: 110.3, lat: Number.NaN }
+  ], projectPoint, 48)
+
+  assert.equal(merged.length, 1)
+  assert.equal(merged[0].type, 'point')
+  assert.equal(merged[0].point.id, 'valid')
+})

+ 1 - 1
tests/waterDeviceAssigneeSelector.test.mjs

@@ -62,7 +62,7 @@ test('opens the split dispatch dialog with a fresh assignee query', () => {
 test('dispatch form exposes fixed fault type and editable priority', () => {
   assert.match(source, /<el-form-item label="工单类型">\s*<span>1-故障维修<\/span>\s*<\/el-form-item>/)
   assert.match(source, /<el-form-item label="优先级" required>\s*<el-select v-model="dispatchForm\.orderLevel"/)
-  assert.match(source, /<el-option\s+label="紧急"\s+:value="1"\s*\/?>/)
+  assert.match(source, /<el-option\s+label="紧急"\s+:value="0"\s*\/?>/)
 })
 
 test('dispatch requires a valid server order summary and preserves failure state', () => {

+ 5 - 1
tests/waterFacilityGis.test.mjs

@@ -71,5 +71,9 @@ test('GIS page exposes REST-backed right detail drawer and empty layer state', (
   assert.match(source, /当前筛选下没有可显示的供水设施/u)
   assert.match(source, /const centerPoint = \{ lng: 110\.3965, lat: 28\.4638 \}/u)
   assert.match(source, /function featureFocus\(feature\)/u)
-  assert.match(source, /overflow-y: auto/u)
+  assert.match(source, /<WaterGisPageShell/u)
+
+  const shellSource = fs.readFileSync('src/components/WaterGisPageShell.vue', 'utf8')
+  assert.match(shellSource, /class="side-panel-body"/u)
+  assert.match(shellSource, /overflow-y: auto/u)
 })

+ 11 - 13
tests/waterSupplyUiModel.test.mjs

@@ -42,7 +42,7 @@ test('keeps device operation, online, alarm, and work-order states distinct', ()
     alertLevel: 1,
     abnormalReason: '供水压力超限',
     relatedWorkOrderNo: 'WO202609020001',
-    relatedWorkOrderStatus: 4,
+    relatedWorkOrderStatus: 3,
     relatedWorkOrderPerson: '张三'
   })
 
@@ -78,20 +78,18 @@ test('uses the abnormal-device order endpoint for device repair dispatch', () =>
 })
 
 test('maps numeric and numeric-string work-order statuses and marks terminal states', () => {
-  assert.deepEqual(normalizeWorkOrderStatus(4), { code: 4, text: '处理中', terminal: false })
-  assert.deepEqual(normalizeWorkOrderStatus(8), { code: 8, text: '延期审核', terminal: false })
-  assert.deepEqual(normalizeWorkOrderStatus('9'), { code: 9, text: '已延期', terminal: false })
-  assert.deepEqual(normalizeWorkOrderStatus('6'), { code: 6, text: '已结案', terminal: true })
-  assert.deepEqual(normalizeWorkOrderStatus('7'), { code: 7, text: '已驳回', terminal: true })
+  assert.deepEqual(normalizeWorkOrderStatus(3), { code: 3, text: '处理中', terminal: false })
+  assert.deepEqual(normalizeWorkOrderStatus(6), { code: 6, text: '延期中', terminal: false })
+  assert.deepEqual(normalizeWorkOrderStatus('5'), { code: 5, text: '已结案', terminal: true })
+  assert.deepEqual(normalizeWorkOrderStatus('7'), { code: 7, text: '已取消', terminal: true })
   assert.deepEqual(normalizeWorkOrderStatus('暂无工单'), { code: null, text: '暂无工单', terminal: false })
 })
 
 test('allows dispatch when the latest order is terminal, including numeric-string status', () => {
-  assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-6', relatedWorkOrderStatus: '6' }), true)
+  assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-5', relatedWorkOrderStatus: '5' }), true)
   assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-7', relatedWorkOrderStatus: 7 }), true)
-  assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-4', relatedWorkOrderStatus: '4' }), false)
-  assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-8', relatedWorkOrderStatus: '8' }), false)
-  assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-9', relatedWorkOrderStatus: 9 }), false)
+  assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-3', relatedWorkOrderStatus: '3' }), false)
+  assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-6', relatedWorkOrderStatus: '6' }), false)
   assert.equal(canDispatchDevice({ relatedOrderNo: '-' }), true)
 })
 
@@ -169,7 +167,7 @@ test('keeps only the last clicked assignee for single selection', () => {
 })
 
 test('accepts only a real dispatched order summary', () => {
-  assert.equal(normalizeDispatchOrderResponse({ data: { orderId: 12, orderNo: 'WO-12', orderStatus: '2' } }).orderNo, 'WO-12')
-  assert.equal(normalizeDispatchOrderResponse({ data: { orderId: 12, orderNo: 'WO-12', orderStatus: 1 } }), null)
-  assert.equal(normalizeDispatchOrderResponse({ data: { orderId: 12, orderStatus: 2 } }), null)
+  assert.equal(normalizeDispatchOrderResponse({ data: { orderId: 12, orderNo: 'WO-12', orderStatus: '1' } }).orderNo, 'WO-12')
+  assert.equal(normalizeDispatchOrderResponse({ data: { orderId: 12, orderNo: 'WO-12', orderStatus: 2 } }), null)
+  assert.equal(normalizeDispatchOrderResponse({ data: { orderId: 12, orderStatus: 1 } }), null)
 })