Преглед изворни кода

refactor(gis): unify water supply page shell

Kazerin пре 1 месец
родитељ
комит
9485615135

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

@@ -8,6 +8,7 @@ accepted
 供水 GIS 一张图包含供水设施 GIS 一张图、监测设备 GIS 一张图和监测报警一张图。三张图共用统一地图页壳与交互框架,但数据边界、图层语义和详情内容不同。
 
 统一地图使用 `GisMapCanvas` 渲染点位。输入坐标保持 WGS84;组件在渲染到百度地图前统一转换为 BD09。
+统一页壳使用 `WaterGisPageShell` 承载页头、左侧信息面板和地图区;设施图、设备图和报警图只替换面板数据与地图内容。
 
 ### 坐标点
 
@@ -49,4 +50,4 @@ accepted
 - 默认地图中心为沅陵县。
 - 地图同时显示当前查询范围内全部有效点位和管线,不得只显示第一条记录。
 - 点击地图点位或左侧搜索结果时,定位并放大到对应要素,并打开右侧详情抽屉。
-- 左侧面板采用有界滚动,统计、图层、图例和搜索结果完整可用。
+- 左侧面板由 `side-panel-body` 统一采用有界滚动;统计、图层、图例和搜索结果完整可用,搜索结果不得再嵌套独立滚动。

+ 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 非阻塞项。

+ 188 - 0
src/components/WaterGisPageShell.vue

@@ -0,0 +1,188 @@
+<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(280px, 330px) minmax(0, 1fr);
+  gap: 16px;
+  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: 14px;
+  padding: 16px;
+  overflow-y: auto;
+}
+
+.metric-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 8px;
+}
+
+.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>

+ 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"
@@ -260,7 +279,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,
@@ -274,6 +293,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'
@@ -289,6 +309,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);
@@ -304,7 +325,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,
@@ -371,6 +400,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, '前端确认处理')
@@ -574,6 +615,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;

+ 33 - 83
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,11 @@
               <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>
+      <main class="map-panel">
         <GisMapCanvas
           ref="gisMapRef"
           :points="mapPoints"
@@ -108,9 +112,10 @@
         />
         <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 +166,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 +340,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 +349,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 +385,6 @@ onMounted(loadData)
   display: flex;
   flex-direction: column;
   gap: 6px;
-  overflow-y: auto;
 }
 .type-list {
   max-height: 160px;
@@ -479,7 +440,7 @@ onMounted(loadData)
   flex-direction: column;
 }
 .device-result-list {
-  flex: 1;
+  flex: none;
 }
 .device-result-list button {
   display: grid;
@@ -499,10 +460,6 @@ onMounted(loadData)
   font-size: 12px;
   color: #7793aa;
 }
-.map-panel {
-  position: relative;
-  overflow: hidden;
-}
 .map-container {
   width: 100%;
   height: 100%;
@@ -557,13 +514,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;

+ 32 - 88
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,11 @@
               <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>
+      <main class="map-panel">
         <GisMapCanvas
           ref="gisMapRef"
           :points="mapPoints"
@@ -98,9 +102,10 @@
           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 +128,9 @@
         </template>
         <el-empty v-else-if="!detailLoading && !detailError" description="暂无设施详情" />
       </div>
-    </el-drawer>
-  </div>
+      </el-drawer>
+    </template>
+  </WaterGisPageShell>
 </template>
 
 <script setup>
@@ -132,6 +138,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 +369,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 +378,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 +439,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 +468,6 @@ async function refreshScene() {
   font-size: 12px;
   color: #7793aa;
 }
-.map-panel {
-  position: relative;
-  overflow: hidden;
-}
 .map-error {
   position: absolute;
   left: 16px;
@@ -575,13 +526,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;

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

+ 45 - 0
tests/waterGisShell.test.mjs

@@ -0,0 +1,45 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const pageSources = [
+  'src/views/subSystem/waterSupply/facility/gis.vue',
+  'src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue',
+  'src/views/subSystem/waterSupply/components/WaterAlarmPage.vue'
+]
+
+test('water supply GIS pages use the shared page shell', async () => {
+  for (const path of pageSources) {
+    const source = await readFile(path, 'utf8')
+    assert.match(source, /<WaterGisPageShell/u, `${path} should use the shared shell`)
+    assert.match(source, /#metrics/u, `${path} should provide metrics`)
+    assert.match(source, /#controls/u, `${path} should provide filters`)
+    assert.match(source, /#legend/u, `${path} should provide legend`)
+    assert.match(source, /#results/u, `${path} should provide search results`)
+    assert.match(source, /#map/u, `${path} should provide the map`)
+    assert.match(source, /<el-drawer/u, `${path} should keep the right drawer`)
+  }
+})
+
+test('shared shell keeps the side panel and map equal height with one bounded scroll', async () => {
+  const source = await readFile('src/components/WaterGisPageShell.vue', 'utf8')
+
+  assert.match(source, /<header class="gis-header"/u)
+  assert.match(source, /<div class="gis-layout"/u)
+  assert.match(source, /<aside class="side-panel"/u)
+  assert.match(source, /<main class="map-panel"/u)
+  assert.match(source, /class="side-panel-body"/u)
+  assert.match(source, /height: calc\(100vh - 132px\)/u)
+  assert.match(source, /overflow: hidden/u)
+  assert.match(source, /overflow-y: auto/u)
+  assert.doesNotMatch(source, /grid-template-columns:\s*1fr\s+310px/u)
+})
+
+test('alarm GIS keeps map mode inside the common panel contract', async () => {
+  const source = await readFile('src/views/subSystem/waterSupply/components/WaterAlarmPage.vue', 'utf8')
+
+  assert.match(source, /<WaterGisPageShell[\s\S]*?embedded/u)
+  assert.match(source, /报警图层与图例/u)
+  assert.match(source, /选中报警/u)
+  assert.match(source, /@point-click="openDetail"/u)
+})