5 Комити 24af8cc9bc ... fb44069416

Аутор SHA1 Порука Датум
  Kazerin fb44069416 fix(water): refine statistics dashboard acceptance (#7) пре 3 дана
  Kazerin 78775e5f1c docs(memory): record water statistics T3 delivery пре 4 дана
  Kazerin 8f9a740f95 feat(water): add statistics dashboard filters (#7) пре 4 дана
  Kazerin 214a59a9e8 feat(water): add facility import and coordinates пре 4 дана
  Kazerin f888a16f05 feat(water): deliver T2 facility GIS map (#6) пре 5 дана

+ 1 - 0
memory/index.md

@@ -53,3 +53,4 @@ npm run dev       # 默认 http://localhost:80
 - [2026-08-17:供水管网安全运行监测系统前端实现](sessions/2026-08-17.md)
 - [2026-08-21:工程技能仓库配置](sessions/2026-08-21.md)
 - [2026-08-22:供水报警与工单前端适配设计](sessions/2026-08-22.md)
+- [2026-09-01:供水管网数据统计分析 T3](sessions/2026-09-01.md)

+ 43 - 0
memory/sessions/2026-09-01.md

@@ -0,0 +1,43 @@
+# 2026-09-01:供水管网数据统计分析 T3
+
+## 目标
+
+完成 Gogs Issue #7「T3 管网数据统计分析」的增量交付:统计筛选、导出参数一致性、空数据兜底、KingbaseES 年代分组兼容和六个固定导出工作表契约。
+
+## 结论
+
+- 后端提交:`0a5cc990fe55a9ce3a343c97f874b2b8d7f081a3`。
+- 前端提交:`8f9a740f95db2921a77e87230c6e3cf59c7af889`。
+- 统计仪表盘与 Excel 导出共用设施类型、状态筛选;取消全部设施类型时清空页面数据并禁用导出。
+- 后端空数据库返回五类设施零值概览,管网年代分组使用 `FLOOR` 保持 KingbaseES 兼容。
+- Excel 导出通过公共导出服务测试锁定六个固定工作表:综合概览、管网统计、水厂统计、泵站统计、水源地统计、用水户统计。
+
+## 验证
+
+- 后端 `mvn test` 通过。
+- 前端 `node --test tests/waterFacilityStatistics.test.mjs` 4/4 通过。
+- 前端 `npm run build:prod` 通过,仅存在既有 Sass 弃用警告。
+- 前端全量 Node 测试为 27/29;两个失败均为 T3 外遗留问题:`monitor/job` 只读详情仍使用居中弹窗、`waterFacilityDetailLabels` 与当前遗留修改不一致。
+
+## 风险与后续
+
+- 本次未连接真实 KingbaseES 执行集成验收;最终验收仍需以 KingbaseES 作为唯一生产真相源。
+- Issue #7 尚未在 Gogs 关闭,等待用户人工验收。
+
+## 人工验收修正轮次
+
+### 目标
+
+针对用户对 T3「管网数据统计分析」的人工验收反馈,在原边界内优化视觉表现、恢复“展示全部”筛选,并统一五类设施的状态口径。
+
+### 结论
+
+- 统计卡片拆分图标与文字区域,图表改用渐变堆叠柱状图与中心总数环形图,底部表格改为中文字段、状态标签和提示说明。
+- 状态筛选增加显式“展示全部”选项;查询构造忽略空状态,切换设施类型时先归一化状态,清除筛选只触发一次加载。
+- 建立设施专属状态契约:水源地正常/异常,水厂正常/停产,泵站正常/故障停运/检修中,管网正常/停用,用水户正常/欠费/停用;未知状态统一显示为“未知”。
+- 后端总览统计增加 `warningCount` 与 `unknownCount`,约定外状态计入未知,保证总数与状态分项闭合; Excel 导出使用设施专属状态文案。
+- 前端针对性测试 16/16 通过,后端针对性契约测试 4/4 通过,后端全量测试 42/42 通过;前端生产构建通过。前端全量 Node 测试 44/45,唯一失败为 T3 外既有 `monitor/job` 详情居中弹窗规则问题。
+
+### 风险与后续
+
+- 本轮仍未连接真实 KingbaseES 做人工验收,最终验收需以 KingbaseES 环境确认统计口径、筛选和 Excel 导出。

+ 87 - 0
src/utils/waterFacilityGisModel.js

@@ -0,0 +1,87 @@
+function finiteCoordinate(value) {
+  if (value === null || value === undefined || value === '') return null
+  const coordinate = Number(value)
+  return Number.isFinite(coordinate) ? coordinate : null
+}
+
+function featureIdentity(feature, properties) {
+  return {
+    id: properties.id ?? feature.id,
+    featureId: feature.id,
+    type: properties.facilityType || properties.type || '',
+    name: properties.facilityName || properties.name || properties.pipeName || properties.facilityTypeName || '供水设施',
+    code: properties.facilityCode || properties.code || properties.pipeCode || '-',
+    status: properties.status,
+    road: properties.road || properties.address || properties.location || '',
+    raw: feature
+  }
+}
+
+export function buildFacilityMapModel(collection = {}) {
+  const points = []
+  const lines = []
+
+  for (const feature of Array.isArray(collection.features) ? collection.features : []) {
+    const properties = feature.properties || {}
+    const identity = featureIdentity(feature, properties)
+    const coordinates = feature.geometry?.coordinates
+
+    if (feature.geometry?.type === 'Point' && Array.isArray(coordinates)) {
+      const lng = finiteCoordinate(coordinates[0])
+      const lat = finiteCoordinate(coordinates[1])
+      if (lng === null || lat === null) continue
+      points.push({
+        ...identity,
+        address: properties.address || properties.location || '',
+        lng,
+        lat
+      })
+    }
+
+    if (feature.geometry?.type === 'LineString' && Array.isArray(coordinates) && coordinates.length >= 2) {
+      const linePoints = coordinates.map(pair => ({
+        lng: finiteCoordinate(pair?.[0]),
+        lat: finiteCoordinate(pair?.[1])
+      }))
+      if (linePoints.some(point => point.lng === null || point.lat === null)) continue
+      lines.push({
+        ...identity,
+        type: identity.type || 'pipe',
+        material: properties.pipeMaterial,
+        diameter: properties.pipeDiameter,
+        startPoint: properties.startPoint,
+        endPoint: properties.endPoint,
+        layingYear: properties.layingYear,
+        length: properties.pipeLength,
+        points: linePoints
+      })
+    }
+  }
+
+  return { points, lines }
+}
+
+export function filterFacilityMapModel(model = {}, enabledTypes = {}) {
+  const points = (model.points || []).filter(item => enabledTypes[item.type] !== false)
+  const lines = (model.lines || []).filter(item => enabledTypes[item.type] !== false)
+  return { points, lines, isEmpty: points.length === 0 && lines.length === 0 }
+}
+
+export function findFacilityFeature(model = {}, keyword = '') {
+  const normalized = String(keyword).trim().toLocaleLowerCase()
+  if (!normalized) return null
+  const items = [
+    ...(model.points || []).map(item => ({ ...item, kind: 'point' })),
+    ...(model.lines || []).map(item => {
+      const first = item.points[0]
+      const last = item.points[item.points.length - 1]
+      const midpoint = {
+        lng: Number(((first.lng + last.lng) / 2).toFixed(8)),
+        lat: Number(((first.lat + last.lat) / 2).toFixed(8))
+      }
+      return { ...item, kind: 'pipeline', lng: midpoint.lng, lat: midpoint.lat }
+    })
+  ]
+  return items.find(item => [item.name, item.code, item.road]
+    .some(value => String(value || '').toLocaleLowerCase().includes(normalized))) || null
+}

+ 89 - 0
src/utils/waterFacilityStatisticsModel.js

@@ -0,0 +1,89 @@
+export const FACILITY_STATUS_LABELS = {
+  source: { '0': '正常', '1': '异常' },
+  plant: { '0': '正常', '1': '停产' },
+  pumpStation: { '0': '正常', '1': '故障停运', '2': '检修中' },
+  pipe: { '0': '正常', '1': '停用' },
+  user: { '0': '正常', '1': '欠费', '2': '停用' }
+}
+
+export const FACILITY_TYPE_LABELS = {
+  source: '水源地',
+  plant: '水厂',
+  pumpStation: '泵站',
+  pipe: '管网',
+  user: '用水户'
+}
+
+export const FACILITY_SECTION_TYPES = {
+  pipeNetwork: 'pipe',
+  waterPlant: 'plant',
+  waterSource: 'source',
+  waterUser: 'user',
+  pumpStation: 'pumpStation'
+}
+
+export const FACILITY_SECTION_LABELS = {
+  pipeNetwork: FACILITY_TYPE_LABELS.pipe,
+  waterPlant: FACILITY_TYPE_LABELS.plant,
+  waterSource: FACILITY_TYPE_LABELS.source,
+  waterUser: FACILITY_TYPE_LABELS.user,
+  pumpStation: FACILITY_TYPE_LABELS.pumpStation
+}
+
+export const FACILITY_STATUS_TAG_TYPES = {
+  source: { '0': 'success', '1': 'danger' },
+  plant: { '0': 'success', '1': 'danger' },
+  pumpStation: { '0': 'success', '1': 'danger', '2': 'warning' },
+  pipe: { '0': 'success', '1': 'danger' },
+  user: { '0': 'success', '1': 'warning', '2': 'danger' }
+}
+
+const unknownStatus = { label: '未知', tagType: 'info' }
+const FACILITY_TYPE_ORDER = ['source', 'plant', 'pumpStation', 'pipe', 'user']
+
+export function buildPipeNetworkStatisticsQuery({ facilityTypes = [], status = '' } = {}) {
+  const query = {}
+  const types = Array.isArray(facilityTypes)
+    ? facilityTypes.map(type => String(type).trim()).filter(Boolean)
+    : []
+  const normalizedStatus = String(status ?? '').trim()
+
+  if (types.length) query.facilityTypes = types.join(',')
+  if (normalizedStatus) query.status = normalizedStatus
+  return query
+}
+
+export function getFacilityStatusOptions(facilityTypes = []) {
+  const types = FACILITY_TYPE_ORDER.filter(type => facilityTypes.includes(type))
+  const statusValues = new Set()
+
+  types.forEach(type => {
+    Object.keys(FACILITY_STATUS_LABELS[type] || {}).forEach(status => statusValues.add(status))
+  })
+
+  return [...statusValues].sort().map(status => {
+    const labels = types
+      .map(type => ({
+        type,
+        label: FACILITY_STATUS_LABELS[type]?.[status]
+      }))
+      .filter(item => item.label)
+
+    const distinctLabels = [...new Set(labels.map(item => item.label))]
+    const label = types.length === 1
+      ? `${FACILITY_TYPE_LABELS[types[0]]}: ${distinctLabels[0]}`
+      : distinctLabels.join(' / ')
+
+    return { value: status, label }
+  })
+}
+
+export function resolveFacilityStatusLabel(type, status) {
+  const value = status === null || status === undefined ? '' : String(status)
+  return FACILITY_STATUS_LABELS[type]?.[value] || unknownStatus.label
+}
+
+export function resolveFacilityStatusTagType(type, status) {
+  const value = status === null || status === undefined ? '' : String(status)
+  return FACILITY_STATUS_TAG_TYPES[type]?.[value] || unknownStatus.tagType
+}

+ 20 - 0
src/views/subSystem/basic/pipeInfo/index.vue

@@ -67,6 +67,9 @@
       <el-col :span="1.5">
         <el-button type="success" plain icon="Download" size="small" @click="handleExport" v-hasPermi="['waterSupply:waterPipe:export']">导出</el-button>
       </el-col>
+      <el-col :span="1.5">
+        <el-button type="info" plain icon="Upload" size="small" @click="importVisible = true" v-hasPermi="['waterSupply:waterPipe:import']">导入</el-button>
+      </el-col>
       <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
     </el-row>
 
@@ -84,6 +87,10 @@
       <el-table-column label="长度(m)" align="center" prop="pipeLength" width="100" />
       <el-table-column label="起点位置" align="center" prop="startPoint" :show-overflow-tooltip="true" min-width="140" />
       <el-table-column label="终点位置" align="center" prop="endPoint" :show-overflow-tooltip="true" min-width="140" />
+      <el-table-column label="起点经度" align="center" prop="startLongitude" width="120" />
+      <el-table-column label="起点纬度" align="center" prop="startLatitude" width="120" />
+      <el-table-column label="终点经度" align="center" prop="endLongitude" width="120" />
+      <el-table-column label="终点纬度" align="center" prop="endLatitude" width="120" />
       <el-table-column label="铺设年份" align="center" prop="layingYear" width="100" />
       <el-table-column label="设计压力(MPa)" align="center" prop="pressureRating" width="130" />
       <el-table-column label="状态" align="center" width="80">
@@ -174,6 +181,10 @@
               <el-input v-model="form.endPoint" placeholder="请输入终点位置" maxlength="200" />
             </el-form-item>
           </el-col>
+          <el-col :span="12"><el-form-item label="起点经度" prop="startLongitude"><el-input-number v-model="form.startLongitude" :precision="8" :step="0.00000001" controls-position="right" style="width: 100%" /></el-form-item></el-col>
+          <el-col :span="12"><el-form-item label="起点纬度" prop="startLatitude"><el-input-number v-model="form.startLatitude" :precision="8" :step="0.00000001" controls-position="right" style="width: 100%" /></el-form-item></el-col>
+          <el-col :span="12"><el-form-item label="终点经度" prop="endLongitude"><el-input-number v-model="form.endLongitude" :precision="8" :step="0.00000001" controls-position="right" style="width: 100%" /></el-form-item></el-col>
+          <el-col :span="12"><el-form-item label="终点纬度" prop="endLatitude"><el-input-number v-model="form.endLatitude" :precision="8" :step="0.00000001" controls-position="right" style="width: 100%" /></el-form-item></el-col>
           <el-col :span="24">
             <el-form-item label="备注" prop="remark">
               <el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注" maxlength="500" />
@@ -204,6 +215,10 @@
         </el-descriptions-item>
         <el-descriptions-item label="起点位置" :span="2">{{ detailData.startPoint }}</el-descriptions-item>
         <el-descriptions-item label="终点位置" :span="2">{{ detailData.endPoint }}</el-descriptions-item>
+        <el-descriptions-item label="起点经度">{{ detailData.startLongitude }}</el-descriptions-item>
+        <el-descriptions-item label="起点纬度">{{ detailData.startLatitude }}</el-descriptions-item>
+        <el-descriptions-item label="终点经度">{{ detailData.endLongitude }}</el-descriptions-item>
+        <el-descriptions-item label="终点纬度">{{ detailData.endLatitude }}</el-descriptions-item>
         <el-descriptions-item label="备注" :span="2">{{ detailData.remark }}</el-descriptions-item>
         <el-descriptions-item label="创建者">{{ detailData.createBy }}</el-descriptions-item>
         <el-descriptions-item label="创建时间">{{ detailData.createTime }}</el-descriptions-item>
@@ -215,6 +230,8 @@
       </template>
     </el-drawer>
 
+    <WaterFacilityImportDialog v-model="importVisible" title="管网信息" upload-url="/api/water/pipe/importData" template-url="/api/water/pipe/importTemplate" @imported="getList" />
+
     <!-- 回收站对话框 -->
     <el-dialog title="回收站" v-model:visible="recycleVisible" width="900px" append-to-body>
       <el-form :model="recycleQuery" ref="recycleForm" :inline="true" size="small">
@@ -262,9 +279,11 @@ import { getDicts } from '@/api/system/dict/data'
 import { saveAs } from 'file-saver'
 import { exportWaterPipe } from '@/api/pipeNetwork/waterSupply'
 import { buildWaterFacilityQuery, getRequestErrorMessage, normalizePipeStatus } from '@/utils/waterFacilityQuery'
+import WaterFacilityImportDialog from '@/views/subSystem/waterSupply/components/WaterFacilityImportDialog.vue'
 
 export default {
   name: 'WaterPipeInfo',
+  components: { WaterFacilityImportDialog },
   data() {
     return {
 
@@ -290,6 +309,7 @@ export default {
       dialogVisible: false,
       // 详情显示
       detailVisible: false,
+      importVisible: false,
       // 详情数据
       detailData: {},
       // 回收站显示

+ 14 - 0
src/views/subSystem/basic/pumpStation/index.vue

@@ -54,6 +54,9 @@
       <el-col :span="1.5">
         <el-button type="success" plain icon="Download" size="small" @click="handleExport" v-hasPermi="['waterSupply:waterPumpStation:export']">导出</el-button>
       </el-col>
+      <el-col :span="1.5">
+        <el-button type="info" plain icon="Upload" size="small" @click="importVisible = true" v-hasPermi="['waterSupply:waterPumpStation:import']">导入</el-button>
+      </el-col>
       <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
     </el-row>
 
@@ -63,6 +66,8 @@
       <el-table-column label="泵站编号" align="center" prop="stationCode" :show-overflow-tooltip="true" min-width="120" />
       <el-table-column label="泵站名称" align="center" prop="stationName" :show-overflow-tooltip="true" min-width="140" />
       <el-table-column label="泵站地址" align="center" prop="location" :show-overflow-tooltip="true" min-width="160" />
+      <el-table-column label="经度" align="center" prop="longitude" width="120" />
+      <el-table-column label="纬度" align="center" prop="latitude" width="120" />
       <el-table-column label="水泵数量" align="center" prop="pumpCount" width="90" />
       <el-table-column label="设计流量(m³/h)" align="center" prop="designFlow" width="130" />
       <el-table-column label="设计扬程(m)" align="center" prop="designHead" width="110" />
@@ -109,6 +114,8 @@
               <el-input v-model="form.location" placeholder="请输入泵站地址" maxlength="200" />
             </el-form-item>
           </el-col>
+          <el-col :span="12"><el-form-item label="经度" prop="longitude"><el-input-number v-model="form.longitude" :precision="8" :step="0.00000001" controls-position="right" style="width: 100%" /></el-form-item></el-col>
+          <el-col :span="12"><el-form-item label="纬度" prop="latitude"><el-input-number v-model="form.latitude" :precision="8" :step="0.00000001" controls-position="right" style="width: 100%" /></el-form-item></el-col>
           <el-col :span="12">
             <el-form-item label="水泵数量" prop="pumpCount">
               <el-input-number v-model="form.pumpCount" :min="0" :precision="0" style="width: 100%" placeholder="请输入水泵数量" />
@@ -172,6 +179,8 @@
         <el-descriptions-item label="泵站编号">{{ detailData.stationCode }}</el-descriptions-item>
         <el-descriptions-item label="泵站名称">{{ detailData.stationName }}</el-descriptions-item>
         <el-descriptions-item label="泵站地址" :span="2">{{ detailData.location }}</el-descriptions-item>
+        <el-descriptions-item label="经度">{{ detailData.longitude }}</el-descriptions-item>
+        <el-descriptions-item label="纬度">{{ detailData.latitude }}</el-descriptions-item>
         <el-descriptions-item label="水泵数量">{{ detailData.pumpCount }}</el-descriptions-item>
         <el-descriptions-item label="设计流量(m³/h)">{{ detailData.designFlow }}</el-descriptions-item>
         <el-descriptions-item label="设计扬程(m)">{{ detailData.designHead }}</el-descriptions-item>
@@ -193,6 +202,8 @@
       </template>
     </el-drawer>
 
+    <WaterFacilityImportDialog v-model="importVisible" title="泵站信息" upload-url="/api/water/pumpStation/importData" template-url="/api/water/pumpStation/importTemplate" @imported="getList" />
+
     <!-- 回收站对话框 -->
     <el-dialog title="回收站" v-model="recycleVisible" width="900px" append-to-body>
       <el-form :model="recycleQuery" ref="recycleForm" :inline="true" size="small">
@@ -238,9 +249,11 @@ import { listPumpStation, getPumpStation, addPumpStation, updatePumpStation, del
 import { saveAs } from 'file-saver'
 import { exportWaterPumpStation } from '@/api/pipeNetwork/waterSupply'
 import { buildWaterFacilityQuery, getRequestErrorMessage } from '@/utils/waterFacilityQuery'
+import WaterFacilityImportDialog from '@/views/subSystem/waterSupply/components/WaterFacilityImportDialog.vue'
 
 export default {
   name: 'WaterPumpStation',
+  components: { WaterFacilityImportDialog },
   data() {
     return {
       loading: true,
@@ -254,6 +267,7 @@ export default {
       dialogTitle: '',
       dialogVisible: false,
       detailVisible: false,
+      importVisible: false,
       detailData: {},
       recycleVisible: false,
       recycleLoading: false,

+ 14 - 0
src/views/subSystem/basic/waterPlant/index.vue

@@ -53,6 +53,9 @@
       <el-col :span="1.5">
         <el-button type="success" plain icon="Download" size="small" @click="handleExport" v-hasPermi="['waterSupply:waterPlant:export']">导出</el-button>
       </el-col>
+      <el-col :span="1.5">
+        <el-button type="info" plain icon="Upload" size="small" @click="importVisible = true" v-hasPermi="['waterSupply:waterPlant:import']">导入</el-button>
+      </el-col>
       <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
     </el-row>
 
@@ -62,6 +65,8 @@
       <el-table-column label="水厂编号" align="center" prop="plantCode" :show-overflow-tooltip="true" min-width="120" />
       <el-table-column label="水厂名称" align="center" prop="plantName" :show-overflow-tooltip="true" min-width="140" />
       <el-table-column label="水厂地址" align="center" prop="location" :show-overflow-tooltip="true" min-width="160" />
+      <el-table-column label="经度" align="center" prop="longitude" width="120" />
+      <el-table-column label="纬度" align="center" prop="latitude" width="120" />
       <el-table-column label="水源类型" align="center" prop="waterSourceType" width="100" />
       <el-table-column label="设计能力(万m³/d)" align="center" prop="designCapacity" width="150" />
       <el-table-column label="实际能力(万m³/d)" align="center" prop="currentCapacity" width="150" />
@@ -109,6 +114,8 @@
               <el-input v-model="form.location" placeholder="请输入水厂地址" maxlength="200" />
             </el-form-item>
           </el-col>
+          <el-col :span="12"><el-form-item label="经度" prop="longitude"><el-input-number v-model="form.longitude" :precision="8" :step="0.00000001" controls-position="right" style="width: 100%" /></el-form-item></el-col>
+          <el-col :span="12"><el-form-item label="纬度" prop="latitude"><el-input-number v-model="form.latitude" :precision="8" :step="0.00000001" controls-position="right" style="width: 100%" /></el-form-item></el-col>
           <el-col :span="12">
             <el-form-item label="水源类型" prop="waterSourceType">
               <el-input v-model="form.waterSourceType" placeholder="请输入水源类型" maxlength="50" />
@@ -176,6 +183,8 @@
         <el-descriptions-item label="水厂编号">{{ detailData.plantCode }}</el-descriptions-item>
         <el-descriptions-item label="水厂名称">{{ detailData.plantName }}</el-descriptions-item>
         <el-descriptions-item label="水厂地址" :span="2">{{ detailData.location }}</el-descriptions-item>
+        <el-descriptions-item label="经度">{{ detailData.longitude }}</el-descriptions-item>
+        <el-descriptions-item label="纬度">{{ detailData.latitude }}</el-descriptions-item>
         <el-descriptions-item label="水源类型">{{ detailData.waterSourceType }}</el-descriptions-item>
         <el-descriptions-item label="设计能力(万m³/d)">{{ detailData.designCapacity }}</el-descriptions-item>
         <el-descriptions-item label="实际能力(万m³/d)">{{ detailData.currentCapacity }}</el-descriptions-item>
@@ -198,6 +207,8 @@
       </template>
     </el-drawer>
 
+    <WaterFacilityImportDialog v-model="importVisible" title="水厂信息" upload-url="/api/water/plantInfo/importData" template-url="/api/water/plantInfo/importTemplate" @imported="getList" />
+
     <!-- 回收站对话框 -->
     <el-dialog title="回收站" v-model="recycleVisible" width="900px" append-to-body>
       <el-form :model="recycleQuery" ref="recycleForm" :inline="true" size="small">
@@ -243,9 +254,11 @@ import { listWaterPlant, getWaterPlant, addWaterPlant, updateWaterPlant, delWate
 import { saveAs } from 'file-saver'
 import { exportWaterPlant } from '@/api/pipeNetwork/waterSupply'
 import { buildWaterFacilityQuery, getRequestErrorMessage } from '@/utils/waterFacilityQuery'
+import WaterFacilityImportDialog from '@/views/subSystem/waterSupply/components/WaterFacilityImportDialog.vue'
 
 export default {
   name: 'WaterPlant',
+  components: { WaterFacilityImportDialog },
   data() {
     return {
       loading: true,
@@ -259,6 +272,7 @@ export default {
       dialogTitle: '',
       dialogVisible: false,
       detailVisible: false,
+      importVisible: false,
       detailData: {},
       recycleVisible: false,
       recycleLoading: false,

+ 14 - 0
src/views/subSystem/basic/waterSource/index.vue

@@ -53,6 +53,9 @@
       <el-col :span="1.5">
         <el-button type="success" plain icon="Download" size="small" @click="handleExport" v-hasPermi="['waterSupply:waterSource:export']">导出</el-button>
       </el-col>
+      <el-col :span="1.5">
+        <el-button type="info" plain icon="Upload" size="small" @click="importVisible = true" v-hasPermi="['waterSupply:waterSource:import']">导入</el-button>
+      </el-col>
       <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
     </el-row>
 
@@ -64,6 +67,8 @@
       <el-table-column label="水源地类型" align="center" prop="sourceType" width="100" />
       <el-table-column label="所属单位" align="center" prop="unit" :show-overflow-tooltip="true" min-width="120" />
       <el-table-column label="详细地址" align="center" prop="address" :show-overflow-tooltip="true" min-width="160" />
+      <el-table-column label="经度" align="center" prop="longitude" width="120" />
+      <el-table-column label="纬度" align="center" prop="latitude" width="120" />
       <el-table-column label="水源类型" align="center" prop="waterType" width="100" />
       <el-table-column label="保护等级" align="center" prop="protectionLevel" width="100" />
       <el-table-column label="水质等级" align="center" prop="waterQualityLevel" width="100" />
@@ -158,6 +163,8 @@
               </el-radio-group>
             </el-form-item>
           </el-col>
+          <el-col :span="12"><el-form-item label="经度" prop="longitude"><el-input-number v-model="form.longitude" :precision="8" :step="0.00000001" controls-position="right" style="width: 100%" /></el-form-item></el-col>
+          <el-col :span="12"><el-form-item label="纬度" prop="latitude"><el-input-number v-model="form.latitude" :precision="8" :step="0.00000001" controls-position="right" style="width: 100%" /></el-form-item></el-col>
           <el-col :span="24">
             <el-form-item label="保护范围" prop="protectionScope">
               <el-input v-model="form.protectionScope" placeholder="请输入保护范围描述" maxlength="500" />
@@ -192,6 +199,8 @@
         <el-descriptions-item label="状态">
           <el-tag :type="statusTagType(detailData.status)" size="small">{{ statusLabel(detailData.status) }}</el-tag>
         </el-descriptions-item>
+        <el-descriptions-item label="经度">{{ detailData.longitude }}</el-descriptions-item>
+        <el-descriptions-item label="纬度">{{ detailData.latitude }}</el-descriptions-item>
         <el-descriptions-item label="备注" :span="2">{{ detailData.remark }}</el-descriptions-item>
         <el-descriptions-item label="创建者">{{ detailData.createBy }}</el-descriptions-item>
         <el-descriptions-item label="创建时间">{{ detailData.createTime }}</el-descriptions-item>
@@ -203,6 +212,8 @@
       </template>
     </el-drawer>
 
+    <WaterFacilityImportDialog v-model="importVisible" title="水源地信息" upload-url="/api/water/sourceInfo/importData" template-url="/api/water/sourceInfo/importTemplate" @imported="getList" />
+
     <!-- 回收站对话框 -->
     <el-dialog title="回收站" v-model="recycleVisible" width="900px" append-to-body>
       <el-form :model="recycleQuery" ref="recycleForm" :inline="true" size="small">
@@ -248,9 +259,11 @@ import { listWaterSource, getWaterSource, addWaterSource, updateWaterSource, del
 import { saveAs } from 'file-saver'
 import { exportWaterSource } from '@/api/pipeNetwork/waterSupply'
 import { buildWaterFacilityQuery, getRequestErrorMessage } from '@/utils/waterFacilityQuery'
+import WaterFacilityImportDialog from '@/views/subSystem/waterSupply/components/WaterFacilityImportDialog.vue'
 
 export default {
   name: 'WaterSource',
+  components: { WaterFacilityImportDialog },
   data() {
     return {
       loading: true,
@@ -264,6 +277,7 @@ export default {
       dialogTitle: '',
       dialogVisible: false,
       detailVisible: false,
+      importVisible: false,
       detailData: {},
       recycleVisible: false,
       recycleLoading: false,

+ 14 - 0
src/views/subSystem/basic/waterUserInfo/index.vue

@@ -59,6 +59,9 @@
       <el-col :span="1.5">
         <el-button type="success" plain icon="Download" size="small" @click="handleExport" v-hasPermi="['waterSupply:waterUser:export']">导出</el-button>
       </el-col>
+      <el-col :span="1.5">
+        <el-button type="info" plain icon="Upload" size="small" @click="importVisible = true" v-hasPermi="['waterSupply:waterUser:import']">导入</el-button>
+      </el-col>
       <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
     </el-row>
 
@@ -69,6 +72,8 @@
       <el-table-column label="用水户名称" align="center" prop="userName" :show-overflow-tooltip="true" min-width="140" />
       <el-table-column label="用水户类型" align="center" prop="userType" width="100" />
       <el-table-column label="用水地址" align="center" prop="address" :show-overflow-tooltip="true" min-width="160" />
+      <el-table-column label="经度" align="center" prop="longitude" width="120" />
+      <el-table-column label="纬度" align="center" prop="latitude" width="120" />
       <el-table-column label="联系人" align="center" prop="contactPerson" width="100" />
       <el-table-column label="联系电话" align="center" prop="contactPhone" width="120" />
       <el-table-column label="水表编号" align="center" prop="meterNumber" width="120" />
@@ -133,6 +138,8 @@
               <el-input v-model="form.address" placeholder="请输入用水地址" maxlength="200" />
             </el-form-item>
           </el-col>
+          <el-col :span="12"><el-form-item label="经度" prop="longitude"><el-input-number v-model="form.longitude" :precision="8" :step="0.00000001" controls-position="right" style="width: 100%" /></el-form-item></el-col>
+          <el-col :span="12"><el-form-item label="纬度" prop="latitude"><el-input-number v-model="form.latitude" :precision="8" :step="0.00000001" controls-position="right" style="width: 100%" /></el-form-item></el-col>
           <el-col :span="12">
             <el-form-item label="联系人" prop="contactPerson">
               <el-input v-model="form.contactPerson" placeholder="请输入联系人" maxlength="50" />
@@ -186,6 +193,8 @@
           <el-tag :type="statusTagType(detailData.status)" size="small">{{ statusLabel(detailData.status) }}</el-tag>
         </el-descriptions-item>
         <el-descriptions-item label="用水地址" :span="2">{{ detailData.address }}</el-descriptions-item>
+        <el-descriptions-item label="经度">{{ detailData.longitude }}</el-descriptions-item>
+        <el-descriptions-item label="纬度">{{ detailData.latitude }}</el-descriptions-item>
         <el-descriptions-item label="联系人">{{ detailData.contactPerson }}</el-descriptions-item>
         <el-descriptions-item label="联系电话">{{ detailData.contactPhone }}</el-descriptions-item>
         <el-descriptions-item label="水表编号">{{ detailData.meterNumber }}</el-descriptions-item>
@@ -203,6 +212,8 @@
       </template>
     </el-drawer>
 
+    <WaterFacilityImportDialog v-model="importVisible" title="用水户信息" upload-url="/api/water/userInfo/importData" template-url="/api/water/userInfo/importTemplate" @imported="getList" />
+
     <!-- 回收站对话框 -->
     <el-dialog title="回收站" v-model="recycleVisible" width="900px" append-to-body>
       <el-form :model="recycleQuery" ref="recycleForm" :inline="true" size="small">
@@ -249,9 +260,11 @@ import { listWaterUser, getWaterUser, addWaterUser, updateWaterUser, delWaterUse
 import { saveAs } from 'file-saver'
 import { exportWaterUser } from '@/api/pipeNetwork/waterSupply'
 import { buildWaterFacilityQuery, getRequestErrorMessage } from '@/utils/waterFacilityQuery'
+import WaterFacilityImportDialog from '@/views/subSystem/waterSupply/components/WaterFacilityImportDialog.vue'
 
 export default {
   name: 'WaterUser',
+  components: { WaterFacilityImportDialog },
   data() {
     return {
       loading: true,
@@ -265,6 +278,7 @@ export default {
       dialogTitle: '',
       dialogVisible: false,
       detailVisible: false,
+      importVisible: false,
       detailData: {},
       recycleVisible: false,
       recycleLoading: false,

Разлика између датотеке није приказан због своје велике величине
+ 251 - 18
src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue


+ 85 - 0
src/views/subSystem/waterSupply/components/WaterFacilityImportDialog.vue

@@ -0,0 +1,85 @@
+<template>
+  <el-dialog v-model="visible" :title="`${title}批量导入`" width="460px" append-to-body @closed="reset">
+    <el-upload
+      ref="uploadRef"
+      drag
+      :limit="1"
+      accept=".xlsx, .xls"
+      :headers="headers"
+      :action="actionUrl"
+      :disabled="isUploading"
+      :auto-upload="false"
+      :before-upload="validateFile"
+      :on-progress="onProgress"
+      :on-success="onSuccess"
+      :on-error="onError"
+    >
+      <el-icon class="el-icon--upload"><upload-filled /></el-icon>
+      <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
+      <template #tip>
+        <div class="el-upload__tip">
+          <el-checkbox v-model="updateSupport">更新已存在的业务编码</el-checkbox>
+          <el-link type="primary" :underline="false" @click="downloadTemplate">下载模板</el-link>
+          <div>仅支持 xls、xlsx,业务编码为空的行将被跳过。</div>
+        </div>
+      </template>
+    </el-upload>
+    <template #footer>
+      <el-button type="primary" :loading="isUploading" @click="submit">开始导入</el-button>
+      <el-button @click="visible = false">取消</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import { getToken } from '@/utils/auth'
+import { download } from '@/utils/request'
+import { ElMessage } from 'element-plus'
+
+const props = defineProps({
+  modelValue: { type: Boolean, default: false },
+  title: { type: String, required: true },
+  uploadUrl: { type: String, required: true },
+  templateUrl: { type: String, required: true }
+})
+const emit = defineEmits(['update:modelValue', 'imported'])
+const uploadRef = ref()
+const updateSupport = ref(false)
+const isUploading = ref(false)
+const headers = { Authorization: `Bearer ${getToken()}` }
+const actionUrl = computed(() => `${import.meta.env.VITE_APP_BASE_API}${props.uploadUrl}?updateSupport=${updateSupport.value}`)
+const visible = computed({ get: () => props.modelValue, set: value => emit('update:modelValue', value) })
+
+function submit() { uploadRef.value?.submit() }
+function validateFile(file) {
+  if (!/\.(xlsx|xls)$/iu.test(file?.name || '')) {
+    ElMessage.error('仅支持 xls、xlsx 文件')
+    return false
+  }
+  return true
+}
+function onProgress() { isUploading.value = true }
+function onSuccess(response) {
+  isUploading.value = false
+  if (response?.code && response.code !== 200) {
+    ElMessage.error(response.msg || '导入失败,请检查文件格式')
+    return
+  }
+  ElMessage.success(response?.msg || '导入成功')
+  emit('imported')
+  visible.value = false
+}
+function onError(error) {
+  isUploading.value = false
+  ElMessage.error(error?.message || '导入失败,请检查文件格式')
+}
+function downloadTemplate() {
+  download(props.templateUrl, {}, `${props.title}导入模板.xlsx`)
+}
+function reset() {
+  updateSupport.value = false
+  isUploading.value = false
+  uploadRef.value?.clearFiles()
+}
+</script>

+ 92 - 261
src/views/subSystem/waterSupply/facility/gis.vue

@@ -74,25 +74,6 @@
           </div>
         </el-card>
 
-        <el-card shadow="never" class="panel-card detail-card">
-          <template #header>
-            <span>要素详情</span>
-          </template>
-          <div v-if="selectedFeature" class="detail-body">
-            <div class="detail-name">{{ selectedFeature.name }}</div>
-            <div class="detail-tag">{{ selectedFeature.category }}</div>
-            <el-descriptions :column="1" border size="small">
-              <el-descriptions-item
-                v-for="item in selectedFeatureDetails"
-                :key="item.label"
-                :label="item.label"
-              >
-                {{ item.value }}
-              </el-descriptions-item>
-            </el-descriptions>
-          </div>
-          <el-empty v-else description="点击地图要素查看详情" :image-size="72" />
-        </el-card>
       </aside>
 
       <section class="dashboard-map-panel">
@@ -114,9 +95,36 @@
           <div class="toolbar-right">当前坐标: {{ currentCoordinate }}</div>
         </div>
         <div id="gisMap" ref="mapRef" class="gis-map"></div>
-        <div v-if="mapError" class="map-error">{{ mapError }}</div>
+        <div v-if="mapError" class="map-error">
+          <el-result icon="warning" title="供水设施图层加载失败" :sub-title="mapError">
+            <template #extra><el-button type="primary" @click="loadGisData">重试</el-button></template>
+          </el-result>
+        </div>
+        <div v-else-if="mapEmpty" class="map-error">
+          <el-empty description="当前筛选下没有可显示的供水设施" />
+        </div>
       </section>
     </div>
+
+    <el-drawer
+      v-model="detailOpen"
+      :title="detailTitle"
+      direction="rtl"
+      class="detail-drawer"
+      size="560px"
+      append-to-body
+    >
+      <el-skeleton v-if="detailLoading" :rows="8" animated />
+      <el-result v-else-if="detailError" icon="warning" title="设施详情加载失败" :sub-title="detailError">
+        <template #extra><el-button type="primary" @click="loadFacilityDetail">重试</el-button></template>
+      </el-result>
+      <el-descriptions v-else-if="detailRecord" :column="1" border>
+        <el-descriptions-item v-for="item in detailEntries" :key="item.label" :label="item.label">
+          {{ item.value }}
+        </el-descriptions-item>
+      </el-descriptions>
+      <el-empty v-else description="暂无设施详情" />
+    </el-drawer>
   </div>
 </template>
 
@@ -127,6 +135,8 @@ import { ElMessage } from 'element-plus'
 import { convertCoord } from '@/utils/coordTransform'
 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'
 
 const centerPoint = { lng: 110.386, lat: 28.445 }
 
@@ -174,15 +184,19 @@ const mapRef = ref(null)
 const screenRef = ref(null)
 const searchKeyword = ref('')
 const currentCoordinate = ref(`${centerPoint.lng}, ${centerPoint.lat}`)
-const selectedFeature = ref(null)
 const mapError = ref('')
+const dataLoaded = ref(false)
 const layerState = reactive(defaultLayerState())
 const facilityOverview = ref([])
 const pipelineData = ref([])
 const pointData = ref([])
+const detailOpen = ref(false)
+const detailLoading = ref(false)
+const detailError = ref('')
+const detailRecord = ref(null)
+const detailTarget = ref(null)
 
 let mapInstance = null
-let activeInfoWindow = null
 let overlayClicked = false
 
 const typeCount = type => {
@@ -198,34 +212,13 @@ const summaryStats = computed(() => ({
   userCount: typeCount('user')
 }))
 
-const selectedFeatureDetails = computed(() => {
-  if (!selectedFeature.value) return []
-  return Object.entries(selectedFeature.value.details).map(([label, value]) => ({ label, value }))
-})
-
-const searchableFeatures = computed(() => {
-  const pipelines = pipelineData.value.map(item => ({
-    kind: 'pipeline',
-    type: item.type,
-    name: item.name,
-    code: item.code,
-    road: item.road,
-    lng: getMidPoint(item.points).lng,
-    lat: getMidPoint(item.points).lat,
-    raw: item
-  }))
-  const points = pointData.value.map(item => ({
-    kind: 'point',
-    type: item.type,
-    name: item.name,
-    code: item.code,
-    road: item.road,
-    lng: item.lng,
-    lat: item.lat,
-    raw: item
-  }))
-  return [...pipelines, ...points]
-})
+const fullMapModel = computed(() => ({ points: pointData.value, lines: pipelineData.value }))
+const visibleMapModel = computed(() => filterFacilityMapModel(fullMapModel.value, layerState))
+const mapEmpty = computed(() => dataLoaded.value && !mapError.value && visibleMapModel.value.isEmpty)
+const detailTitle = computed(() => `${labelMap[detailTarget.value?.type] || '供水设施'}详情`)
+const detailEntries = computed(() => Object.entries(detailRecord.value || {})
+  .filter(([, value]) => value !== null && value !== undefined && typeof value !== 'object')
+  .map(([label, value]) => ({ label, value: value === '' ? '-' : value })))
 
 watch(layerState, () => {
   renderMapScene()
@@ -318,8 +311,7 @@ function initMap() {
       overlayClicked = false
       return
     }
-    closeInfoWindow()
-    selectedFeature.value = null
+    detailOpen.value = false
   })
 
   mapInstance.addEventListener('mousemove', (event) => {
@@ -341,7 +333,6 @@ function initMap() {
 function renderMapScene() {
   if (!mapInstance || typeof BMapGL === 'undefined') return
 
-  closeInfoWindow()
   try { mapInstance.clearOverlays() } catch (_) { /* ignore */ }
   try { drawPipelines() } catch (e) { console.warn('绘制供水管网失败:', e) }
   try { drawPoints() } catch (e) { console.warn('绘制供水设施失败:', e) }
@@ -357,13 +348,11 @@ function drawPipelines() {
         strokeWeight: 6,
         strokeOpacity: 0.92
       })
-      const firstPt = { lng: convertCoord(item.points[0].lng, item.points[0].lat)[0], lat: convertCoord(item.points[0].lng, item.points[0].lat)[1] }
       try {
         polyline.addEventListener('click', (e) => {
           overlayClicked = true
           e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
-          showPopup(firstPt.lng, firstPt.lat, buildPopupHTML(buildPipelineDetail(item)))
-          selectedFeature.value = buildPipelineDetail(item)
+          openFacilityDetail(item)
         })
       } catch (_) { /* polyline click listener failed */ }
       mapInstance.addOverlay(polyline)
@@ -383,8 +372,7 @@ function drawPoints() {
       if (!layerState[item.type]) return
       const [plng, plat] = convertCoord(item.lng, item.lat)
       addPointMarker(plng, plat, colorMap[item.type], item.name, 'dot', () => {
-        showPopup(plng, plat, buildPopupHTML(buildPointDetail(item)))
-        selectedFeature.value = buildPointDetail(item)
+        openFacilityDetail(item)
       })
     } catch (e) {
       console.warn(`绘制供水设施 ${item.name} 失败:`, e)
@@ -404,8 +392,7 @@ function addPipeLabel(lng, lat, text, color, rawItem) {
     label.addEventListener('click', (e) => {
       overlayClicked = true
       e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
-      showPopup(lng, lat, buildPopupHTML(buildPipelineDetail(rawItem)))
-      selectedFeature.value = buildPipelineDetail(rawItem)
+      openFacilityDetail(rawItem)
     })
   } catch (_) { /* label click listener failed */ }
   mapInstance.addOverlay(label)
@@ -434,80 +421,18 @@ function addPointMarker(lng, lat, color, name, shape, onClick) {
   mapInstance.addOverlay(label)
 }
 
-function showPopup(lng, lat, html) {
-  try {
-    closeInfoWindow()
-    const point = new BMapGL.Point(lng, lat)
-    const infoWindow = new BMapGL.InfoWindow(html, {
-      width: 310,
-      title: '',
-      enableMessage: false
-    })
-    activeInfoWindow = infoWindow
-    mapInstance.openInfoWindow(infoWindow, point)
-  } catch (e) {
-    console.warn('弹出信息窗口失败:', e)
-  }
-}
-
-function closeInfoWindow() {
-  if (activeInfoWindow && mapInstance) {
-    mapInstance.closeInfoWindow()
-    activeInfoWindow = null
-  }
-}
-
-function buildPopupHTML(detail) {
-  const rows = Object.entries(detail.details)
-    .map(([k, v]) => `<div class="popup-row"><span class="popup-label">${k}</span><span class="popup-value">${v}</span></div>`)
-    .join('')
-  return `<div class="info-popup">
-    <div class="popup-header">${detail.name}</div>
-    <div class="popup-tag">${detail.category}</div>
-    <div class="popup-divider"></div>
-    ${rows}
-  </div>`
-}
-
-function buildPipelineDetail(item) {
+function getMidPoint(points) {
+  const first = points[0]
+  const last = points[points.length - 1]
   return {
-    name: item.name,
-    category: labelMap[item.type] || '供水管网',
-    details: {
-      '管网编号': item.code || '-',
-      '管网名称': item.name,
-      '管径': item.diameter || '-',
-      '材质': item.material || '-',
-      '起点位置': item.startPoint || '-',
-      '终点位置': item.endPoint || '-',
-      '铺设年份': item.layingYear || '-',
-      '长度': item.length || '-',
-      '运行状态': item.status || '-'
-    }
+    lng: (first.lng + last.lng) / 2,
+    lat: (first.lat + last.lat) / 2
   }
 }
 
-function buildPointDetail(item) {
-  return {
-    name: item.name,
-    category: labelMap[item.type] || '供水设施',
-    details: {
-      '设施编号': item.code || '-',
-      '设施名称': item.name,
-      '所属类型': labelMap[item.type] || '-',
-      '地址': item.address || item.location || '-',
-      '状态': item.status || '-',
-      '经度': item.lng.toFixed(5),
-      '纬度': item.lat.toFixed(5)
-    }
-  }
-}
-
-function getMidPoint(points) {
-  return points[Math.floor(points.length / 2)]
-}
-
 async function loadGisData() {
+  mapError.value = ''
+  dataLoaded.value = false
   try {
     const [overviewRes, featureRes] = await Promise.all([
       getWaterFacilityOverview(),
@@ -516,49 +441,18 @@ async function loadGisData() {
     const overviewData = unwrapResponse(overviewRes)
     facilityOverview.value = Array.isArray(overviewData) ? overviewData : (overviewData?.facilityOverview || [])
     const geoJson = normalizeGeoJson(featureRes)
-    const lines = []
-    const points = []
-    ;(geoJson.features || []).forEach(feature => {
-      const coordinates = feature.geometry?.coordinates || []
-      const properties = feature.properties || {}
-      const type = properties.facilityType || properties.type || ''
-      if (feature.geometry?.type === 'LineString' && coordinates.length > 1) {
-        lines.push({
-          id: feature.id || properties.id,
-          type: type || 'pipe',
-          name: properties.pipeName || properties.name || properties.facilityTypeName || '供水管网',
-          code: properties.pipeCode || properties.code || '-',
-          material: properties.pipeMaterial,
-          diameter: properties.pipeDiameter,
-          startPoint: properties.startPoint,
-          endPoint: properties.endPoint,
-          layingYear: properties.layingYear,
-          length: properties.pipeLength,
-          status: properties.status,
-          road: properties.road || properties.address || '',
-          points: coordinates.map(([lng, lat]) => ({ lng, lat }))
-        })
-      }
-      if (feature.geometry?.type === 'Point' && coordinates.length >= 2) {
-        points.push({
-          id: feature.id || properties.id,
-          type: type,
-          name: properties.facilityName || properties.name || properties.pipeName || properties.facilityTypeName || '供水设施',
-          code: properties.facilityCode || properties.code || '-',
-          address: properties.address || properties.location || '',
-          status: properties.status,
-          road: properties.address || properties.location || '',
-          lng: coordinates[0],
-          lat: coordinates[1]
-        })
-      }
-    })
-    pipelineData.value = lines
-    pointData.value = points
+    const model = buildFacilityMapModel(geoJson)
+    pipelineData.value = model.lines
+    pointData.value = model.points
+    dataLoaded.value = true
     if (mapInstance && typeof BMapGL !== 'undefined') renderMapScene()
   } catch (e) {
     console.error('加载供水GIS数据失败', e)
-    ElMessage.warning('供水GIS数据加载失败')
+    facilityOverview.value = []
+    pipelineData.value = []
+    pointData.value = []
+    dataLoaded.value = true
+    mapError.value = getRequestErrorMessage(e, '供水GIS数据加载失败,请重试。')
   }
 }
 
@@ -569,9 +463,7 @@ function handleSearch() {
     return
   }
 
-  const matched = searchableFeatures.value.find(item =>
-    [item.name, item.code, item.road].some(text => text?.includes(keyword))
-  )
+  const matched = findFacilityFeature(visibleMapModel.value, keyword)
 
   if (!matched) {
     ElMessage.info('未找到匹配要素')
@@ -579,19 +471,38 @@ function handleSearch() {
   }
 
   const [mlng, mlat] = convertCoord(matched.lng, matched.lat)
-  mapInstance.centerAndZoom(new BMapGL.Point(mlng, mlat), 18)
-
-  if (matched.kind === 'pipeline') {
-    selectedFeature.value = buildPipelineDetail(matched.raw)
-    showPopup(mlng, mlat, buildPopupHTML(selectedFeature.value))
-  } else {
-    selectedFeature.value = buildPointDetail(matched.raw)
-    showPopup(mlng, mlat, buildPopupHTML(selectedFeature.value))
+  if (mapInstance && typeof BMapGL !== 'undefined') {
+    mapInstance.centerAndZoom(new BMapGL.Point(mlng, mlat), 18)
   }
 
+  openFacilityDetail(matched)
+
   ElMessage.success(`已定位到 ${matched.name}`)
 }
 
+async function openFacilityDetail(feature) {
+  detailTarget.value = feature
+  detailOpen.value = true
+  detailRecord.value = null
+  detailError.value = ''
+  await loadFacilityDetail()
+}
+
+async function loadFacilityDetail() {
+  const feature = detailTarget.value
+  if (!feature?.type || feature.id === null || feature.id === undefined) return
+  detailLoading.value = true
+  detailError.value = ''
+  try {
+    detailRecord.value = unwrapResponse(await getWaterFacilityDetail(feature.type, feature.id))
+  } catch (error) {
+    detailRecord.value = null
+    detailError.value = getRequestErrorMessage(error, '设施详情查询失败,请重试。')
+  } finally {
+    detailLoading.value = false
+  }
+}
+
 async function refreshScene() {
   layerState.source = true
   layerState.plant = true
@@ -599,8 +510,7 @@ async function refreshScene() {
   layerState.pipe = true
   layerState.user = true
   searchKeyword.value = ''
-  selectedFeature.value = null
-  closeInfoWindow()
+  detailOpen.value = false
   await loadGisData()
   locateCenter()
 }
@@ -839,29 +749,6 @@ function handleFullscreenChange() {
   min-height: 280px;
 }
 
-.detail-body {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-}
-
-.detail-name {
-  color: #1f2d3d;
-  font-size: 16px;
-  font-weight: 700;
-}
-
-.detail-tag {
-  display: inline-flex;
-  align-items: center;
-  width: fit-content;
-  padding: 3px 10px;
-  color: #2d6cdf;
-  background: #edf4ff;
-  border-radius: 999px;
-  font-size: 12px;
-}
-
 .dashboard-map-panel {
   position: relative;
   flex: 1;
@@ -944,62 +831,6 @@ function handleFullscreenChange() {
   text-shadow: 0 0 3px #fff;
 }
 
-.info-popup {
-  padding: 4px 2px;
-  font-size: 13px;
-  color: #333;
-}
-
-.popup-header {
-  font-size: 15px;
-  font-weight: 700;
-  color: #1a1a2e;
-  margin-bottom: 6px;
-}
-
-.popup-tag {
-  display: inline-block;
-  padding: 2px 10px;
-  font-size: 11px;
-  color: #1d4ed8;
-  background: #eff6ff;
-  border-radius: 999px;
-  margin-bottom: 10px;
-}
-
-.popup-divider {
-  height: 1px;
-  background: #e5e7eb;
-  margin-bottom: 10px;
-}
-
-.popup-row {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  padding: 5px 0;
-  border-bottom: 1px dashed #f0f0f0;
-  font-size: 12px;
-  line-height: 1.5;
-}
-
-.popup-row:last-child {
-  border-bottom: none;
-}
-
-.popup-label {
-  color: #6b7280;
-  flex-shrink: 0;
-  margin-right: 12px;
-}
-
-.popup-value {
-  color: #1f2937;
-  font-weight: 500;
-  text-align: right;
-  word-break: break-all;
-}
-
 .BMap_cpyCtrl,
 .anchorBL {
   display: none;

+ 72 - 0
tests/waterFacilityGis.test.mjs

@@ -0,0 +1,72 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+
+import {
+  buildFacilityMapModel,
+  filterFacilityMapModel,
+  findFacilityFeature
+} from '../src/utils/waterFacilityGisModel.js'
+
+const collection = {
+  type: 'FeatureCollection',
+  features: [
+    {
+      type: 'Feature',
+      id: 'plant:1',
+      geometry: { type: 'Point', coordinates: [113.1, 23.1] },
+      properties: { facilityType: 'plant', id: 1, code: 'WP-1', name: '城北水厂', location: '城北路' }
+    },
+    {
+      type: 'Feature',
+      id: 'pipe:2',
+      geometry: { type: 'LineString', coordinates: [[113.2, 23.2], [113.4, 23.4]] },
+      properties: { facilityType: 'pipe', id: 2, code: 'PIPE-2', name: '输水干线', location: '滨江路' }
+    },
+    {
+      type: 'Feature',
+      id: 'user:3',
+      geometry: { type: 'Point', coordinates: [113.5, null] },
+      properties: { facilityType: 'user', id: 3, code: 'USER-3', name: '缺坐标用水户' }
+    }
+  ]
+}
+
+test('facility map model keeps WGS84 coordinate order and omits incomplete features', () => {
+  const model = buildFacilityMapModel(collection)
+
+  assert.deepEqual(model.points.map(item => [item.lng, item.lat]), [[113.1, 23.1]])
+  assert.deepEqual(model.lines[0].points.map(item => [item.lng, item.lat]), [[113.2, 23.2], [113.4, 23.4]])
+})
+
+test('facility type filters produce an observable empty layer state', () => {
+  const model = buildFacilityMapModel(collection)
+  const visible = filterFacilityMapModel(model, { plant: false, pipe: false, user: true })
+
+  assert.equal(visible.points.length, 0)
+  assert.equal(visible.lines.length, 0)
+  assert.equal(visible.isEmpty, true)
+})
+
+test('facility search locates points and line midpoints by name, code, or address', () => {
+  const model = buildFacilityMapModel(collection)
+
+  assert.deepEqual(findFacilityFeature(model, 'WP-1'), {
+    ...model.points[0],
+    kind: 'point'
+  })
+  assert.deepEqual(
+    [findFacilityFeature(model, '滨江路').lng, findFacilityFeature(model, '滨江路').lat],
+    [113.3, 23.3]
+  )
+})
+
+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')
+
+  assert.match(source, /<el-drawer[\s\S]*?direction="rtl"[\s\S]*?class="detail-drawer"/u)
+  assert.match(source, /getWaterFacilityDetail\(feature\.type, feature\.id\)/u)
+  assert.match(source, /detailLoading/u)
+  assert.match(source, /detailError/u)
+  assert.match(source, /当前筛选下没有可显示的供水设施/u)
+})

+ 58 - 0
tests/waterFacilityImportCoordinates.test.mjs

@@ -0,0 +1,58 @@
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import test from 'node:test'
+
+const facilities = [
+  {
+    path: 'src/views/subSystem/basic/waterSource/index.vue',
+    fields: ['longitude', 'latitude']
+  },
+  {
+    path: 'src/views/subSystem/basic/waterPlant/index.vue',
+    fields: ['longitude', 'latitude']
+  },
+  {
+    path: 'src/views/subSystem/basic/pumpStation/index.vue',
+    fields: ['longitude', 'latitude']
+  },
+  {
+    path: 'src/views/subSystem/basic/pipeInfo/index.vue',
+    fields: ['startLongitude', 'startLatitude', 'endLongitude', 'endLatitude']
+  },
+  {
+    path: 'src/views/subSystem/basic/waterUserInfo/index.vue',
+    fields: ['longitude', 'latitude']
+  }
+]
+
+test('all T1 facility pages expose Excel import through the shared dialog', () => {
+  for (const facility of facilities) {
+    const source = fs.readFileSync(facility.path, 'utf8')
+    assert.match(source, /WaterFacilityImportDialog/u, facility.path)
+    assert.match(source, />导入<\/el-button>/u, facility.path)
+    assert.match(source, /@imported="getList"/u, facility.path)
+  }
+
+  const dialog = fs.readFileSync('src/views/subSystem/waterSupply/components/WaterFacilityImportDialog.vue', 'utf8')
+  assert.match(dialog, /accept="\.xlsx, \.xls"/u)
+  assert.match(dialog, /:before-upload="validateFile"/u)
+  assert.match(dialog, /function validateFile/u)
+  assert.match(dialog, /\(xlsx\|xls\)/u)
+  assert.match(dialog, /updateSupport/u)
+  assert.match(dialog, /下载模板/u)
+  assert.match(dialog, /VITE_APP_BASE_API/u)
+  assert.match(dialog, /download\(props\.templateUrl/u)
+  assert.match(dialog, /response\.code !== 200/u)
+})
+
+test('all T1 facility pages expose coordinates in list, edit drawer, and detail drawer', () => {
+  for (const facility of facilities) {
+    const source = fs.readFileSync(facility.path, 'utf8')
+    for (const field of facility.fields) {
+      const occurrences = source.match(new RegExp(field, 'gu')) || []
+      assert.ok(occurrences.length >= 3, `${facility.path} must expose ${field} for list, form, and detail`)
+      assert.match(source, new RegExp(`v-model="form\\.${field}"`, 'u'), `${facility.path} must edit ${field}`)
+      assert.match(source, new RegExp(`detailData\\.${field}`, 'u'), `${facility.path} must show ${field} in details`)
+    }
+  }
+})

+ 200 - 0
tests/waterFacilityStatistics.test.mjs

@@ -0,0 +1,200 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+
+import {
+  buildPipeNetworkStatisticsQuery,
+  FACILITY_SECTION_LABELS,
+  FACILITY_SECTION_TYPES,
+  FACILITY_STATUS_TAG_TYPES,
+  getFacilityStatusOptions,
+  resolveFacilityStatusLabel
+} from '../src/utils/waterFacilityStatisticsModel.js'
+
+test('statistics query mirrors dashboard and export filters', () => {
+  assert.deepEqual(
+    buildPipeNetworkStatisticsQuery({
+      facilityTypes: ['plant', 'pipe', ''],
+      status: '1'
+    }),
+    { facilityTypes: 'plant,pipe', status: '1' }
+  )
+})
+
+test('statistics status filter returns to the all-state contract', () => {
+  assert.equal(buildPipeNetworkStatisticsQuery({ facilityTypes: ['pipe'], status: '' }).status, undefined)
+  assert.equal(buildPipeNetworkStatisticsQuery({ facilityTypes: ['pipe'], status: null }).status, undefined)
+})
+
+test('statistics query omits empty filters instead of requesting empty datasets', () => {
+  assert.deepEqual(buildPipeNetworkStatisticsQuery(), {})
+  assert.deepEqual(buildPipeNetworkStatisticsQuery({ facilityTypes: ['', ' '], status: '' }), {})
+})
+
+test('statistics page prevents empty facility-type requests and export', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    'utf8'
+  )
+
+  assert.match(source, /buildPipeNetworkStatisticsQuery/u)
+  assert.match(source, /:disabled="!selectedTypes\.length"/u)
+  assert.match(source, /请至少选择一类设施/u)
+  assert.match(source, /if \(!selectedTypes\.value\.length\)/u)
+})
+
+test('statistics status filter follows the facility status contract', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    'utf8'
+  )
+
+  assert.match(source, /getFacilityStatusOptions/u)
+  assert.match(source, /resolveFacilityStatusLabel/u)
+  assert.match(source, /label="展示全部" value=""/u)
+  assert.doesNotMatch(source, /@clear="/u)
+  assert.match(source, /warningCount/u)
+  assert.match(source, /unknownCount/u)
+})
+
+test('statistics table uses Chinese field labels and explicit hints', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    'utf8'
+  )
+
+  assert.match(source, /STATISTIC_LABELS/u)
+  assert.match(source, /el-tooltip content="按设施类型汇总的关键统计指标"/u)
+  assert.match(source, /el-tooltip content="当前行的统计口径和数据来源"/u)
+})
+
+test('statistics status rows use the agreed unknown label', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    'utf8'
+  )
+
+  assert.match(source, /resolveFacilityStatusLabel\(facilityType, item\.name\)/u)
+  assert.match(source, /category === 'normal' \? '正常' : '未知'/u)
+  assert.doesNotMatch(source, /未填报/u)
+})
+
+test('statistics status filter returns to the unfiltered contract', () => {
+  assert.deepEqual(
+    buildPipeNetworkStatisticsQuery({ facilityTypes: ['plant'], status: null }),
+    { facilityTypes: 'plant' }
+  )
+  assert.doesNotMatch(
+    JSON.stringify(buildPipeNetworkStatisticsQuery({ facilityTypes: ['plant'], status: '' })),
+    /"status"/u
+  )
+})
+
+test('facility status labels follow the per-facility-type contract', () => {
+  assert.equal(resolveFacilityStatusLabel('source', 0), '正常')
+  assert.equal(resolveFacilityStatusLabel('source', 1), '异常')
+  assert.equal(resolveFacilityStatusLabel('plant', 1), '停产')
+  assert.equal(resolveFacilityStatusLabel('pumpStation', 1), '故障停运')
+  assert.equal(resolveFacilityStatusLabel('pumpStation', 2), '检修中')
+  assert.equal(resolveFacilityStatusLabel('pipe', 1), '停用')
+  assert.equal(resolveFacilityStatusLabel('user', 1), '欠费')
+  assert.equal(resolveFacilityStatusLabel('user', 2), '停用')
+  assert.equal(resolveFacilityStatusLabel('source', null), '未知')
+})
+
+test('dashboard sections map to the shared facility status contract', () => {
+  assert.deepEqual(FACILITY_SECTION_TYPES, {
+    pipeNetwork: 'pipe',
+    waterPlant: 'plant',
+    waterSource: 'source',
+    waterUser: 'user',
+    pumpStation: 'pumpStation'
+  })
+
+  assert.equal(resolveFacilityStatusLabel(FACILITY_SECTION_TYPES.waterSource, 1), '异常')
+  assert.equal(resolveFacilityStatusLabel(FACILITY_SECTION_TYPES.waterPlant, 1), '停产')
+  assert.equal(resolveFacilityStatusLabel(FACILITY_SECTION_TYPES.pipeNetwork, 1), '停用')
+  assert.equal(resolveFacilityStatusLabel(FACILITY_SECTION_TYPES.waterUser, 1), '欠费')
+  assert.deepEqual(FACILITY_SECTION_LABELS, {
+    pipeNetwork: '管网',
+    waterPlant: '水厂',
+    waterSource: '水源地',
+    waterUser: '用水户',
+    pumpStation: '泵站'
+  })
+})
+
+test('facility status colors follow the per-facility-type contract', () => {
+  assert.deepEqual(FACILITY_STATUS_TAG_TYPES.source, { 0: 'success', 1: 'danger' })
+  assert.deepEqual(FACILITY_STATUS_TAG_TYPES.plant, { 0: 'success', 1: 'danger' })
+  assert.deepEqual(FACILITY_STATUS_TAG_TYPES.pumpStation, {
+    0: 'success',
+    1: 'danger',
+    2: 'warning'
+  })
+  assert.deepEqual(FACILITY_STATUS_TAG_TYPES.pipe, { 0: 'success', 1: 'danger' })
+  assert.deepEqual(FACILITY_STATUS_TAG_TYPES.user, {
+    0: 'success',
+    1: 'warning',
+    2: 'danger'
+  })
+})
+
+test('status options only expose values valid for selected facility types', () => {
+  assert.deepEqual(getFacilityStatusOptions(['source']), [
+    { value: '0', label: '水源地: 正常' },
+    { value: '1', label: '水源地: 异常' }
+  ])
+  assert.deepEqual(getFacilityStatusOptions(['source', 'pumpStation', 'user']), [
+    { value: '0', label: '正常' },
+    { value: '1', label: '异常 / 故障停运 / 欠费' },
+    { value: '2', label: '检修中 / 停用' }
+  ])
+  assert.deepEqual(getFacilityStatusOptions([]), [])
+})
+
+test('dashboard provides an explicit path back to the unfiltered state', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    'utf8'
+  )
+
+  assert.match(source, /@change="handleStatusChange"/u)
+  assert.match(source, /statusFilter\.value = String\(value \?\? ''\)/u)
+})
+
+test('changing facility types normalizes a stale status filter before loading data', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    'utf8'
+  )
+
+  assert.match(source, /@change="handleTypeChange"/u)
+  assert.match(
+    source,
+    /const handleTypeChange = \(\) => \{\s*const options = getFacilityStatusOptions\(selectedTypes\.value\)\s*if \(!options\.some\(option => option\.value === statusFilter\.value\)\) statusFilter\.value = ''\s*loadData\(\)\s*\}/u
+  )
+})
+
+test('statistics cards and charts use a non-overlapping technical layout', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    'utf8'
+  )
+
+  assert.match(source, /\.metric-icon \{\s*[^}]*position: static/u)
+  assert.match(source, /grid-template-columns: repeat\(5, minmax\(160px, 1fr\)\)/u)
+  assert.match(source, /radius: \['52%', '72%'\]/u)
+  assert.doesNotMatch(source, /roseType:/u)
+})
+
+test('statistics charts hide status series without data', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    'utf8'
+  )
+
+  assert.match(source, /\.filter\(series => facilityOverview\.some\(item => Number\(item\[series\.key\] \|\| 0\) > 0\)\)/u)
+  assert.match(source, /resolveFacilityStatusLabel\(facilityType, item\.name\)/u)
+  assert.doesNotMatch(source, /未填报/u)
+})

Неке датотеке нису приказане због велике количине промена