Explorar el Código

fix(gis): improve mixed cluster markers and facility details

Kazerin hace 1 mes
padre
commit
683ed9469a

+ 3 - 0
docs/design/2026-09-09-water-supply-gis-marker-style.md

@@ -20,6 +20,8 @@ accepted
 ### 重叠点位合并
 
 - 同屏相邻点位按像素半径(默认 48px)合并为一个簇,簇复用点位图标并显示数量;合并只发生在渲染层,不修改业务数据。
+- 当簇内包含多种类型或多种状态图标时,使用 `T_MarkPoint_Colorful.png` 作为聚合图标。
+- 聚合文本带业务前缀:设施图显示“设施集合:数量”,设备图显示“设备集合:数量”,报警图显示“报警集合:数量”。
 - 点击簇时,画布优先缩放展开簇内点位;当簇内坐标完全重合、无法通过缩放展开时,画布发出 `cluster-click` 事件,由页面层展示簇内点位列表。
 
 ### 设施类型颜色
@@ -56,3 +58,4 @@ accepted
 - 地图同时显示当前查询范围内全部有效点位和管线,不得只显示第一条记录。
 - 点击地图点位或左侧搜索结果时,定位并放大到对应要素,并打开右侧详情抽屉。
 - 左侧面板由 `side-panel-body` 统一采用有界滚动;统计、图层、图例和搜索结果完整可用,搜索结果不得再嵌套独立滚动。
+- 设施详情抽屉中的字段名和枚举值必须使用中文业务口径,不得直接展示接口原始英文字段。

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

@@ -41,3 +41,11 @@
 - 详情页已独立表达设备状态、在线状态、报警状态、报警等级、异常原因、最新数据和维修记录。
 - 验证:监测设备 GIS、设备模型和 GIS 画布契约测试 24/24 通过;全量测试 83/83 通过;生产构建通过。
 - 本轮 #3 未产生新的代码差异,无需提交实现代码;真实登录态下的 BMapGL 地图效果仍需人工验收。
+
+## 补充:GIS 人工验收修正
+
+- 用户人工调整统一 GIS 左侧面板:左栏固定为 340px,补充面板内边距和滚动条预留空间;统计卡改为单列布局并保留标题不换行。该人工样式已纳入版本记录。
+- 混合类型或混合状态聚合点不再使用白色背景兜底,改用用户提供的 `T_MarkPoint_Colorful.png` 彩色聚合图标。
+- 聚合文本从纯数量改为业务前缀:设施图“设施集合:数量”,设备图“设备集合:数量”,报警图“报警集合:数量”。
+- 供水设施详情抽屉新增中文字段字典,过滤接口原始英文字段,并将设施类型、业务归属、运行状态和删除标志等枚举值转换为中文业务口径。
+- 验证:GIS 单文件测试 13/13 通过;全量测试 84/84 通过;生产构建通过。

BIN
src/assets/gis/T_MarkPoint_Colorful.png


+ 4 - 1
src/components/GisMapCanvas.vue

@@ -16,6 +16,7 @@ const props = defineProps({
   markerSize: { type: Number, default: 28 },
   clusterRadius: { type: Number, default: 48 },
   enableClustering: { type: Boolean, default: true },
+  clusterLabel: { type: String, default: '点位集合' },
   showMarkerName: { type: Boolean, default: true },
   mapStyleId: { type: String, default: 'a0f4e6e6f9e7a6c5d8e7f7e7f7e7f7e5' }
 })
