Kaynağa Gözat

refactor(gis): unify three GIS maps shell and states

Kazerin 1 ay önce
ebeveyn
işleme
e3732940cd

+ 3 - 0
.gitignore

@@ -24,3 +24,6 @@ yarn.lock
 
 # Local document parsing cache
 .firecrawl/
+
+# Vite build temp config
+vite.config.js.timestamp-*.mjs

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

@@ -26,3 +26,15 @@
 
 - 后端 `AlarmMapPointOutDTO` 仍缺少设备报警状态、在线状态和关联工单字段;当前前端通过报警台账与设备 GIS 接口补全。后续若报警数据规模继续增长,建议后端在地图点位契约中直接返回这些字段。
 - 报警台账分页设置 200 条/页、最多 50 页的安全上限;超过上限会进入加载失败状态,而不是静默丢失状态。
+
+## 补充:三张 GIS 图一致性回归(Ticket #5)
+
+- 共享页壳 `WaterGisPageShell` 新增统一的指标卡样式(图标框 + 文本块 + 数值单位)、地图错误态与空态定位样式;三张图不再各自维护重复的指标卡/错误态 CSS。
+- 设施图与设备图移除了共享页壳内嵌套的 `.metric-grid`(此前为 2 列,与报警图单列结构冲突),统一由页壳提供网格;三张图的指标卡结构统一为 `metric-icon + metric-body + metric-value + small`。
+- 设备图补齐地图空态;三张图的空态均按“未加载完成不显示空态”处理。
+- 三张图的地图错误提示统一提供“重试”入口;刷新与“全图”按钮在三张图保持一致。
+- 报警图详情抽屉按模式区分宽度:报警审核保持 720px,监测报警一张图与另外两图一致使用 420px 右侧抽屉。
+- 新增契约测试 `tests/waterGisConsistency.test.mjs`:锁定共享指标网格、三图指标结构、刷新/空态/错误重试/抽屉契约、搜索结果卡片结构,以及“聚合后点位数之和等于有效点位总数”的数据完整性属性。
+- 删除设备图遗留的未使用 `.map-container` 规则;`.gitignore` 增加 Vite 构建临时配置忽略规则。
+- 验证:前端全量测试 112/112 通过;生产构建通过;`git diff --check` 通过。
+- 仍需人工验收:真实登录态下三张图的点位数量、图标颜色、名称标签、左侧面板滚动、抽屉遮挡与门户/菜单路由。

+ 68 - 0
src/components/WaterGisPageShell.vue

@@ -139,10 +139,62 @@ defineProps({
   width: 100%;
 }
 
+.metric-grid :deep(.metric) {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  min-height: 62px;
+  padding: 10px 12px;
+  background: #fff;
+  border: 1px solid #e1ebf3;
+  border-radius: 8px;
+  box-shadow: 0 6px 14px rgba(15, 23, 42, .04);
+}
+
+.metric-grid :deep(.metric-icon) {
+  flex: 0 0 38px;
+  display: grid;
+  place-items: center;
+  width: 38px;
+  height: 38px;
+  color: #2563eb;
+  background: linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%);
+  border-radius: 8px;
+  font-size: 20px;
+}
+
+.metric-grid :deep(.metric-body) {
+  min-width: 0;
+  flex: 1;
+}
+
 .metric-grid :deep(.metric span) {
+  display: block;
+  margin-bottom: 3px;
+  overflow: hidden;
+  color: #64748b;
+  font-size: 13px;
+  text-overflow: ellipsis;
   white-space: nowrap;
 }
 