@@ -159,7 +160,7 @@ function addClusterMarker(BMapGL, cluster) {
   mapInstance.addOverlay(marker)
   overlays.push(marker)
 
-  const label = new BMapGL.Label(`<div class="gis-cluster-label-text">${cluster.count}</div>`, {
+  const label = new BMapGL.Label(`<div class="gis-cluster-label-text">${escapeHtml(props.clusterLabel)}:${cluster.count}</div>`, {
     position,
     offset: new BMapGL.Size(-28, -props.markerSize - 18)
   })
@@ -335,6 +336,8 @@ defineExpose({
 }
 
 .gis-cluster-label-text {
+  width: auto;
+  min-width: 56px;
   font-size: 11px;
   line-height: 16px;
   font-weight: 700;

+ 7 - 4
src/components/WaterGisPageShell.vue

@@ -97,7 +97,7 @@ defineProps({
 
 .gis-layout {
   display: grid;
-  grid-template-columns: minmax(340px, 380px) minmax(0, 1fr);
+  grid-template-columns: minmax(340px, 340px) minmax(0, 1fr);
   gap: 12px;
   height: calc(100vh - 132px);
   min-height: 620px;
@@ -116,6 +116,7 @@ defineProps({
 }
 
 .side-panel {
+  padding: 5px;
   display: flex;
   flex-direction: column;
   overflow: hidden;
@@ -125,15 +126,17 @@ defineProps({
   height: 100%;
   display: flex;
   flex-direction: column;
-  gap: 12px;
-  padding: 12px;
+  gap: 10px;
+  padding: 12px 10px;
   overflow-y: auto;
+  scrollbar-gutter: stable; /* 可选:预留滚动条空间,避免跳动 */
 }
 
 .metric-grid {
   display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
+  grid-template-columns: repeat(1, minmax(0, 1fr));
   gap: 8px;
+  width: 100%;
 }
 
 .metric-grid :deep(.metric span) {

+ 1 - 1
src/utils/gisMapColors.js

@@ -26,7 +26,7 @@ export function getFacilityPointIcon(type = '') {
   return facilityPointIcons[type] || facilityPointIcons.plant
 }
 
-const clusterPointIcon = new URL('../assets/gis/T_MarkPoint_WhiteBackground.png', import.meta.url).href
+const clusterPointIcon = new URL('../assets/gis/T_MarkPoint_Colorful.png', import.meta.url).href
 
 export function getMapClusterIcon(points = []) {
   const icons = [...new Set((Array.isArray(points) ? points : [])

+ 94 - 0
src/utils/waterFacilityGisModel.js

@@ -85,3 +85,97 @@ export function findFacilityFeature(model = {}, keyword = '') {
   return items.find(item => [item.name, item.code, item.road]
     .some(value => String(value || '').toLocaleLowerCase().includes(normalized))) || null
 }
+
+const facilityDetailLabels = {
+  id: '系统编号',
+  sourceId: '水源地编号',
+  sourceName: '水源地名称',
+  sourceType: '水源地类型',
+  unit: '所属单位',
+  address: '详细地址',
+  location: '位置描述',
+  longitude: '经度',
+  latitude: '纬度',
+  waterType: '水源类型',
+  protectionLevel: '保护等级',
+  waterQualityLevel: '水质等级',
+  protectionScope: '保护范围',
+  reportTime: '上报时间',
+  status: '运行状态',
+  plantCode: '水厂编号',
+  plantName: '水厂名称',
+  waterSourceType: '水源类型',
+  designCapacity: '设计供水能力',
+  currentCapacity: '实际供水能力',
+  treatmentProcess: '处理工艺',
+  commissionDate: '投运日期',
+  waterQualityStandard: '水质执行标准',
+  stationCode: '泵站编号',
+  stationName: '泵站名称',
+  stationType: '设施类型',
+  pumpCount: '水泵数量',
+  designFlow: '设计流量',
+  designHead: '设计扬程',
+  powerCapacity: '装机容量',
+  userCode: '用水户编号',
+  userName: '用水户名称',
+  userType: '用水户类型',
+  contactPerson: '联系人',
+  contactPhone: '联系电话',
+  meterNumber: '水表编号',
+  initialReading: '初始读数',
+  currentReading: '当前读数',
+  annualWaterConsumption: '年用水量',
+  networkId: '管网系统编号',
+  networkCode: '管网编号',
+  networkName: '管网名称',
+  networkLevel: '管网等级',
+  networkType: '业务归属',
+  managementUnit: '管养单位',
+  material: '管道材质',
+  diameter: '管径',
+  length: '管道长度',
+  buildDate: '建设日期',
+  buildYear: '建设年份',
+  principal: '负责人',
+  remark: '备注',
+  delFlag: '删除标志',
+  createBy: '创建人',
+  createTime: '创建时间',
+  updateBy: '更新人',
+  updateTime: '更新时间'
+}
+
+const facilityStatusLabels = {
+  source: { 0: '正常', 1: '异常' },
+  plant: { 0: '正常', 1: '停产' },
+  pumpStation: { 0: '正常', 1: '故障停运', 2: '检修中' },
+  pipe: { 0: '正常', 1: '停用' },
+  user: { 0: '正常', 1: '欠费', 2: '停用' }
+}
+
+function formatFacilityDetailValue(key, value, facilityType) {
+  if (key === 'status') {
+    return facilityStatusLabels[facilityType]?.[String(value)] ?? value
+  }
+  if (key === 'stationType') {
+    return { water_supply: '供水泵站', drainage: '排水泵站' }[value] ?? value
+  }
+  if (key === 'networkType') {
+    return { 0: '燃气', 1: '排水', 2: '供水' }[String(value)] ?? value
+  }
+  if (key === 'delFlag') {
+    return { 0: '正常', 2: '已删除' }[String(value)] ?? value
+  }
+  return value
+}
+
+export function formatFacilityDetail(detail = {}, facilityType = '') {
+  if (!detail || typeof detail !== 'object') return []
+  return Object.entries(detail)
+    .filter(([key, value]) => key in facilityDetailLabels && value !== null && value !== undefined && value !== '')
+    .map(([key, value]) => ({
+      label: facilityDetailLabels[key],
+      value: formatFacilityDetailValue(key, value, facilityType)
+    }))
+}

+ 1 - 0
src/views/subSystem/waterSupply/components/WaterAlarmPage.vue

@@ -181,6 +181,7 @@
                     <GisMapCanvas
                         ref="gisMapRef"
                         :points="gisMapPoints"
+                        cluster-label="报警集合"
                         :center="{ lng: 110.3965, lat: 28.4638 }"
                         :zoom="15"
                         @point-click="openDetail"

+ 1 - 0
src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue

@@ -104,6 +104,7 @@
         <GisMapCanvas
           ref="gisMapRef"
           :points="mapPoints"
+          cluster-label="设备集合"
           :center="{ lng: 110.3965, lat: 28.4638 }"
           :zoom="15"
           @point-click="openDetail($event.equipmentId)"

+ 3 - 4
src/views/subSystem/waterSupply/facility/gis.vue

@@ -85,6 +85,7 @@
           ref="gisMapRef"
           :points="mapPoints"
           :lines="mapLines"
+          cluster-label="设施集合"
           :center="centerPoint"
           :zoom="15"
           @point-click="locateFeature"
@@ -140,7 +141,7 @@ 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'
-import { buildFacilityMapModel, filterFacilityMapModel, findFacilityFeature } from '@/utils/waterFacilityGisModel'
+import { buildFacilityMapModel, filterFacilityMapModel, findFacilityFeature, formatFacilityDetail } from '@/utils/waterFacilityGisModel'
 import { getFacilityPointIcon } from '@/utils/gisMapColors'
 
 const centerPoint = { lng: 110.3965, lat: 28.4638 }
@@ -240,9 +241,7 @@ const mapLines = computed(() => visibleMapModel.value.lines.map(line => ({
   name: line.name || '供水管网'
 })))
 
-const detailEntries = computed(() => Object.entries(detailRecord.value || {})
-  .filter(([, value]) => value !== null && value !== undefined && typeof value !== 'object')
-  .map(([label, value]) => ({ label, value: value === '' ? '-' : value })))
+const detailEntries = computed(() => formatFacilityDetail(detailRecord.value, detailTarget.value?.type))
 
 watch(layerState, () => {
   if (mapReady.value) nextTick(() => gisMapRef.value?.fitBounds())

+ 8 - 3
tests/gisMapCanvas.test.mjs

@@ -25,14 +25,16 @@ test('shared gis map canvas renders pipelines without endpoint markers', async (
   assert.doesNotMatch(addLine, /终点/u)
 })
 
-test('shared gis map canvas merges overlapping markers and emits cluster clicks', async () => {
+test('shared gis map canvas renders contextual clusters with colorful mixed icons', 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, /clusterLabel: \{ type: String, default: '点位集合' \}/u)
   assert.match(source, /mergeMapPoints\(renderPoints, projectMapPoint, props\.clusterRadius\)/u)
   assert.match(source, /getMapClusterIcon\(cluster\.points\)/u)
+  assert.match(source, /escapeHtml\(props\.clusterLabel\)/u)
   assert.match(source, /focusCluster\(cluster\)/u)
   assert.match(source, /emit\('cluster-click', cluster\)/u)
   assert.match(source, /function isClusterUnexpandable\(cluster\)/u)
@@ -76,10 +78,10 @@ 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', () => {
+test('cluster icons reuse the single member icon or fall back to the colorful 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)
+  assert.match(getMapClusterIcon([{ alarmStatus: 1 }, { onlineStatus: 0 }]), /T_MarkPoint_Colorful\.png/u)
 })
 
 test('monitoring point colors follow the documented status semantics', () => {
@@ -113,6 +115,7 @@ test('water supply GIS pages share the common canvas component', async () => {
   const facilitySource = await readFile('src/views/subSystem/waterSupply/facility/gis.vue', 'utf8')
 
   assert.match(deviceSource, /<GisMapCanvas/u)
+  assert.match(deviceSource, /cluster-label="设备集合"/u)
   assert.match(deviceSource, /value="workOrder">关联工单/u)
   assert.match(deviceSource, /legend-dot warning/u)
   assert.match(deviceSource, /getMonitoringPointTagType/u)
@@ -123,10 +126,12 @@ test('water supply GIS pages share the common canvas component', async () => {
   assert.match(facilitySource, /:points="mapPoints"/u)
   assert.match(facilitySource, /:lines="mapLines"/u)
   assert.match(facilitySource, /iconUrl: getFacilityPointIcon\(point\.type\)/u)
+  assert.match(facilitySource, /cluster-label="设施集合"/u)
   assert.match(facilitySource, /@point-click="locateFeature"/u)
   const alarmSource = await readFile('src/views/subSystem/waterSupply/components/WaterAlarmPage.vue', 'utf8')
 
   assert.match(alarmSource, /<GisMapCanvas/u)
+  assert.match(alarmSource, /cluster-label="报警集合"/u)
   assert.match(alarmSource, /getMonitoringPointColor\(point\)/u)
   assert.match(alarmSource, /一级报警/u)
   assert.doesNotMatch(alarmSource, /color: point\.relatedOrderNo \?/u)

+ 18 - 1
tests/waterFacilityGis.test.mjs

@@ -5,7 +5,8 @@ import fs from 'node:fs'
 import {
   buildFacilityMapModel,
   filterFacilityMapModel,
-  findFacilityFeature
+  findFacilityFeature,
+  formatFacilityDetail
 } from '../src/utils/waterFacilityGisModel.js'
 
 const collection = {
@@ -61,6 +62,22 @@ test('facility search locates points and line midpoints by name, code, or addres
   )
 })
 
+test('facility detail entries use Chinese labels and business values', () => {
+  const entries = formatFacilityDetail({
+    id: 6,
+    stationCode: 'GIS-TEST-PS-001',
+    stationType: 'water_supply',
+    status: '0',
+    pumpCount: 4
+  }, 'pumpStation')
+
+  assert.ok(entries.some(item => item.label === '泵站编号'))
+  assert.ok(entries.some(item => item.label === '水泵数量' && item.value === 4))
+  assert.ok(entries.some(item => item.label === '设施类型' && item.value === '供水泵站'))
+  assert.ok(entries.some(item => item.label === '运行状态' && item.value === '正常'))
+  assert.ok(entries.every(item => !/[A-Za-z]/u.test(item.label)))
+})
+
 test('GIS page exposes REST-backed right detail drawer and empty layer state', () => {
   const source = fs.readFileSync('src/views/subSystem/waterSupply/facility/gis.vue', 'utf8')