+.metric-grid :deep(.metric-value) {
+  display: flex;
+  align-items: baseline;
+  gap: 5px;
+}
+
+.metric-grid :deep(.metric-value strong) {
+  color: #1e293b;
+  font-size: 22px;
+  line-height: 1.2;
+}
+
+.metric-grid :deep(.metric-value small) {
+  color: #94a3b8;
+  font-size: 12px;
+}
+
 .panel-section :deep(h3) {
   margin: 0 0 8px;
   font-size: 14px;
@@ -164,6 +216,22 @@ defineProps({
   overflow: hidden;
 }
 
+.map-panel :deep(.map-error) {
+  position: absolute;
+  left: 16px;
+  bottom: 16px;
+  width: 360px;
+  z-index: 1;
+}
+
+.map-panel :deep(.map-empty) {
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 1;
+}
+
 @media (max-width: 1200px) {
   .gis-header {
     align-items: flex-start;

+ 23 - 10
src/views/subSystem/waterSupply/components/WaterAlarmPage.vue

@@ -198,20 +198,32 @@
                 </template>
                 <template #metrics>
                     <div class="metric">
-                        <el-icon class="metric-icon"><Bell/></el-icon>
-                        <span>报警点位</span><strong>{{ mapStatistics.total }}</strong><small>个</small>
+                        <div class="metric-icon"><el-icon><Bell/></el-icon></div>
+                        <div class="metric-body">
+                            <span>报警点位</span>
+                            <div class="metric-value"><strong>{{ mapStatistics.total }}</strong><small>个</small></div>
+                        </div>
                     </div>
                     <div class="metric">
-                        <el-icon class="metric-icon"><Warning/></el-icon>
-                        <span>高等级报警</span><strong class="danger">{{ mapStatistics.highLevel }}</strong><small>个</small>
+                        <div class="metric-icon"><el-icon><Warning/></el-icon></div>
+                        <div class="metric-body">
+                            <span>高等级报警</span>
+                            <div class="metric-value"><strong class="danger">{{ mapStatistics.highLevel }}</strong><small>个</small></div>
+                        </div>
                     </div>
                     <div class="metric">
-                        <el-icon class="metric-icon"><Tickets/></el-icon>
-                        <span>有关联工单</span><strong class="primary">{{ mapStatistics.byStatus.workOrder }}</strong><small>个</small>
+                        <div class="metric-icon"><el-icon><Tickets/></el-icon></div>
+                        <div class="metric-body">
+                            <span>有关联工单</span>
+                            <div class="metric-value"><strong class="primary">{{ mapStatistics.byStatus.workOrder }}</strong><small>个</small></div>
+                        </div>
                     </div>
                     <div class="metric">
-                        <el-icon class="metric-icon"><Location/></el-icon>
-                        <span>离线点位</span><strong>{{ mapStatistics.byStatus.offline }}</strong><small>个</small>
+                        <div class="metric-icon"><el-icon><Location/></el-icon></div>
+                        <div class="metric-body">
+                            <span>离线点位</span>
+                            <div class="metric-value"><strong>{{ mapStatistics.byStatus.offline }}</strong><small>个</small></div>
+                        </div>
                     </div>
                 </template>
                 <template #controls>
@@ -302,13 +314,14 @@
                     <el-alert v-if="mapLoadError" class="map-error" type="error" show-icon :closable="false" :title="mapLoadError">
                         <el-button link type="primary" @click="loadData">重试</el-button>
                     </el-alert>
-                    <el-empty v-else-if="!gisMapPoints.length && !mapError" class="map-empty" description="当前筛选下没有可显示的报警点位"/>
+                    <el-empty v-else-if="!loading && !gisMapPoints.length && !mapError" class="map-empty" description="当前筛选下没有可显示的报警点位"/>
                 </template>
             </WaterGisPageShell>
         </el-card>
 
         <el-drawer v-model="detailVisible" class="detail-drawer alarm-detail-drawer"
-                   :title="mode === 'audit' ? '报警审核' : '报警详情'" direction="rtl" size="720px" append-to-body>
+                   :title="mode === 'audit' ? '报警审核' : '报警详情'" direction="rtl"
+                   :size="mode === 'audit' ? '720px' : '420px'" append-to-body>
             <el-descriptions :column="2" border class="detail-descriptions">
                 <el-descriptions-item v-for="field in detailFields" :key="field.label" :label="field.label">
                     {{ field.value }}

+ 42 - 52
src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue

@@ -20,23 +20,33 @@
     </template>
 
     <template #metrics>
-      <div class="metric-grid">
-          <div class="metric">
-            <span>设备总数</span>
-            <strong>{{ summary.total }}</strong>
-          </div>
-          <div class="metric">
-            <span>在线</span>
-            <strong>{{ summary.online }}</strong>
-          </div>
-          <div class="metric">
-            <span>离线</span>
-            <strong>{{ summary.offline }}</strong>
-          </div>
-          <div class="metric">
-            <span>异常</span>
-            <strong>{{ summary.abnormal }}</strong>
-          </div>
+      <div class="metric">
+        <div class="metric-icon"><el-icon><Odometer/></el-icon></div>
+        <div class="metric-body">
+          <span>设备总数</span>
+          <div class="metric-value"><strong>{{ summary.total }}</strong><small>台</small></div>
+        </div>
+      </div>
+      <div class="metric">
+        <div class="metric-icon"><el-icon><CircleCheck/></el-icon></div>
+        <div class="metric-body">
+          <span>在线</span>
+          <div class="metric-value"><strong>{{ summary.online }}</strong><small>台</small></div>
+        </div>
+      </div>
+      <div class="metric">
+        <div class="metric-icon"><el-icon><CircleClose/></el-icon></div>
+        <div class="metric-body">
+          <span>离线</span>
+          <div class="metric-value"><strong>{{ summary.offline }}</strong><small>台</small></div>
+        </div>
+      </div>
+      <div class="metric">
+        <div class="metric-icon"><el-icon><Warning/></el-icon></div>
+        <div class="metric-body">
+          <span>异常</span>
+          <div class="metric-value"><strong>{{ summary.abnormal }}</strong><small>台</small></div>
+        </div>
       </div>
     </template>
 
@@ -84,7 +94,9 @@
     <template #results>
       <div class="panel-section result-section">
           <h3>搜索设备</h3>
-          <el-alert v-if="loadError" type="error" :closable="false" show-icon :title="loadError" />
+          <el-alert v-if="loadError" type="error" :closable="false" show-icon :title="loadError">
+            <el-button link type="primary" @click="loadData">重试</el-button>
+          </el-alert>
           <el-empty
             v-else-if="!filteredDevices.length"
             description="暂无可显示的监测设备"
@@ -110,7 +122,14 @@
           @point-click="openDetail($event.equipmentId)"
           @error="mapError = $event"
         />
-        <el-alert v-if="mapError" class="map-error" type="warning" show-icon :closable="false" :title="mapError" />
+        <el-alert v-if="mapError" class="map-error" type="warning" show-icon :closable="false" :title="mapError">
+          <el-button link type="primary" @click="loadData">重试</el-button>
+        </el-alert>
+        <el-empty
+          v-if="!loading && !mapError && !mapPoints.length"
+          class="map-empty"
+          description="当前筛选下没有可显示的监测设备"
+        />
     </template>
 
     <template #drawer>
@@ -172,7 +191,7 @@
 
 <script setup>
 import { computed, nextTick, onMounted, ref } from 'vue'
-import { Aim, Refresh, Search } from '@element-plus/icons-vue'
+import { Aim, CircleCheck, CircleClose, Odometer, Refresh, Search, Warning } 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'
@@ -348,28 +367,7 @@ onMounted(loadData)
 .type-select {
   width: 240px;
 }
-.metric-grid {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  gap: 8px;
-}
-.metric {
-  padding: 10px;
-  border: 1px solid #dceaf7;
-  border-radius: 8px;
-  background: #f7fbff;
-}
-.metric span {
-  display: block;
-  font-size: 12px;
-  color: #668099;
-}
-.metric strong {
-  display: block;
-  margin-top: 3px;
-  font-size: 20px;
-  color: #1b5c98;
-}
+
 .panel-section h3 {
   margin: 0 0 8px;
   font-size: 14px;
@@ -459,16 +457,8 @@ onMounted(loadData)
   font-size: 12px;
   color: #7793aa;
 }
-.map-container {
-  width: 100%;
-  height: 100%;
-}
-.map-error {
-  position: absolute;
-  left: 16px;
-  bottom: 16px;
-  width: 360px;
-}
+
+
 .device-detail {
   min-height: 240px;
 }

+ 39 - 57
src/views/subSystem/waterSupply/facility/gis.vue

@@ -21,23 +21,33 @@
     </template>
 
     <template #metrics>
-      <div class="metric-grid">
-          <div class="metric">
-            <span>设施总数</span>
-            <strong>{{ summaryStats.total }}</strong>
-          </div>
-          <div class="metric">
-            <span>供水管网</span>
-            <strong>{{ summaryStats.pipeCount }}</strong>
-          </div>
-          <div class="metric">
-            <span>水厂</span>
-            <strong>{{ summaryStats.plantCount }}</strong>
-          </div>
-          <div class="metric">
-            <span>泵站</span>
-            <strong>{{ summaryStats.pumpStationCount }}</strong>
-          </div>
+      <div class="metric">
+        <div class="metric-icon"><el-icon><Odometer/></el-icon></div>
+        <div class="metric-body">
+          <span>设施总数</span>
+          <div class="metric-value"><strong>{{ summaryStats.total }}</strong><small>处</small></div>
+        </div>
+      </div>
+      <div class="metric">
+        <div class="metric-icon"><el-icon><Connection/></el-icon></div>
+        <div class="metric-body">
+          <span>供水管网</span>
+          <div class="metric-value"><strong>{{ summaryStats.pipeCount }}</strong><small>条</small></div>
+        </div>
+      </div>
+      <div class="metric">
+        <div class="metric-icon"><el-icon><OfficeBuilding/></el-icon></div>
+        <div class="metric-body">
+          <span>水厂</span>
+          <div class="metric-value"><strong>{{ summaryStats.plantCount }}</strong><small>座</small></div>
+        </div>
+      </div>
+      <div class="metric">
+        <div class="metric-icon"><el-icon><SetUp/></el-icon></div>
+        <div class="metric-body">
+          <span>泵站</span>
+          <div class="metric-value"><strong>{{ summaryStats.pumpStationCount }}</strong><small>座</small></div>
+        </div>
       </div>
     </template>
 
@@ -64,7 +74,9 @@
     <template #results>
       <div class="panel-section result-section">
           <h3>搜索设施</h3>
-          <el-alert v-if="loadError" type="error" :closable="false" show-icon :title="loadError" />
+          <el-alert v-if="loadError" type="error" :closable="false" show-icon :title="loadError">
+            <el-button link type="primary" @click="loadGisData">重试</el-button>
+          </el-alert>
           <el-empty
             v-else-if="!filteredFeatures.length"
             description="暂无可显示的供水设施"
@@ -94,8 +106,12 @@
           @ready="handleMapReady"
           @error="mapError = $event"
         />
-        <el-alert v-if="mapError" class="map-error" type="warning" show-icon :closable="false" :title="mapError" />
-        <el-alert v-else-if="loadError" class="map-error" type="error" show-icon :closable="false" :title="loadError" />
+        <el-alert v-if="mapError" class="map-error" type="warning" show-icon :closable="false" :title="mapError">
+          <el-button link type="primary" @click="loadGisData">重试</el-button>
+        </el-alert>
+        <el-alert v-else-if="loadError" class="map-error" type="error" show-icon :closable="false" :title="loadError">
+          <el-button link type="primary" @click="loadGisData">重试</el-button>
+        </el-alert>
         <el-empty
           v-if="dataLoaded && !mapError && !loadError && !visibleFeatures.length"
           class="map-empty"
@@ -134,7 +150,7 @@
 
 <script setup>
 import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
-import { Aim, Refresh, Search } from '@element-plus/icons-vue'
+import { Aim, Connection, Odometer, OfficeBuilding, Refresh, Search, SetUp } from '@element-plus/icons-vue'
 import { ElMessage } from 'element-plus'
 import GisMapCanvas from '@/components/GisMapCanvas.vue'
 import WaterGisPageShell from '@/components/WaterGisPageShell.vue'
@@ -375,28 +391,7 @@ async function refreshScene() {
 .type-select {
   width: 240px;
 }
-.metric-grid {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  gap: 8px;
-}
-.metric {
-  padding: 10px;
-  border: 1px solid #dceaf7;
-  border-radius: 8px;
-  background: #f7fbff;
-}
-.metric span {
-  display: block;
-  font-size: 12px;
-  color: #668099;
-}
-.metric strong {
-  display: block;
-  margin-top: 3px;
-  font-size: 20px;
-  color: #1b5c98;
-}
+
 .panel-section h3 {
   margin: 0 0 8px;
   font-size: 14px;
@@ -465,20 +460,7 @@ async function refreshScene() {
   font-size: 12px;
   color: #7793aa;
 }
-.map-error {
-  position: absolute;
-  left: 16px;
-  bottom: 16px;
-  width: 360px;
-  z-index: 1;
-}
-.map-empty {
-  position: absolute;
-  left: 50%;
-  top: 50%;
-  transform: translate(-50%, -50%);
-  z-index: 1;
-}
+
 .device-detail {
   min-height: 240px;
 }

+ 79 - 0
tests/waterGisConsistency.test.mjs

@@ -0,0 +1,79 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const pages = {
+  facility: 'src/views/subSystem/waterSupply/facility/gis.vue',
+  device: 'src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue',
+  alarm: 'src/views/subSystem/waterSupply/components/WaterAlarmPage.vue'
+}
+
+const shellPath = 'src/components/WaterGisPageShell.vue'
+
+test('the shared shell owns one metric grid, map error and map empty styling', async () => {
+  const shell = await readFile(shellPath, 'utf8')
+
+  assert.match(shell, /class="metric-grid"/u)
+  assert.match(shell, /<slot name="metrics" \/>/u)
+  assert.match(shell, /\.metric-grid :deep\(\.metric\)/u)
+  assert.match(shell, /\.metric-grid :deep\(\.metric-icon\)/u)
+  assert.match(shell, /\.metric-grid :deep\(\.metric-body\)/u)
+  assert.match(shell, /\.metric-grid :deep\(\.metric-value\)/u)
+  assert.match(shell, /:deep\(\.map-error\)/u)
+  assert.match(shell, /:deep\(\.map-empty\)/u)
+})
+
+test('all three GIS maps fill the metrics slot without nesting another metric grid', async () => {
+  for (const [name, path] of Object.entries(pages)) {
+    const source = await readFile(path, 'utf8')
+    const metricsSlot = source.match(/#metrics>[\s\S]*?<\/template>/u)?.[0] || ''
+
+    assert.ok(metricsSlot, `${name} should declare a #metrics slot`)
+    assert.doesNotMatch(metricsSlot, /class="metric-grid"/u, `${name} should not nest a metric grid`)
+    assert.match(metricsSlot, /class="metric"/u, `${name} should render metric cards`)
+    assert.match(metricsSlot, /class="metric-icon"/u, `${name} should render a metric icon`)
+    assert.match(metricsSlot, /class="metric-body"/u, `${name} should render a metric body`)
+    assert.match(metricsSlot, /class="metric-value"/u, `${name} should render a metric value row`)
+    assert.match(metricsSlot, /<small>/u, `${name} should render a metric unit`)
+  }
+})
+
+test('all three GIS maps keep the same refresh, empty, error and drawer contract', async () => {
+  for (const [name, path] of Object.entries(pages)) {
+    const source = await readFile(path, 'utf8')
+
+    assert.match(source, />刷新<\/el-button>/u, `${name} should expose a refresh action`)
+    assert.match(source, />全图<\/el-button>/u, `${name} should expose a fit-bounds action`)
+    assert.match(source, /class="map-error"/u, `${name} should expose a map error state`)
+    assert.match(source, /class="map-error"[\s\S]*?重试<\/el-button>/u, `${name} should offer a retry action on map errors`)
+    assert.match(source, /class="map-empty"/u, `${name} should expose a map empty state`)
+    assert.match(source, /<el-drawer/u, `${name} should render the shared detail drawer component`)
+    assert.match(source, /direction="rtl"/u, `${name} should open the detail drawer from the right`)
+    assert.match(source, /class="detail-drawer/u, `${name} should use the shared detail drawer class`)
+    assert.match(source, /420px/u, `${name} should size the map detail drawer at 420px`)
+  }
+})
+
+test('all three GIS maps share the searchable result list structure', async () => {
+  for (const [name, path] of Object.entries(pages)) {
+    const source = await readFile(path, 'utf8')
+
+    assert.match(source, /class="device-result-list"/u, `${name} should render the shared result list`)
+    assert.match(source, /class="device-name"/u, `${name} should render a result name`)
+    assert.match(source, /class="device-meta"/u, `${name} should render a result meta line`)
+  }
+})
+test('clustering preserves every valid input point in the rendered marker count', async () => {
+  const { mergeMapPoints } = await import('../src/utils/gisMapClustering.js')
+  const points = [
+    { id: 'a', lng: 110.3, lat: 28.4 },
+    { id: 'b', lng: 110.3004, lat: 28.4003 },
+    { id: 'c', lng: 110.9, lat: 28.9 }
+  ]
+  const project = ({ lng, lat }) => ({ x: lng * 1000, y: lat * 1000 })
+
+  const merged = mergeMapPoints(points, project, 48)
+  const renderedCount = merged.reduce((sum, item) => sum + (item.type === 'cluster' ? item.count : 1), 0)
+
+  assert.equal(renderedCount, points.length)
+})